agentcache-core 0.9.9__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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- agentcache_core-0.9.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, List, Tuple
|
|
7
|
+
|
|
8
|
+
# Constants
|
|
9
|
+
MAX_FILES_DEFAULT = 200
|
|
10
|
+
MAX_FILES_UPPER_BOUND = 1000
|
|
11
|
+
|
|
12
|
+
SENSITIVE_PATH_PATTERNS = [
|
|
13
|
+
re.compile(r"(^|[\\/_.-])secret([\\/_.-]|s?$)", re.IGNORECASE),
|
|
14
|
+
re.compile(r"(^|[\\/_.-])credentials?([\\/_.-]|$)", re.IGNORECASE),
|
|
15
|
+
re.compile(r"(^|[\\/_.-])private[_-]?key([\\/_.-]|$)", re.IGNORECASE),
|
|
16
|
+
re.compile(r"(^|[\\/])\.env(\.[\w-]+)?$", re.IGNORECASE),
|
|
17
|
+
re.compile(r"(^|[\\/_.-])id_rsa([\\/_.-]|$)", re.IGNORECASE),
|
|
18
|
+
re.compile(r"(^|[\\/])auth[_-]?token([\\/_.-]|$)", re.IGNORECASE),
|
|
19
|
+
re.compile(r"(^|[\\/])bearer[_-]?token([\\/_.-]|$)", re.IGNORECASE),
|
|
20
|
+
re.compile(r"(^|[\\/])access[_-]?token([\\/_.-]|$)", re.IGNORECASE),
|
|
21
|
+
re.compile(r"(^|[\\/])api[_-]?token([\\/_.-]|$)", re.IGNORECASE),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
LESSON_PATTERNS = [
|
|
25
|
+
re.compile(
|
|
26
|
+
r"\b(always|never|don'?t|do not|make sure|remember to|note:|caveat:|warning:)\b[^.\n]{10,200}[.!\n]",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
),
|
|
29
|
+
re.compile(r"\b(prefer|avoid)\s[^.\n]{10,200}[.!\n]", re.IGNORECASE),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_id(prefix: str) -> str:
|
|
34
|
+
import uuid
|
|
35
|
+
|
|
36
|
+
return f"{prefix}_{uuid.uuid4().hex}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fingerprint_id(prefix: str, content: str) -> str:
|
|
40
|
+
# Hash content for stable ID (similar to TS fingerprintId)
|
|
41
|
+
h = hashlib.sha256(content.strip().encode("utf-8")).hexdigest()
|
|
42
|
+
return f"{prefix}_{h[:32]}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_sensitive(path: str) -> bool:
|
|
46
|
+
return any(pattern.search(path) for pattern in SENSITIVE_PATH_PATTERNS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def derive_project(cwd: str) -> str:
|
|
50
|
+
if not cwd:
|
|
51
|
+
return "unknown"
|
|
52
|
+
parts = [p for p in re.split(r"[\\/]", cwd) if p]
|
|
53
|
+
return parts[-1] if parts else "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def to_text(content: Any) -> str:
|
|
57
|
+
if isinstance(content, str):
|
|
58
|
+
return content
|
|
59
|
+
if not isinstance(content, list):
|
|
60
|
+
return ""
|
|
61
|
+
parts = []
|
|
62
|
+
for item in content:
|
|
63
|
+
if (
|
|
64
|
+
isinstance(item, dict)
|
|
65
|
+
and item.get("type") == "text"
|
|
66
|
+
and isinstance(item.get("text"), str)
|
|
67
|
+
):
|
|
68
|
+
parts.append(item["text"])
|
|
69
|
+
return "\n".join(parts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_tool_uses(content: Any) -> List[Dict[str, Any]]:
|
|
73
|
+
if not isinstance(content, list):
|
|
74
|
+
return []
|
|
75
|
+
out = []
|
|
76
|
+
for item in content:
|
|
77
|
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
78
|
+
out.append(
|
|
79
|
+
{
|
|
80
|
+
"id": item.get("id", ""),
|
|
81
|
+
"name": item.get("name", "unknown"),
|
|
82
|
+
"input": item.get("input"),
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def extract_tool_results(content: Any) -> List[Dict[str, Any]]:
|
|
89
|
+
if not isinstance(content, list):
|
|
90
|
+
return []
|
|
91
|
+
out = []
|
|
92
|
+
for item in content:
|
|
93
|
+
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
94
|
+
out.append(
|
|
95
|
+
{
|
|
96
|
+
"toolUseId": item.get("tool_use_id", ""),
|
|
97
|
+
"output": item.get("content"),
|
|
98
|
+
"isError": item.get("is_error") is True,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_jsonl_text(text: str, fallback_session_id: str = None) -> Dict[str, Any]:
|
|
105
|
+
lines = [line_str for line_str in text.split("\n") if line_str.strip()]
|
|
106
|
+
entries = []
|
|
107
|
+
for line in lines:
|
|
108
|
+
try:
|
|
109
|
+
parsed = json.loads(line)
|
|
110
|
+
if isinstance(parsed, dict):
|
|
111
|
+
entries.append(parsed)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
session_id = ""
|
|
116
|
+
cwd = ""
|
|
117
|
+
first_ts = ""
|
|
118
|
+
last_ts = ""
|
|
119
|
+
observations = []
|
|
120
|
+
|
|
121
|
+
for entry in entries:
|
|
122
|
+
if entry.get("sessionId") and not session_id:
|
|
123
|
+
session_id = entry["sessionId"]
|
|
124
|
+
if entry.get("cwd") and not cwd:
|
|
125
|
+
cwd = entry["cwd"]
|
|
126
|
+
|
|
127
|
+
ts = entry.get("timestamp") or datetime.datetime.now(
|
|
128
|
+
datetime.timezone.utc
|
|
129
|
+
).isoformat().replace("+00:00", "Z")
|
|
130
|
+
if not first_ts:
|
|
131
|
+
first_ts = ts
|
|
132
|
+
last_ts = ts
|
|
133
|
+
|
|
134
|
+
msg = entry.get("message") or {}
|
|
135
|
+
role = msg.get("role")
|
|
136
|
+
content = msg.get("content")
|
|
137
|
+
|
|
138
|
+
if entry.get("type") == "user" and role == "user":
|
|
139
|
+
tool_results = extract_tool_results(content)
|
|
140
|
+
if tool_results:
|
|
141
|
+
for result in tool_results:
|
|
142
|
+
observations.append(
|
|
143
|
+
{
|
|
144
|
+
"id": generate_id("obs"),
|
|
145
|
+
"sessionId": session_id or "imported",
|
|
146
|
+
"timestamp": ts,
|
|
147
|
+
"hookType": "post_tool_failure"
|
|
148
|
+
if result["isError"]
|
|
149
|
+
else "post_tool_use",
|
|
150
|
+
"toolName": None,
|
|
151
|
+
"toolInput": {"toolUseId": result["toolUseId"]},
|
|
152
|
+
"toolOutput": result["output"],
|
|
153
|
+
"raw": entry,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
txt = to_text(content)
|
|
158
|
+
if txt.strip():
|
|
159
|
+
observations.append(
|
|
160
|
+
{
|
|
161
|
+
"id": generate_id("obs"),
|
|
162
|
+
"sessionId": session_id or "imported",
|
|
163
|
+
"timestamp": ts,
|
|
164
|
+
"hookType": "prompt_submit",
|
|
165
|
+
"userPrompt": txt,
|
|
166
|
+
"raw": entry,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
elif entry.get("type") == "assistant" and role == "assistant":
|
|
170
|
+
txt = to_text(content)
|
|
171
|
+
tools = extract_tool_uses(content)
|
|
172
|
+
if txt.strip():
|
|
173
|
+
observations.append(
|
|
174
|
+
{
|
|
175
|
+
"id": generate_id("obs"),
|
|
176
|
+
"sessionId": session_id or "imported",
|
|
177
|
+
"timestamp": ts,
|
|
178
|
+
"hookType": "stop",
|
|
179
|
+
"assistantResponse": txt,
|
|
180
|
+
"raw": entry,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
for tool in tools:
|
|
184
|
+
observations.append(
|
|
185
|
+
{
|
|
186
|
+
"id": generate_id("obs"),
|
|
187
|
+
"sessionId": session_id or "imported",
|
|
188
|
+
"timestamp": ts,
|
|
189
|
+
"hookType": "pre_tool_use",
|
|
190
|
+
"toolName": tool["name"],
|
|
191
|
+
"toolInput": tool["input"],
|
|
192
|
+
"raw": {"toolUseId": tool["id"], "entry": entry},
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
effective_session_id = session_id or fallback_session_id or generate_id("sess")
|
|
197
|
+
for obs in observations:
|
|
198
|
+
if obs["sessionId"] == "imported":
|
|
199
|
+
obs["sessionId"] = effective_session_id
|
|
200
|
+
|
|
201
|
+
now_iso = (
|
|
202
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
203
|
+
)
|
|
204
|
+
return {
|
|
205
|
+
"sessionId": effective_session_id,
|
|
206
|
+
"project": derive_project(cwd),
|
|
207
|
+
"cwd": cwd or os.getcwd(),
|
|
208
|
+
"startedAt": first_ts or now_iso,
|
|
209
|
+
"endedAt": last_ts or now_iso,
|
|
210
|
+
"observations": observations,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def derive_crystal_and_lessons(
|
|
215
|
+
kv,
|
|
216
|
+
session_id: str,
|
|
217
|
+
project: str,
|
|
218
|
+
raw_obs: List[Dict[str, Any]],
|
|
219
|
+
compressed: List[Dict[str, Any]],
|
|
220
|
+
first_prompt: str = None,
|
|
221
|
+
) -> None:
|
|
222
|
+
from .core import KV
|
|
223
|
+
|
|
224
|
+
if not raw_obs:
|
|
225
|
+
return
|
|
226
|
+
created_at = (
|
|
227
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
files = set()
|
|
231
|
+
tools = set()
|
|
232
|
+
for c in compressed:
|
|
233
|
+
for f in c.get("files", []):
|
|
234
|
+
files.add(f)
|
|
235
|
+
if c.get("type") and c.get("type") != "conversation" and c.get("title"):
|
|
236
|
+
tools.add(c["title"])
|
|
237
|
+
|
|
238
|
+
assistant_texts = []
|
|
239
|
+
user_prompts = []
|
|
240
|
+
for r in raw_obs:
|
|
241
|
+
if (
|
|
242
|
+
isinstance(r.get("assistantResponse"), str)
|
|
243
|
+
and r["assistantResponse"].strip()
|
|
244
|
+
):
|
|
245
|
+
assistant_texts.append(r["assistantResponse"])
|
|
246
|
+
if isinstance(r.get("userPrompt"), str) and r["userPrompt"].strip():
|
|
247
|
+
user_prompts.append(r["userPrompt"])
|
|
248
|
+
|
|
249
|
+
lesson_matches = {}
|
|
250
|
+
for text in (assistant_texts + user_prompts)[:200]:
|
|
251
|
+
for pat in LESSON_PATTERNS:
|
|
252
|
+
for m in pat.finditer(text):
|
|
253
|
+
if len(lesson_matches) >= 40:
|
|
254
|
+
break
|
|
255
|
+
snippet = re.sub(r"\s+", " ", m.group(0)).strip()
|
|
256
|
+
if 20 <= len(snippet) <= 220:
|
|
257
|
+
key = snippet.lower()
|
|
258
|
+
if key not in lesson_matches:
|
|
259
|
+
lesson_matches[key] = snippet
|
|
260
|
+
|
|
261
|
+
lesson_entries = list(lesson_matches.values())[:20]
|
|
262
|
+
lesson_ids = []
|
|
263
|
+
for content in lesson_entries:
|
|
264
|
+
lesson_id = fingerprint_id("lesson", content.lower())
|
|
265
|
+
try:
|
|
266
|
+
existing = kv.get(KV.lessons, lesson_id)
|
|
267
|
+
if existing:
|
|
268
|
+
existing_sources = existing.get("sourceIds", [])
|
|
269
|
+
merged_sources = (
|
|
270
|
+
existing_sources
|
|
271
|
+
if session_id in existing_sources
|
|
272
|
+
else (existing_sources + [session_id])
|
|
273
|
+
)
|
|
274
|
+
existing_tags = existing.get("tags", [])
|
|
275
|
+
merged_tags = (
|
|
276
|
+
existing_tags
|
|
277
|
+
if "auto-import" in existing_tags
|
|
278
|
+
else (existing_tags + ["auto-import"])
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
existing["sourceIds"] = merged_sources
|
|
282
|
+
existing["tags"] = merged_tags
|
|
283
|
+
existing["reinforcements"] = existing.get("reinforcements", 0) + 1
|
|
284
|
+
existing["updatedAt"] = created_at
|
|
285
|
+
existing["lastReinforcedAt"] = created_at
|
|
286
|
+
kv.set(KV.lessons, lesson_id, existing)
|
|
287
|
+
else:
|
|
288
|
+
lesson = {
|
|
289
|
+
"id": lesson_id,
|
|
290
|
+
"content": content,
|
|
291
|
+
"context": first_prompt or project,
|
|
292
|
+
"confidence": 0.4,
|
|
293
|
+
"reinforcements": 0,
|
|
294
|
+
"source": "consolidation",
|
|
295
|
+
"sourceIds": [session_id],
|
|
296
|
+
"project": project,
|
|
297
|
+
"tags": ["auto-import"],
|
|
298
|
+
"createdAt": created_at,
|
|
299
|
+
"updatedAt": created_at,
|
|
300
|
+
"decayRate": 0.05,
|
|
301
|
+
}
|
|
302
|
+
kv.set(KV.lessons, lesson_id, lesson)
|
|
303
|
+
lesson_ids.append(lesson_id)
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
crystal_id = fingerprint_id("crystal", session_id)
|
|
308
|
+
if first_prompt:
|
|
309
|
+
narrative_preview = first_prompt[:300]
|
|
310
|
+
else:
|
|
311
|
+
previews = []
|
|
312
|
+
for c in compressed[:5]:
|
|
313
|
+
p = c.get("narrative") or c.get("title")
|
|
314
|
+
if p:
|
|
315
|
+
previews.append(p)
|
|
316
|
+
narrative_preview = (" · ".join(previews))[:300]
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
existing_crystal = kv.get(KV.crystals, crystal_id) or {}
|
|
320
|
+
crystal = {
|
|
321
|
+
"id": crystal_id,
|
|
322
|
+
"narrative": narrative_preview
|
|
323
|
+
or f"Session {session_id[:12]} ({len(raw_obs)} observations)",
|
|
324
|
+
"keyOutcomes": list(tools)[:8],
|
|
325
|
+
"filesAffected": list(files)[:20],
|
|
326
|
+
"lessons": lesson_ids,
|
|
327
|
+
"sourceActionIds": existing_crystal.get("sourceActionIds", []),
|
|
328
|
+
"sessionId": session_id,
|
|
329
|
+
"project": project,
|
|
330
|
+
"createdAt": existing_crystal.get("createdAt", created_at),
|
|
331
|
+
}
|
|
332
|
+
kv.set(KV.crystals, crystal_id, crystal)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def find_jsonl_files(root: str, limit=200) -> Tuple[List[str], bool, int, bool]:
|
|
338
|
+
out = []
|
|
339
|
+
discovered = 0
|
|
340
|
+
walked = 0
|
|
341
|
+
traversal_cap = max(limit * 50, 50000)
|
|
342
|
+
|
|
343
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
344
|
+
if walked >= traversal_cap:
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
# skip symlinks or hidden directories
|
|
348
|
+
dirnames[:] = [
|
|
349
|
+
d
|
|
350
|
+
for d in dirnames
|
|
351
|
+
if not d.startswith(".") and not os.path.islink(os.path.join(dirpath, d))
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
for name in filenames:
|
|
355
|
+
walked += 1
|
|
356
|
+
if walked >= traversal_cap:
|
|
357
|
+
break
|
|
358
|
+
if name.endswith(".jsonl"):
|
|
359
|
+
full = os.path.join(dirpath, name)
|
|
360
|
+
if not os.path.islink(full):
|
|
361
|
+
discovered += 1
|
|
362
|
+
if len(out) < limit:
|
|
363
|
+
out.append(full)
|
|
364
|
+
|
|
365
|
+
traversal_capped = walked >= traversal_cap
|
|
366
|
+
truncated = discovered > len(out) or traversal_capped
|
|
367
|
+
return out, truncated, discovered, traversal_capped
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, Any]:
|
|
371
|
+
from .core import KV
|
|
372
|
+
from .legacy import build_synthetic_compression
|
|
373
|
+
|
|
374
|
+
default_root = os.path.expanduser(os.path.join("~", ".claude", "projects"))
|
|
375
|
+
raw_path = path or default_root
|
|
376
|
+
|
|
377
|
+
expanded = os.path.expanduser(raw_path)
|
|
378
|
+
abs_path = os.path.abspath(expanded)
|
|
379
|
+
|
|
380
|
+
if is_sensitive(abs_path):
|
|
381
|
+
return {"success": False, "error": "refusing to process sensitive-looking path"}
|
|
382
|
+
if os.path.islink(abs_path):
|
|
383
|
+
return {"success": False, "error": "symlinks are not supported"}
|
|
384
|
+
if not os.path.exists(abs_path):
|
|
385
|
+
return {"success": False, "error": "path not found"}
|
|
386
|
+
|
|
387
|
+
limit = (
|
|
388
|
+
max_files if isinstance(max_files, int) and max_files > 0 else MAX_FILES_DEFAULT
|
|
389
|
+
)
|
|
390
|
+
limit = min(limit, MAX_FILES_UPPER_BOUND)
|
|
391
|
+
|
|
392
|
+
files = []
|
|
393
|
+
truncated = False
|
|
394
|
+
discovered = 0
|
|
395
|
+
traversal_capped = False
|
|
396
|
+
|
|
397
|
+
if os.path.isdir(abs_path):
|
|
398
|
+
files, truncated, discovered, traversal_capped = find_jsonl_files(
|
|
399
|
+
abs_path, limit
|
|
400
|
+
)
|
|
401
|
+
elif os.path.isfile(abs_path) and abs_path.endswith(".jsonl"):
|
|
402
|
+
files = [abs_path]
|
|
403
|
+
discovered = 1
|
|
404
|
+
else:
|
|
405
|
+
return {"success": False, "error": "path must be a .jsonl file or directory"}
|
|
406
|
+
|
|
407
|
+
if not files:
|
|
408
|
+
return {
|
|
409
|
+
"success": True,
|
|
410
|
+
"imported": 0,
|
|
411
|
+
"sessionIds": [],
|
|
412
|
+
"observations": 0,
|
|
413
|
+
"discovered": discovered,
|
|
414
|
+
"truncated": truncated,
|
|
415
|
+
"traversalCapped": traversal_capped,
|
|
416
|
+
"maxFiles": limit,
|
|
417
|
+
"maxFilesUpperBound": MAX_FILES_UPPER_BOUND,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
session_ids = []
|
|
421
|
+
observation_count = 0
|
|
422
|
+
|
|
423
|
+
for file in files:
|
|
424
|
+
if is_sensitive(file):
|
|
425
|
+
continue
|
|
426
|
+
if os.path.islink(file):
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
with open(file, "r", encoding="utf-8", errors="ignore") as f:
|
|
431
|
+
text = f.read()
|
|
432
|
+
except Exception as e:
|
|
433
|
+
print(f"[import-jsonl] Failed to read {file}: {e}")
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
parsed = parse_jsonl_text(text, generate_id("sess"))
|
|
437
|
+
if not parsed["observations"]:
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
first_prompt_obs = None
|
|
441
|
+
for o in parsed["observations"]:
|
|
442
|
+
if isinstance(o.get("userPrompt"), str) and o["userPrompt"].strip():
|
|
443
|
+
first_prompt_obs = o
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
first_prompt = None
|
|
447
|
+
if first_prompt_obs:
|
|
448
|
+
first_prompt = re.sub(r"\s+", " ", first_prompt_obs["userPrompt"]).strip()[
|
|
449
|
+
:200
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
existing = kv.get(KV.sessions, parsed["sessionId"])
|
|
453
|
+
if existing:
|
|
454
|
+
existing["observationCount"] = existing.get("observationCount", 0) + len(
|
|
455
|
+
parsed["observations"]
|
|
456
|
+
)
|
|
457
|
+
if parsed["endedAt"] > existing.get("endedAt", ""):
|
|
458
|
+
existing["endedAt"] = parsed["endedAt"]
|
|
459
|
+
if existing.get("status") == "active":
|
|
460
|
+
existing["status"] = "completed"
|
|
461
|
+
|
|
462
|
+
existing_tags = existing.get("tags", [])
|
|
463
|
+
if "jsonl-import" not in existing_tags:
|
|
464
|
+
existing["tags"] = existing_tags + ["jsonl-import"]
|
|
465
|
+
if not existing.get("firstPrompt") and first_prompt:
|
|
466
|
+
existing["firstPrompt"] = first_prompt
|
|
467
|
+
if not existing.get("id"):
|
|
468
|
+
existing["id"] = parsed["sessionId"]
|
|
469
|
+
|
|
470
|
+
kv.set(KV.sessions, parsed["sessionId"], existing)
|
|
471
|
+
else:
|
|
472
|
+
session = {
|
|
473
|
+
"id": parsed["sessionId"],
|
|
474
|
+
"project": parsed["project"],
|
|
475
|
+
"cwd": parsed["cwd"],
|
|
476
|
+
"startedAt": parsed["startedAt"],
|
|
477
|
+
"endedAt": parsed["endedAt"],
|
|
478
|
+
"status": "completed",
|
|
479
|
+
"observationCount": len(parsed["observations"]),
|
|
480
|
+
"tags": ["jsonl-import"],
|
|
481
|
+
"firstPrompt": first_prompt,
|
|
482
|
+
}
|
|
483
|
+
kv.set(KV.sessions, session["id"], session)
|
|
484
|
+
|
|
485
|
+
compressed = []
|
|
486
|
+
for obs in parsed["observations"]:
|
|
487
|
+
synthetic = build_synthetic_compression(obs)
|
|
488
|
+
compressed.append(synthetic)
|
|
489
|
+
kv.set(KV.observations(parsed["sessionId"]), obs["id"], synthetic)
|
|
490
|
+
|
|
491
|
+
observation_count += len(parsed["observations"])
|
|
492
|
+
session_ids.append(parsed["sessionId"])
|
|
493
|
+
|
|
494
|
+
derive_crystal_and_lessons(
|
|
495
|
+
kv,
|
|
496
|
+
parsed["sessionId"],
|
|
497
|
+
parsed["project"],
|
|
498
|
+
parsed["observations"],
|
|
499
|
+
compressed,
|
|
500
|
+
first_prompt,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Audit trail
|
|
504
|
+
try:
|
|
505
|
+
from .legacy import log_audit
|
|
506
|
+
|
|
507
|
+
log_audit(
|
|
508
|
+
kv,
|
|
509
|
+
"import",
|
|
510
|
+
"mem::replay::import-jsonl",
|
|
511
|
+
f"Imported {len(session_ids)} sessions: {','.join(session_ids[:3])}...",
|
|
512
|
+
)
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
|
|
516
|
+
# Dolt commit if enabled
|
|
517
|
+
try:
|
|
518
|
+
kv.commit_version(
|
|
519
|
+
f"Import {len(session_ids)} Claude Code sessions from JSONL", "system"
|
|
520
|
+
)
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
"success": True,
|
|
526
|
+
"imported": len(files),
|
|
527
|
+
"sessionIds": session_ids,
|
|
528
|
+
"observations": observation_count,
|
|
529
|
+
"discovered": discovered,
|
|
530
|
+
"truncated": truncated,
|
|
531
|
+
"traversalCapped": traversal_capped,
|
|
532
|
+
"maxFiles": limit,
|
|
533
|
+
"maxFilesUpperBound": MAX_FILES_UPPER_BOUND,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def kind_from_hook(obs: Dict[str, Any]) -> str:
|
|
538
|
+
ht = obs.get("hookType")
|
|
539
|
+
if ht == "session_start":
|
|
540
|
+
return "session_start"
|
|
541
|
+
elif ht == "session_end":
|
|
542
|
+
return "session_end"
|
|
543
|
+
elif ht == "prompt_submit":
|
|
544
|
+
return "prompt"
|
|
545
|
+
elif ht == "stop":
|
|
546
|
+
return "response" if obs.get("assistantResponse") else "hook"
|
|
547
|
+
elif ht == "pre_tool_use":
|
|
548
|
+
return "tool_call"
|
|
549
|
+
elif ht == "post_tool_use":
|
|
550
|
+
return "tool_result"
|
|
551
|
+
elif ht == "post_tool_failure":
|
|
552
|
+
return "tool_error"
|
|
553
|
+
else:
|
|
554
|
+
return "hook"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def label_for(obs: Dict[str, Any], kind: str) -> str:
|
|
558
|
+
if kind == "prompt":
|
|
559
|
+
val = obs.get("userPrompt") or "User prompt"
|
|
560
|
+
return val[:79] + "…" if len(val) > 80 else val
|
|
561
|
+
elif kind == "response":
|
|
562
|
+
val = obs.get("assistantResponse") or "Assistant response"
|
|
563
|
+
return val[:79] + "…" if len(val) > 80 else val
|
|
564
|
+
elif kind == "tool_call":
|
|
565
|
+
return f"{obs.get('toolName') or 'tool'} ▸ call"
|
|
566
|
+
elif kind == "tool_result":
|
|
567
|
+
return f"{obs.get('toolName') or 'tool'} ▸ result"
|
|
568
|
+
elif kind == "tool_error":
|
|
569
|
+
return f"{obs.get('toolName') or 'tool'} ▸ error"
|
|
570
|
+
elif kind == "session_start":
|
|
571
|
+
return "Session start"
|
|
572
|
+
elif kind == "session_end":
|
|
573
|
+
return "Session end"
|
|
574
|
+
else:
|
|
575
|
+
return obs.get("hookType") or ""
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def estimate_duration_ms(event: Dict[str, Any]) -> int:
|
|
579
|
+
body = event.get("body") or ""
|
|
580
|
+
tool_input = event.get("toolInput") or ""
|
|
581
|
+
tool_output = event.get("toolOutput") or ""
|
|
582
|
+
|
|
583
|
+
chars = len(body)
|
|
584
|
+
if isinstance(tool_input, str):
|
|
585
|
+
chars += len(tool_input)
|
|
586
|
+
elif tool_input is not None:
|
|
587
|
+
chars += len(json.dumps(tool_input))
|
|
588
|
+
|
|
589
|
+
if isinstance(tool_output, str):
|
|
590
|
+
chars += len(tool_output)
|
|
591
|
+
elif tool_output is not None:
|
|
592
|
+
chars += len(json.dumps(tool_output))
|
|
593
|
+
|
|
594
|
+
if chars == 0:
|
|
595
|
+
return 300
|
|
596
|
+
ms = round((chars / 40) * 1000)
|
|
597
|
+
return max(300, min(20000, ms))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def project_timeline(observations: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
601
|
+
if not observations:
|
|
602
|
+
now = (
|
|
603
|
+
datetime.datetime.now(datetime.timezone.utc)
|
|
604
|
+
.isoformat()
|
|
605
|
+
.replace("+00:00", "Z")
|
|
606
|
+
)
|
|
607
|
+
return {
|
|
608
|
+
"sessionId": "",
|
|
609
|
+
"startedAt": now,
|
|
610
|
+
"endedAt": now,
|
|
611
|
+
"totalDurationMs": 0,
|
|
612
|
+
"eventCount": 0,
|
|
613
|
+
"events": [],
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
sorted_obs = sorted(observations, key=lambda o: o.get("timestamp", ""))
|
|
617
|
+
started_at = sorted_obs[0].get("timestamp", "")
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
import dateutil.parser
|
|
621
|
+
|
|
622
|
+
start_dt = dateutil.parser.isoparse(started_at)
|
|
623
|
+
start_ms = start_dt.timestamp() * 1000
|
|
624
|
+
except Exception:
|
|
625
|
+
start_ms = 0
|
|
626
|
+
|
|
627
|
+
events = []
|
|
628
|
+
synthetic_offset = 0
|
|
629
|
+
all_same_ts = all(o.get("timestamp") == started_at for o in sorted_obs)
|
|
630
|
+
|
|
631
|
+
for obs in sorted_obs:
|
|
632
|
+
kind = kind_from_hook(obs)
|
|
633
|
+
body = (
|
|
634
|
+
obs.get("userPrompt")
|
|
635
|
+
if kind == "prompt"
|
|
636
|
+
else (obs.get("assistantResponse") if kind == "response" else None)
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
import dateutil.parser
|
|
641
|
+
|
|
642
|
+
obs_dt = dateutil.parser.isoparse(obs.get("timestamp", ""))
|
|
643
|
+
obs_ms = obs_dt.timestamp() * 1000
|
|
644
|
+
offset_ms = (
|
|
645
|
+
int(max(0, obs_ms - start_ms)) if not all_same_ts else synthetic_offset
|
|
646
|
+
)
|
|
647
|
+
except Exception:
|
|
648
|
+
offset_ms = synthetic_offset
|
|
649
|
+
|
|
650
|
+
event = {
|
|
651
|
+
"id": obs.get("id"),
|
|
652
|
+
"sessionId": obs.get("sessionId"),
|
|
653
|
+
"ts": obs.get("timestamp"),
|
|
654
|
+
"offsetMs": offset_ms,
|
|
655
|
+
"durationMs": 0,
|
|
656
|
+
"kind": kind,
|
|
657
|
+
"label": label_for(obs, kind),
|
|
658
|
+
"body": body,
|
|
659
|
+
"toolName": obs.get("toolName"),
|
|
660
|
+
"toolInput": obs.get("toolInput"),
|
|
661
|
+
"toolOutput": obs.get("toolOutput"),
|
|
662
|
+
}
|
|
663
|
+
event["durationMs"] = estimate_duration_ms(event)
|
|
664
|
+
events.append(event)
|
|
665
|
+
synthetic_offset += event["durationMs"]
|
|
666
|
+
|
|
667
|
+
if not events:
|
|
668
|
+
total_duration_ms = 0
|
|
669
|
+
else:
|
|
670
|
+
last = events[-1]
|
|
671
|
+
total_duration_ms = last["offsetMs"] + last["durationMs"]
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
"sessionId": sorted_obs[0].get("sessionId"),
|
|
675
|
+
"startedAt": started_at,
|
|
676
|
+
"endedAt": sorted_obs[-1].get("timestamp"),
|
|
677
|
+
"totalDurationMs": total_duration_ms,
|
|
678
|
+
"eventCount": len(events),
|
|
679
|
+
"events": events,
|
|
680
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask blueprints for agentmemory-python.
|
|
3
|
+
|
|
4
|
+
Import and register all blueprints via register_blueprints(app).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .graph import graph_bp
|
|
8
|
+
from .health import health_bp
|
|
9
|
+
from .mcp import mcp_bp
|
|
10
|
+
from .memories import memories_bp
|
|
11
|
+
from .migration import migration_bp
|
|
12
|
+
from .observations import create_observations_bp, observations_bp
|
|
13
|
+
from .search import search_bp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_blueprints(app, observation_store=None, search_service=None):
|
|
17
|
+
"""Register all route blueprints on a Flask application instance."""
|
|
18
|
+
obs_bp = (
|
|
19
|
+
create_observations_bp(observation_store)
|
|
20
|
+
if observation_store
|
|
21
|
+
else observations_bp
|
|
22
|
+
)
|
|
23
|
+
app.register_blueprint(obs_bp)
|
|
24
|
+
app.register_blueprint(memories_bp)
|
|
25
|
+
app.register_blueprint(search_bp)
|
|
26
|
+
app.register_blueprint(graph_bp)
|
|
27
|
+
app.register_blueprint(health_bp)
|
|
28
|
+
app.register_blueprint(mcp_bp)
|
|
29
|
+
app.register_blueprint(migration_bp)
|