1// scratchpad — a real .ts zut 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 zut_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 zut to $ZUT_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>/.zut/scratchpad-notes.jsonl so
100// they survive zut 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 zut session per cwd at a time.
108// Concurrent writes from two zut 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, ".zut", "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 protocol_version: 2,
181 name: NAME,
182 version: VERSION,
183 capabilities: ["commands", "tools"],
184});
185
186send({
187 type: "register_command",
188 name: "note",
189 description: "append text to the scratchpad",
190});
191send({
192 type: "register_command",
193 name: "notes",
194 description: "show the scratchpad",
195});
196send({
197 type: "register_command",
198 name: "clear-notes",
199 description: "wipe the scratchpad",
200});
201
202send({
203 type: "register_tool",
204 name: "read_notes",
205 description:
206 "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.",
207 schema: {
208 type: "object",
209 properties: {},
210 },
211});
212
213// Sentinel: tells zut all initial registrations are flushed so the
214// agent's tool registry can be built without racing the read loop.
215send({ type: "ready" });
216
217// ---- frame loop ----
218
219const rl = createInterface({ input: stdin, crlfDelay: Infinity });
220
221rl.on("line", (line: string) => {
222 let frame: Frame;
223 try {
224 frame = JSON.parse(line) as Frame;
225 } catch (err) {
226 log(`malformed frame: ${err}`);
227 return;
228 }
229
230 switch (frame.type) {
231 case "hello_ack":
232 handleHelloAck(frame as unknown as HelloAck);
233 break;
234 case "command_invoked":
235 handleCommand(frame as unknown as CommandInvoked);
236 break;
237 case "tool_call":
238 handleToolCall(frame as unknown as ToolCall);
239 break;
240 case "shutdown":
241 send({ type: "shutdown_ack" });
242 rl.close();
243 break;
244 default:
245 log(`unknown frame: ${frame.type}`);
246 }
247});
248
249rl.on("close", () => {
250 log("read loop closed; exiting");
251 process.exit(0);
252});
253
254function handleHelloAck(ack: HelloAck): void {
255 if (ack.protocol_version !== 2) {
256 log(`unsupported protocol version: ${ack.protocol_version}`);
257 process.exitCode = 1;
258 rl.close();
259 return;
260 }
261 log(
262 `connected to zut ${ack.zut_version} ` +
263 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
264 );
265 if (ack.cwd) setNotesPath(ack.cwd);
266}
267
268function handleCommand(frame: CommandInvoked): void {
269 const args = (frame.args ?? "").trim();
270
271 switch (frame.name) {
272 case "note": {
273 if (args === "") {
274 respond(frame.id, {
275 type: "command_response",
276 id: frame.id,
277 action: "noop",
278 error: "note: usage is /note <text>",
279 });
280 return;
281 }
282 const n = appendNote(args);
283 respond(frame.id, {
284 type: "command_response",
285 id: frame.id,
286 action: "display",
287 display: `noted (#${n}): ${args}`,
288 });
289 return;
290 }
291
292 case "notes": {
293 respond(frame.id, {
294 type: "command_response",
295 id: frame.id,
296 action: "display",
297 display: renderNotes(),
298 });
299 return;
300 }
301
302 case "clear-notes": {
303 clearNotes();
304 respond(frame.id, {
305 type: "command_response",
306 id: frame.id,
307 action: "display",
308 display: "scratchpad cleared",
309 });
310 return;
311 }
312
313 default:
314 respond(frame.id, {
315 type: "command_response",
316 id: frame.id,
317 action: "noop",
318 error: `unknown command /${frame.name}`,
319 });
320 }
321}
322
323function handleToolCall(frame: ToolCall): void {
324 switch (frame.name) {
325 case "read_notes": {
326 sendToolResult(frame.id, [
327 { type: "text", text: renderNotes() },
328 ]);
329 return;
330 }
331 default:
332 sendToolResult(
333 frame.id,
334 [{ type: "text", text: `unknown tool ${frame.name}` }],
335 true,
336 );
337 }
338}
339
340// ---- send wrappers (typed so misuse is a compile error) ----
341
342function respond(_id: string, response: CommandResponse): void {
343 send(response);
344}
345
346function sendToolResult(
347 id: string,
348 content: ToolContent[],
349 is_error = false,
350): void {
351 const result: ToolResult = {
352 type: "tool_result",
353 id,
354 content,
355 is_error: is_error || undefined,
356 };
357 send(result);
358}