@miadi/hooks-interpreter
v0.11.0
Published
Interpreter/Observer role over Miadi hook streams: reads another agent's raw sessiondata JSONL, normalizes it into canonical events, projects the session, and emits RISE aspects plus a ceremony entry.
Readme
@miadi/hooks-interpreter
Read another agent's session trace and tell a person what it is doing, and whether to step in.
In the Miadi stack, @miadi/hooks-core records what happened in a session. This package reads
those records, and the raw logs other agents write into the same folders, and answers what the
session is becoming. It projects a session into its mission, tools, creations, corrections and
state, writes four short RISE aspects about it, and builds a ceremony entry. It only reads: it
never writes into session data or into the observed agent's repositories. digest runs
read-only git commands in those repositories to check commits.
Works with:
@miadi/hooks-core: its canonical event map is applied first.@miadi/hooks-gateway: owns transport and delivery, which this package does not.
sessiondata JSONL ──► read.ts ──► InterpreterEvent[] ──► project.ts ──► SessionProjection
▲ │
classify.ts │
(what each count is worth) │
│
aspects.ts ◄────────┤
(4 RISE aspects, │
≤55 words each) │
ceremony.ts ◄────────┤
(built, never written) │
│
watch.ts ◄── poll ── diff ── emit ONLY what is new ────┤
fleet.ts ◄── many sessions, one line each ─────────────┤
pane.ts ◄── tmux correlation, confidence not identity ┤
digest.ts ◄── what an inventory needs, bounded ─────────┘
binding line ──┐
live registry ─┼──► sessions.ts ──► SessionJoin: live pane, recorded pane, team, episode,
inventory ─────┘ inventory snapshot, keyed by session id
side ledgers (_terminal_binding, _claude_files_written, _claude_publications)
└──► read.ts as side_ledger records ──► project.ts: binding, files_written,
publications, creations with their copies under written/Install / use
npm install @miadi/hooks-interpreterimport { interpretSession } from "@miadi/hooks-interpreter";
const { projection, aspects, ceremony } = await interpretSession(
"ace95c7d-9ca8-464b-b4dc-f77157241257"
);miadi-hooks-interpret session <id|dir> projection: mission, tools, creations, corrections, state
miadi-hooks-interpret aspects <id|dir> 4 RISE aspects + the ceremony entry
miadi-hooks-interpret agents <id|dir> launch/output correlation, orphans named
miadi-hooks-interpret ceremony <id|dir> the ceremony entry alone
miadi-hooks-interpret watch <id|dir> poll; print ONLY what is new since the last emission
miadi-hooks-interpret pane <id|dir> best-effort tmux correlation, confidence and candidates
miadi-hooks-interpret fleet one line per session across the whole capture root
miadi-hooks-interpret digest <id>... bounded: prompts, last reply, commits vs origin, pushes
miadi-hooks-interpret join [<id>...] session id → live pane, team, episode, inventory record
miadi-hooks-interpret memory [<key>] what Claude memorized: the newest copy of each memory file
miadi-hooks-interpret search <words> which sessions said, wrote, ran or committed it, with the moments
--json --root <dir> --no-dedupe --include-aggregate --include-transcripts --max-bytes <n>
watch: --interval <ms> --once-on-change --quiet --ticks <n>
fleet: --since <45m|2h|3d> --limit <n>
digest: --no-git
join: --episode <folder> --live --inventory <dir> --registry <dir> (repeatable)
search: --since <t> --until <t> --team <id> --session <id> --kind <k,...>
--limit <n> --moments <n> --budget <ms>Session root: --root, else $MIADI_SESSIONDATA_ROOT, else /src/_sessiondata (the same
default hooks-core uses). The package ships compiled CommonJS in dist/ and runs on Node 24.
The digest
digest answers what an agent needs to judge a session, in one call and a few thousand
characters per session, so that agent never reads the raw ledgers or the transcript:
- prompts: how many came from a person and how many from other sessions or the harness
(
<cross-session-message>, task notifications, local commands), the mission, and the last twelve a person typed. The last three keep 800 characters, the others 200. - last reply: the main session's last
assistant.stop, never a subagent's. A long reply keeps its first 600 and last 1,800 characters, because a named next step sits at the end. - typed: the time and opening words of the last text typed into the session, from the
tail of its transcript. A seat that steers with
tmux send-keyslands there exactly as a person does, so it is not called the person's. - commits: from Claude Code's own
tool_response.gitOperationwhen it recorded one, else from git's output (a quietgit commit -qrecords nothing). Each is checked in the tree it was made in: the origin'sowner/repo, and whether a remote-tracking branch holds it. A commit that a later rebase replaced is not on origin, andgit cherrythen says whether its change is on origin's default branch under another sha (rebased, change on origin). - state: the session's state, where closed comes from the last start or end on its binding line when it has one. A resumed session is not reported closed because an earlier run ended. A stamped session-end record newer than the binding line's last start closes the session: hermes writes a binding start and no binding end.
- pushes, pull requests, files written, and how many subagent replies came back.
- published: the Artifact pages, GitHub issues, comments, pull requests, releases and npm versions in the session's publication index, with the prompt each one answered. Commits from the index join the commit list, checked in the tree the index recorded.
- created: each path with the newest copy of it under the session's
written/folder, when the session has a files-written index, so a reader opens the file a session wrote even after it was moved or deleted. - memory: each memory file the session wrote under
<config>/projects/<key>/memory/, with its type and description read from the copy and the path of the copy.MEMORY.mdis marked as the index. - binding: the session's name, team, episode, pane and host from its binding line, and
the miadi-run mission that started it: the pinned tracing prompt (
miadi-tracing:<domain>/prompt/<name>@<version>) and its trace.
Measured 2026-10-07 on gaia: four sessions in 1.1 s and 26 KB, where three agents reading the same kind of sessions raw had taken 180 to 210 KB of tool output each.
Search inside sessions
search <words> answers which sessions said, wrote, ran or committed something, so a reader
finds a session without opening the capture's files. A moment matches when it holds every
word, without regard to case. "a phrase" is one word, and owner/repo#n also matches that
issue's or pull request's URL, and #n in a commit of that repository.
| kind | what it is |
|---|---|
| prompt | what a person typed |
| message | a prompt another session or the harness wrote (a task notification, a cross-session message) |
| reply | the main agent's text: each stamped turn of the progressive ledger, and a turn's last message when no stamped turn holds it |
| command | a command a tool ran |
| file | a file written, from the files-written index, else a write tool's input |
| commit, push | from the publication index, else from what git printed |
| publication | an issue, comment, pull request, page or release |
Each session comes with its name, team, episode and host from the binding line, how many
moments matched by kind, and the newest moments (5 by default, --moments), each with its
kind, time and an excerpt of at most 200 characters around the first word. A time marked
at_inferred is an upper bound: Claude stamps no hook record, so a prompt's time is the first
stamped record of the same prompt, else the ledger's last write.
--since and --until take a duration back from now (2h, 3d), a date or an ISO time.
--team reads the binding line's team, --session takes ids or prefixes of 8 characters or
more, --kind takes kinds from the table.
The scan stats every session directory. The read goes newest first and stops once --limit
sessions matched (10 by default) or --budget milliseconds passed, and bounds.not_read
names how many candidates it left. A ledger is parsed only when its bytes hold every word,
and then only its lines that hold them. Measured 2026-11-10 on gaia: "Konsole" over the
newest 380 of 6,047 sessions in 9 s, and a word in no session over all of them (4.2 GB) in
27 s.
import { searchSessions } from "@miadi/hooks-interpreter";
const { sessions, bounds } = await searchSessions({ query: "Konsole", since: "3d", limit: 5 });What Claude memorized
Claude Code keeps a project's memory in <config>/projects/<key>/memory/, one Markdown file
per memory and MEMORY.md as its index, readable by the user that ran the session only.
The capture copies each memory file a session writes to that session's written/ folder.
memory lists the newest copy of each file across the capture root, by project, with its
type, description, the session that wrote it, how many writes the capture holds, and the
copy's absolute path, so an agent that is not Claude, or runs as another user, can read it.
memory <key> keeps the projects whose key contains <key>.
A session's projection carries the same notes as memories. They come from
_claude_memory.jsonl when the capture keeps it, else from the files-written lines whose path
is a memory file. A memory file removed later is still listed, because the capture does not
see removals. Measured 2026-10-30 on gaia: 68 memory files in 8 projects, read from 149 session
folders in 1.5 s.
The join
join answers where a session stands now, keyed by its session id. It reads three sources
for what each one is:
- The binding line,
<root>/data/terminal_bindings.jsonl. The session-observability hooks append one line per session start, rename, end or observation, with the pane, team and episode at that moment. This is history: each field takes the newest line that carries it, so an end line with no pane keeps the pane recorded before it. A session asked for by id that this file does not hold is read from its own copy,<root>/<id>/_terminal_binding.jsonl. - The live registry,
~/.claude/sessions/<pid>.jsonby default (--registryadds others). Claude Code keeps one entry per running session with its tmux pane now. An entry counts only while its pid runs and is the same process (procStartequals the start time in/proc/<pid>/stat), so a reused pid never makes a dead session look live. - The inventory,
<dir>/<session_id>.jsonwith--inventory <dir>. It is read as a dated snapshot (modified_at). Its stored pane is never used. Since 0.6.0 the snapshot carries what the record says by title:mission,episode,work_completed,held_decisionsandnext_steps, each entry with whom it waits on when the record names one; since 0.7.0 alsorepo(owner/repo),tmux_session_nameandevidence(the refs oflinks.evidence); since 0.10.0trace(theowner/repo@sharefs oflinks.trace),related(episodes, issues, circles, ceremonies, foundations, sessions),missions({id, how, given_at, episode}) andcapabilities({capability, relation, version, evidence}). The keeper's descriptions and file lists stay in the record. WithepisodeandinventoryDirtogether,sessionJoinsalso returns the sessions whose record names that episode folder, including a session whose line carries only thedeclaredfallback and one no line on this host holds.
Missions (0.8.0, jgwill/Miadi#868). linkMissions(joins, missions) gives each session the
missions it belongs to, in missions: [{ id, how, of? }]. sessionJoins does the same when you
pass missions (what a host reads from miadi-run's records: id, promptName, seats,
signers). how is one of four values, strongest first:
received: itssession.missionline, or the record's seat.signed: it wrote one of the mission's events.fork-of: it was launched with--resume <id> --fork-sessionfrom a session that belongs, andofnames that session. The join keeps only the parent id, asfork_of, never argv.named-after: its name or tmux session begins with the prompt's name.
The links are made over every session before an episode filter, so a fork's parent outside the episode still counts.
The pane to steer or peek is live.pane_id. recorded stays beside it with the time it was
written. A recorded pane is never promoted to a live one, because after a tmux restore
another occupant may hold it. An episode whose source is declared is the exported
MIADI_CHRONICLE_PROD_EPISODE fallback, so it binds no session to that episode.
import { sessionJoins, observeEpisodeSessions, parseBindingLines } from "@miadi/hooks-interpreter";
const [me] = sessionJoins({ sessionIds: [sessionId], inventoryDir });
me.live?.pane_id; // where it runs now, or no live block when it does not run here
// pure, no fs: the sessions that worked in one episode, from lines the host read
observeEpisodeSessions(parseBindingLines(jsonl), "2026-10-10-episode-552-what-an-episode-holds");No join or observation carries argv, cwd, transcript_path or pid.
How it reads
Five producers, five shapes. Claude, Codex, Gemini, Hermes and openclaw write different JSONL into the same folders. The reader takes all of them, including pretty-printed JSON inside a
.jsonlfile. Alias ledgers holding identical records are collapsed. Aggregate ledgers that repeat every other file are skipped unless--include-aggregateis given. Large files are streamed and cut at--max-bytes(64 MiB by default), and a cut file is named in the report.Time is stated, not assumed. Where a producer writes no timestamp (Claude writes none), an event's time is the file's modification time, an upper bound, and is flagged
at_inferred.report.orderingsays how far the order of events can be trusted, and tool calls are paired without relying on it.Aspects are assembled, not generated. Same projection, same bytes: no model, no network, no key. Each of the four aspects is at most 55 words, by construction. A detail is added only while it fits, so the desired-state sentence is never the part that gets cut.
Corrections carry their evidence. Each correction a person made is graded:
| confidence | meaning | |---|---| |
rejected-tool-recorded| the record names the tool the person rejected, with their words | |distinct-feedback-field| a correction-only field carried the text, with no rejected tool | |ledger-filename-only| only the name of the file it landed in says it is a correction |A record contradicted by the session itself (it is the first prompt, or it is the mission) moves to
corrections_disputedwith its reason, unless it names a rejected tool. Nothing is dropped silently. Aledger-filename-onlycount is a ceiling on redirections, not a count of them.Side ledgers are read for what they are. The session-observability capture keeps four ledgers beside the hook stream: the session's binding line (
_terminal_binding.jsonl), the files-written index (_claude_files_written.jsonl, one line per file any tool call created, changed or moved, with its copy underwritten/), the publication index (_claude_publications.jsonl) and the memory index (_claude_memory.jsonl, one line per memory file Claude wrote). Their records are markedmapping_source: "side_ledger"and counted inreport.side_ledgers. Their stamps never make a stream look ordered: fororderingthey count as unstamped records, as the binding line did before 0.5.0. They are kept out of the time window and the state's newest record. The projection reads them intobinding,files_written,publications,memoriesandcreations. A session without them reads as before: creations come from write tools' inputs alone, and the classification says so.Plans are derived.
plan_or_pde.proposedis derived fromExitPlanMode,exit_plan_modeandupdate_plantool calls, and kept out of the main stream so a derivation is never mistaken for a capture.watchpolls. Producers replace their files by rename, which leaves a file watcher silent.watchchecks every--interval(3 s by default), prints only what changed, and has no channel back to the observed agent. A new publication is printed even under--quiet, because it reached people outside the session. Steering belongs to the surface that owns the agent.panereports confidence. No event on disk carriesTMUX_PANE, so a session is matched to a tmux pane by working directory, and a pane is named only when the match is unique.fleetscans everything and projects a bounded number. What--limitleft out is named inbounds.not_projected.searchscans everything and reads newest first. What--limitor--budgetleft unread is named inbounds.not_read.readSession'slineFilterandfileFilterare how it reads only what holds the words.The ceremony entry is built, never written.
buildCeremonyEntry()returns it withwritten: falseand astore_hint, and the caller decides where it goes.
Reading the report
ReadReport is the honesty surface. Nothing vanishes quietly:
files_read / files_skipped every skip states its reason
lines_seen / records_parsed
malformed_skipped lines that never became JSON
multiline_recovered pretty-printed records rescued
duplicates_collapsed alias-ledger records collapsed
truncated_files read cut short; tail NOT read
filtered_out one-line records a lineFilter skipped unparsed
unmapped_native_events native names no map claimed, with counts
side_ledgers records read from the capture's side ledgers, by stem
ordering how far the array order can be trustedBoundary
hooks-interpreter owns:
- reading and normalising heterogeneous sessiondata JSONL into
InterpreterEvent - projecting a session into mission / tools / creations / corrections / state
- RISE aspect emission under an enforced word budget
- subagent launch↔output correlation, orphans included
- building the ceremony entry
hooks-interpreter does not own:
- provider hook behaviour or JSONL writing (that is
hooks-core) - transport, ledger or worker dispatch (that is
hooks-gateway) - writing into the Medicine-Wheel store, or any act on the Implementer's repos
Limits
It makes a long run legible, not steerable, and it cannot tell who it is watching. "Live" means
within --interval, and only for what a producer has already written to disk. There is no
trace_id in the data and no cross-session causality: two sessions that worked on the same
thing are two rows.
