# 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', '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 fastcore.xml import to_xml

from fastcore.utils import *
from fastcore.meta import delegates
from ghapi.all import *
from fastlite 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():
    "Get the message id by searching the call stack for __dialog_id."
    return dh_settings.get('dname', find_var('__dialog_name'))

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, **data):
    if not dname: dname = find_dname()
    data['dlg_name'] = dname
    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='' # Running 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(
    msgid=None,  # Message id to find (defaults to current message)
    dname:str='' # Running dialog to get info for; defaults to current dialog
):
    "Get absolute index of message in dialog."
    _diff_dialog(not msgid, "`msgid` parameter must be provided when target dialog is different")
    if not msgid: msgid = find_msg_id()
    return call_endp('msg_idx_', dname, json=True, msgid=msgid)['msgid']

# %% ../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='' # Running dialog to get info for; defaults to current dialog
):
    """Find `list[dict]` of messages in current specific dialog that contain the given information. Call with no args to see the full dialog.
    Message ids are identical to those in LLM chat history, so do NOT call this to view a specific message -- 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 message found later, 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='' # Running 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)?
    msgid: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='' # Running 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 `msgid`.
    - 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."""
    _diff_dialog(relative and not msgid, "`msgid` parameter must be provided, or use `relative=False` with `n`, when target dialog is different")
    if relative and not msgid: msgid = find_msg_id()
    data = dict(n=n, relative=relative, msgid=msgid)
    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(
    msgid: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='' # Running dialog to get info for; defaults to current dialog
    ):
    """Get message `msgid`. Message IDs can be view directly in LLM chat history/context, or found in `find_msgs` results."""
    return read_msg(0, msgid=msgid, 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'
    msgid: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='' # Running dialog to get info for; defaults to current dialog
):
    "Add/update a message to the queue to show after code execution completes."
    _diff_dialog(placement not in ('at_start','at_end') and not msgid, "`msgid` 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 msgid: msgid = find_msg_id()
    res = call_endp(
        'add_relative_', dname, content=content, placement=placement, msgid=msgid, 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(
    msgid:str=None, # id of message to delete
    dname:str='' # Running dialog to get info for; defaults to current dialog
):
    "Delete a message from the dialog."
    return call_endp('rm_msg_', dname, raiseex=True, msid=msgid, 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'
    msgid: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='', # Running dialog to get info for; defaults to current dialog
    **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 msgid, "`msgid` 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 msgid: msgid = find_msg_id()
    res = call_endp(
        'add_relative_', dname, content=content, placement=placement, msgid=msgid, 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(
    msgid: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='', # Running 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 msg: kwargs |= msg.get('msg', msg)
    if not msgid: msgid = kwargs.pop('id', None)
    if not msgid: raise TypeError("update_msg needs either a dict message with and id, or `msgid=`")
    res = call_endp('update_msg_', dname, msgid=msgid, **kwargs)
    set_var('__msg_id', res)
    return res

# %% ../nbs/00_core.ipynb
def run_msg(
    msgids:str=None, # Comma-separated ids of message(s) to execute
    dname:str='' # Running dialog to get info for; defaults to current dialog
):
    "Adds a message to the run queue. Use read_msg to see the output once it runs."
    return call_endp('add_runq_', dname, msgids=msgids, api=True)

# %% ../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
@delegates(sym2file)
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(
    msgid:str,  # Message id to edit
    insert_line: int, # The line number after which to insert the text (0 for beginning of file)
    new_str: str, # The text to insert
    dname:str='' # Running 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."
    content = read_msg(n=0, msgid=msgid, dname=dname)['msg']['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(msgid=msgid, content='\n'.join(lines), dname=dname)
    return {'success': f'Inserted text after line {insert_line} in message {msgid}'}

# %% ../nbs/00_core.ipynb
def msg_str_replace(
    msgid:str,  # Message id to edit
    old_str: str, # Text to find and replace
    new_str: str, # Text to replace with
    dname:str='' # Running dialog to get info for; defaults to current dialog
):
    "Replace first occurrence of old_str with new_str in a message"
    content = read_msg(n=0, msgid=msgid, dname=dname)['msg']['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(msgid=msgid, content=content.replace(old_str, new_str, 1), dname=dname)
    return {'success': f'Replaced text in message {msgid}'}

# %% ../nbs/00_core.ipynb
def msg_strs_replace(
    msgid: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='' # Running dialog to get info for; defaults to current dialog
):
    "Replace multiple strings simultaneously in a message"
    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, msgid=msgid, dname=dname)['msg']['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(msgid=msgid, content=content, dname=dname)
    return {'success': f'Successfully replaced all the strings in message {msgid}'}

# %% ../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(
    msgid: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='' # Running 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."""
    content = read_msg(n=0, msgid=msgid, dname=dname)['msg']['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(msgid=msgid, content=''.join(lines), dname=dname)
    return {'success': f'Replaced lines {start_line} to {end_line} in message {msgid}'}

# %% ../nbs/00_core.ipynb
def msg_del_lines(
    msgid: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='' # Running 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."
    content = read_msg(n=0, msgid=msgid, dname=dname)['msg']['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(msgid=msgid, content=''.join(lines), dname=dname)
    return {'success': f'Deleted lines {start_line} to {end_line} in message {msgid}'}

# %% ../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)
