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.
coding_memory.py ADDED
@@ -0,0 +1,438 @@
1
+ """Indexed coding activity memory with source-bound diffs and symbols."""
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 ast
9
+ import difflib
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import sqlite3
15
+
16
+ from temporal_state import timestamp
17
+
18
+
19
+ CAPTURE_VERSION = "coding-memory-v0.1"
20
+ DEFAULT_SUFFIXES = frozenset({
21
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".kt", ".swift",
22
+ ".go", ".rs", ".cs", ".c", ".cc", ".cpp", ".h", ".hpp", ".sql",
23
+ ".toml", ".yaml", ".yml",
24
+ })
25
+ DEFAULT_EXCLUDED_PARTS = frozenset({
26
+ ".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__",
27
+ "external", "runs",
28
+ })
29
+
30
+
31
+ def _json(value):
32
+ return json.dumps(value, ensure_ascii=False, sort_keys=True,
33
+ separators=(",", ":"), allow_nan=False)
34
+
35
+
36
+ def _digest(value):
37
+ return hashlib.sha256(value).hexdigest()
38
+
39
+
40
+ def _text(value, error):
41
+ if not isinstance(value, str) or not value.strip():
42
+ raise ValueError(error)
43
+ return value.strip()
44
+
45
+
46
+ def _string_list(value, error):
47
+ if value is None:
48
+ return []
49
+ if not isinstance(value, list) or not all(isinstance(item, str) and item.strip()
50
+ for item in value):
51
+ raise ValueError(error)
52
+ return list(dict.fromkeys(item.strip() for item in value))
53
+
54
+
55
+ def _decode_source(value):
56
+ try:
57
+ return value.decode("utf-8-sig", "strict")
58
+ except UnicodeDecodeError:
59
+ return value.decode("utf-8", "replace")
60
+
61
+
62
+ def _python_symbols(text):
63
+ try:
64
+ tree = ast.parse(text)
65
+ except (SyntaxError, ValueError):
66
+ return []
67
+ result = []
68
+
69
+ def visit(body, prefix=""):
70
+ for node in body:
71
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
72
+ name = f"{prefix}.{node.name}" if prefix else node.name
73
+ kind = "CLASS" if isinstance(node, ast.ClassDef) else "FUNCTION"
74
+ result.append({"name": name, "kind": kind,
75
+ "start_line": node.lineno,
76
+ "end_line": getattr(node, "end_lineno", node.lineno)})
77
+ visit(getattr(node, "body", []), name)
78
+ visit(tree.body)
79
+ return result
80
+
81
+
82
+ def _changed_lines(before, after):
83
+ old_lines, new_lines = before.splitlines(), after.splitlines()
84
+ old_changed, new_changed = set(), set()
85
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(
86
+ None, old_lines, new_lines, autojunk=False).get_opcodes():
87
+ if tag == "equal":
88
+ continue
89
+ old_changed.update(range(i1 + 1, i2 + 1))
90
+ new_changed.update(range(j1 + 1, j2 + 1))
91
+ return old_changed, new_changed
92
+
93
+
94
+ def _affected_symbols(path, before, after):
95
+ if Path(path).suffix.lower() != ".py":
96
+ return []
97
+ old_changed, new_changed = _changed_lines(before, after)
98
+ found = {}
99
+ for side, text, changed in (("BEFORE", before, old_changed),
100
+ ("AFTER", after, new_changed)):
101
+ for symbol in _python_symbols(text):
102
+ if any(symbol["start_line"] <= line <= symbol["end_line"] for line in changed):
103
+ found[(side, symbol["name"])] = {**symbol, "side": side}
104
+ if not found and (old_changed or new_changed):
105
+ return [{"name": "<module>", "kind": "MODULE", "start_line": 1,
106
+ "end_line": max(len(after.splitlines()), 1), "side": "AFTER"}]
107
+ return list(found.values())
108
+
109
+
110
+ class CodingMemoryStore:
111
+ """Indexed structured code-change events; query never reads the workspace."""
112
+
113
+ def __init__(self, path, *, clock="SOURCE_LOCAL"):
114
+ self.path = Path(path)
115
+ self.path.parent.mkdir(parents=True, exist_ok=True)
116
+ self.clock = clock
117
+ # Claude, Codex and the capture service each open this file from their own process.
118
+ # WAL lets them read while one writes; the timeout makes a writer wait its turn
119
+ # instead of failing with "database is locked".
120
+ self.db = sqlite3.connect(str(self.path), timeout=30)
121
+ use_wal(self.db)
122
+ self.db.row_factory = sqlite3.Row
123
+ self.db.execute("PRAGMA foreign_keys=ON")
124
+ self.db.executescript("""
125
+ CREATE TABLE IF NOT EXISTS coding_changes(
126
+ revision INTEGER PRIMARY KEY AUTOINCREMENT,
127
+ change_id TEXT NOT NULL UNIQUE,
128
+ project_id TEXT NOT NULL,
129
+ session_id TEXT NOT NULL,
130
+ workspace TEXT NOT NULL,
131
+ happened_at TEXT NOT NULL,
132
+ known_at TEXT NOT NULL,
133
+ change_kind TEXT NOT NULL,
134
+ file_path TEXT NOT NULL,
135
+ language TEXT NOT NULL,
136
+ before_sha256 TEXT,
137
+ after_sha256 TEXT,
138
+ reason TEXT NOT NULL,
139
+ related_tests_json TEXT NOT NULL,
140
+ diff_ref TEXT NOT NULL,
141
+ before_ref TEXT,
142
+ after_ref TEXT,
143
+ provenance_json TEXT NOT NULL,
144
+ fingerprint TEXT NOT NULL);
145
+ CREATE TABLE IF NOT EXISTS coding_change_symbols(
146
+ change_id TEXT NOT NULL,
147
+ symbol_name TEXT NOT NULL,
148
+ symbol_kind TEXT NOT NULL,
149
+ side TEXT NOT NULL,
150
+ start_line INTEGER NOT NULL,
151
+ end_line INTEGER NOT NULL,
152
+ PRIMARY KEY(change_id,symbol_name,side),
153
+ FOREIGN KEY(change_id) REFERENCES coding_changes(change_id));
154
+ CREATE INDEX IF NOT EXISTS coding_project_time
155
+ ON coding_changes(project_id,happened_at DESC);
156
+ CREATE INDEX IF NOT EXISTS coding_file_time
157
+ ON coding_changes(file_path,happened_at DESC);
158
+ CREATE INDEX IF NOT EXISTS coding_symbol_name
159
+ ON coding_change_symbols(symbol_name,change_id);
160
+ """)
161
+ self.closed = False
162
+
163
+ def __enter__(self):
164
+ return self
165
+
166
+ def __exit__(self, *args):
167
+ self.close()
168
+
169
+ def close(self):
170
+ if not self.closed:
171
+ self.closed = True
172
+ self.db.close()
173
+
174
+ def _time(self, value):
175
+ return timestamp(value, self.clock).isoformat(timespec="microseconds")
176
+
177
+ def _query_time(self, value, *, end=False):
178
+ if isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
179
+ value += "T23:59:59.999999" if end else "T00:00:00"
180
+ if isinstance(value, str) and self.clock == "SOURCE_LOCAL":
181
+ try:
182
+ parsed = datetime.fromisoformat(value)
183
+ except ValueError:
184
+ pass
185
+ else:
186
+ if parsed.utcoffset() is not None:
187
+ value = parsed.replace(tzinfo=None).isoformat(timespec="microseconds")
188
+ return self._time(value)
189
+
190
+ def record(self, change):
191
+ required = ("change_id", "project_id", "session_id", "workspace",
192
+ "change_kind", "file_path", "language", "reason", "diff_ref")
193
+ normalized = {key: _text(change.get(key), key.upper() + "_REQUIRED")
194
+ for key in required}
195
+ normalized["happened_at"] = self._time(change.get("happened_at"))
196
+ normalized["known_at"] = self._time(change.get("known_at"))
197
+ if normalized["happened_at"] > normalized["known_at"]:
198
+ raise ValueError("CODE_CHANGE_AFTER_RECORDING")
199
+ normalized["before_sha256"] = change.get("before_sha256")
200
+ normalized["after_sha256"] = change.get("after_sha256")
201
+ normalized["before_ref"] = change.get("before_ref")
202
+ normalized["after_ref"] = change.get("after_ref")
203
+ normalized["related_tests"] = _string_list(
204
+ change.get("related_tests"), "RELATED_TESTS_LIST_REQUIRED")
205
+ symbols = change.get("symbols", [])
206
+ if not isinstance(symbols, list):
207
+ raise ValueError("CODE_SYMBOLS_LIST_REQUIRED")
208
+ provenance = change.get("provenance")
209
+ if not isinstance(provenance, dict):
210
+ raise ValueError("CODE_CHANGE_PROVENANCE_REQUIRED")
211
+ normalized["provenance"] = {
212
+ "source_type": _text(provenance.get("source_type"), "SOURCE_TYPE_REQUIRED"),
213
+ "source_ref": _text(provenance.get("source_ref"), "SOURCE_REF_REQUIRED"),
214
+ }
215
+ fingerprint = _digest(_json({**normalized, "symbols": symbols}).encode("utf-8"))
216
+ existing = self.db.execute(
217
+ "SELECT revision,fingerprint FROM coding_changes WHERE change_id=?",
218
+ (normalized["change_id"],)).fetchone()
219
+ if existing:
220
+ if existing["fingerprint"] != fingerprint:
221
+ raise ValueError("CODE_CHANGE_ID_COLLISION")
222
+ return {"change_id": normalized["change_id"], "revision": existing["revision"],
223
+ "disposition": "ALREADY_RECORDED"}
224
+ with self.db:
225
+ cursor = self.db.execute("""INSERT INTO coding_changes(
226
+ change_id,project_id,session_id,workspace,happened_at,known_at,
227
+ change_kind,file_path,language,before_sha256,after_sha256,reason,
228
+ related_tests_json,diff_ref,before_ref,after_ref,provenance_json,fingerprint)
229
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
230
+ normalized["change_id"], normalized["project_id"],
231
+ normalized["session_id"], normalized["workspace"],
232
+ normalized["happened_at"], normalized["known_at"],
233
+ normalized["change_kind"], normalized["file_path"],
234
+ normalized["language"], normalized["before_sha256"],
235
+ normalized["after_sha256"], normalized["reason"],
236
+ _json(normalized["related_tests"]), normalized["diff_ref"],
237
+ normalized["before_ref"], normalized["after_ref"],
238
+ _json(normalized["provenance"]), fingerprint))
239
+ for symbol in symbols:
240
+ self.db.execute("""INSERT INTO coding_change_symbols VALUES(?,?,?,?,?,?)""", (
241
+ normalized["change_id"], _text(symbol.get("name"), "SYMBOL_NAME_REQUIRED"),
242
+ _text(symbol.get("kind"), "SYMBOL_KIND_REQUIRED"),
243
+ _text(symbol.get("side"), "SYMBOL_SIDE_REQUIRED"),
244
+ int(symbol.get("start_line")), int(symbol.get("end_line"))))
245
+ return {"change_id": normalized["change_id"], "revision": cursor.lastrowid,
246
+ "disposition": "RECORDED"}
247
+
248
+ def query(self, *, project_id=None, happened_from=None, happened_to=None,
249
+ file_path=None, symbol=None, change_kind=None, limit=20,
250
+ include_diff_excerpt=True, max_diff_chars=6000):
251
+ if type(limit) is not int or not 1 <= limit <= 100:
252
+ raise ValueError("INVALID_CODE_QUERY_LIMIT")
253
+ if type(max_diff_chars) is not int or not 200 <= max_diff_chars <= 20000:
254
+ raise ValueError("INVALID_DIFF_EXCERPT_LIMIT")
255
+ where, parameters = [], []
256
+ if project_id:
257
+ where.append("c.project_id=?")
258
+ parameters.append(_text(project_id, "PROJECT_ID_REQUIRED"))
259
+ if happened_from:
260
+ where.append("c.happened_at>=?")
261
+ parameters.append(self._query_time(happened_from))
262
+ if happened_to:
263
+ where.append("c.happened_at<=?")
264
+ parameters.append(self._query_time(happened_to, end=True))
265
+ if file_path:
266
+ selected_path = _text(file_path, "FILE_PATH_REQUIRED").replace("\\", "/")
267
+ where.append("(c.file_path=? OR c.file_path LIKE ?)")
268
+ parameters.extend([selected_path, "%/" + selected_path])
269
+ if change_kind:
270
+ where.append("c.change_kind=?")
271
+ parameters.append(_text(change_kind, "CHANGE_KIND_REQUIRED").upper())
272
+ if symbol:
273
+ where.append("EXISTS(SELECT 1 FROM coding_change_symbols s "
274
+ "WHERE s.change_id=c.change_id AND s.symbol_name LIKE ?)")
275
+ parameters.append("%" + _text(symbol, "SYMBOL_REQUIRED") + "%")
276
+ clause = " WHERE " + " AND ".join(where) if where else ""
277
+ parameters.append(limit)
278
+ rows = self.db.execute("SELECT c.* FROM coding_changes c" + clause
279
+ + " ORDER BY c.happened_at DESC,c.revision DESC LIMIT ?",
280
+ parameters).fetchall()
281
+ changes = []
282
+ for row in rows:
283
+ symbols = [dict(item) for item in self.db.execute("""SELECT symbol_name,symbol_kind,
284
+ side,start_line,end_line FROM coding_change_symbols WHERE change_id=?
285
+ ORDER BY symbol_name,side""", (row["change_id"],)).fetchall()]
286
+ diff_excerpt, diff_truncated = None, False
287
+ if include_diff_excerpt:
288
+ try:
289
+ full_diff = Path(row["diff_ref"]).read_text(encoding="utf-8")
290
+ diff_excerpt = full_diff[:max_diff_chars]
291
+ diff_truncated = len(full_diff) > max_diff_chars
292
+ except (OSError, UnicodeError):
293
+ diff_excerpt = None
294
+ changes.append({"change_id": row["change_id"], "revision": row["revision"],
295
+ "project_id": row["project_id"], "session_id": row["session_id"],
296
+ "happened_at": row["happened_at"], "change_kind": row["change_kind"],
297
+ "file_path": row["file_path"], "language": row["language"],
298
+ "reason": row["reason"],
299
+ "related_tests": json.loads(row["related_tests_json"]),
300
+ "symbols": symbols, "diff_ref": row["diff_ref"],
301
+ "diff_excerpt": diff_excerpt, "diff_truncated": diff_truncated,
302
+ "before_ref": row["before_ref"], "after_ref": row["after_ref"],
303
+ "provenance": json.loads(row["provenance_json"])})
304
+ return {"status": "KNOWN" if changes else "UNKNOWN",
305
+ "reason": "CODE_CHANGES_FOUND" if changes else "NO_MATCHING_CODE_CHANGE",
306
+ "scope_completeness": "UNATTESTED", "changes": changes}
307
+
308
+
309
+ class CodingMemoryCapture:
310
+ """Create structured code-change events from content-addressed local snapshots."""
311
+
312
+ def __init__(self, workspace, store, *, project_id, session_id, state_dir,
313
+ suffixes=DEFAULT_SUFFIXES, excluded_parts=DEFAULT_EXCLUDED_PARTS,
314
+ now=None):
315
+ self.workspace = Path(workspace).resolve()
316
+ if not self.workspace.is_dir():
317
+ raise ValueError("CODING_WORKSPACE_NOT_FOUND")
318
+ if not isinstance(store, CodingMemoryStore):
319
+ raise ValueError("CODING_MEMORY_STORE_REQUIRED")
320
+ self.store = store
321
+ self.project_id = _text(project_id, "PROJECT_ID_REQUIRED")
322
+ self.session_id = _text(session_id, "SESSION_ID_REQUIRED")
323
+ self.state_dir = Path(state_dir).resolve()
324
+ self.state_dir.mkdir(parents=True, exist_ok=True)
325
+ self.blob_dir = self.state_dir / "blobs"
326
+ self.diff_dir = self.state_dir / "diffs"
327
+ self.manifest_path = self.state_dir / "coding-manifest.json"
328
+ self.suffixes = frozenset(item.lower() for item in suffixes)
329
+ self.excluded_parts = frozenset(excluded_parts)
330
+ self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
331
+
332
+ def _included(self, path):
333
+ try:
334
+ relative = path.resolve().relative_to(self.workspace)
335
+ except (ValueError, OSError):
336
+ return False
337
+ return (path.is_file() and not path.is_symlink()
338
+ and path.suffix.lower() in self.suffixes
339
+ and not any(part in self.excluded_parts for part in relative.parts)
340
+ and self.state_dir not in path.resolve().parents)
341
+
342
+ def _blob(self, content):
343
+ digest = _digest(content)
344
+ target = self.blob_dir / digest[:2] / (digest + ".blob")
345
+ target.parent.mkdir(parents=True, exist_ok=True)
346
+ if not target.exists():
347
+ target.write_bytes(content)
348
+ return digest, str(target)
349
+
350
+ def snapshot(self):
351
+ files = {}
352
+ for path in self.workspace.rglob("*"):
353
+ if not self._included(path):
354
+ continue
355
+ content = path.read_bytes()
356
+ digest, blob_ref = self._blob(content)
357
+ relative = path.resolve().relative_to(self.workspace).as_posix()
358
+ files[relative] = {"sha256": digest, "size": len(content),
359
+ "blob_ref": blob_ref}
360
+ return files
361
+
362
+ def _manifest(self, files):
363
+ return {"capture_version": CAPTURE_VERSION, "project_id": self.project_id,
364
+ "workspace": str(self.workspace), "suffixes": sorted(self.suffixes),
365
+ "excluded_parts": sorted(self.excluded_parts), "files": files}
366
+
367
+ def _write_manifest(self, files):
368
+ temporary = self.manifest_path.with_suffix(".tmp")
369
+ temporary.write_text(json.dumps(self._manifest(files), ensure_ascii=False, indent=2)
370
+ + "\n", encoding="utf-8")
371
+ os.replace(temporary, self.manifest_path)
372
+
373
+ def initialize_baseline(self):
374
+ files = self.snapshot()
375
+ self._write_manifest(files)
376
+ return {"status": "BASELINE_CREATED", "file_count": len(files),
377
+ "manifest": str(self.manifest_path)}
378
+
379
+ def _load_manifest(self):
380
+ if not self.manifest_path.is_file():
381
+ raise ValueError("CODING_BASELINE_REQUIRED")
382
+ value = json.loads(self.manifest_path.read_text(encoding="utf-8"))
383
+ if (value.get("capture_version") != CAPTURE_VERSION
384
+ or value.get("project_id") != self.project_id
385
+ or Path(value.get("workspace", "")).resolve() != self.workspace
386
+ or value.get("suffixes") != sorted(self.suffixes)
387
+ or value.get("excluded_parts") != sorted(self.excluded_parts)
388
+ or not isinstance(value.get("files"), dict)):
389
+ raise ValueError("CODING_MANIFEST_SCOPE_OR_VERSION_MISMATCH")
390
+ return value
391
+
392
+ def scan(self, *, reason, related_tests=None):
393
+ reason = _text(reason, "CODE_CHANGE_REASON_REQUIRED")
394
+ related_tests = _string_list(related_tests, "RELATED_TESTS_LIST_REQUIRED")
395
+ previous = self._load_manifest()["files"]
396
+ current = self.snapshot()
397
+ now = self.now()
398
+ recorded, changes = [], []
399
+ self.diff_dir.mkdir(parents=True, exist_ok=True)
400
+ for path in sorted(set(previous) | set(current)):
401
+ before, after = previous.get(path), current.get(path)
402
+ if before and after and before["sha256"] == after["sha256"]:
403
+ continue
404
+ kind = "ADDED" if before is None else "DELETED" if after is None else "MODIFIED"
405
+ before_bytes = Path(before["blob_ref"]).read_bytes() if before else b""
406
+ after_bytes = Path(after["blob_ref"]).read_bytes() if after else b""
407
+ before_text, after_text = _decode_source(before_bytes), _decode_source(after_bytes)
408
+ symbols = _affected_symbols(path, before_text, after_text)
409
+ binding = {"project_id": self.project_id, "session_id": self.session_id,
410
+ "file_path": path, "before": before and before["sha256"],
411
+ "after": after and after["sha256"], "reason": reason}
412
+ change_id = "code-change-" + _digest(_json(binding).encode("utf-8"))
413
+ diff = "".join(difflib.unified_diff(
414
+ before_text.splitlines(True), after_text.splitlines(True),
415
+ fromfile="a/" + path, tofile="b/" + path, n=3))
416
+ diff_path = self.diff_dir / (change_id + ".patch")
417
+ diff_path.write_text(diff, encoding="utf-8")
418
+ change = {"change_id": change_id, "project_id": self.project_id,
419
+ "session_id": self.session_id, "workspace": str(self.workspace),
420
+ "happened_at": now, "known_at": now, "change_kind": kind,
421
+ "file_path": path, "language": Path(path).suffix.lower().lstrip(".") or "text",
422
+ "before_sha256": before and before["sha256"],
423
+ "after_sha256": after and after["sha256"], "reason": reason,
424
+ "related_tests": related_tests, "symbols": symbols,
425
+ "diff_ref": str(diff_path), "before_ref": before and before["blob_ref"],
426
+ "after_ref": after and after["blob_ref"],
427
+ "provenance": {"source_type": "workspace-snapshot-diff",
428
+ "source_ref": path}}
429
+ result = self.store.record(change)
430
+ recorded.append(result)
431
+ changes.append({"change_id": change_id, "change_kind": kind,
432
+ "file_path": path,
433
+ "symbols": list(dict.fromkeys(item["name"] for item in symbols)),
434
+ "diff_ref": str(diff_path)})
435
+ self._write_manifest(current)
436
+ return {"status": "CHANGES_RECORDED" if changes else "NO_CHANGES",
437
+ "change_count": len(changes), "changes": changes,
438
+ "record_results": recorded}