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…

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
Claude Code deletes session transcripts older than 30 days, and nothing tells you it did:
The official knob is cleanupPeriodDays in settings.json: raise it and Claude Code keeps transcripts longer. A copy of your own still helps:
~/.claude/projects, a new machine set up from settings, or a cleanup that ran before you raised the number.grep -ri kafka ~/.claude/history-keeper works without Claude Code./history lists this project's sessions by date and first prompt, and searches their text.| Where | What |
|---|---|
| Vault | After 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 |
| Transcript | A dim local time beside each of your prompts, 14:32 today and 10-05 14:32 before; with timestamps: all also beside each reply |
/history | This 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 |
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
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
vaultDir | ~/.claude/history-keeper | Where copies go, one folder per project |
vaultEveryMinutes | 5 | After 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 |
markdown | true | Write the readable .md beside each .jsonl |
timestamps | prompts | off, prompts, or all (prompts and replies) |
transcript_path. The mod reads it from classic.SessionStart and classic.UserPromptSubmit and remembers the folder; the file is <folder>/<session id>.jsonl.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.cp and read for the markdown in pieces with tail -c, so that part needs a POSIX shell (macOS, Linux, WSL).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.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.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./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:
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.The mod makes no network calls and sends nothing anywhere.
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, её не трогает ни очистка, ни переустановка:
/history показывает сессии проекта, /history <текст> ищет по ним.MIT
hooks/register.tsx 379 lines1import { 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}
379hooks/logic.ts 448 lines1const 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}
448types/index.d.ts 15 lines1/** 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