@bojackduy/opencode-learn 0.1.5 → 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.
- package/agents/classify.md +17 -0
- package/dist/server.js +175 -2
- package/dist/tui.js +864 -483
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +187 -22
- package/plugins/learn.ts +155 -2
- package/scripts/install.mjs +3 -3
|
@@ -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;
|