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,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
|