@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.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: classify
3
+ description: Map learner free-text note (Vietnamese or English) to closest quiz option(s) and judge semantic correctness. Returns strict JSON only.
4
+ thinking: low
5
+ system-prompt: append
6
+ auto-exit: true
7
+ mode: subagent
8
+ ---
9
+
10
+ You map a learner's free-text note to quiz options. You are lenient for learner-easy but auditable.
11
+
12
+ Rules:
13
+
14
+ - Only pick from given Options 1..N, no new options. Return inferred as indices that note best matches. Consider Vietnamese translations, synonyms, and "not fully" hedges.
15
+ - Also judge semanticCorrect: true if note demonstrates valid understanding or deeper nuance, even when inferred != correct key. For standard facts (e.g., binary search requires sorted for vanilla), a note about rotate/mountain variant is valid nuance but the standard True still stands — in that case inferred is [2] but semanticCorrect may be true if note shows insight; the popup will show both.
16
+ - Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"short reason in English"} — no extra text, no markdown.
17
+ - If note is vague, empty, or "I don't know", inferred=[], semanticCorrect=false.
package/dist/server.js CHANGED
@@ -147,6 +147,18 @@ function resolveCorrect(correctAnswer, options) {
147
147
  }
148
148
  return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) };
149
149
  }
150
+ function decodeQuizText(s) {
151
+ if (!s || typeof s !== "string")
152
+ return s;
153
+ if (!s.includes("\\"))
154
+ return s;
155
+ let out = s.replace(/\\r\\n/g, `
156
+ `).replace(/\\n/g, `
157
+ `).replace(/\\r/g, "\r").replace(/\\t/g, "\t");
158
+ out = out.replace(/\\"/g, '"').replace(/\\'/g, "'");
159
+ out = out.replace(/\\\\/g, "\\");
160
+ return out;
161
+ }
150
162
  var mdLogFile = null;
151
163
  var mdLogWriteLock = Promise.resolve();
152
164
  function withMdLock(fn) {
@@ -513,10 +525,183 @@ var server = async ({ client, directory }) => {
513
525
  const loggedTextPartIds = new Set;
514
526
  const loggedToolCallIds = new Set;
515
527
  const messageIdToRole = new Map;
528
+ function heuristicClassify(note, options) {
529
+ const n = note.toLowerCase();
530
+ const out = [];
531
+ for (let i = 0;i < options.length; i++) {
532
+ const o = options[i];
533
+ const label = (o.label || "").toLowerCase();
534
+ const value = (o.value || "").toLowerCase();
535
+ if (label && n.includes(label))
536
+ out.push(i + 1);
537
+ else if (value && n.includes(value))
538
+ out.push(i + 1);
539
+ else {
540
+ const tokens = label.split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
541
+ if (tokens.some((t) => n.includes(t)))
542
+ out.push(i + 1);
543
+ }
544
+ }
545
+ return [...new Set(out)];
546
+ }
547
+ async function llmClassify(client2, directory2, note, options, question, parentSessionID) {
548
+ 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.
549
+
550
+ ${question ? `Question: ${question}
551
+ ` : ""}Options:
552
+ ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join(`
553
+ `)}
554
+
555
+ Learner note: "${note}"
556
+
557
+ 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.
558
+
559
+ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`;
560
+ try {
561
+ const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`;
562
+ const body = { title };
563
+ if (parentSessionID)
564
+ body.parentID = parentSessionID;
565
+ const created = await client2.session.create({ body, query: { directory: directory2 } });
566
+ const sid = created?.data?.id || created?.id || created?.data?.sessionID;
567
+ if (!sid)
568
+ throw new Error("no sid");
569
+ const createdSession = created?.data || created;
570
+ slog("classify subagent created", sid, `requestedParent:${parentSessionID || "none"}`, `actualParent:${createdSession?.parentID || "none"}`, note.slice(0, 40));
571
+ if (parentSessionID && createdSession?.parentID !== parentSessionID) {
572
+ throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`);
573
+ }
574
+ await client2.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } });
575
+ for (let i = 0;i < 24; i++) {
576
+ await new Promise((r) => setTimeout(r, 500));
577
+ try {
578
+ const msgs = await client2.session.messages({ path: { id: sid } });
579
+ const data = msgs?.data || msgs;
580
+ const arr = Array.isArray(data) ? data : [];
581
+ for (let j = arr.length - 1;j >= 0; j--) {
582
+ const entry = arr[j];
583
+ if (entry?.info?.role === "assistant") {
584
+ const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
585
+ const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
586
+ if (objMatch) {
587
+ try {
588
+ const parsed = JSON.parse(objMatch[0]);
589
+ if (parsed && Array.isArray(parsed.inferred)) {
590
+ const nums = parsed.inferred.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
591
+ slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
592
+ return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
593
+ }
594
+ } catch {}
595
+ }
596
+ const m = text.match(/\[[\s\d,]*\]/);
597
+ if (m) {
598
+ try {
599
+ const parsed = JSON.parse(m[0]);
600
+ if (Array.isArray(parsed)) {
601
+ const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
602
+ if (nums.length) {
603
+ slog("llmClassify success array", note.slice(0, 40), nums.join(","));
604
+ return { inferred: nums, sessionID: sid };
605
+ }
606
+ }
607
+ } catch {}
608
+ }
609
+ if (text.includes("1") || text.includes("2")) {
610
+ const nums = [...text.matchAll(/\b([1-9])\b/g)].map((x) => parseInt(x[1])).filter((n) => n <= options.length);
611
+ if (nums.length)
612
+ return { inferred: [...new Set(nums)], sessionID: sid };
613
+ }
614
+ }
615
+ }
616
+ } catch {}
617
+ }
618
+ slog("llmClassify timeout", note.slice(0, 40));
619
+ } catch (e) {
620
+ slog("llmClassify failed", String(e).slice(0, 200));
621
+ }
622
+ return { inferred: [] };
623
+ }
624
+ function startClassifyWatcher(client2, directory2) {
625
+ const dir = pendingDir(directory2);
626
+ try {
627
+ fs.mkdirSync(dir, { recursive: true });
628
+ } catch {}
629
+ const processClassify = async (filename) => {
630
+ if (!filename.startsWith("classify-") || filename.startsWith("classify-response-"))
631
+ return;
632
+ const fp = path.join(dir, filename);
633
+ if (!fs.existsSync(fp))
634
+ return;
635
+ const respPath = path.join(dir, filename.replace("classify-", "classify-response-"));
636
+ if (fs.existsSync(respPath))
637
+ return;
638
+ let data;
639
+ try {
640
+ data = JSON.parse(fs.readFileSync(fp, "utf8"));
641
+ } catch {
642
+ return;
643
+ }
644
+ if (data?.type !== "classify" || !data?.note || !Array.isArray(data?.options))
645
+ return;
646
+ slog("classify watcher processing", data.id, data.note.slice(0, 80));
647
+ const byVal = new Map(data.options.map((o, i) => [o.value, i + 1]));
648
+ const start = Date.now();
649
+ let inferred = [];
650
+ let semanticCorrect;
651
+ let reason;
652
+ const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID);
653
+ if (llmRes.inferred.length) {
654
+ inferred = llmRes.inferred;
655
+ semanticCorrect = llmRes.semanticCorrect;
656
+ reason = llmRes.reason;
657
+ slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`);
658
+ } else {
659
+ inferred = heuristicClassify(data.note, data.options);
660
+ if (inferred.length)
661
+ slog("classify heuristic hit", data.id, inferred.join(","));
662
+ else
663
+ slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`);
664
+ }
665
+ const elapsed = Date.now() - start;
666
+ if (elapsed < 1200)
667
+ await new Promise((r) => setTimeout(r, 1200 - elapsed));
668
+ const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
669
+ if (!inferred.length && data.note) {
670
+ const n = data.note.toLowerCase();
671
+ for (const o of data.options) {
672
+ const v = o.value ? String(o.value).toLowerCase() : "";
673
+ if (v && n.includes(v) && !inferred.includes(byVal.get(o.value))) {
674
+ const idx = byVal.get(o.value);
675
+ if (idx)
676
+ inferred.push(idx);
677
+ }
678
+ }
679
+ }
680
+ slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
681
+ const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
682
+ try {
683
+ fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
684
+ slog("classify response written", data.id, inferred.join(","));
685
+ } catch {}
686
+ };
687
+ try {
688
+ for (const f of fs.readdirSync(dir).filter((f2) => f2.startsWith("classify-") && !f2.startsWith("classify-response-"))) {
689
+ processClassify(f);
690
+ }
691
+ } catch {}
692
+ try {
693
+ const w = fs.watch(dir, (_e, filename) => {
694
+ if (filename)
695
+ processClassify(filename);
696
+ });
697
+ w.on("error", () => {});
698
+ } catch {}
699
+ }
700
+ startClassifyWatcher(client, directory);
516
701
  try {
517
702
  const dir = pendingDir(directory);
518
703
  if (fs.existsSync(dir)) {
519
- for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
704
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
520
705
  try {
521
706
  const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
522
707
  if (j?.id && j?.sessionID) {
@@ -574,7 +759,7 @@ Explanation: ${j.explanation}${note}`;
574
759
  config: async (output) => {
575
760
  const agents = output.agent ?? {};
576
761
  let mutated = false;
577
- for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
762
+ for (const name of ["researcher", "mermaid-maker", "svg-maker", "classify"]) {
578
763
  if (!agents[name]) {
579
764
  agents[name] = { mode: "subagent", description: `${name} subagent (from learn plugin)`, permission: { "*": "allow" } };
580
765
  mutated = true;
@@ -718,9 +903,13 @@ Explanation: ${j.explanation}${note}`;
718
903
  shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters.")
719
904
  },
720
905
  async execute(args, ctx) {
906
+ const qFixed = decodeQuizText(args.question) ?? args.question;
907
+ const dFixed = decodeQuizText(args.details);
908
+ const eFixed = decodeQuizText(args.explanation) ?? args.explanation;
909
+ const optsDecoded = args.options?.map((o) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description }));
721
910
  let options;
722
911
  try {
723
- options = normalizeQuizOptions(args.options);
912
+ options = normalizeQuizOptions(optsDecoded);
724
913
  } catch (e) {
725
914
  return `quiz error: ${e.message}`;
726
915
  }
@@ -744,11 +933,11 @@ Explanation: ${j.explanation}${note}`;
744
933
  const payload = {
745
934
  id,
746
935
  type: "quiz",
747
- question: args.question,
748
- details: args.details,
936
+ question: qFixed,
937
+ details: dFixed,
749
938
  options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
750
939
  correctIndices,
751
- explanation: args.explanation,
940
+ explanation: eFixed,
752
941
  multiSelect: !!args.multiSelect,
753
942
  sessionID: ctx.sessionID,
754
943
  timestamp: Date.now()
@@ -760,7 +949,7 @@ Explanation: ${j.explanation}${note}`;
760
949
  slog("quiz write failed", String(e));
761
950
  }
762
951
  try {
763
- await ctx.metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { pendingId: id } });
952
+ await ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } });
764
953
  } catch {}
765
954
  watchAndInject(client, directory, id, ctx.sessionID, (r) => {
766
955
  const dk = !!r?.dontKnow;
@@ -776,21 +965,21 @@ Note: ${r.note}` : "";
776
965
  answers: r?.answers || [],
777
966
  correct: ok,
778
967
  correctIndices,
779
- explanation: args.explanation,
968
+ explanation: eFixed,
780
969
  dontKnow: dk,
781
970
  note: r?.note
782
971
  };
783
972
  withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
784
973
  }
785
- return dk ? `[quiz answered] "${args.question}" -> I don't know (genuine gap).
974
+ return dk ? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).
786
975
  Correct: ${correctStr}
787
- Explanation: ${args.explanation}${note}` : `[quiz answered] "${args.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.
976
+ Explanation: ${eFixed}${note}` : `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.
788
977
  Correct: ${correctStr}
789
- Explanation: ${args.explanation}${note}`;
978
+ Explanation: ${eFixed}${note}`;
790
979
  });
791
980
  if (mdLogFile) {
792
981
  try {
793
- await withMdLock(() => appendToMdLog(questionCallout("Quiz", args.question, args.details?.trim() || undefined, options.map((o) => ({ label: o.label })))));
982
+ await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label })))));
794
983
  } catch {}
795
984
  }
796
985
  if (tuiAlive) {
@@ -825,9 +1014,9 @@ ${args.multiSelect ? "Select all correct (comma-separated numbers, e.g. 1,3) or
825
1014
  if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
826
1015
  const msg = `User selected "I don't know" \u2014 genuine gap, not a guess.
827
1016
  Correct: ${correctStr}
828
- Explanation: ${args.explanation}`;
1017
+ Explanation: ${eFixed}`;
829
1018
  if (mdLogFile)
830
- await withMdLock(() => appendToMdLog(callout("question", "Quiz \u2014 I don't know", [args.question, trimmed, `Correct: ${correctStr}`, args.explanation])));
1019
+ await withMdLock(() => appendToMdLog(callout("question", "Quiz \u2014 I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])));
831
1020
  return msg;
832
1021
  }
833
1022
  const nums = trimmed.split(/[,\s]+/).map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n >= 1 && n <= options.length);
@@ -839,30 +1028,30 @@ Explanation: ${args.explanation}`;
839
1028
  const result = `User answered ${verdict}.
840
1029
  Selected: ${selectedStr}
841
1030
  Correct: ${correctStr}
842
- Explanation: ${args.explanation}`;
843
- ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation: args.explanation } });
1031
+ Explanation: ${eFixed}`;
1032
+ ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation: eFixed } });
844
1033
  if (mdLogFile)
845
- await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${args.question}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, args.explanation])));
1034
+ await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])));
846
1035
  return result;
847
1036
  }
848
1037
  const instruction = [
849
1038
  `[quiz ready \u2014 awaiting user answer via \`question\` tool]`,
850
- `Question: ${args.question}`,
851
- args.details ? `Details: ${args.details}` : null,
1039
+ `Question: ${qFixed}`,
1040
+ dFixed ? `Details: ${dFixed}` : null,
852
1041
  `Options (display order, already shuffled):`,
853
1042
  ...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` \u2014 ${o.description}` : ""} (value="${o.value}")`),
854
1043
  `Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
855
- `Explanation (reveal AFTER answer): ${args.explanation}`,
1044
+ `Explanation (reveal AFTER answer): ${eFixed}`,
856
1045
  `Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
857
1046
  ``,
858
1047
  `INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
859
1048
  ` header: "Quiz"`,
860
- ` question: "${args.question.replace(/"/g, "\\\"")}"`,
1049
+ ` question: "${qFixed.replace(/"/g, "\\\"")}"`,
861
1050
  ` options: [${options.map((o) => `{label:"${o.label.replace(/"/g, "\\\"")}", description:"${(o.description ?? "").replace(/"/g, "\\\"")}"}`).join(", ")}]`,
862
1051
  `Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show \u2713/\u2717, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`
863
1052
  ].filter(Boolean).join(`
864
1053
  `);
865
- ctx.metadata?.({ title: `Quiz: ${args.question.slice(0, 40)}`, metadata: { correctIndices, explanation: args.explanation, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } });
1054
+ ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } });
866
1055
  return instruction;
867
1056
  }
868
1057
  }),
@@ -890,23 +1079,27 @@ Explanation: ${args.explanation}`;
890
1079
  slog("quiz_batch isAlive", isAlive);
891
1080
  const normalized = [];
892
1081
  for (const q of args.quizzes) {
1082
+ const qFixed = decodeQuizText(q.question) ?? q.question;
1083
+ const dFixed = decodeQuizText(q.details);
1084
+ const eFixed = decodeQuizText(q.explanation) ?? q.explanation;
1085
+ const optsDecoded = q.options?.map((o) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description }));
893
1086
  let opts;
894
1087
  try {
895
- opts = normalizeQuizOptions(q.options);
1088
+ opts = normalizeQuizOptions(optsDecoded);
896
1089
  } catch (e) {
897
1090
  slog("quiz_batch normalize error", e.message);
898
- return `quiz_batch error: ${e.message} in "${q.question}"`;
1091
+ return `quiz_batch error: ${e.message} in "${qFixed}"`;
899
1092
  }
900
1093
  if (q.shuffle !== false)
901
1094
  opts = shuffleOptions(opts);
902
1095
  const { indices, error } = resolveCorrect(q.correctAnswer, opts);
903
1096
  if (error) {
904
1097
  slog("quiz_batch resolveCorrect error", error);
905
- return `quiz_batch error: ${error} in "${q.question}"`;
1098
+ return `quiz_batch error: ${error} in "${qFixed}"`;
906
1099
  }
907
1100
  if (opts.length < 2)
908
- return `quiz_batch error: need 2+ options in "${q.question}"`;
909
- normalized.push({ question: q.question, details: q.details, options: opts, correctIndices: indices, explanation: q.explanation, multiSelect: !!q.multiSelect });
1101
+ return `quiz_batch error: need 2+ options in "${qFixed}"`;
1102
+ normalized.push({ question: qFixed, details: dFixed, options: opts, correctIndices: indices, explanation: eFixed, multiSelect: !!q.multiSelect });
910
1103
  }
911
1104
  slog("quiz_batch normalized", normalized.length);
912
1105
  try {