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.
development_memory.py ADDED
@@ -0,0 +1,450 @@
1
+ """Persistent, append-only development memory for Graph-MIND dogfooding.
2
+
3
+ The event log is the source of truth. ``resume_project`` derives a bounded
4
+ session-continuity view from events visible at an effective/knowledge cutoff.
5
+ It deliberately reports un-attested history instead of inferring absence.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+ from pathlib import Path, PurePosixPath
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import sqlite3
15
+ import uuid
16
+
17
+ from temporal_state import timestamp
18
+
19
+
20
+ CONTRACT_VERSION = "development-memory-v0.1"
21
+ EVENT_TYPES = frozenset({
22
+ "OBJECTIVE_SET",
23
+ "TASK_STARTED",
24
+ "TASK_COMPLETED",
25
+ "TASK_BLOCKED",
26
+ "TASK_CANCELLED",
27
+ "FILE_CHANGED",
28
+ "DECISION_RECORDED",
29
+ "TEST_RECORDED",
30
+ "NEXT_ACTION_SET",
31
+ "NEXT_ACTION_COMPLETED",
32
+ "SESSION_SUMMARY",
33
+ "EXPERIMENT_STARTED",
34
+ "EXPERIMENT_STOPPED",
35
+ })
36
+ ACTORS = frozenset({"user", "assistant", "system", "tool"})
37
+ TEST_STATUSES = frozenset({"PASSED", "FAILED", "SKIPPED", "STOPPED"})
38
+ EXPERIMENT_STATUSES = frozenset({"PASSED", "FAILED", "STOPPED"})
39
+ EVENT_KEYS = frozenset({
40
+ "event_id", "project_id", "session_id", "event_type", "effective_at",
41
+ "known_at", "actor", "subject_id", "payload", "provenance",
42
+ "supersedes_event_id",
43
+ })
44
+ REQUIRED_PAYLOAD_FIELDS = {
45
+ "OBJECTIVE_SET": ("objective",),
46
+ "TASK_STARTED": ("title",),
47
+ "TASK_COMPLETED": ("outcome",),
48
+ "TASK_BLOCKED": ("reason",),
49
+ "TASK_CANCELLED": ("reason",),
50
+ "FILE_CHANGED": ("path", "summary", "reason"),
51
+ "DECISION_RECORDED": ("decision", "reason"),
52
+ "TEST_RECORDED": ("name", "status", "summary"),
53
+ "NEXT_ACTION_SET": ("action",),
54
+ "NEXT_ACTION_COMPLETED": ("outcome",),
55
+ "SESSION_SUMMARY": ("summary",),
56
+ "EXPERIMENT_STARTED": ("title",),
57
+ "EXPERIMENT_STOPPED": ("status", "reason"),
58
+ }
59
+
60
+
61
+ def _canonical_json(value):
62
+ return json.dumps(value, ensure_ascii=False, sort_keys=True,
63
+ separators=(",", ":"), allow_nan=False)
64
+
65
+
66
+ def _fingerprint(value):
67
+ return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
68
+
69
+
70
+ def _text(value, error):
71
+ if not isinstance(value, str) or not value.strip():
72
+ raise ValueError(error)
73
+ return value.strip()
74
+
75
+
76
+ def _portable_path(value):
77
+ raw = _text(value, "FILE_PATH_REQUIRED").replace("\\", "/")
78
+ if re.match(r"^[A-Za-z]:", raw) or raw.startswith("/"):
79
+ raise ValueError("PROJECT_RELATIVE_FILE_PATH_REQUIRED")
80
+ path = PurePosixPath(raw)
81
+ if ".." in path.parts or not path.parts:
82
+ raise ValueError("PROJECT_RELATIVE_FILE_PATH_REQUIRED")
83
+ return str(path)
84
+
85
+
86
+ class DevelopmentMemoryStore:
87
+ """SQLite event store and deterministic project-resume projection."""
88
+
89
+ def __init__(self, path=":memory:", *, clock="SOURCE_LOCAL"):
90
+ if clock not in {"UTC", "SOURCE_LOCAL"}:
91
+ raise ValueError("INVALID_STORE_CLOCK")
92
+ self.path = path if path == ":memory:" else Path(path)
93
+ if self.path != ":memory:":
94
+ self.path.parent.mkdir(parents=True, exist_ok=True)
95
+ self.db = sqlite3.connect(str(self.path))
96
+ self.closed = False
97
+ self.db.row_factory = sqlite3.Row
98
+ self.db.execute("PRAGMA foreign_keys=ON")
99
+ self.db.executescript("""
100
+ CREATE TABLE IF NOT EXISTS development_memory_meta(
101
+ key TEXT PRIMARY KEY, value TEXT NOT NULL);
102
+ CREATE TABLE IF NOT EXISTS development_events(
103
+ revision INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ event_id TEXT NOT NULL UNIQUE,
105
+ project_id TEXT NOT NULL,
106
+ session_id TEXT NOT NULL,
107
+ event_type TEXT NOT NULL,
108
+ effective_at TEXT NOT NULL,
109
+ known_at TEXT NOT NULL,
110
+ actor TEXT NOT NULL,
111
+ subject_id TEXT NOT NULL,
112
+ payload_json TEXT NOT NULL,
113
+ provenance_json TEXT NOT NULL,
114
+ supersedes_event_id TEXT,
115
+ event_fingerprint TEXT NOT NULL,
116
+ FOREIGN KEY(supersedes_event_id) REFERENCES development_events(event_id));
117
+ CREATE INDEX IF NOT EXISTS development_project_clock
118
+ ON development_events(project_id,known_at,effective_at,revision);
119
+ CREATE INDEX IF NOT EXISTS development_project_type
120
+ ON development_events(project_id,event_type,subject_id,revision);
121
+ CREATE INDEX IF NOT EXISTS development_session
122
+ ON development_events(project_id,session_id,revision);
123
+ """)
124
+ with self.db:
125
+ self.db.execute("INSERT OR IGNORE INTO development_memory_meta VALUES('store_id',?)",
126
+ (uuid.uuid4().hex,))
127
+ self.db.execute("INSERT OR IGNORE INTO development_memory_meta VALUES('clock',?)",
128
+ (clock,))
129
+ self.db.execute("INSERT OR IGNORE INTO development_memory_meta VALUES('contract_version',?)",
130
+ (CONTRACT_VERSION,))
131
+ self.store_id = self.db.execute(
132
+ "SELECT value FROM development_memory_meta WHERE key='store_id'").fetchone()[0]
133
+ stored_clock = self.db.execute(
134
+ "SELECT value FROM development_memory_meta WHERE key='clock'").fetchone()[0]
135
+ stored_contract = self.db.execute(
136
+ "SELECT value FROM development_memory_meta WHERE key='contract_version'").fetchone()[0]
137
+ if stored_clock != clock or stored_contract != CONTRACT_VERSION:
138
+ self.close()
139
+ raise ValueError("STORE_CLOCK_OR_CONTRACT_CHANGED")
140
+ self.clock = clock
141
+
142
+ def __enter__(self):
143
+ return self
144
+
145
+ def __exit__(self, *args):
146
+ self.close()
147
+
148
+ def close(self):
149
+ if not self.closed:
150
+ self.closed = True
151
+ self.db.close()
152
+
153
+ def _time(self, value):
154
+ return timestamp(value, self.clock).isoformat(timespec="microseconds")
155
+
156
+ def _validate_event(self, event):
157
+ if not isinstance(event, dict):
158
+ raise ValueError("DEVELOPMENT_EVENT_OBJECT_REQUIRED")
159
+ extra = set(event) - EVENT_KEYS
160
+ if extra:
161
+ raise ValueError("UNSUPPORTED_DEVELOPMENT_EVENT_FIELDS")
162
+ normalized = dict(event)
163
+ for key in ("event_id", "project_id", "session_id", "event_type", "actor", "subject_id"):
164
+ normalized[key] = _text(event.get(key), key.upper() + "_REQUIRED")
165
+ if normalized["event_type"] not in EVENT_TYPES:
166
+ raise ValueError("UNSUPPORTED_DEVELOPMENT_EVENT_TYPE")
167
+ if normalized["actor"] not in ACTORS:
168
+ raise ValueError("UNSUPPORTED_DEVELOPMENT_EVENT_ACTOR")
169
+ effective = self._time(event.get("effective_at"))
170
+ known = self._time(event.get("known_at"))
171
+ if effective > known:
172
+ raise ValueError("DEVELOPMENT_EVENT_AFTER_RECORDING")
173
+ normalized["effective_at"], normalized["known_at"] = effective, known
174
+
175
+ payload = event.get("payload")
176
+ if not isinstance(payload, dict):
177
+ raise ValueError("DEVELOPMENT_EVENT_PAYLOAD_REQUIRED")
178
+ payload = dict(payload)
179
+ for field in REQUIRED_PAYLOAD_FIELDS[normalized["event_type"]]:
180
+ payload[field] = _text(payload.get(field), field.upper() + "_REQUIRED")
181
+ if normalized["event_type"] == "FILE_CHANGED":
182
+ payload["path"] = _portable_path(payload["path"])
183
+ if normalized["event_type"] == "TEST_RECORDED" and payload["status"] not in TEST_STATUSES:
184
+ raise ValueError("INVALID_TEST_STATUS")
185
+ if (normalized["event_type"] == "EXPERIMENT_STOPPED"
186
+ and payload["status"] not in EXPERIMENT_STATUSES):
187
+ raise ValueError("INVALID_EXPERIMENT_STATUS")
188
+ normalized["payload"] = payload
189
+
190
+ provenance = event.get("provenance")
191
+ if not isinstance(provenance, dict):
192
+ raise ValueError("DEVELOPMENT_EVENT_PROVENANCE_REQUIRED")
193
+ provenance = dict(provenance)
194
+ for key in ("source_type", "source_ref"):
195
+ provenance[key] = _text(provenance.get(key), key.upper() + "_REQUIRED")
196
+ digest = provenance.get("content_sha256")
197
+ if digest is not None and (not isinstance(digest, str)
198
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None):
199
+ raise ValueError("INVALID_PROVENANCE_SHA256")
200
+ normalized["provenance"] = provenance
201
+
202
+ supersedes = event.get("supersedes_event_id")
203
+ if supersedes is not None:
204
+ supersedes = _text(supersedes, "INVALID_SUPERSEDES_EVENT_ID")
205
+ if supersedes == normalized["event_id"]:
206
+ raise ValueError("EVENT_CANNOT_SUPERSEDE_ITSELF")
207
+ normalized["supersedes_event_id"] = supersedes
208
+ return normalized
209
+
210
+ def _insert(self, event):
211
+ normalized = self._validate_event(event)
212
+ fingerprint = _fingerprint(normalized)
213
+ existing = self.db.execute(
214
+ "SELECT revision,event_fingerprint FROM development_events WHERE event_id=?",
215
+ (normalized["event_id"],)).fetchone()
216
+ if existing:
217
+ if existing["event_fingerprint"] != fingerprint:
218
+ raise ValueError("DEVELOPMENT_EVENT_ID_COLLISION")
219
+ return {"event_id": normalized["event_id"], "revision": existing["revision"],
220
+ "disposition": "ALREADY_RECORDED"}
221
+
222
+ head = self.db.execute(
223
+ "SELECT known_at FROM development_events ORDER BY revision DESC LIMIT 1").fetchone()
224
+ if head and normalized["known_at"] < head["known_at"]:
225
+ raise ValueError("BACKDATED_DEVELOPMENT_KNOWLEDGE_MUTATION")
226
+
227
+ required_predecessor = {
228
+ "TASK_COMPLETED": "TASK_STARTED",
229
+ "TASK_BLOCKED": "TASK_STARTED",
230
+ "TASK_CANCELLED": "TASK_STARTED",
231
+ "NEXT_ACTION_COMPLETED": "NEXT_ACTION_SET",
232
+ "EXPERIMENT_STOPPED": "EXPERIMENT_STARTED",
233
+ }.get(normalized["event_type"])
234
+ if required_predecessor:
235
+ predecessor = self.db.execute("""SELECT 1 FROM development_events
236
+ WHERE project_id=? AND subject_id=? AND event_type=? LIMIT 1""",
237
+ (normalized["project_id"], normalized["subject_id"], required_predecessor)).fetchone()
238
+ if not predecessor:
239
+ raise ValueError(required_predecessor + "_NOT_FOUND")
240
+ if normalized["supersedes_event_id"]:
241
+ target = self.db.execute(
242
+ "SELECT project_id,event_type,subject_id FROM development_events WHERE event_id=?",
243
+ (normalized["supersedes_event_id"],)).fetchone()
244
+ if not target:
245
+ raise ValueError("SUPERSEDED_EVENT_NOT_FOUND")
246
+ if target["project_id"] != normalized["project_id"]:
247
+ raise ValueError("CROSS_PROJECT_SUPERSESSION_FORBIDDEN")
248
+ if (target["event_type"] != normalized["event_type"]
249
+ or target["subject_id"] != normalized["subject_id"]):
250
+ raise ValueError("SUPERSESSION_TYPE_OR_SUBJECT_MISMATCH")
251
+ cursor = self.db.execute("""INSERT INTO development_events(
252
+ event_id,project_id,session_id,event_type,effective_at,known_at,actor,
253
+ subject_id,payload_json,provenance_json,supersedes_event_id,event_fingerprint)
254
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", (
255
+ normalized["event_id"], normalized["project_id"], normalized["session_id"],
256
+ normalized["event_type"], normalized["effective_at"], normalized["known_at"],
257
+ normalized["actor"], normalized["subject_id"],
258
+ _canonical_json(normalized["payload"]), _canonical_json(normalized["provenance"]),
259
+ normalized["supersedes_event_id"], fingerprint))
260
+ return {"event_id": normalized["event_id"], "revision": cursor.lastrowid,
261
+ "disposition": "RECORDED"}
262
+
263
+ def record_event(self, event):
264
+ with self.db:
265
+ return self._insert(event)
266
+
267
+ def record_events(self, events):
268
+ if not isinstance(events, list) or not events:
269
+ raise ValueError("NONEMPTY_DEVELOPMENT_EVENT_LIST_REQUIRED")
270
+ try:
271
+ self.db.execute("BEGIN IMMEDIATE")
272
+ results = [self._insert(event) for event in events]
273
+ self.db.commit()
274
+ return results
275
+ except BaseException:
276
+ self.db.rollback()
277
+ raise
278
+
279
+ @staticmethod
280
+ def _event(row):
281
+ return {"revision": row["revision"], "event_id": row["event_id"],
282
+ "project_id": row["project_id"], "session_id": row["session_id"],
283
+ "event_type": row["event_type"], "effective_at": row["effective_at"],
284
+ "known_at": row["known_at"], "actor": row["actor"],
285
+ "subject_id": row["subject_id"], "payload": json.loads(row["payload_json"]),
286
+ "provenance": json.loads(row["provenance_json"]),
287
+ "supersedes_event_id": row["supersedes_event_id"]}
288
+
289
+ def events(self, project_id, *, as_of, knowledge_cutoff=None):
290
+ project_id = _text(project_id, "PROJECT_ID_REQUIRED")
291
+ effective = self._time(as_of)
292
+ cutoff = self._time(knowledge_cutoff or as_of)
293
+ rows = self.db.execute("""SELECT * FROM development_events
294
+ WHERE project_id=? AND effective_at<=? AND known_at<=?
295
+ ORDER BY effective_at,known_at,revision""", (project_id, effective, cutoff)).fetchall()
296
+ events = [self._event(row) for row in rows]
297
+ superseded = {e["supersedes_event_id"] for e in events if e["supersedes_event_id"]}
298
+ return [e for e in events if e["event_id"] not in superseded]
299
+
300
+ def resume_project(self, project_id, *, as_of, knowledge_cutoff=None,
301
+ recent_file_limit=10, recent_test_limit=10):
302
+ if type(recent_file_limit) is not int or recent_file_limit < 1:
303
+ raise ValueError("INVALID_RECENT_FILE_LIMIT")
304
+ if type(recent_test_limit) is not int or recent_test_limit < 1:
305
+ raise ValueError("INVALID_RECENT_TEST_LIMIT")
306
+ selected = self.events(project_id, as_of=as_of,
307
+ knowledge_cutoff=knowledge_cutoff)
308
+ cutoff = self._time(knowledge_cutoff or as_of)
309
+ effective = self._time(as_of)
310
+ if not selected:
311
+ return {"status": "UNKNOWN", "reason": "PROJECT_MEMORY_NOT_FOUND",
312
+ "project_id": project_id, "as_of": effective,
313
+ "knowledge_cutoff": cutoff,
314
+ "scope_completeness": "UNATTESTED", "evidence": []}
315
+
316
+ objective = None
317
+ tasks, files, decisions, next_actions, experiments = {}, {}, {}, {}, {}
318
+ tests, sessions = [], []
319
+ event_by_id = {e["event_id"]: e for e in selected}
320
+
321
+ for event in selected:
322
+ kind, subject, payload = event["event_type"], event["subject_id"], event["payload"]
323
+ if kind == "OBJECTIVE_SET":
324
+ objective = {"value": payload["objective"],
325
+ "evidence_event_ids": [event["event_id"]]}
326
+ elif kind == "TASK_STARTED":
327
+ old = tasks.get(subject, {})
328
+ tasks[subject] = {"task_id": subject, "title": payload["title"],
329
+ "status": "OPEN", "detail": None,
330
+ "last_revision": event["revision"],
331
+ "evidence_event_ids": old.get("evidence_event_ids", []) + [event["event_id"]]}
332
+ elif kind in {"TASK_COMPLETED", "TASK_BLOCKED", "TASK_CANCELLED"}:
333
+ old = tasks.get(subject, {"task_id": subject, "title": subject,
334
+ "evidence_event_ids": []})
335
+ status = {"TASK_COMPLETED": "COMPLETED", "TASK_BLOCKED": "BLOCKED",
336
+ "TASK_CANCELLED": "CANCELLED"}[kind]
337
+ detail = payload.get("outcome") or payload.get("reason")
338
+ tasks[subject] = {**old, "status": status, "detail": detail,
339
+ "last_revision": event["revision"],
340
+ "evidence_event_ids": old["evidence_event_ids"] + [event["event_id"]]}
341
+ elif kind == "FILE_CHANGED":
342
+ files[payload["path"]] = {"path": payload["path"],
343
+ "summary": payload["summary"], "reason": payload["reason"],
344
+ "last_revision": event["revision"],
345
+ "evidence_event_ids": [event["event_id"]]}
346
+ elif kind == "DECISION_RECORDED":
347
+ decisions[subject] = {"decision_id": subject, "decision": payload["decision"],
348
+ "reason": payload["reason"], "last_revision": event["revision"],
349
+ "evidence_event_ids": [event["event_id"]]}
350
+ elif kind == "TEST_RECORDED":
351
+ tests.append({"test_id": subject, "name": payload["name"],
352
+ "status": payload["status"], "summary": payload["summary"],
353
+ "last_revision": event["revision"],
354
+ "evidence_event_ids": [event["event_id"]]})
355
+ elif kind == "NEXT_ACTION_SET":
356
+ old = next_actions.get(subject, {})
357
+ next_actions[subject] = {"action_id": subject, "action": payload["action"],
358
+ "status": "OPEN", "last_revision": event["revision"],
359
+ "evidence_event_ids": old.get("evidence_event_ids", []) + [event["event_id"]]}
360
+ elif kind == "NEXT_ACTION_COMPLETED":
361
+ old = next_actions.get(subject, {"action_id": subject, "action": subject,
362
+ "evidence_event_ids": []})
363
+ next_actions[subject] = {**old, "status": "COMPLETED",
364
+ "outcome": payload["outcome"], "last_revision": event["revision"],
365
+ "evidence_event_ids": old["evidence_event_ids"] + [event["event_id"]]}
366
+ elif kind == "EXPERIMENT_STARTED":
367
+ old = experiments.get(subject, {})
368
+ experiments[subject] = {"experiment_id": subject, "title": payload["title"],
369
+ "status": "RUNNING", "last_revision": event["revision"],
370
+ "evidence_event_ids": old.get("evidence_event_ids", []) + [event["event_id"]]}
371
+ elif kind == "EXPERIMENT_STOPPED":
372
+ old = experiments.get(subject, {"experiment_id": subject, "title": subject,
373
+ "evidence_event_ids": []})
374
+ experiments[subject] = {**old, "status": payload["status"],
375
+ "reason": payload["reason"], "last_revision": event["revision"],
376
+ "evidence_event_ids": old["evidence_event_ids"] + [event["event_id"]]}
377
+ elif kind == "SESSION_SUMMARY":
378
+ sessions.append({"session_id": subject, "summary": payload["summary"],
379
+ "last_revision": event["revision"],
380
+ "evidence_event_ids": [event["event_id"]]})
381
+
382
+ open_tasks = sorted((v for v in tasks.values() if v["status"] in {"OPEN", "BLOCKED"}),
383
+ key=lambda v: v["last_revision"], reverse=True)
384
+ current_task = open_tasks[0] if open_tasks else None
385
+ open_actions = sorted((v for v in next_actions.values() if v["status"] == "OPEN"),
386
+ key=lambda v: v["last_revision"], reverse=True)
387
+ active_experiments = sorted((v for v in experiments.values() if v["status"] == "RUNNING"),
388
+ key=lambda v: v["last_revision"], reverse=True)
389
+ recent_files = sorted(files.values(), key=lambda v: v["last_revision"], reverse=True)[:recent_file_limit]
390
+ recent_tests = sorted(tests, key=lambda v: v["last_revision"], reverse=True)[:recent_test_limit]
391
+ decision_list = sorted(decisions.values(), key=lambda v: v["last_revision"], reverse=True)
392
+ last_session = max(sessions, key=lambda v: v["last_revision"]) if sessions else None
393
+
394
+ projected = [objective, current_task, *open_tasks, *open_actions, *active_experiments,
395
+ *recent_files, *recent_tests, *decision_list, last_session]
396
+ evidence_ids = []
397
+ for item in projected:
398
+ if item:
399
+ evidence_ids.extend(item.get("evidence_event_ids", []))
400
+ evidence_ids = list(dict.fromkeys(evidence_ids))
401
+ evidence = [event_by_id[event_id] for event_id in evidence_ids if event_id in event_by_id]
402
+ evidence.sort(key=lambda e: e["revision"])
403
+
404
+ return {"status": "KNOWN", "reason": "PROJECT_MEMORY_RECONSTRUCTED",
405
+ "project_id": project_id, "as_of": effective, "knowledge_cutoff": cutoff,
406
+ "scope_completeness": "UNATTESTED",
407
+ "warnings": ["PROJECT_HISTORY_COMPLETENESS_NOT_ATTESTED"],
408
+ "objective": objective, "current_task": current_task,
409
+ "open_tasks": open_tasks, "next_actions": open_actions,
410
+ "active_experiments": active_experiments, "recent_files": recent_files,
411
+ "recent_tests": recent_tests, "decisions": decision_list,
412
+ "last_session": last_session, "evidence_event_ids": evidence_ids,
413
+ "evidence": evidence}
414
+
415
+ def explain_decision(self, project_id, decision_id, *, as_of,
416
+ knowledge_cutoff=None):
417
+ project_id = _text(project_id, "PROJECT_ID_REQUIRED")
418
+ decision_id = _text(decision_id, "DECISION_ID_REQUIRED")
419
+ effective = self._time(as_of)
420
+ cutoff = self._time(knowledge_cutoff or as_of)
421
+ rows = self.db.execute("""SELECT * FROM development_events
422
+ WHERE project_id=? AND subject_id=? AND event_type='DECISION_RECORDED'
423
+ AND effective_at<=? AND known_at<=?
424
+ ORDER BY effective_at,known_at,revision""",
425
+ (project_id, decision_id, effective, cutoff)).fetchall()
426
+ history = [self._event(row) for row in rows]
427
+ if not history:
428
+ return {"status": "UNKNOWN", "reason": "DECISION_NOT_FOUND",
429
+ "project_id": project_id, "decision_id": decision_id,
430
+ "as_of": effective, "knowledge_cutoff": cutoff,
431
+ "scope_completeness": "UNATTESTED", "history": []}
432
+ superseded = {event["supersedes_event_id"] for event in history
433
+ if event["supersedes_event_id"]}
434
+ active = [event for event in history if event["event_id"] not in superseded]
435
+ if len(active) != 1:
436
+ return {"status": "UNKNOWN", "reason": "CONFLICTING_ACTIVE_DECISIONS",
437
+ "project_id": project_id, "decision_id": decision_id,
438
+ "as_of": effective, "knowledge_cutoff": cutoff,
439
+ "scope_completeness": "UNATTESTED",
440
+ "evidence_event_ids": [event["event_id"] for event in active],
441
+ "history": history}
442
+ current = active[0]
443
+ return {"status": "KNOWN", "reason": "DECISION_RECONSTRUCTED",
444
+ "project_id": project_id, "decision_id": decision_id,
445
+ "as_of": effective, "knowledge_cutoff": cutoff,
446
+ "scope_completeness": "UNATTESTED",
447
+ "decision": current["payload"]["decision"],
448
+ "decision_reason": current["payload"]["reason"],
449
+ "evidence_event_ids": [current["event_id"]],
450
+ "provenance": current["provenance"], "history": history}
development_paths.py ADDED
@@ -0,0 +1,40 @@
1
+ """Stable user-owned paths shared by every Graph-MIND client."""
2
+ from pathlib import Path
3
+ import hashlib
4
+ import os
5
+
6
+
7
+ def graph_mind_home():
8
+ configured = os.environ.get("GRAPH_MIND_HOME")
9
+ return Path(configured).expanduser() if configured else Path.home() / ".graph-mind"
10
+
11
+
12
+ def default_development_db():
13
+ return graph_mind_home() / "development.sqlite"
14
+
15
+
16
+ def default_capture_state_dir(project_id):
17
+ suffix = hashlib.sha256(project_id.encode("utf-8")).hexdigest()[:16]
18
+ return graph_mind_home() / "capture" / suffix
19
+
20
+
21
+ def default_coding_state_dir(project_id):
22
+ suffix = hashlib.sha256(project_id.encode("utf-8")).hexdigest()[:16]
23
+ return graph_mind_home() / "coding" / suffix
24
+
25
+
26
+ def use_wal(db):
27
+ """Put a store in WAL mode, waiting for the other clients if need be. Changing the journal
28
+ mode needs the database to itself, and SQLite does not wait for that the way it waits for a
29
+ write: three clients opening a new store at once failed with "database is locked" (macOS CI).
30
+ """
31
+ import sqlite3
32
+ import time
33
+ for attempt in range(100):
34
+ try:
35
+ db.execute("PRAGMA journal_mode=WAL")
36
+ return
37
+ except sqlite3.OperationalError as error:
38
+ if "locked" not in str(error) or attempt == 99:
39
+ raise
40
+ time.sleep(0.1)
embedding_warmup.py ADDED
@@ -0,0 +1,171 @@
1
+ """Embed memories when they are STORED, not when they are asked about.
2
+
3
+ semantic_recall embeds each candidate's text at query time. The vectors are cached, so the cost is
4
+ paid once — but it is paid by whoever asks the first question after a batch of memories arrives, and
5
+ on CPU that is seconds to minutes. Writing is the right moment: it happens once per memory, in the
6
+ background of a conversation the user is already having.
7
+
8
+ The text embedded here is exactly what the recall path will hash (the engram's FTS text, first 500
9
+ characters), so a warmed memory is a cache hit rather than a near miss.
10
+
11
+ python embedding_warmup.py [db_path] # warm a store, or self-check with no arguments
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ import sqlite3
17
+ import sys
18
+
19
+ EXCERPT = 500 # semantic_recall embeds text_excerpt = text[:500]
20
+ BATCH = 128
21
+
22
+
23
+ def pending_texts(db: sqlite3.Connection, embedder, limit: int) -> list[str]:
24
+ """Engram excerpts that have no vector yet, newest first."""
25
+ rows = db.execute(
26
+ "SELECT f.text FROM associative_engrams_fts f "
27
+ "JOIN associative_engrams e ON e.engram_id = f.engram_id "
28
+ "ORDER BY e.revision DESC LIMIT ?", (limit,)).fetchall()
29
+ texts, seen = [], set()
30
+ for (text,) in rows:
31
+ excerpt = (text or "")[:EXCERPT].strip()[:2000]
32
+ if excerpt and excerpt not in seen and embedder._key(excerpt) not in embedder.cache:
33
+ seen.add(excerpt)
34
+ texts.append(excerpt)
35
+ return texts
36
+
37
+
38
+ def warm(db_path, *, limit: int = 500, embedder=None) -> int:
39
+ """Embed everything unembedded in the newest `limit` engrams. Returns how many were added."""
40
+ from semantic_recall import default_embedder
41
+
42
+ embedder = embedder or default_embedder(db_path)
43
+ db = sqlite3.connect(db_path)
44
+ try:
45
+ db.execute("SELECT 1 FROM associative_engrams LIMIT 1")
46
+ except sqlite3.OperationalError:
47
+ db.close()
48
+ return 0 # no associative index yet: nothing to warm
49
+ try:
50
+ texts = pending_texts(db, embedder, limit)
51
+ for start in range(0, len(texts), BATCH):
52
+ embedder.embed(texts[start:start + BATCH])
53
+ if texts and hasattr(embedder, "flush"):
54
+ embedder.flush()
55
+ return len(texts)
56
+ finally:
57
+ db.close()
58
+
59
+
60
+ PIECE = 500 # conversation turns are searched by meaning in 500-character pieces
61
+
62
+
63
+ def turn_pieces(text: str) -> list[tuple[int, str]]:
64
+ """(offset, piece) for one turn, exactly as the recall path cuts and searches it."""
65
+ return [(start, text[start:start + PIECE]) for start in range(0, len(text), PIECE)
66
+ if text[start:start + PIECE].strip()]
67
+
68
+
69
+ def warm_turns(db_path, *, limit: int = 512, embedder=None) -> int:
70
+ """Embed up to `limit` unembedded pieces of conversation turns, the user's first, newest first.
71
+
72
+ The capture service calls this after every pass, so the turns it has just stored are
73
+ searchable by meaning before anyone asks about them. Without it the first question after a
74
+ busy day met thousands of unembedded pieces, and the server, which never makes a question
75
+ wait for that, answered it from word search alone (79.2% against 86.0% on LongMemEval)."""
76
+ from semantic_recall import default_embedder
77
+ embedder = embedder or default_embedder(db_path)
78
+ if hasattr(embedder.cache, "refresh"):
79
+ embedder.cache.refresh() # what the memory servers embedded meanwhile
80
+ db = sqlite3.connect(db_path, timeout=30)
81
+ try:
82
+ # the user's turns first (every search uses them), then the assistant's (searched when
83
+ # the question is about what it said: left to the server, a fresh server spent minutes
84
+ # embedding all of them on the first such question, and every query waited meanwhile)
85
+ rows = db.execute("SELECT content_redacted FROM conversation_turns "
86
+ "WHERE role IN ('user','assistant') "
87
+ "ORDER BY role='user' DESC, happened_at DESC")
88
+ texts, seen = [], set()
89
+ for (content,) in rows:
90
+ for _, piece in turn_pieces(content or ""):
91
+ key = piece.strip()[:2000]
92
+ if key not in seen and embedder._key(key) not in embedder.cache:
93
+ seen.add(key)
94
+ texts.append(piece)
95
+ if len(texts) >= limit:
96
+ break
97
+ except sqlite3.OperationalError as error:
98
+ if "no such table" not in str(error):
99
+ raise
100
+ return 0 # no conversations captured yet
101
+ finally:
102
+ db.close()
103
+ texts = texts[:limit]
104
+ for start in range(0, len(texts), BATCH):
105
+ embedder.embed(texts[start:start + BATCH])
106
+ if texts and hasattr(embedder, "flush"):
107
+ embedder.flush()
108
+ return len(texts)
109
+
110
+
111
+ def _self_check():
112
+ import tempfile
113
+ from associative_memory import AssociativeMemoryIndex
114
+ from local_brain import LocalBrainStore
115
+
116
+ path = Path(tempfile.mkdtemp()) / "brain.sqlite"
117
+ with LocalBrainStore(path) as store:
118
+ for i, title in enumerate(["JWT expiry cut to 1h", "Bought an air fryer"], start=1):
119
+ store.remember({"memory_id": f"m{i}", "scope": "global", "memory_type": "fact",
120
+ "title": title, "content": title + " — details follow",
121
+ "effective_at": "2026-04-02T09:00:00",
122
+ "known_at": "2026-04-02T09:00:00", "actor": "assistant",
123
+ "tags": [], "entities": ["auth module"],
124
+ "provenance": {"source_type": "test", "source_ref": "unit"},
125
+ "supersedes_memory_id": None})
126
+ with AssociativeMemoryIndex(path) as index:
127
+ index.sync_sources()
128
+
129
+ class FakeEmbedder:
130
+ def __init__(self):
131
+ self.cache, self.calls = {}, 0
132
+
133
+ def _key(self, text):
134
+ return "k:" + text[:40]
135
+
136
+ def embed(self, texts):
137
+ self.calls += 1
138
+ for text in texts:
139
+ self.cache.setdefault(self._key(text), [0.1, 0.2])
140
+ return [self.cache[self._key(t)] for t in texts]
141
+
142
+ fake = FakeEmbedder()
143
+ first = warm(path, embedder=fake)
144
+ assert first >= 2, first # both memories embedded
145
+ assert warm(path, embedder=fake) == 0 # nothing left to do
146
+ calls = fake.calls
147
+ assert warm(path, embedder=fake) == 0 and fake.calls == calls, "warm must not re-embed"
148
+
149
+ db = sqlite3.connect(path)
150
+ texts = pending_texts(db, fake, 500)
151
+ db.close()
152
+ assert texts == [], texts
153
+ with sqlite3.connect(path) as db:
154
+ db.execute("CREATE TABLE conversation_turns(content_redacted TEXT, role TEXT, happened_at TEXT)")
155
+ db.executemany("INSERT INTO conversation_turns VALUES(?,?,?)", [
156
+ ("".join(f"{i:04d}" for i in range(300)), "user", "2026-01-01"), ("answer " * 300, "assistant", "2026-01-02"),
157
+ ("newest question", "user", "2026-01-03")])
158
+ turns = FakeEmbedder()
159
+ assert warm_turns(path, embedder=turns, limit=2) == 2 # newest first, capped
160
+ assert turns._key("newest question") in turns.cache
161
+ assert not any("answer" in key for key in turns.cache), "the user's turns come first"
162
+ assert warm_turns(path, embedder=turns) == 7 # 2 user + 5 assistant pieces
163
+ assert warm_turns(path, embedder=turns) == 0
164
+ print(f"embedding_warmup self-check ok ({first} vectors warmed, second pass free)")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ if len(sys.argv) > 1:
169
+ print("warmed", warm(sys.argv[1]), "vectors")
170
+ else:
171
+ _self_check()