@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/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(args.options as any) } catch (e) { return `quiz error: ${(e as Error).message}` }
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: args.question,
659
- details: args.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: args.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: ${args.question.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
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: args.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] "${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}`
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", args.question, args.details?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
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: ${args.explanation}`
714
- if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [args.question, trimmed, `Correct: ${correctStr}`, args.explanation])))
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: ${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])))
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: ${args.question}`,
731
- args.details ? `Details: ${args.details}` : null,
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): ${args.explanation}`,
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: "${args.question.replace(/"/g, '\\"')}"`,
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: ${args.question.slice(0, 40)}`, metadata: { correctIndices, explanation: args.explanation, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
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(q.options) } catch (e) { slog("quiz_batch normalize error", (e as Error).message); return `quiz_batch error: ${(e as Error).message} in "${q.question}"` }
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 "${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 })
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 {}
@@ -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)