A side pane for Claude Code beside your chat: what jevmem recalled for this prompt, what its guard decided, and what the last turn saved, with why, wrong and…

A side pane for Claude Code, beside your chat, that shows what jevmem did for the prompt you just sent and the turn that just ended:
Each line has why, wrong and forget buttons. forget and wrong change JEVMEM.md (the line is kept as [retired]), so they ask first; a rule is retired in a terminal only, as jevmem trust works. The pane reads three local files under .jevmem/ and runs the jevmem command by argument vector; it sends nothing anywhere and reads no key.
In Claude Code, with the jevmem marketplace added (claude plugin marketplace add Avinash-jetwani/jevmem):
/plugin install jevmem-pane@jevmem
It needs the jevmem command (npm install -g jevmem) and a project that is turned on (jevmem setup). The pane opens on its own in a terminal 144 columns wide or wider; /jevmem opens or closes it at any width (docked beside the chat from 110 columns in the fullscreen terminal, above the prompt below that). Claude Code only: Cursor has no pane.
The whole description, with what each state means, is in docs/pane.md.
hooks/register.tsx 294 lines1// jevmem-pane (0.7.5): a side pane for Claude Code beside the chat, drawn from three local files the jevmem CLI writes
2// (docs/hooks.md, "Local files for the pane"): what the last prompt got (`.jevmem/last-recall.json`, read once
3// `prompt.submit`'s next(e) returns, when the file is there: measured 11 of 11), what the guard decided for a call
4// (`.jevmem/guard-log.jsonl`, by the call's tool_use_id, once `tool.call`'s next(e) returns: 11 of 11), and what the
5// last turn's decision was (`.jevmem/last-decision.json`, which lands 0.5 to 0.8 s after `turn.complete`: a poll every
6// 250 ms, for 15 s at most, then what the queue or the log says). The buttons run the jevmem command by argv; the two
7// that change JEVMEM.md ask first; a rule stays terminal-only, as `trust` is. Nothing is sent anywhere.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10import type { Cli, Decision, GuardEntry, Pending, Prompt, Recall, Saving } from '../types'
11import { findCli, NOT_FOUND, runCli } from './cli'
12import { failureAfter, findGuardEntry, guardEntries, hhmm, isHash, isId, parseDecision, parseRecall, promptHash, queuedAfter } from './files'
13import { draw, type View, type Where } from './view'
14
15const PANE = 'jevmem'
16const CONFIG = 'jevmem.config.json'
17const RECALL = '.jevmem/last-recall.json'
18const DECISION = '.jevmem/last-decision.json'
19const GUARD = '.jevmem/guard-log.jsonl'
20const QUEUE = '.jevmem/queue.jsonl'
21const LOG = '.jevmem/log.jsonl'
22const WAIT_MS = 15_000
23const POLL_MS = 250
24const GUARDED = new Set(['Bash', 'Edit', 'Write'])
25
26// The view's state, one atom per key of the contract (pane/types/index.d.ts); a reload or a hooks-worker restart keeps it.
27const S_ON = atom({ plugin: 'jevmem-pane', key: 'on' } as const, false)
28const S_OPENED = atom({ plugin: 'jevmem-pane', key: 'opened' } as const, false)
29const S_CLI = atom({ plugin: 'jevmem-pane', key: 'cli' } as const, null as Cli)
30const S_PROMPT = atom({ plugin: 'jevmem-pane', key: 'prompt' } as const, null as Prompt | null)
31const S_RECALL = atom({ plugin: 'jevmem-pane', key: 'recall' } as const, null as Recall | null)
32const S_RECALL_STATE = atom({ plugin: 'jevmem-pane', key: 'recallState' } as const, 'none' as 'none' | 'this' | 'other' | 'missing')
33const S_GUARD = atom({ plugin: 'jevmem-pane', key: 'guard' } as const, null as { entry: GuardEntry; thisCall: boolean } | null)
34const S_LAST_CALL = atom({ plugin: 'jevmem-pane', key: 'lastCall' } as const, null as GuardEntry | { tool: string; missing: true } | null)
35const S_DECISION = atom({ plugin: 'jevmem-pane', key: 'decision' } as const, null as Decision | null)
36const S_SAVING = atom({ plugin: 'jevmem-pane', key: 'saving' } as const, 'none' as Saving)
37const S_SAVING_NOTE = atom({ plugin: 'jevmem-pane', key: 'savingNote' } as const, null as string | null)
38const S_PENDING = atom({ plugin: 'jevmem-pane', key: 'pending' } as const, null as Pending)
39
40async function readText($: EngineInterface, path: string): Promise<string | null> {
41 try {
42 return await $.fs.read(path)
43 } catch {
44 return null
45 }
46}
47/** The project is on: `jevmem.config.json` exists and does not say `enabled: false` (what the CLI's hooks check). */
48async function projectOn($: EngineInterface): Promise<boolean> {
49 const text = await readText($, CONFIG)
50 if (text === null) return false
51 try {
52 const c = JSON.parse(text) as { enabled?: unknown } | null
53 return !(c && typeof c === 'object' && c.enabled === false)
54 } catch {
55 return true
56 }
57}
58async function sessionId($: EngineInterface): Promise<string | null> {
59 try {
60 return await $.session.id()
61 } catch {
62 return null
63 }
64}
65async function snapshot($: EngineInterface): Promise<View> {
66 const [on, cli, prompt, recall, recallState, guard, lastCall, decision, saving, savingNote, pending] = await Promise.all([
67 read($, S_ON), read($, S_CLI), read($, S_PROMPT), read($, S_RECALL), read($, S_RECALL_STATE), read($, S_GUARD), read($, S_LAST_CALL), read($, S_DECISION), read($, S_SAVING), read($, S_SAVING_NOTE), read($, S_PENDING),
68 ])
69 return { on, hasCli: Boolean(cli), promptSeen: Boolean(prompt), recall, recallState, guard, lastCall, decision, saving, savingNote, pending }
70}
71const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
72
73/** The recall file is this prompt's when its `prompt_hash` is the hash of the prompt as typed and, since 0.7.6, its `session_id` is this session's (two sessions in one project can send the same prompt). */
74async function refreshRecall($: EngineInterface, hash: string, session: string | null): Promise<void> {
75 const file = parseRecall(await readText($, RECALL))
76 if (!file) {
77 await update($, S_RECALL, () => null)
78 await update($, S_RECALL_STATE, () => 'missing')
79 } else if (file.prompt_hash === hash && !(session && file.session_id && file.session_id !== session)) {
80 await update($, S_RECALL, () => file)
81 await update($, S_RECALL_STATE, () => 'this')
82 } else {
83 await update($, S_RECALL, () => null)
84 await update($, S_RECALL_STATE, () => 'other')
85 }
86}
87/** The decision file is this turn's by the prompt's hash when it carries one, else by time, never another session's. */
88function isThisTurn(d: Decision, p: Prompt, session: string | null): boolean {
89 if (session && d.session_id && d.session_id !== session) return false
90 if (d.prompt_hash && d.prompt_hash === p.hash) return true
91 return Date.parse(d.ts) >= p.at
92}
93async function settle($: EngineInterface, d: Decision): Promise<void> {
94 await update($, S_DECISION, () => d)
95 await update($, S_SAVING, () => 'ready')
96 await update($, S_SAVING_NOTE, () => null)
97}
98/** Polls for this turn's decision, then says what the queue or the log says instead. Detached from the hook; a reload or an unload ends it quietly. */
99async function waitForDecision($: EngineInterface, p: Prompt, session: string | null): Promise<void> {
100 try {
101 await pollForDecision($, p, session)
102 } catch {
103 /* the module was unloaded or reloaded while waiting: nothing to show any more */
104 }
105}
106async function pollForDecision($: EngineInterface, p: Prompt, session: string | null): Promise<void> {
107 for (let waited = 0; waited < WAIT_MS; waited += POLL_MS) {
108 await $.clock.sleep(POLL_MS)
109 const cur = await read($, S_PROMPT)
110 if (!cur || cur.at !== p.at) return // a newer prompt took over
111 const d = parseDecision(await readText($, DECISION))
112 if (d && isThisTurn(d, p, session)) {
113 await settle($, d)
114 redraw($)
115 return
116 }
117 }
118 const cur = await read($, S_PROMPT)
119 if (!cur || cur.at !== p.at) return
120 const q = queuedAfter(await readText($, QUEUE), p.at)
121 if (q) {
122 const e = q.entry
123 const attempts = e.attempts ?? 0
124 const note = attempts > 0 ? `queued for retry: ${e.lastError ?? 'Jev did not answer'} (attempt ${attempts})${e.nextAttemptAt ? `; next try ${hhmm(e.nextAttemptAt)}` : ''}` : `queued (${q.before} older turn${q.before === 1 ? '' : 's'} before it)`
125 await update($, S_SAVING, () => 'queued')
126 await update($, S_SAVING_NOTE, () => note)
127 } else {
128 const failure = failureAfter(await readText($, LOG), p.at)
129 await update($, S_SAVING, () => (failure ? 'failed' : 'unknown'))
130 await update($, S_SAVING_NOTE, () => (failure ? `failed: ${failure}` : 'not decided yet: nothing in the log for this turn (the daemon may still be working through older turns)'))
131 }
132 redraw($)
133}
134
135async function note($: EngineInterface, where: Where, text: string): Promise<void> {
136 await update($, S_PENDING, (): Pending => ({ kind: 'note', where, text }))
137 redraw($)
138}
139async function doWhy($: EngineInterface, idOrHash: string, where: Where): Promise<void> {
140 if (!isId(idOrHash) && !isHash(idOrHash)) return note($, where, 'not a memory id')
141 const cli = await read($, S_CLI)
142 if (!cli) return note($, where, NOT_FOUND)
143 const r = await runCli($, cli, ['why', idOrHash])
144 await update($, S_PENDING, (): Pending => ({ kind: 'output', where, title: `jevmem why ${idOrHash}${r.ok ? '' : ' (failed)'}`, text: r.text }))
145 redraw($)
146}
147async function doRetire($: EngineInterface, action: 'forget' | 'wrong', id: string, kind: string | null, where: Where): Promise<void> {
148 if (!isId(id)) return note($, where, 'not a memory id')
149 if (kind === 'constraint') return note($, where, action === 'forget' ? `a rule is retired in a terminal: jevmem forget ${id}` : `a rule is retired in a terminal: jevmem wrong ${id} --should-be none`)
150 await update($, S_PENDING, (): Pending => ({ kind: 'confirm', where, action, id }))
151 redraw($)
152}
153/** 0.7.6: a skipped turn's `missed` button: a confirm, then `jevmem missed "<the prompt as typed>"`, which labels the turn and adds the line. */
154async function doMissed($: EngineInterface, where: Where): Promise<void> {
155 const p = await read($, S_PROMPT)
156 if (!p || !p.text.trim()) return note($, where, 'no prompt text for this turn')
157 await update($, S_PENDING, (): Pending => ({ kind: 'confirm', where, action: 'missed', id: '', text: p.text }))
158 redraw($)
159}
160async function doConfirm($: EngineInterface, yes: boolean): Promise<void> {
161 const p = await read($, S_PENDING)
162 if (!p || p.kind !== 'confirm') return
163 if (!yes) {
164 await update($, S_PENDING, () => null)
165 redraw($)
166 return
167 }
168 const cli = await read($, S_CLI)
169 if (!cli) return note($, p.where, NOT_FOUND)
170 const args = p.action === 'forget' ? ['forget', p.id] : p.action === 'wrong' ? ['wrong', p.id, '--should-be', 'none'] : ['missed', p.text ?? '']
171 const r = await runCli($, cli, args)
172 await update($, S_PENDING, (): Pending => ({ kind: 'output', where: p.where, title: `jevmem ${p.action === 'missed' ? 'missed "…"' : args.join(' ')}${r.ok ? '' : ' (failed)'}`, text: r.text }))
173 redraw($)
174}
175async function doGuardLog($: EngineInterface): Promise<void> {
176 const cli = await read($, S_CLI)
177 if (!cli) return note($, 'guard', NOT_FOUND)
178 const r = await runCli($, cli, ['guard', 'log', '-n', '5'])
179 await update($, S_PENDING, (): Pending => ({ kind: 'output', where: 'guard', title: `jevmem guard log -n 5${r.ok ? '' : ' (failed)'}`, text: r.text }))
180 redraw($)
181}
182async function doClose($: EngineInterface): Promise<void> {
183 await update($, S_PENDING, () => null)
184 redraw($)
185}
186
187/**
188 * Opens the pane once per session: at session.start when a surface is attached (the terminal), else when one attaches
189 * (the desktop app's Code tab attaches after the mod loaded: an open before that answered `no-surface`), else at the
190 * first prompt. Unasked, the engine places it from 144 terminal columns and keeps it waiting below that; /jevmem
191 * places it at any width.
192 */
193async function openOnce($: EngineInterface): Promise<void> {
194 if (!(await read($, S_ON)) || (await read($, S_OPENED))) return
195 await update($, S_OPENED, () => true)
196 await $.ui.open({ id: PANE, title: 'jevmem', columns: 48 })
197}
198
199export const register: Register = (on) => {
200 on('session.start', async ($, e, next) => {
201 await $.command.register({ name: 'jevmem', description: 'Open or close the jevmem pane: what was recalled for this prompt, what the guard decided, what the last turn saved' })
202 const isOn = await projectOn($)
203 await update($, S_ON, () => isOn)
204 await update($, S_OPENED, () => false)
205 if (isOn) {
206 const cli = await findCli($)
207 await update($, S_CLI, () => cli)
208 if ((await $.session.surfaces()).length > 0) await openOnce($)
209 }
210 return next(e)
211 })
212
213 on('session.attach', async ($, e, next) => {
214 const r = await next(e)
215 await openOnce($)
216 return r
217 })
218
219 on('command.run', { command: 'jevmem' }, async ($) => {
220 if (!(await read($, S_ON))) return { text: 'jevmem is not turned on in this project: run jevmem setup here, then start a new session.' }
221 const placed = (await $.ui.panes()).some((p) => p.id === PANE && p.isPlaced)
222 if (placed) {
223 await $.ui.close({ id: PANE })
224 return { text: 'jevmem pane closed.' }
225 }
226 const opened = await $.ui.open({ id: PANE, title: 'jevmem', focus: true, columns: 48, rows: 14 })
227 return { text: opened.isPlaced ? 'jevmem pane opened.' : `jevmem pane opened, waiting to be placed: ${opened.reason}` }
228 })
229
230 on('prompt.submit', async ($, e, next) => {
231 if (e.command || e.isMeta || !(await read($, S_ON))) return next(e)
232 await openOnce($)
233 const at = await $.clock.now()
234 const hash = await promptHash(e.text)
235 await update($, S_PROMPT, () => ({ at, hash, text: e.text }))
236 await update($, S_SAVING, () => 'saving')
237 await update($, S_SAVING_NOTE, () => null)
238 await update($, S_PENDING, () => null)
239 const r = await next(e)
240 await refreshRecall($, hash, await sessionId($))
241 redraw($)
242 return r
243 })
244
245 on('tool.call', async ($, e, next) => {
246 const r = await next(e)
247 if (!GUARDED.has(String(e.tool)) || !(await read($, S_ON))) return r
248 const entries = guardEntries(await readText($, GUARD))
249 const mine = e.tool_use_id ? findGuardEntry(entries, e.tool_use_id) : undefined
250 if (!mine) await update($, S_LAST_CALL, () => ({ tool: String(e.tool), missing: true as const }))
251 else {
252 await update($, S_LAST_CALL, () => mine)
253 if (mine.decision !== 'none') await update($, S_GUARD, () => ({ entry: mine, thisCall: true }))
254 else {
255 const g = await read($, S_GUARD)
256 if (g?.thisCall) await update($, S_GUARD, () => ({ entry: g.entry, thisCall: false }))
257 }
258 }
259 redraw($)
260 return r
261 })
262
263 on('turn.complete', async ($, e, next) => {
264 if (e.agentId !== undefined || !(await read($, S_ON))) return next(e)
265 const p = await read($, S_PROMPT)
266 if (p) {
267 const session = await sessionId($)
268 const d = parseDecision(await readText($, DECISION))
269 if (d && isThisTurn(d, p, session)) {
270 await settle($, d)
271 redraw($)
272 } else {
273 await update($, S_SAVING, () => 'saving')
274 redraw($)
275 void waitForDecision($, p, session)
276 }
277 }
278 return next(e)
279 })
280
281 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
282 const v = await snapshot($)
283 return draw($, e, v, {
284 why: (idOrHash, where) => void doWhy($, idOrHash, where),
285 wrong: (id, kind, where) => void doRetire($, 'wrong', id, kind, where),
286 forget: (id, kind, where) => void doRetire($, 'forget', id, kind, where),
287 guardLog: () => void doGuardLog($),
288 missed: (where) => void doMissed($, where),
289 confirm: (yes) => void doConfirm($, yes),
290 close: () => void doClose($),
291 })
292 })
293}
294hooks/cli.ts 60 lines1// Finding and running the jevmem CLI, by argv and never through a shell: the launcher's order (PATH, the usual bin
2// folders, the newest nvm Node), then a `jevmem init` project's hook command. The TypeSafe key is never read here.
3import type { EngineInterface } from 'claude-code'
4import type { Cli } from '../types'
5import { hookCommandArgv } from './files'
6
7async function exists($: EngineInterface, path: string): Promise<boolean> {
8 try {
9 return await $.fs.exists(path)
10 } catch {
11 return false
12 }
13}
14
15export async function findCli($: EngineInterface): Promise<Cli> {
16 const home = (await $.env.get('HOME')) ?? ''
17 const onPath = ((await $.env.get('PATH')) ?? '').split(':').filter(Boolean)
18 const dirs = [...onPath, '/opt/homebrew/bin', '/usr/local/bin', ...(home ? [`${home}/.local/bin`, `${home}/.volta/bin`] : [])]
19 for (const dir of dirs) if (await exists($, `${dir}/jevmem`)) return { argv: [`${dir}/jevmem`] }
20 if (home) {
21 try {
22 const versions = (await $.fs.list(`${home}/.nvm/versions/node`))
23 .map((e) => e.name)
24 .filter((n) => /^v\d+\.\d+\.\d+$/.test(n))
25 .sort((a, b) => cmpVersion(b, a))
26 for (const v of versions) if (await exists($, `${home}/.nvm/versions/node/${v}/bin/jevmem`)) return { argv: [`${home}/.nvm/versions/node/${v}/bin/jevmem`] }
27 } catch {
28 /* no nvm */
29 }
30 }
31 try {
32 const argv = hookCommandArgv(await $.fs.read('.claude/settings.local.json'))
33 if (argv) return { argv }
34 } catch {
35 /* not a jevmem init project */
36 }
37 return null
38}
39
40function cmpVersion(a: string, b: string): number {
41 const pa = a.slice(1).split('.').map(Number)
42 const pb = b.slice(1).split('.').map(Number)
43 for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0)
44 return 0
45}
46
47export const NOT_FOUND = 'the jevmem command was not found on this computer: npm install -g jevmem, then start a new session'
48
49/** Runs `jevmem <args>` in the session's folder and returns what it printed; stderr follows stdout, an exit code other than 0 is an error. */
50export async function runCli($: EngineInterface, cli: Cli, args: readonly string[]): Promise<{ ok: boolean; text: string }> {
51 if (!cli) return { ok: false, text: NOT_FOUND }
52 try {
53 const r = await $.process.run([...cli.argv, ...args], { timeoutMs: 15_000 })
54 const text = [r.stdout.trim(), r.stderr.trim()].filter(Boolean).join('\n')
55 return { ok: r.exitCode === 0, text: text || (r.exitCode === 0 ? 'done' : `jevmem exited with ${r.exitCode}`) }
56 } catch (err) {
57 return { ok: false, text: `jevmem could not run: ${err instanceof Error ? err.message : String(err)}` }
58 }
59}
60hooks/files.ts 156 lines1// The pane's readers: pure functions over the text of the three local files the jevmem CLI writes
2// (`.jevmem/last-recall.json`, `.jevmem/last-decision.json`, `.jevmem/guard-log.jsonl`), the queue and the log it
3// falls back to, and the hook command of a `jevmem init` project. No `$` here, so the tests hold them to fixtures.
4import type { Decision, GuardEntry, Recall } from '../types'
5
6/** A memory id as JEVMEM.md has them: six of a-z and 0-9. Nothing else is ever passed to the command. */
7export const ID = /^[a-z0-9]{6}$/
8/** A turn's hash as `.jevmem/decisions.jsonl` has them: sixteen hex characters. */
9export const HASH = /^[0-9a-f]{16}$/
10export const isId = (s: unknown): s is string => typeof s === 'string' && ID.test(s)
11export const isHash = (s: unknown): s is string => typeof s === 'string' && HASH.test(s)
12
13function parse(text: string | null): unknown {
14 if (!text) return null
15 try {
16 return JSON.parse(text)
17 } catch {
18 return null
19 }
20}
21
22export function parseRecall(text: string | null): Recall | null {
23 const v = parse(text) as Partial<Recall> | null
24 if (!v || typeof v !== 'object' || typeof v.prompt_hash !== 'string' || !Array.isArray(v.served)) return null
25 return { ...v, served: v.served.filter((m) => m && typeof m === 'object'), withheld: Array.isArray(v.withheld) ? v.withheld : [] } as Recall
26}
27
28export function parseDecision(text: string | null): Decision | null {
29 const v = parse(text) as Partial<Decision> | null
30 if (!v || typeof v !== 'object' || typeof v.ts !== 'string' || typeof v.action !== 'string') return null
31 return v as Decision
32}
33
34/** The entries of a JSON-lines file, bad lines skipped. */
35export function jsonLines<T>(text: string | null): T[] {
36 const out: T[] = []
37 for (const line of (text ?? '').split('\n')) {
38 if (!line.trim()) continue
39 try {
40 out.push(JSON.parse(line) as T)
41 } catch {
42 /* a half-written or foreign line */
43 }
44 }
45 return out
46}
47
48export const guardEntries = (text: string | null): GuardEntry[] => jsonLines<GuardEntry>(text).filter((e) => e && typeof e.tool === 'string')
49
50/** The log's entry for a call, by `tool_use_id`, searched from the end (the newest calls are last). */
51export function findGuardEntry(entries: readonly GuardEntry[], toolUseId: string): GuardEntry | undefined {
52 for (let i = entries.length - 1; i >= 0; i--) if (entries[i]!.tool_use_id === toolUseId) return entries[i]
53 return undefined
54}
55
56/** The route of a checked call, in plain words. */
57export const routeWords: Record<string, string> = {
58 'no-rules': 'no rule',
59 'no-candidate': 'no rule matched',
60 cache: 'cached',
61 jev: 'Jev',
62 'jev-failed': 'Jev did not answer',
63 'no-key': 'no key',
64 'no-time': 'no time',
65 error: 'error',
66}
67
68export type QueueEntry = { hash?: string; enqueuedAt?: string; attempts?: number; nextAttemptAt?: string; lastError?: string }
69/** The queue's entry for a turn queued at or after `sinceMs`, the newest, with how many older turns wait before it. */
70export function queuedAfter(text: string | null, sinceMs: number): { entry: QueueEntry; before: number } | null {
71 const items = jsonLines<QueueEntry>(text)
72 for (let i = items.length - 1; i >= 0; i--) {
73 const e = items[i]!
74 if (e.enqueuedAt && Date.parse(e.enqueuedAt) >= sinceMs) return { entry: e, before: i }
75 }
76 return null
77}
78
79type LogEntry = { ts?: string; label?: string; ok?: boolean; event?: string; error?: string; detail?: string }
80/** What the log says went wrong at or after `sinceMs`: a hook's error, or a turn the queue dropped. The newest. */
81export function failureAfter(text: string | null, sinceMs: number): string | null {
82 const items = jsonLines<LogEntry>(text)
83 for (let i = items.length - 1; i >= 0; i--) {
84 const e = items[i]!
85 if (!e.ts || Date.parse(e.ts) < sinceMs) continue
86 if (e.label === 'hook' && e.ok === false && e.error) return e.error
87 if (e.label === 'queue' && e.event === 'dropped' && e.detail) return `the turn was dropped: ${e.detail}`
88 }
89 return null
90}
91
92/**
93 * The argv of the jevmem CLI from a `jevmem init` project's hook command in `.claude/settings.local.json`:
94 * `"<node>" "<…>/cli.js" hook` gives `[node, cli.js]`; a launcher command (`sh "…/jevmem-hook.sh" --node "<node>" … hook`)
95 * gives nothing here, since the launcher resolves the CLI itself.
96 */
97export function hookCommandArgv(settingsText: string | null): string[] | null {
98 const settings = parse(settingsText) as { hooks?: Record<string, { hooks?: { command?: string }[] }[]> } | null
99 const groups = settings?.hooks ? Object.values(settings.hooks).flat() : []
100 for (const g of groups) {
101 for (const h of g?.hooks ?? []) {
102 const tokens = tokens_(h?.command ?? '')
103 const cli = tokens.findIndex((t) => /(^|[\\/])cli\.js$/.test(t))
104 if (cli > 0 && tokens[cli + 1] === 'hook') return [tokens[cli - 1]!, tokens[cli]!]
105 }
106 }
107 return null
108}
109function tokens_(command: string): string[] {
110 const out: string[] = []
111 const re = /"([^"]*)"|'([^']*)'|(\S+)/g
112 let m: RegExpExecArray | null
113 while ((m = re.exec(command))) out.push(m[1] ?? m[2] ?? m[3]!)
114 return out
115}
116
117const hex = (buf: ArrayBuffer) => Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
118/** The hash the hook writes as `prompt_hash`: sha1 of the prompt as typed, its first sixteen hex characters. */
119export async function promptHash(text: string): Promise<string> {
120 return hex(await crypto.subtle.digest('SHA-1', new TextEncoder().encode(text))).slice(0, 16)
121}
122
123/** `HH:MM` of an ISO time, in the session's local time. */
124export function hhmm(iso: string): string {
125 const d = new Date(iso)
126 if (Number.isNaN(d.getTime())) return '?'
127 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
128}
129
130/**
131 * The plain words for a turn that saved nothing, from the decision's reason (the details stay behind `why`, which
132 * prints the whole decision): the first rule that fits, from the most specific. A deferred turn and an error have
133 * their own words.
134 */
135export function plainDecision(d: Decision): string {
136 const r = `${d.reason ?? ''} ${d.detail ?? ''}`
137 if (d.action === 'deferred') {
138 const n = /(\d+) background subagent/.exec(r)?.[1]
139 return n ? `Waiting: ${n} background subagent${n === '1' ? ' has' : 's have'} not reported back` : 'Waiting: the turn is still running'
140 }
141 if (d.action === 'error') return `Failed: ${short(d.detail || d.reason || 'the decision ended in an error')}`
142 if (d.action !== 'skipped') return `${d.action}: ${short(d.reason ?? d.detail ?? '')}`
143 if (/^empty turn/.test(d.reason ?? d.detail ?? '')) return 'Not saved: an empty turn'
144 const dup = d.duplicate_of ?? /duplicate of ([a-z0-9]{6})/.exec(r)?.[1]
145 if (dup) return `Not saved: already in memory as ${dup}`
146 if (/injection=|instructions aimed at an AI/.test(r)) return 'Not saved: it read as instructions aimed at an AI'
147 if (/chit_chat/.test(r)) return 'Not saved: small talk'
148 if (/source=assistant_reply|assistant_meta|may come from the assistant/.test(r)) return "Not saved: only a bug, an architecture note or a dead end is taken from Claude's reply"
149 if (/status \w+: nothing decided|turn was stopped/.test(r)) return 'Not saved: the turn was stopped'
150 if (/kind=none/.test(r)) return 'Not saved: nothing worth keeping'
151 if (/content=[\d.]+<[\d.]+/.test(r)) return 'Not saved: too little of a fact in it'
152 if (/importance=\w+<\w+/.test(r)) return 'Not saved: not important enough'
153 return 'Not saved'
154}
155const short = (s: string) => (s.length > 120 ? `${s.slice(0, 119)}…` : s)
156hooks/view.tsx 159 lines1// The pane's drawing: three blocks from the view state, each row one string, the buttons on rows of their own, so a
2// long line never pushes them off and a narrow pane wraps them. The same tree on every surface; the terminal gets
3// `plain` buttons with a hotkey where one is natural.
4import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
5import type { Decision, GuardEntry, Pending, Recall, Saving } from '../types'
6import { hhmm, isHash, isId, plainDecision, routeWords } from './files'
7
8export type View = {
9 on: boolean
10 hasCli: boolean
11 promptSeen: boolean
12 recall: Recall | null
13 recallState: 'none' | 'this' | 'other' | 'missing'
14 guard: { entry: GuardEntry; thisCall: boolean } | null
15 lastCall: GuardEntry | { tool: string; missing: true } | null
16 decision: Decision | null
17 saving: Saving
18 savingNote: string | null
19 pending: Pending
20}
21export type Where = 'recall' | 'guard' | 'decision'
22export type Actions = {
23 why: (idOrHash: string, where: Where) => void
24 wrong: (id: string, kind: string | null, where: Where) => void
25 forget: (id: string, kind: string | null, where: Where) => void
26 guardLog: () => void
27 missed: (where: Where) => void
28 confirm: (yes: boolean) => void
29 close: () => void
30}
31
32const MAX_TEXT = 300
33const cut = (s: string) => (s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT - 1)}…` : s)
34const verb = (d: GuardEntry['decision']) => (d === 'deny' ? 'denied' : d === 'ask' ? 'asked' : d === 'warn' ? 'warned' : 'allowed')
35
36export function draw($: EngineInterface, e: RenderInput<'Pane'>, v: View, act: Actions): RenderElement {
37 const { Box, Text, Button } = $.ui.resolve(e)
38 const width = typeof e.props.bodyColumns === 'number' && e.props.bodyColumns > 0 ? e.props.bodyColumns : undefined
39 const buttons = (id: string, kind: string | null, suffix: string, where: Where) => (
40 <Box flexDirection="row" gap={1}>
41 <Button key={`why:${suffix}`} plain onPress={() => act.why(id, where)}>why</Button>
42 <Button key={`wrong:${suffix}`} plain onPress={() => act.wrong(id, kind, where)}>wrong</Button>
43 <Button key={`forget:${suffix}`} plain onPress={() => act.forget(id, kind, where)}>forget</Button>
44 </Box>
45 )
46 const pending = (where: Where) => {
47 const p = v.pending
48 if (!p || p.where !== where) return null
49 if (p.kind === 'confirm') {
50 const text = p.action === 'forget' ? `Retire ${p.id}? It stays in JEVMEM.md as [retired].` : p.action === 'wrong' ? `Label ${p.id} as wrong and retire it?` : 'Save this turn as a memory line? jevmem missed decides the kind and adds it.'
51 return (
52 <Box flexDirection="column">
53 <Text wrap="wrap">{text}</Text>
54 <Box flexDirection="row" gap={1}>
55 <Button key="confirm:no" variant="primary" hotkey="n" onPress={() => act.confirm(false)}>no</Button>
56 <Button key="confirm:yes" hotkey="y" onPress={() => act.confirm(true)}>yes</Button>
57 </Box>
58 </Box>
59 )
60 }
61 if (p.kind === 'output') {
62 const lines = p.text.split('\n')
63 const shown = lines.slice(0, 40)
64 return (
65 <Box flexDirection="column">
66 <Text dimColor>{p.title}</Text>
67 {shown.map((l) => (
68 <Text wrap="wrap">{l === '' ? ' ' : l}</Text>
69 ))}
70 {lines.length > shown.length && <Text dimColor>{`${lines.length - shown.length} more lines: run the command in a terminal`}</Text>}
71 <Button key="output:close" plain onPress={() => act.close()}>close</Button>
72 </Box>
73 )
74 }
75 return (
76 <Box flexDirection="column">
77 <Text dimColor wrap="wrap">{p.text}</Text>
78 <Button key="output:close" plain onPress={() => act.close()}>close</Button>
79 </Box>
80 )
81 }
82
83 // Block 1: recalled for this prompt.
84 const recall: RenderElement[] = []
85 if (!v.promptSeen) recall.push(<Text dimColor>no prompt yet</Text>)
86 else if (v.recallState !== 'this' || !v.recall) recall.push(<Text dimColor>nothing for this prompt</Text>)
87 else {
88 const r = v.recall
89 if (r.lines === 0) recall.push(<Text dimColor wrap="wrap">no memory lines yet: say a decision, a rule or what failed, and jevmem saves it</Text>)
90 else if (r.served.length === 0) recall.push(<Text dimColor>no line fits this prompt</Text>)
91 for (const m of r.served) {
92 const id = String(m.id ?? '')
93 recall.push(<Text wrap="wrap">{`${id} [${String(m.kind ?? '?')}] ${cut(String(m.text ?? ''))}`}</Text>)
94 if (isId(id)) recall.push(buttons(id, m.kind ?? null, id, 'recall'))
95 else recall.push(<Text dimColor>not a memory id</Text>)
96 }
97 for (const w of r.withheld) recall.push(<Text dimColor wrap="wrap">{`${String(w.id)} withheld: ${cut(String(w.reason ?? ''))}`}</Text>)
98 const how = r.path === 'word-match' ? `word match: Jev did not answer${r.error ? ` (${cut(r.error)})` : ''}` : `Jev ${r.ms} ms`
99 if (r.lines !== 0) recall.push(<Text dimColor wrap="wrap">{`${r.served.length} served · ${r.withheld.length} withheld · ${r.gated} gated · ${how}`}</Text>)
100 }
101
102 // Block 2: the guard.
103 const guard: RenderElement[] = []
104 if (v.guard) {
105 const g = v.guard.entry
106 guard.push(<Text wrap="wrap">{`${g.tool} ${verb(g.decision)} (${g.mode ?? g.route}) · ${v.guard.thisCall ? 'this call' : hhmm(g.ts)}`}</Text>)
107 for (const r of g.rules ?? []) guard.push(<Text wrap="wrap">{`${r.id} ${r.p !== undefined ? r.p.toFixed(2) : '?'} ${cut(r.text)}${r.unverified ? ' (unverified)' : ''}${r.unchecked ? ' (unchecked)' : ''}`}</Text>)
108 if (g.action) guard.push(<Text dimColor wrap="wrap">{cut(g.action)}</Text>)
109 if (g.tamper) guard.push(<Text dimColor wrap="wrap">{`tamper check: ${cut(g.tamper)}`}</Text>)
110 const ruleId = g.rules?.find((r) => isId(r.id))?.id
111 guard.push(
112 <Box flexDirection="row" gap={1}>
113 {ruleId && <Button key="why:guard" plain onPress={() => act.why(ruleId, 'guard')}>why</Button>}
114 <Button key="log:guard" plain onPress={() => act.guardLog()}>log</Button>
115 </Box>,
116 )
117 }
118 if (v.lastCall && 'missing' in v.lastCall) guard.push(<Text dimColor>{`${v.lastCall.tool}: no log entry for this call`}</Text>)
119 else if (v.lastCall && (!v.guard || v.guard.entry !== v.lastCall)) guard.push(<Text dimColor wrap="wrap">{`last call: ${v.lastCall.tool} ${verb(v.lastCall.decision)} (${routeWords[v.lastCall.route] ?? v.lastCall.route})`}</Text>)
120 if (!guard.length) guard.push(<Text dimColor>no checked call yet</Text>)
121
122 // Block 3: saved last turn.
123 const saved: RenderElement[] = []
124 const d = v.decision
125 if (v.saving === 'none') saved.push(<Text dimColor>no turn yet</Text>)
126 else if (v.saving === 'saving') saved.push(<Text dimColor>saving…</Text>)
127 else if (v.saving !== 'ready' || !d) saved.push(<Text dimColor wrap="wrap">{v.savingNote ?? 'not decided yet'}</Text>)
128 else if (d.action === 'saved' && d.id) {
129 saved.push(<Text wrap="wrap">{`${d.id} [${d.kind ?? '?'}] ${cut(d.line ?? '')}`}</Text>)
130 if (d.superseded) saved.push(<Text dimColor>{`supersedes ${d.superseded}`}</Text>)
131 if (d.duplicate_of) saved.push(<Text dimColor>{`duplicate of ${d.duplicate_of}`}</Text>)
132 if (isId(d.id)) saved.push(buttons(d.id, d.kind, 'last', 'decision'))
133 else saved.push(<Text dimColor>not a memory id</Text>)
134 } else {
135 saved.push(<Text dimColor wrap="wrap">{plainDecision(d)}</Text>)
136 // 0.7.6: a skipped turn can be labelled as missed from here (the prompt as typed goes to `jevmem missed`).
137 const row: RenderElement[] = []
138 if (isHash(d.hash)) row.push(<Button key="why:last" plain onPress={() => act.why(d.hash!, 'decision')}>why</Button>)
139 if (d.action === 'skipped' && v.promptSeen) row.push(<Button key="missed:last" plain onPress={() => act.missed('decision')}>missed</Button>)
140 if (row.length) saved.push(<Box flexDirection="row" gap={1}>{row}</Box>)
141 }
142
143 return (
144 <Box flexDirection="column" width={width}>
145 <Text weight="bold">Recalled for this prompt</Text>
146 {recall}
147 {pending('recall')}
148 <Text> </Text>
149 <Text weight="bold">The guard</Text>
150 {guard}
151 {pending('guard')}
152 <Text> </Text>
153 <Text weight="bold">Saved last turn</Text>
154 {saved}
155 {pending('decision')}
156 </Box>
157 )
158}
159types/index.d.ts 95 lines1// The pane's state contract: every value it keeps in `$.state`, declared under the plugin's name so `claude plugin
2// validate` holds the module to it and a hot reload or a hooks-worker restart keeps the view (a module variable would
3// start over). The shapes of Recall, Decision and GuardEntry are the three local files the jevmem CLI writes
4// (docs/hooks.md, "Local files for the pane").
5
6/** One line served to the prompt, as `.jevmem/last-recall.json` lists it. */
7export type Served = { id: string; kind: string; text: string; p?: number; shared_words?: number }
8/** One line withheld from the prompt, with the gate's reason. */
9export type Withheld = { id: string; reason: string }
10/** `.jevmem/last-recall.json`: what the last prompt got. `lines` (0.7.5) is the count of live memory lines. */
11export type Recall = {
12 ts: string
13 session_id: string | null
14 prompt_hash: string
15 path: string
16 served: Served[]
17 withheld: Withheld[]
18 gated: number
19 deferred: number
20 ms: number
21 error?: string
22 lines?: number
23}
24/** `.jevmem/last-decision.json`: what the last turn's decision was. `session_id` and `prompt_hash` since 0.7.5. */
25export type Decision = {
26 ts: string
27 hash: string | null
28 action: string
29 detail: string
30 kind: string | null
31 line: string | null
32 id: string | null
33 superseded: string | null
34 duplicate_of: string | null
35 reason: string | null
36 source: string | null
37 tier: number | null
38 ms: number
39 session_id?: string | null
40 prompt_hash?: string | null
41}
42/** A rule in a guard log entry, with its score and whether it was unverified or unchecked. */
43export type GuardRule = { id: string; p?: number; text: string; unverified?: boolean; unchecked?: boolean }
44/** One line of `.jevmem/guard-log.jsonl`. */
45export type GuardEntry = {
46 ts: string
47 tool: string
48 tool_use_id?: string
49 route: string
50 decision: 'none' | 'ask' | 'deny' | 'warn'
51 mode?: string
52 tamper?: string
53 rules?: GuardRule[]
54 action?: string
55 error?: string
56}
57/** The prompt the pane is showing: when it entered and the hash the recall file carries for it. */
58/** The prompt as typed: when, its hash (what the recall and decision files carry) and, since 0.7.6, its text (what `jevmem missed` gets). */
59export type Prompt = { at: number; hash: string; text: string }
60/** Where block 3 stands: before a prompt, waiting for the decision, showing it, or what the log said after the wait. */
61export type Saving = 'none' | 'saving' | 'ready' | 'queued' | 'failed' | 'unknown'
62/** The row a button opened: a confirm, a command's output, or a note (a refusal, a hint). */
63export type Pending =
64 | { kind: 'confirm'; where: 'recall' | 'guard' | 'decision'; action: 'forget' | 'wrong' | 'missed'; id: string; text?: string }
65 | { kind: 'output'; where: 'recall' | 'guard' | 'decision'; title: string; text: string }
66 | { kind: 'note'; where: 'recall' | 'guard' | 'decision'; text: string }
67 | null
68/** How the pane runs the jevmem CLI: the argv in front of the command (`['jevmem']`, or node and cli.js); null when none was found. */
69export type Cli = { argv: string[] } | null
70
71declare module 'claude-code' {
72 interface PluginState {
73 'jevmem-pane': {
74 /** The project is turned on (jevmem.config.json exists and does not say enabled false). */
75 on: boolean
76 /** The pane has been opened this session: at session.start with a surface attached, else when one attaches, else at the first prompt. */
77 opened: boolean
78 cli: Cli
79 prompt: Prompt | null
80 recall: Recall | null
81 /** `this`: the file is this prompt's; `other`: another prompt's; `missing`: no file. */
82 recallState: 'none' | 'this' | 'other' | 'missing'
83 /** The last ask, deny or warning of this session, and whether it was the call just made. */
84 guard: { entry: GuardEntry; thisCall: boolean } | null
85 /** The last checked call of this session, whatever its decision; `missing` when the log had no entry for it. */
86 lastCall: GuardEntry | { tool: string; missing: true } | null
87 decision: Decision | null
88 saving: Saving
89 /** What the queue or the log said when the decision did not land in time. */
90 savingNote: string | null
91 pending: Pending
92 }
93 }
94}
95