graph-mind-memory 0.1.0__py3-none-any.whl

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.
automatic_capture.py ADDED
@@ -0,0 +1,288 @@
1
+ """Persistent background capture for Graph-MIND code and conversation memory."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import time
10
+
11
+ from associative_memory import AssociativeMemoryIndex
12
+ from coding_memory import CodingMemoryCapture, CodingMemoryStore
13
+ from conversation_memory import (ClaudeCodeConversationCapture, ClaudeCoworkConversationCapture,
14
+ cowork_root, CodexConversationCapture,
15
+ ConversationMemoryStore)
16
+ from development_paths import default_coding_state_dir, default_development_db, graph_mind_home
17
+ from semantic_memory import SemanticMemoryIngestion
18
+ from semantic_llm_extractor import (OpenAIResponsesSemanticExtractor,
19
+ SemanticLLMIngestion)
20
+
21
+
22
+ SERVICE_VERSION = "automatic-capture-v0.2"
23
+
24
+
25
+ def default_config_path():
26
+ return graph_mind_home() / "automatic-capture.json"
27
+
28
+
29
+ def default_service_state_dir():
30
+ return graph_mind_home() / "automatic-capture"
31
+
32
+
33
+ def _now():
34
+ return datetime.now().isoformat(timespec="microseconds")
35
+
36
+
37
+ def _atomic_json(path, value):
38
+ path = Path(path)
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ temporary = path.with_suffix(path.suffix + ".tmp")
41
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n",
42
+ encoding="utf-8")
43
+ os.replace(temporary, path)
44
+
45
+
46
+ def _workspace_key(path):
47
+ return str(Path(path).expanduser().resolve())
48
+
49
+
50
+ def make_config(*, workspace, project_id, session_id, sessions_root,
51
+ poll_seconds=5, code_poll_seconds=120, db_path=None):
52
+ workspace = _workspace_key(workspace)
53
+ sessions_root = _workspace_key(sessions_root)
54
+ return {
55
+ "service_version": SERVICE_VERSION,
56
+ "db_path": str(Path(db_path or default_development_db()).expanduser().resolve()),
57
+ "poll_seconds": max(1, int(poll_seconds)),
58
+ "code_poll_seconds": max(int(poll_seconds), int(code_poll_seconds)),
59
+ "semantic_llm": {"enabled": False, "provider": "openai",
60
+ "model": None, "poll_seconds": 60, "limit": 25},
61
+ "codex_sessions_root": sessions_root,
62
+ "workspace_scopes": {workspace: project_id},
63
+ "code_workspaces": [{"workspace": workspace, "project_id": project_id,
64
+ "session_id": session_id,
65
+ "state_dir": str(default_coding_state_dir(project_id))}],
66
+ }
67
+
68
+
69
+ def load_config(path=None):
70
+ path = Path(path or default_config_path()).expanduser().resolve()
71
+ if not path.is_file():
72
+ raise ValueError("AUTOMATIC_CAPTURE_CONFIG_REQUIRED")
73
+ value = json.loads(path.read_text(encoding="utf-8"))
74
+ if value.get("service_version") != SERVICE_VERSION:
75
+ raise ValueError("AUTOMATIC_CAPTURE_CONFIG_VERSION_MISMATCH")
76
+ if not isinstance(value.get("code_workspaces"), list):
77
+ raise ValueError("AUTOMATIC_CAPTURE_WORKSPACES_REQUIRED")
78
+ return value
79
+
80
+
81
+ class AutomaticCaptureService:
82
+ def __init__(self, config, *, state_dir=None, now=None):
83
+ self.config = config
84
+ self.state_dir = Path(state_dir or default_service_state_dir()).resolve()
85
+ self.state_dir.mkdir(parents=True, exist_ok=True)
86
+ self.status_path = self.state_dir / "status.json"
87
+ self.pid_path = self.state_dir / "service.pid"
88
+ self.conversation_cursor = self.state_dir / "codex-session-cursor.json"
89
+ self.claude_cursor = self.state_dir / "claude-code-session-cursor.json"
90
+ self.cowork_cursor = self.state_dir / "claude-cowork-session-cursor.json"
91
+ self.now = now or _now
92
+ self.db_path = Path(config["db_path"]).expanduser().resolve()
93
+
94
+ def _coding_capture(self, entry, store):
95
+ return CodingMemoryCapture(entry["workspace"], store,
96
+ project_id=entry["project_id"], session_id=entry["session_id"],
97
+ state_dir=entry.get("state_dir") or default_coding_state_dir(
98
+ entry["project_id"]), now=self.now)
99
+
100
+ def _conversation_captures(self, store):
101
+ """Every conversation source on this machine, each with its own read cursor.
102
+
103
+ Codex is configured explicitly. Claude Code is picked up from ~/.claude/projects when
104
+ that folder exists, unless claude_projects_root names another one, so that whatever the
105
+ user talks to is stored in full and not only what a model chose to remember.
106
+ """
107
+ scopes = self.config.get("workspace_scopes", {})
108
+ # With a shared folder, every captured turn also goes into the shared log, so a
109
+ # conversation held on this PC can be recalled on any other (brain_log.py).
110
+ from brain_log import folder
111
+ shared = folder() # config, GRAPH_MIND_FOLDER, or the chosen one
112
+ if self.config.get("memory_folder"): # the service's own config wins
113
+ from brain_log import is_database
114
+ chosen = self.config["memory_folder"]
115
+ shared = chosen if is_database(chosen) else Path(chosen).expanduser() # folder() also reads the
116
+ # one chosen in conversation
117
+ publish = None
118
+ if shared:
119
+ from brain_log import append
120
+ publish = lambda turn: append(shared, "turn", turn) # a folder or a Postgres URL
121
+ sources = []
122
+ # A source whose folder does not exist yet (Codex never run on this PC) is skipped, not
123
+ # fatal: it used to stop every source, so a Claude-Code-only user captured nothing.
124
+ if Path(self.config["codex_sessions_root"]).is_dir():
125
+ sources.append(("codex", CodexConversationCapture(
126
+ self.config["codex_sessions_root"], store, state_path=self.conversation_cursor,
127
+ workspace_scopes=scopes, now=self.now, publish=publish), self.conversation_cursor))
128
+ claude_root = Path(self.config.get("claude_projects_root")
129
+ or Path.home() / ".claude" / "projects")
130
+ if claude_root.is_dir():
131
+ sources.append(("claude-code", ClaudeCodeConversationCapture(
132
+ claude_root, store, state_path=self.claude_cursor,
133
+ workspace_scopes=scopes, now=self.now, publish=publish), self.claude_cursor))
134
+ cowork = self.config.get("claude_cowork_root") or cowork_root()
135
+ if cowork and Path(cowork).is_dir():
136
+ sources.append(("claude-cowork", ClaudeCoworkConversationCapture(
137
+ cowork, store, state_path=self.cowork_cursor,
138
+ workspace_scopes=scopes, now=self.now, publish=publish), self.cowork_cursor))
139
+ return sources
140
+
141
+ def baseline(self):
142
+ coding = []
143
+ with CodingMemoryStore(self.db_path) as store:
144
+ for entry in self.config["code_workspaces"]:
145
+ capture = self._coding_capture(entry, store)
146
+ if capture.manifest_path.exists():
147
+ coding.append({"project_id": entry["project_id"],
148
+ "status": "BASELINE_ALREADY_EXISTS",
149
+ "manifest": str(capture.manifest_path)})
150
+ else:
151
+ coding.append({"project_id": entry["project_id"],
152
+ **capture.initialize_baseline()})
153
+ with ConversationMemoryStore(self.db_path) as store:
154
+ conversation = {
155
+ name: ({"status": "BASELINE_ALREADY_EXISTS", "state_path": str(cursor)}
156
+ if cursor.exists() else capture.initialize_baseline())
157
+ for name, capture, cursor in self._conversation_captures(store)}
158
+ result = {"status": "BASELINE_READY", "service_version": SERVICE_VERSION,
159
+ "at": self.now(), "coding": coding,
160
+ "conversation": conversation.get("codex"), "conversation_sources": conversation}
161
+ self._write_status(result)
162
+ return result
163
+
164
+ def run_once(self, *, capture_code=True, capture_llm=True):
165
+ started = self.now()
166
+ coding, errors = [], []
167
+ if capture_code:
168
+ with CodingMemoryStore(self.db_path) as store:
169
+ for entry in self.config["code_workspaces"]:
170
+ try:
171
+ capture = self._coding_capture(entry, store)
172
+ if not capture.manifest_path.exists():
173
+ outcome = capture.initialize_baseline()
174
+ else:
175
+ outcome = capture.scan(
176
+ reason="Automatically observed workspace change; intent pending",
177
+ related_tests=[])
178
+ coding.append({"project_id": entry["project_id"], **outcome})
179
+ except Exception as error: # keep other capture sources alive
180
+ errors.append({"source": "code",
181
+ "project_id": entry.get("project_id"),
182
+ "error": type(error).__name__, "detail": str(error)})
183
+ else:
184
+ coding.append({"status": "SKIPPED_NOT_DUE"})
185
+ semantic = None
186
+ conversation = {}
187
+ try:
188
+ with ConversationMemoryStore(self.db_path) as store:
189
+ for name, capture, cursor in self._conversation_captures(store):
190
+ try: # one broken source must not stop the others
191
+ conversation[name] = (capture.scan() if cursor.exists()
192
+ else capture.initialize_baseline())
193
+ except Exception as error:
194
+ errors.append({"source": f"conversation:{name}",
195
+ "error": type(error).__name__, "detail": str(error)})
196
+ except Exception as error:
197
+ errors.append({"source": "conversation", "error": type(error).__name__,
198
+ "detail": str(error)})
199
+ try:
200
+ with SemanticMemoryIngestion(self.db_path, now=self.now) as ingestion:
201
+ semantic = ingestion.process_new(limit=200)
202
+ except Exception as error:
203
+ errors.append({"source": "semantic", "error": type(error).__name__,
204
+ "detail": str(error)})
205
+ semantic_llm = {"status": "DISABLED"}
206
+ llm_config = self.config.get("semantic_llm", {})
207
+ if llm_config.get("enabled") and not capture_llm:
208
+ semantic_llm = {"status": "SKIPPED_NOT_DUE"}
209
+ elif llm_config.get("enabled"):
210
+ try:
211
+ if llm_config.get("provider") != "openai":
212
+ raise ValueError("UNSUPPORTED_SEMANTIC_LLM_PROVIDER")
213
+ extractor = OpenAIResponsesSemanticExtractor(llm_config.get("model"))
214
+ with SemanticLLMIngestion(self.db_path, now=self.now) as ingestion:
215
+ semantic_llm = ingestion.process_new(
216
+ extractor, limit=int(llm_config.get("limit", 25)))
217
+ except Exception as error:
218
+ errors.append({"source": "semantic_llm",
219
+ "error": type(error).__name__, "detail": str(error)})
220
+ semantic_llm = {"status": "ERROR"}
221
+ associative = None
222
+ try:
223
+ with AssociativeMemoryIndex(self.db_path) as index:
224
+ associative = index.sync_sources(limit=1000)
225
+ except Exception as error:
226
+ errors.append({"source": "associative", "error": type(error).__name__,
227
+ "detail": str(error)})
228
+ result = {"status": "CAPTURE_ERRORS" if errors else "CAPTURE_OK",
229
+ "service_version": SERVICE_VERSION, "started_at": started,
230
+ "completed_at": self.now(), "coding": coding,
231
+ "conversation": conversation.get("codex"),
232
+ "conversation_sources": conversation, "semantic": semantic,
233
+ "semantic_llm": semantic_llm,
234
+ "associative": associative,
235
+ "errors": errors}
236
+ self._write_status(result)
237
+ return result
238
+
239
+ def _write_status(self, result):
240
+ _atomic_json(self.status_path, {**result, "pid": os.getpid(),
241
+ "config_db": str(self.db_path)})
242
+
243
+ def embed_new_turns(self, limit=512):
244
+ """Make what was just captured searchable by meaning before anyone asks about it (the
245
+ memory servers pick the vectors up from the shared cache). Only the long-running service
246
+ does this: a one-off `once` or a test should not load the embedding model."""
247
+ try:
248
+ from embedding_warmup import warm_turns
249
+ from semantic_recall import default_embedder
250
+ if getattr(self, "_embedder", None) is None:
251
+ self._embedder = default_embedder(self.db_path)
252
+ return warm_turns(self.db_path, limit=limit, embedder=self._embedder)
253
+ except Exception: # capture must go on if embedding cannot
254
+ return 0
255
+
256
+ def run_forever(self, *, max_iterations=None):
257
+ self.pid_path.write_text(str(os.getpid()) + "\n", encoding="ascii")
258
+ iteration = 0
259
+ last_code_scan = None
260
+ last_llm_scan = None
261
+ try:
262
+ while max_iterations is None or iteration < max_iterations:
263
+ monotonic_now = time.monotonic()
264
+ code_due = (last_code_scan is None or monotonic_now - last_code_scan
265
+ >= self.config.get("code_poll_seconds", 120))
266
+ llm_config = self.config.get("semantic_llm", {})
267
+ llm_due = (last_llm_scan is None or monotonic_now - last_llm_scan
268
+ >= llm_config.get("poll_seconds", 60))
269
+ self.run_once(capture_code=code_due, capture_llm=llm_due)
270
+ self.embed_new_turns()
271
+ if code_due:
272
+ last_code_scan = time.monotonic()
273
+ if llm_config.get("enabled") and llm_due:
274
+ last_llm_scan = time.monotonic()
275
+ iteration += 1
276
+ if max_iterations is None or iteration < max_iterations:
277
+ time.sleep(self.config["poll_seconds"])
278
+ finally:
279
+ try:
280
+ if self.pid_path.read_text(encoding="ascii").strip() == str(os.getpid()):
281
+ self.pid_path.unlink()
282
+ except (FileNotFoundError, OSError):
283
+ pass
284
+
285
+
286
+ def config_identity(config):
287
+ raw = json.dumps(config, sort_keys=True, separators=(",", ":")).encode("utf-8")
288
+ return hashlib.sha256(raw).hexdigest()[:16]
@@ -0,0 +1,72 @@
1
+ """Command line control for the Graph-MIND automatic capture service."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+
8
+ from automatic_capture import (AutomaticCaptureService, default_config_path,
9
+ default_service_state_dir, load_config, make_config,
10
+ _atomic_json)
11
+
12
+
13
+ def _emit(value):
14
+ print(json.dumps(value, ensure_ascii=False, indent=2))
15
+
16
+
17
+ def main(argv=None):
18
+ parser = argparse.ArgumentParser(description="Graph-MIND automatic capture v0.2")
19
+ parser.add_argument("--config", default=str(default_config_path()))
20
+ parser.add_argument("--state-dir", default=str(default_service_state_dir()))
21
+ commands = parser.add_subparsers(dest="command", required=True)
22
+ configure = commands.add_parser("configure")
23
+ configure.add_argument("--workspace", required=True)
24
+ configure.add_argument("--project", required=True)
25
+ configure.add_argument("--session", required=True)
26
+ configure.add_argument("--sessions-root", required=True)
27
+ configure.add_argument("--db")
28
+ configure.add_argument("--poll-seconds", type=int, default=5)
29
+ configure.add_argument("--code-poll-seconds", type=int, default=120)
30
+ configure.add_argument("--enable-semantic-llm", action="store_true")
31
+ configure.add_argument("--semantic-model")
32
+ commands.add_parser("baseline")
33
+ commands.add_parser("once")
34
+ commands.add_parser("run")
35
+ commands.add_parser("status")
36
+ args = parser.parse_args(argv)
37
+ if args.command == "configure":
38
+ config = make_config(workspace=args.workspace, project_id=args.project,
39
+ session_id=args.session, sessions_root=args.sessions_root,
40
+ poll_seconds=args.poll_seconds, code_poll_seconds=args.code_poll_seconds,
41
+ db_path=args.db)
42
+ if args.enable_semantic_llm:
43
+ if not args.semantic_model:
44
+ parser.error("--semantic-model is required with --enable-semantic-llm")
45
+ config["semantic_llm"].update({"enabled": True,
46
+ "model": args.semantic_model})
47
+ _atomic_json(args.config, config)
48
+ _emit({"status": "CONFIGURED", "config": os.path.abspath(args.config),
49
+ "value": config})
50
+ return 0
51
+ if args.command == "status":
52
+ path = os.path.join(args.state_dir, "status.json")
53
+ if not os.path.isfile(path):
54
+ _emit({"status": "NOT_STARTED", "state": path})
55
+ else:
56
+ with open(path, encoding="utf-8") as handle:
57
+ _emit(json.load(handle))
58
+ return 0
59
+ service = AutomaticCaptureService(load_config(args.config), state_dir=args.state_dir)
60
+ if args.command == "baseline":
61
+ result = service.baseline()
62
+ elif args.command == "once":
63
+ result = service.run_once()
64
+ else:
65
+ service.run_forever()
66
+ return 0
67
+ _emit(result)
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())