A cross-session journal of turns per project, shown with /journal

A cross-session journal of what you worked on, per project and per day.
| Command | Effect |
|---|---|
/journal | Today's entries, grouped by project |
/journal yesterday | Yesterday's entries |
/journal week | Turn counts per day and project over the last 7 days |
/journal clear | Deletes the whole journal |
claude --plugin-dir /path/to/ModsTools/mods/session-journal
(no prompt).claude plugin validate mods/session-journal
claude plugin test mods/session-journal # 10 testshooks/register.ts 75 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { addEntry, firstLine, formatDay, formatWeek, projectName } from './journal'
5import type { Entry } from './journal'
6
7const KEY = 'entries'
8const DAY_MS = 86_400_000
9const pending = atom({ plugin: 'session-journal', key: 'pending' } as const, { prompt: '', tools: 0, files: [] as string[] })
10
11async function entries($: EngineInterface): Promise<Entry[]> {
12 const value = await $.store.get(KEY)
13
14 return Array.isArray(value) ? (value as Entry[]) : []
15}
16
17async function note($: EngineInterface, path: string | undefined) {
18 await update($, pending, p => ({
19 ...p,
20 tools: p.tools + 1,
21 files: path !== undefined && !p.files.includes(path) ? [...p.files, path] : p.files,
22 }))
23}
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 await $.command.register({ name: 'journal', description: 'Cross-session journal: [yesterday|week|clear]' })
28
29 return next(e)
30 })
31
32 on('prompt.submit', async ($, e, next) => {
33 await update($, pending, () => ({ prompt: firstLine(e.text), tools: 0, files: [] }))
34
35 return next(e)
36 })
37
38 on('tool.call', async ($, e, next) => {
39 const ran = await next(e)
40 if (e.agentId !== undefined || ran.deny !== undefined) return ran
41 const input = e as { file_path?: string; notebook_path?: string }
42 const isEdit = e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit'
43 await note($, isEdit ? (e.tool === 'NotebookEdit' ? input.notebook_path : input.file_path) : undefined)
44
45 return ran
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 if (e.agentId === undefined) {
50 const p = await read($, pending)
51 const now = await $.clock.now()
52 const entry: Entry = { time: now, project: projectName(await $.session.cwd()), prompt: p.prompt, tools: p.tools, files: p.files.length }
53 await $.store.set(KEY, addEntry(await entries($), entry, now))
54 await update($, pending, () => ({ prompt: '', tools: 0, files: [] }))
55 }
56
57 return next(e)
58 })
59
60 on('command.run', { command: 'journal' }, async ($, e) => {
61 const arg = e.args.trim()
62 const now = await $.clock.now()
63 if (arg === 'clear') {
64 await $.store.delete(KEY)
65
66 return { text: 'Journal cleared.' }
67 }
68 if (arg === 'week') return { text: formatWeek(await entries($), now) }
69 if (arg === 'yesterday') return { text: formatDay(await entries($), now - DAY_MS) }
70 if (arg === '') return { text: formatDay(await entries($), now) }
71
72 return { text: 'Usage: /journal [yesterday|week|clear]' }
73 })
74}
75hooks/journal.ts 71 lines1export type Entry = { time: number; project: string; prompt: string; tools: number; files: number }
2
3export const MAX_DAYS = 30
4export const MAX_ENTRIES = 2000
5export const PROMPT_MAX = 80
6const DAY_MS = 86_400_000
7
8export function firstLine(text: string): string {
9 const line = text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
10
11 return line.length > PROMPT_MAX ? `${line.slice(0, PROMPT_MAX - 1)}…` : line
12}
13
14export function projectName(cwd: string): string {
15 return cwd.split('/').filter(Boolean).pop() ?? cwd
16}
17
18// Appends, drops entries older than 30 days, keeps the newest 2000.
19export function addEntry(entries: readonly Entry[], entry: Entry, now: number): Entry[] {
20 const kept = [...entries, entry].filter(e => now - e.time <= MAX_DAYS * DAY_MS)
21
22 return kept.slice(-MAX_ENTRIES)
23}
24
25const pad = (n: number) => String(n).padStart(2, '0')
26
27export function dayKey(ms: number): string {
28 const d = new Date(ms)
29
30 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
31}
32
33const clock = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}`
34
35const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
36
37// Entries of the local day containing `ms`, grouped by project.
38export function formatDay(entries: readonly Entry[], ms: number): string {
39 const key = dayKey(ms)
40 const day = entries.filter(e => dayKey(e.time) === key)
41 if (day.length === 0) return `No journal entries for ${key}.`
42 const byProject = new Map<string, Entry[]>()
43 for (const e of day) byProject.set(e.project, [...(byProject.get(e.project) ?? []), e])
44 const lines = [`Journal ${key}`]
45 for (const [project, list] of byProject) {
46 lines.push('', `${project} (${plural(list.length, 'turn')})`)
47 for (const e of list) {
48 lines.push(` ${clock(e.time)} ${e.prompt || '(no prompt)'} · ${plural(e.tools, 'tool')} · ${plural(e.files, 'file')}`)
49 }
50 }
51
52 return lines.join('\n')
53}
54
55// Turn counts per day then project over the 7 days ending at `now`, newest day first.
56export function formatWeek(entries: readonly Entry[], now: number): string {
57 const lines = ['Journal, last 7 days']
58 let total = 0
59 for (let i = 0; i < 7; i++) {
60 const key = dayKey(now - i * DAY_MS)
61 const counts = new Map<string, number>()
62 for (const e of entries) if (dayKey(e.time) === key) counts.set(e.project, (counts.get(e.project) ?? 0) + 1)
63 if (counts.size === 0) continue
64 const n = [...counts.values()].reduce((a, b) => a + b, 0)
65 total += n
66 lines.push(`${key}: ${[...counts].map(([p, c]) => `${p} ${c}`).join(', ')}`)
67 }
68
69 return total === 0 ? 'No journal entries in the last 7 days.' : [...lines, `Total: ${plural(total, 'turn')}`].join('\n')
70}
71types/index.d.ts 8 lines1export type Pending = { prompt: string; tools: number; files: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-journal': { pending: Pending }
6 }
7}
8