Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cheetahclaws/commands/advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -2126,7 +2126,7 @@ def cmd_summarize(args: str, _state, config) -> bool:

def cmd_memory(args: str, _state, config) -> bool:
from cheetahclaws.memory import search_memory, load_index
from cheetahclaws.memory.scan import scan_all_memories, format_memory_manifest, memory_freshness_text
from cheetahclaws.memory.scan import scan_all_memories, format_memory_manifest, memory_freshness_text, verified_epoch

stripped = args.strip()

Expand Down Expand Up @@ -2160,7 +2160,7 @@ def cmd_memory(args: str, _state, config) -> bool:
return True
info(f" {len(headers)} memory/memories (newest first):")
for h in headers:
fresh_warn = " ⚠ stale" if memory_freshness_text(h.mtime_s) else ""
fresh_warn = " ⚠ stale" if memory_freshness_text(verified_epoch(h.last_verified, h.created, h.mtime_s)) else ""
tag = f"[{h.type or '?':9s}|{h.scope:7s}]"
info(f" {tag} {h.filename}{fresh_warn}")
if h.description:
Expand Down
18 changes: 13 additions & 5 deletions cheetahclaws/memory/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
load_entries,
search_memory,
)
from .scan import scan_all_memories, format_memory_manifest, memory_freshness_text
from .scan import scan_all_memories, format_memory_manifest, memory_freshness_text, verified_epoch
from .types import MEMORY_SYSTEM_PROMPT


Expand Down Expand Up @@ -126,14 +126,17 @@ def find_relevant_memories(
return []

if not use_ai or not config:
# Return top max_results by recency (newest first)
# Return top max_results by recency-of-verification (most recently
# verified first). mtime is kept only as a legacy fallback inside
# verified_epoch for files that predate the date fields.
from .scan import scan_all_memories
headers = scan_all_memories()
path_to_mtime = {h.file_path: h.mtime_s for h in headers}

results = []
for entry in keyword_results[:max_results * 3]:
mtime_s = path_to_mtime.get(entry.file_path, 0)
vepoch = verified_epoch(entry.last_verified, entry.created, mtime_s)
results.append({
"name": entry.name,
"description": entry.description,
Expand All @@ -142,11 +145,13 @@ def find_relevant_memories(
"content": entry.content,
"file_path": entry.file_path,
"mtime_s": mtime_s,
"freshness_text": memory_freshness_text(mtime_s),
"verified_s": vepoch,
"last_verified": entry.last_verified or entry.created,
"freshness_text": memory_freshness_text(vepoch),
"confidence": entry.confidence,
"source": entry.source,
})
results.sort(key=lambda r: r["mtime_s"], reverse=True)
results.sort(key=lambda r: r["verified_s"], reverse=True)
return results[:max_results]

# Step 2: AI-powered relevance selection (optional, lightweight)
Expand Down Expand Up @@ -210,6 +215,7 @@ def _ai_select_memories(
continue
entry = candidates[i]
mtime_s = path_to_mtime.get(entry.file_path, 0) if "path_to_mtime" in dir() else 0
vepoch = verified_epoch(entry.last_verified, entry.created, mtime_s)
results.append({
"name": entry.name,
"description": entry.description,
Expand All @@ -218,7 +224,9 @@ def _ai_select_memories(
"content": entry.content,
"file_path": entry.file_path,
"mtime_s": mtime_s,
"freshness_text": memory_freshness_text(mtime_s),
"verified_s": vepoch,
"last_verified": entry.last_verified or entry.created,
"freshness_text": memory_freshness_text(vepoch),
"confidence": entry.confidence,
"source": entry.source,
})
Expand Down
62 changes: 62 additions & 0 deletions cheetahclaws/memory/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@ class MemoryHeader:
description: value from frontmatter `description:` field
type: value from frontmatter `type:` field
scope: "user" or "project"
created: value from frontmatter `created:` field (may be "")
last_verified: value from frontmatter `last_verified:` field, falling back
to `created`. Anchors staleness (see verified_epoch).
"""
filename: str
file_path: str
mtime_s: float
description: str
type: str
scope: str
created: str = ""
last_verified: str = ""


# ── Scanning ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -68,6 +73,8 @@ def scan_memory_dir(mem_dir: Path, scope: str) -> list[MemoryHeader]:
description=meta.get("description", ""),
type=meta.get("type", ""),
scope=scope,
created=meta.get("created", ""),
last_verified=meta.get("last_verified", "") or meta.get("created", ""),
))
except Exception:
continue
Expand Down Expand Up @@ -111,6 +118,12 @@ def memory_freshness_text(mtime_s: float) -> str:

Motivated by user reports of stale code-state memories (file:line
citations to code that has since changed) being asserted as fact.

Note: callers should pass a *verification* timestamp (see verified_epoch),
not a raw filesystem mtime — otherwise simply retrieving a memory (which
can rewrite the file) would suppress this warning. The parameter name is
kept for backward compatibility; semantically it is "seconds since the
memory's claim was last established".
"""
d = memory_age_days(mtime_s)
if d <= 1:
Expand All @@ -123,6 +136,55 @@ def memory_freshness_text(mtime_s: float) -> str:
)


# ── Verification-anchored staleness ────────────────────────────────────────

def parse_date_epoch(date_str: str) -> float:
"""Parse a 'YYYY-MM-DD' (or full ISO) date string to an epoch in seconds.

Returns 0.0 if the string is empty or unparseable. Day granularity is
sufficient here: both the staleness warning and the recency decay work in
whole days.
"""
if not date_str:
return 0.0
try:
from datetime import datetime
return datetime.fromisoformat(str(date_str).strip()).timestamp()
except (ValueError, TypeError):
return 0.0


def verified_epoch(last_verified: str, created: str, mtime_s: float = 0.0) -> float:
"""Resolve the timestamp that anchors a memory's staleness.

Preference order: last_verified -> created -> filesystem mtime.

The filesystem mtime is only a last-resort fallback for legacy memory
files written before the date fields existed. Because an explicit date is
always preferred over mtime, a *read* of the memory (which may rewrite the
file, and which we additionally guard with os.utime in touch_last_used)
can never reset its staleness once a date is present. This is the fix for
the "retrieval resets staleness" bug: freshness reflects when the claim
was last *verified*, not when the file was last *touched*.
"""
e = parse_date_epoch(last_verified) or parse_date_epoch(created)
return e if e else (mtime_s or 0.0)


def trust_recency(verified_s: float, now: float | None = None) -> float:
"""Exponential recency weight from the last-verified time.

exp(-age_days / 30) → half-life ≈ 21 days. Older-since-verified yields a
smaller weight. Used as the recency factor in confidence × recency
retrieval ranking, replacing the previous mtime-based recency that a read
could reset to 1.0.
"""
if now is None:
now = time.time()
age_days = max(0.0, (now - (verified_s or 0.0)) / 86_400.0)
return math.exp(-age_days / 30.0)


# ── Manifest formatting ────────────────────────────────────────────────────

def format_memory_manifest(headers: list[MemoryHeader]) -> str:
Expand Down
65 changes: 61 additions & 4 deletions cheetahclaws/memory/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ class MemoryEntry:
scope: "user" | "project" — which directory this was loaded from
confidence: 0.0–1.0 reliability score (default 1.0 = explicit user statement)
source: origin: "user" | "model" | "tool" | "consolidator"
last_used_at: ISO date of last retrieval (updated on MemorySearch hits)
last_used_at: ISO date of last *retrieval* (updated on MemorySearch hits;
utility/analytics signal only — does NOT affect staleness)
last_verified: ISO date the memory's claim was last *re-checked* against the
live environment. Anchors the staleness clock. Defaults to
`created` at save time; refreshed only via mark_verified().
conflict_group: tag linking related/conflicting memories (e.g. "writing_style")
"""
name: str
Expand All @@ -71,6 +75,7 @@ class MemoryEntry:
confidence: float = 1.0
source: str = "user"
last_used_at: str = ""
last_verified: str = ""
conflict_group: str = ""


Expand Down Expand Up @@ -111,6 +116,12 @@ def _format_entry_md(entry: MemoryEntry) -> str:
f"type: {entry.type}",
f"created: {entry.created}",
]
# last_verified anchors the staleness clock. A freshly written memory is
# "verified now", so if it is not set explicitly it defaults to the
# creation date. It is refreshed ONLY by mark_verified(), never by a read.
lv = entry.last_verified or entry.created
if lv:
lines.append(f"last_verified: {lv}")
if entry.confidence != 1.0:
lines.append(f"confidence: {entry.confidence:.2f}")
if entry.source and entry.source != "user":
Expand Down Expand Up @@ -187,6 +198,7 @@ def load_entries(scope: str = "user") -> list[MemoryEntry]:
confidence=float(meta.get("confidence", 1.0)),
source=meta.get("source", "user"),
last_used_at=meta.get("last_used_at", ""),
last_verified=meta.get("last_verified", "") or meta.get("created", ""),
conflict_group=meta.get("conflict_group", ""),
))
return entries
Expand Down Expand Up @@ -272,14 +284,22 @@ def check_conflict(entry: "MemoryEntry", scope: str = "user") -> dict | None:
def touch_last_used(file_path: str) -> None:
"""Update the last_used_at frontmatter field of a memory file to today.

Called by MemorySearch when a memory is returned so staleness/utility
Called by MemorySearch when a memory is returned, so retrieval/utility
tracking stays current. Silent on any error.

Importantly, this is a *read*-side bookkeeping write: it must NOT make the
memory look freshly verified. We therefore (a) never touch last_verified,
and (b) restore the file's original mtime after rewriting, so any legacy
mtime-based staleness consumer is not reset merely because the memory was
retrieved. Staleness is anchored to last_verified (see mark_verified).
"""
from datetime import date
import os
fp = Path(file_path)
if not fp.exists():
return
try:
st = fp.stat() # capture original (a,m)time before the bookkeeping write
text = fp.read_text()
meta, body = parse_frontmatter(text)
today = date.today().isoformat()
Expand All @@ -288,13 +308,50 @@ def touch_last_used(file_path: str) -> None:
meta["last_used_at"] = today
# Rebuild frontmatter
fm_lines = ["---"]
for k in ("name", "description", "type", "created", "confidence",
"source", "last_used_at", "conflict_group"):
for k in ("name", "description", "type", "created", "last_verified",
"confidence", "source", "last_used_at", "conflict_group"):
v = meta.get(k)
if v is not None and str(v):
fm_lines.append(f"{k}: {v}")
fm_lines.append("---")
new_text = "\n".join(fm_lines) + "\n" + body + "\n"
fp.write_text(new_text)
# A read must not look like a write: restore the original mtime.
os.utime(fp, (st.st_atime, st.st_mtime))
except Exception:
pass


def mark_verified(file_path: str) -> bool:
"""Stamp last_verified = today after a memory's claim was re-checked against
the live environment.

This is the ONLY operation that refreshes the staleness clock; plain
retrieval (touch_last_used) deliberately does not. Call this once the
agent has confirmed the memory still holds (e.g. the file/function/flag it
cites still exists). Unlike touch_last_used, this is a genuine freshness
event, so the file's mtime is allowed to advance.

Returns True if the field is set to today, False on any error.
"""
from datetime import date
fp = Path(file_path)
if not fp.exists():
return False
try:
meta, body = parse_frontmatter(fp.read_text())
today = date.today().isoformat()
if meta.get("last_verified") == today:
return True
meta["last_verified"] = today
fm_lines = ["---"]
for k in ("name", "description", "type", "created", "last_verified",
"confidence", "source", "last_used_at", "conflict_group"):
v = meta.get(k)
if v is not None and str(v):
fm_lines.append(f"{k}: {v}")
fm_lines.append("---")
fp.write_text("\n".join(fm_lines) + "\n" + body + "\n")
return True
except Exception:
return False
Loading