@bojackduy/opencode-learn 0.1.6 → 1.1.0
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/dist/server.js +45 -25
- package/dist/tui.js +1296 -573
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +195 -26
- package/plugins/learn.ts +42 -23
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": "
|
|
4
|
+
"version": "1.1.0",
|
|
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",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -6,12 +6,66 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
|
6
6
|
import * as fs from "node:fs"
|
|
7
7
|
import * as path from "node:path"
|
|
8
8
|
import { watch } from "node:fs"
|
|
9
|
+
import { SyntaxStyle } from "@opentui/core"
|
|
10
|
+
function syntaxStyle(theme:any){
|
|
11
|
+
return SyntaxStyle.fromTheme([
|
|
12
|
+
{ scope: ["default"], style: { foreground: theme.text } },
|
|
13
|
+
{ scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
|
|
14
|
+
{ scope: ["string", "symbol", "character", "character.special"], style: { foreground: theme.syntaxString } },
|
|
15
|
+
{ scope: ["number", "boolean", "float", "constant"], style: { foreground: theme.syntaxNumber } },
|
|
16
|
+
{ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine", "keyword", "keyword.directive", "keyword.modifier", "keyword.exception"], style: { foreground: theme.syntaxKeyword, italic: true } },
|
|
17
|
+
{ scope: ["keyword.type"], style: { foreground: theme.syntaxType, bold: true, italic: true } },
|
|
18
|
+
{ scope: ["keyword.import", "keyword.export", "tag.attribute"], style: { foreground: theme.syntaxKeyword } },
|
|
19
|
+
{ scope: ["keyword.function", "function.method", "variable.member", "function", "constructor"], style: { foreground: theme.syntaxFunction } },
|
|
20
|
+
{ scope: ["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary", "punctuation.special", "tag.delimiter"], style: { foreground: theme.syntaxOperator } },
|
|
21
|
+
{ scope: ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"], style: { foreground: theme.syntaxVariable } },
|
|
22
|
+
{ scope: ["type", "module", "class", "namespace"], style: { foreground: theme.syntaxType } },
|
|
23
|
+
{ scope: ["punctuation", "punctuation.bracket"], style: { foreground: theme.syntaxPunctuation } },
|
|
24
|
+
{ scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super", "tag"], style: { foreground: theme.error } },
|
|
25
|
+
{ scope: ["string.escape", "string.regexp"], style: { foreground: theme.syntaxKeyword } },
|
|
26
|
+
{ scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
|
|
27
|
+
{ scope: ["markup.heading.1"], style: { foreground: theme.markdownHeading, bold: true, underline: true } },
|
|
28
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
|
|
29
|
+
{ scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
|
|
30
|
+
{ scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
|
|
31
|
+
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
32
|
+
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } },
|
|
33
|
+
{ scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } },
|
|
34
|
+
{ scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"], style: { foreground: theme.markdownLink, underline: true } },
|
|
35
|
+
{ scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
|
|
36
|
+
{ scope: ["spell", "nospell", "markup.underline"], style: { foreground: theme.text } },
|
|
37
|
+
{ scope: ["conceal", "markup.strikethrough", "markup.list.unchecked", "debug"], style: { foreground: theme.textMuted } },
|
|
38
|
+
{ scope: ["comment.error", "error"], style: { foreground: theme.error, italic: true, bold: true } },
|
|
39
|
+
{ scope: ["comment.warning", "warning"], style: { foreground: theme.warning, italic: true, bold: true } },
|
|
40
|
+
{ scope: ["comment.todo", "comment.note"], style: { foreground: theme.info, italic: true, bold: true } },
|
|
41
|
+
{ scope: ["type.definition"], style: { foreground: theme.syntaxType, bold: true } },
|
|
42
|
+
{ scope: ["attribute", "annotation"], style: { foreground: theme.warning } },
|
|
43
|
+
{ scope: ["markup.list.checked"], style: { foreground: theme.success } },
|
|
44
|
+
{ scope: ["diff.plus"], style: { foreground: theme.diffAdded, background: theme.diffAddedBg } },
|
|
45
|
+
{ scope: ["diff.minus"], style: { foreground: theme.diffRemoved, background: theme.diffRemovedBg } },
|
|
46
|
+
{ scope: ["diff.delta"], style: { foreground: theme.diffContext, background: theme.diffContextBg } },
|
|
47
|
+
{ scope: ["info"], style: { foreground: theme.info } },
|
|
48
|
+
])
|
|
49
|
+
}
|
|
9
50
|
|
|
10
51
|
const PENDING_DIR = ".opencode/learn-pending"
|
|
11
52
|
import { tmpdir } from "node:os"
|
|
12
53
|
const TUI_LOG = path.join(tmpdir(), "learn-tui.log")
|
|
13
54
|
function tlog(...a: any[]) { try { fs.appendFileSync(TUI_LOG, `[${new Date().toISOString()}] ${a.map(x=> typeof x==="string"? x : JSON.stringify(x)).join(" ")}\n`) } catch {} }
|
|
14
55
|
function ensureDir(dir: string) { try { fs.mkdirSync(dir, { recursive: true }) } catch {} }
|
|
56
|
+
function decodeQuizText(s: string): string {
|
|
57
|
+
if (!s || typeof s !== "string") return s
|
|
58
|
+
if (!s.includes("\\")) return s
|
|
59
|
+
// Convert literal \n / \r\n / \t escapes to real whitespace. Handles both single and double-escaped payloads (e.g. file contains \\n after JSON round-trip).
|
|
60
|
+
// Only touches backslash sequences, leaves actual newlines intact.
|
|
61
|
+
let out = s.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
|
|
62
|
+
// Decode escaped quotes/backslashes that may survive double-escaping: \" -> ", \' -> ', \\ -> \
|
|
63
|
+
// Do this after newline handling to avoid re-introducing \n.
|
|
64
|
+
out = out.replace(/\\"/g, '"').replace(/\\'/g, "'")
|
|
65
|
+
// Collapse double-escaped backslashes that are not part of \n already handled
|
|
66
|
+
out = out.replace(/\\\\/g, "\\")
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
15
69
|
|
|
16
70
|
type QuizPending = {
|
|
17
71
|
id: string
|
|
@@ -43,9 +97,16 @@ function QuizDialog(props: {
|
|
|
43
97
|
onCancel: () => void
|
|
44
98
|
}) {
|
|
45
99
|
const theme = () => props.api.theme.current
|
|
100
|
+
const syntax = () => syntaxStyle(theme())
|
|
46
101
|
const dims = useTerminalDimensions()
|
|
47
|
-
const popupWidth = () =>
|
|
48
|
-
|
|
102
|
+
const popupWidth = () => {
|
|
103
|
+
const w = dims().width
|
|
104
|
+
return Math.max(62, Math.min(w - 8, Math.floor(w * 0.80), 92))
|
|
105
|
+
}
|
|
106
|
+
const popupHeight = () => {
|
|
107
|
+
const h = dims().height
|
|
108
|
+
return Math.max(14, Math.min(h - 6, Math.floor(h * 0.62), 26))
|
|
109
|
+
}
|
|
49
110
|
const options = () => props.request.options
|
|
50
111
|
const correctSet = new Set(props.request.correctIndices)
|
|
51
112
|
const isMulti = () => !!props.request.multiSelect
|
|
@@ -61,12 +122,38 @@ function QuizDialog(props: {
|
|
|
61
122
|
const [feedback, setFeedback] = createSignal<{ correct: boolean; selectedIndices: number[] } | null>(null)
|
|
62
123
|
|
|
63
124
|
let noteInputEl: any
|
|
64
|
-
|
|
125
|
+
let scrollRef: any
|
|
126
|
+
const [canScrollUp, setCanScrollUp] = createSignal(false)
|
|
127
|
+
const [canScrollDown, setCanScrollDown] = createSignal(false)
|
|
128
|
+
const updateScrollIndicators = () => {
|
|
129
|
+
try {
|
|
130
|
+
if (!scrollRef) { setCanScrollUp(false); setCanScrollDown(false); return }
|
|
131
|
+
const st = typeof scrollRef.scrollTop === "number" ? scrollRef.scrollTop : 0
|
|
132
|
+
const h = typeof scrollRef.height === "number" ? scrollRef.height : (scrollRef.viewportHeight ?? popupHeight())
|
|
133
|
+
const sh = typeof scrollRef.scrollHeight === "number" ? scrollRef.scrollHeight : 0
|
|
134
|
+
let effectiveSh = sh
|
|
135
|
+
if (!effectiveSh && typeof scrollRef.getChildren === "function") {
|
|
136
|
+
try { const kids = scrollRef.getChildren(); if (kids?.length) effectiveSh = Math.max(...kids.map((c:any)=> (c.y||0)+(c.height||0)), h) } catch {}
|
|
137
|
+
}
|
|
138
|
+
if (!effectiveSh || effectiveSh <= h + 1) { setCanScrollUp(false); setCanScrollDown(false); return }
|
|
139
|
+
setCanScrollUp(st > 0)
|
|
140
|
+
setCanScrollDown(st + h < effectiveSh - 1)
|
|
141
|
+
} catch { setCanScrollUp(false); setCanScrollDown(false) }
|
|
142
|
+
}
|
|
143
|
+
const scrollAmount = () => Math.max(1, Math.floor((scrollRef?.height ?? popupHeight()) / 3))
|
|
65
144
|
createEffect(() => {
|
|
66
145
|
if (focused() === "note" && noteInputEl) {
|
|
67
146
|
try { noteInputEl.focus() } catch {}
|
|
68
147
|
}
|
|
69
148
|
})
|
|
149
|
+
// Keep indicators in sync on phase/dims/feedback changes
|
|
150
|
+
createEffect(() => { phase(); feedback(); dims(); setTimeout(updateScrollIndicators, 40); setTimeout(updateScrollIndicators, 200) })
|
|
151
|
+
createEffect(() => { note(); setTimeout(updateScrollIndicators, 40) })
|
|
152
|
+
createEffect(() => {
|
|
153
|
+
if (phase() !== "feedback" && phase() !== "select") return
|
|
154
|
+
const id = setInterval(updateScrollIndicators, 200)
|
|
155
|
+
onCleanup(() => clearInterval(id))
|
|
156
|
+
})
|
|
70
157
|
|
|
71
158
|
const toggleOption = (idx: number) => {
|
|
72
159
|
const opt = options()[idx]
|
|
@@ -170,13 +257,28 @@ function QuizDialog(props: {
|
|
|
170
257
|
props.onSubmit({ answers: dontKnow() ? [] : sel, dontKnow: dontKnow(), note: note().trim() || undefined })
|
|
171
258
|
}
|
|
172
259
|
|
|
260
|
+
const isPlainKey = (evt:any, want:string) => {
|
|
261
|
+
try {
|
|
262
|
+
const n = String(evt.name||evt.sequence||"").toLowerCase()
|
|
263
|
+
if (n !== want.toLowerCase()) return false
|
|
264
|
+
if (evt.ctrl || evt.meta || evt.option || evt.alt) return false
|
|
265
|
+
return true
|
|
266
|
+
} catch { return false }
|
|
267
|
+
}
|
|
173
268
|
useKeyboard((evt: any) => {
|
|
174
269
|
const key = evt.name || evt.sequence || evt.raw || ""
|
|
175
270
|
const seq = evt.sequence || ""
|
|
271
|
+
const lower = String(key||"").toLowerCase()
|
|
176
272
|
if ((phase() as any) === "classifying") { prevent(evt); return }
|
|
177
|
-
// When in feedback,
|
|
273
|
+
// When in feedback, handle scroll first, then confirm
|
|
178
274
|
if (phase() === "feedback") {
|
|
179
|
-
if (
|
|
275
|
+
if (isPlainKey(evt,"d") || seq === "\x04") { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators, 30); setTimeout(updateScrollIndicators, 120) } catch {} return }
|
|
276
|
+
if (isPlainKey(evt,"u") || seq === "\x15") { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators, 30); setTimeout(updateScrollIndicators, 120) } catch {} return }
|
|
277
|
+
if (isPlainKey(evt,"j") || seq === "\x1b[B") { prevent(evt); try { scrollRef?.scrollBy(1); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
278
|
+
if (isPlainKey(evt,"k") || seq === "\x1b[A") { prevent(evt); try { scrollRef?.scrollBy(-1); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
279
|
+
if (key === "pageup" || seq === "\x1b[5~") { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
280
|
+
if (key === "pagedown" || seq === "\x1b[6~") { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators, 30) } catch {} return }
|
|
281
|
+
if ( lower === "enter" || seq === "\r" || lower === "escape" || lower === "esc") {
|
|
180
282
|
prevent(evt)
|
|
181
283
|
confirmFeedback()
|
|
182
284
|
}
|
|
@@ -190,6 +292,11 @@ function QuizDialog(props: {
|
|
|
190
292
|
// Allow typing to go to input; don't prevent
|
|
191
293
|
return
|
|
192
294
|
}
|
|
295
|
+
// d/u scroll works in both select and feedback — page scroll even before answer
|
|
296
|
+
if (phase() === "select" && (isPlainKey(evt,"d") || seq === "\x04")) { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators,30); setTimeout(updateScrollIndicators,120) } catch {} return }
|
|
297
|
+
if (phase() === "select" && (isPlainKey(evt,"u") || seq === "\x15")) { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators,30); setTimeout(updateScrollIndicators,120) } catch {} return }
|
|
298
|
+
if (phase() === "select" && (lower === "pageup" || seq === "\x1b[5~")) { prevent(evt); try { scrollRef?.scrollBy(-scrollAmount()); setTimeout(updateScrollIndicators,30) } catch {} return }
|
|
299
|
+
if (phase() === "select" && (lower === "pagedown" || seq === "\x1b[6~")) { prevent(evt); try { scrollRef?.scrollBy(scrollAmount()); setTimeout(updateScrollIndicators,30) } catch {} return }
|
|
193
300
|
// Options focused — extra Submit for single note-only at dontKnowIdx+1
|
|
194
301
|
const maxIdx = () => {
|
|
195
302
|
if (isMulti()) return submitIdx()
|
|
@@ -238,13 +345,21 @@ function QuizDialog(props: {
|
|
|
238
345
|
<text fg={theme().background} bold>{phase() === "feedback" ? (feedback()?.correct ? "✓ CORRECT" : dontKnow() ? "○ I DON'T KNOW" : "✗ INCORRECT") : isMulti() ? "☑ QUIZ · MULTI-SELECT" : "● QUIZ · SINGLE" }</text>
|
|
239
346
|
<text fg={theme().background} dim>learn</text>
|
|
240
347
|
</box>
|
|
348
|
+
{/* Pinned scroll cue — header-anchored, high contrast so user instantly knows explanation is below */}
|
|
349
|
+
<Show when={phase()==="feedback" && (canScrollUp() || canScrollDown())}>
|
|
350
|
+
<box justifyContent="center" height={1} backgroundColor={canScrollDown() ? theme().warning : theme().accent} paddingLeft={1} paddingRight={1}>
|
|
351
|
+
<text fg={theme().background} bold>
|
|
352
|
+
{canScrollUp() && canScrollDown() ? "▲ more above · ▼ more below — d / u to scroll" : canScrollDown() ? "▼ more below — press d to see explanation" : "▲ more above — press u to scroll up"}
|
|
353
|
+
</text>
|
|
354
|
+
</box>
|
|
355
|
+
</Show>
|
|
241
356
|
|
|
242
|
-
<scrollbox flexGrow={1}>
|
|
243
|
-
{/* Question */}
|
|
357
|
+
<scrollbox ref={(el:any)=> scrollRef = el} flexGrow={1} verticalScrollbarOptions={{ visible: true, trackOptions: { backgroundColor: theme().background, foregroundColor: theme().borderActive } }}>
|
|
358
|
+
{/* Question — use opencode markdown render so ```python blocks get syntax coloring like native messages */}
|
|
244
359
|
<box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
|
|
245
|
-
<
|
|
360
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.question)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
246
361
|
<Show when={props.request.details}>
|
|
247
|
-
<
|
|
362
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.details)} fg={theme().textMuted} bg={theme().backgroundPanel} />
|
|
248
363
|
</Show>
|
|
249
364
|
</box>
|
|
250
365
|
|
|
@@ -293,9 +408,8 @@ function QuizDialog(props: {
|
|
|
293
408
|
</box>
|
|
294
409
|
|
|
295
410
|
<box flexDirection="row" justifyContent="space-between" paddingTop={1}>
|
|
296
|
-
<text fg={theme().textMuted}>{
|
|
297
|
-
<
|
|
298
|
-
<Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}><text fg={theme().warning}>note → classify on Enter</text></Show>
|
|
411
|
+
<text fg={theme().textMuted}>{isMulti() ? `${selected().size} selected${dontKnow() ? " · I don't know" : ""}` : note().trim() && !selected().size && !dontKnow() ? "note → classify" : dontKnow() ? "I don't know" : ""}</text>
|
|
412
|
+
<text fg={theme().textMuted}>{focused() === "note" ? "Tab/Esc back" : ""}</text>
|
|
299
413
|
</box>
|
|
300
414
|
<Show when={isMulti()}>
|
|
301
415
|
<box justifyContent="center" paddingTop={1}>
|
|
@@ -351,14 +465,18 @@ function QuizDialog(props: {
|
|
|
351
465
|
<text fg={theme().textMuted}>Correct: {props.request.correctIndices.map(i => `${i}. ${options()[i-1]?.label}`).join(", ")}</text>
|
|
352
466
|
<Show when={note()}><text fg={theme().textMuted}>Your note: {note()}</text></Show>
|
|
353
467
|
<box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}>
|
|
354
|
-
<
|
|
468
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(props.request.explanation)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
355
469
|
</box>
|
|
356
470
|
</box>
|
|
357
471
|
</Show>
|
|
358
472
|
</scrollbox>
|
|
359
473
|
<box height={1} justifyContent="center">
|
|
360
|
-
<text fg={theme().textMuted}>
|
|
361
|
-
{phase() === "feedback"
|
|
474
|
+
<text fg={theme().textMuted} wrapMode="wrap">
|
|
475
|
+
{phase() === "feedback"
|
|
476
|
+
? (canScrollUp() && canScrollDown() ? <><span style={{fg: theme.warning, bold: true}}>▲ more above · ▼ more below</span><span style={{fg: theme().textMuted}}> — d/u to scroll · Enter to continue</span></> : canScrollDown() ? <><span style={{fg: theme.warning, bold: true}}>▼ more below</span><span style={{fg: theme().textMuted}}> — d to scroll · Enter to continue</span></> : canScrollUp() ? <><span style={{fg: theme.accent, bold: true}}>▲ more above</span><span style={{fg: theme().textMuted}}> — u to scroll · Enter to continue</span></> : "↵ Enter / Esc to continue → next probe")
|
|
477
|
+
: phase() === "classifying" ? "Classifying your note..."
|
|
478
|
+
: focused() === "note" ? "Enter submit note → classify · Tab/Esc back"
|
|
479
|
+
: (canScrollUp() || canScrollDown()) ? <><span style={{fg: theme().textMuted}}>j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel</span><span style={{fg: theme.warning, bold: true}}> · d/u scroll</span></> : "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel"}
|
|
362
480
|
</text>
|
|
363
481
|
</box>
|
|
364
482
|
</box>
|
|
@@ -373,9 +491,16 @@ function QuizBatchDialog(props: {
|
|
|
373
491
|
onCancel: () => void
|
|
374
492
|
}) {
|
|
375
493
|
const theme = () => props.api.theme.current
|
|
494
|
+
const syntax = () => syntaxStyle(theme())
|
|
376
495
|
const dims = useTerminalDimensions()
|
|
377
|
-
const popupWidth = () =>
|
|
378
|
-
|
|
496
|
+
const popupWidth = () => {
|
|
497
|
+
const w = dims().width
|
|
498
|
+
return Math.max(64, Math.min(w - 8, Math.floor(w * 0.82), 96))
|
|
499
|
+
}
|
|
500
|
+
const popupHeight = () => {
|
|
501
|
+
const h = dims().height
|
|
502
|
+
return Math.max(14, Math.min(h - 6, Math.floor(h * 0.64), 28))
|
|
503
|
+
}
|
|
379
504
|
const [idx, setIdx] = createSignal(0)
|
|
380
505
|
// Guard: if no quizzes, cancel
|
|
381
506
|
if (!props.request.quizzes || props.request.quizzes.length === 0) {
|
|
@@ -396,7 +521,33 @@ function QuizBatchDialog(props: {
|
|
|
396
521
|
const dontKnowIdx = () => cur().options.length
|
|
397
522
|
const submitIdx = () => isMulti() ? cur().options.length + 1 : -1
|
|
398
523
|
let noteEl: any
|
|
524
|
+
let scrollRefBatch: any
|
|
525
|
+
const [canScrollUpBatch, setCanScrollUpBatch] = createSignal(false)
|
|
526
|
+
const [canScrollDownBatch, setCanScrollDownBatch] = createSignal(false)
|
|
527
|
+
const updateScrollBatch = () => {
|
|
528
|
+
try {
|
|
529
|
+
if (!scrollRefBatch) { setCanScrollUpBatch(false); setCanScrollDownBatch(false); return }
|
|
530
|
+
const st = typeof scrollRefBatch.scrollTop === "number" ? scrollRefBatch.scrollTop : 0
|
|
531
|
+
const h = typeof scrollRefBatch.height === "number" ? scrollRefBatch.height : (scrollRefBatch.viewportHeight ?? popupHeight())
|
|
532
|
+
const sh = typeof scrollRefBatch.scrollHeight === "number" ? scrollRefBatch.scrollHeight : 0
|
|
533
|
+
let effectiveSh = sh
|
|
534
|
+
if (!effectiveSh && typeof scrollRefBatch.getChildren === "function") {
|
|
535
|
+
try { const kids = scrollRefBatch.getChildren(); if (kids?.length) effectiveSh = Math.max(...kids.map((c:any)=> (c.y||0)+(c.height||0)), h) } catch {}
|
|
536
|
+
}
|
|
537
|
+
if (!effectiveSh || effectiveSh <= h + 1) { setCanScrollUpBatch(false); setCanScrollDownBatch(false); return }
|
|
538
|
+
setCanScrollUpBatch(st > 0)
|
|
539
|
+
setCanScrollDownBatch(st + h < effectiveSh - 1)
|
|
540
|
+
} catch { setCanScrollUpBatch(false); setCanScrollDownBatch(false) }
|
|
541
|
+
}
|
|
542
|
+
const scrollAmountBatch = () => Math.max(1, Math.floor((scrollRefBatch?.height ?? popupHeight()) / 3))
|
|
399
543
|
createEffect(() => { if (focused()==="note" && noteEl) try{noteEl.focus()}catch(e){ tlog("note focus failed", String(e)) } })
|
|
544
|
+
createEffect(() => { phase(); feedback(); dims(); idx(); setTimeout(updateScrollBatch, 40); setTimeout(updateScrollBatch, 200) })
|
|
545
|
+
createEffect(() => { note(); setTimeout(updateScrollBatch, 40) })
|
|
546
|
+
createEffect(() => {
|
|
547
|
+
if (phase() !== "feedback" && phase() !== "select") return
|
|
548
|
+
const id = setInterval(updateScrollBatch, 200)
|
|
549
|
+
onCleanup(() => clearInterval(id))
|
|
550
|
+
})
|
|
400
551
|
const toggle = (i:number) => {
|
|
401
552
|
try {
|
|
402
553
|
const o = cur().options[i]; if(!o) return
|
|
@@ -490,12 +641,22 @@ function QuizBatchDialog(props: {
|
|
|
490
641
|
setPhase("feedback")
|
|
491
642
|
} catch(e){ tlog("submitSelect failed", String(e)) }
|
|
492
643
|
}
|
|
644
|
+
const isPlainKeyBatch = (evt:any, want:string) => {
|
|
645
|
+
try { const n=String(evt.name||evt.sequence||"").toLowerCase(); if(n!==want.toLowerCase()) return false; if(evt.ctrl||evt.meta||evt.option||evt.alt) return false; return true } catch { return false }
|
|
646
|
+
}
|
|
493
647
|
useKeyboard((evt:any)=>{
|
|
494
648
|
try {
|
|
495
|
-
const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""
|
|
649
|
+
const k=evt.name||evt.sequence||evt.raw||""; const seq=evt.sequence||""; const lower=String(k||"").toLowerCase()
|
|
496
650
|
if((phase() as any)==="classifying"){ prevent(evt); return }
|
|
497
|
-
if(phase()==="feedback"){
|
|
651
|
+
if(phase()==="feedback"){
|
|
652
|
+
if (isPlainKeyBatch(evt,"d")||seq==="\x04"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
653
|
+
if (isPlainKeyBatch(evt,"u")||seq==="\x15"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
654
|
+
if (lower==="pageup"||seq==="\x1b[5~"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
|
|
655
|
+
if (lower==="pagedown"||seq==="\x1b[6~"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
|
|
656
|
+
if(lower==="enter"||seq==="\r"||lower==="escape"||lower==="esc"){ prevent(evt); goNext() } return }
|
|
498
657
|
if(focused()==="note"){ if(k==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(k==="escape"){prevent(evt); setFocused("options"); return} return }
|
|
658
|
+
if(phase()==="select" && (isPlainKeyBatch(evt,"d")||seq==="\x04")){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
659
|
+
if(phase()==="select" && (isPlainKeyBatch(evt,"u")||seq==="\x15")){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
499
660
|
if(k==="up"||k==="k"||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
|
|
500
661
|
if(k==="down"||k==="j"||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
|
|
501
662
|
if(k==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
|
|
@@ -511,16 +672,24 @@ function QuizBatchDialog(props: {
|
|
|
511
672
|
<text fg={theme().background} bold> decks.quiz batch {idx()+1}/{props.request.quizzes.length} {phase()==="feedback"?(feedback()?.correct?"✓":"✗"):""}</text>
|
|
512
673
|
<text fg={theme().background} dim>learn</text>
|
|
513
674
|
</box>
|
|
514
|
-
|
|
515
|
-
<
|
|
516
|
-
|
|
675
|
+
{/* Pinned scroll cue — header-anchored, high contrast */}
|
|
676
|
+
<Show when={phase()==="feedback" && (canScrollUpBatch() || canScrollDownBatch())}>
|
|
677
|
+
<box justifyContent="center" height={1} backgroundColor={canScrollDownBatch() ? theme().warning : theme().accent} paddingLeft={1} paddingRight={1}>
|
|
678
|
+
<text fg={theme().background} bold>
|
|
679
|
+
{canScrollUpBatch() && canScrollDownBatch() ? "▲ more above · ▼ more below — d / u to scroll" : canScrollDownBatch() ? "▼ more below — press d to see explanation" : "▲ more above — press u to scroll up"}
|
|
680
|
+
</text>
|
|
681
|
+
</box>
|
|
682
|
+
</Show>
|
|
683
|
+
<scrollbox ref={(el:any)=> scrollRefBatch = el} flexGrow={1} verticalScrollbarOptions={{ visible: true, trackOptions: { backgroundColor: theme().background, foregroundColor: theme().borderActive } }}>
|
|
684
|
+
<markdown syntaxStyle={syntax()} content={decodeQuizText(cur().question)} fg={theme().text} bg={theme().backgroundPanel} />
|
|
685
|
+
<Show when={cur().details}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().details)} fg={theme().textMuted} bg={theme().backgroundPanel} /></Show>
|
|
517
686
|
<Show when={phase()==="select"}>
|
|
518
687
|
<box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
|
|
519
688
|
<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>
|
|
520
689
|
<box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
|
|
521
690
|
<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>
|
|
522
691
|
<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}>{
|
|
692
|
+
<box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>{isMulti() ? `${selected().size} selected` : note().trim() && !selected().size ? "note → classify" : ""}</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
|
|
524
693
|
<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>
|
|
525
694
|
</box>
|
|
526
695
|
</Show>
|
|
@@ -536,13 +705,13 @@ function QuizBatchDialog(props: {
|
|
|
536
705
|
<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>
|
|
537
706
|
<text fg={feedback()?.correct?theme().success:theme().error} bold>{feedback()?.correct?"✓ Correct":"✗ Incorrect"}</text>
|
|
538
707
|
<text fg={theme().textMuted}>Correct: {cur().correctIndices.map((i:number)=>`${i}. ${cur().options[i-1]?.label}`).join(", ")}</text>
|
|
539
|
-
<box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}><
|
|
708
|
+
<box border={true} borderColor={theme().borderSubtle} backgroundColor={theme().backgroundPanel} padding={1}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().explanation)} fg={theme().text} bg={theme().backgroundPanel} /></box>
|
|
540
709
|
</box>
|
|
541
710
|
</Show>
|
|
542
711
|
</scrollbox>
|
|
543
712
|
<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..." : "
|
|
713
|
+
<text fg={theme().textMuted} wrapMode="wrap">
|
|
714
|
+
{phase()==="feedback" ? (canScrollUpBatch() && canScrollDownBatch() ? <><span style={{fg: theme.warning, bold: true}}>▲ more above · ▼ more below</span><span style={{fg: theme().textMuted}}> — d/u to scroll · Enter → next ({idx()+1}/{props.request.quizzes.length})</span></> : canScrollDownBatch() ? <><span style={{fg: theme.warning, bold: true}}>▼ more below</span><span style={{fg: theme().textMuted}}> — d to scroll · Enter → next ({idx()+1}/{props.request.quizzes.length})</span></> : canScrollUpBatch() ? <><span style={{fg: theme.accent, bold: true}}>▲ more above</span><span style={{fg: theme().textMuted}}> — u to scroll · Enter → next ({idx()+1}/{props.request.quizzes.length})</span></> : `Enter → next (${idx()+1}/${props.request.quizzes.length})`) : phase()==="classifying" ? "Classifying your note..." : focused()==="note" ? "Enter submit note → classify · Tab/Esc back" : (canScrollUpBatch() || canScrollDownBatch()) ? <><span style={{fg: theme().textMuted}}>j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel</span><span style={{fg: theme.warning, bold: true}}> · d/u scroll</span></> : "j/k or ↑↓ move · Space toggle · Tab note · Enter submit · Esc cancel"}
|
|
546
715
|
</text>
|
|
547
716
|
</box>
|
|
548
717
|
</box>
|
package/plugins/learn.ts
CHANGED
|
@@ -125,6 +125,15 @@ function resolveCorrect(correctAnswer: string | string[] | undefined, options: A
|
|
|
125
125
|
return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) as number[] }
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function decodeQuizText(s: string | undefined): string | undefined {
|
|
129
|
+
if (!s || typeof s !== "string") return s
|
|
130
|
+
if (!s.includes("\\")) return s
|
|
131
|
+
let out = s.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t")
|
|
132
|
+
out = out.replace(/\\"/g, '"').replace(/\\'/g, "'")
|
|
133
|
+
out = out.replace(/\\\\/g, "\\")
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
128
137
|
// ────────────────────────────────────────────────────────────────────────────
|
|
129
138
|
// md-log helpers (ported from .pi/extensions/md-log.ts)
|
|
130
139
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -788,8 +797,14 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
788
797
|
shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters."),
|
|
789
798
|
},
|
|
790
799
|
async execute(args, ctx) {
|
|
800
|
+
// Fix double-escaped \n coming from LLM JSON (e.g. "\\n" literal instead of newline)
|
|
801
|
+
const qFixed = (decodeQuizText(args.question) ?? args.question) as string
|
|
802
|
+
const dFixed = decodeQuizText(args.details) as string | undefined
|
|
803
|
+
const eFixed = (decodeQuizText(args.explanation) ?? args.explanation) as string
|
|
804
|
+
// Also decode option labels in case they contain code
|
|
805
|
+
const optsDecoded = (args.options as any[] | undefined)?.map((o: any) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description })) as any
|
|
791
806
|
let options: Array<{ label: string; value: string; description?: string }>
|
|
792
|
-
try { options = normalizeQuizOptions(
|
|
807
|
+
try { options = normalizeQuizOptions(optsDecoded) } catch (e) { return `quiz error: ${(e as Error).message}` }
|
|
793
808
|
if (args.shuffle !== false) options = shuffleOptions(options)
|
|
794
809
|
const { indices: correctIndices, error: correctError } = resolveCorrect(args.correctAnswer as any, options)
|
|
795
810
|
if (correctError) return `quiz error: ${correctError}`
|
|
@@ -808,17 +823,17 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
808
823
|
const payload = {
|
|
809
824
|
id,
|
|
810
825
|
type: "quiz" as const,
|
|
811
|
-
question:
|
|
812
|
-
details:
|
|
826
|
+
question: qFixed,
|
|
827
|
+
details: dFixed,
|
|
813
828
|
options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
|
|
814
829
|
correctIndices,
|
|
815
|
-
explanation:
|
|
830
|
+
explanation: eFixed,
|
|
816
831
|
multiSelect: !!args.multiSelect,
|
|
817
832
|
sessionID: (ctx as any).sessionID,
|
|
818
833
|
timestamp: Date.now(),
|
|
819
834
|
}
|
|
820
835
|
try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
|
|
821
|
-
try { await (ctx as any).metadata?.({ title: `Quiz: ${
|
|
836
|
+
try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
|
|
822
837
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
823
838
|
const dk = !!r?.dontKnow
|
|
824
839
|
const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
|
|
@@ -832,19 +847,19 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
832
847
|
answers: r?.answers || [],
|
|
833
848
|
correct: ok,
|
|
834
849
|
correctIndices,
|
|
835
|
-
explanation:
|
|
850
|
+
explanation: eFixed,
|
|
836
851
|
dontKnow: dk,
|
|
837
852
|
note: r?.note,
|
|
838
853
|
}
|
|
839
854
|
void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
|
|
840
855
|
}
|
|
841
856
|
return dk
|
|
842
|
-
? `[quiz answered] "${
|
|
843
|
-
: `[quiz answered] "${
|
|
857
|
+
? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
858
|
+
: `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
844
859
|
})
|
|
845
860
|
// Always mirror question with TRUE shuffled order (pi: tool_execution_update)
|
|
846
861
|
if (mdLogFile) {
|
|
847
|
-
try { await withMdLock(() => appendToMdLog(questionCallout("Quiz",
|
|
862
|
+
try { await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
|
|
848
863
|
}
|
|
849
864
|
if (tuiAlive) {
|
|
850
865
|
return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
|
|
@@ -863,8 +878,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
863
878
|
if (raw === null) return "User cancelled the quiz"
|
|
864
879
|
const trimmed = (raw as string).trim()
|
|
865
880
|
if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
|
|
866
|
-
const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${
|
|
867
|
-
if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [
|
|
881
|
+
const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
882
|
+
if (mdLogFile) await withMdLock(() => appendToMdLog(callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
|
|
868
883
|
return msg
|
|
869
884
|
}
|
|
870
885
|
const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
|
|
@@ -873,28 +888,28 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
873
888
|
const correct = selectedSet.size === correctSet.size && [...selectedSet].every(n => correctSet.has(n))
|
|
874
889
|
const selectedStr = nums.map(n => `${n}. ${options[n - 1].label}`).join(", ") || "(none)"
|
|
875
890
|
const verdict = correct ? "correctly" : "incorrectly"
|
|
876
|
-
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${
|
|
877
|
-
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation:
|
|
878
|
-
if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${
|
|
891
|
+
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
892
|
+
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: eFixed } })
|
|
893
|
+
if (mdLogFile) await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
|
|
879
894
|
return result
|
|
880
895
|
}
|
|
881
896
|
const instruction = [
|
|
882
897
|
`[quiz ready — awaiting user answer via \`question\` tool]`,
|
|
883
|
-
`Question: ${
|
|
884
|
-
|
|
898
|
+
`Question: ${qFixed}`,
|
|
899
|
+
dFixed ? `Details: ${dFixed}` : null,
|
|
885
900
|
`Options (display order, already shuffled):`,
|
|
886
901
|
...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""} (value="${o.value}")`),
|
|
887
902
|
`Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
|
|
888
|
-
`Explanation (reveal AFTER answer): ${
|
|
903
|
+
`Explanation (reveal AFTER answer): ${eFixed}`,
|
|
889
904
|
`Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
|
|
890
905
|
``,
|
|
891
906
|
`INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
|
|
892
907
|
` header: "Quiz"`,
|
|
893
|
-
` question: "${
|
|
908
|
+
` question: "${qFixed.replace(/"/g, '\\"')}"`,
|
|
894
909
|
` options: [${options.map(o => `{label:"${o.label.replace(/"/g, '\\"')}", description:"${(o.description ?? "").replace(/"/g, '\\"')}"}`).join(", ")}]`,
|
|
895
910
|
`Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show ✓/✗, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`,
|
|
896
911
|
].filter(Boolean).join("\n")
|
|
897
|
-
;(ctx as any).metadata?.({ title: `Quiz: ${
|
|
912
|
+
;(ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } })
|
|
898
913
|
return instruction
|
|
899
914
|
},
|
|
900
915
|
}),
|
|
@@ -924,13 +939,17 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
924
939
|
slog("quiz_batch isAlive", isAlive)
|
|
925
940
|
const normalized: any[] = []
|
|
926
941
|
for (const q of (args.quizzes as any[])) {
|
|
942
|
+
const qFixed = (decodeQuizText(q.question) ?? q.question) as string
|
|
943
|
+
const dFixed = decodeQuizText(q.details) as string | undefined
|
|
944
|
+
const eFixed = (decodeQuizText(q.explanation) ?? q.explanation) as string
|
|
945
|
+
const optsDecoded = (q.options as any[] | undefined)?.map((o: any) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description })) as any
|
|
927
946
|
let opts: any
|
|
928
|
-
try { opts = normalizeQuizOptions(
|
|
947
|
+
try { opts = normalizeQuizOptions(optsDecoded) } catch (e) { slog("quiz_batch normalize error", (e as Error).message); return `quiz_batch error: ${(e as Error).message} in "${qFixed}"` }
|
|
929
948
|
if (q.shuffle !== false) opts = shuffleOptions(opts)
|
|
930
949
|
const { indices, error } = resolveCorrect(q.correctAnswer as any, opts)
|
|
931
|
-
if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${
|
|
932
|
-
if (opts.length < 2) return `quiz_batch error: need 2+ options in "${
|
|
933
|
-
normalized.push({ question:
|
|
950
|
+
if (error) { slog("quiz_batch resolveCorrect error", error); return `quiz_batch error: ${error} in "${qFixed}"` }
|
|
951
|
+
if (opts.length < 2) return `quiz_batch error: need 2+ options in "${qFixed}"`
|
|
952
|
+
normalized.push({ question: qFixed, details: dFixed, options: opts, correctIndices: indices, explanation: eFixed, multiSelect: !!q.multiSelect })
|
|
934
953
|
}
|
|
935
954
|
slog("quiz_batch normalized", normalized.length)
|
|
936
955
|
try { fs.mkdirSync(pendingDirPath, { recursive: true }) } catch {}
|