examples: scratchpad notes persist to .zot/scratchpad-notes.jsonl
Notes are now project-local and survive zot restarts. The
extension reads its cwd from the hello_ack handshake, then:
- on /note appends one line of {"at":..,"text":..} JSONL
under <cwd>/.zot/scratchpad-notes.jsonl
- on /notes reads the in-memory cache (loaded on hello_ack)
- on /clear-notes truncates the file and clears the cache
- on read_notes (tool) returns the cached set
Single-writer assumption (one zot session per cwd at a time);
two concurrent zot processes writing to the same file would
interleave but JSONL line boundaries stay intact under POSIX
PIPE_BUF semantics. Good enough for an example.
Verified end-to-end: round 1 writes two notes via the slash
command, round 2 (fresh extension process, same cwd) loads
them and surfaces them via both /notes and the read_notes
tool.- commit
- 83b64e25623a9ff0f16b14a24b1e07ebcfe7ed48
- author
- patriceckhart · · 2026-04-19 13:16 UTC
- parent
- 619ed58
- signature
- unsigned
2 files · +86 -6
M examples/extensions/scratchpad/README.md +11 -3
| @@ -44,9 +44,17 @@ The model also has a `read_notes` tool. Ask it: | |||
| 44 | 44 | ||
| 45 | 45 | > "What did I tell you to remember?" | |
| 46 | 46 | ||
| 47 | - | …and it will call the tool and tell you. The scratchpad is | |
| 48 | - | process-local: it lives only as long as the extension subprocess | |
| 49 | - | (i.e. the duration of one `zot` session). | |
| 47 | + | …and it will call the tool and tell you. | |
| 48 | + | ||
| 49 | + | ## Storage | |
| 50 | + | ||
| 51 | + | Notes persist as JSONL at `<cwd>/.zot/scratchpad-notes.jsonl`. The | |
| 52 | + | file is created on first `/note` and survives zot restarts. Each line | |
| 53 | + | is one note: `{"at":"2026-04-19T13:00:00.000Z","text":"..."}`. | |
| 54 | + | ||
| 55 | + | Scope is per-project: switching to a different cwd gives you a | |
| 56 | + | different scratchpad. Cross-project sharing isn't supported in this | |
| 57 | + | example (would just be a matter of changing the path constant). | |
| 50 | 58 | ||
| 51 | 59 | ## Why TypeScript here | |
| 52 | 60 | ||
| @@ -44,9 +44,17 @@ The model also has a `read_notes` tool. Ask it: | |||||
| 44 | 44 | ||||
| 45 | > "What did I tell you to remember?" | 45 | > "What did I tell you to remember?" | ||
| 46 | 46 | ||||
| 47 | - | …and it will call the tool and tell you. The scratchpad is | 47 | + | …and it will call the tool and tell you. |
| 48 | - | process-local: it lives only as long as the extension subprocess | 48 | + | |
| 49 | - | (i.e. the duration of one `zot` session). | 49 | + | ## Storage |
| 50 | + | ||||
| 51 | + | Notes persist as JSONL at `<cwd>/.zot/scratchpad-notes.jsonl`. The | |||
| 52 | + | file is created on first `/note` and survives zot restarts. Each line | |||
| 53 | + | is one note: `{"at":"2026-04-19T13:00:00.000Z","text":"..."}`. | |||
| 54 | + | ||||
| 55 | + | Scope is per-project: switching to a different cwd gives you a | |||
| 56 | + | different scratchpad. Cross-project sharing isn't supported in this | |||
| 57 | + | example (would just be a matter of changing the path constant). | |||
| 50 | 58 | ||||
| 51 | ## Why TypeScript here | 59 | ## Why TypeScript here | ||
| 52 | 60 | ||||
M examples/extensions/scratchpad/index.ts +75 -3
| @@ -17,6 +17,14 @@ | |||
| 17 | 17 | ||
| 18 | 18 | import { createInterface } from "node:readline"; | |
| 19 | 19 | import { stderr, stdin, stdout } from "node:process"; | |
| 20 | + | import { | |
| 21 | + | appendFileSync, | |
| 22 | + | existsSync, | |
| 23 | + | mkdirSync, | |
| 24 | + | readFileSync, | |
| 25 | + | writeFileSync, | |
| 26 | + | } from "node:fs"; | |
| 27 | + | import { dirname, join } from "node:path"; | |
| 20 | 28 | ||
| 21 | 29 | // ---- protocol types (a tiny subset of internal/extproto) ---- | |
| 22 | 30 | ||
| @@ -87,14 +95,77 @@ function log(msg: string): void { | |||
| 87 | 95 | } | |
| 88 | 96 | ||
| 89 | 97 | // ---- the scratchpad state itself ---- | |
| 98 | + | // | |
| 99 | + | // Notes persist as JSONL under <cwd>/.zot/scratchpad-notes.jsonl so | |
| 100 | + | // they survive zot restarts and stay scoped to the project. The path | |
| 101 | + | // is resolved once HelloAck arrives (which carries cwd); until then | |
| 102 | + | // notesPath is empty and reads/writes no-op safely. | |
| 103 | + | // | |
| 104 | + | // One note per line, format: {"at":"<iso>","text":"<body>"} | |
| 105 | + | // Append-only on /note; full rewrite on /clear-notes. | |
| 106 | + | // | |
| 107 | + | // Single-writer assumption: only one zot session per cwd at a time. | |
| 108 | + | // Concurrent writes from two zot instances would interleave but not | |
| 109 | + | // corrupt JSONL line boundaries on POSIX (writes ≤ PIPE_BUF are | |
| 110 | + | // atomic). Good enough for a demo. | |
| 111 | + | ||
| 112 | + | type Note = { at: string; text: string }; | |
| 113 | + | ||
| 114 | + | let notes: Note[] = []; | |
| 115 | + | let notesPath = ""; | |
| 90 | 116 | ||
| 91 | - | const notes: Array<{ at: string; text: string }> = []; | |
| 117 | + | function setNotesPath(cwd: string): void { | |
| 118 | + | notesPath = join(cwd, ".zot", "scratchpad-notes.jsonl"); | |
| 119 | + | loadNotes(); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | function loadNotes(): void { | |
| 123 | + | notes = []; | |
| 124 | + | if (!notesPath || !existsSync(notesPath)) return; | |
| 125 | + | try { | |
| 126 | + | const raw = readFileSync(notesPath, "utf8"); | |
| 127 | + | for (const line of raw.split("\n")) { | |
| 128 | + | const trimmed = line.trim(); | |
| 129 | + | if (!trimmed) continue; | |
| 130 | + | try { | |
| 131 | + | const parsed = JSON.parse(trimmed) as Note; | |
| 132 | + | if (typeof parsed?.text === "string") notes.push(parsed); | |
| 133 | + | } catch { | |
| 134 | + | // skip malformed lines silently; the next /note will | |
| 135 | + | // append correctly anyway. | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | log(`loaded ${notes.length} note(s) from ${notesPath}`); | |
| 139 | + | } catch (err) { | |
| 140 | + | log(`failed to read ${notesPath}: ${err}`); | |
| 141 | + | } | |
| 142 | + | } | |
| 92 | 143 | ||
| 93 | 144 | function appendNote(text: string): number { | |
| 94 | - | notes.push({ at: new Date().toISOString(), text }); | |
| 145 | + | const note: Note = { at: new Date().toISOString(), text }; | |
| 146 | + | notes.push(note); | |
| 147 | + | if (notesPath) { | |
| 148 | + | try { | |
| 149 | + | mkdirSync(dirname(notesPath), { recursive: true }); | |
| 150 | + | appendFileSync(notesPath, JSON.stringify(note) + "\n", "utf8"); | |
| 151 | + | } catch (err) { | |
| 152 | + | log(`failed to persist note to ${notesPath}: ${err}`); | |
| 153 | + | } | |
| 154 | + | } | |
| 95 | 155 | return notes.length; | |
| 96 | 156 | } | |
| 97 | 157 | ||
| 158 | + | function clearNotes(): void { | |
| 159 | + | notes = []; | |
| 160 | + | if (notesPath) { | |
| 161 | + | try { | |
| 162 | + | writeFileSync(notesPath, "", "utf8"); | |
| 163 | + | } catch (err) { | |
| 164 | + | log(`failed to clear ${notesPath}: ${err}`); | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 98 | 169 | function renderNotes(): string { | |
| 99 | 170 | if (notes.length === 0) return "(scratchpad is empty)"; | |
| 100 | 171 | return notes | |
| @@ -184,6 +255,7 @@ function handleHelloAck(ack: HelloAck): void { | |||
| 184 | 255 | `connected to zot ${ack.zot_version} ` + | |
| 185 | 256 | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | |
| 186 | 257 | ); | |
| 258 | + | if (ack.cwd) setNotesPath(ack.cwd); | |
| 187 | 259 | } | |
| 188 | 260 | ||
| 189 | 261 | function handleCommand(frame: CommandInvoked): void { | |
| @@ -221,7 +293,7 @@ function handleCommand(frame: CommandInvoked): void { | |||
| 221 | 293 | } | |
| 222 | 294 | ||
| 223 | 295 | case "clear-notes": { | |
| 224 | - | notes.length = 0; | |
| 296 | + | clearNotes(); | |
| 225 | 297 | respond(frame.id, { | |
| 226 | 298 | type: "command_response", | |
| 227 | 299 | id: frame.id, | |
| @@ -17,6 +17,14 @@ | |||||
| 17 | 17 | ||||
| 18 | import { createInterface } from "node:readline"; | 18 | import { createInterface } from "node:readline"; | ||
| 19 | import { stderr, stdin, stdout } from "node:process"; | 19 | import { stderr, stdin, stdout } from "node:process"; | ||
| 20 | + | import { | |||
| 21 | + | appendFileSync, | |||
| 22 | + | existsSync, | |||
| 23 | + | mkdirSync, | |||
| 24 | + | readFileSync, | |||
| 25 | + | writeFileSync, | |||
| 26 | + | } from "node:fs"; | |||
| 27 | + | import { dirname, join } from "node:path"; | |||
| 20 | 28 | ||||
| 21 | // ---- protocol types (a tiny subset of internal/extproto) ---- | 29 | // ---- protocol types (a tiny subset of internal/extproto) ---- | ||
| 22 | 30 | ||||
| @@ -87,14 +95,77 @@ function log(msg: string): void { | |||||
| 87 | } | 95 | } | ||
| 88 | 96 | ||||
| 89 | // ---- the scratchpad state itself ---- | 97 | // ---- the scratchpad state itself ---- | ||
| 98 | + | // | |||
| 99 | + | // Notes persist as JSONL under <cwd>/.zot/scratchpad-notes.jsonl so | |||
| 100 | + | // they survive zot restarts and stay scoped to the project. The path | |||
| 101 | + | // is resolved once HelloAck arrives (which carries cwd); until then | |||
| 102 | + | // notesPath is empty and reads/writes no-op safely. | |||
| 103 | + | // | |||
| 104 | + | // One note per line, format: {"at":"<iso>","text":"<body>"} | |||
| 105 | + | // Append-only on /note; full rewrite on /clear-notes. | |||
| 106 | + | // | |||
| 107 | + | // Single-writer assumption: only one zot session per cwd at a time. | |||
| 108 | + | // Concurrent writes from two zot instances would interleave but not | |||
| 109 | + | // corrupt JSONL line boundaries on POSIX (writes ≤ PIPE_BUF are | |||
| 110 | + | // atomic). Good enough for a demo. | |||
| 111 | + | ||||
| 112 | + | type Note = { at: string; text: string }; | |||
| 113 | + | ||||
| 114 | + | let notes: Note[] = []; | |||
| 115 | + | let notesPath = ""; | |||
| 90 | 116 | ||||
| 91 | - | const notes: Array<{ at: string; text: string }> = []; | 117 | + | function setNotesPath(cwd: string): void { |
| 118 | + | notesPath = join(cwd, ".zot", "scratchpad-notes.jsonl"); | |||
| 119 | + | loadNotes(); | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | function loadNotes(): void { | |||
| 123 | + | notes = []; | |||
| 124 | + | if (!notesPath || !existsSync(notesPath)) return; | |||
| 125 | + | try { | |||
| 126 | + | const raw = readFileSync(notesPath, "utf8"); | |||
| 127 | + | for (const line of raw.split("\n")) { | |||
| 128 | + | const trimmed = line.trim(); | |||
| 129 | + | if (!trimmed) continue; | |||
| 130 | + | try { | |||
| 131 | + | const parsed = JSON.parse(trimmed) as Note; | |||
| 132 | + | if (typeof parsed?.text === "string") notes.push(parsed); | |||
| 133 | + | } catch { | |||
| 134 | + | // skip malformed lines silently; the next /note will | |||
| 135 | + | // append correctly anyway. | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | log(`loaded ${notes.length} note(s) from ${notesPath}`); | |||
| 139 | + | } catch (err) { | |||
| 140 | + | log(`failed to read ${notesPath}: ${err}`); | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 92 | 143 | ||||
| 93 | function appendNote(text: string): number { | 144 | function appendNote(text: string): number { | ||
| 94 | - | notes.push({ at: new Date().toISOString(), text }); | 145 | + | const note: Note = { at: new Date().toISOString(), text }; |
| 146 | + | notes.push(note); | |||
| 147 | + | if (notesPath) { | |||
| 148 | + | try { | |||
| 149 | + | mkdirSync(dirname(notesPath), { recursive: true }); | |||
| 150 | + | appendFileSync(notesPath, JSON.stringify(note) + "\n", "utf8"); | |||
| 151 | + | } catch (err) { | |||
| 152 | + | log(`failed to persist note to ${notesPath}: ${err}`); | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 95 | return notes.length; | 155 | return notes.length; | ||
| 96 | } | 156 | } | ||
| 97 | 157 | ||||
| 158 | + | function clearNotes(): void { | |||
| 159 | + | notes = []; | |||
| 160 | + | if (notesPath) { | |||
| 161 | + | try { | |||
| 162 | + | writeFileSync(notesPath, "", "utf8"); | |||
| 163 | + | } catch (err) { | |||
| 164 | + | log(`failed to clear ${notesPath}: ${err}`); | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 98 | function renderNotes(): string { | 169 | function renderNotes(): string { | ||
| 99 | if (notes.length === 0) return "(scratchpad is empty)"; | 170 | if (notes.length === 0) return "(scratchpad is empty)"; | ||
| 100 | return notes | 171 | return notes | ||
| @@ -184,6 +255,7 @@ function handleHelloAck(ack: HelloAck): void { | |||||
| 184 | `connected to zot ${ack.zot_version} ` + | 255 | `connected to zot ${ack.zot_version} ` + | ||
| 185 | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | 256 | `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`, | ||
| 186 | ); | 257 | ); | ||
| 258 | + | if (ack.cwd) setNotesPath(ack.cwd); | |||
| 187 | } | 259 | } | ||
| 188 | 260 | ||||
| 189 | function handleCommand(frame: CommandInvoked): void { | 261 | function handleCommand(frame: CommandInvoked): void { | ||
| @@ -221,7 +293,7 @@ function handleCommand(frame: CommandInvoked): void { | |||||
| 221 | } | 293 | } | ||
| 222 | 294 | ||||
| 223 | case "clear-notes": { | 295 | case "clear-notes": { | ||
| 224 | - | notes.length = 0; | 296 | + | clearNotes(); |
| 225 | respond(frame.id, { | 297 | respond(frame.id, { | ||
| 226 | type: "command_response", | 298 | type: "command_response", | ||
| 227 | id: frame.id, | 299 | id: frame.id, | ||