memd 0.3.0__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.
memd/curator.py ADDED
@@ -0,0 +1,251 @@
1
+ """The distill brain: curator charter, prompt assembly, LLM invocation, and
2
+ output validation."""
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+
11
+ from memd.config import CONFIG_PATH, HOME, STATE_DIR, log, today
12
+ from memd.errors import ConfigError, CuratorError
13
+ from memd.memory import SKELETONS, split_frontmatter
14
+
15
+ # --------------------------------------------------------------------------
16
+ # curator charter — the system contract for the distillation model
17
+ # --------------------------------------------------------------------------
18
+
19
+ CHARTER = """\
20
+ You are a project-memory curator. You receive (1) the current contents of a
21
+ project's .memory/ files and (2) a digest of recent AI work sessions on that
22
+ project (possibly from several agents, CLIs, or a swarm). You produce updated
23
+ memory files. You are not a participant in the project; you are its archivist.
24
+
25
+ FILE PURPOSES
26
+ - state.md: single source of truth for CURRENT live status: configs, directory
27
+ maps, services, ports, active workarounds, hardware facts. Present tense.
28
+ Replace stale facts; never accumulate history here.
29
+ - decisions.md: active canonical architecture decisions and preferences.
30
+ Each entry: what was decided, why, and what it rules out. Only decisions
31
+ that constrain future work. Superseded decisions move to archive.
32
+ - mistakes.md: append-only audit log of configuration/implementation mistakes:
33
+ symptom, root cause, exact prevention rule. You may only ADD entries.
34
+ - todo.md: open tasks, roadmap, pending verification loops. Completed or
35
+ abandoned items move to archive, they do not linger.
36
+
37
+ SIGNAL VS NOISE — keep only what a FUTURE session needs:
38
+ KEEP: decisions made and their rationale; state changes (services, files,
39
+ ports, versions, paths); discovered constraints and gotchas; root causes of
40
+ failures and their fixes; open threads and explicitly deferred work; exact
41
+ commands/flags that were hard to derive; scope agreements with the user.
42
+ DISCARD: conversational chatter, tangents, abandoned exploration that taught
43
+ nothing, restated file contents, tool-call play-by-play, anything derivable
44
+ by reading the repo itself, pleasantries, duplicate statements of known facts.
45
+ When in doubt: would omitting this cause a future agent to repeat work or
46
+ repeat a mistake? If no, discard.
47
+
48
+ PRUNING DISCIPLINE
49
+ - Close or archive an open item ONLY when the digest explicitly states it is
50
+ done, verified, or abandoned. Never infer redundancy.
51
+ - The session digest may describe memory-curation work that earlier distills
52
+ already applied. The current files are the result of those distills: if the
53
+ digest and the files disagree about what was already pruned or closed,
54
+ trust the files and do not re-apply or extend old prunes.
55
+ - An item the user explicitly said to keep open is untouchable until they
56
+ say otherwise. Err on the side of keeping items open.
57
+
58
+ STYLE
59
+ - Succinct and thorough. Dense declarative prose or tight bullets.
60
+ - No meta-commentary ("in this session we..."), no praise, no hedging.
61
+ - Use absolute dates (YYYY-MM-DD), never "today"/"recently".
62
+ - Stay grounded in the project's stated scope; do not invent goals.
63
+ - Preserve existing structure and headings where they still serve.
64
+
65
+ MECHANICS
66
+ - Do NOT emit YAML frontmatter; the tool manages it.
67
+ - Do NOT rewrite mistakes.md; only supply new entries.
68
+ - If a file needs no change, return null for it.
69
+ - If files exceed their budgets (given below), move the least-current
70
+ sections into archive_entries rather than deleting them.
71
+ - Multiple agents may have worked in parallel; merge their threads, dedupe.
72
+
73
+ SELF-CHECK (before you output)
74
+ - Every todo you closed or archived: does the digest EXPLICITLY say it was
75
+ completed, verified, or abandoned? If not, keep it open.
76
+ - Every new fact in state.md: is it stated in the digest or an inbox note,
77
+ not inferred or assumed?
78
+ - No YAML frontmatter, no markdown fences, no prose around the JSON.
79
+
80
+ OUTPUT — exactly one JSON object, no markdown fences, no prose around it:
81
+ {
82
+ "summary": "<one line: what changed in memory>",
83
+ "state_body": "<full new body without frontmatter, or null>",
84
+ "decisions_body": "<full new body, or null>",
85
+ "todo_body": "<full new body, or null>",
86
+ "mistakes_new_entries": ["### YYYY-MM-DD — <title>\\n<symptom/cause/prevention>", ...],
87
+ "archive_entries": [{"source": "<file it came from>", "content": "<verbatim section>"}, ...]
88
+ }
89
+
90
+ EXAMPLE (illustrative; real inputs are far longer)
91
+ Digest: "U: move api to port 9090, 8080 clashes with unifi
92
+ A: changed config/server.yaml 8080->9090, restarted, health check passes"
93
+ Output: {"summary": "api port 8080->9090 (unifi clash)", "state_body":
94
+ "<full state.md body with the port fact replaced>", "decisions_body": null,
95
+ "todo_body": null, "mistakes_new_entries": [], "archive_entries": []}
96
+ """
97
+ # --------------------------------------------------------------------------
98
+ # the distill: prompt -> claude -p -> validated apply
99
+ # --------------------------------------------------------------------------
100
+
101
+ def build_prompt(cfg, project_path, name, memory, digest, inbox_notes):
102
+ budgets = cfg["budgets"]
103
+ parts = [
104
+ CHARTER,
105
+ f"\nPROJECT: {name} (root: {project_path})",
106
+ f"DATE: {today()}",
107
+ "BUDGETS (chars of body): "
108
+ + ", ".join(f"{k}={v}" for k, v in budgets.items()),
109
+ "\n===== CURRENT state.md =====\n" + (memory["state.md"] or "(empty)"),
110
+ "\n===== CURRENT decisions.md =====\n" + (memory["decisions.md"] or "(empty)"),
111
+ "\n===== CURRENT mistakes.md (append-only; for reference) =====\n"
112
+ + (memory["mistakes.md"] or "(empty)"),
113
+ "\n===== CURRENT todo.md =====\n" + (memory["todo.md"] or "(empty)"),
114
+ ]
115
+ if inbox_notes:
116
+ parts.append("\n===== CURATOR INBOX =====\n" + "\n\n".join(inbox_notes))
117
+ parts.append(
118
+ "\n===== SESSION DIGEST (U=user, A=assistant, T=tool call, R=result) =====\n"
119
+ + (digest or "(no new transcript content)")
120
+ )
121
+ parts.append("\nProduce the JSON object now.")
122
+ return "\n".join(parts)
123
+
124
+
125
+ # Backoff between call_curator attempts (2 retries after the first try).
126
+ CURATOR_RETRY_DELAYS = (5, 15)
127
+
128
+
129
+ def call_curator(cfg, prompt, model):
130
+ """Invoke the curator, retrying transient failures (timeout, nonzero
131
+ exit, unparseable output) with backoff. A missing backend raises
132
+ ConfigError and fails fast — retrying cannot install it."""
133
+ for attempt, delay in enumerate(CURATOR_RETRY_DELAYS, start=1):
134
+ try:
135
+ return _call_curator_once(cfg, prompt, model)
136
+ except CuratorError as e:
137
+ log(f"curator attempt {attempt}/{len(CURATOR_RETRY_DELAYS) + 1} "
138
+ f"failed, retrying in {delay}s: {e}")
139
+ time.sleep(delay)
140
+ return _call_curator_once(cfg, prompt, model)
141
+
142
+
143
+ def _call_curator_once(cfg, prompt, model):
144
+ cmd = cfg.get("curator_cmd") or []
145
+ if cmd:
146
+ cmd = [arg.replace("{model}", model) for arg in cmd]
147
+ else:
148
+ bin_ = cfg["claude_bin"]
149
+ if not shutil.which(bin_):
150
+ for cand in (os.path.join(HOME, ".local", "bin", "claude"),):
151
+ if os.path.exists(cand):
152
+ bin_ = cand
153
+ break
154
+ cmd = [bin_, "-p", "--model", model, "--output-format", "json", "--max-turns", "1"]
155
+ env = dict(os.environ)
156
+ env.pop("CLAUDECODE", None) # allow nested invocation from inside a session
157
+ os.makedirs(STATE_DIR, exist_ok=True)
158
+ try:
159
+ proc = subprocess.run(
160
+ cmd, input=prompt, capture_output=True, text=True,
161
+ timeout=600, cwd=STATE_DIR, env=env,
162
+ )
163
+ except subprocess.TimeoutExpired:
164
+ raise CuratorError("curator call timed out")
165
+ except FileNotFoundError:
166
+ raise ConfigError(
167
+ f"curator backend not found: {cmd[0]!r} — install it or set "
168
+ f"curator_cmd in {CONFIG_PATH}; backlog is preserved and will "
169
+ "replay once a backend works")
170
+ except OSError as e:
171
+ raise CuratorError(f"curator invocation failed: {e}")
172
+ if proc.returncode != 0:
173
+ raise CuratorError(f"claude -p failed rc={proc.returncode}: {proc.stderr[:400]}")
174
+ # Output is either a claude -p envelope ({"result": "<text>"}), a bare
175
+ # curator JSON object (custom curator_cmd contract: output contains one
176
+ # JSON object), or text with the JSON embedded (fences/prose tolerated).
177
+ try:
178
+ envelope = json.loads(proc.stdout)
179
+ except json.JSONDecodeError:
180
+ text = proc.stdout
181
+ else:
182
+ if isinstance(envelope, dict) and isinstance(envelope.get("result"), str):
183
+ text = envelope["result"]
184
+ elif isinstance(envelope, dict):
185
+ return envelope
186
+ else:
187
+ text = proc.stdout
188
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
189
+ start = text.find("{")
190
+ if start < 0:
191
+ raise CuratorError(f"no JSON in curator output: {text[:300]}")
192
+ obj, _ = json.JSONDecoder().raw_decode(text[start:])
193
+ return obj
194
+
195
+
196
+ def validate(result, memory):
197
+ """Reject obviously destructive or malformed curator output."""
198
+ if not isinstance(result, dict):
199
+ raise CuratorError("curator output is not an object")
200
+ for key in ("state_body", "decisions_body", "todo_body"):
201
+ v = result.get(key)
202
+ if v is None:
203
+ continue
204
+ if not isinstance(v, str):
205
+ raise CuratorError(f"{key} is not a string")
206
+
207
+ v2 = re.sub(r"^```(?:markdown|md)?\s*\n|\n```\s*$", "", v.strip())
208
+ if v2 != v.strip():
209
+ log(f"stripped fences from {key}")
210
+
211
+ meta, body2 = split_frontmatter(v2)
212
+ if meta:
213
+ v2 = body2
214
+ log(f"stripped curator-emitted frontmatter from {key}")
215
+
216
+ fname = key.replace("_body", "") + ".md"
217
+ if v2.strip() == SKELETONS[fname]:
218
+ result[key] = None
219
+ log(f"curator returned skeleton for {key}; treated as no-change")
220
+ continue
221
+
222
+ result[key] = v2
223
+
224
+ old = memory[fname]
225
+ archived = sum(
226
+ len(a.get("content", "")) for a in result.get("archive_entries", [])
227
+ if isinstance(a, dict)
228
+ )
229
+ if len(old) > 800 and len(v2) + archived < len(old) * 0.4:
230
+ raise CuratorError(f"shrink guard tripped on {key} "
231
+ f"({len(old)} -> {len(v2)} chars, {archived} archived)")
232
+ for e in result.get("mistakes_new_entries", []):
233
+ if not isinstance(e, str):
234
+ raise CuratorError("mistakes_new_entries contains a non-string")
235
+ return result
236
+
237
+
238
+ def dedupe_mistakes(entries, existing_body):
239
+ """Drop entries whose '### ' heading line already appears verbatim in
240
+ mistakes.md (inbox is duplicate-tolerated; the audit log must not be)."""
241
+ out = []
242
+ for e in entries:
243
+ e = (e or "").strip()
244
+ if not e:
245
+ continue
246
+ head = e.splitlines()[0].strip()
247
+ if head.startswith("### ") and head in existing_body:
248
+ log(f"duplicate mistakes entry skipped: {head[:80]}")
249
+ continue
250
+ out.append(e)
251
+ return out
memd/digest.py ADDED
@@ -0,0 +1,378 @@
1
+ """Transcript digestion: claude-code JSONL, antigravity SQLite/protobuf,
2
+ credential redaction, and per-source dispatch."""
3
+
4
+ import json
5
+ import os
6
+ import re
7
+
8
+ from memd.config import AG_INDEX_PATH, load_config, load_json, log, save_json
9
+
10
+ # --------------------------------------------------------------------------
11
+ # transcript digestion
12
+ # --------------------------------------------------------------------------
13
+
14
+
15
+ def _flatten(content, limit):
16
+ if isinstance(content, str):
17
+ return content[:limit]
18
+ if isinstance(content, list):
19
+ parts = []
20
+ for b in content:
21
+ if isinstance(b, dict):
22
+ parts.append(str(b.get("text") or b.get("content") or ""))
23
+ else:
24
+ parts.append(str(b))
25
+ return " ".join(parts)[:limit]
26
+ return str(content)[:limit]
27
+
28
+
29
+ def digest_jsonl(path, offset):
30
+ """Digest new entries of a claude-code transcript from byte offset.
31
+
32
+ Returns (digest_text, new_offset)."""
33
+ lines = []
34
+ try:
35
+ with open(path, "rb") as f:
36
+ f.seek(offset)
37
+ raw = f.read()
38
+ new_offset = f.tell()
39
+ except OSError as e:
40
+ # unchanged offset means the content replays next sweep — safe, but
41
+ # the failure must be visible in the log, not silent
42
+ log(f"digest error: {e}")
43
+ return "", offset
44
+ for rawline in raw.splitlines():
45
+ try:
46
+ e = json.loads(rawline)
47
+ except (json.JSONDecodeError, UnicodeDecodeError):
48
+ continue
49
+ if e.get("isSidechain") or e.get("isMeta"):
50
+ continue
51
+ etype = e.get("type")
52
+ msg = e.get("message") or {}
53
+ content = msg.get("content")
54
+ if etype == "user":
55
+ if isinstance(content, str):
56
+ t = content.strip()
57
+ if t and not t.startswith("<"): # skip injected reminders
58
+ lines.append("U: " + t[:2500])
59
+ elif isinstance(content, list):
60
+ for b in content:
61
+ if not isinstance(b, dict):
62
+ continue
63
+ if b.get("type") == "text":
64
+ t = (b.get("text") or "").strip()
65
+ if t and not t.startswith("<"):
66
+ lines.append("U: " + t[:2500])
67
+ elif b.get("type") == "tool_result":
68
+ lines.append("R: " + _flatten(b.get("content"), 240))
69
+ elif etype == "assistant" and isinstance(content, list):
70
+ for b in content:
71
+ if not isinstance(b, dict):
72
+ continue
73
+ if b.get("type") == "text":
74
+ t = (b.get("text") or "").strip()
75
+ if t:
76
+ lines.append("A: " + t[:2500])
77
+ elif b.get("type") == "tool_use":
78
+ try:
79
+ inp = json.dumps(b.get("input", {}))[:300]
80
+ except (TypeError, ValueError):
81
+ inp = ""
82
+ lines.append(f"T: {b.get('name', '?')} {inp}")
83
+ return "\n".join(lines), new_offset
84
+
85
+
86
+ # Sessions sometimes read credentials; never let them reach the curator.
87
+ # One raw regex per named pattern; names become regex group names (so: valid
88
+ # identifiers) and are what gets logged when a pattern fires — never the
89
+ # matched text. Order matters where prefixes overlap: anthropic_key (sk-ant-)
90
+ # must precede openai_key (sk-).
91
+ REDACT_PATTERNS = {
92
+ # Google OAuth2 access token, ya29. prefix (developers.google.com/identity)
93
+ "google_oauth": r"ya29\.[\w.\-]{20,}",
94
+ # GitHub classic/app tokens ghp_/gho_/ghu_/ghs_/ghr_ and fine-grained
95
+ # PATs github_pat_ (github.blog token-prefix format, 2021/2022)
96
+ "github_pat": r"gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[\w]{20,}",
97
+ # Anthropic API key, sk-ant-... (docs.anthropic.com)
98
+ "anthropic_key": r"sk-ant-[\w\-]{20,}",
99
+ # OpenAI API key, sk-... incl. sk-proj-... (platform.openai.com)
100
+ "openai_key": r"sk-[\w\-]{20,}",
101
+ # AWS access key ID, AKIA + 16 uppercase alnum (docs.aws.amazon.com IAM)
102
+ "aws_access": r"AKIA[0-9A-Z]{16}",
103
+ # Slack tokens xoxb/xoxa/xoxp/xoxs/xoxe (api.slack.com token types)
104
+ "slack_token": r"xox[bapse]-[\w\-]{10,}",
105
+ # GitLab personal access token, glpat- prefix (docs.gitlab.com)
106
+ "gitlab_token": r"glpat-[\w\-]{20,}",
107
+ # npm access token, npm_ prefix (docs.npmjs.com, GitHub secret scanning)
108
+ "npm_token": r"npm_[A-Za-z0-9]{30,}",
109
+ # JSON Web Token: three base64url segments; header always starts eyJ
110
+ "jwt": r"eyJ[\w\-]{20,}\.[\w\-]{10,}\.[\w\-]{10,}",
111
+ # OAuth token fields inside captured JSON bodies
112
+ "json_token_field": r"\"(?:access|refresh|id)_token\"\s*:\s*\"[^\"]+\"",
113
+ # HTTP Authorization header with a Bearer token (RFC 6750 token68 charset)
114
+ "bearer_header": r"(?i:authorization)\s*:\s*(?i:bearer)\s+[A-Za-z0-9\-._~+/]{16,}=*",
115
+ # .env / shell-export credential assignment: UPPERCASE name ending in a
116
+ # credential suffix, non-trivial unquoted-token value. Anchored to line
117
+ # start and suffix-gated to avoid plain "key=" false positives.
118
+ "env_credential": r"(?m:^)(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=[\"']?[^\s\"']{12,}",
119
+ # PEM private key block (RSA/EC/OPENSSH/PKCS8). Replaced with
120
+ # "[REDACTED KEY]"; compiled separately below because it spans lines.
121
+ "ssh_private_key": r"(?s:-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----)",
122
+ # NOTE: Azure storage/service keys omitted — no distinctive prefix (plain
123
+ # base64), so any pattern is all false positives. Use REDACT_EXTRA_PATTERNS.
124
+ }
125
+
126
+ REDACT_RE = re.compile("|".join(
127
+ f"(?P<{n}>{p})" for n, p in REDACT_PATTERNS.items()
128
+ if n != "ssh_private_key"))
129
+ REDACT_PEM_RE = re.compile(REDACT_PATTERNS["ssh_private_key"])
130
+
131
+ _REDACT_EXTRA = None # lazy: [(name, compiled)] from config REDACT_EXTRA_PATTERNS
132
+
133
+
134
+ def _redact_extras():
135
+ """Per-instance additions from config key REDACT_EXTRA_PATTERNS (a list of
136
+ raw regex strings), compiled once per process; broken regexes are skipped
137
+ loudly rather than disabling redaction."""
138
+ global _REDACT_EXTRA
139
+ if _REDACT_EXTRA is None:
140
+ _REDACT_EXTRA = []
141
+ for i, pat in enumerate(load_config().get("REDACT_EXTRA_PATTERNS") or []):
142
+ try:
143
+ _REDACT_EXTRA.append((f"extra_{i}", re.compile(pat)))
144
+ except re.error as e:
145
+ log(f"bad regex in REDACT_EXTRA_PATTERNS[{i}]: {e}")
146
+ return _REDACT_EXTRA
147
+
148
+
149
+ def redact(text):
150
+ def sub(m):
151
+ log(f"redacted credential: {m.lastgroup}")
152
+ return "[REDACTED]"
153
+
154
+ def sub_key(m):
155
+ log("redacted credential: ssh_private_key")
156
+ return "[REDACTED KEY]"
157
+
158
+ text = REDACT_RE.sub(sub, text)
159
+ text = REDACT_PEM_RE.sub(sub_key, text)
160
+ for name, rx in _redact_extras():
161
+ def sub_extra(m, _name=name):
162
+ log(f"redacted credential: {_name}")
163
+ return "[REDACTED]"
164
+ text = rx.sub(sub_extra, text)
165
+ return text
166
+
167
+
168
+ # --- antigravity-cli adapter ------------------------------------------------
169
+ # Conversations live in <antigravity_dir>/conversations/*.db (SQLite, one
170
+ # trajectory per file; `steps` rows hold protobuf payloads). There is no
171
+ # published schema, so text is extracted as printable-string runs — noisy but
172
+ # the curator model tolerates it. Legacy *.pb conversations are not parsed.
173
+
174
+ # step types observed on antigravity-cli as of 2026-07 — undocumented, may
175
+ # change with any update
176
+ AG_STEP_USER = 14
177
+ AG_STEP_ASSISTANT = 33
178
+ AG_STEP_TOOL = 15
179
+ AG_STEP_ERROR = 17
180
+ AG_KNOWN_STEP_TYPES = frozenset(
181
+ {AG_STEP_USER, AG_STEP_ASSISTANT, AG_STEP_TOOL, AG_STEP_ERROR})
182
+
183
+ _STR_RUN = re.compile(rb"[\x20-\x7e\n\t]{12,}")
184
+
185
+
186
+ def _pb_strings(blob, minlen=12):
187
+ """Printable-ASCII runs >= minlen from a protobuf blob. Heuristic, not a
188
+ parser: wire framing can glue adjacent fields into one run or split a
189
+ string containing non-ASCII bytes into several."""
190
+ return [s for s in _STR_RUN.findall(blob or b"") if len(s) >= minlen]
191
+
192
+
193
+ def ag_conversations_dir(cfg):
194
+ return os.path.join(os.path.expanduser(cfg["antigravity_dir"]), "conversations")
195
+
196
+
197
+ def _ag_connect(path):
198
+ import sqlite3
199
+ return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
200
+
201
+
202
+ def ag_workspace(path):
203
+ """Best-effort workspace dir for a conversation DB (file:// URIs in meta)."""
204
+ import sqlite3 # lazy, like _ag_connect
205
+ try:
206
+ con = _ag_connect(path)
207
+ blobs = [r[0] for r in con.execute(
208
+ "select data from trajectory_metadata_blob")]
209
+ blobs += [r[0] for r in con.execute(
210
+ "select step_payload from steps order by idx limit 5")]
211
+ con.close()
212
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
213
+ return None
214
+ candidates = set()
215
+ for blob in blobs:
216
+ for m in re.finditer(rb"file://(/[A-Za-z0-9_\-./]+)", blob or b""):
217
+ p = m.group(1).decode("utf-8", "replace")
218
+ while p and p != "/" and not os.path.isdir(p):
219
+ p = p[:-1] # protobuf adjacency can glue trailing bytes on
220
+ if p and p != "/":
221
+ candidates.add(p)
222
+ return max(candidates, key=len) if candidates else None
223
+
224
+
225
+ def ag_max_idx(path):
226
+ try:
227
+ con = _ag_connect(path)
228
+ n = con.execute("select coalesce(max(idx), 0) from steps").fetchone()[0]
229
+ con.close()
230
+ return n
231
+ except Exception:
232
+ return 0
233
+
234
+
235
+ def _ag_clean(s):
236
+ t = s.decode("utf-8", "replace").strip().strip('"').strip()
237
+ return re.sub(r"^[^\w#/<\[({`*-]+", "", t)
238
+
239
+
240
+ def digest_ag_db(path, last_idx):
241
+ """Digest antigravity conversation steps with idx > last_idx.
242
+
243
+ Returns (digest_text, new_last_idx)."""
244
+ lines = []
245
+ new_idx = last_idx
246
+ try:
247
+ con = _ag_connect(path)
248
+ rows = list(con.execute(
249
+ "select idx, step_type, step_payload from steps "
250
+ "where idx > ? order by idx", (last_idx,)))
251
+ con.close()
252
+ except Exception as e:
253
+ log(f"ag digest failed for {path}: {e}")
254
+ return "", last_idx
255
+ for idx, st, payload in rows:
256
+ new_idx = max(new_idx, idx)
257
+ if st not in AG_KNOWN_STEP_TYPES:
258
+ log(f"unknown ag step type: {st}")
259
+ continue
260
+ strs = _pb_strings(payload)
261
+ if not strs:
262
+ continue
263
+ if st == AG_STEP_USER:
264
+ lines.append("U: " + _ag_clean(max(strs, key=len))[:2500])
265
+ elif st == AG_STEP_ASSISTANT:
266
+ lines.append("A: " + _ag_clean(max(strs, key=len))[:2500])
267
+ elif st == AG_STEP_TOOL: # json descriptor with toolAction/Summary
268
+ for s in strs:
269
+ t = s.decode("utf-8", "replace")
270
+ start = t.find('{"')
271
+ if start < 0:
272
+ continue
273
+ try:
274
+ obj, _ = json.JSONDecoder().raw_decode(t[start:])
275
+ summary = obj.get("toolSummary") or obj.get("toolAction")
276
+ if summary:
277
+ lines.append("T: " + str(summary)[:200])
278
+ break
279
+ except (json.JSONDecodeError, AttributeError):
280
+ continue
281
+ elif st == AG_STEP_ERROR:
282
+ lines.append("E: " + _ag_clean(max(strs, key=len))[:300])
283
+ return "\n".join(lines), new_idx
284
+
285
+
286
+ def _ag_assign_project(db, roots):
287
+ """Antigravity records the launch dir, not the project, as workspace —
288
+ so attribute a conversation to the registered root its payloads
289
+ reference most (>=3 mentions to avoid drive-by matches)."""
290
+ hits = dict.fromkeys(roots, 0)
291
+ try:
292
+ con = _ag_connect(db)
293
+ for (payload,) in con.execute("select step_payload from steps"):
294
+ for r in roots:
295
+ hits[r] += (payload or b"").count(r.encode())
296
+ con.close()
297
+ except Exception:
298
+ return None
299
+ best = max(hits, key=hits.get) if hits else None
300
+ return best if best and hits[best] >= 3 else None
301
+
302
+
303
+ def project_ag_dbs(cfg, project_path):
304
+ """Conversation DBs attributed to this project (cached in ag_index)."""
305
+ import glob as _glob
306
+ conv = ag_conversations_dir(cfg)
307
+ if not os.path.isdir(conv):
308
+ return []
309
+ # Exclude the global root: its path ($HOME) is a substring of every home
310
+ # path, so it would win _ag_assign_project's mention-count and steal every
311
+ # conversation from the real (nested) project. Global is inbox-only anyway.
312
+ roots = sorted(os.path.realpath(p) for p, e in cfg["projects"].items()
313
+ if not e.get("global"))
314
+ index = load_json(AG_INDEX_PATH, {})
315
+ changed = False
316
+ out = []
317
+ root = os.path.realpath(project_path)
318
+ for db in _glob.glob(os.path.join(conv, "*.db")):
319
+ e = index.get(db)
320
+ if not isinstance(e, dict):
321
+ e = {"project": "", "scanned_idx": -1}
322
+ maxidx = ag_max_idx(db)
323
+ # unattributed conversations get rescanned as they grow
324
+ if not e["project"] and maxidx > e.get("scanned_idx", -1):
325
+ proj = None
326
+ ws = ag_workspace(db)
327
+ if ws:
328
+ inside = [r for r in roots
329
+ if ws == r or ws.startswith(r.rstrip("/") + "/")]
330
+ proj = max(inside, key=len, default=None)
331
+ if not proj:
332
+ proj = _ag_assign_project(db, roots)
333
+ e = {"project": proj or "", "scanned_idx": maxidx}
334
+ index[db] = e
335
+ changed = True
336
+ if e["project"] == root:
337
+ out.append(db)
338
+ if changed:
339
+ save_json(AG_INDEX_PATH, index)
340
+ return sorted(out)
341
+
342
+
343
+ def digest_source(src, cursor):
344
+ """Dispatch to the right digester. Returns (text, new_cursor)."""
345
+ if src.endswith(".db"):
346
+ return digest_ag_db(src, cursor)
347
+ return digest_jsonl(src, cursor)
348
+
349
+
350
+ def baseline_cursor(src):
351
+ return ag_max_idx(src) if src.endswith(".db") else os.path.getsize(src)
352
+
353
+
354
+ def cap_digest(text, cap):
355
+ if len(text) <= cap:
356
+ return text
357
+ head = int(cap * 0.3)
358
+ tail = cap - head
359
+ return text[:head] + "\n[... digest truncated ...]\n" + text[-tail:]
360
+
361
+
362
+ def collapse_repeats(text):
363
+ """Collapse runs of identical non-blank lines (agent retry loops)
364
+ into one line plus a repeat marker."""
365
+ out, prev, reps = [], None, 0
366
+ for line in text.splitlines():
367
+ if line == prev and line.strip():
368
+ reps += 1
369
+ continue
370
+ if reps:
371
+ out.append(f"[... line repeated {reps} more time(s) ...]")
372
+ reps = 0
373
+ out.append(line)
374
+ prev = line
375
+ if reps:
376
+ out.append(f"[... line repeated {reps} more time(s) ...]")
377
+ return "\n".join(out)
378
+
memd/errors.py ADDED
@@ -0,0 +1,28 @@
1
+ """Exception hierarchy for memd failures.
2
+
3
+ MemdError subclasses RuntimeError so existing `except RuntimeError` callers
4
+ (e.g. sync_project's failure guard) keep catching everything memd raises.
5
+
6
+ CLI exit-code contract: 0 = ok, 1 = known error, 2 = config error,
7
+ 3 = curator/distill failure.
8
+ """
9
+
10
+
11
+ class MemdError(RuntimeError):
12
+ """Base for all memd-raised failures."""
13
+
14
+
15
+ class ConfigError(MemdError):
16
+ """Bad or missing configuration (including a missing curator backend);
17
+ retrying cannot fix it. CLI exit code 2 where surfaced directly."""
18
+
19
+
20
+ class DigestError(MemdError):
21
+ """A transcript source could not be digested. Digesters deliberately log
22
+ and return an unchanged cursor instead of raising (so backlog replays);
23
+ this class is part of the public hierarchy for downstream tooling."""
24
+
25
+
26
+ class CuratorError(MemdError):
27
+ """Curator backend invocation or output failure (transient: timeouts,
28
+ nonzero exits, unparseable output). call_curator retries these."""