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.
@@ -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}"