Skip to content

Commit fe1adb6

Browse files
authored
Merge pull request #20 from nickroci/feat/fired-helpful-capture
feat(daemon): capture fired-helpful via a new used_helpfully signal
2 parents 0cc9a4f + d440ac7 commit fe1adb6

11 files changed

Lines changed: 792 additions & 81 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Ultan is modelled — deliberately, at the level of the architecture, not as dec
5858
- **Contradicts** an existing entry — user has changed their mind. Highest priority. Deprecates the old, writes the new. *(Prediction-error: stored belief was wrong.)*
5959
- **Novel** — not in the library, not derivable from the model's training (user-specific facts, strict overrides of defaults, idiosyncratic preferences). *(Novelty: no matching trace exists.)*
6060
- **Reinforces** — user repeated something we already have. No new entry; daemon bumps a `reinforced` counter on the existing entry to track how often it's reasserted. *(Reactivation: existing trace strengthened, not duplicated — Sinclair & Barense, 2019.)*
61+
- **Use-tracking, not a write trigger.** A fourth `salience_signal` value, **used_helpfully**, fires when the agent actually *relied on* a surfaced entry to answer — the Librarian judges genuine reliance (a mere mention is not use; disagreement stays *contradicts*), and the Scholar deterministically bumps a separate `fired-helpful` counter on the cited entry, deduped per turn so a re-scanned turn never double-counts. It is the positive-evidence half of the prefrontal-inhibition analog (see *Roadmap*), kept distinct from the three write-gating signals above. *(Capture only today — nothing yet consumes the counter.)*
6162
- **Two-tier curator with asymmetric bars.** The Librarian (Sonnet) does fast salience detection — low bar, recall-tuned. The Scholar (Opus) deliberates — higher bar, precision-tuned. System 1 gates System 2; cheap-and-broad gates expensive-and-narrow.
6263
- **Organises a real library, not a flat pile.** Topical hierarchy emerges from content. Every folder has a README. ≤5 entries per directory before splitting. Auto-maintained child listings between marker comments. Wikilinks validate. Frontmatter validates. Scope/path agreement enforced.
6364
- **Three slash commands** wire it into Claude Code without ceremony:
@@ -200,8 +201,8 @@ flowchart TD
200201
direction TB
201202
Tailer["TailerThread<br/>RollingBuffer per session"]
202203
Debounce["DebounceScheduler<br/><i>per-session quiet timer</i>"]
203-
Librarian["<b>LibrarianPool</b> — N × Sonnet <i>(System 1)</i><br/>classify salience: contradicts · novel · reinforces<br/>propose actions"]
204-
Reinforce["bump <code>reinforced</code> counters<br/><i>deterministic, no SDK cost</i>"]
204+
Librarian["<b>LibrarianPool</b> — N × Sonnet <i>(System 1)</i><br/>classify salience: contradicts · novel · reinforces · used_helpfully<br/>propose actions"]
205+
Reinforce["bump <code>reinforced</code> + <code>fired-helpful</code> counters<br/><i>deterministic, no SDK cost</i>"]
205206
Scholar["<b>ScholarWorker</b> — Opus <i>(System 2)</i><br/>'would I produce this unprompted?'<br/>approve + execute · or veto + drop"]
206207
Reconciler["<b>Reconciler</b> <i>(deterministic, post-batch)</i><br/>READMEs · child listings · wikilinks · frontmatter · scope"]
207208
@@ -366,7 +367,7 @@ We don't need to replicate biology exactly. We need the mechanisms to be *presen
366367
| Surprise-calibrated encoding strength (Greve et al. 2017; Rouhani, Norman & Niv 2018; Wixted 2004) | `encoding_strength` stamped by Scholar at write time, calibrated from the salience-signal mix; sets the entry's initial decay slope | TODO |
367368
| LTP — reactivation strengthens the trace (Roediger & Karpicke 2006 testing effect; Bjork & Bjork 1992) | `reinforced` counter bumped on reuse | **Done** |
368369
| LTD — passive decay of unused weak traces (Ebbinghaus 1885; Wixted 2004; Bear & Malenka 1994) | Half-life on each entry as `f(encoding_strength, reinforced_count)`; only effective for low/moderate strength entries | Partial — fixed 30-day surfacing-aware sweep + archive shipped (PR #7); the `f(encoding_strength, …)` half-life formula awaits the `encoding_strength` field |
369-
| **Prefrontal inhibition of retrieval** (Anderson & Green 2001 Nature, Think/No-Think paradigm) | **Agent seeing a surfaced memory and not acting on it is functionally a no-think signal.** Counts as weak negative evidence — accelerates decay for low/moderate-strength entries; for high-strength entries it triggers a reconsolidation/update review (relevance-drift, not irrelevance). Asymmetric: "agent explicitly cited / `ultan-search`-fetched" is *strong positive* evidence; "surfaced but ignored" is *weak negative* evidence, requiring multiple instances. Mirrors the brain's asymmetric weighting of presence-of-use vs absence-of-use. | TODO |
370+
| **Prefrontal inhibition of retrieval** (Anderson & Green 2001 Nature, Think/No-Think paradigm) | **Agent seeing a surfaced memory and not acting on it is functionally a no-think signal.** Counts as weak negative evidence — accelerates decay for low/moderate-strength entries; for high-strength entries it triggers a reconsolidation/update review (relevance-drift, not irrelevance). Asymmetric: "agent explicitly cited / `ultan-search`-fetched" is *strong positive* evidence; "surfaced but ignored" is *weak negative* evidence, requiring multiple instances. Mirrors the brain's asymmetric weighting of presence-of-use vs absence-of-use. | Partial — **positive-use capture shipped** (PR #20): the Librarian judges genuine reliance on a surfaced entry and emits a `used_helpfully` signal; the Scholar deterministically bumps a `fired-helpful` counter on the cited entry, deduped per (session, entry, turn) via a stable seal-time `turn_seq` so a turn re-seen on a later scan never double-counts. **TODO: actually _consume_ the signal** — feed `fired-helpful` into decay resistance / retrieval ranking. The negative half ("surfaced but ignored" → weak no-think evidence) is still unbuilt. |
370371
| **Reconsolidation** — retrieved memories become labile and are re-stored mutated (Nader et al. 2000; Schiller et al. 2010; Lee et al. 2017; Hupbach et al. 2007) | Librarian gets a `drift` salience signal alongside `contradicts`/`novel`/`reinforces`: *"high-strength entry [[X]] keeps surfacing in contexts that don't quite match its current text — propose an `update`."* Scholar evaluates as a partial mutation, not full replacement. Closest published OSS analog: A-MEM's Zettelkasten evolution (Xu et al., NeurIPS 2025). | Partial — `update` action exists; drift-driven proposal pathway does not |
371372
| Sleep-based selective consolidation (Diekelmann & Born 2010; Stickgold 2005; Wilhelm et al. 2011) | The Scholar's batch reconciliation phase plays this role architecturally — periodic, deliberate, prioritises high-salience entries; no behavioural analog of replay yet | Partial |
372373
| **Reflective abstraction — offline integration of leaf episodes into higher-order rules** (van Kesteren et al. 2012; Jung-Beeman et al. 2004; Kizilirmak et al. 2016; Schlichting & Preston 2015; Tse et al. 2007; Behrens et al. 2018; Preston & Eichenbaum 2013; Eichenbaum 2017; LLM analog: Park et al. 2023 Generative Agents reflection) | `abstract_entries` Librarian action proposes a parent abstraction over ≥2 related leaves during its normal scan — agent judgment, not cosine clustering. Surprise-/aha-gated (remote children + predictive lift + non-obvious + compresses) on both the Librarian propose-side and Scholar veto-side; the Scholar approves or vetoes; on approve, the executor writes the parent (`type: abstraction`) with `[[wikilink]]` backlinks into each child, which stay in place. See *Reflective abstraction* above. | **Done (slice 1)** — encoding_strength-derived parent durability awaits the `encoding_strength` field; ships on the existing `reinforced`/decay lifecycle |
@@ -412,7 +413,7 @@ Prior art worth borrowing from: **MemoryBank** (Zhong et al., 2024) applies the
412413

413414
## Status
414415

415-
582 daemon + 174 search + 68 hooks tests passing (824 total). Live-tested end-to-end against real Sonnet + Opus calls — now driven through the typed-output shim over the subscription SDK — including the three retrieval tiers, the curator's salience-signal classification, README reconciler, wikilink validator, and the PreToolUse advisory/block hook. Currently a personal dogfood project — not packaged for `pip install`. Expect to clone, `uv sync`, and tune the prompts to your own preferences.
416+
623 daemon + 174 search + 68 hooks tests passing (865 total). Live-tested end-to-end against real Sonnet + Opus calls — now driven through the typed-output shim over the subscription SDK — including the three retrieval tiers, the curator's salience-signal classification, README reconciler, wikilink validator, and the PreToolUse advisory/block hook. Currently a personal dogfood project — not packaged for `pip install`. Expect to clone, `uv sync`, and tune the prompts to your own preferences.
416417

417418
## License
418419

daemon/agent_mem_daemon/_schemas.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,28 +83,50 @@ class _BaseAction(BaseModel):
8383
),
8484
)
8585

86-
salience_signal: Optional[Literal["contradicts", "novel", "reinforces"]] = Field(
86+
salience_signal: Optional[Literal["contradicts", "novel", "reinforces", "used_helpfully"]] = (
87+
Field(
88+
default=None,
89+
description=(
90+
"Why this is worth remembering, in cognitive-science "
91+
"terms. 'contradicts': disagrees with an existing entry "
92+
"(cite path in existing_entry); user has changed their "
93+
"mind. 'novel': not in library AND not derivable from "
94+
"the model's baseline knowledge. 'reinforces': restates "
95+
"an existing entry's claim (cite path in existing_entry); "
96+
"the daemon will bump that entry's reinforced counter "
97+
"and the Scholar may veto the write. 'used_helpfully': "
98+
"the assistant actually RELIED ON / AGREED WITH a "
99+
"surfaced or cited existing entry to answer THIS turn "
100+
"(cite path in existing_entry AND the stable turn id in "
101+
"cited_turn_seq); the daemon bumps that entry's "
102+
"fired-helpful counter once per cited turn. A mere "
103+
"mention is NOT use, and disagreement is 'contradicts', "
104+
"not this. Null if unsure — the Scholar will infer."
105+
),
106+
)
107+
)
108+
109+
existing_entry: Optional[str] = Field(
87110
default=None,
88111
description=(
89-
"Why this is worth remembering, in cognitive-science "
90-
"terms. 'contradicts': disagrees with an existing entry "
91-
"(cite path in existing_entry); user has changed their "
92-
"mind. 'novel': not in library AND not derivable from "
93-
"the model's baseline knowledge. 'reinforces': restates "
94-
"an existing entry's claim (cite path in existing_entry); "
95-
"the daemon will bump that entry's reinforced counter "
96-
"and the Scholar may veto the write. Null if unsure — "
97-
"the Scholar will infer."
112+
"For 'contradicts'/'reinforces'/'used_helpfully' signals: "
113+
"path of the existing library entry the candidate relates "
114+
"to, relative to knowledge/. Required for those signals so "
115+
"the Scholar can verify the relationship."
98116
),
99117
)
100118

101-
existing_entry: Optional[str] = Field(
119+
cited_turn_seq: Optional[int] = Field(
102120
default=None,
103121
description=(
104-
"For 'contradicts'/'reinforces' signals: path of the "
105-
"existing library entry the candidate relates to, "
106-
"relative to knowledge/. Required for those signals so "
107-
"the Scholar can verify the relationship."
122+
"For the 'used_helpfully' signal ONLY: the STABLE turn id "
123+
"(the `(turn_seq=S)` token rendered on the rolling-buffer "
124+
"line — NOT the bracketed `[N]` scan-local label) of the "
125+
"turn in which the assistant relied on `existing_entry`. The "
126+
"daemon dedups fired-helpful bumps on (session, entry, "
127+
"turn_seq) so re-seeing the same turn on a later scan does "
128+
"not double-count. Required for 'used_helpfully'; ignored "
129+
"for other signals."
108130
),
109131
)
110132

@@ -829,10 +851,10 @@ def _action_name(cls: type[BaseModel]) -> str:
829851
def _action_payload_fields(cls: type[BaseModel]) -> List[str]:
830852
"""Non-discriminator, non-base field names for an action class, in
831853
declaration order. Excludes inherited fields (``action``,
832-
``reasoning``, ``salience_signal``, ``existing_entry``) — those
833-
are documented once globally near the action table, not repeated
834-
on every row."""
835-
skip = {"action", "reasoning", "salience_signal", "existing_entry"}
854+
``reasoning``, ``salience_signal``, ``existing_entry``,
855+
``cited_turn_seq``) — those are documented once globally near the
856+
action table, not repeated on every row."""
857+
skip = {"action", "reasoning", "salience_signal", "existing_entry", "cited_turn_seq"}
836858
return [name for name in cls.model_fields if name not in skip]
837859

838860

daemon/agent_mem_daemon/buffer.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,23 @@ class Event:
5050

5151
@dataclass
5252
class Turn:
53-
"""One sealed turn (a list of events between two Stops)."""
53+
"""One sealed turn (a list of events between two Stops).
54+
55+
``turn_seq`` is a STABLE, monotonic per-session id assigned when the
56+
turn is sealed (see :meth:`RollingBuffer._seal_turn`). Unlike the
57+
Librarian's scan-local ``turn_id`` (a per-event counter recomputed on
58+
every scan over whatever is currently in the deque), ``turn_seq`` does
59+
NOT shift as older turns age out of the deque — it identifies the same
60+
physical turn for the life of the session. The fired-helpful counter
61+
relies on this stability to dedup a (turn, entry) citation event seen
62+
across consecutive Librarian scans (see ``scholar_prompt.apply_fired_
63+
helpful_counters``). 0 means "unsealed / unassigned".
64+
"""
5465

5566
events: List[Event] = field(default_factory=list["Event"])
5667
started_at: float = 0.0
5768
sealed_at: float = 0.0
69+
turn_seq: int = 0
5870

5971
def is_empty(self) -> bool:
6072
return not self.events
@@ -78,6 +90,14 @@ class SessionState:
7890
# Librarian has consumed it. Lives here because it is per-session
7991
# state that survives across scheduler ticks.
8092
needs_librarian: bool = False
93+
# Monotonic allocator for ``Turn.turn_seq``. Incremented (never reset)
94+
# each time a turn is sealed, so every sealed turn gets a stable,
95+
# strictly-increasing id even as old turns are evicted from the deque.
96+
# In-memory only: a daemon restart rebuilds the buffer from the tailer
97+
# offset (it does NOT replay old turns), so this resets to 0 on
98+
# restart — which is fine, because the old turns it would clash with
99+
# are gone too. See the fired-helpful dedup note in scholar_prompt.
100+
next_turn_seq: int = 0
81101

82102

83103
class RollingBuffer:
@@ -147,6 +167,11 @@ def _seal_turn(self, sess: SessionState, sealing_ev: Event) -> SessionState:
147167
sess.open_turn.started_at = sealing_ev.ts
148168
sess.open_turn.events.append(sealing_ev)
149169
sess.open_turn.sealed_at = sealing_ev.ts
170+
# Stamp a stable, monotonic per-session id. Allocated here (at
171+
# seal time) so it never changes for this physical turn, even
172+
# after older turns are evicted from the deque below.
173+
sess.next_turn_seq += 1
174+
sess.open_turn.turn_seq = sess.next_turn_seq
150175
sess.turns.append(sess.open_turn)
151176
while len(sess.turns) > self.max_turns:
152177
sess.turns.popleft()
@@ -205,6 +230,7 @@ def snapshot(self, session_id: str) -> Optional[Dict[str, Any]]:
205230
{
206231
"started_at": t.started_at,
207232
"sealed_at": t.sealed_at,
233+
"turn_seq": t.turn_seq,
208234
"events": [
209235
{
210236
"ts": e.ts,

0 commit comments

Comments
 (0)