sqlite-graph-memory 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ # -*- coding: utf-8 -*-
2
+ """mcp_server.py — Model Context Protocol (MCP) server for sqlite-graph-memory.
3
+
4
+ Exposes associative memory recall as a standard MCP tool over stdio transport.
5
+ Compatible with any MCP client (Cursor, Claude Desktop, Antigravity, Zed, Windsurf).
6
+
7
+ Protocol: JSON-RPC 2.0 over stdio (newline-delimited JSON).
8
+ Zero third-party dependencies required (pure standard library).
9
+
10
+ USAGE:
11
+ # Normal stdio mode (started by MCP client)
12
+ python examples/mcp_server.py
13
+
14
+ # Direct test mode (runs a recall query without an MCP client)
15
+ sgm-mcp --test "how do I think about agent memory"
16
+
17
+ Config (env, optional):
18
+ BRAIN_INDEX_DIR dir holding embedding index (default: ./index)
19
+ TURNSTATE_DB SQLite db for telemetry (default: ./turnstate.db)
20
+ BRAIN_ANSWER_OUT file to mirror answer into (default: ./_brain_answer.txt)
21
+ """
22
+ import sys, os, json, subprocess, tempfile
23
+ from pathlib import Path
24
+
25
+ # Ensure UTF-8 for stdio communication on all platforms (including Windows console)
26
+ try:
27
+ sys.stdin.reconfigure(encoding='utf-8')
28
+ sys.stdout.reconfigure(encoding='utf-8')
29
+ sys.stderr.reconfigure(encoding='utf-8')
30
+ except Exception:
31
+ pass
32
+
33
+ # Where the recall subprocess runs. The server deliberately shells out instead
34
+ # of importing brain_ask: a model load that segfaults or runs out of memory then
35
+ # kills a child process and returns an MCP error, rather than taking the server
36
+ # down mid-session.
37
+ ROOT_DIR = Path(__file__).resolve().parent
38
+
39
+
40
+ def _brain_ask_cmd():
41
+ """How to invoke brain_ask — a guaranteed sibling module inside this package."""
42
+ return [sys.executable, "-m", "sqlite_graph_memory.brain_ask"]
43
+
44
+ SERVER_INFO = {
45
+ "name": "sqlite-graph-memory",
46
+ "version": "0.3.0"
47
+ }
48
+
49
+ TOOLS = [
50
+ {
51
+ "name": "memory_recall",
52
+ "description": (
53
+ "Associative memory recall over linked markdown notes. "
54
+ "Retrieves relevant context using vector search, 1-hop [[wikilink]] graph expansion, "
55
+ "and cross-encoder reranking. Returns a formatted context bundle."
56
+ ),
57
+ "inputSchema": {
58
+ "type": "object",
59
+ "properties": {
60
+ "query": {
61
+ "type": "string",
62
+ "description": "The search query or topic to retrieve memory for."
63
+ },
64
+ "mode": {
65
+ "type": "string",
66
+ "enum": ["associative", "direct", "ab"],
67
+ "default": "associative",
68
+ "description": (
69
+ "Retrieval mode: 'associative' (vector + graph expansion, recommended), "
70
+ "'direct' (vector search only), or 'ab' (run both and log comparison telemetry)."
71
+ )
72
+ }
73
+ },
74
+ "required": ["query"]
75
+ }
76
+ }
77
+ ]
78
+
79
+
80
+ def run_recall(query: str, mode: str = "associative") -> tuple[str, bool]:
81
+ """Invoke brain_ask.py in a crash-safe subprocess and return (text, is_error).
82
+
83
+ An empty answer file means no notes matched; stdout is never promoted to an answer
84
+ because it carries startup notices, load reports, and tokenizer warnings.
85
+ """
86
+ base_cmd = _brain_ask_cmd()
87
+ if base_cmd is None:
88
+ return (
89
+ "Error: brain_ask not found. Install it with "
90
+ "`pip install sqlite-graph-memory[embeddings]`, or run this example "
91
+ "from a checkout of the repository.",
92
+ True,
93
+ )
94
+
95
+ if mode not in ("associative", "direct", "ab"):
96
+ mode = "associative"
97
+
98
+ cmd = list(base_cmd)
99
+ if mode == "ab":
100
+ cmd.append("--ab")
101
+ elif mode == "direct":
102
+ cmd.append("--ask")
103
+ else: # associative (default)
104
+ cmd.extend(["--graph", "--ask"])
105
+ cmd.append(query)
106
+
107
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tf:
108
+ temp_ans_file = Path(tf.name)
109
+
110
+ try:
111
+ call_env = {**os.environ, "BRAIN_ANSWER_OUT": str(temp_ans_file)}
112
+ res = subprocess.run(
113
+ cmd,
114
+ cwd=str(ROOT_DIR),
115
+ env=call_env,
116
+ capture_output=True,
117
+ text=True,
118
+ encoding="utf-8",
119
+ errors="replace",
120
+ timeout=60
121
+ )
122
+ if res.returncode != 0:
123
+ err = (res.stderr or res.stdout or "").strip()
124
+ return f"Recall error (exit code {res.returncode}):\n{err}", True
125
+
126
+ # Read the scoped per-call answer file
127
+ try:
128
+ content = temp_ans_file.read_text(encoding="utf-8", errors="replace").strip()
129
+ if content:
130
+ return content, False
131
+ except Exception as e:
132
+ return f"Recall error: failed to read answer file: {e}", True
133
+
134
+ # Empty answer file: return explicit message and flag error so agent does not ingest stdout noise
135
+ return "(no matching notes found)", True
136
+ except subprocess.TimeoutExpired:
137
+ return "Recall error: timed out after 60 seconds.", True
138
+ except Exception as e:
139
+ return f"Recall exception: {e}", True
140
+ finally:
141
+ if temp_ans_file.exists():
142
+ try:
143
+ temp_ans_file.unlink()
144
+ except Exception:
145
+ pass
146
+
147
+
148
+ def handle_request(req: dict) -> dict:
149
+ """Handle a single JSON-RPC 2.0 MCP request."""
150
+ method = req.get("method")
151
+ req_id = req.get("id")
152
+
153
+ if method == "initialize":
154
+ return {
155
+ "jsonrpc": "2.0",
156
+ "id": req_id,
157
+ "result": {
158
+ "protocolVersion": "2024-11-05",
159
+ "capabilities": {
160
+ "tools": {}
161
+ },
162
+ "serverInfo": SERVER_INFO
163
+ }
164
+ }
165
+
166
+ elif method == "notifications/initialized":
167
+ # Notification from client, no response required
168
+ return None
169
+
170
+ elif method == "ping":
171
+ return {
172
+ "jsonrpc": "2.0",
173
+ "id": req_id,
174
+ "result": {}
175
+ }
176
+
177
+ elif method == "tools/list":
178
+ return {
179
+ "jsonrpc": "2.0",
180
+ "id": req_id,
181
+ "result": {
182
+ "tools": TOOLS
183
+ }
184
+ }
185
+
186
+ elif method == "tools/call":
187
+ params = req.get("params") or {}
188
+ tool_name = params.get("name")
189
+ args = params.get("arguments") or {}
190
+
191
+ if tool_name == "memory_recall":
192
+ query = args.get("query", "")
193
+ mode = args.get("mode", "associative")
194
+ if not query:
195
+ return {
196
+ "jsonrpc": "2.0",
197
+ "id": req_id,
198
+ "result": {
199
+ "content": [{"type": "text", "text": "Error: 'query' argument is required."}],
200
+ "isError": True
201
+ }
202
+ }
203
+ result_text, is_error = run_recall(query, mode=mode)
204
+ return {
205
+ "jsonrpc": "2.0",
206
+ "id": req_id,
207
+ "result": {
208
+ "content": [{"type": "text", "text": result_text}],
209
+ "isError": is_error
210
+ }
211
+ }
212
+ else:
213
+ return {
214
+ "jsonrpc": "2.0",
215
+ "id": req_id,
216
+ "error": {
217
+ "code": -32601,
218
+ "message": f"Tool not found: {tool_name}"
219
+ }
220
+ }
221
+
222
+ else:
223
+ if req_id is not None:
224
+ return {
225
+ "jsonrpc": "2.0",
226
+ "id": req_id,
227
+ "error": {
228
+ "code": -32601,
229
+ "message": f"Method not found: {method}"
230
+ }
231
+ }
232
+ return None
233
+
234
+
235
+ def main():
236
+ # Direct CLI testing mode: sgm-mcp --test "query"
237
+ if len(sys.argv) > 1 and sys.argv[1] in ("--test", "-t", "--query"):
238
+ flags = {"--test", "-t", "--query", "--direct", "--ab", "--associative"}
239
+ mode = "associative"
240
+ if "--direct" in sys.argv:
241
+ mode = "direct"
242
+ elif "--ab" in sys.argv:
243
+ mode = "ab"
244
+ query_parts = [a for a in sys.argv[1:] if a not in flags]
245
+ q = " ".join(query_parts) if query_parts else "agent memory"
246
+ text, _ = run_recall(q, mode=mode)
247
+ print(f"=== MCP Test Query: {q} (mode={mode}) ===")
248
+ print(text)
249
+ return
250
+
251
+
252
+ # Standard MCP stdio loop
253
+ for line in sys.stdin:
254
+ line = line.strip()
255
+ if not line:
256
+ continue
257
+ try:
258
+ req = json.loads(line)
259
+ except Exception as e:
260
+ sys.stderr.write(f"JSON decode error: {e}\n")
261
+ continue
262
+
263
+ resp = handle_request(req)
264
+ if resp is not None:
265
+ sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
266
+ sys.stdout.flush()
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
271
+
@@ -0,0 +1,45 @@
1
+ -- SQLite schema for the graph-memory pilot.
2
+ -- One file (turnstate.db by default) holds both the per-turn ledger and the
3
+ -- retrieval A/B telemetry. Both tables are auto-created by the scripts;
4
+ -- this file is documentation, not a required migration.
5
+
6
+ -- Per-turn semantic state ledger (written by turnstate_hook.py after every
7
+ -- assistant turn; 0 LLM tokens, pure transcript parsing).
8
+ CREATE TABLE IF NOT EXISTS turns(
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ session_id TEXT, -- agent session id
11
+ ts TEXT, -- wall-clock 'YYYY-MM-DD HH:MM:SS'
12
+ cwd TEXT, -- working directory of the session
13
+ project TEXT, -- basename(cwd), cheap grouping key
14
+ ask TEXT, -- last real user message in the turn (clipped)
15
+ summary TEXT, -- assistant text of the turn (clipped)
16
+ files TEXT, -- JSON array: files touched via Write/Edit tools
17
+ tools TEXT, -- JSON array: tool names used this turn
18
+ commands TEXT, -- JSON array: shell commands run (clipped)
19
+ decisions TEXT, -- JSON array: lines matching the decision regex
20
+ evidence TEXT, -- path to the raw transcript (.jsonl) = source of truth
21
+ byte_start INTEGER, -- transcript byte range this row was derived from
22
+ byte_end INTEGER
23
+ );
24
+ CREATE INDEX IF NOT EXISTS ix_turns_sid ON turns(session_id);
25
+
26
+ -- Retrieval A/B telemetry (written by brain_ask.py --ab).
27
+ -- Each row = one real query answered by BOTH pipelines:
28
+ -- A "Direct" = vector retrieve -> rerank
29
+ -- B "Associative" = vector + 1-hop wikilink graph expansion -> rerank
30
+ CREATE TABLE IF NOT EXISTS ab_recall(
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ ts TEXT,
33
+ query TEXT,
34
+ cand_added INTEGER, -- graph neighbours added to the candidate pool
35
+ new_in_top INTEGER, -- notes in B's top-N that A's top-N missed
36
+ promoted_via_graph INTEGER, -- of those, how many arrived via the graph hop
37
+ promoted_titles TEXT, -- '; '-joined titles (clipped) for eyeballing
38
+ source TEXT -- 'work' = live usage, or a batch/eval label
39
+ );
40
+
41
+ -- The graph itself is deliberately NOT materialized in SQL in this pilot:
42
+ -- edges are [[wikilinks]] parsed from the markdown notes at query time
43
+ -- (bounded: 1 hop from top-15 hits, max 40 neighbours). Zero maintenance,
44
+ -- always in sync with the notes. Materialize an edges(src, dst) table only
45
+ -- when/if hop depth or corpus size makes query-time parsing too slow.
@@ -0,0 +1,243 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Stop hook — PER-TURN SEMANTIC STATE LEDGER (Phase 1 of always-on agent memory).
4
+
5
+ After EVERY assistant turn, write a small DETERMINISTIC "what just happened" row to a
6
+ SQLite ledger (TurnState). 0 LLM tokens: everything is extracted by parsing the transcript
7
+ tail (user ask, assistant summary, files touched, tools used, commands, decision/TODO lines,
8
+ evidence pointer back to the raw transcript). The raw .jsonl stays the immutable source;
9
+ this is the cheap DERIVED working-state (distilled, not a replacement).
10
+
11
+ Written for Claude Code's Stop-hook protocol (JSON event on stdin with session_id,
12
+ transcript_path, cwd), but the pattern ports to any agent harness that exposes a
13
+ per-turn transcript.
14
+
15
+ Design principles:
16
+ - pure stdlib (sqlite3 + json + re), 0 deps, milliseconds.
17
+ - INCREMENTAL: per-session byte offset in %temp% -> reads only NEW lines each turn.
18
+ - NEVER breaks a session: ANY error -> exit 0, no output. Outputs nothing on success either
19
+ (a Stop hook that prints structured JSON could alter control flow; we only WRITE to SQLite).
20
+ - LLM extraction is deliberately NOT here (cost gate). If the deterministic delta proves
21
+ too thin, a cheap model extractor can be added in a later pass.
22
+
23
+ Config (env, optional):
24
+ TURNSTATE_DB path to the ledger db (default: ~/.claude/turnstate/turnstate.db)
25
+ """
26
+ import sys, os, json, re, sqlite3, tempfile, time
27
+
28
+
29
+ def db_path():
30
+ env = os.getenv("TURNSTATE_DB")
31
+ if env:
32
+ return env
33
+ base = os.path.join(os.path.expanduser("~"), ".claude", "turnstate")
34
+ try:
35
+ os.makedirs(base, exist_ok=True)
36
+ return os.path.join(base, "turnstate.db")
37
+ except Exception:
38
+ return os.path.join(tempfile.gettempdir(), "turnstate.db")
39
+
40
+ CLIP_ASK = 500
41
+ CLIP_SUMMARY = 1200
42
+ CLIP_CMD = 200
43
+ MAX_NEW_BYTES = 4 * 1024 * 1024 # don't read more than 4MB of new tail in one turn
44
+
45
+ # Lines that look like a decision / commitment / next step.
46
+ # The alternation is DELIBERATELY BILINGUAL (English + Russian) because the sessions this was
47
+ # built on are: a turn states its verdict in whichever language the human was using. Dropping
48
+ # the non-English half does not raise an error and does not fail any check -- the ledger simply
49
+ # stops recording decisions for half the turns, which looks exactly like a quiet week.
50
+ # Add your own language here rather than translating these away.
51
+ DECISION_RX = re.compile(
52
+ r"(реш(?:ил|ено|аем)|дел(?:аем|ать)\b|не дел(?:аем|ать)|выбра(?:л|ли|ем)|вердикт|"
53
+ r"\btodo\b|to-?do|next step|следующий шаг|\bвыбор\b|"
54
+ r"decision|decided|we (?:will|should)|рекомендаци)",
55
+ re.IGNORECASE)
56
+
57
+ FILE_TOOLS = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
58
+
59
+
60
+ def text_of(ev):
61
+ msg = ev.get("message") or {}
62
+ c = msg.get("content")
63
+ if isinstance(c, str):
64
+ return c.strip()
65
+ if isinstance(c, list):
66
+ out = []
67
+ for b in c:
68
+ if isinstance(b, dict) and b.get("type") == "text":
69
+ out.append(b.get("text") or "")
70
+ return " ".join(p.strip() for p in out if p).strip()
71
+ return ""
72
+
73
+
74
+ def is_real_user_text(t):
75
+ if not t or len(t) < 3:
76
+ return False
77
+ head = t.lstrip()[:40].lower()
78
+ if head.startswith("<") or "system-reminder" in head or "<command-" in head:
79
+ return False
80
+ if "caveat:" in head or "local-command" in head:
81
+ return False
82
+ return True
83
+
84
+
85
+ def tool_uses(ev):
86
+ """Yield (name, input_dict) for tool_use blocks in an assistant event."""
87
+ msg = ev.get("message") or {}
88
+ c = msg.get("content")
89
+ if isinstance(c, list):
90
+ for b in c:
91
+ if isinstance(b, dict) and b.get("type") == "tool_use":
92
+ yield (b.get("name") or "", b.get("input") or {})
93
+
94
+
95
+ def offset_file(sid):
96
+ sid = "".join(ch for ch in str(sid or "unknown") if ch.isalnum() or ch in "-_")[:80]
97
+ return os.path.join(tempfile.gettempdir(), "claude-turnstate-%s.off" % (sid or "unknown"))
98
+
99
+
100
+ def read_offset(sid, fsize):
101
+ try:
102
+ with open(offset_file(sid), "r") as f:
103
+ off = int(f.read().strip())
104
+ if 0 <= off <= fsize:
105
+ return off
106
+ except Exception:
107
+ pass
108
+ return 0
109
+
110
+
111
+ def write_offset(sid, off):
112
+ try:
113
+ with open(offset_file(sid), "w") as f:
114
+ f.write(str(off))
115
+ except Exception:
116
+ pass
117
+
118
+
119
+ def ensure_db(con):
120
+ con.execute("""CREATE TABLE IF NOT EXISTS turns(
121
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
122
+ session_id TEXT, ts TEXT, cwd TEXT, project TEXT,
123
+ ask TEXT, summary TEXT,
124
+ files TEXT, tools TEXT, commands TEXT, decisions TEXT,
125
+ evidence TEXT, byte_start INTEGER, byte_end INTEGER
126
+ )""")
127
+ con.execute("CREATE INDEX IF NOT EXISTS ix_turns_sid ON turns(session_id)")
128
+ con.commit()
129
+
130
+
131
+ def main():
132
+ raw = sys.stdin.buffer.read()
133
+ data = json.loads(raw.decode("utf-8", errors="replace"))
134
+
135
+ sid = str(data.get("session_id") or "")
136
+ tpath = data.get("transcript_path") or ""
137
+ cwd = data.get("cwd") or ""
138
+ if (not tpath or not os.path.isfile(tpath)) and sid:
139
+ # fallback: some hook events may omit transcript_path.
140
+ # locate the session's transcript by id under ~/.claude/projects/*/<sid>.jsonl
141
+ import glob as _glob
142
+ cands = _glob.glob(os.path.join(os.path.expanduser("~"), ".claude",
143
+ "projects", "*", sid + ".jsonl"))
144
+ if cands:
145
+ tpath = max(cands, key=os.path.getmtime)
146
+ if not tpath or not os.path.isfile(tpath):
147
+ return
148
+
149
+ fsize = os.path.getsize(tpath)
150
+ start = read_offset(sid, fsize)
151
+ if fsize - start > MAX_NEW_BYTES: # huge gap -> skip body, just advance
152
+ write_offset(sid, fsize)
153
+ return
154
+ if fsize <= start: # nothing new
155
+ return
156
+
157
+ with open(tpath, "r", encoding="utf-8", errors="ignore") as fh:
158
+ fh.seek(start)
159
+ chunk = fh.read()
160
+ write_offset(sid, fsize) # advance regardless, never re-process
161
+
162
+ ask = summary = ""
163
+ files, tools, commands, decisions = [], [], [], []
164
+ summary_parts = []
165
+ for line in chunk.splitlines():
166
+ line = line.strip()
167
+ if not line:
168
+ continue
169
+ try:
170
+ ev = json.loads(line)
171
+ except Exception:
172
+ continue
173
+ t = ev.get("type")
174
+ if t == "user":
175
+ txt = text_of(ev)
176
+ if is_real_user_text(txt):
177
+ ask = txt # last real user msg in this slice = the turn's ask
178
+ elif t == "assistant":
179
+ txt = text_of(ev)
180
+ if txt:
181
+ summary_parts.append(txt)
182
+ for name, inp in tool_uses(ev):
183
+ if name:
184
+ tools.append(name)
185
+ if name in FILE_TOOLS:
186
+ fp = inp.get("file_path") or inp.get("notebook_path")
187
+ if fp:
188
+ files.append(fp)
189
+ if name == "Bash":
190
+ cmd = (inp.get("command") or "").strip()
191
+ if cmd:
192
+ commands.append(cmd[:CLIP_CMD])
193
+
194
+ summary = "\n".join(summary_parts).strip()
195
+ for ln in summary.splitlines():
196
+ ln = ln.strip(" -*#>").strip()
197
+ if 8 <= len(ln) <= 180 and DECISION_RX.search(ln):
198
+ decisions.append(ln)
199
+
200
+ # nothing meaningful happened (e.g. pure tool-result turn) -> skip writing noise
201
+ if not (ask or summary or files or commands):
202
+ return
203
+
204
+ def uniq(seq):
205
+ seen, out = set(), []
206
+ for x in seq:
207
+ if x not in seen:
208
+ seen.add(x); out.append(x)
209
+ return out
210
+
211
+ row = (
212
+ sid,
213
+ time.strftime("%Y-%m-%d %H:%M:%S"),
214
+ cwd,
215
+ os.path.basename(cwd.rstrip("\\/")) if cwd else "",
216
+ ask[:CLIP_ASK],
217
+ summary[:CLIP_SUMMARY],
218
+ json.dumps(uniq(files), ensure_ascii=False),
219
+ json.dumps(uniq(tools), ensure_ascii=False),
220
+ json.dumps(commands[:20], ensure_ascii=False),
221
+ json.dumps(uniq(decisions)[:10], ensure_ascii=False),
222
+ tpath,
223
+ start,
224
+ fsize,
225
+ )
226
+ con = sqlite3.connect(db_path(), timeout=3.0)
227
+ try:
228
+ ensure_db(con)
229
+ con.execute(
230
+ "INSERT INTO turns(session_id,ts,cwd,project,ask,summary,files,tools,"
231
+ "commands,decisions,evidence,byte_start,byte_end) "
232
+ "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", row)
233
+ con.commit()
234
+ finally:
235
+ con.close()
236
+
237
+
238
+ if __name__ == "__main__":
239
+ try:
240
+ main()
241
+ except Exception:
242
+ pass # never break a session
243
+ sys.exit(0)
@@ -0,0 +1,85 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Viewer for the per-turn semantic-state ledger (TurnState).
4
+ Read-only. 0 tokens.
5
+
6
+ Usage:
7
+ python turnstate_show.py # last 15 turns (all sessions)
8
+ python turnstate_show.py --session <id> # one session
9
+ python turnstate_show.py --n 40 # last N
10
+ python turnstate_show.py --stats # totals
11
+
12
+ Config (env, optional):
13
+ TURNSTATE_DB path to the ledger db (default: ~/.claude/turnstate/turnstate.db)
14
+ """
15
+ import sqlite3, json, os, sys, argparse
16
+
17
+ DB = os.getenv("TURNSTATE_DB") or os.path.join(
18
+ os.path.expanduser("~"), ".claude", "turnstate", "turnstate.db")
19
+
20
+ try:
21
+ sys.stdout.reconfigure(encoding="utf-8")
22
+ except Exception:
23
+ pass
24
+
25
+
26
+ def jl(s):
27
+ try:
28
+ return json.loads(s or "[]")
29
+ except Exception:
30
+ return []
31
+
32
+
33
+ def main():
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--session")
36
+ ap.add_argument("--n", type=int, default=15)
37
+ ap.add_argument("--stats", action="store_true")
38
+ a = ap.parse_args()
39
+
40
+ if not os.path.isfile(DB):
41
+ print("(no ledger yet:", DB, ")"); return
42
+ c = sqlite3.connect(DB)
43
+
44
+ if a.stats:
45
+ tot = c.execute("select count(*) from turns").fetchone()[0]
46
+ sess = c.execute("select count(distinct session_id) from turns").fetchone()[0]
47
+ files = c.execute("select count(*) from turns where files != '[]'").fetchone()[0]
48
+ print("ledger:", DB)
49
+ print("turns: %d | sessions: %d | turns-that-touched-files: %d" % (tot, sess, files))
50
+ for sid, n, last in c.execute(
51
+ "select session_id,count(*),max(ts) from turns group by session_id "
52
+ "order by max(ts) desc limit 12"):
53
+ print(" %-38s %4d turns last %s" % (sid[:38], n, last))
54
+ return
55
+
56
+ q = ("select ts,project,ask,summary,files,tools,commands,decisions,session_id "
57
+ "from turns ")
58
+ args = ()
59
+ if a.session:
60
+ q += "where session_id=? "; args = (a.session,)
61
+ q += "order by id desc limit ?"; args = args + (a.n,)
62
+
63
+ rows = c.execute(q, args).fetchall()
64
+ for ts, project, ask, summary, files, tools, commands, decisions, sid in reversed(rows):
65
+ print("=" * 78)
66
+ print("🕒 %s [%s] sid %s" % (ts, project, sid[:12]))
67
+ if ask:
68
+ print("❓ ask: %s" % ask.replace("\n", " ")[:160])
69
+ f, t, cm, d = jl(files), jl(tools), jl(commands), jl(decisions)
70
+ if f:
71
+ print("📝 files: %s" % ", ".join(os.path.basename(x) for x in f[:6]))
72
+ if t:
73
+ print("🔧 tools: %s" % ", ".join(t[:12]))
74
+ if cm:
75
+ print("⌨ cmds: %d (%s ...)" % (len(cm), (cm[0][:60] if cm else "")))
76
+ if d:
77
+ print("✅ decisions:")
78
+ for x in d[:6]:
79
+ print(" - %s" % x)
80
+ if summary:
81
+ print("💬 summary: %s" % summary.replace("\n", " ")[:200])
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()