graph-mind-memory 0.5.4__tar.gz → 0.5.5__tar.gz

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.
Files changed (66) hide show
  1. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/PKG-INFO +34 -14
  2. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/README.md +33 -13
  3. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/pyproject.toml +1 -1
  4. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_mcp_server.py +163 -59
  5. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/PKG-INFO +34 -14
  6. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/SOURCES.txt +1 -0
  7. graph_mind_memory-0.5.5/tests/test_piece_index.py +57 -0
  8. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/LICENSE +0 -0
  9. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/setup.cfg +0 -0
  10. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/associative_memory.py +0 -0
  11. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/automatic_capture.py +0 -0
  12. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/automatic_capture_cli.py +0 -0
  13. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/brain_log.py +0 -0
  14. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/clock.py +0 -0
  15. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/coding_memory.py +0 -0
  16. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/conversation_memory.py +0 -0
  17. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/development_memory.py +0 -0
  18. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/development_paths.py +0 -0
  19. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/embedding_warmup.py +0 -0
  20. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/entity_timeline.py +0 -0
  21. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/forget.py +0 -0
  22. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/dependency_links.txt +0 -0
  23. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/entry_points.txt +0 -0
  24. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/requires.txt +0 -0
  25. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/top_level.txt +0 -0
  26. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/install.py +0 -0
  27. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/local_brain.py +0 -0
  28. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/local_embedder.py +0 -0
  29. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/semantic_llm_extractor.py +0 -0
  30. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/semantic_memory.py +0 -0
  31. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/semantic_recall.py +0 -0
  32. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/temporal_resolver.py +0 -0
  33. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/universal_personal_memory.py +0 -0
  34. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/vector_cache.py +0 -0
  35. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_associative_memory.py +0 -0
  36. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_automatic_capture.py +0 -0
  37. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_capture_health.py +0 -0
  38. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_coding_memory.py +0 -0
  39. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_cold_start.py +0 -0
  40. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_concurrent_use.py +0 -0
  41. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_conversation_memory.py +0 -0
  42. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_executable_memory.py +0 -0
  43. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_executable_plan_v2_diagnostic.py +0 -0
  44. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_forget.py +0 -0
  45. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_forget_conversation.py +0 -0
  46. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_graph_mind_mcp_server.py +0 -0
  47. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_ingestion_gate.py +0 -0
  48. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_ingestion_synonyms.py +0 -0
  49. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_install.py +0 -0
  50. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_llm_concept_planner.py +0 -0
  51. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_local_brain.py +0 -0
  52. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_adapter.py +0 -0
  53. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_calibration.py +0 -0
  54. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_preflight.py +0 -0
  55. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_v7.py +0 -0
  56. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_memory_algebra.py +0 -0
  57. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_precondition_verifier.py +0 -0
  58. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_recent_recall.py +0 -0
  59. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_resume_retries.py +0 -0
  60. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_secret_masking.py +0 -0
  61. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_semantic_llm_extractor.py +0 -0
  62. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_semantic_memory.py +0 -0
  63. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_semantic_recall.py +0 -0
  64. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_shared_brain.py +0 -0
  65. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_temporal_state.py +0 -0
  66. {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_universal_personal_memory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.5.4
3
+ Version: 0.5.5
4
4
  Summary: Local-first memory for AI coding assistants: verbatim, on your own PC, no LLM calls at write time.
5
5
  Author-email: Yohan Ko <goyohan0611@gmail.com>
6
6
  License-Expression: AGPL-3.0-only
@@ -31,8 +31,9 @@ Dynamic: license-file
31
31
 
32
32
  # Graph-MIND
33
33
 
34
- Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
35
- **88.8% on LongMemEval with zero model calls at write time.**
34
+ ### Your AI forgets everything when the session ends. This one remembers, and it never calls an LLM to do it.
35
+
36
+ **88.8% on LongMemEval. Zero model tokens spent saving memory. Runs on your own PC.**
36
37
 
37
38
  [![][license-shield]][license-link]
38
39
  [![][python-shield]][python-link]
@@ -41,7 +42,7 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
41
42
  [![][ci-shield]][ci-link]
42
43
  [![][pypi-shield]][pypi-link]
43
44
 
44
- *Switch models; keep the memory.*
45
+ *Decide something in Claude Code on Monday. Ask Codex on Wednesday. It knows.*
45
46
 
46
47
  <img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
47
48
 
@@ -49,6 +50,30 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
49
50
 
50
51
  </div>
51
52
 
53
+ ### The part that sounds wrong
54
+
55
+ Same 40 questions, same answer model, same prompt, same official judge, pre-registered before
56
+ anyone looked:
57
+
58
+ | | accuracy | model tokens spent **saving** memory, per question |
59
+ |---|---|---|
60
+ | **Graph-MIND** | **85.0%** | **0** |
61
+ | MemPalace 3.10.0 | 57.5% | 0 |
62
+ | Mem0 2.2.1 (open source) | 52.5% | ~640,000 |
63
+
64
+ Mem0 runs an LLM over your conversations to decide what to keep. Graph-MIND keeps them word for
65
+ word and finds the right part when you ask. It is cheaper, and in this test it was also more
66
+ accurate.
67
+
68
+ **Don't believe it? Good.** Every question, answer and judge verdict is in
69
+ [`research/runs/`](research/runs), and [REPORT.md](research/REPORT.md) shows how to rerun it,
70
+ including the runs that went wrong. Or just try it on your own conversations:
71
+
72
+ ```bash
73
+ pip install graph-mind-memory
74
+ graph-mind-install # connects Claude Code, Codex, Claude Desktop, Cursor, VS Code, ...
75
+ ```
76
+
52
77
  > [!NOTE]
53
78
  > **Alpha.** Windows: used daily, with full end-to-end checks (fresh install, two PCs, real
54
79
  > Claude and Codex). macOS and Linux: tests and a clean install from PyPI pass in CI on every
@@ -89,16 +114,11 @@ official gpt-4o-2024-08-06.
89
114
  | **All 500 questions** | **88.8%** (444/500) | **0** | 3.4k tokens |
90
115
  | The 380 never used for tuning | **86.8%** (330/380) | 0 | 3.4k tokens |
91
116
 
92
- **Head to head: same 40 questions, same answer model, prompt and judge.** The run was
93
- pre-registered with code hashes; nobody had tuned on these questions.
94
-
95
- | system | accuracy | model tokens at write time, per question |
96
- |---|---|---|
97
- | **Graph-MIND** (shipped path, earlier 20-item packet) | **85.0%** | **0** |
98
- | MemPalace 3.10.0 | 57.5% | 0 |
99
- | Mem0 2.2.1 (open source, latest on PyPI) | 52.5% | ~640k |
100
-
101
- Graph-MIND's lead over both is significant (exact McNemar p = 0.002 and 0.003).
117
+ **Head to head** ([table above](#the-part-that-sounds-wrong)): the run was pre-registered with
118
+ code hashes, and nobody had tuned on those 40 questions. Graph-MIND ran its shipped path with the
119
+ earlier 20-item packet; Mem0 2.2.1 was the latest open-source release on PyPI. Graph-MIND's lead
120
+ over both is significant (exact McNemar p = 0.002 and 0.003); with 40 questions, each score has an
121
+ interval of about ±14 points.
102
122
 
103
123
  **Reading other published numbers.** They measure different things, so they do not compare
104
124
  directly with the tables above:
@@ -3,8 +3,9 @@
3
3
 
4
4
  # Graph-MIND
5
5
 
6
- Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
7
- **88.8% on LongMemEval with zero model calls at write time.**
6
+ ### Your AI forgets everything when the session ends. This one remembers, and it never calls an LLM to do it.
7
+
8
+ **88.8% on LongMemEval. Zero model tokens spent saving memory. Runs on your own PC.**
8
9
 
9
10
  [![][license-shield]][license-link]
10
11
  [![][python-shield]][python-link]
@@ -13,7 +14,7 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
13
14
  [![][ci-shield]][ci-link]
14
15
  [![][pypi-shield]][pypi-link]
15
16
 
16
- *Switch models; keep the memory.*
17
+ *Decide something in Claude Code on Monday. Ask Codex on Wednesday. It knows.*
17
18
 
18
19
  <img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
19
20
 
@@ -21,6 +22,30 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
21
22
 
22
23
  </div>
23
24
 
25
+ ### The part that sounds wrong
26
+
27
+ Same 40 questions, same answer model, same prompt, same official judge, pre-registered before
28
+ anyone looked:
29
+
30
+ | | accuracy | model tokens spent **saving** memory, per question |
31
+ |---|---|---|
32
+ | **Graph-MIND** | **85.0%** | **0** |
33
+ | MemPalace 3.10.0 | 57.5% | 0 |
34
+ | Mem0 2.2.1 (open source) | 52.5% | ~640,000 |
35
+
36
+ Mem0 runs an LLM over your conversations to decide what to keep. Graph-MIND keeps them word for
37
+ word and finds the right part when you ask. It is cheaper, and in this test it was also more
38
+ accurate.
39
+
40
+ **Don't believe it? Good.** Every question, answer and judge verdict is in
41
+ [`research/runs/`](research/runs), and [REPORT.md](research/REPORT.md) shows how to rerun it,
42
+ including the runs that went wrong. Or just try it on your own conversations:
43
+
44
+ ```bash
45
+ pip install graph-mind-memory
46
+ graph-mind-install # connects Claude Code, Codex, Claude Desktop, Cursor, VS Code, ...
47
+ ```
48
+
24
49
  > [!NOTE]
25
50
  > **Alpha.** Windows: used daily, with full end-to-end checks (fresh install, two PCs, real
26
51
  > Claude and Codex). macOS and Linux: tests and a clean install from PyPI pass in CI on every
@@ -61,16 +86,11 @@ official gpt-4o-2024-08-06.
61
86
  | **All 500 questions** | **88.8%** (444/500) | **0** | 3.4k tokens |
62
87
  | The 380 never used for tuning | **86.8%** (330/380) | 0 | 3.4k tokens |
63
88
 
64
- **Head to head: same 40 questions, same answer model, prompt and judge.** The run was
65
- pre-registered with code hashes; nobody had tuned on these questions.
66
-
67
- | system | accuracy | model tokens at write time, per question |
68
- |---|---|---|
69
- | **Graph-MIND** (shipped path, earlier 20-item packet) | **85.0%** | **0** |
70
- | MemPalace 3.10.0 | 57.5% | 0 |
71
- | Mem0 2.2.1 (open source, latest on PyPI) | 52.5% | ~640k |
72
-
73
- Graph-MIND's lead over both is significant (exact McNemar p = 0.002 and 0.003).
89
+ **Head to head** ([table above](#the-part-that-sounds-wrong)): the run was pre-registered with
90
+ code hashes, and nobody had tuned on those 40 questions. Graph-MIND ran its shipped path with the
91
+ earlier 20-item packet; Mem0 2.2.1 was the latest open-source release on PyPI. Graph-MIND's lead
92
+ over both is significant (exact McNemar p = 0.002 and 0.003); with 40 questions, each score has an
93
+ interval of about ±14 points.
74
94
 
75
95
  **Reading other published numbers.** They measure different things, so they do not compare
76
96
  directly with the tables above:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graph-mind-memory"
7
- version = "0.5.4"
7
+ version = "0.5.5"
8
8
  description = "Local-first memory for AI coding assistants: verbatim, on your own PC, no LLM calls at write time."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -184,6 +184,107 @@ def embedder_for(database):
184
184
  return _EMBEDDER[key]
185
185
 
186
186
 
187
+ class _Piece:
188
+ __slots__ = ("revision", "turn_id", "start", "user", "scope", "time", "key", "piece")
189
+
190
+ def __init__(self, *values):
191
+ for name, value in zip(self.__slots__, values):
192
+ setattr(self, name, value)
193
+
194
+
195
+ class PieceIndex:
196
+ """Every turn's 500-character pieces and their vectors, held in memory for one store and
197
+ topped up with the turns added since the previous question. Rebuilt per question (hashing
198
+ every piece, gathering every vector) it cost 6.3 s a question at 100,000 turns and grew with
199
+ the store. Turns are only ever added or forgotten; a forgotten one triggers a rebuild."""
200
+
201
+ def __init__(self):
202
+ self.lock = threading.Lock()
203
+ self.reset()
204
+
205
+ def reset(self):
206
+ import numpy as np
207
+ self.last = 0 # highest turn revision read
208
+ self.turns = 0 # turns read
209
+ self.pending = [] # pieces with no vector yet
210
+ self.seen_vectors = -1
211
+ self.turn_ids = []
212
+ self.revisions = np.zeros(0, dtype=np.int64)
213
+ self.starts = np.zeros(0, dtype=np.int64)
214
+ self.user = np.zeros(0, dtype=bool)
215
+ self.scopes = np.zeros(0, dtype=object)
216
+ self.times = np.zeros(0, dtype="U32")
217
+ self.matrix = None
218
+ self.norms = np.zeros(0, dtype=np.float64)
219
+
220
+ def sync(self, database, embedder):
221
+ from embedding_warmup import turn_pieces
222
+ with ConversationMemoryStore(database) as store:
223
+ count = store.db.execute("SELECT COUNT(*) FROM conversation_turns").fetchone()[0]
224
+ new = store.db.execute(
225
+ "SELECT revision,turn_id,role,scope,happened_at,content_redacted "
226
+ "FROM conversation_turns WHERE revision>? ORDER BY revision",
227
+ (self.last,)).fetchall()
228
+ if count != self.turns + len(new): # something was forgotten
229
+ self.reset()
230
+ new = store.db.execute(
231
+ "SELECT revision,turn_id,role,scope,happened_at,content_redacted "
232
+ "FROM conversation_turns ORDER BY revision").fetchall()
233
+ for revision, turn_id, role, scope, happened_at, content in new:
234
+ for start, piece in turn_pieces(content):
235
+ self.pending.append(_Piece(revision, turn_id, start, role == "user", scope,
236
+ happened_at, embedder._key(piece.strip()[:2000]),
237
+ piece))
238
+ if new:
239
+ self.last = new[-1][0]
240
+ self.turns += len(new)
241
+ self.promote(embedder, force=bool(new))
242
+
243
+ def promote(self, embedder, force=False):
244
+ """Move pending pieces whose vectors now exist into the matrix (only when it can have
245
+ changed: new pieces, or more vectors than last time)."""
246
+ import numpy as np
247
+ if not force and len(embedder.cache) == self.seen_vectors:
248
+ return
249
+ self.seen_vectors = len(embedder.cache)
250
+ ready = [entry for entry in self.pending if entry.key in embedder.cache]
251
+ if not ready:
252
+ return
253
+ self.pending = [entry for entry in self.pending if entry.key not in embedder.cache]
254
+ vectors = np.asarray([embedder.cache.get(entry.key) for entry in ready], dtype=np.float32)
255
+ self.matrix = vectors if self.matrix is None else np.concatenate([self.matrix, vectors])
256
+ self.norms = np.concatenate([self.norms, np.linalg.norm(vectors.astype(np.float64), axis=1)])
257
+ self.turn_ids.extend(entry.turn_id for entry in ready)
258
+ self.revisions = np.concatenate([self.revisions, [e.revision for e in ready]])
259
+ self.starts = np.concatenate([self.starts, [e.start for e in ready]])
260
+ self.user = np.concatenate([self.user, [e.user for e in ready]])
261
+ self.scopes = np.concatenate([self.scopes, np.array([e.scope for e in ready], dtype=object)])
262
+ self.times = np.concatenate([self.times, np.array([e.time for e in ready], dtype="U32")])
263
+
264
+ def select(self, *, about_assistant, scopes, since, until):
265
+ import numpy as np
266
+ mask = np.ones(len(self.turn_ids), dtype=bool) if about_assistant else self.user.copy()
267
+ if scopes:
268
+ mask &= np.isin(self.scopes, list(scopes))
269
+ if since:
270
+ mask &= self.times >= since
271
+ if until:
272
+ mask &= self.times < until
273
+ return mask
274
+
275
+ @staticmethod
276
+ def wanted(entry, about_assistant, scopes, since, until):
277
+ return ((about_assistant or entry.user) and (not scopes or entry.scope in scopes)
278
+ and (not since or entry.time >= since) and (not until or entry.time < until))
279
+
280
+
281
+ _PIECES = {}
282
+
283
+
284
+ def pieces_for(database):
285
+ return _PIECES.setdefault(str(database), PieceIndex())
286
+
287
+
187
288
  def semantic_turns(database, query, *, scopes=None, since=None, until=None, limit=20, embedder,
188
289
  about_assistant=False):
189
290
  """Turns ranked by meaning; each carries the 500-character piece that matched.
@@ -191,66 +292,69 @@ def semantic_turns(database, query, *, scopes=None, since=None, until=None, limi
191
292
  Only the user's turns by default: indexing the assistant's long prose too buried the user's
192
293
  facts (benchmark: answer sessions in the top 8 went 105 -> 115 of 120 when it was left out).
193
294
  When the question is about what the assistant itself said, its turns join the index."""
194
- with ConversationMemoryStore(database) as store:
195
- where, parameters = ["role IN ('user','assistant')" if about_assistant
196
- else "role='user'"], []
197
- if scopes:
198
- where.append("scope IN (" + ",".join("?" for _ in scopes) + ")")
199
- parameters.extend(scopes)
200
- if since:
201
- where.append("happened_at>=?")
202
- parameters.append(since)
203
- if until:
204
- where.append("happened_at<?")
205
- parameters.append(until)
206
- turns = [store._turn(row) for row in store.db.execute(
207
- "SELECT * FROM conversation_turns WHERE " + " AND ".join(where), parameters)]
208
- from embedding_warmup import turn_pieces
209
- owners, pieces = [], []
210
- for turn in turns:
211
- for start, piece in turn_pieces(turn["content"]):
212
- owners.append((turn, start))
213
- pieces.append(piece)
214
- if not pieces:
215
- return []
216
- if hasattr(embedder.cache, "refresh"):
217
- embedder.cache.refresh() # vectors the capture service embedded since we loaded
218
- # Never make a question wait for the store to be embedded: a store that has never been
219
- # searched by meaning holds ~1,500 pieces per thousand turns, minutes on a busy laptop. A few
220
- # new pieces are embedded inline; a backlog is filled in the background while this question
221
- # is answered from what is already embedded (word search covers the rest meanwhile).
222
- missing = [p for p in pieces if embedder._key(p.strip()[:2000]) not in embedder.cache]
223
- # Nor wait for the model itself: an app's first question used to sit through the torch import
224
- # (38.8 s measured). If it is not loaded within WAIT_FOR_MODEL seconds, this question is
225
- # answered from word search plus what is embedded already, and loading carries on.
226
- asked = embedder._key(query.strip()[:2000]) in embedder.cache
227
- wait = getattr(embedder, "WAIT_FOR_MODEL", None)
228
- loading = (wait is not None and (missing or not asked) and not embedder.ready(wait))
229
- if loading and not asked:
230
- return []
231
- if len(missing) > INLINE_EMBED_LIMIT or (loading and missing):
232
- embed_in_background(embedder, missing)
233
- keep = [i for i, p in enumerate(pieces)
234
- if embedder._key(p.strip()[:2000]) in embedder.cache]
235
- owners, pieces = [owners[i] for i in keep], [pieces[i] for i in keep]
236
- if not pieces:
237
- return []
238
295
  import numpy as np
239
- question = np.asarray(embedder.embed([query])[0], dtype=np.float64)
240
- # One matrix product instead of a Python loop per piece: 5 s -> 0.05 s at 40,000 pieces, which
241
- # a few months of captured chat reaches. Same cosine, in float64 as before.
242
- matrix = np.stack([np.asarray(v, dtype=np.float64) for v in embedder.embed(pieces)])
243
- norms = np.linalg.norm(matrix, axis=1) * np.linalg.norm(question)
244
- scores = np.divide(matrix @ question, norms, out=np.zeros(len(pieces)), where=norms > 0)
245
- best = {}
246
- # ponytail: a full scan of every piece per query; fine for one person's years of chat, an
247
- # approximate-nearest-neighbour index is the upgrade if a store reaches millions of pieces.
248
- for (turn, start), piece, score in zip(owners, pieces, scores.tolist()):
249
- if score > best.get(turn["turn_id"], (-2.0,))[0]:
250
- best[turn["turn_id"]] = (score, turn, start, piece)
251
- ranked = sorted(best.values(), key=lambda hit: hit[0], reverse=True)[:limit]
252
- return [{**turn, "content": piece, "span": [start, start + len(piece)], "full": turn["content"],
253
- "similarity": round(score, 4)} for score, turn, start, piece in ranked]
296
+ index = pieces_for(database)
297
+ with index.lock:
298
+ if hasattr(embedder.cache, "refresh"):
299
+ embedder.cache.refresh() # vectors the capture service embedded since we loaded
300
+ index.sync(database, embedder)
301
+ mask = index.select(about_assistant=about_assistant, scopes=scopes, since=since,
302
+ until=until)
303
+ waiting = [entry for entry in index.pending
304
+ if index.wanted(entry, about_assistant, scopes, since, until)]
305
+ if not mask.any() and not waiting:
306
+ return []
307
+ # Never make a question wait for the store to be embedded: a store that has never been
308
+ # searched by meaning holds ~1,500 pieces per thousand turns, minutes on a busy laptop. A
309
+ # few new pieces are embedded inline; a backlog is filled in the background while this
310
+ # question is answered from what is already embedded (word search covers the rest).
311
+ missing = [entry.piece for entry in waiting]
312
+ # Nor wait for the model itself: an app's first question used to sit through the torch
313
+ # import (38.8 s measured). If it is not loaded within WAIT_FOR_MODEL seconds, this
314
+ # question is answered from word search plus what is embedded already.
315
+ asked = embedder._key(query.strip()[:2000]) in embedder.cache
316
+ wait = getattr(embedder, "WAIT_FOR_MODEL", None)
317
+ loading = (wait is not None and (missing or not asked) and not embedder.ready(wait))
318
+ if loading and not asked:
319
+ return []
320
+ if len(missing) > INLINE_EMBED_LIMIT or (loading and missing):
321
+ embed_in_background(embedder, missing)
322
+ elif missing:
323
+ embedder.embed(missing)
324
+ index.promote(embedder, force=True)
325
+ mask = index.select(about_assistant=about_assistant, scopes=scopes, since=since,
326
+ until=until)
327
+ rows = np.flatnonzero(mask)
328
+ if not len(rows):
329
+ return []
330
+ question = np.asarray(embedder.embed([query])[0], dtype=np.float32)
331
+ # ponytail: a full scan of every piece per query (one matrix product: ~0.1 s at 100,000
332
+ # turns); an approximate-nearest-neighbour index is the upgrade at millions of pieces.
333
+ norms = index.norms[rows] * float(np.linalg.norm(question.astype(np.float64)))
334
+ dots = (index.matrix[rows] @ question).astype(np.float64)
335
+ scores = np.divide(dots, norms, out=np.zeros(len(rows)), where=norms > 0)
336
+ # best piece per turn; ties go to the earlier turn, then the earlier piece, as before
337
+ order = np.lexsort((index.starts[rows], index.revisions[rows], -scores))
338
+ best = {}
339
+ for i in order:
340
+ turn_id = index.turn_ids[rows[i]]
341
+ if turn_id not in best:
342
+ best[turn_id] = (float(scores[i]), int(index.starts[rows[i]]))
343
+ if len(best) == limit:
344
+ break
345
+ with ConversationMemoryStore(database) as store:
346
+ found = {row["turn_id"]: store._turn(row) for row in store.db.execute(
347
+ "SELECT * FROM conversation_turns WHERE turn_id IN (" + ",".join("?" * len(best)) + ")",
348
+ list(best))}
349
+ hits = []
350
+ for turn_id, (score, start) in best.items():
351
+ turn = found.get(turn_id)
352
+ if turn is None: # forgotten between the scan and this read
353
+ continue
354
+ piece = turn["content"][start:start + PIECE]
355
+ hits.append({**turn, "content": piece, "span": [start, start + len(piece)],
356
+ "full": turn["content"], "similarity": round(score, 4)})
357
+ return hits
254
358
 
255
359
 
256
360
  def fuse(*rankings, limit):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.5.4
3
+ Version: 0.5.5
4
4
  Summary: Local-first memory for AI coding assistants: verbatim, on your own PC, no LLM calls at write time.
5
5
  Author-email: Yohan Ko <goyohan0611@gmail.com>
6
6
  License-Expression: AGPL-3.0-only
@@ -31,8 +31,9 @@ Dynamic: license-file
31
31
 
32
32
  # Graph-MIND
33
33
 
34
- Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
35
- **88.8% on LongMemEval with zero model calls at write time.**
34
+ ### Your AI forgets everything when the session ends. This one remembers, and it never calls an LLM to do it.
35
+
36
+ **88.8% on LongMemEval. Zero model tokens spent saving memory. Runs on your own PC.**
36
37
 
37
38
  [![][license-shield]][license-link]
38
39
  [![][python-shield]][python-link]
@@ -41,7 +42,7 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
41
42
  [![][ci-shield]][ci-link]
42
43
  [![][pypi-shield]][pypi-link]
43
44
 
44
- *Switch models; keep the memory.*
45
+ *Decide something in Claude Code on Monday. Ask Codex on Wednesday. It knows.*
45
46
 
46
47
  <img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
47
48
 
@@ -49,6 +50,30 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
49
50
 
50
51
  </div>
51
52
 
53
+ ### The part that sounds wrong
54
+
55
+ Same 40 questions, same answer model, same prompt, same official judge, pre-registered before
56
+ anyone looked:
57
+
58
+ | | accuracy | model tokens spent **saving** memory, per question |
59
+ |---|---|---|
60
+ | **Graph-MIND** | **85.0%** | **0** |
61
+ | MemPalace 3.10.0 | 57.5% | 0 |
62
+ | Mem0 2.2.1 (open source) | 52.5% | ~640,000 |
63
+
64
+ Mem0 runs an LLM over your conversations to decide what to keep. Graph-MIND keeps them word for
65
+ word and finds the right part when you ask. It is cheaper, and in this test it was also more
66
+ accurate.
67
+
68
+ **Don't believe it? Good.** Every question, answer and judge verdict is in
69
+ [`research/runs/`](research/runs), and [REPORT.md](research/REPORT.md) shows how to rerun it,
70
+ including the runs that went wrong. Or just try it on your own conversations:
71
+
72
+ ```bash
73
+ pip install graph-mind-memory
74
+ graph-mind-install # connects Claude Code, Codex, Claude Desktop, Cursor, VS Code, ...
75
+ ```
76
+
52
77
  > [!NOTE]
53
78
  > **Alpha.** Windows: used daily, with full end-to-end checks (fresh install, two PCs, real
54
79
  > Claude and Codex). macOS and Linux: tests and a clean install from PyPI pass in CI on every
@@ -89,16 +114,11 @@ official gpt-4o-2024-08-06.
89
114
  | **All 500 questions** | **88.8%** (444/500) | **0** | 3.4k tokens |
90
115
  | The 380 never used for tuning | **86.8%** (330/380) | 0 | 3.4k tokens |
91
116
 
92
- **Head to head: same 40 questions, same answer model, prompt and judge.** The run was
93
- pre-registered with code hashes; nobody had tuned on these questions.
94
-
95
- | system | accuracy | model tokens at write time, per question |
96
- |---|---|---|
97
- | **Graph-MIND** (shipped path, earlier 20-item packet) | **85.0%** | **0** |
98
- | MemPalace 3.10.0 | 57.5% | 0 |
99
- | Mem0 2.2.1 (open source, latest on PyPI) | 52.5% | ~640k |
100
-
101
- Graph-MIND's lead over both is significant (exact McNemar p = 0.002 and 0.003).
117
+ **Head to head** ([table above](#the-part-that-sounds-wrong)): the run was pre-registered with
118
+ code hashes, and nobody had tuned on those 40 questions. Graph-MIND ran its shipped path with the
119
+ earlier 20-item packet; Mem0 2.2.1 was the latest open-source release on PyPI. Graph-MIND's lead
120
+ over both is significant (exact McNemar p = 0.002 and 0.003); with 40 questions, each score has an
121
+ interval of about ±14 points.
102
122
 
103
123
  **Reading other published numbers.** They measure different things, so they do not compare
104
124
  directly with the tables above:
@@ -51,6 +51,7 @@ tests/test_longmemeval_ingestion_calibration.py
51
51
  tests/test_longmemeval_ingestion_preflight.py
52
52
  tests/test_longmemeval_ingestion_v7.py
53
53
  tests/test_memory_algebra.py
54
+ tests/test_piece_index.py
54
55
  tests/test_precondition_verifier.py
55
56
  tests/test_recent_recall.py
56
57
  tests/test_resume_retries.py
@@ -0,0 +1,57 @@
1
+ """Meaning search keeps its pieces in memory between questions: turns added later are searched,
2
+ and a forgotten turn is never returned again."""
3
+ import _isolate # noqa: F401 (first: never touch the real ~/.graph-mind)
4
+ from pathlib import Path
5
+ import tempfile
6
+ import unittest
7
+
8
+ import graph_mind_mcp_server as server
9
+ from conversation_memory import ConversationMemoryStore
10
+
11
+ TOPICS = ("dog", "cat", "beach", "food")
12
+
13
+
14
+ class Words:
15
+ """A stand-in embedder: one dimension per topic word."""
16
+ WAIT_FOR_MODEL = None
17
+
18
+ def __init__(self):
19
+ self.cache = {}
20
+
21
+ def _key(self, text):
22
+ return text
23
+
24
+ def embed(self, texts):
25
+ for text in texts:
26
+ self.cache.setdefault(text, [float(word in text) + 0.01 for word in TOPICS])
27
+ return [self.cache[text] for text in texts]
28
+
29
+
30
+ def turn(i, text):
31
+ when = f"2026-10-08T10:{i:02d}:00"
32
+ return {"turn_id": f"t{i}", "client": "codex", "client_session_id": "s", "scope": "global",
33
+ "role": "user", "content_redacted": text, "raw_sha256": f"t{i}", "source_path": "p",
34
+ "happened_at": when, "known_at": when, "source_ordinal": i}
35
+
36
+
37
+ class PieceIndexTests(unittest.TestCase):
38
+ def test_new_turns_are_searched_and_forgotten_ones_are_gone(self):
39
+ with tempfile.TemporaryDirectory() as directory:
40
+ db, words = Path(directory) / "brain.sqlite", Words()
41
+ ask = lambda: [h["turn_id"] for h in # noqa: E731
42
+ server.semantic_turns(db, "dog", embedder=words, limit=5)]
43
+ with ConversationMemoryStore(db) as store:
44
+ store.record(turn(0, "my dog at the park"))
45
+ store.record(turn(1, "cat food prices"))
46
+ self.assertEqual(ask()[0], "t0")
47
+ with ConversationMemoryStore(db) as store:
48
+ store.record(turn(2, "dog on the beach"))
49
+ self.assertIn("t2", ask())
50
+ with ConversationMemoryStore(db) as store, store.db:
51
+ store.db.execute("DELETE FROM conversation_turns WHERE turn_id='t0'")
52
+ self.assertNotIn("t0", ask())
53
+ self.assertIn("t2", ask())
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()