SLOPSHOPPER

agent-diary

In-terminal Stoic engineering diary pane, /diary commands, and automatic session distiller for Claude Code

newpanebandspinnerguardcommand
★ 1v0.2.0MITupdated 2026-10-05hemanth/agent-diary
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-diary
│ ┃ Agent Diary (Ta Eis Heauton) ✕ › fix the failing auth test and╭───────────────────────────────────╮ │ ┃ 1: Entries (2) 2: Meditations 3: Write N │ agent-diary │ │ ┃ ⏺ Read(src/auth.ts) │ Opened diary.html in your browser │ │ ┃ Attach a personal note ⎿ Read 6 lines ╰───────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ Target entry: audio-worklet - Zero-copy ring ⎿ Added 2 lines, removed 1 line │ ┃ buffers in the WebAudio render quantum ⏺ Bash(bun test) │ ┃ Reflection: What bug, fix, or lesson stood o ⎿ 3 pass, 1 fail │ ┃ ▎ No notes saved in this workspace store │ ┃ ▎ yet. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Session telemetry: 9 tool call(s), 1 ✻ Worked for 42s · done 4:20 PM │ ┃ obstacle(s) | Keys: 1/2/3 tabs, n/p cycle, m │ ┃ lot, b banner, o browser, Esc close › /diary │ │ ✻ Thinking | diary: 1 obstacle(s) via Bash… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Diary (Ta Eis Heauton)
1: Entries (2) 2: Meditations 3: Write Note (0) b: Sto Attach a personal note Target entry: audio-worklet - Zero-copy ring buffers in the WebAudio render quantum Reflection: What bug, fix, or lesson stood out today? Press ▎ No notes saved in this workspace store yet. Session telemetry: 9 tool call(s), 1 obstacle(s) | Keys: 1/2/3 tabs, n/p cycle, m lot, b banner, o browser, Esc close
README

agent-diary

Pull learnings, bugs, and fixes from AI agent sessions into a local HTML diary and an interactive Claude Code Mod pane with Marcus Aurelius style notes.

git clone https://github.com/hemanth/agent-diary.git
cd agent-diary
npm link
ln -s "$PWD" ~/.gemini/config/skills/agent-diary
ln -s "$PWD" ~/.claude/skills/agent-diary

Quick start

# Scan recent sessions from Antigravity and Claude Code
npm run harvest

# Build and open diary.html
npm run open

harvest.mjs reads .jsonl transcripts for errors, follow-up questions, and fixes. build-diary.mjs compiles data/diary-entries.json into diary.html, docs/index.html, and DIARY.md.

Claude Code Mod

agent-diary includes a Claude Code Mod (hooks/register.js) that draws an interactive pane inside your terminal or Claude Desktop app.

# Load from ~/.claude/skills/agent-diary or pass --plugin-dir directly
claude --plugin-dir .

# Validate and run the mod test suite
npm run validate
npm test

Inside a Claude Code session:

  • /diary opens the interactive Agent Diary (Ta Eis Heauton) pane with three tabs: 1: Entries, 2: Meditations, and 3: Write Note.
  • /diary-note <reflection> saves a personal note straight from the prompt into $.store and data/diary-entries.json.
  • /diary-open opens diary.html in your browser.
  • Hotkeys inside the pane: 1/2/3 switch tabs, n/p cycle entries, m draws another Stoic meditation lot, b toggles the AbovePrompt Stoic quote banner, and o opens the HTML diary.

CLI usage

# Sync latest sessions and open diary.html in your browser
agent-diary

# Print recent sessions in the terminal
agent-diary harvest

# Sync and rebuild without opening a browser tab
agent-diary sync

Add an entry from the terminal

node scripts/build-diary.mjs --add-json '{
  "id": "entry-2026-10-05-my-project",
  "date": "2026-10-05",
  "project": "my-project",
  "title": "Zero-copy buffers in the audio worker",
  "summary": "Removed redundant ArrayBuffer copies on the hot path.",
  "virtue": "Economy and Precision (Euteleia)",
  "challenges": ["GC pauses hit 45ms when cloning 16MB audio frames."],
  "solutions": ["Passed ownership with postMessage transferable ArrayBuffers."],
  "learnings": ["Transferables move ownership in O(1) without copying bytes."],
  "meditation": {
    "book": "Book IV",
    "verse": "Section 24",
    "stoicConcept": "Apallage (Pruning the unnecessary)",
    "quote": "Why copy the vessel when you can hand it across the threshold?",
    "reflection": "Most latency comes from extra work we added ourselves. Pass the buffer directly."
  },
  "userNote": "Cut frame latency from 45ms to 0.2ms."
}' --open

Automatic background hook

agent-diary registers a Stop hook in ~/.gemini/config/hooks.json (Antigravity), ~/.claude/settings.json (Claude Code settings), and turn.complete in hooks/register.js (Claude Code Mod). When a turn ends, scripts/auto-hook.mjs pulls new sessions into data/diary-entries.json and rebuilds diary.html without overwriting your manual notes.

License

MIT (c) Hemanth.HM

Source 1 files
hooks/register.js 480 lines
1const PANE_ID = 'agent-diary';
2const DIARY_JSON_PATH = 'data/diary-entries.json';
3
4const FALLBACK_ENTRIES = [
5  {
6    id: 'entry-2026-10-05-audio-worklet',
7    date: '2026-10-05',
8    project: 'audio-worklet',
9    title: 'Zero-copy ring buffers in the WebAudio render quantum',
10    virtue: 'Temperance and Proportion (Sophrosyne)',
11    challenges: [
12      'The UI showed Render Complete, but the exported MP4 had no captions because the canvas overlay was not passed into the FFmpeg WASM filtergraph.',
13      'Portrait screen recordings (484x1080) rendered 54px captions that clipped horizontally.'
14    ],
15    solutions: [
16      'Mounted the subtitle file in the FFmpeg WASM pass and extracted a PNG frame after export to verify the pixels.',
17      'Scaled subtitle font size by Math.min(outW, outH) / 480 with line wrapping.'
18    ],
19    learnings: [
20      'Scale video overlay text against min(width, height) rather than height alone.',
21      'Inspect an extracted frame on disk before trusting a Render Complete badge.'
22    ],
23    meditation: {
24      book: 'Book IV',
25      verse: 'Section 19',
26      stoicConcept: 'Metron (Measure and proportion)',
27      quote: 'The herald cries that the work is finished, yet the stone has no inscription. Measure both dimensions of the column before you carve.',
28      reflection: 'Do not assume a job succeeded because a function returned without throwing. Verify the artifact on disk.'
29    },
30    userNote: 'Testing on a real 484x1080 mobile recording caught the height-only font scaling bug right away.'
31  },
32  {
33    id: 'entry-2026-10-03-sqlite-wal',
34    date: '2026-10-03',
35    project: 'sqlite-wal',
36    title: 'Preventing WAL starvation under continuous reader load',
37    virtue: 'Truthfulness (Aletheia)',
38    challenges: [
39      'Chrome SQLite visits table was empty when History Sync was off, causing the dashboard to show fake fallback numbers.'
40    ],
41    solutions: [
42      'Built a pure-Node Snappy decompressor to read Chromium Local Extension Settings LevelDB (.ldb) files directly.'
43    ],
44    learnings: [
45      'Never ship silent mock fallback data when a parser fails; surface the empty state so the root cause gets fixed.'
46    ],
47    meditation: {
48      book: 'Book III',
49      verse: 'Section 11',
50      stoicConcept: 'Hypolepsis (Testing impressions)',
51      quote: 'Strip away the painted mask and examine the thing itself: is this gold from the mine, or brass coined in your own workshop to quiet a question?',
52      reflection: 'A blank screen is honest and makes you find the real data source. A fake chart hides the bug.'
53    },
54    userNote: 'Reading the Snappy-compressed .ldb files directly gave us the exact per-domain seconds.'
55  }
56];
57
58let entries = FALLBACK_ENTRIES.slice();
59let notes = [];
60let activeTab = 'entries';
61let selectedIndex = 0;
62let meditationIndex = 0;
63let showBanner = false;
64let turnTools = 0;
65let turnObstacles = 0;
66let lastObstacleTool = '';
67let diaryJsonPath = DIARY_JSON_PATH;
68
69function applyParsedEntries(raw) {
70  try {
71    const parsed = JSON.parse(raw);
72    if (Array.isArray(parsed) && parsed.length > 0) {
73      entries = parsed;
74      if (selectedIndex >= entries.length) {
75        selectedIndex = 0;
76      }
77    }
78  } catch {
79    // Keep existing entries if JSON parsing fails.
80  }
81}
82
83function buildNoteRecord(text) {
84  const trimmed = String(text || '').trim();
85  if (!trimmed) {
86    return null;
87  }
88  const target = entries[selectedIndex] || entries[0];
89  const record = {
90    date: new Date().toISOString().slice(0, 10),
91    project: target ? target.project : 'session',
92    entryId: target ? target.id : 'custom',
93    text: trimmed
94  };
95  notes = [record, ...notes.slice(0, 19)];
96  if (target) {
97    target.userNote = trimmed;
98  }
99  return record;
100}
101
102export function register(on) {
103  on('session.start', async ($, e, next) => {
104    try {
105      const home = await $.env.get('HOME');
106      if (home) {
107        diaryJsonPath = home + '/.agent-diary/diary-entries.json';
108      }
109    } catch {
110      // Keep default relative path in sandboxed environments.
111    }
112
113    const savedNotes = await $.store.get('notes');
114    if (Array.isArray(savedNotes)) {
115      notes = savedNotes;
116    }
117    const savedBanner = await $.store.get('showBanner');
118    if (typeof savedBanner === 'boolean') {
119      showBanner = savedBanner;
120    }
121
122    try {
123      const raw = await $.fs.read(diaryJsonPath);
124      applyParsedEntries(raw);
125    } catch {
126      // Keep fallback entries in sandboxed environments.
127    }
128
129    try {
130      await $.command.register({
131        name: 'diary',
132        description: 'Open the Stoic Agent Diary pane in Claude Code',
133        immediate: true
134      });
135      await $.command.register({
136        name: 'diary-open',
137        description: 'Open the full HTML Agent Diary in your browser',
138        immediate: true
139      });
140      await $.command.register({
141        name: 'diary-note',
142        description: 'Save a personal reflection note to your Agent Diary',
143        argumentHint: '<reflection>',
144        immediate: true
145      });
146    } catch {
147      // Ignore duplicate command registration errors on reload.
148    }
149
150    return next(e);
151  });
152
153  on('command.run', { command: 'diary' }, async ($) => {
154    try {
155      const raw = await $.fs.read(diaryJsonPath);
156      applyParsedEntries(raw);
157    } catch {
158      // Keep existing entries.
159    }
160    await $.ui.open({
161      id: PANE_ID,
162      title: 'Agent Diary (Ta Eis Heauton)',
163      focus: true,
164      closeOnEscape: true
165    });
166    return {};
167  });
168
169  on('command.run', { command: 'diary-open' }, async ($) => {
170    try {
171      await $.process.run(['agent-diary', 'open']);
172      $.ui.toast('Opened diary.html in your browser');
173    } catch {
174      $.ui.toast('Run: agent-diary open');
175    }
176    return {};
177  });
178
179  on('command.run', { command: 'diary-note' }, async ($, e) => {
180    const record = buildNoteRecord(e.args);
181    if (!record) {
182      activeTab = 'write';
183      await $.ui.open({
184        id: PANE_ID,
185        title: 'Agent Diary (Ta Eis Heauton)',
186        focus: true,
187        closeOnEscape: true
188      });
189      return {};
190    }
191    await $.store.set('notes', notes);
192    try {
193      await $.fs.write(diaryJsonPath, JSON.stringify(entries, null, 2));
194    } catch {
195      // Ignore write failures in sandboxed environments.
196    }
197    $.ui.invalidate('ui.render');
198    return {
199      text: 'Saved personal reflection to Agent Diary (' + record.project + '): "' + record.text + '"'
200    };
201  });
202
203  on('tool.call', async ($, e, next) => {
204    const result = await next(e);
205    turnTools += 1;
206    if (result && result.isError) {
207      turnObstacles += 1;
208      lastObstacleTool = e.tool || 'tool';
209    }
210    $.ui.invalidate('ui.render');
211    return result;
212  });
213
214  on('turn.complete', async ($, e, next) => {
215    try {
216      await $.process.run(['agent-diary', 'sync']);
217      const raw = await $.fs.read(diaryJsonPath);
218      applyParsedEntries(raw);
219    } catch {
220      // Ignore background hook errors in sandboxed environments.
221    }
222    $.ui.invalidate('ui.render');
223    return next(e);
224  });
225
226  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
227    const suffix =
228      turnObstacles > 0
229        ? ' | diary: ' + turnObstacles + ' obstacle(s) via ' + lastObstacleTool
230        : ' | diary: ' + entries.length + ' entries';
231    return next({
232      ...e,
233      props: {
234        ...e.props,
235        suffix
236      }
237    });
238  });
239
240  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241    if (!showBanner) {
242      return next(e);
243    }
244    const { Box, Text } = $.ui.resolve(e);
245    const current = entries[meditationIndex] || entries[0];
246    const med = current && current.meditation ? current.meditation : FALLBACK_ENTRIES[0].meditation;
247    return Box({
248      borderStyle: 'round',
249      borderColor: 'yellow',
250      paddingX: 1,
251      flexDirection: 'row',
252      columnGap: 1,
253      children: [
254        Text({ bold: true, color: 'yellow', children: ['[' + med.book + ' ' + med.verse + ']'] }),
255        Text({ dimColor: true, children: [med.quote] })
256      ]
257    });
258  });
259
260  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
261    if (e.requestId !== PANE_ID) {
262      return next(e);
263    }
264    const { Box, Text, Button, Input, Markdown } = $.ui.resolve(e);
265    const entry = entries[selectedIndex] || entries[0];
266    const medEntry = entries[meditationIndex] || entry;
267    const med = medEntry && medEntry.meditation ? medEntry.meditation : FALLBACK_ENTRIES[0].meditation;
268
269    const navBar = Box({
270      flexDirection: 'row',
271      columnGap: 3,
272      children: [
273        Button({
274          key: 'tab-entries',
275          label: 'Entries (' + entries.length + ')',
276          hotkey: '1',
277          plain: true,
278          dimColor: activeTab !== 'entries',
279          onPress: () => {
280            activeTab = 'entries';
281            $.ui.invalidate('ui.render');
282          }
283        }),
284        Button({
285          key: 'tab-meditations',
286          label: 'Meditations',
287          hotkey: '2',
288          plain: true,
289          dimColor: activeTab !== 'meditations',
290          onPress: () => {
291            activeTab = 'meditations';
292            $.ui.invalidate('ui.render');
293          }
294        }),
295        Button({
296          key: 'tab-write',
297          label: 'Write Note (' + notes.length + ')',
298          hotkey: '3',
299          plain: true,
300          dimColor: activeTab !== 'write',
301          onPress: () => {
302            activeTab = 'write';
303            $.ui.invalidate('ui.render');
304          }
305        }),
306        Button({
307          key: 'toggle-banner',
308          label: 'Stoic Banner: ' + (showBanner ? 'ON' : 'OFF'),
309          hotkey: 'b',
310          plain: true,
311          dimColor: !showBanner,
312          onPress: async () => {
313            showBanner = !showBanner;
314            await $.store.set('showBanner', showBanner);
315            $.ui.invalidate('ui.render');
316          }
317        }),
318        Button({
319          key: 'open-html',
320          label: 'Open HTML',
321          hotkey: 'o',
322          plain: true,
323          onPress: async () => {
324            try {
325              await $.process.run(['agent-diary', 'open']);
326              $.ui.toast('Opened diary.html in your browser');
327            } catch {
328              $.ui.toast('Run: agent-diary open');
329            }
330          }
331        })
332      ]
333    });
334
335    let bodyChildren = [];
336
337    if (activeTab === 'entries' && entry) {
338      const entryMarkdown = [
339        '## ' + (selectedIndex + 1) + '/' + entries.length + ' · `' + entry.project + '` (' + entry.date + ')',
340        '',
341        '**' + entry.title + '** (' + (entry.virtue || 'Practical Wisdom') + ')',
342        '',
343        '### Obstacles',
344        '',
345        ...(entry.challenges || []).slice(0, 3).map((c) => '- ' + c),
346        '',
347        '### Fixes',
348        '',
349        ...(entry.solutions || []).slice(0, 3).map((s) => '- ' + s),
350        '',
351        '### Takeaways',
352        '',
353        ...(entry.learnings || []).slice(0, 3).map((l) => '- ' + l),
354        '',
355        entry.userNote
356          ? '> Personal note: ' + entry.userNote
357          : '> No personal note yet. Press `3` to add one.'
358      ].join('\n');
359
360      bodyChildren = [
361        Markdown({ text: entryMarkdown }),
362        Box({
363          flexDirection: 'row',
364          columnGap: 2,
365          children: [
366            Button({
367              key: 'prev-entry',
368              label: 'Previous Entry',
369              hotkey: 'p',
370              onPress: () => {
371                selectedIndex = (selectedIndex - 1 + entries.length) % entries.length;
372                meditationIndex = selectedIndex;
373                $.ui.invalidate('ui.render');
374              }
375            }),
376            Button({
377              key: 'next-entry',
378              label: 'Next Entry',
379              hotkey: 'n',
380              onPress: () => {
381                selectedIndex = (selectedIndex + 1) % entries.length;
382                meditationIndex = selectedIndex;
383                $.ui.invalidate('ui.render');
384              }
385            })
386          ]
387        })
388      ];
389    } else if (activeTab === 'meditations') {
390      const medMarkdown = [
391        '## ' + med.book + ', ' + med.verse + ' (' + (med.stoicConcept || 'Ta Eis Heauton') + ')',
392        '',
393        '> ' + med.quote,
394        '',
395        med.reflection || '',
396        '',
397        'Source session: `' + (medEntry ? medEntry.project : 'session') + '` (' + (medEntry ? medEntry.date : '') + ')'
398      ].join('\n');
399
400      bodyChildren = [
401        Markdown({ text: medMarkdown }),
402        Box({
403          flexDirection: 'row',
404          columnGap: 2,
405          children: [
406            Button({
407              key: 'draw-lot',
408              label: 'Draw Another Stoic Lot',
409              hotkey: 'm',
410              onPress: () => {
411                meditationIndex = (meditationIndex + 1) % entries.length;
412                $.ui.invalidate('ui.render');
413              }
414            })
415          ]
416        })
417      ];
418    } else {
419      const notesMarkdown =
420        notes.length > 0
421          ? [
422              '### Recent notes saved from Claude Code',
423              '',
424              ...notes.slice(0, 5).map((n) => '- **' + n.date + '** (`' + n.project + '`): ' + n.text)
425            ].join('\n')
426          : '> No notes saved in this workspace store yet.';
427
428      bodyChildren = [
429        Markdown({
430          text:
431            '## Attach a personal note\n\nTarget entry: `' +
432            (entry ? entry.project : 'session') +
433            '` - **' +
434            (entry ? entry.title : 'current session') +
435            '**'
436        }),
437        Input({
438          key: 'diary-note-input',
439          label: 'Reflection',
440          placeholder: 'What bug, fix, or lesson stood out today? Press Enter to save',
441          value: '',
442          submitLabel: 'save',
443          onSubmit: async (val) => {
444            const record = buildNoteRecord(val);
445            if (record) {
446              await $.store.set('notes', notes);
447              try {
448                await $.fs.write(diaryJsonPath, JSON.stringify(entries, null, 2));
449              } catch {
450                // Ignore write failures in sandboxed environments.
451              }
452              $.ui.toast('Saved note to Agent Diary');
453              $.ui.invalidate('ui.render');
454            }
455          }
456        }),
457        Markdown({ text: notesMarkdown })
458      ];
459    }
460
461    const footer = Text({
462      dimColor: true,
463      children: [
464        'Session telemetry: ' + turnTools + ' tool call(s), ' + turnObstacles + ' obstacle(s) | Keys: 1/2/3 tabs, n/p cycle, m lot, b banner, o browser, Esc close'
465      ]
466    });
467
468    return Box({
469      flexDirection: 'column',
470      children: [
471        navBar,
472        Text({ children: [' '] }),
473        ...bodyChildren,
474        Text({ children: [' '] }),
475        footer
476      ]
477    });
478  });
479}
480