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.
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/PKG-INFO +34 -14
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/README.md +33 -13
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/pyproject.toml +1 -1
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_mcp_server.py +163 -59
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/PKG-INFO +34 -14
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/SOURCES.txt +1 -0
- graph_mind_memory-0.5.5/tests/test_piece_index.py +57 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/LICENSE +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/setup.cfg +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/associative_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/automatic_capture.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/automatic_capture_cli.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/brain_log.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/clock.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/coding_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/conversation_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/development_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/development_paths.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/embedding_warmup.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/entity_timeline.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/forget.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/dependency_links.txt +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/entry_points.txt +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/requires.txt +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/top_level.txt +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/install.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/local_brain.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/local_embedder.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/semantic_llm_extractor.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/semantic_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/semantic_recall.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/temporal_resolver.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/universal_personal_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/vector_cache.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_associative_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_automatic_capture.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_capture_health.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_coding_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_cold_start.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_concurrent_use.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_conversation_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_executable_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_executable_plan_v2_diagnostic.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_forget.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_forget_conversation.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_graph_mind_mcp_server.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_ingestion_gate.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_ingestion_synonyms.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_install.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_llm_concept_planner.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_local_brain.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_adapter.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_calibration.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_preflight.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_v7.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_memory_algebra.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_precondition_verifier.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_recent_recall.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_resume_retries.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_secret_masking.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_semantic_llm_extractor.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_semantic_memory.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_semantic_recall.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_shared_brain.py +0 -0
- {graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_temporal_state.py +0 -0
- {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.
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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:
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/SOURCES.txt
RENAMED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/requires.txt
RENAMED
|
File without changes
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/src/graph_mind_memory.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_executable_plan_v2_diagnostic.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_calibration.py
RENAMED
|
File without changes
|
{graph_mind_memory-0.5.4 → graph_mind_memory-0.5.5}/tests/test_longmemeval_ingestion_preflight.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|