From b8b4ebc4d2646e4f464c4b90f35ee38cdb67d53d Mon Sep 17 00:00:00 2001 From: Jeremy Howard Date: Mon, 31 Aug 2026 09:24:03 +1000 Subject: [PATCH] Add kernel-free dialog navigation and editing commands --- README.md | 25 +++++++-- aidialog/_modidx.py | 3 +- aidialog/cli.py | 125 ++++++++++++++++++++++++++++++++++++++++++++ nbs/index.ipynb | 46 +++++++++++----- pyproject.toml | 8 +++ 5 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 aidialog/cli.py diff --git a/README.md b/README.md index 93f3df4..65a8c70 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The [`Dialog`](https://AnswerDotAI.github.io/aidialog/dialog.html#dialog) is the | canonical chat ([`Msg`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#msg)/[`Part`](https://AnswerDotAI.github.io/aidialog/msg_parts.html#part)) | transmission, normalizing | [`chat2dlg`](https://AnswerDotAI.github.io/aidialog/hist.html#chat2dlg) | [`dlg2chat`](https://AnswerDotAI.github.io/aidialog/hist.html#dlg2chat) | | hist (live call input) | transmission, one-way | | [`dlg2hist`](https://AnswerDotAI.github.io/aidialog/hist.html#dlg2hist) | | a prompt’s reply | self-similar | [`reply2dlg`](https://AnswerDotAI.github.io/aidialog/hist.html#reply2dlg) | [`dlg2reply`](https://AnswerDotAI.github.io/aidialog/hist.html#dlg2reply) | -| XML views | display, one-way | | [`view_dlg`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#view_dlg), [`msg2xml`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#msg2xml) | +| XML views | display, one-way | | [`view_dlg`](https://AnswerDotAI.github.io/aidialog/dlgskill.html#view_dlg), [`msg2xml`](https://AnswerDotAI.github.io/aidialog/dialog.html#msg2xml) | The session codecs (in [llmsurgery](https://github.com/AnswerDotAI/llmsurgery)) route through chat on their way to the wire: ant’s `dlg2msgs` and oai’s `dlg2items` are each `denorm_msgs(dlg2chat(...))`. @@ -67,11 +67,28 @@ A quick taste - create a dialog, add a message, and view it as concise XML: ``` python from aidialog.dlgskill import * import tempfile +``` +``` python p = tempfile.mkdtemp() + '/demo.ipynb' -create_dlg(p, '## A tiny dialog', 'note') -add_msg('6*7', after=find_msgs(dlg=p)[0].id, dlg=p) +d = create_dlg(p, '## A tiny dialog', 'note') +add_msg('6*7', after=d.messages[0].id, dlg=p) view_dlg(p) ``` - ## A tiny dialog6*7 + ## A tiny dialog6*7 + +## Command line + +The flat commands expose dialog-aware reading and structural edits without a Python kernel: + +``` bash +aidialog-summary nbs/00_core.ipynb +aidialog-find nbs/00_core.ipynb 'read_csv' --context 1 +aidialog-view nbs/00_core.ipynb ab12cd34 --out +aidialog-add nbs/00_core.ipynb --after ab12cd34 --msg-type code < new-cell.py +aidialog-del nbs/00_core.ipynb ab12cd34 +aidialog-move nbs/00_core.ipynb ab12cd34,ef56ab78 --before 9012cdef +``` + +Message ID arguments are comma-separated where a command accepts several. Mutating commands accept `--dry-run`; run any command with `--help` for its full filters and display options. diff --git a/aidialog/_modidx.py b/aidialog/_modidx.py index 985c61b..ec907bf 100644 --- a/aidialog/_modidx.py +++ b/aidialog/_modidx.py @@ -5,7 +5,8 @@ 'doc_host': 'https://AnswerDotAI.github.io', 'git_url': 'https://github.com/AnswerDotAI/aidialog', 'lib_path': 'aidialog'}, - 'syms': { 'aidialog.dialog': { 'aidialog.dialog.Attachment': ('dialog.html#attachment', 'aidialog/dialog.py'), + 'syms': { 'aidialog.cli': {}, + 'aidialog.dialog': { 'aidialog.dialog.Attachment': ('dialog.html#attachment', 'aidialog/dialog.py'), 'aidialog.dialog.Attachment.__init__': ('dialog.html#attachment.__init__', 'aidialog/dialog.py'), 'aidialog.dialog.Dialog': ('dialog.html#dialog', 'aidialog/dialog.py'), 'aidialog.dialog.Dialog.__bool__': ('dialog.html#dialog.__bool__', 'aidialog/dialog.py'), diff --git a/aidialog/cli.py b/aidialog/cli.py new file mode 100644 index 0000000..ec769ff --- /dev/null +++ b/aidialog/cli.py @@ -0,0 +1,125 @@ +"Command-line access to dialog and notebook navigation and structure." + +import sys +from fastcore.script import call_parse +from .dlgskill import summary_dlg, find_msgs, view_dlg, view_msgs +from .ipynb import read_ipynb + + +def _die(msg): raise SystemExit(msg) + + +def _dialog(fname): + if (res := read_ipynb(fname)) is None: _die(f"Could not read dialog: {fname}") + return res + + +def _placement(before, after, required=False): + if before and after: _die("Pass only one of --before and --after") + if required and not (before or after): _die("Pass one of --before and --after") + + +def _ids(ids): return [o.strip() for o in ids.split(',') if o.strip()] + + +@call_parse +def summary_cli( + fname:str, # Dialog or notebook path + maxlen:int=180, # Maximum characters displayed per message +): + "Show one preview row per message." + print(summary_dlg(fname, maxlen=maxlen)) + + +@call_parse(pos=['pattern']) +def find_cli( + fname:str, # Dialog or notebook path + pattern:str='', # Regex or plain text to find + msg_type:str=None, # Limit matches to code, note, prompt, or raw messages + errors:bool=False, # Match only code messages with errors? + exported:bool=False, # Match only exported messages? + ids:str='', # Comma-separated message IDs to select + before:int=0, # Messages of context before each match + after:int=0, # Messages of context after each match + context:int=None, # Messages of context before and after each match + limit:int=None, # Maximum matched messages + case:bool=False, # Match case sensitively? + plain:bool=False, # Treat pattern as plain text rather than regex? + headers:bool=False, # Match only heading notes? + section:str=None, # Return the section beginning at this heading +): + "Find messages using dialog-aware filters and context." + print(find_msgs(pattern, dlg=fname, msg_type=msg_type, only_err=errors, only_exp=exported, ids=ids, + before=before, after=after, context=context, limit=limit, use_case=case, use_regex=not plain, + headers_only=headers, header_section=section)) + + +@call_parse(pos=['ids']) +def view_cli( + fname:str, # Dialog or notebook path + ids:str='', # Comma-separated message IDs; omit to view the whole dialog + nums:bool=True, # Show line numbers for individual messages? + lnhashs:bool=False, # Show hash-verified line addresses instead of line numbers? + start_line:int=1, # First source line to show + end_line:int=None, # Last source line to show + out:bool=False, # Include prompt replies and code outputs? + full_out:bool=False, # Do not truncate included outputs? + errors:bool=False, # Show only code messages with errors? +): + "View complete dialogs or selected messages." + if (sel := _ids(ids)): + if errors: _die("--errors applies only when viewing a whole dialog") + res = view_msgs(*sel, dlg=fname, nums=nums, start_line=start_line, end_line=end_line, + lnhashs=lnhashs, incl_out=out, trunc_out=not full_out) + else: + if lnhashs or not nums or start_line != 1 or end_line is not None: _die("Line options require message IDs") + res = view_dlg(fname, incl_out=out, only_errors=errors, trunc_out=not full_out) + print(res) + + +@call_parse +def add_cli( + fname:str, # Dialog or notebook path + source:str=None, # Message source; defaults to stdin + msg_type:str='code', # Message type: code, note, prompt, or raw + before:str=None, # Insert before this message ID + after:str=None, # Insert after this message ID + export:bool=False, # Mark the new message for nbdev export? + dry_run:bool=False, # Preview without saving? +): + "Add a message, reading multiline source from stdin by default." + _placement(before, after) + if source is None: source = sys.stdin.read() + d = _dialog(fname) + m = d.mk_message(source, before=before, after=after, msg_type=msg_type, export=export) + if not dry_run: d.save() + print(m.preview()) + + +@call_parse +def del_cli( + fname:str, # Dialog or notebook path + ids:str, # Comma-separated message IDs to delete + dry_run:bool=False, # Preview without saving? +): + "Delete messages by stable ID." + d = _dialog(fname) + removed = d.remove_msgs([d.msg(i) for i in _ids(ids)]) + if not dry_run: d.save() + print('\n'.join(str(m.preview()) for m in removed)) + + +@call_parse +def move_cli( + fname:str, # Dialog or notebook path + ids:str, # Comma-separated message IDs to move + before:str=None, # Move before this message ID + after:str=None, # Move after this message ID + dry_run:bool=False, # Preview without saving? +): + "Move messages while retaining their relative order." + _placement(before, after, required=True) + d = _dialog(fname) + d.move_msgs(_ids(ids), before=before, after=after) + if not dry_run: d.save() + print(d.summary()) diff --git a/nbs/index.ipynb b/nbs/index.ipynb index ef813ad..2d650ba 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -1,16 +1,5 @@ { "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "f7806d71", - "metadata": {}, - "outputs": [], - "source": [ - "#| hide\n", - "from aidialog.dlgskill import *" - ] - }, { "cell_type": "markdown", "id": "7b8db7d9", @@ -162,6 +151,17 @@ "A quick taste - create a dialog, add a message, and view it as concise XML:" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "f7806d71", + "metadata": {}, + "outputs": [], + "source": [ + "from aidialog.dlgskill import *\n", + "import tempfile" + ] + }, { "cell_type": "code", "execution_count": null, @@ -180,14 +180,32 @@ } ], "source": [ - "from aidialog.dlgskill import *\n", - "import tempfile\n", - "\n", "p = tempfile.mkdtemp() + '/demo.ipynb'\n", "d = create_dlg(p, '## A tiny dialog', 'note')\n", "add_msg('6*7', after=d.messages[0].id, dlg=p)\n", "view_dlg(p)" ] + }, + { + "cell_type": "markdown", + "id": "dfad191c", + "metadata": {}, + "source": [ + "## Command line\n", + "\n", + "The flat commands expose dialog-aware reading and structural edits without a Python kernel:\n", + "\n", + "```bash\n", + "aidialog-summary nbs/00_core.ipynb\n", + "aidialog-find nbs/00_core.ipynb 'read_csv' --context 1\n", + "aidialog-view nbs/00_core.ipynb ab12cd34 --out\n", + "aidialog-add nbs/00_core.ipynb --after ab12cd34 --msg-type code < new-cell.py\n", + "aidialog-del nbs/00_core.ipynb ab12cd34\n", + "aidialog-move nbs/00_core.ipynb ab12cd34,ef56ab78 --before 9012cdef\n", + "```\n", + "\n", + "Message ID arguments are comma-separated where a command accepts several. Mutating commands accept `--dry-run`; run any command with `--help` for its full filters and display options." + ] } ], "metadata": { diff --git a/pyproject.toml b/pyproject.toml index d17af13..2f1cb2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,14 @@ aidialog = "aidialog._modidx:d" [project.entry-points.pyskills] "aidialog.dlgskill" = "aidialog.dlgskill" +[project.scripts] +aidialog-summary = "aidialog.cli:summary_cli" +aidialog-find = "aidialog.cli:find_cli" +aidialog-view = "aidialog.cli:view_cli" +aidialog-add = "aidialog.cli:add_cli" +aidialog-del = "aidialog.cli:del_cli" +aidialog-move = "aidialog.cli:move_cli" + [tool.setuptools.dynamic] version = {attr = "aidialog.__version__"}