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

# %% auto 0
__all__ = ['md_cls_d', 'dh_settings', 'Placements', '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',
           'add_html', 'read_msg', 'read_msgid', 'add_msg', 'del_msg', 'update_msg', 'run_msg', 'copy_msg', 'paste_msg',
           '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
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
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
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
dh_settings = {'port':5001}

# %% ../nbs/00_core.ipynb
def find_dname(dname=None):
    "Get the message id by searching the call stack for __dialog_id."
    if dname:
        dname = dname.removesuffix('.ipynb')
        if dname.startswith('/'): return dname[1:]
    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
def call_endp(path, dname='', json=False, raiseex=False, id=None, **data):
    dname = find_dname(dname)
    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
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
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
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
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
def pop_data(idx, timeout=15):
    return dict2obj(call_endp('pop_data_blocking_', data_id=idx, timeout=timeout, json=True))

# %% ../nbs/00_core.ipynb
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
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
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')
    limit:int=None, # Optionally limit number of returned items
    include_output:bool=True, # Include output in returned dict?
    dname:str='' # Dialog to get info for; defaults to current dialog
):
    """Find `list[dict]` of messages in requested dialog that contain the given information. Call with no args to see the full dialog. Often it is more efficient to call once with no `re_pattern` to see the whole dialog at once, so you can then use the full context from then on.
    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`.
    Note that LLM chat history 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=True, re_pattern=re_pattern, msg_type=msg_type, limit=limit)['msgs']
    if not include_output:
        for o in res: o.pop('output', None)
    return res

# %% ../nbs/00_core.ipynb
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
Placements = str_enum('Placements', 'add_after', 'add_before', 'at_start', 'at_end')

# %% ../nbs/00_core.ipynb
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.
    - 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
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
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
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."
    return call_endp('rm_msg_', dname, raiseex=True, msid=id, json=True)

# %% ../nbs/00_core.ipynb
@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. Be sure to pass a `sid` (stable id) not a `mid` (which is used only for sorting, and can change).
    *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
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
@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
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
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
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
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
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
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
def ast_grep(
    pattern:str, # ast-grep pattern to search
    path=".", # path to recursively search for files
    lang="python" # language to search/scan
):
    "Use the `ast-grep` command to find `pattern` in `path`"
    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
@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?
    **kwargs
):
    "Convert folder to XML context and place in a new message"
    res = folder2ctx(path, types=types, out=out, **kwargs)
    if not raw: res = f'```\n{res}\n```'
    return add_msg(res, msg_type='raw' if raw else 'note')

# %% ../nbs/00_core.ipynb
@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
    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, **kwargs)
    if not raw: res = f'```\n{res}\n```'
    return add_msg(res, msg_type='raw' if raw else 'note')

# %% ../nbs/00_core.ipynb
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
@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
@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
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
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
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
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
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
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
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
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
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
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
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
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
def tool_info():
    cts='''Tools available from dialoghelper: &`[curr_dialog, msg_idx, add_html, find_msg_id, 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
def fc_tool_info():
    cts='''Tools available from fastcore.tools: &`[rg, sed, view, create, insert, str_replace, strs_replace, replace_lines]`'''
    add_msg(cts)
    add_msg('from fastcore.tools import *', msg_type='code')

# %% ../nbs/00_core.ipynb
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)
