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

# %% auto #0
__all__ = ['md_cls_d', 'dh_settings', 'Placements', 'mermaid_url', 'empty', 'add_styles', 'find_var', 'set_var', 'find_dname',
           'find_msg_id', 'call_endp', 'curr_dialog', 'msg_idx', 'add_scr', 'iife', 'pop_data', 'fire_event',
           'event_get', 'find_msgs', 'view_dlg', 'add_html', 'read_msg', 'read_msgid', 'add_msg', 'del_msg',
           'update_msg', 'run_msg', 'copy_msg', 'paste_msg', 'enable_mermaid', 'mermaid', 'toggle_header', 'url2note',
           'ast_py', 'ast_grep', 'ctx_folder', 'ctx_repo', 'ctx_symfile', 'ctx_symfolder', 'ctx_sympkg',
           'msg_insert_line', 'msg_str_replace', 'msg_strs_replace', 'msg_replace_lines', 'msg_del_lines', 'load_gist',
           'gist_file', 'import_string', 'is_usable_tool', 'mk_toollist', 'import_gist', 'tool_info', 'fc_tool_info',
           'is_tool']

# %% ../nbs/00_core.ipynb #e881cda4
import json,importlib,linecache,re,inspect,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.utils import *
from fastcore.meta import delegates
from ghapi.all import *
from fastcore.xtras import asdict
from inspect import currentframe,Parameter,signature
from httpx import get as xget, post as xpost
from IPython.display import display,Markdown
from monsterui.all import franken_class_map,apply_classes
from fasthtml.common import Safe,Script,Div
from toolslm.xml import *

# %% ../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 #f80e3a63
def _find_frame_dict(var:str):
    "Find the dict (globals or locals) containing var"
    frame = currentframe().f_back.f_back
    while frame:
        if var in frame.f_globals: return frame.f_globals
        frame = frame.f_back
    raise ValueError(f"Could not find {var} in any scope")

def find_var(var:str):
    "Search for var in all frames of the call stack"
    return _find_frame_dict(var)[var]
    
def set_var(var:str, val):
    "Set var to val after finding it in all frames of the call stack"
    _find_frame_dict(var)[var] = val

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

# %% ../nbs/00_core.ipynb #a01ad161
def find_dname(dname=None):
    "Get the dialog name by searching the call stack for __dialog_id, and resolving `dname` if supplied."
    if dname:
        dname = dname.removesuffix('.ipynb')
        if dname.startswith('/'): return dname
    curr = dh_settings.get('dname', 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

def find_msg_id():
    "Get the message id by searching the call stack for __msg_id."
    return find_var('__msg_id')

def _diff_dialog(pred, err):
    "Raise ValueError if targeting a different dialog and `pred` is True"
    if 'dname' in dh_settings and pred: raise ValueError(err)

# %% ../nbs/00_core.ipynb #96bc23d8
def call_endp(path, dname='', json=False, raiseex=False, id=None, **data):
    dname = find_dname(dname).strip('/')
    data['dlg_name'] = dname
    if id: data['id_'] = id
    headers = {'Accept': 'application/json'} if json else {}
    res = xpost(f'http://localhost:{dh_settings["port"]}/{path}', data=data, headers=headers)
    if raiseex: res.raise_for_status()
    try: return dict2obj(res.json()) if json else res.text
    except Exception as e: return str(e)

# %% ../nbs/00_core.ipynb #a9cb5512
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 = call_endp('curr_dialog_', dname, json=True, with_messages=with_messages)
    if res: return {'name': res['name'], 'mode': res['mode']}

# %% ../nbs/00_core.ipynb #8810450f
def msg_idx(
    id=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(not id, "`id` parameter must be provided when target dialog is different")
    if not id: id = find_msg_id()
    return call_endp('msg_idx_', dname, json=True, id=id)['idx']

# %% ../nbs/00_core.ipynb #4b43e4e9
def add_scr(scr, oob='beforeend:#js-script'):
    "Swap a script element to the end of the js-script element"
    if isinstance(scr,str): scr = Script(scr)
    add_html(Div(scr, hx_swap_oob=oob))

# %% ../nbs/00_core.ipynb #0823ff14
def iife(code: str) -> str:
    "Wrap javascript code string in an IIFE and execute it via `add_html`"
    add_scr(f'''
(async () => {{
{code}
}})();
''')

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

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

# %% ../nbs/00_core.ipynb #b1bb1088
def event_get(evt:str, timeout=15, data=None):
    "Call `fire_event` and then `pop_data` to get a response"
    idx = uuid.uuid4()
    if not data: data = {}
    data['idx'] = str(idx)
    fire_event(evt, data=data)
    return pop_data(idx, timeout)

# %% ../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 #6a4aa03b
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_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?
    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`.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension.
    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 `read_msgid`.
    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 = call_endp('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_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)
    return _maybe_xml(res, as_xml=as_xml, key='msgs')

# %% ../nbs/00_core.ipynb #9ff2a38e
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 `read_msg` multiple times."
    return 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 #2489a94d
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 #fdc5a465
Placements = str_enum('Placements', 'add_after', 'add_before', 'at_start', 'at_end')

# %% ../nbs/00_core.ipynb #d30ef9c9
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.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    _diff_dialog(relative and not id, "`id` parameter must be provided, or use `relative=False` with `n`, when target dialog is different")
    if relative and not id: id = find_msg_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 call_endp('read_msg_', dname, json=True, **data)

# %% ../nbs/00_core.ipynb #7c4c7f5e
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
    ):
    """Get message `id`. Message IDs can be view directly in LLM chat history/context, or found in `find_msgs` results.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    return read_msg(0, id=id, view_range=view_range, nums=nums, dname=dname)

# %% ../nbs/00_core.ipynb #3ad14786
def add_msg(
    content:str, # Content of the message (i.e the message prompt, code, or note text)
    placement:str='add_after', # Can be 'add_after', 'add_before', 'at_start', 'at_end'
    id:str=None, # id of message that placement is relative to (if None, uses current message; note: each add_msg updates "current" to the newly created message)
    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?
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Add/update a message to the queue to show after code execution completes.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    _diff_dialog(placement not in ('at_start','at_end') and not id, "`id` or `placement='at_end'`/`placement='at_start'` must be provided when target dialog is different")
    if placement not in ('at_start','at_end') and not id: id = find_msg_id()
    res = call_endp(
        'add_relative_', dname, content=content, placement=placement, id=id, msg_type=msg_type, output=output,
        time_run=time_run, is_exported=is_exported, skipped=skipped, pinned=pinned,
        i_collapsed=i_collapsed, o_collapsed=o_collapsed, heading_collapsed=heading_collapsed)
    set_var('__msg_id', res)
    return res

# %% ../nbs/00_core.ipynb #f1ee1903
def del_msg(
    id:str=None, # id of message to delete
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    "Delete a message from the dialog. DO NOT USE THIS unless you have been explicitly instructed to delete messages."
    return call_endp('rm_msg_', dname, raiseex=True, msid=id, json=False)

# %% ../nbs/00_core.ipynb #a9614fcc
@delegates(add_msg)
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 'add_after', 'add_before', 'at_start', 'at_end'
    id:str=None, # id of message that placement is relative to (if None, uses current message)
    run:bool=False, # For prompts, send it to the AI; for code, execute it (*DANGEROUS -- be careful of what you run!)
    dname:str='', # Dialog to get info for; defaults to current dialog (`run` only has a effect if dialog is currently running)
    **kwargs
):
    """Add/update a message to the queue to show after code execution completes, and optionally run it.
    *WARNING*--This can execute arbitrary code, so check carefully what you run!--*WARNING"""
    _diff_dialog(placement not in ('at_start','at_end') and not id, "`id` or `placement='at_end'`/`placement='at_start'` must be provided when target dialog is different")    
    if placement not in ('at_start','at_end') and not id: id = find_msg_id()
    res = call_endp(
        'add_relative_', dname, content=content, placement=placement, id=id, run=run, **kwargs)
    set_var('__msg_id', res)
    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 #76ea5418
@delegates(_umsg)
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
    **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.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    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 = call_endp('update_msg_', dname, id=id, **kwargs)
    set_var('__msg_id', res)
    return res

# %% ../nbs/00_core.ipynb #316bd7a0
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 call_endp('add_runq_', dname, ids=ids, api=True)

# %% ../nbs/00_core.ipynb #73025e57
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 = call_endp('msg_clipboard_', dname, ids=ids, id=id, cmd='cut' if cut else 'copy')
    return {'success':'complete'}

# %% ../nbs/00_core.ipynb #80def27e
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 = call_endp('msg_paste_', dname, id=id, after=after)
    return {'success':'complete'}

# %% ../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
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 = call_endp('toggle_header_collapse_', dname, id=id)
    return {'success':'complete'}

# %% ../nbs/00_core.ipynb #1827e124
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 [add_msg(s) for s in re.split(split_re, res, flags=re.MULTILINE) if s.strip()]
    return add_msg(res)


# %% ../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
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)
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 add_msg(res, msg_type='raw' if raw else 'note')

# %% ../nbs/00_core.ipynb #2dc4dc40
@delegates(repo2ctx)
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 add_msg(res, msg_type='raw' if raw else 'note')

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

# %% ../nbs/00_core.ipynb #bbc9bd0c
@delegates(sym2folderctx)
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 add_msg(sym2folderctx(sym, **kwargs), msg_type='raw');

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

# %% ../nbs/00_core.ipynb #c4f0c88d
def msg_insert_line(
    id:str,  # Message id to edit
    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
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Insert text at a specific line number in a message. Be sure you've called `read_msg(..., nums=True)` to ensure you know the line numbers.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    content = read_msg(n=0, id=id, dname=dname)['content']
    lines = content.splitlines()
    if not (0 <= insert_line <= len(lines)): return {'error': f'Invalid line number {insert_line}. Valid range: 0-{len(lines)}'}
    lines.insert(insert_line, new_str)
    update_msg(id=id, content='\n'.join(lines), dname=dname)
    return {'success': f'Inserted text after line {insert_line} in message {id}'}

# %% ../nbs/00_core.ipynb #aacdc043
def msg_str_replace(
    id:str,  # Message id to edit
    old_str: str, # Text to find and replace
    new_str: str, # Text to replace with
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Replace first occurrence of old_str with new_str in a message.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    content = read_msg(n=0, id=id, dname=dname)['content']
    count = content.count(old_str)
    if count == 0: return {'error': f"Text not found in message: {repr(old_str)}"}
    if count > 1: return {'error': f"Multiple matches found ({count}) for text: {repr(old_str)}"}
    update_msg(id=id, content=content.replace(old_str, new_str, 1), dname=dname)
    return {'success': f'Replaced text in message {id}'}

# %% ../nbs/00_core.ipynb #cf3609ad
def msg_strs_replace(
    id:str,  # Message id to edit
    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)
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Replace multiple strings simultaneously in a message.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    if not isinstance(old_strs, list): return {'error': f"`old_strs` should be a list[str] but got {type(old_strs)}"}
    if not isinstance(new_strs, list): return {'error': f"`new_strs` should be a list[str] but got {type(new_strs)}"}
    if len(old_strs) != len(new_strs): return {'error': f"Length mismatch: {len(old_strs)} old_strs vs {len(new_strs)} new_strs"}
    content = read_msg(n=0, id=id, dname=dname)['content']
    for idx, (old_str, new_str) in enumerate(zip(old_strs, new_strs)):
        count = content.count(old_str)
        if count == 0: return {'error': f"Text not found in message at index {idx}: {repr(old_str)}"}
        if count > 1: return {'error': f"Multiple matches ({count}) found at index {idx} for: {repr(old_str)}"}
        content = content.replace(old_str, new_str, 1)
    update_msg(id=id, content=content, dname=dname)
    return {'success': f'Successfully replaced all the strings in message {id}'}

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

# %% ../nbs/00_core.ipynb #fd2f2107
def msg_replace_lines(
    id:str,  # Message id to edit
    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
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Replace a range of lines with new content in a message.
    Be sure you've called `read_msg(..., nums=True)` to ensure you know the line numbers.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    content = read_msg(n=0, id=id, dname=dname)['content']
    lines = content.splitlines(keepends=True)
    res = _norm_lines(len(lines), start_line, end_line)
    if isinstance(res, dict): return res
    start_line, end_line = res
    if lines and new_content and not new_content.endswith('\n'): new_content += '\n'
    lines[start_line-1:end_line] = [new_content] if new_content else []
    update_msg(id=id, content=''.join(lines), dname=dname)
    return {'success': f'Replaced lines {start_line} to {end_line} in message {id}'}

# %% ../nbs/00_core.ipynb #70353f98
def msg_del_lines(
    id:str,  # Message id to edit
    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)
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Delete a range of lines from a message. Be sure you've called `read_msg(..., nums=True)` to ensure you know the line numbers.
    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 the solveit root directory (if starting with `/`) or relative to the current dialog (if not starting with `/`), and should *not* include the .ipynb extension."""
    content = read_msg(n=0, id=id, dname=dname)['content']
    lines = content.splitlines(keepends=True)
    res = _norm_lines(len(lines), start_line, end_line)
    if isinstance(res, dict): return res
    start_line, end_line = res
    del lines[start_line-1:end_line]
    update_msg(id=id, content=''.join(lines), dname=dname)
    return {'success': f'Deleted lines {start_line} to {end_line} in message {id}'}

# %% ../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 #752bf2df
empty = Parameter.empty

def is_usable_tool(func:callable):
    "True if the function has a docstring and all parameters have types, meaning that it can be used as an LLM tool."    
    if not func.__doc__ or not callable(func): return False
    return all(p.annotation != empty for p in signature(func).parameters.values())

# %% ../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:")
        add_msg(f"{pref}\n\n{mk_toollist(syms)}")
    return module

# %% ../nbs/00_core.ipynb #c304279a
def tool_info():
    cts='''Tools available from dialoghelper: &`[curr_dialog, view_dlg, msg_idx, find_msgs, read_msg, del_msg, add_msg, update_msg, msg_insert_line, msg_str_replace, msg_strs_replace, msg_replace_lines]`'''
    add_msg(cts)

# %% ../nbs/00_core.ipynb #609f529d
def fc_tool_info():
    cts='''Tools available from fastcore.tools: &`[rg, sed, view, create, insert, str_replace, strs_replace, replace_lines, move_lines]`'''
    add_msg(cts)
    add_msg('from fastcore.tools import *', msg_type='code')

# %% ../nbs/00_core.ipynb #06a1c70f
def is_tool(o):
    "Check if `o` is a valid tool (has name, docstring, and all params have type annotations)"
    if not hasattr(o, '__name__') or o.__name__[0]=='_' or not o.__doc__: return False
    return is_usable_tool(o)
