A scribe for your memory tools: a small classifier reads each exchange and notices which memory tools it called for. Shadow mode logs; whisper mode nudges. It…

A scribe for an agent's memory tools. After each exchange, a small classifier reads what was said and answers a few yes/no questions, one per memory "organ" (a group of tools): did the user just share a lasting fact, did a decision get made, did the agent make a prediction. The scribe compares those answers with the organs the agent actually used.
/scribe prints a table of what the judge noticed against what was used (caught, missed, extra).organs.json.The scribe never writes to memory itself. Tools: mcp__scribe__hush (the agent goes deaf for private conversations; nothing is sent to the classifier until an hour passes with no turns) and mcp__scribe__judge (the agent marks a proposal as fair or noise, so you can see how good the judge is). Command: /scribe [hush|wake|shadow|whisper|off|clear].
The exchange text goes to the classifier endpoint, so by default that endpoint must be on the same machine (loopback). A non-loopback endpoint is refused unless allow_remote is true, and then it needs a bearer token in the SCRIBE_API_KEY environment variable. Nothing is sent at all until you create organs.json.
~/.claude/mods/scribe.CLAUDE_CODE_PLUGIN_DIRS=/path/to/scribe (colon-separate several) in the environment that starts Claude Code, or pass --plugin-dir /path/to/scribe.cp organs.example.json organs.json and edit it. Keys:mode: shadow, whisper or off.endpoint: the classifier URL (default http://127.0.0.1:8765/v1/systemone), model, key_file (optional file holding a bearer token for a loopback endpoint), allow_remote, noul_labels, max_len, timezone.context: one paragraph telling the judge what it is judging.organs: each with an id, the tools that count as using it (full tool names, such as mcp__memory__remember), a threshold, the question (instructions, true, false) and a list of nudges. The example organs use placeholder tool names; change them to your own memory tools.tools/classifier/ is a small loopback server around the open Laya model; see its README. Any service that speaks the same request and response shape will do.SCRIBE_API_KEY (only for a remote endpoint).claude plugin validate .
Mods (function hooks) are an early-access feature of Claude Code and may change between releases without notice. Claude Code writes the type declarations into .claude-plugin/types/ itself when it loads the mod, so they are not included here. Small classifiers are noisy: start in shadow mode, use judge for a while, and only then consider whisper.
hooks/register.ts 284 lines1import type { Register } from 'claude-code'
2
3// scribe: hears each exchange, asks a small classifier (a local endpoint by default) which memory "organs"
4// (groups of tools) the exchange called for, and compares with the organs the agent actually used.
5// shadow mode: log only. whisper mode: slip a nudge into the agent's context. off: do nothing.
6// It never writes to memory. It goes deaf while hushed (private or sensitive conversations).
7// Nothing is sent anywhere unless organs.json exists; remote endpoints also need allow_remote: true.
8
9type Organ = { id: string; tools: string[]; threshold: number; instructions: string; true: string; false: string; nudges: string[] }
10type Cfg = {
11 mode: 'shadow' | 'whisper' | 'off'; model?: string; context: string; organs: Organ[]
12 endpoint?: string // default: a local classifier service on loopback (see tools/classifier)
13 allow_remote?: boolean // must be true to send anything to a non-localhost endpoint
14 key_file?: string // file holding the bearer token for a localhost endpoint; remote endpoints use SCRIBE_API_KEY
15 noul_labels?: { true: string; false: string } // neutral option labels for the yes/no question type
16 max_len?: number // token budget per question sequence
17 timezone?: string // IANA name used when printing times in /scribe (default UTC)
18}
19type Entry = { at: number; user: string; agent: string; used: string[]; scores: Record<string, number>; model?: string; ms?: number; err?: string; whispered?: string[]; verdicts?: Record<string, 'fair' | 'noise'> }
20
21const LOCAL_ENDPOINT = 'http://127.0.0.1:8765/v1/systemone'
22const HUSH_IDLE_MS = 60 * 60000
23
24// True only for http(s) URLs whose host is this machine's loopback.
25function isLoopback(url: string): boolean {
26 try {
27 const u = new URL(url)
28 if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
29 return ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(u.hostname)
30 } catch {
31 return false
32 }
33}
34
35const localKeys = new Map<string, string>()
36async function localKey($: any, path: string | undefined): Promise<string | undefined> {
37 if (!path) return undefined
38 const hit = localKeys.get(path)
39 if (hit) return hit
40 try {
41 const k = String(await $.fs.read(path)).trim()
42 if (k) localKeys.set(path, k)
43 return k || undefined
44 } catch {
45 return undefined
46 }
47}
48
49// Per-turn buffers (main loop only).
50let userText: string[] = []
51let agentText: string[] = []
52let tools = new Set<string>()
53let fromWake = false
54let cachedKey: string | undefined
55
56const OFF: Cfg = { mode: 'off', context: '', organs: [] }
57let tz = 'UTC'
58
59async function cfgOf($: any): Promise<Cfg> {
60 try {
61 const c = JSON.parse(await $.fs.read(`${$.plugin.root}/organs.json`)) as Cfg
62 tz = c.timezone || 'UTC'
63 return c
64 } catch {
65 return OFF // no organs.json yet: copy organs.example.json to organs.json to switch the scribe on
66 }
67}
68
69async function apiKey($: any): Promise<string | undefined> {
70 if (cachedKey) return cachedKey
71 const fromEnv = await $.env.get('SCRIBE_API_KEY')
72 if (fromEnv) return (cachedKey = String(fromEnv).trim())
73 return undefined
74}
75
76// Latency per judge model.
77function byModel(ok: Entry[]): string {
78 const g: Record<string, number[]> = {}
79 for (const e of ok) if (typeof e.ms === 'number') (g[e.model || 'unknown'] ??= []).push(e.ms)
80 const parts = Object.entries(g).map(([m, xs]) => `${m}: avg ${Math.round(xs.reduce((a, b) => a + b, 0) / xs.length)} ms over ${xs.length}`)
81 return parts.length ? `judges · ${parts.join(' · ')}` : 'judges · none yet'
82}
83
84const snip = (s: string, n: number) => (s.length > n ? s.slice(0, n) + '…' : s)
85const flat = (s: string) => s.replace(/\s+/g, ' ').trim()
86const clockTime = (ms: number) => new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(ms))
87
88async function isHushed($: any, now: number): Promise<boolean> {
89 const until = Number((await $.store.get('hushUntil')) ?? 0)
90 if (now < until) {
91 // Still hushed: every turn keeps the hush alive for another idle hour.
92 await $.store.set('hushUntil', Math.max(until, now + HUSH_IDLE_MS))
93 return true
94 }
95 return false
96}
97
98async function record($: any, entry: Entry) {
99 const log = ((await $.store.get('shadow')) as Entry[] | undefined) ?? []
100 log.push(entry)
101 await $.store.set('shadow', log.slice(-400))
102}
103
104async function classify($: any, snap: { user: string; agent: string; used: string[]; wake: boolean }) {
105 const cfg = await cfgOf($)
106 if (cfg.mode === 'off') return
107 const at = await $.clock.now()
108 const base: Entry = { at, user: snip(flat(snap.user), 90), agent: snip(flat(snap.agent), 90), used: snap.used, scores: {} }
109 const endpoint = cfg.endpoint || LOCAL_ENDPOINT
110 const local = isLoopback(endpoint)
111 // Nothing the scribe hears leaves this machine unless organs.json says so in as many words.
112 if (!local && cfg.allow_remote !== true) return record($, { ...base, err: 'refused: endpoint is not localhost and allow_remote is not true' })
113 const key = local ? await localKey($, cfg.key_file) : await apiKey($)
114 if (!local && !key) return record($, { ...base, err: 'no SCRIBE_API_KEY' })
115 if (local && cfg.key_file && !key) return record($, { ...base, err: `cannot read key_file ${cfg.key_file}` })
116
117 const questions: Record<string, unknown> = {}
118 for (const o of cfg.organs) {
119 const q: Record<string, unknown> = { type: 'noul', instructions: o.instructions, criteria: { true: o.true, false: o.false } }
120 if (cfg.noul_labels) q.labels = cfg.noul_labels
121 questions[o.id] = q
122 }
123 const state = {
124 context: cfg.context,
125 user_said: snap.wake ? '(no message from the user: the agent is answering one of its own scheduled wakes)' : snip(snap.user, 2000) || '(nothing)',
126 agent_replied: snip(snap.agent, 3500),
127 }
128 try {
129 const headers: Record<string, string> = { 'Content-Type': 'application/json' }
130 if (key) headers.Authorization = `Bearer ${key}`
131 const body: Record<string, unknown> = { model: cfg.model || 'laya', state, questions }
132 if (cfg.max_len) body.max_len = cfg.max_len
133 const r = await $.http.fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) })
134 const ms = (await $.clock.now()) - at
135 if (!r.ok) return record($, { ...base, ms, err: `http ${r.status}: ${snip(r.text, 160)}` })
136 const out = JSON.parse(r.text) as { model?: string; answers?: Record<string, { noul?: number }> }
137 const scores: Record<string, number> = {}
138 for (const o of cfg.organs) {
139 const p = out.answers?.[o.id]?.noul
140 if (typeof p === 'number') scores[o.id] = Math.round(p * 1000) / 1000
141 }
142 const entry: Entry = { ...base, scores, model: out.model, ms }
143
144 if (cfg.mode === 'whisper') {
145 const missed = cfg.organs
146 .filter(o => (scores[o.id] ?? 0) >= o.threshold && !snap.used.includes(o.id))
147 .sort((a, b) => (scores[b.id] ?? 0) - (scores[a.id] ?? 0))
148 .slice(0, 2)
149 if (missed.length) {
150 const n = Number((await $.store.get('whisperCount')) ?? 0)
151 await $.store.set('whisperCount', n + 1)
152 const lines = missed.map(o => `${o.nudges[n % o.nudges.length] ?? o.id} (score ${(scores[o.id] ?? 0).toFixed(2)})`)
153 entry.whispered = missed.map(o => o.id)
154 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `[scribe: a nudge, not an order; you decide] ${lines.join(' · ')}` }] } })
155 }
156 }
157 return record($, entry)
158 } catch (err) {
159 return record($, { ...base, err: snip(String(err), 160) })
160 }
161}
162
163function organsUsed(cfg: Cfg, names: Set<string>): string[] {
164 return cfg.organs.filter(o => o.tools.some(t => names.has(t))).map(o => o.id)
165}
166
167async function report($: any): Promise<string> {
168 const cfg = await cfgOf($)
169 const log = ((await $.store.get('shadow')) as Entry[] | undefined) ?? []
170 const now = await $.clock.now()
171 const hushUntil = Number((await $.store.get('hushUntil')) ?? 0)
172 const head = [`scribe · mode: ${cfg.mode} · ${now < hushUntil ? `hushed (lifts after an idle hour, ${clockTime(hushUntil)} at the earliest)` : 'listening'} · ${log.length} exchanges heard`]
173 const ok = log.filter(e => !e.err)
174 const errs = log.length - ok.length
175 if (errs) head.push(`errors: ${errs} (last: ${log.filter(e => e.err).slice(-1)[0]?.err})`)
176 if (!ok.length) return [...head, '', 'Nothing classified yet.'].join('\n')
177
178 const rows = ['', 'organ judge-yes used caught missed extra fair noise (judge-yes = p >= threshold; fair/noise = the agent verdicts)']
179 for (const o of cfg.organs) {
180 let yes = 0, used = 0, caught = 0, missed = 0, extra = 0, fair = 0, noise = 0
181 for (const e of ok) {
182 const y = (e.scores[o.id] ?? 0) >= o.threshold, u = e.used.includes(o.id)
183 if (y) yes++
184 if (u) used++
185 if (y && u) caught++
186 if (y && !u) missed++
187 if (!y && u) extra++
188 if (e.verdicts?.[o.id] === 'fair') fair++
189 if (e.verdicts?.[o.id] === 'noise') noise++
190 }
191 rows.push(`${o.id.padEnd(14)} ${String(yes).padStart(7)} ${String(used).padStart(5)} ${String(caught).padStart(7)} ${String(missed).padStart(7)} ${String(extra).padStart(6)} ${String(fair).padStart(5)} ${String(noise).padStart(6)}`)
192 }
193 const recent = ok.slice(-6).map(e => {
194 const flags = cfg.organs.filter(o => (e.scores[o.id] ?? 0) >= o.threshold).map(o => `${o.id} ${(e.scores[o.id] ?? 0).toFixed(2)}`)
195 const t = clockTime(e.at)
196 return `${t} ${e.model || 'judge'}: ${flags.join(', ') || '-'} | used: ${e.used.join(', ') || '-'}${e.whispered ? ` | whispered: ${e.whispered.join(', ')}` : ''}${e.verdicts ? ` | judged: ${Object.entries(e.verdicts).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}\n "${flat(e.agent)}"`
197 })
198 return [...head, '', '```', ...rows.slice(1), '```', '', byModel(ok), '', 'recent:', ...recent.map(r => `- ${r}`)].join('\n')
199}
200
201export const register: Register = on => {
202 on('session.start', async ($, e, next) => {
203 await $.tool.register({
204 name: 'hush',
205 description: "Make the scribe go deaf: nothing from this conversation is sent to its classifier while hushed. Call it yourself when a private or sensitive conversation begins. It lifts on its own after an hour with no turns, or with action 'wake'.",
206 inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['hush', 'wake'] } } },
207 })
208 await $.tool.register({
209 name: 'judge',
210 description: "Give your verdict on what the scribe's judge (the classifier) proposed: for one organ on a recent exchange, 'fair' (it read it right, whether or not you acted) or 'noise' (it was wrong). The judge proposes; you decide. back=0 is the latest heard exchange, 1 the one before.",
211 inputSchema: { type: 'object', properties: { organ: { type: 'string' }, verdict: { type: 'string', enum: ['fair', 'noise'] }, back: { type: 'number' } }, required: ['organ', 'verdict'] },
212 })
213 await $.command.register({ name: 'scribe', description: "The scribe: what its judge noticed vs which memory organs were used. /scribe [hush|wake|shadow|whisper|off|clear]" })
214 return next(e)
215 })
216
217 on('session.append', async ($, e, next) => {
218 const stored = await next(e)
219 if (e.agentId) return stored
220 try {
221 const m = e.message
222 const kind = (e.origin as { kind?: string } | undefined)?.kind
223 if (e.door === 'prompt' && m.type === 'user') {
224 const text = m.content.filter((b: any) => b.type === 'text').map((b: any) => b.text as string).join('\n')
225 if (kind === 'composer' || kind === 'bridge') userText.push(text)
226 else if (kind === 'plugin' && text.startsWith('[clock')) fromWake = true
227 } else if (e.door === 'response' && m.type === 'assistant') {
228 for (const b of m.content as any[]) {
229 if (b.type === 'text' && b.text) agentText.push(b.text as string)
230 if (b.type === 'tool_use' && b.name) tools.add(b.name as string)
231 }
232 }
233 } catch {}
234 return stored
235 })
236
237 on('turn.complete', async ($, e, next) => {
238 if (e.agentId) return next(e)
239 const snap = { user: userText.join('\n\n'), agent: agentText.join('\n\n'), names: tools, wake: fromWake && userText.length === 0 }
240 userText = []; agentText = []; tools = new Set(); fromWake = false
241 if (e.isAborted || !snap.agent.trim()) return next(e)
242 if (await isHushed($, await $.clock.now())) return next(e)
243 const cfg = await cfgOf($)
244 const used = organsUsed(cfg, snap.names)
245 $.clock.after(1, () => { void classify($, { user: snap.user, agent: snap.agent, used, wake: snap.wake }).catch(() => {}) })
246 return next(e)
247 })
248
249 on('tool.call', { tool: 'mcp__scribe__hush' }, async ($, e) => {
250 const i = e as unknown as { action?: string }
251 if (i.action === 'wake') { await $.store.set('hushUntil', 0); return { result: 'scribe listening again' } }
252 await $.store.set('hushUntil', (await $.clock.now()) + HUSH_IDLE_MS)
253 userText = []; agentText = []; tools = new Set()
254 return { result: 'scribe hushed: nothing goes to the classifier until an hour passes with no turns, or you wake it' }
255 })
256
257 on('tool.call', { tool: 'mcp__scribe__judge' }, async ($, e) => {
258 const i = e as unknown as { organ?: string; verdict?: string; back?: number }
259 const log = ((await $.store.get('shadow')) as Entry[] | undefined) ?? []
260 const ok = log.filter(x => !x.err)
261 const target = ok[ok.length - 1 - Math.max(0, Math.floor(Number(i.back ?? 0)))]
262 if (!target) return { result: 'no such exchange' }
263 const organ = String(i.organ ?? ''), verdict = i.verdict === 'noise' ? 'noise' : 'fair'
264 target.verdicts = { ...(target.verdicts ?? {}), [organ]: verdict }
265 await $.store.set('shadow', log)
266 return { result: `judged ${organ} ${verdict} on "${target.agent.slice(0, 50)}..." (score ${(target.scores[organ] ?? 0).toFixed(2)})` }
267 })
268
269 on('command.run', { command: 'scribe' }, async ($, e) => {
270 const arg = e.args.trim()
271 if (arg === 'hush') { await $.store.set('hushUntil', (await $.clock.now()) + HUSH_IDLE_MS); return { text: 'scribe hushed.' } }
272 if (arg === 'wake') { await $.store.set('hushUntil', 0); return { text: 'scribe listening.' } }
273 if (arg === 'clear') { await $.store.set('shadow', []); return { text: 'shadow log cleared.' } }
274 if (arg === 'shadow' || arg === 'whisper' || arg === 'off') {
275 const path = `${$.plugin.root}/organs.json`
276 const cfg = await cfgOf($)
277 cfg.mode = arg
278 await $.fs.write(path, JSON.stringify(cfg, null, 2) + '\n')
279 return { text: `scribe mode: ${arg}` }
280 }
281 return { text: await report($) }
282 })
283}
284