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.
conversation_memory.py ADDED
@@ -0,0 +1,476 @@
1
+ """Incremental, redacted conversation capture for the user-owned Local Brain."""
2
+ from __future__ import annotations
3
+
4
+ from development_paths import use_wal
5
+
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import sqlite3
13
+
14
+ from temporal_state import timestamp
15
+
16
+
17
+ CAPTURE_VERSION = "codex-conversation-capture-v0.1"
18
+ SECRET_PATTERNS = (
19
+ (re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b"), "[REDACTED_OPENAI_KEY]"),
20
+ (re.compile(r"\bkey_[A-Za-z0-9_-]{10,}\b"), "[REDACTED_API_KEY]"),
21
+ (re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s]+"), r"\1[REDACTED]"),
22
+ (re.compile(r"(?i)((?:api[_ -]?key|secret|token)\s*[:=]\s*)[^\s]+"),
23
+ r"\1[REDACTED]"),
24
+ (re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b"),
25
+ "[REDACTED_GITHUB_TOKEN]"),
26
+ (re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), "[REDACTED_AWS_KEY]"),
27
+ (re.compile(r"\bAIza[A-Za-z0-9_-]{35}\b"), "[REDACTED_GOOGLE_KEY]"),
28
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED_SLACK_TOKEN]"),
29
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
30
+ re.DOTALL), "[REDACTED_PRIVATE_KEY]"),
31
+ # a database URL carries its password: postgres://user:PASSWORD@host
32
+ (re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+(@)"), r"\1[REDACTED]\2"),
33
+ )
34
+ AUTOMATIC_BLOCKS = re.compile(
35
+ r"<(?:environment_context|recommended_plugins|in-app-browser-context)\b[^>]*>.*?"
36
+ r"</(?:environment_context|recommended_plugins|in-app-browser-context)>",
37
+ flags=re.DOTALL,
38
+ )
39
+
40
+
41
+ def _text(value, error):
42
+ if not isinstance(value, str) or not value.strip():
43
+ raise ValueError(error)
44
+ return value.strip()
45
+
46
+
47
+ # Codex opens each task by injecting the workspace's AGENTS.md as a user message. It is the
48
+ # harness talking, not the person, and it is long enough to fill a context packet on its own.
49
+ AGENT_INSTRUCTIONS = re.compile(
50
+ r"(?:#\s*AGENTS\.md instructions[^\n]*\s*)?<INSTRUCTIONS>.*?</INSTRUCTIONS>", flags=re.DOTALL)
51
+
52
+
53
+ def _redact(value):
54
+ cleaned = AGENT_INSTRUCTIONS.sub("", AUTOMATIC_BLOCKS.sub("", value))
55
+ count = 0
56
+ for pattern, replacement in SECRET_PATTERNS:
57
+ cleaned, replacements = pattern.subn(replacement, cleaned)
58
+ count += replacements
59
+ return cleaned.strip(), count
60
+
61
+
62
+ def _message_text(payload):
63
+ content = payload.get("content")
64
+ if isinstance(content, str):
65
+ return content
66
+ if not isinstance(content, list):
67
+ return ""
68
+ parts = []
69
+ for item in content:
70
+ if not isinstance(item, dict):
71
+ continue
72
+ if item.get("type") in {"input_text", "output_text", "text"}:
73
+ value = item.get("text")
74
+ if isinstance(value, str):
75
+ parts.append(value)
76
+ return "\n".join(parts)
77
+
78
+
79
+ class ConversationMemoryStore:
80
+ def __init__(self, path, *, clock="SOURCE_LOCAL"):
81
+ self.path = Path(path)
82
+ self.path.parent.mkdir(parents=True, exist_ok=True)
83
+ self.clock = clock
84
+ # Claude, Codex and the capture service each open this file from their own process.
85
+ # WAL lets them read while one writes; the timeout makes a writer wait its turn
86
+ # instead of failing with "database is locked".
87
+ self.db = sqlite3.connect(str(self.path), timeout=30)
88
+ use_wal(self.db)
89
+ self.db.row_factory = sqlite3.Row
90
+ self.db.executescript("""
91
+ CREATE TABLE IF NOT EXISTS conversation_turns(
92
+ revision INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ turn_id TEXT NOT NULL UNIQUE,
94
+ client TEXT NOT NULL,
95
+ client_session_id TEXT NOT NULL,
96
+ scope TEXT NOT NULL,
97
+ cwd TEXT,
98
+ happened_at TEXT NOT NULL,
99
+ known_at TEXT NOT NULL,
100
+ role TEXT NOT NULL,
101
+ content_redacted TEXT NOT NULL,
102
+ raw_sha256 TEXT NOT NULL,
103
+ redaction_count INTEGER NOT NULL,
104
+ source_path TEXT NOT NULL,
105
+ source_ordinal INTEGER NOT NULL,
106
+ fingerprint TEXT NOT NULL);
107
+ CREATE INDEX IF NOT EXISTS conversation_scope_time
108
+ ON conversation_turns(scope,happened_at DESC);
109
+ CREATE INDEX IF NOT EXISTS conversation_session_time
110
+ ON conversation_turns(client_session_id,happened_at);
111
+ CREATE VIRTUAL TABLE IF NOT EXISTS conversation_turns_fts USING fts5(
112
+ content_redacted, content='conversation_turns', content_rowid='revision',
113
+ tokenize='unicode61');
114
+ CREATE TRIGGER IF NOT EXISTS conversation_turns_ai AFTER INSERT ON conversation_turns BEGIN
115
+ INSERT INTO conversation_turns_fts(rowid,content_redacted)
116
+ VALUES(new.revision,new.content_redacted);
117
+ END;
118
+ """)
119
+ self.closed = False
120
+
121
+ def __enter__(self):
122
+ return self
123
+
124
+ def __exit__(self, *args):
125
+ self.close()
126
+
127
+ def close(self):
128
+ if not self.closed:
129
+ self.closed = True
130
+ self.db.close()
131
+
132
+ def _time(self, value):
133
+ if isinstance(value, str):
134
+ try:
135
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
136
+ except ValueError:
137
+ pass
138
+ else:
139
+ if self.clock == "SOURCE_LOCAL" and parsed.utcoffset() is not None:
140
+ value = parsed.astimezone().replace(tzinfo=None).isoformat(
141
+ timespec="microseconds")
142
+ return timestamp(value, self.clock).isoformat(timespec="microseconds")
143
+
144
+ def record(self, turn):
145
+ required = ("turn_id", "client", "client_session_id", "scope", "role",
146
+ "content_redacted", "raw_sha256", "source_path")
147
+ normalized = {key: _text(turn.get(key), key.upper() + "_REQUIRED")
148
+ for key in required}
149
+ normalized["cwd"] = turn.get("cwd")
150
+ normalized["happened_at"] = self._time(turn.get("happened_at"))
151
+ normalized["known_at"] = self._time(turn.get("known_at"))
152
+ normalized["source_ordinal"] = int(turn.get("source_ordinal"))
153
+ normalized["redaction_count"] = int(turn.get("redaction_count", 0))
154
+ fingerprint = hashlib.sha256(json.dumps(normalized, ensure_ascii=False,
155
+ sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
156
+ existing = self.db.execute(
157
+ "SELECT revision,fingerprint,raw_sha256 FROM conversation_turns WHERE turn_id=?",
158
+ (normalized["turn_id"],)).fetchone()
159
+ if existing:
160
+ # The id already binds the session, the record and the hash of what was said, so the
161
+ # same id with the same text is the same turn seen again, even if this read happened
162
+ # at another time. Only the same id carrying different text is a real collision.
163
+ if existing["raw_sha256"] != normalized["raw_sha256"]:
164
+ raise ValueError("CONVERSATION_TURN_ID_COLLISION")
165
+ return {"turn_id": normalized["turn_id"], "revision": existing["revision"],
166
+ "disposition": "ALREADY_RECORDED"}
167
+ with self.db:
168
+ cursor = self.db.execute("""INSERT INTO conversation_turns(
169
+ turn_id,client,client_session_id,scope,cwd,happened_at,known_at,role,
170
+ content_redacted,raw_sha256,redaction_count,source_path,source_ordinal,fingerprint)
171
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (
172
+ normalized["turn_id"], normalized["client"],
173
+ normalized["client_session_id"], normalized["scope"], normalized["cwd"],
174
+ normalized["happened_at"], normalized["known_at"], normalized["role"],
175
+ normalized["content_redacted"], normalized["raw_sha256"],
176
+ normalized["redaction_count"], normalized["source_path"],
177
+ normalized["source_ordinal"], fingerprint))
178
+ return {"turn_id": normalized["turn_id"], "revision": cursor.lastrowid,
179
+ "disposition": "RECORDED"}
180
+
181
+ def search(self, query, *, scopes=None, limit=10):
182
+ query = _text(query, "CONVERSATION_QUERY_REQUIRED")
183
+ if type(limit) is not int or not 1 <= limit <= 50:
184
+ raise ValueError("INVALID_CONVERSATION_QUERY_LIMIT")
185
+ terms = list(dict.fromkeys(re.findall(r"[^\W_]{2,}", query.casefold())))
186
+ if not terms:
187
+ return {"status": "UNKNOWN", "reason": "NO_SEARCHABLE_QUERY_TERMS",
188
+ "scope_completeness": "UNATTESTED", "turns": []}
189
+ # Prefix terms, not exact ones: Korean glues particles to the noun, so the index holds
190
+ # "콩이를" and "제주도에" as single tokens and an exact "콩이" matched neither.
191
+ expression = " OR ".join('"' + term.replace('"', '""') + '"*' for term in terms)
192
+ where, parameters = ["conversation_turns_fts MATCH ?"], [expression]
193
+ if scopes:
194
+ where.append("t.scope IN (" + ",".join("?" for _ in scopes) + ")")
195
+ parameters.extend(scopes)
196
+ parameters.append(limit)
197
+ rows = self.db.execute("""SELECT t.*,bm25(conversation_turns_fts) AS rank
198
+ FROM conversation_turns_fts JOIN conversation_turns t
199
+ ON t.revision=conversation_turns_fts.rowid WHERE """
200
+ + " AND ".join(where) + " ORDER BY rank,t.happened_at DESC LIMIT ?",
201
+ parameters).fetchall()
202
+ turns = [self._turn(row) for row in rows]
203
+ return {"status": "KNOWN" if turns else "UNKNOWN",
204
+ "reason": "CONVERSATION_TURNS_FOUND" if turns else "NO_MATCHING_TURN",
205
+ "scope_completeness": "UNATTESTED", "turns": turns}
206
+
207
+ def latest(self, *, scopes=None, since=None, limit=10):
208
+ """The most recent turns, newest first: what "what was I just doing?" is asking for."""
209
+ if type(limit) is not int or not 1 <= limit <= 50:
210
+ raise ValueError("INVALID_CONVERSATION_QUERY_LIMIT")
211
+ where, parameters = ["1=1"], []
212
+ if scopes:
213
+ where.append("scope IN (" + ",".join("?" for _ in scopes) + ")")
214
+ parameters.extend(scopes)
215
+ if since:
216
+ where.append("happened_at>=?")
217
+ parameters.append(self._time(since))
218
+ rows = self.db.execute("SELECT * FROM conversation_turns WHERE " + " AND ".join(where)
219
+ + " ORDER BY happened_at DESC, revision DESC LIMIT ?",
220
+ [*parameters, limit]).fetchall()
221
+ return {"status": "KNOWN" if rows else "UNKNOWN",
222
+ "reason": "RECENT_TURNS" if rows else "NO_TURNS_IN_RANGE",
223
+ "scope_completeness": "UNATTESTED", "turns": [self._turn(row) for row in rows]}
224
+
225
+ @staticmethod
226
+ def _turn(row):
227
+ return {"turn_id": row["turn_id"], "client": row["client"],
228
+ "client_session_id": row["client_session_id"], "scope": row["scope"],
229
+ "cwd": row["cwd"], "happened_at": row["happened_at"],
230
+ "role": row["role"], "content": row["content_redacted"],
231
+ "redaction_count": row["redaction_count"],
232
+ "source_path": row["source_path"], "source_ordinal": row["source_ordinal"]}
233
+
234
+
235
+ class CodexConversationCapture:
236
+ """Read only newly appended complete JSONL records from Codex sessions."""
237
+
238
+ CLIENT = "codex"
239
+
240
+ def __init__(self, sessions_root, store, *, state_path, workspace_scopes=None,
241
+ now=None, publish=None):
242
+ self.sessions_root = Path(sessions_root).resolve()
243
+ if not self.sessions_root.is_dir():
244
+ raise ValueError("CODEX_SESSIONS_ROOT_NOT_FOUND")
245
+ if not isinstance(store, ConversationMemoryStore):
246
+ raise ValueError("CONVERSATION_MEMORY_STORE_REQUIRED")
247
+ self.store = store
248
+ self.state_path = Path(state_path).resolve()
249
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
250
+ self.workspace_scopes = {str(Path(key).resolve()): value
251
+ for key, value in (workspace_scopes or {}).items()}
252
+ self.now = now or (lambda: datetime.now().isoformat(timespec="microseconds"))
253
+ self.publish = publish # called with each turn that is new to this store
254
+
255
+ def _files(self):
256
+ return sorted(self.sessions_root.rglob("*.jsonl"))
257
+
258
+ def _session_meta(self, path):
259
+ session_id, cwd = path.stem, None
260
+ try:
261
+ with path.open(encoding="utf-8", errors="replace") as handle:
262
+ first = json.loads(handle.readline())
263
+ payload = first.get("payload") or {}
264
+ session_id = str(payload.get("session_id") or payload.get("id") or session_id)
265
+ cwd = payload.get("cwd")
266
+ except (OSError, ValueError, TypeError):
267
+ pass
268
+ resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
269
+ scope = self.workspace_scopes.get(resolved_cwd, "global")
270
+ return session_id, cwd, scope
271
+
272
+ def _identity(self, item, ordinal):
273
+ """What makes this record the same record on every scan. Codex numbers its records."""
274
+ return ordinal
275
+
276
+ def _message(self, item):
277
+ """(role, text) when this record is something a person or the assistant said, else None."""
278
+ payload = item.get("payload") or {}
279
+ if (item.get("type") != "response_item" or payload.get("type") != "message"
280
+ or payload.get("role") not in {"user", "assistant"}):
281
+ return None
282
+ return payload["role"], _message_text(payload)
283
+
284
+ def initialize_baseline(self):
285
+ files = {str(path): path.stat().st_size for path in self._files()}
286
+ self._write_state(files)
287
+ return {"status": "BASELINE_CREATED", "file_count": len(files),
288
+ "state_path": str(self.state_path)}
289
+
290
+ def _load_state(self):
291
+ if not self.state_path.exists():
292
+ raise ValueError("CONVERSATION_CAPTURE_BASELINE_REQUIRED")
293
+ value = json.loads(self.state_path.read_text(encoding="utf-8"))
294
+ if value.get("capture_version") != CAPTURE_VERSION:
295
+ raise ValueError("CONVERSATION_CAPTURE_VERSION_MISMATCH")
296
+ return value.get("offsets", {})
297
+
298
+ def _write_state(self, offsets):
299
+ value = {"capture_version": CAPTURE_VERSION,
300
+ "sessions_root": str(self.sessions_root), "offsets": offsets}
301
+ temporary = self.state_path.with_suffix(".tmp")
302
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n",
303
+ encoding="utf-8")
304
+ os.replace(temporary, self.state_path)
305
+
306
+ def scan(self):
307
+ offsets = self._load_state()
308
+ new_offsets = dict(offsets)
309
+ recorded, scanned_rows, redactions = [], 0, 0
310
+ known_at = self.now()
311
+ for path in self._files():
312
+ key = str(path)
313
+ start = int(offsets.get(key, 0))
314
+ size = path.stat().st_size
315
+ if size < start:
316
+ start = 0
317
+ session_id, cwd, scope = self._session_meta(path)
318
+ with path.open("rb") as handle:
319
+ handle.seek(start)
320
+ committed = start
321
+ while True:
322
+ line_start = handle.tell()
323
+ line = handle.readline()
324
+ if not line:
325
+ break
326
+ if not line.endswith(b"\n"):
327
+ handle.seek(line_start)
328
+ break
329
+ committed = handle.tell()
330
+ scanned_rows += 1
331
+ try:
332
+ item = json.loads(line.decode("utf-8", "strict"))
333
+ except (UnicodeDecodeError, json.JSONDecodeError):
334
+ continue
335
+ message = self._message(item)
336
+ if message is None:
337
+ continue
338
+ role, raw = message
339
+ if not raw.strip():
340
+ continue
341
+ cleaned, count = _redact(raw)
342
+ if not cleaned:
343
+ continue
344
+ redactions += count
345
+ raw_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest()
346
+ ordinal_value = item.get("ordinal")
347
+ # where the line sits in the file: unlike the row count of this one scan,
348
+ # it is the same every time the file is read
349
+ ordinal = int(ordinal_value) if ordinal_value is not None else line_start
350
+ binding = (f"{session_id}\0{self._identity(item, ordinal)}\0{raw_hash}"
351
+ .encode("utf-8"))
352
+ turn = {"turn_id": f"{self.CLIENT}-turn-"
353
+ + hashlib.sha256(binding).hexdigest(),
354
+ "client": self.CLIENT, "client_session_id": session_id,
355
+ "scope": scope, "cwd": cwd,
356
+ "happened_at": item.get("timestamp") or known_at,
357
+ "known_at": known_at, "role": role,
358
+ "content_redacted": cleaned, "raw_sha256": raw_hash,
359
+ "redaction_count": count, "source_path": key,
360
+ "source_ordinal": ordinal}
361
+ outcome = self.store.record(turn)
362
+ if self.publish and outcome.get("disposition") != "ALREADY_RECORDED":
363
+ try:
364
+ self.publish(turn)
365
+ except Exception:
366
+ # ponytail: the hub is unreachable; the turn is kept on this PC and
367
+ # brain_folder(share_existing=true) sends it to the others later
368
+ pass
369
+ recorded.append(outcome)
370
+ new_offsets[key] = committed
371
+ self._write_state(new_offsets)
372
+ return {"status": "TURNS_RECORDED" if recorded else "NO_NEW_TURNS",
373
+ "recorded_turns": len(recorded), "scanned_rows": scanned_rows,
374
+ "redactions": redactions, "record_results": recorded}
375
+
376
+
377
+ CLAUDE_CODE_BLOCKS = re.compile(
378
+ r"<(system-reminder|task-notification|local-command-caveat|local-command-stdout|"
379
+ r"command-name|command-message|command-args)\b[^>]*>.*?</\1>", flags=re.DOTALL)
380
+
381
+
382
+ class ClaudeCodeConversationCapture(CodexConversationCapture):
383
+ """Read only newly appended complete JSONL records from Claude Code transcripts.
384
+
385
+ A transcript holds far more than the conversation: tool calls, tool results, the model's
386
+ thinking, attachments, mode switches. Only two kinds of record are something somebody SAID — a
387
+ user turn typed as text, and an assistant text block — and only those are kept, verbatim.
388
+ Harness notices injected into user turns (system reminders, task notifications, command
389
+ echoes) are stripped, and sub-agent side chains are skipped: the user did not have that
390
+ conversation. Secrets are masked by the same rules as every other capture.
391
+ """
392
+
393
+ CLIENT = "claude-code"
394
+
395
+ def _identity(self, item, ordinal):
396
+ """Claude Code records carry no ordinal, and the row count of one scan is not stable: a
397
+ re-read from the start would give every turn a new id and store it twice. Each record's
398
+ own uuid is."""
399
+ return item.get("uuid") or ordinal
400
+
401
+ def _session_meta(self, path):
402
+ session_id, cwd = path.stem, None
403
+ try:
404
+ with path.open(encoding="utf-8", errors="replace") as handle:
405
+ for line in handle:
406
+ record = json.loads(line)
407
+ if record.get("cwd"):
408
+ session_id = str(record.get("sessionId") or session_id)
409
+ cwd = record["cwd"]
410
+ break
411
+ except (OSError, ValueError, TypeError):
412
+ pass
413
+ resolved_cwd = str(Path(cwd).resolve()) if isinstance(cwd, str) and cwd else None
414
+ return session_id, cwd, self.workspace_scopes.get(resolved_cwd, "global")
415
+
416
+ def _message(self, item):
417
+ if item.get("isSidechain") or item.get("isMeta"):
418
+ return None
419
+ queued = item.get("attachment") if item.get("type") == "attachment" else None
420
+ if isinstance(queued, dict) and queued.get("type") == "queued_command":
421
+ # A message typed while the assistant was still working is not a "user" record: it
422
+ # is queued and delivered as an attachment. Missing it loses exactly the corrections
423
+ # and asides people make mid-task. (The matching queue-operation records are the
424
+ # queue's bookkeeping of the same text and are skipped to avoid storing it twice.)
425
+ # Only what the person typed: background-task notifications queue the same way.
426
+ if queued.get("humanTurn") is False or queued.get("commandMode") != "prompt":
427
+ return None
428
+ prompt = queued.get("prompt")
429
+ if isinstance(prompt, list): # a pasted image arrives as blocks
430
+ prompt = "\n".join(block.get("text", "") for block in prompt
431
+ if isinstance(block, dict) and block.get("type") == "text")
432
+ if not isinstance(prompt, str):
433
+ return None
434
+ return "user", CLAUDE_CODE_BLOCKS.sub("", prompt)
435
+ if item.get("type") not in {"user", "assistant"}:
436
+ return None
437
+ message = item.get("message") or {}
438
+ content = message.get("content")
439
+ if isinstance(content, str):
440
+ text = content
441
+ elif isinstance(content, list): # text blocks only: no tool_use, tool_result, thinking
442
+ text = "\n".join(block.get("text", "") for block in content
443
+ if isinstance(block, dict) and block.get("type") == "text")
444
+ else:
445
+ return None
446
+ return item["type"], CLAUDE_CODE_BLOCKS.sub("", text)
447
+
448
+
449
+ class ClaudeCoworkConversationCapture(ClaudeCodeConversationCapture):
450
+ """Claude desktop's Cowork mode: the same agent and transcript format as Claude Code, but each
451
+ session keeps its own .claude/projects inside local-agent-mode-sessions. Only those
452
+ transcripts are read; the session folders also hold credentials and uploads, never opened."""
453
+
454
+ CLIENT = "claude-cowork"
455
+
456
+ def _files(self):
457
+ # Cowork transcript paths run to ~500 characters; past Windows' 260 a plain rglob skips
458
+ # them without an error (5 of 68 found). The extended-length prefix lifts the limit.
459
+ # ponytail: only here; the Codex and Claude Code roots stay short and keep cursor keys.
460
+ root = self.sessions_root
461
+ if os.name == "nt" and not str(root).startswith("\\\\?\\"):
462
+ root = Path("\\\\?\\" + str(root))
463
+ return sorted(path for path in root.rglob("*.jsonl")
464
+ if ".claude" in path.parts and "projects" in path.parts
465
+ and "subagents" not in path.parts)
466
+
467
+
468
+ def cowork_root():
469
+ """Where Claude desktop keeps Cowork sessions: the Store (MSIX) build or the plain installer."""
470
+ candidates = [Path(os.environ.get("APPDATA", "")) / "Claude" / "local-agent-mode-sessions"]
471
+ packages = Path(os.environ.get("LOCALAPPDATA", "")) / "Packages"
472
+ if packages.is_dir():
473
+ candidates += [package / "LocalCache" / "Roaming" / "Claude" / "local-agent-mode-sessions"
474
+ for package in packages.glob("Claude_*")]
475
+ return next((path for path in candidates if path.is_dir()), None)
476
+