SLOPSHOPPER

history-keeper

Keep a copy of every Claude Code session that the 30-day cleanup cannot delete, as JSONL and readable markdown, with message times in the transcript and…

newrowscommandprocess
v0.1.0MITupdated 2026-10-06Jvrd97/claude-history-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · history-keeper
› fix the failing auth test and add an audit log call ● history-keeper: turn: not vaulted, no transcript at /Users/dev/.claude/projects/app/preview-session.jsonl ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /history ⎿ history-keeper: No vaulted sessions for this project yet (~/.claude/history-keeper/-work-app). A session is copied after its f ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

history-keeper

A Claude Code mod that keeps a copy of every session the 30-day cleanup cannot delete, puts the time beside each of your prompts, and lets you list and search old sessions with /history.

❯ fix the login test, it fails on CI                                                     14:32

> /history
3 vaulted sessions, newest first (~/.claude/history-keeper/-Users-me-app):
2026-10-06   48 KB  61f2df73-9117-4789-880b-0cd74a665b25  Flaky login test
2026-10-02  1.2 MB  9c1d33aa-0b6e-4c1f-a0de-2b51f1f0c7aa  move the billing cron to the worker
2026-09-14    7 KB  3e0b7d2c-5a61-4c3e-9f0e-6d2f8a9b1c44  set up kafka locally
Resume one with claude --resume <id>; search with /history <text>.

> /history kafka
Sessions mentioning "kafka", most matches first (searched 3):
2026-09-14  3e0b7d2c-5a61-4c3e-9f0e-6d2f8a9b1c44  6×  set up kafka locally with docker compose

Why

Claude Code deletes session transcripts older than 30 days, and nothing tells you it did:

  • #59248: transcripts silently deleted, people lost work history (41 👍, 58 comments, open on 2026-10-06).
  • #44763: messages have no timestamps (93 👍, 38 comments, open on 2026-10-06).
  • #2112: old sessions are hard to name and find.

The official knob is cleanupPeriodDays in settings.json: raise it and Claude Code keeps transcripts longer. A copy of your own still helps:

  • It survives what the setting does not. A reinstall, a wiped ~/.claude/projects, a new machine set up from settings, or a cleanup that ran before you raised the number.
  • It is readable. Next to each JSONL copy the mod writes a markdown file with every prompt, reply and tool name under its local time. grep -ri kafka ~/.claude/history-keeper works without Claude Code.
  • It is findable from the session. /history lists this project's sessions by date and first prompt, and searches their text.

What it does

WhereWhat
VaultAfter a turn (at most every 5 minutes) and when the session ends, copies the transcript to ~/.claude/history-keeper/<project>/<date>_<session-id>.jsonl, with a readable .md beside it. Never deletes anything
TranscriptA dim local time beside each of your prompts, 14:32 today and 10-05 14:32 before; with timestamps: all also beside each reply
/historyThis project's vaulted sessions, newest first, top 20: date, size, session id, title or first prompt
/history <text>Case-insensitive search of the vaulted markdown, top 10 sessions: date, session id, number of matches and a one-line snippet

Install

You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

From the marketplace in this repo:

/plugin marketplace add Jvrd97/claude-history-keeper
/plugin install history-keeper@history-keeper

Or straight from disk, for one session:

git clone https://github.com/Jvrd97/claude-history-keeper.git
claude --plugin-dir ./claude-history-keeper

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
vaultDir~/.claude/history-keeperWhere copies go, one folder per project
vaultEveryMinutes5After a turn, copy again only if this many minutes passed since the last copy; 0 copies after every turn. The session's end always copies
markdowntrueWrite the readable .md beside each .jsonl
timestampspromptsoff, prompts, or all (prompts and replies)

How it works

  • Where the transcript is. Every classic hook event carries transcript_path. The mod reads it from classic.SessionStart and classic.UserPromptSubmit and remembers the folder; the file is <folder>/<session id>.jsonl.
  • When it copies. turn.complete on the main loop (subagent turns are skipped), throttled by vaultEveryMinutes, and session.end, which covers /exit, /clear, a finished claude -p and a closed terminal.
  • How it copies. A transcript up to 4 MiB is read and written through the plugin file API. A bigger one is copied with cp and read for the markdown in pieces with tail -c, so that part needs a POSIX shell (macOS, Linux, WSL).
  • What it writes. Only inside vaultDir: <project-slug>/<YYYY-MM-DD>_<session-id>.jsonl and .md. The project slug is the project path with every character but a letter or a digit turned into -, as Claude Code names its own folders. The date is the local date of the session's first row, so a session that runs past midnight or is resumed next week keeps one file. Each copy replaces the previous copy of the same session, unless the vaulted file is larger than the transcript: then it is left as it is. Nothing is ever deleted.
  • What the markdown holds. A header (title, session id, project, start, first prompt), then each prompt and reply with its local time and the names of the tools called between them. Tool inputs and outputs, thinking, meta rows, subagent rows and compaction summaries stay out; they are in the JSONL.
  • Where the times come from. A message row's render requestId is its uuid in the transcript. The mod notes the time each prompt (and, with all, each reply) is appended through session.append, keyed by that uuid; on --resume it reads the times back from the transcript itself, the newest 300 rows.
  • How a time is drawn. The ui.render hook on UserMessage (and AssistantMessage with all) wraps the engine's own drawing, untouched, in a row with the time dim at its right, bottom-aligned. The row's text and the stored message never change. A surface that refuses the tree draws the engine's row alone. The expanded view (ctrl+o) gets the engine's row as it is.
  • What /history reads. The vault folder of the current project root, newest first by the copy's modification time. Search reads the markdown of the newest 500 sessions and counts matches in the conversation part only, so a first prompt is not counted twice.

What it can't do:

  • Bring back a session deleted before the mod was installed, or copy a session that ran without it.
  • Copy the very last turn if Claude Code is killed hard (kill -9, a crash): the end flush runs inside the engine's 1.5 s exit window, and a huge transcript may not finish in it. The copy from the turn before is still there.
  • Show times for rows it has no record of: prompts of a resumed session older than its newest 300 rows, and rows from before the mod was loaded in a running session.
  • Find a session by meaning: search is a plain substring match.

The mod makes no network calls and sends nothing anywhere.

Develop

Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:

tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

По-русски

Claude Code молча удаляет сессии старше 30 дней. Мод держит свою копию каждой сессии в ~/.claude/history-keeper, её не трогает ни очистка, ни переустановка:

  • Копия. После хода (не чаще раза в 5 минут) и при выходе мод копирует JSONL сессии и пишет рядом читаемый markdown с временем каждого сообщения. Мод ничего не удаляет.
  • Время в переписке. Рядом с каждым вашим промптом стоит тусклое «14:32».
  • Поиск. /history показывает сессии проекта, /history <текст> ищет по ним.

License

MIT

Source 3 files
hooks/register.tsx 379 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import {
5  byteLength,
6  clockLabel,
7  dirOf,
8  expandHome,
9  FS_READ_LIMIT,
10  hasConversation,
11  headerOf,
12  isStamped,
13  isVaultDue,
14  listText,
15  localDate,
16  offsetAt,
17  parseMode,
18  parseTranscript,
19  parseVaultName,
20  renderMarkdown,
21  search,
22  searchText,
23  slugOf,
24  stampsOf,
25  tildeOf,
26  vaultBase,
27  wholeLines,
28} from './logic'
29import type { ListedSession, SearchDoc, TimestampMode } from './logic'
30
31const COMMAND = 'history'
32const LIST_LIMIT = 20
33const SEARCH_LIMIT = 10
34/** Sessions read per search, newest first; bounds the time one /history takes. */
35const SEARCH_SESSIONS = 500
36/** Stamps loaded from a resumed transcript: the newest rows, the ones a person scrolls back to. */
37const RESUMED_STAMPS = 300
38/** Pieces of a transcript over 4 MiB read through `tail`: 64 pieces is 256 MiB. */
39const MAX_PIECES = 64
40const PIECE_TIMEOUT_MS = 30_000
41
42const transcriptDir = atom({ plugin: 'history-keeper', key: 'transcriptDir' } as const, null)
43const lastVault = atom({ plugin: 'history-keeper', key: 'lastVault' } as const, null)
44const stamp = atom({ plugin: 'history-keeper', key: 'stamp' } as const, null)
45
46type Settings = { vaultDir: string; everyMinutes: number; markdown: boolean; timestamps: TimestampMode }
47
48async function homeOf($: EngineInterface): Promise<string | undefined> {
49  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
50}
51
52async function vaultRootOf($: EngineInterface, settings: Settings): Promise<string> {
53  return expandHome(settings.vaultDir, await homeOf($)).replace(/\/$/, '')
54}
55
56async function projectFolderOf($: EngineInterface, settings: Settings): Promise<string> {
57  return `${await vaultRootOf($, settings)}/${slugOf(await $.session.root())}`
58}
59
60/** A transcript's text: whole through `$.fs.read` up to its 4 MiB, beyond that in whole-line pieces through `tail`. */
61async function readText($: EngineInterface, path: string, size: number): Promise<string | null> {
62  if (size <= FS_READ_LIMIT) {
63    return $.fs.read(path)
64  }
65
66  const pieces: string[] = []
67  let offset = 0
68
69  for (let i = 0; i < MAX_PIECES && offset < size; i += 1) {
70    const run = await $.process.run(['tail', '-c', `+${offset + 1}`, path], { timeoutMs: PIECE_TIMEOUT_MS })
71
72    if (run.exitCode !== 0) {
73      return null
74    }
75
76    const piece = run.isStdoutTruncated ? wholeLines(run.stdout) : run.stdout
77
78    if (piece === '') {
79      break
80    }
81
82    pieces.push(piece)
83    offset += byteLength(piece)
84
85    if (!run.isStdoutTruncated) {
86      break
87    }
88  }
89
90  return pieces.join('')
91}
92
93async function sizeOf($: EngineInterface, path: string): Promise<number | null> {
94  if (!(await $.fs.exists(path))) {
95    return null
96  }
97
98  const stat = await $.fs.stat(path).catch(() => null)
99
100  return stat === null || stat.kind !== 'file' ? null : stat.size
101}
102
103/** Copies the JSONL: back through `$.fs.write` when it was read whole, with `cp` when it is over 4 MiB. */
104async function copyJsonl($: EngineInterface, source: string, target: string, text: string, size: number): Promise<void> {
105  if (size <= FS_READ_LIMIT) {
106    await $.fs.write(target, text)
107
108    return
109  }
110
111  await $.process.run(['mkdir', '-p', dirOf(target)])
112  const run = await $.process.run(['cp', source, target], { timeoutMs: PIECE_TIMEOUT_MS })
113
114  if (run.exitCode !== 0) {
115    throw new Error(`cp exited ${run.exitCode}: ${run.stderr.trim()}`)
116  }
117}
118
119type VaultOutcome = { isCopied: true; target: string } | { isCopied: false; reason: string }
120
121async function vault($: EngineInterface, sessionId: string, settings: Settings): Promise<VaultOutcome> {
122  const dir = await read($, transcriptDir)
123
124  if (dir === null) {
125    return { isCopied: false, reason: 'no transcript path seen yet' }
126  }
127
128  const source = `${dir}/${sessionId}.jsonl`
129  const size = await sizeOf($, source)
130
131  if (size === null) {
132    return { isCopied: false, reason: `no transcript at ${source}` }
133  }
134
135  const text = await readText($, source, size)
136
137  if (text === null) {
138    return { isCopied: false, reason: `could not read ${source}` }
139  }
140
141  const now = await $.clock.now()
142  const transcript = parseTranscript(text)
143
144  if (!hasConversation(transcript)) {
145    return { isCopied: false, reason: 'nothing but slash commands yet' }
146  }
147
148  const startedAt = transcript.startedAt ?? now
149  const base = `${await projectFolderOf($, settings)}/${vaultBase(localDate(startedAt, offsetAt(startedAt)), sessionId)}`
150  const kept = await sizeOf($, `${base}.jsonl`)
151
152  if (kept !== null && kept > size) {
153    return { isCopied: false, reason: `the vaulted copy (${kept} bytes) is larger than the transcript (${size}); left as it is` }
154  }
155
156  await copyJsonl($, source, `${base}.jsonl`, text, size)
157
158  if (settings.markdown) {
159    await $.fs.write(`${base}.md`, renderMarkdown(transcript, sessionId, offsetAt(startedAt)))
160  }
161
162  await update($, lastVault, () => ({ sessionId, at: now }))
163
164  return { isCopied: true, target: `${base}.jsonl` }
165}
166
167async function vaultLogged($: EngineInterface, sessionId: string, settings: Settings, why: string): Promise<void> {
168  try {
169    const outcome = await vault($, sessionId, settings)
170    $.ui.log(outcome.isCopied ? `${why}: vaulted ${outcome.target}` : `${why}: not vaulted, ${outcome.reason}`, { to: 'debug' })
171  } catch (error) {
172    $.ui.log(`${why}: vault failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
173  }
174}
175
176/** Remembers where the engine keeps this project's transcripts; every classic event names the file. */
177async function noteTranscript($: EngineInterface, transcriptPath: string): Promise<void> {
178  if (transcriptPath === '') {
179    return
180  }
181
182  const dir = dirOf(transcriptPath)
183
184  if ((await read($, transcriptDir)) !== dir) {
185    await update($, transcriptDir, () => dir)
186  }
187}
188
189/** A resumed session's rows were loaded, not appended: their times come from the transcript itself. */
190async function loadStamps($: EngineInterface, transcriptPath: string, mode: TimestampMode): Promise<void> {
191  const size = await sizeOf($, transcriptPath)
192  const text = size === null ? null : await readText($, transcriptPath, size)
193
194  if (text === null) {
195    return
196  }
197
198  for (const [uuid, at] of stampsOf(parseTranscript(text), mode).slice(-RESUMED_STAMPS)) {
199    await $.state.set({ plugin: 'history-keeper', key: 'stamp', id: uuid }, at)
200  }
201}
202
203type Session = { base: string; date: string; sessionId: string; mtimeMs: number; size: number; hasMarkdown: boolean }
204
205async function sessionsOf($: EngineInterface, folder: string): Promise<Session[]> {
206  const entries = await $.fs.list(folder).catch(() => [])
207  const markdown = new Set(entries.filter(entry => entry.name.endsWith('.md')).map(entry => entry.name.slice(0, -'.md'.length)))
208
209  return entries
210    .flatMap(entry => {
211      const name = parseVaultName(entry.name)
212
213      if (name === null || name.ext !== 'jsonl' || entry.kind !== 'file') {
214        return []
215      }
216
217      const base = entry.name.slice(0, -'.jsonl'.length)
218
219      return [{ base, date: name.date, sessionId: name.sessionId, mtimeMs: entry.mtimeMs, size: entry.size, hasMarkdown: markdown.has(base) }]
220    })
221    .sort((a, b) => b.mtimeMs - a.mtimeMs)
222}
223
224/** The readable copy of one session: the .md beside it, or rendered from the JSONL when there is none. */
225async function markdownOf($: EngineInterface, folder: string, session: Session): Promise<string | null> {
226  if (session.hasMarkdown) {
227    return $.fs.read(`${folder}/${session.base}.md`).catch(() => null)
228  }
229
230  const text = await readText($, `${folder}/${session.base}.jsonl`, session.size).catch(() => null)
231
232  return text === null ? null : renderMarkdown(parseTranscript(text), session.sessionId, offsetAt(session.mtimeMs))
233}
234
235async function history($: EngineInterface, query: string, settings: Settings): Promise<string> {
236  const folder = await projectFolderOf($, settings)
237  const sessions = await sessionsOf($, folder)
238
239  if (query === '') {
240    const listed: ListedSession[] = []
241
242    for (const session of sessions.slice(0, LIST_LIMIT)) {
243      const markdown = await markdownOf($, folder, session)
244      const header = markdown === null ? null : headerOf(markdown)
245      const label = header === null ? null : header.title ?? header.firstPrompt
246
247      listed.push({ date: session.date, sessionId: session.sessionId, size: session.size, mtimeMs: session.mtimeMs, label })
248    }
249
250    return listText(listed, sessions.length, tildeOf(folder, await homeOf($)))
251  }
252
253  const docs: SearchDoc[] = []
254
255  for (const session of sessions.slice(0, SEARCH_SESSIONS)) {
256    const markdown = await markdownOf($, folder, session)
257
258    if (markdown !== null) {
259      docs.push({ date: session.date, sessionId: session.sessionId, mtimeMs: session.mtimeMs, markdown })
260    }
261  }
262
263  return searchText(search(docs, query, SEARCH_LIMIT), query, docs.length)
264}
265
266/**
267 * The engine's own drawing of the row, untouched, with the time dim at its right. The row keeps its look and its
268 * text; a surface that refuses the tree draws the engine's row alone, so the worst case is a row without a time.
269 */
270async function besideTime($: EngineInterface, e: RenderInput<'UserMessage' | 'AssistantMessage'>, drawn: RenderElement, at: number): Promise<RenderElement> {
271  const { Box, Text } = $.ui.resolve(e)
272  const now = await $.clock.now()
273
274  return (
275    <Box flexDirection="row" alignItems="flex-end">
276      <Box flexGrow={1} flexShrink={1}>
277        {drawn}
278      </Box>
279      <Text dimColor> {clockLabel(at, now, offsetAt(at))}</Text>
280    </Box>
281  )
282}
283
284export const register: Register = (on, options) => {
285  const settings: Settings = {
286    vaultDir: String(options.vaultDir ?? '~/.claude/history-keeper'),
287    everyMinutes: Number(options.vaultEveryMinutes ?? 5),
288    markdown: options.markdown !== false,
289    timestamps: parseMode(options.timestamps),
290  }
291
292  on('session.start', async ($, e, next) => {
293    await $.command.register({
294      name: COMMAND,
295      description: 'List vaulted sessions of this project, or search them: /history <text>',
296      argumentHint: '[text]',
297    })
298
299    return next(e)
300  })
301
302  on('classic.SessionStart', async ($, e, next) => {
303    await noteTranscript($, e.transcript_path)
304
305    if (settings.timestamps !== 'off' && (e.source === 'resume' || e.source === 'fork')) {
306      await loadStamps($, e.transcript_path, settings.timestamps).catch((error: unknown) => {
307        $.ui.log(`timestamps not loaded: ${String(error)}`, { to: 'debug' })
308      })
309    }
310
311    return next(e)
312  })
313
314  on('classic.UserPromptSubmit', async ($, e, next) => {
315    await noteTranscript($, e.transcript_path)
316
317    return next(e)
318  })
319
320  on('turn.complete', async ($, e, next) => {
321    const answered = await next(e)
322
323    if (e.agentId !== undefined) {
324      return answered
325    }
326
327    const sessionId = await $.session.id()
328
329    if (isVaultDue(await read($, lastVault), sessionId, await $.clock.now(), settings.everyMinutes)) {
330      await vaultLogged($, sessionId, settings, 'turn')
331    }
332
333    return answered
334  })
335
336  on('session.end', async ($, e, next) => {
337    const ended = await next(e)
338    await vaultLogged($, e.sessionId, settings, `session end (${e.reason})`)
339
340    return ended
341  })
342
343  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await history($, e.args.trim(), settings) }))
344
345  if (settings.timestamps === 'off') {
346    return
347  }
348
349  on('session.append', async ($, e, next) => {
350    const facts = { door: e.door, type: e.message.type, isMeta: e.message.isMeta === true, isSubagent: e.agentId !== undefined }
351
352    if (isStamped(settings.timestamps, facts)) {
353      await $.state.set({ plugin: 'history-keeper', key: 'stamp', id: e.uuid }, await $.clock.now())
354    }
355
356    return next(e)
357  })
358
359  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
360    if (e.props.isExpanded || e.props.origin.kind === 'task-notification') {
361      return next(e)
362    }
363
364    const at = await read($, memberOf(stamp, e))
365
366    return at === null ? next(e) : besideTime($, e, await next(e), at)
367  })
368
369  if (settings.timestamps !== 'all') {
370    return
371  }
372
373  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
374    const at = e.props.isFirstOfReply ? await read($, memberOf(stamp, e)) : null
375
376    return at === null ? next(e) : besideTime($, e, await next(e), at)
377  })
378}
379
hooks/logic.ts 448 lines
1const MINUTE_MS = 60_000
2const KIB = 1024
3const MIB = KIB * KIB
4
5/** `$.fs.read` rejects a file over 4 MiB; bigger transcripts are read in pieces through `tail`. */
6export const FS_READ_LIMIT = 4 * MIB
7/** Long project paths are cut and given a hash, so the folder name stays under the file system's 255. */
8const MAX_SLUG = 200
9const LABEL_WIDTH = 80
10const PROMPT_WIDTH = 200
11const SNIPPET_WIDTH = 100
12const PAD = 2
13
14export type TimestampMode = 'off' | 'prompts' | 'all'
15
16export type Entry =
17  | { kind: 'user'; at: number | null; uuid: string | null; text: string }
18  | { kind: 'assistant'; at: number | null; uuid: string | null; text: string; tools: string[] }
19
20export type Transcript = {
21  /** The first timestamp any row carries: when the session started. */
22  startedAt: number | null
23  cwd: string | null
24  /** The last name `/rename` (or the engine) gave the session. */
25  title: string | null
26  entries: Entry[]
27}
28
29const pad = (n: number): string => String(n).padStart(PAD, '0')
30
31/** Minutes east of UTC at that moment on this machine; pure callers take it as an argument. */
32export const offsetAt = (ms: number): number => -new Date(ms).getTimezoneOffset()
33
34const shifted = (ms: number, offsetMinutes: number): Date => new Date(ms + offsetMinutes * MINUTE_MS)
35
36export const localDate = (ms: number, offsetMinutes: number): string => {
37  const d = shifted(ms, offsetMinutes)
38
39  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
40}
41
42export const localTime = (ms: number, offsetMinutes: number): string => {
43  const d = shifted(ms, offsetMinutes)
44
45  return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
46}
47
48/** "14:32" for today, "10-05 14:32" for an older row. */
49export const clockLabel = (at: number, now: number, offsetMinutes: number): string => {
50  const time = localTime(at, offsetMinutes)
51  const date = localDate(at, offsetMinutes)
52
53  return date === localDate(now, offsetMinutes) ? time : `${date.slice(5)} ${time}`
54}
55
56const hashOf = (text: string): string => {
57  let hash = 5381
58
59  for (const char of text) {
60    hash = (hash * 33 + (char.codePointAt(0) ?? 0)) >>> 0
61  }
62
63  return hash.toString(36)
64}
65
66/** A project's folder name: every character but a letter or a digit becomes "-", as Claude Code names its own. */
67export const slugOf = (path: string): string => {
68  const slug = path.replace(/[^A-Za-z0-9]/g, '-')
69
70  return slug.length <= MAX_SLUG ? slug : `${slug.slice(0, MAX_SLUG)}-${hashOf(path)}`
71}
72
73/** Keeps a session id safe to put in a file name. */
74export const safeId = (sessionId: string): string => sessionId.replace(/[^A-Za-z0-9._-]/g, '_')
75
76export const vaultBase = (date: string, sessionId: string): string => `${date}_${safeId(sessionId)}`
77
78export type VaultName = { date: string; sessionId: string; ext: 'jsonl' | 'md' }
79
80export const parseVaultName = (name: string): VaultName | null => {
81  const match = /^(\d{4}-\d{2}-\d{2})_(.+)\.(jsonl|md)$/.exec(name)
82
83  if (match === null) {
84    return null
85  }
86
87  const [, date = '', sessionId = '', ext] = match
88
89  return { date, sessionId, ext: ext === 'md' ? 'md' : 'jsonl' }
90}
91
92export const expandHome = (path: string, home: string | undefined): string => {
93  if (home === undefined || !(path === '~' || path.startsWith('~/'))) {
94    return path
95  }
96
97  return `${home.replace(/\/$/, '')}${path.slice(1)}`
98}
99
100/** A path as a person writes it: the home folder as "~". */
101export const tildeOf = (path: string, home: string | undefined): string =>
102  home !== undefined && home !== '' && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
103
104export const dirOf = (path: string): string => {
105  const cut = path.lastIndexOf('/')
106
107  return cut <= 0 ? '/' : path.slice(0, cut)
108}
109
110export type Vaulted = { sessionId: string; at: number }
111
112/** A new session always vaults; the same one waits `everyMinutes` between copies (0: every turn). */
113export const isVaultDue = (last: Vaulted | null, sessionId: string, now: number, everyMinutes: number): boolean =>
114  last === null || last.sessionId !== sessionId || now - last.at >= Math.max(0, everyMinutes) * MINUTE_MS
115
116export const byteLength = (text: string): number => new TextEncoder().encode(text).length
117
118/** The whole lines of a piece cut at an arbitrary byte: everything up to and with the last newline. */
119export const wholeLines = (piece: string): string => piece.slice(0, piece.lastIndexOf('\n') + 1)
120
121export const oneLine = (text: string, width: number): string => {
122  const flat = text.replace(/\s+/g, ' ').trim()
123
124  return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`
125}
126
127type Block = { type?: unknown; text?: unknown; name?: unknown }
128type Row = {
129  type?: unknown
130  timestamp?: unknown
131  uuid?: unknown
132  cwd?: unknown
133  customTitle?: unknown
134  isMeta?: unknown
135  isSidechain?: unknown
136  isCompactSummary?: unknown
137  message?: { content?: unknown }
138}
139
140const COMMAND_NAME = /<command-name>([\s\S]*?)<\/command-name>/
141const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/
142const ENGINE_ONLY = /^<(local-command-stdout|local-command-stderr|local-command-caveat|bash-stdout|bash-stderr)>/
143const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
144
145const blocksOf = (content: unknown): Block[] => {
146  if (typeof content === 'string') {
147    return [{ type: 'text', text: content }]
148  }
149
150  return Array.isArray(content) ? content.filter((block): block is Block => block !== null && typeof block === 'object') : []
151}
152
153/** What a person typed, as the row shows it: a slash command as "/name args", the engine's own echoes dropped. */
154const userText = (raw: string): string => {
155  const name = COMMAND_NAME.exec(raw)
156
157  if (name !== null) {
158    const args = COMMAND_ARGS.exec(raw)?.[1]?.trim() ?? ''
159    const command = (name[1] ?? '').trim()
160
161    return args === '' ? command : `${command} ${args}`
162  }
163
164  return ENGINE_ONLY.test(raw.trim()) ? '' : raw.replace(REMINDER, '').trim()
165}
166
167const timeOf = (value: unknown): number | null => {
168  if (typeof value !== 'string') {
169    return null
170  }
171
172  const at = Date.parse(value)
173
174  return Number.isNaN(at) ? null : at
175}
176
177const entryOf = (row: Row): Entry | null => {
178  if (row.isMeta === true || row.isSidechain === true || row.isCompactSummary === true) {
179    return null
180  }
181
182  const at = timeOf(row.timestamp)
183  const uuid = typeof row.uuid === 'string' ? row.uuid : null
184  const blocks = blocksOf(row.message?.content)
185
186  if (row.type === 'user') {
187    const parts = blocks.flatMap(block => {
188      if (block.type === 'text' && typeof block.text === 'string') {
189        return [userText(block.text)]
190      }
191
192      return block.type === 'image' ? ['[image]'] : []
193    })
194    const text = parts.filter(part => part !== '').join('\n\n')
195
196    return text === '' ? null : { kind: 'user', at, uuid, text }
197  }
198
199  if (row.type === 'assistant') {
200    const text = blocks
201      .flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text.trim()] : []))
202      .filter(part => part !== '')
203      .join('\n\n')
204    const tools = blocks.flatMap(block => (block.type === 'tool_use' && typeof block.name === 'string' ? [block.name] : []))
205
206    return text === '' && tools.length === 0 ? null : { kind: 'assistant', at, uuid, text, tools }
207  }
208
209  return null
210}
211
212/** Reads a transcript JSONL: user and assistant rows in order; meta, sidechain and bookkeeping rows skipped. */
213export const parseTranscript = (text: string): Transcript => {
214  const transcript: Transcript = { startedAt: null, cwd: null, title: null, entries: [] }
215
216  for (const line of text.split('\n')) {
217    if (line.trim() === '') {
218      continue
219    }
220
221    let row: Row
222
223    try {
224      row = JSON.parse(line) as Row
225    } catch {
226      // A torn last line while the engine is still writing; the next copy has it whole.
227      continue
228    }
229
230    if (row === null || typeof row !== 'object') {
231      continue
232    }
233
234    transcript.startedAt ??= timeOf(row.timestamp)
235
236    if (transcript.cwd === null && typeof row.cwd === 'string') {
237      transcript.cwd = row.cwd
238    }
239
240    if (row.type === 'custom-title' && typeof row.customTitle === 'string' && row.customTitle.trim() !== '') {
241      transcript.title = row.customTitle.trim()
242    }
243
244    const entry = entryOf(row)
245
246    if (entry !== null) {
247      transcript.entries.push(entry)
248    }
249  }
250
251  return transcript
252}
253
254/** A session worth a copy has a reply or a prompt that is not a slash command (a lone `/history` run is not). */
255export const hasConversation = (transcript: Transcript): boolean =>
256  transcript.entries.some(entry => entry.kind === 'assistant' || !entry.text.startsWith('/'))
257
258export const firstPromptOf = (transcript: Transcript): string | null => {
259  const first = transcript.entries.find(entry => entry.kind === 'user')
260
261  return first === undefined ? null : oneLine(first.text, PROMPT_WIDTH)
262}
263
264/** The rows a mode stamps: prompts alone, or every user and assistant row. */
265export const stampsOf = (transcript: Transcript, mode: TimestampMode): [string, number][] =>
266  mode === 'off'
267    ? []
268    : transcript.entries.flatMap(entry =>
269        entry.uuid !== null && entry.at !== null && (mode === 'all' || entry.kind === 'user') ? [[entry.uuid, entry.at] as [string, number]] : [],
270      )
271
272/** What `session.append` says about a row, as far as stamping goes. */
273export type AppendFacts = { door: string; type: string; isMeta: boolean; isSubagent: boolean }
274
275export const isStamped = (mode: TimestampMode, row: AppendFacts): boolean => {
276  if (mode === 'off' || row.isSubagent || row.isMeta) {
277    return false
278  }
279
280  return (row.door === 'prompt' && row.type === 'user') || (mode === 'all' && row.door === 'response' && row.type === 'assistant')
281}
282
283export const parseMode = (value: unknown): TimestampMode => (value === 'off' || value === 'all' ? value : 'prompts')
284
285/** The line under which the conversation starts; header lines above it are not searched. */
286export const BODY_MARK = '\n---\n'
287
288/** The readable copy: a header to find it by, then each prompt, reply and tool call with its local time. */
289export const renderMarkdown = (transcript: Transcript, sessionId: string, offsetMinutes: number): string => {
290  const first = firstPromptOf(transcript)
291  const heading = transcript.title ?? (first === null ? `Session ${sessionId}` : oneLine(first, LABEL_WIDTH))
292  const lines = [`# ${heading}`, '', `- Session: \`${sessionId}\` (resume with \`claude --resume ${sessionId}\`)`]
293
294  if (transcript.title !== null) {
295    lines.push(`- Title: ${transcript.title}`)
296  }
297
298  if (transcript.cwd !== null) {
299    lines.push(`- Project: \`${transcript.cwd}\``)
300  }
301
302  if (transcript.startedAt !== null) {
303    lines.push(`- Started: ${localDate(transcript.startedAt, offsetMinutes)} ${localTime(transcript.startedAt, offsetMinutes)}`)
304  }
305
306  if (first !== null) {
307    lines.push(`- First prompt: ${first}`)
308  }
309
310  lines.push('', BODY_MARK.trim(), '')
311
312  const stampOf = (at: number | null): string => (at === null ? '' : `${localTime(at, offsetMinutes)} · `)
313  let tools: string[] = []
314  let toolsAt: number | null = null
315
316  const flushTools = (): void => {
317    if (tools.length > 0) {
318      lines.push(`_${stampOf(toolsAt)}tools: ${tools.join(', ')}_`, '')
319    }
320
321    tools = []
322    toolsAt = null
323  }
324
325  for (const entry of transcript.entries) {
326    if (entry.kind === 'assistant' && entry.text === '') {
327      toolsAt ??= entry.at
328      tools.push(...entry.tools)
329      continue
330    }
331
332    flushTools()
333    lines.push(`**${stampOf(entry.at)}${entry.kind === 'user' ? 'You' : 'Claude'}**`, '', entry.text, '')
334
335    if (entry.kind === 'assistant' && entry.tools.length > 0) {
336      toolsAt = entry.at
337      tools.push(...entry.tools)
338    }
339  }
340
341  flushTools()
342
343  return `${lines.join('\n').trimEnd()}\n`
344}
345
346export type MarkdownHeader = { title: string | null; firstPrompt: string | null }
347
348export const headerOf = (markdown: string): MarkdownHeader => {
349  const head = markdown.split(BODY_MARK)[0] ?? ''
350
351  return {
352    title: /^- Title: (.*)$/m.exec(head)?.[1] ?? null,
353    firstPrompt: /^- First prompt: (.*)$/m.exec(head)?.[1] ?? null,
354  }
355}
356
357export const formatSize = (bytes: number): string => {
358  if (bytes < KIB) {
359    return `${bytes} B`
360  }
361
362  return bytes < MIB ? `${Math.round(bytes / KIB)} KB` : `${(bytes / MIB).toFixed(1)} MB`
363}
364
365export type ListedSession = { date: string; sessionId: string; size: number; mtimeMs: number; label: string | null }
366
367export const listText = (sessions: readonly ListedSession[], total: number, folder: string): string => {
368  if (sessions.length === 0) {
369    return `No vaulted sessions for this project yet (${folder}). A session is copied after its first turn.`
370  }
371
372  const sizes = sessions.map(session => formatSize(session.size))
373  const width = Math.max(...sizes.map(size => size.length))
374  const rows = sessions.map(
375    (session, i) => `${session.date}  ${(sizes[i] ?? '').padStart(width)}  ${session.sessionId}  ${oneLine(session.label ?? '(no prompt)', LABEL_WIDTH)}`,
376  )
377  const shown = sessions.length < total ? `${sessions.length} of ${total} vaulted sessions` : total === 1 ? '1 vaulted session' : `${total} vaulted sessions`
378
379  return [`${shown}, newest first (${folder}):`, ...rows, 'Resume one with claude --resume <id>; search with /history <text>.'].join('\n')
380}
381
382export type SearchDoc = { date: string; sessionId: string; mtimeMs: number; markdown: string }
383export type SearchHit = { date: string; sessionId: string; count: number; snippet: string }
384
385const countOf = (haystack: string, needle: string): number => {
386  let count = 0
387  let from = haystack.indexOf(needle)
388
389  while (from !== -1) {
390    count += 1
391    from = haystack.indexOf(needle, from + needle.length)
392  }
393
394  return count
395}
396
397/** The match with some text either side, on one line. */
398export const snippetOf = (line: string, query: string, width = SNIPPET_WIDTH): string => {
399  const flat = line.replace(/\s+/g, ' ').trim()
400  const at = flat.toLowerCase().indexOf(query.toLowerCase())
401
402  if (flat.length <= width || at === -1) {
403    return oneLine(flat, width)
404  }
405
406  const start = Math.max(0, Math.min(at - Math.floor((width - query.length) / 2), flat.length - width))
407  const end = Math.min(flat.length, start + width)
408
409  return `${start > 0 ? '…' : ''}${flat.slice(start, end)}${end < flat.length ? '…' : ''}`
410}
411
412/** Case-insensitive substring search over the conversation part of each copy: most matches first, then newest. */
413export const search = (docs: readonly SearchDoc[], query: string, limit: number): SearchHit[] => {
414  const needle = query.toLowerCase()
415
416  if (needle.trim() === '') {
417    return []
418  }
419
420  return docs
421    .flatMap(doc => {
422      const cut = doc.markdown.indexOf(BODY_MARK)
423      const body = cut === -1 ? doc.markdown : doc.markdown.slice(cut + BODY_MARK.length)
424      const count = countOf(body.toLowerCase(), needle)
425
426      if (count === 0) {
427        return []
428      }
429
430      const line = body.split('\n').find(one => one.toLowerCase().includes(needle)) ?? ''
431
432      return [{ hit: { date: doc.date, sessionId: doc.sessionId, count, snippet: snippetOf(line, query) }, mtimeMs: doc.mtimeMs }]
433    })
434    .sort((a, b) => b.hit.count - a.hit.count || b.mtimeMs - a.mtimeMs)
435    .slice(0, limit)
436    .map(found => found.hit)
437}
438
439export const searchText = (hits: readonly SearchHit[], query: string, searched: number): string => {
440  if (hits.length === 0) {
441    return `No vaulted session mentions "${query}" (searched ${searched}).`
442  }
443
444  const rows = hits.map(hit => `${hit.date}  ${hit.sessionId}  ${hit.count}×  ${hit.snippet}`)
445
446  return [`Sessions mentioning "${query}", most matches first (searched ${searched}):`, ...rows].join('\n')
447}
448
types/index.d.ts 15 lines
1/** The last copy made for one session: throttles the per-turn vault. */
2export type HistoryKeeperVaulted = { sessionId: string; at: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'history-keeper': {
7      /** The folder the engine writes this project's transcripts to, from the classic events' `transcript_path`. */
8      transcriptDir: string | null
9      lastVault: HistoryKeeperVaulted | null
10      /** When a transcript row was written, ms since the epoch, keyed by the row's uuid (the render site's requestId). */
11      stamp: StateFamily<number | null>
12    }
13  }
14}
15