@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.
@@ -0,0 +1,991 @@
1
+ import type { Plugin, PluginModule } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import * as fs from "node:fs"
4
+ import * as path from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { spawn } from "node:child_process"
7
+
8
+ // ────────────────────────────────────────────────────────────────────────────
9
+ // Helpers shared across visual-tools (ported from .pi/extensions/visual-tools)
10
+ // ────────────────────────────────────────────────────────────────────────────
11
+ const EXTRA_PATH = ["/opt/local/bin", "/usr/local/bin", "/opt/homebrew/bin"]
12
+ const STAGING_ROOT = path.join(tmpdir(), "opencode-visual-tools")
13
+ const FILES_DIRNAME = "viz"
14
+
15
+ function findChrome(): string | undefined {
16
+ const cands = [
17
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
18
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
19
+ ]
20
+ for (const c of cands) if (fs.existsSync(c)) return c
21
+ return undefined
22
+ }
23
+
24
+ type RunResult = { code: number | null; stdout: string; stderr: string; timedOut: boolean }
25
+ function run(cmd: string, args: string[], opts: { cwd: string; timeoutMs: number; env?: Record<string, string> }): Promise<RunResult> {
26
+ return new Promise((resolveRun) => {
27
+ const augmentedPath = [...EXTRA_PATH, process.env.PATH ?? ""].join(":")
28
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...(opts.env ?? {}), PATH: augmentedPath } })
29
+ let stdout = ""
30
+ let stderr = ""
31
+ let timedOut = false
32
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL") }, opts.timeoutMs)
33
+ child.stdout.on("data", (d) => (stdout += d.toString()))
34
+ child.stderr.on("data", (d) => (stderr += d.toString()))
35
+ child.on("error", (err) => { clearTimeout(timer); resolveRun({ code: null, stdout, stderr: stderr + String(err), timedOut }) })
36
+ child.on("close", (code) => { clearTimeout(timer); resolveRun({ code, stdout, stderr, timedOut }) })
37
+ })
38
+ }
39
+
40
+ function sessionDir(group: string): string { return path.join(STAGING_ROOT, `${group}-${process.pid}`) }
41
+ function writeBody(group: string, bodyFileName: string, source: string) {
42
+ const workDir = sessionDir(group)
43
+ fs.mkdirSync(workDir, { recursive: true })
44
+ const bodyPath = path.join(workDir, bodyFileName)
45
+ fs.writeFileSync(bodyPath, source, "utf8")
46
+ return { workDir, bodyPath }
47
+ }
48
+ function applyEdit(current: string, oldText: string, newText: string) {
49
+ if (oldText === "") throw new Error("`old_text` must be non-empty.")
50
+ if (oldText === newText) throw new Error("`old_text` and `new_text` are identical.")
51
+ const first = current.indexOf(oldText)
52
+ if (first === -1) throw new Error("`old_text` not found in the current source — match it exactly.")
53
+ const second = current.indexOf(oldText, first + 1)
54
+ if (second !== -1) throw new Error("`old_text` appears multiple times — add surrounding context to make it unique.")
55
+ return { updated: current.slice(0, first) + newText + current.slice(first + oldText.length), index: first }
56
+ }
57
+ function snippetAround(content: string, index: number, contextLines = 3) {
58
+ const before = content.slice(0, index)
59
+ const hitLine = before.split("\n").length - 1
60
+ const lines = content.split("\n")
61
+ const start = Math.max(0, hitLine - contextLines)
62
+ const end = Math.min(lines.length - 1, hitLine + contextLines)
63
+ const width = String(end + 1).length
64
+ const out: string[] = []
65
+ for (let i = start; i <= end; i++) out.push(`${String(i + 1).padStart(width)} ${lines[i]}`)
66
+ return out.join("\n")
67
+ }
68
+ function publishPng(pngPath: string, slug: string, directory: string) {
69
+ const filesDir = path.join(directory, FILES_DIRNAME)
70
+ fs.mkdirSync(filesDir, { recursive: true })
71
+ const clean = slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "viz"
72
+ const filename = `viz-${clean}-${Date.now()}.png`
73
+ const dest = path.join(filesDir, filename)
74
+ fs.copyFileSync(pngPath, dest)
75
+ return { filename, path: dest }
76
+ }
77
+
78
+ // ────────────────────────────────────────────────────────────────────────────
79
+ // Quiz helpers (ported from .pi/extensions/quiz.ts)
80
+ // ────────────────────────────────────────────────────────────────────────────
81
+ function normalizeQuizOptions(options: Array<{ label: string; value?: string; description?: string }> | undefined) {
82
+ const seen = new Set<string>()
83
+ return (options || []).map(o => ({
84
+ label: o.label.trim(),
85
+ value: o.value?.trim() || o.label.trim(),
86
+ description: o.description?.trim() || undefined,
87
+ })).filter(o => {
88
+ if (o.label.length === 0) return false
89
+ if (seen.has(o.value)) throw new Error(`duplicate option value "${o.value}"`)
90
+ seen.add(o.value)
91
+ return true
92
+ })
93
+ }
94
+ function shuffleOptions<T>(options: T[]): T[] {
95
+ const out = [...options]
96
+ for (let i = out.length - 1; i > 0; i--) {
97
+ const j = Math.floor(Math.random() * (i + 1))
98
+ const tmp = out[i]; out[i] = out[j]; out[j] = tmp
99
+ }
100
+ return out
101
+ }
102
+ function coerceCorrectAnswer(correctAnswer: string | string[]): string[] {
103
+ if (Array.isArray(correctAnswer)) return correctAnswer
104
+ const trimmed = correctAnswer.trim()
105
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
106
+ try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) return parsed.map(v => String(v)) } catch {}
107
+ }
108
+ return [correctAnswer]
109
+ }
110
+ function resolveCorrect(correctAnswer: string | string[] | undefined, options: Array<{ value: string }>) {
111
+ if (correctAnswer === undefined) return { indices: [] as number[], error: "correctAnswer is required" }
112
+ const arr = coerceCorrectAnswer(correctAnswer)
113
+ if (arr.length === 0) return { indices: [] as number[], error: "correctAnswer is required" }
114
+ const byValue = new Map(options.map((o, i) => [o.value, i + 1]))
115
+ const indices: number[] = []
116
+ for (const raw of arr) {
117
+ const v = typeof raw === "string" ? raw.trim() : raw
118
+ const idx = byValue.get(v)
119
+ if (idx === undefined) {
120
+ const known = options.map(o => `"${o.value}"`).join(", ")
121
+ return { indices: [] as number[], error: `correctAnswer "${v}" does not match any option value (${known})` }
122
+ }
123
+ indices.push(idx)
124
+ }
125
+ return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) as number[] }
126
+ }
127
+
128
+ // ────────────────────────────────────────────────────────────────────────────
129
+ // md-log helpers (ported from .pi/extensions/md-log.ts)
130
+ // ────────────────────────────────────────────────────────────────────────────
131
+ let mdLogFile: string | null = null
132
+ let mdLogWriteLock: Promise<void> = Promise.resolve()
133
+ function withMdLock<T>(fn: () => T | Promise<T>): Promise<T> {
134
+ const prev = mdLogWriteLock
135
+ let release!: () => void
136
+ mdLogWriteLock = new Promise<void>(r => { release = r })
137
+ return prev.then(fn).finally(() => release())
138
+ }
139
+ function appendToMdLog(text: string) {
140
+ if (!mdLogFile) return
141
+ try {
142
+ let current = ""
143
+ if (fs.existsSync(mdLogFile)) current = fs.readFileSync(mdLogFile, "utf-8")
144
+ const prefix = current.trim().length > 0 ? "\n\n" : ""
145
+ fs.writeFileSync(mdLogFile, current + prefix + text + "\n", "utf-8")
146
+ } catch {}
147
+ }
148
+ function callout(type: string, title: string, bodyLines: string[]) {
149
+ const lines = [`> [!${type}] ${title}`]
150
+ for (const line of bodyLines) lines.push(line.length === 0 ? ">" : `> ${line}`)
151
+ return lines.join("\n")
152
+ }
153
+ function stripSkillBlocks(text: string) {
154
+ return text.replace(/<skill\b([^>]*)>[\s\S]*?<\/skill>/g, (_m, attrs: string) => {
155
+ const name = /name="([^"]+)"/.exec(attrs)?.[1]
156
+ return `> [!note] SKILL loaded: ${name ?? "(unknown)"}`
157
+ })
158
+ }
159
+ function userBlock(text: string) { return `> [!quote] YOU\n\n${text}` }
160
+ function assistantBlock(text: string) { return `> [!abstract] OPENCODE\n\n${text}` }
161
+ function optionsList(options: Array<{ label: string }>): string[] { return options.map((o, i) => `${i + 1}. ${o.label}`) }
162
+ function questionCallout(label: string, question: string, context: string | undefined, options: Array<{ label: string }>): string {
163
+ const body: string[] = []
164
+ for (const line of question.split("\n")) body.push(line)
165
+ if (context) { body.push(""); for (const line of context.split("\n")) body.push(line) }
166
+ if (options.length > 0) { body.push(""); body.push(...optionsList(options)) }
167
+ return callout("question", label, body)
168
+ }
169
+ function answerCalloutQuiz(details: any): string {
170
+ const status = details?.status
171
+ if (status === "cancelled") return callout("warning", "Quiz — cancelled", ["(user skipped)"])
172
+ if (status === "unavailable") return callout("warning", "Quiz — unavailable", [details?.message || ""])
173
+ const dontKnow = details?.dontKnow === true
174
+ const correct = details?.correct === true
175
+ const type = dontKnow ? "question" : correct ? "success" : "failure"
176
+ const title = dontKnow ? "Quiz — I don't know" : correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗"
177
+ const body: string[] = []
178
+ if (dontKnow) body.push("Your answer: I don't know")
179
+ else { const answers: any[] = details?.answers || []; const sel = answers.map((a) => `${a.index}. ${a.label}`).join(", ") || "(none)"; body.push(`Your answer: ${sel}`) }
180
+ const correctIndices: number[] = details?.correctIndices || []
181
+ if (correctIndices.length) body.push(`Correct answer: ${correctIndices.map((i) => `${i}`).join(", ")}`)
182
+ if (details?.note) { body.push(""); const noteLines = String(details.note).split("\n"); body.push(`Note: ${noteLines[0]}`); for (let i = 1; i < noteLines.length; i++) body.push(noteLines[i]) }
183
+ if (details?.explanation) { body.push(""); for (const line of String(details.explanation).split("\n")) body.push(line) }
184
+ return callout(type, title, body)
185
+ }
186
+ function answerCalloutAsk(details: any): string {
187
+ const status = details?.status
188
+ if (status === "cancelled") return callout("warning", "Question — cancelled", ["(user skipped)"])
189
+ if (status === "unavailable") return callout("warning", "Question — unavailable", [details?.message || ""])
190
+ const answers: any[] = details?.answers || []
191
+ const body: string[] = answers.map((a) => { if (a.type === "other") return `Other: ${a.label}`; if (a.type === "text") return a.label; return `${a.index}. ${a.label}` })
192
+ if (body.length === 0) body.push("(no answer)")
193
+ return callout("example", "Answer", body)
194
+ }
195
+ async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
196
+ if (!mdLogFile || !sessionID) return 0
197
+ try {
198
+ const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
199
+ const data: any = res?.data ?? res
200
+ const entries: any[] = Array.isArray(data) ? data : []
201
+ if (!entries.length) return 0
202
+ const blocks: string[] = []
203
+ for (const entry of entries) {
204
+ const info: any = entry.info
205
+ const parts: any[] = entry.parts ?? []
206
+ if (!info || !info.role) continue
207
+ if (info.role === "user") {
208
+ const text = parts.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n").trim()
209
+ const fallback = typeof info.content === "string" ? info.content : ""
210
+ const raw = text || fallback
211
+ const trimmed = stripSkillBlocks(raw.trim())
212
+ if (!trimmed) continue
213
+ // Skip system-injected quiz/batch answer prompts — they are mirrored as beautiful callouts via watchAndInject, not as plain user quotes
214
+ if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(trimmed) || trimmed.startsWith("[quiz answered]") || trimmed.startsWith("[quiz_batch answered]") || trimmed.startsWith("[question answered]")) continue
215
+ blocks.push(userBlock(trimmed))
216
+ } else if (info.role === "assistant") {
217
+ const textParts = parts.filter((p: any) => p.type === "text" && !p.synthetic && !p.ignored).map((p: any) => (p.text || "").trim()).filter(Boolean)
218
+ if (textParts.length) blocks.push(assistantBlock(textParts.join("\n\n")))
219
+ for (const p of parts) {
220
+ if (p.type !== "tool") continue
221
+ const toolName = p.tool
222
+ if (toolName !== "quiz" && toolName !== "question" && toolName !== "ask_user_question" && toolName !== "quiz_batch") continue // keep ask for old sessions
223
+ const st: any = p.state ?? {}
224
+ const input = st.input ?? {}
225
+ const output = st.output ?? ""
226
+ const meta = st.metadata ?? {}
227
+ if (toolName === "quiz_batch") {
228
+ const quizzes: any[] = input.quizzes ?? []
229
+ if (st.status === "pending" || st.status === "running") {
230
+ for (let i = 0; i < quizzes.length; i++) {
231
+ const qq = quizzes[i]
232
+ const label = `Quiz ${i + 1}/${quizzes.length}`
233
+ blocks.push(questionCallout(label, qq.question, qq.details?.trim() || undefined, qq.options ?? []))
234
+ }
235
+ } else if (st.status === "completed") {
236
+ for (let i = 0; i < quizzes.length; i++) {
237
+ const qq = quizzes[i]
238
+ const label = `Quiz ${i + 1}/${quizzes.length}`
239
+ blocks.push(questionCallout(label, qq.question, qq.details?.trim() || undefined, qq.options ?? []))
240
+ // Try to get per-quiz answer from meta.results if available (live path via watchAndInject will have beautiful logs anyway)
241
+ const results: any[] = meta.results ?? []
242
+ const x = results[i] || {}
243
+ if (x && (x.answers || x.correct !== undefined)) {
244
+ const details = { status: "completed" as const, answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note }
245
+ blocks.push(answerCalloutQuiz(details))
246
+ }
247
+ }
248
+ }
249
+ continue
250
+ }
251
+ if (st.status === "pending" || st.status === "running") {
252
+ if (input.question) {
253
+ const opts = Array.isArray(input.options) ? input.options : []
254
+ const label = toolName === "quiz" ? "Quiz" : "Question"
255
+ blocks.push(questionCallout(label, input.question, input.details?.trim() || undefined, opts))
256
+ }
257
+ } else if (st.status === "completed") {
258
+ // Question (with true order if shuffled, fallback to input)
259
+ if (input.question) {
260
+ const opts = Array.isArray(input.options) ? input.options : []
261
+ const label = toolName === "quiz" ? "Quiz" : "Question"
262
+ // Only push question if not already pushed as pending (avoid duplicate)
263
+ // For backfill we push both Q and A together
264
+ if (!blocks.length || !blocks[blocks.length - 1].includes(input.question.slice(0, 20))) {
265
+ blocks.push(questionCallout(label, input.question, input.details?.trim() || undefined, opts))
266
+ }
267
+ }
268
+ if (toolName === "quiz") {
269
+ const details = { status: "completed", answers: meta.answers ?? [], correct: meta.correct, correctIndices: meta.correctIndices ?? [], explanation: meta.explanation ?? "", dontKnow: meta.dontKnow ?? false, note: meta.note }
270
+ blocks.push(answerCalloutQuiz(details))
271
+ } else {
272
+ const details = { answers: meta.answers ?? [], status: "completed" }
273
+ blocks.push(answerCalloutAsk(details))
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+ if (blocks.length) {
280
+ let current = ""
281
+ try { if (fs.existsSync(mdLogFile)) current = fs.readFileSync(mdLogFile, "utf-8") } catch {}
282
+ // If file empty, overwrite; else append with separator (preserve user notes)
283
+ if (current.trim().length === 0) {
284
+ fs.writeFileSync(mdLogFile, blocks.join("\n\n") + "\n", "utf-8")
285
+ } else {
286
+ // Avoid duplicating if already contains same session text
287
+ const prefix = current.trim().length > 0 ? "\n\n" : ""
288
+ fs.writeFileSync(mdLogFile, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
289
+ }
290
+ }
291
+ return blocks.length
292
+ } catch (e) {
293
+ slog("backfill failed", String(e))
294
+ return 0
295
+ }
296
+ }
297
+
298
+ // ── Pending IPC for beautiful TUI (server ↔ tui) ─────────────────────────
299
+ const PENDING_DIRNAME = ".opencode/learn-pending"
300
+ const SERVER_LOG = path.join(tmpdir(), "learn-server.log")
301
+ function slog(...a: any[]) { try { const line = `[${new Date().toISOString()}] ${a.map(x=> typeof x==="string"? x : JSON.stringify(x)).join(" ")}\n`; fs.appendFileSync(SERVER_LOG, line) } catch {} }
302
+ function pendingDir(directory: string) { return path.join(directory, PENDING_DIRNAME) }
303
+ function isTuiAlive(directory: string): boolean {
304
+ try {
305
+ const p = path.join(pendingDir(directory), ".tui-alive")
306
+ const s = fs.statSync(p)
307
+ return Date.now() - s.mtimeMs < 8000
308
+ } catch { return false }
309
+ }
310
+ function randomId(): string {
311
+ try {
312
+ const c = (globalThis as any).crypto
313
+ if (c?.randomUUID) return c.randomUUID()
314
+ } catch {}
315
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
316
+ }
317
+ async function waitForResponse(directory: string, id: string, abort: AbortSignal): Promise<any | null> {
318
+ const dir = pendingDir(directory)
319
+ const respPath = path.join(dir, `response-${id}.json`)
320
+ // Fast path: already answered (race)
321
+ if (fs.existsSync(respPath)) {
322
+ try {
323
+ const raw = fs.readFileSync(respPath, "utf8")
324
+ const data = JSON.parse(raw)
325
+ try { fs.unlinkSync(respPath) } catch {}
326
+ return data
327
+ } catch {}
328
+ }
329
+ // Event-driven forever wait — no polling, no time limit, just abort or answer
330
+ return new Promise<any | null>((resolve) => {
331
+ let settled = false
332
+ const done = (v: any | null) => {
333
+ if (settled) return
334
+ settled = true
335
+ try { watcher.close() } catch {}
336
+ abort.removeEventListener("abort", onAbort)
337
+ resolve(v)
338
+ }
339
+ const onAbort = () => done(null)
340
+ if (abort.aborted) return done(null)
341
+ abort.addEventListener("abort", onAbort, { once: true })
342
+ let watcher: fs.FSWatcher
343
+ try {
344
+ watcher = fs.watch(dir, (_event, filename) => {
345
+ if (filename === `response-${id}.json` && fs.existsSync(respPath)) {
346
+ try {
347
+ const raw = fs.readFileSync(respPath, "utf8")
348
+ const data = JSON.parse(raw)
349
+ try { fs.unlinkSync(respPath) } catch {}
350
+ done(data)
351
+ } catch { done(null) }
352
+ }
353
+ })
354
+ watcher.on("error", () => {})
355
+ } catch {
356
+ // Fallback to polling if watch fails (e.g., dir missing)
357
+ const interval = setInterval(() => {
358
+ if (abort.aborted) { clearInterval(interval); done(null); return }
359
+ if (fs.existsSync(respPath)) {
360
+ clearInterval(interval)
361
+ try {
362
+ const raw = fs.readFileSync(respPath, "utf8")
363
+ const data = JSON.parse(raw)
364
+ try { fs.unlinkSync(respPath) } catch {}
365
+ done(data)
366
+ } catch { done(null) }
367
+ }
368
+ }, 400)
369
+ const origDone = done
370
+ // Wrap done to clear interval
371
+ const wrappedDone = (v: any | null) => { clearInterval(interval); origDone(v) }
372
+ // Replace done for abort path
373
+ abort.removeEventListener("abort", onAbort)
374
+ abort.addEventListener("abort", () => wrappedDone(null), { once: true })
375
+ }
376
+ })
377
+ }
378
+
379
+ // Server-side inject (loopd pattern: host-adapter.ts:100 promptAsync + path.id + body.parts)
380
+ const activeWatchers = new Map<string, fs.FSWatcher>()
381
+ function watchAndInject(client: any, directory: string, id: string, sessionID: string, buildText: (result: any) => string) {
382
+ slog("watchAndInject start", id, sessionID)
383
+ if (!sessionID) { slog("watchAndInject no sessionID", id); return }
384
+ const dir = pendingDir(directory)
385
+ const respPath = path.join(dir, `response-${id}.json`)
386
+ const fire = async () => {
387
+ let data: any
388
+ try { data = JSON.parse(fs.readFileSync(respPath, "utf8")) } catch { return }
389
+ try { fs.unlinkSync(respPath) } catch {}
390
+ const w = activeWatchers.get(id); if (w) { try { w.close() } catch {}; activeWatchers.delete(id) }
391
+ slog("watchAndInject fire", id, JSON.stringify(data).slice(0,400))
392
+ const effectiveSessionID = (data as any)?.sessionID || sessionID
393
+ const text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result)
394
+ // opencode-loop sdk.js:24 — SDK returns {data,error}, it does NOT throw. Must inspect .error.
395
+ const sdkCall = async (method: any, ...argsList: any[]) => {
396
+ let firstErr: any
397
+ for (const args of argsList) {
398
+ if (args === undefined) continue
399
+ try {
400
+ const res = await method(args)
401
+ const err = res && typeof res === "object" ? (res as any).error : undefined
402
+ if (!err) return res
403
+ firstErr = firstErr || err
404
+ } catch (e) { firstErr = firstErr || e }
405
+ }
406
+ throw firstErr || new Error("SDK call failed")
407
+ }
408
+ const parts = [{ type: "text", text }]
409
+ const shapes = [
410
+ { path: { id: effectiveSessionID }, body: { parts } },
411
+ { path: { sessionID: effectiveSessionID }, body: { parts } },
412
+ { sessionID: effectiveSessionID, parts },
413
+ ]
414
+ slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0,300))
415
+ let ok = false
416
+ // loopd host-adapter.ts:100 — promptAsync wakes the session (fire-and-forget turn)
417
+ if (client?.session?.promptAsync) {
418
+ try { await sdkCall(client.session.promptAsync.bind(client.session), ...shapes); ok = true } catch {}
419
+ }
420
+ if (!ok && client?.session?.prompt) {
421
+ try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch {}
422
+ }
423
+ try {
424
+ await client.app.log({ body: { service: "learn", level: ok ? "info" : "error", message: ok ? `injected into ${effectiveSessionID}` : `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
425
+ } catch {}
426
+ }
427
+ if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
428
+ try {
429
+ const w = fs.watch(dir, (_e, filename) => { if (filename === `response-${id}.json` && fs.existsSync(respPath)) void fire() })
430
+ w.on("error", () => {})
431
+ activeWatchers.set(id, w)
432
+ } catch {}
433
+ }
434
+
435
+ // ────────────────────────────────────────────────────────────────────────────
436
+ // Plugin definition
437
+ // ────────────────────────────────────────────────────────────────────────────
438
+ const server: Plugin = async ({ client, directory }) => {
439
+ // Try to restore md-log file from a marker file if exists
440
+ const markerPath = path.join(directory, ".opencode", "learn-md-log.json")
441
+ try {
442
+ if (fs.existsSync(markerPath)) {
443
+ const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"))
444
+ if (data?.file && fs.existsSync(data.file)) mdLogFile = data.file
445
+ }
446
+ } catch {}
447
+
448
+ // Session-scoped visual state (one per plugin instance; subagents get separate plugin instances per session, so isolation is natural)
449
+ let mermaidSession: { workDir: string; bodyPath: string } | null = null
450
+ let svgSession: { workDir: string; bodyPath: string } | null = null
451
+
452
+ // md-log dedup state (per plugin instance, survives across sessions but mdLogFile is global)
453
+ const loggedTextPartIds = new Set<string>()
454
+ const loggedToolCallIds = new Set<string>()
455
+ const messageIdToRole = new Map<string, string>()
456
+
457
+ // Durability: on (re)start, re-watch any pending quizzes left from a crash/exit
458
+ try {
459
+ const dir = pendingDir(directory)
460
+ if (fs.existsSync(dir)) {
461
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
462
+ try {
463
+ const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
464
+ if (j?.id && j?.sessionID) {
465
+ watchAndInject(client, directory, j.id, j.sessionID, (r: any) => {
466
+ if (j.type === "quiz") {
467
+ const cs = new Set(j.correctIndices || [])
468
+ const si = (r?.answers || []).map((a:any)=>a.index)
469
+ const sel = (r?.answers || []).map((a:any)=>`${a.index}. ${a.label}`).join(", ") || "(none)"
470
+ const cstr = (j.correctIndices||[]).map((i:number)=>`${i}. ${j.options[i-1]?.label}`).join(", ")
471
+ const dk = !!r?.dontKnow
472
+ const ok = !dk && si.length === (j.correctIndices||[]).length && si.every((i:number)=>cs.has(i))
473
+ const note = r?.note ? `\nNote: ${r.note}` : ""
474
+ if (mdLogFile) {
475
+ const details = { status: "completed" as const, answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note }
476
+ void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
477
+ }
478
+ return dk ? `[quiz answered] "${j.question}" -> I don't know.\nCorrect: ${cstr}\nExplanation: ${j.explanation}${note}` : `[quiz answered] "${j.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${cstr}\nExplanation: ${j.explanation}${note}`
479
+ } else if (j.type === "quiz_batch") {
480
+ const results = (r as any)?.results || []
481
+ if (mdLogFile) {
482
+ for (let i = 0; i < (j.quizzes||[]).length; i++) {
483
+ const qq = j.quizzes[i]
484
+ const x = results[i] || {}
485
+ const details = { status: "completed" as const, answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note }
486
+ void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
487
+ }
488
+ }
489
+ const lines = (j.quizzes || []).map((qq:any, i:number) => {
490
+ const x = results[i] || {}
491
+ const cs = (qq.correctIndices||[]).map((idx:number)=>`${idx}. ${qq.options[idx-1]?.label}`).join(", ")
492
+ const sel = x?.dontKnow ? "I don't know" : (x?.answers||[]).map((a:any)=>`${a.index}. ${a.label}`).join(", ") || "(none)"
493
+ const ok = x?.correct ? "CORRECT" : x?.dontKnow ? "GAP" : "INCORRECT"
494
+ return `Q${i+1}: "${qq.question}" -> ${sel} = ${ok}. Correct: ${cs}`
495
+ }).join("\n")
496
+ return `[quiz_batch answered] ${(j.quizzes||[]).length} quizzes\n` + lines
497
+ } else {
498
+ const arr = Array.isArray(r) ? r : (r?.answers || [])
499
+ const txt = arr.map((a:any)=> a.type==="other"?`Other: ${a.label}`: a.index?`${a.index}. ${a.label}`:a.label).join(", ") || "(no answer)"
500
+ return `[question answered] "${j.question}" -> ${txt}`
501
+ }
502
+ })
503
+ }
504
+ } catch {}
505
+ }
506
+ }
507
+ } catch {}
508
+
509
+ return {
510
+ // Inject agents if not already present via config hook
511
+ config: async (output) => {
512
+ const agents = (output as any).agent ?? {}
513
+ let mutated = false
514
+ for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
515
+ if (!agents[name]) {
516
+ // Minimal placeholder — real agent definitions live in .opencode/agents/*.md
517
+ // We inject a lightweight config so `task` tool can discover them even if md file is missing.
518
+ agents[name] = { mode: "subagent", description: `${name} subagent (from learn plugin)`, permission: { "*": "allow" } }
519
+ mutated = true
520
+ }
521
+ }
522
+ if (mutated) (output as any).agent = agents
523
+ await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } })
524
+ },
525
+
526
+ "chat.message": async (_input, output) => {
527
+ if (!mdLogFile) return
528
+ try {
529
+ const msg: any = (output as any).message
530
+ const parts: any[] = (output as any).parts ?? []
531
+ let text = ""
532
+ if (Array.isArray(parts) && parts.length) text = parts.filter((p) => p.type === "text").map((p) => p.text).join("\n").trim()
533
+ if (!text && typeof msg?.content === "string") text = msg.content
534
+ else if (!text && Array.isArray(msg?.content)) text = msg.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n")
535
+ text = stripSkillBlocks((text || "").trim())
536
+ if (!text) return
537
+ // Skip system-injected quiz/batch answer prompts — they are mirrored as beautiful callouts via watchAndInject, not as plain user quotes
538
+ if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(text) || text.startsWith("[quiz answered]") || text.startsWith("[quiz_batch answered]") || text.startsWith("[question answered]")) return
539
+ const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`
540
+ if (loggedTextPartIds.has(mid)) return
541
+ loggedTextPartIds.add(mid)
542
+ await withMdLock(() => appendToMdLog(userBlock(text)))
543
+ } catch {}
544
+ },
545
+ "experimental.text.complete": async (input, output) => {
546
+ if (!mdLogFile) return
547
+ try {
548
+ const text = (output as any).text?.trim()
549
+ if (!text) return
550
+ const partID = (input as any).partID
551
+ if (partID && loggedTextPartIds.has(partID)) return
552
+ if (partID) loggedTextPartIds.add(partID)
553
+ await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))))
554
+ } catch {}
555
+ },
556
+ "tool.execute.before": async (input) => {
557
+ if (!mdLogFile) return
558
+ try {
559
+ const toolName = (input as any).tool
560
+ const args = (input as any).args ?? {}
561
+ // Built-in `question` tool is used as fallback when TUI not alive; mirror it.
562
+ // `ask_user_question` is already mirrored inside its own execute (with correct TUI handling), so skip to avoid duplicate.
563
+ if (toolName === "question") {
564
+ const q = args.question || args.header || ""
565
+ const ctx2 = args.details?.trim() || undefined
566
+ const opts = Array.isArray(args.options) ? args.options : []
567
+ const callID = (input as any).callID
568
+ if (callID && loggedToolCallIds.has(`q:${callID}`)) return
569
+ if (callID) loggedToolCallIds.add(`q:${callID}`)
570
+ if (q) await withMdLock(() => appendToMdLog(questionCallout("Question", q, ctx2, opts)))
571
+ }
572
+ } catch {}
573
+ },
574
+ "tool.execute.after": async (input, output) => {
575
+ if (!mdLogFile) return
576
+ try {
577
+ const toolName = (input as any).tool
578
+ const callID = (input as any).callID
579
+ if (callID && loggedToolCallIds.has(`answer:${callID}`)) return
580
+ if (toolName === "question") {
581
+ const meta: any = (output as any).metadata ?? {}
582
+ let answers: any[] = meta.answers ?? []
583
+ if (!answers.length && (output as any).output) answers = []
584
+ const details: any = { answers, status: "completed" }
585
+ await withMdLock(() => appendToMdLog(answerCalloutAsk(details)))
586
+ if (callID) loggedToolCallIds.add(`answer:${callID}`)
587
+ }
588
+ } catch {}
589
+ },
590
+ // Mirror session to markdown file (best-effort, mirrors pi's md-log)
591
+ event: async ({ event }) => {
592
+ if (!mdLogFile) return
593
+ const t = (event as any).type as string
594
+ const props = (event as any).properties ?? {}
595
+ try {
596
+ if (t === "message.updated") {
597
+ const info: any = props.info
598
+ if (info?.id && info?.role) messageIdToRole.set(info.id, info.role)
599
+ } else if (t === "message.part.updated") {
600
+ const part: any = props.part
601
+ const delta: string | undefined = props.delta
602
+ if (!part || !part.id) return
603
+ if (part.type === "text") {
604
+ if (part.synthetic || part.ignored) return
605
+ const isFinal = !!(part.time?.end !== undefined) || delta === undefined
606
+ if (!isFinal) return
607
+ if (loggedTextPartIds.has(part.id)) return
608
+ const text = (part.text || "").trim()
609
+ if (!text) return
610
+ const role = messageIdToRole.get(part.messageID)
611
+ if (role === "user") return
612
+ // Fallback for assistant when experimental.text.complete not fired
613
+ loggedTextPartIds.add(part.id)
614
+ await withMdLock(() => appendToMdLog(assistantBlock(stripSkillBlocks(text))))
615
+ }
616
+ }
617
+ } catch {}
618
+ },
619
+
620
+ tool: {
621
+ // ── quiz: graded question ────────────────────────────────────────
622
+ quiz: tool({
623
+ 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.",
624
+ args: {
625
+ question: tool.schema.string().describe("Single quiz question to ask. One per call."),
626
+ details: tool.schema.string().optional().describe("Extra context shown under question."),
627
+ options: tool.schema.array(tool.schema.object({
628
+ label: tool.schema.string().describe("Display label"),
629
+ value: tool.schema.string().optional().describe("Machine value, defaults to label"),
630
+ description: tool.schema.string().optional(),
631
+ })).min(2).describe("Answer options (2+). No free-text."),
632
+ multiSelect: tool.schema.boolean().optional().describe("True if multiple options correct (exact-set grading)."),
633
+ 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."),
634
+ explanation: tool.schema.string().describe("REQUIRED explanation revealed AFTER answer."),
635
+ shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters."),
636
+ },
637
+ async execute(args, ctx) {
638
+ let options: Array<{ label: string; value: string; description?: string }>
639
+ try { options = normalizeQuizOptions(args.options as any) } catch (e) { return `quiz error: ${(e as Error).message}` }
640
+ if (args.shuffle !== false) options = shuffleOptions(options)
641
+ const { indices: correctIndices, error: correctError } = resolveCorrect(args.correctAnswer as any, options)
642
+ if (correctError) return `quiz error: ${correctError}`
643
+ if (options.length < 2) return "quiz requires at least 2 options"
644
+ const correctStr = correctIndices.map(i => `${i}. ${options[i - 1]?.label ?? ""}`).join(", ")
645
+ const display = options.map((o, i) => `${i + 1}. ${o.label}`).join("\n")
646
+
647
+ // ── Beautiful TUI path — non-blocking, inject prompt on answer ──
648
+ // Server frees immediately; TUI shows rich dialog and injects answer as new user prompt to wake agent.
649
+ const pDir = pendingDir(directory)
650
+ const tuiAlive = isTuiAlive(directory)
651
+ // Always write durably so exit → restart still shows it (like opencode-loop guardLoopOwnedUserMessage)
652
+ try { fs.mkdirSync(pDir, { recursive: true }) } catch {}
653
+ const id = randomId()
654
+ const pendingPath = path.join(pDir, `quiz-${id}.json`)
655
+ const payload = {
656
+ id,
657
+ type: "quiz" as const,
658
+ question: args.question,
659
+ details: args.details,
660
+ options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
661
+ correctIndices,
662
+ explanation: args.explanation,
663
+ multiSelect: !!args.multiSelect,
664
+ sessionID: (ctx as any).sessionID,
665
+ timestamp: Date.now(),
666
+ }
667
+ try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
668
+ try { await (ctx as any).metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
669
+ watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
670
+ const dk = !!r?.dontKnow
671
+ const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
672
+ const cs = new Set(correctIndices)
673
+ const si = (r?.answers || []).map((a: any) => a.index)
674
+ const ok = !dk && si.length === correctIndices.length && si.every((i: number) => cs.has(i))
675
+ const note = r?.note ? `\nNote: ${r.note}` : ""
676
+ if (mdLogFile) {
677
+ const details = {
678
+ status: "completed" as const,
679
+ answers: r?.answers || [],
680
+ correct: ok,
681
+ correctIndices,
682
+ explanation: args.explanation,
683
+ dontKnow: dk,
684
+ note: r?.note,
685
+ }
686
+ void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
687
+ }
688
+ return dk
689
+ ? `[quiz answered] "${args.question}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${args.explanation}${note}`
690
+ : `[quiz answered] "${args.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${args.explanation}${note}`
691
+ })
692
+ // Always mirror question with TRUE shuffled order (pi: tool_execution_update)
693
+ if (mdLogFile) {
694
+ try { await withMdLock(() => appendToMdLog(questionCallout("Quiz", args.question, args.details?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
695
+ }
696
+ if (tuiAlive) {
697
+ return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
698
+ }
699
+ // ── Fallback: console TTY (NEVER inside opencode TUI — readline steals raw mode + mouse SGR `^[[<35;...M` and garbles alt-screen)
700
+ // Inside opencode `OPENCODE=1` is always set, so skip readline and use instruction fallback that works with native `question` tool.
701
+ const isTTY = (process as any).stdin?.isTTY && (process as any).stdout?.isTTY
702
+ const insideOpencode = !!(process as any).env?.OPENCODE || !!(process as any).env?.OPENCODE_TUI
703
+ if (isTTY && !insideOpencode) {
704
+ const readline = await import("node:readline")
705
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
706
+ const abortPromise = new Promise<null>((resolve) => ctx.abort.addEventListener("abort", () => { try { rl.close() } catch {}; resolve(null) }, { once: true }))
707
+ const promptText = `\n[quiz] ${args.question}\n${args.details ? args.details + "\n" : ""}${display}\n${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': "}`
708
+ const answerPromise = new Promise<string>((resolve) => { rl.question(promptText, (ans) => { rl.close(); resolve(ans) }) })
709
+ const raw = await Promise.race([answerPromise, abortPromise])
710
+ if (raw === null) return "User cancelled the quiz"
711
+ const trimmed = (raw as string).trim()
712
+ if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
713
+ const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${args.explanation}`
714
+ if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [args.question, trimmed, `Correct: ${correctStr}`, args.explanation])))
715
+ return msg
716
+ }
717
+ const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
718
+ const selectedSet = new Set(nums)
719
+ const correctSet = new Set(correctIndices)
720
+ const correct = selectedSet.size === correctSet.size && [...selectedSet].every(n => correctSet.has(n))
721
+ const selectedStr = nums.map(n => `${n}. ${options[n - 1].label}`).join(", ") || "(none)"
722
+ const verdict = correct ? "correctly" : "incorrectly"
723
+ const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${args.explanation}`
724
+ ;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: args.explanation } })
725
+ if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${args.question}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, args.explanation])))
726
+ return result
727
+ }
728
+ const instruction = [
729
+ `[quiz ready — awaiting user answer via \`question\` tool]`,
730
+ `Question: ${args.question}`,
731
+ args.details ? `Details: ${args.details}` : null,
732
+ `Options (display order, already shuffled):`,
733
+ ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""} (value="${o.value}")`),
734
+ `Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
735
+ `Explanation (reveal AFTER answer): ${args.explanation}`,
736
+ `Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
737
+ ``,
738
+ `INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
739
+ ` header: "Quiz"`,
740
+ ` question: "${args.question.replace(/"/g, '\\"')}"`,
741
+ ` options: [${options.map(o => `{label:"${o.label.replace(/"/g, '\\"')}", description:"${(o.description ?? "").replace(/"/g, '\\"')}"}`).join(", ")}]`,
742
+ `Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show ✓/✗, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`,
743
+ ].filter(Boolean).join("\n")
744
+ ;(ctx as any).metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { correctIndices, explanation: args.explanation, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
745
+ return instruction
746
+ },
747
+ }),
748
+
749
+ // ── quiz_batch: optional deck — quiz 1/3 → 2/3 → 3/3 in one dialog, one inject
750
+ quiz_batch: tool({
751
+ description: "Batch version of quiz — shows 2-8 graded questions as a deck (Quiz 1/3 → 2/3 → 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.",
752
+ args: {
753
+ quizzes: tool.schema.array(tool.schema.object({
754
+ question: tool.schema.string(),
755
+ details: tool.schema.string().optional(),
756
+ options: tool.schema.array(tool.schema.object({
757
+ label: tool.schema.string(),
758
+ value: tool.schema.string().optional(),
759
+ description: tool.schema.string().optional(),
760
+ })).min(2),
761
+ correctAnswer: tool.schema.union([tool.schema.string(), tool.schema.array(tool.schema.string())]),
762
+ explanation: tool.schema.string(),
763
+ multiSelect: tool.schema.boolean().optional(),
764
+ shuffle: tool.schema.boolean().optional(),
765
+ })).min(2).max(8).describe("2-8 quizzes for the deck"),
766
+ },
767
+ async execute(args, ctx) {
768
+ slog("quiz_batch called", JSON.stringify(args.quizzes).slice(0,500))
769
+ const pendingDirPath = pendingDir(directory)
770
+ const isAlive = isTuiAlive(directory)
771
+ slog("quiz_batch isAlive", isAlive)
772
+ const normalized: any[] = []
773
+ for (const q of (args.quizzes as any[])) {
774
+ let opts: any
775
+ try { opts = normalizeQuizOptions(q.options) } catch (e) { slog("quiz_batch normalize error", (e as Error).message); return `quiz_batch error: ${(e as Error).message} in "${q.question}"` }
776
+ if (q.shuffle !== false) opts = shuffleOptions(opts)
777
+ const { indices, error } = resolveCorrect(q.correctAnswer as any, opts)
778
+ if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${q.question}"` }
779
+ if (opts.length < 2) return `quiz_batch error: need 2+ options in "${q.question}"`
780
+ normalized.push({ question: q.question, details: q.details, options: opts, correctIndices: indices, explanation: q.explanation, multiSelect: !!q.multiSelect })
781
+ }
782
+ slog("quiz_batch normalized", normalized.length)
783
+ try { fs.mkdirSync(pendingDirPath, { recursive: true }) } catch {}
784
+ const id = randomId()
785
+ const payload = { id, type: "quiz_batch" as const, quizzes: normalized, sessionID: (ctx as any).sessionID, timestamp: Date.now() }
786
+ const file = path.join(pendingDirPath, `quiz_batch-${id}.json`)
787
+ try { fs.writeFileSync(file, JSON.stringify(payload), "utf8"); slog("quiz_batch wrote durably", file, "alive", isAlive) } catch (e) { slog("quiz_batch write failed", String(e)) }
788
+ try { await (ctx as any).metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } }) } catch {}
789
+ // Mirror each question in batch as a beautiful callout (like single quiz)
790
+ if (mdLogFile) {
791
+ for (let i = 0; i < normalized.length; i++) {
792
+ const q = normalized[i]
793
+ const label = `Quiz ${i + 1}/${normalized.length}`
794
+ try { await withMdLock(() => appendToMdLog(questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o: any) => ({ label: o.label })))) ) } catch {}
795
+ }
796
+ }
797
+ watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
798
+ const results = r?.results || []
799
+ // Mirror each answer as a beautiful callout (like single quiz) — not just plain text
800
+ if (mdLogFile) {
801
+ for (let i = 0; i < normalized.length; i++) {
802
+ const q = normalized[i]
803
+ const x = results[i] || {}
804
+ const details = {
805
+ status: "completed" as const,
806
+ answers: x.answers || [],
807
+ correct: !!x.correct,
808
+ correctIndices: q.correctIndices || [],
809
+ explanation: q.explanation || "",
810
+ dontKnow: !!x.dontKnow,
811
+ note: x.note,
812
+ }
813
+ const label = `Quiz ${i + 1}/${normalized.length}`
814
+ // Use same callout helper as single quiz but with batch label context
815
+ try {
816
+ // withMdLock is async, but watchAndInject buildText is sync — queue without await and let it flush
817
+ void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
818
+ } catch {}
819
+ }
820
+ }
821
+ const lines = results.map((x: any, i: number) => {
822
+ const q = normalized[i]
823
+ const cs = (q.correctIndices||[]).map((idx:number)=>`${idx}. ${q.options[idx-1]?.label}`).join(", ")
824
+ const sel = x?.dontKnow ? "I don't know" : (x?.answers||[]).map((a:any)=>`${a.index}. ${a.label}`).join(", ") || "(none)"
825
+ const ok = x?.correct ? "CORRECT" : x?.dontKnow ? "GAP" : "INCORRECT"
826
+ return `Q${i+1}: "${q.question}" -> ${sel} = ${ok}. Correct: ${cs}`
827
+ }).join("\n")
828
+ return `[quiz_batch answered] ${normalized.length} quizzes\n` + lines
829
+ })
830
+ slog("quiz_batch watchAndInject armed", id, "alive", isAlive)
831
+ if (isAlive) return `[quiz batch displayed in TUI — ${normalized.length} quizzes as deck Quiz 1/${normalized.length} → ${normalized.length}/${normalized.length}. Answer all, then one combined inject.]`
832
+ else return `[quiz batch displayed durably — TUI not alive yet, will appear on restart. Answer all, then one combined inject.]`
833
+ }
834
+ }),
835
+
836
+ // ── md_log: link a markdown file ───────────────────────────────────
837
+ md_log: tool({
838
+ 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.",
839
+ args: {
840
+ filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist."),
841
+ },
842
+ async execute(args, ctx) {
843
+ const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath)
844
+ if (!fs.existsSync(resolved)) return `File does not exist: ${resolved}`
845
+ if (!fs.statSync(resolved).isFile()) return `Not a file: ${resolved}`
846
+ mdLogFile = resolved
847
+ try { fs.mkdirSync(path.dirname(markerPath), { recursive: true }); fs.writeFileSync(markerPath, JSON.stringify({ file: resolved }), "utf-8") } catch {}
848
+ // Backfill history for this session (like pi: ctx.sessionManager.getEntries() parent chain)
849
+ let backfilled = 0
850
+ const sessionID = (ctx as any).sessionID as string | undefined
851
+ if (sessionID) {
852
+ try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
853
+ }
854
+ await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled } } })
855
+ return `Linked: ${resolved} — ${backfilled ? `${backfilled} entries backfilled — ` : ""}future messages will be mirrored. View it rendered in Obsidian for LaTeX/math.`
856
+ },
857
+ }),
858
+
859
+ md_unlog: tool({
860
+ description: "Stop mirroring the session to a markdown file.",
861
+ args: {},
862
+ async execute() {
863
+ if (!mdLogFile) return "No file linked"
864
+ const name = path.basename(mdLogFile)
865
+ mdLogFile = null
866
+ try { fs.writeFileSync(markerPath, JSON.stringify({ file: null }), "utf-8") } catch {}
867
+ await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}` } })
868
+ return `Unlinked: ${name}`
869
+ },
870
+ }),
871
+
872
+ // ── Visual tools (mermaid) ─────────────────────────────────────────
873
+ write_mermaid: tool({
874
+ description: "Write the FULL Mermaid source to this session's managed file (first draft or rewrite). You do NOT name the file — edit_mermaid and render_mermaid act on same one. `source` is complete Mermaid diagram. Writing does NOT render — call render_mermaid when ready. For small fix prefer edit_mermaid.",
875
+ args: { source: tool.schema.string().describe("Complete Mermaid diagram source") },
876
+ async execute(args, ctx) {
877
+ const source = (args.source ?? "").trim()
878
+ if (!source) throw new Error("write_mermaid requires non-empty source")
879
+ mermaidSession = writeBody("mermaid", "diagram.mmd", source)
880
+ return `Wrote ${source.split("\n").length}-line Mermaid source at ${mermaidSession.bodyPath}. Call render_mermaid to render, or edit_mermaid to tweak.`
881
+ },
882
+ }),
883
+ edit_mermaid: tool({
884
+ description: "Make single exact-match replacement in this session's Mermaid source — same contract as edit, locked to managed file. `old_text` must appear EXACTLY ONCE. Call write_mermaid first. Editing does NOT render.",
885
+ args: {
886
+ old_text: tool.schema.string().describe("Exact substring to replace (must match once)"),
887
+ new_text: tool.schema.string().describe("Replacement text"),
888
+ },
889
+ async execute(args) {
890
+ if (!mermaidSession || !fs.existsSync(mermaidSession.bodyPath)) throw new Error("edit_mermaid: no source yet — call write_mermaid first.")
891
+ const current = fs.readFileSync(mermaidSession.bodyPath, "utf8")
892
+ const { updated, index } = applyEdit(current, String(args.old_text ?? ""), String(args.new_text ?? ""))
893
+ fs.writeFileSync(mermaidSession.bodyPath, updated, "utf8")
894
+ return `Applied edit. Updated region:\n\`\`\`\n${snippetAround(updated, index)}\n\`\`\`\nCall render_mermaid to see it.`
895
+ },
896
+ }),
897
+ render_mermaid: tool({
898
+ 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 — 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 — fix with edit_mermaid.",
899
+ 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.") },
900
+ async execute(args, ctx) {
901
+ if (!mermaidSession || !fs.existsSync(mermaidSession.bodyPath)) throw new Error("render_mermaid: no source yet — call write_mermaid first.")
902
+ const { workDir, bodyPath } = mermaidSession
903
+ fs.mkdirSync(workDir, { recursive: true })
904
+ const chrome = findChrome()
905
+ const cfgPath = path.join(workDir, "puppeteer.json")
906
+ fs.writeFileSync(cfgPath, JSON.stringify(chrome ? { executablePath: chrome, args: ["--no-sandbox"] } : { args: ["--no-sandbox"] }), "utf8")
907
+ // Resolve mmdc bin from .opencode install or fallback to npx
908
+ const mmdcCandidates = [
909
+ path.join(directory, ".opencode", "node_modules", ".bin", "mmdc"),
910
+ path.join(directory, "node_modules", ".bin", "mmdc"),
911
+ "mmdc",
912
+ ]
913
+ let mmdc = "mmdc"
914
+ for (const c of mmdcCandidates) if (fs.existsSync(c)) { mmdc = c; break }
915
+ const outPath = path.join(workDir, `render-${Date.now()}.png`)
916
+ const res = await run(mmdc, ["-i", bodyPath, "-o", outPath, "-p", cfgPath, "-s", "2", "-b", "white"], { cwd: workDir, timeoutMs: 120_000, env: { PUPPETEER_SKIP_DOWNLOAD: "1" } })
917
+ if (res.code !== 0 || !fs.existsSync(outPath)) {
918
+ const detail = (res.stderr || res.stdout || "unknown error").split("\n").slice(-30).join("\n")
919
+ const note = res.timedOut ? "mmdc timed out.\n\n" : ""
920
+ return `${note}Mermaid render FAILED — no image produced. Fix with edit_mermaid and re-render.\n\nError:\n${detail}`
921
+ }
922
+ if (args.save_as) {
923
+ const { filename, path: dest } = publishPng(outPath, String(args.save_as), ctx.directory)
924
+ // Return image as attachment if possible? For now return filename instruction.
925
+ return `Published to viz/.\nfilename: ${filename}\npath: ${dest}\n\nLOOK at the diagram below to confirm it is correct before returning it.\nEmbed as ![[${filename}|500]]`
926
+ }
927
+ 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.`
928
+ },
929
+ }),
930
+
931
+ // ── Visual tools (svg) ─────────────────────────────────────────────
932
+ write_svg: tool({
933
+ description: "Write the FULL SVG source to this session's managed file. You do NOT name the file — edit_svg and render_svg act on same one. `source` is complete <svg ...>…</svg> with explicit width/height or viewBox, readable fonts, light/transparent bg. Writing does NOT render — call render_svg. For small fix prefer edit_svg.",
934
+ args: { source: tool.schema.string().describe("Complete SVG document from <svg to </svg>") },
935
+ async execute(args) {
936
+ const source = (args.source ?? "").trim()
937
+ if (!source) throw new Error("write_svg requires non-empty source")
938
+ if (!source.includes("<svg")) throw new Error("source must be complete <svg>…</svg>")
939
+ svgSession = writeBody("svg", "diagram.svg", source)
940
+ return `Wrote ${source.split("\n").length}-line SVG source. Call render_svg to render, or edit_svg to tweak.`
941
+ },
942
+ }),
943
+ edit_svg: tool({
944
+ description: "Make single exact-match replacement in this session's SVG source — same contract as edit, locked to managed file. `old_text` must appear EXACTLY ONCE. Call write_svg first. Editing does NOT render.",
945
+ args: {
946
+ old_text: tool.schema.string().describe("Exact substring to replace (must match once)"),
947
+ new_text: tool.schema.string().describe("Replacement text"),
948
+ },
949
+ async execute(args) {
950
+ if (!svgSession || !fs.existsSync(svgSession.bodyPath)) throw new Error("edit_svg: no source yet — call write_svg first.")
951
+ const current = fs.readFileSync(svgSession.bodyPath, "utf8")
952
+ const { updated, index } = applyEdit(current, String(args.old_text ?? ""), String(args.new_text ?? ""))
953
+ fs.writeFileSync(svgSession.bodyPath, updated, "utf8")
954
+ return `Applied edit. Updated region:\n\`\`\`\n${snippetAround(updated, index)}\n\`\`\`\nCall render_svg to see it.`
955
+ },
956
+ }),
957
+ render_svg: tool({
958
+ 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 — 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 — fix with edit_svg.",
959
+ 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.") },
960
+ async execute(args, ctx) {
961
+ if (!svgSession || !fs.existsSync(svgSession.bodyPath)) throw new Error("render_svg: no source yet — call write_svg first.")
962
+ const { workDir, bodyPath } = svgSession
963
+ fs.mkdirSync(workDir, { recursive: true })
964
+ const outPath = path.join(workDir, `render-${Date.now()}.png`)
965
+ // Try rsvg-convert then magick
966
+ let res = await run("rsvg-convert", ["-z", "2", bodyPath, "-o", outPath], { cwd: workDir, timeoutMs: 60_000 })
967
+ let ok = res.code === 0 && fs.existsSync(outPath)
968
+ if (!ok) {
969
+ const magickRes = await run("magick", ["-density", "192", "-background", "white", bodyPath, outPath], { cwd: workDir, timeoutMs: 60_000 })
970
+ if (magickRes.code === 0 && fs.existsSync(outPath)) { res = magickRes; ok = true }
971
+ }
972
+ if (!ok) {
973
+ const detail = (res.stderr || res.stdout || "unknown error").split("\n").slice(-30).join("\n")
974
+ const note = res.timedOut ? "SVG render timed out.\n\n" : ""
975
+ return `${note}SVG render FAILED — no image produced (tried rsvg-convert then magick). Fix with edit_svg and re-render.\n\nError:\n${detail}`
976
+ }
977
+ if (args.save_as) {
978
+ const { filename, path: dest } = publishPng(outPath, String(args.save_as), ctx.directory)
979
+ return `Published to viz/.\nfilename: ${filename}\npath: ${dest}\n\nLOOK at the picture below to confirm geometry is correct before returning. Embed as ![[${filename}|500]]`
980
+ }
981
+ 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.`
982
+ },
983
+ }),
984
+ },
985
+ }
986
+ }
987
+
988
+ export default {
989
+ id: "learn",
990
+ server,
991
+ } satisfies PluginModule & { id: string }