detached 83b64e2

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:
4444
4545 > "What did I tell you to remember?"
4646
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).
5058
5159 ## Why TypeScript here
5260
@@ -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 is47+…and it will call the tool and tell you.
48-process-local: it lives only as long as the extension subprocess48+
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 here59 ## Why TypeScript here
52 60
M examples/extensions/scratchpad/index.ts +75 -3
@@ -17,6 +17,14 @@
1717
1818 import { createInterface } from "node:readline";
1919 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";
2028
2129 // ---- protocol types (a tiny subset of internal/extproto) ----
2230
@@ -87,14 +95,77 @@ function log(msg: string): void {
8795 }
8896
8997 // ---- 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 = "";
90116
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+}
92143
93144 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+ }
95155 return notes.length;
96156 }
97157
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+
98169 function renderNotes(): string {
99170 if (notes.length === 0) return "(scratchpad is empty)";
100171 return notes
@@ -184,6 +255,7 @@ function handleHelloAck(ack: HelloAck): void {
184255 `connected to zot ${ack.zot_version} ` +
185256 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
186257 );
258+ if (ack.cwd) setNotesPath(ack.cwd);
187259 }
188260
189261 function handleCommand(frame: CommandInvoked): void {
@@ -221,7 +293,7 @@ function handleCommand(frame: CommandInvoked): void {
221293 }
222294
223295 case "clear-notes": {
224- notes.length = 0;
296+ clearNotes();
225297 respond(frame.id, {
226298 type: "command_response",
227299 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 notes171 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,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close