Skip to content

Commit c599a30

Browse files
Thezenmonsterclaude
andcommitted
v0.2.0: Truth governance engine
Add memory lifecycle states (hypothesis -> active -> validated -> deprecated -> superseded), provenance tracking (source_path, source_hash, source_section), conflict detection, staleness detection, health scoring, and trust-ranked recall. Core changes: - Schema v2 with backward-compatible migration - promote(), deprecate(), supersede() lifecycle methods - governance.py: conflict detection (duplicate vs contradiction), staleness, health check - Trust-ranked recall: validated > active > hypothesis, provenance-weighted - Search no longer bumps access_count (fixes self-reinforcing popularity bias) - Deprecated/superseded memories excluded from search results - MCP server: 13 tools including promote, deprecate, supersede, health, conflicts - CLI: health, conflicts, stale, promote, deprecate commands - 59 tests passing Positioning: governed memory for long-lived coding agents. Published to PyPI as quilmem 0.2.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a86faa8 commit c599a30

File tree

12 files changed

+1397
-227
lines changed

12 files changed

+1397
-227
lines changed

README.md

Lines changed: 202 additions & 181 deletions
Large diffs are not rendered by default.

agentmem/__init__.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
"""agentmem — Production-ready agent memory. SQLite + FTS5. No infrastructure."""
1+
"""agentmem — Trusted memory for long-lived coding agents. SQLite + FTS5. No infrastructure."""
22

33
from .core import Memory
4+
from .governance import detect_conflicts, detect_stale, health_check, hash_content, hash_file_section
45
from .importer import import_markdown
5-
from .models import MEMORY_TYPES, MemoryRecord
6+
from .models import MEMORY_TYPES, MEMORY_STATUSES, MemoryRecord
67

7-
__version__ = "0.1.0"
8-
__all__ = ["Memory", "MemoryRecord", "MEMORY_TYPES", "import_markdown"]
8+
__version__ = "0.2.0"
9+
__all__ = [
10+
"Memory", "MemoryRecord", "MEMORY_TYPES", "MEMORY_STATUSES",
11+
"import_markdown",
12+
"detect_conflicts", "detect_stale", "health_check",
13+
"hash_content", "hash_file_section",
14+
]

agentmem/cli.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import click
99

1010
from .core import Memory
11-
from .models import MEMORY_TYPES
11+
from .models import MEMORY_TYPES, MEMORY_STATUSES
1212

1313

1414
def _get_mem(ctx: click.Context) -> Memory:
@@ -212,6 +212,128 @@ def load_session(ctx):
212212
mem.close()
213213

214214

215+
@main.command()
216+
@click.argument("id")
217+
@click.pass_context
218+
def promote(ctx, id):
219+
"""Promote a memory: hypothesis → active → validated."""
220+
mem = _get_mem(ctx)
221+
record = mem.promote(id)
222+
if record:
223+
click.echo(f"Promoted: {record.id} -> {record.status}")
224+
click.echo(f" [{record.type}] {record.title}")
225+
else:
226+
click.echo(f"Not found: {id}", err=True)
227+
sys.exit(1)
228+
mem.close()
229+
230+
231+
@main.command()
232+
@click.argument("id")
233+
@click.option("--reason", default="", help="Why this memory is deprecated.")
234+
@click.pass_context
235+
def deprecate(ctx, id, reason):
236+
"""Mark a memory as deprecated. Excluded from recall, kept for history."""
237+
mem = _get_mem(ctx)
238+
record = mem.deprecate(id, reason=reason)
239+
if record:
240+
click.echo(f"Deprecated: {record.id}")
241+
click.echo(f" [{record.type}] {record.title}")
242+
if reason:
243+
click.echo(f" Reason: {reason}")
244+
else:
245+
click.echo(f"Not found: {id}", err=True)
246+
sys.exit(1)
247+
mem.close()
248+
249+
250+
@main.command()
251+
@click.pass_context
252+
def conflicts(ctx):
253+
"""Detect contradictions between active memories."""
254+
from .governance import detect_conflicts
255+
mem = _get_mem(ctx)
256+
found = detect_conflicts(mem._conn, project=ctx.obj.get("project", ""))
257+
258+
if not found:
259+
click.echo("No conflicts detected.")
260+
else:
261+
click.echo(f"Found {len(found)} potential conflict(s):\n")
262+
for i, c in enumerate(found, 1):
263+
icon = "!!" if c.severity == "critical" else "?"
264+
click.echo(f" {icon} Conflict {i} ({c.severity})")
265+
click.echo(f" A: [{c.memory_a.type}] {c.memory_a.title}")
266+
click.echo(f" id: {c.memory_a.id} status: {c.memory_a.status}")
267+
click.echo(f" B: [{c.memory_b.type}] {c.memory_b.title}")
268+
click.echo(f" id: {c.memory_b.id} status: {c.memory_b.status}")
269+
click.echo(f" Reason: {c.reason}")
270+
click.echo()
271+
mem.close()
272+
273+
274+
@main.command()
275+
@click.option("--days", default=30, help="Days without update to consider stale.")
276+
@click.pass_context
277+
def stale(ctx, days):
278+
"""Find memories that may be outdated."""
279+
from .governance import detect_stale
280+
mem = _get_mem(ctx)
281+
found = detect_stale(mem._conn, project=ctx.obj.get("project", ""), stale_days=days)
282+
283+
if not found:
284+
click.echo(f"No stale memories (threshold: {days} days).")
285+
else:
286+
click.echo(f"Found {len(found)} stale memory/memories (>{days} days):\n")
287+
for s in found:
288+
click.echo(f" [{s.memory.type}] {s.memory.title}")
289+
click.echo(f" id: {s.memory.id} status: {s.memory.status}")
290+
click.echo(f" Last updated: {s.days_since_update} days ago")
291+
click.echo(f" Reason: {s.reason}")
292+
click.echo()
293+
mem.close()
294+
295+
296+
@main.command()
297+
@click.option("--days", default=30, help="Stale threshold in days.")
298+
@click.pass_context
299+
def health(ctx, days):
300+
"""Run a full health check on the memory system."""
301+
from .governance import health_check
302+
mem = _get_mem(ctx)
303+
report = health_check(mem._conn, project=ctx.obj.get("project", ""), stale_days=days)
304+
305+
click.echo(f"{'=' * 50}")
306+
click.echo(f"MEMORY HEALTH: {report.health_score:.0f}/100")
307+
click.echo(f"{'=' * 50}")
308+
click.echo(f" Total memories: {report.total_memories}")
309+
click.echo(f" By status:")
310+
for status in ("validated", "active", "hypothesis", "deprecated", "superseded"):
311+
count = report.by_status.get(status, 0)
312+
if count:
313+
click.echo(f" {status}: {count}")
314+
click.echo(f" Never accessed: {report.never_accessed}")
315+
click.echo()
316+
317+
if report.conflicts:
318+
click.echo(f" CONFLICTS: {len(report.conflicts)}")
319+
for c in report.conflicts:
320+
icon = "!!" if c.severity == "critical" else "?"
321+
click.echo(f" {icon} {c.memory_a.title[:40]} vs {c.memory_b.title[:40]}")
322+
323+
if report.stale:
324+
click.echo(f" STALE: {len(report.stale)}")
325+
for s in report.stale[:5]:
326+
click.echo(f" {s.memory.title[:50]} ({s.days_since_update}d)")
327+
if len(report.stale) > 5:
328+
click.echo(f" ... and {len(report.stale) - 5} more")
329+
330+
if report.orphaned_supersedes:
331+
click.echo(f" ORPHANED: {len(report.orphaned_supersedes)}")
332+
333+
click.echo(f"\n{'=' * 50}")
334+
mem.close()
335+
336+
215337
@main.command()
216338
@click.pass_context
217339
def serve(ctx):

agentmem/core.py

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,22 @@
77
from datetime import datetime, timezone
88
from pathlib import Path
99

10-
from .models import MEMORY_TYPES, MemoryRecord
10+
from .models import MEMORY_TYPES, MEMORY_STATUSES, MemoryRecord
1111
from .schema import init_db
1212

1313

1414
def _now() -> str:
1515
return datetime.now(timezone.utc).isoformat()
1616

1717

18+
def _safe_get(row, key, default=""):
19+
"""Safely get a column value, returning default if column doesn't exist."""
20+
try:
21+
return row[key]
22+
except (IndexError, KeyError):
23+
return default
24+
25+
1826
def _row_to_record(row: sqlite3.Row, rank: float | None = None) -> MemoryRecord:
1927
return MemoryRecord(
2028
id=row["id"],
@@ -26,6 +34,13 @@ def _row_to_record(row: sqlite3.Row, rank: float | None = None) -> MemoryRecord:
2634
project=row["project"],
2735
confidence=row["confidence"],
2836
supersedes=row["supersedes"],
37+
status=_safe_get(row, "status", "active"),
38+
source_path=_safe_get(row, "source_path", ""),
39+
source_section=_safe_get(row, "source_section", ""),
40+
source_hash=_safe_get(row, "source_hash", ""),
41+
validated_at=_safe_get(row, "validated_at", ""),
42+
deprecated_at=_safe_get(row, "deprecated_at", ""),
43+
superseded_by=_safe_get(row, "superseded_by", ""),
2944
created_at=row["created_at"],
3045
updated_at=row["updated_at"],
3146
accessed_at=row["accessed_at"],
@@ -67,29 +82,42 @@ def add(
6782
project: str | None = None,
6883
confidence: float = 1.0,
6984
supersedes: str = "",
85+
status: str = "active",
86+
source_path: str = "",
87+
source_section: str = "",
88+
source_hash: str = "",
7089
) -> MemoryRecord:
7190
if type not in MEMORY_TYPES:
7291
raise ValueError(f"Invalid type '{type}'. Must be one of: {MEMORY_TYPES}")
92+
if status not in MEMORY_STATUSES:
93+
raise ValueError(f"Invalid status '{status}'. Must be one of: {MEMORY_STATUSES}")
7394

7495
record_id = str(uuid.uuid4())
7596
now = _now()
7697
tags_str = ",".join(tags) if tags else ""
7798
proj = project if project is not None else self.project
99+
validated_at = now if status == "validated" else ""
78100

79101
self._conn.execute(
80102
"""INSERT INTO memories
81103
(id, type, title, content, tags, source, project, confidence,
82-
supersedes, created_at, updated_at, accessed_at, access_count)
83-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)""",
104+
supersedes, status, source_path, source_section, source_hash,
105+
validated_at, deprecated_at, superseded_by,
106+
created_at, updated_at, accessed_at, access_count)
107+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', ?, ?, ?, 0)""",
84108
(record_id, type, title, content, tags_str, source, proj,
85-
confidence, supersedes, now, now, now),
109+
confidence, supersedes, status, source_path, source_section,
110+
source_hash, validated_at, now, now, now),
86111
)
87112
self._conn.commit()
88113

89114
return MemoryRecord(
90115
id=record_id, type=type, title=title, content=content,
91116
tags=tags or [], source=source, project=proj,
92117
confidence=confidence, supersedes=supersedes,
118+
status=status, source_path=source_path,
119+
source_section=source_section, source_hash=source_hash,
120+
validated_at=validated_at,
93121
created_at=now, updated_at=now, accessed_at=now, access_count=0,
94122
)
95123

@@ -112,7 +140,9 @@ def update(self, id: str, **kwargs) -> MemoryRecord | None:
112140
if not record:
113141
return None
114142

115-
allowed = {"title", "content", "tags", "type", "confidence", "project", "supersedes"}
143+
allowed = {"title", "content", "tags", "type", "confidence", "project",
144+
"supersedes", "status", "source_path", "source_section",
145+
"source_hash", "validated_at", "deprecated_at", "superseded_by"}
116146
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
117147
if not updates:
118148
return record
@@ -121,6 +151,8 @@ def update(self, id: str, **kwargs) -> MemoryRecord | None:
121151
updates["tags"] = ",".join(updates["tags"])
122152
if "type" in updates and updates["type"] not in MEMORY_TYPES:
123153
raise ValueError(f"Invalid type '{updates['type']}'. Must be one of: {MEMORY_TYPES}")
154+
if "status" in updates and updates["status"] not in MEMORY_STATUSES:
155+
raise ValueError(f"Invalid status '{updates['status']}'. Must be one of: {MEMORY_STATUSES}")
124156

125157
updates["updated_at"] = _now()
126158
set_clause = ", ".join(f"{k} = ?" for k in updates)
@@ -130,6 +162,70 @@ def update(self, id: str, **kwargs) -> MemoryRecord | None:
130162
self._conn.commit()
131163
return self.get(id)
132164

165+
# ── Truth governance lifecycle ──────────────────────────────────
166+
167+
def promote(self, id: str) -> MemoryRecord | None:
168+
"""Promote a memory: hypothesis → active → validated.
169+
Each call advances one step. Validated is the highest trust level."""
170+
record = self.get(id)
171+
if not record:
172+
return None
173+
174+
promotions = {"hypothesis": "active", "active": "validated"}
175+
next_status = promotions.get(record.status)
176+
if not next_status:
177+
return record # Already validated or in a terminal state
178+
179+
now = _now()
180+
updates = {"status": next_status, "updated_at": now}
181+
if next_status == "validated":
182+
updates["validated_at"] = now
183+
184+
set_clause = ", ".join(f"{k} = ?" for k in updates)
185+
values = list(updates.values()) + [id]
186+
self._conn.execute(f"UPDATE memories SET {set_clause} WHERE id = ?", values)
187+
self._conn.commit()
188+
return self.get(id)
189+
190+
def deprecate(self, id: str, reason: str = "") -> MemoryRecord | None:
191+
"""Mark a memory as deprecated. Excluded from recall, kept for history."""
192+
record = self.get(id)
193+
if not record:
194+
return None
195+
196+
now = _now()
197+
content = record.content
198+
if reason:
199+
content = f"{content}\n\n[DEPRECATED {now[:10]}] {reason}"
200+
201+
self._conn.execute(
202+
"UPDATE memories SET status = 'deprecated', deprecated_at = ?, "
203+
"content = ?, updated_at = ? WHERE id = ?",
204+
(now, content, now, id),
205+
)
206+
self._conn.commit()
207+
return self.get(id)
208+
209+
def supersede(self, old_id: str, new_id: str) -> tuple[MemoryRecord | None, MemoryRecord | None]:
210+
"""Mark old_id as superseded by new_id. Old memory points to replacement."""
211+
old = self.get(old_id)
212+
new = self.get(new_id)
213+
if not old or not new:
214+
return (old, new)
215+
216+
now = _now()
217+
self._conn.execute(
218+
"UPDATE memories SET status = 'superseded', superseded_by = ?, "
219+
"deprecated_at = ?, updated_at = ? WHERE id = ?",
220+
(new_id, now, now, old_id),
221+
)
222+
self._conn.execute(
223+
"UPDATE memories SET supersedes = ?, updated_at = ? WHERE id = ?",
224+
(old_id, now, new_id),
225+
)
226+
self._conn.commit()
227+
return (self.get(old_id), self.get(new_id))
228+
133229
def delete(self, id: str) -> bool:
134230
cur = self._conn.execute("DELETE FROM memories WHERE id = ?", (id,))
135231
self._conn.commit()
@@ -210,24 +306,32 @@ def save_session(self, summary: str, tags: list[str] | None = None) -> MemoryRec
210306
The summary should capture: what's in progress, what's blocked, what's done,
211307
and any decisions made this session.
212308
"""
213-
# Find and supersede the previous session
309+
# Find previous active session
214310
prev = self._conn.execute(
215311
"SELECT id FROM memories WHERE type = 'session' AND project = ? "
312+
"AND COALESCE(status, 'active') = 'active' "
216313
"ORDER BY created_at DESC LIMIT 1",
217314
(self.project,),
218315
).fetchone()
219316

220-
supersedes = prev["id"] if prev else ""
317+
prev_id = prev["id"] if prev else ""
221318

222-
return self.add(
319+
# Create new session
320+
new_record = self.add(
223321
type="session",
224322
title=f"Session state — {self.project or 'default'}",
225323
content=summary,
226324
tags=tags or ["session", "state"],
227325
source="session",
228-
supersedes=supersedes,
326+
supersedes=prev_id,
229327
)
230328

329+
# Actually supersede the old session using the governance lifecycle
330+
if prev_id:
331+
self.supersede(prev_id, new_record.id)
332+
333+
return new_record
334+
231335
def load_session(self) -> MemoryRecord | None:
232336
"""Load the most recent session state for this project.
233337

0 commit comments

Comments
 (0)