SLOPSHOPPER

memoir

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

newpanespinnertoastprocesstimer
★ 622v0.6.0Apache-2.0updated 2026-10-15zhangfengcdt/memoir/plugins/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memoir
│ ┃ memoir ✕ › fix the failing auth test and add an audit log call │ ┃ ┏━━━━━┓ memoir │ ┃ ┏┛┏┓ ┏┓┗┓ 1: Memories 2: Recent 3: Metric ⏺ Read(src/auth.ts) │ ┃ ┗━┛┗━┛┗━┛ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No memoir store for this folder. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ r: refresh ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · memoir
┏━━━━━┓ memoir ┏┛┏┓ ┏┓┗┓ 1: Memories 2: Recent 3: Metrics ┗━┛┗━┛┗━┛ No memoir store for this folder. r: refresh
README

Memoir Plugin for Claude Code

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/.

Install

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.

CLI resolution

The plugin shells out to the memoir CLI. It picks, in order:

  1. memoir on PATH — install with pip install memoir-ai, pipx install memoir-ai, or uv tool install memoir-ai.
  2. 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.
  3. Neither — the plugin disables capture/recall and surfaces an install hint in the status line.

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.

What you get

ComponentRole
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.)
Skillsmemory-recall (user facts, auto-triggered, runs in a forked context) and memoir-onboard (maintains the codebase:onboard snapshot).
Lifecycle hooksSessionStart (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.

Branch sync

/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.

Capture feed pane and built-in status line (Claude Code 2.1.287+)

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.
  • Hint line. 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).

Dev install (local checkout)

Point Claude Code at your working copy:

{
  "plugins": ["/path/to/memoir/plugins/claude-code"]
}

MCP (optional)

The plugin does not ship an .mcp.json — MCP is opt-in. See the Claude Code plugin docs for the memoir-mcp server configuration.

Learn more

  • Claude Code plugin guide — hooks, auto-capture internals, sticky branches, concurrent sessions, per-command reference.
  • CLI reference — every memoir command and flag.
  • UI — the visual explorer launched by /memoir:ui.
Source 5 files
hooks/mod/register.tsx 419 lines
1// 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}
419
hooks/mod/format.ts 406 lines
1// 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}
406
hooks/mod/pane.tsx 594 lines
1// 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}
594
hooks/mod/store.ts 392 lines
1// 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}
392
types/index.d.ts 152 lines
1// 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