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)