agents-memory 0.42__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.
- agent_memory/__init__.py +18 -0
- agent_memory/__main__.py +92 -0
- agent_memory/cli_help.py +166 -0
- agent_memory/consolidate.py +22 -0
- agent_memory/extract_openai.py +102 -0
- agent_memory/ingest.py +110 -0
- agent_memory/ingest_catalog.py +346 -0
- agent_memory/ingest_chats.py +14 -0
- agent_memory/ingest_common.py +170 -0
- agent_memory/ingest_config.py +245 -0
- agent_memory/ingest_extractors.py +374 -0
- agent_memory/inventory.py +101 -0
- agent_memory/mcp_server.py +313 -0
- agent_memory/store.py +2211 -0
- agent_memory/sync.py +67 -0
- agents_memory-0.42.dist-info/METADATA +148 -0
- agents_memory-0.42.dist-info/RECORD +21 -0
- agents_memory-0.42.dist-info/WHEEL +5 -0
- agents_memory-0.42.dist-info/entry_points.txt +2 -0
- agents_memory-0.42.dist-info/licenses/LICENSE +21 -0
- agents_memory-0.42.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Catalog phase: chats-index.md + entity reference cards (bodies stay on disk)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
from .ingest_common import chats_index_path, clip, record_phase, write_entity_card
|
|
11
|
+
from .ingest_config import (
|
|
12
|
+
discover_openai_exports,
|
|
13
|
+
get_source,
|
|
14
|
+
list_sources,
|
|
15
|
+
load_ingest,
|
|
16
|
+
resolve_source_roots,
|
|
17
|
+
)
|
|
18
|
+
from .store import USER_MEMORY, ensure_memory_layout, _write
|
|
19
|
+
|
|
20
|
+
Row = Tuple[str, str, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ts(value) -> str:
|
|
24
|
+
if value is None:
|
|
25
|
+
return ""
|
|
26
|
+
try:
|
|
27
|
+
if isinstance(value, (int, float)):
|
|
28
|
+
if value > 1e12:
|
|
29
|
+
value = value / 1000.0
|
|
30
|
+
return datetime.fromtimestamp(value, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
31
|
+
return str(value)[:10]
|
|
32
|
+
except (OSError, OverflowError, ValueError, TypeError):
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _md_cell(text: str) -> str:
|
|
37
|
+
return clip(text).replace("|", "\\|")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def openai_rows(root: Path) -> List[Row]:
|
|
41
|
+
rows: List[Row] = []
|
|
42
|
+
if not root.is_dir():
|
|
43
|
+
return rows
|
|
44
|
+
for shard in sorted(root.glob("conversations-*.json")):
|
|
45
|
+
data = json.loads(shard.read_text(encoding="utf-8"))
|
|
46
|
+
for conv in data if isinstance(data, list) else []:
|
|
47
|
+
if not isinstance(conv, dict):
|
|
48
|
+
continue
|
|
49
|
+
title = conv.get("title") or "(untitled)"
|
|
50
|
+
created = _ts(conv.get("create_time"))
|
|
51
|
+
rows.append((created, title, shard.name))
|
|
52
|
+
rows.sort(key=lambda r: r[0], reverse=True)
|
|
53
|
+
return rows
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def agent_transcript_rows(root: Path) -> List[Row]:
|
|
57
|
+
rows: List[Row] = []
|
|
58
|
+
if not root.is_dir():
|
|
59
|
+
return rows
|
|
60
|
+
tdir = root / "agent-transcripts" if (root / "agent-transcripts").is_dir() else root
|
|
61
|
+
workspace = root.name
|
|
62
|
+
if workspace.startswith("c-Users-"):
|
|
63
|
+
parts = workspace.split("-")
|
|
64
|
+
if len(parts) > 3:
|
|
65
|
+
workspace = "/".join(parts[3:])
|
|
66
|
+
for path in sorted(tdir.glob("*/*.jsonl")):
|
|
67
|
+
if "subagents" in path.parts:
|
|
68
|
+
continue
|
|
69
|
+
title = path.parent.name[:8]
|
|
70
|
+
try:
|
|
71
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
72
|
+
for line in fh:
|
|
73
|
+
obj = json.loads(line)
|
|
74
|
+
if obj.get("role") != "user":
|
|
75
|
+
continue
|
|
76
|
+
msg = obj.get("message") or {}
|
|
77
|
+
content = msg.get("content") or []
|
|
78
|
+
texts = []
|
|
79
|
+
for part in content:
|
|
80
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
81
|
+
texts.append(part.get("text") or "")
|
|
82
|
+
blob = "\n".join(texts)
|
|
83
|
+
m = re.search(r"<user_query>\s*(.*?)\s*</user_query>", blob, re.S)
|
|
84
|
+
if m:
|
|
85
|
+
title = clip(m.group(1), 90)
|
|
86
|
+
elif blob.strip():
|
|
87
|
+
title = clip(blob, 90)
|
|
88
|
+
break
|
|
89
|
+
except (OSError, json.JSONDecodeError):
|
|
90
|
+
pass
|
|
91
|
+
rows.append((workspace, title, str(path)))
|
|
92
|
+
return rows
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def copilot_rows(root: Path) -> List[Row]:
|
|
96
|
+
rows: List[Row] = []
|
|
97
|
+
if not root.is_dir():
|
|
98
|
+
return rows
|
|
99
|
+
if root.name == "chatSessions":
|
|
100
|
+
sessions = sorted(root.glob("*.jsonl"))
|
|
101
|
+
elif (root / "chatSessions").is_dir():
|
|
102
|
+
sessions = sorted((root / "chatSessions").glob("*.jsonl"))
|
|
103
|
+
else:
|
|
104
|
+
sessions = sorted(root.glob("*/chatSessions/*.jsonl"))
|
|
105
|
+
if not sessions:
|
|
106
|
+
sessions = sorted(root.rglob("*.jsonl"))
|
|
107
|
+
|
|
108
|
+
for session in sessions:
|
|
109
|
+
folder = "?"
|
|
110
|
+
for parent in (session.parent, session.parent.parent, session.parent.parent.parent):
|
|
111
|
+
wj = parent / "workspace.json"
|
|
112
|
+
if wj.exists():
|
|
113
|
+
try:
|
|
114
|
+
raw = json.loads(wj.read_text(encoding="utf-8")).get("folder") or "?"
|
|
115
|
+
folder = raw.replace("file:///", "").replace("%3A", ":")
|
|
116
|
+
break
|
|
117
|
+
except (json.JSONDecodeError, OSError):
|
|
118
|
+
pass
|
|
119
|
+
title = session.stem[:8]
|
|
120
|
+
try:
|
|
121
|
+
with session.open(encoding="utf-8", errors="replace") as fh:
|
|
122
|
+
for line in fh:
|
|
123
|
+
if not line.strip():
|
|
124
|
+
continue
|
|
125
|
+
obj = json.loads(line)
|
|
126
|
+
if obj.get("kind") == 1 and obj.get("k") == ["customTitle"] and obj.get("v"):
|
|
127
|
+
title = clip(str(obj["v"]), 90)
|
|
128
|
+
break
|
|
129
|
+
v = obj.get("v") or {}
|
|
130
|
+
if isinstance(v, dict):
|
|
131
|
+
input_text = v.get("inputText") or ""
|
|
132
|
+
if input_text.strip():
|
|
133
|
+
title = clip(input_text, 90)
|
|
134
|
+
except (OSError, json.JSONDecodeError):
|
|
135
|
+
pass
|
|
136
|
+
rows.append((folder, title, str(session)))
|
|
137
|
+
return rows
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def claude_rows(root: Path) -> List[Row]:
|
|
141
|
+
rows: List[Row] = []
|
|
142
|
+
if not root.is_dir():
|
|
143
|
+
return rows
|
|
144
|
+
for path in sorted(root.rglob("*.jsonl")):
|
|
145
|
+
project = path.parent.name
|
|
146
|
+
title = path.stem[:12]
|
|
147
|
+
try:
|
|
148
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
149
|
+
for line in fh:
|
|
150
|
+
obj = json.loads(line)
|
|
151
|
+
role = obj.get("role") or (obj.get("message") or {}).get("role")
|
|
152
|
+
if role != "user":
|
|
153
|
+
continue
|
|
154
|
+
msg = obj.get("message") or obj
|
|
155
|
+
content = msg.get("content")
|
|
156
|
+
blob = ""
|
|
157
|
+
if isinstance(content, str):
|
|
158
|
+
blob = content
|
|
159
|
+
elif isinstance(content, list):
|
|
160
|
+
texts = [
|
|
161
|
+
p.get("text") or ""
|
|
162
|
+
for p in content
|
|
163
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
164
|
+
]
|
|
165
|
+
blob = " ".join(texts)
|
|
166
|
+
if blob.strip():
|
|
167
|
+
title = clip(blob, 90)
|
|
168
|
+
break
|
|
169
|
+
except (OSError, json.JSONDecodeError):
|
|
170
|
+
pass
|
|
171
|
+
rows.append((project, title, str(path)))
|
|
172
|
+
return rows
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def brain_rows(label: str, root: Path) -> List[Row]:
|
|
176
|
+
rows: List[Row] = []
|
|
177
|
+
if not root.is_dir():
|
|
178
|
+
return rows
|
|
179
|
+
for brain in sorted(root.iterdir()):
|
|
180
|
+
if not brain.is_dir() or brain.name.startswith("."):
|
|
181
|
+
continue
|
|
182
|
+
title = brain.name[:8]
|
|
183
|
+
for name in ("task.md", "walkthrough.md", "implementation_plan.md"):
|
|
184
|
+
path = brain / name
|
|
185
|
+
if path.exists():
|
|
186
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
187
|
+
line = line.strip()
|
|
188
|
+
if line.startswith("# "):
|
|
189
|
+
title = clip(line[2:], 90)
|
|
190
|
+
break
|
|
191
|
+
break
|
|
192
|
+
rows.append((label, title, str(brain)))
|
|
193
|
+
return rows
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def pi_rows(root: Path) -> List[Row]:
|
|
197
|
+
rows: List[Row] = []
|
|
198
|
+
for path in sorted(root.rglob("*.jsonl")):
|
|
199
|
+
cwd = ""
|
|
200
|
+
title = path.stem[:19]
|
|
201
|
+
try:
|
|
202
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
203
|
+
for line in fh:
|
|
204
|
+
obj = json.loads(line)
|
|
205
|
+
if obj.get("type") == "session":
|
|
206
|
+
cwd = obj.get("cwd") or ""
|
|
207
|
+
if obj.get("type") == "message":
|
|
208
|
+
msg = obj.get("message") or {}
|
|
209
|
+
if msg.get("role") != "user":
|
|
210
|
+
continue
|
|
211
|
+
parts = msg.get("content") or []
|
|
212
|
+
texts = [
|
|
213
|
+
p.get("text") or ""
|
|
214
|
+
for p in parts
|
|
215
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
216
|
+
]
|
|
217
|
+
blob = " ".join(texts).strip()
|
|
218
|
+
if blob:
|
|
219
|
+
title = clip(blob, 90)
|
|
220
|
+
break
|
|
221
|
+
except (OSError, json.JSONDecodeError):
|
|
222
|
+
pass
|
|
223
|
+
rows.append((cwd, title, str(path)))
|
|
224
|
+
return rows
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def collect_source_rows(src: dict) -> List[Row]:
|
|
228
|
+
kind = src.get("kind") or ""
|
|
229
|
+
label = str(src.get("label") or src.get("id") or kind)
|
|
230
|
+
rows: List[Row] = []
|
|
231
|
+
for path in resolve_source_roots(src):
|
|
232
|
+
if kind == "agent-jsonl":
|
|
233
|
+
rows.extend(agent_transcript_rows(path))
|
|
234
|
+
elif kind == "copilot-jsonl":
|
|
235
|
+
rows.extend(copilot_rows(path))
|
|
236
|
+
elif kind == "claude-jsonl":
|
|
237
|
+
rows.extend(claude_rows(path))
|
|
238
|
+
elif kind == "antigravity-brain":
|
|
239
|
+
rows.extend(brain_rows(label, path))
|
|
240
|
+
elif kind == "pi-jsonl":
|
|
241
|
+
rows.extend(pi_rows(path))
|
|
242
|
+
return rows
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def render_table(headers: List[str], rows: List[Tuple]) -> str:
|
|
246
|
+
lines = [
|
|
247
|
+
"| " + " | ".join(headers) + " |",
|
|
248
|
+
"| " + " | ".join("---" for _ in headers) + " |",
|
|
249
|
+
]
|
|
250
|
+
for row in rows:
|
|
251
|
+
lines.append("| " + " | ".join(_md_cell(str(c)) for c in row) + " |")
|
|
252
|
+
return "\n".join(lines)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def catalog_source(src: dict) -> int:
|
|
256
|
+
sid = str(src["id"])
|
|
257
|
+
if not src.get("catalog", True):
|
|
258
|
+
return 0
|
|
259
|
+
roots = resolve_source_roots(src)
|
|
260
|
+
if src.get("kind") == "openai-export":
|
|
261
|
+
count = sum(len(openai_rows(r)) for r in roots)
|
|
262
|
+
else:
|
|
263
|
+
count = len(collect_source_rows(src))
|
|
264
|
+
write_entity_card(src, roots, count)
|
|
265
|
+
record_phase(sid, "catalog", catalog_count=count, paths=[str(p) for p in roots[:8]])
|
|
266
|
+
return count
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_index(cfg: dict | None = None) -> Path:
|
|
270
|
+
cfg = cfg or load_ingest()
|
|
271
|
+
exports = discover_openai_exports(cfg)
|
|
272
|
+
openai: List[Row] = []
|
|
273
|
+
for export_dir in exports:
|
|
274
|
+
openai.extend(openai_rows(export_dir))
|
|
275
|
+
|
|
276
|
+
source_tables: list[tuple[str, List[Row]]] = []
|
|
277
|
+
map_rows: list[tuple[str, str, str, str]] = []
|
|
278
|
+
|
|
279
|
+
openai_src = get_source("openai-export", cfg)
|
|
280
|
+
if openai_src and openai_src.get("catalog", True):
|
|
281
|
+
catalog_source(openai_src)
|
|
282
|
+
if len(openai) > 0:
|
|
283
|
+
map_rows.append(
|
|
284
|
+
(
|
|
285
|
+
str(openai_src.get("label") or "Open AI — GDPR export"),
|
|
286
|
+
str(exports[0]) if exports else "",
|
|
287
|
+
str(len(openai)),
|
|
288
|
+
"openai-export",
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
for src in list_sources(cfg):
|
|
293
|
+
if (src.get("kind") or "") == "openai-export":
|
|
294
|
+
continue
|
|
295
|
+
if not src.get("catalog", True):
|
|
296
|
+
continue
|
|
297
|
+
rows = collect_source_rows(src)
|
|
298
|
+
catalog_source(src)
|
|
299
|
+
if rows:
|
|
300
|
+
source_tables.append((str(src.get("label") or src.get("id")), rows))
|
|
301
|
+
roots = resolve_source_roots(src)
|
|
302
|
+
map_rows.append(
|
|
303
|
+
(
|
|
304
|
+
str(src.get("label") or src.get("id")),
|
|
305
|
+
", ".join(str(p) for p in roots[:2]),
|
|
306
|
+
str(len(rows)),
|
|
307
|
+
str(src.get("kind") or ""),
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
map_section = (
|
|
312
|
+
render_table(["source", "path", "count", "kind"], map_rows)
|
|
313
|
+
if map_rows
|
|
314
|
+
else "*(no chat stores found on disk yet; configure sources in ingest.json)*"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
parts = [
|
|
318
|
+
"# Chat index",
|
|
319
|
+
"",
|
|
320
|
+
"Catalog of **where chats already live**. Not a transcript dump.",
|
|
321
|
+
"Configure sources in `~/.agents/memory/ingest.json`. "
|
|
322
|
+
"Run `python -m agent_memory ingest catalog` to refresh.",
|
|
323
|
+
"",
|
|
324
|
+
"## Store map",
|
|
325
|
+
"",
|
|
326
|
+
map_section,
|
|
327
|
+
"",
|
|
328
|
+
]
|
|
329
|
+
if openai:
|
|
330
|
+
parts.extend(["## Open AI — GDPR export", "", render_table(["date", "title", "shard"], openai), ""])
|
|
331
|
+
for title, rows in source_tables:
|
|
332
|
+
if not rows:
|
|
333
|
+
continue
|
|
334
|
+
parts.extend([f"## {title}", "", render_table(["workspace", "title hint", "path"], rows), ""])
|
|
335
|
+
|
|
336
|
+
index = chats_index_path()
|
|
337
|
+
USER_MEMORY.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
_write(index, "\n".join(parts) + "\n")
|
|
339
|
+
return index
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def run_catalog(cfg: dict | None = None) -> dict:
|
|
343
|
+
ensure_memory_layout()
|
|
344
|
+
cfg = cfg or load_ingest()
|
|
345
|
+
path = build_index(cfg)
|
|
346
|
+
return {"chats_index": str(path), "sources": len(list_sources(cfg))}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Build ~/.agents/memory/chats-index.md — thin wrapper around ingest catalog phase."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .ingest_catalog import run_catalog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> int:
|
|
8
|
+
result = run_catalog()
|
|
9
|
+
print(f"wrote {result['chats_index']}")
|
|
10
|
+
return 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Shared ingest filters, staging paths, and reversible state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
from .store import USER_MEMORY, _append_bullet, _read, _write
|
|
11
|
+
|
|
12
|
+
HOW_TO = re.compile(
|
|
13
|
+
r"^(how (can|do|to|would)|write (a|me|the)|fix |create (a|an) |"
|
|
14
|
+
r"implement |help me|can you|could you|please |instead of |"
|
|
15
|
+
r"why (is|does|do|won't)|what is the (best|correct) )",
|
|
16
|
+
re.I,
|
|
17
|
+
)
|
|
18
|
+
PII = re.compile(
|
|
19
|
+
r"("
|
|
20
|
+
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
|
|
21
|
+
r"|(\+?\d[\d\s().-]{8,}\d)"
|
|
22
|
+
r"|sk-[A-Za-z0-9]{10,}"
|
|
23
|
+
r"|github_pat_[A-Za-z0-9_]+"
|
|
24
|
+
r"|ghp_[A-Za-z0-9]+"
|
|
25
|
+
r"|xox[baprs]-[A-Za-z0-9-]+"
|
|
26
|
+
r")",
|
|
27
|
+
re.I,
|
|
28
|
+
)
|
|
29
|
+
CODEISH = re.compile(
|
|
30
|
+
r"^\s*(def |class |import |from |function |const |let |var |#include )",
|
|
31
|
+
re.M,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def staging_path(source_id: str) -> Path:
|
|
36
|
+
return USER_MEMORY / "staging" / "ingest" / source_id / "captured.md"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ingest_state_path() -> Path:
|
|
40
|
+
return USER_MEMORY / "ingest" / "state.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def chats_index_path() -> Path:
|
|
44
|
+
return USER_MEMORY / "chats-index.md"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def utc_now() -> str:
|
|
48
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def scrub(text: str) -> str:
|
|
52
|
+
text = re.sub(r"```.*?```", " ", text, flags=re.S)
|
|
53
|
+
text = PII.sub("[redacted]", text)
|
|
54
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
55
|
+
return text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def keep_user_line(title: str, text: str) -> bool:
|
|
59
|
+
if not text or len(text) < 12:
|
|
60
|
+
return False
|
|
61
|
+
if text.count("[redacted]") and len(text) < 40:
|
|
62
|
+
return False
|
|
63
|
+
if len(text) > 600:
|
|
64
|
+
return False
|
|
65
|
+
if CODEISH.search(text) and len(text) > 120:
|
|
66
|
+
return False
|
|
67
|
+
if HOW_TO.search(text):
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clip(text: str, n: int = 120) -> str:
|
|
73
|
+
text = re.sub(r"\s+", " ", text or "").strip()
|
|
74
|
+
return text[: n - 1] + "…" if len(text) > n else text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def entity_path(source_id: str) -> Path:
|
|
78
|
+
return USER_MEMORY / "entities" / f"chat-source-{source_id}.md"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def staging_header(source_id: str, label: str) -> str:
|
|
82
|
+
return (
|
|
83
|
+
f"# Staging (ingest: {source_id})\n\n"
|
|
84
|
+
f"Source: {label}. Not memory. "
|
|
85
|
+
"Distill each bullet with MCP `add_memory(kind=..., name=...)`, then delete here.\n\n"
|
|
86
|
+
"## Captured\n\n"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_state() -> dict:
|
|
91
|
+
path = ingest_state_path()
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
return {"sources": {}}
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(_read(path))
|
|
96
|
+
if isinstance(data, dict):
|
|
97
|
+
return data
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
pass
|
|
100
|
+
return {"sources": {}}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_state(state: dict) -> None:
|
|
104
|
+
path = ingest_state_path()
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
_write(path, json.dumps(state, indent=2, ensure_ascii=False))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def record_phase(source_id: str, phase: str, **fields: Any) -> None:
|
|
110
|
+
state = load_state()
|
|
111
|
+
entry = state.setdefault("sources", {}).setdefault(source_id, {})
|
|
112
|
+
entry[f"last_{phase}"] = utc_now()
|
|
113
|
+
entry.update(fields)
|
|
114
|
+
save_state(state)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_staging(source_id: str, label: str, lines: List[str]) -> Path:
|
|
118
|
+
path = staging_path(source_id)
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
header = staging_header(source_id, label)
|
|
121
|
+
body = header
|
|
122
|
+
for line in lines:
|
|
123
|
+
body += f"- {line}\n"
|
|
124
|
+
if body == header:
|
|
125
|
+
body += "- (none yet)\n"
|
|
126
|
+
_write(path, body)
|
|
127
|
+
return path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def append_staging_bullets(source_id: str, label: str, lines: List[str]) -> Path:
|
|
131
|
+
path = staging_path(source_id)
|
|
132
|
+
if not path.exists():
|
|
133
|
+
return write_staging(source_id, label, lines)
|
|
134
|
+
for line in lines:
|
|
135
|
+
if line.strip():
|
|
136
|
+
_append_bullet(path, line.strip())
|
|
137
|
+
return path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_entity_card(source: dict, paths: List[Path], catalog_count: int) -> Path:
|
|
141
|
+
sid = str(source["id"])
|
|
142
|
+
label = str(source.get("label") or sid)
|
|
143
|
+
kind = str(source.get("kind") or "")
|
|
144
|
+
path_lines = "\n".join(f"- `{p}`" for p in paths[:8]) or "- (not found on disk)"
|
|
145
|
+
body = (
|
|
146
|
+
f"---\n"
|
|
147
|
+
f"id: {sid}\n"
|
|
148
|
+
f"kind: {kind}\n"
|
|
149
|
+
f"role: chat-source\n"
|
|
150
|
+
f"---\n\n"
|
|
151
|
+
f"# {label}\n\n"
|
|
152
|
+
f"**Kind:** `{kind}` \n"
|
|
153
|
+
f"**Catalog count:** {catalog_count} \n"
|
|
154
|
+
f"**Paths:**\n{path_lines}\n\n"
|
|
155
|
+
f"Bodies stay on disk. Titles in `chats-index.md`. "
|
|
156
|
+
f"Extract: `python -m agent_memory ingest extract --source {sid}` -> "
|
|
157
|
+
f"`staging/ingest/{sid}/captured.md`. "
|
|
158
|
+
f"Distill with MCP `add_memory`, then clear staging.\n"
|
|
159
|
+
)
|
|
160
|
+
dest = entity_path(sid)
|
|
161
|
+
_write(dest, body)
|
|
162
|
+
return dest
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def format_bullet(title: str, text: str, origin: str = "") -> str:
|
|
166
|
+
title = clip(title, 80)
|
|
167
|
+
text = clip(text, 280)
|
|
168
|
+
if origin:
|
|
169
|
+
return f"[{title} @ {origin}] {text}"
|
|
170
|
+
return f"[{title}] {text}"
|