loop-memory 0.4.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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,782 @@
1
+ """Conversation ingest — turn local assistant transcripts into memories.
2
+
3
+ Each loader normalises a different tool's transcript shape into a
4
+ common ``IngestedSession`` model so the storage layer can ingest them
5
+ uniformly.
6
+
7
+ Currently supported:
8
+
9
+ - **Codex CLI**: ``~/.codex/sessions/**/*.jsonl`` (the event-stream
10
+ format actually written by Codex Desktop / CLI today).
11
+ - **Claude Code**: ``~/.claude/history.jsonl`` plus per-project JSONL.
12
+ - **Hermes / generic**: any JSONL of ``{"role":...,"content":...}``.
13
+
14
+ Adding a new tool: subclass ``BaseLoader`` and put the file-glob +
15
+ parser in ``_parse``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import time
23
+ import uuid
24
+ from abc import ABC, abstractmethod
25
+ from collections.abc import Iterable
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+
30
+ @dataclass
31
+ class IngestedTurn:
32
+ role: str # "user" | "assistant" | "system"
33
+ text: str
34
+ created_at: float | None = None
35
+
36
+
37
+ @dataclass
38
+ class IngestedSession:
39
+ source: str # "codex" | "claude" | "hermes" | "generic"
40
+ external_id: str
41
+ title: str | None = None
42
+ started_at: float = field(default_factory=time.time)
43
+ ended_at: float | None = None
44
+ turns: list[IngestedTurn] = field(default_factory=list)
45
+
46
+ @property
47
+ def message_count(self) -> int:
48
+ return len(self.turns)
49
+
50
+
51
+ # ---------- helpers ---------------------------------------------------------
52
+
53
+ _TEXT_BLOCK_RE = re.compile(r"[\s\n]+")
54
+
55
+
56
+ def _norm(text: str) -> str:
57
+ if not text:
58
+ return ""
59
+ return _TEXT_BLOCK_RE.sub(" ", text).strip()
60
+
61
+
62
+ def _to_float(ts) -> float | None:
63
+ if ts is None:
64
+ return None
65
+ if isinstance(ts, (int, float)):
66
+ # seconds-or-ms heuristic
67
+ if ts > 1e12:
68
+ return float(ts) / 1000.0
69
+ return float(ts)
70
+ if isinstance(ts, str):
71
+ try:
72
+ from datetime import datetime
73
+ return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
74
+ except Exception:
75
+ try:
76
+ return float(ts)
77
+ except Exception:
78
+ return None
79
+ return None
80
+
81
+
82
+ def _read_text(record) -> str:
83
+ """Extract best-effort text from arbitrary Codex/Claude content shapes."""
84
+ if record is None:
85
+ return ""
86
+ if isinstance(record, str):
87
+ return _norm(record)
88
+ if isinstance(record, list):
89
+ out: list[str] = []
90
+ for part in record:
91
+ if isinstance(part, dict):
92
+ out.append(_read_text(part.get("text")))
93
+ elif isinstance(part, str):
94
+ out.append(part)
95
+ return _norm(" ".join(s for s in out if s))
96
+ if isinstance(record, dict):
97
+ for key in ("text", "content", "input_text", "output_text", "message"):
98
+ if key in record:
99
+ t = _read_text(record[key])
100
+ if t:
101
+ return t
102
+ return ""
103
+
104
+
105
+ # ---------- base ------------------------------------------------------------
106
+
107
+ class BaseLoader(ABC):
108
+ source: str = "generic"
109
+
110
+ @abstractmethod
111
+ def discover(self, root: Path) -> Iterable[Path]:
112
+ ...
113
+
114
+ def discover_all(self, root: Path) -> list[Path]:
115
+ return list(self.discover(root))
116
+
117
+ def load_one(self, path: Path) -> IngestedSession | None:
118
+ try:
119
+ return self._parse(path)
120
+ except Exception:
121
+ return None
122
+
123
+ @abstractmethod
124
+ def _parse(self, path: Path) -> IngestedSession | None:
125
+ ...
126
+
127
+
128
+ # ---------- codex ------------------------------------------------------------
129
+
130
+ class CodexLoader(BaseLoader):
131
+ """Codex CLI events: a JSONL stream of session events.
132
+
133
+ We accept both the live shape (each line has ``type``/``payload``)
134
+ and the older flat ``[{"role":...,"content":...}]`` shape.
135
+ Each file maps to a single ``IngestedSession``.
136
+ """
137
+
138
+ source = "codex"
139
+
140
+ def discover(self, root: Path) -> Iterable[Path]:
141
+ root = Path(root).expanduser()
142
+ if not root.exists():
143
+ return []
144
+ yield from sorted(root.glob("**/*.jsonl"))
145
+ yield from sorted(root.glob("**/*.json"))
146
+
147
+ def _parse(self, path: Path) -> IngestedSession | None:
148
+ text = path.read_text(encoding="utf-8", errors="ignore").strip()
149
+ if not text:
150
+ return None
151
+ # First try: live event-stream JSONL.
152
+ if "\n" in text and text.startswith("{"):
153
+ return self._parse_event_stream(path)
154
+ # Otherwise: flat JSON array / dict with messages.
155
+ return self._parse_flat(path)
156
+
157
+ # -- parsers ------------------------------------------------------------
158
+
159
+ def _parse_event_stream(self, path: Path) -> IngestedSession | None:
160
+ turns: list[IngestedTurn] = []
161
+ started: float | None = None
162
+ ended: float | None = None
163
+ external_id: str | None = None
164
+ title: str | None = None
165
+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
166
+ line = line.strip()
167
+ if not line:
168
+ continue
169
+ try:
170
+ rec = json.loads(line)
171
+ except Exception:
172
+ continue
173
+ if not isinstance(rec, dict):
174
+ continue
175
+ ts = _to_float(rec.get("timestamp"))
176
+
177
+ if external_id is None:
178
+ payload = rec.get("payload") or {}
179
+ if isinstance(payload, dict):
180
+ external_id = (
181
+ payload.get("id")
182
+ or payload.get("session_id")
183
+ or rec.get("session_id")
184
+ )
185
+
186
+ rtype = (rec.get("type") or "").lower()
187
+ payload = rec.get("payload") or {}
188
+
189
+ # session_meta: capture the id but emit no turn
190
+ if rtype in {"session_meta", "session.started"}:
191
+ continue
192
+
193
+ # User message: payload = { role, content: [{type, text}] }
194
+ if rtype in {"user", "user_message", "human"} or (
195
+ rtype == "message" and (payload.get("role") or "").lower() == "user"
196
+ ):
197
+ content = payload.get("content") if isinstance(payload, dict) else None
198
+ text = _read_text(content)
199
+ if text:
200
+ turns.append(IngestedTurn("user", text, ts))
201
+ if title is None:
202
+ title = text[:80]
203
+ if started is None or (ts and ts < started):
204
+ started = ts
205
+ if ended is None or (ts and ts > ended):
206
+ ended = ts
207
+ continue
208
+
209
+ # Assistant message: payload = { role, content: [{type, text}] }
210
+ if rtype in {"assistant", "assistant_message", "ai"} or (
211
+ rtype == "message" and (payload.get("role") or "").lower() == "assistant"
212
+ ):
213
+ content = payload.get("content") if isinstance(payload, dict) else None
214
+ text = _read_text(content)
215
+ if text:
216
+ turns.append(IngestedTurn("assistant", text, ts))
217
+ if ended is None or (ts and ts > ended):
218
+ ended = ts
219
+ continue
220
+
221
+ # Codex also emits "response_item" events with content arrays
222
+ if rtype in {"response_item", "item"}:
223
+ role = (payload.get("role") or "").lower() if isinstance(payload, dict) else ""
224
+ if role in {"user", "assistant"}:
225
+ content = payload.get("content") if isinstance(payload, dict) else None
226
+ text = _read_text(content)
227
+ if text:
228
+ turns.append(IngestedTurn(role, text, ts))
229
+ if role == "user" and title is None:
230
+ title = text[:80]
231
+ if started is None or (ts and ts < started):
232
+ started = ts
233
+ if ended is None or (ts and ts > ended):
234
+ ended = ts
235
+
236
+ if not turns:
237
+ return None
238
+ return IngestedSession(
239
+ source=self.source,
240
+ external_id=external_id or str(path.stem),
241
+ title=title,
242
+ started_at=started or time.time(),
243
+ ended_at=ended,
244
+ turns=turns,
245
+ )
246
+
247
+ def _parse_flat(self, path: Path) -> IngestedSession | None:
248
+ data = json.loads(path.read_text(encoding="utf-8"))
249
+ if isinstance(data, dict):
250
+ messages = data.get("messages") or data.get("conversation") or []
251
+ elif isinstance(data, list):
252
+ messages = data
253
+ else:
254
+ return None
255
+ turns: list[IngestedTurn] = []
256
+ started: float | None = None
257
+ ended: float | None = None
258
+ title: str | None = None
259
+ for m in messages:
260
+ role = (m.get("role") or "").strip().lower()
261
+ text = _norm(str(m.get("content") or m.get("text") or m.get("message") or ""))
262
+ ts = _to_float(m.get("ts") or m.get("timestamp") or m.get("created_at"))
263
+ if not text:
264
+ continue
265
+ turns.append(IngestedTurn(role, text, ts))
266
+ if started is None or (ts and ts < started):
267
+ started = ts
268
+ if ended is None or (ts and ts > ended):
269
+ ended = ts
270
+ if title is None and role == "user":
271
+ title = text[:80]
272
+ if not turns:
273
+ return None
274
+ return IngestedSession(
275
+ source=self.source,
276
+ external_id=str(path.stem),
277
+ title=title,
278
+ started_at=started or time.time(),
279
+ ended_at=ended,
280
+ turns=turns,
281
+ )
282
+
283
+
284
+ # ---------- claude -----------------------------------------------------------
285
+
286
+ class ClaudeLoader(BaseLoader):
287
+ """Claude Code.
288
+
289
+ We accept two shapes that exist on real machines:
290
+
291
+ 1. ``~/.claude/history.jsonl`` — every line is a single user prompt::
292
+
293
+ {"display": "...", "timestamp": <ms>, "project": "...", "sessionId": "..."}
294
+
295
+ 2. Per-session JSONL in any ``**/*.jsonl`` with the Claude Code
296
+ ``{"type":"user|assistant", "message": {"role":..., "content":...}}`` shape.
297
+ """
298
+
299
+ source = "claude"
300
+
301
+ def discover(self, root: Path) -> Iterable[Path]:
302
+ root = Path(root).expanduser()
303
+ if not root.exists():
304
+ return []
305
+ yield from sorted(root.glob("**/sessions/*.jsonl"))
306
+ yield from sorted(root.glob("history.jsonl"))
307
+ yield from sorted(root.glob("**/*.jsonl"))
308
+
309
+ def _parse(self, path: Path) -> IngestedSession | None:
310
+ if path.name == "history.jsonl":
311
+ return self._parse_history(path)
312
+ return self._parse_session(path)
313
+
314
+ def _parse_history(self, path: Path) -> IngestedSession | None:
315
+ """Group ``history.jsonl`` lines by ``sessionId`` → one session per file.
316
+
317
+ ``history.jsonl`` only contains user prompts, but it's the most
318
+ consistently-written log on a Claude Code install, so we make
319
+ the most of it: each session is the list of user prompts in
320
+ a single file.
321
+ """
322
+ groups: dict[str, list[IngestedTurn]] = {}
323
+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
324
+ line = line.strip()
325
+ if not line:
326
+ continue
327
+ try:
328
+ rec = json.loads(line)
329
+ except Exception:
330
+ continue
331
+ sid = rec.get("sessionId") or rec.get("session_id") or "default"
332
+ display = _norm(str(rec.get("display") or rec.get("content") or ""))
333
+ ts = _to_float(rec.get("timestamp"))
334
+ if not display:
335
+ continue
336
+ groups.setdefault(sid, []).append(IngestedTurn("user", display, ts))
337
+
338
+ if not groups:
339
+ return None
340
+
341
+ # Largest session wins → the file usually represents one user.
342
+ sid, turns = max(groups.items(), key=lambda kv: len(kv[1]))
343
+ ts_list = [t.created_at for t in turns if t.created_at]
344
+ started = min(ts_list) if ts_list else time.time()
345
+ ended = max(ts_list) if ts_list else None
346
+
347
+ return IngestedSession(
348
+ source=self.source,
349
+ external_id=str(path.stem) + "::" + sid[:8],
350
+ title=turns[0].text[:80] if turns else None,
351
+ started_at=started,
352
+ ended_at=ended,
353
+ turns=turns,
354
+ )
355
+
356
+ def _parse_session(self, path: Path) -> IngestedSession | None:
357
+ turns: list[IngestedTurn] = []
358
+ started: float | None = None
359
+ ended: float | None = None
360
+ title: str | None = None
361
+ external_id: str | None = None
362
+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
363
+ line = line.strip()
364
+ if not line:
365
+ continue
366
+ try:
367
+ rec = json.loads(line)
368
+ except Exception:
369
+ continue
370
+ msg = rec.get("message") if isinstance(rec, dict) else None
371
+ if not isinstance(msg, dict):
372
+ continue
373
+ role = (msg.get("role") or rec.get("type") or "").strip().lower()
374
+ content = msg.get("content")
375
+ text = _read_text(content)
376
+ if not text:
377
+ continue
378
+ ts = _to_float(rec.get("ts") or rec.get("timestamp"))
379
+ turns.append(IngestedTurn(role, text, ts))
380
+ if started is None or (ts and ts < started):
381
+ started = ts
382
+ if ended is None or (ts and ts > ended):
383
+ ended = ts
384
+ if title is None and role == "user":
385
+ title = text[:80]
386
+ if external_id is None:
387
+ external_id = rec.get("sessionId") or rec.get("session_id")
388
+ if not turns:
389
+ return None
390
+ return IngestedSession(
391
+ source=self.source,
392
+ external_id=external_id or str(path.stem) + "::" + uuid.uuid4().hex[:6],
393
+ title=title,
394
+ started_at=started or time.time(),
395
+ ended_at=ended,
396
+ turns=turns,
397
+ )
398
+
399
+
400
+ # ---------- hermes -----------------------------------------------------------
401
+
402
+ class HermesLoader(BaseLoader):
403
+ """Generic JSONL of ``{"role":..., "content":...}`` per line."""
404
+
405
+ source = "hermes"
406
+
407
+ def discover(self, root: Path) -> Iterable[Path]:
408
+ root = Path(root).expanduser()
409
+ if not root.exists():
410
+ return []
411
+ yield from sorted(root.glob("**/*.jsonl"))
412
+
413
+ def _parse(self, path: Path) -> IngestedSession | None:
414
+ turns: list[IngestedTurn] = []
415
+ started: float | None = None
416
+ ended: float | None = None
417
+ title: str | None = None
418
+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
419
+ line = line.strip()
420
+ if not line:
421
+ continue
422
+ try:
423
+ rec = json.loads(line)
424
+ except Exception:
425
+ continue
426
+ role = (rec.get("role") or "").strip().lower()
427
+ text = _norm(str(rec.get("content") or rec.get("text") or ""))
428
+ if not text:
429
+ continue
430
+ ts = _to_float(rec.get("ts") or rec.get("timestamp") or rec.get("created_at"))
431
+ turns.append(IngestedTurn(role, text, ts))
432
+ if started is None or (ts and ts < started):
433
+ started = ts
434
+ if ended is None or (ts and ts > ended):
435
+ ended = ts
436
+ if title is None and role == "user":
437
+ title = text[:80]
438
+ if not turns:
439
+ return None
440
+ return IngestedSession(
441
+ source=self.source,
442
+ external_id=str(path),
443
+ title=title,
444
+ started_at=started or time.time(),
445
+ ended_at=ended,
446
+ turns=turns,
447
+ )
448
+
449
+
450
+
451
+
452
+ # ---------- openclaw --------------------------------------------------------
453
+
454
+ class OpenClawLoader(BaseLoader):
455
+ """OpenClaw session transcripts (clawx client + clawx OpenClaw agent).
456
+
457
+ Real-world file format (clawx main agent):
458
+
459
+ L1 {"type": "session", "id": "<uuid>", "timestamp": "<iso>",
460
+ "cwd": "...", "version": "3"}
461
+ L2 {"type": "model_change", ...}
462
+ L3 {"type": "thinking_level_change", ...}
463
+ L4 {"type": "custom", "customType": "model-snapshot", ...}
464
+ L5 {"type": "message", "id": "...", "timestamp": "<iso>",
465
+ "message": {"role": "user"|"assistant"|"toolResult",
466
+ "content": [{"type": "text", "text": "..."} |
467
+ {"type": "thinking", "thinking": "..."} |
468
+ {"type": "toolCall", "name": "exec",
469
+ "arguments": {"command": "..."}} |
470
+ {"type": "toolResult", "content": [...]}],
471
+ "timestamp": <epoch_ms>}}
472
+ ...
473
+
474
+ Companion files (skipped to avoid double counting):
475
+ <id>.trajectory.jsonl - runtime trace events
476
+ <id>.trajectory-path.json
477
+ <id>.checkpoint.<uuid>.jsonl - mid-run snapshots
478
+
479
+ We also still accept the legacy flat shape:
480
+
481
+ {"role": "...", "content": "...", "ts": <epoch or ISO8601>}
482
+
483
+ so existing JSONL exports keep working.
484
+
485
+ Files live under any of:
486
+ ~/.openclaw/agents/main/sessions/*.jsonl (clawx real path)
487
+ ~/.openclaw/sessions/*.jsonl
488
+ ~/.openclaw/workspace/memory/*.md (clawx daily logs)
489
+
490
+ `discover()` walks ``**/*.jsonl`` + ``**/*.json`` + ``**/*.md``.
491
+ """
492
+
493
+ source = "openclaw"
494
+
495
+ # Companion suffixes to skip — they describe the same session but
496
+ # at a finer granularity than we need (trajectory/checkpoint).
497
+ _COMPANION_SUFFIXES = (
498
+ ".trajectory.jsonl",
499
+ ".trajectory-path.json",
500
+ ".checkpoint.",
501
+ )
502
+
503
+ # Subdirectories under ~/.openclaw that contain real transcripts.
504
+ # Anything else (extensions/, plugins/, npm/, node_modules/, ...) is
505
+ # skipped so we don't try to ingest 1000s of config / vendor files.
506
+ _WHITELIST_DIRS = (
507
+ "agents/main/sessions",
508
+ "sessions",
509
+ "workspace/memory",
510
+ "memory",
511
+ )
512
+
513
+ def discover(self, root: Path) -> Iterable[Path]:
514
+ root = Path(root).expanduser()
515
+ if not root.exists():
516
+ return []
517
+ seen: set = set()
518
+ # 1) If the root itself matches a whitelist dir, scan it directly.
519
+ rel = None
520
+ try:
521
+ rel = str(root.relative_to(Path.home() / ".openclaw"))
522
+ except ValueError:
523
+ pass
524
+ if rel and any(rel == d or rel.startswith(d) for d in self._WHITELIST_DIRS):
525
+ roots = [root]
526
+ else:
527
+ # 2) Otherwise pick all whitelist dirs that exist under root.
528
+ roots = [root / d for d in self._WHITELIST_DIRS if (root / d).exists()]
529
+ if not roots:
530
+ # Fallback: shallow scan of root (one level only) so a
531
+ # custom path like /tmp/foo/ still works.
532
+ roots = [root]
533
+
534
+ for base in roots:
535
+ for pat in ("**/*.jsonl", "**/*.json", "**/*.md"):
536
+ for p in sorted(base.glob(pat)):
537
+ # Skip companion files describing the same session
538
+ if any(s in p.name for s in self._COMPANION_SUFFIXES):
539
+ continue
540
+ if p.name.endswith(".trajectory.json"):
541
+ continue
542
+ # Skip session index / pointer metadata files
543
+ if p.name in ("sessions.json", "trajectory-path.json"):
544
+ continue
545
+ if p in seen:
546
+ continue
547
+ # Skip vendor / node_modules anywhere they appear
548
+ parts = set(p.parts)
549
+ if parts & {"node_modules", ".git", "dist", "build"}:
550
+ continue
551
+ seen.add(p)
552
+ yield p
553
+
554
+ def _parse(self, path: Path) -> IngestedSession | None:
555
+ text = path.read_text(encoding="utf-8", errors="ignore").strip()
556
+ if not text:
557
+ return None
558
+ # Markdown daily logs (clawx workspace/memory/*.md)
559
+ if path.suffix.lower() == ".md":
560
+ return self._parse_markdown(path, text)
561
+ # Real clawx main-agent format
562
+ if text.startswith("{") and ('"type": "session"' in text or '"type":"session"' in text):
563
+ return self._parse_clawx_session(path, text)
564
+ # Legacy shapes: single-JSON with messages, or flat JSONL of role/content
565
+ return self._parse_jsonl(path)
566
+
567
+ # --- clawx main-agent format -----------------------------------------
568
+
569
+ def _parse_clawx_session(self, path: Path, text: str) -> IngestedSession | None:
570
+ """Parse the actual clawx / OpenClaw main-agent JSONL where each
571
+ line is an event with ``type=session|message|model_change|...``.
572
+ We only care about ``type=message`` records; everything else
573
+ (model change, custom snapshots, thinking-level changes) is
574
+ context we don't surface as a turn."""
575
+ import json
576
+ session_id = path.stem
577
+ cwd = None
578
+ started: float | None = None
579
+ ended: float | None = None
580
+ title: str | None = None
581
+ turns: list[IngestedTurn] = []
582
+ for line in text.splitlines():
583
+ line = line.strip()
584
+ if not line:
585
+ continue
586
+ try:
587
+ rec = json.loads(line)
588
+ except Exception:
589
+ continue
590
+ rtype = rec.get("type")
591
+ if rtype == "session":
592
+ if rec.get("id"):
593
+ session_id = str(rec["id"])
594
+ cwd = rec.get("cwd") or cwd
595
+ ts = _to_float(rec.get("timestamp"))
596
+ if ts and (started is None or ts < started):
597
+ started = ts
598
+ continue
599
+ if rtype != "message":
600
+ continue
601
+ msg = rec.get("message") or {}
602
+ role = (msg.get("role") or "").strip().lower() or "assistant"
603
+ ts_ms = msg.get("timestamp") or rec.get("timestamp")
604
+ ts = _to_float(ts_ms)
605
+ content = msg.get("content")
606
+ text_part = self._extract_message_text(content)
607
+ if not text_part:
608
+ continue
609
+ turns.append(IngestedTurn(role, text_part, ts))
610
+ if started is None or (ts and ts < started):
611
+ started = ts
612
+ if ended is None or (ts and ts > ended):
613
+ ended = ts
614
+ if title is None and role == "user":
615
+ title = text_part[:80]
616
+ if not turns:
617
+ return None
618
+ if cwd and title:
619
+ title = f"{title} · {cwd}"
620
+ elif cwd:
621
+ title = cwd
622
+ return IngestedSession(
623
+ source=self.source,
624
+ external_id=session_id,
625
+ title=title,
626
+ started_at=started or time.time(),
627
+ ended_at=ended,
628
+ turns=turns,
629
+ )
630
+
631
+ @staticmethod
632
+ def _extract_message_text(content) -> str:
633
+ """Pull the user-visible text out of a message.content which can
634
+ be a string, a list of typed parts (text / thinking / toolCall /
635
+ toolResult), or a dict."""
636
+ if content is None:
637
+ return ""
638
+ if isinstance(content, str):
639
+ return _norm(content)
640
+ if isinstance(content, dict):
641
+ content = [content]
642
+ if not isinstance(content, list):
643
+ return ""
644
+ parts: list[str] = []
645
+ for p in content:
646
+ if not isinstance(p, dict):
647
+ parts.append(str(p))
648
+ continue
649
+ t = p.get("type")
650
+ if t == "text":
651
+ if p.get("text"):
652
+ parts.append(str(p["text"]))
653
+ elif t == "thinking":
654
+ # keep a brief marker so we don't lose the assistant's plan
655
+ think = p.get("thinking") or ""
656
+ if think:
657
+ parts.append("[thinking] " + str(think)[:400])
658
+ elif t == "toolCall":
659
+ name = p.get("name") or "tool"
660
+ args = p.get("arguments") or {}
661
+ if isinstance(args, dict) and "command" in args:
662
+ parts.append(f"[toolCall:{name}] {str(args['command'])[:200]}")
663
+ else:
664
+ parts.append(f"[toolCall:{name}] {str(args)[:200]}")
665
+ elif t == "toolResult":
666
+ inner = p.get("content") or p.get("text") or ""
667
+ if isinstance(inner, list):
668
+ inner = OpenClawLoader._extract_message_text(inner)
669
+ if inner:
670
+ parts.append(f"[toolResult] {str(inner)[:300]}")
671
+ else:
672
+ if p.get("text"):
673
+ parts.append(str(p["text"]))
674
+ elif p.get("content"):
675
+ parts.append(str(p["content"])[:300])
676
+ return _norm("\n".join(parts))
677
+
678
+ # --- Markdown daily logs ---------------------------------------------
679
+
680
+ def _parse_markdown(self, path: Path, text: str) -> IngestedSession | None:
681
+ """clawx writes daily memory journals under workspace/memory/*.md.
682
+ These are not conversations but they ARE the user's distilled
683
+ memory. We ingest each as a single 'reflection' turn so the
684
+ consolidator can pick them up alongside normal sessions."""
685
+ title = None
686
+ for line in text.splitlines():
687
+ if line.strip().startswith("# "):
688
+ title = line.strip()[2:].strip()[:120]
689
+ break
690
+ if not title:
691
+ title = path.stem
692
+ # Heuristic timestamp from filename (2026-05-31.md) or mtime
693
+ ts: float | None = None
694
+ try:
695
+ from datetime import datetime
696
+ ts = datetime.strptime(path.stem[:10], "%Y-%m-%d").timestamp()
697
+ except Exception:
698
+ try:
699
+ ts = path.stat().st_mtime
700
+ except Exception:
701
+ ts = time.time()
702
+ turn = IngestedTurn("reflection", _norm(text), ts)
703
+ return IngestedSession(
704
+ source=self.source,
705
+ external_id=path.stem,
706
+ title=title,
707
+ started_at=ts or time.time(),
708
+ ended_at=ts,
709
+ turns=[turn],
710
+ )
711
+
712
+ # --- legacy JSONL fallback -------------------------------------------
713
+
714
+ def _parse_jsonl(self, path: Path) -> IngestedSession | None:
715
+ turns: list[IngestedTurn] = []
716
+ started: float | None = None
717
+ ended: float | None = None
718
+ title: str | None = None
719
+ external_id = path.stem
720
+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
721
+ line = line.strip()
722
+ if not line:
723
+ continue
724
+ try:
725
+ rec = json.loads(line)
726
+ except Exception:
727
+ continue
728
+ role = (rec.get("role") or rec.get("speaker") or "").strip().lower()
729
+ text = _norm(str(rec.get("content") or rec.get("text") or ""))
730
+ if not text:
731
+ continue
732
+ ts = _to_float(rec.get("ts") or rec.get("timestamp") or rec.get("created_at"))
733
+ turns.append(IngestedTurn(role, text, ts))
734
+ if started is None or (ts and ts < started):
735
+ started = ts
736
+ if ended is None or (ts and ts > ended):
737
+ ended = ts
738
+ if title is None and role == "user":
739
+ title = text[:80]
740
+ if "session_id" in rec:
741
+ external_id = str(rec["session_id"])
742
+ if not turns:
743
+ return None
744
+ return IngestedSession(
745
+ source=self.source,
746
+ external_id=external_id,
747
+ title=title,
748
+ started_at=started or time.time(),
749
+ ended_at=ended,
750
+ turns=turns,
751
+ )
752
+
753
+
754
+ # ---------- registry ---------------------------------------------------------
755
+
756
+
757
+ LOADERS = {
758
+ "codex": CodexLoader,
759
+ "claude": ClaudeLoader,
760
+ "hermes": HermesLoader,
761
+ "openclaw": OpenClawLoader,
762
+ }
763
+
764
+
765
+ def get_loader(source: str) -> BaseLoader:
766
+ cls = LOADERS.get(source.lower())
767
+ if cls is None:
768
+ raise ValueError(f"unknown source: {source!r}; expected one of {sorted(LOADERS)}")
769
+ return cls()
770
+
771
+
772
+ def default_paths() -> dict:
773
+ """Best-guess roots per source — adjust to taste."""
774
+ return {
775
+ "codex": Path.home() / ".codex" / "sessions",
776
+ "claude": Path.home() / ".claude",
777
+ "hermes": Path.home() / ".hermes",
778
+ # clawx OpenClaw stores real sessions under agents/main/sessions;
779
+ # we still walk the broader ~/.openclaw so workspace/memory/*.md
780
+ # daily journals and other agent dirs are picked up.
781
+ "openclaw": Path.home() / ".openclaw",
782
+ }