@bojackduy/opencode-learn 1.1.0 → 1.1.2

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.
@@ -12,6 +12,7 @@ You map a learner's free-text note to quiz options. You are lenient for learner-
12
12
  Rules:
13
13
 
14
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
+ - Respect question type from prompt: if SINGLE-SELECT (default) you MUST return at most ONE inferred index ( [] or [k] ), never multiple — if note mentions several options, pick the single best. If MULTI-SELECT you may return 0..N. The prompt will state which mode.
15
16
  - 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
17
  - Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"short reason in English"} — no extra text, no markdown.
17
18
  - If note is vague, empty, or "I don't know", inferred=[], semanticCorrect=false.
package/dist/server.js CHANGED
@@ -525,27 +525,38 @@ var server = async ({ client, directory }) => {
525
525
  const loggedTextPartIds = new Set;
526
526
  const loggedToolCallIds = new Set;
527
527
  const messageIdToRole = new Map;
528
- function heuristicClassify(note, options) {
528
+ function heuristicClassify(note, options, multiSelect) {
529
529
  const n = note.toLowerCase();
530
- const out = [];
530
+ const scored = [];
531
531
  for (let i = 0;i < options.length; i++) {
532
532
  const o = options[i];
533
533
  const label = (o.label || "").toLowerCase();
534
534
  const value = (o.value || "").toLowerCase();
535
+ let score = 0;
535
536
  if (label && n.includes(label))
536
- out.push(i + 1);
537
+ score = 3;
537
538
  else if (value && n.includes(value))
538
- out.push(i + 1);
539
+ score = 2;
539
540
  else {
540
541
  const tokens = label.split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
541
542
  if (tokens.some((t) => n.includes(t)))
542
- out.push(i + 1);
543
+ score = 1;
543
544
  }
545
+ if (score)
546
+ scored.push({ idx: i + 1, score, len: label.length });
544
547
  }
545
- return [...new Set(out)];
548
+ scored.sort((a, b) => b.score - a.score || b.len - a.len);
549
+ const out = scored.map((s) => s.idx);
550
+ const uniq = [...new Set(out)];
551
+ if (!multiSelect && uniq.length > 1) {
552
+ slog("heuristicClassify single-select trimmed", uniq.join(","), "->", uniq[0]);
553
+ return [uniq[0]];
554
+ }
555
+ return uniq;
546
556
  }
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.
557
+ async function llmClassify(client2, directory2, note, options, question, parentSessionID, multiSelect) {
558
+ const modeHint = multiSelect ? "This is a MULTI-SELECT question (0..N options may be correct). You may return 0..N inferred indices." : "This is a SINGLE-SELECT question (exactly 0 or 1 inferred). You MUST return at most ONE inferred index. Never return multiple. If note is ambiguous or mentions several options, pick the SINGLE best match. Return [] if vague.";
559
+ 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. ${modeHint}
549
560
 
550
561
  ${question ? `Question: ${question}
551
562
  ` : ""}Options:
@@ -554,7 +565,7 @@ ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).j
554
565
 
555
566
  Learner note: "${note}"
556
567
 
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.
568
+ Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed) \u2014 respect single/multi mode above. 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
569
 
559
570
  Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`;
560
571
  try {
@@ -582,14 +593,23 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
582
593
  const entry = arr[j];
583
594
  if (entry?.info?.role === "assistant") {
584
595
  const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
596
+ const enforceSingle = (arr2) => {
597
+ if (!multiSelect && arr2.length > 1) {
598
+ const trimmed = [arr2[0]];
599
+ slog("llmClassify enforce single", arr2.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single");
600
+ return trimmed;
601
+ }
602
+ return arr2;
603
+ };
585
604
  const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
586
605
  if (objMatch) {
587
606
  try {
588
607
  const parsed = JSON.parse(objMatch[0]);
589
608
  if (parsed && Array.isArray(parsed.inferred)) {
590
609
  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 };
610
+ const fnums = enforceSingle(nums);
611
+ slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
612
+ return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
593
613
  }
594
614
  } catch {}
595
615
  }
@@ -600,16 +620,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
600
620
  if (Array.isArray(parsed)) {
601
621
  const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
602
622
  if (nums.length) {
603
- slog("llmClassify success array", note.slice(0, 40), nums.join(","));
604
- return { inferred: nums, sessionID: sid };
623
+ const fnums = enforceSingle(nums);
624
+ slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`);
625
+ return { inferred: fnums, sessionID: sid };
605
626
  }
606
627
  }
607
628
  } catch {}
608
629
  }
609
630
  if (text.includes("1") || text.includes("2")) {
610
631
  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 };
632
+ if (nums.length) {
633
+ const uniq = [...new Set(nums)];
634
+ const fnums = enforceSingle(uniq);
635
+ return { inferred: fnums, sessionID: sid };
636
+ }
613
637
  }
614
638
  }
615
639
  }
@@ -649,35 +673,53 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
649
673
  let inferred = [];
650
674
  let semanticCorrect;
651
675
  let reason;
652
- const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID);
676
+ const multi = !!data.multiSelect;
677
+ const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID, multi);
653
678
  if (llmRes.inferred.length) {
654
679
  inferred = llmRes.inferred;
655
680
  semanticCorrect = llmRes.semanticCorrect;
656
681
  reason = llmRes.reason;
657
- slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`);
682
+ slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} multi:${multi} sid:${llmRes.sessionID || ""}`);
658
683
  } else {
659
- inferred = heuristicClassify(data.note, data.options);
684
+ inferred = heuristicClassify(data.note, data.options, multi);
660
685
  if (inferred.length)
661
- slog("classify heuristic hit", data.id, inferred.join(","));
686
+ slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi}`);
662
687
  else
663
688
  slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`);
664
689
  }
690
+ if (!multi && inferred.length > 1) {
691
+ const before = inferred.join(",");
692
+ inferred = [inferred[0]];
693
+ slog("classify enforce single at watcher", data.id, `${before} -> ${inferred.join(",")}`);
694
+ }
665
695
  const elapsed = Date.now() - start;
666
696
  if (elapsed < 1200)
667
697
  await new Promise((r) => setTimeout(r, 1200 - elapsed));
668
- const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
669
698
  if (!inferred.length && data.note) {
670
699
  const n = data.note.toLowerCase();
671
700
  for (const o of data.options) {
672
701
  const v = o.value ? String(o.value).toLowerCase() : "";
673
702
  if (v && n.includes(v) && !inferred.includes(byVal.get(o.value))) {
674
703
  const idx = byVal.get(o.value);
675
- if (idx)
704
+ if (idx) {
676
705
  inferred.push(idx);
706
+ if (!multi)
707
+ break;
708
+ }
677
709
  }
678
710
  }
711
+ if (!multi && inferred.length > 1) {
712
+ slog("classify fallback enforce single", data.id, inferred.join(","));
713
+ inferred = [inferred[0]];
714
+ }
679
715
  }
680
- slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
716
+ if (!multi && inferred.length > 1) {
717
+ const before2 = inferred.join(",");
718
+ inferred = [inferred[0]];
719
+ slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`);
720
+ }
721
+ const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
722
+ slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} multi:${multi} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
681
723
  const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
682
724
  try {
683
725
  fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
package/dist/tui.js CHANGED
@@ -387,6 +387,7 @@ function QuizDialog(props) {
387
387
  value: o.value,
388
388
  index: i + 1
389
389
  })),
390
+ multiSelect: isMulti(),
390
391
  timestamp: Date.now(),
391
392
  sessionID: props.request.sessionID || routeSessionID
392
393
  };
@@ -424,9 +425,12 @@ function QuizDialog(props) {
424
425
  return idxs.length === props.request.correctIndices.length && idxs.every((v) => correctSet.has(v)) && props.request.correctIndices.every((v) => idxs.includes(v));
425
426
  };
426
427
  if (inferred && inferred.length) {
428
+ const eff = !isMulti() && inferred.length > 1 ? [inferred[0]] : inferred;
429
+ if (eff.length !== inferred.length)
430
+ tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","));
427
431
  const m = new Map;
428
- for (let i = 0;i < inferred.length; i++) {
429
- const idx = inferred[i];
432
+ for (let i = 0;i < eff.length; i++) {
433
+ const idx = eff[i];
430
434
  const opt = options()[idx - 1];
431
435
  if (opt)
432
436
  m.set(`opt:${idx - 1}`, {
@@ -436,17 +440,22 @@ function QuizDialog(props) {
436
440
  });
437
441
  }
438
442
  setSelected(m);
439
- const correct2 = computeCorrect(inferred);
443
+ const correct2 = computeCorrect(eff);
440
444
  setFeedback({
441
445
  correct: correct2,
442
- selectedIndices: inferred
446
+ selectedIndices: eff
443
447
  });
444
448
  if (reason)
445
449
  setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
446
- tlog("QuizDialog classify done", inferred.join(","), correct2, reason || "");
450
+ tlog("QuizDialog classify done", eff.join(","), correct2, reason || "");
447
451
  } else if (inferredValues && inferredValues.length) {
448
452
  const byVal = new Map(options().map((o, i) => [o.value, i + 1]));
449
- const idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
453
+ let idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
454
+ if (!isMulti() && idxs.length > 1) {
455
+ const b = idxs.join(",");
456
+ idxs = [idxs[0]];
457
+ tlog("QuizDialog classifyValues enforce single", b, "->", idxs.join(","));
458
+ }
450
459
  const m = new Map;
451
460
  for (const idx of idxs) {
452
461
  const opt = options()[idx - 1];
@@ -589,17 +598,17 @@ function QuizDialog(props) {
589
598
  return;
590
599
  }
591
600
  if (focused() === "note") {
592
- if (key === "tab" || seq === "\t") {
601
+ if (lower === "tab" || seq === "\t") {
593
602
  prevent(evt);
594
603
  setFocused("options");
595
604
  return;
596
605
  }
597
- if (key === "escape" || key === "esc") {
606
+ if (lower === "escape" || lower === "esc") {
598
607
  prevent(evt);
599
608
  setFocused("options");
600
609
  return;
601
610
  }
602
- if (key === "enter" && (evt.ctrl || evt.meta)) {
611
+ if (lower === "enter" && (evt.ctrl || evt.meta)) {
603
612
  prevent(evt);
604
613
  setFocused("options");
605
614
  return;
@@ -647,27 +656,27 @@ function QuizDialog(props) {
647
656
  return dontKnowIdx() + 1;
648
657
  return dontKnowIdx();
649
658
  };
650
- if (key === "up" || key === "k" || seq === "\x1B[A") {
659
+ if (lower === "up" || isPlainKey(evt, "k") || seq === "\x1B[A") {
651
660
  prevent(evt);
652
661
  setOptionIndex((i) => Math.max(0, i - 1));
653
662
  return;
654
663
  }
655
- if (key === "down" || key === "j" || seq === "\x1B[B") {
664
+ if (lower === "down" || isPlainKey(evt, "j") || seq === "\x1B[B") {
656
665
  prevent(evt);
657
666
  setOptionIndex((i) => Math.min(maxIdx(), i + 1));
658
667
  return;
659
668
  }
660
- if (key === "tab" || seq === "\t") {
669
+ if (lower === "tab" || seq === "\t") {
661
670
  prevent(evt);
662
671
  setFocused("note");
663
672
  return;
664
673
  }
665
- if (key === "escape" || key === "esc") {
674
+ if (lower === "escape" || lower === "esc") {
666
675
  prevent(evt);
667
676
  props.onCancel();
668
677
  return;
669
678
  }
670
- if (key === "space" || seq === " ") {
679
+ if (lower === "space" || seq === " ") {
671
680
  prevent(evt);
672
681
  const idx = optionIndex();
673
682
  if (idx === dontKnowIdx())
@@ -692,7 +701,7 @@ function QuizDialog(props) {
692
701
  }
693
702
  return;
694
703
  }
695
- if (key === "enter" || seq === "\r") {
704
+ if (lower === "enter" || seq === "\r") {
696
705
  prevent(evt);
697
706
  const idx = optionIndex();
698
707
  if (idx === dontKnowIdx())
@@ -715,7 +724,7 @@ function QuizDialog(props) {
715
724
  }
716
725
  return;
717
726
  }
718
- if (seq === "ctrl+j" || key === "enter" && evt.ctrl) {
727
+ if (seq === "ctrl+j" || lower === "enter" && evt.ctrl) {
719
728
  prevent(evt);
720
729
  submitSelect();
721
730
  return;
@@ -1525,6 +1534,7 @@ function QuizBatchDialog(props) {
1525
1534
  value: o.value,
1526
1535
  index: i + 1
1527
1536
  })),
1537
+ multiSelect: isMulti(),
1528
1538
  timestamp: Date.now(),
1529
1539
  sessionID: props.request.sessionID || routeSessionID
1530
1540
  };
@@ -1683,12 +1693,12 @@ function QuizBatchDialog(props) {
1683
1693
  return;
1684
1694
  }
1685
1695
  if (focused() === "note") {
1686
- if (k === "tab" || seq === "\t") {
1696
+ if (lower === "tab" || seq === "\t") {
1687
1697
  prevent(evt);
1688
1698
  setFocused("options");
1689
1699
  return;
1690
1700
  }
1691
- if (k === "escape") {
1701
+ if (lower === "escape" || lower === "esc") {
1692
1702
  prevent(evt);
1693
1703
  setFocused("options");
1694
1704
  return;
@@ -1713,27 +1723,27 @@ function QuizBatchDialog(props) {
1713
1723
  } catch {}
1714
1724
  return;
1715
1725
  }
1716
- if (k === "up" || k === "k" || seq === "\x1B[A") {
1726
+ if (lower === "up" || isPlainKeyBatch(evt, "k") || seq === "\x1B[A") {
1717
1727
  prevent(evt);
1718
1728
  setOptionIndex((i) => Math.max(0, i - 1));
1719
1729
  return;
1720
1730
  }
1721
- if (k === "down" || k === "j" || seq === "\x1B[B") {
1731
+ if (lower === "down" || isPlainKeyBatch(evt, "j") || seq === "\x1B[B") {
1722
1732
  prevent(evt);
1723
1733
  setOptionIndex((i) => Math.min(isMulti() ? submitIdx() : dontKnowIdx(), i + 1));
1724
1734
  return;
1725
1735
  }
1726
- if (k === "tab" || seq === "\t") {
1736
+ if (lower === "tab" || seq === "\t") {
1727
1737
  prevent(evt);
1728
1738
  setFocused("note");
1729
1739
  return;
1730
1740
  }
1731
- if (k === "escape" || k === "esc") {
1741
+ if (lower === "escape" || lower === "esc") {
1732
1742
  prevent(evt);
1733
1743
  props.onCancel();
1734
1744
  return;
1735
1745
  }
1736
- if (k === "space" || seq === " ") {
1746
+ if (lower === "space" || seq === " ") {
1737
1747
  prevent(evt);
1738
1748
  const i = optionIndex();
1739
1749
  if (i === dontKnowIdx()) {
@@ -1759,7 +1769,7 @@ function QuizBatchDialog(props) {
1759
1769
  }
1760
1770
  return;
1761
1771
  }
1762
- if (k === "enter" || seq === "\r") {
1772
+ if (lower === "enter" || seq === "\r") {
1763
1773
  prevent(evt);
1764
1774
  const i = optionIndex();
1765
1775
  if (i === dontKnowIdx()) {
@@ -1783,7 +1793,7 @@ function QuizBatchDialog(props) {
1783
1793
  }
1784
1794
  return;
1785
1795
  }
1786
- if (seq === "ctrl+j" || k === "enter" && evt.ctrl) {
1796
+ if (seq === "ctrl+j" || lower === "enter" && evt.ctrl) {
1787
1797
  prevent(evt);
1788
1798
  submitSelect();
1789
1799
  return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
5
5
  "description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -183,7 +183,7 @@ function QuizDialog(props: {
183
183
  try {
184
184
  const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
185
185
  const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
186
- const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
186
+ const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
187
187
  fs.writeFileSync(path.join(pDir, `classify-${props.request.id}.json`), JSON.stringify(pendingClassify), "utf8")
188
188
  tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50))
189
189
  // Poll for classify-response
@@ -207,20 +207,23 @@ function QuizDialog(props: {
207
207
  return idxs.length === props.request.correctIndices.length && idxs.every((v: number) => correctSet.has(v)) && props.request.correctIndices.every((v: number) => idxs.includes(v))
208
208
  }
209
209
  if (inferred && inferred.length) {
210
+ const eff = !isMulti() && inferred.length > 1 ? [inferred[0]!] : inferred
211
+ if (eff.length !== inferred.length) tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","))
210
212
  const m = new Map<string, { label: string; value: string; index: number }>()
211
- for (let i = 0; i < inferred.length; i++) {
212
- const idx = inferred[i]
213
+ for (let i = 0; i < eff.length; i++) {
214
+ const idx = eff[i]
213
215
  const opt = options()[idx - 1]
214
216
  if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx })
215
217
  }
216
218
  setSelected(m)
217
- const correct = computeCorrect(inferred)
218
- setFeedback({ correct, selectedIndices: inferred })
219
+ const correct = computeCorrect(eff)
220
+ setFeedback({ correct, selectedIndices: eff })
219
221
  if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
220
- tlog("QuizDialog classify done", inferred.join(","), correct, reason || "")
222
+ tlog("QuizDialog classify done", eff.join(","), correct, reason || "")
221
223
  } else if (inferredValues && inferredValues.length) {
222
224
  const byVal = new Map(options().map((o, i) => [o.value, i + 1]))
223
- const idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
225
+ let idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
226
+ if (!isMulti() && idxs.length > 1) { const b=idxs.join(","); idxs=[idxs[0]!]; tlog("QuizDialog classifyValues enforce single", b, "->", idxs.join(",")) }
224
227
  const m = new Map<string, { label: string; value: string; index: number }>()
225
228
  for (const idx of idxs) { const opt = options()[idx - 1]; if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
226
229
  setSelected(m)
@@ -286,9 +289,9 @@ function QuizDialog(props: {
286
289
  }
287
290
  // Note focused: handle Tab/Esc/Enter to exit note, otherwise let input handle typing
288
291
  if (focused() === "note") {
289
- if (key === "tab" || seq === "\t") { prevent(evt); setFocused("options"); return }
290
- if (key === "escape" || key === "esc") { prevent(evt); setFocused("options"); return }
291
- if (key === "enter" && (evt.ctrl || evt.meta)) { prevent(evt); setFocused("options"); return }
292
+ if (lower === "tab" || seq === "\t") { prevent(evt); setFocused("options"); return }
293
+ if (lower === "escape" || lower === "esc") { prevent(evt); setFocused("options"); return }
294
+ if (lower === "enter" && (evt.ctrl || evt.meta)) { prevent(evt); setFocused("options"); return }
292
295
  // Allow typing to go to input; don't prevent
293
296
  return
294
297
  }
@@ -303,11 +306,11 @@ function QuizDialog(props: {
303
306
  if (note().trim() && !selected().size && !dontKnow()) return dontKnowIdx() + 1
304
307
  return dontKnowIdx()
305
308
  }
306
- if (key === "up" || key === "k" || seq === "\x1b[A") { prevent(evt); setOptionIndex(i => Math.max(0, i - 1)); return }
307
- if (key === "down" || key === "j" || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(maxIdx(), i + 1)); return }
308
- if (key === "tab" || seq === "\t") { prevent(evt); setFocused("note"); return }
309
- if (key === "escape" || key === "esc") { prevent(evt); props.onCancel(); return }
310
- if (key === "space" || seq === " ") {
309
+ if (lower === "up" || isPlainKey(evt,"k") || seq === "\x1b[A") { prevent(evt); setOptionIndex(i => Math.max(0, i - 1)); return }
310
+ if (lower === "down" || isPlainKey(evt,"j") || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(maxIdx(), i + 1)); return }
311
+ if (lower === "tab" || seq === "\t") { prevent(evt); setFocused("note"); return }
312
+ if (lower === "escape" || lower === "esc") { prevent(evt); props.onCancel(); return }
313
+ if (lower === "space" || seq === " ") {
311
314
  prevent(evt)
312
315
  const idx = optionIndex()
313
316
  if (idx === dontKnowIdx()) handleDontKnow()
@@ -321,7 +324,7 @@ function QuizDialog(props: {
321
324
  }
322
325
  return
323
326
  }
324
- if (key === "enter" || seq === "\r") {
327
+ if (lower === "enter" || seq === "\r") {
325
328
  prevent(evt)
326
329
  const idx = optionIndex()
327
330
  if (idx === dontKnowIdx()) handleDontKnow()
@@ -333,7 +336,7 @@ function QuizDialog(props: {
333
336
  }
334
337
  return
335
338
  }
336
- if (seq === "ctrl+j" || (key === "enter" && (evt as any).ctrl)) {
339
+ if (seq === "ctrl+j" || (lower === "enter" && (evt as any).ctrl)) {
337
340
  prevent(evt); submitSelect(); return
338
341
  }
339
342
  })
@@ -588,7 +591,7 @@ function QuizBatchDialog(props: {
588
591
  const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
589
592
  const cid = `${props.request.id}-${idx()}`
590
593
  const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
591
- const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
594
+ const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
592
595
  fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8")
593
596
  tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
594
597
  const respPath = path.join(pDir, `classify-response-${cid}.json`)
@@ -654,16 +657,16 @@ function QuizBatchDialog(props: {
654
657
  if (lower==="pageup"||seq==="\x1b[5~"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
655
658
  if (lower==="pagedown"||seq==="\x1b[6~"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
656
659
  if(lower==="enter"||seq==="\r"||lower==="escape"||lower==="esc"){ prevent(evt); goNext() } return }
657
- if(focused()==="note"){ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(k==="escape"){prevent(evt); setFocused("options"); return} return }
660
+ if(focused()==="note"){ if(lower==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(lower==="escape"||lower==="esc"){prevent(evt); setFocused("options"); return} return }
658
661
  if(phase()==="select" && (isPlainKeyBatch(evt,"d")||seq==="\x04")){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
659
662
  if(phase()==="select" && (isPlainKeyBatch(evt,"u")||seq==="\x15")){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
660
- if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
661
- if(k==="down"||k==="j"||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
662
- if(k==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
663
- if(k==="escape"||k==="esc"){prevent(evt); props.onCancel(); return}
664
- if(k==="space"||seq===" "){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti() && i===submitIdx()) submitSelect(); else if(isMulti()) toggle(i); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
665
- if(k==="enter"||seq==="\r"){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti()) submitSelect(); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
666
- if(seq==="ctrl+j" || (k==="enter" && evt.ctrl)){prevent(evt); submitSelect(); return}
663
+ if(lower==="up"||isPlainKeyBatch(evt,"k")||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
664
+ if(lower==="down"||isPlainKeyBatch(evt,"j")||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
665
+ if(lower==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
666
+ if(lower==="escape"||lower==="esc"){prevent(evt); props.onCancel(); return}
667
+ if(lower==="space"||seq===" "){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti() && i===submitIdx()) submitSelect(); else if(isMulti()) toggle(i); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
668
+ if(lower==="enter"||seq==="\r"){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti()) submitSelect(); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
669
+ if(seq==="ctrl+j" || (lower==="enter" && evt.ctrl)){prevent(evt); submitSelect(); return}
667
670
  } catch(e){ tlog("useKeyboard batch failed", String(e)) }
668
671
  })
669
672
  return (
package/plugins/learn.ts CHANGED
@@ -464,31 +464,41 @@ const server: Plugin = async ({ client, directory }) => {
464
464
  const messageIdToRole = new Map<string, string>()
465
465
 
466
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[] {
467
+ function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
468
468
  const n = note.toLowerCase()
469
- const out: number[] = []
469
+ const scored: Array<{ idx:number, score:number, len:number }> = []
470
470
  for (let i = 0; i < options.length; i++) {
471
471
  const o = options[i]
472
472
  const label = (o.label || "").toLowerCase()
473
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)
474
+ let score = 0
475
+ if (label && n.includes(label)) score = 3
476
+ else if (value && n.includes(value)) score = 2
476
477
  else {
477
478
  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
+ if (tokens.some(t => n.includes(t))) score = 1
479
480
  }
481
+ if (score) scored.push({ idx: i + 1, score, len: label.length })
480
482
  }
481
- return [...new Set(out)]
483
+ scored.sort((a,b)=> b.score - a.score || b.len - a.len)
484
+ const out = scored.map(s=> s.idx)
485
+ const uniq = [...new Set(out)]
486
+ if (!multiSelect && uniq.length > 1) {
487
+ slog("heuristicClassify single-select trimmed", uniq.join(","), "->", uniq[0])
488
+ return [uniq[0]!]
489
+ }
490
+ return uniq
482
491
  }
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.
492
+ async function llmClassify(client: any, directory: string, note: string, options: Array<{ label: string; value?: string }>, question?: string, parentSessionID?: string, multiSelect?: boolean): Promise<{ inferred: number[]; semanticCorrect?: boolean; reason?: string; sessionID?: string }> {
493
+ const modeHint = multiSelect ? "This is a MULTI-SELECT question (0..N options may be correct). You may return 0..N inferred indices." : "This is a SINGLE-SELECT question (exactly 0 or 1 inferred). You MUST return at most ONE inferred index. Never return multiple. If note is ambiguous or mentions several options, pick the SINGLE best match. Return [] if vague."
494
+ 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. ${modeHint}
485
495
 
486
496
  ${question ? `Question: ${question}\n` : ""}Options:
487
497
  ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join("\n")}
488
498
 
489
499
  Learner note: "${note}"
490
500
 
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.
501
+ Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed) — respect single/multi mode above. 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
502
 
493
503
  Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`
494
504
  try {
@@ -515,6 +525,14 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
515
525
  const entry = arr[j]
516
526
  if (entry?.info?.role === "assistant") {
517
527
  const text = (entry.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join(" ") || ""
528
+ const enforceSingle = (arr:number[]) => {
529
+ if (!multiSelect && arr.length > 1) {
530
+ const trimmed = [arr[0]!]
531
+ slog("llmClassify enforce single", arr.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single")
532
+ return trimmed
533
+ }
534
+ return arr
535
+ }
518
536
  // Try object JSON {"inferred":[2],"semanticCorrect":false}
519
537
  const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/)
520
538
  if (objMatch) {
@@ -522,8 +540,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
522
540
  const parsed = JSON.parse(objMatch[0])
523
541
  if (parsed && Array.isArray(parsed.inferred)) {
524
542
  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 }
543
+ const fnums = enforceSingle(nums)
544
+ slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`)
545
+ return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid }
527
546
  }
528
547
  } catch {}
529
548
  }
@@ -534,15 +553,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
534
553
  if (Array.isArray(parsed)) {
535
554
  const nums = parsed.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
536
555
  if (nums.length) {
537
- slog("llmClassify success array", note.slice(0, 40), nums.join(","))
538
- return { inferred: nums, sessionID: sid }
556
+ const fnums = enforceSingle(nums)
557
+ slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`)
558
+ return { inferred: fnums, sessionID: sid }
539
559
  }
540
560
  }
541
561
  } catch {}
542
562
  }
543
563
  if (text.includes("1") || text.includes("2")) {
544
564
  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 }
565
+ if (nums.length) {
566
+ const uniq = [...new Set(nums)]
567
+ const fnums = enforceSingle(uniq)
568
+ return { inferred: fnums, sessionID: sid }
569
+ }
546
570
  }
547
571
  }
548
572
  }
@@ -572,33 +596,52 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
572
596
  let inferred: number[] = []
573
597
  let semanticCorrect: boolean | undefined
574
598
  let reason: string | undefined
575
- const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID)
599
+ const multi = !!data.multiSelect
600
+ const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID, multi)
576
601
  if (llmRes.inferred.length) {
577
602
  inferred = llmRes.inferred
578
603
  semanticCorrect = llmRes.semanticCorrect
579
604
  reason = llmRes.reason
580
- slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`)
605
+ slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} multi:${multi} sid:${llmRes.sessionID || ""}`)
581
606
  } else {
582
- inferred = heuristicClassify(data.note, data.options)
583
- if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","))
607
+ inferred = heuristicClassify(data.note, data.options, multi)
608
+ if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi}`)
584
609
  else slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`)
585
610
  }
611
+ // Enforce single-select at the watcher level too (defense in depth — prompt + llmClassify + heuristic may still return multi)
612
+ if (!multi && inferred.length > 1) {
613
+ const before = inferred.join(",")
614
+ inferred = [inferred[0]!]
615
+ slog("classify enforce single at watcher", data.id, `${before} -> ${inferred.join(",")}`)
616
+ }
586
617
  // Ensure minimal classify time so UI doesn't feel instant-wrong (at least 1200ms)
587
618
  const elapsed = Date.now() - start
588
619
  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
620
+ // Final fallback if still empty (respects single-select)
591
621
  if (!inferred.length && data.note) {
592
622
  const n = data.note.toLowerCase()
593
623
  for (const o of data.options) {
594
624
  const v = o.value ? String(o.value).toLowerCase() : ""
595
625
  if (v && n.includes(v) && !inferred.includes(byVal.get(o.value) as number)) {
596
626
  const idx = byVal.get(o.value) as number | undefined
597
- if (idx) inferred.push(idx)
627
+ if (idx) {
628
+ inferred.push(idx)
629
+ if (!multi) break
630
+ }
598
631
  }
599
632
  }
633
+ if (!multi && inferred.length > 1) {
634
+ slog("classify fallback enforce single", data.id, inferred.join(","))
635
+ inferred = [inferred[0]!]
636
+ }
600
637
  }
601
- slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
638
+ if (!multi && inferred.length > 1) {
639
+ const before2 = inferred.join(",")
640
+ inferred = [inferred[0]!]
641
+ slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`)
642
+ }
643
+ const inferredValues = inferred.map((i: number) => data.options[i - 1]?.value).filter(Boolean) as string[]
644
+ slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} multi:${multi} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
602
645
  const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
603
646
  try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
604
647
  }