graph-mind-memory 0.3.4__tar.gz → 0.5.0__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 (33) hide show
  1. {graph_mind_memory-0.3.4/graph_mind_memory.egg-info → graph_mind_memory-0.5.0}/PKG-INFO +22 -16
  2. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/README.md +17 -12
  3. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/brain_log.py +68 -5
  4. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/forget.py +10 -6
  5. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0/graph_mind_memory.egg-info}/PKG-INFO +22 -16
  6. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/graph_mind_memory.egg-info/requires.txt +4 -3
  7. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/install.py +69 -25
  8. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/local_embedder.py +45 -20
  9. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/pyproject.toml +5 -4
  10. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/LICENSE +0 -0
  11. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/associative_memory.py +0 -0
  12. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/automatic_capture.py +0 -0
  13. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/automatic_capture_cli.py +0 -0
  14. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/clock.py +0 -0
  15. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/coding_memory.py +0 -0
  16. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/conversation_memory.py +0 -0
  17. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/development_memory.py +0 -0
  18. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/development_paths.py +0 -0
  19. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/embedding_warmup.py +0 -0
  20. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/entity_timeline.py +0 -0
  21. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/graph_mind_mcp_server.py +0 -0
  22. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/graph_mind_memory.egg-info/SOURCES.txt +0 -0
  23. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
  24. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/graph_mind_memory.egg-info/entry_points.txt +0 -0
  25. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/graph_mind_memory.egg-info/top_level.txt +0 -0
  26. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/local_brain.py +0 -0
  27. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/semantic_llm_extractor.py +0 -0
  28. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/semantic_memory.py +0 -0
  29. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/semantic_recall.py +0 -0
  30. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/setup.cfg +0 -0
  31. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/temporal_resolver.py +0 -0
  32. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/universal_personal_memory.py +0 -0
  33. {graph_mind_memory-0.3.4 → graph_mind_memory-0.5.0}/vector_cache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.3.4
3
+ Version: 0.5.0
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
@@ -13,13 +13,14 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.10
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
- Requires-Dist: torch<3,>=2.2
17
16
  Requires-Dist: mcp<3,>=2
18
17
  Requires-Dist: numpy
19
- Requires-Dist: transformers
20
- Requires-Dist: tiktoken
18
+ Requires-Dist: onnxruntime>=1.17
19
+ Requires-Dist: tokenizers>=0.15
20
+ Requires-Dist: huggingface_hub>=0.20
21
21
  Requires-Dist: pgserver; python_version < "3.13"
22
22
  Requires-Dist: psycopg[binary]
23
+ Requires-Dist: cryptography
23
24
  Requires-Dist: psutil
24
25
  Requires-Dist: fasteners
25
26
  Dynamic: license-file
@@ -48,8 +49,9 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
48
49
  </div>
49
50
 
50
51
  > [!NOTE]
51
- > **Alpha.** Used daily on Windows 11, and checked in a fresh-install, two-PC end-to-end run.
52
- > The test suite also passes on Linux and macOS in CI, but nobody has used it day to day there yet.
52
+ > **Alpha.** Windows: used daily, with full end-to-end checks (fresh install, two PCs, real
53
+ > Claude and Codex). macOS and Linux: tests and a clean install from PyPI pass in CI on every
54
+ > release and every day, but nobody has used them day to day yet; consider them experimental.
53
55
 
54
56
  ---
55
57
 
@@ -125,8 +127,8 @@ graph-mind-install
125
127
 
126
128
  If `graph-mind-install` is "not recognized", pip put it in a Scripts folder that is not on your
127
129
  PATH (common with the Windows Python install manager). `python -m install` runs the same thing.
128
- If it stops with `WinError 1114` loading `c10.dll`, Windows is missing the Microsoft Visual C++
129
- runtime that PyTorch needs: install [vc_redist.x64.exe](https://aka.ms/vs/17/release/vc_redist.x64.exe),
130
+ If it stops with a DLL load error, Windows is missing the Microsoft Visual C++ runtime that the
131
+ embedding runtime needs: install [vc_redist.x64.exe](https://aka.ms/vs/17/release/vc_redist.x64.exe),
130
132
  restart, and run the installer again.
131
133
 
132
134
  or from source:
@@ -139,12 +141,13 @@ python install.py
139
141
 
140
142
  The installer:
141
143
 
142
- - installs the packages (PyTorch CPU is the large one, about 2 GB);
144
+ - installs the packages (no PyTorch: the embedding model runs on ONNX Runtime);
143
145
  - registers the MCP server with every app it finds: Claude Code, Claude Desktop (including the
144
- Microsoft Store build) and Codex;
146
+ Microsoft Store build), Codex, Cursor, Windsurf, Gemini CLI, VS Code (Copilot), Cline and
147
+ LM Studio;
145
148
  - starts the capture service at login (Windows Startup folder, a macOS LaunchAgent, or an XDG
146
149
  autostart entry on Linux);
147
- - downloads the embedding model.
150
+ - downloads the embedding model (about 470 MB, once).
148
151
 
149
152
  Then restart your AI apps. The server is also listed in the
150
153
  [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.goyohan0611-png/graph-mind`. Running the installer again is safe. Run it again if you move the folder.
@@ -168,6 +171,7 @@ stops the capture service. Your memory stays in `~/.graph-mind` unless you add `
168
171
  | Claude Code: terminal, VS Code, Claude desktop's Code tab | every turn |
169
172
  | Claude desktop: Cowork | every turn |
170
173
  | Claude desktop: chat | what the model saves with `brain_remember` |
174
+ | Cursor, Windsurf, Gemini CLI, VS Code, Cline, LM Studio | what the model saves with `brain_remember` (memory tools connected; their transcripts are not read yet) |
171
175
 
172
176
  Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
173
177
  private keys, passwords inside URLs, values written after `password:` / `api_key=` / `token:`,
@@ -231,8 +235,10 @@ PCs reach it over the local network in the office, or over [Tailscale](https://t
231
235
  anywhere. Each connection tries the addresses in turn and uses the first that answers.
232
236
 
233
237
  Other PCs log in with a generated password, as a role that can reach only the memory database. The
234
- Windows firewall rule admits only the local network and Tailscale. The code contains the password:
235
- do not post it publicly.
238
+ Windows firewall rule admits only the local network and Tailscale. Every line of memory is
239
+ encrypted (AES with an HMAC) under a key derived from that password before it leaves a PC, so the
240
+ network and the hub's own database carry only ciphertext. The code contains the password: do not
241
+ post it publicly.
236
242
 
237
243
  A synced folder also works. Tell the AI *"use my Google Drive's Graph-MIND folder as my memory"* on
238
244
  each PC.
@@ -258,9 +264,9 @@ python -m unittest discover -p "test_*.py"
258
264
 
259
265
  ## Known limits
260
266
 
261
- - **For about the first half minute after an AI app starts, answers use word search only** while the
262
- embedding model loads in the background (the first answer takes ~2.5 s instead of waiting out a
263
- 30-50 s load on a slow or synced disk). After that, questions take well under a second.
267
+ - **For the first few seconds after an AI app starts, answers use word search only** while the
268
+ embedding model loads in the background (6-9 s on a slow, synced disk; it was 50-70 s before
269
+ 0.4). The first answer never waits for it; later answers take well under a second.
264
270
  - **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
265
271
  use.
266
272
  - **Capture follows each app's transcript format,** which is not a public interface. An app update
@@ -22,8 +22,9 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
22
22
  </div>
23
23
 
24
24
  > [!NOTE]
25
- > **Alpha.** Used daily on Windows 11, and checked in a fresh-install, two-PC end-to-end run.
26
- > The test suite also passes on Linux and macOS in CI, but nobody has used it day to day there yet.
25
+ > **Alpha.** Windows: used daily, with full end-to-end checks (fresh install, two PCs, real
26
+ > Claude and Codex). macOS and Linux: tests and a clean install from PyPI pass in CI on every
27
+ > release and every day, but nobody has used them day to day yet; consider them experimental.
27
28
 
28
29
  ---
29
30
 
@@ -99,8 +100,8 @@ graph-mind-install
99
100
 
100
101
  If `graph-mind-install` is "not recognized", pip put it in a Scripts folder that is not on your
101
102
  PATH (common with the Windows Python install manager). `python -m install` runs the same thing.
102
- If it stops with `WinError 1114` loading `c10.dll`, Windows is missing the Microsoft Visual C++
103
- runtime that PyTorch needs: install [vc_redist.x64.exe](https://aka.ms/vs/17/release/vc_redist.x64.exe),
103
+ If it stops with a DLL load error, Windows is missing the Microsoft Visual C++ runtime that the
104
+ embedding runtime needs: install [vc_redist.x64.exe](https://aka.ms/vs/17/release/vc_redist.x64.exe),
104
105
  restart, and run the installer again.
105
106
 
106
107
  or from source:
@@ -113,12 +114,13 @@ python install.py
113
114
 
114
115
  The installer:
115
116
 
116
- - installs the packages (PyTorch CPU is the large one, about 2 GB);
117
+ - installs the packages (no PyTorch: the embedding model runs on ONNX Runtime);
117
118
  - registers the MCP server with every app it finds: Claude Code, Claude Desktop (including the
118
- Microsoft Store build) and Codex;
119
+ Microsoft Store build), Codex, Cursor, Windsurf, Gemini CLI, VS Code (Copilot), Cline and
120
+ LM Studio;
119
121
  - starts the capture service at login (Windows Startup folder, a macOS LaunchAgent, or an XDG
120
122
  autostart entry on Linux);
121
- - downloads the embedding model.
123
+ - downloads the embedding model (about 470 MB, once).
122
124
 
123
125
  Then restart your AI apps. The server is also listed in the
124
126
  [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.goyohan0611-png/graph-mind`. Running the installer again is safe. Run it again if you move the folder.
@@ -142,6 +144,7 @@ stops the capture service. Your memory stays in `~/.graph-mind` unless you add `
142
144
  | Claude Code: terminal, VS Code, Claude desktop's Code tab | every turn |
143
145
  | Claude desktop: Cowork | every turn |
144
146
  | Claude desktop: chat | what the model saves with `brain_remember` |
147
+ | Cursor, Windsurf, Gemini CLI, VS Code, Cline, LM Studio | what the model saves with `brain_remember` (memory tools connected; their transcripts are not read yet) |
145
148
 
146
149
  Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
147
150
  private keys, passwords inside URLs, values written after `password:` / `api_key=` / `token:`,
@@ -205,8 +208,10 @@ PCs reach it over the local network in the office, or over [Tailscale](https://t
205
208
  anywhere. Each connection tries the addresses in turn and uses the first that answers.
206
209
 
207
210
  Other PCs log in with a generated password, as a role that can reach only the memory database. The
208
- Windows firewall rule admits only the local network and Tailscale. The code contains the password:
209
- do not post it publicly.
211
+ Windows firewall rule admits only the local network and Tailscale. Every line of memory is
212
+ encrypted (AES with an HMAC) under a key derived from that password before it leaves a PC, so the
213
+ network and the hub's own database carry only ciphertext. The code contains the password: do not
214
+ post it publicly.
210
215
 
211
216
  A synced folder also works. Tell the AI *"use my Google Drive's Graph-MIND folder as my memory"* on
212
217
  each PC.
@@ -232,9 +237,9 @@ python -m unittest discover -p "test_*.py"
232
237
 
233
238
  ## Known limits
234
239
 
235
- - **For about the first half minute after an AI app starts, answers use word search only** while the
236
- embedding model loads in the background (the first answer takes ~2.5 s instead of waiting out a
237
- 30-50 s load on a slow or synced disk). After that, questions take well under a second.
240
+ - **For the first few seconds after an AI app starts, answers use word search only** while the
241
+ embedding model loads in the background (6-9 s on a slow, synced disk; it was 50-70 s before
242
+ 0.4). The first answer never waits for it; later answers take well under a second.
238
243
  - **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
239
244
  use.
240
245
  - **Capture follows each app's transcript format,** which is not a public interface. An app update
@@ -145,6 +145,7 @@ def share(open_firewall: bool = True) -> dict:
145
145
  local_postgres() # first: it explains a missing pgserver
146
146
  from pgserver._commands import pg_ctl
147
147
  password = _config().get("share_password") or secrets.token_urlsafe(18)
148
+ _save({"share_password": password}) # before sealing: the line key derives from it
148
149
  data = _pg_data()
149
150
  with psycopg.connect(_admin_uri(), autocommit=True) as admin:
150
151
  # token_urlsafe is [A-Za-z0-9_-]: nothing in it can close the quoted literal
@@ -160,11 +161,11 @@ def share(open_firewall: bool = True) -> dict:
160
161
  pg_ctl(["-w", "-m", "fast", "restart"] if restart else ["reload"], pgdata=data, timeout=60,
161
162
  **_DETACHED)
162
163
  set_folder("local")
163
- _save({"share_password": password})
164
+ sealed = _seal_existing(local_postgres())
164
165
  hosts = _addresses()
165
166
  code = CODE_PREFIX + base64.urlsafe_b64encode(json.dumps(
166
167
  {"h": hosts, "p": PORT, "k": password}).encode()).decode().rstrip("=")
167
- return {"connection_code": code, "addresses": hosts, "port": PORT,
168
+ return {"connection_code": code, "addresses": hosts, "port": PORT, "lines_sealed": sealed,
168
169
  "firewall": _firewall() if open_firewall else "not changed"}
169
170
 
170
171
 
@@ -308,7 +309,8 @@ def _shared_ids(shared) -> set:
308
309
  """(kind, id) of every turn and memory the shared log holds, forgotten ones included."""
309
310
  if is_database(shared):
310
311
  with _pg(shared) as connection:
311
- lines = [row[0] for row in connection.execute("SELECT line FROM brain_log")]
312
+ lines = [line for line in (_unseal(shared, row[0]) for row in
313
+ connection.execute("SELECT line FROM brain_log")) if line is not None]
312
314
  else:
313
315
  lines = [line for log in (Path(shared) / "log").glob("*.jsonl")
314
316
  for line in log.read_text(encoding="utf-8", errors="replace").splitlines()]
@@ -390,6 +392,66 @@ def _cursor_db(index: Path) -> sqlite3.Connection:
390
392
  return db
391
393
 
392
394
 
395
+ SEALED = "gme1:"
396
+ _KEYS = {}
397
+
398
+
399
+ def _line_key(url):
400
+ """The key that seals this hub's lines, or None when it has none.
401
+
402
+ The Postgres that pgserver ships is built without SSL, so the connection to a hub is not
403
+ encrypted: on a shared network anyone could read memory off the wire. Each line is sealed
404
+ instead (Fernet: AES with an HMAC) under a key derived from the password in the connection
405
+ code, which every PC that joined holds and nobody else does. The hub stores ciphertext too.
406
+ A joined PC finds the password in its URL; the hub PC in its own config."""
407
+ import urllib.parse
408
+ password = urllib.parse.urlsplit(url).password
409
+ password = urllib.parse.unquote(password) if password else _config().get("share_password")
410
+ if not password:
411
+ return None # a hub never shared: nothing leaves this PC
412
+ if password not in _KEYS:
413
+ from cryptography.fernet import Fernet
414
+ from cryptography.hazmat.primitives import hashes
415
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
416
+ raw = HKDF(algorithm=hashes.SHA256(), length=32, salt=b"graph-mind brain_log",
417
+ info=b"line key v1").derive(password.encode())
418
+ _KEYS[password] = Fernet(base64.urlsafe_b64encode(raw))
419
+ return _KEYS[password]
420
+
421
+
422
+ def _seal(url, lines):
423
+ key = _line_key(url)
424
+ return [SEALED + key.encrypt(line.encode()).decode() if key else line for line in lines]
425
+
426
+
427
+ def _unseal(url, line):
428
+ """The plain line, or None when it cannot be opened with this hub's key."""
429
+ if not line.startswith(SEALED):
430
+ return line # written before sealing began
431
+ key = _line_key(url)
432
+ if key is None:
433
+ return None
434
+ from cryptography.fernet import InvalidToken
435
+ try:
436
+ return key.decrypt(line[len(SEALED):].encode()).decode()
437
+ except InvalidToken:
438
+ return None
439
+
440
+
441
+ def _seal_existing(url) -> int:
442
+ """Seal the lines the hub held before it was shared, so none crosses the network in clear."""
443
+ if _line_key(url) is None:
444
+ return 0
445
+ with _pg(url) as connection:
446
+ rows = connection.execute("SELECT seq, line FROM brain_log WHERE line NOT LIKE %s",
447
+ (SEALED + "%",)).fetchall()
448
+ with connection.cursor() as cursor:
449
+ cursor.executemany("UPDATE brain_log SET line = %s WHERE seq = %s",
450
+ [(_seal(url, [line])[0], seq) for seq, line in rows])
451
+ connection.commit()
452
+ return len(rows)
453
+
454
+
393
455
  def _pg(url):
394
456
  import psycopg
395
457
  connection = psycopg.connect(url, autocommit=False)
@@ -432,7 +494,7 @@ def _pg_append(url, lines) -> int:
432
494
  connection.execute("SELECT pg_advisory_xact_lock(4242)")
433
495
  with connection.cursor() as cursor:
434
496
  cursor.executemany("INSERT INTO brain_log(device, line) VALUES (%s, %s)",
435
- [(device_name(), line) for line in lines])
497
+ [(device_name(), line) for line in _seal(url, lines)])
436
498
  connection.commit()
437
499
  return len(lines)
438
500
 
@@ -459,7 +521,8 @@ def _new_lines(db: sqlite3.Connection, shared) -> list[str]:
459
521
  with db:
460
522
  db.execute("INSERT OR REPLACE INTO brain_log_cursor VALUES(?,?)",
461
523
  (source, rows[-1][0]))
462
- return [line for _, line in rows]
524
+ opened = [_unseal(shared, line) for _, line in rows]
525
+ return [line for line in opened if line is not None]
463
526
  lines = []
464
527
  for log in sorted((Path(shared) / "log").glob("*.jsonl")):
465
528
  row = db.execute("SELECT offset FROM brain_log_cursor WHERE source=?",
@@ -96,13 +96,17 @@ def scrub_shared(shared, ids: dict) -> int:
96
96
  if not keys:
97
97
  return 0
98
98
  if is_database(shared):
99
- with _pg(shared) as connection:
100
- removed = 0
101
- for key in keys:
102
- removed += connection.execute(
103
- "DELETE FROM brain_log WHERE strpos(line, %s) > 0", (key,)).rowcount
99
+ from brain_log import _unseal
100
+ with _pg(shared) as connection: # lines are sealed: open each to find targets
101
+ doomed = []
102
+ for seq, line in connection.execute("SELECT seq, line FROM brain_log").fetchall():
103
+ text = _unseal(shared, line) or ""
104
+ if any(key in text for key in keys) and '"kind": "forget"' not in text:
105
+ doomed.append(seq)
106
+ if doomed:
107
+ connection.execute("DELETE FROM brain_log WHERE seq = ANY(%s)", (doomed,))
104
108
  connection.commit()
105
- return removed
109
+ return len(doomed)
106
110
  removed = 0
107
111
  for log in (Path(shared) / "log").glob("*.jsonl"):
108
112
  data = log.read_bytes()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.3.4
3
+ Version: 0.5.0
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
@@ -13,13 +13,14 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.10
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
- Requires-Dist: torch<3,>=2.2
17
16
  Requires-Dist: mcp<3,>=2
18
17
  Requires-Dist: numpy
19
- Requires-Dist: transformers
20
- Requires-Dist: tiktoken
18
+ Requires-Dist: onnxruntime>=1.17
19
+ Requires-Dist: tokenizers>=0.15
20
+ Requires-Dist: huggingface_hub>=0.20
21
21
  Requires-Dist: pgserver; python_version < "3.13"
22
22
  Requires-Dist: psycopg[binary]
23
+ Requires-Dist: cryptography
23
24
  Requires-Dist: psutil
24
25
  Requires-Dist: fasteners
25
26
  Dynamic: license-file
@@ -48,8 +49,9 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
48
49
  </div>
49
50
 
50
51
  > [!NOTE]
51
- > **Alpha.** Used daily on Windows 11, and checked in a fresh-install, two-PC end-to-end run.
52
- > The test suite also passes on Linux and macOS in CI, but nobody has used it day to day there yet.
52
+ > **Alpha.** Windows: used daily, with full end-to-end checks (fresh install, two PCs, real
53
+ > Claude and Codex). macOS and Linux: tests and a clean install from PyPI pass in CI on every
54
+ > release and every day, but nobody has used them day to day yet; consider them experimental.
53
55
 
54
56
  ---
55
57
 
@@ -125,8 +127,8 @@ graph-mind-install
125
127
 
126
128
  If `graph-mind-install` is "not recognized", pip put it in a Scripts folder that is not on your
127
129
  PATH (common with the Windows Python install manager). `python -m install` runs the same thing.
128
- If it stops with `WinError 1114` loading `c10.dll`, Windows is missing the Microsoft Visual C++
129
- runtime that PyTorch needs: install [vc_redist.x64.exe](https://aka.ms/vs/17/release/vc_redist.x64.exe),
130
+ If it stops with a DLL load error, Windows is missing the Microsoft Visual C++ runtime that the
131
+ embedding runtime needs: install [vc_redist.x64.exe](https://aka.ms/vs/17/release/vc_redist.x64.exe),
130
132
  restart, and run the installer again.
131
133
 
132
134
  or from source:
@@ -139,12 +141,13 @@ python install.py
139
141
 
140
142
  The installer:
141
143
 
142
- - installs the packages (PyTorch CPU is the large one, about 2 GB);
144
+ - installs the packages (no PyTorch: the embedding model runs on ONNX Runtime);
143
145
  - registers the MCP server with every app it finds: Claude Code, Claude Desktop (including the
144
- Microsoft Store build) and Codex;
146
+ Microsoft Store build), Codex, Cursor, Windsurf, Gemini CLI, VS Code (Copilot), Cline and
147
+ LM Studio;
145
148
  - starts the capture service at login (Windows Startup folder, a macOS LaunchAgent, or an XDG
146
149
  autostart entry on Linux);
147
- - downloads the embedding model.
150
+ - downloads the embedding model (about 470 MB, once).
148
151
 
149
152
  Then restart your AI apps. The server is also listed in the
150
153
  [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.goyohan0611-png/graph-mind`. Running the installer again is safe. Run it again if you move the folder.
@@ -168,6 +171,7 @@ stops the capture service. Your memory stays in `~/.graph-mind` unless you add `
168
171
  | Claude Code: terminal, VS Code, Claude desktop's Code tab | every turn |
169
172
  | Claude desktop: Cowork | every turn |
170
173
  | Claude desktop: chat | what the model saves with `brain_remember` |
174
+ | Cursor, Windsurf, Gemini CLI, VS Code, Cline, LM Studio | what the model saves with `brain_remember` (memory tools connected; their transcripts are not read yet) |
171
175
 
172
176
  Before anything is stored, these are masked: API keys, tokens from GitHub, AWS, Google and Slack,
173
177
  private keys, passwords inside URLs, values written after `password:` / `api_key=` / `token:`,
@@ -231,8 +235,10 @@ PCs reach it over the local network in the office, or over [Tailscale](https://t
231
235
  anywhere. Each connection tries the addresses in turn and uses the first that answers.
232
236
 
233
237
  Other PCs log in with a generated password, as a role that can reach only the memory database. The
234
- Windows firewall rule admits only the local network and Tailscale. The code contains the password:
235
- do not post it publicly.
238
+ Windows firewall rule admits only the local network and Tailscale. Every line of memory is
239
+ encrypted (AES with an HMAC) under a key derived from that password before it leaves a PC, so the
240
+ network and the hub's own database carry only ciphertext. The code contains the password: do not
241
+ post it publicly.
236
242
 
237
243
  A synced folder also works. Tell the AI *"use my Google Drive's Graph-MIND folder as my memory"* on
238
244
  each PC.
@@ -258,9 +264,9 @@ python -m unittest discover -p "test_*.py"
258
264
 
259
265
  ## Known limits
260
266
 
261
- - **For about the first half minute after an AI app starts, answers use word search only** while the
262
- embedding model loads in the background (the first answer takes ~2.5 s instead of waiting out a
263
- 30-50 s load on a slow or synced disk). After that, questions take well under a second.
267
+ - **For the first few seconds after an AI app starts, answers use word search only** while the
268
+ embedding model loads in the background (6-9 s on a slow, synced disk; it was 50-70 s before
269
+ 0.4). The first answer never waits for it; later answers take well under a second.
264
270
  - **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
265
271
  use.
266
272
  - **Capture follows each app's transcript format,** which is not a public interface. An app update
@@ -1,9 +1,10 @@
1
- torch<3,>=2.2
2
1
  mcp<3,>=2
3
2
  numpy
4
- transformers
5
- tiktoken
3
+ onnxruntime>=1.17
4
+ tokenizers>=0.15
5
+ huggingface_hub>=0.20
6
6
  psycopg[binary]
7
+ cryptography
7
8
  psutil
8
9
  fasteners
9
10
 
@@ -3,8 +3,8 @@
3
3
  python install.py # this PC keeps its own brain
4
4
  python install.py --join gm1.XXXX # ...and joins the brain another PC shared
5
5
 
6
- It installs the Python packages, registers the memory server with Claude Code, Claude Desktop and
7
- Codex (whichever are installed), starts the capture service at login, and downloads the local
6
+ It installs the Python packages, registers the memory server with every MCP client it finds
7
+ (Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Gemini CLI, VS Code, Cline, LM Studio), starts the capture service at login, and downloads the local
8
8
  embedding model so the first question is not the one that waits for it. Running it again is
9
9
  safe, and after moving this folder it re-points everything at the new place.
10
10
  """
@@ -42,9 +42,9 @@ def packages():
42
42
  " or install Python or this folder under a shorter path, e.g. C:\\graph-mind")
43
43
 
44
44
 
45
- def _json_entry(path: Path, **extra) -> None:
45
+ def _json_entry(path: Path, key: str = "mcpServers", **extra) -> None:
46
46
  settings = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
47
- servers = settings.setdefault("mcpServers", {})
47
+ servers = settings.setdefault(key, {})
48
48
  environment = {**servers.get(NAME, {}).get("env", {}), **ENV} # keep the user's own settings
49
49
  servers[NAME] = {**extra, "command": sys.executable, "args": [str(SERVER)], "env": environment}
50
50
  temporary = path.with_suffix(".graph-mind.tmp")
@@ -85,6 +85,46 @@ def claude_desktop(home: Path) -> list[str]:
85
85
  return done[:1]
86
86
 
87
87
 
88
+ def _vscode_user_dir(home: Path) -> Path:
89
+ if platform.system() == "Windows":
90
+ return Path(os.environ.get("APPDATA", home / "AppData" / "Roaming")) / "Code" / "User"
91
+ if platform.system() == "Darwin":
92
+ return home / "Library" / "Application Support" / "Code" / "User"
93
+ return Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") / "Code" / "User"
94
+
95
+
96
+ def _other_apps(home: Path) -> list:
97
+ """(name, config file, servers key, extra fields) for MCP clients beyond Claude and Codex.
98
+ An app counts as installed when its own folder exists; its config file is created if not."""
99
+ vscode = _vscode_user_dir(home)
100
+ return [
101
+ ("Cursor", home / ".cursor", home / ".cursor" / "mcp.json", "mcpServers", {}),
102
+ ("Windsurf", home / ".codeium" / "windsurf",
103
+ home / ".codeium" / "windsurf" / "mcp_config.json", "mcpServers", {}),
104
+ ("Gemini CLI", home / ".gemini", home / ".gemini" / "settings.json", "mcpServers", {}),
105
+ ("LM Studio", home / ".lmstudio", home / ".lmstudio" / "mcp.json", "mcpServers", {}),
106
+ ("VS Code", vscode, vscode / "mcp.json", "servers", {"type": "stdio"}),
107
+ ("Cline", vscode / "globalStorage" / "saoudrizwan.claude-dev",
108
+ vscode / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
109
+ "mcpServers", {}),
110
+ ]
111
+
112
+
113
+ def other_apps(home: Path) -> list[str]:
114
+ done = []
115
+ for name, folder, config, key, extra in _other_apps(home):
116
+ if not folder.is_dir():
117
+ continue
118
+ try:
119
+ config.parent.mkdir(parents=True, exist_ok=True)
120
+ _json_entry(config, key, **extra)
121
+ done.append(name)
122
+ except ValueError:
123
+ # a settings file with comments (VS Code allows them) is not JSON: leave it alone
124
+ step(f"{name}: could not edit {config}; add the server there by hand")
125
+ return done
126
+
127
+
88
128
  def _without_codex_block(text: str) -> str:
89
129
  """The Codex config without Graph-MIND's server table (and its env table); the rest kept."""
90
130
  header = f"[mcp_servers.{NAME}]"
@@ -180,35 +220,32 @@ def capture_service(start: bool) -> str:
180
220
 
181
221
  def embedding_model():
182
222
  sys.path.insert(0, str(HERE))
183
- from local_embedder import LOCAL_MODEL, own_hf_home
223
+ from local_embedder import LOCAL_MODEL, MODEL_FILES, model_paths, own_hf_home
184
224
  try:
185
- from transformers import AutoModel, AutoTokenizer
186
- except OSError as error:
187
- if "1114" not in str(error):
225
+ import onnxruntime # noqa: F401 (a DLL failure here would leave search on words only)
226
+ except ImportError as error:
227
+ if platform.system() != "Windows":
188
228
  raise
189
- # WinError 1114 on c10.dll: PyTorch needs the Microsoft Visual C++ runtime, which a fresh
190
- # Windows often lacks. Everything else is installed; recall uses word search until then.
191
- sys.exit(f"\n{error}\n\nPyTorch could not load. Install the Microsoft Visual C++ "
192
- "Redistributable (x64), restart, and run this again:\n"
229
+ sys.exit(f"\n{error}\n\nThe embedding runtime could not load. Install the Microsoft "
230
+ "Visual C++ Redistributable (x64), restart, and run this again:\n"
193
231
  " https://aka.ms/vs/17/release/vc_redist.x64.exe\n"
194
232
  "The apps are already connected; until then recall works by word search only.")
195
-
196
233
  try:
197
- AutoTokenizer.from_pretrained(LOCAL_MODEL)
198
- AutoModel.from_pretrained(LOCAL_MODEL)
234
+ model_paths()
199
235
  except OSError as error:
200
236
  # ~/.cache refused (access denied): move Hugging Face's whole home under ~/.graph-mind,
201
237
  # where every Graph-MIND process looks from now on (local_embedder.use_own_hf_home)
202
- if "PermissionError" not in str(error) and not isinstance(error.__cause__, PermissionError):
238
+ if "PermissionError" not in str(error) and not isinstance(error.__cause__, PermissionError) \
239
+ and not isinstance(error, PermissionError):
203
240
  raise
204
241
  own = own_hf_home()
205
242
  own.mkdir(parents=True, exist_ok=True)
206
243
  step(f"the usual model folder is not writable; using {own}")
207
244
  # huggingface_hub fixed its folders when it was imported: download in a fresh process
208
245
  subprocess.run([sys.executable, "-c",
209
- "from transformers import AutoModel, AutoTokenizer; "
210
- f"AutoTokenizer.from_pretrained({LOCAL_MODEL!r}); "
211
- f"AutoModel.from_pretrained({LOCAL_MODEL!r})"],
246
+ "from huggingface_hub import hf_hub_download; "
247
+ + "; ".join(f"hf_hub_download({LOCAL_MODEL!r}, {name!r})"
248
+ for name in MODEL_FILES)],
212
249
  env={**os.environ, "HF_HOME": str(own),
213
250
  "HF_HUB_DISABLE_SYMLINKS_WARNING": "1"}, check=True)
214
251
 
@@ -230,7 +267,8 @@ def main(argv=None):
230
267
  step("installing packages (the first time takes a few minutes)")
231
268
  packages()
232
269
  home = Path.home()
233
- apps = [name for name in [claude_code(home), *claude_desktop(home), codex(home)] if name]
270
+ apps = [name for name in [claude_code(home), *claude_desktop(home), codex(home),
271
+ *other_apps(home)] if name]
234
272
  step("memory connected to: " + (", ".join(apps) if apps else
235
273
  "no AI app found yet; install one and run this again"))
236
274
  step(capture_service(start=not args.no_start))
@@ -258,17 +296,23 @@ def uninstall(argv=None):
258
296
  claude = shutil.which("claude")
259
297
  if claude:
260
298
  subprocess.run([claude, "mcp", "remove", "-s", "user", NAME], capture_output=True)
261
- for config in [home / ".claude.json"] + [r / "claude_desktop_config.json"
262
- for r in _desktop_roots(home)]:
299
+ targets = ([("Claude Code", home / ".claude.json", "mcpServers")]
300
+ + [("Claude Desktop", r / "claude_desktop_config.json", "mcpServers")
301
+ for r in _desktop_roots(home)]
302
+ + [(name, config, key) for name, _, config, key, _ in _other_apps(home)])
303
+ for label, config, key in targets:
263
304
  if not config.exists():
264
305
  continue
265
- settings = json.loads(config.read_text(encoding="utf-8"))
266
- if settings.get("mcpServers", {}).pop(NAME, None) is not None:
306
+ try:
307
+ settings = json.loads(config.read_text(encoding="utf-8"))
308
+ except ValueError:
309
+ continue
310
+ if settings.get(key, {}).pop(NAME, None) is not None:
267
311
  temporary = config.with_suffix(".graph-mind.tmp")
268
312
  temporary.write_text(json.dumps(settings, ensure_ascii=False, indent=2),
269
313
  encoding="utf-8")
270
314
  os.replace(temporary, config)
271
- removed.append("Claude Code" if config.name == ".claude.json" else "Claude Desktop")
315
+ removed.append(label)
272
316
  codex_config = home / ".codex" / "config.toml"
273
317
  if codex_config.exists():
274
318
  text = codex_config.read_text(encoding="utf-8")
@@ -36,17 +36,34 @@ def use_own_hf_home() -> None:
36
36
  use_own_hf_home()
37
37
 
38
38
 
39
+ MODEL_FILES = ("tokenizer.json", "onnx/model.onnx")
40
+
41
+
39
42
  def model_on_disk() -> bool:
43
+ """Both files the embedder reads are already downloaded (an install from before 0.4 has the
44
+ torch weights in the same folder, but not the ONNX file)."""
40
45
  hf_home = Path(os.environ.get("HF_HOME") or Path.home() / ".cache" / "huggingface")
41
- return (hf_home / "hub" / ("models--" + LOCAL_MODEL.replace("/", "--"))).is_dir()
46
+ snapshots = hf_home / "hub" / ("models--" + LOCAL_MODEL.replace("/", "--")) / "snapshots"
47
+ return any(all((snapshot / name).exists() for name in MODEL_FILES)
48
+ for snapshot in (snapshots.iterdir() if snapshots.is_dir() else []))
49
+
50
+
51
+ def model_paths(model=LOCAL_MODEL) -> tuple:
52
+ """Local paths of the tokenizer and the ONNX model, downloaded on first use."""
53
+ from huggingface_hub import hf_hub_download
54
+ return tuple(hf_hub_download(model, name) for name in MODEL_FILES)
42
55
 
43
56
 
44
57
  class LocalEmbedder:
45
- """On-device multilingual embeddings via raw transformers (mean pooling, L2-normalized).
46
- No text leaves the machine — required by the user-owned local-brain promise."""
58
+ """On-device multilingual embeddings: the model's own ONNX export run by ONNX Runtime,
59
+ mean pooling, L2-normalized. No text leaves the machine.
60
+
61
+ Until 0.4 this ran on torch + transformers: same model, same vectors (on 1,000 texts the
62
+ largest difference was 2.4e-7, cosine 1.000000), but a 2 GB install, the Visual C++ runtime,
63
+ and 48-71 s to load on a OneDrive-synced Windows PC against 6-9 s now."""
47
64
 
48
- _model = None
49
- _tok = None
65
+ _model = None # an onnxruntime.InferenceSession
66
+ _tok = None # a tokenizers.Tokenizer
50
67
  _lock = threading.RLock() # model load + cache writes are not thread-safe
51
68
 
52
69
  FLUSH_EVERY = 5000 # vectors between writes: bounds lost work without rewriting per call
@@ -69,11 +86,17 @@ class LocalEmbedder:
69
86
 
70
87
  def _load(self):
71
88
  if LocalEmbedder._model is None:
72
- import torch
73
- from transformers import AutoModel, AutoTokenizer
74
- LocalEmbedder._tok = AutoTokenizer.from_pretrained(self.model_name)
75
- LocalEmbedder._model = AutoModel.from_pretrained(self.model_name).eval()
76
- LocalEmbedder._torch = torch
89
+ import onnxruntime
90
+ from tokenizers import Tokenizer
91
+ tokenizer_file, model_file = model_paths(self.model_name)
92
+ tokenizer = Tokenizer.from_file(tokenizer_file)
93
+ tokenizer.enable_truncation(256) # as transformers did: max_length=256
94
+ tokenizer.enable_padding()
95
+ options = onnxruntime.SessionOptions()
96
+ options.log_severity_level = 3 # warnings about unused initializers: noise
97
+ LocalEmbedder._tok = tokenizer
98
+ LocalEmbedder._model = onnxruntime.InferenceSession(
99
+ model_file, options, providers=["CPUExecutionProvider"])
77
100
  LocalEmbedder._loaded.set()
78
101
 
79
102
  def ready(self, timeout) -> bool:
@@ -97,16 +120,18 @@ class LocalEmbedder:
97
120
  return hashlib.sha256((self.model_name + "\0" + text).encode("utf-8")).hexdigest()
98
121
 
99
122
  def _encode(self, texts):
100
- import torch
101
- torch = LocalEmbedder._torch
102
- batch = LocalEmbedder._tok(texts, padding=True, truncation=True,
103
- max_length=256, return_tensors="pt")
104
- with torch.no_grad():
105
- out = LocalEmbedder._model(**batch)
106
- mask = batch["attention_mask"].unsqueeze(-1).float()
107
- emb = (out.last_hidden_state * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
108
- emb = torch.nn.functional.normalize(emb, dim=1)
109
- return emb.tolist()
123
+ import numpy as np
124
+ encoded = LocalEmbedder._tok.encode_batch(texts)
125
+ ids = np.array([e.ids for e in encoded], dtype=np.int64)
126
+ mask = np.array([e.attention_mask for e in encoded], dtype=np.int64)
127
+ feed = {"input_ids": ids, "attention_mask": mask}
128
+ if "token_type_ids" in {i.name for i in LocalEmbedder._model.get_inputs()}:
129
+ feed["token_type_ids"] = np.zeros_like(ids)
130
+ hidden = LocalEmbedder._model.run(None, feed)[0]
131
+ weights = mask[..., None].astype(np.float32)
132
+ pooled = (hidden * weights).sum(1) / np.clip(weights.sum(1), 1e-9, None)
133
+ pooled /= np.clip(np.linalg.norm(pooled, axis=1, keepdims=True), 1e-12, None)
134
+ return pooled.tolist()
110
135
 
111
136
  def embed(self, texts):
112
137
  texts = [t.strip()[:2000] for t in texts]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graph-mind-memory"
7
- version = "0.3.4"
7
+ version = "0.5.0"
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"
@@ -18,15 +18,16 @@ classifiers = [
18
18
  "Operating System :: OS Independent",
19
19
  ]
20
20
  dependencies = [
21
- "torch>=2.2,<3",
22
21
  "mcp>=2,<3",
23
22
  "numpy",
24
- "transformers",
25
- "tiktoken",
23
+ "onnxruntime>=1.17",
24
+ "tokenizers>=0.15",
25
+ "huggingface_hub>=0.20",
26
26
  # hosts the shared brain's Postgres; built for Python 3.9-3.12 only. Joining another PC's
27
27
  # brain (psycopg) and everything else work on any supported Python.
28
28
  "pgserver; python_version < '3.13'",
29
29
  "psycopg[binary]",
30
+ "cryptography", # seals lines on a shared hub
30
31
  "psutil",
31
32
  "fasteners",
32
33
  ]