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.
This commit is contained in:
patriceckhart 2026-04-19 15:16:06 +02:00
parent 619ed587cd
commit 83b64e2562
2 changed files with 86 additions and 6 deletions

View file

@ -44,9 +44,17 @@ The model also has a `read_notes` tool. Ask it:
> "What did I tell you to remember?"
…and it will call the tool and tell you. The scratchpad is
process-local: it lives only as long as the extension subprocess
(i.e. the duration of one `zot` session).
…and it will call the tool and tell you.
## Storage
Notes persist as JSONL at `<cwd>/.zot/scratchpad-notes.jsonl`. The
file is created on first `/note` and survives zot restarts. Each line
is one note: `{"at":"2026-04-19T13:00:00.000Z","text":"..."}`.
Scope is per-project: switching to a different cwd gives you a
different scratchpad. Cross-project sharing isn't supported in this
example (would just be a matter of changing the path constant).
## Why TypeScript here

View file

@ -17,6 +17,14 @@
import { createInterface } from "node:readline";
import { stderr, stdin, stdout } from "node:process";
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
// ---- protocol types (a tiny subset of internal/extproto) ----
@ -87,14 +95,77 @@ function log(msg: string): void {
}
// ---- the scratchpad state itself ----
//
// Notes persist as JSONL under <cwd>/.zot/scratchpad-notes.jsonl so
// they survive zot restarts and stay scoped to the project. The path
// is resolved once HelloAck arrives (which carries cwd); until then
// notesPath is empty and reads/writes no-op safely.
//
// One note per line, format: {"at":"<iso>","text":"<body>"}
// Append-only on /note; full rewrite on /clear-notes.
//
// Single-writer assumption: only one zot session per cwd at a time.
// Concurrent writes from two zot instances would interleave but not
// corrupt JSONL line boundaries on POSIX (writes ≤ PIPE_BUF are
// atomic). Good enough for a demo.
const notes: Array<{ at: string; text: string }> = [];
type Note = { at: string; text: string };
let notes: Note[] = [];
let notesPath = "";
function setNotesPath(cwd: string): void {
notesPath = join(cwd, ".zot", "scratchpad-notes.jsonl");
loadNotes();
}
function loadNotes(): void {
notes = [];
if (!notesPath || !existsSync(notesPath)) return;
try {
const raw = readFileSync(notesPath, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as Note;
if (typeof parsed?.text === "string") notes.push(parsed);
} catch {
// skip malformed lines silently; the next /note will
// append correctly anyway.
}
}
log(`loaded ${notes.length} note(s) from ${notesPath}`);
} catch (err) {
log(`failed to read ${notesPath}: ${err}`);
}
}
function appendNote(text: string): number {
notes.push({ at: new Date().toISOString(), text });
const note: Note = { at: new Date().toISOString(), text };
notes.push(note);
if (notesPath) {
try {
mkdirSync(dirname(notesPath), { recursive: true });
appendFileSync(notesPath, JSON.stringify(note) + "\n", "utf8");
} catch (err) {
log(`failed to persist note to ${notesPath}: ${err}`);
}
}
return notes.length;
}
function clearNotes(): void {
notes = [];
if (notesPath) {
try {
writeFileSync(notesPath, "", "utf8");
} catch (err) {
log(`failed to clear ${notesPath}: ${err}`);
}
}
}
function renderNotes(): string {
if (notes.length === 0) return "(scratchpad is empty)";
return notes
@ -184,6 +255,7 @@ function handleHelloAck(ack: HelloAck): void {
`connected to zot ${ack.zot_version} ` +
`(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
);
if (ack.cwd) setNotesPath(ack.cwd);
}
function handleCommand(frame: CommandInvoked): void {
@ -221,7 +293,7 @@ function handleCommand(frame: CommandInvoked): void {
}
case "clear-notes": {
notes.length = 0;
clearNotes();
respond(frame.id, {
type: "command_response",
id: frame.id,