diff --git a/README.md b/README.md index a42e147..d5a6870 100644 --- a/README.md +++ b/README.md @@ -545,6 +545,51 @@ ssage-insert() { bind -x '"\C-j": ssage-insert' ``` +### `ssage_log` — read the SQLite log + +When `log = True` in your config, each `ssage` run is stored in +`~/.shell_sage/logs/logs.db`. The **`ssage_log`** command prints that +history: machine-friendly **json** (default) or **md**, with no header +on stdout (safe to pipe to `jq`). + +- `ssage_log` — last **1** most recent turn, oldest-of-one first +- `ssage_log --last N` — **N** most recent rows by time, printed + **oldest to newest** within that set +- `ssage_log --all` — every row, chronological +- `ssage_log --prune` — rotate active `logs.db` to + `logs-YYYY-MM-DD-HHMMSS.db` and create a fresh active `logs.db` +- `ssage_log --format md` — Markdown export +- `ssage_log --format md --frontmatter` — add YAML frontmatter + (id/timestamp/model/mode) +- `ssage_log --format md --context` — include captured context in a + collapsible details block +- `ssage_log --format nb` — export a notebook (`.ipynb`) with one + markdown cell per selected row +- `ssage_log --out /tmp/out.json` — write to a file instead of stdout +- `ssage_log --log_db PATH` — read a specific database file (for example + a rotated or copied `logs-*.db`) +- `ssage_log --ls` — list `*.db` in the log directory (name, active + marker, size, mtime) +- `ssage_log --info` — human index: order, timestamp, and a one-line + user query per row (do not combine with `--all` or `--last`) + +Shareable markdown recap example: + +``` bash +ssage_log --format md --frontmatter --last 5 --out notes.md +``` + +Notebook export example: + +``` bash +ssage_log --format nb --last 10 --out session-log.ipynb +``` + +If `log` is `False` and the database is missing or empty, you get a +message on **stderr** explaining how to enable logging. You can still +run `ssage_log` read-only on an existing file even if logging is off in +the config. + ### Enabling Sassy Mode For a more entertaining experience, try sassy mode (GLaDOS-inspired): diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 7d2a1bc..321160d 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -388,9 +388,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "str" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -424,9 +422,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "int" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -498,9 +494,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "AttrDict" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -687,9 +681,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "ModelResponse" - }, + "metadata": {}, "output_type": "execute_result" } ], @@ -778,9 +770,7 @@ ] }, "execution_count": null, - "metadata": { - "__type": "list" - }, + "metadata": {}, "output_type": "execute_result" } ], diff --git a/nbs/02_logview.ipynb b/nbs/02_logview.ipynb new file mode 100644 index 0000000..c06febd --- /dev/null +++ b/nbs/02_logview.ipynb @@ -0,0 +1,716 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "b377949e", + "metadata": {}, + "outputs": [], + "source": [ + "#| default_exp logview" + ] + }, + { + "cell_type": "markdown", + "id": "f84fe110", + "metadata": {}, + "source": [ + "# View logs" + ] + }, + { + "cell_type": "markdown", + "id": "a5b9c0ea", + "metadata": {}, + "source": [ + "## Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7c5634a", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "import sys, re, json, builtins\n", + "from pathlib import Path\n", + "from datetime import datetime\n", + "from fastcore.script import call_parse\n", + "from fastcore.xtras import asdict\n", + "from fastlite import database\n", + "from shell_sage.config import ShellSageConfig, get_cfg\n", + "from shell_sage.core import log_path, Log, mk_db" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d9615ce", + "metadata": {}, + "outputs": [], + "source": [ + "#| hide\n", + "from tempfile import TemporaryDirectory\n", + "from IPython.display import Markdown\n", + "from fastcore.test import *" + ] + }, + { + "cell_type": "markdown", + "id": "2595a406", + "metadata": {}, + "source": [ + "## Helpers" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0fd4257", + "metadata": {}, + "outputs": [], + "source": [ + "rr = [\n", + " {\n", + " 'id': 1,\n", + " 'timestamp': '2026-04-27T10:00:00',\n", + " 'query': 'abc\\n\\nHow do I list files?\\n',\n", + " 'response': 'Use `ls -la`.',\n", + " 'model': 'claude',\n", + " 'mode': 'default',\n", + " },\n", + " {\n", + " 'id': 2,\n", + " 'timestamp': '2026-04-27T10:01:00',\n", + " 'query': 'Plain legacy query without XML tags',\n", + " 'response': 'Legacy response',\n", + " 'model': 'claude',\n", + " 'mode': 'default',\n", + " },\n", + " {\n", + " \"id\":3,\n", + " \"timestamp\":\"2026-04-26T12:06:45.744887\",\n", + " \"query\":\"\\nsuperduper 3\\n/bin/zsh\\n\\naliases\\n\\n\\nline 1\\nline 2\\\"\\n⠋ Connecting...\\n\\n\\n\\n\\n\\n???\\n\",\n", + " \"response\":\"Yes! I am.\",\n", + " \"model\":\"claude-sonnet-4-5-20250929\",\n", + " \"mode\":\"default\"\n", + " }\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "972d9a11", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_log_field_names = tuple(Log.__annotations__.keys())\n", + "_front_matter = ('id', 'timestamp', 'model', 'mode')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d686166d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "('id', 'timestamp', 'query', 'response', 'model', 'mode')" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "_log_field_names" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7f5b0286", + "metadata": {}, + "outputs": [], + "source": [ + "db = mk_db()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ed04293", + "metadata": {}, + "outputs": [], + "source": [ + "# db.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a138508f", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _q_1line(query, lim=100):\n", + " m = re.search(r'\\s*(.*?)\\s*', query or '', re.DOTALL | re.IGNORECASE)\n", + " s = re.sub(r'\\s+', ' ', (m.group(1) if m else (query or '')).strip(), flags=re.S)\n", + " return s[: lim] + '...' if len(s) > lim else s" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d897ffbb", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(_q_1line(rr[1]['query']), 'Plain legacy query without XML tags')\n", + "test_eq(_q_1line('\\n' + ('x'*120) + '\\n', lim=10), 'xxxxxxxxxx...')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0bb2dd4b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'id': 1,\n", + " 'timestamp': '2026-04-27T18:41:34.167418',\n", + " 'query': 'Hi!',\n", + " 'response': 'I am ShellSage, a command-line teaching assistant!',\n", + " 'model': 'llama3.2',\n", + " 'mode': 'default'}" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "lg = Log(id=1, timestamp=datetime.now().isoformat(), query='Hi!', model='llama3.2', \n", + " response='I am ShellSage, a command-line teaching assistant!', mode='default')\n", + "asdict(lg)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "987380b4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"id\": 1, \"timestamp\": \"2026-04-27T18:41:34.167418\", \"query\": \"Hi!\", \"response\": \"I am ShellSage, a command-line teaching assistant!\", \"model\": \"llama3.2\", \"mode\": \"default\"}'" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "json.dumps(asdict(lg), default=str)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "07ecab02", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def log2json(r, **kw): return json.dumps({k: r.get(k) for k in _log_field_names}, default=str)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3d0e300", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"id\": 1, \"timestamp\": \"2026-04-27T10:00:00\", \"query\": \"abc\\\\n\\\\nHow do I list files?\\\\n\", \"response\": \"Use `ls -la`.\", \"model\": \"claude\", \"mode\": \"default\"}'" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "j = log2json(rr[0])\n", + "test_eq('\\n' in j, False)\n", + "test_eq('\\\\n' in j, True)\n", + "j" + ] + }, + { + "cell_type": "markdown", + "id": "4765aa4e", + "metadata": {}, + "source": [ + "
context\\n\\n{ctx}\\n\\n
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ecf86a3", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def log2fm(r, **kw):\n", + " ks = [k for k in _front_matter if k in r]\n", + " if not ks: return ''\n", + " return f'''---\\n{'\\n'.join(f\"{k}: {r.get(k)}\" for k in ks)}\\n---\\n'''" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "974d830c", + "metadata": {}, + "outputs": [], + "source": [ + "fm = log2fm(rr[0])\n", + "test_is('id: 1' in fm, True)\n", + "test_is('timestamp: 2026-04-27T10:00:00' in fm, True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39568ba6", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _parse_query(qry):\n", + " mtch = re.search(r'\\A(.*)\\n\\s*(.*?)\\s*\\Z', qry, re.DOTALL | re.IGNORECASE)\n", + " ctx, qry = mtch.groups() if mtch else ('','')\n", + " return ctx, qry\n", + "\n", + "def _md_parts(r, frontmatter):\n", + " fm = log2fm(r)+'\\n\\n' if frontmatter else ''\n", + " ctx, qry = _parse_query(r.get('query', ''))\n", + " res = r.get('response')\n", + " return fm, ctx, qry, res\n", + "\n", + "def log2md(r, frontmatter=False, context=False, **kw):\n", + " fm, ctx, qry, res = _md_parts(r, frontmatter)\n", + " if context and ctx:\n", + " ctx = f'''```xml\\n{ctx}\\n```\\n\\n'''\n", + " ctx = f'''
context\\n\\n{ctx}\\n\\n
\\n\\n'''\n", + " else: ctx = ''\n", + " turn = f'''## AI Prompt\\n{qry}\\n## AI Response\\n{res}'''\n", + " return f\"{fm}{ctx}{turn}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c665d015", + "metadata": {}, + "outputs": [], + "source": [ + "md_plain = log2md(rr[1], frontmatter=False, context=False)\n", + "test_is('## AI Response' in md_plain, True)\n", + "test_is(\"
\" not in md_plain, True)\n", + "\n", + "md_full = log2md(rr[0], frontmatter=True, context=True)\n", + "test_is(md_full.startswith('---'), True)\n", + "test_is(\"
\" in md_full, True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8958080e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---\n", + "id: 3\n", + "timestamp: 2026-04-26T12:06:45.744887\n", + "model: claude-sonnet-4-5-20250929\n", + "mode: default\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "print(log2fm(rr[2]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "565dc141", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "---\n", + "id: 3\n", + "timestamp: 2026-04-26T12:06:45.744887\n", + "model: claude-sonnet-4-5-20250929\n", + "mode: default\n", + "---\n", + "\n", + "\n", + "
context\n", + "\n", + "```xml\n", + "\n", + "superduper 3\n", + "/bin/zsh\n", + "\n", + "aliases\n", + "\n", + "\n", + "line 1\n", + "line 2\"\n", + "⠋ Connecting...\n", + "\n", + "\n", + "\n", + "\n", + "```\n", + "\n", + "\n", + "\n", + "
\n", + "\n", + "## AI Prompt\n", + "???\n", + "## AI Response\n", + "Yes! I am." + ], + "text/plain": [ + "" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "Markdown(log2md(rr[2], frontmatter=True, context=True))" + ] + }, + { + "cell_type": "markdown", + "id": "84e14e98", + "metadata": {}, + "source": [ + "## Log View Command" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a6a06f1", + "metadata": {}, + "outputs": [], + "source": [ + "class FakeDB:\n", + " def __init__(self, rows): self._rows = rows\n", + " def q(self, sql, params=None):\n", + " s = sql.lower()\n", + " if 'count(*) as c' in s: return [{'c': len(self._rows)}]\n", + " if 'order by timestamp asc' in s: return list(self._rows)\n", + " if 'order by timestamp desc' in s:\n", + " n = params[0]\n", + " return list(reversed(self._rows))[:n]\n", + " return list(self._rows)\n", + "\n", + "fdb = FakeDB(rr)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad1deabc", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_pr = builtins.print\n", + "def _die(msg, code=2): _pr(msg, file=sys.stderr); raise SystemExit(code)\n", + "\n", + "def _active_db_path(): return log_path / 'logs.db'\n", + "\n", + "def _ls():\n", + " log_path.mkdir(parents=True, exist_ok=True)\n", + " for p in sorted(log_path.glob('*.db'), key=lambda x: x.name):\n", + " st = p.stat()\n", + " dt = datetime.fromtimestamp(st.st_mtime).isoformat()\n", + " act = 'active' if p.name == 'logs.db' else '-'\n", + " _pr(f\"{p.name}\t{act}\t{st.st_size}\t{dt}\")\n", + "\n", + "def _db(log_db):\n", + " pth = (Path(log_db) if log_db else _active_db_path()).expanduser()\n", + " dcfg = asdict(ShellSageConfig())\n", + " log_en = bool(get_cfg().get('log', dcfg['log']))\n", + " if not pth.exists():\n", + " _pr('ssage_log: no log database at', pth, file=sys.stderr)\n", + " if not log_en:\n", + " _pr(\n", + " ' Set `log = True` in ~/.config/shell_sage/shell_sage.conf and run `ssage` to create logs.',\n", + " file=sys.stderr,\n", + " )\n", + " else: _pr(' Run `ssage` to create a log if this path is new.', file=sys.stderr)\n", + " raise SystemExit(1)\n", + " db = database(pth)\n", + " db.logs = db.create(Log)\n", + " c = (db.q('select count(*) as c from log')[0] or {'c': 0})['c']\n", + " if c == 0:\n", + " _pr(\n", + " 'ssage_log: log database is empty; run `ssage` to record entries (with `log = True` in your config).',\n", + " file=sys.stderr,\n", + " )\n", + " if not log_en:\n", + " _pr(' `log` is false in your config; enable it to save new sessions to the database.', file=sys.stderr)\n", + " return\n", + " return db\n", + "\n", + "def _info(out, db):\n", + " w = (open(out, 'w', encoding='utf-8') if out else None)\n", + " f = w or sys.stdout\n", + " try:\n", + " rows = db.q('select * from log order by timestamp asc, id asc') or []\n", + " for i, r in enumerate(rows, 1):\n", + " _pr(f\"{i}\t{r.get('timestamp', '')}\t{_q_1line(r.get('query', ''))}\", file=f)\n", + " finally:\n", + " if w: w.close()\n", + "\n", + "def _rows(all, last, db):\n", + " if all: return list(db.q('select * from log order by timestamp asc, id asc') or [])\n", + " if last < 1: _die('ssage_log: --last must be at least 1', 2)\n", + " tail = db.q('select * from log order by timestamp desc, id desc limit ?', (last,)) or []\n", + " tail.reverse()\n", + " return tail" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ef9af17", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq([r['id'] for r in _rows(all=False, last=2, db=fdb)], [2,3])\n", + "test_eq(len(_rows(all=False, last=1, db=fdb)), 1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7c7299ed", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _to_nb(rows, **kw):\n", + " return {\n", + " 'cells': [{'cell_type': 'markdown', 'metadata': {}, 'source': log2md(r, **kw)} for r in rows],\n", + " 'metadata': {}, 'nbformat': 4, 'nbformat_minor': 5,\n", + " }\n", + "\n", + "def _show(all, last, format, out, db, frontmatter=False, context=False):\n", + " rows = _rows(all, last, db)\n", + " w = open(out, 'w', encoding='utf-8') if out else None\n", + " f = w or sys.stdout\n", + " fmt = log2json if format == 'json' else log2md\n", + " try:\n", + " if format in ('json', 'md'):\n", + " for r in rows: f.write(fmt(r, frontmatter=frontmatter, context=context) + '\\n')\n", + " else:\n", + " json.dump(_to_nb(rows, frontmatter=frontmatter, context=context), f, ensure_ascii=False)\n", + " f.write('\\n')\n", + " finally:\n", + " if w: w.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0b6ab8c2", + "metadata": {}, + "outputs": [], + "source": [ + "nb = _to_nb(rr)\n", + "test_eq(len(nb['cells']), 3)\n", + "test_eq(nb['cells'][0]['cell_type'], 'markdown')\n", + "test_is('## AI Prompt' in nb['cells'][0]['source'], True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea1dd2d3", + "metadata": {}, + "outputs": [], + "source": [ + "with TemporaryDirectory() as td:\n", + " td = Path(td)\n", + "\n", + " out_json = td/'out.json'\n", + " _show(all=True, last=1, format='json', out=str(out_json), db=fdb)\n", + " test_eq(len(out_json.read_text().splitlines()), 3)\n", + "\n", + " out_md = td/'out.md'\n", + " _show(all=False, last=1, format='md', out=str(out_md), db=fdb, frontmatter=True, context=True)\n", + " test_is('## AI Prompt' in out_md.read_text(), True)\n", + "\n", + " out_nb = td/'out.ipynb'\n", + " _show(all=True, last=1, format='nb', out=str(out_nb), db=fdb)\n", + " nbj = json.loads(out_nb.read_text())\n", + " test_eq(len(nbj['cells']), 3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8e87d10", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _prune_db():\n", + " log_path.mkdir(parents=True, exist_ok=True)\n", + " src = _active_db_path()\n", + " if not src.exists(): _die(f'ssage_log: active database not found at {src}', 1)\n", + " ts = datetime.now().strftime('%Y-%m-%d-%H%M%S')\n", + " dst = log_path / f'logs-{ts}.db'\n", + " i = 1\n", + " while dst.exists():\n", + " dst = log_path / f'logs-{ts}-{i}.db'\n", + " i += 1\n", + " src.rename(dst)\n", + " mk_db()\n", + " _pr(f'pruned {src.name} -> {dst.name}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61c63528", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pruned logs.db -> logs-2026-04-27-184134.db\n" + ] + } + ], + "source": [ + "_old_log_path, _old_mk_db = log_path, mk_db\n", + "try:\n", + " with TemporaryDirectory() as td:\n", + " td = Path(td)\n", + " log_path = td\n", + " (td/'logs.db').write_bytes(b'synthetic')\n", + "\n", + " def _mk_db_stub(): (td/'logs.db').write_bytes(b'')\n", + "\n", + " mk_db = _mk_db_stub\n", + " _prune_db()\n", + "\n", + " test_is((td/'logs.db').exists(), True)\n", + " test_eq(len([p for p in td.glob('logs-*.db')]), 1)\n", + "finally:\n", + " log_path, mk_db = _old_log_path, _old_mk_db" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3a5d4f7", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@call_parse\n", + "def ssage_log(\n", + " log_db: str|None = None, # Path to sqlite log database; default: active file at ~/.shell_sage/logs/logs.db\n", + " ls: bool = False, # List .db files in the log directory and exit\n", + " info: bool = False, # Print order, timestamp, and one-line user query for every row; omits other modes\n", + " prune: bool = False, # Rotate active logs.db to logs-YYYY-MM-DD-HHMMSS.db and recreate empty logs.db\n", + " all: bool = False, # Include all rows in time order, oldest first (overrides --last)\n", + " last: int = 1, # How many of the *most recent* rows to print, oldest of that set first, unless you pass --all\n", + " format: str = 'json', # output: json, md, or nb (for default export only; not --info)\n", + " frontmatter: bool = False, # For --format md, include frontmatter with id/timestamp/model/mode\n", + " context: bool = False, # For --format md, include captured context in a collapsible details block\n", + " out: str|None = None, # Write to this file instead of stdout\n", + "):\n", + " \"Print logged ShellSage Q&A from the sqlite log database.\"\n", + " if format not in ('json', 'md', 'nb'): _die(f'ssage_log: --format must be json, md, or nb, not {format!r}', 2)\n", + " if info and (all or any(a == '--last' for a in sys.argv[1:])):\n", + " _die('ssage_log: do not use --info together with --all or --last', 2)\n", + " if ls: return _ls()\n", + " if prune: return _prune_db()\n", + " if not (db := _db(log_db)): return\n", + " if info: return _info(out, db)\n", + " _show(all, last, format, out, db, frontmatter=frontmatter, context=context)" + ] + }, + { + "cell_type": "markdown", + "id": "7bf112da", + "metadata": {}, + "source": [ + "## export -" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8277fb78", + "metadata": {}, + "outputs": [], + "source": [ + "#| hide\n", + "#| eval: false\n", + "from nbdev.doclinks import nbdev_export\n", + "nbdev_export()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "python3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nbs/index.ipynb b/nbs/index.ipynb index 00da9c3..caa4b98 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -816,6 +816,55 @@ "```" ] }, + { + "cell_type": "markdown", + "id": "628cc15c", + "metadata": {}, + "source": [ + "### `ssage_log` — read the SQLite log\n", + "\n", + "When `log = True` in your config, each `ssage` run is stored in\n", + "`~/.shell_sage/logs/logs.db`. The **`ssage_log`** command prints that\n", + "history: machine-friendly **json** (default) or **md**, with no header\n", + "on stdout (safe to pipe to `jq`).\n", + "\n", + "- `ssage_log` — last **1** most recent turn, oldest-of-one first\n", + "- `ssage_log --last N` — **N** most recent rows by time, printed\n", + " **oldest to newest** within that set\n", + "- `ssage_log --all` — every row, chronological\n", + "- `ssage_log --prune` — rotate active `logs.db` to `logs-YYYY-MM-DD-HHMMSS.db` and create a fresh active `logs.db`\n", + "- `ssage_log --format md` — Markdown export\n", + "- `ssage_log --format md --md_frontmatter` — add YAML frontmatter\n", + " (id/timestamp/model/mode)\n", + "- `ssage_log --format md --md_context` — include captured context in a\n", + " collapsible details block\n", + "- `ssage_log --format nb` — export a notebook (`.ipynb`) with one markdown cell per selected row\n", + "- `ssage_log --out /tmp/out.json` — write to a file instead of stdout\n", + "- `ssage_log --log_db PATH` — read a specific database file (for example\n", + " a rotated or copied `logs-*.db`)\n", + "- `ssage_log --ls` — list `*.db` in the log directory (name, active\n", + " marker, size, mtime)\n", + "- `ssage_log --info` — human index: order, timestamp, and a one-line\n", + " user query per row (do not combine with `--all` or `--last`)\n", + "\n", + "Shareable markdown recap example:\n", + "\n", + "```bash\n", + "ssage_log --format md --md_frontmatter --last 5 --out notes.md\n", + "```\n", + "\n", + "Notebook export example:\n", + "\n", + "```bash\n", + "ssage_log --format nb --last 10 --out session-log.ipynb\n", + "```\n", + "\n", + "If `log` is `False` and the database is missing or empty, you get a\n", + "message on **stderr** explaining how to enable logging. You can still\n", + "run `ssage_log` read-only on an existing file even if logging is off in\n", + "the config." + ] + }, { "cell_type": "markdown", "id": "cca2c42c", diff --git a/pyproject.toml b/pyproject.toml index 30d0fe1..53fb86e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ shell_sage = "shell_sage._modidx:d" [project.scripts] ssage = "shell_sage.core:main" ssage_extract = "shell_sage.core:extract" +ssage_log = "shell_sage.logview:ssage_log" [tool.setuptools.dynamic] version = {attr = "shell_sage.__version__"} diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index 6c2cf54..0dc43db 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -26,4 +26,20 @@ 'shell_sage.core.main': ('core.html#main', 'shell_sage/core.py'), 'shell_sage.core.mk_db': ('core.html#mk_db', 'shell_sage/core.py'), 'shell_sage.core.tmux_history_lim': ('core.html#tmux_history_lim', 'shell_sage/core.py'), - 'shell_sage.core.with_permission': ('core.html#with_permission', 'shell_sage/core.py')}}} + 'shell_sage.core.with_permission': ('core.html#with_permission', 'shell_sage/core.py')}, + 'shell_sage.logview': { 'shell_sage.logview._active_db_path': ('logview.html#_active_db_path', 'shell_sage/logview.py'), + 'shell_sage.logview._db': ('logview.html#_db', 'shell_sage/logview.py'), + 'shell_sage.logview._die': ('logview.html#_die', 'shell_sage/logview.py'), + 'shell_sage.logview._info': ('logview.html#_info', 'shell_sage/logview.py'), + 'shell_sage.logview._ls': ('logview.html#_ls', 'shell_sage/logview.py'), + 'shell_sage.logview._md_parts': ('logview.html#_md_parts', 'shell_sage/logview.py'), + 'shell_sage.logview._parse_query': ('logview.html#_parse_query', 'shell_sage/logview.py'), + 'shell_sage.logview._prune_db': ('logview.html#_prune_db', 'shell_sage/logview.py'), + 'shell_sage.logview._q_1line': ('logview.html#_q_1line', 'shell_sage/logview.py'), + 'shell_sage.logview._rows': ('logview.html#_rows', 'shell_sage/logview.py'), + 'shell_sage.logview._show': ('logview.html#_show', 'shell_sage/logview.py'), + 'shell_sage.logview._to_nb': ('logview.html#_to_nb', 'shell_sage/logview.py'), + 'shell_sage.logview.log2fm': ('logview.html#log2fm', 'shell_sage/logview.py'), + 'shell_sage.logview.log2json': ('logview.html#log2json', 'shell_sage/logview.py'), + 'shell_sage.logview.log2md': ('logview.html#log2md', 'shell_sage/logview.py'), + 'shell_sage.logview.ssage_log': ('logview.html#ssage_log', 'shell_sage/logview.py')}}} diff --git a/shell_sage/logview.py b/shell_sage/logview.py new file mode 100644 index 0000000..d33b75c --- /dev/null +++ b/shell_sage/logview.py @@ -0,0 +1,171 @@ +# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_logview.ipynb. + +# %% auto #0 +__all__ = ['log2json', 'log2fm', 'log2md', 'ssage_log'] + +# %% ../nbs/02_logview.ipynb #d7c5634a +import sys, re, json, builtins +from pathlib import Path +from datetime import datetime +from fastcore.script import call_parse +from fastcore.xtras import asdict +from fastlite import database +from .config import ShellSageConfig, get_cfg +from .core import log_path, Log, mk_db + +# %% ../nbs/02_logview.ipynb #972d9a11 +_log_field_names = tuple(Log.__annotations__.keys()) +_front_matter = ('id', 'timestamp', 'model', 'mode') + +# %% ../nbs/02_logview.ipynb #a138508f +def _q_1line(query, lim=100): + m = re.search(r'\s*(.*?)\s*', query or '', re.DOTALL | re.IGNORECASE) + s = re.sub(r'\s+', ' ', (m.group(1) if m else (query or '')).strip(), flags=re.S) + return s[: lim] + '...' if len(s) > lim else s + +# %% ../nbs/02_logview.ipynb #07ecab02 +def log2json(r, **kw): return json.dumps({k: r.get(k) for k in _log_field_names}, default=str) + +# %% ../nbs/02_logview.ipynb #9ecf86a3 +def log2fm(r, **kw): + ks = [k for k in _front_matter if k in r] + if not ks: return '' + return f'''---\n{'\n'.join(f"{k}: {r.get(k)}" for k in ks)}\n---\n''' + +# %% ../nbs/02_logview.ipynb #39568ba6 +def _parse_query(qry): + mtch = re.search(r'\A(.*)\n\s*(.*?)\s*\Z', qry, re.DOTALL | re.IGNORECASE) + ctx, qry = mtch.groups() if mtch else ('','') + return ctx, qry + +def _md_parts(r, frontmatter): + fm = log2fm(r)+'\n\n' if frontmatter else '' + ctx, qry = _parse_query(r.get('query', '')) + res = r.get('response') + return fm, ctx, qry, res + +def log2md(r, frontmatter=False, context=False, **kw): + fm, ctx, qry, res = _md_parts(r, frontmatter) + if context and ctx: + ctx = f'''```xml\n{ctx}\n```\n\n''' + ctx = f'''
context\n\n{ctx}\n\n
\n\n''' + else: ctx = '' + turn = f'''## AI Prompt\n{qry}\n## AI Response\n{res}''' + return f"{fm}{ctx}{turn}" + +# %% ../nbs/02_logview.ipynb #ad1deabc +_pr = builtins.print +def _die(msg, code=2): _pr(msg, file=sys.stderr); raise SystemExit(code) + +def _active_db_path(): return log_path / 'logs.db' + +def _ls(): + log_path.mkdir(parents=True, exist_ok=True) + for p in sorted(log_path.glob('*.db'), key=lambda x: x.name): + st = p.stat() + dt = datetime.fromtimestamp(st.st_mtime).isoformat() + act = 'active' if p.name == 'logs.db' else '-' + _pr(f"{p.name} {act} {st.st_size} {dt}") + +def _db(log_db): + pth = (Path(log_db) if log_db else _active_db_path()).expanduser() + dcfg = asdict(ShellSageConfig()) + log_en = bool(get_cfg().get('log', dcfg['log'])) + if not pth.exists(): + _pr('ssage_log: no log database at', pth, file=sys.stderr) + if not log_en: + _pr( + ' Set `log = True` in ~/.config/shell_sage/shell_sage.conf and run `ssage` to create logs.', + file=sys.stderr, + ) + else: _pr(' Run `ssage` to create a log if this path is new.', file=sys.stderr) + raise SystemExit(1) + db = database(pth) + db.logs = db.create(Log) + c = (db.q('select count(*) as c from log')[0] or {'c': 0})['c'] + if c == 0: + _pr( + 'ssage_log: log database is empty; run `ssage` to record entries (with `log = True` in your config).', + file=sys.stderr, + ) + if not log_en: + _pr(' `log` is false in your config; enable it to save new sessions to the database.', file=sys.stderr) + return + return db + +def _info(out, db): + w = (open(out, 'w', encoding='utf-8') if out else None) + f = w or sys.stdout + try: + rows = db.q('select * from log order by timestamp asc, id asc') or [] + for i, r in enumerate(rows, 1): + _pr(f"{i} {r.get('timestamp', '')} {_q_1line(r.get('query', ''))}", file=f) + finally: + if w: w.close() + +def _rows(all, last, db): + if all: return list(db.q('select * from log order by timestamp asc, id asc') or []) + if last < 1: _die('ssage_log: --last must be at least 1', 2) + tail = db.q('select * from log order by timestamp desc, id desc limit ?', (last,)) or [] + tail.reverse() + return tail + +# %% ../nbs/02_logview.ipynb #7c7299ed +def _to_nb(rows, **kw): + return { + 'cells': [{'cell_type': 'markdown', 'metadata': {}, 'source': log2md(r, **kw)} for r in rows], + 'metadata': {}, 'nbformat': 4, 'nbformat_minor': 5, + } + +def _show(all, last, format, out, db, frontmatter=False, context=False): + rows = _rows(all, last, db) + w = open(out, 'w', encoding='utf-8') if out else None + f = w or sys.stdout + fmt = log2json if format == 'json' else log2md + try: + if format in ('json', 'md'): + for r in rows: f.write(fmt(r, frontmatter=frontmatter, context=context) + '\n') + else: + json.dump(_to_nb(rows, frontmatter=frontmatter, context=context), f, ensure_ascii=False) + f.write('\n') + finally: + if w: w.close() + +# %% ../nbs/02_logview.ipynb #b8e87d10 +def _prune_db(): + log_path.mkdir(parents=True, exist_ok=True) + src = _active_db_path() + if not src.exists(): _die(f'ssage_log: active database not found at {src}', 1) + ts = datetime.now().strftime('%Y-%m-%d-%H%M%S') + dst = log_path / f'logs-{ts}.db' + i = 1 + while dst.exists(): + dst = log_path / f'logs-{ts}-{i}.db' + i += 1 + src.rename(dst) + mk_db() + _pr(f'pruned {src.name} -> {dst.name}') + +# %% ../nbs/02_logview.ipynb #d3a5d4f7 +@call_parse +def ssage_log( + log_db: str|None = None, # Path to sqlite log database; default: active file at ~/.shell_sage/logs/logs.db + ls: bool = False, # List .db files in the log directory and exit + info: bool = False, # Print order, timestamp, and one-line user query for every row; omits other modes + prune: bool = False, # Rotate active logs.db to logs-YYYY-MM-DD-HHMMSS.db and recreate empty logs.db + all: bool = False, # Include all rows in time order, oldest first (overrides --last) + last: int = 1, # How many of the *most recent* rows to print, oldest of that set first, unless you pass --all + format: str = 'json', # output: json, md, or nb (for default export only; not --info) + frontmatter: bool = False, # For --format md, include frontmatter with id/timestamp/model/mode + context: bool = False, # For --format md, include captured context in a collapsible details block + out: str|None = None, # Write to this file instead of stdout +): + "Print logged ShellSage Q&A from the sqlite log database." + if format not in ('json', 'md', 'nb'): _die(f'ssage_log: --format must be json, md, or nb, not {format!r}', 2) + if info and (all or any(a == '--last' for a in sys.argv[1:])): + _die('ssage_log: do not use --info together with --all or --last', 2) + if ls: return _ls() + if prune: return _prune_db() + if not (db := _db(log_db)): return + if info: return _info(out, db) + _show(all, last, format, out, db, frontmatter=frontmatter, context=context)