@bojackduy/opencode-learn 0.1.5 → 1.0.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/agents/classify.md +17 -0
- package/dist/server.js +220 -27
- package/dist/tui.js +1394 -490
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +344 -34
- package/plugins/learn.ts +197 -25
- package/scripts/install.mjs +3 -3
package/plugins/learn.ts
CHANGED
|
@@ -125,6 +125,15 @@ function resolveCorrect(correctAnswer: string | string[] | undefined, options: A
|
|
|
125
125
|
return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) as number[] }
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function decodeQuizText(s: string | undefined): string | undefined {
|
|
129
|
+
if (!s || typeof s !== "string") return s
|
|
130
|
+
if (!s.includes("\\")) return s
|
|
131
|
+
let out = s.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
|
|
132
|
+
out = out.replace(/\\"/g, '"').replace(/\\'/g, "'")
|
|
133
|
+
out = out.replace(/\\\\/g, "\\")
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
// ────────────────────────────────────────────────────────────────────────────
|
|
129
138
|
// md-log helpers (ported from .pi/extensions/md-log.ts)
|
|
130
139
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -454,11 +463,164 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
454
463
|
const loggedToolCallIds = new Set<string>()
|
|
455
464
|
const messageIdToRole = new Map<string, string>()
|
|
456
465
|
|
|
466
|
+
// ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
|
|
467
|
+
function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>): number[] {
|
|
468
|
+
const n = note.toLowerCase()
|
|
469
|
+
const out: number[] = []
|
|
470
|
+
for (let i = 0; i < options.length; i++) {
|
|
471
|
+
const o = options[i]
|
|
472
|
+
const label = (o.label || "").toLowerCase()
|
|
473
|
+
const value = (o.value || "").toLowerCase()
|
|
474
|
+
if (label && n.includes(label)) out.push(i + 1)
|
|
475
|
+
else if (value && n.includes(value)) out.push(i + 1)
|
|
476
|
+
else {
|
|
477
|
+
const tokens = label.split(/[^a-z0-9]+/).filter(t => t.length >= 3)
|
|
478
|
+
if (tokens.some(t => n.includes(t))) out.push(i + 1)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return [...new Set(out)]
|
|
482
|
+
}
|
|
483
|
+
async function llmClassify(client: any, directory: string, note: string, options: Array<{ label: string; value?: string }>, question?: string, parentSessionID?: string): Promise<{ inferred: number[]; semanticCorrect?: boolean; reason?: string; sessionID?: string }> {
|
|
484
|
+
const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options.
|
|
485
|
+
|
|
486
|
+
${question ? `Question: ${question}\n` : ""}Options:
|
|
487
|
+
${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join("\n")}
|
|
488
|
+
|
|
489
|
+
Learner note: "${note}"
|
|
490
|
+
|
|
491
|
+
Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed). 2) semanticCorrect: true if note shows valid understanding or deeper nuance even when inferred != correct key (e.g., note about rotate array variant vs standard sorted is valid nuance). 3) reason: short English reason.
|
|
492
|
+
|
|
493
|
+
Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`
|
|
494
|
+
try {
|
|
495
|
+
const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`
|
|
496
|
+
const body: any = { title }
|
|
497
|
+
if (parentSessionID) body.parentID = parentSessionID
|
|
498
|
+
const created: any = await client.session.create({ body, query: { directory } })
|
|
499
|
+
const sid = created?.data?.id || created?.id || created?.data?.sessionID
|
|
500
|
+
if (!sid) throw new Error("no sid")
|
|
501
|
+
const createdSession = created?.data || created
|
|
502
|
+
slog("classify subagent created", sid, `requestedParent:${parentSessionID || "none"}`, `actualParent:${createdSession?.parentID || "none"}`, note.slice(0, 40))
|
|
503
|
+
if (parentSessionID && createdSession?.parentID !== parentSessionID) {
|
|
504
|
+
throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`)
|
|
505
|
+
}
|
|
506
|
+
await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } })
|
|
507
|
+
// Poll for assistant response up to 12s
|
|
508
|
+
for (let i = 0; i < 24; i++) {
|
|
509
|
+
await new Promise(r => setTimeout(r, 500))
|
|
510
|
+
try {
|
|
511
|
+
const msgs: any = await client.session.messages({ path: { id: sid } })
|
|
512
|
+
const data = msgs?.data || msgs
|
|
513
|
+
const arr = Array.isArray(data) ? data : []
|
|
514
|
+
for (let j = arr.length - 1; j >= 0; j--) {
|
|
515
|
+
const entry = arr[j]
|
|
516
|
+
if (entry?.info?.role === "assistant") {
|
|
517
|
+
const text = (entry.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join(" ") || ""
|
|
518
|
+
// Try object JSON {"inferred":[2],"semanticCorrect":false}
|
|
519
|
+
const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/)
|
|
520
|
+
if (objMatch) {
|
|
521
|
+
try {
|
|
522
|
+
const parsed = JSON.parse(objMatch[0])
|
|
523
|
+
if (parsed && Array.isArray(parsed.inferred)) {
|
|
524
|
+
const nums = parsed.inferred.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
|
|
525
|
+
slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`)
|
|
526
|
+
return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid }
|
|
527
|
+
}
|
|
528
|
+
} catch {}
|
|
529
|
+
}
|
|
530
|
+
const m = text.match(/\[[\s\d,]*\]/)
|
|
531
|
+
if (m) {
|
|
532
|
+
try {
|
|
533
|
+
const parsed = JSON.parse(m[0])
|
|
534
|
+
if (Array.isArray(parsed)) {
|
|
535
|
+
const nums = parsed.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
|
|
536
|
+
if (nums.length) {
|
|
537
|
+
slog("llmClassify success array", note.slice(0, 40), nums.join(","))
|
|
538
|
+
return { inferred: nums, sessionID: sid }
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} catch {}
|
|
542
|
+
}
|
|
543
|
+
if (text.includes("1") || text.includes("2")) {
|
|
544
|
+
const nums = [...text.matchAll(/\b([1-9])\b/g)].map(x => parseInt(x[1])).filter(n => n <= options.length)
|
|
545
|
+
if (nums.length) return { inferred: [...new Set(nums)], sessionID: sid }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
slog("llmClassify timeout", note.slice(0, 40))
|
|
552
|
+
} catch (e) {
|
|
553
|
+
slog("llmClassify failed", String(e).slice(0, 200))
|
|
554
|
+
}
|
|
555
|
+
return { inferred: [] }
|
|
556
|
+
}
|
|
557
|
+
function startClassifyWatcher(client: any, directory: string) {
|
|
558
|
+
const dir = pendingDir(directory)
|
|
559
|
+
try { fs.mkdirSync(dir, { recursive: true }) } catch {}
|
|
560
|
+
const processClassify = async (filename: string) => {
|
|
561
|
+
if (!filename.startsWith("classify-") || filename.startsWith("classify-response-")) return
|
|
562
|
+
const fp = path.join(dir, filename)
|
|
563
|
+
if (!fs.existsSync(fp)) return
|
|
564
|
+
const respPath = path.join(dir, filename.replace("classify-", "classify-response-"))
|
|
565
|
+
if (fs.existsSync(respPath)) return
|
|
566
|
+
let data: any
|
|
567
|
+
try { data = JSON.parse(fs.readFileSync(fp, "utf8")) } catch { return }
|
|
568
|
+
if (data?.type !== "classify" || !data?.note || !Array.isArray(data?.options)) return
|
|
569
|
+
slog("classify watcher processing", data.id, data.note.slice(0, 80))
|
|
570
|
+
const byVal = new Map<string, number>(data.options.map((o: any, i: number) => [o.value, i + 1] as [string, number]))
|
|
571
|
+
const start = Date.now()
|
|
572
|
+
let inferred: number[] = []
|
|
573
|
+
let semanticCorrect: boolean | undefined
|
|
574
|
+
let reason: string | undefined
|
|
575
|
+
const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID)
|
|
576
|
+
if (llmRes.inferred.length) {
|
|
577
|
+
inferred = llmRes.inferred
|
|
578
|
+
semanticCorrect = llmRes.semanticCorrect
|
|
579
|
+
reason = llmRes.reason
|
|
580
|
+
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`)
|
|
581
|
+
} else {
|
|
582
|
+
inferred = heuristicClassify(data.note, data.options)
|
|
583
|
+
if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","))
|
|
584
|
+
else slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`)
|
|
585
|
+
}
|
|
586
|
+
// Ensure minimal classify time so UI doesn't feel instant-wrong (at least 1200ms)
|
|
587
|
+
const elapsed = Date.now() - start
|
|
588
|
+
if (elapsed < 1200) await new Promise(r => setTimeout(r, 1200 - elapsed))
|
|
589
|
+
const inferredValues = inferred.map((i: number) => data.options[i - 1]?.value).filter(Boolean) as string[]
|
|
590
|
+
// Final fallback if still empty
|
|
591
|
+
if (!inferred.length && data.note) {
|
|
592
|
+
const n = data.note.toLowerCase()
|
|
593
|
+
for (const o of data.options) {
|
|
594
|
+
const v = o.value ? String(o.value).toLowerCase() : ""
|
|
595
|
+
if (v && n.includes(v) && !inferred.includes(byVal.get(o.value) as number)) {
|
|
596
|
+
const idx = byVal.get(o.value) as number | undefined
|
|
597
|
+
if (idx) inferred.push(idx)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
|
|
602
|
+
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
|
|
603
|
+
try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
|
|
604
|
+
}
|
|
605
|
+
// Initial sweep
|
|
606
|
+
try {
|
|
607
|
+
for (const f of fs.readdirSync(dir).filter(f => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
608
|
+
void processClassify(f)
|
|
609
|
+
}
|
|
610
|
+
} catch {}
|
|
611
|
+
try {
|
|
612
|
+
const w = fs.watch(dir, (_e, filename) => { if (filename) void processClassify(filename) })
|
|
613
|
+
w.on("error", () => {})
|
|
614
|
+
// Keep watcher alive; store to avoid GC? No need.
|
|
615
|
+
} catch {}
|
|
616
|
+
}
|
|
617
|
+
startClassifyWatcher(client, directory)
|
|
618
|
+
|
|
457
619
|
// Durability: on (re)start, re-watch any pending quizzes left from a crash/exit
|
|
458
620
|
try {
|
|
459
621
|
const dir = pendingDir(directory)
|
|
460
622
|
if (fs.existsSync(dir)) {
|
|
461
|
-
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
|
|
623
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
462
624
|
try {
|
|
463
625
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
|
|
464
626
|
if (j?.id && j?.sessionID) {
|
|
@@ -511,7 +673,7 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
511
673
|
config: async (output) => {
|
|
512
674
|
const agents = (output as any).agent ?? {}
|
|
513
675
|
let mutated = false
|
|
514
|
-
for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
|
|
676
|
+
for (const name of ["researcher", "mermaid-maker", "svg-maker", "classify"]) {
|
|
515
677
|
if (!agents[name]) {
|
|
516
678
|
// Minimal placeholder — real agent definitions live in .opencode/agents/*.md
|
|
517
679
|
// We inject a lightweight config so `task` tool can discover them even if md file is missing.
|
|
@@ -635,8 +797,14 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
635
797
|
shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters."),
|
|
636
798
|
},
|
|
637
799
|
async execute(args, ctx) {
|
|
800
|
+
// Fix double-escaped \n coming from LLM JSON (e.g. "\\n" literal instead of newline)
|
|
801
|
+
const qFixed = (decodeQuizText(args.question) ?? args.question) as string
|
|
802
|
+
const dFixed = decodeQuizText(args.details) as string | undefined
|
|
803
|
+
const eFixed = (decodeQuizText(args.explanation) ?? args.explanation) as string
|
|
804
|
+
// Also decode option labels in case they contain code
|
|
805
|
+
const optsDecoded = (args.options as any[] | undefined)?.map((o: any) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description })) as any
|
|
638
806
|
let options: Array<{ label: string; value: string; description?: string }>
|
|
639
|
-
try { options = normalizeQuizOptions(
|
|
807
|
+
try { options = normalizeQuizOptions(optsDecoded) } catch (e) { return `quiz error: ${(e as Error).message}` }
|
|
640
808
|
if (args.shuffle !== false) options = shuffleOptions(options)
|
|
641
809
|
const { indices: correctIndices, error: correctError } = resolveCorrect(args.correctAnswer as any, options)
|
|
642
810
|
if (correctError) return `quiz error: ${correctError}`
|
|
@@ -655,17 +823,17 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
655
823
|
const payload = {
|
|
656
824
|
id,
|
|
657
825
|
type: "quiz" as const,
|
|
658
|
-
question:
|
|
659
|
-
details:
|
|
826
|
+
question: qFixed,
|
|
827
|
+
details: dFixed,
|
|
660
828
|
options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
|
|
661
829
|
correctIndices,
|
|
662
|
-
explanation:
|
|
830
|
+
explanation: eFixed,
|
|
663
831
|
multiSelect: !!args.multiSelect,
|
|
664
832
|
sessionID: (ctx as any).sessionID,
|
|
665
833
|
timestamp: Date.now(),
|
|
666
834
|
}
|
|
667
835
|
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: ${
|
|
836
|
+
try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
|
|
669
837
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
670
838
|
const dk = !!r?.dontKnow
|
|
671
839
|
const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
|
|
@@ -679,19 +847,19 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
679
847
|
answers: r?.answers || [],
|
|
680
848
|
correct: ok,
|
|
681
849
|
correctIndices,
|
|
682
|
-
explanation:
|
|
850
|
+
explanation: eFixed,
|
|
683
851
|
dontKnow: dk,
|
|
684
852
|
note: r?.note,
|
|
685
853
|
}
|
|
686
854
|
void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
|
|
687
855
|
}
|
|
688
856
|
return dk
|
|
689
|
-
? `[quiz answered] "${
|
|
690
|
-
: `[quiz answered] "${
|
|
857
|
+
? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
858
|
+
: `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
691
859
|
})
|
|
692
860
|
// Always mirror question with TRUE shuffled order (pi: tool_execution_update)
|
|
693
861
|
if (mdLogFile) {
|
|
694
|
-
try { await withMdLock(() => appendToMdLog(questionCallout("Quiz",
|
|
862
|
+
try { await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
|
|
695
863
|
}
|
|
696
864
|
if (tuiAlive) {
|
|
697
865
|
return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
|
|
@@ -710,8 +878,8 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
710
878
|
if (raw === null) return "User cancelled the quiz"
|
|
711
879
|
const trimmed = (raw as string).trim()
|
|
712
880
|
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: ${
|
|
714
|
-
if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [
|
|
881
|
+
const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
882
|
+
if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
|
|
715
883
|
return msg
|
|
716
884
|
}
|
|
717
885
|
const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
|
|
@@ -720,28 +888,28 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
720
888
|
const correct = selectedSet.size === correctSet.size && [...selectedSet].every(n => correctSet.has(n))
|
|
721
889
|
const selectedStr = nums.map(n => `${n}. ${options[n - 1].label}`).join(", ") || "(none)"
|
|
722
890
|
const verdict = correct ? "correctly" : "incorrectly"
|
|
723
|
-
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${
|
|
724
|
-
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation:
|
|
725
|
-
if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${
|
|
891
|
+
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
892
|
+
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: eFixed } })
|
|
893
|
+
if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
|
|
726
894
|
return result
|
|
727
895
|
}
|
|
728
896
|
const instruction = [
|
|
729
897
|
`[quiz ready — awaiting user answer via \`question\` tool]`,
|
|
730
|
-
`Question: ${
|
|
731
|
-
|
|
898
|
+
`Question: ${qFixed}`,
|
|
899
|
+
dFixed ? `Details: ${dFixed}` : null,
|
|
732
900
|
`Options (display order, already shuffled):`,
|
|
733
901
|
...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""} (value="${o.value}")`),
|
|
734
902
|
`Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
|
|
735
|
-
`Explanation (reveal AFTER answer): ${
|
|
903
|
+
`Explanation (reveal AFTER answer): ${eFixed}`,
|
|
736
904
|
`Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
|
|
737
905
|
``,
|
|
738
906
|
`INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
|
|
739
907
|
` header: "Quiz"`,
|
|
740
|
-
` question: "${
|
|
908
|
+
` question: "${qFixed.replace(/"/g, '\\"')}"`,
|
|
741
909
|
` options: [${options.map(o => `{label:"${o.label.replace(/"/g, '\\"')}", description:"${(o.description ?? "").replace(/"/g, '\\"')}"}`).join(", ")}]`,
|
|
742
910
|
`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
911
|
].filter(Boolean).join("\n")
|
|
744
|
-
;(ctx as any).metadata?.({ title: `Quiz: ${
|
|
912
|
+
;(ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
|
|
745
913
|
return instruction
|
|
746
914
|
},
|
|
747
915
|
}),
|
|
@@ -771,13 +939,17 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
771
939
|
slog("quiz_batch isAlive", isAlive)
|
|
772
940
|
const normalized: any[] = []
|
|
773
941
|
for (const q of (args.quizzes as any[])) {
|
|
942
|
+
const qFixed = (decodeQuizText(q.question) ?? q.question) as string
|
|
943
|
+
const dFixed = decodeQuizText(q.details) as string | undefined
|
|
944
|
+
const eFixed = (decodeQuizText(q.explanation) ?? q.explanation) as string
|
|
945
|
+
const optsDecoded = (q.options as any[] | undefined)?.map((o: any) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description })) as any
|
|
774
946
|
let opts: any
|
|
775
|
-
try { opts = normalizeQuizOptions(
|
|
947
|
+
try { opts = normalizeQuizOptions(optsDecoded) } catch (e) { slog("quiz_batch normalize error", (e as Error).message); return `quiz_batch error: ${(e as Error).message} in "${qFixed}"` }
|
|
776
948
|
if (q.shuffle !== false) opts = shuffleOptions(opts)
|
|
777
949
|
const { indices, error } = resolveCorrect(q.correctAnswer as any, opts)
|
|
778
|
-
if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${
|
|
779
|
-
if (opts.length < 2) return `quiz_batch error: need 2+ options in "${
|
|
780
|
-
normalized.push({ question:
|
|
950
|
+
if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${qFixed}"` }
|
|
951
|
+
if (opts.length < 2) return `quiz_batch error: need 2+ options in "${qFixed}"`
|
|
952
|
+
normalized.push({ question: qFixed, details: dFixed, options: opts, correctIndices: indices, explanation: eFixed, multiSelect: !!q.multiSelect })
|
|
781
953
|
}
|
|
782
954
|
slog("quiz_batch normalized", normalized.length)
|
|
783
955
|
try { fs.mkdirSync(pendingDirPath, { recursive: true }) } catch {}
|
package/scripts/install.mjs
CHANGED
|
@@ -184,7 +184,7 @@ async function installOrUpdate() {
|
|
|
184
184
|
const agentsSrc = join(root, "agents")
|
|
185
185
|
const agentsDest = join(config, "agents")
|
|
186
186
|
let agentsCount = 0
|
|
187
|
-
for (const f of ["researcher.md", "mermaid-maker.md", "svg-maker.md"]) {
|
|
187
|
+
for (const f of ["researcher.md", "mermaid-maker.md", "svg-maker.md", "classify.md"]) {
|
|
188
188
|
try {
|
|
189
189
|
await copyFile(join(agentsSrc, f), join(agentsDest, f))
|
|
190
190
|
agentsCount++
|
|
@@ -216,7 +216,7 @@ async function installOrUpdate() {
|
|
|
216
216
|
|
|
217
217
|
console.log(`Installed ${packageName}@${packageVersion} to ${config}`)
|
|
218
218
|
if (changed) console.log(`Updated plugin registration in ${changed} config file(s)`)
|
|
219
|
-
console.log(` Agents: ${agentsCount} (researcher, mermaid-maker, svg-maker)`)
|
|
219
|
+
console.log(` Agents: ${agentsCount} (researcher, mermaid-maker, svg-maker, classify)`)
|
|
220
220
|
console.log(` Skills: ${skillsCount} (teach, visualize, marker-pdf-parser, notebooklm-lecture-notes)`)
|
|
221
221
|
if (commandsCount) console.log(` Commands: ${commandsCount}`)
|
|
222
222
|
console.log(` Plugin: ${packageName} (server) + ${packageName}/tui (TUI)`)
|
|
@@ -230,7 +230,7 @@ async function uninstall() {
|
|
|
230
230
|
const changed = await configurePlugins(true)
|
|
231
231
|
|
|
232
232
|
// Remove agents (only those we own)
|
|
233
|
-
for (const f of ["researcher.md", "mermaid-maker.md", "svg-maker.md"]) {
|
|
233
|
+
for (const f of ["researcher.md", "mermaid-maker.md", "svg-maker.md", "classify.md"]) {
|
|
234
234
|
try { await rm(join(config, "agents", f), { force: true }) } catch {}
|
|
235
235
|
}
|
|
236
236
|
// Remove skills (including subdirectories like scripts/assets)
|