@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.
- package/agents/classify.md +17 -0
- package/dist/server.js +175 -2
- package/dist/tui.js +864 -483
- package/package.json +2 -2
- package/plugins/learn-tui.tsx +187 -22
- package/plugins/learn.ts +155 -2
- package/scripts/install.mjs +3 -3
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": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"opentui"
|
|
31
31
|
],
|
|
32
32
|
"bin": {
|
|
33
|
-
"opencode-learn": "
|
|
33
|
+
"opencode-learn": "scripts/install.mjs"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"dist",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -22,6 +22,7 @@ type QuizPending = {
|
|
|
22
22
|
correctIndices: number[]
|
|
23
23
|
explanation: string
|
|
24
24
|
multiSelect?: boolean
|
|
25
|
+
sessionID?: string
|
|
25
26
|
timestamp: number
|
|
26
27
|
}
|
|
27
28
|
type QuizBatchPending = {
|
|
@@ -44,6 +45,7 @@ function QuizDialog(props: {
|
|
|
44
45
|
const theme = () => props.api.theme.current
|
|
45
46
|
const dims = useTerminalDimensions()
|
|
46
47
|
const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 92))
|
|
48
|
+
const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
|
|
47
49
|
const options = () => props.request.options
|
|
48
50
|
const correctSet = new Set(props.request.correctIndices)
|
|
49
51
|
const isMulti = () => !!props.request.multiSelect
|
|
@@ -52,7 +54,7 @@ function QuizDialog(props: {
|
|
|
52
54
|
|
|
53
55
|
const [focused, setFocused] = createSignal<"options" | "note">("options")
|
|
54
56
|
const [optionIndex, setOptionIndex] = createSignal(0)
|
|
55
|
-
const [phase, setPhase] = createSignal<"select" | "feedback">("select")
|
|
57
|
+
const [phase, setPhase] = createSignal<"select" | "feedback" | "classifying">("select")
|
|
56
58
|
const [note, setNote] = createSignal("")
|
|
57
59
|
const [dontKnow, setDontKnow] = createSignal(false)
|
|
58
60
|
const [selected, setSelected] = createSignal<Map<string, { label: string; value: string; index: number }>>(new Map())
|
|
@@ -86,8 +88,71 @@ function QuizDialog(props: {
|
|
|
86
88
|
}
|
|
87
89
|
const submitSelect = () => {
|
|
88
90
|
const selMap = selected()
|
|
89
|
-
if (!isMulti() && selMap.size === 0 && !dontKnow()) return
|
|
90
|
-
if (isMulti() && selMap.size === 0 && !dontKnow()) return
|
|
91
|
+
if (!isMulti() && selMap.size === 0 && !dontKnow() && !note().trim()) return
|
|
92
|
+
if (isMulti() && selMap.size === 0 && !dontKnow() && !note().trim()) return
|
|
93
|
+
// If 0 selected but note present, trigger AI classify (async popup) — keep popup, show classifying
|
|
94
|
+
if (selMap.size === 0 && !dontKnow() && note().trim()) {
|
|
95
|
+
setPhase("classifying" as any)
|
|
96
|
+
try {
|
|
97
|
+
const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
|
|
98
|
+
const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
|
|
99
|
+
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 }
|
|
100
|
+
fs.writeFileSync(path.join(pDir, `classify-${props.request.id}.json`), JSON.stringify(pendingClassify), "utf8")
|
|
101
|
+
tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50))
|
|
102
|
+
// Poll for classify-response
|
|
103
|
+
const respPath = path.join(pDir, `classify-response-${props.request.id}.json`)
|
|
104
|
+
let attempts = 0
|
|
105
|
+
const poll = setInterval(() => {
|
|
106
|
+
attempts++
|
|
107
|
+
if (attempts > 60) { clearInterval(poll); setPhase("feedback"); setFeedback({ correct: false, selectedIndices: [] }); return }
|
|
108
|
+
try {
|
|
109
|
+
if (fs.existsSync(respPath)) {
|
|
110
|
+
clearInterval(poll)
|
|
111
|
+
const raw = fs.readFileSync(respPath, "utf8")
|
|
112
|
+
const data: any = JSON.parse(raw)
|
|
113
|
+
try { fs.unlinkSync(respPath); fs.unlinkSync(path.join(pDir, `classify-${props.request.id}.json`)) } catch {}
|
|
114
|
+
const inferred = data?.inferredIndices as number[] | undefined
|
|
115
|
+
const inferredValues = data?.inferredValues as string[] | undefined
|
|
116
|
+
const semanticCorrect = data?.semanticCorrect as boolean | undefined
|
|
117
|
+
const reason = data?.reason as string | undefined
|
|
118
|
+
const computeCorrect = (idxs: number[]) => {
|
|
119
|
+
if (typeof semanticCorrect === "boolean") return semanticCorrect
|
|
120
|
+
return idxs.length === props.request.correctIndices.length && idxs.every((v: number) => correctSet.has(v)) && props.request.correctIndices.every((v: number) => idxs.includes(v))
|
|
121
|
+
}
|
|
122
|
+
if (inferred && inferred.length) {
|
|
123
|
+
const m = new Map<string, { label: string; value: string; index: number }>()
|
|
124
|
+
for (let i = 0; i < inferred.length; i++) {
|
|
125
|
+
const idx = inferred[i]
|
|
126
|
+
const opt = options()[idx - 1]
|
|
127
|
+
if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx })
|
|
128
|
+
}
|
|
129
|
+
setSelected(m)
|
|
130
|
+
const correct = computeCorrect(inferred)
|
|
131
|
+
setFeedback({ correct, selectedIndices: inferred })
|
|
132
|
+
if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
|
|
133
|
+
tlog("QuizDialog classify done", inferred.join(","), correct, reason || "")
|
|
134
|
+
} else if (inferredValues && inferredValues.length) {
|
|
135
|
+
const byVal = new Map(options().map((o, i) => [o.value, i + 1]))
|
|
136
|
+
const idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
|
|
137
|
+
const m = new Map<string, { label: string; value: string; index: number }>()
|
|
138
|
+
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 }) }
|
|
139
|
+
setSelected(m)
|
|
140
|
+
const correct = computeCorrect(idxs)
|
|
141
|
+
setFeedback({ correct, selectedIndices: idxs })
|
|
142
|
+
} else {
|
|
143
|
+
const correct = typeof semanticCorrect === "boolean" ? semanticCorrect : false
|
|
144
|
+
setFeedback({ correct, selectedIndices: [] })
|
|
145
|
+
if (reason) setNote(prev => prev ? `${prev} — ${reason}` : reason)
|
|
146
|
+
}
|
|
147
|
+
setPhase("feedback")
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
}, 500)
|
|
151
|
+
// Cleanup on dispose
|
|
152
|
+
onCleanup(() => clearInterval(poll))
|
|
153
|
+
} catch (e) { tlog("classify request failed", String(e)); setPhase("feedback"); setFeedback({ correct: false, selectedIndices: [] }) }
|
|
154
|
+
return
|
|
155
|
+
}
|
|
91
156
|
if (dontKnow()) {
|
|
92
157
|
setFeedback({ correct: false, selectedIndices: [] })
|
|
93
158
|
setPhase("feedback")
|
|
@@ -108,6 +173,7 @@ function QuizDialog(props: {
|
|
|
108
173
|
useKeyboard((evt: any) => {
|
|
109
174
|
const key = evt.name || evt.sequence || evt.raw || ""
|
|
110
175
|
const seq = evt.sequence || ""
|
|
176
|
+
if ((phase() as any) === "classifying") { prevent(evt); return }
|
|
111
177
|
// When in feedback, any Enter/Esc confirms
|
|
112
178
|
if (phase() === "feedback") {
|
|
113
179
|
if (key === "enter" || seq === "\r" || key === "escape" || key === "esc") {
|
|
@@ -124,15 +190,21 @@ function QuizDialog(props: {
|
|
|
124
190
|
// Allow typing to go to input; don't prevent
|
|
125
191
|
return
|
|
126
192
|
}
|
|
127
|
-
// Options focused
|
|
193
|
+
// Options focused — extra Submit for single note-only at dontKnowIdx+1
|
|
194
|
+
const maxIdx = () => {
|
|
195
|
+
if (isMulti()) return submitIdx()
|
|
196
|
+
if (note().trim() && !selected().size && !dontKnow()) return dontKnowIdx() + 1
|
|
197
|
+
return dontKnowIdx()
|
|
198
|
+
}
|
|
128
199
|
if (key === "up" || key === "k" || seq === "\x1b[A") { prevent(evt); setOptionIndex(i => Math.max(0, i - 1)); return }
|
|
129
|
-
if (key === "down" || key === "j" || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(
|
|
200
|
+
if (key === "down" || key === "j" || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(maxIdx(), i + 1)); return }
|
|
130
201
|
if (key === "tab" || seq === "\t") { prevent(evt); setFocused("note"); return }
|
|
131
202
|
if (key === "escape" || key === "esc") { prevent(evt); props.onCancel(); return }
|
|
132
203
|
if (key === "space" || seq === " ") {
|
|
133
204
|
prevent(evt)
|
|
134
205
|
const idx = optionIndex()
|
|
135
206
|
if (idx === dontKnowIdx()) handleDontKnow()
|
|
207
|
+
else if (!isMulti() && idx === dontKnowIdx() + 1 && note().trim() && !selected().size && !dontKnow()) submitSelect()
|
|
136
208
|
else {
|
|
137
209
|
if (isMulti()) toggleOption(idx)
|
|
138
210
|
else {
|
|
@@ -146,6 +218,7 @@ function QuizDialog(props: {
|
|
|
146
218
|
prevent(evt)
|
|
147
219
|
const idx = optionIndex()
|
|
148
220
|
if (idx === dontKnowIdx()) handleDontKnow()
|
|
221
|
+
else if (!isMulti() && idx === dontKnowIdx() + 1 && note().trim() && !selected().size && !dontKnow()) submitSelect()
|
|
149
222
|
else if (isMulti()) submitSelect()
|
|
150
223
|
else {
|
|
151
224
|
const opt = options()[idx]
|
|
@@ -159,13 +232,14 @@ function QuizDialog(props: {
|
|
|
159
232
|
})
|
|
160
233
|
|
|
161
234
|
return (
|
|
162
|
-
<box flexDirection="column" width={popupWidth()} border={true} borderColor={phase() === "feedback" ? (feedback()?.correct ? theme().success : theme().error) : theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
|
|
235
|
+
<box flexDirection="column" width={popupWidth()} height={popupHeight()} border={true} borderColor={phase() === "feedback" ? (feedback()?.correct ? theme().success : theme().error) : theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
|
|
163
236
|
{/* Header */}
|
|
164
237
|
<box flexDirection="row" justifyContent="space-between" alignItems="center" backgroundColor={phase() === "feedback" ? (feedback()?.correct ? theme().success : theme().error) : theme().accent} paddingLeft={1} paddingRight={1} height={1}>
|
|
165
238
|
<text fg={theme().background} bold>{phase() === "feedback" ? (feedback()?.correct ? "✓ CORRECT" : dontKnow() ? "○ I DON'T KNOW" : "✗ INCORRECT") : isMulti() ? "☑ QUIZ · MULTI-SELECT" : "● QUIZ · SINGLE" }</text>
|
|
166
239
|
<text fg={theme().background} dim>learn</text>
|
|
167
240
|
</box>
|
|
168
241
|
|
|
242
|
+
<scrollbox flexGrow={1}>
|
|
169
243
|
{/* Question */}
|
|
170
244
|
<box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
|
|
171
245
|
<text fg={theme().text} bold wrapMode="wrap">{props.request.question}</text>
|
|
@@ -208,25 +282,44 @@ function QuizDialog(props: {
|
|
|
208
282
|
ref={(el: any) => noteInputEl = el}
|
|
209
283
|
value={note()}
|
|
210
284
|
onInput={(value: any) => setNote(typeof value === "string" ? value : value?.target?.value ?? value?.value ?? String(value ?? ""))}
|
|
211
|
-
onSubmit={() =>
|
|
212
|
-
|
|
285
|
+
onSubmit={() => {
|
|
286
|
+
if (!selected().size && !dontKnow() && note().trim()) submitSelect()
|
|
287
|
+
else setFocused("options")
|
|
288
|
+
}}
|
|
289
|
+
placeholder="what was on your mind? (Enter to submit note → classify)"
|
|
213
290
|
/>
|
|
214
291
|
</Show>
|
|
215
292
|
</box>
|
|
216
293
|
</box>
|
|
217
294
|
|
|
218
295
|
<box flexDirection="row" justifyContent="space-between" paddingTop={1}>
|
|
219
|
-
<text fg={theme().textMuted}
|
|
220
|
-
<Show when={isMulti()}><text fg={selected().size > 0 || dontKnow() ? theme().success : theme().warning}>{selected().size} selected {dontKnow() ? "· I don't know" : ""}</text></Show>
|
|
296
|
+
<text fg={theme().textMuted}>{focused() === "note" ? "Enter submit note → classify · Tab back · Esc back" : "↑↓/j k · Space toggle · ↓ to Submit → Enter · Tab note · Esc cancel"}</text>
|
|
297
|
+
<Show when={isMulti()}><text fg={selected().size > 0 || dontKnow() ? theme().success : note().trim() ? theme().warning : theme().warning}>{selected().size > 0 ? `${selected().size} selected` : note().trim() ? "note → classify" : "0 selected"} {dontKnow() ? "· I don't know" : ""}</text></Show>
|
|
298
|
+
<Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}><text fg={theme().warning}>note → classify on Enter</text></Show>
|
|
221
299
|
</box>
|
|
222
300
|
<Show when={isMulti()}>
|
|
223
301
|
<box justifyContent="center" paddingTop={1}>
|
|
224
|
-
<box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() ? theme().success : theme().borderSubtle)} backgroundColor={focused() === "options" && optionIndex() === submitIdx() ? theme().backgroundElement : (selected().size > 0 || dontKnow() ? theme().success : theme().background)} paddingLeft={2} paddingRight={2}>
|
|
225
|
-
<text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() ? theme().background : theme().textMuted)}>{focused() === "options" && optionIndex() === submitIdx() ? "▸" : " "}</text>
|
|
226
|
-
<text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() ? theme().background : theme().textMuted)} bold>↳ Submit</text>
|
|
302
|
+
<box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().borderSubtle)} backgroundColor={focused() === "options" && optionIndex() === submitIdx() ? theme().backgroundElement : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().background)} paddingLeft={2} paddingRight={2}>
|
|
303
|
+
<text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)}>{focused() === "options" && optionIndex() === submitIdx() ? "▸" : " "}</text>
|
|
304
|
+
<text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)} bold>↳ Submit{note().trim() && !selected().size && !dontKnow() ? " note" : ""}</text>
|
|
227
305
|
</box>
|
|
228
306
|
</box>
|
|
229
307
|
</Show>
|
|
308
|
+
<Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}>
|
|
309
|
+
<box justifyContent="center" paddingTop={1}>
|
|
310
|
+
<box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().success} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().backgroundElement : theme().success} paddingLeft={2} paddingRight={2}>
|
|
311
|
+
<text fg={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().background} bold>↳ Submit note → classify</text>
|
|
312
|
+
</box>
|
|
313
|
+
</box>
|
|
314
|
+
</Show>
|
|
315
|
+
</box>
|
|
316
|
+
</Show>
|
|
317
|
+
|
|
318
|
+
<Show when={(phase() as any) === "classifying"}>
|
|
319
|
+
<box flexDirection="column" gap={1} padding={1} border={true} borderColor={theme().accent} backgroundColor={theme().background} alignItems="center">
|
|
320
|
+
<text fg={theme().accent} bold>◐ Classifying your note...</text>
|
|
321
|
+
<text fg={theme().textMuted} wrapMode="wrap">"{note()}"</text>
|
|
322
|
+
<text fg={theme().textMuted}>Mapping to options — please wait</text>
|
|
230
323
|
</box>
|
|
231
324
|
</Show>
|
|
232
325
|
|
|
@@ -260,9 +353,14 @@ function QuizDialog(props: {
|
|
|
260
353
|
<box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}>
|
|
261
354
|
<text fg={theme().text} wrapMode="wrap">{props.request.explanation}</text>
|
|
262
355
|
</box>
|
|
263
|
-
<box justifyContent="center" paddingTop={1}><text fg={theme().textMuted}>↵ Enter / Esc to continue → next probe</text></box>
|
|
264
356
|
</box>
|
|
265
357
|
</Show>
|
|
358
|
+
</scrollbox>
|
|
359
|
+
<box height={1} justifyContent="center">
|
|
360
|
+
<text fg={theme().textMuted}>
|
|
361
|
+
{phase() === "feedback" ? "↵ Enter / Esc to continue → next probe" : phase() === "classifying" ? "Classifying your note..." : "Navigate options · Tab note · Esc cancel"}
|
|
362
|
+
</text>
|
|
363
|
+
</box>
|
|
266
364
|
</box>
|
|
267
365
|
)
|
|
268
366
|
}
|
|
@@ -277,6 +375,7 @@ function QuizBatchDialog(props: {
|
|
|
277
375
|
const theme = () => props.api.theme.current
|
|
278
376
|
const dims = useTerminalDimensions()
|
|
279
377
|
const popupWidth = () => Math.max(68, Math.min(dims().width - 4, 96))
|
|
378
|
+
const popupHeight = () => Math.max(4, Math.min(24, dims().height - 2))
|
|
280
379
|
const [idx, setIdx] = createSignal(0)
|
|
281
380
|
// Guard: if no quizzes, cancel
|
|
282
381
|
if (!props.request.quizzes || props.request.quizzes.length === 0) {
|
|
@@ -285,7 +384,7 @@ function QuizBatchDialog(props: {
|
|
|
285
384
|
return null as any
|
|
286
385
|
}
|
|
287
386
|
const cur = () => props.request.quizzes[idx()] ?? props.request.quizzes[0]
|
|
288
|
-
const [phase, setPhase] = createSignal<"select" | "feedback">("select")
|
|
387
|
+
const [phase, setPhase] = createSignal<"select" | "feedback" | "classifying">("select")
|
|
289
388
|
const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
|
|
290
389
|
const [dontKnow, setDontKnow] = createSignal(false)
|
|
291
390
|
const [selected, setSelected] = createSignal<Map<string, any>>(new Map())
|
|
@@ -329,8 +428,58 @@ function QuizBatchDialog(props: {
|
|
|
329
428
|
const submitSelect = () => {
|
|
330
429
|
try {
|
|
331
430
|
const m = selected()
|
|
332
|
-
if (!isMulti() && m.size===0 && !dontKnow()) return
|
|
333
|
-
if (isMulti() && m.size===0 && !dontKnow()) return
|
|
431
|
+
if (!isMulti() && m.size===0 && !dontKnow() && !note().trim()) return
|
|
432
|
+
if (isMulti() && m.size===0 && !dontKnow() && !note().trim()) return
|
|
433
|
+
// 0 selected + note -> AI classify, keep popup
|
|
434
|
+
if (m.size===0 && !dontKnow() && note().trim()) {
|
|
435
|
+
setPhase("classifying" as any)
|
|
436
|
+
try {
|
|
437
|
+
const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
|
|
438
|
+
const cid = `${props.request.id}-${idx()}`
|
|
439
|
+
const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
|
|
440
|
+
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 }
|
|
441
|
+
fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8")
|
|
442
|
+
tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
|
|
443
|
+
const respPath = path.join(pDir, `classify-response-${cid}.json`)
|
|
444
|
+
let attempts = 0
|
|
445
|
+
const poll = setInterval(() => {
|
|
446
|
+
attempts++
|
|
447
|
+
if (attempts > 60) { clearInterval(poll); setFeedback({ correct: false, selectedIndices: [] }); setPhase("feedback"); return }
|
|
448
|
+
try {
|
|
449
|
+
if (fs.existsSync(respPath)) {
|
|
450
|
+
clearInterval(poll)
|
|
451
|
+
const raw = fs.readFileSync(respPath, "utf8")
|
|
452
|
+
const data: any = JSON.parse(raw)
|
|
453
|
+
try { fs.unlinkSync(respPath); fs.unlinkSync(path.join(pDir, `classify-${cid}.json`)) } catch {}
|
|
454
|
+
const inferred = data?.inferredIndices as number[] | undefined
|
|
455
|
+
const semanticCorrect = data?.semanticCorrect as boolean | undefined
|
|
456
|
+
const reason = data?.reason as string | undefined
|
|
457
|
+
const computeOk2 = (idxs: number[]) => {
|
|
458
|
+
if (typeof semanticCorrect === "boolean") return semanticCorrect
|
|
459
|
+
const correctSet2 = new Set(cur().correctIndices)
|
|
460
|
+
return idxs.length === cur().correctIndices.length && idxs.every(v => correctSet2.has(v)) && cur().correctIndices.every(v => idxs.includes(v))
|
|
461
|
+
}
|
|
462
|
+
if (inferred && inferred.length) {
|
|
463
|
+
const mm = new Map<string, any>()
|
|
464
|
+
for (const idx of inferred) { const opt = cur().options[idx - 1]; if (opt) mm.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
|
|
465
|
+
setSelected(mm)
|
|
466
|
+
const ok2 = computeOk2(inferred)
|
|
467
|
+
setFeedback({ correct: ok2, selectedIndices: inferred })
|
|
468
|
+
if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
|
|
469
|
+
tlog("QuizBatchDialog classify done", inferred.join(","), ok2, reason || "")
|
|
470
|
+
} else {
|
|
471
|
+
const ok2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false
|
|
472
|
+
setFeedback({ correct: ok2, selectedIndices: [] })
|
|
473
|
+
if (reason) setNote(prev => prev ? `${prev} — ${reason}` : reason)
|
|
474
|
+
}
|
|
475
|
+
setPhase("feedback")
|
|
476
|
+
}
|
|
477
|
+
} catch {}
|
|
478
|
+
}, 500)
|
|
479
|
+
onCleanup(() => clearInterval(poll))
|
|
480
|
+
} catch (e) { tlog("classify batch failed", String(e)); setFeedback({ correct: false, selectedIndices: [] }); setPhase("feedback") }
|
|
481
|
+
return
|
|
482
|
+
}
|
|
334
483
|
const sel = Array.from(m.values())
|
|
335
484
|
const dk = dontKnow()
|
|
336
485
|
const correctSet = new Set(cur().correctIndices)
|
|
@@ -344,6 +493,7 @@ function QuizBatchDialog(props: {
|
|
|
344
493
|
useKeyboard((evt:any)=>{
|
|
345
494
|
try {
|
|
346
495
|
const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""
|
|
496
|
+
if((phase() as any)==="classifying"){ prevent(evt); return }
|
|
347
497
|
if(phase()==="feedback"){ if(k==="enter"||seq==="\r"||k==="escape"||k==="esc"){ prevent(evt); goNext() } return }
|
|
348
498
|
if(focused()==="note"){ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(k==="escape"){prevent(evt); setFocused("options"); return} return }
|
|
349
499
|
if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
|
|
@@ -356,11 +506,12 @@ function QuizBatchDialog(props: {
|
|
|
356
506
|
} catch(e){ tlog("useKeyboard batch failed", String(e)) }
|
|
357
507
|
})
|
|
358
508
|
return (
|
|
359
|
-
<box flexDirection="column" width={popupWidth()} border={true} borderColor={phase()==="feedback"?(feedback()?.correct?theme().success:theme().error):theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
|
|
509
|
+
<box flexDirection="column" width={popupWidth()} height={popupHeight()} border={true} borderColor={phase()==="feedback"?(feedback()?.correct?theme().success:theme().error):theme().accent} backgroundColor={theme().backgroundPanel} padding={1} gap={1}>
|
|
360
510
|
<box flexDirection="row" justifyContent="space-between" backgroundColor={theme().accent} paddingLeft={1} paddingRight={1} height={1}>
|
|
361
511
|
<text fg={theme().background} bold> decks.quiz batch {idx()+1}/{props.request.quizzes.length} {phase()==="feedback"?(feedback()?.correct?"✓":"✗"):""}</text>
|
|
362
512
|
<text fg={theme().background} dim>learn</text>
|
|
363
513
|
</box>
|
|
514
|
+
<scrollbox flexGrow={1}>
|
|
364
515
|
<text fg={theme().text} bold wrapMode="wrap">{cur().question}</text>
|
|
365
516
|
<Show when={cur().details}><text fg={theme().textMuted} wrapMode="wrap">{cur().details}</text></Show>
|
|
366
517
|
<Show when={phase()==="select"}>
|
|
@@ -368,20 +519,32 @@ function QuizBatchDialog(props: {
|
|
|
368
519
|
<For each={cur().options}>{(opt:any,i:any)=>{const id=i(); const foc=()=>focused()==="options"&&optionIndex()===id; const sel=()=>selected().has(`opt:${id}`); return <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} backgroundColor={foc()?theme().backgroundElement:undefined}><box width={2}><text fg={foc()?theme().accent:theme().textMuted}>{foc()?"▸":" "}</text></box><box width={2}><text fg={isMulti()?(sel()?theme().success:theme().textMuted):(sel()?theme().accent:theme().textMuted)}>{isMulti()?(sel()?"☑":"☐"):(sel()?"⬢":"○")}</text></box><box flexGrow={1}><text fg={sel()?theme().text:theme().textMuted} bold={foc()} wrapMode="wrap">{id+1}. {opt.label}</text></box></box>}}</For>
|
|
369
520
|
<box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
|
|
370
521
|
<box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().backgroundElement:undefined}><box width={2}><text fg={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===dontKnowIdx()?"▸":" "}</text></box><box width={2}><text fg={dontKnow()?theme().warning:theme().textMuted}>{dontKnow()?"☑":"☐"}</text></box><box flexGrow={1}><text fg={dontKnow()?theme().warning:theme().textMuted} italic>I don't know</text></box></box>
|
|
371
|
-
<box flexDirection="column" paddingTop={1}><text fg={focused()==="note"?theme().accent:theme().textMuted}>✎ Note</text><box border={true} borderColor={focused()==="note"?theme().accent:theme().borderSubtle} backgroundColor={theme().backgroundElement} paddingLeft={1} paddingRight={1}><Show when={focused()==="note"} fallback={<text fg={theme().textMuted}>{note()||"Tab to edit"}</text>}><input ref={(el:any)=>noteEl=el} value={note()} onInput={(v:any)=>setNote(typeof v==="string"?v:v?.target?.value??"")} onSubmit={()=>setFocused("options")} placeholder="note" /></Show></box></box>
|
|
372
|
-
<box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>Space toggle · ↓ to Submit → Enter · Tab note · Ctrl+Enter submit</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
|
|
522
|
+
<box flexDirection="column" paddingTop={1}><text fg={focused()==="note"?theme().accent:theme().textMuted}>✎ Note</text><box border={true} borderColor={focused()==="note"?theme().accent:theme().borderSubtle} backgroundColor={theme().backgroundElement} paddingLeft={1} paddingRight={1}><Show when={focused()==="note"} fallback={<text fg={theme().textMuted}>{note()||"Tab to edit · share what you were thinking"}</text>}><input ref={(el:any)=>noteEl=el} value={note()} onInput={(v:any)=>setNote(typeof v==="string"?v:v?.target?.value??"")} onSubmit={()=>{ if (!selected().size && !dontKnow() && note().trim()) submitSelect(); else setFocused("options") }} placeholder="note (Enter to submit note → classify)" /></Show></box></box>
|
|
523
|
+
<box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>{focused()==="note" ? "Enter submit note → classify · Tab back · Esc back" : "Space toggle · ↓ to Submit → Enter · Tab note · Ctrl+Enter submit"}</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
|
|
373
524
|
<Show when={isMulti()}><box justifyContent="center" paddingTop={1}><box flexDirection="row" gap={1} border={true} borderColor={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().borderSubtle} backgroundColor={focused()==="options"&&optionIndex()===submitIdx()?theme().backgroundElement:theme().background} paddingLeft={2} paddingRight={2}><text fg={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===submitIdx()?"▸":" "}</text><text bold>↳ Submit</text></box></box></Show>
|
|
374
525
|
</box>
|
|
375
526
|
</Show>
|
|
527
|
+
<Show when={(phase() as any)==="classifying"}>
|
|
528
|
+
<box flexDirection="column" gap={1} padding={1} border={true} borderColor={theme().accent} backgroundColor={theme().background} alignItems="center">
|
|
529
|
+
<text fg={theme().accent} bold>◐ Classifying your note...</text>
|
|
530
|
+
<text fg={theme().textMuted} wrapMode="wrap">"{note()}"</text>
|
|
531
|
+
<text fg={theme().textMuted}>Mapping to options — please wait</text>
|
|
532
|
+
</box>
|
|
533
|
+
</Show>
|
|
376
534
|
<Show when={phase()==="feedback"}>
|
|
377
535
|
<box flexDirection="column" gap={1} padding={1} border={true} borderColor={feedback()?.correct?theme().success:theme().error} backgroundColor={theme().background}>
|
|
378
536
|
<For each={cur().options}>{(opt:any,i:any)=>{const id=i()+1; const sel=()=>feedback()?.selectedIndices.includes(id)??false; const ok=()=>new Set(cur().correctIndices).has(id); let ic=" "; let fg=theme().textMuted; let bg:any=undefined; if(dontKnow()){ic=ok()?"✓":" "; fg=ok()?theme().background:theme().textMuted; bg=ok()?theme().success:undefined} else if(sel()&&ok()){ic="✓"; fg=theme().background; bg=theme().success} else if(sel()&&!ok()){ic="✗"; fg=theme().background; bg=theme().error} else if(!sel()&&ok()){ic="○"; fg=theme().background; bg=theme().warning} return <box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={bg}><box width={2}><text fg={fg} bold>{ic}</text></box><box flexGrow={1}><text fg={fg} wrapMode="wrap">{id}. {opt.label}</text></box></box>}}</For>
|
|
379
537
|
<text fg={feedback()?.correct?theme().success:theme().error} bold>{feedback()?.correct?"✓ Correct":"✗ Incorrect"}</text>
|
|
380
538
|
<text fg={theme().textMuted}>Correct: {cur().correctIndices.map((i:number)=>`${i}. ${cur().options[i-1]?.label}`).join(", ")}</text>
|
|
381
539
|
<box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}><text fg={theme().text} wrapMode="wrap">{cur().explanation}</text></box>
|
|
382
|
-
<box justifyContent="center"><text fg={theme().textMuted}>Enter → next ({idx()+1}/{props.request.quizzes.length})</text></box>
|
|
383
540
|
</box>
|
|
384
541
|
</Show>
|
|
542
|
+
</scrollbox>
|
|
543
|
+
<box height={1} justifyContent="center">
|
|
544
|
+
<text fg={theme().textMuted}>
|
|
545
|
+
{phase()==="feedback" ? `Enter → next (${idx()+1}/${props.request.quizzes.length})` : phase()==="classifying" ? "Classifying your note..." : "Navigate options · Tab note · Esc cancel"}
|
|
546
|
+
</text>
|
|
547
|
+
</box>
|
|
385
548
|
</box>
|
|
386
549
|
)
|
|
387
550
|
}
|
|
@@ -389,6 +552,7 @@ function QuizBatchDialog(props: {
|
|
|
389
552
|
export const tui: TuiPlugin = async (api) => {
|
|
390
553
|
const dir = api.state.path.directory || api.state.path.worktree || process.cwd()
|
|
391
554
|
const pendingDir = path.join(dir, PENDING_DIR)
|
|
555
|
+
;(globalThis as any).__learnPendingDir = pendingDir
|
|
392
556
|
ensureDir(pendingDir)
|
|
393
557
|
const heartbeatPath = path.join(pendingDir, ".tui-alive")
|
|
394
558
|
try { fs.writeFileSync(heartbeatPath, String(Date.now()), "utf8") } catch {}
|
|
@@ -415,7 +579,7 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
415
579
|
if (current) return
|
|
416
580
|
if (api.ui.dialog.open) return
|
|
417
581
|
let files: string[] = []
|
|
418
|
-
try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".")).sort() } catch { return }
|
|
582
|
+
try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
|
|
419
583
|
// Session-distinct: only show pending for current session
|
|
420
584
|
const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean) as Array<{f: string, j: any}>
|
|
421
585
|
const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
|
|
@@ -425,6 +589,7 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
425
589
|
let data: Pending | null = null
|
|
426
590
|
try { data = JSON.parse(fs.readFileSync(full, "utf8")) as Pending } catch { try { fs.unlinkSync(full) } catch {}; return }
|
|
427
591
|
if (!data || !data.id) { try { fs.unlinkSync(full) } catch {}; return }
|
|
592
|
+
if (!(data as any).sessionID) (data as any).sessionID = curSid
|
|
428
593
|
// If pending was from a previous session that no longer exists, rebind to current session so inject still wakes you (like loop guardLoopOwnedUserMessage)
|
|
429
594
|
try {
|
|
430
595
|
const cur = (api.route as any)?.current
|
package/plugins/learn.ts
CHANGED
|
@@ -454,11 +454,164 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
454
454
|
const loggedToolCallIds = new Set<string>()
|
|
455
455
|
const messageIdToRole = new Map<string, string>()
|
|
456
456
|
|
|
457
|
+
// ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
|
|
458
|
+
function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>): number[] {
|
|
459
|
+
const n = note.toLowerCase()
|
|
460
|
+
const out: number[] = []
|
|
461
|
+
for (let i = 0; i < options.length; i++) {
|
|
462
|
+
const o = options[i]
|
|
463
|
+
const label = (o.label || "").toLowerCase()
|
|
464
|
+
const value = (o.value || "").toLowerCase()
|
|
465
|
+
if (label && n.includes(label)) out.push(i + 1)
|
|
466
|
+
else if (value && n.includes(value)) out.push(i + 1)
|
|
467
|
+
else {
|
|
468
|
+
const tokens = label.split(/[^a-z0-9]+/).filter(t => t.length >= 3)
|
|
469
|
+
if (tokens.some(t => n.includes(t))) out.push(i + 1)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return [...new Set(out)]
|
|
473
|
+
}
|
|
474
|
+
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 }> {
|
|
475
|
+
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.
|
|
476
|
+
|
|
477
|
+
${question ? `Question: ${question}\n` : ""}Options:
|
|
478
|
+
${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join("\n")}
|
|
479
|
+
|
|
480
|
+
Learner note: "${note}"
|
|
481
|
+
|
|
482
|
+
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.
|
|
483
|
+
|
|
484
|
+
Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`
|
|
485
|
+
try {
|
|
486
|
+
const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`
|
|
487
|
+
const body: any = { title }
|
|
488
|
+
if (parentSessionID) body.parentID = parentSessionID
|
|
489
|
+
const created: any = await client.session.create({ body, query: { directory } })
|
|
490
|
+
const sid = created?.data?.id || created?.id || created?.data?.sessionID
|
|
491
|
+
if (!sid) throw new Error("no sid")
|
|
492
|
+
const createdSession = created?.data || created
|
|
493
|
+
slog("classify subagent created", sid, `requestedParent:${parentSessionID || "none"}`, `actualParent:${createdSession?.parentID || "none"}`, note.slice(0, 40))
|
|
494
|
+
if (parentSessionID && createdSession?.parentID !== parentSessionID) {
|
|
495
|
+
throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`)
|
|
496
|
+
}
|
|
497
|
+
await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } })
|
|
498
|
+
// Poll for assistant response up to 12s
|
|
499
|
+
for (let i = 0; i < 24; i++) {
|
|
500
|
+
await new Promise(r => setTimeout(r, 500))
|
|
501
|
+
try {
|
|
502
|
+
const msgs: any = await client.session.messages({ path: { id: sid } })
|
|
503
|
+
const data = msgs?.data || msgs
|
|
504
|
+
const arr = Array.isArray(data) ? data : []
|
|
505
|
+
for (let j = arr.length - 1; j >= 0; j--) {
|
|
506
|
+
const entry = arr[j]
|
|
507
|
+
if (entry?.info?.role === "assistant") {
|
|
508
|
+
const text = (entry.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join(" ") || ""
|
|
509
|
+
// Try object JSON {"inferred":[2],"semanticCorrect":false}
|
|
510
|
+
const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/)
|
|
511
|
+
if (objMatch) {
|
|
512
|
+
try {
|
|
513
|
+
const parsed = JSON.parse(objMatch[0])
|
|
514
|
+
if (parsed && Array.isArray(parsed.inferred)) {
|
|
515
|
+
const nums = parsed.inferred.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
|
|
516
|
+
slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`)
|
|
517
|
+
return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid }
|
|
518
|
+
}
|
|
519
|
+
} catch {}
|
|
520
|
+
}
|
|
521
|
+
const m = text.match(/\[[\s\d,]*\]/)
|
|
522
|
+
if (m) {
|
|
523
|
+
try {
|
|
524
|
+
const parsed = JSON.parse(m[0])
|
|
525
|
+
if (Array.isArray(parsed)) {
|
|
526
|
+
const nums = parsed.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
|
|
527
|
+
if (nums.length) {
|
|
528
|
+
slog("llmClassify success array", note.slice(0, 40), nums.join(","))
|
|
529
|
+
return { inferred: nums, sessionID: sid }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
if (text.includes("1") || text.includes("2")) {
|
|
535
|
+
const nums = [...text.matchAll(/\b([1-9])\b/g)].map(x => parseInt(x[1])).filter(n => n <= options.length)
|
|
536
|
+
if (nums.length) return { inferred: [...new Set(nums)], sessionID: sid }
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} catch {}
|
|
541
|
+
}
|
|
542
|
+
slog("llmClassify timeout", note.slice(0, 40))
|
|
543
|
+
} catch (e) {
|
|
544
|
+
slog("llmClassify failed", String(e).slice(0, 200))
|
|
545
|
+
}
|
|
546
|
+
return { inferred: [] }
|
|
547
|
+
}
|
|
548
|
+
function startClassifyWatcher(client: any, directory: string) {
|
|
549
|
+
const dir = pendingDir(directory)
|
|
550
|
+
try { fs.mkdirSync(dir, { recursive: true }) } catch {}
|
|
551
|
+
const processClassify = async (filename: string) => {
|
|
552
|
+
if (!filename.startsWith("classify-") || filename.startsWith("classify-response-")) return
|
|
553
|
+
const fp = path.join(dir, filename)
|
|
554
|
+
if (!fs.existsSync(fp)) return
|
|
555
|
+
const respPath = path.join(dir, filename.replace("classify-", "classify-response-"))
|
|
556
|
+
if (fs.existsSync(respPath)) return
|
|
557
|
+
let data: any
|
|
558
|
+
try { data = JSON.parse(fs.readFileSync(fp, "utf8")) } catch { return }
|
|
559
|
+
if (data?.type !== "classify" || !data?.note || !Array.isArray(data?.options)) return
|
|
560
|
+
slog("classify watcher processing", data.id, data.note.slice(0, 80))
|
|
561
|
+
const byVal = new Map<string, number>(data.options.map((o: any, i: number) => [o.value, i + 1] as [string, number]))
|
|
562
|
+
const start = Date.now()
|
|
563
|
+
let inferred: number[] = []
|
|
564
|
+
let semanticCorrect: boolean | undefined
|
|
565
|
+
let reason: string | undefined
|
|
566
|
+
const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID)
|
|
567
|
+
if (llmRes.inferred.length) {
|
|
568
|
+
inferred = llmRes.inferred
|
|
569
|
+
semanticCorrect = llmRes.semanticCorrect
|
|
570
|
+
reason = llmRes.reason
|
|
571
|
+
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`)
|
|
572
|
+
} else {
|
|
573
|
+
inferred = heuristicClassify(data.note, data.options)
|
|
574
|
+
if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","))
|
|
575
|
+
else slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`)
|
|
576
|
+
}
|
|
577
|
+
// Ensure minimal classify time so UI doesn't feel instant-wrong (at least 1200ms)
|
|
578
|
+
const elapsed = Date.now() - start
|
|
579
|
+
if (elapsed < 1200) await new Promise(r => setTimeout(r, 1200 - elapsed))
|
|
580
|
+
const inferredValues = inferred.map((i: number) => data.options[i - 1]?.value).filter(Boolean) as string[]
|
|
581
|
+
// Final fallback if still empty
|
|
582
|
+
if (!inferred.length && data.note) {
|
|
583
|
+
const n = data.note.toLowerCase()
|
|
584
|
+
for (const o of data.options) {
|
|
585
|
+
const v = o.value ? String(o.value).toLowerCase() : ""
|
|
586
|
+
if (v && n.includes(v) && !inferred.includes(byVal.get(o.value) as number)) {
|
|
587
|
+
const idx = byVal.get(o.value) as number | undefined
|
|
588
|
+
if (idx) inferred.push(idx)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
|
|
593
|
+
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
|
|
594
|
+
try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
|
|
595
|
+
}
|
|
596
|
+
// Initial sweep
|
|
597
|
+
try {
|
|
598
|
+
for (const f of fs.readdirSync(dir).filter(f => f.startsWith("classify-") && !f.startsWith("classify-response-"))) {
|
|
599
|
+
void processClassify(f)
|
|
600
|
+
}
|
|
601
|
+
} catch {}
|
|
602
|
+
try {
|
|
603
|
+
const w = fs.watch(dir, (_e, filename) => { if (filename) void processClassify(filename) })
|
|
604
|
+
w.on("error", () => {})
|
|
605
|
+
// Keep watcher alive; store to avoid GC? No need.
|
|
606
|
+
} catch {}
|
|
607
|
+
}
|
|
608
|
+
startClassifyWatcher(client, directory)
|
|
609
|
+
|
|
457
610
|
// Durability: on (re)start, re-watch any pending quizzes left from a crash/exit
|
|
458
611
|
try {
|
|
459
612
|
const dir = pendingDir(directory)
|
|
460
613
|
if (fs.existsSync(dir)) {
|
|
461
|
-
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
|
|
614
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
462
615
|
try {
|
|
463
616
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
|
|
464
617
|
if (j?.id && j?.sessionID) {
|
|
@@ -511,7 +664,7 @@ const server: Plugin = async ({ client, directory }) => {
|
|
|
511
664
|
config: async (output) => {
|
|
512
665
|
const agents = (output as any).agent ?? {}
|
|
513
666
|
let mutated = false
|
|
514
|
-
for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
|
|
667
|
+
for (const name of ["researcher", "mermaid-maker", "svg-maker", "classify"]) {
|
|
515
668
|
if (!agents[name]) {
|
|
516
669
|
// Minimal placeholder — real agent definitions live in .opencode/agents/*.md
|
|
517
670
|
// We inject a lightweight config so `task` tool can discover them even if md file is missing.
|
package/scripts/install.mjs
CHANGED
|
@@ -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)
|