Skip to content

Commit df92398

Browse files
authored
Merge pull request #52 from nickroci/feat/retrieval-cue-learning
feat(curator): retrieval-cue learning + decay-time-bomb test fix + CI security audit
2 parents a39c3f1 + 764b67c commit df92398

11 files changed

Lines changed: 262 additions & 57 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,35 @@ jobs:
7777
- name: Check version coherence
7878
run: bash scripts/check-version-coherence.sh
7979

80+
security-audit:
81+
# Scan third-party dependencies for known vulnerabilities (PyPI Advisory
82+
# DB / OSV) via pip-audit. `uv sync --all-packages` installs every
83+
# workspace member's deps into one env; pip-audit audits that env directly
84+
# (environment mode — no resolver venv, which is robust across runners).
85+
# Local workspace packages (ultan, agent-mem-daemon, …) aren't on PyPI and
86+
# are skipped automatically.
87+
#
88+
# Required, blocking gate (wired into `ci-success` below): a new advisory
89+
# in any dependency fails this and blocks merges until the dep is bumped
90+
# or the advisory is explicitly ignored here. Keep the ignore list curated
91+
# — each entry must carry a reason and a revisit trigger.
92+
#
93+
# Ignored advisories:
94+
# - CVE-2025-3000 (torch 2.12.0): no patched torch release exists yet.
95+
# Drop this ignore once upstream ships a fixed version.
96+
name: security-audit
97+
runs-on: ubuntu-latest
98+
steps:
99+
- uses: actions/checkout@v4
100+
- uses: astral-sh/setup-uv@v6
101+
with:
102+
enable-cache: true
103+
cache-dependency-glob: uv.lock
104+
- name: Install the full workspace
105+
run: uv sync --locked --all-packages
106+
- name: Audit dependencies (pip-audit)
107+
run: uv run --with pip-audit pip-audit --ignore-vuln CVE-2025-3000
108+
80109
ci-success:
81110
# Single required check for branch protection. It goes green only when
82111
# every job above has succeeded, so protection on `main` can require
@@ -86,11 +115,12 @@ jobs:
86115
# skipped past a failing matrix.
87116
name: ci-success
88117
if: always()
89-
needs: [build, version-coherence]
118+
needs: [build, version-coherence, security-audit]
90119
runs-on: ubuntu-latest
91120
steps:
92121
- name: Require all CI jobs to have passed
93122
run: |
94-
echo "build=${{ needs.build.result }} version-coherence=${{ needs.version-coherence.result }}"
123+
echo "build=${{ needs.build.result }} version-coherence=${{ needs.version-coherence.result }} security-audit=${{ needs.security-audit.result }}"
95124
test "${{ needs.build.result }}" = "success"
96125
test "${{ needs.version-coherence.result }}" = "success"
126+
test "${{ needs.security-audit.result }}" = "success"

daemon/agent_mem_daemon/ingest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class _OffsetState(TypedDict, total=False):
7171
"SessionEnd",
7272
"UserPromptSubmit",
7373
"SessionStart",
74+
"Surfaced",
7475
}
7576

7677

daemon/agent_mem_daemon/librarian_prompt.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,30 @@ def _slugify(s: str) -> str:
756756
``used_helpfully`` (that only counts the hit; ``drift`` actually edits the \
757757
entry) — you may emit both for the same entry in one scan.
758758
759+
**RECALL-GAP — sharpen an entry's retrieval triggers** (mutate in place, \
760+
sparingly)
761+
Each turn may carry a ``[recall]`` line — ``instant-recall surfaced: \
762+
[[…]], [[…]]`` — listing what the fast trigger tier actually showed the \
763+
assistant for that turn. When the turn's query clearly needed a library \
764+
entry that is NOT in that ``[recall]`` line, the entry exists but its \
765+
triggers missed the query's vocabulary. Confirm with ``bm25_search`` / \
766+
``embedding_search`` that such an entry exists and genuinely answers the \
767+
query — never infer a gap from the recall line alone.
768+
Action: propose ``update_entry`` with ``salience_signal: "drift"``, \
769+
adding the query's distinctive term(s) to that entry's ``paraphrases`` \
770+
(preferred — query-shaped phrasings BM25 indexes) and/or ``keywords``. The \
771+
load-bearing claim and body stay INTACT; you are only widening how the \
772+
entry is FOUND, not what it says. E.g. a "you can't reliably profit from \
773+
crypto" entry that missed a "how do I make money from BTC" query gains the \
774+
paraphrase "make money from BTC / bitcoin".
775+
PRECISION GATE (critical): add a trigger ONLY when the entry is \
776+
genuinely THE right recall for that class of query, not merely adjacent. A \
777+
loose addition makes the entry fire as NOISE on unrelated future queries — \
778+
polluting the instant tier for every session, which is worse than an \
779+
occasional miss. When in doubt, DON'T; never pad with near-synonyms the \
780+
entry already matches. The embedding tier already catches paraphrases, so \
781+
reserve this for genuine lexical/cue gaps the fast path keeps missing.
782+
759783
**REDUNDANT / no signal** (skip silently — no proposal)
760784
Things every competent assistant would already produce or that don't carry information:
761785
- Code-as-code: "added a for loop", "created a class", "fixed indentation"

daemon/tests/conftest.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from __future__ import annotations
1919

20+
from datetime import date
2021
from pathlib import Path
2122
from typing import List, Optional
2223

@@ -39,14 +40,25 @@ def render_entry(
3940
fired_helpful: int = 0,
4041
body: str = "",
4142
scope: str = "global",
43+
created: Optional[str] = None,
44+
updated: Optional[str] = None,
4245
default_body_template: str = DEFAULT_BODY_TEMPLATE,
4346
) -> str:
4447
"""Render a valid library entry as a YAML-frontmattered markdown
4548
string. Matches the schema enforced by
4649
``scholar_prompt._REQUIRED_FRONTMATTER_FIELDS``.
4750
4851
When ``body`` is the empty string the body is synthesised from
49-
``default_body_template`` (which sees ``id_`` and ``applies_when``)."""
52+
``default_body_template`` (which sees ``id_`` and ``applies_when``).
53+
54+
``created``/``updated`` default to TODAY (ISO) rather than a fixed date:
55+
a hardcoded date silently ages past the decay window (``DECAY_AGE_DAYS``),
56+
after which the Scholar review's opportunistic decay sweep archives the
57+
fixture entry mid-test — a calendar time-bomb. Pass an explicit date only
58+
when a test deliberately needs a stale (or specific-dated) entry."""
59+
_today = date.today().isoformat()
60+
created = created or _today
61+
updated = updated or created
5062
lines = [
5163
"---",
5264
f"id: {id_}",
@@ -60,8 +72,8 @@ def render_entry(
6072
lines.append(f" {line}")
6173
lines.append("keywords: [" + ", ".join(keywords) + "]")
6274
lines.append(f'title: "{title}"')
63-
lines.append("created: 2026-05-19")
64-
lines.append("updated: 2026-05-19")
75+
lines.append(f"created: {created}")
76+
lines.append(f"updated: {updated}")
6577
lines.append(f"fired: {fired}")
6678
lines.append(f"fired-helpful: {fired_helpful}")
6779
if reinforced is not None:

daemon/tests/test_librarian_prompt.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,34 @@ def test_flatten_buffer_user_asserted_flag_propagates():
7979
assert flat[0][4] is True
8080

8181

82+
def test_flatten_buffer_renders_recall_surfaced_event():
83+
# The hook emits a `Surfaced` event (role="recall") recording what the
84+
# instant triggers showed; the Librarian must see it inline with the turn
85+
# so it can spot recall gaps. It renders like any text-bearing turn line.
86+
snap = _snap(
87+
[
88+
[
89+
_ev("UserPromptSubmit", {"text": "how do I make money from BTC"}),
90+
_ev(
91+
"Surfaced",
92+
{"role": "recall", "content": "instant-recall surfaced: [[a/b]], [[c/d]]"},
93+
),
94+
]
95+
]
96+
)
97+
flat = lp.flatten_buffer(snap)
98+
roles = [r for _id, _seq, r, _t, _u in flat]
99+
assert "recall" in roles
100+
recall_text = next(t for _id, _seq, r, t, _u in flat if r == "recall")
101+
assert "[[a/b]]" in recall_text and "[[c/d]]" in recall_text
102+
103+
104+
def test_system_prompt_includes_recall_gap_signal():
105+
s = lp.librarian_system_prompt()
106+
assert "RECALL-GAP" in s
107+
assert "PRECISION GATE" in s # the keyword-pollution safeguard must survive
108+
109+
82110
def test_flatten_buffer_empty_when_no_turns():
83111
assert lp.flatten_buffer({"turns": []}) == []
84112
assert lp.flatten_buffer({}) == []

daemon/tests/test_priming.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,11 @@ def test_bullet_format_includes_title_hook_and_count(tmp_path):
450450
applies_when="writing a Dockerfile or docker-compose service",
451451
keywords=["docker", "image", "version", "pin"],
452452
reinforced=4,
453+
# Fixed old date: this test pins the title/(×N)/hook wire-shape, not
454+
# the freshness ★ marker (entries updated within 7 days get a star).
455+
# render_entry now defaults to today, which would add the star here.
456+
created="2026-01-01",
457+
updated="2026-01-01",
453458
),
454459
)
455460
plain_path = k / "global" / "use-ripgrep.md"
@@ -460,6 +465,8 @@ def test_bullet_format_includes_title_hook_and_count(tmp_path):
460465
title="Prefer ripgrep over grep",
461466
applies_when="searching through a code repository",
462467
keywords=["ripgrep", "grep", "search"],
468+
created="2026-01-01",
469+
updated="2026-01-01",
463470
),
464471
)
465472

daemon/tests/test_scholar.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import json
2323
import logging
24+
from datetime import date
2425
from pathlib import Path
2526
from typing import Any, Dict, List, Optional
2627

@@ -34,6 +35,12 @@
3435

3536
from .conftest import scholar_entry_body
3637

38+
# Inline seed entries must be dated TODAY, not a fixed calendar date: a
39+
# hardcoded date ages past DECAY_AGE_DAYS, after which the Scholar review's
40+
# opportunistic decay sweep archives the seed entry mid-test (a time-bomb that
41+
# broke these tests on 2026-06-19, 31 days after the old 2026-05-19 literal).
42+
_TODAY = date.today().isoformat()
43+
3744

3845
@pytest.fixture(autouse=True)
3946
def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
@@ -354,7 +361,7 @@ def test_review_repairs_phantom_index_row(monkeypatch, tmp_path):
354361
(k / "global" / "python" / "use-uv.md").write_text(
355362
"---\nid: use-uv\ntype: lesson\nscope: global\nstatus: provisional\n"
356363
"confidence: 0.7\napplies-when: |\n x\nkeywords: [a, b, c]\n"
357-
'title: "use-uv"\ncreated: 2026-05-19\nupdated: 2026-05-19\n'
364+
f'title: "use-uv"\ncreated: {_TODAY}\nupdated: {_TODAY}\n'
358365
"fired: 0\nfired-helpful: 0\nsources:\n - manual\n---\n\n# uv\n\nUse uv always for python.\n",
359366
encoding="utf-8",
360367
)
@@ -729,7 +736,7 @@ def test_end_to_end_proposal_approved_writes_entry_to_knowledge_dir(
729736
"applies-when: |\n testing a service with a factory\n"
730737
"keywords: [test, stub, factory]\n"
731738
'title: "Stub the factory"\n'
732-
"created: 2026-05-19\nupdated: 2026-05-19\n"
739+
f"created: {_TODAY}\nupdated: {_TODAY}\n"
733740
"fired: 0\nfired-helpful: 0\nsources:\n - manual\n"
734741
"---\n\n# Stub the factory\n\nBody sentence long enough to pass.\n"
735742
)
@@ -795,7 +802,7 @@ def _seed_library_with_broken_link(k: Path, *, broken: str = "global/ghost/never
795802
entry.write_text(
796803
"---\nid: narr\ntype: lesson\nscope: global\nstatus: provisional\n"
797804
"confidence: 0.7\napplies-when: |\n x\nkeywords: [a, b, c]\n"
798-
'title: "narr"\ncreated: 2026-05-19\nupdated: 2026-05-19\n'
805+
f'title: "narr"\ncreated: {_TODAY}\nupdated: {_TODAY}\n'
799806
"fired: 0\nfired-helpful: 0\nsources:\n - manual\n---\n\n"
800807
f"# narr\n\nThis references [[{broken}]] in a sentence we keep.\n",
801808
encoding="utf-8",
@@ -950,7 +957,7 @@ def _entry_frontmatter(id_: str) -> str:
950957
return (
951958
f"---\nid: {id_}\ntype: lesson\nscope: global\nstatus: provisional\n"
952959
"confidence: 0.7\napplies-when: |\n x\nkeywords: [a, b, c]\n"
953-
f'title: "{id_}"\ncreated: 2026-05-19\nupdated: 2026-05-19\n'
960+
f'title: "{id_}"\ncreated: {_TODAY}\nupdated: {_TODAY}\n'
954961
"fired: 0\nfired-helpful: 0\nsources:\n - manual\n---\n\n"
955962
f"# {id_}\n\nA real body sentence that is clearly long enough.\n"
956963
)

tests/test_capture.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,55 @@ def test_post_tool_use_marks_failure(_isolated_store: Path) -> None:
9292
assert ev["payload"]["summary"].endswith("FAILED")
9393

9494

95+
# ── user-prompt-submit → Surfaced (what the instant triggers showed) ─────────
96+
97+
98+
def test_extract_surfaced_links_dedups_and_handles_empty() -> None:
99+
from ultan import _priming
100+
101+
md = "## Ultan says\n- [[projects/x/foo]] ★ — a\n- [[global/y/bar]] — b\n- [[projects/x/foo]] dup\n"
102+
assert _priming.extract_surfaced_links(md) == ["projects/x/foo", "global/y/bar"]
103+
assert _priming.extract_surfaced_links("") == []
104+
assert _priming.extract_surfaced_links("no links here") == []
105+
106+
107+
def _stub_priming(monkeypatch: pytest.MonkeyPatch, md: str) -> None:
108+
# Don't spawn a daemon or hit the socket; pin the priming output.
109+
monkeypatch.setattr(_hooks._daemon, "ensure_running", lambda: None)
110+
monkeypatch.setattr(_hooks._daemon, "status", lambda: "ready")
111+
monkeypatch.setattr(_hooks._priming, "get_priming", lambda *a, **k: md)
112+
113+
114+
def test_user_prompt_submit_emits_surfaced_event(
115+
_isolated_store: Path, monkeypatch: pytest.MonkeyPatch
116+
) -> None:
117+
_stub_priming(
118+
monkeypatch,
119+
"## Ultan says\n- [[projects/x/crypto-no-profit]] — you can't profit from crypto\n",
120+
)
121+
rc = _hooks.dispatch(
122+
"user-prompt-submit",
123+
{"session_id": "s1", "cwd": "/w", "prompt": "how do I make money from BTC"},
124+
)
125+
assert rc == 0
126+
by_type = {e["type"]: e for e in _read_events(_isolated_store)}
127+
assert "UserPromptSubmit" in by_type # the prompt itself still captured
128+
assert "Surfaced" in by_type # and what the instant triggers surfaced
129+
surfaced = by_type["Surfaced"]
130+
_assert_valid_event(surfaced, expected_type="Surfaced")
131+
assert surfaced["payload"]["role"] == "recall"
132+
assert "[[projects/x/crypto-no-profit]]" in surfaced["payload"]["content"]
133+
134+
135+
def test_user_prompt_submit_no_surfaced_event_when_nothing_primed(
136+
_isolated_store: Path, monkeypatch: pytest.MonkeyPatch
137+
) -> None:
138+
_stub_priming(monkeypatch, "") # daemon found nothing → no Surfaced line
139+
_hooks.dispatch("user-prompt-submit", {"session_id": "s1", "prompt": "hi"})
140+
types = {e["type"] for e in _read_events(_isolated_store)}
141+
assert "Surfaced" not in types
142+
143+
95144
# ── stop / session-end → turn-sealing events (the Librarian triggers) ────────
96145

97146

ultan/_hooks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ def _user_prompt_submit(payload: dict[str, Any]) -> int:
112112
"minute or two.*\n"
113113
)
114114

115+
# Record WHAT priming surfaced this turn so the daemon's Librarian can spot
116+
# recall gaps — an entry relevant to the turn that did NOT surface — and
117+
# sharpen its retrieval triggers (retrieval-cue learning). File write only,
118+
# independent of the stdout priming below; a cheap regex over the rendered
119+
# markdown, so it stays within the per-turn hook budget.
120+
if sid:
121+
surfaced = _priming.extract_surfaced_links(md)
122+
if surfaced:
123+
_events.append_event(
124+
"Surfaced",
125+
payload,
126+
payload={
127+
"role": "recall",
128+
"content": "instant-recall surfaced: "
129+
+ ", ".join(f"[[{t}]]" for t in surfaced),
130+
},
131+
)
132+
115133
# G3: consume + render any pending nudges for this turn (budget-gated,
116134
# cross-project filtered). Needs a session_id to key the per-session budget;
117135
# take_and_render no-ops to "" without one.

ultan/_priming.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,38 @@ def _tokenize(text: str) -> List[str]:
218218
return [t for t in _TOKEN_SPLIT_RE.split(text.lower()) if len(t) >= 2]
219219

220220

221+
_WIKILINK_RE = re.compile(r"\[\[([^\]\n|]+)(?:\|[^\]\n]*)?\]\]")
222+
223+
224+
def extract_surfaced_links(md: str) -> List[str]:
225+
"""Pull the entry wikilink targets out of a rendered priming block.
226+
227+
The priming markdown lists each surfaced entry as ``- [[<target>]] …``;
228+
this returns those ``<target>`` paths (deduped, order preserved) so the
229+
hook can record WHAT the instant triggers surfaced this turn. The daemon's
230+
Librarian uses that to spot recall gaps — an entry that was relevant to the
231+
turn but did NOT surface — and sharpen its retrieval triggers. Pure stdlib;
232+
safe on the per-turn hook hot path. Returns ``[]`` for empty input.
233+
234+
Grounding (retrieval-cue learning): retrieval reopens a trace for re-storage
235+
with altered associations (Bridge & Paller 2012, J Neurosci,
236+
doi:10.1523/JNEUROSCI.1378-12.2012); fast recall misses when the query's
237+
terms were not encoded as cues (encoding specificity — Tulving & Thomson
238+
1973, Psychol Rev, doi:10.1037/h0020071); the deliberative tier links the
239+
missed memory to the new cue (memory integration — Schlichting & Preston
240+
2015, Curr Opin Behav Sci, doi:10.1016/j.cobeha.2014.07.005)."""
241+
if not md:
242+
return []
243+
seen: set[str] = set()
244+
out: List[str] = []
245+
for target in _WIKILINK_RE.findall(md):
246+
t = target.strip()
247+
if t and t not in seen:
248+
seen.add(t)
249+
out.append(t)
250+
return out
251+
252+
221253
def _parse_yaml_lite(fm_block: str) -> Frontmatter:
222254
"""Just enough YAML to pull ``reinforced``, ``title``, ``applies-when``,
223255
``keywords``. Pure stdlib — no PyYAML dep.

0 commit comments

Comments
 (0)