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/__init__.py +113 -0
- memd/cli.py +196 -0
- memd/config.py +164 -0
- memd/curator.py +251 -0
- memd/digest.py +378 -0
- memd/errors.py +28 -0
- memd/hooks.py +117 -0
- memd/inbox.py +80 -0
- memd/memory.py +491 -0
- memd/sweep.py +201 -0
- memd/sync.py +195 -0
- memd-0.3.0.dist-info/METADATA +353 -0
- memd-0.3.0.dist-info/RECORD +17 -0
- memd-0.3.0.dist-info/WHEEL +5 -0
- memd-0.3.0.dist-info/entry_points.txt +2 -0
- memd-0.3.0.dist-info/licenses/LICENSE +21 -0
- memd-0.3.0.dist-info/top_level.txt +1 -0
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."""
|