Git-versioned, taxonomy-structured memory for Claude Code — recall by path, branch to isolate, time-travel to audit.

Give Claude Code a git-versioned, taxonomy-structured memory. Every captured fact is classified into a semantic path (e.g. preferences.coding.style), stored in a per-project git repo, and recalled by path — not by chunk-matching. Memory becomes branchable, time-travelable, and cryptographically verifiable.
Full reference: zhangfengcdt.github.io/memoir/claude_code/.
Inside a Claude Code session:
/plugin marketplace add zhangfengcdt/memoir
/plugin install memoir@memoir
Hooks register on the next session start. Each project gets its own store at ~/.memoir/<slug>/ (override via MEMOIR_STORE=/your/path). Linked git worktrees of the same repo share one store keyed on the main worktree's path — so memories captured from any worktree are recallable from every other. To opt out, set MEMOIR_STORE per worktree.
The plugin shells out to the memoir CLI. It picks, in order:
memoir on PATH — install with pip install memoir-ai, pipx install memoir-ai, or uv tool install memoir-ai.uvx on PATH — transparent fallback as uvx --from memoir-ai==<pinned> memoir … (~1s first-run warmup, zero install). The pin is set in scripts/resolve-memoir-cli.sh (MEMOIR_AI_PIN) so a silent PyPI publish can't change behavior under you.LLM calls inherit Claude Code's auth automatically (MEMOIR_LLM_BACKEND=claude-cli, MEMOIR_LLM_MODEL=claude-haiku-4-5). Override MEMOIR_LLM_MODEL in your shell for sonnet / opus.
| Component | Role |
|---|---|
| Slash commands | /memoir:onboard, /memoir:remember, /memoir:recall, /memoir:status, /memoir:sync, /memoir:ui, /memoir:pane, /memoir:push. (Admin operations like taxonomy and forget are available via the memoir CLI.) |
| Skills | memory-recall (user facts, auto-triggered, runs in a forked context) and memoir-onboard (maintains the codebase:onboard snapshot). |
| Lifecycle hooks | SessionStart (inject status + taxonomy + unmerged-branch suggestions, with a guided /memoir:sync offer when there's meaningful unmerged work), UserPromptSubmit (surface matching hints), Stop (async auto-capture of durable facts), SessionEnd (cleanup). |
| Status line | [memoir] <branch> · N memories, with warnings for concurrent sessions or sticky-branch mode. On Claude Code 2.1.287+ the mod also appends memoir: <branch> · N memories · +N this turn to the hint line under the prompt, with no settings edit. |
| Mod (Claude Code 2.1.287+) | An in-process hooks module (hooks/mod/) that watches the store and drives /memoir:pane — a live memories, commits and metrics view — plus the hint-line status above. Read-only; the shell hooks keep doing capture and recall. |
Memory branches auto-track code branches — switching to feature/x forks a memoir branch from main so you inherit all prior captures but keep new ones isolated until you promote them with /memoir:sync (or memoir sync-branch from a terminal). For deeper git operations (branch, checkout, merge, time-travel, blame, proof, verify, diff, get, keys), use the memoir CLI directly.
/memoir:sync walks you through promoting unmerged memoir branches into main with a select UI: merge all, choose branches, ignore branches permanently, snooze reminders, or delete branches. State lives next to the store:
<store>/.git/plugin-ignored-branches — one branch name per line; delete a line to unignore.<store>/.git/plugin-merge-prompt-cooldown — "snoozed until" epoch + consecutive-decline count.When the total unmerged work reaches 5 commits (and no snooze is active), SessionStart additionally asks the agent to offer the sync once that session. Declining backs off automatically — 1 day, then 7, then 30 on repeated declines — while an explicit snooze resets the escalation. The status-line count and the informational branch list always render regardless of snooze state.
Auto-promotion. When your code branch is merged into main via a merge commit, the next SessionStart on main promotes its memoir branch automatically (the promote is additive-only and conflict-free) and reports it in the status line. Squash- or rebase-merged branches aren't detected — promote those with /memoir:sync. Disable with MEMOIR_AUTO_PROMOTE_MERGED=0.
Branch deletion. /memoir:sync can delete any memoir branch except main and the currently-checked-out one — always behind an explicit pick, never automatically. The picker flags each branch's risk: stale branches (inactive 60 days, MEMOIR_STALE_BRANCH_DAYS to override, and either already synced or their code branch is gone) are marked safe to remove; unsynced branches carry a warning that their unmerged captures will be discarded. Deleting also cleans up the branch's sync marker and ignore-list entry, so the store's branch list stays bounded over time.
Disable per-turn auto-capture with MEMOIR_NO_CAPTURE=1.
Auto-capture runs after the turn ends, so what memoir just learned is otherwise invisible until you ask. On Claude Code 2.1.287 or newer the plugin loads a mod that makes it visible:
/memoir:pane opens a pane with three tabs. Memories (default) lists, on a branch, the keys that differ from main with one line of their value (+ new here, ~ changed here, - on main only), and on main every key; a filter: field narrows the list, the taxonomy button shows the taxonomy (paths grouped by first segment) above the keys, and all branches lists every other branch's differences from main below them, the ten most recently committed branches, latest first. Click or Enter a key to see its value — for ~ and -, main's value beside the branch's — and to forget it (d) or put main's value back (u); one press arms the action, y confirms. merge to main memory merges the branch into main, asking what to do with keys changed on both sides (ours keeps main's value, theirs takes the branch's, both keeps main's and appends the branch's), and returns to the branch afterwards. pull from main memory goes the other way: it merges main into the branch, so memories captured on main (or merged into it from other branches) since the branch left come over — the same choice for a key changed on both sides, from the branch's side (ours keeps the branch's value, theirs takes main's, both keeps the branch's and appends main's) — without leaving the branch. The comparison is memoir diff main HEAD, read from the tree, so the store's checked-out branch is never touched. Recent lists the branch's commits that are not on main (on main, the latest 10) by title, newest first, ● marking the ones that landed this turn; click or Enter a commit to see what it changed, value before and after; m toggles the hidden metrics.* writes, r refreshes. Metrics charts the per-branch turn accumulators (metrics.turn.<branch>) as bars across branches: turns, tool calls, tool errors, average latency, output and tool-result characters, the current branch first, the same figures the web UI's Statistics → Metrics tab shows. Docked beside the transcript in a wide fullscreen terminal, inline above the prompt otherwise. Where nothing can draw (claude -p, the SDK) the command prints a text summary instead.memoir: <branch> · N memories is appended to the shortcuts line under the prompt, and +N this turn appears once the turn's captures have landed (it clears at the next turn). Nothing shows when there is no store. If your ~/.claude/settings.json already pipes statusline.sh into statusLine, the suffix is skipped so memoir's state is not shown twice.The mod polls the store's branch ref every 3 s (two stats, no subprocess) and re-reads git log plus the changed memories only when it moved, so it catches every write path — the Stop hook, /memoir:remember, the CLI, memoir pull. It writes only what you confirm in the Memories detail (forget a key, restore main's value), through the same CLI. On older Claude Code versions nothing changes: the shell hooks run as before, /memoir:pane prints a text summary, and scripts/statusline.sh remains the way to get memoir into the status line.
Develop it with claude plugin validate plugins/claude-code and claude plugin test plugins/claude-code (tests in hooks/mod/*.test.tsx, no store or LLM needed).
Point Claude Code at your working copy:
{
"plugins": ["/path/to/memoir/plugins/claude-code"]
}
The plugin does not ship an .mcp.json — MCP is opt-in. See the Claude Code plugin docs for the memoir-mcp server configuration.
memoir command and flag./memoir:ui.hooks/mod/register.tsx 419 lines1// memoir mod: a capture feed pane (/memoir:pane) and a status suffix on the
2// prompt's hint line. Purely additive: the shell hooks in hooks.json keep
3// doing capture and recall; the pane only writes what the person presses for
4// (forget a key, restore main's value), through the same CLI.
5//
6// Design: the Stop hook captures asynchronously, so a turn's writes land in
7// the store seconds after `turn.complete`. Instead of listening to turn
8// events, the mod watches the store itself — a 3 s timer stats the current
9// branch's ref (no subprocess) and re-reads git log + memory contents only
10// when it changed. That catches every write path (Stop hook, /memoir:remember,
11// the CLI, `memoir pull`).
12
13import { atom, read, update } from 'claude-code'
14import type { Register } from 'claude-code'
15
16import type {
17 MemoirBranchDiff,
18 MemoirCapture,
19 MemoirCommitDetail,
20 MemoirMemories,
21 MemoirMemory,
22 MemoirMemoryAction,
23 MemoirMergeState,
24 MemoirMergeStrategy,
25 MemoirStatus,
26 MemoirTab,
27 MemoirTurnMetrics,
28} from '../../types'
29import * as fmt from './format'
30import { renderPane } from './pane'
31import * as store from './store'
32
33const PANE_ID = 'memoir'
34const COMMAND = 'memoir:pane'
35const POLL_MS = 3000
36// The all-branches view lists this many branches, the most recently changed first.
37const BRANCH_LIMIT = 10
38
39// The drawn state, in `$.state` so a write redraws its readers and a hot reload
40// keeps it. The engine reads these references off this file, so they live here.
41const EMPTY_STATUS: MemoirStatus = { store: null, branch: null, head: null, memories: null, commits: null, commitsWithoutMetrics: null }
42const status = atom({ plugin: 'memoir', key: 'status' } as const, EMPTY_STATUS)
43const captures = atom({ plugin: 'memoir', key: 'captures' } as const, [] as MemoirCapture[])
44const order = atom({ plugin: 'memoir', key: 'order' } as const, [] as string[])
45const turnBaseline = atom({ plugin: 'memoir', key: 'turnBaseline' } as const, null as string | null)
46const tab = atom({ plugin: 'memoir', key: 'tab' } as const, 'memories' as MemoirTab)
47const showMetrics = atom({ plugin: 'memoir', key: 'showMetrics' } as const, false)
48const showTaxonomy = atom({ plugin: 'memoir', key: 'showTaxonomy' } as const, false)
49const expanded = atom({ plugin: 'memoir', key: 'expanded' } as const, [] as string[])
50const taxonomy = atom({ plugin: 'memoir', key: 'taxonomy' } as const, {} as Record<string, number>)
51const metrics = atom({ plugin: 'memoir', key: 'metrics' } as const, {} as Record<string, MemoirTurnMetrics>)
52const memoryList = atom({ plugin: 'memoir', key: 'memories' } as const, { items: [], base: null } as MemoirMemories)
53const filter = atom({ plugin: 'memoir', key: 'filter' } as const, '')
54const selected = atom({ plugin: 'memoir', key: 'selected' } as const, null as string | null)
55const selectedBranch = atom({ plugin: 'memoir', key: 'selectedBranch' } as const, null as string | null)
56const allBranches = atom({ plugin: 'memoir', key: 'allBranches' } as const, false)
57const branchList = atom({ plugin: 'memoir', key: 'branches' } as const, [] as MemoirBranchDiff[])
58const armed = atom({ plugin: 'memoir', key: 'armed' } as const, null as MemoirMemoryAction | null)
59const merge = atom({ plugin: 'memoir', key: 'merge' } as const, 'idle' as MemoirMergeState)
60const pull = atom({ plugin: 'memoir', key: 'pull' } as const, 'idle' as MemoirMergeState)
61const commit = atom({ plugin: 'memoir', key: 'commit' } as const, null as string | null)
62const commitDetail = atom({ plugin: 'memoir', key: 'commitDetail' } as const, null as MemoirCommitDetail | null)
63
64export const register: Register = on => {
65 // Rebuilt by `session.start` (which a hot reload re-runs); the drawn state
66 // lives in the `$.state` atoms above and survives a reload.
67 let refresh: (force?: boolean) => Promise<void> = async () => undefined
68 // The detail view's one write: forget the key, or put main's value back.
69 let apply: (m: MemoirMemory, kind: MemoirMemoryAction) => Promise<string> = async () => 'memoir store not ready'
70 // Merge the branch into main with one of the CLI's conflict strategies.
71 let mergeNow: (strategy: MemoirMergeStrategy) => Promise<string> = async () => 'memoir store not ready'
72 // Pull main into the branch: what main has that the branch does not, the same strategies.
73 let pullNow: (strategy: MemoirMergeStrategy) => Promise<string> = async () => 'memoir store not ready'
74 // What one commit changed, for the Recent tab's detail.
75 let loadCommit: (sha: string) => Promise<MemoirCommitDetail> = async sha => ({ sha, changes: null })
76 let hintSuppressed = false
77 let home: string | null = null
78
79 on('session.start', async ($, e, next) => {
80 const host: store.Host = {
81 root: $.plugin.root,
82 run: (argv, init) => $.process.run(argv, init),
83 read: path => $.fs.read(path),
84 stat: path => $.fs.stat(path),
85 exists: path => $.fs.exists(path),
86 }
87
88 let storePath: string | null = null
89 try {
90 storePath = (await $.env.get('MEMOIR_STORE')) || null
91 } catch {
92 // unset: derive it
93 }
94 if (!storePath) storePath = await store.deriveStore(host, e.cwd)
95 try {
96 home = (await $.env.get('HOME')) || null
97 } catch {
98 home = null
99 }
100 try {
101 // The person already wires scripts/statusline.sh into their status line:
102 // don't show memoir's state twice.
103 hintSuppressed = fmt.usesStatuslineScript(await $.settings.read())
104 } catch {
105 hintSuppressed = false
106 }
107
108 let fingerprint = ''
109 let inFlight: Promise<void> | null = null
110 // Other branches' differences from main, kept until the branch or main moves.
111 const branchCache = new Map<string, { tips: string; diff: MemoirBranchDiff }>()
112 // While a merge checks main out and back, a poll would draw the wrong branch.
113 let merging = false
114 const contents = new Map<string, string | null>() // sha → stored text
115
116 const doRefresh = async (force: boolean): Promise<void> => {
117 if (!storePath || !(await store.storeExists(host, storePath))) {
118 if ((await read($, status)).store !== null) await update($, status, () => EMPTY_STATUS)
119 return
120 }
121 const path = storePath
122 const everyBranch = await read($, allBranches)
123 let fp = await store.fingerprint(host, path)
124 // With every branch on view, any branch moving must redraw it too.
125 const heads = everyBranch ? await store.listBranches(host, path) : []
126 if (everyBranch) fp += '|' + heads.map(h => `${h.branch}@${h.tip}`).join(',')
127 if (!force && fp === fingerprint) return
128 fingerprint = fp
129
130 const head = await store.readHead(host, path)
131 // Off main the pane compares with main, and the Recent tab lists only
132 // the commits not on it.
133 const base = head.branch && head.branch !== 'main' ? 'main' : null
134 const since = base && head.branch ? await store.mergedAt(host, path, base, head.branch) : null
135 const log = await store.readLog(host, path, { base, since })
136 // A miss is not cached: a refresh can fire while the Stop hook is still
137 // writing, when `get` fails or finds nothing, so unknown contents are
138 // asked for again on the next refresh (and on `r`).
139 const missing = log.entries.filter(c => c.kind === 'store' && c.key !== null && !contents.has(c.sha))
140 if (missing.length > 0) {
141 const fetched = await store.fetchContents(
142 host,
143 path,
144 missing.map(c => ({ key: c.key ?? '', namespace: c.namespace ?? 'default' })),
145 )
146 for (const c of missing) {
147 const content = fetched.get(`${c.namespace}:${c.key}`)
148 if (typeof content === 'string') contents.set(c.sha, content)
149 }
150 }
151 for (const sha of [...contents.keys()]) if (!log.order.includes(sha)) contents.delete(sha)
152
153 const memories = await store.readMemoryCount(host, path)
154 const commits = await store.countCommits(host, path, { base, since })
155 const commitsWithoutMetrics = await store.countCommits(host, path, { base, since }, false)
156 const tree = await store.readTaxonomy(host, path)
157
158 // Every default-namespace key's value, in one batched `get`: the Memories
159 // tab lists them, and the per-branch turn accumulators among them feed
160 // the Metrics tab. Read whole on each refresh since metrics change every turn.
161 const keys = Object.keys(tree)
162 const raw = await store.fetchContents(host, path, keys.map(key => ({ key, namespace: 'default' })))
163 const current = new Map<string, string | null>()
164 for (const key of keys) current.set(key, raw.get(`default:${key}`) ?? null)
165
166 const turnMetrics: Record<string, MemoirTurnMetrics> = {}
167 for (const key of keys) {
168 if (!key.startsWith(fmt.TURN_METRICS_PREFIX)) continue
169 const parsed = fmt.parseTurnMetrics(current.get(key))
170 if (parsed) turnMetrics[key.slice(fmt.TURN_METRICS_PREFIX.length)] = parsed
171 }
172
173 // Off main, compare with main: the CLI's key-level diff reads both
174 // commits from the tree (both values, never a checkout); a CLI without
175 // it leaves the comparison to git history (which keys were written).
176 const changes = base ? await store.readKeyChanges(host, path, base) : null
177 const history = base && !changes ? await store.readBranchDiff(host, path, base, since) : null
178 const compared: MemoirMemories = {
179 items: fmt.diffMemories(keys, current, changes, history),
180 base: changes || history ? base : null,
181 }
182
183 await update($, status, () => ({
184 store: path,
185 branch: head.branch,
186 head: log.order[0] ?? null,
187 memories,
188 commits,
189 commitsWithoutMetrics,
190 }))
191 await update($, order, () => log.order)
192 await update($, captures, () => log.entries.map(c => ({ ...c, content: contents.get(c.sha) ?? null })))
193 await update($, taxonomy, () => tree)
194 await update($, metrics, () => turnMetrics)
195 await update($, memoryList, () => compared)
196
197 if (everyBranch) {
198 const mainTip = heads.find(h => h.branch === 'main')?.tip ?? ''
199 const diffs: MemoirBranchDiff[] = []
200 for (const h of heads.filter(h => h.branch !== 'main').slice(0, BRANCH_LIMIT)) {
201 const tips = `${h.tip}:${mainTip}`
202 const cached = branchCache.get(h.branch)
203 if (cached && cached.tips === tips) {
204 diffs.push(cached.diff)
205 continue
206 }
207 const exact = await store.readKeyChanges(host, path, 'main', h.branch)
208 let diff: MemoirBranchDiff
209 if (exact) diff = { branch: h.branch, at: h.at, items: fmt.itemsFromChanges(exact.values()), exact: true }
210 else {
211 const merged = await store.mergedAt(host, path, 'main', h.branch)
212 const written = await store.readBranchDiff(host, path, 'main', merged, h.branch)
213 diff = { branch: h.branch, at: h.at, items: written ? fmt.itemsFromHistory(written) : [], exact: false }
214 }
215 branchCache.set(h.branch, { tips, diff })
216 diffs.push(diff)
217 }
218 await update($, branchList, () => diffs)
219 }
220 }
221
222 refresh = (force = false) => {
223 if (inFlight) return inFlight
224 if (merging && !force) return Promise.resolve()
225 inFlight = doRefresh(force)
226 .catch(() => undefined)
227 .finally(() => {
228 inFlight = null
229 })
230 return inFlight
231 }
232
233 apply = async (m, kind) => {
234 if (!storePath) return 'memoir store not ready'
235 if (!fmt.memoryActions(m, (await read($, memoryList)).base).some(a => a.kind === kind)) return `nothing to do for ${m.key}`
236 const error =
237 kind === 'restore'
238 ? await store.restoreKey(host, storePath, m.key, m.base ?? '')
239 : await store.forgetKey(host, storePath, m.key)
240 await refresh(true)
241 return error ?? (kind === 'restore' ? `${m.key}: restored` : `${m.key}: forgotten`)
242 }
243
244 loadCommit = async sha => {
245 if (!storePath) return { sha, changes: null }
246 const changes = await store.readKeyChanges(host, storePath, `${sha}~1`, sha)
247 return { sha, changes: changes ? [...changes.values()] : null }
248 }
249
250 mergeNow = async strategy => {
251 const current = await read($, status)
252 const base = (await read($, memoryList)).base
253 if (!storePath || !current.branch) return 'memoir store not ready'
254 if (!base) return 'nothing to merge: not on a branch compared with main'
255 const values = new Map((await read($, memoryList)).items.map(m => [m.key, m.content]))
256 merging = true
257 try {
258 return await store.mergeInto(host, storePath, current.branch, base, strategy, values)
259 } finally {
260 merging = false
261 await refresh(true)
262 }
263 }
264
265 pullNow = async strategy => {
266 const current = await read($, status)
267 const base = (await read($, memoryList)).base
268 if (!storePath || !current.branch) return 'memoir store not ready'
269 if (!base) return 'nothing to pull: not on a branch compared with main'
270 // `both` appends main's value to the branch's: the base side of the comparison.
271 const values = new Map((await read($, memoryList)).items.map(m => [m.key, m.base]))
272 merging = true
273 try {
274 return await store.pullFrom(host, storePath, base, strategy, values)
275 } finally {
276 merging = false
277 await refresh(true)
278 }
279 }
280
281 void refresh(true)
282 $.clock.every(POLL_MS, () => refresh())
283 return next(e)
284 })
285
286 // /clear (and resume/fork) keep the module and its timer but start a new
287 // conversation: drop the turn baseline so `+N this turn` does not carry over.
288 on('classic.SessionStart', async ($, e, next) => {
289 try {
290 await update($, turnBaseline, () => null)
291 } catch {
292 // state unavailable: nothing to reset
293 }
294 void refresh(true)
295 return next(e)
296 }).catch(($, e, next) => next(e))
297
298 on('turn.start', async ($, e, next) => {
299 try {
300 const current = await read($, status)
301 await update($, turnBaseline, () => current.head)
302 } catch {
303 // state unavailable: the hint simply shows no `+N`
304 }
305 return next(e)
306 })
307
308 // The slash command is commands/pane.md, so it keeps the `/memoir:` prefix;
309 // this hook answers it without running that prompt. On a Claude Code too
310 // old to load mods the markdown runs as before and prints a text summary.
311 on('command.run', { command: COMMAND }, async $ => {
312 // Right after session start the first read may still be in flight (a `-p`
313 // run gets here within milliseconds): wait for it so the answer is current.
314 await refresh()
315 const summary = async () => fmt.textSummary(await read($, status), await read($, captures), await $.clock.now())
316 let surfaces: readonly string[] = []
317 try {
318 surfaces = await $.session.surfaces()
319 } catch {
320 // nothing draws: fall through to text
321 }
322 if (surfaces.length === 0) return { text: await summary() }
323 if (!(await read($, status)).store) return { text: await summary() }
324 const opened = await $.ui.open({ id: PANE_ID, title: 'memoir', focus: true })
325 if (!opened.isPlaced) return { text: `${await summary()}\n\n(widen the terminal to show the memoir pane)` }
326 void refresh(true)
327 return { text: 'memoir pane opened.' }
328 }).catch(($, e, next) => next(e))
329
330 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) =>
331 renderPane({
332 elements: $.ui.resolve(e),
333 width: e.props.bodyColumns,
334 now: await $.clock.now(),
335 home,
336 status: await read($, status),
337 captures: await read($, captures),
338 order: await read($, order),
339 tab: await read($, tab),
340 showMetrics: await read($, showMetrics),
341 baseline: await read($, turnBaseline),
342 taxonomy: await read($, taxonomy),
343 metrics: await read($, metrics),
344 memories: await read($, memoryList),
345 filter: await read($, filter),
346 selected: await read($, selected),
347 selectedBranch: await read($, selectedBranch),
348 allBranches: await read($, allBranches),
349 branches: await read($, branchList),
350 armed: await read($, armed),
351 merge: await read($, merge),
352 pull: await read($, pull),
353 commit: await read($, commit),
354 commitDetail: await read($, commitDetail),
355 showTaxonomy: await read($, showTaxonomy),
356 expanded: await read($, expanded),
357 actions: {
358 pickTab: next => update($, tab, () => next),
359 toggleMetrics: () => update($, showMetrics, v => !v),
360 toggleTaxonomy: () => update($, showTaxonomy, v => !v),
361 setFilter: text => update($, filter, () => text),
362 select: async (key, branch = null) => {
363 await update($, armed, () => null)
364 await update($, selectedBranch, () => (key === null ? null : branch))
365 await update($, selected, () => key)
366 },
367 toggleAllBranches: async () => {
368 await update($, allBranches, v => !v)
369 await refresh(true)
370 },
371 arm: kind => update($, armed, () => kind),
372 disarm: () => update($, armed, () => null),
373 apply: async (m, kind) => {
374 await update($, armed, () => null)
375 await update($, selected, () => null)
376 $.ui.toast(await apply(m, kind))
377 },
378 openCommit: async sha => {
379 await update($, commitDetail, () => null)
380 await update($, commit, () => sha)
381 if (sha === null) return
382 const detail = await loadCommit(sha)
383 // Still the one open? A quick second click may have moved on.
384 if ((await read($, commit)) === sha) await update($, commitDetail, () => detail)
385 },
386 openMerge: () => update($, merge, () => 'choose'),
387 cancelMerge: () => update($, merge, () => 'idle'),
388 runMerge: async strategy => {
389 await update($, merge, () => 'running')
390 const text = await mergeNow(strategy)
391 await update($, merge, () => 'idle')
392 $.ui.toast(text)
393 },
394 openPull: () => update($, pull, () => 'choose'),
395 cancelPull: () => update($, pull, () => 'idle'),
396 runPull: async strategy => {
397 await update($, pull, () => 'running')
398 const text = await pullNow(strategy)
399 await update($, pull, () => 'idle')
400 $.ui.toast(text)
401 },
402 toggleGroup: name =>
403 update($, expanded, names => (names.includes(name) ? names.filter(n => n !== name) : [...names, name])),
404 refresh: () => refresh(true),
405 },
406 }),
407 )
408
409 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
410 if (hintSuppressed) return next(e)
411 const current = await read($, status)
412 const list = await read($, captures)
413 const shas = await read($, order)
414 const baseline = await read($, turnBaseline)
415 const tail = fmt.hintTail(current, fmt.newCount(list, shas, baseline))
416 return tail === undefined ? next(e) : next({ ...e, props: { ...e.props, tail } })
417 })
418}
419hooks/mod/format.ts 406 lines1// Pure helpers: parsing the store's git log, deciding what is "new this turn",
2// and rendering the one-line hint and the plain-text fallback. Nothing here
3// touches `$`, so every rule is unit-testable without a store.
4
5import type { MemoirCapture, MemoirKeyChange, MemoirMemory, MemoirMemoryAction, MemoirStatus, MemoirTurnMetrics } from '../../types'
6
7export type LogEntry = Omit<MemoirCapture, 'content'>
8
9export type ParsedLog = {
10 /** Every commit's sha, newest first. */
11 order: string[]
12 /** The `Store <key> in <namespace>` commits among them, newest first. */
13 entries: LogEntry[]
14}
15
16const SUBJECT = /^Store (\S+) in (\S+)$/
17const DELETION = /^Delete (\S+) from (\S+)$/
18
19/** Parse `git log --format=%H%x09%ct%x09%s` output: every commit, keyed ones recognised. */
20export function parseLog(stdout: string): ParsedLog {
21 const order: string[] = []
22 const entries: LogEntry[] = []
23 for (const line of stdout.split('\n')) {
24 const [sha, ct, ...rest] = line.split('\t')
25 if (!sha || !ct) continue
26 order.push(sha)
27 const at = Number(ct)
28 if (!Number.isFinite(at)) continue
29 const subject = rest.join('\t')
30 const stored = SUBJECT.exec(subject)
31 const deleted = stored ? null : DELETION.exec(subject)
32 const match = stored ?? deleted
33 entries.push({
34 sha,
35 at,
36 subject,
37 kind: stored ? 'store' : deleted ? 'delete' : 'other',
38 key: match ? match[1]! : null,
39 namespace: match ? match[2]! : null,
40 })
41 }
42 return { order, entries }
43}
44
45/** The keys named by `Store <key> in <namespace>` subjects, default namespace only. */
46export function parseStoredKeys(subjects: string): Set<string> {
47 const keys = new Set<string>()
48 for (const line of subjects.split('\n')) {
49 const match = SUBJECT.exec(line.trim())
50 if (match && match[2] === 'default') keys.add(match[1]!)
51 }
52 return keys
53}
54
55export const isMetric = (key: string): boolean => key.startsWith('metrics.')
56
57export function visibleCaptures<T extends { key: string | null }>(captures: readonly T[], showMetrics: boolean): T[] {
58 return captures.filter(c => showMetrics || c.key === null || !isMetric(c.key))
59}
60
61/**
62 * Shas committed after `baseline` (the head when the turn started): everything
63 * in `order` ahead of it. A baseline that fell out of the window means every
64 * commit in it is newer; no baseline means nothing is new yet.
65 */
66export function newShas(order: readonly string[], baseline: string | null): Set<string> {
67 if (baseline === null) return new Set()
68 const i = order.indexOf(baseline)
69 return new Set(order.slice(0, i === -1 ? order.length : i))
70}
71
72/** Memories stored this turn, metrics excluded: the hint line's `+N`. */
73export function newCount(
74 captures: readonly { sha: string; kind: string; key: string | null }[],
75 order: readonly string[],
76 baseline: string | null,
77): number {
78 const fresh = newShas(order, baseline)
79 return captures.filter(c => fresh.has(c.sha) && c.kind === 'store' && c.key !== null && !isMetric(c.key)).length
80}
81
82/** Read `.git/HEAD`: a branch name, or the short sha of a detached head. */
83export function parseHead(text: string): { branch: string | null; ref: string | null } {
84 const line = text.split('\n')[0]?.trim() ?? ''
85 if (line.startsWith('ref: ')) {
86 const ref = line.slice('ref: '.length)
87 return { branch: ref.replace(/^refs\/heads\//, ''), ref }
88 }
89 if (line) return { branch: line.slice(0, 8), ref: null }
90 return { branch: null, ref: null }
91}
92
93export function plural(n: number, one: string, many: string): string {
94 return `${n} ${n === 1 ? one : many}`
95}
96
97/** `main · 18 memories · 61 commits`, dropping what is unknown. */
98/**
99 * The commit count's label: on a branch the count is what sits on top of
100 * main, so it reads `+N changes`. While metrics are hidden the count leaves
101 * the `metrics.*` writes out, so it matches what the Recent tab lists.
102 */
103export function commitsLabel(status: MemoirStatus, showMetrics = true): string | null {
104 const n = showMetrics ? status.commits : (status.commitsWithoutMetrics ?? status.commits)
105 if (n === null) return null
106 return status.branch && status.branch !== 'main' ? `+${plural(n, 'change', 'changes')}` : plural(n, 'commit', 'commits')
107}
108
109export function statusLine(status: MemoirStatus, showMetrics = true): string {
110 const parts: string[] = []
111 if (status.branch) parts.push(status.branch)
112 if (status.memories !== null) parts.push(plural(status.memories, 'memory', 'memories'))
113 const commits = commitsLabel(status, showMetrics)
114 if (commits !== null) parts.push(commits)
115 return parts.join(' · ')
116}
117
118/** The hint-line suffix, or undefined when there is nothing to show. */
119export function hintTail(status: MemoirStatus, fresh: number): string | undefined {
120 if (!status.store || !status.branch) return undefined
121 const parts = [`memoir: ${status.branch}`]
122 if (status.memories !== null) parts.push(plural(status.memories, 'memory', 'memories'))
123 if (fresh > 0) parts.push(`+${fresh} this turn`)
124 return parts.join(' · ')
125}
126
127/** The store path for the header: home shown as `~`, cut from the left to fit `max` columns. */
128export function storeLabel(path: string, home: string | null, max: number): string {
129 const short = home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
130 if (max < 2) return ''
131 return short.length <= max ? short : `…${short.slice(short.length - (max - 1))}`
132}
133
134/** True when the person's settings already route memoir into the status line. */
135export function usesStatuslineScript(settings: Readonly<Record<string, unknown>>): boolean {
136 const statusLineSetting = settings['statusLine']
137 if (!statusLineSetting || typeof statusLineSetting !== 'object') return false
138 const command = (statusLineSetting as Record<string, unknown>)['command']
139 return typeof command === 'string' && command.includes('statusline.sh')
140}
141
142export function relativeTime(nowMs: number, atSec: number): string {
143 const s = Math.max(0, Math.floor(nowMs / 1000) - atSec)
144 if (s < 60) return 'just now'
145 const m = Math.floor(s / 60)
146 if (m < 60) return `${m} min ago`
147 const h = Math.floor(m / 60)
148 if (h < 24) return `${h} ${h === 1 ? 'hour' : 'hours'} ago`
149 const d = Math.floor(h / 24)
150 return `${d} ${d === 1 ? 'day' : 'days'} ago`
151}
152
153/** Greedy word wrap into at most `max` lines of `width` cells, the last one elided. */
154export function wrapLines(text: string, width: number, max: number): string[] {
155 const w = Math.max(4, width)
156 const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
157 const chunks: string[] = []
158 let current = ''
159 for (const word of words) {
160 if (!current) current = word
161 else if (current.length + 1 + word.length <= w) current += ' ' + word
162 else {
163 chunks.push(current)
164 current = word
165 }
166 }
167 if (current) chunks.push(current)
168 const cut = chunks.flatMap(c => {
169 const out: string[] = []
170 for (let i = 0; i < c.length; i += w) out.push(c.slice(i, i + w))
171 return out
172 })
173 if (cut.length <= max) return cut
174 const kept = cut.slice(0, max)
175 const last = kept[max - 1]!
176 kept[max - 1] = (last.length >= w ? last.slice(0, w - 1) : last) + '…'
177 return kept
178}
179
180export type TaxonomyGroup = {
181 name: string
182 count: number
183 leaves: { path: string; count: number }[]
184}
185
186/** Group leaf paths by their first segment, each group's leaves sorted. */
187export function groupTaxonomy(prefixCounts: Readonly<Record<string, number>>, showMetrics: boolean): TaxonomyGroup[] {
188 const groups = new Map<string, TaxonomyGroup>()
189 for (const [path, count] of Object.entries(prefixCounts)) {
190 if (!showMetrics && isMetric(path)) continue
191 const dot = path.indexOf('.')
192 const name = dot === -1 ? path : path.slice(0, dot)
193 const leaf = dot === -1 ? '' : path.slice(dot + 1)
194 const group = groups.get(name) ?? { name, count: 0, leaves: [] }
195 group.count += count
196 if (leaf) group.leaves.push({ path: leaf, count })
197 groups.set(name, group)
198 }
199 return [...groups.values()]
200 .sort((a, b) => a.name.localeCompare(b.name))
201 .map(g => ({ ...g, leaves: [...g.leaves].sort((a, b) => a.path.localeCompare(b.path)) }))
202}
203
204/** What `/memoir:pane` prints where nothing can draw a pane. */
205export function textSummary(
206 status: MemoirStatus,
207 captures: readonly MemoirCapture[],
208 nowMs: number,
209 limit = 10,
210): string {
211 if (!status.store) return 'No memoir store for this folder.'
212 // The list below leaves metrics writes out, so the count does too.
213 const lines = [`memoir: ${statusLine(status, false)}`]
214 const shown = visibleCaptures(captures, false).slice(0, limit)
215 if (shown.length === 0) {
216 lines.push('No captures yet.')
217 return lines.join('\n')
218 }
219 lines.push('Recent commits:')
220 for (const c of shown) {
221 const content = c.content ? `: ${wrapLines(c.content, 80, 1)[0] ?? ''}` : ''
222 lines.push(` ${relativeTime(nowMs, c.at).padEnd(12)} ${c.subject}${content}`)
223 }
224 return lines.join('\n')
225}
226
227/** `2026-10-11 04:47` in the local time zone. */
228export function absoluteTime(atSec: number): string {
229 const d = new Date(atSec * 1000)
230 const two = (n: number) => String(n).padStart(2, '0')
231 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
232}
233
234export const TURN_METRICS_PREFIX = 'metrics.turn.'
235
236/** Parse a `metrics.turn.<branch>` value as the Stop hook's merge-metrics.py writes it. */
237export function parseTurnMetrics(content: string | null | undefined): MemoirTurnMetrics | null {
238 if (!content) return null
239 let raw: unknown
240 try {
241 raw = JSON.parse(content)
242 } catch {
243 return null
244 }
245 if (!raw || typeof raw !== 'object') return null
246 const v = raw as Record<string, unknown>
247 const num = (key: string) => (typeof v[key] === 'number' && Number.isFinite(v[key]) ? (v[key] as number) : 0)
248 return {
249 turns: num('turns_count'),
250 toolCalls: num('total_tool_calls'),
251 toolErrors: num('total_tool_errors'),
252 repeatedToolCalls: num('total_repeated_tool_calls'),
253 outputChars: num('total_output_chars'),
254 toolInputChars: num('total_tool_input_chars'),
255 toolResultChars: num('total_tool_result_chars'),
256 latencyMs: num('total_latency_ms'),
257 latencySamples: num('latency_samples'),
258 }
259}
260
261/** `28285` → `28k`, `1470251` → `1.5M`; small numbers stay as they are. */
262export function compactNumber(n: number): string {
263 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
264 if (n >= 10_000) return `${Math.round(n / 1_000)}k`
265 return String(Math.round(n))
266}
267
268export type MetricChart = {
269 title: string
270 rows: { branch: string; isCurrent: boolean; value: number; label: string }[]
271}
272
273const METRICS: { title: string; pick: (m: MemoirTurnMetrics) => number | null; format: (n: number) => string }[] = [
274 { title: 'Turns', pick: m => m.turns, format: compactNumber },
275 { title: 'Tool calls', pick: m => m.toolCalls, format: compactNumber },
276 { title: 'Tool errors', pick: m => m.toolErrors, format: compactNumber },
277 { title: 'Avg latency (s)', pick: m => (m.latencySamples > 0 ? m.latencyMs / m.latencySamples / 1000 : null), format: n => n.toFixed(1) },
278 { title: 'Output chars', pick: m => m.outputChars, format: compactNumber },
279 { title: 'Tool result chars', pick: m => m.toolResultChars, format: compactNumber },
280]
281
282/**
283 * One chart per metric, branches as rows in one fixed order (the current
284 * branch first, then by name) so a row keeps its place from chart to chart.
285 * A branch with no sample for a metric is left out of that chart.
286 */
287export function metricCharts(metrics: Readonly<Record<string, MemoirTurnMetrics>>, current: string | null): MetricChart[] {
288 const branches = Object.keys(metrics).sort((a, b) => {
289 if (a === current) return -1
290 if (b === current) return 1
291 return a.localeCompare(b)
292 })
293 return METRICS.map(metric => ({
294 title: metric.title,
295 rows: branches.flatMap(branch => {
296 const value = metric.pick(metrics[branch]!)
297 return value === null ? [] : [{ branch, isCurrent: branch === current, value, label: metric.format(value) }]
298 }),
299 }))
300}
301
302/** A bar of `width` cells at most, scaled to `max`; anything above zero shows at least one cell. */
303export function bar(value: number, max: number, width: number): string {
304 if (value <= 0 || max <= 0 || width <= 0) return ''
305 return '█'.repeat(Math.max(1, Math.round((value / max) * width)))
306}
307
308/**
309 * Compare the branch's keys with main. With `changes` (the CLI's key-level
310 * diff, both values known) every difference is exact, and a key main holds
311 * that the branch dropped is listed as `removed`. Without it, from git
312 * history alone: a key the branch wrote since leaving main is `changed` when
313 * main's history ever stored it, else `added`, and main's values stay unknown.
314 * With neither, every key is `same`; the caller records that no base was compared.
315 */
316export function diffMemories(
317 keys: readonly string[],
318 current: ReadonlyMap<string, string | null>,
319 changes: ReadonlyMap<string, MemoirKeyChange> | null,
320 history: { written: ReadonlySet<string>; onBase: ReadonlySet<string> } | null,
321): MemoirMemory[] {
322 const items: MemoirMemory[] = [...keys].sort().map(key => {
323 const content = current.get(key) ?? null
324 const change = changes?.get(key)
325 if (changes) {
326 const status: MemoirMemory['status'] = change?.operation === 'added' ? 'added' : change?.operation === 'modified' ? 'changed' : 'same'
327 return { key, content, base: change?.old ?? null, status }
328 }
329 let status: MemoirMemory['status'] = 'same'
330 if (history && history.written.has(key)) status = history.onBase.has(key) ? 'changed' : 'added'
331 return { key, content, base: null, status }
332 })
333 if (changes) {
334 const present = new Set(keys)
335 for (const change of changes.values()) {
336 if (change.operation === 'removed' && !present.has(change.key)) {
337 items.push({ key: change.key, content: null, base: change.old, status: 'removed' })
338 }
339 }
340 items.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
341 }
342 return items
343}
344
345/** The memories whose key contains `query` (case-insensitive); all of them for a blank query. */
346export function filterMemories<T extends { key: string }>(items: readonly T[], query: string): T[] {
347 const q = query.trim().toLowerCase()
348 return q ? items.filter(m => m.key.toLowerCase().includes(q)) : [...items]
349}
350
351/** Wrap each line of `text` (newlines kept) to `width`, at most `max` lines in all. */
352export function wrapText(text: string, width: number, max: number): string[] {
353 const out: string[] = []
354 for (const paragraph of text.split('\n')) {
355 if (out.length >= max) break
356 if (!paragraph.trim()) {
357 out.push('')
358 continue
359 }
360 out.push(...wrapLines(paragraph, width, max - out.length))
361 }
362 return out.slice(0, max)
363}
364
365export const MARKS: Record<MemoirMemory['status'], string> = { added: '+', changed: '~', removed: '-', same: ' ' }
366
367/** The memoir mark in three rows of box drawing: the logo's arch over its two loops. */
368export const LOGO: readonly string[] = [' ┏━━━━━┓ ', '┏┛┏┓ ┏┓┗┓', '┗━┛┗━┛┗━┛']
369
370export type MemoryAction = { verb: string; kind: MemoirMemoryAction; hotkey: string }
371
372/**
373 * What the detail view can do to a memory: forget it on this branch (any key
374 * that is here), and put main's value back (a key main holds, when that
375 * value could be read). A `~` key offers both.
376 */
377export function memoryActions(m: MemoirMemory, base: string | null): MemoryAction[] {
378 const actions: MemoryAction[] = []
379 if (m.status !== 'removed') actions.push({ verb: 'forget key', kind: 'forget', hotkey: 'd' })
380 if ((m.status === 'changed' || m.status === 'removed') && m.base !== null) {
381 actions.push({
382 verb: m.status === 'changed' ? `restore ${base ?? 'main'}'s value` : `restore from ${base ?? 'main'}`,
383 kind: 'restore',
384 hotkey: 'u',
385 })
386 }
387 return actions
388}
389
390/** Another branch's differences from main, from the CLI's key-level diff: both values known. */
391export function itemsFromChanges(changes: Iterable<MemoirKeyChange>): MemoirMemory[] {
392 const items: MemoirMemory[] = []
393 for (const c of changes) {
394 const status: MemoirMemory['status'] = c.operation === 'added' ? 'added' : c.operation === 'removed' ? 'removed' : 'changed'
395 items.push({ key: c.key, content: c.new, base: c.old, status })
396 }
397 return items.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
398}
399
400/** Another branch's differences from main, from git history alone: which keys it wrote, no values. */
401export function itemsFromHistory(history: { written: ReadonlySet<string>; onBase: ReadonlySet<string> }): MemoirMemory[] {
402 return [...history.written]
403 .sort()
404 .map(key => ({ key, content: null, base: null, status: history.onBase.has(key) ? 'changed' : 'added' }))
405}
406hooks/mod/pane.tsx 594 lines1// The pane's tree, built from plain values the render hook read: no `$` here,
2// so the layout is a pure function of the view.
3
4import type { EngineInterface, RenderElement } from 'claude-code'
5
6import type {
7 MemoirBranchDiff,
8 MemoirCapture,
9 MemoirCommitDetail,
10 MemoirMemories,
11 MemoirMemory,
12 MemoirMemoryAction,
13 MemoirMergeState,
14 MemoirMergeStrategy,
15 MemoirStatus,
16 MemoirTab,
17 MemoirTurnMetrics,
18} from '../../types'
19import * as fmt from './format'
20
21export type PaneView = {
22 elements: ReturnType<EngineInterface['ui']['resolve']>
23 width: number
24 now: number
25 /** The person's home folder, for showing the store path as `~/…`; null when unknown. */
26 home: string | null
27 status: MemoirStatus
28 captures: readonly MemoirCapture[]
29 order: readonly string[]
30 tab: MemoirTab
31 showMetrics: boolean
32 showTaxonomy: boolean
33 baseline: string | null
34 taxonomy: Readonly<Record<string, number>>
35 metrics: Readonly<Record<string, MemoirTurnMetrics>>
36 memories: MemoirMemories
37 filter: string
38 /** The key whose detail is open, or null for the list. */
39 selected: string | null
40 /** The branch that key belongs to in the all-branches view; null for the current branch. */
41 selectedBranch: string | null
42 allBranches: boolean
43 branches: readonly MemoirBranchDiff[]
44 /** The detail action pressed once, awaiting `y`; null when none. */
45 armed: MemoirMemoryAction | null
46 merge: MemoirMergeState
47 pull: MemoirMergeState
48 commit: string | null
49 commitDetail: MemoirCommitDetail | null
50 expanded: readonly string[]
51 actions: {
52 pickTab: (tab: MemoirTab) => unknown
53 toggleMetrics: () => unknown
54 toggleTaxonomy: () => unknown
55 setFilter: (text: string) => unknown
56 select: (key: string | null, branch?: string | null) => unknown
57 toggleAllBranches: () => unknown
58 arm: (kind: MemoirMemoryAction) => unknown
59 disarm: () => unknown
60 apply: (m: MemoirMemory, kind: MemoirMemoryAction) => unknown
61 openCommit: (sha: string | null) => unknown
62 openMerge: () => unknown
63 cancelMerge: () => unknown
64 runMerge: (strategy: MemoirMergeStrategy) => unknown
65 openPull: () => unknown
66 cancelPull: () => unknown
67 runPull: (strategy: MemoirMergeStrategy) => unknown
68 toggleGroup: (name: string) => unknown
69 refresh: () => unknown
70 }
71}
72
73/** What happens to a key changed on both sides: the CLI's `merge -S ours|theirs`, or both values kept. */
74const STRATEGIES: { strategy: MemoirMergeStrategy; hotkey: string; what: string }[] = [
75 { strategy: 'ours', hotkey: 'o', what: "keep main's value" },
76 { strategy: 'theirs', hotkey: 't', what: "take this branch's value" },
77 { strategy: 'both', hotkey: 'b', what: "keep main's value and append this branch's" },
78]
79/** The same choices pulling main into the branch: `ours` is the branch's side then. */
80const PULL_STRATEGIES: { strategy: MemoirMergeStrategy; hotkey: string; what: string }[] = [
81 { strategy: 'ours', hotkey: 'o', what: "keep this branch's value" },
82 { strategy: 'theirs', hotkey: 't', what: "take main's value" },
83 { strategy: 'both', hotkey: 'b', what: "keep this branch's value and append main's" },
84]
85
86const MARK_COLOR: Record<MemoirMemory['status'], string | undefined> = {
87 added: 'diffAdded',
88 changed: 'warning',
89 removed: 'diffRemoved',
90 same: undefined,
91}
92
93const AGE_WIDTH = 11
94const INDENT = ' '.repeat(AGE_WIDTH + 3)
95
96export function renderPane(view: PaneView): RenderElement {
97 const { Box, Text, Button } = view.elements
98 // The mobile app draws no field yet: there the filter row is left out.
99 const Input = 'Input' in view.elements ? view.elements.Input : null
100 const { status, actions } = view
101 const width = Math.max(20, view.width)
102 const open = new Set(view.expanded)
103
104 // The mark on the left; title, tabs and status stacked beside it.
105 const header = (
106 <Box gap={2}>
107 <Box flexDirection="column">
108 {fmt.LOGO.map(line => (
109 <Text color="claude">{line}</Text>
110 ))}
111 </Box>
112 <Box flexDirection="column">
113 {status.store ? (
114 <Box gap={2}>
115 <Text bold>memoir</Text>
116 {/* logo, gaps and the title take the rest of the row */}
117 <Text dimColor>{fmt.storeLabel(status.store, view.home, width - fmt.LOGO[0]!.length - 'memoir'.length - 4)}</Text>
118 </Box>
119 ) : (
120 <Text bold>memoir</Text>
121 )}
122 <Box gap={2}>
123 <Button key="tab-memories" plain hotkey="1" dimColor={view.tab === 'recent' || view.tab === 'metrics'} onPress={() => void actions.pickTab('memories')}>
124 Memories
125 </Button>
126 <Button key="tab-recent" plain hotkey="2" dimColor={view.tab !== 'recent'} onPress={() => void actions.pickTab('recent')}>
127 Recent
128 </Button>
129 <Button key="tab-metrics" plain hotkey="3" dimColor={view.tab !== 'metrics'} onPress={() => void actions.pickTab('metrics')}>
130 Metrics
131 </Button>
132 </Box>
133 {/* Each part of the status is a shortcut to the tab that explains it. */}
134 <Box>
135 {status.branch ? (
136 <Button key="status-branch" plain dimColor onPress={() => void actions.pickTab('metrics')}>
137 {status.branch}
138 </Button>
139 ) : (
140 []
141 )}
142 {status.memories !== null ? (
143 <Box>
144 {status.branch ? <Text dimColor> · </Text> : []}
145 <Button key="status-memories" plain dimColor onPress={() => void actions.pickTab('memories')}>
146 {fmt.plural(status.memories, 'memory', 'memories')}
147 </Button>
148 </Box>
149 ) : (
150 []
151 )}
152 {status.commits !== null ? (
153 <Box>
154 {status.branch || status.memories !== null ? <Text dimColor> · </Text> : []}
155 <Button key="status-commits" plain dimColor onPress={() => void actions.pickTab('recent')}>
156 {fmt.commitsLabel(status, view.showMetrics) ?? ''}
157 </Button>
158 </Box>
159 ) : (
160 []
161 )}
162 </Box>
163 </Box>
164 </Box>
165 )
166
167 const metricsButton = (
168 <Button key="metrics" plain hotkey="m" dimColor onPress={() => void actions.toggleMetrics()}>
169 {`show metrics (${view.showMetrics ? 'on' : 'off'})`}
170 </Button>
171 )
172 const refreshButton = (
173 <Button key="refresh" plain hotkey="r" dimColor onPress={() => void actions.refresh()}>
174 refresh
175 </Button>
176 )
177
178 let body: RenderElement
179 let footer: RenderElement
180 if (!status.store) {
181 body = <Text dimColor>No memoir store for this folder.</Text>
182 footer = refreshButton
183 } else if (view.tab === 'recent') {
184 const fresh = fmt.newShas(view.order, view.baseline)
185 const shown = fmt.visibleCaptures(view.captures, view.showMetrics)
186 const open = view.commit === null ? undefined : view.captures.find(c => c.sha === view.commit)
187 if (open) {
188 // One commit: when it happened, and what it changed (both values from
189 // the CLI's key-level diff), else the key's value as it is now.
190 const detail = view.commitDetail?.sha === open.sha ? view.commitDetail : null
191 const textWidth = Math.max(8, width - 2)
192 const block = (text: string | null, sign: string, color: string | undefined) =>
193 (text === null ? ['(content unavailable)'] : fmt.wrapText(text, textWidth, 200)).map(line => (
194 <Text color={color} dimColor={text === null}>
195 {`${sign} ${line}`}
196 </Text>
197 ))
198 const changes = detail?.changes ?? null
199 body = (
200 <Box flexDirection="column">
201 <Box>
202 {fresh.has(open.sha) ? <Text color="success">● </Text> : <Text dimColor>○ </Text>}
203 <Text bold>{open.subject}</Text>
204 </Box>
205 <Text dimColor>{`${open.sha.slice(0, 8)} · ${fmt.absoluteTime(open.at)} · ${fmt.relativeTime(view.now, open.at)}`}</Text>
206 <Text> </Text>
207 {detail === null ? (
208 <Text dimColor>reading the commit…</Text>
209 ) : changes === null ? (
210 <Box flexDirection="column">
211 {open.kind === 'store' ? (
212 <Box flexDirection="column">
213 <Text dimColor>{`${open.key} now:`}</Text>
214 {block(open.content, ' ', undefined)}
215 <Text> </Text>
216 <Text dimColor>The change itself needs a memoir with key-level diffs; this is the key's current value.</Text>
217 </Box>
218 ) : (
219 <Text dimColor>The change itself needs a memoir with key-level diffs.</Text>
220 )}
221 </Box>
222 ) : changes.length === 0 ? (
223 <Text dimColor>No memory changed in this commit.</Text>
224 ) : (
225 <Box flexDirection="column">
226 {changes.map(c => (
227 <Box flexDirection="column" marginBottom={1}>
228 <Box>
229 <Text color={c.operation === 'added' ? 'diffAdded' : c.operation === 'removed' ? 'diffRemoved' : 'warning'}>
230 {`${c.operation === 'added' ? '+' : c.operation === 'removed' ? '-' : '~'} `}
231 </Text>
232 <Text bold>{c.key}</Text>
233 </Box>
234 {c.operation === 'added' ? [] : block(c.old, '-', 'diffRemoved')}
235 {c.operation === 'removed' ? [] : block(c.new, '+', 'diffAdded')}
236 </Box>
237 ))}
238 </Box>
239 )}
240 </Box>
241 )
242 footer = (
243 <Box justifyContent="space-between">
244 <Button key="back" plain hotkey="b" dimColor onPress={() => void actions.openCommit(null)}>
245 back
246 </Button>
247 {refreshButton}
248 </Box>
249 )
250 } else {
251 body =
252 shown.length === 0 ? (
253 <Text dimColor>{status.branch && status.branch !== 'main' ? 'Nothing beyond main yet.' : 'No commits yet.'}</Text>
254 ) : (
255 <Box flexDirection="column">
256 {shown.map(c => {
257 const isNew = fresh.has(c.sha)
258 const lines =
259 c.kind !== 'store' ? [] : c.content ? fmt.wrapLines(c.content, width - INDENT.length, 2) : ['(content unavailable)']
260 return (
261 <Box flexDirection="column" marginBottom={1}>
262 <Button key={`commit-${c.sha}`} plain onPress={() => void actions.openCommit(c.sha)}>
263 {isNew ? <Text color="success">●</Text> : <Text dimColor>○</Text>}
264 <Text dimColor>{` ${fmt.relativeTime(view.now, c.at).padEnd(AGE_WIDTH)} `}</Text>
265 <Text bold>{c.subject}</Text>
266 </Button>
267 {lines.map(line => (
268 <Text dimColor>
269 {INDENT}
270 {line}
271 </Text>
272 ))}
273 </Box>
274 )
275 })}
276 </Box>
277 )
278 footer = (
279 <Box justifyContent="space-between">
280 <Box gap={3}>
281 <Text dimColor>{`${status.branch && status.branch !== 'main' ? 'commits not on main · ' : ''}Enter/click a commit for its change`}</Text>
282 {metricsButton}
283 </Box>
284 {refreshButton}
285 </Box>
286 )
287 }
288 } else if (view.tab !== 'metrics') {
289 const { items, base } = view.memories
290 // Machine-written `metrics.*` keys have their own tab; this one is for memories.
291 const visible = fmt.visibleCaptures(items, false)
292 const here = visible.filter(m => m.status !== 'removed')
293 const differing = visible.filter(m => m.status !== 'same')
294 const summary =
295 base === null
296 ? `${fmt.plural(here.length, 'memory', 'memories')}${status.branch === 'main' ? ' on main' : ' · no main branch to compare'}`
297 : `${fmt.plural(here.length, 'memory', 'memories')} · ${differing.length === 1 ? '1 differs' : `${differing.length} differ`} from ${base}`
298 // The open key: the current branch's, or another branch's (read-only).
299 const elsewhere = view.selectedBranch === null ? undefined : view.branches.find(b => b.branch === view.selectedBranch)
300 const current =
301 view.selected === null
302 ? undefined
303 : elsewhere
304 ? elsewhere.items.find(m => m.key === view.selected)
305 : visible.find(m => m.key === view.selected)
306
307 if (current) {
308 // One key: its value, or both values when main's differs, and the one
309 // write the person can ask for (behind a second press). Another
310 // branch's key is shown the same way, with nothing to press.
311 const available = elsewhere ? [] : fmt.memoryActions(current, base)
312 const pending = view.armed === null ? undefined : available.find(a => a.kind === view.armed)
313 const textWidth = Math.max(8, width - 2)
314 const block = (text: string | null, sign: string, color: string | undefined) =>
315 (text === null ? ['(content unavailable)'] : fmt.wrapText(text, textWidth, 200)).map(line => (
316 <Text color={color} dimColor={text === null}>
317 {`${sign} ${line}`}
318 </Text>
319 ))
320 const branch = elsewhere ? elsewhere.branch : (status.branch ?? 'this branch')
321 const against = elsewhere ? 'main' : base
322 const explain =
323 current.status === 'added'
324 ? `new on ${branch}, not on ${against}`
325 : current.status === 'changed'
326 ? `changed on ${branch}; ${against} holds another value`
327 : current.status === 'removed'
328 ? `on ${against} only; forgotten on ${branch}`
329 : against === null
330 ? `on ${branch}`
331 : `same on ${branch} and ${against}`
332 body = (
333 <Box flexDirection="column">
334 <Box>
335 <Text color={MARK_COLOR[current.status]}>{`${fmt.MARKS[current.status]} `}</Text>
336 <Text bold>{current.key}</Text>
337 </Box>
338 <Text dimColor>{explain}</Text>
339 <Text> </Text>
340 {current.status === 'changed' || current.status === 'removed' ? (
341 <Box flexDirection="column">
342 <Text dimColor>{`${against}:`}</Text>
343 {block(current.base, '-', 'diffRemoved')}
344 </Box>
345 ) : (
346 []
347 )}
348 {current.status === 'changed' ? <Text> </Text> : []}
349 {current.status === 'removed' ? (
350 []
351 ) : (
352 <Box flexDirection="column">
353 {current.status === 'same' ? [] : <Text dimColor>{`${branch}:`}</Text>}
354 {block(current.content, current.status === 'same' ? ' ' : '+', current.status === 'same' ? undefined : 'diffAdded')}
355 </Box>
356 )}
357 {(current.status === 'changed' || current.status === 'removed') && current.base === null ? (
358 <Text dimColor>{`${against}'s value could not be read: update memoir for key-level diffs.`}</Text>
359 ) : (
360 []
361 )}
362 </Box>
363 )
364 footer = (
365 <Box justifyContent="space-between">
366 <Box gap={3}>
367 <Button key="back" plain hotkey="b" dimColor onPress={() => void actions.select(null)}>
368 back
369 </Button>
370 {pending ? (
371 <Box gap={3}>
372 <Button key="confirm" plain hotkey="y" onPress={() => void actions.apply(current, pending.kind)}>
373 {`confirm: ${pending.verb}`}
374 </Button>
375 <Button key="cancel" plain hotkey="n" dimColor onPress={() => void actions.disarm()}>
376 cancel
377 </Button>
378 </Box>
379 ) : (
380 available.map(a => (
381 <Button key={`act-${a.kind}`} plain hotkey={a.hotkey} dimColor onPress={() => void actions.arm(a.kind)}>
382 {a.verb}
383 </Button>
384 ))
385 )}
386 </Box>
387 {refreshButton}
388 </Box>
389 )
390 } else if (view.pull === 'running') {
391 body = <Text dimColor>{`Pulling ${base} into ${status.branch}…`}</Text>
392 footer = <Text dimColor>the store stays on this branch</Text>
393 } else if (view.pull === 'choose') {
394 // What main has that this branch does not: new keys, and keys changed or forgotten here.
395 const incoming = differing.filter(m => m.status !== 'added')
396 body = (
397 <Box flexDirection="column">
398 <Text>{`Pull ${base} into ${status.branch}: ${incoming.length === 1 ? '1 key differs' : `${incoming.length} keys differ`} on ${base}.`}</Text>
399 <Text dimColor>Keys changed only on main are applied either way; pick what to do with a key changed on both sides.</Text>
400 <Text> </Text>
401 {PULL_STRATEGIES.map(s => (
402 <Button key={`pull-${s.strategy}`} plain hotkey={s.hotkey} onPress={() => void actions.runPull(s.strategy)}>
403 {`${s.strategy.padEnd(7)}`}
404 <Text dimColor>{s.what}</Text>
405 </Button>
406 ))}
407 </Box>
408 )
409 footer = (
410 <Button key="pull-cancel" plain hotkey="n" dimColor onPress={() => void actions.cancelPull()}>
411 cancel
412 </Button>
413 )
414 } else if (view.merge === 'running') {
415 body = <Text dimColor>{`Merging ${status.branch} into ${base}…`}</Text>
416 footer = <Text dimColor>the store is on main for a moment; it comes back by itself</Text>
417 } else if (view.merge === 'choose') {
418 body = (
419 <Box flexDirection="column">
420 <Text>{`Merge ${status.branch} into ${base}: ${differing.length === 1 ? '1 key differs' : `${differing.length} keys differ`}.`}</Text>
421 <Text dimColor>Keys changed only here are applied either way; pick what to do with a key changed on both sides.</Text>
422 <Text> </Text>
423 {STRATEGIES.map(s => (
424 <Button key={`strategy-${s.strategy}`} plain hotkey={s.hotkey} onPress={() => void actions.runMerge(s.strategy)}>
425 {`${s.strategy.padEnd(7)}`}
426 <Text dimColor>{s.what}</Text>
427 </Button>
428 ))}
429 </Box>
430 )
431 footer = (
432 <Button key="merge-cancel" plain hotkey="n" dimColor onPress={() => void actions.cancelMerge()}>
433 cancel
434 </Button>
435 )
436 } else {
437 // Off main only what differs; on main (nothing to compare) every key.
438 const shown = fmt.filterMemories(base === null ? visible : differing, view.filter)
439 // Every other branch's differences from main, latest first; the current branch is the list above.
440 const others = view.allBranches ? view.branches.filter(b => b.branch !== status.branch) : []
441 const keyWidth = Math.min(
442 40,
443 Math.max(8, ...shown.map(m => m.key.length), ...others.flatMap(b => b.items.map(m => m.key.length))),
444 )
445 const snippetWidth = Math.max(8, width - 2 - keyWidth - 2)
446 const row = (m: MemoirMemory, branch: string | null) => {
447 const snippet = m.content ?? m.base
448 return (
449 <Button key={branch ? `mem-${branch}-${m.key}` : `mem-${m.key}`} plain onPress={() => void actions.select(m.key, branch)}>
450 <Text color={MARK_COLOR[m.status]}>{`${fmt.MARKS[m.status]} `}</Text>
451 <Text bold={m.status !== 'same'}>{m.key.padEnd(keyWidth).slice(0, keyWidth)}</Text>
452 <Text dimColor>{` ${snippet ? fmt.wrapLines(snippet, snippetWidth, 1)[0] ?? '' : '(content unavailable)'}`}</Text>
453 </Button>
454 )
455 }
456 const groups = view.showTaxonomy ? fmt.groupTaxonomy(view.taxonomy, false) : []
457 body = (
458 <Box flexDirection="column">
459 <Text dimColor>{summary}</Text>
460 {view.showTaxonomy ? (
461 <Box flexDirection="column" marginBottom={1}>
462 {groups.length === 0 ? (
463 <Text dimColor>No taxonomy yet.</Text>
464 ) : (
465 groups.map(g => (
466 <Box flexDirection="column">
467 <Button key={`group-${g.name}`} plain onPress={() => void actions.toggleGroup(g.name)}>
468 {`${open.has(g.name) ? '▾' : '▸'} ${g.name.padEnd(12)} ${g.count}`}
469 </Button>
470 {open.has(g.name) ? g.leaves.map(leaf => <Text dimColor>{` ${leaf.path}`}</Text>) : []}
471 </Box>
472 ))
473 )}
474 </Box>
475 ) : (
476 []
477 )}
478 {Input ? (
479 <Input
480 key="filter"
481 label="filter:"
482 placeholder="type part of a key"
483 value={view.filter}
484 submitLabel="apply"
485 onInput={(text: string) => void actions.setFilter(text)}
486 onSubmit={(text: string) => void actions.setFilter(text)}
487 />
488 ) : (
489 []
490 )}
491 {shown.length === 0 ? (
492 <Text dimColor>
493 {visible.length === 0 ? 'No memories yet.' : view.filter.trim() ? `No key contains "${view.filter.trim()}".` : `Nothing differs from ${base}.`}
494 </Text>
495 ) : (
496 shown.map(m => row(m, null))
497 )}
498 {view.allBranches
499 ? others.map(b => {
500 const rows = fmt.filterMemories(fmt.visibleCaptures(b.items, false), view.filter)
501 return (
502 <Box flexDirection="column" marginTop={1}>
503 <Box>
504 <Text bold>{b.branch}</Text>
505 <Text dimColor>
506 {` · ${rows.length === 1 ? '1 differs' : `${rows.length} differ`} from main · ${fmt.relativeTime(view.now, b.at)}${b.exact ? '' : ' · keys only'}`}
507 </Text>
508 </Box>
509 {rows.length === 0 ? <Text dimColor> nothing</Text> : rows.map(m => row(m, b.branch))}
510 </Box>
511 )
512 })
513 : []}
514 {view.allBranches && others.length === 0 ? <Text dimColor>No other branch.</Text> : []}
515 </Box>
516 )
517 footer = (
518 <Box justifyContent="space-between">
519 <Box gap={3}>
520 {/* No letter hotkeys on these three: a click, or Tab and Enter, so a stray key never toggles them. */}
521 <Button key="taxonomy" plain dimColor onPress={() => void actions.toggleTaxonomy()}>
522 {`taxonomy (${view.showTaxonomy ? 'on' : 'off'})`}
523 </Button>
524 <Button key="all" plain dimColor onPress={() => void actions.toggleAllBranches()}>
525 {`all branches (${view.allBranches ? 'on' : 'off'})`}
526 </Button>
527 {base !== null && differing.length > 0 ? (
528 <Button key="merge" plain dimColor onPress={() => void actions.openMerge()}>
529 {`merge to ${base} memory`}
530 </Button>
531 ) : (
532 []
533 )}
534 {base !== null && differing.some(m => m.status !== 'added') ? (
535 <Button key="pull" plain dimColor onPress={() => void actions.openPull()}>
536 {`pull from ${base} memory`}
537 </Button>
538 ) : (
539 []
540 )}
541 </Box>
542 {refreshButton}
543 </Box>
544 )
545 }
546 } else {
547 const charts = fmt.metricCharts(view.metrics, status.branch)
548 const hasRows = charts.some(c => c.rows.length > 0)
549 // One fixed label column across every chart, so the bars line up.
550 const labelWidth = Math.min(24, Math.max(4, ...charts.flatMap(c => c.rows.map(r => r.branch.length))))
551 const valueWidth = Math.max(1, ...charts.flatMap(c => c.rows.map(r => r.label.length)))
552 const barWidth = Math.max(4, width - 2 - labelWidth - 2 - valueWidth)
553 body = !hasRows ? (
554 <Text dimColor>No turn metrics yet. They accumulate after each turn.</Text>
555 ) : (
556 <Box flexDirection="column">
557 {charts.map(chart => {
558 const max = Math.max(0, ...chart.rows.map(r => r.value))
559 return (
560 <Box flexDirection="column" marginBottom={1}>
561 <Text bold>{chart.title}</Text>
562 {chart.rows.map(row => (
563 <Box>
564 <Text bold={row.isCurrent} dimColor={!row.isCurrent}>
565 {`${row.isCurrent ? '●' : ' '} ${fmt.wrapLines(row.branch, labelWidth, 1)[0]?.padEnd(labelWidth) ?? ''} `}
566 </Text>
567 <Text color={row.isCurrent ? 'claude' : 'inactive'}>{fmt.bar(row.value, max, barWidth).padEnd(barWidth)}</Text>
568 <Text dimColor>{` ${row.label}`}</Text>
569 </Box>
570 ))}
571 </Box>
572 )
573 })}
574 </Box>
575 )
576 footer = (
577 <Box justifyContent="space-between">
578 <Text dimColor>per branch, from metrics.turn.*</Text>
579 {refreshButton}
580 </Box>
581 )
582 }
583
584 return (
585 <Box flexDirection="column" width={width}>
586 {header}
587 <Box flexDirection="column" marginY={1}>
588 {body}
589 </Box>
590 {footer}
591 </Box>
592 )
593}
594hooks/mod/store.ts 392 lines1// The one unit that reads the host: the store's git metadata through the
2// filesystem and `git`, and memory contents through the plugin's CLI resolver
3// (scripts/memoir-cli.sh), never a bare `memoir` — hook environments lack the
4// venv PATH. `$` never crosses into this file (the engine refuses a passed
5// `$`); the hook hands over closures as a `Host`. Every call swallows failure
6// into "unknown".
7
8import type { FsStat, ProcessRunInit, ProcessRunResult } from 'claude-code'
9
10import type { MemoirKeyChange, MemoirMergeStrategy } from '../../types'
11import { parseHead, parseLog, parseStoredKeys, type ParsedLog } from './format'
12
13export type Host = {
14 /** The plugin's folder (`$.plugin.root`). */
15 root: string
16 run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
17 read: (path: string) => Promise<unknown>
18 stat: (path: string) => Promise<FsStat>
19 exists: (path: string) => Promise<boolean>
20}
21
22const TIMEOUT_MS = 10_000
23// A merge or a write on a large store can take a while; a kill mid-merge
24// would leave the store on main.
25const WRITE_TIMEOUT_MS = 180_000
26// How many commits the Recent tab reads: on main the latest ten, on a branch up to twenty of its own.
27const MAIN_LOG_WINDOW = 10
28const BRANCH_LOG_WINDOW = 20
29
30async function exec(host: Host, argv: readonly string[], cwd?: string, timeoutMs = TIMEOUT_MS): Promise<ProcessRunResult | null> {
31 try {
32 return await host.run(argv, { timeoutMs, ...(cwd ? { cwd } : {}) })
33 } catch {
34 return null
35 }
36}
37
38async function run(host: Host, argv: readonly string[], cwd?: string): Promise<string | null> {
39 const result = await exec(host, argv, cwd)
40 return result && result.exitCode === 0 ? result.stdout : null
41}
42
43async function readText(host: Host, path: string): Promise<string> {
44 const text = await host.read(path)
45 return typeof text === 'string' ? text : ''
46}
47
48async function mtime(host: Host, path: string): Promise<number> {
49 try {
50 return (await host.stat(path)).mtimeMs
51 } catch {
52 return 0
53 }
54}
55
56/** The store for `cwd`, by the same derivation the shell hooks use. */
57export async function deriveStore(host: Host, cwd: string): Promise<string | null> {
58 const out = await run(host, ['bash', `${host.root}/scripts/derive-store-path.sh`], cwd)
59 const path = out?.trim()
60 return path ? path : null
61}
62
63export async function storeExists(host: Host, store: string): Promise<boolean> {
64 try {
65 return await host.exists(`${store}/.git/HEAD`)
66 } catch {
67 return false
68 }
69}
70
71export async function readHead(host: Host, store: string): Promise<{ branch: string | null; ref: string | null }> {
72 try {
73 return parseHead(await readText(host, `${store}/.git/HEAD`))
74 } catch {
75 return { branch: null, ref: null }
76 }
77}
78
79/**
80 * A cheap fingerprint of the store's head: changes whenever HEAD or the
81 * checked-out branch's ref is rewritten (a capture, a checkout, a merge).
82 * One small read and two stats; no subprocess.
83 */
84export async function fingerprint(host: Host, store: string): Promise<string> {
85 const head = await readHead(host, store)
86 const headMtime = await mtime(host, `${store}/.git/HEAD`)
87 const refMtime = head.ref ? await mtime(host, `${store}/.git/${head.ref}`) : 0
88 // Off main the view compares with main, so main moving (another worktree
89 // or process merging this branch, say) must redraw it too.
90 const mainMtime = head.branch && head.branch !== 'main' ? await mtime(host, `${store}/.git/refs/heads/main`) : 0
91 const packedMtime = refMtime && (mainMtime || head.branch === 'main') ? 0 : await mtime(host, `${store}/.git/packed-refs`)
92 return `${head.branch ?? ''}:${headMtime}:${refMtime}:${mainMtime}:${packedMtime}`
93}
94
95/** Which commits the Recent tab lists: on a branch, those not on `base` (after `since`, when main took a merge of the branch). */
96export type LogScope = { base: string | null; since: string | null }
97
98export async function readLog(host: Host, store: string, scope: LogScope = { base: null, since: null }): Promise<ParsedLog> {
99 const range = scope.since ? ['HEAD', `--after=${scope.since}`] : scope.base ? [`${scope.base}..HEAD`] : []
100 const window = scope.base || scope.since ? BRANCH_LOG_WINDOW : MAIN_LOG_WINDOW
101 const out = await run(host, ['git', '-C', store, 'log', ...range, '--format=%H%x09%ct%x09%s', '-n', String(window)])
102 return out ? parseLog(out) : { order: [], entries: [] }
103}
104
105/**
106 * When `base` last took a merge of `branch`, as an ISO time, else null. A
107 * memoir merge commit has one parent, so `base..HEAD` keeps listing the
108 * merged commits; after that time only the branch's later commits count.
109 */
110export async function mergedAt(host: Host, store: string, base: string, branch: string): Promise<string | null> {
111 const out = await run(host, [
112 'git', '-C', store, 'log', base, '-n', '1', '--format=%cI', '--fixed-strings',
113 `--grep=Merge branch '${branch}' into ${base}`,
114 ])
115 const at = out?.trim()
116 return at ? at : null
117}
118
119/**
120 * How many commits the Recent tab's scope holds: on a branch, those on top
121 * of main; on main, all of them. With `metrics` false the machine-written
122 * `metrics.*` commits are left out, as the tab leaves them out by default.
123 */
124export async function countCommits(
125 host: Host,
126 store: string,
127 scope: LogScope = { base: null, since: null },
128 metrics = true,
129): Promise<number | null> {
130 const range = scope.since ? ['HEAD', `--after=${scope.since}`] : scope.base ? [`${scope.base}..HEAD`] : ['HEAD']
131 const filter = metrics ? [] : ['--invert-grep', '--grep=^Store metrics\\.', '--grep=^Delete metrics\\.']
132 const out = await run(host, ['git', '-C', store, 'rev-list', '--count', ...filter, ...range])
133 const n = Number(out?.trim())
134 return out && Number.isFinite(n) ? n : null
135}
136
137/** The user-facing memory count the shell hooks cache after every capture. */
138export async function readMemoryCount(host: Host, store: string): Promise<number | null> {
139 try {
140 const first = (await readText(host, `${store}/.git/plugin-statusline-cache`)).split('\n')[0] ?? ''
141 const digits = first.replace(/\D/g, '')
142 return digits ? Number(digits) : null
143 } catch {
144 return null
145 }
146}
147
148function cli(host: Host, store: string): string[] {
149 return ['bash', `${host.root}/scripts/memoir-cli.sh`, '--json', '-s', store]
150}
151
152/** Contents for the given keys, keyed `<namespace>:<key>`; one CLI call per namespace. */
153export async function fetchContents(
154 host: Host,
155 store: string,
156 entries: readonly { key: string; namespace: string }[],
157): Promise<Map<string, string | null>> {
158 const contents = new Map<string, string | null>()
159 const byNamespace = new Map<string, Set<string>>()
160 for (const entry of entries) {
161 const keys = byNamespace.get(entry.namespace) ?? new Set<string>()
162 keys.add(entry.key)
163 byNamespace.set(entry.namespace, keys)
164 }
165 for (const [namespace, keys] of byNamespace) {
166 const out = await run(host, [...cli(host, store), 'get', '-n', namespace, ...keys], store)
167 if (!out) continue
168 try {
169 const parsed = JSON.parse(out) as { items?: { key: string; found: boolean; value?: { content?: unknown } }[] }
170 for (const item of parsed.items ?? []) {
171 const content = item.found && typeof item.value?.content === 'string' ? item.value.content : null
172 contents.set(`${namespace}:${item.key}`, content)
173 }
174 } catch {
175 // not JSON: leave these unknown
176 }
177 }
178 return contents
179}
180
181/** Full key → count for the default namespace (deep enough that branch names with dots stay whole). */
182export async function readTaxonomy(host: Host, store: string): Promise<Record<string, number>> {
183 const out = await run(host, [...cli(host, store), 'summarize', '--depth', '8', '-n', 'default'], store)
184 if (!out) return {}
185 try {
186 const parsed = JSON.parse(out) as { prefix_counts?: Record<string, Record<string, number>> }
187 return parsed.prefix_counts?.['default'] ?? {}
188 } catch {
189 return {}
190 }
191}
192
193/**
194 * Which keys this branch wrote since it left main (`main..HEAD`, or after
195 * `since` once main took a merge of the branch), and which keys main's own
196 * history has ever stored: enough to tell what the branch added or changed,
197 * from git metadata alone. Never `memoir get --branch`: the CLI routes that
198 * by checking main out and back, and a Stop-hook write in that window lands
199 * on main. Null when main cannot be compared.
200 */
201export async function readBranchDiff(
202 host: Host,
203 store: string,
204 base: string,
205 since: string | null,
206 tip = 'HEAD',
207): Promise<{ written: Set<string>; onBase: Set<string> } | null> {
208 const written = since
209 ? await run(host, ['git', '-C', store, 'log', tip, `--after=${since}`, '--format=%s'])
210 : await run(host, ['git', '-C', store, 'log', `${base}..${tip}`, '--format=%s'])
211 if (written === null) return null
212 const onBase = await run(host, ['git', '-C', store, 'log', base, '--format=%s'])
213 if (onBase === null) return null
214 return { written: parseStoredKeys(written), onBase: parseStoredKeys(onBase) }
215}
216
217function contentOf(value: unknown): string | null {
218 if (typeof value === 'string') return value
219 if (value && typeof value === 'object' && typeof (value as { content?: unknown }).content === 'string') {
220 return (value as { content: string }).content
221 }
222 return null
223}
224
225/**
226 * Every default-namespace key that differs between `from` and `to`, with
227 * both values: `memoir diff <from> <to>`, which reads the two commits
228 * straight from the tree and never checks anything out. Null when the CLI
229 * predates key-level diffs (no `changes` in its answer) or fails; the caller
230 * then falls back to the git-history comparison.
231 */
232export async function readKeyChanges(
233 host: Host,
234 store: string,
235 from: string,
236 to = 'HEAD',
237): Promise<Map<string, MemoirKeyChange> | null> {
238 const out = await run(host, [...cli(host, store), 'diff', from, to], store)
239 if (!out) return null
240 try {
241 const parsed = JSON.parse(out) as {
242 changes?: { namespace?: string; key?: string; operation?: string; old?: unknown; new?: unknown }[]
243 }
244 if (!Array.isArray(parsed.changes)) return null
245 const changes = new Map<string, MemoirKeyChange>()
246 for (const c of parsed.changes) {
247 if (c.namespace !== 'default' || typeof c.key !== 'string') continue
248 if (c.operation !== 'added' && c.operation !== 'modified' && c.operation !== 'removed') continue
249 changes.set(c.key, { key: c.key, operation: c.operation, old: contentOf(c.old), new: contentOf(c.new) })
250 }
251 return changes
252 } catch {
253 return null
254 }
255}
256
257function failure(result: ProcessRunResult | null, what: string): string | null {
258 if (result && result.exitCode === 0) return null
259 const detail = result ? (result.stderr || result.stdout).trim().split('\n').pop() ?? '' : 'could not run the memoir CLI'
260 return `${what} failed${detail ? `: ${detail}` : ''}`
261}
262
263/** Delete `key` on the checked-out branch; the error text on failure, else null. */
264export async function forgetKey(host: Host, store: string, key: string): Promise<string | null> {
265 const result = await exec(host, [...cli(host, store), 'forget', '-n', 'default', '--force', '--', key], store, WRITE_TIMEOUT_MS)
266 return failure(result, `forget ${key}`)
267}
268
269/** Write `content` at `key` on the checked-out branch, replacing what is there; the error text on failure, else null. */
270export async function restoreKey(host: Host, store: string, key: string, content: string): Promise<string | null> {
271 const result = await exec(
272 host,
273 [...cli(host, store), 'remember', '-p', key, '-n', 'default', '--replace', '--', content],
274 store,
275 WRITE_TIMEOUT_MS,
276 )
277 return failure(result, `restore ${key}`)
278}
279
280/**
281 * Merge `source` into `target` (the checked-out branch when null) with the
282 * CLI's native three-way merge. `ours` and `theirs` are the CLI's own
283 * strategies, from the target's side; `both` merges with `ours` and then
284 * appends the other side's value (from `values`) to each key the merge
285 * reported as changed on both sides. Resolves to one line for a toast, or
286 * the failure.
287 */
288async function runMerge(
289 host: Host,
290 store: string,
291 source: string,
292 target: string | null,
293 strategy: MemoirMergeStrategy,
294 values: ReadonlyMap<string, string | null>,
295): Promise<{ text: string; failed: string | null }> {
296 const native = strategy === 'both' ? 'ours' : strategy
297 const into = target === null ? [] : ['--into', target]
298 const merged = await exec(host, [...cli(host, store), 'merge', source, ...into, '-S', native], store, WRITE_TIMEOUT_MS)
299 let parsed: { conflicts?: unknown; error?: unknown } = {}
300 try {
301 parsed = JSON.parse(merged?.stdout ?? '') as typeof parsed
302 } catch {
303 // not JSON: the exit code tells
304 }
305 const conflicts = (Array.isArray(parsed.conflicts) ? parsed.conflicts : [])
306 .filter((k): k is string => typeof k === 'string' && k.startsWith('default:'))
307 .map(k => k.slice('default:'.length))
308
309 let appended = 0
310 let unappended = 0
311 if (merged && merged.exitCode === 0 && strategy === 'both') {
312 for (const key of conflicts) {
313 const content = values.get(key)
314 if (content === null || content === undefined) {
315 unappended += 1
316 continue
317 }
318 const result = await exec(
319 host,
320 [...cli(host, store), 'remember', '-p', key, '-n', 'default', '--merge-policy', 'append', '--', content],
321 store,
322 WRITE_TIMEOUT_MS,
323 )
324 if (result && result.exitCode === 0) appended += 1
325 else unappended += 1
326 }
327 }
328
329 const what = target === null ? `merge of ${source}` : `merge into ${target}`
330 const failed = failure(merged, what)
331 if (failed) return { text: '', failed: typeof parsed.error === 'string' ? `${what} failed: ${parsed.error}` : failed }
332 const n = conflicts.length
333 const both = `${appended} appended${unappended ? `, ${unappended} not appended` : ''}`
334 const resolved = n ? `, ${n} ${n === 1 ? 'key' : 'keys'} changed on both sides: ${strategy === 'both' ? both : strategy}` : ''
335 return { text: `${target === null ? 'pulled' : 'merged'} ${source} into ${target ?? 'this branch'}${resolved}`, failed: null }
336}
337
338/**
339 * Merge `source` into `target`, then check `source` out again: `merge --into`
340 * leaves the store on the target. `values` holds the branch's values, which
341 * `both` appends to main's.
342 */
343export async function mergeInto(
344 host: Host,
345 store: string,
346 source: string,
347 target: string,
348 strategy: MemoirMergeStrategy,
349 values: ReadonlyMap<string, string | null>,
350): Promise<string> {
351 const result = await runMerge(host, store, source, target, strategy, values)
352 const back = await exec(host, [...cli(host, store), 'checkout', source], store, WRITE_TIMEOUT_MS)
353 if (result.failed) return result.failed
354 const stuck = failure(back, `checkout ${source}`)
355 return stuck ? `${result.text}; ${stuck}` : result.text
356}
357
358/**
359 * Pull `source` (main) into the checked-out branch: the same three-way merge
360 * the other way round, with no checkout, so a capture landing meanwhile stays
361 * on the branch. `values` holds main's values, which `both` appends to the
362 * branch's.
363 */
364export async function pullFrom(
365 host: Host,
366 store: string,
367 source: string,
368 strategy: MemoirMergeStrategy,
369 values: ReadonlyMap<string, string | null>,
370): Promise<string> {
371 const result = await runMerge(host, store, source, null, strategy, values)
372 return result.failed ?? result.text
373}
374
375export type BranchHead = { branch: string; at: number; tip: string }
376
377/** Every local branch, the most recently committed first. */
378export async function listBranches(host: Host, store: string): Promise<BranchHead[]> {
379 const out = await run(host, [
380 'git', '-C', store, 'for-each-ref', '--sort=-committerdate',
381 '--format=%(refname:short)%09%(committerdate:unix)%09%(objectname)', 'refs/heads',
382 ])
383 if (!out) return []
384 const heads: BranchHead[] = []
385 for (const line of out.split('\n')) {
386 const [branch, ct, tip] = line.split('\t')
387 const at = Number(ct)
388 if (branch && tip && Number.isFinite(at)) heads.push({ branch, at, tip })
389 }
390 return heads
391}
392types/index.d.ts 152 lines1// State contract for the memoir mod (hooks/mod/): the values it keeps in
2// `$.state`, declared under the plugin's name so `claude plugin validate`
3// can hold every `$.state` reference in the module to this shape.
4
5/** One commit of the memoir store, newest first in the Recent tab. */
6export type MemoirCapture = {
7 sha: string
8 /** Commit time, seconds since the epoch. */
9 at: number
10 /** The commit's title (`Store <key> in <namespace>`, `Delete …`, `Merge branch …`). */
11 subject: string
12 /** `store` and `delete` name a key; anything else (a merge, the first commit) is `other`. */
13 kind: 'store' | 'delete' | 'other'
14 key: string | null
15 namespace: string | null
16 /** The stored value's text for a `store` commit, or null when it could not be read. */
17 content: string | null
18}
19
20/** What one commit changed, as `memoir diff <sha>~1 <sha>` reports it. */
21export type MemoirCommitDetail = {
22 sha: string
23 /** Null when the CLI cannot diff commits (older memoir, or the first commit). */
24 changes: MemoirKeyChange[] | null
25}
26
27export type MemoirStatus = {
28 /** The store's path once it resolved and exists; null otherwise. */
29 store: string | null
30 branch: string | null
31 /** The head commit's sha, the baseline `turn.start` records. */
32 head: string | null
33 /** User-facing memory count, as the shell hooks cache it. */
34 memories: number | null
35 /** On a branch, the commits on top of main (after main's latest merge of the branch); on main, all commits. */
36 commits: number | null
37 /** The same count without the `metrics.*` writes: what the Recent tab lists while metrics are hidden. */
38 commitsWithoutMetrics: number | null
39}
40
41/** One `metrics.turn.<branch>` accumulator the Stop hook maintains. */
42export type MemoirTurnMetrics = {
43 turns: number
44 toolCalls: number
45 toolErrors: number
46 repeatedToolCalls: number
47 outputChars: number
48 toolInputChars: number
49 toolResultChars: number
50 latencyMs: number
51 latencySamples: number
52}
53
54/** One memory key on the current branch, compared with the same key on main. */
55export type MemoirMemory = {
56 key: string
57 /** The value on the current branch; null when the key is not here (`removed`) or unreadable. */
58 content: string | null
59 /** The value on the compared branch (main) when it could be read; null otherwise. */
60 base: string | null
61 /** `added`: not on main; `changed`: on main with another value; `removed`: on main only; else `same`. */
62 status: 'same' | 'added' | 'changed' | 'removed'
63}
64
65/** One key's change between the compared branch and HEAD, as `memoir diff` reports it. */
66export type MemoirKeyChange = {
67 key: string
68 operation: 'added' | 'modified' | 'removed'
69 /** The stored text on the compared branch, null when absent there. */
70 old: string | null
71 /** The stored text on HEAD, null when absent here. */
72 new: string | null
73}
74
75export type MemoirMemories = {
76 items: MemoirMemory[]
77 /** The branch the items were compared with (`main`), or null when no comparison ran. */
78 base: string | null
79}
80
81/** One branch's keys that differ from main, for the Memories tab's all-branches view. */
82export type MemoirBranchDiff = {
83 branch: string
84 /** Its last commit, seconds since the epoch. */
85 at: number
86 /** Only the keys that differ from main (`added`, `changed`, `removed`). */
87 items: MemoirMemory[]
88 /** True when both values were read (the CLI's key-level diff); false from git history alone. */
89 exact: boolean
90}
91
92export type MemoirTab = 'memories' | 'recent' | 'metrics'
93
94/**
95 * What to do with a key changed on both the branch and main: `ours` and
96 * `theirs` are `memoir merge -S`, from the target's point of view; `both`
97 * keeps the target's value and appends the other side's to it.
98 */
99export type MemoirMergeStrategy = 'ours' | 'theirs' | 'both'
100
101/** What the Memories detail can do to a key: delete it here, or write main's value back. */
102export type MemoirMemoryAction = 'forget' | 'restore'
103
104/** The Memories tab's merge-to-main and pull-from-main flows: closed, picking a strategy, or running. */
105export type MemoirMergeState = 'idle' | 'choose' | 'running'
106
107declare module 'claude-code' {
108 interface PluginState {
109 memoir: {
110 status: MemoirStatus
111 /** Capture commits, newest first (last ~20 commits of the store). */
112 captures: MemoirCapture[]
113 /** Every sha in the same window, newest first, captures or not. */
114 order: string[]
115 /** The head sha when the current turn started; null before any turn. */
116 turnBaseline: string | null
117 tab: MemoirTab
118 showMetrics: boolean
119 /** Memories tab: the taxonomy block above the keys is shown. */
120 showTaxonomy: boolean
121 /** Taxonomy groups (first path segment) the person has expanded. */
122 expanded: string[]
123 /** Full key → memory count, from `memoir summarize`. */
124 taxonomy: Record<string, number>
125 /** Branch → its turn accumulator, from the `metrics.turn.*` keys. */
126 metrics: Record<string, MemoirTurnMetrics>
127 /** Every key on the branch with its value and how it compares with main. */
128 memories: MemoirMemories
129 /** Memories tab: the key filter the person typed. */
130 filter: string
131 /** Memories tab: the key whose detail is open, or null for the list. */
132 selected: string | null
133 /** Memories tab: the branch the open key belongs to in the all-branches view; null for the current branch. */
134 selectedBranch: string | null
135 /** Memories tab: every branch's differences from main are listed under the current one. */
136 allBranches: boolean
137 /** The ten most recently changed branches other than main, latest first, each with its differences from main. */
138 branches: MemoirBranchDiff[]
139 /** Memories tab: the detail action that awaits its confirmation, if any. */
140 armed: MemoirMemoryAction | null
141 /** Memories tab: where the merge-to-main flow stands. */
142 merge: MemoirMergeState
143 /** Memories tab: where the pull-from-main flow stands. */
144 pull: MemoirMergeState
145 /** Recent tab: the sha whose detail is open, or null for the list. */
146 commit: string | null
147 /** Recent tab: the open commit's changes once read; null while loading. */
148 commitDetail: MemoirCommitDetail | null
149 }
150 }
151}
152