graph-mind-memory 0.1.4__tar.gz → 0.2.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 (32) hide show
  1. {graph_mind_memory-0.1.4/graph_mind_memory.egg-info → graph_mind_memory-0.2.0}/PKG-INFO +15 -14
  2. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/README.md +14 -13
  3. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/brain_log.py +4 -1
  4. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/graph_mind_mcp_server.py +47 -243
  5. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0/graph_mind_memory.egg-info}/PKG-INFO +15 -14
  6. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/install.py +13 -9
  7. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/local_embedder.py +16 -14
  8. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/pyproject.toml +1 -1
  9. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/LICENSE +0 -0
  10. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/associative_memory.py +0 -0
  11. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/automatic_capture.py +0 -0
  12. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/automatic_capture_cli.py +0 -0
  13. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/clock.py +0 -0
  14. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/coding_memory.py +0 -0
  15. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/conversation_memory.py +0 -0
  16. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/development_memory.py +0 -0
  17. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/development_paths.py +0 -0
  18. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/embedding_warmup.py +0 -0
  19. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/entity_timeline.py +0 -0
  20. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/graph_mind_memory.egg-info/SOURCES.txt +0 -0
  21. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
  22. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/graph_mind_memory.egg-info/entry_points.txt +0 -0
  23. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/graph_mind_memory.egg-info/requires.txt +0 -0
  24. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/graph_mind_memory.egg-info/top_level.txt +0 -0
  25. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/local_brain.py +0 -0
  26. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/semantic_llm_extractor.py +0 -0
  27. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/semantic_memory.py +0 -0
  28. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/semantic_recall.py +0 -0
  29. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/setup.cfg +0 -0
  30. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/temporal_resolver.py +0 -0
  31. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.0}/universal_personal_memory.py +0 -0
  32. {graph_mind_memory-0.1.4 → graph_mind_memory-0.2.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.1.4
3
+ Version: 0.2.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
@@ -41,6 +41,10 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
41
41
 
42
42
  *Switch models; keep the memory.*
43
43
 
44
+ <img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
45
+
46
+ <sub>A real run, not a mock-up: <code>python demo/record_demo.py</code> records it from a fresh store.</sub>
47
+
44
48
  </div>
45
49
 
46
50
  > [!NOTE]
@@ -168,19 +172,14 @@ private keys, and passwords inside URLs.
168
172
 
169
173
  | tool | what it does |
170
174
  |---|---|
171
- | `brain_context` | a bounded packet of the memories and conversation turns that answer a request |
172
- | `brain_recall` | inspect memories and captured turns directly |
173
- | `brain_remember` | save a sourced memory (secrets masked) |
174
- | `brain_associate` | recall from a vague cue |
175
- | `brain_timeline` | everything about one thing, oldest first, with replaced entries marked |
176
- | `brain_folder` | show or choose where the memory lives; share it with other PCs |
177
- | `brain_index` | bring an imported backlog up to date |
178
- | `conversation_recall` | search the captured turns themselves |
179
- | `code_activity` | what changed in a project, file or symbol, and when |
180
- | `memory_record` | record a development event or decision |
181
- | `project_status` | current state of a project |
182
- | `resume_project` | everything needed to pick a project back up |
183
- | `explain_decision` | a decision, its reason and its history |
175
+ | `brain_context` | a bounded packet of the past turns and memories that answer a request; `recent=true` for "where did we leave off?" |
176
+ | `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
177
+ | `brain_remember` | save a sourced memory or decision (secrets masked) |
178
+ | `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
179
+ | `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
180
+
181
+ Five tools on purpose: every tool's description is read by the model on every turn, and similar
182
+ tools get confused with each other. Version 0.1 had thirteen.
184
183
 
185
184
  ---
186
185
 
@@ -228,6 +227,8 @@ python -m unittest discover -p "test_*.py"
228
227
 
229
228
  ## Known limits
230
229
 
230
+ - **The first question after an AI app starts waits for the embedding model to load** (a few
231
+ seconds; 30-50 s on a slow or synced disk). Later questions take well under a second.
231
232
  - **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
232
233
  use.
233
234
  - **Capture follows each app's transcript format,** which is not a public interface. An app update
@@ -15,6 +15,10 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
15
15
 
16
16
  *Switch models; keep the memory.*
17
17
 
18
+ <img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
19
+
20
+ <sub>A real run, not a mock-up: <code>python demo/record_demo.py</code> records it from a fresh store.</sub>
21
+
18
22
  </div>
19
23
 
20
24
  > [!NOTE]
@@ -142,19 +146,14 @@ private keys, and passwords inside URLs.
142
146
 
143
147
  | tool | what it does |
144
148
  |---|---|
145
- | `brain_context` | a bounded packet of the memories and conversation turns that answer a request |
146
- | `brain_recall` | inspect memories and captured turns directly |
147
- | `brain_remember` | save a sourced memory (secrets masked) |
148
- | `brain_associate` | recall from a vague cue |
149
- | `brain_timeline` | everything about one thing, oldest first, with replaced entries marked |
150
- | `brain_folder` | show or choose where the memory lives; share it with other PCs |
151
- | `brain_index` | bring an imported backlog up to date |
152
- | `conversation_recall` | search the captured turns themselves |
153
- | `code_activity` | what changed in a project, file or symbol, and when |
154
- | `memory_record` | record a development event or decision |
155
- | `project_status` | current state of a project |
156
- | `resume_project` | everything needed to pick a project back up |
157
- | `explain_decision` | a decision, its reason and its history |
149
+ | `brain_context` | a bounded packet of the past turns and memories that answer a request; `recent=true` for "where did we leave off?" |
150
+ | `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
151
+ | `brain_remember` | save a sourced memory or decision (secrets masked) |
152
+ | `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
153
+ | `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
154
+
155
+ Five tools on purpose: every tool's description is read by the model on every turn, and similar
156
+ tools get confused with each other. Version 0.1 had thirteen.
158
157
 
159
158
  ---
160
159
 
@@ -202,6 +201,8 @@ python -m unittest discover -p "test_*.py"
202
201
 
203
202
  ## Known limits
204
203
 
204
+ - **The first question after an AI app starts waits for the embedding model to load** (a few
205
+ seconds; 30-50 s on a slow or synced disk). Later questions take well under a second.
205
206
  - **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
206
207
  use.
207
208
  - **Capture follows each app's transcript format,** which is not a public interface. An app update
@@ -48,7 +48,10 @@ def device_name() -> str:
48
48
  def config_path() -> Path:
49
49
  """Where the chosen brain folder is remembered for every client on this machine."""
50
50
  override = os.environ.get("GRAPH_MIND_CONFIG")
51
- return Path(override) if override else Path.home() / ".graph-mind" / "config.json"
51
+ if override:
52
+ return Path(override)
53
+ from development_paths import graph_mind_home
54
+ return graph_mind_home() / "config.json" # ~/.graph-mind unless GRAPH_MIND_HOME
52
55
 
53
56
 
54
57
  def _config() -> dict:
@@ -15,7 +15,6 @@ from mcp.server import MCPServer
15
15
  from associative_memory import AssociativeMemoryIndex
16
16
  from coding_memory import CodingMemoryStore
17
17
  from conversation_memory import ConversationMemoryStore
18
- from development_memory import DevelopmentMemoryStore
19
18
  from development_paths import default_development_db
20
19
  from local_brain import LocalBrainStore
21
20
  from universal_personal_memory import build_context_pack, decide_recall
@@ -26,14 +25,6 @@ if hasattr(sys.stderr, "reconfigure"):
26
25
 
27
26
 
28
27
  SERVER_VERSION = "0.5.0"
29
- RecordableEventType = Literal[
30
- "OBJECTIVE_SET", "TASK_STARTED", "TASK_COMPLETED", "TASK_BLOCKED",
31
- "TASK_CANCELLED", "FILE_CHANGED", "DECISION_RECORDED", "TEST_RECORDED",
32
- "NEXT_ACTION_SET", "NEXT_ACTION_COMPLETED", "SESSION_SUMMARY",
33
- "EXPERIMENT_STARTED", "EXPERIMENT_STOPPED",
34
- ]
35
-
36
-
37
28
  # The embedding model is loaded from the local Hugging Face cache, but the library still asks the
38
29
  # hub over the network whether a newer revision exists, every time it loads. That made the first
39
30
  # save of a session take ~95 s and sent requests off the machine for no reason. Once the model is
@@ -346,17 +337,12 @@ def build_server(db_path=None):
346
337
  "stored or noted something, and do not add lines like 'I saved that to memory'. "
347
338
  "Mention memory only when the user asks what you remember or whether it was saved. "
348
339
  "Call brain_context first when a request may depend on the user's prior conversations, "
349
- "facts, preferences, documents, projects or work. It skips unrelated requests and "
350
- "returns a bounded evidence packet instead of replaying full history. Use brain_recall "
351
- "for direct memory inspection. It searches curated memories and automatically captured "
352
- "conversation turns without requiring a project ID. Use brain_associate for vague cues "
353
- "or indirect reminders, then verify its candidate sources with the deterministic tools. "
354
- "For questions about code changed in a time range, project, file or symbol, translate "
355
- "the wording into code_activity filters and use its indexed evidence. "
356
- "Call project_status or resume_project when an exact project ID is already known. "
357
- "Treat scope_completeness=UNATTESTED as partial observed history. "
358
- "Use brain_remember for general sourced memories and memory_record for sourced "
359
- "development events. Preserve provenance."
340
+ "facts, preferences, decisions, projects or work, including 'where did we leave off?'. "
341
+ "It skips unrelated requests and returns a bounded evidence packet instead of "
342
+ "replaying full history. Use brain_recall to look memories up directly, or with "
343
+ "`entity` for the history of one thing in order. For code changed in a time range, "
344
+ "project, file or symbol, use code_activity. brain_folder shows or changes where the "
345
+ "memory lives and shares it with the user's other PCs. Preserve provenance."
360
346
  ),
361
347
  version=SERVER_VERSION,
362
348
  )
@@ -395,6 +381,8 @@ def build_server(db_path=None):
395
381
  required: put the user's own sentence there verbatim. Add `entities` (who or what it is
396
382
  about) and `effective_at` (when it happened, ISO time) when you know them. If it updates
397
383
  something remembered earlier, pass that memory's id as `supersedes_memory_id`.
384
+ Decisions and their reasons are memories too: `memory_type="decision"`, the reason in
385
+ `content`, the project or component in `entities`.
398
386
 
399
387
  Save silently. Do not tell the user you saved it ("I've noted that", "저장해 뒀어"); just
400
388
  carry on with the conversation. Talk about memory only if the user asks what you remember
@@ -443,14 +431,21 @@ def build_server(db_path=None):
443
431
  openWorldHint=False,
444
432
  ),
445
433
  )
446
- def brain_recall(query: str, scopes: list[str] | None = None,
434
+ def brain_recall(query: str = "", entity: str | None = None,
435
+ scopes: list[str] | None = None,
447
436
  as_of: str | None = None,
448
437
  knowledge_cutoff: str | None = None,
449
438
  limit: int = 8, recent: bool = False,
450
439
  since: str | None = None,
451
440
  about_assistant: bool = False,
452
441
  around: str | None = None) -> dict[str, Any]:
453
- """Recall relevant local memories from natural wording without requiring a project ID.
442
+ """Look up memories and captured conversation turns directly, from natural wording.
443
+
444
+ `entity` instead answers "what happened to X, in order": every memory about that one
445
+ thing (the auth module, this project, my address), oldest first, with replaced entries
446
+ marked and the current one picked out. Similarity search returns only the nearest one or
447
+ two of four decisions made about one thing in four sessions; this returns all four. With
448
+ neither `query` nor `entity`, it lists what the store knows about, busiest first.
454
449
 
455
450
  Set `recent=true` whenever the question is about what happened lately or what the user was
456
451
  just doing: "what was I working on?", "where did we leave off?", "what did I do
@@ -467,6 +462,21 @@ def build_server(db_path=None):
467
462
  mention events days or weeks before or after, so that date misleads (measured: on dev2
468
463
  it pushed the answering turn out in 2 of 11 questions and helped 2).
469
464
  """
465
+ if entity or not query:
466
+ import sqlite3
467
+
468
+ import entity_timeline as timeline_index
469
+ shared_sync(database) # every device's memories are in this one index
470
+ db = sqlite3.connect(database)
471
+ try:
472
+ if not entity:
473
+ return {"entities": timeline_index.entities(db, limit=max(limit, 50))}
474
+ rows = timeline_index.timeline(db, entity, limit=max(limit, 50))
475
+ finally:
476
+ db.close()
477
+ current = [r for r in rows if not r["superseded"]]
478
+ return {"entity": entity, "entries": rows, "count": len(rows),
479
+ "current": current[-1] if current else None}
470
480
  return gather(database, query, scopes=scopes, as_of=as_of or _now(),
471
481
  knowledge_cutoff=knowledge_cutoff, limit=limit, recent=recent, since=since,
472
482
  about_assistant=about_assistant, around=around)
@@ -526,75 +536,6 @@ def build_server(db_path=None):
526
536
  "STRUCTURED_EXECUTION_REQUIRED_FOR_COMPLETE_SCOPE_CLAIMS")
527
537
  return packet
528
538
 
529
- @server.tool(
530
- name="brain_associate",
531
- structured_output=True,
532
- annotations=types.ToolAnnotations(
533
- readOnlyHint=True,
534
- destructiveHint=False,
535
- idempotentHint=True,
536
- openWorldHint=False,
537
- ),
538
- )
539
- def brain_associate(query: str, scopes: list[str] | None = None,
540
- concept_cues: list[str] | None = None,
541
- seed_limit: int = 12, hops: int = 2,
542
- fanout: int = 12, max_nodes: int = 40,
543
- limit: int = 8,
544
- minimum_concept_coverage: float = 0.75,
545
- grounding: str = "semantic",
546
- semantic_threshold: float = 0.35) -> dict[str, Any]:
547
- """Recall indirect candidate memories through bounded associative activation.
548
-
549
- grounding="semantic" (default, v0.7.3): on-device embedding pipeline — FTS seed ->
550
- local-embedding semantic gate -> query re-ranking. On untouched production evals it
551
- recovers Korean paraphrase recall (Vague Hit@5 0%->57%) at 0% control false association,
552
- and matches or beats literal on exact. Loads a local model lazily; all text stays on-device.
553
- grounding="auto": cheap literal gate first, semantic fallback only when literal abstains.
554
- grounding="literal": the deterministic token-overlap coverage gate only.
555
- """
556
- with AssociativeMemoryIndex(database) as index:
557
- if grounding in ("semantic", "auto"):
558
- from semantic_recall import (auto_associate, default_embedder,
559
- semantic_associate)
560
- embedder = default_embedder(database)
561
- if grounding == "auto":
562
- result = auto_associate(index, query, concept_cues, embedder=embedder,
563
- literal_gate=minimum_concept_coverage,
564
- semantic_threshold=semantic_threshold, scopes=scopes)
565
- else:
566
- result = semantic_associate(index, query, concept_cues,
567
- embedder=embedder, scopes=scopes, threshold=semantic_threshold)
568
- else:
569
- result = index.activate(query, scopes=scopes, concept_cues=concept_cues,
570
- seed_limit=seed_limit, hops=hops, fanout=fanout,
571
- max_nodes=max_nodes, limit=limit,
572
- minimum_concept_coverage=minimum_concept_coverage)
573
- result["evidence_status"] = "CANDIDATES_REQUIRE_SOURCE_VERIFICATION"
574
- return result
575
-
576
- @server.tool(
577
- name="brain_index",
578
- structured_output=True,
579
- annotations=types.ToolAnnotations(
580
- readOnlyHint=False,
581
- destructiveHint=False,
582
- idempotentHint=True,
583
- openWorldHint=False,
584
- ),
585
- )
586
- def brain_index(limit: int = 1000) -> dict[str, Any]:
587
- """Bring the search index up to date: link new memories, then embed the unembedded ones.
588
-
589
- brain_remember already does this for what it writes; run this after importing a backlog, or
590
- on a store that was written by an older version.
591
- """
592
- from embedding_warmup import warm
593
- with AssociativeMemoryIndex(database) as index:
594
- synced = index.sync_sources(limit=limit)
595
- return {"synced": synced, "vectors_warmed": warm(database, limit=limit),
596
- "store": str(database)}
597
-
598
539
  @server.tool(
599
540
  name="brain_folder",
600
541
  structured_output=True,
@@ -605,7 +546,8 @@ def build_server(db_path=None):
605
546
  openWorldHint=False,
606
547
  ),
607
548
  )
608
- def brain_folder(path: str | None = None, share_existing: bool = False) -> dict[str, Any]:
549
+ def brain_folder(path: str | None = None, share_existing: bool = False,
550
+ reindex: bool = False) -> dict[str, Any]:
609
551
  """Show or choose the folder that holds the user's brain.
610
552
 
611
553
  Call it with `path` when the user names a folder for their memory, e.g. "use my Google
@@ -624,7 +566,16 @@ def build_server(db_path=None):
624
566
 
625
567
  `share_existing=true` copies what this PC already remembered into the folder, which uploads
626
568
  it to wherever the folder syncs. Set it only when the user has explicitly agreed to that.
569
+
570
+ `reindex=true` brings the search index up to date (links new memories, embeds unembedded
571
+ ones). Saving already does this; it is for a large imported backlog.
627
572
  """
573
+ if reindex:
574
+ from embedding_warmup import warm
575
+ with AssociativeMemoryIndex(database) as index:
576
+ synced = index.sync_sources(limit=1000)
577
+ return {"synced": synced, "vectors_warmed": warm(database, limit=1000),
578
+ "store": str(database)}
628
579
  from brain_log import device_name, devices, export_history, folder, set_folder, share, sync
629
580
  shared_out = None
630
581
  if path == "share":
@@ -654,62 +605,6 @@ def build_server(db_path=None):
654
605
  "existing_memories_shared": exported or "not shared; ask the user before "
655
606
  "setting share_existing=true"}
656
607
 
657
- @server.tool(
658
- name="brain_timeline",
659
- structured_output=True,
660
- annotations=types.ToolAnnotations(
661
- readOnlyHint=True,
662
- destructiveHint=False,
663
- idempotentHint=True,
664
- openWorldHint=False,
665
- ),
666
- )
667
- def brain_timeline(entity: str | None = None, limit: int = 50) -> dict[str, Any]:
668
- """Every memory about ONE thing, oldest first, with replaced entries marked.
669
-
670
- Similarity search answers "what relates to this question"; this answers "what happened to
671
- the auth module / this project / this device, in order". Those are the questions that keep
672
- failing otherwise, because the four decisions about one thing sit in four different
673
- sessions and only the nearest one or two come back. Call it with no entity to list what the
674
- store knows about, busiest first.
675
- """
676
- import sqlite3
677
-
678
- import entity_timeline as timeline_index
679
-
680
- shared_sync(database) # every device's memories are in this one index
681
- db = sqlite3.connect(database)
682
- try:
683
- if not entity:
684
- return {"entities": timeline_index.entities(db, limit=limit)}
685
- rows = timeline_index.timeline(db, entity, limit=limit)
686
- finally:
687
- db.close()
688
- current = [r for r in rows if not r["superseded"]]
689
- return {"entity": entity, "entries": rows[:limit], "count": len(rows),
690
- "current": current[-1] if current else None}
691
-
692
- @server.tool(
693
- name="conversation_recall",
694
- structured_output=True,
695
- annotations=types.ToolAnnotations(
696
- readOnlyHint=True,
697
- destructiveHint=False,
698
- idempotentHint=True,
699
- openWorldHint=False,
700
- ),
701
- )
702
- def conversation_recall(query: str, scopes: list[str] | None = None,
703
- limit: int = 10) -> dict[str, Any]:
704
- """Search redacted conversation turns captured after Local Brain activation."""
705
- try:
706
- with ConversationMemoryStore(database) as store:
707
- return store.search(query, scopes=scopes, limit=limit)
708
- except ValueError as error:
709
- return {"status": "UNKNOWN", "reason": "INVALID_CONVERSATION_QUERY",
710
- "detail": str(error), "scope_completeness": "UNATTESTED",
711
- "turns": []}
712
-
713
608
  @server.tool(
714
609
  name="code_activity",
715
610
  structured_output=True,
@@ -728,7 +623,10 @@ def build_server(db_path=None):
728
623
  change_kind: str | None = None,
729
624
  limit: int = 20,
730
625
  max_diff_chars: int = 6000) -> dict[str, Any]:
731
- """Return indexed code changes and diff evidence without rereading a repository."""
626
+ """Code changes the capture service recorded, with diff excerpts, filtered by project,
627
+ time range (`happened_from`/`happened_to`, ISO), file, symbol or kind. For "what did I
628
+ change in auth.py last week?" or "when did the parser change?". Empty unless a code folder
629
+ is being watched (automatic-capture.json, code_workspaces)."""
732
630
  try:
733
631
  bounded_diff_chars = max(200, min(max_diff_chars, 20000))
734
632
  with CodingMemoryStore(database) as store:
@@ -741,100 +639,6 @@ def build_server(db_path=None):
741
639
  "detail": str(error), "scope_completeness": "UNATTESTED",
742
640
  "changes": []}
743
641
 
744
- @server.tool(
745
- name="memory_record",
746
- structured_output=True,
747
- annotations=types.ToolAnnotations(
748
- readOnlyHint=False,
749
- destructiveHint=False,
750
- idempotentHint=False,
751
- openWorldHint=False,
752
- ),
753
- )
754
- def memory_record(project_id: str, session_id: str, event_type: RecordableEventType,
755
- subject_id: str, payload: dict[str, Any], source_ref: str,
756
- effective_at: str | None = None,
757
- source_type: str = "mcp-client",
758
- supersedes_event_id: str | None = None,
759
- event_id: str | None = None) -> dict[str, Any]:
760
- """Append one sourced development event without overwriting prior history."""
761
- known_at = _now()
762
- event = {"event_id": event_id or "mcp-" + uuid.uuid4().hex,
763
- "project_id": project_id, "session_id": session_id,
764
- "event_type": event_type, "effective_at": effective_at or known_at,
765
- "known_at": known_at, "actor": "assistant", "subject_id": subject_id,
766
- "payload": payload,
767
- "provenance": {"source_type": source_type, "source_ref": source_ref},
768
- "supersedes_event_id": supersedes_event_id}
769
- with DevelopmentMemoryStore(database) as store:
770
- result = store.record_event(event)
771
- return {**result, "project_id": project_id, "store": str(database)}
772
-
773
- @server.tool(
774
- name="project_status",
775
- structured_output=True,
776
- annotations=types.ToolAnnotations(
777
- readOnlyHint=True,
778
- destructiveHint=False,
779
- idempotentHint=True,
780
- openWorldHint=False,
781
- ),
782
- )
783
- def project_status(project_id: str, as_of: str | None = None,
784
- knowledge_cutoff: str | None = None) -> dict[str, Any]:
785
- """Return the current objective, active work and next actions for a project."""
786
- when = as_of or _now()
787
- with DevelopmentMemoryStore(database) as store:
788
- resumed = store.resume_project(project_id, as_of=when,
789
- knowledge_cutoff=knowledge_cutoff)
790
- if resumed["status"] != "KNOWN":
791
- return resumed
792
- return {key: resumed[key] for key in (
793
- "status", "reason", "project_id", "as_of", "knowledge_cutoff",
794
- "scope_completeness", "warnings", "objective", "current_task",
795
- "open_tasks", "next_actions", "active_experiments", "last_session",
796
- "evidence_event_ids")}
797
-
798
- @server.tool(
799
- name="resume_project",
800
- structured_output=True,
801
- annotations=types.ToolAnnotations(
802
- readOnlyHint=True,
803
- destructiveHint=False,
804
- idempotentHint=True,
805
- openWorldHint=False,
806
- ),
807
- )
808
- def resume_project(project_id: str, as_of: str | None = None,
809
- knowledge_cutoff: str | None = None,
810
- recent_file_limit: int = 10,
811
- recent_test_limit: int = 10) -> dict[str, Any]:
812
- """Build a full evidence-backed packet for continuing work in a new session."""
813
- when = as_of or _now()
814
- with DevelopmentMemoryStore(database) as store:
815
- return store.resume_project(project_id, as_of=when,
816
- knowledge_cutoff=knowledge_cutoff, recent_file_limit=recent_file_limit,
817
- recent_test_limit=recent_test_limit)
818
-
819
- @server.tool(
820
- name="explain_decision",
821
- structured_output=True,
822
- annotations=types.ToolAnnotations(
823
- readOnlyHint=True,
824
- destructiveHint=False,
825
- idempotentHint=True,
826
- openWorldHint=False,
827
- ),
828
- )
829
- def explain_decision(project_id: str, decision_id: str,
830
- as_of: str | None = None,
831
- knowledge_cutoff: str | None = None) -> dict[str, Any]:
832
- """Return the active decision, reason, correction history and provenance."""
833
- when = as_of or _now()
834
- with DevelopmentMemoryStore(database) as store:
835
- return store.explain_decision(project_id, decision_id, as_of=when,
836
- knowledge_cutoff=knowledge_cutoff)
837
-
838
642
  return server
839
643
 
840
644
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.1.4
3
+ Version: 0.2.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
@@ -41,6 +41,10 @@ Local-first memory for AI coding assistants. Verbatim storage, on your own PC:
41
41
 
42
42
  *Switch models; keep the memory.*
43
43
 
44
+ <img src="demo/demo.gif" alt="A decision made in Claude Code on Monday, recalled from Codex on Wednesday" width="760">
45
+
46
+ <sub>A real run, not a mock-up: <code>python demo/record_demo.py</code> records it from a fresh store.</sub>
47
+
44
48
  </div>
45
49
 
46
50
  > [!NOTE]
@@ -168,19 +172,14 @@ private keys, and passwords inside URLs.
168
172
 
169
173
  | tool | what it does |
170
174
  |---|---|
171
- | `brain_context` | a bounded packet of the memories and conversation turns that answer a request |
172
- | `brain_recall` | inspect memories and captured turns directly |
173
- | `brain_remember` | save a sourced memory (secrets masked) |
174
- | `brain_associate` | recall from a vague cue |
175
- | `brain_timeline` | everything about one thing, oldest first, with replaced entries marked |
176
- | `brain_folder` | show or choose where the memory lives; share it with other PCs |
177
- | `brain_index` | bring an imported backlog up to date |
178
- | `conversation_recall` | search the captured turns themselves |
179
- | `code_activity` | what changed in a project, file or symbol, and when |
180
- | `memory_record` | record a development event or decision |
181
- | `project_status` | current state of a project |
182
- | `resume_project` | everything needed to pick a project back up |
183
- | `explain_decision` | a decision, its reason and its history |
175
+ | `brain_context` | a bounded packet of the past turns and memories that answer a request; `recent=true` for "where did we leave off?" |
176
+ | `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
177
+ | `brain_remember` | save a sourced memory or decision (secrets masked) |
178
+ | `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
179
+ | `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
180
+
181
+ Five tools on purpose: every tool's description is read by the model on every turn, and similar
182
+ tools get confused with each other. Version 0.1 had thirteen.
184
183
 
185
184
  ---
186
185
 
@@ -228,6 +227,8 @@ python -m unittest discover -p "test_*.py"
228
227
 
229
228
  ## Known limits
230
229
 
230
+ - **The first question after an AI app starts waits for the embedding model to load** (a few
231
+ seconds; 30-50 s on a slow or synced disk). Later questions take well under a second.
231
232
  - **Daily use on Windows only so far.** macOS and Linux pass the tests in CI but have not seen real
232
233
  use.
233
234
  - **Capture follows each app's transcript format,** which is not a public interface. An app update
@@ -159,7 +159,7 @@ def capture_service(start: bool) -> str:
159
159
 
160
160
  def embedding_model():
161
161
  sys.path.insert(0, str(HERE))
162
- from local_embedder import LOCAL_MODEL, own_model_cache
162
+ from local_embedder import LOCAL_MODEL, own_hf_home
163
163
  try:
164
164
  from transformers import AutoModel, AutoTokenizer
165
165
  except OSError as error:
@@ -172,20 +172,24 @@ def embedding_model():
172
172
  " https://aka.ms/vs/17/release/vc_redist.x64.exe\n"
173
173
  "The apps are already connected; until then recall works by word search only.")
174
174
 
175
- def download(cache_dir):
176
- AutoTokenizer.from_pretrained(LOCAL_MODEL, cache_dir=cache_dir)
177
- AutoModel.from_pretrained(LOCAL_MODEL, cache_dir=cache_dir)
178
175
  try:
179
- download(None)
176
+ AutoTokenizer.from_pretrained(LOCAL_MODEL)
177
+ AutoModel.from_pretrained(LOCAL_MODEL)
180
178
  except OSError as error:
181
- # ~/.cache refused (access denied): keep the model in Graph-MIND's own folder instead,
182
- # where the server looks too (local_embedder.model_cache)
179
+ # ~/.cache refused (access denied): move Hugging Face's whole home under ~/.graph-mind,
180
+ # where every Graph-MIND process looks from now on (local_embedder.use_own_hf_home)
183
181
  if "PermissionError" not in str(error) and not isinstance(error.__cause__, PermissionError):
184
182
  raise
185
- own = own_model_cache()
183
+ own = own_hf_home()
186
184
  own.mkdir(parents=True, exist_ok=True)
187
185
  step(f"the usual model folder is not writable; using {own}")
188
- download(str(own))
186
+ # huggingface_hub fixed its folders when it was imported: download in a fresh process
187
+ subprocess.run([sys.executable, "-c",
188
+ "from transformers import AutoModel, AutoTokenizer; "
189
+ f"AutoTokenizer.from_pretrained({LOCAL_MODEL!r}); "
190
+ f"AutoModel.from_pretrained({LOCAL_MODEL!r})"],
191
+ env={**os.environ, "HF_HOME": str(own),
192
+ "HF_HUB_DISABLE_SYMLINKS_WARNING": "1"}, check=True)
189
193
 
190
194
 
191
195
  def main(argv=None):
@@ -19,22 +19,26 @@ ACTIVATE = dict(seed_limit=12, hops=2, fanout=8, max_nodes=24, limit=8) # froze
19
19
  SEED_LIMIT = ACTIVATE["seed_limit"]
20
20
 
21
21
 
22
- def own_model_cache() -> Path:
22
+ def own_hf_home() -> Path:
23
23
  from development_paths import graph_mind_home
24
- return graph_mind_home() / "models"
24
+ return graph_mind_home() / "huggingface"
25
25
 
26
26
 
27
- def model_cache():
28
- """None means Hugging Face's usual cache. Graph-MIND's own folder once the installer had to
29
- use it: on one fresh Windows profile ~/.cache could not be written (access denied)."""
30
- own = own_model_cache()
31
- return str(own) if own.is_dir() else None
27
+ def use_own_hf_home() -> None:
28
+ """Hugging Face keeps everything under HF_HOME (~/.cache/huggingface): the model, its xet
29
+ download cache, its lock files. On one fresh Windows profile that folder could not be created
30
+ (access denied), so the installer moved all of it under ~/.graph-mind; from then on every
31
+ process must look there. Read before huggingface_hub is imported, which fixes the paths."""
32
+ if not os.environ.get("HF_HOME") and own_hf_home().is_dir():
33
+ os.environ["HF_HOME"] = str(own_hf_home())
34
+
35
+
36
+ use_own_hf_home()
32
37
 
33
38
 
34
39
  def model_on_disk() -> bool:
35
- name = "models--" + LOCAL_MODEL.replace("/", "--")
36
- usual = Path(os.environ.get("HF_HOME") or Path.home() / ".cache" / "huggingface") / "hub"
37
- return (usual / name).is_dir() or (own_model_cache() / name).is_dir()
40
+ hf_home = Path(os.environ.get("HF_HOME") or Path.home() / ".cache" / "huggingface")
41
+ return (hf_home / "hub" / ("models--" + LOCAL_MODEL.replace("/", "--"))).is_dir()
38
42
 
39
43
 
40
44
  class LocalEmbedder:
@@ -61,10 +65,8 @@ class LocalEmbedder:
61
65
  if LocalEmbedder._model is None:
62
66
  import torch
63
67
  from transformers import AutoModel, AutoTokenizer
64
- cache = model_cache()
65
- LocalEmbedder._tok = AutoTokenizer.from_pretrained(self.model_name, cache_dir=cache)
66
- LocalEmbedder._model = AutoModel.from_pretrained(self.model_name,
67
- cache_dir=cache).eval()
68
+ LocalEmbedder._tok = AutoTokenizer.from_pretrained(self.model_name)
69
+ LocalEmbedder._model = AutoModel.from_pretrained(self.model_name).eval()
68
70
  LocalEmbedder._torch = torch
69
71
 
70
72
  def _key(self, text):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graph-mind-memory"
7
- version = "0.1.4"
7
+ version = "0.2.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"