@bojackduy/opencode-learn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,1181 @@
1
+ // @bun
2
+ var __require = import.meta.require;
3
+
4
+ // plugins/learn.ts
5
+ import { tool } from "@opencode-ai/plugin";
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { tmpdir } from "os";
9
+ import { spawn } from "child_process";
10
+ var EXTRA_PATH = ["/opt/local/bin", "/usr/local/bin", "/opt/homebrew/bin"];
11
+ var STAGING_ROOT = path.join(tmpdir(), "opencode-visual-tools");
12
+ var FILES_DIRNAME = "viz";
13
+ function findChrome() {
14
+ const cands = [
15
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
16
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
17
+ ];
18
+ for (const c of cands)
19
+ if (fs.existsSync(c))
20
+ return c;
21
+ return;
22
+ }
23
+ function run(cmd, args, opts) {
24
+ return new Promise((resolveRun) => {
25
+ const augmentedPath = [...EXTRA_PATH, process.env.PATH ?? ""].join(":");
26
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env ?? {}, PATH: augmentedPath } });
27
+ let stdout = "";
28
+ let stderr = "";
29
+ let timedOut = false;
30
+ const timer = setTimeout(() => {
31
+ timedOut = true;
32
+ child.kill("SIGKILL");
33
+ }, opts.timeoutMs);
34
+ child.stdout.on("data", (d) => stdout += d.toString());
35
+ child.stderr.on("data", (d) => stderr += d.toString());
36
+ child.on("error", (err) => {
37
+ clearTimeout(timer);
38
+ resolveRun({ code: null, stdout, stderr: stderr + String(err), timedOut });
39
+ });
40
+ child.on("close", (code) => {
41
+ clearTimeout(timer);
42
+ resolveRun({ code, stdout, stderr, timedOut });
43
+ });
44
+ });
45
+ }
46
+ function sessionDir(group) {
47
+ return path.join(STAGING_ROOT, `${group}-${process.pid}`);
48
+ }
49
+ function writeBody(group, bodyFileName, source) {
50
+ const workDir = sessionDir(group);
51
+ fs.mkdirSync(workDir, { recursive: true });
52
+ const bodyPath = path.join(workDir, bodyFileName);
53
+ fs.writeFileSync(bodyPath, source, "utf8");
54
+ return { workDir, bodyPath };
55
+ }
56
+ function applyEdit(current, oldText, newText) {
57
+ if (oldText === "")
58
+ throw new Error("`old_text` must be non-empty.");
59
+ if (oldText === newText)
60
+ throw new Error("`old_text` and `new_text` are identical.");
61
+ const first = current.indexOf(oldText);
62
+ if (first === -1)
63
+ throw new Error("`old_text` not found in the current source \u2014 match it exactly.");
64
+ const second = current.indexOf(oldText, first + 1);
65
+ if (second !== -1)
66
+ throw new Error("`old_text` appears multiple times \u2014 add surrounding context to make it unique.");
67
+ return { updated: current.slice(0, first) + newText + current.slice(first + oldText.length), index: first };
68
+ }
69
+ function snippetAround(content, index, contextLines = 3) {
70
+ const before = content.slice(0, index);
71
+ const hitLine = before.split(`
72
+ `).length - 1;
73
+ const lines = content.split(`
74
+ `);
75
+ const start = Math.max(0, hitLine - contextLines);
76
+ const end = Math.min(lines.length - 1, hitLine + contextLines);
77
+ const width = String(end + 1).length;
78
+ const out = [];
79
+ for (let i = start;i <= end; i++)
80
+ out.push(`${String(i + 1).padStart(width)} ${lines[i]}`);
81
+ return out.join(`
82
+ `);
83
+ }
84
+ function publishPng(pngPath, slug, directory) {
85
+ const filesDir = path.join(directory, FILES_DIRNAME);
86
+ fs.mkdirSync(filesDir, { recursive: true });
87
+ const clean = slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "viz";
88
+ const filename = `viz-${clean}-${Date.now()}.png`;
89
+ const dest = path.join(filesDir, filename);
90
+ fs.copyFileSync(pngPath, dest);
91
+ return { filename, path: dest };
92
+ }
93
+ function normalizeQuizOptions(options) {
94
+ const seen = new Set;
95
+ return (options || []).map((o) => ({
96
+ label: o.label.trim(),
97
+ value: o.value?.trim() || o.label.trim(),
98
+ description: o.description?.trim() || undefined
99
+ })).filter((o) => {
100
+ if (o.label.length === 0)
101
+ return false;
102
+ if (seen.has(o.value))
103
+ throw new Error(`duplicate option value "${o.value}"`);
104
+ seen.add(o.value);
105
+ return true;
106
+ });
107
+ }
108
+ function shuffleOptions(options) {
109
+ const out = [...options];
110
+ for (let i = out.length - 1;i > 0; i--) {
111
+ const j = Math.floor(Math.random() * (i + 1));
112
+ const tmp = out[i];
113
+ out[i] = out[j];
114
+ out[j] = tmp;
115
+ }
116
+ return out;
117
+ }
118
+ function coerceCorrectAnswer(correctAnswer) {
119
+ if (Array.isArray(correctAnswer))
120
+ return correctAnswer;
121
+ const trimmed = correctAnswer.trim();
122
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
123
+ try {
124
+ const parsed = JSON.parse(trimmed);
125
+ if (Array.isArray(parsed))
126
+ return parsed.map((v) => String(v));
127
+ } catch {}
128
+ }
129
+ return [correctAnswer];
130
+ }
131
+ function resolveCorrect(correctAnswer, options) {
132
+ if (correctAnswer === undefined)
133
+ return { indices: [], error: "correctAnswer is required" };
134
+ const arr = coerceCorrectAnswer(correctAnswer);
135
+ if (arr.length === 0)
136
+ return { indices: [], error: "correctAnswer is required" };
137
+ const byValue = new Map(options.map((o, i) => [o.value, i + 1]));
138
+ const indices = [];
139
+ for (const raw of arr) {
140
+ const v = typeof raw === "string" ? raw.trim() : raw;
141
+ const idx = byValue.get(v);
142
+ if (idx === undefined) {
143
+ const known = options.map((o) => `"${o.value}"`).join(", ");
144
+ return { indices: [], error: `correctAnswer "${v}" does not match any option value (${known})` };
145
+ }
146
+ indices.push(idx);
147
+ }
148
+ return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) };
149
+ }
150
+ var mdLogFile = null;
151
+ var mdLogWriteLock = Promise.resolve();
152
+ function withMdLock(fn) {
153
+ const prev = mdLogWriteLock;
154
+ let release;
155
+ mdLogWriteLock = new Promise((r) => {
156
+ release = r;
157
+ });
158
+ return prev.then(fn).finally(() => release());
159
+ }
160
+ function appendToMdLog(text) {
161
+ if (!mdLogFile)
162
+ return;
163
+ try {
164
+ let current = "";
165
+ if (fs.existsSync(mdLogFile))
166
+ current = fs.readFileSync(mdLogFile, "utf-8");
167
+ const prefix = current.trim().length > 0 ? `
168
+
169
+ ` : "";
170
+ fs.writeFileSync(mdLogFile, current + prefix + text + `
171
+ `, "utf-8");
172
+ } catch {}
173
+ }
174
+ function callout(type, title, bodyLines) {
175
+ const lines = [`> [!${type}] ${title}`];
176
+ for (const line of bodyLines)
177
+ lines.push(line.length === 0 ? ">" : `> ${line}`);
178
+ return lines.join(`
179
+ `);
180
+ }
181
+ function stripSkillBlocks(text) {
182
+ return text.replace(/<skill\b([^>]*)>[\s\S]*?<\/skill>/g, (_m, attrs) => {
183
+ const name = /name="([^"]+)"/.exec(attrs)?.[1];
184
+ return `> [!note] SKILL loaded: ${name ?? "(unknown)"}`;
185
+ });
186
+ }
187
+ function userBlock(text) {
188
+ return `> [!quote] YOU
189
+
190
+ ${text}`;
191
+ }
192
+ function assistantBlock(text) {
193
+ return `> [!abstract] OPENCODE
194
+
195
+ ${text}`;
196
+ }
197
+ function optionsList(options) {
198
+ return options.map((o, i) => `${i + 1}. ${o.label}`);
199
+ }
200
+ function questionCallout(label, question, context, options) {
201
+ const body = [];
202
+ for (const line of question.split(`
203
+ `))
204
+ body.push(line);
205
+ if (context) {
206
+ body.push("");
207
+ for (const line of context.split(`
208
+ `))
209
+ body.push(line);
210
+ }
211
+ if (options.length > 0) {
212
+ body.push("");
213
+ body.push(...optionsList(options));
214
+ }
215
+ return callout("question", label, body);
216
+ }
217
+ function answerCalloutQuiz(details) {
218
+ const status = details?.status;
219
+ if (status === "cancelled")
220
+ return callout("warning", "Quiz \u2014 cancelled", ["(user skipped)"]);
221
+ if (status === "unavailable")
222
+ return callout("warning", "Quiz \u2014 unavailable", [details?.message || ""]);
223
+ const dontKnow = details?.dontKnow === true;
224
+ const correct = details?.correct === true;
225
+ const type = dontKnow ? "question" : correct ? "success" : "failure";
226
+ const title = dontKnow ? "Quiz \u2014 I don't know" : correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717";
227
+ const body = [];
228
+ if (dontKnow)
229
+ body.push("Your answer: I don't know");
230
+ else {
231
+ const answers = details?.answers || [];
232
+ const sel = answers.map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
233
+ body.push(`Your answer: ${sel}`);
234
+ }
235
+ const correctIndices = details?.correctIndices || [];
236
+ if (correctIndices.length)
237
+ body.push(`Correct answer: ${correctIndices.map((i) => `${i}`).join(", ")}`);
238
+ if (details?.note) {
239
+ body.push("");
240
+ const noteLines = String(details.note).split(`
241
+ `);
242
+ body.push(`Note: ${noteLines[0]}`);
243
+ for (let i = 1;i < noteLines.length; i++)
244
+ body.push(noteLines[i]);
245
+ }
246
+ if (details?.explanation) {
247
+ body.push("");
248
+ for (const line of String(details.explanation).split(`
249
+ `))
250
+ body.push(line);
251
+ }
252
+ return callout(type, title, body);
253
+ }
254
+ function answerCalloutAsk(details) {
255
+ const status = details?.status;
256
+ if (status === "cancelled")
257
+ return callout("warning", "Question \u2014 cancelled", ["(user skipped)"]);
258
+ if (status === "unavailable")
259
+ return callout("warning", "Question \u2014 unavailable", [details?.message || ""]);
260
+ const answers = details?.answers || [];
261
+ const body = answers.map((a) => {
262
+ if (a.type === "other")
263
+ return `Other: ${a.label}`;
264
+ if (a.type === "text")
265
+ return a.label;
266
+ return `${a.index}. ${a.label}`;
267
+ });
268
+ if (body.length === 0)
269
+ body.push("(no answer)");
270
+ return callout("example", "Answer", body);
271
+ }
272
+ async function backfillMdLog(client, sessionID, directory) {
273
+ if (!mdLogFile || !sessionID)
274
+ return 0;
275
+ try {
276
+ const res = await client.session.messages({ path: { id: sessionID }, query: { directory } });
277
+ const data = res?.data ?? res;
278
+ const entries = Array.isArray(data) ? data : [];
279
+ if (!entries.length)
280
+ return 0;
281
+ const blocks = [];
282
+ for (const entry of entries) {
283
+ const info = entry.info;
284
+ const parts = entry.parts ?? [];
285
+ if (!info || !info.role)
286
+ continue;
287
+ if (info.role === "user") {
288
+ const text = parts.filter((p) => p.type === "text").map((p) => p.text).join(`
289
+ `).trim();
290
+ const fallback = typeof info.content === "string" ? info.content : "";
291
+ const raw = text || fallback;
292
+ const trimmed = stripSkillBlocks(raw.trim());
293
+ if (!trimmed)
294
+ continue;
295
+ if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(trimmed) || trimmed.startsWith("[quiz answered]") || trimmed.startsWith("[quiz_batch answered]") || trimmed.startsWith("[question answered]"))
296
+ continue;
297
+ blocks.push(userBlock(trimmed));
298
+ } else if (info.role === "assistant") {
299
+ const textParts = parts.filter((p) => p.type === "text" && !p.synthetic && !p.ignored).map((p) => (p.text || "").trim()).filter(Boolean);
300
+ if (textParts.length)
301
+ blocks.push(assistantBlock(textParts.join(`
302
+
303
+ `)));
304
+ for (const p of parts) {
305
+ if (p.type !== "tool")
306
+ continue;
307
+ const toolName = p.tool;
308
+ if (toolName !== "quiz" && toolName !== "question" && toolName !== "ask_user_question" && toolName !== "quiz_batch")
309
+ continue;
310
+ const st = p.state ?? {};
311
+ const input = st.input ?? {};
312
+ const output = st.output ?? "";
313
+ const meta = st.metadata ?? {};
314
+ if (toolName === "quiz_batch") {
315
+ const quizzes = input.quizzes ?? [];
316
+ if (st.status === "pending" || st.status === "running") {
317
+ for (let i = 0;i < quizzes.length; i++) {
318
+ const qq = quizzes[i];
319
+ const label = `Quiz ${i + 1}/${quizzes.length}`;
320
+ blocks.push(questionCallout(label, qq.question, qq.details?.trim() || undefined, qq.options ?? []));
321
+ }
322
+ } else if (st.status === "completed") {
323
+ for (let i = 0;i < quizzes.length; i++) {
324
+ const qq = quizzes[i];
325
+ const label = `Quiz ${i + 1}/${quizzes.length}`;
326
+ blocks.push(questionCallout(label, qq.question, qq.details?.trim() || undefined, qq.options ?? []));
327
+ const results = meta.results ?? [];
328
+ const x = results[i] || {};
329
+ if (x && (x.answers || x.correct !== undefined)) {
330
+ const details = { status: "completed", answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note };
331
+ blocks.push(answerCalloutQuiz(details));
332
+ }
333
+ }
334
+ }
335
+ continue;
336
+ }
337
+ if (st.status === "pending" || st.status === "running") {
338
+ if (input.question) {
339
+ const opts = Array.isArray(input.options) ? input.options : [];
340
+ const label = toolName === "quiz" ? "Quiz" : "Question";
341
+ blocks.push(questionCallout(label, input.question, input.details?.trim() || undefined, opts));
342
+ }
343
+ } else if (st.status === "completed") {
344
+ if (input.question) {
345
+ const opts = Array.isArray(input.options) ? input.options : [];
346
+ const label = toolName === "quiz" ? "Quiz" : "Question";
347
+ if (!blocks.length || !blocks[blocks.length - 1].includes(input.question.slice(0, 20))) {
348
+ blocks.push(questionCallout(label, input.question, input.details?.trim() || undefined, opts));
349
+ }
350
+ }
351
+ if (toolName === "quiz") {
352
+ const details = { status: "completed", answers: meta.answers ?? [], correct: meta.correct, correctIndices: meta.correctIndices ?? [], explanation: meta.explanation ?? "", dontKnow: meta.dontKnow ?? false, note: meta.note };
353
+ blocks.push(answerCalloutQuiz(details));
354
+ } else {
355
+ const details = { answers: meta.answers ?? [], status: "completed" };
356
+ blocks.push(answerCalloutAsk(details));
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+ if (blocks.length) {
363
+ let current = "";
364
+ try {
365
+ if (fs.existsSync(mdLogFile))
366
+ current = fs.readFileSync(mdLogFile, "utf-8");
367
+ } catch {}
368
+ if (current.trim().length === 0) {
369
+ fs.writeFileSync(mdLogFile, blocks.join(`
370
+
371
+ `) + `
372
+ `, "utf-8");
373
+ } else {
374
+ const prefix = current.trim().length > 0 ? `
375
+
376
+ ` : "";
377
+ fs.writeFileSync(mdLogFile, current + prefix + blocks.join(`
378
+
379
+ `) + `
380
+ `, "utf-8");
381
+ }
382
+ }
383
+ return blocks.length;
384
+ } catch (e) {
385
+ slog("backfill failed", String(e));
386
+ return 0;
387
+ }
388
+ }
389
+ var PENDING_DIRNAME = ".opencode/learn-pending";
390
+ var SERVER_LOG = path.join(tmpdir(), "learn-server.log");
391
+ function slog(...a) {
392
+ try {
393
+ const line = `[${new Date().toISOString()}] ${a.map((x) => typeof x === "string" ? x : JSON.stringify(x)).join(" ")}
394
+ `;
395
+ fs.appendFileSync(SERVER_LOG, line);
396
+ } catch {}
397
+ }
398
+ function pendingDir(directory) {
399
+ return path.join(directory, PENDING_DIRNAME);
400
+ }
401
+ function isTuiAlive(directory) {
402
+ try {
403
+ const p = path.join(pendingDir(directory), ".tui-alive");
404
+ const s = fs.statSync(p);
405
+ return Date.now() - s.mtimeMs < 8000;
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
410
+ function randomId() {
411
+ try {
412
+ const c = globalThis.crypto;
413
+ if (c?.randomUUID)
414
+ return c.randomUUID();
415
+ } catch {}
416
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
417
+ }
418
+ var activeWatchers = new Map;
419
+ function watchAndInject(client, directory, id, sessionID, buildText) {
420
+ slog("watchAndInject start", id, sessionID);
421
+ if (!sessionID) {
422
+ slog("watchAndInject no sessionID", id);
423
+ return;
424
+ }
425
+ const dir = pendingDir(directory);
426
+ const respPath = path.join(dir, `response-${id}.json`);
427
+ const fire = async () => {
428
+ let data;
429
+ try {
430
+ data = JSON.parse(fs.readFileSync(respPath, "utf8"));
431
+ } catch {
432
+ return;
433
+ }
434
+ try {
435
+ fs.unlinkSync(respPath);
436
+ } catch {}
437
+ const w = activeWatchers.get(id);
438
+ if (w) {
439
+ try {
440
+ w.close();
441
+ } catch {}
442
+ activeWatchers.delete(id);
443
+ }
444
+ slog("watchAndInject fire", id, JSON.stringify(data).slice(0, 400));
445
+ const effectiveSessionID = data?.sessionID || sessionID;
446
+ const text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result);
447
+ const sdkCall = async (method, ...argsList) => {
448
+ let firstErr;
449
+ for (const args of argsList) {
450
+ if (args === undefined)
451
+ continue;
452
+ try {
453
+ const res = await method(args);
454
+ const err = res && typeof res === "object" ? res.error : undefined;
455
+ if (!err)
456
+ return res;
457
+ firstErr = firstErr || err;
458
+ } catch (e) {
459
+ firstErr = firstErr || e;
460
+ }
461
+ }
462
+ throw firstErr || new Error("SDK call failed");
463
+ };
464
+ const parts = [{ type: "text", text }];
465
+ const shapes = [
466
+ { path: { id: effectiveSessionID }, body: { parts } },
467
+ { path: { sessionID: effectiveSessionID }, body: { parts } },
468
+ { sessionID: effectiveSessionID, parts }
469
+ ];
470
+ slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0, 300));
471
+ let ok = false;
472
+ if (client?.session?.promptAsync) {
473
+ try {
474
+ await sdkCall(client.session.promptAsync.bind(client.session), ...shapes);
475
+ ok = true;
476
+ } catch {}
477
+ }
478
+ if (!ok && client?.session?.prompt) {
479
+ try {
480
+ await sdkCall(client.session.prompt.bind(client.session), ...shapes);
481
+ ok = true;
482
+ } catch {}
483
+ }
484
+ try {
485
+ await client.app.log({ body: { service: "learn", level: ok ? "info" : "error", message: ok ? `injected into ${effectiveSessionID}` : `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
486
+ } catch {}
487
+ };
488
+ if (fs.existsSync(respPath)) {
489
+ slog("watchAndInject fast-path", id);
490
+ fire();
491
+ return;
492
+ }
493
+ try {
494
+ const w = fs.watch(dir, (_e, filename) => {
495
+ if (filename === `response-${id}.json` && fs.existsSync(respPath))
496
+ fire();
497
+ });
498
+ w.on("error", () => {});
499
+ activeWatchers.set(id, w);
500
+ } catch {}
501
+ }
502
+ var server = async ({ client, directory }) => {
503
+ const markerPath = path.join(directory, ".opencode", "learn-md-log.json");
504
+ try {
505
+ if (fs.existsSync(markerPath)) {
506
+ const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"));
507
+ if (data?.file && fs.existsSync(data.file))
508
+ mdLogFile = data.file;
509
+ }
510
+ } catch {}
511
+ let mermaidSession = null;
512
+ let svgSession = null;
513
+ const loggedTextPartIds = new Set;
514
+ const loggedToolCallIds = new Set;
515
+ const messageIdToRole = new Map;
516
+ try {
517
+ const dir = pendingDir(directory);
518
+ if (fs.existsSync(dir)) {
519
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
520
+ try {
521
+ const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
522
+ if (j?.id && j?.sessionID) {
523
+ watchAndInject(client, directory, j.id, j.sessionID, (r) => {
524
+ if (j.type === "quiz") {
525
+ const cs = new Set(j.correctIndices || []);
526
+ const si = (r?.answers || []).map((a) => a.index);
527
+ const sel = (r?.answers || []).map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
528
+ const cstr = (j.correctIndices || []).map((i) => `${i}. ${j.options[i - 1]?.label}`).join(", ");
529
+ const dk = !!r?.dontKnow;
530
+ const ok = !dk && si.length === (j.correctIndices || []).length && si.every((i) => cs.has(i));
531
+ const note = r?.note ? `
532
+ Note: ${r.note}` : "";
533
+ if (mdLogFile) {
534
+ const details = { status: "completed", answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note };
535
+ withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
536
+ }
537
+ return dk ? `[quiz answered] "${j.question}" -> I don't know.
538
+ Correct: ${cstr}
539
+ Explanation: ${j.explanation}${note}` : `[quiz answered] "${j.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.
540
+ Correct: ${cstr}
541
+ Explanation: ${j.explanation}${note}`;
542
+ } else if (j.type === "quiz_batch") {
543
+ const results = r?.results || [];
544
+ if (mdLogFile) {
545
+ for (let i = 0;i < (j.quizzes || []).length; i++) {
546
+ const qq = j.quizzes[i];
547
+ const x = results[i] || {};
548
+ const details = { status: "completed", answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note };
549
+ withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
550
+ }
551
+ }
552
+ const lines = (j.quizzes || []).map((qq, i) => {
553
+ const x = results[i] || {};
554
+ const cs = (qq.correctIndices || []).map((idx) => `${idx}. ${qq.options[idx - 1]?.label}`).join(", ");
555
+ const sel = x?.dontKnow ? "I don't know" : (x?.answers || []).map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
556
+ const ok = x?.correct ? "CORRECT" : x?.dontKnow ? "GAP" : "INCORRECT";
557
+ return `Q${i + 1}: "${qq.question}" -> ${sel} = ${ok}. Correct: ${cs}`;
558
+ }).join(`
559
+ `);
560
+ return `[quiz_batch answered] ${(j.quizzes || []).length} quizzes
561
+ ` + lines;
562
+ } else {
563
+ const arr = Array.isArray(r) ? r : r?.answers || [];
564
+ const txt = arr.map((a) => a.type === "other" ? `Other: ${a.label}` : a.index ? `${a.index}. ${a.label}` : a.label).join(", ") || "(no answer)";
565
+ return `[question answered] "${j.question}" -> ${txt}`;
566
+ }
567
+ });
568
+ }
569
+ } catch {}
570
+ }
571
+ }
572
+ } catch {}
573
+ return {
574
+ config: async (output) => {
575
+ const agents = output.agent ?? {};
576
+ let mutated = false;
577
+ for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
578
+ if (!agents[name]) {
579
+ agents[name] = { mode: "subagent", description: `${name} subagent (from learn plugin)`, permission: { "*": "allow" } };
580
+ mutated = true;
581
+ }
582
+ }
583
+ if (mutated)
584
+ output.agent = agents;
585
+ await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } });
586
+ },
587
+ "chat.message": async (_input, output) => {
588
+ if (!mdLogFile)
589
+ return;
590
+ try {
591
+ const msg = output.message;
592
+ const parts = output.parts ?? [];
593
+ let text = "";
594
+ if (Array.isArray(parts) && parts.length)
595
+ text = parts.filter((p) => p.type === "text").map((p) => p.text).join(`
596
+ `).trim();
597
+ if (!text && typeof msg?.content === "string")
598
+ text = msg.content;
599
+ else if (!text && Array.isArray(msg?.content))
600
+ text = msg.content.filter((c) => c.type === "text").map((c) => c.text).join(`
601
+ `);
602
+ text = stripSkillBlocks((text || "").trim());
603
+ if (!text)
604
+ return;
605
+ if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(text) || text.startsWith("[quiz answered]") || text.startsWith("[quiz_batch answered]") || text.startsWith("[question answered]"))
606
+ return;
607
+ const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`;
608
+ if (loggedTextPartIds.has(mid))
609
+ return;
610
+ loggedTextPartIds.add(mid);
611
+ await withMdLock(() => appendToMdLog(userBlock(text)));
612
+ } catch {}
613
+ },
614
+ "experimental.text.complete": async (input, output) => {
615
+ if (!mdLogFile)
616
+ return;
617
+ try {
618
+ const text = output.text?.trim();
619
+ if (!text)
620
+ return;
621
+ const partID = input.partID;
622
+ if (partID && loggedTextPartIds.has(partID))
623
+ return;
624
+ if (partID)
625
+ loggedTextPartIds.add(partID);
626
+ await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))));
627
+ } catch {}
628
+ },
629
+ "tool.execute.before": async (input) => {
630
+ if (!mdLogFile)
631
+ return;
632
+ try {
633
+ const toolName = input.tool;
634
+ const args = input.args ?? {};
635
+ if (toolName === "question") {
636
+ const q = args.question || args.header || "";
637
+ const ctx2 = args.details?.trim() || undefined;
638
+ const opts = Array.isArray(args.options) ? args.options : [];
639
+ const callID = input.callID;
640
+ if (callID && loggedToolCallIds.has(`q:${callID}`))
641
+ return;
642
+ if (callID)
643
+ loggedToolCallIds.add(`q:${callID}`);
644
+ if (q)
645
+ await withMdLock(() => appendToMdLog(questionCallout("Question", q, ctx2, opts)));
646
+ }
647
+ } catch {}
648
+ },
649
+ "tool.execute.after": async (input, output) => {
650
+ if (!mdLogFile)
651
+ return;
652
+ try {
653
+ const toolName = input.tool;
654
+ const callID = input.callID;
655
+ if (callID && loggedToolCallIds.has(`answer:${callID}`))
656
+ return;
657
+ if (toolName === "question") {
658
+ const meta = output.metadata ?? {};
659
+ let answers = meta.answers ?? [];
660
+ if (!answers.length && output.output)
661
+ answers = [];
662
+ const details = { answers, status: "completed" };
663
+ await withMdLock(() => appendToMdLog(answerCalloutAsk(details)));
664
+ if (callID)
665
+ loggedToolCallIds.add(`answer:${callID}`);
666
+ }
667
+ } catch {}
668
+ },
669
+ event: async ({ event }) => {
670
+ if (!mdLogFile)
671
+ return;
672
+ const t = event.type;
673
+ const props = event.properties ?? {};
674
+ try {
675
+ if (t === "message.updated") {
676
+ const info = props.info;
677
+ if (info?.id && info?.role)
678
+ messageIdToRole.set(info.id, info.role);
679
+ } else if (t === "message.part.updated") {
680
+ const part = props.part;
681
+ const delta = props.delta;
682
+ if (!part || !part.id)
683
+ return;
684
+ if (part.type === "text") {
685
+ if (part.synthetic || part.ignored)
686
+ return;
687
+ const isFinal = !!(part.time?.end !== undefined) || delta === undefined;
688
+ if (!isFinal)
689
+ return;
690
+ if (loggedTextPartIds.has(part.id))
691
+ return;
692
+ const text = (part.text || "").trim();
693
+ if (!text)
694
+ return;
695
+ const role = messageIdToRole.get(part.messageID);
696
+ if (role === "user")
697
+ return;
698
+ loggedTextPartIds.add(part.id);
699
+ await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))));
700
+ }
701
+ }
702
+ } catch {}
703
+ },
704
+ tool: {
705
+ quiz: tool({
706
+ description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals correct answer, and shows explanation. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
707
+ args: {
708
+ question: tool.schema.string().describe("Single quiz question to ask. One per call."),
709
+ details: tool.schema.string().optional().describe("Extra context shown under question."),
710
+ options: tool.schema.array(tool.schema.object({
711
+ label: tool.schema.string().describe("Display label"),
712
+ value: tool.schema.string().optional().describe("Machine value, defaults to label"),
713
+ description: tool.schema.string().optional()
714
+ })).min(2).describe("Answer options (2+). No free-text."),
715
+ multiSelect: tool.schema.boolean().optional().describe("True if multiple options correct (exact-set grading)."),
716
+ correctAnswer: tool.schema.union([tool.schema.string(), tool.schema.array(tool.schema.string())]).describe("REQUIRED correct answer as option value(s). Single: string. Multi: string[]; exact match required."),
717
+ explanation: tool.schema.string().describe("REQUIRED explanation revealed AFTER answer."),
718
+ shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters.")
719
+ },
720
+ async execute(args, ctx) {
721
+ let options;
722
+ try {
723
+ options = normalizeQuizOptions(args.options);
724
+ } catch (e) {
725
+ return `quiz error: ${e.message}`;
726
+ }
727
+ if (args.shuffle !== false)
728
+ options = shuffleOptions(options);
729
+ const { indices: correctIndices, error: correctError } = resolveCorrect(args.correctAnswer, options);
730
+ if (correctError)
731
+ return `quiz error: ${correctError}`;
732
+ if (options.length < 2)
733
+ return "quiz requires at least 2 options";
734
+ const correctStr = correctIndices.map((i) => `${i}. ${options[i - 1]?.label ?? ""}`).join(", ");
735
+ const display = options.map((o, i) => `${i + 1}. ${o.label}`).join(`
736
+ `);
737
+ const pDir = pendingDir(directory);
738
+ const tuiAlive = isTuiAlive(directory);
739
+ try {
740
+ fs.mkdirSync(pDir, { recursive: true });
741
+ } catch {}
742
+ const id = randomId();
743
+ const pendingPath = path.join(pDir, `quiz-${id}.json`);
744
+ const payload = {
745
+ id,
746
+ type: "quiz",
747
+ question: args.question,
748
+ details: args.details,
749
+ options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
750
+ correctIndices,
751
+ explanation: args.explanation,
752
+ multiSelect: !!args.multiSelect,
753
+ sessionID: ctx.sessionID,
754
+ timestamp: Date.now()
755
+ };
756
+ try {
757
+ fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8");
758
+ slog("quiz wrote durably", pendingPath, "alive", tuiAlive);
759
+ } catch (e) {
760
+ slog("quiz write failed", String(e));
761
+ }
762
+ try {
763
+ await ctx.metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { pendingId: id } });
764
+ } catch {}
765
+ watchAndInject(client, directory, id, ctx.sessionID, (r) => {
766
+ const dk = !!r?.dontKnow;
767
+ const sel = (r?.answers || []).map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
768
+ const cs = new Set(correctIndices);
769
+ const si = (r?.answers || []).map((a) => a.index);
770
+ const ok = !dk && si.length === correctIndices.length && si.every((i) => cs.has(i));
771
+ const note = r?.note ? `
772
+ Note: ${r.note}` : "";
773
+ if (mdLogFile) {
774
+ const details = {
775
+ status: "completed",
776
+ answers: r?.answers || [],
777
+ correct: ok,
778
+ correctIndices,
779
+ explanation: args.explanation,
780
+ dontKnow: dk,
781
+ note: r?.note
782
+ };
783
+ withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
784
+ }
785
+ return dk ? `[quiz answered] "${args.question}" -> I don't know (genuine gap).
786
+ Correct: ${correctStr}
787
+ Explanation: ${args.explanation}${note}` : `[quiz answered] "${args.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.
788
+ Correct: ${correctStr}
789
+ Explanation: ${args.explanation}${note}`;
790
+ });
791
+ if (mdLogFile) {
792
+ try {
793
+ await withMdLock(() => appendToMdLog(questionCallout("Quiz", args.question, args.details?.trim() || undefined, options.map((o) => ({ label: o.label })))));
794
+ } catch {}
795
+ }
796
+ if (tuiAlive) {
797
+ return `[quiz displayed in TUI \u2014 waiting for your answer in the popup. I'll continue once you respond.]`;
798
+ }
799
+ const isTTY = process.stdin?.isTTY && process.stdout?.isTTY;
800
+ const insideOpencode = !!process.env?.OPENCODE || !!process.env?.OPENCODE_TUI;
801
+ if (isTTY && !insideOpencode) {
802
+ const readline = await import("readline");
803
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
804
+ const abortPromise = new Promise((resolve2) => ctx.abort.addEventListener("abort", () => {
805
+ try {
806
+ rl.close();
807
+ } catch {}
808
+ resolve2(null);
809
+ }, { once: true }));
810
+ const promptText = `
811
+ [quiz] ${args.question}
812
+ ${args.details ? args.details + `
813
+ ` : ""}${display}
814
+ ${args.multiSelect ? "Select all correct (comma-separated numbers, e.g. 1,3) or 0 for 'I don't know': " : "Select one number or 0 for 'I don't know': "}`;
815
+ const answerPromise = new Promise((resolve2) => {
816
+ rl.question(promptText, (ans) => {
817
+ rl.close();
818
+ resolve2(ans);
819
+ });
820
+ });
821
+ const raw = await Promise.race([answerPromise, abortPromise]);
822
+ if (raw === null)
823
+ return "User cancelled the quiz";
824
+ const trimmed = raw.trim();
825
+ if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
826
+ const msg = `User selected "I don't know" \u2014 genuine gap, not a guess.
827
+ Correct: ${correctStr}
828
+ Explanation: ${args.explanation}`;
829
+ if (mdLogFile)
830
+ await withMdLock(() => appendToMdLog(callout("question", "Quiz \u2014 I don't know", [args.question, trimmed, `Correct: ${correctStr}`, args.explanation])));
831
+ return msg;
832
+ }
833
+ const nums = trimmed.split(/[,\s]+/).map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n >= 1 && n <= options.length);
834
+ const selectedSet = new Set(nums);
835
+ const correctSet = new Set(correctIndices);
836
+ const correct = selectedSet.size === correctSet.size && [...selectedSet].every((n) => correctSet.has(n));
837
+ const selectedStr = nums.map((n) => `${n}. ${options[n - 1].label}`).join(", ") || "(none)";
838
+ const verdict = correct ? "correctly" : "incorrectly";
839
+ const result = `User answered ${verdict}.
840
+ Selected: ${selectedStr}
841
+ Correct: ${correctStr}
842
+ Explanation: ${args.explanation}`;
843
+ ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation: args.explanation } });
844
+ if (mdLogFile)
845
+ await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${args.question}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, args.explanation])));
846
+ return result;
847
+ }
848
+ const instruction = [
849
+ `[quiz ready \u2014 awaiting user answer via \`question\` tool]`,
850
+ `Question: ${args.question}`,
851
+ args.details ? `Details: ${args.details}` : null,
852
+ `Options (display order, already shuffled):`,
853
+ ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` \u2014 ${o.description}` : ""} (value="${o.value}")`),
854
+ `Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
855
+ `Explanation (reveal AFTER answer): ${args.explanation}`,
856
+ `Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
857
+ ``,
858
+ `INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
859
+ ` header: "Quiz"`,
860
+ ` question: "${args.question.replace(/"/g, "\\\"")}"`,
861
+ ` options: [${options.map((o) => `{label:"${o.label.replace(/"/g, "\\\"")}", description:"${(o.description ?? "").replace(/"/g, "\\\"")}"}`).join(", ")}]`,
862
+ `Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show \u2713/\u2717, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`
863
+ ].filter(Boolean).join(`
864
+ `);
865
+ ctx.metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { correctIndices, explanation: args.explanation, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } });
866
+ return instruction;
867
+ }
868
+ }),
869
+ quiz_batch: tool({
870
+ description: "Batch version of quiz \u2014 shows 2-8 graded questions as a deck (Quiz 1/3 \u2192 2/3 \u2192 3/3) in one beautiful TUI, then one combined inject. Use when you want multiple probes without separate tool calls. Each entry has same schema as quiz.",
871
+ args: {
872
+ quizzes: tool.schema.array(tool.schema.object({
873
+ question: tool.schema.string(),
874
+ details: tool.schema.string().optional(),
875
+ options: tool.schema.array(tool.schema.object({
876
+ label: tool.schema.string(),
877
+ value: tool.schema.string().optional(),
878
+ description: tool.schema.string().optional()
879
+ })).min(2),
880
+ correctAnswer: tool.schema.union([tool.schema.string(), tool.schema.array(tool.schema.string())]),
881
+ explanation: tool.schema.string(),
882
+ multiSelect: tool.schema.boolean().optional(),
883
+ shuffle: tool.schema.boolean().optional()
884
+ })).min(2).max(8).describe("2-8 quizzes for the deck")
885
+ },
886
+ async execute(args, ctx) {
887
+ slog("quiz_batch called", JSON.stringify(args.quizzes).slice(0, 500));
888
+ const pendingDirPath = pendingDir(directory);
889
+ const isAlive = isTuiAlive(directory);
890
+ slog("quiz_batch isAlive", isAlive);
891
+ const normalized = [];
892
+ for (const q of args.quizzes) {
893
+ let opts;
894
+ try {
895
+ opts = normalizeQuizOptions(q.options);
896
+ } catch (e) {
897
+ slog("quiz_batch normalize error", e.message);
898
+ return `quiz_batch error: ${e.message} in "${q.question}"`;
899
+ }
900
+ if (q.shuffle !== false)
901
+ opts = shuffleOptions(opts);
902
+ const { indices, error } = resolveCorrect(q.correctAnswer, opts);
903
+ if (error) {
904
+ slog("quiz_batch resolveCorrect error", error);
905
+ return `quiz_batch error: ${error} in "${q.question}"`;
906
+ }
907
+ if (opts.length < 2)
908
+ return `quiz_batch error: need 2+ options in "${q.question}"`;
909
+ normalized.push({ question: q.question, details: q.details, options: opts, correctIndices: indices, explanation: q.explanation, multiSelect: !!q.multiSelect });
910
+ }
911
+ slog("quiz_batch normalized", normalized.length);
912
+ try {
913
+ fs.mkdirSync(pendingDirPath, { recursive: true });
914
+ } catch {}
915
+ const id = randomId();
916
+ const payload = { id, type: "quiz_batch", quizzes: normalized, sessionID: ctx.sessionID, timestamp: Date.now() };
917
+ const file = path.join(pendingDirPath, `quiz_batch-${id}.json`);
918
+ try {
919
+ fs.writeFileSync(file, JSON.stringify(payload), "utf8");
920
+ slog("quiz_batch wrote durably", file, "alive", isAlive);
921
+ } catch (e) {
922
+ slog("quiz_batch write failed", String(e));
923
+ }
924
+ try {
925
+ await ctx.metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } });
926
+ } catch {}
927
+ if (mdLogFile) {
928
+ for (let i = 0;i < normalized.length; i++) {
929
+ const q = normalized[i];
930
+ const label = `Quiz ${i + 1}/${normalized.length}`;
931
+ try {
932
+ await withMdLock(() => appendToMdLog(questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o) => ({ label: o.label })))));
933
+ } catch {}
934
+ }
935
+ }
936
+ watchAndInject(client, directory, id, ctx.sessionID, (r) => {
937
+ const results = r?.results || [];
938
+ if (mdLogFile) {
939
+ for (let i = 0;i < normalized.length; i++) {
940
+ const q = normalized[i];
941
+ const x = results[i] || {};
942
+ const details = {
943
+ status: "completed",
944
+ answers: x.answers || [],
945
+ correct: !!x.correct,
946
+ correctIndices: q.correctIndices || [],
947
+ explanation: q.explanation || "",
948
+ dontKnow: !!x.dontKnow,
949
+ note: x.note
950
+ };
951
+ const label = `Quiz ${i + 1}/${normalized.length}`;
952
+ try {
953
+ withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
954
+ } catch {}
955
+ }
956
+ }
957
+ const lines = results.map((x, i) => {
958
+ const q = normalized[i];
959
+ const cs = (q.correctIndices || []).map((idx) => `${idx}. ${q.options[idx - 1]?.label}`).join(", ");
960
+ const sel = x?.dontKnow ? "I don't know" : (x?.answers || []).map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)";
961
+ const ok = x?.correct ? "CORRECT" : x?.dontKnow ? "GAP" : "INCORRECT";
962
+ return `Q${i + 1}: "${q.question}" -> ${sel} = ${ok}. Correct: ${cs}`;
963
+ }).join(`
964
+ `);
965
+ return `[quiz_batch answered] ${normalized.length} quizzes
966
+ ` + lines;
967
+ });
968
+ slog("quiz_batch watchAndInject armed", id, "alive", isAlive);
969
+ if (isAlive)
970
+ return `[quiz batch displayed in TUI \u2014 ${normalized.length} quizzes as deck Quiz 1/${normalized.length} \u2192 ${normalized.length}/${normalized.length}. Answer all, then one combined inject.]`;
971
+ else
972
+ return `[quiz batch displayed durably \u2014 TUI not alive yet, will appear on restart. Answer all, then one combined inject.]`;
973
+ }
974
+ }),
975
+ md_log: tool({
976
+ description: "Mirror the session to a markdown file for comfortable reading in Obsidian. The file mirrors user prompts, assistant text, and quiz/question Q&A. Use an existing file; it will be backfilled with history. Use `md_unlog` to stop.",
977
+ args: {
978
+ filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist.")
979
+ },
980
+ async execute(args, ctx) {
981
+ const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath);
982
+ if (!fs.existsSync(resolved))
983
+ return `File does not exist: ${resolved}`;
984
+ if (!fs.statSync(resolved).isFile())
985
+ return `Not a file: ${resolved}`;
986
+ mdLogFile = resolved;
987
+ try {
988
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
989
+ fs.writeFileSync(markerPath, JSON.stringify({ file: resolved }), "utf-8");
990
+ } catch {}
991
+ let backfilled = 0;
992
+ const sessionID = ctx.sessionID;
993
+ if (sessionID) {
994
+ try {
995
+ backfilled = await backfillMdLog(client, sessionID, directory);
996
+ } catch (e) {
997
+ slog("backfill error", String(e));
998
+ }
999
+ }
1000
+ await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled } } });
1001
+ return `Linked: ${resolved} \u2014 ${backfilled ? `${backfilled} entries backfilled \u2014 ` : ""}future messages will be mirrored. View it rendered in Obsidian for LaTeX/math.`;
1002
+ }
1003
+ }),
1004
+ md_unlog: tool({
1005
+ description: "Stop mirroring the session to a markdown file.",
1006
+ args: {},
1007
+ async execute() {
1008
+ if (!mdLogFile)
1009
+ return "No file linked";
1010
+ const name = path.basename(mdLogFile);
1011
+ mdLogFile = null;
1012
+ try {
1013
+ fs.writeFileSync(markerPath, JSON.stringify({ file: null }), "utf-8");
1014
+ } catch {}
1015
+ await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}` } });
1016
+ return `Unlinked: ${name}`;
1017
+ }
1018
+ }),
1019
+ write_mermaid: tool({
1020
+ description: "Write the FULL Mermaid source to this session's managed file (first draft or rewrite). You do NOT name the file \u2014 edit_mermaid and render_mermaid act on same one. `source` is complete Mermaid diagram. Writing does NOT render \u2014 call render_mermaid when ready. For small fix prefer edit_mermaid.",
1021
+ args: { source: tool.schema.string().describe("Complete Mermaid diagram source") },
1022
+ async execute(args, ctx) {
1023
+ const source = (args.source ?? "").trim();
1024
+ if (!source)
1025
+ throw new Error("write_mermaid requires non-empty source");
1026
+ mermaidSession = writeBody("mermaid", "diagram.mmd", source);
1027
+ return `Wrote ${source.split(`
1028
+ `).length}-line Mermaid source at ${mermaidSession.bodyPath}. Call render_mermaid to render, or edit_mermaid to tweak.`;
1029
+ }
1030
+ }),
1031
+ edit_mermaid: tool({
1032
+ description: "Make single exact-match replacement in this session's Mermaid source \u2014 same contract as edit, locked to managed file. `old_text` must appear EXACTLY ONCE. Call write_mermaid first. Editing does NOT render.",
1033
+ args: {
1034
+ old_text: tool.schema.string().describe("Exact substring to replace (must match once)"),
1035
+ new_text: tool.schema.string().describe("Replacement text")
1036
+ },
1037
+ async execute(args) {
1038
+ if (!mermaidSession || !fs.existsSync(mermaidSession.bodyPath))
1039
+ throw new Error("edit_mermaid: no source yet \u2014 call write_mermaid first.");
1040
+ const current = fs.readFileSync(mermaidSession.bodyPath, "utf8");
1041
+ const { updated, index } = applyEdit(current, String(args.old_text ?? ""), String(args.new_text ?? ""));
1042
+ fs.writeFileSync(mermaidSession.bodyPath, updated, "utf8");
1043
+ return `Applied edit. Updated region:
1044
+ \`\`\`
1045
+ ${snippetAround(updated, index)}
1046
+ \`\`\`
1047
+ Call render_mermaid to see it.`;
1048
+ }
1049
+ }),
1050
+ render_mermaid: tool({
1051
+ description: "Render CURRENT session Mermaid source to PNG and return inline so you can SEE the diagram and iterate. You do NOT pass source here \u2014 it comes from managed file; call write_mermaid first. Iterate with no save_as (preview). When correct, call again with save_as kebab slug to publish to <cwd>/viz and get filename to embed as ![[viz-...png|500]]. On error returns text \u2014 fix with edit_mermaid.",
1052
+ args: { save_as: tool.schema.string().optional().describe("Short kebab-case slug e.g. 'internet-packets'. When set, publishes PNG to viz/ and returns filename. Omit for preview.") },
1053
+ async execute(args, ctx) {
1054
+ if (!mermaidSession || !fs.existsSync(mermaidSession.bodyPath))
1055
+ throw new Error("render_mermaid: no source yet \u2014 call write_mermaid first.");
1056
+ const { workDir, bodyPath } = mermaidSession;
1057
+ fs.mkdirSync(workDir, { recursive: true });
1058
+ const chrome = findChrome();
1059
+ const cfgPath = path.join(workDir, "puppeteer.json");
1060
+ fs.writeFileSync(cfgPath, JSON.stringify(chrome ? { executablePath: chrome, args: ["--no-sandbox"] } : { args: ["--no-sandbox"] }), "utf8");
1061
+ const mmdcCandidates = [
1062
+ path.join(directory, ".opencode", "node_modules", ".bin", "mmdc"),
1063
+ path.join(directory, "node_modules", ".bin", "mmdc"),
1064
+ "mmdc"
1065
+ ];
1066
+ let mmdc = "mmdc";
1067
+ for (const c of mmdcCandidates)
1068
+ if (fs.existsSync(c)) {
1069
+ mmdc = c;
1070
+ break;
1071
+ }
1072
+ const outPath = path.join(workDir, `render-${Date.now()}.png`);
1073
+ const res = await run(mmdc, ["-i", bodyPath, "-o", outPath, "-p", cfgPath, "-s", "2", "-b", "white"], { cwd: workDir, timeoutMs: 120000, env: { PUPPETEER_SKIP_DOWNLOAD: "1" } });
1074
+ if (res.code !== 0 || !fs.existsSync(outPath)) {
1075
+ const detail = (res.stderr || res.stdout || "unknown error").split(`
1076
+ `).slice(-30).join(`
1077
+ `);
1078
+ const note = res.timedOut ? `mmdc timed out.
1079
+
1080
+ ` : "";
1081
+ return `${note}Mermaid render FAILED \u2014 no image produced. Fix with edit_mermaid and re-render.
1082
+
1083
+ Error:
1084
+ ${detail}`;
1085
+ }
1086
+ if (args.save_as) {
1087
+ const { filename, path: dest } = publishPng(outPath, String(args.save_as), ctx.directory);
1088
+ return `Published to viz/.
1089
+ filename: ${filename}
1090
+ path: ${dest}
1091
+
1092
+ LOOK at the diagram below to confirm it is correct before returning it.
1093
+ Embed as ![[${filename}|500]]`;
1094
+ }
1095
+ return `Preview render (not yet saved) at ${outPath}. LOOK: are arrows/relationships correct, labels right, nothing cramped? Fix with edit_mermaid, or re-render with save_as to publish.`;
1096
+ }
1097
+ }),
1098
+ write_svg: tool({
1099
+ description: "Write the FULL SVG source to this session's managed file. You do NOT name the file \u2014 edit_svg and render_svg act on same one. `source` is complete <svg ...>\u2026</svg> with explicit width/height or viewBox, readable fonts, light/transparent bg. Writing does NOT render \u2014 call render_svg. For small fix prefer edit_svg.",
1100
+ args: { source: tool.schema.string().describe("Complete SVG document from <svg to </svg>") },
1101
+ async execute(args) {
1102
+ const source = (args.source ?? "").trim();
1103
+ if (!source)
1104
+ throw new Error("write_svg requires non-empty source");
1105
+ if (!source.includes("<svg"))
1106
+ throw new Error("source must be complete <svg>\u2026</svg>");
1107
+ svgSession = writeBody("svg", "diagram.svg", source);
1108
+ return `Wrote ${source.split(`
1109
+ `).length}-line SVG source. Call render_svg to render, or edit_svg to tweak.`;
1110
+ }
1111
+ }),
1112
+ edit_svg: tool({
1113
+ description: "Make single exact-match replacement in this session's SVG source \u2014 same contract as edit, locked to managed file. `old_text` must appear EXACTLY ONCE. Call write_svg first. Editing does NOT render.",
1114
+ args: {
1115
+ old_text: tool.schema.string().describe("Exact substring to replace (must match once)"),
1116
+ new_text: tool.schema.string().describe("Replacement text")
1117
+ },
1118
+ async execute(args) {
1119
+ if (!svgSession || !fs.existsSync(svgSession.bodyPath))
1120
+ throw new Error("edit_svg: no source yet \u2014 call write_svg first.");
1121
+ const current = fs.readFileSync(svgSession.bodyPath, "utf8");
1122
+ const { updated, index } = applyEdit(current, String(args.old_text ?? ""), String(args.new_text ?? ""));
1123
+ fs.writeFileSync(svgSession.bodyPath, updated, "utf8");
1124
+ return `Applied edit. Updated region:
1125
+ \`\`\`
1126
+ ${snippetAround(updated, index)}
1127
+ \`\`\`
1128
+ Call render_svg to see it.`;
1129
+ }
1130
+ }),
1131
+ render_svg: tool({
1132
+ description: "Render CURRENT session SVG source to PNG and return inline so you can SEE the picture and iterate. You do NOT pass source here \u2014 it comes from managed file; call write_svg first. Iterate with no save_as (preview). When correct, call again with save_as kebab slug to publish to viz/ and get filename to embed as ![[viz-...png|500]]. On error returns text \u2014 fix with edit_svg.",
1133
+ args: { save_as: tool.schema.string().optional().describe("Short kebab slug e.g. 'number-line'. When set, publishes PNG to viz/ as viz-<slug>-<timestamp>.png and returns filename. Omit for preview.") },
1134
+ async execute(args, ctx) {
1135
+ if (!svgSession || !fs.existsSync(svgSession.bodyPath))
1136
+ throw new Error("render_svg: no source yet \u2014 call write_svg first.");
1137
+ const { workDir, bodyPath } = svgSession;
1138
+ fs.mkdirSync(workDir, { recursive: true });
1139
+ const outPath = path.join(workDir, `render-${Date.now()}.png`);
1140
+ let res = await run("rsvg-convert", ["-z", "2", bodyPath, "-o", outPath], { cwd: workDir, timeoutMs: 60000 });
1141
+ let ok = res.code === 0 && fs.existsSync(outPath);
1142
+ if (!ok) {
1143
+ const magickRes = await run("magick", ["-density", "192", "-background", "white", bodyPath, outPath], { cwd: workDir, timeoutMs: 60000 });
1144
+ if (magickRes.code === 0 && fs.existsSync(outPath)) {
1145
+ res = magickRes;
1146
+ ok = true;
1147
+ }
1148
+ }
1149
+ if (!ok) {
1150
+ const detail = (res.stderr || res.stdout || "unknown error").split(`
1151
+ `).slice(-30).join(`
1152
+ `);
1153
+ const note = res.timedOut ? `SVG render timed out.
1154
+
1155
+ ` : "";
1156
+ return `${note}SVG render FAILED \u2014 no image produced (tried rsvg-convert then magick). Fix with edit_svg and re-render.
1157
+
1158
+ Error:
1159
+ ${detail}`;
1160
+ }
1161
+ if (args.save_as) {
1162
+ const { filename, path: dest } = publishPng(outPath, String(args.save_as), ctx.directory);
1163
+ return `Published to viz/.
1164
+ filename: ${filename}
1165
+ path: ${dest}
1166
+
1167
+ LOOK at the picture below to confirm geometry is correct before returning. Embed as ![[${filename}|500]]`;
1168
+ }
1169
+ return `Preview render (not yet saved) at ${outPath}. LOOK: are coordinates, angles, directions, proportions correct? Labels clear and unclipped? Fix with edit_svg, or re-render with save_as to publish.`;
1170
+ }
1171
+ })
1172
+ }
1173
+ };
1174
+ };
1175
+ var learn_default = {
1176
+ id: "learn",
1177
+ server
1178
+ };
1179
+ export {
1180
+ learn_default as default
1181
+ };