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

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.
One entry per Here I Am hook row, newest first, and the newest one open:
In each entry:
matched: N new …), the [MEMORY STATUS NOTICE] / [MEMORY ARCHIVE NOTICE], failuresopen for the full text as printedin context, whole: its whole block was in what the model readsummary 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 filenot in context; in a file: nothing of it reached context (a block past the harness's 2 KB preview of an oversized hook output)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 soshow 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.
! lines.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:
claude --plugin-dir /path/to/here-i-am/claude-code-mode/mods/memory-paneCLAUDE_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.
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.
hooks/register.tsx 399 lines1// 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}
399hooks/parse.ts 324 lines1// 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}
324types/index.d.ts 87 lines1/**
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