graph-mind-memory 0.2.1__tar.gz → 0.3.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.2.1/graph_mind_memory.egg-info → graph_mind_memory-0.3.0}/PKG-INFO +7 -2
  2. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/README.md +6 -1
  3. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/automatic_capture.py +5 -3
  4. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/conversation_memory.py +62 -2
  5. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/forget.py +42 -5
  6. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/graph_mind_mcp_server.py +64 -1
  7. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0/graph_mind_memory.egg-info}/PKG-INFO +7 -2
  8. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/pyproject.toml +1 -1
  9. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/LICENSE +0 -0
  10. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/associative_memory.py +0 -0
  11. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/automatic_capture_cli.py +0 -0
  12. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/brain_log.py +0 -0
  13. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/clock.py +0 -0
  14. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/coding_memory.py +0 -0
  15. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/development_memory.py +0 -0
  16. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/development_paths.py +0 -0
  17. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/embedding_warmup.py +0 -0
  18. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/entity_timeline.py +0 -0
  19. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/SOURCES.txt +0 -0
  20. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/dependency_links.txt +0 -0
  21. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/entry_points.txt +0 -0
  22. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/requires.txt +0 -0
  23. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/graph_mind_memory.egg-info/top_level.txt +0 -0
  24. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/install.py +0 -0
  25. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/local_brain.py +0 -0
  26. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/local_embedder.py +0 -0
  27. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/semantic_llm_extractor.py +0 -0
  28. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/semantic_memory.py +0 -0
  29. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/semantic_recall.py +0 -0
  30. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/setup.cfg +0 -0
  31. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/temporal_resolver.py +0 -0
  32. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.0}/universal_personal_memory.py +0 -0
  33. {graph_mind_memory-0.2.1 → graph_mind_memory-0.3.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.2.1
3
+ Version: 0.3.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
@@ -174,6 +174,10 @@ like `hunter2` has no recognizable format and is **not** masked. To remove somet
174
174
  graph-mind-forget # asks for the phrase without showing it, then confirms
175
175
  ```
176
176
 
177
+ Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
178
+ (`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
179
+ is not saved.
180
+
177
181
  It deletes every captured turn and memory containing the phrase from this PC and its indexes,
178
182
  removes their lines from the shared memory, and tells your other PCs to delete their copies on
179
183
  their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
@@ -189,9 +193,10 @@ their next sync. Only ids are shared for that, never the phrase. Your AI app's o
189
193
  | `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
190
194
  | `brain_remember` | save a sourced memory or decision (secrets masked) |
191
195
  | `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
196
+ | `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
192
197
  | `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
193
198
 
194
- Five tools on purpose: every tool's description is read by the model on every turn, and similar
199
+ Six tools on purpose: every tool's description is read by the model on every turn, and similar
195
200
  tools get confused with each other. Version 0.1 had thirteen.
196
201
 
197
202
  ---
@@ -148,6 +148,10 @@ like `hunter2` has no recognizable format and is **not** masked. To remove somet
148
148
  graph-mind-forget # asks for the phrase without showing it, then confirms
149
149
  ```
150
150
 
151
+ Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
152
+ (`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
153
+ is not saved.
154
+
151
155
  It deletes every captured turn and memory containing the phrase from this PC and its indexes,
152
156
  removes their lines from the shared memory, and tells your other PCs to delete their copies on
153
157
  their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
@@ -163,9 +167,10 @@ their next sync. Only ids are shared for that, never the phrase. Your AI app's o
163
167
  | `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
164
168
  | `brain_remember` | save a sourced memory or decision (secrets masked) |
165
169
  | `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
170
+ | `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
166
171
  | `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
167
172
 
168
- Five tools on purpose: every tool's description is read by the model on every turn, and similar
173
+ Six tools on purpose: every tool's description is read by the model on every turn, and similar
169
174
  tools get confused with each other. Version 0.1 had thirteen.
170
175
 
171
176
  ---
@@ -118,24 +118,26 @@ class AutomaticCaptureService:
118
118
  if shared:
119
119
  from brain_log import append
120
120
  publish = lambda turn: append(shared, "turn", turn) # a folder or a Postgres URL
121
+ from forget import forget_ids
122
+ forget = lambda turn_ids: forget_ids({"turn_ids": turn_ids}, self.db_path, shared)
121
123
  sources = []
122
124
  # A source whose folder does not exist yet (Codex never run on this PC) is skipped, not
123
125
  # fatal: it used to stop every source, so a Claude-Code-only user captured nothing.
124
126
  if Path(self.config["codex_sessions_root"]).is_dir():
125
127
  sources.append(("codex", CodexConversationCapture(
126
128
  self.config["codex_sessions_root"], store, state_path=self.conversation_cursor,
127
- workspace_scopes=scopes, now=self.now, publish=publish), self.conversation_cursor))
129
+ workspace_scopes=scopes, now=self.now, publish=publish, forget=forget), self.conversation_cursor))
128
130
  claude_root = Path(self.config.get("claude_projects_root")
129
131
  or Path.home() / ".claude" / "projects")
130
132
  if claude_root.is_dir():
131
133
  sources.append(("claude-code", ClaudeCodeConversationCapture(
132
134
  claude_root, store, state_path=self.claude_cursor,
133
- workspace_scopes=scopes, now=self.now, publish=publish), self.claude_cursor))
135
+ workspace_scopes=scopes, now=self.now, publish=publish, forget=forget), self.claude_cursor))
134
136
  cowork = self.config.get("claude_cowork_root") or cowork_root()
135
137
  if cowork and Path(cowork).is_dir():
136
138
  sources.append(("claude-cowork", ClaudeCoworkConversationCapture(
137
139
  cowork, store, state_path=self.cowork_cursor,
138
- workspace_scopes=scopes, now=self.now, publish=publish), self.cowork_cursor))
140
+ workspace_scopes=scopes, now=self.now, publish=publish, forget=forget), self.cowork_cursor))
139
141
  return sources
140
142
 
141
143
  def baseline(self):
@@ -238,7 +238,7 @@ class CodexConversationCapture:
238
238
  CLIENT = "codex"
239
239
 
240
240
  def __init__(self, sessions_root, store, *, state_path, workspace_scopes=None,
241
- now=None, publish=None):
241
+ now=None, publish=None, forget=None):
242
242
  self.sessions_root = Path(sessions_root).resolve()
243
243
  if not self.sessions_root.is_dir():
244
244
  raise ValueError("CODEX_SESSIONS_ROOT_NOT_FOUND")
@@ -251,6 +251,46 @@ class CodexConversationCapture:
251
251
  for key, value in (workspace_scopes or {}).items()}
252
252
  self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
253
253
  self.publish = publish # called with each turn that is new to this store
254
+ # erases captured turns everywhere (forget.forget_ids with the shared log); by default
255
+ # this store only
256
+ self.forget = forget or (lambda turn_ids: __import__("forget").erase(
257
+ self.store.path, {"turn_ids": turn_ids}))
258
+
259
+ FORGET_TOOL = "brain_forget"
260
+ FORGET_WINDOW_MINUTES = 30
261
+
262
+ def _forget_call(self, item):
263
+ """Whether this record is the assistant calling brain_forget. Codex: a function call
264
+ named for it, or (code mode) a completed MCP tool call of it."""
265
+ payload = item.get("payload") or {}
266
+ if payload.get("type") in {"function_call", "custom_tool_call"}:
267
+ return str(payload.get("name", "")).endswith(self.FORGET_TOOL)
268
+ call = payload.get("item") if isinstance(payload.get("item"), dict) else {}
269
+ return call.get("type") == "McpToolCall" and call.get("tool") == self.FORGET_TOOL
270
+
271
+ def _forget_exchange(self, session_id, at, sessions):
272
+ """The assistant called brain_forget: what led up to it is a conversation about deleting,
273
+ so it goes too. From the user turn that asked (or from the previous forget call, when this
274
+ is the second, deleting call of the same exchange) up to now; the assistant's reply that
275
+ follows is skipped until the user speaks again."""
276
+ at = self.store._time(at)
277
+ previous = sessions.get(session_id, {}).get("signal_at")
278
+ db = self.store.db
279
+ if previous and (datetime.fromisoformat(at) - datetime.fromisoformat(previous)
280
+ ).total_seconds() <= self.FORGET_WINDOW_MINUTES * 60:
281
+ since = previous
282
+ else:
283
+ since = db.execute(
284
+ "SELECT max(happened_at) FROM conversation_turns WHERE client=? AND "
285
+ "client_session_id=? AND role='user' AND happened_at<=?",
286
+ (self.CLIENT, session_id, at)).fetchone()[0]
287
+ if since:
288
+ ids = [row[0] for row in db.execute(
289
+ "SELECT turn_id FROM conversation_turns WHERE client=? AND client_session_id=? "
290
+ "AND happened_at>=?", (self.CLIENT, session_id, since))]
291
+ if ids:
292
+ self.forget(ids)
293
+ sessions[session_id] = {"signal_at": at, "quiet": True}
254
294
 
255
295
  def _files(self):
256
296
  return sorted(self.sessions_root.rglob("*.jsonl"))
@@ -293,11 +333,13 @@ class CodexConversationCapture:
293
333
  value = json.loads(self.state_path.read_text(encoding="utf-8"))
294
334
  if value.get("capture_version") != CAPTURE_VERSION:
295
335
  raise ValueError("CONVERSATION_CAPTURE_VERSION_MISMATCH")
336
+ self._forget_sessions = value.get("forget_sessions", {})
296
337
  return value.get("offsets", {})
297
338
 
298
339
  def _write_state(self, offsets):
299
340
  value = {"capture_version": CAPTURE_VERSION,
300
- "sessions_root": str(self.sessions_root), "offsets": offsets}
341
+ "sessions_root": str(self.sessions_root), "offsets": offsets,
342
+ "forget_sessions": getattr(self, "_forget_sessions", {})}
301
343
  temporary = self.state_path.with_suffix(".tmp")
302
344
  temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n",
303
345
  encoding="utf-8")
@@ -332,12 +374,21 @@ class CodexConversationCapture:
332
374
  item = json.loads(line.decode("utf-8", "strict"))
333
375
  except (UnicodeDecodeError, json.JSONDecodeError):
334
376
  continue
377
+ if self._forget_call(item):
378
+ self._forget_exchange(session_id, item.get("timestamp") or known_at,
379
+ self._forget_sessions)
380
+ continue
335
381
  message = self._message(item)
336
382
  if message is None:
337
383
  continue
338
384
  role, raw = message
339
385
  if not raw.strip():
340
386
  continue
387
+ quiet = self._forget_sessions.get(session_id)
388
+ if quiet and quiet.get("quiet"):
389
+ if role == "assistant":
390
+ continue # the reply to a forget call: not remembered
391
+ quiet["quiet"] = False # the user spoke again: the exchange is over
341
392
  cleaned, count = _redact(raw)
342
393
  if not cleaned:
343
394
  continue
@@ -413,6 +464,15 @@ class ClaudeCodeConversationCapture(CodexConversationCapture):
413
464
  resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
414
465
  return session_id, cwd, self.workspace_scopes.get(resolved_cwd, "global")
415
466
 
467
+ def _forget_call(self, item):
468
+ """An assistant record with a tool_use block calling brain_forget (any MCP prefix)."""
469
+ if item.get("type") != "assistant" or item.get("isSidechain"):
470
+ return False
471
+ content = (item.get("message") or {}).get("content")
472
+ return isinstance(content, list) and any(
473
+ isinstance(block, dict) and block.get("type") == "tool_use"
474
+ and str(block.get("name", "")).endswith(self.FORGET_TOOL) for block in content)
475
+
416
476
  def _message(self, item):
417
477
  if item.get("isSidechain") or item.get("isMeta"):
418
478
  return None
@@ -119,19 +119,56 @@ def scrub_shared(shared, ids: dict) -> int:
119
119
  return removed
120
120
 
121
121
 
122
- def forget(phrase: str, index: Path, shared=None) -> dict:
123
- """Find, erase here, scrub the shared log, and tell the other PCs."""
122
+ def forget_ids(ids: dict, index: Path, shared=None) -> dict:
123
+ """Erase these turns and memories here, scrub the shared log, and tell the other PCs."""
124
124
  from brain_log import append
125
- ids = find(index, phrase)
126
- if not (ids["turn_ids"] or ids["memory_ids"]):
125
+ if not (ids.get("turn_ids") or ids.get("memory_ids")):
127
126
  return {"turns": 0, "memories": 0, "shared_lines_removed": 0}
127
+ ids = {"turn_ids": list(ids.get("turn_ids", [])),
128
+ "memory_ids": list(ids.get("memory_ids", []))}
128
129
  result = erase(index, ids)
129
130
  if shared:
130
131
  result["shared_lines_removed"] = scrub_shared(shared, ids)
131
- append(shared, "forget", ids) # ids only: the phrase never leaves here
132
+ append(shared, "forget", ids) # ids only: the text never leaves here
132
133
  return result
133
134
 
134
135
 
136
+ def forget(phrase: str, index: Path, shared=None) -> dict:
137
+ """Find everything containing the phrase, then forget it as forget_ids does."""
138
+ return forget_ids(find(index, phrase), index, shared)
139
+
140
+
141
+ SECRET_HINTS = ("password", "passwd", "pw", "pin", "비밀번호", "비번", "암호", "key", "token",
142
+ "secret", "api")
143
+ LINKS = {"is", "was", "=", ":", "->", "는", "은", "이", "가"}
144
+
145
+
146
+ def masked_preview(text: str, width: int = 90) -> str:
147
+ """Enough of a turn to recognise it, with what could be a secret replaced by ****: the word
148
+ after "password"/"비번"/"key"..., anything after "password:" or "key=", and any word that
149
+ mixes letters with digits or symbols. Over-masks on purpose; the original is never shown, so
150
+ listing candidates does not put a secret back into the chat it is being removed from."""
151
+ out, hide_next = [], False
152
+ for token in str(text).split():
153
+ lowered = token.lower()
154
+ bare = token.strip(".,!?\"'()[]{}")
155
+ hint = any(word in lowered for word in SECRET_HINTS)
156
+ if hint and (":" in token or "=" in token):
157
+ separator = ":" if ":" in token else "="
158
+ out.append(token.split(separator, 1)[0] + separator + "****")
159
+ hide_next = not token.split(separator, 1)[1]
160
+ continue
161
+ if hide_next and lowered in LINKS: # "password is ...", "비번은 = ..."
162
+ out.append(token)
163
+ continue
164
+ secretish = hide_next or (any(c.isdigit() for c in bare) and any(c.isalpha() for c in bare)) \
165
+ or any(c in "!@#$%^&*_=+<>|~`\\" for c in bare)
166
+ out.append("****" if secretish else token)
167
+ hide_next = hint
168
+ preview = " ".join(out)
169
+ return preview if len(preview) <= width else preview[:width] + "…"
170
+
171
+
135
172
  def main(argv=None):
136
173
  from brain_log import folder
137
174
  from development_paths import default_development_db
@@ -342,7 +342,9 @@ def build_server(db_path=None):
342
342
  "replaying full history. Use brain_recall to look memories up directly, or with "
343
343
  "`entity` for the history of one thing in order. For code changed in a time range, "
344
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."
345
+ "memory lives and shares it with the user's other PCs. When the user asks to "
346
+ "delete something from memory, use brain_forget: list first, delete only what the "
347
+ "user picks. Preserve provenance."
346
348
  ),
347
349
  version=SERVER_VERSION,
348
350
  )
@@ -605,6 +607,67 @@ def build_server(db_path=None):
605
607
  "existing_memories_shared": exported or "not shared; ask the user before "
606
608
  "setting share_existing=true"}
607
609
 
610
+ @server.tool(
611
+ name="brain_forget",
612
+ structured_output=True,
613
+ annotations=types.ToolAnnotations(
614
+ readOnlyHint=False,
615
+ destructiveHint=True,
616
+ idempotentHint=True,
617
+ openWorldHint=False,
618
+ ),
619
+ )
620
+ def brain_forget(query: str | None = None, ids: list[str] | None = None) -> dict[str, Any]:
621
+ """Delete what the user wants gone from memory: "delete the password I typed earlier",
622
+ "그 토큰 지워줘", "forget what I said about my ex".
623
+
624
+ Always two calls. First `query`: what to look for, in general words ("password", "wifi",
625
+ "DB login"); never repeat a secret the user typed. It returns numbered candidates with
626
+ masked previews. Show the user exactly those previews and ask which to delete; never
627
+ quote the original text. Then call again with the `ids` the user picked, and only those.
628
+
629
+ Deleting removes them from this PC, its search indexes, the shared memory and the user's
630
+ other PCs. This exchange about deleting is not saved either. The AI app's own chat
631
+ history is separate and untouched (Claude Code keeps ~/.claude/projects).
632
+ """
633
+ import sqlite3
634
+
635
+ from forget import forget_ids, masked_preview
636
+ if ids:
637
+ db = sqlite3.connect(database, timeout=30)
638
+ try:
639
+ turns = [row[0] for row in db.execute(
640
+ "SELECT turn_id FROM conversation_turns WHERE turn_id IN ("
641
+ + ",".join("?" * len(ids)) + ")", ids)]
642
+ memories = [row[0] for row in db.execute(
643
+ "SELECT memory_id FROM local_brain_memories WHERE memory_id IN ("
644
+ + ",".join("?" * len(ids)) + ")", ids)]
645
+ except sqlite3.OperationalError:
646
+ turns, memories = [], []
647
+ finally:
648
+ db.close()
649
+ from brain_log import folder
650
+ try:
651
+ shared = folder()
652
+ except Exception:
653
+ shared = None
654
+ result = forget_ids({"turn_ids": turns, "memory_ids": memories}, database, shared)
655
+ return {"status": "FORGOTTEN", **result,
656
+ "not_found": [i for i in ids if i not in turns and i not in memories]}
657
+ if not query:
658
+ return {"status": "QUERY_OR_IDS_REQUIRED", "candidates": []}
659
+ recalled = gather(database, query, as_of=_now(), limit=10)
660
+ candidates = [{"id": m["memory_id"], "kind": "memory", "when": m.get("effective_at"),
661
+ "preview": masked_preview(m.get("content", ""))}
662
+ for m in recalled.get("matches", [])]
663
+ candidates += [{"id": t["turn_id"], "kind": f"{t.get('role')} turn in {t.get('client')}",
664
+ "when": t.get("happened_at"),
665
+ "preview": masked_preview(t.get("content", ""))}
666
+ for t in recalled.get("conversation_turns", [])]
667
+ return {"status": "CANDIDATES", "candidates": candidates,
668
+ "next": "Show these numbered with their masked previews exactly as given, ask "
669
+ "which to delete, then call brain_forget with those ids."}
670
+
608
671
  @server.tool(
609
672
  name="code_activity",
610
673
  structured_output=True,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graph-mind-memory
3
- Version: 0.2.1
3
+ Version: 0.3.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
@@ -174,6 +174,10 @@ like `hunter2` has no recognizable format and is **not** masked. To remove somet
174
174
  graph-mind-forget # asks for the phrase without showing it, then confirms
175
175
  ```
176
176
 
177
+ Or just ask your AI: *"delete the password I typed earlier"*. It lists masked candidates
178
+ (`wifi password is ****`), deletes only the ones you pick, and the conversation about deleting
179
+ is not saved.
180
+
177
181
  It deletes every captured turn and memory containing the phrase from this PC and its indexes,
178
182
  removes their lines from the shared memory, and tells your other PCs to delete their copies on
179
183
  their next sync. Only ids are shared for that, never the phrase. Your AI app's own history
@@ -189,9 +193,10 @@ their next sync. Only ids are shared for that, never the phrase. Your AI app's o
189
193
  | `brain_recall` | look memories and captured turns up directly; `entity=` for everything about one thing, in order |
190
194
  | `brain_remember` | save a sourced memory or decision (secrets masked) |
191
195
  | `brain_folder` | where the memory lives; share it with your other PCs; reindex an imported backlog |
196
+ | `brain_forget` | "delete the password I typed": lists masked candidates, deletes only what you pick, everywhere; the exchange itself is not saved |
192
197
  | `code_activity` | what changed in a project, file or symbol, and when (when a code folder is watched) |
193
198
 
194
- Five tools on purpose: every tool's description is read by the model on every turn, and similar
199
+ Six tools on purpose: every tool's description is read by the model on every turn, and similar
195
200
  tools get confused with each other. Version 0.1 had thirteen.
196
201
 
197
202
  ---
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graph-mind-memory"
7
- version = "0.2.1"
7
+ version = "0.3.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"