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,245 @@
1
+ """Normalize ingest.json and discover source paths."""
2
+ from __future__ import annotations
3
+
4
+ import glob
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List
9
+
10
+ from .store import EXAMPLES, _read
11
+
12
+ INGEST_EXAMPLE = EXAMPLES / "ingest.example.json"
13
+
14
+
15
+ def _expand(path: str) -> Path:
16
+ raw = path.replace("%APPDATA%", os.environ.get("APPDATA") or "")
17
+ return Path(os.path.expandvars(os.path.expanduser(raw)))
18
+
19
+
20
+ def _expand_glob(pattern: str) -> List[Path]:
21
+ pat = str(pattern).replace("%APPDATA%", os.environ.get("APPDATA") or "")
22
+ pat = os.path.expanduser(pat)
23
+ out: List[Path] = []
24
+ for hit in glob.glob(pat):
25
+ p = Path(hit)
26
+ if p.exists():
27
+ out.append(p)
28
+ return out
29
+
30
+
31
+ def default_ingest() -> dict:
32
+ if INGEST_EXAMPLE.is_file():
33
+ return normalize_ingest(json.loads(_read(INGEST_EXAMPLE)))
34
+ return {"version": 1, "sources": []}
35
+
36
+
37
+ def ingest_defaults(raw: dict) -> dict:
38
+ return {
39
+ "extract_max_bullets": max(0, int(raw.get("extract_max_bullets") or 100)),
40
+ "staging_nag_threshold": max(0, int(raw.get("staging_nag_threshold") or 50)),
41
+ }
42
+
43
+
44
+ def extract_max_bullets(cfg: dict, src: dict) -> int:
45
+ if src.get("extract_max_bullets") is not None:
46
+ return max(0, int(src["extract_max_bullets"]))
47
+ return max(0, int(cfg.get("extract_max_bullets") or 100))
48
+
49
+
50
+ def _migrate_source(src: dict) -> dict:
51
+ """Rename legacy generic ids to product-specific ones."""
52
+ src = dict(src)
53
+ if src.get("id") in ("agent-transcripts", "cursor-transcripts"):
54
+ src["id"] = "cursor"
55
+ label = str(src.get("label") or "")
56
+ if not label or label == "your Agent transcripts":
57
+ src["label"] = "Cursor"
58
+ return src
59
+
60
+
61
+ def normalize_ingest(raw: dict) -> dict:
62
+ """Accept legacy split keys or unified sources list."""
63
+ defaults = ingest_defaults(raw)
64
+ if raw.get("sources"):
65
+ sources = [_migrate_source(s) for s in raw["sources"]]
66
+ return {"version": int(raw.get("version") or 1), "sources": sources, **defaults}
67
+ sources: List[dict] = []
68
+ for item in raw.get("openai_export_dirs") or []:
69
+ sources.append(
70
+ {
71
+ "id": "openai-export",
72
+ "kind": "openai-export",
73
+ "label": "Open AI — GDPR export",
74
+ "paths": [str(item)],
75
+ "catalog": True,
76
+ "extract": True,
77
+ }
78
+ )
79
+ globs = raw.get("openai_export_globs") or []
80
+ if globs and not any(s.get("id") == "openai-export" for s in sources):
81
+ sources.append(
82
+ {
83
+ "id": "openai-export",
84
+ "kind": "openai-export",
85
+ "label": "Open AI — GDPR export",
86
+ "paths": [],
87
+ "globs": globs,
88
+ "catalog": True,
89
+ "extract": True,
90
+ }
91
+ )
92
+ elif globs:
93
+ for s in sources:
94
+ if s.get("id") == "openai-export":
95
+ s.setdefault("globs", globs)
96
+ for src in raw.get("chat_sources") or []:
97
+ if isinstance(src, dict) and src.get("id"):
98
+ src.setdefault("catalog", True)
99
+ src.setdefault("extract", True)
100
+ sources.append(_migrate_source(src))
101
+ sources = [_migrate_source(s) for s in sources]
102
+ return {"version": 1, "sources": sources, **defaults}
103
+
104
+
105
+ INGEST_SOURCE_ID_ALIASES: Dict[str, str] = {
106
+ "agent-transcripts": "cursor",
107
+ "cursor-transcripts": "cursor",
108
+ }
109
+
110
+
111
+ def migrate_ingest_legacy_ids() -> List[str]:
112
+ """Rename on-disk ingest artifacts when source ids change (called from sync)."""
113
+ from .store import USER_MEMORY, _read, _write
114
+
115
+ notes: List[str] = []
116
+ staging_base = USER_MEMORY / "staging" / "ingest"
117
+ for old, new in INGEST_SOURCE_ID_ALIASES.items():
118
+ if old == new:
119
+ continue
120
+ old_dir = staging_base / old
121
+ new_dir = staging_base / new
122
+ if old_dir.is_dir() and not new_dir.exists():
123
+ new_dir.parent.mkdir(parents=True, exist_ok=True)
124
+ old_dir.rename(new_dir)
125
+ notes.append(f"migrated staging/ingest/{old} -> {new}")
126
+
127
+ state_path = USER_MEMORY / "ingest" / "state.json"
128
+ if state_path.is_file():
129
+ try:
130
+ state = json.loads(_read(state_path))
131
+ except json.JSONDecodeError:
132
+ state = {}
133
+ sources = state.setdefault("sources", {})
134
+ for old, new in INGEST_SOURCE_ID_ALIASES.items():
135
+ if old == new or old not in sources:
136
+ continue
137
+ entry = sources.pop(old)
138
+ staging = str(entry.get("staging") or "")
139
+ if staging:
140
+ entry["staging"] = staging.replace(f"/{old}/", f"/{new}/").replace(
141
+ f"\\{old}\\", f"\\{new}\\"
142
+ )
143
+ if new in sources:
144
+ for key, value in entry.items():
145
+ sources[new].setdefault(key, value)
146
+ else:
147
+ sources[new] = entry
148
+ notes.append(f"migrated ingest state {old} -> {new}")
149
+ _write(state_path, json.dumps(state, indent=2) + "\n")
150
+
151
+ entities = USER_MEMORY / "entities"
152
+ for old, new in INGEST_SOURCE_ID_ALIASES.items():
153
+ if old == new:
154
+ continue
155
+ old_card = entities / f"chat-source-{old}.md"
156
+ new_card = entities / f"chat-source-{new}.md"
157
+ if old_card.is_file() and not new_card.is_file():
158
+ new_card.parent.mkdir(parents=True, exist_ok=True)
159
+ old_card.rename(new_card)
160
+ notes.append(f"migrated entity chat-source-{old} -> chat-source-{new}")
161
+
162
+ return notes
163
+
164
+
165
+ def load_ingest(path: Path | None = None) -> dict:
166
+ from .store import USER_MEMORY
167
+
168
+ ingest_json = path or (USER_MEMORY / "ingest.json")
169
+ if ingest_json.is_file():
170
+ try:
171
+ return normalize_ingest(json.loads(_read(ingest_json)))
172
+ except json.JSONDecodeError:
173
+ pass
174
+ return default_ingest()
175
+
176
+
177
+ def list_sources(cfg: dict | None = None) -> List[dict]:
178
+ cfg = cfg or load_ingest()
179
+ return [s for s in cfg.get("sources") or [] if isinstance(s, dict) and s.get("id")]
180
+
181
+
182
+ def get_source(source_id: str, cfg: dict | None = None) -> dict | None:
183
+ for src in list_sources(cfg):
184
+ if src.get("id") == source_id:
185
+ return src
186
+ return None
187
+
188
+
189
+ def chat_sources(cfg: dict | None = None) -> List[dict]:
190
+ """Non-OpenAI sources that participate in chats-index (legacy name)."""
191
+ return [
192
+ s
193
+ for s in list_sources(cfg)
194
+ if s.get("catalog", True) and (s.get("kind") or "") != "openai-export"
195
+ ]
196
+
197
+
198
+ def discover_openai_exports(cfg: dict | None = None) -> List[Path]:
199
+ src = get_source("openai-export", cfg)
200
+ if not src:
201
+ return []
202
+ return resolve_source_roots(src)
203
+
204
+
205
+ def resolve_source_paths(src: dict) -> List[Path]:
206
+ return resolve_source_roots(src)
207
+
208
+
209
+ def resolve_source_roots(src: dict) -> List[Path]:
210
+ roots: List[Path] = []
211
+ seen: set[str] = set()
212
+ for item in src.get("paths") or []:
213
+ raw = str(item)
214
+ if "*" in raw:
215
+ for hit in _expand_glob(raw):
216
+ key = str(hit.resolve())
217
+ if key not in seen:
218
+ seen.add(key)
219
+ roots.append(hit)
220
+ else:
221
+ p = _expand(raw)
222
+ if p.exists():
223
+ key = str(p.resolve())
224
+ if key not in seen:
225
+ seen.add(key)
226
+ roots.append(p)
227
+ for pattern in src.get("globs") or []:
228
+ for hit in _expand_glob(str(pattern)):
229
+ key = str(hit.resolve())
230
+ if key in seen:
231
+ continue
232
+ kind = src.get("kind") or ""
233
+ if kind == "openai-export":
234
+ if hit.is_dir() and any(hit.glob("conversations-*.json")):
235
+ seen.add(key)
236
+ roots.append(hit)
237
+ elif hit.is_file() and hit.suffix.lower() == ".zip":
238
+ seen.add(key)
239
+ roots.append(hit)
240
+ continue
241
+ if not hit.is_dir():
242
+ continue
243
+ seen.add(key)
244
+ roots.append(hit)
245
+ return roots
@@ -0,0 +1,374 @@
1
+ """Extract phase: filter durable user lines into staging/ingest/<source-id>/captured.md."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Callable, Dict, List
10
+
11
+ from . import ingest_common
12
+ from .ingest_catalog import brain_rows
13
+ from .ingest_common import (
14
+ clip,
15
+ format_bullet,
16
+ keep_user_line,
17
+ record_phase,
18
+ scrub,
19
+ write_staging,
20
+ )
21
+ from .ingest_config import extract_max_bullets, get_source, list_sources, load_ingest, resolve_source_roots
22
+
23
+
24
+ def _parts_text(content: object) -> str:
25
+ if not isinstance(content, dict):
26
+ return ""
27
+ parts = content.get("parts") or []
28
+ bits: list[str] = []
29
+ for part in parts:
30
+ if isinstance(part, str):
31
+ bits.append(part)
32
+ elif isinstance(part, dict):
33
+ bits.append(str(part.get("text") or ""))
34
+ return "\n".join(bits)
35
+
36
+
37
+ def user_messages(conv: dict) -> List[str]:
38
+ out: List[str] = []
39
+ mapping = conv.get("mapping") or {}
40
+ for node in mapping.values():
41
+ if not isinstance(node, dict):
42
+ continue
43
+ msg = node.get("message") or {}
44
+ if not isinstance(msg, dict):
45
+ continue
46
+ if (msg.get("author") or {}).get("role") != "user":
47
+ continue
48
+ text = _parts_text(msg.get("content"))
49
+ if text.strip():
50
+ out.append(text)
51
+ return out
52
+
53
+
54
+ def unzip_export(src: Path, dest: Path | None = None) -> Path:
55
+ dest = dest or Path(tempfile.gettempdir()) / "agent-memory-openai-export"
56
+ dest.mkdir(parents=True, exist_ok=True)
57
+ if src.suffix.lower() == ".zip" and src.is_file():
58
+ with zipfile.ZipFile(src) as zf:
59
+ zf.extractall(dest)
60
+ return dest
61
+ if src.is_dir():
62
+ return src
63
+ raise FileNotFoundError(f"not an Open AI GDPR export zip or folder: {src}")
64
+
65
+
66
+ def _dedupe_bullets(lines: List[str]) -> List[str]:
67
+ seen: set[str] = set()
68
+ out: List[str] = []
69
+ for line in lines:
70
+ key = re.sub(r"\s+", " ", line.lower())
71
+ if key in seen:
72
+ continue
73
+ seen.add(key)
74
+ out.append(line)
75
+ return out
76
+
77
+
78
+ def extract_openai(src: dict) -> List[str]:
79
+ lines: List[str] = []
80
+ for root in resolve_source_roots(src):
81
+ export = root
82
+ if root.suffix.lower() == ".zip":
83
+ export = unzip_export(root)
84
+ elif not any(root.glob("conversations-*.json")) and root.is_dir():
85
+ continue
86
+ for shard in sorted(export.glob("conversations-*.json")):
87
+ data = json.loads(shard.read_text(encoding="utf-8"))
88
+ for conv in data if isinstance(data, list) else []:
89
+ if not isinstance(conv, dict):
90
+ continue
91
+ title = str(conv.get("title") or "(untitled)")
92
+ for raw in user_messages(conv):
93
+ text = scrub(raw)
94
+ if not keep_user_line(title, text):
95
+ continue
96
+ lines.append(format_bullet(title, text[:400], shard.name))
97
+ return _dedupe_bullets(lines)
98
+
99
+
100
+ def _jsonl_user_lines(path: Path, parse_user) -> List[tuple[str, str]]:
101
+ rows: List[tuple[str, str]] = []
102
+ try:
103
+ with path.open(encoding="utf-8", errors="replace") as fh:
104
+ for line in fh:
105
+ obj = json.loads(line)
106
+ title, text = parse_user(obj)
107
+ if text:
108
+ rows.append((title, text))
109
+ except (OSError, json.JSONDecodeError):
110
+ pass
111
+ return rows
112
+
113
+
114
+ def extract_agent_jsonl(src: dict) -> List[str]:
115
+ lines: List[str] = []
116
+ for root in resolve_source_roots(src):
117
+ tdir = root / "agent-transcripts" if (root / "agent-transcripts").is_dir() else root
118
+ for path in sorted(tdir.glob("*/*.jsonl")):
119
+ if "subagents" in path.parts:
120
+ continue
121
+ title = path.parent.name[:8]
122
+
123
+ def parse(obj, _title=title, _path=path):
124
+ if obj.get("role") != "user":
125
+ return _title, ""
126
+ msg = obj.get("message") or {}
127
+ content = msg.get("content") or []
128
+ texts = []
129
+ for part in content:
130
+ if isinstance(part, dict) and part.get("type") == "text":
131
+ texts.append(part.get("text") or "")
132
+ blob = "\n".join(texts)
133
+ m = re.search(r"<user_query>\s*(.*?)\s*</user_query>", blob, re.S)
134
+ if m:
135
+ return m.group(1)[:80], m.group(1)
136
+ if blob.strip():
137
+ return blob[:80], blob
138
+ return _title, ""
139
+
140
+ for t, raw in _jsonl_user_lines(path, parse):
141
+ text = scrub(raw)
142
+ if keep_user_line(t, text):
143
+ lines.append(format_bullet(t, text[:400], str(path)))
144
+ return _dedupe_bullets(lines)
145
+
146
+
147
+ def extract_copilot_jsonl(src: dict) -> List[str]:
148
+ lines: List[str] = []
149
+ for root in resolve_source_roots(src):
150
+ if not root.is_dir():
151
+ continue
152
+ if root.name == "chatSessions":
153
+ sessions = sorted(root.glob("*.jsonl"))
154
+ elif (root / "chatSessions").is_dir():
155
+ sessions = sorted((root / "chatSessions").glob("*.jsonl"))
156
+ else:
157
+ sessions = sorted(root.glob("*/chatSessions/*.jsonl"))
158
+ if not sessions:
159
+ sessions = sorted(root.rglob("*.jsonl"))
160
+
161
+ for session in sessions:
162
+ title = session.stem[:12]
163
+
164
+ def parse(obj, _session=session):
165
+ nonlocal title
166
+ if obj.get("kind") == 1 and obj.get("k") == ["customTitle"] and obj.get("v"):
167
+ title = str(obj["v"])
168
+ return title, ""
169
+ # Parse v -> inputText or requests
170
+ v = obj.get("v")
171
+ if isinstance(v, dict):
172
+ input_text = v.get("inputText") or ""
173
+ if input_text.strip():
174
+ return title, input_text
175
+ for req in v.get("requests") or []:
176
+ if isinstance(req, dict):
177
+ msg_text = req.get("message", {}).get("text") or req.get("inputText") or ""
178
+ if msg_text.strip():
179
+ return title, msg_text
180
+ # Fallback standard role
181
+ role = obj.get("role") or (obj.get("message") or {}).get("role")
182
+ if role == "user":
183
+ msg = obj.get("message") or obj
184
+ content = msg.get("content")
185
+ blob = content if isinstance(content, str) else ""
186
+ if isinstance(content, list):
187
+ blob = " ".join(
188
+ p.get("text") or ""
189
+ for p in content
190
+ if isinstance(p, dict) and p.get("type") == "text"
191
+ )
192
+ return title, blob
193
+ return title, ""
194
+
195
+ for t, raw in _jsonl_user_lines(session, parse):
196
+ text = scrub(raw)
197
+ if keep_user_line(t, text):
198
+ lines.append(format_bullet(t, text[:400], str(session)))
199
+ return _dedupe_bullets(lines)
200
+
201
+
202
+ def extract_claude_jsonl(src: dict) -> List[str]:
203
+ lines: List[str] = []
204
+ for root in resolve_source_roots(src):
205
+ for path in sorted(root.rglob("*.jsonl")):
206
+
207
+ def parse(obj, _path=path):
208
+ role = obj.get("role") or (obj.get("message") or {}).get("role")
209
+ if role != "user":
210
+ return _path.stem, ""
211
+ msg = obj.get("message") or obj
212
+ content = msg.get("content")
213
+ blob = content if isinstance(content, str) else ""
214
+ if isinstance(content, list):
215
+ blob = " ".join(
216
+ p.get("text") or ""
217
+ for p in content
218
+ if isinstance(p, dict) and p.get("type") == "text"
219
+ )
220
+ return _path.stem, blob
221
+
222
+ for t, raw in _jsonl_user_lines(path, parse):
223
+ text = scrub(raw)
224
+ if keep_user_line(t, text):
225
+ lines.append(format_bullet(t, text[:400], str(path)))
226
+ return _dedupe_bullets(lines)
227
+
228
+
229
+ BRAIN_ARTIFACTS = ("task.md", "walkthrough.md", "implementation_plan.md")
230
+ ANTIGRAVITY_TRANSCRIPT = Path(".system_generated") / "logs" / "transcript.jsonl"
231
+ USER_REQUEST_RE = re.compile(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", re.S)
232
+
233
+
234
+ def _extract_brain_artifact_bullets(brain: Path, title: str) -> List[str]:
235
+ lines: List[str] = []
236
+ for name in BRAIN_ARTIFACTS:
237
+ md = brain / name
238
+ if not md.is_file():
239
+ continue
240
+ for line in md.read_text(encoding="utf-8", errors="replace").splitlines():
241
+ line = line.strip()
242
+ if not line or line.startswith("#"):
243
+ continue
244
+ if not (line.startswith("- ") or line.startswith("* ")):
245
+ continue
246
+ text = scrub(line[2:])
247
+ text = re.sub(r"^\[[xX ]\]\s*", "", text).strip()
248
+ if keep_user_line(title, text):
249
+ lines.append(format_bullet(title, text[:400], name))
250
+ return lines
251
+
252
+
253
+ def _parse_antigravity_transcript_obj(obj: dict, title: str) -> tuple[str, str]:
254
+ if obj.get("type") != "USER_INPUT":
255
+ return title, ""
256
+ content = str(obj.get("content") or "")
257
+ match = USER_REQUEST_RE.search(content)
258
+ if not match:
259
+ return title, ""
260
+ text = match.group(1).strip()
261
+ if not text:
262
+ return title, ""
263
+ return clip(text, 80), text
264
+
265
+
266
+ def _extract_antigravity_transcript(brain: Path, title: str) -> List[str]:
267
+ path = brain / ANTIGRAVITY_TRANSCRIPT
268
+ if not path.is_file():
269
+ return []
270
+
271
+ def parse(obj, _title=title, _path=path):
272
+ return _parse_antigravity_transcript_obj(obj, _title)
273
+
274
+ lines: List[str] = []
275
+ for t, raw in _jsonl_user_lines(path, parse):
276
+ text = scrub(raw)
277
+ if keep_user_line(t, text):
278
+ lines.append(format_bullet(t, text[:400], "transcript.jsonl"))
279
+ return lines
280
+
281
+
282
+ def extract_antigravity_brain(src: dict) -> List[str]:
283
+ lines: List[str] = []
284
+ label = str(src.get("label") or src.get("id"))
285
+ for root in resolve_source_roots(src):
286
+ for _label, title, brain_path in brain_rows(label, root):
287
+ brain = Path(brain_path)
288
+ lines.extend(_extract_brain_artifact_bullets(brain, title))
289
+ lines.extend(_extract_antigravity_transcript(brain, title))
290
+ return _dedupe_bullets(lines)
291
+
292
+
293
+ def extract_pi_jsonl(src: dict) -> List[str]:
294
+ lines: List[str] = []
295
+ for root in resolve_source_roots(src):
296
+ for path in sorted(root.rglob("*.jsonl")):
297
+
298
+ def parse(obj, _path=path):
299
+ if obj.get("type") != "message":
300
+ return _path.stem, ""
301
+ msg = obj.get("message") or {}
302
+ if msg.get("role") != "user":
303
+ return _path.stem, ""
304
+ parts = msg.get("content") or []
305
+ blob = " ".join(
306
+ p.get("text") or ""
307
+ for p in parts
308
+ if isinstance(p, dict) and p.get("type") == "text"
309
+ )
310
+ return _path.stem, blob
311
+
312
+ for t, raw in _jsonl_user_lines(path, parse):
313
+ text = scrub(raw)
314
+ if keep_user_line(t, text):
315
+ lines.append(format_bullet(t, text[:400], str(path)))
316
+ return _dedupe_bullets(lines)
317
+
318
+
319
+ EXTRACTORS: Dict[str, Callable[[dict], List[str]]] = {
320
+ "openai-export": extract_openai,
321
+ "agent-jsonl": extract_agent_jsonl,
322
+ "copilot-jsonl": extract_copilot_jsonl,
323
+ "claude-jsonl": extract_claude_jsonl,
324
+ "antigravity-brain": extract_antigravity_brain,
325
+ "pi-jsonl": extract_pi_jsonl,
326
+ }
327
+
328
+
329
+ def extract_source(src: dict, cfg: dict | None = None) -> tuple[int, Path]:
330
+ cfg = cfg or load_ingest()
331
+ sid = str(src["id"])
332
+ label = str(src.get("label") or sid)
333
+ kind = str(src.get("kind") or "")
334
+ if not src.get("extract", True):
335
+ return 0, write_staging(sid, label, [])
336
+ handler = EXTRACTORS.get(kind)
337
+ if not handler:
338
+ raise ValueError(f"no extractor for kind={kind!r} (source {sid})")
339
+ lines = handler(src)
340
+ total_before = len(lines)
341
+ max_b = extract_max_bullets(cfg, src)
342
+ capped = False
343
+ if max_b > 0 and len(lines) > max_b:
344
+ lines = lines[:max_b]
345
+ capped = True
346
+ path = write_staging(sid, label, lines)
347
+ record_phase(
348
+ sid,
349
+ "extract",
350
+ extract_count=len(lines),
351
+ extract_total_before_cap=total_before if capped else None,
352
+ extract_capped=capped,
353
+ staging=str(path.resolve().relative_to(ingest_common.USER_MEMORY.resolve())).replace(
354
+ "\\", "/"
355
+ ),
356
+ )
357
+ return len(lines), path
358
+
359
+
360
+ def run_extract(source_id: str = "", cfg: dict | None = None) -> dict:
361
+ cfg = cfg or load_ingest()
362
+ results: dict = {"sources": {}}
363
+ targets = list_sources(cfg)
364
+ if source_id:
365
+ src = get_source(source_id, cfg)
366
+ if not src:
367
+ raise ValueError(f"unknown source id: {source_id}")
368
+ targets = [src]
369
+ for src in targets:
370
+ if not src.get("extract", True):
371
+ continue
372
+ count, path = extract_source(src, cfg=cfg)
373
+ results["sources"][str(src["id"])] = {"count": count, "staging": str(path)}
374
+ return results