v0.2.13 84fd98e

index.ts RawHistoryFolder

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}

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