agent-memory-brain 0.2.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.
Files changed (54) hide show
  1. agent_memory_brain-0.2.0.dist-info/METADATA +98 -0
  2. agent_memory_brain-0.2.0.dist-info/RECORD +54 -0
  3. agent_memory_brain-0.2.0.dist-info/WHEEL +4 -0
  4. agent_memory_brain-0.2.0.dist-info/entry_points.txt +3 -0
  5. agent_memory_brain-0.2.0.dist-info/licenses/LICENSE +67 -0
  6. devmem/__init__.py +3 -0
  7. devmem/ado.py +433 -0
  8. devmem/agent_colors.py +74 -0
  9. devmem/cli.py +500 -0
  10. devmem/db.py +1069 -0
  11. devmem/dispatch.py +478 -0
  12. devmem/embed.py +111 -0
  13. devmem/filelock.py +53 -0
  14. devmem/git_sync.py +261 -0
  15. devmem/guard.py +214 -0
  16. devmem/hook_log.py +49 -0
  17. devmem/ingest/__init__.py +1 -0
  18. devmem/ingest/claude.py +87 -0
  19. devmem/ingest/codex.py +175 -0
  20. devmem/ingest/runner.py +318 -0
  21. devmem/ingest/subagents.py +250 -0
  22. devmem/maintenance.py +801 -0
  23. devmem/mcp_server.py +542 -0
  24. devmem/messages.py +684 -0
  25. devmem/panel/__init__.py +1 -0
  26. devmem/panel/app.py +971 -0
  27. devmem/panel/state.py +358 -0
  28. devmem/panel/static/base.css +27 -0
  29. devmem/panel/static/brain.css +473 -0
  30. devmem/panel/static/brain.js +1118 -0
  31. devmem/panel/static/brain3d.js +600 -0
  32. devmem/panel/static/chat.js +18 -0
  33. devmem/panel/static/common.js +208 -0
  34. devmem/panel/static/site.css +508 -0
  35. devmem/panel/static/tokens.css +19 -0
  36. devmem/panel/templates/ado.html +62 -0
  37. devmem/panel/templates/agents.html +35 -0
  38. devmem/panel/templates/audit.html +19 -0
  39. devmem/panel/templates/base.html +42 -0
  40. devmem/panel/templates/brain.html +101 -0
  41. devmem/panel/templates/maintenance.html +32 -0
  42. devmem/panel/templates/memory.html +71 -0
  43. devmem/panel/templates/message_thread.html +49 -0
  44. devmem/panel/templates/messages.html +8 -0
  45. devmem/panel/templates/overview.html +109 -0
  46. devmem/panel/templates/project.html +48 -0
  47. devmem/panel/templates/projects.html +21 -0
  48. devmem/panel/templates/session.html +49 -0
  49. devmem/panel/templates/sessions.html +67 -0
  50. devmem/panel/templates/subagent.html +27 -0
  51. devmem/procs.py +10 -0
  52. devmem/projects.py +17 -0
  53. devmem/secrets.py +9 -0
  54. devmem/skills.py +105 -0
devmem/maintenance.py ADDED
@@ -0,0 +1,801 @@
1
+ """Automatic memory maintenance: mechanical evidence, guarded LLM proposals, one short apply transaction."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime, timedelta
13
+ from pathlib import Path
14
+
15
+ from .db import audit_memories, connection, maintenance_failing_since, now, parse_ts, restore_memory, validate_content, validate_review_after
16
+ from .filelock import acquire, release
17
+ from .procs import executable
18
+ from .secrets import SECRET
19
+
20
+ KINDS = {"note", "decision", "preference", "snippet", "todo"}
21
+ ACTIONS = {"update", "merge", "supersede", "keep", "flag"}
22
+ MODEL_ACTIONS = ACTIONS | {"none"} # "none" is never stored as an action: it becomes a closed no_change row
23
+ TRIGGERS = {"startup", "session_end", "manual"}
24
+ # The user's own rules: flag-only whatever maintain.json says (config may add kinds, never remove these).
25
+ ALWAYS_PROTECTED = {"preference", "decision"}
26
+ DEFAULTS = {"enabled": False, "engine": "codex", "model": None, "full_every_hours": 20,
27
+ "max_changes": 15, "max_shrink": .5, "use_transcripts": True,
28
+ "protected_kinds": ["preference", "decision"]}
29
+ CHILD_ENV = "DEVMEM_MAINTAIN_CHILD" # set for engine subprocesses so their own hooks never start maintenance
30
+ ENGINE_TIMEOUT, GIT_TIMEOUT, TRANSCRIPT_BYTES = 300, 10, 40_000
31
+ FLAG_SKIP, REVERT_SKIP, USER_EDIT_GRACE, PER_MEMORY = timedelta(days=7), timedelta(days=7), timedelta(hours=2), timedelta(hours=24)
32
+ NO_CHANGE_SUMMARY = "no change: the evidence shows nothing wrong"
33
+ AGE_REASONS = {"review date has passed", "not updated in 30+ days"}
34
+ _COMMIT_RE = r"(?<![\w/.-])(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}(?![\w/-])"
35
+ TOKENS = re.compile(_COMMIT_RE + r"|!\d+|#\d+|\b\d{4}-\d\d-\d\d\b|\b\w+/[\w./-]+|(?<!\w)[\w.-]+/[\w./-]+|\b\d{3,}\b")
36
+ _URL = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.I)
37
+ _SLASHY = re.compile(r"(?<![\w./~:-])(?:~/|\.{1,2}/|/)?(?:[\w.-]+/)+[\w.-]*\w")
38
+ _COMMIT = re.compile(_COMMIT_RE)
39
+ _BRANCH_WORD = re.compile(r"\bbranch(?:es)?\s+[`'\"]?([A-Za-z0-9_][\w./-]*)", re.I)
40
+ _BRANCH_PREFIX = re.compile(r"^(?:feature|feat|fix|bugfix|hotfix|release|chore|refactor|docs|test|dev|wip|users?|topic)/[\w./-]+$", re.I)
41
+ _REF_OK = re.compile(r"^[A-Za-z0-9_][\w./-]*$")
42
+ _STALE_WORDS = re.compile(r"\b(WIP|uncommitted|waiting)\b", re.I)
43
+ _dropped_item = {"type": "object", "additionalProperties": False, "required": ["token", "reason"], "properties": {"token": {"type": "string"}, "reason": {"type": "string"}}}
44
+ _evidence_item = {"type": "object", "additionalProperties": False, "required": ["ref", "detail"], "properties": {"ref": {"type": "string"}, "detail": {"type": "string"}}}
45
+ _PROPOSAL_SCHEMA = {"type": "object", "additionalProperties": False, "required": ["action", "memory_id", "target_id", "new_title", "new_content", "new_review_after", "dropped", "evidence", "summary"], "properties": {"action": {"enum": sorted(MODEL_ACTIONS)}, "memory_id": {"type": "integer"}, "target_id": {"type": ["integer", "null"]}, "new_title": {"type": ["string", "null"]}, "new_content": {"type": ["string", "null"]}, "new_review_after": {"type": ["string", "null"]}, "dropped": {"type": "array", "items": _dropped_item}, "evidence": {"type": "array", "items": _evidence_item}, "summary": {"type": "string"}}}
46
+ _VERIFY_SCHEMA = {"type": "object", "additionalProperties": False, "required": ["ok", "reason"], "properties": {"ok": {"type": "boolean"}, "reason": {"type": "string"}}}
47
+ # A flag is "open" until dismissed; dry-run rows are previews, never open flags.
48
+ OPEN_FLAG_SQL = "c.action='flag' AND c.applied=0 AND c.reverted_at IS NULL AND NOT EXISTS(SELECT 1 FROM maintenance_runs r WHERE r.id=c.run_id AND r.status='dry_run')"
49
+
50
+
51
+ # ---------------------------------------------------------------- config
52
+
53
+ def _number(value) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool)
54
+ _CHECKS = {
55
+ "enabled": (lambda v: isinstance(v, bool), "a boolean"),
56
+ "engine": (lambda v: v in ("codex", "claude"), '"codex" or "claude"'),
57
+ "model": (lambda v: v is None or (isinstance(v, str) and bool(v.strip()) and not v.startswith("-")), "null or a model name"),
58
+ "full_every_hours": (lambda v: _number(v) and 0 < v <= 24 * 365, "a positive number of hours"),
59
+ "max_changes": (lambda v: isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 1000, "an integer from 0 to 1000"),
60
+ "max_shrink": (lambda v: _number(v) and 0 <= v < 1, "a number from 0 up to (not including) 1"),
61
+ "use_transcripts": (lambda v: isinstance(v, bool), "a boolean"),
62
+ "protected_kinds": (lambda v: isinstance(v, list) and all(isinstance(k, str) and k in KINDS for k in v), "a list of memory kinds"),
63
+ }
64
+
65
+
66
+ def config_path() -> Path: return Path(os.environ.get("DEVMEM_MAINTAIN", "~/.devmem/maintain.json")).expanduser()
67
+ def _broken(reason: str) -> dict: return {**DEFAULTS, "enabled": False, "error": reason}
68
+
69
+
70
+ def config() -> dict:
71
+ """A missing file means defaults; anything unreadable or invalid disables maintenance and carries an error."""
72
+ try: loaded = json.loads(config_path().read_text(encoding="utf-8"))
73
+ except FileNotFoundError: return dict(DEFAULTS)
74
+ except ValueError as exc: return _broken(f"maintain.json is not valid JSON: {exc}")
75
+ except OSError as exc: return _broken(f"maintain.json unreadable: {type(exc).__name__}")
76
+ if not isinstance(loaded, dict): return _broken("maintain.json must be a JSON object")
77
+ for key, value in loaded.items():
78
+ if key not in _CHECKS: return _broken(f"maintain.json: unknown key {key!r}")
79
+ valid, expected = _CHECKS[key]
80
+ if not valid(value): return _broken(f"maintain.json: {key} must be {expected}")
81
+ cfg = {**DEFAULTS, **loaded}
82
+ cfg["protected_kinds"] = sorted(set(cfg["protected_kinds"]) | ALWAYS_PROTECTED)
83
+ return cfg
84
+
85
+
86
+ # ---------------------------------------------------------------- engine
87
+
88
+ class EngineUnavailable(RuntimeError):
89
+ """The engine cannot run at all (missing binary, bad name); no single memory is to blame."""
90
+
91
+
92
+ def _tail(text: str | None, size: int = 400) -> str: return SECRET.sub("[redacted]", (text or "").strip()[-size:])
93
+ def _strip_fences(text: str) -> str: return re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", text)
94
+
95
+
96
+ def _engine_cwd(payload: dict) -> Path:
97
+ project = payload.get("memory", {}).get("project") if isinstance(payload.get("memory"), dict) else None
98
+ return Path(project).expanduser() if isinstance(project, str) and Path(project).expanduser().is_dir() else Path.home()
99
+
100
+
101
+ class Engine:
102
+ """Production engine boundary; tests supply a small fake with proposal/verify.
103
+
104
+ The prompt goes on stdin (never argv, never an error message) and errors are sanitized,
105
+ so memory text and transcripts cannot leak into maintenance_runs.error."""
106
+ def __init__(self, name="codex", model=None): self.name, self.model = name, model
107
+
108
+ def _run(self, prompt: str, payload: dict, schema: dict) -> dict:
109
+ cwd, env = _engine_cwd(payload), {**os.environ, CHILD_ENV: "1"}
110
+ prompt = "<devmem-spawned>\n" + prompt
111
+ with tempfile.TemporaryDirectory(prefix="devmem-maintain-") as directory:
112
+ schema_path, output_path = Path(directory) / "schema.json", Path(directory) / "output.json"
113
+ schema_path.write_text(json.dumps(schema), encoding="utf-8")
114
+ if self.name == "codex":
115
+ command = [executable("codex"), "exec", "--sandbox", "read-only", "--skip-git-repo-check", "-C", str(cwd)] + (["--model", self.model] if self.model else [])
116
+ command += ["--output-schema", str(schema_path), "-o", str(output_path), "-"]
117
+ elif self.name == "claude":
118
+ command = [executable("claude"), "-p", "--output-format", "json"] + (["--model", self.model] if self.model else [])
119
+ prompt += "\n\nReply with exactly one JSON object matching this JSON schema, no prose:\n" + json.dumps(schema)
120
+ else: raise EngineUnavailable("engine must be codex or claude")
121
+ try: result = subprocess.run(command, input=prompt, cwd=cwd, env=env, text=True, capture_output=True, timeout=ENGINE_TIMEOUT)
122
+ except FileNotFoundError: raise EngineUnavailable(f"{self.name} binary not found on PATH") from None
123
+ except subprocess.TimeoutExpired: raise EngineRunFailed(f"{self.name} timed out after {ENGINE_TIMEOUT} s") from None
124
+ if result.returncode: raise EngineRunFailed(f"{self.name} exited with code {result.returncode}: {_tail(result.stderr) or 'no stderr'}")
125
+ if self.name == "codex":
126
+ if not output_path.is_file(): raise EngineRunFailed("codex wrote no output file")
127
+ raw = output_path.read_text(encoding="utf-8")
128
+ else:
129
+ try: outer = json.loads(result.stdout)
130
+ except ValueError: raise EngineOutputError("claude returned non-JSON output") from None
131
+ if not isinstance(outer, dict) or outer.get("is_error") or not isinstance(outer.get("result"), str):
132
+ raise EngineRunFailed("claude reported an error: " + _tail(str(outer.get("result") if isinstance(outer, dict) else outer), 200))
133
+ raw = _strip_fences(outer["result"])
134
+ try: parsed = json.loads(raw)
135
+ except ValueError: raise EngineOutputError(f"{self.name} returned invalid JSON") from None
136
+ if not isinstance(parsed, dict): raise EngineOutputError(f"{self.name} returned a non-object JSON result")
137
+ return parsed
138
+
139
+ def proposal(self, payload: dict) -> dict:
140
+ prompt = """You are proposing conservative local-memory maintenance. Return only schema JSON.
141
+ Rules: memory_id must equal memory.id. Use only the supplied mechanical evidence and transcript lines (cite them as transcript:N exactly); entries under "context" are not evidence. Flag uncertainty; never delete.
142
+ Protected memories (memory.protected or a protected partner) are flag-only. update rewrites memory; merge keeps memory with the merged text and supersedes target_id; supersede marks memory as replaced by target_id; target_id must be one of partners.
143
+ none means the evidence shows nothing wrong (a false finding, or history that is still true); prefer it to flag when the user need not look. keep is only for an age/review-date finding. Updates, merges and supersedes need evidence. Do not shrink content by more than 50% or exceed 2,000 characters.
144
+ Every commit hash, !N/#N, ISO date, branch-like token, file path or 3+ digit number that is missing from the new text must be listed in dropped with a reason naming a mechanical evidence ref or transcript:N. Never include secrets.
145
+ A relative-date item only says the words are present; updated_at is not when they were written. Replace one only when the text itself shows which day it was and it names a past event; when it means "currently" or "these days", leave it.
146
+ Payload:
147
+ """ + json.dumps(payload, ensure_ascii=False, default=str)
148
+ return self._run(prompt, payload, _PROPOSAL_SCHEMA)
149
+
150
+ def verify(self, payload: dict) -> dict:
151
+ prompt = "Return only schema JSON. Is every claim in the new text supported by the evidence, and is nothing true from the old text lost without a stated reason? Reject unsupported claims or unexplained loss. Payload:\n" + json.dumps(payload, ensure_ascii=False, default=str)
152
+ return self._run(prompt, payload, _VERIFY_SCHEMA)
153
+
154
+
155
+ # ---------------------------------------------------------------- lock, schedule, transcript
156
+
157
+ @dataclass
158
+ class Lock:
159
+ """flock on a held fd: the kernel releases it when the process dies, so stale/garbled files and PID reuse cannot block."""
160
+ path: Path
161
+ acquired: bool = False
162
+ fd: int | None = None
163
+ def __enter__(self):
164
+ self.path.parent.mkdir(parents=True, exist_ok=True)
165
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
166
+ try: acquire(fd)
167
+ except BlockingIOError: os.close(fd); return self
168
+ except BaseException: os.close(fd); raise
169
+ os.ftruncate(fd, 0); os.write(fd, str(os.getpid()).encode()); self.fd, self.acquired = fd, True
170
+ return self
171
+ def __exit__(self, *_):
172
+ if self.fd is not None:
173
+ release(self.fd)
174
+ os.close(self.fd) # the file stays: unlinking would let a racing opener lock a dead inode
175
+ self.fd, self.acquired = None, False
176
+
177
+
178
+ def lock_path() -> Path: return Path(os.environ.get("DEVMEM_MAINTAIN_LOCK", "~/.devmem/maintain.lock")).expanduser()
179
+ def _hidden_sql(alias="m"): return f"NOT EXISTS(SELECT 1 FROM memory_links sl WHERE sl.to_id={alias}.id AND sl.type='supersedes')"
180
+
181
+
182
+ def due_for_startup() -> bool:
183
+ """Only an unlimited, project-less, non-dry-run successful run resets the full-run window; so does a missing engine, which retrying sooner cannot fix."""
184
+ cfg = config()
185
+ if not cfg["enabled"] and not cfg.get("error"): return False
186
+ with connection() as conn:
187
+ row = conn.execute("""SELECT finished_at FROM maintenance_runs WHERE full=1 AND finished_at IS NOT NULL
188
+ AND (status='success' OR (status='error' AND error LIKE 'engine unavailable:%')) ORDER BY id DESC LIMIT 1""").fetchone()
189
+ finished = parse_ts(row["finished_at"]) if row else None
190
+ return not finished or datetime.now(UTC) - finished > timedelta(hours=float(cfg["full_every_hours"]))
191
+
192
+
193
+ def _transcript_text(line: str) -> str:
194
+ try: item = json.loads(line)
195
+ except ValueError: return ""
196
+ if not isinstance(item, dict): return ""
197
+ message = item.get("message")
198
+ role, content = (message.get("role"), message.get("content")) if isinstance(message, dict) else (item.get("role") or item.get("type"), item.get("content"))
199
+ if role not in {"user", "assistant"}: return ""
200
+ if isinstance(content, str): return content.strip()
201
+ if isinstance(content, list): return "\n".join(str(part.get("text", "")) for part in content if isinstance(part, dict) and part.get("type") == "text").strip()
202
+ return ""
203
+
204
+
205
+ def read_transcript(path: str | None) -> tuple[str | None, str | None]:
206
+ """Return (excerpt, note): only user/assistant text, newest ~40 KB of whole `transcript:N` lines."""
207
+ if not path: return None, None
208
+ try: lines = Path(path).expanduser().read_text(encoding="utf-8", errors="replace").splitlines()
209
+ except OSError as exc: return None, f"transcript unavailable ({type(exc).__name__})"
210
+ pieces = [f"transcript:{number} {text}" for number, line in enumerate(lines, 1) if (text := _transcript_text(line))]
211
+ kept, size = [], 0
212
+ for piece in reversed(pieces):
213
+ size += len(piece.encode()) + 1
214
+ if size > TRANSCRIPT_BYTES:
215
+ if not kept: # the newest line alone is too long: keep its citation prefix and its tail
216
+ prefix, _, text = piece.partition(" ")
217
+ kept.append(prefix + " …" + text.encode()[-(TRANSCRIPT_BYTES - 200):].decode(errors="ignore"))
218
+ break
219
+ kept.append(piece)
220
+ excerpt = "\n".join(reversed(kept))
221
+ return (excerpt, None) if excerpt else (None, "transcript has no user or assistant text")
222
+
223
+
224
+ def transcript_excerpt(path: str | None) -> str | None: return read_transcript(path)[0]
225
+
226
+
227
+ # ---------------------------------------------------------------- mechanical evidence
228
+
229
+ class _GitError(Exception): pass
230
+
231
+
232
+ def _git(project: Path, *args: str) -> subprocess.CompletedProcess:
233
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"}
234
+ return subprocess.run([executable("git"), "-C", str(project), *args], capture_output=True, text=True, timeout=GIT_TIMEOUT, stdin=subprocess.DEVNULL, env=env)
235
+
236
+
237
+ def _git_ok(project: Path, *args: str) -> bool:
238
+ """rc 0 → True, rc 1 → False; anything else is a check error, never evidence."""
239
+ result = _git(project, *args)
240
+ if result.returncode in (0, 1): return result.returncode == 0
241
+ raise _GitError(f"git {args[0]} exited {result.returncode}: {_tail(result.stderr, 200)}")
242
+
243
+
244
+ def _default_branch(project: Path) -> str | None:
245
+ head = _git(project, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
246
+ if head.returncode == 0 and head.stdout.strip():
247
+ candidate = head.stdout.strip().removeprefix("refs/remotes/")
248
+ if _git_ok(project, "rev-parse", "--verify", "--quiet", "--end-of-options", candidate + "^{commit}"): return candidate
249
+ for name in ("main", "master"):
250
+ if _git_ok(project, "rev-parse", "--verify", "--quiet", "--end-of-options", f"refs/heads/{name}"): return name
251
+ return None
252
+
253
+
254
+ def _clean(token: str) -> str: return token.strip("`'\"").rstrip(".,;:)")
255
+
256
+
257
+ def _branch_check(project: Path, branch: str, base: str | None, strong: bool) -> tuple[dict | None, dict | None]:
258
+ """Return (evidence, context) for one branch name."""
259
+ if not _REF_OK.match(branch) or ".." in branch or branch.endswith((".", "/", ".lock")): return None, None
260
+ if not _git_ok(project, "rev-parse", "--verify", "--quiet", "--end-of-options", branch + "^{commit}"):
261
+ return ({"ref": branch, "check": "branch", "result": "missing", "command": f"git rev-parse --verify {branch}"} if strong else None), None
262
+ if not base or branch == base or branch == base.rsplit("/", 1)[-1]: return None, None
263
+ if _git(project, "rev-parse", "--end-of-options", branch).stdout == _git(project, "rev-parse", "--end-of-options", base).stdout: return None, None
264
+ if not _git_ok(project, "merge-base", "--is-ancestor", "--end-of-options", branch, base): return None, None
265
+ merges = _git(project, "rev-list", "--ancestry-path", "--merges", "--reverse", "--end-of-options", f"{branch}..{base}").stdout.split()
266
+ if merges:
267
+ commit = _git(project, "rev-parse", "--short", "--end-of-options", merges[0]).stdout.strip()
268
+ return {"ref": branch, "check": "branch", "result": "merged", "command": f"git merge-base --is-ancestor {branch} {base}", "commit": commit, "base": base}, None
269
+ return None, {"ref": branch, "check": "branch", "result": f"contained in {base} without a merge commit (not evidence)", "command": f"git merge-base --is-ancestor {branch} {base}"}
270
+
271
+
272
+ def _tracked_suffixes(project: Path) -> set[str]:
273
+ """Every tracked file and directory path plus each of its trailing segments, so core/settings.py matches packages/x/src/x/core/settings.py."""
274
+ result = _git(project, "ls-files", "-z")
275
+ if result.returncode != 0: raise _GitError(f"git ls-files exited {result.returncode}: {_tail(result.stderr, 200)}") # an empty set would make every path "missing"
276
+ out = set()
277
+ for path in filter(None, result.stdout.split("\0")):
278
+ parts = path.split("/")
279
+ for end in range(1, len(parts) + 1):
280
+ for start in range(end): out.add("/".join(parts[start:end]))
281
+ return out
282
+
283
+
284
+ def _path_exists(project: Path, token: str, repo: bool) -> bool:
285
+ if token.startswith(("~", "/")): return Path(token).expanduser().exists()
286
+ if (project / token).exists() or (project.parent / token).exists(): return True # project.parent: notes such as knowledge-library/ beside the repos
287
+ if not repo: return False
288
+ key = str(project)
289
+ if key not in _SUFFIX_CACHE: _SUFFIX_CACHE[key] = _tracked_suffixes(project)
290
+ return token.strip("/") in _SUFFIX_CACHE[key]
291
+
292
+
293
+ _SUFFIX_CACHE: dict[str, set[str]] = {}
294
+
295
+
296
+ def _evidence(memory: dict) -> tuple[list[dict], list[dict]]:
297
+ """Return (evidence, context): failing mechanical checks, and informational items/check errors that never count."""
298
+ text, out, info = memory["title"] + "\n" + memory["content"], [], []
299
+ age = parse_ts(memory["updated_at"])
300
+ if age and datetime.now(UTC) - age > timedelta(days=3) and _STALE_WORDS.search(text):
301
+ out.append({"ref": "wording", "check": "age", "result": "stale status", "command": "timestamp comparison"})
302
+ if not memory["project"]: return out, info # a global memory has no project directory to check against
303
+ project = Path(memory["project"]).expanduser()
304
+ if not project.is_dir():
305
+ info.append({"ref": str(project), "check": "error", "result": "project directory missing", "command": "filesystem exists"}); return out, info
306
+ scan = _URL.sub(" ", text)
307
+ branch_words = {_clean(word) for word in _BRANCH_WORD.findall(scan)}
308
+ repo, base, git_failed = (project / ".git").exists(), None, False
309
+ def broken(exc):
310
+ nonlocal repo, git_failed
311
+ repo, git_failed = False, True; info.append({"ref": str(project), "check": "error", "result": f"git check failed: {type(exc).__name__}: {_tail(str(exc), 200)}", "command": "git"})
312
+ if repo:
313
+ try:
314
+ if not _git_ok(project, "rev-parse", "--git-dir"): raise _GitError("not a git repository")
315
+ base = _default_branch(project)
316
+ if not base: info.append({"ref": str(project), "check": "error", "result": "no default branch (origin/HEAD, main or master)", "command": "git symbolic-ref"})
317
+ except (_GitError, OSError, subprocess.TimeoutExpired) as exc: broken(exc)
318
+ branches: dict[str, bool] = {word: bool(re.search(r"[/\-_\d]", word)) for word in branch_words}
319
+ for token in dict.fromkeys(_clean(t) for t in _SLASHY.findall(scan)):
320
+ if not token or re.fullmatch(r"[\d/.\-]+", token): continue # dates and ratios such as 10/28
321
+ first, last = token.split("/", 1)[0], token.rsplit("/", 1)[-1]
322
+ if token.startswith(("/", "~/", "./", "../")) or re.search(r"\.[A-Za-z][A-Za-z0-9]{0,7}$", last) or (project / first).exists(): kind = "path"
323
+ elif _BRANCH_PREFIX.match(token) or token in branch_words: kind = "branch"
324
+ else: kind = "maybe-branch" # "and/or", "Q3/Q4": only checked if it is a real ref
325
+ if kind == "path":
326
+ if token.startswith("/") and not re.search(r"\.[A-Za-z][A-Za-z0-9]{0,7}$", last) and not Path("/" + token.split("/")[1]).exists(): continue # a route such as /v1/extract
327
+ try: exists = _path_exists(project, token, repo)
328
+ except (_GitError, OSError, subprocess.TimeoutExpired) as exc: broken(exc); exists = False
329
+ if exists: continue
330
+ # fix(2026-10-28): with git broken a repo-relative path cannot be checked against tracked files, so it is context, never "missing" evidence.
331
+ if git_failed and not token.startswith(("~", "/")):
332
+ info.append({"ref": token, "check": "path", "result": "unverifiable: not on disk and git failed", "command": "filesystem exists"})
333
+ else:
334
+ out.append({"ref": token, "check": "path", "result": "missing", "command": "filesystem exists, git ls-files suffix, project parent"})
335
+ else: branches.setdefault(token, kind == "branch")
336
+ if repo:
337
+ try:
338
+ for branch, strong in branches.items():
339
+ found, context = _branch_check(project, branch, base, strong)
340
+ if found: out.append(found)
341
+ if context: info.append(context)
342
+ for commit in dict.fromkeys(_COMMIT.findall(scan)):
343
+ if not _git_ok(project, "rev-parse", "--verify", "--quiet", "--end-of-options", commit + "^{commit}"):
344
+ out.append({"ref": commit, "check": "commit", "result": "missing", "command": "git rev-parse --verify " + commit}); continue
345
+ newer = _git(project, "log", "-1", "--format=%h", "--end-of-options", commit + "..HEAD")
346
+ if newer.returncode == 0 and newer.stdout.strip():
347
+ # Context only: some later commit on HEAD is nearly always true and says nothing about the memory being stale.
348
+ info.append({"ref": commit, "check": "commit", "result": "newer commit exists", "command": f"git log -1 {commit}..HEAD", "commit": newer.stdout.strip()})
349
+ except (_GitError, OSError, subprocess.TimeoutExpired) as exc: broken(exc)
350
+ return out, info
351
+
352
+
353
+ # ---------------------------------------------------------------- guardrails
354
+
355
+ def _protected(memory: dict, cfg: dict) -> bool:
356
+ return memory["kind"] in ALWAYS_PROTECTED | set(cfg["protected_kinds"]) or "no-auto" in re.split(r"[\s,]+", (memory["tags"] or "").lower()) or any(line.startswith("GUARD:") for line in memory.get("content", "").splitlines())
357
+ def _superseded(conn, memory_id: int) -> bool: return bool(conn.execute("SELECT 1 FROM memory_links WHERE to_id=? AND type='supersedes'", (memory_id,)).fetchone())
358
+
359
+
360
+ def _applied_within(conn, memory_id: int, window: timedelta, column: str) -> bool:
361
+ """Applied automatic changes only: a dismissed flag is not a revert."""
362
+ cutoff = datetime.now(UTC) - window
363
+ return any((stamp := parse_ts(row[0])) and stamp > cutoff for row in conn.execute(f"SELECT {column} FROM maintenance_changes WHERE applied=1 AND (memory_id=? OR target_id=?)", (memory_id, memory_id)))
364
+
365
+
366
+ def _auto_edited_after(conn, memory: dict) -> bool:
367
+ updated = parse_ts(memory["updated_at"])
368
+ return bool(updated and any((stamp := parse_ts(row[0])) and stamp >= updated for row in conn.execute("SELECT created_at FROM maintenance_changes WHERE memory_id=? AND applied=1", (memory["id"],))))
369
+
370
+
371
+ def _mentions(text: str, ref: str) -> bool: return bool(ref) and bool(re.search(r"(?<![\w/.-])" + re.escape(ref) + r"(?![\w/-])(?!\.\w)", text))
372
+ def _tokens(text: str) -> set[str]: return {token.rstrip(".,;:") for token in TOKENS.findall(text)} - {""}
373
+ def _cited(proposal: dict, ctx: dict) -> set[str]:
374
+ return {ref for item in proposal.get("evidence") or [] if isinstance(item, dict) and isinstance(ref := item.get("ref"), str)
375
+ and (m := re.fullmatch(r"transcript:(\d+)", ref.strip())) and m.group(1) in ctx["citations"]}
376
+
377
+
378
+ def _blocked(memory: dict, proposal: dict, ctx: dict, conn) -> tuple[str | None, dict | None]:
379
+ """Return (guardrail name or None, target row). ctx holds only code-produced facts; nothing the model says counts as evidence."""
380
+ cfg, action = ctx["cfg"], proposal.get("action")
381
+ if action not in ACTIONS: return "action", None
382
+ if action == "flag": return None, None
383
+ target = None
384
+ if action in {"merge", "supersede"}:
385
+ target_id = proposal.get("target_id")
386
+ if not isinstance(target_id, int) or isinstance(target_id, bool) or target_id == memory["id"] or target_id not in ctx["partners"] or target_id in ctx["handled"]: return "target", None
387
+ row = conn.execute("SELECT * FROM memories WHERE id=?", (target_id,)).fetchone()
388
+ if not row or row["project"] != memory["project"] or _superseded(conn, target_id): return "target", None
389
+ target = dict(row)
390
+ if _protected(memory, cfg) or (target and _protected(target, cfg)): return "protected", target # every writing action, keep included
391
+ for changed in (memory, target):
392
+ if not changed: continue
393
+ if changed["id"] in ctx["handled"] or _applied_within(conn, changed["id"], PER_MEMORY, "created_at"): return "per_memory_limit", target
394
+ if _applied_within(conn, changed["id"], REVERT_SKIP, "reverted_at"): return "recent_revert", target
395
+ edited = parse_ts(changed["updated_at"])
396
+ if edited and datetime.now(UTC) - edited < USER_EDIT_GRACE and not _auto_edited_after(conn, changed): return "recent_edit", target
397
+ if action == "keep": return (None if ctx["age_only"] else "keep_reason"), target
398
+ # "relative-date" and "wording" only say some words are present; they never justify dropping a token.
399
+ refs = {item["ref"] for item in ctx["evidence"] if item["check"] not in ("relative-date", "age")} | _cited(proposal, ctx)
400
+ if not ctx["evidence"] and not _cited(proposal, ctx) and not (target and target["id"] in ctx["duplicates"]): return "evidence", target
401
+ if action == "supersede": return None, target
402
+ content, title = proposal.get("new_content"), proposal.get("new_title")
403
+ if not isinstance(content, str) or not content.strip(): return "content", target
404
+ if title is not None and (not isinstance(title, str) or not title.strip() or len(title) > 200): return "content", target
405
+ try: validate_content(content)
406
+ except ValueError: return "size", target
407
+ try: validate_review_after(proposal.get("new_review_after"))
408
+ except ValueError: return "review_after", target
409
+ old = memory["title"] + "\n" + memory["content"] + ("\n" + target["title"] + "\n" + target["content"] if target else "")
410
+ if len(content) < (len(memory["content"]) + (len(target["content"]) if target else 0)) * (1 - float(cfg["max_shrink"])): return "shrink", target
411
+ dropped = {d.get("token"): d.get("reason") for d in proposal.get("dropped") or [] if isinstance(d, dict)}
412
+ for token in _tokens(old) - _tokens(content + "\n" + (title or memory["title"])):
413
+ reason = dropped.get(token)
414
+ if not isinstance(reason, str) or not any(_mentions(reason, ref) for ref in refs): return "information", target
415
+ return None, target
416
+
417
+
418
+ def _has_secret(proposal: dict) -> bool:
419
+ texts = [proposal.get(key) for key in ("new_title", "new_content", "summary", "new_review_after")]
420
+ for key in ("evidence", "dropped"):
421
+ for item in proposal.get(key) or []: texts += list(item.values()) if isinstance(item, dict) else [item]
422
+ return any(SECRET.search(value if isinstance(value, str) else json.dumps(value, default=str)) for value in texts if value is not None)
423
+
424
+
425
+ # ---------------------------------------------------------------- records and apply
426
+
427
+ def _record(conn, run_id, memory, proposal, evidence, applied=False, blocked=None, revision_id=None, link_id=None, old_review_after=None, new_review_after=None):
428
+ text = lambda key: proposal.get(key) if isinstance(proposal.get(key), str) else None
429
+ target = proposal.get("target_id") if isinstance(proposal.get("target_id"), int) and not isinstance(proposal.get("target_id"), bool) else None
430
+ action = proposal.get("action") if proposal.get("action") in ACTIONS and (applied or blocked == "dry_run") else "flag"
431
+ summary = str(proposal.get("summary") or "flagged")[:500]
432
+ if action == "flag" and proposal.get("action") in ACTIONS - {"flag"}: summary = f"[{proposal['action']}] {summary}"
433
+ return conn.execute("""INSERT INTO maintenance_changes(run_id,memory_id,target_id,action,applied,summary,evidence,old_title,old_content,new_title,new_content,revision_id,link_id,blocked_by,old_review_after,new_review_after,created_at)
434
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (run_id, memory["id"], target, action, int(applied), summary, json.dumps(evidence, default=str), memory["title"], memory["content"],
435
+ text("new_title"), text("new_content"), revision_id, link_id, blocked, old_review_after, new_review_after, now())).lastrowid
436
+
437
+
438
+ def _evidence_for_record(code: list[dict], proposal: dict) -> list[dict]:
439
+ model = [{"ref": str(item.get("ref", ""))[:200], "detail": str(item.get("detail", ""))[:500], "source": "model"} for item in proposal.get("evidence") or [] if isinstance(item, dict)]
440
+ return code + model
441
+
442
+
443
+ def _relative_dates(memory: dict) -> dict | None:
444
+ """Evidence item, never an edit: "today" often means "currently", and updated_at is not when the words were written."""
445
+ found = [word for word in ("today", "yesterday", "this week") if re.search(r"\b" + word + r"\b", memory["content"], re.I)]
446
+ if not found: return None
447
+ return {"ref": "relative-date", "check": "relative-date", "result": "relative date words present: " + ", ".join(found), "command": "text scan"}
448
+
449
+
450
+ def _apply(conn, run_id, memory, target, proposal, evidence) -> None:
451
+ action, revision_id, link_id, new_review = proposal["action"], None, None, None
452
+ if action in {"update", "merge"}:
453
+ before = conn.execute("SELECT COALESCE(MAX(id),0) FROM memory_revisions WHERE memory_id=?", (memory["id"],)).fetchone()[0]
454
+ new_review = validate_review_after(proposal.get("new_review_after"))
455
+ conn.execute("UPDATE memories SET title=?,content=?,review_after=?,updated_at=? WHERE id=?", (proposal.get("new_title") or memory["title"], proposal["new_content"], new_review or memory["review_after"], now(), memory["id"]))
456
+ # Only a revision written by this UPDATE; an older unrelated one would make revert wipe a later user edit.
457
+ revision_id = conn.execute("SELECT MAX(id) FROM memory_revisions WHERE memory_id=? AND id>?", (memory["id"], before)).fetchone()[0]
458
+ if action in {"merge", "supersede"}:
459
+ newer, older = (memory["id"], target["id"]) if action == "merge" else (target["id"], memory["id"])
460
+ link_id = conn.execute("INSERT INTO memory_links(from_id,to_id,type,note,created_at) VALUES(?,?,'supersedes','automatic maintenance',?)", (newer, older, now())).lastrowid
461
+ if action == "keep":
462
+ new_review = (datetime.now(UTC) + timedelta(days=14)).date().isoformat()
463
+ conn.execute("UPDATE memories SET review_after=? WHERE id=?", (new_review, memory["id"]))
464
+ _record(conn, run_id, memory, proposal, evidence, applied=True, revision_id=revision_id, link_id=link_id, old_review_after=memory["review_after"], new_review_after=new_review)
465
+
466
+
467
+ def _skip_ids(conn) -> set[int]:
468
+ """Memories with a recent real flag they have not been edited since (dedupe), or a recent engine error (retry later)."""
469
+ cutoff, out = datetime.now(UTC) - FLAG_SKIP, set()
470
+ for row in conn.execute("""SELECT c.memory_id,c.created_at,c.blocked_by,c.reverted_at,m.updated_at FROM maintenance_changes c JOIN memories m ON m.id=c.memory_id
471
+ WHERE c.action='flag' AND c.applied=0 AND NOT EXISTS(SELECT 1 FROM maintenance_runs r WHERE r.id=c.run_id AND r.status='dry_run')"""):
472
+ flagged, edited = parse_ts(row["created_at"]), parse_ts(row["updated_at"])
473
+ if not flagged or flagged <= cutoff: continue
474
+ if (row["reverted_at"] is None) if row["blocked_by"] == "engine_error" else (not edited or edited <= flagged): out.add(row["memory_id"])
475
+ return out
476
+
477
+
478
+ class _MemoryFailed(Exception):
479
+ def __init__(self, memory_id: int, message: str): super().__init__(message); self.memory_id = memory_id
480
+
481
+
482
+ class EngineRunFailed(RuntimeError):
483
+ """The engine process failed, so the run rather than one memory failed."""
484
+
485
+
486
+ class EngineOutputError(RuntimeError):
487
+ """One engine response could not be used for this memory."""
488
+
489
+
490
+ def _error_text(exc: BaseException) -> str:
491
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)): return f"interrupted ({type(exc).__name__})"
492
+ if isinstance(exc, (_MemoryFailed, EngineUnavailable, EngineRunFailed, EngineOutputError)) or type(exc) is RuntimeError: return _tail(str(exc), 500) # already sanitized by Engine
493
+ return _tail(f"{type(exc).__name__}: {exc}", 500)
494
+
495
+
496
+ def _call_engine(memory_id: int, fn, payload: dict) -> dict:
497
+ try: result = fn(payload)
498
+ except EngineOutputError as exc: raise _MemoryFailed(memory_id, f"memory #{memory_id}: {_error_text(exc)}") from None
499
+ if not isinstance(result, dict): raise _MemoryFailed(memory_id, f"memory #{memory_id}: engine returned a non-object result")
500
+ return result
501
+
502
+
503
+ def _shape_problem(proposal: dict, memory_id: int) -> str | None:
504
+ if proposal.get("memory_id") != memory_id: return "engine proposal has the wrong memory_id"
505
+ if not isinstance(proposal.get("evidence", []), list) or not isinstance(proposal.get("dropped", []), list): return "engine proposal has invalid evidence or dropped lists"
506
+ return None
507
+
508
+
509
+ def _candidates(conn, project) -> tuple[dict[int, dict], dict[int, list[dict]], dict[int, set[int]]]:
510
+ audit_by_id, duplicates, today = {}, {}, datetime.now(UTC).date().isoformat()
511
+ for finding in audit_memories(conn):
512
+ if finding.get("other_id"):
513
+ duplicates.setdefault(finding["id"], set()).add(finding["other_id"]); duplicates.setdefault(finding["other_id"], set()).add(finding["id"])
514
+ audit_by_id.setdefault(finding["id"], []).append(finding)
515
+ where, params = _hidden_sql(), []
516
+ if project: where += " AND m.project=?"; params.append(project)
517
+ rows = {r["id"]: dict(r) for r in conn.execute(f"SELECT m.* FROM memories m WHERE {where}", params)}
518
+ for memory_id, findings in list(audit_by_id.items()):
519
+ memory = rows.get(memory_id)
520
+ # A memory reviewed into the future is not "old" again until that date.
521
+ kept = [f for f in findings if memory and not (f["reason"] == "not updated in 30+ days" and memory["review_after"] and memory["review_after"] >= today)]
522
+ if kept: audit_by_id[memory_id] = kept
523
+ else: del audit_by_id[memory_id]
524
+ selected = {mid: row for mid, row in sorted(rows.items()) if row["kind"] in ("todo", "note") or row["pinned"] or mid in audit_by_id}
525
+ return selected, audit_by_id, duplicates
526
+
527
+
528
+ def _partners(conn, memory: dict, duplicate_ids: set[int]) -> dict[int, dict]:
529
+ linked = {row[0] for row in conn.execute("SELECT CASE WHEN from_id=? THEN to_id ELSE from_id END FROM memory_links WHERE (from_id=? OR to_id=?) AND type IN ('overlaps','relates_to')", (memory["id"],) * 3)}
530
+ ids = sorted((duplicate_ids | linked) - {memory["id"]})
531
+ if not ids: return {}
532
+ rows = conn.execute(f"SELECT m.* FROM memories m WHERE m.id IN ({','.join('?' * len(ids))}) AND m.project IS ? AND {_hidden_sql()}", (*ids, memory["project"]))
533
+ return {row["id"]: dict(row) for row in rows}
534
+
535
+
536
+ def _gather(project, limit, engine, transcript, cfg, stats: dict) -> tuple[list[dict], int]:
537
+ """Collect proposals and verdicts with no write transaction open (engine calls can take minutes)."""
538
+ citations = set(re.findall(r"^transcript:(\d+) ", transcript or "", re.M))
539
+ _SUFFIX_CACHE.clear() # a repo's files change between runs in a long-lived process
540
+ decisions, handled, accepted, deferred = [], set(), 0, 0
541
+ with connection() as conn:
542
+ candidates, audit_by_id, duplicates = _candidates(conn, project); skip = _skip_ids(conn)
543
+ for memory in candidates.values():
544
+ if memory["id"] in handled or memory["id"] in skip or _superseded(conn, memory["id"]): continue
545
+ if _applied_within(conn, memory["id"], PER_MEMORY, "created_at"): continue # the per-memory limit would block any change: don't spend a model call
546
+ if limit and stats["checked"] >= limit: break
547
+ stats["checked"] += 1
548
+ evidence, info = _evidence(memory); audited = audit_by_id.get(memory["id"], [])
549
+ if relative := _relative_dates(memory): evidence = evidence + [relative]
550
+ if not evidence and not audited: continue
551
+ if accepted >= int(cfg["max_changes"]): deferred += 1; continue # no engine calls once the run is full
552
+ partners = _partners(conn, memory, duplicates.get(memory["id"], set()))
553
+ age_only = not any(item["check"] in {"branch", "commit", "path", "relative-date"} for item in evidence) and all(item["reason"] in AGE_REASONS for item in audited)
554
+ code_evidence = evidence + [{"ref": "audit", "check": "audit", "result": item["reason"], "command": "audit_memories"} for item in audited] + info
555
+ payload = {"memory": {**memory, "protected": _protected(memory, cfg)}, "partners": [{**p, "protected": _protected(p, cfg)} for p in partners.values()],
556
+ "evidence": evidence, "context": code_evidence[len(evidence):], "transcript": transcript}
557
+ proposal = _call_engine(memory["id"], engine.proposal, payload)
558
+ if problem := _shape_problem(proposal, memory["id"]): raise _MemoryFailed(memory["id"], f"memory #{memory['id']}: {problem}")
559
+ decision = {"memory": memory, "proposal": proposal, "evidence": code_evidence, "target": None, "blocked": None,
560
+ "ctx": {"cfg": cfg, "evidence": evidence, "citations": citations, "partners": partners, "duplicates": duplicates.get(memory["id"], set()), "handled": set(handled), "age_only": age_only}}
561
+ if _has_secret(proposal): # first: a "none" proposal is stored too and must not carry a secret either
562
+ decision.update(blocked="secret", proposal={"action": "flag", "memory_id": memory["id"], "summary": "proposal dropped: it contained secret-like text"}); decisions.append(decision); continue
563
+ if proposal.get("action") == "none":
564
+ # Stored as a fixed summary with no new text: nobody sees the row, so nothing model-written is kept in it.
565
+ decisions.append({**decision, "blocked": "no_change", "proposal": {"action": "none", "memory_id": memory["id"], "summary": NO_CHANGE_SUMMARY}}); continue
566
+ decision["blocked"], decision["target"] = _blocked(memory, proposal, decision["ctx"], conn)
567
+ if not decision["blocked"] and proposal["action"] in {"update", "merge", "supersede"}:
568
+ target = decision["target"]; join = lambda *rows: "\n\n".join(r["title"] + "\n" + r["content"] for r in rows if r)
569
+ old_text, new_text = (join(memory), join(target)) if proposal["action"] == "supersede" else (join(memory, target), (proposal.get("new_title") or memory["title"]) + "\n" + proposal["new_content"])
570
+ cited = [line for line in (transcript or "").splitlines() if any(line.startswith(ref + " ") for ref in _cited(proposal, decision["ctx"]))]
571
+ verdict = _call_engine(memory["id"], engine.verify, {"memory": {"project": memory["project"]}, "old_text": old_text, "new_text": new_text, "mechanical_evidence": evidence,
572
+ "model_evidence": proposal.get("evidence") or [], "dropped": proposal.get("dropped") or [], "transcript_lines": cited})
573
+ if verdict.get("ok") is not True: decision["blocked"] = "verifier"
574
+ decisions.append(decision)
575
+ if not decision["blocked"] and proposal["action"] != "flag":
576
+ accepted += 1; handled.add(memory["id"])
577
+ if decision["target"]: handled.add(decision["target"]["id"])
578
+ return decisions, deferred
579
+
580
+
581
+ def _apply_all(run_id, decisions, dry_run, cfg, checked, note) -> dict:
582
+ """Every write of the run in one short IMMEDIATE transaction, re-checking guards against fresh rows."""
583
+ applied = flagged = proposed = unchanged = 0; done: set[int] = set(); preview = []
584
+ with connection() as conn:
585
+ conn.execute("BEGIN IMMEDIATE")
586
+ try:
587
+ for decision in decisions:
588
+ row = conn.execute("SELECT * FROM memories WHERE id=?", (decision["memory"]["id"],)).fetchone()
589
+ if not row: continue # deleted while the run was thinking
590
+ memory, proposal, blocked, target = dict(row), decision["proposal"], decision["blocked"], decision["target"]
591
+ if not blocked and proposal["action"] != "flag":
592
+ fresh_target = target and conn.execute("SELECT * FROM memories WHERE id=?", (target["id"],)).fetchone()
593
+ if memory["updated_at"] != decision["memory"]["updated_at"] or (target and (not fresh_target or fresh_target["updated_at"] != target["updated_at"])): blocked = "recent_edit"
594
+ else: blocked, target = _blocked(memory, proposal, {**decision["ctx"], "handled": done}, conn)
595
+ if not blocked and applied + proposed >= int(cfg["max_changes"]): blocked = "max_changes"
596
+ if blocked == "no_change":
597
+ change_id = _record(conn, run_id, memory, {**proposal, "action": "flag"}, decision["evidence"], blocked="no_change"); unchanged += 1
598
+ conn.execute("UPDATE maintenance_changes SET reverted_at=?, seen_at=? WHERE id=?", (now(), now(), change_id)) # closed at once: nothing for the user to see
599
+ elif blocked or proposal["action"] == "flag":
600
+ if not dry_run: conn.execute("UPDATE maintenance_changes SET reverted_at=? WHERE memory_id=? AND action='flag' AND applied=0 AND reverted_at IS NULL", (now(), memory["id"])) # one open flag per memory
601
+ evidence = decision["evidence"] if blocked == "secret" else _evidence_for_record(decision["evidence"], proposal)
602
+ _record(conn, run_id, memory, proposal, evidence, blocked=blocked or "flag"); flagged += 1
603
+ else:
604
+ if dry_run: _record(conn, run_id, memory, proposal, _evidence_for_record(decision["evidence"], proposal), blocked="dry_run"); proposed += 1
605
+ else: _apply(conn, run_id, memory, target, proposal, _evidence_for_record(decision["evidence"], proposal)); applied += 1
606
+ done.update({memory["id"]} | ({target["id"]} if target else set()))
607
+ preview.append({"memory_id": memory["id"], "action": proposal.get("action"), "target_id": proposal.get("target_id"), "blocked_by": blocked or ("flag" if proposal.get("action") == "flag" else None),
608
+ "summary": proposal.get("summary"), "new_title": proposal.get("new_title") if blocked != "secret" else None, "new_content": proposal.get("new_content") if blocked != "secret" else None})
609
+ status = "dry_run" if dry_run else "success"
610
+ conn.execute("UPDATE maintenance_runs SET finished_at=?,status=?,checked=?,applied=?,flagged=?,note=? WHERE id=?", (now(), status, checked, applied, flagged, note, run_id))
611
+ conn.commit()
612
+ except BaseException:
613
+ conn.rollback(); raise
614
+ result = {"status": status, "checked": checked, "applied": applied, "flagged": flagged, "unchanged": unchanged, "run_id": run_id}
615
+ if dry_run: result.update(proposed=proposed, changes=preview)
616
+ if note: result["note"] = note
617
+ return result
618
+
619
+
620
+ def _run_locked(trigger, project, dry_run, limit, engine, transcript_path, cfg) -> dict:
621
+ full = int(project is None and not limit and not dry_run)
622
+ with connection() as conn:
623
+ run_id = conn.execute("INSERT INTO maintenance_runs(trigger,project,started_at,status,engine,model,full) VALUES(?,?,?,?,?,?,?)", (trigger, project, now(), "running", engine.name, engine.model, full)).lastrowid
624
+ stats = {"checked": 0}
625
+ try:
626
+ transcript, note = read_transcript(transcript_path) if transcript_path and cfg["use_transcripts"] else (None, None)
627
+ decisions, deferred = _gather(project, limit, engine, transcript, cfg, stats)
628
+ if deferred: note = "; ".join(filter(None, [note, f"max_changes reached: {deferred} memories left for the next run"]))
629
+ return _apply_all(run_id, decisions, dry_run, cfg, stats["checked"], note)
630
+ except BaseException as exc:
631
+ message = _error_text(exc)
632
+ if isinstance(exc, EngineUnavailable): message = "engine unavailable: " + message
633
+ with connection() as conn:
634
+ conn.execute("UPDATE maintenance_runs SET finished_at=?,status='error',checked=?,applied=0,flagged=0,error=? WHERE id=?", (now(), stats["checked"], message, run_id))
635
+ if isinstance(exc, _MemoryFailed) and not dry_run: # remembered so the next run skips it instead of failing again
636
+ row = conn.execute("SELECT * FROM memories WHERE id=?", (exc.memory_id,)).fetchone()
637
+ if row:
638
+ conn.execute("UPDATE maintenance_changes SET reverted_at=? WHERE memory_id=? AND action='flag' AND applied=0 AND reverted_at IS NULL", (now(), exc.memory_id))
639
+ _record(conn, run_id, dict(row), {"action": "flag", "summary": "engine failed on this memory; skipped for 7 days or until dismissed"}, [], blocked="engine_error")
640
+ if not isinstance(exc, Exception): raise
641
+ return {"status": "error", "checked": stats["checked"], "applied": 0, "flagged": 0, "error": message, "run_id": run_id}
642
+
643
+
644
+ def _failed_run(trigger, project, cfg, message) -> dict:
645
+ with connection() as conn:
646
+ run_id = conn.execute("INSERT INTO maintenance_runs(trigger,project,started_at,finished_at,status,engine,model,error) VALUES(?,?,?,?,?,?,?,?)", (trigger, project, now(), now(), "error", cfg["engine"], cfg["model"], message)).lastrowid
647
+ return {"status": "error", "checked": 0, "applied": 0, "flagged": 0, "error": message, "run_id": run_id}
648
+
649
+
650
+ def _request(trigger, project, dry_run, limit, transcript_path, cfg) -> dict:
651
+ """A run is active: remember one real request per (trigger, project). Dry or limited runs are never queued."""
652
+ result = {"status": "locked", "checked": 0, "applied": 0, "flagged": 0}
653
+ if dry_run or limit: return result
654
+ options = json.dumps({"transcript": transcript_path}) if transcript_path else None
655
+ with connection() as conn:
656
+ existing = conn.execute("SELECT id FROM maintenance_runs WHERE status='requested' AND trigger=? AND project IS ?", (trigger, project)).fetchone()
657
+ if existing:
658
+ if options: conn.execute("UPDATE maintenance_runs SET options=? WHERE id=?", (options, existing["id"]))
659
+ else: conn.execute("INSERT INTO maintenance_runs(trigger,project,started_at,status,engine,model,options) VALUES(?,?,?,?,?,?,?)", (trigger, project, now(), "requested", cfg["engine"], cfg["model"], options))
660
+ return {**result, "queued": True}
661
+
662
+
663
+ def _drain(engine, cfg) -> None:
664
+ for _ in range(20): # bounded: requests arriving forever must not keep one process alive forever
665
+ with connection() as conn: pending = conn.execute("SELECT * FROM maintenance_runs WHERE status='requested' ORDER BY id LIMIT 1").fetchone()
666
+ if not pending: return
667
+ if pending["trigger"] == "startup" and not due_for_startup(): status = "skipped"
668
+ else:
669
+ try: options = json.loads(pending["options"] or "{}")
670
+ except ValueError: options = {}
671
+ _run_locked(pending["trigger"], pending["project"], False, None, engine, options.get("transcript"), cfg); status = "done"
672
+ with connection() as conn: conn.execute("UPDATE maintenance_runs SET status=?,finished_at=? WHERE id=? AND status='requested'", (status, now(), pending["id"]))
673
+
674
+
675
+ def run(trigger="manual", project=None, dry_run=False, limit=None, engine=None, transcript_path=None) -> dict:
676
+ if trigger not in TRIGGERS: raise ValueError("invalid maintenance trigger")
677
+ cfg = config()
678
+ if not dry_run: prune_messages() # fix(2026-10-28): also when maintenance is disabled or misconfigured
679
+ if cfg.get("error"): return _failed_run(trigger, project, cfg, cfg["error"])
680
+ if not cfg["enabled"]: return {"status": "disabled", "checked": 0, "applied": 0, "flagged": 0}
681
+ try:
682
+ with Lock(lock_path()) as lock:
683
+ if not lock.acquired: return _request(trigger, project, dry_run, limit, transcript_path, cfg)
684
+ with connection() as conn: # we hold the lock, so any 'running' row belongs to a process that died
685
+ conn.execute("UPDATE maintenance_runs SET status='error',finished_at=?,error='interrupted: the process ended before the run finished' WHERE status='running'", (now(),))
686
+ engine = engine or Engine(cfg["engine"], cfg["model"])
687
+ result = _run_locked(trigger, project, dry_run, limit, engine, transcript_path, cfg)
688
+ if not dry_run: _drain(engine, cfg) # queued requests are real runs; a dry run must change nothing
689
+ return result
690
+ except OSError as exc:
691
+ return _failed_run(trigger, project, cfg, f"maintenance lock unavailable: {type(exc).__name__}: {exc}")
692
+
693
+
694
+ def _cleanup_reason(conn, flag: dict) -> str | None:
695
+ memory = conn.execute("SELECT * FROM memories WHERE id=?", (flag["memory_id"],)).fetchone()
696
+ if not memory: return "memory deleted"
697
+ memory = dict(memory)
698
+ if _superseded(conn, memory["id"]): return "memory superseded"
699
+ flagged = parse_ts(flag["created_at"])
700
+ edited = parse_ts(memory["updated_at"])
701
+ if flagged and edited and edited > flagged: return "memory edited"
702
+ if flag["blocked_by"] in {"protected", "secret", "engine_error"}: return None
703
+ try: stored = json.loads(flag["evidence"] or "[]")
704
+ except ValueError: stored = []
705
+ refs = {(item.get("ref"), item.get("check")) for item in stored if isinstance(item, dict)}
706
+ if not refs or any(not ref or check not in {"path", "branch", "commit", "age"} for ref, check in refs): return None
707
+ current, info = _evidence(memory)
708
+ failing = {(item.get("ref"), item.get("check")) for item in current}
709
+ unverifiable = {(item.get("ref"), item.get("check")) for item in info}
710
+ if not any(item.get("check") == "error" for item in info) and not refs & failing and not refs & unverifiable: return "evidence no longer holds"
711
+ return None
712
+
713
+
714
+ def cleanup(dry_run: bool = False) -> dict:
715
+ """Close resolved flags and discard old maintenance bookkeeping without running an engine."""
716
+ try:
717
+ _SUFFIX_CACHE.clear()
718
+ with Lock(lock_path()) as lock:
719
+ if not lock.acquired: return {"status": "locked"}
720
+ with connection() as conn:
721
+ conn.execute("BEGIN IMMEDIATE")
722
+ try:
723
+ flags = [dict(row) for row in conn.execute(f"SELECT c.* FROM maintenance_changes c WHERE {OPEN_FLAG_SQL}")]
724
+ reasons: dict[str, int] = {}
725
+ for flag in flags:
726
+ reason = _cleanup_reason(conn, flag)
727
+ if not reason: continue
728
+ reasons[reason] = reasons.get(reason, 0) + 1
729
+ if not dry_run:
730
+ summary = (flag["summary"] + " · closed: " + reason)[:500]
731
+ conn.execute("UPDATE maintenance_changes SET reverted_at=?,summary=? WHERE id=?", (now(), summary, flag["id"]))
732
+ old_day, old_week, old_month = "-1 day", "-7 days", "-30 days"
733
+ dry_runs = [row[0] for row in conn.execute("SELECT id FROM maintenance_runs WHERE status='dry_run' AND datetime(COALESCE(finished_at,started_at)) < datetime('now',?)", (old_day,))]
734
+ book_runs = [row[0] for row in conn.execute("SELECT id FROM maintenance_runs WHERE status IN ('requested','done','skipped') AND datetime(COALESCE(finished_at,started_at)) < datetime('now',?)", (old_day,))]
735
+ error_runs = [row[0] for row in conn.execute("""SELECT e.id FROM maintenance_runs e WHERE e.status='error'
736
+ AND datetime(COALESCE(e.finished_at,e.started_at)) < datetime('now',?) AND EXISTS(SELECT 1 FROM maintenance_runs s
737
+ WHERE s.status='success' AND datetime(COALESCE(s.finished_at,s.started_at)) > datetime(COALESCE(e.finished_at,e.started_at)))""", (old_week,))]
738
+ closed = conn.execute("""SELECT COUNT(*) FROM maintenance_changes WHERE datetime(created_at) < datetime('now',?) AND
739
+ ((action='flag' AND applied=0 AND reverted_at IS NOT NULL) OR (applied=1 AND reverted_at IS NOT NULL) OR blocked_by='no_change')""", (old_month,)).fetchone()[0]
740
+ if not dry_run:
741
+ run_ids = dry_runs + book_runs + error_runs
742
+ if run_ids:
743
+ marks = ",".join("?" * len(run_ids))
744
+ conn.execute(f"DELETE FROM maintenance_changes WHERE run_id IN ({marks})", run_ids)
745
+ conn.execute(f"DELETE FROM maintenance_runs WHERE id IN ({marks})", run_ids)
746
+ conn.execute("""DELETE FROM maintenance_changes WHERE datetime(created_at) < datetime('now',?) AND
747
+ ((action='flag' AND applied=0 AND reverted_at IS NOT NULL) OR (applied=1 AND reverted_at IS NOT NULL) OR blocked_by='no_change')""", (old_month,))
748
+ conn.commit()
749
+ except BaseException:
750
+ conn.rollback(); raise
751
+ except OSError:
752
+ return {"status": "locked"}
753
+ closed_flags = sum(reasons.values())
754
+ detail = ", ".join(f"{reason}: {count}" for reason, count in reasons.items())
755
+ line = f"closed {closed_flags} flags" + (f" ({detail})" if detail else "")
756
+ line += f" · deleted {len(dry_runs)} dry-run runs · deleted {len(book_runs)} bookkeeping runs · deleted {len(error_runs)} error runs · deleted {closed} closed rows"
757
+ return {"status": "dry_run" if dry_run else "ok", "line": line, "closed_flags": closed_flags, "dry_run_runs": len(dry_runs),
758
+ "bookkeeping_runs": len(book_runs), "error_runs": len(error_runs), "closed_rows": closed}
759
+
760
+
761
+ def prune_messages() -> int:
762
+ """Delete old closed message threads (and their subagents' timelines); never fails a run."""
763
+ from .messages import prune # local: messages imports SECRET from here
764
+ try:
765
+ with connection() as conn: return prune(conn)
766
+ except sqlite3.Error as exc:
767
+ logging.getLogger("devmem.maintenance").warning("message prune failed: %s: %s", type(exc).__name__, exc); return 0
768
+
769
+
770
+ def revert(change_id: int) -> bool:
771
+ """Undo one applied change. False when there is nothing to revert; ValueError when reverting would destroy a later edit."""
772
+ with connection() as conn:
773
+ conn.execute("BEGIN IMMEDIATE")
774
+ change = conn.execute("SELECT * FROM maintenance_changes WHERE id=? AND applied=1 AND reverted_at IS NULL", (change_id,)).fetchone()
775
+ if not change: return False
776
+ memory = conn.execute("SELECT * FROM memories WHERE id=?", (change["memory_id"],)).fetchone()
777
+ if change["action"] in {"update", "merge", "keep"}:
778
+ if not memory: raise ValueError("the memory no longer exists; restore it from its history instead")
779
+ edited, applied_at = parse_ts(memory["updated_at"]), parse_ts(change["created_at"])
780
+ if edited and applied_at and edited > applied_at: raise ValueError("the memory was edited after this change; use its history to restore an older version")
781
+ if change["revision_id"]: restore_memory(conn, change["memory_id"], change["revision_id"], "maintenance")
782
+ if change["action"] in {"update", "merge", "keep"} and change["new_review_after"] is not None:
783
+ conn.execute("UPDATE memories SET review_after=? WHERE id=?", (change["old_review_after"], change["memory_id"]))
784
+ if change["link_id"]:
785
+ newer, older = (change["memory_id"], change["target_id"]) if change["action"] == "merge" else (change["target_id"], change["memory_id"])
786
+ conn.execute("DELETE FROM memory_links WHERE id=? AND from_id=? AND to_id=? AND type='supersedes'", (change["link_id"], newer, older))
787
+ conn.execute("UPDATE maintenance_changes SET reverted_at=? WHERE id=?", (now(), change_id)); return True
788
+
789
+
790
+ def dismiss(change_id: int) -> bool:
791
+ """Close one open flag; anything else (applied, dry-run, already closed) is not dismissable."""
792
+ with connection() as conn:
793
+ return bool(conn.execute(f"UPDATE maintenance_changes AS c SET reverted_at=? WHERE c.id=? AND {OPEN_FLAG_SQL}", (now(), change_id)).rowcount)
794
+
795
+
796
+ def attention(conn) -> dict:
797
+ """Badge state: unseen applied changes and unseen open flags, plus a failing-runs marker."""
798
+ try:
799
+ unseen = conn.execute(f"SELECT COUNT(*) FROM maintenance_changes c WHERE c.seen_at IS NULL AND ((c.applied=1 AND c.reverted_at IS NULL) OR ({OPEN_FLAG_SQL}))").fetchone()[0]
800
+ except sqlite3.OperationalError: return {"unseen": 0, "failing_since": None}
801
+ return {"unseen": unseen, "failing_since": maintenance_failing_since(conn)}