@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/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.5",
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",
@@ -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(isMulti() ? submitIdx() : dontKnowIdx(), i + 1)); return }
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={() => setFocused("options")}
212
- placeholder="what was on your mind?"
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}>↑↓/j k · Space toggle · ↓ to Submit → Enter · Tab note · Esc cancel</text>
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.
@@ -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)