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
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()
|