SLOPSHOPPER

memory-pane

Here I Am: a read-only pane showing what memory handed the entity this turn, as it reached context, and the memory tools it called

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-pane
│ ┃ Memory ✕ › fix the failing auth test and add an audit log call │ ┃ No memory has surfaced yet. collapse │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /memory-pane │ ⎿ memory-pane: Memory pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Memory
No memory has surfaced yet. collapse
README

memory-pane

A Claude Code mod (issue #385): a pane beside the conversation showing what memory handed the entity, while it happens. It is for the witness. What the entity paints from memory and what retrieval actually gave it feel the same from inside, so the pane puts the page it was given next to the sentence it writes from it. The design notes are in docs/claude-code-mode.md.

What it shows

One entry per Here I Am hook row, newest first, and the newest one open:

  • prompt: the retrieval row for a prompt (or for a letter or a wakeup tick)
  • session start / after compaction / resumed: the SessionStart row
  • tool calls: memory tool calls made before any row

In each entry:

  • the hooks' own status lines: the retrieval stamp (matched: N new …), the [MEMORY STATUS NOTICE] / [MEMORY ARCHIVE NOTICE], failures
  • every memory: id, date, role label, provenance, link markers, the first line, and open for the full text as printed
  • where each memory was, which is the line that matters most:
  • in context, whole: its whole block was in what the model read
  • summary line in context; full text in a file, file NOT read: the hook spilled it, so the entity had one line of it, not its words. This changes to file read at HH:MM once the entity Reads that file
  • not in context; in a file: nothing of it reached context (a block past the harness's 2 KB preview of an oversized hook output)
  • the files the row pointed at, and whether each was read
  • each memory tool call (memory_query, memory_read, memory_find, memory_neighbors, memory_save, memory_mark, memory_release) with its arguments (conversation_id left out), the ids of the memories its result names (from its --- Memory xxxxxxxx ( headers), and, on open, the result exactly as the model read it. A subagent's call is labeled so
  • show the row as it reached context: the hook row verbatim, the backstop for anything the parser didn't understand (an unparsed [MEMORY header is reported as a ! problem line, never dropped)

collapse shrinks the pane to one line (the newest entry's summary). Closing it with the pane's own close control keeps it closed in later sessions, until /memory-pane opens it again.

What it never does

  • It changes nothing. Every hook passes its event on unchanged and only reads what came back. It calls no memory tool and writes nothing but the pane's own state.
  • It never reads the model's own rows, so thinking never reaches it.
  • If it breaks, the turn doesn't notice. Its errors are caught and shown in the pane as ! lines.

Enabling it

It is a separate plugin from claude-code-mode, so the hooks work exactly as before with or without it. Load it with one of:

  • one session: claude --plugin-dir /path/to/here-i-am/claude-code-mode/mods/memory-pane
  • every session, including the desktop app's: add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (one absolute path, or several separated by the platform's path-list separator)

Desktop panes open docked beside the transcript. In the terminal, a pane opened unasked waits until the window is 144 columns wide (110 once you've opened it yourself), and /memory-pane places it at any width.

Checking it

claude plugin validate claude-code-mode/mods/memory-pane
claude plugin test claude-code-mode/mods/memory-pane

The kit can't drive session.append, the event the hook rows arrive on: nothing in it stands in for the engine's store. So the tests cover the parser and the pane as reached through the memory tools, and the hook-row path was measured end to end in a headless session (see the PR). The mod is EARLY ACCESS API: claude plugin validate after a Claude Code update.

Source 3 files
hooks/register.tsx 399 lines
1// The memory pane (issue #385): what memory handed the entity, shown beside
2// the conversation while it happens, so a witness can hold the page the
3// entity was given next to the sentence it wrote from it.
4//
5// Read-only by construction. Every hook here passes its event on unchanged
6// and only looks at what came back: the Here I Am hooks' rows as they were
7// stored (what reached context), and the memory tools' results as the model
8// read them. Nothing the model writes is read, so thinking never reaches the
9// pane. No memory tool is called, nothing is written but the pane's own
10// state, and a failure in here costs the pane, never the turn: it is caught
11// and shown in the pane as a problem line.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { Entry, MemoryCard, ReadSpan, SpillFile, ToolEntry } from '../types'
17import {
18  argsText, cardsFor, clip, clock, coverage, entryKind, isOurs, normPath, parseFiles, parseStamps, pieces,
19  readSpanOf, toolMemoryIds,
20} from './parse'
21
22const PANE = 'memory'
23const TITLE = 'Memory'
24// The memory tools as a manual `claude mcp add here-i-am` names them, and as
25// the claude-code-mode plugin's own .mcp.json does (plugin here-i-am, server
26// here-i-am: the harness's mcp__plugin_<plugin>_<server>__ rule)
27const MEMORY_TOOL = /^mcp__(?:plugin_here-i-am_)?here-i-am__memory_/
28const SERVER_PREFIX = /^mcp__(?:plugin_here-i-am_)?here-i-am__/
29// The person's opened cards, kept to the newest this many keys
30const MAX_OPENED = 200
31const MAX_ENTRIES = 30
32// A memory_read page renders within 44,800 bytes (harness_limits.READ_PAGE_MAX_BYTES),
33// so one result is kept whole up to here
34const TOOL_TEXT_CAP = 60_000
35// Every update rewrites the whole value, so the oldest entries go first past this
36const STATE_BUDGET_CHARS = 3_000_000
37// The person closed the pane by hand: don't open it unasked again
38const CLOSED_KEY = 'closedByPerson'
39
40const entries = atom({ plugin: 'memory-pane', key: 'entries' } as const, [] as Entry[])
41const opened = atom({ plugin: 'memory-pane', key: 'opened' } as const, [] as string[])
42const isCollapsed = atom({ plugin: 'memory-pane', key: 'isCollapsed' } as const, false)
43
44type Api = EngineInterface
45
46function rowText(content: readonly { type: string; text?: unknown }[]): string {
47  return content.map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : '')).join('\n')
48}
49
50// Newest kept: past the entry count or the state budget, the oldest go first
51// (the newest entry always stays, whatever its size)
52function trim(list: Entry[]): Entry[] {
53  const kept = list.slice(-MAX_ENTRIES)
54  let size = kept.reduce((sum, entry) => sum + JSON.stringify(entry).length, 0)
55  while (kept.length > 1 && size > STATE_BUDGET_CHARS) size -= JSON.stringify(kept.shift()).length
56  return kept
57}
58
59async function addEntry($: Api, entry: Entry): Promise<void> {
60  await update($, entries, list => trim([...list, entry]))
61}
62
63async function addProblem($: Api, problem: string): Promise<void> {
64  await update($, entries, list => {
65    const last = list[list.length - 1]
66    if (last === undefined) {
67      const entry: Entry = { id: `problem-${Date.now()}`, at: Date.now(), kind: 'tools', stamps: [], memories: [], files: [], tools: [], raw: '', problems: [problem] }
68      return [entry]
69    }
70    return [...list.slice(0, -1), { ...last, problems: [...last.problems, problem] }]
71  })
72}
73
74async function ingestRow($: Api, uuid: string, kind: Entry['kind'], text: string): Promise<void> {
75  const files = parseFiles(text)
76  const fileTexts: { path: string; text: string }[] = []
77  const problems: string[] = []
78  // A file the harness persisted can itself name our spill files (the hook's
79  // pointer lines went to disk with the rest), so it is read for them too;
80  // `files` grows as the loop walks it
81  for (let i = 0; i < files.length; i += 1) {
82    const file = files[i]!
83    try {
84      const fileText = String(await $.fs.read(file.path))
85      fileTexts.push({ path: file.path, text: fileText })
86      if (file.kind !== 'harness') continue
87      for (const named of parseFiles(fileText)) {
88        if (!files.some(known => normPath(known.path) === normPath(named.path))) files.push(named)
89      }
90    } catch (err) {
91      problems.push(`couldn't read ${file.path}: ${String(err)}`)
92    }
93  }
94  const { memories, problems: parseProblems } = cardsFor(text, fileTexts)
95  const stamps = parseStamps(text)
96  // A harness-persisted row reached context as a 2 KB preview; its status
97  // lines are in the file, and are shown as what they are
98  for (const { path, text: fileText } of fileTexts) {
99    if (files.find(file => file.path === path)?.kind !== 'harness') continue
100    for (const stamp of parseStamps(fileText)) {
101      if (!stamps.includes(stamp)) stamps.push(`(on disk, not in context) ${stamp}`)
102    }
103  }
104  await addEntry($, {
105    id: uuid, at: await $.clock.now(), kind, stamps, memories, files, tools: [], raw: text,
106    problems: [...problems, ...parseProblems],
107  })
108}
109
110async function attachTool($: Api, tool: ToolEntry): Promise<void> {
111  await update($, entries, list => {
112    const last = list[list.length - 1]
113    if (last === undefined) {
114      const entry: Entry = { id: `tools-${tool.id}`, at: tool.at, kind: 'tools', stamps: [], memories: [], files: [], tools: [tool], raw: '', problems: [] }
115      return [entry]
116    }
117    return [...list.slice(0, -1), { ...last, tools: [...last.tools, tool] }]
118  })
119}
120
121async function settleTool($: Api, id: string, change: Partial<ToolEntry>): Promise<void> {
122  await update($, entries, list =>
123    trim(
124      list.map(entry =>
125        entry.tools.some(tool => tool.id === id)
126          ? { ...entry, tools: entry.tools.map(tool => (tool.id === id ? { ...tool, ...change } : tool)) }
127          : entry,
128      ),
129    ),
130  )
131}
132
133async function addRead($: Api, path: string, span: ReadSpan): Promise<void> {
134  const target = normPath(path)
135  const list = await read($, entries)
136  if (!list.some(entry => entry.files.some(file => normPath(file.path) === target))) return
137  await update($, entries, current =>
138    current.map(entry => ({
139      ...entry,
140      files: entry.files.map(file => (normPath(file.path) === target ? { ...file, reads: [...(file.reads ?? []), span] } : file)),
141    })),
142  )
143}
144
145function readsLabel(file: SpillFile): string {
146  const reads = file.reads ?? []
147  if (reads.length === 0) return 'not read'
148  return reads.map(span =>
149    span.from === 1 && span.to >= span.total
150      ? `read whole at ${clock(span.at)}`
151      : `lines ${span.from}–${span.to} of ${span.total} read at ${clock(span.at)}`).join('; ')
152}
153
154const KIND_LABEL: Record<Entry['kind'], string> = {
155  prompt: 'prompt',
156  start: 'session start',
157  compact: 'after compaction',
158  resume: 'resumed',
159  tools: 'tool calls',
160}
161
162function whereLabel(card: MemoryCard, files: SpillFile[]): string {
163  if (card.where === 'context') return 'in context, whole'
164  const file = card.file === undefined ? undefined : files.find(one => normPath(one.path) === normPath(card.file!))
165  // Read only when the Reads covered this memory's own lines in the file
166  const covered = coverage(card.lines, file?.reads ?? [])
167  const readNote = covered.state === 'read'
168    ? `its lines read at ${clock(covered.at ?? 0)}`
169    : covered.state === 'partly' ? 'its lines only PARTLY read' : 'file NOT read'
170  if (card.where === 'summary') {
171    return card.file === undefined
172      ? 'summary line only in context; full text unavailable'
173      : `summary line in context; full text in a file, ${readNote}`
174  }
175  if (card.where === 'cut') {
176    return `opening in context (the harness's 2 KB preview ends inside it); the rest in a file, ${readNote}`
177  }
178  return `not in context; in a file, ${readNote}`
179}
180
181function entrySummary(entry: Entry): string {
182  const whole = entry.memories.filter(card => card.where === 'context').length
183  const parts = [`${clock(entry.at)} · ${KIND_LABEL[entry.kind]}`]
184  if (entry.memories.length > 0) {
185    parts.push(`${entry.memories.length} ${entry.memories.length === 1 ? 'memory' : 'memories'} (${whole} whole in context)`)
186  }
187  if (entry.tools.length > 0) parts.push(`${entry.tools.length} memory tool ${entry.tools.length === 1 ? 'call' : 'calls'}`)
188  if (entry.problems.length > 0) parts.push(`${entry.problems.length} problem${entry.problems.length === 1 ? '' : 's'}`)
189  return parts.join(' · ')
190}
191
192function firstLine(text: string): string {
193  return clip(text.split(/\r?\n/).find(line => line.trim() !== '') ?? '')
194}
195
196export const register: Register = on => {
197  // The source of the SessionStart now firing (startup / resume / compact /
198  // clear), read when its row is appended; its classic event settles first
199  let startSource: string | undefined
200
201  on('classic.SessionStart', async ($, e, next) => {
202    startSource = e.source
203    return next(e)
204  })
205
206  on('session.start', async ($, e, next) => {
207    try {
208      await $.command.register({
209        name: 'memory-pane',
210        description: 'Open the Here I Am memory pane (what memory handed the entity this turn)',
211      })
212      if ((await $.store.get(CLOSED_KEY)) !== true) void $.ui.open({ id: PANE, title: TITLE })
213    } catch (err) {
214      $.ui.log(`memory-pane: couldn't set up: ${String(err)}`)
215    }
216    return next(e)
217  })
218
219  on('command.run', { command: 'memory-pane' }, async $ => {
220    await $.store.set(CLOSED_KEY, false)
221    await update($, isCollapsed, () => false)
222    const shown = await $.ui.open({ id: PANE, title: TITLE })
223    return { text: shown.isPlaced ? 'Memory pane opened.' : `Memory pane not placed: ${shown.reason}` }
224  })
225
226  on('ui.close', async ($, e, next) => {
227    const result = await next(e)
228    if (e.id === PANE && e.origin.kind === 'person') {
229      try {
230        await $.store.set(CLOSED_KEY, true)
231      } catch {
232        // The preference is a courtesy; the pane is already closed
233      }
234    }
235    return result
236  })
237
238  // The Here I Am hooks' rows, read as stored: the row the model reads
239  on('session.append', { door: 'hook-context' }, async ($, e, next) => {
240    const stored = await next(e)
241    try {
242      if (stored.deny !== undefined || e.agentId !== undefined || e.origin.kind !== 'hook') return stored
243      const text = rowText(stored.message.content)
244      if (text === '' || !isOurs(text)) return stored
245      const kind = entryKind(e.origin.event, text, e.origin.event === 'SessionStart' ? startSource : undefined)
246      if (kind === undefined) return stored
247      await ingestRow($, stored.uuid, kind, text)
248    } catch (err) {
249      await addProblem($, `couldn't read a ${e.origin.kind === 'hook' ? e.origin.event : ''} hook row: ${String(err)}`).catch(() => {})
250    }
251    return stored
252  })
253
254  on('tool.call', { tool: MEMORY_TOOL }, async ($, e, next) => {
255    const id = e.tool_use_id
256    try {
257      await attachTool($, {
258        id, tool: String(e.tool).replace(SERVER_PREFIX, ''), args: argsText(e as Record<string, unknown>),
259        at: await $.clock.now(), status: 'running', ids: [],
260        ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
261      })
262    } catch (err) {
263      // Shown or not, the call runs as it would without the pane
264      await addProblem($, `couldn't list a ${String(e.tool)} call: ${String(err)}`).catch(() => {})
265    }
266    const ran = await next(e)
267    try {
268      const text = ran.deny !== undefined ? `refused: ${ran.deny}` : (ran.text ?? '')
269      const isCapped = text.length > TOOL_TEXT_CAP
270      await settleTool($, id, {
271        status: ran.deny !== undefined || ran.isError === true ? 'error' : 'done',
272        text: isCapped ? text.slice(0, TOOL_TEXT_CAP) : text,
273        isCapped,
274        ids: toolMemoryIds(text),
275      })
276    } catch (err) {
277      await addProblem($, `couldn't record a ${String(e.tool)} result: ${String(err)}`).catch(() => {})
278    }
279    return ran
280  })
281
282  // A spill file the entity opened: the lines that Read showed are in its
283  // context from then on (the main conversation's Reads only)
284  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
285    const ran = await next(e)
286    try {
287      const span = ran.deny === undefined && ran.isError !== true
288        ? readSpanOf(e.agentId, ran.result, await $.clock.now())
289        : undefined
290      if (span !== undefined) await addRead($, String(e.file_path), span)
291    } catch {
292      // Read-tracking is the pane's; the Read already happened
293    }
294    return ran
295  })
296
297  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
298    const { Box, Text, Button } = $.ui.resolve(e)
299    const list = await read($, entries)
300    const keys = new Set(await read($, opened))
301    const collapsed = await read($, isCollapsed)
302    const latest = list[list.length - 1]
303
304    const toggle = (key: string) => () =>
305      update($, opened, current =>
306        current.includes(key) ? current.filter(one => one !== key) : [...current, key].slice(-MAX_OPENED))
307
308    // Collapsed, the one line is the newest entry's summary; open, that
309    // summary heads its own section below
310    const headline = latest === undefined ? 'No memory has surfaced yet.' : collapsed ? entrySummary(latest) : 'Newest first'
311    const header = (
312      <Box flexDirection="row" justifyContent="space-between">
313        <Text bold={collapsed} dimColor={!collapsed} wrap="truncate-end">{headline}</Text>
314        <Button key="collapse" plain label={collapsed ? 'expand' : 'collapse'} onPress={() => update($, isCollapsed, value => !value)} />
315      </Box>
316    )
317    if (collapsed || latest === undefined) return header
318
319    const card = (entry: Entry, memory: MemoryCard) => {
320      const key = `m:${entry.id}:${memory.id}`
321      const isOpen = keys.has(key)
322      return (
323        <Box key={key} flexDirection="column" marginTop={1}>
324          <Text wrap="wrap">
325            <Text bold>{memory.id}</Text> · {memory.date.slice(0, 19)} · {memory.from} · {memory.via}
326          </Text>
327          <Text dimColor wrap="wrap">{whereLabel(memory, entry.files)}</Text>
328          {memory.marks.map(mark => <Text dimColor wrap="wrap">{clip(mark)}</Text>)}
329          {isOpen
330            ? pieces(memory.text).map(piece => <Text wrap="wrap">{piece}</Text>)
331            : <Text wrap="truncate-end">{firstLine(memory.text)}</Text>}
332          <Button key={key} plain label={isOpen ? 'close' : 'open'} onPress={toggle(key)} />
333        </Box>
334      )
335    }
336
337    const tool = (call: ToolEntry) => {
338      const key = `t:${call.id}`
339      const isOpen = keys.has(key)
340      const who = call.agentId !== undefined ? ' (subagent)' : ''
341      const state = call.status === 'running' ? 'running' : call.status === 'error' ? 'error' : firstLine(call.text ?? '')
342      return (
343        <Box key={key} flexDirection="column" marginTop={1}>
344          <Text wrap="wrap"><Text bold>{call.tool}</Text>{who} · {clock(call.at)}</Text>
345          {isOpen
346            ? pieces(call.args).map(piece => <Text dimColor wrap="wrap">{piece}</Text>)
347            : <Text dimColor wrap="truncate-end">{clip(call.args)}</Text>}
348          {call.ids.length > 0 && <Text wrap="wrap">memories: {call.ids.join(' ')}</Text>}
349          {isOpen
350            ? pieces(`${call.text ?? ''}${call.isCapped ? `\n[… the pane kept the first ${TOOL_TEXT_CAP} characters]` : ''}`).map(piece => <Text wrap="wrap">{piece}</Text>)
351            : <Text wrap="truncate-end">→ {state}</Text>}
352          {call.status !== 'running' && <Button key={key} plain label={isOpen ? 'close' : 'open'} onPress={toggle(key)} />}
353        </Box>
354      )
355    }
356
357    const section = (entry: Entry, isNewest: boolean) => {
358      const entryKey = `e:${entry.id}`
359      // The newest entry is open unless closed; older ones are closed unless opened
360      const isOpen = keys.has(entryKey) !== isNewest
361      const rawKey = `r:${entry.id}`
362      return (
363        <Box key={entryKey} flexDirection="column" marginTop={1}>
364          <Box flexDirection="row" justifyContent="space-between">
365            <Text bold={isNewest} dimColor={!isNewest} wrap="truncate-end">{entrySummary(entry)}</Text>
366            <Button key={entryKey} plain label={isOpen ? 'hide' : 'show'} onPress={toggle(entryKey)} />
367          </Box>
368          {isOpen && (
369            <Box flexDirection="column" paddingLeft={1}>
370              {entry.problems.map(problem => <Text bold wrap="wrap">! {clip(problem, 1000)}</Text>)}
371              {entry.stamps.map(stamp => <Text dimColor wrap="wrap">{clip(stamp, 1000)}</Text>)}
372              {entry.files.map(file => (
373                <Text dimColor wrap="wrap">
374                  file{file.kind === 'harness' ? ' (harness persisted)' : ''}{file.size ? ` ${file.size}` : ''}: {file.path} · {readsLabel(file)}
375                </Text>
376              ))}
377              {entry.memories.map(memory => card(entry, memory))}
378              {entry.tools.map(call => tool(call))}
379              {entry.raw !== '' && (
380                <Box flexDirection="column" marginTop={1}>
381                  <Button key={rawKey} plain label={keys.has(rawKey) ? 'hide the row as it reached context' : 'show the row as it reached context'} onPress={toggle(rawKey)} />
382                  {keys.has(rawKey) && pieces(entry.raw).map(piece => <Text wrap="wrap">{piece}</Text>)}
383                </Box>
384              )}
385            </Box>
386          )}
387        </Box>
388      )
389    }
390
391    return (
392      <Box flexDirection="column">
393        {header}
394        {[...list].reverse().map((entry, index) => section(entry, index === 0))}
395      </Box>
396    )
397  })
398}
399
hooks/parse.ts 324 lines
1// Reading the Here I Am hooks' rows back into cards (issue #385).
2//
3// The pane shows the page the entity was handed, so it reads the row as it
4// reached context, not a structured copy from the backend: what is parsed
5// here is the same text the model read. Whatever this can't parse stays
6// visible through the entry's raw view, and an unparsed [MEMORY] header is
7// reported as a problem rather than dropped.
8
9import type { Entry, MemoryCard, MemoryWhere, ReadSpan, SpillFile } from '../types'
10
11// [MEMORY <id> from <created_at> - <role label> - via <origin>]
12// (memory_context.build_memory_message). The role label can hold " - " in a
13// multi-entity room, so the origin is anchored at the end instead. The close
14// counts only in the shape the backend writes it, alone at the start of its
15// line: a memory that quotes the marker mid-line (a94e68cc does) is not cut.
16const BLOCK = /\[MEMORY ([0-9a-f]{6,}) from (\S+) - (.+?) - (via [^\]\r\n]+)\]\r?\n([\s\S]*?)\r?\n\[\/MEMORY\][ \t]*(?=\r?\n|$)/g
17// A header where the backend writes one: at the start of a line (in a
18// wrapped row, right after the harness's "hook success: " prefix)
19const HEADER = /(?:^|hook success: )\[MEMORY ([0-9a-f]{6,}) from /gm
20// In a parsed memory's own text, a header or close at a line start means the
21// block's edges may be wrong (a memory quoting a whole block)
22const EDGE_IN_TEXT = /^(?:\[MEMORY [0-9a-f]{6,} from |\[\/MEMORY\][ \t]*$)/m
23
24// - <id> (<date> - <role label> - via <origin>): <first line>
25// (claude_code_mode.render_retrieval_summary_line)
26const SUMMARY = /^- ([0-9a-f]{8}) \((\d{4}-\d{2}-\d{2}) - (.+?) - (via [^)]+)\): (.*?)\r?$/gm
27
28// Link marker lines sit directly under a header (memory_context.format_memory_link_lines)
29const MARK = /^\[(?:revises|sources:|later |cited by|source withdrawn)[^\]]*\]$/
30
31// Our hooks name a spill file on a line of its own, optionally sized:
32//   C:\...\here-i-am-sessions\<session>-retrieval-131619.md (79 KB)
33const HOOK_FILE = /^\s*((?:[A-Za-z]:[\\/]|\/)[^\r\n]*?here-i-am-sessions[\\/][^\r\n]*?\.md)(?: \(([\d.]+ ?[KMG]?B)\))?\s*$/gm
34// The harness's persist line: "Full output saved to: <path>"
35const HARNESS_FILE = /Full output saved to: ([^\r\n]+?)\s*$/gm
36
37// The hooks' own lines worth showing: stamps, notices, failures. The long
38// identity paragraphs are left to the raw view.
39const STAMP = /^\[(HERE I AM[^\]]*|MEMORY (?:STATUS|ARCHIVE) NOTICE)\] ?(.*)$/
40
41// The memory tools head each result "--- Memory xxxxxxxx (" (memory_neighbors
42// marks its target "--- >> Memory"), the shape memory_tools' reload parser keys on
43const TOOL_HEADER = /^--- (?:>> )?Memory ([0-9a-f]{8}) \(/gm
44
45// What a surface draws in one text child: at most 10,000 characters, and no
46// control characters but tab and newline. Kept under the line with room
47const TEXT_CHILD_MAX = 8000
48const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F]/g
49
50const IDENTITY_TAGS = new Set(['HERE I AM MEMORY TOOLS', 'ROOMS REGISTRY', 'YOUR NOTES', 'GIT IDENTITY'])
51
52export function normPath(path: string): string {
53  return path.trim().replace(/\\/g, '/').toLowerCase()
54}
55
56// A capture group of a match, '' when it didn't take part
57function g(m: RegExpMatchArray, i: number): string {
58  return m[i] ?? ''
59}
60
61function splitMarks(body: string): { marks: string[]; text: string } {
62  const lines = body.split(/\r?\n/)
63  const marks: string[] = []
64  while (lines.length > 0 && MARK.test((lines[0] ?? '').trim())) marks.push((lines.shift() ?? '').trim())
65  return { marks, text: lines.join('\n') }
66}
67
68function newlines(text: string): number {
69  let count = 0
70  for (const char of text) if (char === '\n') count += 1
71  return count
72}
73
74/**
75 * Every whole [MEMORY] block in `text`, in order. From a file, each card
76 * also carries its line span there, so a Read of part of the file can be
77 * told from a Read of this memory.
78 */
79export function parseBlocks(text: string, where: MemoryWhere, file?: string): MemoryCard[] {
80  const cards: MemoryCard[] = []
81  for (const m of text.matchAll(BLOCK)) {
82    const { marks, text: body } = splitMarks(g(m, 5))
83    const card: MemoryCard = { id: g(m, 1), date: g(m, 2), from: g(m, 3), via: g(m, 4), marks, text: body, where }
84    if (file !== undefined) {
85      const first = newlines(text.slice(0, m.index ?? 0)) + 1
86      Object.assign(card, { file, lines: [first, first + newlines(m[0])] })
87    }
88    cards.push(card)
89  }
90  return cards
91}
92
93/** The ids of the [MEMORY] headers `text` holds, whole blocks or not. */
94export function headerIds(text: string): string[] {
95  return [...text.matchAll(HEADER)].map(m => g(m, 1))
96}
97
98/** How many [MEMORY] headers `text` holds, whole blocks or not. */
99export function countHeaders(text: string): number {
100  return headerIds(text).length
101}
102
103export function parseSummaries(text: string): MemoryCard[] {
104  return [...text.matchAll(SUMMARY)].map(m => ({
105    id: g(m, 1), date: g(m, 2), from: g(m, 3), via: g(m, 4), marks: [], text: g(m, 5), where: 'summary' as const,
106  }))
107}
108
109export function parseFiles(text: string): SpillFile[] {
110  const files: SpillFile[] = []
111  const seen = new Set<string>()
112  for (const m of text.matchAll(HOOK_FILE)) {
113    const path = g(m, 1).trim()
114    if (seen.has(normPath(path))) continue
115    seen.add(normPath(path))
116    files.push({ path, kind: 'hook', ...(m[2] ? { size: m[2] } : {}) })
117  }
118  for (const m of text.matchAll(HARNESS_FILE)) {
119    const path = g(m, 1).trim()
120    if (seen.has(normPath(path))) continue
121    seen.add(normPath(path))
122    files.push({ path, kind: 'harness' })
123  }
124  return files
125}
126
127export function parseStamps(text: string): string[] {
128  const out: string[] = []
129  for (const raw of text.split(/\r?\n/)) {
130    const line = raw.trim()
131    const m = STAMP.exec(line)
132    if (!m) continue
133    if (IDENTITY_TAGS.has(g(m, 1))) continue
134    // The opening identity paragraph and the block header are context, not status
135    if (g(m, 1) === 'HERE I AM' && g(m, 2).startsWith('You are ')) continue
136    if (line.startsWith('[HERE I AM MEMORY RETRIEVAL] Memories from your past')) continue
137    out.push(line)
138  }
139  return out
140}
141
142/**
143 * Which of our hooks wrote a row, from the event the row names. A
144 * SessionStart's kind comes from the `source` its classic event carried
145 * (measured on 2.1.288: that event settles before the row is appended),
146 * else from the harness's "SessionStart:<source>" prefix, which a mod that
147 * unwraps the row (#384) removes.
148 */
149export function entryKind(event: string, text: string, source?: string): Entry['kind'] | undefined {
150  if (event === 'UserPromptSubmit') return 'prompt'
151  if (event !== 'SessionStart') return undefined
152  const from = source ?? /SessionStart:(\w+)/.exec(text)?.[1]
153  if (from === 'compact') return 'compact'
154  if (from === 'resume') return 'resume'
155  return 'start'
156}
157
158/** Is this row one of the Here I Am hooks' own? */
159export function isOurs(text: string): boolean {
160  return /\[(HERE I AM|MEMORY (STATUS|ARCHIVE) NOTICE|MEMORY [0-9a-f]{6,} )/.test(text)
161}
162
163/**
164 * The cards for one hook row: whole blocks in the row are `context`; a
165 * summary line stands for a memory whose text is in a file (filled in later
166 * from `fileText`); a block whose header the harness's preview cut after is
167 * `cut`; a block found only in a file is `disk`.
168 */
169export function cardsFor(rowText: string, fileTexts: { path: string; text: string }[]): {
170  memories: MemoryCard[]
171  problems: string[]
172} {
173  const problems: string[] = []
174  const memories: MemoryCard[] = parseBlocks(rowText, 'context')
175  const byId = new Map(memories.map(card => [card.id, card]))
176
177  const inRow = headerIds(rowText)
178  // A harness preview cuts the row mid-block, so a header without its close
179  // is expected there (that memory's opening reached context); anywhere else
180  // it means the parser missed a shape
181  const isPreview = rowText.includes('<persisted-output>')
182  const opened = new Set(isPreview ? inRow.filter(id => !byId.has(id)) : [])
183  if (inRow.length > memories.length && !isPreview) {
184    problems.push(`${inRow.length - memories.length} [MEMORY] header(s) in this row didn't parse; the raw view has them`)
185  }
186
187  for (const summary of parseSummaries(rowText)) {
188    if (byId.has(summary.id)) continue
189    byId.set(summary.id, summary)
190    memories.push(summary)
191  }
192
193  for (const { path, text } of fileTexts) {
194    for (const card of parseBlocks(text, 'disk', path)) {
195      const known = byId.get(card.id)
196      if (known === undefined) {
197        if (opened.has(card.id)) card.where = 'cut'
198        byId.set(card.id, card)
199        memories.push(card)
200      } else if (known.where === 'summary') {
201        // The summary line was what reached context; the words are the file's
202        Object.assign(known, { text: card.text, marks: card.marks, date: card.date, file: path, lines: card.lines })
203      }
204    }
205  }
206
207  for (const card of memories) {
208    if (EDGE_IN_TEXT.test(card.text)) {
209      problems.push(`${card.id}'s text holds a [MEMORY] header or close at a line start, so its edges may be wrong; the raw view has the row`)
210    }
211  }
212  return { memories, problems }
213}
214
215/**
216 * The span a Read showed, from its structured result (`file.startLine`,
217 * `numLines`, `totalLines`): a Read with `offset`/`limit`, or one the
218 * harness cut at its token cap, shows part of the file. Undefined for a
219 * subagent's Read (its words entered the subagent's context, not the
220 * entity's), a refused or failed Read, and a non-text one.
221 */
222export function readSpanOf(agentId: string | undefined, result: unknown, at: number): ReadSpan | undefined {
223  if (agentId !== undefined) return undefined
224  const file = (result as { type?: unknown; file?: Record<string, unknown> } | undefined)?.type === 'text'
225    ? (result as { file: Record<string, unknown> }).file
226    : undefined
227  const from = file?.['startLine']
228  const count = file?.['numLines']
229  const total = file?.['totalLines']
230  if (typeof from !== 'number' || typeof count !== 'number' || typeof total !== 'number') return undefined
231  return { from, to: Math.min(total, from + Math.max(count, 1) - 1), total, at }
232}
233
234/**
235 * How much of a memory's lines the Reads covered: `read` (all of them,
236 * `at` being when the last needed Read landed), `partly`, or `unread`. With
237 * no span known, the whole file must have been covered.
238 */
239export function coverage(lines: [number, number] | undefined, reads: readonly ReadSpan[]): {
240  state: 'read' | 'partly' | 'unread'
241  at?: number
242} {
243  const total = reads[0]?.total ?? 0
244  const [first, last] = lines ?? [1, Math.max(total, 1)]
245  const seen = new Array<boolean>(last - first + 1).fill(false)
246  let left = seen.length
247  let touched = false
248  for (const span of [...reads].sort((a, b) => a.at - b.at)) {
249    for (let line = Math.max(span.from, first); line <= Math.min(span.to, last); line += 1) {
250      touched = true
251      if (!seen[line - first]) {
252        seen[line - first] = true
253        left -= 1
254      }
255    }
256    if (left === 0) return { state: 'read', at: span.at }
257  }
258  return { state: touched ? 'partly' : 'unread' }
259}
260
261/** Arguments as the pane shows them: the caller's id is the same on every call. */
262export function argsText(e: Record<string, unknown>): string {
263  const shown: Record<string, unknown> = {}
264  for (const [key, value] of Object.entries(e)) {
265    if (['tool', 'tool_use_id', 'agentId', 'consent', 'conversation_id'].includes(key)) continue
266    shown[key] = value
267  }
268  return JSON.stringify(shown)
269}
270
271export function clock(ms: number): string {
272  const d = new Date(ms)
273  const two = (n: number) => String(n).padStart(2, '0')
274  return `${two(d.getHours())}:${two(d.getMinutes())}`
275}
276
277/** The memory ids a tool result names, in order, each once. */
278export function toolMemoryIds(text: string): string[] {
279  const ids: string[] = []
280  for (const m of text.matchAll(TOOL_HEADER)) if (!ids.includes(g(m, 1))) ids.push(g(m, 1))
281  for (const card of parseBlocks(text, 'context')) if (!ids.includes(card.id)) ids.push(card.id)
282  return ids
283}
284
285/** Text as a surface may draw it: CRLF made LF, other control characters dropped. */
286export function clean(text: string): string {
287  return text.replace(/\r\n?/g, '\n').replace(CONTROL, '')
288}
289
290/**
291 * Cleaned text cut into pieces a text child can hold, at line ends where it
292 * can be (one Text each, so a long result draws whole instead of refusing
293 * the tree). Nothing is dropped: in order, they hold every character of the
294 * cleaned text, a line longer than a piece being split across pieces.
295 */
296export function pieces(text: string): string[] {
297  const out: string[] = []
298  let current = ''
299  for (const line of clean(text).split('\n')) {
300    let rest = line
301    // A single line longer than a piece is cut where it must be
302    while (rest.length > TEXT_CHILD_MAX) {
303      if (current !== '') out.push(current)
304      out.push(rest.slice(0, TEXT_CHILD_MAX))
305      current = ''
306      rest = rest.slice(TEXT_CHILD_MAX)
307    }
308    if (current === '') current = rest
309    else if (current.length + 1 + rest.length <= TEXT_CHILD_MAX) current += '\n' + rest
310    else {
311      out.push(current)
312      current = rest
313    }
314  }
315  out.push(current)
316  return out
317}
318
319/** One line for a closed card or call: cleaned and kept short. */
320export function clip(text: string, max = 300): string {
321  const line = clean(text).replace(/\s+/g, ' ').trim()
322  return line.length > max ? `${line.slice(0, max - 1)}…` : line
323}
324
types/index.d.ts 87 lines
1/**
2 * How a memory reached the entity:
3 * - `context`: its whole [MEMORY] block was in the row that entered context
4 * - `summary`: only its summary line was in context; the text shown is from
5 *   the spill file the row named (in context only if that file was Read)
6 * - `cut`: its header and opening were in context, the harness's preview of
7 *   an oversized row ending inside it; the rest is in the persisted file
8 * - `disk`: nothing of it was in context; it is only in a file the row named
9 */
10export type MemoryWhere = 'context' | 'summary' | 'cut' | 'disk'
11
12/** One Read of a spill file by the entity: its lines `from`–`to` of `total`. */
13export type ReadSpan = { from: number; to: number; total: number; at: number }
14
15export type MemoryCard = {
16  /** The id prefix exactly as printed in the header. */
17  id: string
18  date: string
19  /** The role label: "originally from you", "a reflection you saved", ... */
20  from: string
21  /** "via Here I Am" or "via Claude Code". */
22  via: string
23  /** Link marker lines under the header ([revises ...], [sources: ...]). */
24  marks: string[]
25  /** The memory's text as printed, marker lines excluded. */
26  text: string
27  where: MemoryWhere
28  /** The file the full text came from, when it wasn't in context whole. */
29  file?: string
30  /** The block's first and last line in `file`, 1-based, as Read counts them. */
31  lines?: [number, number]
32}
33
34/** A file a hook row pointed at instead of putting its content in context. */
35export type SpillFile = {
36  path: string
37  /** `hook`: our hooks' spill; `harness`: the harness's persist line. */
38  kind: 'hook' | 'harness'
39  size?: string
40  /** The main conversation's Reads of it (a subagent's put nothing in view). */
41  reads?: ReadSpan[]
42}
43
44export type ToolEntry = {
45  id: string
46  /** The tool name without the `mcp__here-i-am__` prefix. */
47  tool: string
48  /** Arguments, conversation_id dropped, as JSON. */
49  args: string
50  at: number
51  status: 'running' | 'done' | 'error'
52  /** The result exactly as the model read it, up to TOOL_TEXT_CAP. */
53  text?: string
54  isCapped?: boolean
55  /** The memory ids the result names, in order: what can be cited from it. */
56  ids: string[]
57  /** Set when a subagent made the call. */
58  agentId?: string
59}
60
61export type Entry = {
62  id: string
63  at: number
64  kind: 'prompt' | 'start' | 'compact' | 'resume' | 'tools'
65  /** The hook's own status lines: the retrieval stamp, notices, failures. */
66  stamps: string[]
67  memories: MemoryCard[]
68  files: SpillFile[]
69  tools: ToolEntry[]
70  /** The row text exactly as stored: what reached context. */
71  raw: string
72  /** What the pane could not do with this row, said aloud. */
73  problems: string[]
74}
75
76declare module 'claude-code' {
77  interface PluginState {
78    'memory-pane': {
79      entries: Entry[]
80      /** Keys of the cards, entries and raw views the person has opened. */
81      opened: string[]
82      /** Collapsed: the pane keeps one summary line. */
83      isCollapsed: boolean
84    }
85  }
86}
87