Links to the notelet notes this session wrote, above the prompt and in /notes

A Claude Code plugin that shows links to the notelet notes a session wrote.
A tool.call hook records each successful call of these tools on a notelet MCP server (mcp__notelet__* or mcp__notelet-local__*):
note_put, note_append, note_deletenote_get with to_file (a plain note_get is a read and is not recorded)Path and version come from the tool's JSON result. Newest first, one entry per path, at most 30. Error and non-JSON results are ignored.
path@vN, linked to <ui_base_url>/ui/note/<path>. A deleted note is shown as (deleted vN) without a link./notes lists all of them with their URLs and toggles the band between the last three and all./plugin marketplace add ynishi/notelet
/plugin install notelet-links@notelet
Set ui_base_url through /config (plugin settings): where the notelet browser view is served, without /ui, for example http://127.0.0.1:8080. On the desktop app a link is drawn only for an https: or http://localhost address; any other address is shown as plain text. Empty (the default) shows paths without links. No URL is written into the plugin.
Nothing leaves the client. The plugin only reads tool results the session already has; it makes no network request and changes nothing on the server.
MIT OR Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.
hooks/register.tsx 114 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Touched } from '../types'
5
6const notes = atom({ plugin: 'notelet-links', key: 'notes' } as const, [] as readonly Touched[])
7const isExpanded = atom({ plugin: 'notelet-links', key: 'isExpanded' } as const, false)
8
9/** notelet MCP tools on either server, by the op they record. */
10const TOOL = /^mcp__notelet(?:-local)?__note_(put|append|get|delete)$/
11
12/** How many notes the band shows folded, and the most kept at all. */
13const FOLDED = 3
14const KEPT = 30
15
16/**
17 * What a successful notelet call touched, or null. `note_get` counts only
18 * with `to_file` (a read into a file to edit); a plain read is not a write.
19 */
20export function touched(
21 tool: string,
22 args: Record<string, unknown>,
23 text: string | undefined,
24): Touched | null {
25 const m = TOOL.exec(tool)
26 if (!m || text === undefined) return null
27 const op = m[1] as Touched['op']
28 if (op === 'get' && typeof args.to_file !== 'string') return null
29 let out: unknown
30 try {
31 out = JSON.parse(text)
32 } catch {
33 return null
34 }
35 if (typeof out !== 'object' || out === null) return null
36 const { path, version } = out as { path?: unknown; version?: unknown }
37 if (typeof path !== 'string' || typeof version !== 'number') return null
38 return { path, version, op }
39}
40
41/** The /ui page of a note, or null without a base URL. */
42export function uiUrl(base: string, path: string): string | null {
43 const b = base.trim().replace(/\/+$/, '')
44 return b === '' ? null : `${b}/ui/note/${path}`
45}
46
47/** Newest first, one entry per path, at most KEPT. */
48export function remember(list: readonly Touched[], t: Touched): readonly Touched[] {
49 return [t, ...list.filter(n => n.path !== t.path)].slice(0, KEPT)
50}
51
52function label(n: Touched): string {
53 return n.op === 'delete' ? `${n.path} (deleted v${n.version})` : `${n.path}@v${n.version}`
54}
55
56export const register: Register = (on, options) => {
57 const base = typeof options.ui_base_url === 'string' ? options.ui_base_url : ''
58
59 on('session.start', async ($, e, next) => {
60 await $.command.register({
61 name: 'notes',
62 description: 'List the notelet notes this session wrote, with /ui links',
63 })
64 return next(e)
65 })
66
67 on('command.run', { command: 'notes' }, async $ => {
68 const list = await read($, notes)
69 if (list.length === 0) return { text: 'No notelet note written in this session yet.' }
70 const expanded = await update($, isExpanded, v => !v)
71 const lines = list.map(n => {
72 const url = n.op === 'delete' ? null : uiUrl(base, n.path)
73 return url ? `- ${label(n)} ${url}` : `- ${label(n)}`
74 })
75 const hint = base.trim() === '' ? '\n(set ui_base_url in /config for links)' : ''
76 const mode = expanded ? 'band: all' : `band: last ${FOLDED}`
77 return { text: `notelet notes in this session (${mode}):\n${lines.join('\n')}${hint}` }
78 })
79
80 on('tool.call', async ($, e, next) => {
81 const ran = await next(e)
82 if (ran.deny === undefined && ran.isError !== true) {
83 const t = touched(e.tool, e as Record<string, unknown>, ran.text)
84 if (t) await update($, notes, list => remember(list, t))
85 }
86 return ran
87 }).catch(($, e, next) => next(e)) // a failed record must not change the call's result
88
89 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
90 const list = await read($, notes)
91 if (e.props.hasSurvey || list.length === 0) return next(e)
92 const expanded = await read($, isExpanded)
93 const shown = expanded ? list : list.slice(0, FOLDED)
94 const { Box, Text, Link } = $.ui.resolve(e)
95
96 return (
97 <Box flexDirection="column">
98 {shown.map(n => {
99 const url = n.op === 'delete' ? null : uiUrl(base, n.path)
100 return (
101 <Box key={n.path}>
102 <Text dimColor>notelet </Text>
103 {url ? <Link href={url} label={label(n)} /> : <Text>{label(n)}</Text>}
104 </Box>
105 )
106 })}
107 {!expanded && list.length > FOLDED ? (
108 <Text dimColor>+{list.length - FOLDED} more: /notes</Text>
109 ) : null}
110 </Box>
111 )
112 })
113}
114types/index.d.ts 15 lines1export type Touched = {
2 /** Note path, e.g. `projects/x/drafts/y.md`. */
3 path: string
4 /** Version the write answered (for a delete: the version removed). */
5 version: number
6 /** The tool that touched it. */
7 op: 'put' | 'append' | 'get' | 'delete'
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'notelet-links': { notes: readonly Touched[]; isExpanded: boolean }
13 }
14}
15