@bojackduy/opencode-learn 0.1.4 → 0.1.6

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
@@ -513,10 +513,183 @@ var server = async ({ client, directory }) => {
513
513
  const loggedTextPartIds = new Set;
514
514
  const loggedToolCallIds = new Set;
515
515
  const messageIdToRole = new Map;
516
+ function heuristicClassify(note, options) {
517
+ const n = note.toLowerCase();
518
+ const out = [];
519
+ for (let i = 0;i < options.length; i++) {
520
+ const o = options[i];
521
+ const label = (o.label || "").toLowerCase();
522
+ const value = (o.value || "").toLowerCase();
523
+ if (label && n.includes(label))
524
+ out.push(i + 1);
525
+ else if (value && n.includes(value))
526
+ out.push(i + 1);
527
+ else {
528
+ const tokens = label.split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
529
+ if (tokens.some((t) => n.includes(t)))
530
+ out.push(i + 1);
531
+ }
532
+ }
533
+ return [...new Set(out)];
534
+ }
535
+ async function llmClassify(client2, directory2, note, options, question, parentSessionID) {
536
+ 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.
537
+
538
+ ${question ? `Question: ${question}
539
+ ` : ""}Options:
540
+ ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join(`
541
+ `)}
542
+
543
+ Learner note: "${note}"
544
+
545
+ 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.
546
+
547
+ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`;
548
+ try {
549
+ const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`;
550
+ const body = { title };
551
+ if (parentSessionID)
552
+ body.parentID = parentSessionID;
553
+ const created = await client2.session.create({ body, query: { directory: directory2 } });
554
+ const sid = created?.data?.id || created?.id || created?.data?.sessionID;
555
+ if (!sid)
556
+ throw new Error("no sid");
557
+ const createdSession = created?.data || created;
558
+ slog("classify subagent created", sid, `requestedParent:${parentSessionID || "none"}`, `actualParent:${createdSession?.parentID || "none"}`, note.slice(0, 40));
559
+ if (parentSessionID && createdSession?.parentID !== parentSessionID) {
560
+ throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`);
561
+ }
562
+ await client2.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } });
563
+ for (let i = 0;i < 24; i++) {
564
+ await new Promise((r) => setTimeout(r, 500));
565
+ try {
566
+ const msgs = await client2.session.messages({ path: { id: sid } });
567
+ const data = msgs?.data || msgs;
568
+ const arr = Array.isArray(data) ? data : [];
569
+ for (let j = arr.length - 1;j >= 0; j--) {
570
+ const entry = arr[j];
571
+ if (entry?.info?.role === "assistant") {
572
+ const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
573
+ const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
574
+ if (objMatch) {
575
+ try {
576
+ const parsed = JSON.parse(objMatch[0]);
577
+ if (parsed && Array.isArray(parsed.inferred)) {
578
+ const nums = parsed.inferred.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
579
+ slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
580
+ return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
581
+ }
582
+ } catch {}
583
+ }
584
+ const m = text.match(/\[[\s\d,]*\]/);
585
+ if (m) {
586
+ try {
587
+ const parsed = JSON.parse(m[0]);
588
+ if (Array.isArray(parsed)) {
589
+ const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
590
+ if (nums.length) {
591
+ slog("llmClassify success array", note.slice(0, 40), nums.join(","));
592
+ return { inferred: nums, sessionID: sid };
593
+ }
594
+ }
595
+ } catch {}
596
+ }
597
+ if (text.includes("1") || text.includes("2")) {
598
+ const nums = [...text.matchAll(/\b([1-9])\b/g)].map((x) => parseInt(x[1])).filter((n) => n <= options.length);
599
+ if (nums.length)
600
+ return { inferred: [...new Set(nums)], sessionID: sid };
601
+ }
602
+ }
603
+ }
604
+ } catch {}
605
+ }
606
+ slog("llmClassify timeout", note.slice(0, 40));
607
+ } catch (e) {
608
+ slog("llmClassify failed", String(e).slice(0, 200));
609
+ }
610
+ return { inferred: [] };
611
+ }
612
+ function startClassifyWatcher(client2, directory2) {
613
+ const dir = pendingDir(directory2);
614
+ try {
615
+ fs.mkdirSync(dir, { recursive: true });
616
+ } catch {}
617
+ const processClassify = async (filename) => {
618
+ if (!filename.startsWith("classify-") || filename.startsWith("classify-response-"))
619
+ return;
620
+ const fp = path.join(dir, filename);
621
+ if (!fs.existsSync(fp))
622
+ return;
623
+ const respPath = path.join(dir, filename.replace("classify-", "classify-response-"));
624
+ if (fs.existsSync(respPath))
625
+ return;
626
+ let data;
627
+ try {
628
+ data = JSON.parse(fs.readFileSync(fp, "utf8"));
629
+ } catch {
630
+ return;
631
+ }
632
+ if (data?.type !== "classify" || !data?.note || !Array.isArray(data?.options))
633
+ return;
634
+ slog("classify watcher processing", data.id, data.note.slice(0, 80));
635
+ const byVal = new Map(data.options.map((o, i) => [o.value, i + 1]));
636
+ const start = Date.now();
637
+ let inferred = [];
638
+ let semanticCorrect;
639
+ let reason;
640
+ const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID);
641
+ if (llmRes.inferred.length) {
642
+ inferred = llmRes.inferred;
643
+ semanticCorrect = llmRes.semanticCorrect;
644
+ reason = llmRes.reason;
645
+ slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`);
646
+ } else {
647
+ inferred = heuristicClassify(data.note, data.options);
648
+ if (inferred.length)
649
+ slog("classify heuristic hit", data.id, inferred.join(","));
650
+ else
651
+ slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`);
652
+ }
653
+ const elapsed = Date.now() - start;
654
+ if (elapsed < 1200)
655
+ await new Promise((r) => setTimeout(r, 1200 - elapsed));
656
+ const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
657
+ if (!inferred.length && data.note) {
658
+ const n = data.note.toLowerCase();
659
+ for (const o of data.options) {
660
+ const v = o.value ? String(o.value).toLowerCase() : "";
661
+ if (v && n.includes(v) && !inferred.includes(byVal.get(o.value))) {
662
+ const idx = byVal.get(o.value);
663
+ if (idx)
664
+ inferred.push(idx);
665
+ }
666
+ }
667
+ }
668
+ slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
669
+ const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
670
+ try {
671
+ fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
672
+ slog("classify response written", data.id, inferred.join(","));
673
+ } catch {}
674
+ };
675
+ try {
676
+ for (const f of fs.readdirSync(dir).filter((f2) => f2.startsWith("classify-") && !f2.startsWith("classify-response-"))) {
677
+ processClassify(f);
678
+ }
679
+ } catch {}
680
+ try {
681
+ const w = fs.watch(dir, (_e, filename) => {
682
+ if (filename)
683
+ processClassify(filename);
684
+ });
685
+ w.on("error", () => {});
686
+ } catch {}
687
+ }
688
+ startClassifyWatcher(client, directory);
516
689
  try {
517
690
  const dir = pendingDir(directory);
518
691
  if (fs.existsSync(dir)) {
519
- for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
692
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
520
693
  try {
521
694
  const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
522
695
  if (j?.id && j?.sessionID) {
@@ -574,7 +747,7 @@ Explanation: ${j.explanation}${note}`;
574
747
  config: async (output) => {
575
748
  const agents = output.agent ?? {};
576
749
  let mutated = false;
577
- for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
750
+ for (const name of ["researcher", "mermaid-maker", "svg-maker", "classify"]) {
578
751
  if (!agents[name]) {
579
752
  agents[name] = { mode: "subagent", description: `${name} subagent (from learn plugin)`, permission: { "*": "allow" } };
580
753
  mutated = true;