Skip to content

Commit 3e0f678

Browse files
authored
Merge pull request #22 from nickroci/fix/scholar-counter-preservation
fix(daemon): preserve server-owned counters on rewrite; wire fired co…
2 parents 4d2ca1a + 3f8207c commit 3e0f678

6 files changed

Lines changed: 403 additions & 23 deletions

File tree

daemon/agent_mem_daemon/decay.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,53 @@ def stamp_last_surfaced(
171171
return _atomic_write_entry(entry_path, fm, body)
172172

173173

174+
def _fired_count(fm: Dict[str, Any]) -> int:
175+
"""Current ``fired`` counter as a non-negative int (0 if absent or
176+
unparseable — same permissive coercion as ``reinforced``)."""
177+
raw = fm.get("fired")
178+
if raw is None:
179+
return 0
180+
try:
181+
n = int(raw)
182+
except (TypeError, ValueError):
183+
return 0
184+
return n if n > 0 else 0
185+
186+
187+
def record_surface(
188+
entry_path: Path,
189+
*,
190+
today_iso: Optional[str] = None,
191+
) -> bool:
192+
"""Record one priming surface event on the entry: increment ``fired``
193+
by 1 AND stamp ``last_surfaced`` to today, in a single atomic write.
194+
195+
Unlike :func:`stamp_last_surfaced`, ``fired`` is NOT daily-gated — it
196+
counts every surface event so the invariant ``fired-helpful <= fired``
197+
holds (``fired-helpful`` increments per (session, entry, turn), which
198+
can be several within a single day). ``last_surfaced`` is still set to
199+
today either way; the date is naturally idempotent so re-stamping it is
200+
harmless.
201+
202+
Args:
203+
entry_path: absolute path to the entry's ``.md`` file.
204+
today_iso: ISO date string to write (default: UTC today).
205+
Tests pin this for deterministic assertions.
206+
207+
Returns:
208+
True if the file was updated, False on any failure path (file
209+
missing, bad frontmatter, write failed). Never raises.
210+
"""
211+
iso = today_iso or _today_iso()
212+
parsed = _read_and_parse_frontmatter(entry_path)
213+
if parsed is None:
214+
return False
215+
fm, _raw_fm, body = parsed
216+
fm["fired"] = _fired_count(fm) + 1
217+
fm["last_surfaced"] = iso
218+
return _atomic_write_entry(entry_path, fm, body)
219+
220+
174221
# ── Sweep state I/O ──────────────────────────────────────────────────
175222

176223

@@ -521,6 +568,7 @@ def maybe_run_sweep(
521568
"SweepResult",
522569
"maybe_run_sweep",
523570
"read_last_sweep_at",
571+
"record_surface",
524572
"run_sweep",
525573
"should_sweep",
526574
"stamp_last_surfaced",

daemon/agent_mem_daemon/priming_rpc.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,13 @@ def _post_render_bookkeeping(
294294
295295
1. Per-session sent-cache update (only when body non-empty and
296296
session_id present — otherwise there's no state to track).
297-
2. ``last_surfaced`` frontmatter stamp on each newly-shown entry,
298-
so the decay sweep treats surfacing as activity. Runs even
299-
without ``session_id`` because the agent saw the entries
300-
regardless of how the daemon tracks sessions.
297+
2. Surface bookkeeping on each newly-shown entry via
298+
``decay.record_surface``: increment the ``fired`` counter and
299+
stamp ``last_surfaced``, so the decay sweep treats surfacing as
300+
activity and ``fired`` tracks the surface denominator for
301+
``fired-helpful``. Runs even without ``session_id`` because the
302+
agent saw the entries regardless of how the daemon tracks
303+
sessions.
301304
3. Opportunistic ``maybe_run_sweep`` — self-skips unless the 24h
302305
cooldown has elapsed. One ``stat()`` on the sweep-state file
303306
on the common path.
@@ -310,9 +313,9 @@ def _post_render_bookkeeping(
310313
for link in newly_sent or ():
311314
entry_path = (kdir / f"{link}.md").resolve()
312315
try:
313-
decay.stamp_last_surfaced(entry_path)
316+
decay.record_surface(entry_path)
314317
except Exception:
315-
log.exception("priming_rpc: last_surfaced stamp raised for %s", link)
318+
log.exception("priming_rpc: surface bookkeeping raised for %s", link)
316319
try:
317320
decay.maybe_run_sweep(kdir)
318321
except Exception:

daemon/agent_mem_daemon/scholar_executor.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,56 @@ def _reserialise(fm: Dict[str, Any], body: str) -> str:
9090
return f"---\n{dumped}\n---\n{body}"
9191

9292

93+
# Bookkeeping fields the daemon owns deterministically. The Scholar/Librarian
94+
# LLM is only *prompted* to preserve them and routinely emits the template
95+
# default (``0`` / absent), which clobbers the counters the daemon just bumped
96+
# on disk. So on any rewrite that REPLACES an existing entry, the executor is
97+
# the single source of truth: it forces each of these from the prior on-disk
98+
# entry onto the proposed frontmatter, discarding whatever the model emitted.
99+
# Everything else (title, keywords, status, confidence, applies-when, sources,
100+
# prose, …) stays LLM-owned and untouched.
101+
_SERVER_OWNED_FIELDS = (
102+
"fired",
103+
"fired-helpful",
104+
"last_fired_helpful",
105+
"reinforced",
106+
"last_reinforced",
107+
"last_surfaced",
108+
"created",
109+
)
110+
111+
112+
def _preserve_server_owned_fields(abs_path: Path, proposed_body: str) -> str:
113+
"""Force the server-owned bookkeeping fields from the EXISTING on-disk
114+
entry at ``abs_path`` onto ``proposed_body``'s frontmatter.
115+
116+
When ``abs_path`` has no pre-existing file (a brand-new entry) or its
117+
frontmatter is unparseable, return ``proposed_body`` unchanged — there is
118+
nothing to preserve. A server-owned field absent from the prior entry is
119+
left as whatever the proposed body carries (defaults stand); a present one
120+
overrides the LLM value verbatim.
121+
"""
122+
if not abs_path.exists():
123+
return proposed_body
124+
prior_fm, _prior_body = _split_body(_safe_read(abs_path))
125+
if not prior_fm:
126+
return proposed_body
127+
proposed_fm, body = _split_body(proposed_body)
128+
if not proposed_fm:
129+
# No frontmatter to graft onto — write the body as-is rather than
130+
# synthesising a frontmatter block the model didn't intend.
131+
return proposed_body
132+
changed = False
133+
for key in _SERVER_OWNED_FIELDS:
134+
if key in prior_fm:
135+
if proposed_fm.get(key) != prior_fm[key]:
136+
proposed_fm[key] = prior_fm[key]
137+
changed = True
138+
if not changed:
139+
return proposed_body
140+
return _reserialise(proposed_fm, body)
141+
142+
93143
def _atomic_write(path: Path, text: str) -> None:
94144
"""Write ``text`` to ``path`` atomically (tmp + os.replace)."""
95145
path.parent.mkdir(parents=True, exist_ok=True)
@@ -257,9 +307,13 @@ def _apply_write_like(
257307
now: datetime,
258308
result: ExecResult,
259309
) -> None:
260-
"""Shared body for write_entry / update_entry (write the body verbatim,
261-
sync the index row, append the log)."""
310+
"""Shared body for write_entry / update_entry. When the entry already
311+
exists on disk (update, or a write that replaces an entry), the
312+
server-owned bookkeeping counters are forced from the prior entry before
313+
the verbatim write so an LLM-emitted default can't clobber them; then
314+
sync the index row and append the log."""
262315
abs_path = (knowledge_dir / rel_path).resolve()
316+
body = _preserve_server_owned_fields(abs_path, body)
263317
_atomic_write(abs_path, body)
264318
fm, fm_body = _split_body(body)
265319
row = _index_row(rel_path, fm, fm_body, session_id=session_id)
@@ -301,8 +355,12 @@ def _do_merge(
301355
kd: Path, a: "ScholarMergeEntries", *, session_id: str, now: datetime, result: ExecResult
302356
) -> None:
303357
"""Write the merged target, then archive every distinct source path."""
304-
_atomic_write((kd / a.target_path).resolve(), a.target_body)
305-
fm, fm_body = _split_body(a.target_body)
358+
target_abs = (kd / a.target_path).resolve()
359+
# If the merge overwrites an existing entry in place, keep its
360+
# server-owned counters rather than the model's emitted defaults.
361+
target_body = _preserve_server_owned_fields(target_abs, a.target_body)
362+
_atomic_write(target_abs, target_body)
363+
fm, fm_body = _split_body(target_body)
306364
_upsert_index_row(
307365
kd, a.target_path, _index_row(a.target_path, fm, fm_body, session_id=session_id)
308366
)
@@ -418,8 +476,11 @@ def _do_abstract(
418476
read is recorded as a per-child miss but does not abort the action; the
419477
parent and the readable backlinks are still applied (integrity-first)."""
420478
parent_abs = (kd / a.parent_path).resolve()
421-
_atomic_write(parent_abs, a.parent_body)
422-
fm, fm_body = _split_body(a.parent_body)
479+
# Abstraction usually writes a fresh parent, but if it overwrites an
480+
# existing entry, keep that entry's server-owned counters.
481+
parent_body = _preserve_server_owned_fields(parent_abs, a.parent_body)
482+
_atomic_write(parent_abs, parent_body)
483+
fm, fm_body = _split_body(parent_body)
423484
_upsert_index_row(
424485
kd, a.parent_path, _index_row(a.parent_path, fm, fm_body, session_id=session_id)
425486
)

daemon/tests/test_decay.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def _write_entry(
4242
created: str = "2026-01-01",
4343
updated: str | None = None,
4444
status: str | None = None,
45+
fired: int | None = None,
4546
) -> None:
4647
"""Create an entry with the supplied frontmatter fields."""
4748
path.parent.mkdir(parents=True, exist_ok=True)
@@ -51,6 +52,8 @@ def _write_entry(
5152
"created": created,
5253
"reinforced": reinforced,
5354
}
55+
if fired is not None:
56+
fm["fired"] = fired
5457
if updated is not None:
5558
fm["updated"] = updated
5659
if last_surfaced is not None:
@@ -128,6 +131,97 @@ def test_stamp_last_surfaced_returns_false_for_no_frontmatter(home: Path) -> Non
128131
assert decay.stamp_last_surfaced(p, today_iso="2026-05-27") is False
129132

130133

134+
# ── record_surface (fired counter; Defect 2) ─────────────────────────
135+
136+
137+
def test_record_surface_increments_fired_and_stamps_last_surfaced(home: Path) -> None:
138+
p = home / "knowledge" / "global" / "foo.md"
139+
_write_entry(p, id_="foo", title="Foo", fired=0)
140+
ok = decay.record_surface(p, today_iso="2026-05-27")
141+
assert ok is True
142+
fm = yaml.safe_load(p.read_text(encoding="utf-8").split("---")[1])
143+
assert fm["fired"] == 1
144+
assert str(fm["last_surfaced"]) == "2026-05-27"
145+
146+
147+
def test_record_surface_is_per_event_not_daily_gated(home: Path) -> None:
148+
"""The crux of the invariant: two surface events on the SAME day must
149+
bump ``fired`` twice (unlike ``last_surfaced``, which is daily-idempotent)
150+
so ``fired-helpful`` — which can bump several times a day — never exceeds
151+
``fired``."""
152+
p = home / "knowledge" / "global" / "foo.md"
153+
_write_entry(p, id_="foo", title="Foo", fired=0)
154+
decay.record_surface(p, today_iso="2026-05-27")
155+
decay.record_surface(p, today_iso="2026-05-27")
156+
fm = yaml.safe_load(p.read_text(encoding="utf-8").split("---")[1])
157+
assert fm["fired"] == 2
158+
assert str(fm["last_surfaced"]) == "2026-05-27"
159+
160+
161+
def test_record_surface_starts_from_zero_when_field_absent(home: Path) -> None:
162+
"""An entry with no ``fired`` field starts the counter at 1, not crash."""
163+
p = home / "knowledge" / "global" / "foo.md"
164+
_write_entry(p, id_="foo", title="Foo") # no fired field written
165+
assert "fired:" not in p.read_text(encoding="utf-8")
166+
decay.record_surface(p, today_iso="2026-05-27")
167+
fm = yaml.safe_load(p.read_text(encoding="utf-8").split("---")[1])
168+
assert fm["fired"] == 1
169+
170+
171+
def test_record_surface_coerces_bad_fired_value(home: Path) -> None:
172+
"""A non-int ``fired`` (corrupt frontmatter) is treated as 0, then +1."""
173+
p = home / "knowledge" / "global" / "foo.md"
174+
p.parent.mkdir(parents=True, exist_ok=True)
175+
p.write_text(
176+
"---\nid: foo\ntitle: Foo\ncreated: '2026-01-01'\nfired: not-a-number\n---\n\nbody\n"
177+
)
178+
decay.record_surface(p, today_iso="2026-05-27")
179+
fm = yaml.safe_load(p.read_text(encoding="utf-8").split("---")[1])
180+
assert fm["fired"] == 1
181+
182+
183+
def test_record_surface_preserves_other_frontmatter(home: Path) -> None:
184+
p = home / "knowledge" / "global" / "foo.md"
185+
_write_entry(p, id_="foo", title="Foo", fired=4, reinforced=3, last_reinforced="2026-04-01")
186+
decay.record_surface(p, today_iso="2026-05-27")
187+
fm = yaml.safe_load(p.read_text(encoding="utf-8").split("---")[1])
188+
assert fm["id"] == "foo"
189+
assert fm["reinforced"] == 3
190+
assert fm["last_reinforced"] == "2026-04-01"
191+
assert fm["fired"] == 5
192+
193+
194+
def test_record_surface_returns_false_for_missing_file(home: Path) -> None:
195+
p = home / "knowledge" / "global" / "nonexistent.md"
196+
assert decay.record_surface(p, today_iso="2026-05-27") is False
197+
198+
199+
def test_record_surface_returns_false_for_no_frontmatter(home: Path) -> None:
200+
p = home / "knowledge" / "global" / "raw.md"
201+
p.parent.mkdir(parents=True, exist_ok=True)
202+
p.write_text("# Just a heading\n\nNo frontmatter here.\n")
203+
assert decay.record_surface(p, today_iso="2026-05-27") is False
204+
205+
206+
def test_fired_helpful_never_exceeds_fired_under_surface_semantics(home: Path) -> None:
207+
"""Invariant guard: with N surface events and M<=N helpful bumps in the
208+
same day, ``fired-helpful <= fired`` holds. ``record_surface`` counts each
209+
surface; ``fired-helpful`` (modelled here as a same-day multi-bump) stays
210+
bounded by it."""
211+
p = home / "knowledge" / "global" / "foo.md"
212+
_write_entry(p, id_="foo", title="Foo", fired=0)
213+
# Three surface events today.
214+
for _ in range(3):
215+
decay.record_surface(p, today_iso="2026-05-27")
216+
fm = yaml.safe_load(p.read_text(encoding="utf-8").split("---")[1])
217+
fired = int(fm["fired"])
218+
# The Scholar would bump fired-helpful at most once per surfaced turn; the
219+
# denominator (fired) must dominate. Two helpful bumps <= three surfaces.
220+
fired_helpful = 2
221+
assert fired == 3
222+
assert fired_helpful <= fired
223+
224+
131225
# ── sweep-state I/O ──────────────────────────────────────────────────
132226

133227

daemon/tests/test_priming_rpc.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -808,13 +808,14 @@ def test_post_render_bookkeeping_stamps_last_surfaced(
808808
home_with_isolated_paths, monkeypatch
809809
) -> None:
810810
"""Each entry in ``newly_sent`` should get its frontmatter
811-
``last_surfaced`` updated."""
811+
``last_surfaced`` stamped and its ``fired`` counter bumped."""
812812
home = home_with_isolated_paths
813813
k = home / "knowledge"
814814
entry = k / "global" / "foo.md"
815815
entry.parent.mkdir(parents=True, exist_ok=True)
816816
entry.write_text(
817-
"---\nid: foo\ntitle: Foo\ncreated: '2026-01-01'\nreinforced: 0\n---\n\n# Foo\n\nbody.\n"
817+
"---\nid: foo\ntitle: Foo\ncreated: '2026-01-01'\n"
818+
"fired: 0\nreinforced: 0\n---\n\n# Foo\n\nbody.\n"
818819
)
819820
# Skip the sweep — separate concern, separately tested.
820821
monkeypatch.setattr(
@@ -825,6 +826,7 @@ def test_post_render_bookkeeping_stamps_last_surfaced(
825826
priming_rpc._post_render_bookkeeping(k, "test-session", "rendered body", ["global/foo"])
826827
text = entry.read_text(encoding="utf-8")
827828
assert "last_surfaced" in text
829+
assert "fired: 1" in text
828830

829831

830832
def test_post_render_bookkeeping_records_session_cache(
@@ -843,7 +845,7 @@ def test_post_render_bookkeeping_records_session_cache(
843845
)
844846
monkeypatch.setattr(
845847
priming_rpc.decay,
846-
"stamp_last_surfaced",
848+
"record_surface",
847849
lambda *args, **kwargs: True,
848850
)
849851
priming_rpc._post_render_bookkeeping(
@@ -865,18 +867,18 @@ def test_post_render_bookkeeping_no_session_skips_cache_record(
865867
"maybe_run_sweep",
866868
lambda *args, **kwargs: None,
867869
)
868-
stamped: list[str] = []
870+
surfaced: list[str] = []
869871
monkeypatch.setattr(
870872
priming_rpc.decay,
871-
"stamp_last_surfaced",
872-
lambda path, **kwargs: stamped.append(str(path)) or True,
873+
"record_surface",
874+
lambda path, **kwargs: surfaced.append(str(path)) or True,
873875
)
874876
priming_rpc._post_render_bookkeeping(k, None, "rendered", ["global/foo"])
875877
# Cache untouched (empty).
876878
with priming_rpc._sent_cache_lock:
877879
assert len(priming_rpc._sent_cache) == 0
878-
# But stamp still ran.
879-
assert len(stamped) == 1
880+
# But surface bookkeeping still ran.
881+
assert len(surfaced) == 1
880882

881883

882884
def test_post_render_bookkeeping_swallows_stamp_failure(
@@ -889,7 +891,7 @@ def test_post_render_bookkeeping_swallows_stamp_failure(
889891
def _boom(*args, **kwargs):
890892
raise RuntimeError("disk full")
891893

892-
monkeypatch.setattr(priming_rpc.decay, "stamp_last_surfaced", _boom)
894+
monkeypatch.setattr(priming_rpc.decay, "record_surface", _boom)
893895
monkeypatch.setattr(priming_rpc.decay, "maybe_run_sweep", lambda *args, **kwargs: None)
894896
# Should not raise.
895897
priming_rpc._post_render_bookkeeping(k, "s", "body", ["global/foo"])
@@ -906,7 +908,7 @@ def _boom(*args, **kwargs):
906908
raise RuntimeError("filesystem error")
907909

908910
monkeypatch.setattr(priming_rpc.decay, "maybe_run_sweep", _boom)
909-
monkeypatch.setattr(priming_rpc.decay, "stamp_last_surfaced", lambda *args, **kwargs: True)
911+
monkeypatch.setattr(priming_rpc.decay, "record_surface", lambda *args, **kwargs: True)
910912
priming_rpc._post_render_bookkeeping(k, "s", "body", ["global/foo"])
911913

912914

0 commit comments

Comments
 (0)