graph-mind-memory 0.1.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.
- associative_memory.py +507 -0
- automatic_capture.py +288 -0
- automatic_capture_cli.py +72 -0
- brain_log.py +501 -0
- coding_memory.py +438 -0
- concept_plan_benchmark_v071.py +248 -0
- conversation_memory.py +476 -0
- development_memory.py +450 -0
- development_paths.py +40 -0
- embedding_warmup.py +171 -0
- entity_timeline.py +130 -0
- executable_memory.py +312 -0
- executable_plan_v2_diagnostic.py +272 -0
- graph_mind_mcp_server.py +864 -0
- graph_mind_memory-0.1.0.dist-info/METADATA +252 -0
- graph_mind_memory-0.1.0.dist-info/RECORD +43 -0
- graph_mind_memory-0.1.0.dist-info/WHEEL +5 -0
- graph_mind_memory-0.1.0.dist-info/entry_points.txt +4 -0
- graph_mind_memory-0.1.0.dist-info/licenses/LICENSE +661 -0
- graph_mind_memory-0.1.0.dist-info/top_level.txt +37 -0
- grounded_concept_bridge_v072.py +511 -0
- ingestion_gate.py +113 -0
- ingestion_synonyms.py +165 -0
- install.py +198 -0
- llm_concept_planner.py +170 -0
- local_brain.py +354 -0
- longmemeval_adapter.py +120 -0
- longmemeval_ingestion_calibration.py +366 -0
- longmemeval_ingestion_preflight.py +188 -0
- longmemeval_ingestion_v7.py +356 -0
- memory_algebra.py +155 -0
- memory_contract.py +154 -0
- memory_normalization.py +13 -0
- memory_vocabulary.py +34 -0
- semantic_grounding_v073.py +501 -0
- semantic_llm_extractor.py +215 -0
- semantic_memory.py +155 -0
- semantic_recall.py +100 -0
- state_relation_contract.py +158 -0
- temporal_resolver.py +272 -0
- temporal_state.py +210 -0
- universal_personal_memory.py +234 -0
- vector_cache.py +194 -0
conversation_memory.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Incremental, redacted conversation capture for the user-owned Local Brain."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from development_paths import use_wal
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
from temporal_state import timestamp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
CAPTURE_VERSION = "codex-conversation-capture-v0.1"
|
|
18
|
+
SECRET_PATTERNS = (
|
|
19
|
+
(re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b"), "[REDACTED_OPENAI_KEY]"),
|
|
20
|
+
(re.compile(r"\bkey_[A-Za-z0-9_-]{10,}\b"), "[REDACTED_API_KEY]"),
|
|
21
|
+
(re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s]+"), r"\1[REDACTED]"),
|
|
22
|
+
(re.compile(r"(?i)((?:api[_ -]?key|secret|token)\s*[:=]\s*)[^\s]+"),
|
|
23
|
+
r"\1[REDACTED]"),
|
|
24
|
+
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b"),
|
|
25
|
+
"[REDACTED_GITHUB_TOKEN]"),
|
|
26
|
+
(re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), "[REDACTED_AWS_KEY]"),
|
|
27
|
+
(re.compile(r"\bAIza[A-Za-z0-9_-]{35}\b"), "[REDACTED_GOOGLE_KEY]"),
|
|
28
|
+
(re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_SLACK_TOKEN]"),
|
|
29
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
30
|
+
re.DOTALL), "[REDACTED_PRIVATE_KEY]"),
|
|
31
|
+
# a database URL carries its password: postgres://user:PASSWORD@host
|
|
32
|
+
(re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+(@)"), r"\1[REDACTED]\2"),
|
|
33
|
+
)
|
|
34
|
+
AUTOMATIC_BLOCKS = re.compile(
|
|
35
|
+
r"<(?:environment_context|recommended_plugins|in-app-browser-context)\b[^>]*>.*?"
|
|
36
|
+
r"</(?:environment_context|recommended_plugins|in-app-browser-context)>",
|
|
37
|
+
flags=re.DOTALL,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(value, error):
|
|
42
|
+
if not isinstance(value, str) or not value.strip():
|
|
43
|
+
raise ValueError(error)
|
|
44
|
+
return value.strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Codex opens each task by injecting the workspace's AGENTS.md as a user message. It is the
|
|
48
|
+
# harness talking, not the person, and it is long enough to fill a context packet on its own.
|
|
49
|
+
AGENT_INSTRUCTIONS = re.compile(
|
|
50
|
+
r"(?:#\s*AGENTS\.md instructions[^\n]*\s*)?<INSTRUCTIONS>.*?</INSTRUCTIONS>", flags=re.DOTALL)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _redact(value):
|
|
54
|
+
cleaned = AGENT_INSTRUCTIONS.sub("", AUTOMATIC_BLOCKS.sub("", value))
|
|
55
|
+
count = 0
|
|
56
|
+
for pattern, replacement in SECRET_PATTERNS:
|
|
57
|
+
cleaned, replacements = pattern.subn(replacement, cleaned)
|
|
58
|
+
count += replacements
|
|
59
|
+
return cleaned.strip(), count
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _message_text(payload):
|
|
63
|
+
content = payload.get("content")
|
|
64
|
+
if isinstance(content, str):
|
|
65
|
+
return content
|
|
66
|
+
if not isinstance(content, list):
|
|
67
|
+
return ""
|
|
68
|
+
parts = []
|
|
69
|
+
for item in content:
|
|
70
|
+
if not isinstance(item, dict):
|
|
71
|
+
continue
|
|
72
|
+
if item.get("type") in {"input_text", "output_text", "text"}:
|
|
73
|
+
value = item.get("text")
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
parts.append(value)
|
|
76
|
+
return "\n".join(parts)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConversationMemoryStore:
|
|
80
|
+
def __init__(self, path, *, clock="SOURCE_LOCAL"):
|
|
81
|
+
self.path = Path(path)
|
|
82
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self.clock = clock
|
|
84
|
+
# Claude, Codex and the capture service each open this file from their own process.
|
|
85
|
+
# WAL lets them read while one writes; the timeout makes a writer wait its turn
|
|
86
|
+
# instead of failing with "database is locked".
|
|
87
|
+
self.db = sqlite3.connect(str(self.path), timeout=30)
|
|
88
|
+
use_wal(self.db)
|
|
89
|
+
self.db.row_factory = sqlite3.Row
|
|
90
|
+
self.db.executescript("""
|
|
91
|
+
CREATE TABLE IF NOT EXISTS conversation_turns(
|
|
92
|
+
revision INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
turn_id TEXT NOT NULL UNIQUE,
|
|
94
|
+
client TEXT NOT NULL,
|
|
95
|
+
client_session_id TEXT NOT NULL,
|
|
96
|
+
scope TEXT NOT NULL,
|
|
97
|
+
cwd TEXT,
|
|
98
|
+
happened_at TEXT NOT NULL,
|
|
99
|
+
known_at TEXT NOT NULL,
|
|
100
|
+
role TEXT NOT NULL,
|
|
101
|
+
content_redacted TEXT NOT NULL,
|
|
102
|
+
raw_sha256 TEXT NOT NULL,
|
|
103
|
+
redaction_count INTEGER NOT NULL,
|
|
104
|
+
source_path TEXT NOT NULL,
|
|
105
|
+
source_ordinal INTEGER NOT NULL,
|
|
106
|
+
fingerprint TEXT NOT NULL);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS conversation_scope_time
|
|
108
|
+
ON conversation_turns(scope,happened_at DESC);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS conversation_session_time
|
|
110
|
+
ON conversation_turns(client_session_id,happened_at);
|
|
111
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS conversation_turns_fts USING fts5(
|
|
112
|
+
content_redacted, content='conversation_turns', content_rowid='revision',
|
|
113
|
+
tokenize='unicode61');
|
|
114
|
+
CREATE TRIGGER IF NOT EXISTS conversation_turns_ai AFTER INSERT ON conversation_turns BEGIN
|
|
115
|
+
INSERT INTO conversation_turns_fts(rowid,content_redacted)
|
|
116
|
+
VALUES(new.revision,new.content_redacted);
|
|
117
|
+
END;
|
|
118
|
+
""")
|
|
119
|
+
self.closed = False
|
|
120
|
+
|
|
121
|
+
def __enter__(self):
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *args):
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def close(self):
|
|
128
|
+
if not self.closed:
|
|
129
|
+
self.closed = True
|
|
130
|
+
self.db.close()
|
|
131
|
+
|
|
132
|
+
def _time(self, value):
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
try:
|
|
135
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
136
|
+
except ValueError:
|
|
137
|
+
pass
|
|
138
|
+
else:
|
|
139
|
+
if self.clock == "SOURCE_LOCAL" and parsed.utcoffset() is not None:
|
|
140
|
+
value = parsed.astimezone().replace(tzinfo=None).isoformat(
|
|
141
|
+
timespec="microseconds")
|
|
142
|
+
return timestamp(value, self.clock).isoformat(timespec="microseconds")
|
|
143
|
+
|
|
144
|
+
def record(self, turn):
|
|
145
|
+
required = ("turn_id", "client", "client_session_id", "scope", "role",
|
|
146
|
+
"content_redacted", "raw_sha256", "source_path")
|
|
147
|
+
normalized = {key: _text(turn.get(key), key.upper() + "_REQUIRED")
|
|
148
|
+
for key in required}
|
|
149
|
+
normalized["cwd"] = turn.get("cwd")
|
|
150
|
+
normalized["happened_at"] = self._time(turn.get("happened_at"))
|
|
151
|
+
normalized["known_at"] = self._time(turn.get("known_at"))
|
|
152
|
+
normalized["source_ordinal"] = int(turn.get("source_ordinal"))
|
|
153
|
+
normalized["redaction_count"] = int(turn.get("redaction_count", 0))
|
|
154
|
+
fingerprint = hashlib.sha256(json.dumps(normalized, ensure_ascii=False,
|
|
155
|
+
sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
156
|
+
existing = self.db.execute(
|
|
157
|
+
"SELECT revision,fingerprint,raw_sha256 FROM conversation_turns WHERE turn_id=?",
|
|
158
|
+
(normalized["turn_id"],)).fetchone()
|
|
159
|
+
if existing:
|
|
160
|
+
# The id already binds the session, the record and the hash of what was said, so the
|
|
161
|
+
# same id with the same text is the same turn seen again, even if this read happened
|
|
162
|
+
# at another time. Only the same id carrying different text is a real collision.
|
|
163
|
+
if existing["raw_sha256"] != normalized["raw_sha256"]:
|
|
164
|
+
raise ValueError("CONVERSATION_TURN_ID_COLLISION")
|
|
165
|
+
return {"turn_id": normalized["turn_id"], "revision": existing["revision"],
|
|
166
|
+
"disposition": "ALREADY_RECORDED"}
|
|
167
|
+
with self.db:
|
|
168
|
+
cursor = self.db.execute("""INSERT INTO conversation_turns(
|
|
169
|
+
turn_id,client,client_session_id,scope,cwd,happened_at,known_at,role,
|
|
170
|
+
content_redacted,raw_sha256,redaction_count,source_path,source_ordinal,fingerprint)
|
|
171
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
|
|
172
|
+
normalized["turn_id"], normalized["client"],
|
|
173
|
+
normalized["client_session_id"], normalized["scope"], normalized["cwd"],
|
|
174
|
+
normalized["happened_at"], normalized["known_at"], normalized["role"],
|
|
175
|
+
normalized["content_redacted"], normalized["raw_sha256"],
|
|
176
|
+
normalized["redaction_count"], normalized["source_path"],
|
|
177
|
+
normalized["source_ordinal"], fingerprint))
|
|
178
|
+
return {"turn_id": normalized["turn_id"], "revision": cursor.lastrowid,
|
|
179
|
+
"disposition": "RECORDED"}
|
|
180
|
+
|
|
181
|
+
def search(self, query, *, scopes=None, limit=10):
|
|
182
|
+
query = _text(query, "CONVERSATION_QUERY_REQUIRED")
|
|
183
|
+
if type(limit) is not int or not 1 <= limit <= 50:
|
|
184
|
+
raise ValueError("INVALID_CONVERSATION_QUERY_LIMIT")
|
|
185
|
+
terms = list(dict.fromkeys(re.findall(r"[^\W_]{2,}", query.casefold())))
|
|
186
|
+
if not terms:
|
|
187
|
+
return {"status": "UNKNOWN", "reason": "NO_SEARCHABLE_QUERY_TERMS",
|
|
188
|
+
"scope_completeness": "UNATTESTED", "turns": []}
|
|
189
|
+
# Prefix terms, not exact ones: Korean glues particles to the noun, so the index holds
|
|
190
|
+
# "콩이를" and "제주도에" as single tokens and an exact "콩이" matched neither.
|
|
191
|
+
expression = " OR ".join('"' + term.replace('"', '""') + '"*' for term in terms)
|
|
192
|
+
where, parameters = ["conversation_turns_fts MATCH ?"], [expression]
|
|
193
|
+
if scopes:
|
|
194
|
+
where.append("t.scope IN (" + ",".join("?" for _ in scopes) + ")")
|
|
195
|
+
parameters.extend(scopes)
|
|
196
|
+
parameters.append(limit)
|
|
197
|
+
rows = self.db.execute("""SELECT t.*,bm25(conversation_turns_fts) AS rank
|
|
198
|
+
FROM conversation_turns_fts JOIN conversation_turns t
|
|
199
|
+
ON t.revision=conversation_turns_fts.rowid WHERE """
|
|
200
|
+
+ " AND ".join(where) + " ORDER BY rank,t.happened_at DESC LIMIT ?",
|
|
201
|
+
parameters).fetchall()
|
|
202
|
+
turns = [self._turn(row) for row in rows]
|
|
203
|
+
return {"status": "KNOWN" if turns else "UNKNOWN",
|
|
204
|
+
"reason": "CONVERSATION_TURNS_FOUND" if turns else "NO_MATCHING_TURN",
|
|
205
|
+
"scope_completeness": "UNATTESTED", "turns": turns}
|
|
206
|
+
|
|
207
|
+
def latest(self, *, scopes=None, since=None, limit=10):
|
|
208
|
+
"""The most recent turns, newest first: what "what was I just doing?" is asking for."""
|
|
209
|
+
if type(limit) is not int or not 1 <= limit <= 50:
|
|
210
|
+
raise ValueError("INVALID_CONVERSATION_QUERY_LIMIT")
|
|
211
|
+
where, parameters = ["1=1"], []
|
|
212
|
+
if scopes:
|
|
213
|
+
where.append("scope IN (" + ",".join("?" for _ in scopes) + ")")
|
|
214
|
+
parameters.extend(scopes)
|
|
215
|
+
if since:
|
|
216
|
+
where.append("happened_at>=?")
|
|
217
|
+
parameters.append(self._time(since))
|
|
218
|
+
rows = self.db.execute("SELECT * FROM conversation_turns WHERE " + " AND ".join(where)
|
|
219
|
+
+ " ORDER BY happened_at DESC, revision DESC LIMIT ?",
|
|
220
|
+
[*parameters, limit]).fetchall()
|
|
221
|
+
return {"status": "KNOWN" if rows else "UNKNOWN",
|
|
222
|
+
"reason": "RECENT_TURNS" if rows else "NO_TURNS_IN_RANGE",
|
|
223
|
+
"scope_completeness": "UNATTESTED", "turns": [self._turn(row) for row in rows]}
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _turn(row):
|
|
227
|
+
return {"turn_id": row["turn_id"], "client": row["client"],
|
|
228
|
+
"client_session_id": row["client_session_id"], "scope": row["scope"],
|
|
229
|
+
"cwd": row["cwd"], "happened_at": row["happened_at"],
|
|
230
|
+
"role": row["role"], "content": row["content_redacted"],
|
|
231
|
+
"redaction_count": row["redaction_count"],
|
|
232
|
+
"source_path": row["source_path"], "source_ordinal": row["source_ordinal"]}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class CodexConversationCapture:
|
|
236
|
+
"""Read only newly appended complete JSONL records from Codex sessions."""
|
|
237
|
+
|
|
238
|
+
CLIENT = "codex"
|
|
239
|
+
|
|
240
|
+
def __init__(self, sessions_root, store, *, state_path, workspace_scopes=None,
|
|
241
|
+
now=None, publish=None):
|
|
242
|
+
self.sessions_root = Path(sessions_root).resolve()
|
|
243
|
+
if not self.sessions_root.is_dir():
|
|
244
|
+
raise ValueError("CODEX_SESSIONS_ROOT_NOT_FOUND")
|
|
245
|
+
if not isinstance(store, ConversationMemoryStore):
|
|
246
|
+
raise ValueError("CONVERSATION_MEMORY_STORE_REQUIRED")
|
|
247
|
+
self.store = store
|
|
248
|
+
self.state_path = Path(state_path).resolve()
|
|
249
|
+
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
self.workspace_scopes = {str(Path(key).resolve()): value
|
|
251
|
+
for key, value in (workspace_scopes or {}).items()}
|
|
252
|
+
self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
|
|
253
|
+
self.publish = publish # called with each turn that is new to this store
|
|
254
|
+
|
|
255
|
+
def _files(self):
|
|
256
|
+
return sorted(self.sessions_root.rglob("*.jsonl"))
|
|
257
|
+
|
|
258
|
+
def _session_meta(self, path):
|
|
259
|
+
session_id, cwd = path.stem, None
|
|
260
|
+
try:
|
|
261
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
262
|
+
first = json.loads(handle.readline())
|
|
263
|
+
payload = first.get("payload") or {}
|
|
264
|
+
session_id = str(payload.get("session_id") or payload.get("id") or session_id)
|
|
265
|
+
cwd = payload.get("cwd")
|
|
266
|
+
except (OSError, ValueError, TypeError):
|
|
267
|
+
pass
|
|
268
|
+
resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
|
|
269
|
+
scope = self.workspace_scopes.get(resolved_cwd, "global")
|
|
270
|
+
return session_id, cwd, scope
|
|
271
|
+
|
|
272
|
+
def _identity(self, item, ordinal):
|
|
273
|
+
"""What makes this record the same record on every scan. Codex numbers its records."""
|
|
274
|
+
return ordinal
|
|
275
|
+
|
|
276
|
+
def _message(self, item):
|
|
277
|
+
"""(role, text) when this record is something a person or the assistant said, else None."""
|
|
278
|
+
payload = item.get("payload") or {}
|
|
279
|
+
if (item.get("type") != "response_item" or payload.get("type") != "message"
|
|
280
|
+
or payload.get("role") not in {"user", "assistant"}):
|
|
281
|
+
return None
|
|
282
|
+
return payload["role"], _message_text(payload)
|
|
283
|
+
|
|
284
|
+
def initialize_baseline(self):
|
|
285
|
+
files = {str(path): path.stat().st_size for path in self._files()}
|
|
286
|
+
self._write_state(files)
|
|
287
|
+
return {"status": "BASELINE_CREATED", "file_count": len(files),
|
|
288
|
+
"state_path": str(self.state_path)}
|
|
289
|
+
|
|
290
|
+
def _load_state(self):
|
|
291
|
+
if not self.state_path.exists():
|
|
292
|
+
raise ValueError("CONVERSATION_CAPTURE_BASELINE_REQUIRED")
|
|
293
|
+
value = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
294
|
+
if value.get("capture_version") != CAPTURE_VERSION:
|
|
295
|
+
raise ValueError("CONVERSATION_CAPTURE_VERSION_MISMATCH")
|
|
296
|
+
return value.get("offsets", {})
|
|
297
|
+
|
|
298
|
+
def _write_state(self, offsets):
|
|
299
|
+
value = {"capture_version": CAPTURE_VERSION,
|
|
300
|
+
"sessions_root": str(self.sessions_root), "offsets": offsets}
|
|
301
|
+
temporary = self.state_path.with_suffix(".tmp")
|
|
302
|
+
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
|
303
|
+
encoding="utf-8")
|
|
304
|
+
os.replace(temporary, self.state_path)
|
|
305
|
+
|
|
306
|
+
def scan(self):
|
|
307
|
+
offsets = self._load_state()
|
|
308
|
+
new_offsets = dict(offsets)
|
|
309
|
+
recorded, scanned_rows, redactions = [], 0, 0
|
|
310
|
+
known_at = self.now()
|
|
311
|
+
for path in self._files():
|
|
312
|
+
key = str(path)
|
|
313
|
+
start = int(offsets.get(key, 0))
|
|
314
|
+
size = path.stat().st_size
|
|
315
|
+
if size < start:
|
|
316
|
+
start = 0
|
|
317
|
+
session_id, cwd, scope = self._session_meta(path)
|
|
318
|
+
with path.open("rb") as handle:
|
|
319
|
+
handle.seek(start)
|
|
320
|
+
committed = start
|
|
321
|
+
while True:
|
|
322
|
+
line_start = handle.tell()
|
|
323
|
+
line = handle.readline()
|
|
324
|
+
if not line:
|
|
325
|
+
break
|
|
326
|
+
if not line.endswith(b"\n"):
|
|
327
|
+
handle.seek(line_start)
|
|
328
|
+
break
|
|
329
|
+
committed = handle.tell()
|
|
330
|
+
scanned_rows += 1
|
|
331
|
+
try:
|
|
332
|
+
item = json.loads(line.decode("utf-8", "strict"))
|
|
333
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
334
|
+
continue
|
|
335
|
+
message = self._message(item)
|
|
336
|
+
if message is None:
|
|
337
|
+
continue
|
|
338
|
+
role, raw = message
|
|
339
|
+
if not raw.strip():
|
|
340
|
+
continue
|
|
341
|
+
cleaned, count = _redact(raw)
|
|
342
|
+
if not cleaned:
|
|
343
|
+
continue
|
|
344
|
+
redactions += count
|
|
345
|
+
raw_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
346
|
+
ordinal_value = item.get("ordinal")
|
|
347
|
+
# where the line sits in the file: unlike the row count of this one scan,
|
|
348
|
+
# it is the same every time the file is read
|
|
349
|
+
ordinal = int(ordinal_value) if ordinal_value is not None else line_start
|
|
350
|
+
binding = (f"{session_id}\0{self._identity(item, ordinal)}\0{raw_hash}"
|
|
351
|
+
.encode("utf-8"))
|
|
352
|
+
turn = {"turn_id": f"{self.CLIENT}-turn-"
|
|
353
|
+
+ hashlib.sha256(binding).hexdigest(),
|
|
354
|
+
"client": self.CLIENT, "client_session_id": session_id,
|
|
355
|
+
"scope": scope, "cwd": cwd,
|
|
356
|
+
"happened_at": item.get("timestamp") or known_at,
|
|
357
|
+
"known_at": known_at, "role": role,
|
|
358
|
+
"content_redacted": cleaned, "raw_sha256": raw_hash,
|
|
359
|
+
"redaction_count": count, "source_path": key,
|
|
360
|
+
"source_ordinal": ordinal}
|
|
361
|
+
outcome = self.store.record(turn)
|
|
362
|
+
if self.publish and outcome.get("disposition") != "ALREADY_RECORDED":
|
|
363
|
+
try:
|
|
364
|
+
self.publish(turn)
|
|
365
|
+
except Exception:
|
|
366
|
+
# ponytail: the hub is unreachable; the turn is kept on this PC and
|
|
367
|
+
# brain_folder(share_existing=true) sends it to the others later
|
|
368
|
+
pass
|
|
369
|
+
recorded.append(outcome)
|
|
370
|
+
new_offsets[key] = committed
|
|
371
|
+
self._write_state(new_offsets)
|
|
372
|
+
return {"status": "TURNS_RECORDED" if recorded else "NO_NEW_TURNS",
|
|
373
|
+
"recorded_turns": len(recorded), "scanned_rows": scanned_rows,
|
|
374
|
+
"redactions": redactions, "record_results": recorded}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
CLAUDE_CODE_BLOCKS = re.compile(
|
|
378
|
+
r"<(system-reminder|task-notification|local-command-caveat|local-command-stdout|"
|
|
379
|
+
r"command-name|command-message|command-args)\b[^>]*>.*?</\1>", flags=re.DOTALL)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class ClaudeCodeConversationCapture(CodexConversationCapture):
|
|
383
|
+
"""Read only newly appended complete JSONL records from Claude Code transcripts.
|
|
384
|
+
|
|
385
|
+
A transcript holds far more than the conversation: tool calls, tool results, the model's
|
|
386
|
+
thinking, attachments, mode switches. Only two kinds of record are something somebody SAID — a
|
|
387
|
+
user turn typed as text, and an assistant text block — and only those are kept, verbatim.
|
|
388
|
+
Harness notices injected into user turns (system reminders, task notifications, command
|
|
389
|
+
echoes) are stripped, and sub-agent side chains are skipped: the user did not have that
|
|
390
|
+
conversation. Secrets are masked by the same rules as every other capture.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
CLIENT = "claude-code"
|
|
394
|
+
|
|
395
|
+
def _identity(self, item, ordinal):
|
|
396
|
+
"""Claude Code records carry no ordinal, and the row count of one scan is not stable: a
|
|
397
|
+
re-read from the start would give every turn a new id and store it twice. Each record's
|
|
398
|
+
own uuid is."""
|
|
399
|
+
return item.get("uuid") or ordinal
|
|
400
|
+
|
|
401
|
+
def _session_meta(self, path):
|
|
402
|
+
session_id, cwd = path.stem, None
|
|
403
|
+
try:
|
|
404
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
405
|
+
for line in handle:
|
|
406
|
+
record = json.loads(line)
|
|
407
|
+
if record.get("cwd"):
|
|
408
|
+
session_id = str(record.get("sessionId") or session_id)
|
|
409
|
+
cwd = record["cwd"]
|
|
410
|
+
break
|
|
411
|
+
except (OSError, ValueError, TypeError):
|
|
412
|
+
pass
|
|
413
|
+
resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
|
|
414
|
+
return session_id, cwd, self.workspace_scopes.get(resolved_cwd, "global")
|
|
415
|
+
|
|
416
|
+
def _message(self, item):
|
|
417
|
+
if item.get("isSidechain") or item.get("isMeta"):
|
|
418
|
+
return None
|
|
419
|
+
queued = item.get("attachment") if item.get("type") == "attachment" else None
|
|
420
|
+
if isinstance(queued, dict) and queued.get("type") == "queued_command":
|
|
421
|
+
# A message typed while the assistant was still working is not a "user" record: it
|
|
422
|
+
# is queued and delivered as an attachment. Missing it loses exactly the corrections
|
|
423
|
+
# and asides people make mid-task. (The matching queue-operation records are the
|
|
424
|
+
# queue's bookkeeping of the same text and are skipped to avoid storing it twice.)
|
|
425
|
+
# Only what the person typed: background-task notifications queue the same way.
|
|
426
|
+
if queued.get("humanTurn") is False or queued.get("commandMode") != "prompt":
|
|
427
|
+
return None
|
|
428
|
+
prompt = queued.get("prompt")
|
|
429
|
+
if isinstance(prompt, list): # a pasted image arrives as blocks
|
|
430
|
+
prompt = "\n".join(block.get("text", "") for block in prompt
|
|
431
|
+
if isinstance(block, dict) and block.get("type") == "text")
|
|
432
|
+
if not isinstance(prompt, str):
|
|
433
|
+
return None
|
|
434
|
+
return "user", CLAUDE_CODE_BLOCKS.sub("", prompt)
|
|
435
|
+
if item.get("type") not in {"user", "assistant"}:
|
|
436
|
+
return None
|
|
437
|
+
message = item.get("message") or {}
|
|
438
|
+
content = message.get("content")
|
|
439
|
+
if isinstance(content, str):
|
|
440
|
+
text = content
|
|
441
|
+
elif isinstance(content, list): # text blocks only: no tool_use, tool_result, thinking
|
|
442
|
+
text = "\n".join(block.get("text", "") for block in content
|
|
443
|
+
if isinstance(block, dict) and block.get("type") == "text")
|
|
444
|
+
else:
|
|
445
|
+
return None
|
|
446
|
+
return item["type"], CLAUDE_CODE_BLOCKS.sub("", text)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class ClaudeCoworkConversationCapture(ClaudeCodeConversationCapture):
|
|
450
|
+
"""Claude desktop's Cowork mode: the same agent and transcript format as Claude Code, but each
|
|
451
|
+
session keeps its own .claude/projects inside local-agent-mode-sessions. Only those
|
|
452
|
+
transcripts are read; the session folders also hold credentials and uploads, never opened."""
|
|
453
|
+
|
|
454
|
+
CLIENT = "claude-cowork"
|
|
455
|
+
|
|
456
|
+
def _files(self):
|
|
457
|
+
# Cowork transcript paths run to ~500 characters; past Windows' 260 a plain rglob skips
|
|
458
|
+
# them without an error (5 of 68 found). The extended-length prefix lifts the limit.
|
|
459
|
+
# ponytail: only here; the Codex and Claude Code roots stay short and keep cursor keys.
|
|
460
|
+
root = self.sessions_root
|
|
461
|
+
if os.name == "nt" and not str(root).startswith("\\\\?\\"):
|
|
462
|
+
root = Path("\\\\?\\" + str(root))
|
|
463
|
+
return sorted(path for path in root.rglob("*.jsonl")
|
|
464
|
+
if ".claude" in path.parts and "projects" in path.parts
|
|
465
|
+
and "subagents" not in path.parts)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def cowork_root():
|
|
469
|
+
"""Where Claude desktop keeps Cowork sessions: the Store (MSIX) build or the plain installer."""
|
|
470
|
+
candidates = [Path(os.environ.get("APPDATA", "")) / "Claude" / "local-agent-mode-sessions"]
|
|
471
|
+
packages = Path(os.environ.get("LOCALAPPDATA", "")) / "Packages"
|
|
472
|
+
if packages.is_dir():
|
|
473
|
+
candidates += [package / "LocalCache" / "Roaming" / "Claude" / "local-agent-mode-sessions"
|
|
474
|
+
for package in packages.glob("Claude_*")]
|
|
475
|
+
return next((path for path in candidates if path.is_dir()), None)
|
|
476
|
+
|