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.
- sqlite_graph_memory/__init__.py +26 -0
- sqlite_graph_memory/brain_ask.py +310 -0
- sqlite_graph_memory/index_notes.py +151 -0
- sqlite_graph_memory/mcp_server.py +271 -0
- sqlite_graph_memory/schema.sql +45 -0
- sqlite_graph_memory/turnstate_hook.py +243 -0
- sqlite_graph_memory/turnstate_show.py +85 -0
- sqlite_graph_memory-0.3.0.dist-info/METADATA +452 -0
- sqlite_graph_memory-0.3.0.dist-info/RECORD +12 -0
- sqlite_graph_memory-0.3.0.dist-info/WHEEL +4 -0
- sqlite_graph_memory-0.3.0.dist-info/entry_points.txt +6 -0
- sqlite_graph_memory-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|