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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|