1// scratchpad — a real .ts zot extension with no SDK and no build step.
2//
3// Runs via `npx --yes tsx index.ts` (declared in extension.json).
4// First invocation downloads tsx into npm's cache; every subsequent
5// run is instant. Pure node + tsx, no other dependencies.
6//
7// What it does:
8//
9// /note <text> — append <text> to a process-local scratchpad
10// /notes — show the scratchpad inline (no model call)
11// /clear-notes — wipe the scratchpad
12//
13// tool: read_notes() — the model can read the scratchpad on demand
14//
15// Use this as a template for any TypeScript extension that needs to
16// register slash commands, expose tools to the model, or both.
17
18import { createInterface } from "node:readline";
19import { stderr, stdin, stdout } from "node:process";
20import {
21 appendFileSync,
22 existsSync,
23 mkdirSync,
24 readFileSync,
25 writeFileSync,
26} from "node:fs";
27import { dirname, join } from "node:path";
28
29// ---- protocol types (a tiny subset of internal/extproto) ----
30
31type Frame = { type: string; id?: string; [k: string]: unknown };
32
33interface CommandInvoked {
34 type: "command_invoked";
35 id: string;
36 name: string;
37 args?: string;
38}
39
40interface ToolCall {
41 type: "tool_call";
42 id: string;
43 name: string;
44 args: Record<string, unknown>;
45}
46
47interface HelloAck {
48 type: "hello_ack";
49 protocol_version: number;
50 zot_version: string;
51 provider: string;
52 model: string;
53 cwd: string;
54}
55
56type Action = "prompt" | "insert" | "display" | "noop";
57
58interface CommandResponse {
59 type: "command_response";
60 id: string;
61 action: Action;
62 prompt?: string;
63 insert?: string;
64 display?: string;
65 error?: string;
66}
67
68interface ToolContent {
69 type: "text" | "image";
70 text?: string;
71 mime_type?: string;
72 data?: string; // base64
73}
74
75interface ToolResult {
76 type: "tool_result";
77 id: string;
78 content: ToolContent[];
79 is_error?: boolean;
80}
81
82// ---- I/O helpers ----
83
84const NAME = "scratchpad";
85const VERSION = "1.0.0";
86
87function send(frame: Frame): void {
88 stdout.write(JSON.stringify(frame) + "\n");
89}
90
91function log(msg: string): void {
92 // stderr is captured by zot to $ZOT_HOME/logs/ext-<name>.log;
93 // safe for debug output. stdout is reserved for the protocol.
94 stderr.write(`[${NAME}] ${msg}\n`);
95}
96
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
112type Note = { at: string; text: string };
113
114let notes: Note[] = [];
115let notesPath = "";
116
117function setNotesPath(cwd: string): void {
118 notesPath = join(cwd, ".zot", "scratchpad-notes.jsonl");
119 loadNotes();
120}
121
122function 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}
143
144function appendNote(text: string): number {
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 }
155 return notes.length;
156}
157
158function 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
169function renderNotes(): string {
170 if (notes.length === 0) return "(scratchpad is empty)";
171 return notes
172 .map((n, i) => `${i + 1}. [${n.at}] ${n.text}`)
173 .join("\n");
174}
175
176// ---- handshake + registration ----
177
178send({
179 type: "hello",
180 name: NAME,
181 version: VERSION,
182 capabilities: ["commands", "tools"],
183});
184
185send({
186 type: "register_command",
187 name: "note",
188 description: "append text to the scratchpad",
189});
190send({
191 type: "register_command",
192 name: "notes",
193 description: "show the scratchpad",
194});
195send({
196 type: "register_command",
197 name: "clear-notes",
198 description: "wipe the scratchpad",
199});
200
201send({
202 type: "register_tool",
203 name: "read_notes",
204 description:
205 "Read the scratchpad. Use this when the user asks about notes or context they have stored, or refers to something from earlier they wanted you to remember.",
206 schema: {
207 type: "object",
208 properties: {},
209 },
210});
211
212// Sentinel: tells zot all initial registrations are flushed so the
213// agent's tool registry can be built without racing the read loop.
214send({ type: "ready" });
215
216// ---- frame loop ----
217
218const rl = createInterface({ input: stdin, crlfDelay: Infinity });
219
220rl.on("line", (line: string) => {
221 let frame: Frame;
222 try {
223 frame = JSON.parse(line) as Frame;
224 } catch (err) {
225 log(`malformed frame: ${err}`);
226 return;
227 }
228
229 switch (frame.type) {
230 case "hello_ack":
231 handleHelloAck(frame as unknown as HelloAck);
232 break;
233 case "command_invoked":
234 handleCommand(frame as unknown as CommandInvoked);
235 break;
236 case "tool_call":
237 handleToolCall(frame as unknown as ToolCall);
238 break;
239 case "shutdown":
240 send({ type: "shutdown_ack" });
241 rl.close();
242 break;
243 default:
244 log(`unknown frame: ${frame.type}`);
245 }
246});
247
248rl.on("close", () => {
249 log("read loop closed; exiting");
250 process.exit(0);
251});
252
253function handleHelloAck(ack: HelloAck): void {
254 log(
255 `connected to zot ${ack.zot_version} ` +
256 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
257 );
258 if (ack.cwd) setNotesPath(ack.cwd);
259}
260
261function handleCommand(frame: CommandInvoked): void {
262 const args = (frame.args ?? "").trim();
263
264 switch (frame.name) {
265 case "note": {
266 if (args === "") {
267 respond(frame.id, {
268 type: "command_response",
269 id: frame.id,
270 action: "noop",
271 error: "note: usage is /note <text>",
272 });
273 return;
274 }
275 const n = appendNote(args);
276 respond(frame.id, {
277 type: "command_response",
278 id: frame.id,
279 action: "display",
280 display: `noted (#${n}): ${args}`,
281 });
282 return;
283 }
284
285 case "notes": {
286 respond(frame.id, {
287 type: "command_response",
288 id: frame.id,
289 action: "display",
290 display: renderNotes(),
291 });
292 return;
293 }
294
295 case "clear-notes": {
296 clearNotes();
297 respond(frame.id, {
298 type: "command_response",
299 id: frame.id,
300 action: "display",
301 display: "scratchpad cleared",
302 });
303 return;
304 }
305
306 default:
307 respond(frame.id, {
308 type: "command_response",
309 id: frame.id,
310 action: "noop",
311 error: `unknown command /${frame.name}`,
312 });
313 }
314}
315
316function handleToolCall(frame: ToolCall): void {
317 switch (frame.name) {
318 case "read_notes": {
319 sendToolResult(frame.id, [
320 { type: "text", text: renderNotes() },
321 ]);
322 return;
323 }
324 default:
325 sendToolResult(
326 frame.id,
327 [{ type: "text", text: `unknown tool ${frame.name}` }],
328 true,
329 );
330 }
331}
332
333// ---- send wrappers (typed so misuse is a compile error) ----
334
335function respond(_id: string, response: CommandResponse): void {
336 send(response);
337}
338
339function sendToolResult(
340 id: string,
341 content: ToolContent[],
342 is_error = false,
343): void {
344 const result: ToolResult = {
345 type: "tool_result",
346 id,
347 content,
348 is_error: is_error || undefined,
349 };
350 send(result);
351}