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";
20
21// ---- protocol types (a tiny subset of internal/extproto) ----
22
23type Frame = { type: string; id?: string; [k: string]: unknown };
24
25interface CommandInvoked {
26 type: "command_invoked";
27 id: string;
28 name: string;
29 args?: string;
30}
31
32interface ToolCall {
33 type: "tool_call";
34 id: string;
35 name: string;
36 args: Record<string, unknown>;
37}
38
39interface HelloAck {
40 type: "hello_ack";
41 protocol_version: number;
42 zot_version: string;
43 provider: string;
44 model: string;
45 cwd: string;
46}
47
48type Action = "prompt" | "insert" | "display" | "noop";
49
50interface CommandResponse {
51 type: "command_response";
52 id: string;
53 action: Action;
54 prompt?: string;
55 insert?: string;
56 display?: string;
57 error?: string;
58}
59
60interface ToolContent {
61 type: "text" | "image";
62 text?: string;
63 mime_type?: string;
64 data?: string; // base64
65}
66
67interface ToolResult {
68 type: "tool_result";
69 id: string;
70 content: ToolContent[];
71 is_error?: boolean;
72}
73
74// ---- I/O helpers ----
75
76const NAME = "scratchpad";
77const VERSION = "1.0.0";
78
79function send(frame: Frame): void {
80 stdout.write(JSON.stringify(frame) + "\n");
81}
82
83function log(msg: string): void {
84 // stderr is captured by zot to $ZOT_HOME/logs/ext-<name>.log;
85 // safe for debug output. stdout is reserved for the protocol.
86 stderr.write(`[${NAME}] ${msg}\n`);
87}
88
89// ---- the scratchpad state itself ----
90
91const notes: Array<{ at: string; text: string }> = [];
92
93function appendNote(text: string): number {
94 notes.push({ at: new Date().toISOString(), text });
95 return notes.length;
96}
97
98function renderNotes(): string {
99 if (notes.length === 0) return "(scratchpad is empty)";
100 return notes
101 .map((n, i) => `${i + 1}. [${n.at}] ${n.text}`)
102 .join("\n");
103}
104
105// ---- handshake + registration ----
106
107send({
108 type: "hello",
109 name: NAME,
110 version: VERSION,
111 capabilities: ["commands", "tools"],
112});
113
114send({
115 type: "register_command",
116 name: "note",
117 description: "append text to the scratchpad",
118});
119send({
120 type: "register_command",
121 name: "notes",
122 description: "show the scratchpad",
123});
124send({
125 type: "register_command",
126 name: "clear-notes",
127 description: "wipe the scratchpad",
128});
129
130send({
131 type: "register_tool",
132 name: "read_notes",
133 description:
134 "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.",
135 schema: {
136 type: "object",
137 properties: {},
138 },
139});
140
141// Sentinel: tells zot all initial registrations are flushed so the
142// agent's tool registry can be built without racing the read loop.
143send({ type: "ready" });
144
145// ---- frame loop ----
146
147const rl = createInterface({ input: stdin, crlfDelay: Infinity });
148
149rl.on("line", (line: string) => {
150 let frame: Frame;
151 try {
152 frame = JSON.parse(line) as Frame;
153 } catch (err) {
154 log(`malformed frame: ${err}`);
155 return;
156 }
157
158 switch (frame.type) {
159 case "hello_ack":
160 handleHelloAck(frame as unknown as HelloAck);
161 break;
162 case "command_invoked":
163 handleCommand(frame as unknown as CommandInvoked);
164 break;
165 case "tool_call":
166 handleToolCall(frame as unknown as ToolCall);
167 break;
168 case "shutdown":
169 send({ type: "shutdown_ack" });
170 rl.close();
171 break;
172 default:
173 log(`unknown frame: ${frame.type}`);
174 }
175});
176
177rl.on("close", () => {
178 log("read loop closed; exiting");
179 process.exit(0);
180});
181
182function handleHelloAck(ack: HelloAck): void {
183 log(
184 `connected to zot ${ack.zot_version} ` +
185 `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
186 );
187}
188
189function handleCommand(frame: CommandInvoked): void {
190 const args = (frame.args ?? "").trim();
191
192 switch (frame.name) {
193 case "note": {
194 if (args === "") {
195 respond(frame.id, {
196 type: "command_response",
197 id: frame.id,
198 action: "noop",
199 error: "note: usage is /note <text>",
200 });
201 return;
202 }
203 const n = appendNote(args);
204 respond(frame.id, {
205 type: "command_response",
206 id: frame.id,
207 action: "display",
208 display: `noted (#${n}): ${args}`,
209 });
210 return;
211 }
212
213 case "notes": {
214 respond(frame.id, {
215 type: "command_response",
216 id: frame.id,
217 action: "display",
218 display: renderNotes(),
219 });
220 return;
221 }
222
223 case "clear-notes": {
224 notes.length = 0;
225 respond(frame.id, {
226 type: "command_response",
227 id: frame.id,
228 action: "display",
229 display: "scratchpad cleared",
230 });
231 return;
232 }
233
234 default:
235 respond(frame.id, {
236 type: "command_response",
237 id: frame.id,
238 action: "noop",
239 error: `unknown command /${frame.name}`,
240 });
241 }
242}
243
244function handleToolCall(frame: ToolCall): void {
245 switch (frame.name) {
246 case "read_notes": {
247 sendToolResult(frame.id, [
248 { type: "text", text: renderNotes() },
249 ]);
250 return;
251 }
252 default:
253 sendToolResult(
254 frame.id,
255 [{ type: "text", text: `unknown tool ${frame.name}` }],
256 true,
257 );
258 }
259}
260
261// ---- send wrappers (typed so misuse is a compile error) ----
262
263function respond(_id: string, response: CommandResponse): void {
264 send(response);
265}
266
267function sendToolResult(
268 id: string,
269 content: ToolContent[],
270 is_error = false,
271): void {
272 const result: ToolResult = {
273 type: "tool_result",
274 id,
275 content,
276 is_error: is_error || undefined,
277 };
278 send(result);
279}