Skip to content

Commit 4195768

Browse files
authored
Merge pull request #34 from nickroci/feat/retire-legacy-src-hooks
refactor: retire legacy src/ hooks; migrate all live behavior to the daemon path
2 parents 6272537 + 3cd951a commit 4195768

79 files changed

Lines changed: 3403 additions & 10372 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ jobs:
2525
python-version: "3.10"
2626
- package: tools/search
2727
python-version: "3.10"
28-
- package: src
29-
python-version: "3.12"
3028
- package: tools/ultan
3129
python-version: "3.12"
3230
defaults:

README.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,8 @@ Ultan is modelled — deliberately, at the level of the architecture, not as dec
170170
- **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. *(Now consumed by Tier-1 ranking as a gentle usefulness tiebreaker; feeding it into decay resistance is still a TODO — see Roadmap.)*
171171
- **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.
172172
- **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.
173-
- **Three slash commands** wire it into Claude Code without ceremony:
173+
- **Two slash commands** wire it into Claude Code without ceremony:
174174
- `/ultan <text>` — drop something into memory now, no extraction needed.
175-
- `/ultan-install` — wire the hooks into the current project's `.claude/settings.json`.
176175
- `/ultan-advisor <question>` — query the library before asking the user a preference question. The advisor finds relevant entries (Sonnet, BM25 + embeddings + Read), writes a referenced answer (Opus), and clearly distinguishes stored knowledge from its own opinion. *Always cheaper to check than to ask.*
177176
- **Pure markdown store.** No database. The library is `~/.agent-mem/knowledge/``ls`, `cat`, `git` it. Two derived indexes alongside (`.bm25.idx` for keyword, `.embeddings.idx` for semantic) auto-rebuild on drift.
178177

@@ -216,13 +215,14 @@ cd daemon && uv sync --group dev
216215
# are downloaded on first daemon start (see step 4 below).
217216
cd ../tools/search && uv sync
218217

219-
# 3. Install the slash commands and hooks
220-
# - /ultan, /ultan-install, /ultan-advisor live at ~/.claude/commands/
221-
# - `/ultan-install` writes hooks into ~/.claude/settings.json (GLOBAL — every
222-
# Claude Code project). One daemon per machine serves the whole library
223-
# across every repo, so global is the recommended default.
224-
# - `/ultan-install --project` if you'd rather scope to one repo only.
225-
# NOTE: don't run this if you've installed the plugin — the hooks would double-fire.
218+
# 3. Wire the hooks
219+
# The plugin is the supported install path: it registers the hooks (and the
220+
# /ultan, /ultan-advisor commands) automatically from `hooks/hooks.json`.
221+
# Running from source WITHOUT the plugin, mirror the `ultan hook <event>`
222+
# entries in `hooks/hooks.json` into your own ~/.claude/settings.json. One
223+
# daemon per machine serves the whole library across every repo.
224+
# NOTE: don't wire these by hand if you've installed the plugin — the hooks
225+
# would double-fire.
226226

227227
# 4. Start the daemon (foreground; logs to ~/.agent-mem/daemon.log). One per
228228
# machine — it listens on ~/.agent-mem/priming.sock and answers Tier-1
@@ -292,16 +292,17 @@ What we deliberately didn't borrow from `claude-hooks`: the Qdrant / pgvector /
292292
```
293293
agent-mem/
294294
README.md ← this file
295+
ultan/ ← hook runtime + `ultan` CLI (dispatch, doctor, mcp) and
296+
the hot-path modules (events, priming, blockers, nudges)
297+
hooks/ ← hooks.json — the plugin's hook manifest
298+
commands/ ← /ultan, /ultan-advisor slash commands
299+
skills/ ← ultan-search skill
295300
daemon/ ← the long-lived event-ingest daemon
296301
agent_mem_daemon/ ← package
297302
tests/ ← pytest suite (see Status below for current count)
298303
pyproject.toml ← uv-managed
299-
src/ ← Phase-0 hook layer (forked from claude-memory-compiler)
300-
hooks/ ← UserPromptSubmit, PostToolUse, Stop, ...
301-
scripts/ ← flush / lint / query
302-
AGENTS.md ← entry-schema reference
303304
tools/
304-
ultan/ ← /ultan, /ultan-install, /ultan-advisor scripts
305+
ultan/ ← /ultan, /ultan-advisor command logic (advisor, remember)
305306
search/ ← `agent-mem search` CLI + BM25 indexer (shared library)
306307
docs/
307308
LIBRARIAN_PROMPT.md ← Librarian role reference

daemon/agent_mem_daemon/paths.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ def offset_state_path() -> Path:
5555
return home() / "daemon.offset.json"
5656

5757

58+
def transcript_marker_path() -> Path:
59+
"""Where the seal path persists which assistant-prose transcript turns
60+
it has already folded into the buffer, keyed per session.
61+
62+
Keyed ``{session_id: [seen_transcript_uuid, ...]}``. A Stop /
63+
SessionEnd event carries the Claude Code ``transcript_path``; the
64+
daemon reads the assistant's natural-language turns since the last
65+
read and injects them into the buffer so the Librarian sees the
66+
model's reasoning (not just tool I/O). This marker is what makes that
67+
read incremental — a Stop firing twice on a growing transcript only
68+
yields the NEW turns. Follows the offset-state idiom
69+
(``ingest._load_offset_state`` / ``_save_offset_state``): atomic
70+
tmp+rename JSON. See ``transcript.read_new_assistant_prose``.
71+
"""
72+
return home() / "daemon.transcript-marker.json"
73+
74+
5875
def fired_helpful_state_path() -> Path:
5976
"""Where the Scholar persists the fired-helpful dedup high-water mark
6077
across daemon restarts.

daemon/agent_mem_daemon/scheduler.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,16 @@
5757
import time
5858
from concurrent.futures import Future, ThreadPoolExecutor
5959
from dataclasses import dataclass
60+
from pathlib import Path
6061
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, cast
6162

6263
from . import librarian as librarian_mod
6364
from . import repair_queue
6465
from . import scholar as scholar_mod
66+
from . import transcript as transcript_mod
6567
from .buffer import Event, RollingBuffer
6668
from .librarian import EvidencePacket
69+
from .paths import transcript_marker_path
6770

6871
if TYPE_CHECKING:
6972
from .ingest import JsonlTailer
@@ -81,6 +84,13 @@
8184
DEFAULT_QUEUE_CEILING = 100 # bounded queue capacity
8285
DEFAULT_SWEEP_INTERVAL_SECS = 300.0 # buffer.sweep cadence
8386

87+
# Role tag stamped on synthetic events carrying assistant natural-language
88+
# prose pulled from the Claude Code transcript on a Stop/SessionEnd. Distinct
89+
# from "assistant" (the default role for tool-I/O events) so the Librarian can
90+
# tell the model's REASONING apart from tool calls. ``flatten_buffer`` renders
91+
# it via the explicit-``payload.role`` path, so it reaches the prompt verbatim.
92+
ASSISTANT_PROSE_ROLE = "assistant-prose"
93+
8494

8595
LibrarianFn = Callable[[Dict[str, Any]], EvidencePacket]
8696
# The scholar callable normally returns None; on an agent TIMEOUT it returns
@@ -438,12 +448,17 @@ def __init__(
438448
config: Optional[SchedulerConfig] = None,
439449
librarian: LibrarianFn = librarian_mod.scan,
440450
scholar: ScholarFn = scholar_mod.review,
451+
transcript_marker: Optional[Path] = None,
441452
) -> None:
442453
self.buffer = buffer
443454
self.config = config or SchedulerConfig()
444455
self._librarian = librarian
445456
self._scholar = scholar
446457
self.stats = SchedulerStats()
458+
# Where assistant-prose transcript reads persist their per-session
459+
# seen-uuid marker (incremental reads). Defaults to the canonical
460+
# daemon-home path; tests inject a tmp path.
461+
self._transcript_marker = transcript_marker or transcript_marker_path()
447462

448463
# Bounded queues — capacity == queue_ceiling, per scope brief.
449464
# We use two separate queues so a slow Scholar doesn't choke
@@ -572,7 +587,17 @@ def stop(self) -> None:
572587

573588
def on_event(self, ev: Event) -> None:
574589
"""Fold an Event into the buffer; arm the debounce timer when
575-
the event seals a turn."""
590+
the event seals a turn.
591+
592+
On a sealing event (``Stop`` / ``SessionEnd``) that carries a
593+
``transcript_path``, we first read the assistant's natural-language
594+
turns from the Claude Code transcript and fold them into the OPEN
595+
turn as synthetic ``assistant-prose`` events — so the same Stop
596+
seals the model's reasoning alongside the tool I/O the Librarian
597+
already sees. Without this the Librarian never sees the assistant's
598+
prose (events.jsonl carries only prompts + tool calls)."""
599+
if ev.type in ("Stop", "SessionEnd"):
600+
self._inject_assistant_prose(ev)
576601
sealed_sess = self.buffer.ingest(ev)
577602
if sealed_sess is None:
578603
return
@@ -606,6 +631,64 @@ def on_event(self, ev: Event) -> None:
606631
self.stats.librarian_debounced += 1
607632
# ``needs_librarian`` is cleared once the snapshot is enqueued.
608633

634+
# ---- assistant-prose injection (transcript → buffer) -----------
635+
636+
@staticmethod
637+
def _transcript_path_of(ev: Event) -> Optional[str]:
638+
"""Pull ``transcript_path`` off a sealing event's payload (or, as a
639+
fallback, the raw event dict the hook author may put it on directly).
640+
Returns ``None`` when absent — the common case until Phase 2 wires the
641+
hook to forward it, so the rest of the daemon degrades to no prose."""
642+
for src in (ev.payload, ev.raw):
643+
cand = src.get("transcript_path")
644+
if isinstance(cand, str) and cand.strip():
645+
return cand
646+
return None
647+
648+
def _inject_assistant_prose(self, sealing_ev: Event) -> None:
649+
"""Read NEW assistant prose for this session's transcript and fold it
650+
into the buffer as synthetic ``assistant-prose`` events BEFORE the
651+
sealing event seals the open turn.
652+
653+
Fail-soft on every axis: no ``transcript_path`` → no-op; unreadable
654+
transcript → empty list (logged DEBUG in the transcript module);
655+
already-seen turns → skipped via the per-session marker. Any
656+
unexpected error is swallowed with ``log.exception`` so transcript
657+
trouble can never break ingestion."""
658+
transcript_path = self._transcript_path_of(sealing_ev)
659+
if not transcript_path:
660+
return
661+
try:
662+
prose = transcript_mod.read_new_assistant_prose(
663+
Path(transcript_path),
664+
session_id=sealing_ev.session_id,
665+
marker_path=self._transcript_marker,
666+
)
667+
except Exception:
668+
log.exception(
669+
"assistant-prose read failed for session=%s; continuing without prose",
670+
sealing_ev.session_id,
671+
)
672+
return
673+
if not prose:
674+
return
675+
for turn in prose:
676+
synthetic = Event(
677+
ts=sealing_ev.ts,
678+
session_id=sealing_ev.session_id,
679+
type="AssistantProse",
680+
cwd=sealing_ev.cwd,
681+
payload={"role": ASSISTANT_PROSE_ROLE, "text": turn.text},
682+
)
683+
# Fold into the OPEN turn (not the Stop itself) so it seals into
684+
# the same turn and flatten_buffer renders it as a quotable line.
685+
self.buffer.ingest(synthetic)
686+
log.info(
687+
"scheduler: folded %d assistant-prose turn(s) into buffer (session=%s)",
688+
len(prose),
689+
sealing_ev.session_id,
690+
)
691+
609692
# ---- debounce → librarian queue --------------------------------
610693

611694
def _on_debounce_fire(self, session_id: str) -> None:

0 commit comments

Comments
 (0)