SLOPSHOPPER

jevmem pane

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…

paneguardcommandpromptprocess
★ 133v0.7.6MITupdated 2026-10-19Avinash-jetwani/jevmem/pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jevmem-pane
│ ┃ jevmem ✕ › fix the failing auth test and add an audit log call │ ┃ Recalled for this prompt │ ┃ no prompt yet ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ The guard ⏺ Update(src/auth.ts) │ ┃ no checked call yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Saved last turn ⎿ 3 pass, 1 fail │ ┃ no turn yet │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jevmem │ ⎿ jevmem-pane: jevmem is not turned on in this project: run jevmem │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jevmem
Recalled for this prompt no prompt yet The guard no checked call yet Saved last turn no turn yet
README

jevmem pane

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:

  1. Recalled for this prompt: each memory line served to Claude (its id, kind and text) and how many were withheld.
  2. The guard: the last call the guard asked about or denied, with the rule and its score, matched to the call.
  3. Saved last turn: the line jevmem saved from the turn and its kind, or why the turn was skipped.

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.

Install

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.

Source 5 files
hooks/register.tsx 294 lines
1// 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}
294
hooks/cli.ts 60 lines
1// 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}
60
hooks/files.ts 156 lines
1// 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)
156
hooks/view.tsx 159 lines
1// 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}
159
types/index.d.ts 95 lines
1// 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