# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.

# %% auto #0
__all__ = ['dname_doc', 'md_cls_d', 'dh_settings', 'pyrun', 'Placements', 'mermaid_url', 'msg_insert_line', 'file_insert_line',
           'msg_str_replace', 'file_str_replace', 'msg_strs_replace', 'file_strs_replace', 'msg_replace_lines',
           'file_replace_lines', 'msg_del_lines', 'file_del_lines', 'msg_pyrun', 'file_pyrun', 'add_styles',
           'find_dname', 'xposta', 'xgeta', 'call_endp', 'call_endpa', 'curr_dialog', 'msg_idx', 'add_html_a',
           'add_html', 'add_scr_a', 'add_scr', 'iife_a', 'iife', 'pop_data_a', 'pop_data', 'fire_event_a', 'fire_event',
           'event_get_a', 'event_get', 'trigger_now', 'display_response', 'set_pyrun', 'doc', 'read_msg', 'find_msgs',
           'view_dlg', 'add_msg', 'add_prompt', 'read_msgid', 'view_msg', 'del_msg', 'update_msg', 'run_msg',
           'copy_msg', 'paste_msg', 'enable_mermaid', 'mermaid', 'toggle_header', 'toggle_bookmark', 'toggle_comment',
           'url2note', 'create_or_run_dialog', 'stop_dialog', 'rm_dialog', 'run_code_interactive', 'ast_py', 'ast_grep',
           'ctx_folder', 'ctx_repo', 'ctx_symfile', 'ctx_symfolder', 'ctx_sympkg', 'load_gist', 'gist_file',
           'import_string', 'mk_toollist', 'import_gist', 'update_gist', 'dialoghelper_explain_dialog_editing',
           'solveit_docs', 'dialog_link', 'spawn_agent']

# %% ../nbs/00_core.ipynb #468aa264
import re,inspect,ast,collections,time,asyncio,json,linecache,importlib,difflib,uuid

from typing import Dict
from tempfile import TemporaryDirectory
from ipykernel_helper import *
from dataclasses import dataclass
from os.path import normpath
from fastcore.xml import to_xml
from fastcore.meta import splice_sig, delegates, delegated

from fastcore.utils import *
from fastcore.xtras import asdict
from fastcore.docments import MarkdownRenderer
from ghapi.all import *
from inspect import currentframe,Parameter,signature
from httpx import AsyncClient, get as xget, post as xpost
from IPython.display import display,Markdown,HTML
from monsterui.all import franken_class_map,apply_classes
from fasthtml.common import Safe,Script,Div
from toolslm.xml import *
from lisette.core import ToolResponse
from urllib.parse import urlencode
from fastcore.imports import __llmtools__
from safepyrun import RunPython,allow,find_var

# %% ../nbs/00_core.ipynb #e54b45ad
dname_doc = """If `dname` is None, the current dialog is used. If it is an open dialog, it will be updated interactively with real-time updates to the browser. If it is a closed dialog, it will be updated on disk. Dialog names must be paths relative to solveit root (if starting with `/`, e.g. `/myproject/dlg`) or relative to the current dialog's folder (if not starting with `/`), and should *not* include the .ipynb extension. **Use absolute paths when targeting dialogs outside the current dialog's folder tree.**"""

# %% ../nbs/00_core.ipynb #cc9f963f
md_cls_d = {
    **{f'h{i}': f'uk-h{i}' for i in range(1,5)},
    'a': "uk-link",
    'blockquote': "uk-blockquote",
    'hr':'uk-divider-icon',
    'table':'uk-table uk-table-sm uk-table-middle uk-table-divider border [&_tr]:divide-x w-[80%] mx-auto',
    'ol': 'uk-list uk-list-decimal space-y-0', 
    'ul': 'uk-list uk-list-bullet space-y-0',
    'p': 'leading-tight',
    'li': 'leading-tight',
    'pre': '', 'pre code': '',
    'code': 'tracking-tight'
}

def add_styles(s:str, cls_map:dict=None):
    "Add solveit styles to `s`"
    return Safe(apply_classes(s, class_map=cls_map or md_cls_d))

# %% ../nbs/00_core.ipynb #eb1636a6
dh_settings = {'port':5001}
# dh_settings = {'port':6001}

# %% ../nbs/00_core.ipynb #65a8b58b
def find_dname(dname=None):
    "Get the dialog name by searching the call stack for __dialog_name, and resolving `dname` if supplied."
    if dname:
        dname = dname.removesuffix('.ipynb')
        if dname.startswith('/'): return dname
    curr = dh_settings.get('dname', None)
    if not curr: curr = find_var('__dialog_name')
    if not dname: return '/'+curr
    p = Path(curr).parent
    res = normpath((p/dname))
    assert '../' not in res, "Path traversal not permitted"
    return '/'+res

# %% ../nbs/00_core.ipynb #a01ad161
def _diff_dialog(pred, dname, err="`id` parameter must be provided when target dialog is different", id=None):
    "Raise ValueError if targeting a different dialog, `pred` is True, and no `id` provided"
    if not pred or id: return
    if dname or ('dname' in dh_settings): raise ValueError(err)

# %% ../nbs/00_core.ipynb #39232620
async def xposta(url, **kwargs):
    async with AsyncClient() as c: return await c.post(url, **kwargs)
async def xgeta(url, **kwargs):
    async with AsyncClient() as c: return await c.get (url, **kwargs)

# %% ../nbs/00_core.ipynb #bdac4ecb
def _prep_endp(path, dname, json, id, data):
    dname = find_dname(dname).strip('/')
    data['dlg_name'] = dname
    if id: data['id_'] = id
    url = f'http://localhost:{dh_settings["port"]}/{path}'
    headers = {'Accept': 'application/json'} if json else {}
    return url, data, headers

def _handle_resp(res, json, raiseex):
    if raiseex: res.raise_for_status()
    try: return adict(res.json()) if json else res.text
    except Exception: return res.text

# %% ../nbs/00_core.ipynb #5fc896fe
def call_endp(path, dname='', json=False, raiseex=False, id=None, **data):
    url, data, headers = _prep_endp(path, dname, json, id, data)
    return _handle_resp(xpost(url, data=data, headers=headers), json, raiseex)

async def call_endpa(path, dname='', json=False, raiseex=False, id=None, **data):
    url, data, headers = _prep_endp(path, dname, json, id, data)
    return _handle_resp(await xposta(url, data=data, headers=headers), json, raiseex)

# %% ../nbs/00_core.ipynb #1a5b4b75
def _check_res(res, dname):
    "Check if a route call succeeded; return success or error dict"
    if not res: return {'error': f'Dialog {dname} may not be running, or message not found'}
    return {'success': 'complete'}

# %% ../nbs/00_core.ipynb #a9cb5512
@llmtool
async def curr_dialog(
    with_messages:bool=False,  # Include messages as well?
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    "Get the current dialog info."
    res = await call_endpa('curr_dialog_', dname, json=True, with_messages=with_messages)
    if res: return {'name': res['name'], 'mode': res['mode']}

# %% ../nbs/00_core.ipynb #8810450f
@llmtool
async def msg_idx(
    id:str=None,  # Message id to find (defaults to current message)
    dname:str='' # Dialog to get message index from; defaults to current dialog
):
    "Get absolute index of message in dialog."
    _diff_dialog(True, dname, id=id)
    res = await call_endpa('msg_idx_', dname, json=True, id=id)
    return (await call_endpa('msg_idx_', dname, json=True, id=id))['idx']

# %% ../nbs/00_core.ipynb #5335c78c
async def add_html_a(
    content:str, # The HTML to send to the client (generally should include hx-swap-oob)
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    "Send HTML to the browser to be swapped into the DOM"
    await call_endpa('add_html_', dname, content=to_xml(content))
    return {'success': 'Content added to DOM'}

def add_html(
    content:str, # The HTML to send to the client (generally should include hx-swap-oob)
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    "Send HTML to the browser to be swapped into the DOM"
    call_endp('add_html_', dname, content=to_xml(content))
    return {'success': 'Content added to DOM'}

# %% ../nbs/00_core.ipynb #4b43e4e9
async def add_scr_a(scr, oob='innerHTML:#ephemeral'):
    "Swap a script element to the end of the ephemeral element"
    if isinstance(scr,str): scr = Script(scr)
    await add_html_a(Div(scr, hx_swap_oob=oob))

def add_scr(scr, oob='innerHTML:#ephemeral'):
    "Swap a script element to the end of the ephemeral element"
    if isinstance(scr,str): scr = Script(scr)
    add_html(Div(scr, hx_swap_oob=oob))

# %% ../nbs/00_core.ipynb #6f1b12d3
def _iife_scr(code: str):
    return f'''
(async () => {{
{code}
}})();
'''

async def iife_a(code: str):
    "Wrap javascript code string in an IIFE and execute it via `add_html`"
    await add_scr_a(_iife_scr(code))

def iife(code: str):
    "Wrap javascript code string in an IIFE and execute it via `add_html`"
    add_scr(_iife_scr(code))

# %% ../nbs/00_core.ipynb #99a07c05
async def pop_data_a(idx, timeout=15):
    return dict2obj(await call_endpa('pop_data_blocking_', data_id=idx, timeout=timeout, json=True))

def pop_data(idx, timeout=15):
    return dict2obj(call_endp('pop_data_blocking_', data_id=idx, timeout=timeout, json=True))

# %% ../nbs/00_core.ipynb #ddcee8d5
def _fire_event_scr(evt, data=None):
    params = f"'{evt}'"
    if data is not None: params += f", {json.dumps(data)}"
    return Script(f"htmx.trigger(document.body, {params});", id='js-event', hx_swap_oob='true')

async def fire_event_a(evt:str, data=None): await add_html_a(_fire_event_scr(evt, data))
def fire_event(evt:str, data=None): add_html(_fire_event_scr(evt, data))

# %% ../nbs/00_core.ipynb #25ed8a64
def _event_prep(data):
    idx = uuid.uuid4()
    return idx, (data or {}) | {'idx': str(idx)}

async def event_get_a(evt:str, timeout=15, data=None):
    "Call `fire_event` and then `pop_data` to get a response"
    idx, data = _event_prep(data)
    await fire_event_a(evt, data=data)
    return await pop_data_a(idx, timeout)

def event_get(evt:str, timeout=15, data=None):
    "Call `fire_event` and then `pop_data` to get a response"
    idx, data = _event_prep(data)
    fire_event(evt, data=data)
    return pop_data(idx, timeout)

# %% ../nbs/00_core.ipynb #e2d1a5be
def trigger_now(evt, data=None, ttl=5000):
    "Synchronously trigger a browser event, safe against replay"
    ts = time.time_ns() // 1_000_000
    params = f"'{evt}'"
    guard = f'window["_trig_{ts}"]'
    if data is not None: params += f", {json.dumps(data)}"
    display(HTML(f'''<script>
if (Date.now() - {ts} < {ttl} && !{guard}) {{
    {guard}=1;
    htmx.trigger(document.body, {params});
}}</script>'''))

# %% ../nbs/00_core.ipynb #80334098
def display_response(display:str, result:str=None):
    "Return a special response where `display` is added as markdown/HTML to the prompt output, and `result` is returned to the LLM"
    if result is None: result = f"The following has been added to the user's markdown/HTML dialog response:\n{display}"
    return ToolResponse({'_display': display, 'result': result})

# %% ../nbs/00_core.ipynb #d0bd7086
pyrun = RunPython(sentinel='__dialog_name')
__llmtools__.add('pyrun')

# %% ../nbs/00_core.ipynb #0f51c26d
def set_pyrun(rp:RunPython):
    "Replace the default RunPython used by msg_pyrun"
    global pyrun
    pyrun = rp

# %% ../nbs/00_core.ipynb #0afdb9f2
def doc(sym)->str:
    """Get documentation (signature, docstring, + docments if they exist) for `sym`.
    **NB**: This is not an llm tool, so must be run with python(). `sym` must be available in the namespace."""
    return str(MarkdownRenderer(sym))

# %% ../nbs/00_core.ipynb #f978d4aa
allow('doc')

# %% ../nbs/00_core.ipynb #f819e9bd
def _maybe_xml(res, as_xml, key=None):
    if as_xml: return res
    res = loads(res)
    if 'error' in res: return res
    if key: res = res[key]
    return dict2obj(res)

# %% ../nbs/00_core.ipynb #558c6aa7
@llmtool(dname=dname_doc)
async def read_msg(
    n:int=-1,      # Message index (if relative, +ve is downwards)
    relative:bool=True,  # Is `n` relative to current message (True) or absolute (False)?
    id:str=None,  # Message id to find (defaults to current message)
    view_range:list[int,int]=None, # Optional 1-indexed (start, end) line range for files, end=-1 for EOF
    nums:bool=False, # Whether to show line numbers
    dname:str='' # Dialog to get info for; defaults to current dialog
    ):
    """Get the message indexed in the current dialog.
    NB: Messages in the current dialog above the current message are *already* visible; use this only when you need line numbers for editing operations, or for messages not in the current dialog or below the current message.
    - To get the exact message use `n=0` and `relative=True` together with `id`.
    - To get a relative message use `n` (relative position index).
    - To get the nth message use `n` with `relative=False`, e.g `n=0` first message, `n=-1` last message.
    {dname}"""
    _diff_dialog(relative, dname, "`id` parameter must be provided, or use `relative=False` with `n`, when target dialog is different", id=id)
    data = dict(n=n, relative=relative, id=id)
    if view_range: data['view_range'] = view_range # None gets converted to '' so we avoid passing it to use the p.default
    if nums: data['nums'] = nums
    return await call_endpa('read_msg_', dname, json=True, **data)

# %% ../nbs/00_core.ipynb #6a4aa03b
@llmtool(dname=dname_doc)
async def find_msgs(
    re_pattern:str='', # Optional regex to search for (re.DOTALL+re.MULTILINE is used)
    msg_type:str=None, # optional limit by message type ('code', 'note', or 'prompt')
    use_case:bool=False, # Use case-sensitive matching?
    use_regex:bool=True, # Use regex matching?
    only_err:bool=False, # Only return messages that have errors?
    only_exp:bool=False, # Only return messages that are exported?
    only_chg:bool=False, # Only return messages that have changed vs git HEAD?
    ids:str='', # Optionally filter by comma-separated list of message ids
    limit:int=None, # Optionally limit number of returned items
    include_output:bool=True, # Include output in returned dict?
    include_meta:bool=True, # Include all additional message metadata
    as_xml:bool=False, # Use concise unescaped XML output format
    nums:bool=False, # Show line numbers?
    trunc_out:bool=False, # Middle-out truncate code output to 100 characters?
    trunc_in:bool=False, # Middle-out truncate cell content to 80 characters?
    headers_only:bool=False, # Only return note messages that are headers (first line only); cannot be used together with `header_section`
    header_section:str=None, # Find section starting with this header; returns it plus all children (i.e until next header of equal or more significant level)
    dname:str='' # Dialog to get info for; defaults to current dialog
)->list[dict]: # Messages in requested dialog that contain the given information
    """Often it is more efficient to call `view_dlg` to see the whole dialog at once, so you can use it all from then on, instead of using `find_msgs`.
    {dname}
    Message ids are identical to those in LLM chat history, so do NOT call this to view a specific message if it's in the chat history--instead use `view_msg`.
    Do NOT use find_msgs to view message content in the current dialog above the current prompt -- these are *already* provided in LLM context, so just read the content there directly. (NB: LLM context only includes messages *above* the current prompt, whereas `find_msgs` can access *all* messages.)
    To refer to a found message from code or tools, use its `id` field."""
    res = await call_endpa('find_msgs_', dname, json=False, re_pattern=re_pattern, msg_type=msg_type, limit=limit, ids=ids,
                    use_case=use_case, use_regex=use_regex, only_err=only_err, only_exp=only_exp, only_chg=only_chg,
                    include_output=include_output, include_meta=include_meta, as_xml=as_xml, nums=nums, trunc_out=trunc_out, trunc_in=trunc_in,
                    headers_only=headers_only, header_section=header_section)
    return _maybe_xml(res, as_xml=as_xml, key='msgs')


# %% ../nbs/00_core.ipynb #9ff2a38e
@llmtool
async def view_dlg(
    dname:str='', # Dialog to get info for; defaults to current dialog
    msg_type:str=None, # optional limit by message type ('code', 'note', or 'prompt')
    nums:bool=False, # Whether to show line numbers
    include_output:bool=False, # Include output in returned dict?
    trunc_out:bool=True, # Middle-out truncate code output to 100 characters (only applies if `include_output`)?
    trunc_in:bool=False, # Middle-out truncate cell content to 80 characters?
):
    "Concise XML view of all messages (optionally filtered by type), not including metadata. Often it is more efficient to call this to see the whole dialog at once (including line numbers if needed), instead of running `find_msgs` or `view_msg` multiple times."
    return await find_msgs(msg_type=msg_type, dname=dname, as_xml=True, nums=nums,
        include_meta=False, include_output=include_output, trunc_out=trunc_out, trunc_in=trunc_in)


# %% ../nbs/00_core.ipynb #fdc5a465
Placements = str_enum('Placements', 'add_after', 'add_before', 'at_start', 'at_end')

# %% ../nbs/00_core.ipynb #5093cfe4
def _add_msg(
    msg_type: str='note', # Message type, can be 'code', 'note', or 'prompt'
    output:str='', # Prompt/code output; Code outputs must be .ipynb-compatible JSON array
    time_run: str | None = '', # When was message executed
    is_exported: int | None = 0, # Export message to a module?
    skipped: int | None = 0, # Hide message from prompt?
    i_collapsed: int | None = 0, # Collapse input?
    o_collapsed: int | None = 0, # Collapse output?
    heading_collapsed: int | None = 0, # Collapse heading section?
    pinned: int | None = 0, # Pin to context?
): ...

# %% ../nbs/00_core.ipynb #ca694d38
@delegated(_add_msg)
async def _add_msg_unsafe(
    content:str, # Content of the message (i.e the message prompt, code, or note text)
    placement:str='add_after', # Can be 'at_start' or 'at_end', and for default dname can also be 'add_after' or 'add_before'
    id:str=None, # id of message that placement is relative to (if None, uses current message)
dname:str='', # Dialog to get info for; defaults to current dialog (`run` only has a effect if dialog is currently running)
    run_mode:str|None=None, # Run mode: None (don't run) or 'run' (run the message)
    **kwargs
)->str: # Message ID of newly created message
    """Add/update a message to the queue to show after code execution completes, and optionally run it.
    **NB**: when creating multiple messages in a row, after the 1st message set `id` to the result of the last `add_msg` call,
    otherwise messages will appear in the dialog in REVERSE order.
    *WARNING*--This can execute arbitrary code, so check carefully what you run!--*WARNING"""
    _diff_dialog(placement not in ('at_start','at_end'), dname,
        "`id` or `placement='at_end'`/`placement='at_start'` must be provided when target dialog is different", id=id)    
    res = await call_endpa('add_relative_', dname, json=True, content=content, placement=placement, id=id, run_mode=run_mode, **kwargs)
    if 'error' in res: return f"error: {res['error']}"
    return res['id']

# %% ../nbs/00_core.ipynb #3ad14786
@llmtool(dname=dname_doc)
@delegates(_add_msg_unsafe, but=['run_mode'])
async def add_msg(
    content:str, # Content of the message (i.e the message prompt, code, or note text)
    **kwargs
)->str: # Message ID of newly created message
    """Add/update a message to the queue to show after code execution completes.
    **NB**: when creating multiple messages in a row, after the 1st message set `id` to the result of the last `add_msg` call,
    otherwise messages will appear in the dialog in REVERSE order.
    {dname}"""
    return await _add_msg_unsafe(content=content, **kwargs)

# %% ../nbs/00_core.ipynb #e40e896c
@llmtool
@delegates(_add_msg_unsafe, but=['content','msg_type','run_mode'])
async def add_prompt(
    content:str,    # Prompt to run
    dname:str=None, # Dialog to run prompt in; defaults to current dialog
    msg_id:str=None, # Message id to place prompt after (if None, places at end)
    wait:bool=True, # Wait for and return response?
    poll:float=0.5, # Frequency of polling to check for completion
    placement:str='', # Location to place message, defaults to 'at_end' if no msg_id
    **kwargs
):
    "Run a prompt and, if `wait`, wait for and return the response text"
    assert not (wait and not dname), "Can not wait in current dialog"
    if not placement: placement = 'add_after' if msg_id else 'at_end'
    msg_id = await _add_msg_unsafe(content, msg_type='prompt', run_mode='run', dname=dname, placement=placement, **kwargs)
    if not wait: return msg_id
    while True:
        res = await read_msgid(msg_id, dname=dname)
        if not res.get('run', False): return res['output']
        await asyncio.sleep(poll)

# %% ../nbs/00_core.ipynb #afc62c45
@llmtool(dname=dname_doc)
async def read_msgid(
    id:str,  # Message id to find
    view_range:list[int,int]=None, # Optional 1-indexed (start, end) line range for files, end=-1 for EOF
    nums:bool=False, # Whether to show line numbers
    dname:str='', # Dialog to get message from; defaults to current dialog
    add_to_dlg:bool=False # Whether to add message content to current dialog (as a raw message)
):
    """Get message `id`. Message IDs can be view directly in LLM chat history/context, or found in `find_msgs` results.
    Use `add_to_dlg` if the LLM or human may need to refer to the message content again later.
    {dname}"""
    res = await read_msg(0, id=id, view_range=view_range, nums=nums, dname=dname)
    if add_to_dlg: await add_msg(res['content'], msg_type='raw')
    return res

# %% ../nbs/00_core.ipynb #801783c2
@llmtool(dname=dname_doc)
async def view_msg(
    id:str,  # Message id to view
    dname:str='', # Dialog to get message from; defaults to current dialog
    nums:bool=True, # Whether to show line numbers
    view_range:list[int,int]=None, # Optional 1-indexed (start, end) line range for files, end=-1 for EOF. Rarely needed--read whole message in nearly all cases instead
    add_to_dlg:bool=False # Whether to add message content to current dialog (as a raw message)
):
    """Views the *content* of message `id`. Same as `read_msgid(...)['content']`, defaulting to `nums=True`.
    Use `add_to_dlg` if the LLM or human may need to refer to the message content again later.
    {dname}"""
    res = (await read_msg(0, id=id, view_range=view_range, nums=nums, dname=dname))['content']
    if add_to_dlg: await add_msg(res, msg_type='raw')
    return res

# %% ../nbs/00_core.ipynb #f1ee1903
@llmtool
async def del_msg(
    id:str=None, # id of message to delete
    dname:str='', # Dialog to get info for; defaults to current dialog
    log_changed:bool=False # Add a note showing the deleted content?
):
    "Delete a message from the dialog. DO NOT USE THIS unless you have been explicitly instructed to delete messages."
    if log_changed: msg = await read_msgid(id, dname=dname)
    res = await call_endpa('rm_msg_', dname, raiseex=True, msid=id, json=True)
    if log_changed: await add_msg(f"> Deleted #{id}\n\n```\n{msg.content}\n```")
    return res


# %% ../nbs/00_core.ipynb #023dcb74
def _umsg(
    content:str|None=None, # Content of the message (i.e the message prompt, code, or note text)
    msg_type: str|None = None, # Message type, can be 'code', 'note', or 'prompt'
    output:str|None = None, # Prompt/code output; Code outputs must be .ipynb-compatible JSON array
    time_run: str | None = None, # When was message executed
    is_exported: int | None = None, # Export message to a module?
    skipped: int | None = None, # Hide message from prompt?
    i_collapsed: int | None = None, # Collapse input?
    o_collapsed: int | None = None, # Collapse output?
    heading_collapsed: int | None = None, # Collapse heading section?
    pinned: int | None = None # Pin to context?
): ...

# %% ../nbs/00_core.ipynb #38875a12
@llmtool(dname=dname_doc)
@delegates(_umsg)
async def update_msg(
    id:str=None, # id of message to update (if None, uses current message)
    msg:Optional[Dict]=None, # Dictionary of field keys/values to update
    dname:str='', # Dialog to get info for; defaults to current dialog
    log_changed:bool=False, # Add a note showing the diff?
    **kwargs):
    """Update an existing message. Provide either `msg` OR field key/values to update.
    - Use `content` param to update contents.
    - Only include parameters to update--missing ones will be left unchanged.
    {dname}"""
    if msg: kwargs |= msg.get('msg', msg)
    if not id: id = kwargs.pop('id', None)
    if not id: raise TypeError("update_msg needs either a dict message with and id, or `id=`")
    res = await call_endpa('update_msg_', dname, json=True, id=id, log_changed=log_changed, **kwargs)
    if 'error' in res: return f"error: {res['error']}"
    if log_changed:
        diff = res.get('diff', '')
        note = f"> Updated #{id}\n\n```diff\n{diff}\n```" if diff else f"> Updated #{id}\n\nNo changes."
        await add_msg(note)
    return res['id']

# %% ../nbs/00_core.ipynb #316bd7a0
async def run_msg(
    ids:str=None, # Comma-separated ids of message(s) to execute
    dname:str='' # Running dialog to get info for; defaults to current dialog. (Note dialog *must* be running for this function)
):
    "Adds a message to the run queue. Use read_msg to see the output once it runs."
    return await call_endpa('add_runq_', dname, ids=ids, json=True)

# %% ../nbs/00_core.ipynb #73025e57
@llmtool
async def copy_msg(
    ids:str=None, # Comma-separated ids of message(s) to copy
    cut:bool=False, # Cut message(s)? (If not, copies)
    dname:str='' # Running dialog to copy messages from; defaults to current dialog. (Note dialog *must* be running for this function)
):
    "Add `ids` to clipboard."
    id,*_ = ids.split(',')
    res = await call_endpa('msg_clipboard_', dname, ids=ids, id=id, cmd='cut' if cut else 'copy')
    return _check_res(res, dname)

# %% ../nbs/00_core.ipynb #80def27e
@llmtool
async def paste_msg(
    id:str=None, # Message id to paste next to
    after:bool=True, # Paste after id? (If not, pastes before)
    dname:str='' # Running dialog to copy messages from; defaults to current dialog. (Note dialog *must* be running for this function)
):
    "Paste clipboard msg(s) after/before the current selected msg (id)."
    res = await call_endpa('msg_paste_', dname, id=id, after=after)
    return _check_res(res, dname)

# %% ../nbs/00_core.ipynb #13e9163a
mermaid_url = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"

def enable_mermaid():
    return Script("""
if (window.mermaid) mermaid.run()
else {
    import('%s').then(m => {
        window.mermaid = m.default;
        window.mermaid.run();
        htmx.onLoad(elt => {
            if (elt.matches('div.mermaid, pre.mermaid') || htmx.findAll(elt, 'div.mermaid, pre.mermaid')) window.mermaid.run();
        });
    });
}""" % mermaid_url, type="module")

# %% ../nbs/00_core.ipynb #f3d1e424
def mermaid(code, cls="mermaid", **kwargs):
    "A mermaid diagram"
    return Div(code, cls=cls, **kwargs)

# %% ../nbs/00_core.ipynb #b220e29b
@llmtool
async def toggle_header(
    id:str, # id of markdown header note message to toggle collapsed state
    dname:str='' # Running dialog to copy messages from; defaults to current dialog. (Note dialog *must* be running for this function)
):
    "Toggle collapsed header state for `id`"
    res = await call_endpa('toggle_header_collapse_', dname, id=id)
    return _check_res(res, dname)

# %% ../nbs/00_core.ipynb #90b55ef4
@llmtool
async def toggle_bookmark(
    id:str, # id of message to toggle bookmark on
    n:int, # Bookmark number (1-9)
    dname:str='' # Dialog to set bookmark in; defaults to current dialog
):
    "Toggle numbered bookmark (1-9) on a message, clearing it from any other message when setting"
    return await call_endpa('bookmark_', dname, json=True, id=id, n=n)

# %% ../nbs/00_core.ipynb #334395f8
@llmtool
async def toggle_comment(
    id:str, # id of code message (or comma-separated ids) to toggle comments on
    dname:str='' # Dialog to toggle comments in; defaults to current dialog. (Note dialog *must* be running for this function)
):
    "Toggle line comments on code message(s). If any lines are uncommented, comments all; otherwise uncomments all."
    ids = id
    id,*_ = ids.split(',')
    res = await call_endpa('toggle_comment_', dname, ids=ids, id=id)
    return _check_res(res, dname)

# %% ../nbs/00_core.ipynb #1827e124
async def url2note(
    url:str, # URL to read
    extract_section:bool=True, # If url has an anchor, return only that section
    selector:str=None, # Select section(s) using BeautifulSoup.select (overrides extract_section)
    ai_img:bool=True, # Make images visible to the AI
    split_re:str='' # Regex to split content into multiple notes, set to '' for single note
):
    "Read URL as markdown, and add note(s) below current message with the result"
    res = read_url(url, as_md=True, extract_section=extract_section, selector=selector, ai_img=ai_img)
    if split_re: return [await add_msg(s) for s in re.split(split_re, res, flags=re.MULTILINE) if s.strip()]
    return await add_msg(res)


# %% ../nbs/00_core.ipynb #f26259cf
@llmtool
async def create_or_run_dialog(
    name:str, # Name/path of the dialog (relative to current dialog's folder, or absolute if starts with '/')
):
    "Create a new dialog, or set an existing one running"
    name = find_dname(name).lstrip('/')
    return await call_endpa('create_dialog_', name=name, json=True)

# %% ../nbs/00_core.ipynb #80433dd1
@llmtool
async def stop_dialog(
    name:str, # Name/path of the dialog (relative to current dialog's folder, or absolute if starts with '/')
):
    "Stop a running dialog kernel"
    name = find_dname(name).lstrip('/')
    return await call_endpa('stop_kernel_', name=name, json=True)

# %% ../nbs/00_core.ipynb #e393f14b
async def rm_dialog(
    name:str, # Name/path of the dialog to delete (relative to current dialog's folder, or absolute if starts with '/')
):
    "Delete a dialog (or folder) and associated records, stopping the kernel if running"
    name = find_dname(name).lstrip('/')
    return await call_endpa('rm_dialog_', name=name, sess='{}', json=True)

# %% ../nbs/00_core.ipynb #5617305b
@llmtool
async def run_code_interactive(
    code:str # Code to have user run
):
    """Insert code into user's dialog and request for the user to run it. Use other tools where possible, 
    but if they can not find needed information, *ALWAYS* use this instead of guessing or giving up.
    IMPORTANT: This tool is TERMINAL - after calling it, you MUST stop all tool usage 
    and wait for user response. Never call additional tools after this one."""
    await add_msg('# Please run this:\n'+code, msg_type='code')
    return {'success': "CRITICAL: Message added to user dialog. STOP IMMEDIATELY. Do NOT call any more tools. Wait for user to run code and respond."}


# %% ../nbs/00_core.ipynb #5250fa23
_msg_edit_doc = f"""
Be sure you've called `view_msg(…)` to ensure you know the line nums.
{dname_doc}

Message editing standard parameters:

id: Message id to edit
dname: Dialog to get info for; defaults to current dialog
update_output: If True, replace in output instead of content
log_changed: Add a note showing the deleted content?

returns: diff of changes, or "none: No changes.", or "error: ..." """

# %% ../nbs/00_core.ipynb #5163fd13
def _msg_edit(f, name=None):
    async def wrapper(id:str, *args, update_output:bool=False, dname:str='', log_changed:bool=False, **kw):
        msg = await read_msg(n=0, id=id, dname=dname)
        field = 'output' if update_output else 'content'
        text = msg.get(field, '')
        if not text: return f"error: Message has no {field}"
        try: new_text = await maybe_await(f(text, *args, **kw))
        except ValueError as e: return f'error: {e}'
        await update_msg(id=id, **{field: new_text}, dname=dname, log_changed=log_changed)
        diff = '\n'.join(list(difflib.unified_diff(text.splitlines(), new_text.splitlines(), n=1, lineterm=''))[2:])
        return diff or 'none: No changes.'
    res = splice_sig(wrapper, f, 'text')
    if name: res.__name__ = res.__qualname__ = name
    res.__doc__ = (f.__doc__ or '') + _msg_edit_doc
    return llmtool(res)

# %% ../nbs/00_core.ipynb #dd14352d
_file_edit_doc = """
This is a *file* editing function.

File editing standard parameters:

path: Path to the file to modify
log_changed: Add a note showing the diff?

returns: diff of changes, or "none: No changes.", or "error: ..."
"""

# %% ../nbs/00_core.ipynb #fcd412d8
def _file_edit(f, name=None):
    async def wrapper(path:str, *args, log_changed:bool=False, **kw):
        text = Path(path).read_text()
        try: new_text = await maybe_await(f(text, *args, **kw))
        except ValueError as e: return f'error: {e}'
        Path(path).write_text(new_text)
        diff = '\n'.join(list(difflib.unified_diff(text.splitlines(), new_text.splitlines(), n=1, lineterm=''))[2:])
        if log_changed and diff: await add_msg(f"> Updated `{path}`\n\n```diff\n{diff}\n```")
        return diff or 'none: No changes.'
    res = splice_sig(wrapper, f, 'text')
    if name: res.__name__ = res.__qualname__ = name
    res.__doc__ = (f.__doc__ or '') + _file_edit_doc
    return llmtool(res)

# %% ../nbs/00_core.ipynb #ceb1ad3b
def _insert_line(
    text:str,
    insert_line:int, # The 1-based line number after which to insert the text (0: before 1st line, 1: after 1st line, 2: after 2nd, etc.)
    new_str:str, # The text to insert
):
    "Insert new_str at specified line number"
    lines = text.splitlines()
    if not (0 <= insert_line <= len(lines)): raise ValueError(f'Invalid line {insert_line}. Valid range: 0-{len(lines)}')
    lines.insert(insert_line, new_str)
    return '\n'.join(lines)

msg_insert_line  = _msg_edit (_insert_line, 'msg_insert_line')
file_insert_line = _file_edit(_insert_line, 'file_insert_line')

# %% ../nbs/00_core.ipynb #e6b2a19b
def _str_replace(
    text:str,
    old_str:str, # Text to find and replace
    new_str:str, # Text to replace with
    start_line:int=None, # Optional 1-based start line to limit search
    end_line:int=None, # Optional 1-based end line to limit search
    n_matches:int=None, # Max replacements (None=all)
    re_filter:str=None, # If provided, only process lines matching this regex (like g// in ex)
    invert_filter:bool=False, # Invert the filter (like g!// in ex)
):
    "Replace occurrence(s) of old_str with new_str"
    def _repl(s, label=''):
        if s.count(old_str) == 0: raise ValueError(f"Text not found{label}: {repr(old_str)}")
        return s.replace(old_str, new_str) if n_matches is None else s.replace(old_str, new_str, n_matches)
    if re_filter or start_line or end_line:
        lines = text.splitlines(True)
        s,e = (start_line or 1)-1, end_line or len(lines)
        if not re_filter: return ''.join(lines[:s]) + _repl(''.join(lines[s:e]), f' in lines {s+1}-{e}') + ''.join(lines[e:])
        pat = re.compile(re_filter)
        matched = [i for i in range(s,e) if bool(pat.search(lines[i])) != invert_filter]
        if not matched: return text
        for i in matched: lines[i] = _repl(lines[i], f' on line {i+1}')
        return ''.join(lines)
    return _repl(text)

msg_str_replace =  _msg_edit (_str_replace, 'msg_str_replace')
file_str_replace = _file_edit(_str_replace, 'file_str_replace')

# %% ../nbs/00_core.ipynb #983ce14a
def _strs_replace(
    text:str,
    old_strs:list[str], # List of strings to find and replace
    new_strs:list[str], # List of replacement strings (must match length of old_strs)
    start_line:int=None, # Optional 1-based start line to limit search
    end_line:int=None, # Optional 1-based end line to limit search
    n_matches:int=None, # Max replacements per string (None=all)
    re_filter:str=None, # If provided, only process lines matching this regex (like g// in ex)
    invert_filter:bool=False, # Invert the filter (like g!// in ex)
):
    "Replace multiple strings simultaneously"
    if not isinstance(old_strs, list): raise ValueError(f"`old_strs` should be a list[str] but got {type(old_strs)}")
    if not isinstance(new_strs, list): raise ValueError(f"`new_strs` should be a list[str] but got {type(new_strs)}")
    if len(old_strs) != len(new_strs): raise ValueError(f"Length mismatch: {len(old_strs)} old_strs vs {len(new_strs)} new_strs")
    for idx,(old_str,new_str) in enumerate(zip(old_strs, new_strs)):
        text = _str_replace(text, old_str, new_str, start_line=start_line, end_line=end_line,
                            n_matches=n_matches, re_filter=re_filter, invert_filter=invert_filter)
    return text

msg_strs_replace =  _msg_edit (_strs_replace, 'msg_strs_replace')
file_strs_replace = _file_edit(_strs_replace, 'file_strs_replace')

# %% ../nbs/00_core.ipynb #7b11e714
def _norm_lines(n:int, start:int, end:int=None):
    "Normalize and validate line range. Returns (start, end) or raises ValueError."
    if end is None: end = start
    if end < 0: end = n + end + 1
    if not (1 <= start <= n): raise ValueError(f'Invalid start line {start}. Valid range: 1-{n}')
    if not (start <= end <= n): raise ValueError(f'Invalid end line {end}. Valid range: {start}-{n}')
    return start, end

# %% ../nbs/00_core.ipynb #1002423f
def _replace_lines(
    text:str,
    start_line:int, # Starting line number to replace (1-based indexing)
    end_line:int=None, # Ending line number to replace (1-based, inclusive, negative counts from end, None for single line)
    new_content:str='', # New content to replace the specified lines
):
    "Replace line range with new content"
    lines = text.splitlines(keepends=True)
    s,e = _norm_lines(len(lines), start_line, end_line)
    if lines and new_content and not new_content.endswith('\n'): new_content += '\n'
    lines[s-1:e] = [new_content] if new_content else []
    return ''.join(lines)

msg_replace_lines =  _msg_edit (_replace_lines, 'msg_replace_lines')
file_replace_lines = _file_edit(_replace_lines, 'file_replace_lines')

# %% ../nbs/00_core.ipynb #cbd87701
def _del_lines(
    text:str,
    start_line:int, # Starting line number to delete (1-based indexing)
    end_line:int=None, # Ending line number to delete (1-based, inclusive, negative counts from end, None for single line)
    re_filter:str=None, # If provided, only delete lines matching this regex (like g// in ex)
    invert_filter:bool=False, # Invert the filter (like g!// in ex)
):
    "Delete line range"
    lines = text.splitlines(keepends=True)
    s,e = _norm_lines(len(lines), start_line, end_line)
    if re_filter:
        pat = re.compile(re_filter)
        matched = {i for i in range(s-1,e) if bool(pat.search(lines[i])) != invert_filter}
        if not matched: return text
        lines = [l for i,l in enumerate(lines) if i not in matched]
    else: del lines[s-1:e]
    return ''.join(lines)

msg_del_lines =  _msg_edit (_del_lines, 'msg_del_lines')
file_del_lines = _file_edit(_del_lines, 'file_del_lines')

# %% ../nbs/00_core.ipynb #04feb916
async def _pyrun_edit(
    text:str,
    code:str, # Python code; `text` var has content, last expr is new content
):
    "Edit text by running `code` in pyrun. `text` var has content, last expr is new content"
    res = await pyrun(f'text = {repr(text)}\n{code}', concise=False)
    if 'error' in res: raise ValueError(str(res['error']))
    return res['result']

msg_pyrun =  _msg_edit (_pyrun_edit, 'msg_pyrun')
file_pyrun = _file_edit(_pyrun_edit, 'file_pyrun')

# %% ../nbs/00_core.ipynb #9adf1cbb
def ast_py(code:str):
    "Get an SgRoot root node for python `code`"
    from ast_grep_py import SgRoot
    return SgRoot(code, "python").root()

# %% ../nbs/00_core.ipynb #22c2316b
@llmtool
def ast_grep(
    pattern:str, # ast-grep pattern to search, e.g "post($A, data=$B, $$$)"
    path:str=".", # path to recursively search for files
    lang:str="python" # language to search/scan
): # json format from calling `ast-grep --json=compact
    """Use `ast-grep` to find code patterns by AST structure (not text).
    
    Pattern syntax:
    - $VAR captures single nodes, $$$ captures multiple
    - Match structure directly: `def $FUNC($$$)` finds any function; `class $CLASS` finds classes regardless of inheritance
    - DON'T include `:` - it's concrete syntax, not AST structure
    - Whitespace/formatting ignored - matches structural equivalence
    
    Examples: `import $MODULE` (find imports); `$OBJ.$METHOD($$$)` (find method calls); `await $EXPR` (find await expressions)
    
    Useful for: Refactoring—find all uses of deprecated APIs or changed signatures; Security review—locate SQL queries, file operations, eval calls; Code exploration—understand how libraries are used across codebase; Pattern analysis—find async functions, error handlers, decorators; Better than regex—handles multi-line code, nested structures, respects syntax"""
    import json, subprocess
    cmd = f"ast-grep --pattern '{pattern}' --lang {lang} --json=compact"
    if path != ".": cmd = f"cd {path} && {cmd}"
    res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return json.loads(res.stdout) if res.stdout else res.stderr

# %% ../nbs/00_core.ipynb #5fd28219
@delegates(folder2ctx)
async def ctx_folder(
    path:Path='.',  # Path to collect
    types:str|list='py,doc',  # list or comma-separated str of ext types from: py, js, java, c, cpp, rb, r, ex, sh, web, doc, cfg
    out=False, # Include notebook cell outputs?
    raw=True, # Add raw message, or note?
    exts:str|list=None, # list or comma-separated str of exts to include (overrides `types`)
    **kwargs
):
    "Convert folder to XML context and place in a new message"
    if exts: types=None
    res = folder2ctx(path, types=types, out=out, exts=exts, **kwargs)
    if not raw: res = f'```\n{res}\n```'
    return await add_msg(res, msg_type='raw' if raw else 'note')


# %% ../nbs/00_core.ipynb #2dc4dc40
@delegates(repo2ctx)
async def ctx_repo(
    owner:str,  # GitHub repo owner
    repo:str,   # GitHub repo name
    types:str|list='py,doc',  # list or comma-separated str of ext types from: py, js, java, c, cpp, rb, r, ex, sh, web, doc, cfg
    exts:str|list=None, # list or comma-separated str of exts to include (overrides `types`)
    out=False, # Include notebook cell outputs?
    raw=True, # Add raw message, or note?
    **kwargs
):
    "Convert GitHub repo to XML context and place in a new message"
    res = repo2ctx(owner, repo, out=out, types=types, exts=exts, **kwargs)
    if exts: types=None
    if not raw: res = f'```\n{res}\n```'
    return await add_msg(res, msg_type='raw' if raw else 'note')


# %% ../nbs/00_core.ipynb #5bd8915b
async def ctx_symfile(sym):
    "Add note with filepath and contents for a symbol's source file"
    return await add_msg(sym2file(sym), msg_type='note');


# %% ../nbs/00_core.ipynb #bbc9bd0c
@delegates(sym2folderctx)
async def ctx_symfolder(
    sym, # Symbol to get folder context from
    **kwargs):
    "Add raw message with folder context for a symbol's source file location"
    return await add_msg(sym2folderctx(sym, **kwargs), msg_type='raw');


# %% ../nbs/00_core.ipynb #9d8d83b5
@delegates(sym2folderctx)
async def ctx_sympkg(
    sym, # Symbol to get folder context from
    **kwargs):
    "Add raw message with repo context for a symbol's root package"
    return await add_msg(sym2pkgctx(sym, **kwargs), msg_type='raw');


# %% ../nbs/00_core.ipynb #eb4c6bf4
def load_gist(gist_id:str):
    "Retrieve a gist"
    api = GhApi()
    if '/' in gist_id: *_,user,gist_id = gist_id.split('/')
    else: user = None
    return api.gists.get(gist_id, user=user)

# %% ../nbs/00_core.ipynb #3b151f8f
def gist_file(gist_id:str):
    "Get the first file from a gist"
    gist = load_gist(gist_id)
    return first(gist.files.values())

# %% ../nbs/00_core.ipynb #1ccfc95a
def import_string(
    code:str, # Code to import as a module
    name:str  # Name of module to create
):
    with TemporaryDirectory() as tmpdir:
        path = Path(tmpdir) / f"{name}.py"
        path.write_text(code)
        # linecache.cache storage allows inspect.getsource() after tmpdir lifetime ends
        linecache.cache[str(path)] = (len(code), None, code.splitlines(keepends=True), str(path))
        spec = importlib.util.spec_from_file_location(name, path)
        module = importlib.util.module_from_spec(spec)
        sys.modules[name] = module
        spec.loader.exec_module(module)
        return module

# %% ../nbs/00_core.ipynb #27730708
def mk_toollist(syms):
    return "\n".join(f"- &`{sym.__name__}`: {sym.__doc__}" for sym in syms if is_usable_tool(sym))

# %% ../nbs/00_core.ipynb #84868ea2
def import_gist(
    gist_id:str, # user/id or just id of gist to import as a module
    mod_name:str=None, # module name to create (taken from gist filename if not passed)
    add_global:bool=True, # add module to caller's globals?
    import_wildcard:bool=False, # import all exported symbols to caller's globals
    create_msg:bool=False # Add a message that lists usable tools
):
    "Import gist directly from string without saving to disk"
    fil = gist_file(gist_id)
    mod_name = mod_name or Path(fil['filename']).stem
    module = import_string(fil['content'], mod_name)
    glbs = currentframe().f_back.f_globals
    if add_global: glbs[mod_name] = module
    syms = getattr(module, '__all__', None)
    if syms is None: syms = [o for o in dir(module) if not o.startswith('_')]
    syms = [getattr(module, nm) for nm in syms]
    if import_wildcard:
        for sym in syms: glbs[sym.__name__] = sym
    if create_msg:
        pref = getattr(module, '__doc__', "Tools added to dialog:")
        asyncio.ensure_future(add_msg(f"{pref}\n\n{mk_toollist(syms)}"))
    return module

# %% ../nbs/00_core.ipynb #f790682a
def update_gist(gist_id:str, content:str):
    "Update the first file in a gist with new content"
    api = GhApi()
    if '/' in gist_id: *_,user,gist_id = gist_id.split('/')
    gist = api.gists.get(gist_id)
    fname = first(gist.files.keys())
    res = api.gists.update(gist_id, files={fname: {'content': content}})
    return res['html_url']

# %% ../nbs/00_core.ipynb #fccb02d6
@llmtool
def dialoghelper_explain_dialog_editing(
)->str: # Detailed documention on dialoghelper dialog editing
    """Call this to get a detailed explanation of how dialog editing is done in dialoghelper.
    **ALWAYS** call this first, if dialog editing has not previously occured in this session"""
    return """# dialoghelper dialog editing functionality

This guide consolidates understanding of how dialoghelper tools work together. Individual tool schemas are already in context—this adds architectural insight and usage patterns.

## Core Concepts

- **Dialog addressing**: All functions accepting `dname` resolve paths relative to current dialog (no leading `/`) or absolute from Solveit's runtime data path (with leading `/`). The `.ipynb` extension is never included.
- **Message addressing**: Messages have stable `id` strings (e.g., `_a9cb5512`). Solveit sets the "current message" to the most recently run message.
- **Implicit state**: After `add_msg`/`update_msg`, the "current message" is updated to the new/modified message. This enables chaining: successive `add_msg` calls create messages in sequence.

## Tool Workflow Patterns

### Reading dialog state
- `view_dlg` — fastest way to see entire dialog structure with line numbers for editing
- `find_msgs` — search with regex, filter by type/errors/changes
- `read_msg` — navigate relative to current message
- `view_msg` (content+line numbers only) or `read_msgid` (including metadata and output)  — direct access when you have the id

**Key insight**: Messages above the current prompt are already in LLM context—their content and outputs are always up-to-date. Do NOT use read tools just to review content you can already see. Use read tools only for: (1) getting line numbers immediately before editing, (2) accessing messages below current prompt (if you're sure the user wants you to "look ahead"), (3) accessing other dialogs.

### Modifying dialogs
- `add_msg` — placement can be `add_after`/`add_before` (relative to current) or `at_start`/`at_end` (absolute)
  - **NB** When not passing a message id, it defaults to the *current* message. So if you call it multiple times with no message id, the messages will be added in REVERSE! Instead, get the return value of `add_msg` after each call, and use that for the next call
- `update_msg` — partial updates; only pass fields to change
- `del_msg` — use sparingly, only when explicitly requested
`copy_msg` → `paste_msg` — for moving/duplicating messages within running dialogs.

## Non-decorated Functions Worth Knowing

There are additional functions available that can be added to fenced blocks, or the user may add as tools; they are not included in schemas by default.

**Browser integration:**
- `add_html(content)` — inject HTML with `hx-swap-oob` into live browser DOM
- `iife(code)` — execute JavaScript immediately in browser
- `fire_event(evt, data)` / `event_get(evt)` — trigger/await browser events

**Content helpers:**
- `url2note(url, ...)` — fetch URL as markdown, add as note message
- `mermaid(code)` / `enable_mermaid()` — render mermaid diagrams
- `add_styles(s)` — apply solveit's MonsterUI styling to HTML

**Dangerous (not exposed by default):**
- `_add_msg_unsafe(content, run_mode='run', ...)` — add AND execute message (code or prompt)
- `run_msg(ids)` — queue messages for execution
- `rm_dialog(name)` — delete entire dialog

## Important Patterns

### Key Principles

1. **Always re-read before editing.** Past tool call results in chat history are TRUNCATED. Never rely on line numbers from earlier in the conversation—call `view_msg(id)` immediately before any edit operation.
2. **Work backwards.** When making multiple edits to a message, start from the end and work towards the beginning. This prevents line number shifts from invalidating your planned edits.
3. **Don't guess when tools fail.** If a tool call returns an error, STOP and ask for clarification. Do not retry with guessed parameters.
4. **Verify after complex edits.** After significant changes, re-read the affected region to confirm the edit worked as expected before proceeding.

### Typical Workflow

```
1. view_msg(id)           # Get current state with line numbers
2. Identify lines to change
3. msg_replace_lines(...) or msg_str_replace(...)  # Make edit
4. If more edits needed: re-read, then repeat from step 2
```

### Tool Selection

- **`msg_replace_lines`**: Best for replacing/inserting contiguous blocks. Use `view_range` on read to focus on the area.
- **`msg_str_replace`**: Best for targeted single small string replacements when you know the exact text.
- **`msg_strs_replace`**: Best for multiple small independent replacements in one call.
- **`msg_insert_line`**: Best for adding new content without replacing existing lines.
- **`msg_del_lines`**: Best for removing content.

**Rough rule of thumb:** Prefer `msg_replace_lines` over `msg_str(s)_replace` unless there's >1 match to change or it's just a word or two. Use the insert/delete functions for inserting/deleting; don't use `msg_str(s)_replace` for that.

### Common Mistakes to Avoid

- Using line numbers from a truncated earlier result
- Making multiple edits without re-reading between them
- Guessing line numbers when a view_range was truncated
- Always call `view_msg(id)` first to get accurate line numbers
- String-based tools (`msg_str_replace`, `msg_strs_replace`) fail if the search string appears zero or multiple times—use exact unique substrings."""

# %% ../nbs/00_core.ipynb #11ee26d9
@llmtool
def solveit_docs():
    """Full reference documentation for Solveit - use this to answer questions about how to use Solveit.
    **NB**: The whole docs fit in LLM context, so read the whole thing, don't search/filter it. *Always* re-run rather than relying on truncated history or assumptions."""
    _ref_gist_id = '9e7b444aba5ecf6d14295ba2cee890c3'
    pre = f"""⚠️ This content will be truncated in your next turn. Re-call this tool if you need it again.
If the user wants more info, give them a link to https://gist.github.com/jph00/{_ref_gist_id}."""
    return pre + gist_file(_ref_gist_id)['content']

# %% ../nbs/00_core.ipynb #70ec67db
@llmtool
def dialog_link(
    path:str='', # Path to dialog (e.g. '/aai-ws/dialoghelper/nbs/00_core'), defaults to current dialog
    msg_id:str=None # Optional message id to scroll to
):
    """Return an IPython HTML link to open a dialog in Solveit.
    After calling this tool, output the resulting HTML anchor tag exactly as returned—do not wrap in a fenced code block or convert to markdown link format."""
    if not (path or msg_id): return 'err: no path or id'
    path = path.removeprefix('/')
    url = ''
    if path: url += f"/dialog_?{urlencode({'name': path})}"
    if msg_id: url += f"#{msg_id}"
    return HTML(f'<a href="{url}" target="_blank">{path}</a>') if path else Markdown(f'[{url}]({url})')

# %% ../nbs/00_core.ipynb #c147990d
@llmtool
async def spawn_agent(prompt:str):
    """Spawn a subagent to complete a task defined by `prompt`. Must be run as a tool - not from Python.
    The subagent's context and tools is defined by the parent prompt's history"""
    raise Exception("Do not run from python: this is a server-side only tool")
