@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/CHANGELOG.md +10 -0
- package/LICENSE +25 -0
- package/README.md +151 -0
- package/agents/mermaid-maker.md +61 -0
- package/agents/researcher.md +53 -0
- package/agents/svg-maker.md +65 -0
- package/commands/md_log.md +5 -0
- package/commands/md_unlog.md +5 -0
- package/dist/server.js +1181 -0
- package/dist/tui.js +1528 -0
- package/package.json +92 -0
- package/plugins/learn-tui.tsx +548 -0
- package/plugins/learn.ts +991 -0
- package/scripts/build-tui.ts +20 -0
- package/scripts/install.mjs +250 -0
- package/skills/marker-pdf-parser/README.md +38 -0
- package/skills/marker-pdf-parser/SKILL.md +130 -0
- package/skills/marker-pdf-parser/requirements.txt +1 -0
- package/skills/marker-pdf-parser/scripts/parse_pdf.py +150 -0
- package/skills/notebooklm-lecture-notes/SKILL.md +173 -0
- package/skills/notebooklm-lecture-notes/references/prompts.md +40 -0
- package/skills/teach/SKILL.md +150 -0
- package/skills/visualize/SKILL.md +82 -0
package/plugins/learn.ts
ADDED
|
@@ -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 }
|