@bojackduy/opencode-learn 1.2.0 → 1.2.2
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/LICENSE +28 -24
- package/README.md +2 -2
- package/dist/server.js +240 -131
- package/dist/tui.js +14 -14
- package/package.json +2 -2
- package/plugins/learn.ts +204 -94
package/dist/tui.js
CHANGED
|
@@ -453,14 +453,14 @@ function QuizDialog(props) {
|
|
|
453
453
|
});
|
|
454
454
|
}
|
|
455
455
|
setSelected(m);
|
|
456
|
-
const
|
|
456
|
+
const correct = computeCorrect(eff);
|
|
457
457
|
setFeedback({
|
|
458
|
-
correct
|
|
458
|
+
correct,
|
|
459
459
|
selectedIndices: eff
|
|
460
460
|
});
|
|
461
461
|
if (reason)
|
|
462
462
|
setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
|
|
463
|
-
tlog("QuizDialog classify done", eff.join(","),
|
|
463
|
+
tlog("QuizDialog classify done", eff.join(","), correct, reason || "");
|
|
464
464
|
} else if (inferredValues && inferredValues.length) {
|
|
465
465
|
const byVal = new Map(options().map((o, i) => [o.value, i + 1]));
|
|
466
466
|
let idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
|
|
@@ -480,15 +480,15 @@ function QuizDialog(props) {
|
|
|
480
480
|
});
|
|
481
481
|
}
|
|
482
482
|
setSelected(m);
|
|
483
|
-
const
|
|
483
|
+
const correct = computeCorrect(idxs);
|
|
484
484
|
setFeedback({
|
|
485
|
-
correct
|
|
485
|
+
correct,
|
|
486
486
|
selectedIndices: idxs
|
|
487
487
|
});
|
|
488
488
|
} else {
|
|
489
|
-
const
|
|
489
|
+
const correct = typeof semanticCorrect === "boolean" ? semanticCorrect : false;
|
|
490
490
|
setFeedback({
|
|
491
|
-
correct
|
|
491
|
+
correct,
|
|
492
492
|
selectedIndices: []
|
|
493
493
|
});
|
|
494
494
|
if (reason)
|
|
@@ -1722,13 +1722,13 @@ function QuizBatchDialog(props) {
|
|
|
1722
1722
|
if (eff.length !== inferred.length)
|
|
1723
1723
|
tlog("QuizBatchDialog classify enforce single", inferred.join(","), "->", eff.join(","));
|
|
1724
1724
|
const mm = new Map;
|
|
1725
|
-
for (const
|
|
1726
|
-
const opt = cur().options[
|
|
1725
|
+
for (const idx of eff) {
|
|
1726
|
+
const opt = cur().options[idx - 1];
|
|
1727
1727
|
if (opt)
|
|
1728
|
-
mm.set(`opt:${
|
|
1728
|
+
mm.set(`opt:${idx - 1}`, {
|
|
1729
1729
|
label: opt.label,
|
|
1730
1730
|
value: opt.value,
|
|
1731
|
-
index:
|
|
1731
|
+
index: idx
|
|
1732
1732
|
});
|
|
1733
1733
|
}
|
|
1734
1734
|
setSelected(mm);
|
|
@@ -2667,12 +2667,12 @@ var tui = async (api) => {
|
|
|
2667
2667
|
data.sessionID = curSid;
|
|
2668
2668
|
try {
|
|
2669
2669
|
const cur = api.route?.current;
|
|
2670
|
-
const
|
|
2671
|
-
if (
|
|
2670
|
+
const curSid = cur?.params?.sessionID || cur?.sessionID;
|
|
2671
|
+
if (curSid && data.sessionID && data.sessionID !== curSid) {
|
|
2672
2672
|
const anyState = api.state;
|
|
2673
2673
|
const exists = anyState.session?.get ? anyState.session.get(data.sessionID) : undefined;
|
|
2674
2674
|
if (!exists)
|
|
2675
|
-
data.sessionID =
|
|
2675
|
+
data.sessionID = curSid;
|
|
2676
2676
|
}
|
|
2677
2677
|
} catch {}
|
|
2678
2678
|
current = {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-learn",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.2",
|
|
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
|
-
"license": "
|
|
7
|
+
"license": "AGPL-3.0-or-later",
|
|
8
8
|
"private": false,
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
package/plugins/learn.ts
CHANGED
|
@@ -136,24 +136,76 @@ function decodeQuizText(s: string | undefined): string | undefined {
|
|
|
136
136
|
|
|
137
137
|
// ────────────────────────────────────────────────────────────────────────────
|
|
138
138
|
// md-log helpers (ported from .pi/extensions/md-log.ts)
|
|
139
|
+
// 1-1-1 model: session : link : log file. No global file.
|
|
139
140
|
// ────────────────────────────────────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
type MdLinkMeta = { file: string; directory: string; linkedAt: number }
|
|
142
|
+
const mdLinks = new Map<string, MdLinkMeta>() // sessionID -> link
|
|
143
|
+
const mdFileLocks = new Map<string, Promise<void>>()
|
|
144
|
+
function withMdFileLock<T>(file: string, fn: () => T | Promise<T>): Promise<T> {
|
|
145
|
+
const prev = mdFileLocks.get(file) ?? Promise.resolve()
|
|
144
146
|
let release!: () => void
|
|
145
|
-
|
|
147
|
+
const next = new Promise<void>(r => { release = r })
|
|
148
|
+
mdFileLocks.set(file, next)
|
|
146
149
|
return prev.then(fn).finally(() => release())
|
|
147
150
|
}
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
// Back-compat alias used by older call sites during migration (per-file lock)
|
|
152
|
+
function withMdLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
153
|
+
// Fallback: no file context — run directly (callers should migrate to withMdFileLock)
|
|
154
|
+
return Promise.resolve().then(fn)
|
|
155
|
+
}
|
|
156
|
+
function getMdFile(sessionID: string | undefined): string | undefined {
|
|
157
|
+
if (!sessionID) return undefined
|
|
158
|
+
return mdLinks.get(sessionID)?.file
|
|
159
|
+
}
|
|
160
|
+
function appendToMdLogForSession(sessionID: string | undefined, text: string) {
|
|
161
|
+
const file = getMdFile(sessionID)
|
|
162
|
+
if (!file || !sessionID) return
|
|
150
163
|
try {
|
|
151
164
|
let current = ""
|
|
152
|
-
if (fs.existsSync(
|
|
165
|
+
if (fs.existsSync(file)) current = fs.readFileSync(file, "utf-8")
|
|
153
166
|
const prefix = current.trim().length > 0 ? "\n\n" : ""
|
|
154
|
-
fs.writeFileSync(
|
|
167
|
+
fs.writeFileSync(file, current + prefix + text + "\n", "utf-8")
|
|
155
168
|
} catch {}
|
|
156
169
|
}
|
|
170
|
+
function loadMdLinks(markerPath: string, directory: string) {
|
|
171
|
+
try {
|
|
172
|
+
if (!fs.existsSync(markerPath)) return 0
|
|
173
|
+
const data = JSON.parse(fs.readFileSync(markerPath, "utf-8"))
|
|
174
|
+
// Legacy shape {file} — do NOT auto-migrate (would bleed). Back up and start empty.
|
|
175
|
+
if (data && typeof data.file === "string" && !data.links) {
|
|
176
|
+
try { fs.writeFileSync(markerPath + ".bak", JSON.stringify(data), "utf-8") } catch {}
|
|
177
|
+
try { fs.writeFileSync(markerPath, JSON.stringify({ version: 1, links: {} }), "utf-8") } catch {}
|
|
178
|
+
try { slog("md-log legacy marker backed up, starting empty 1-1-1", markerPath) } catch {}
|
|
179
|
+
return 0
|
|
180
|
+
}
|
|
181
|
+
const links = (data as any)?.links ?? {}
|
|
182
|
+
let n = 0
|
|
183
|
+
for (const [ses, v] of Object.entries<any>(links)) {
|
|
184
|
+
const f = (v as any)?.file ?? (typeof v === "string" ? v : undefined)
|
|
185
|
+
if (typeof ses === "string" && typeof f === "string" && fs.existsSync(f)) {
|
|
186
|
+
mdLinks.set(ses, { file: f, directory: (v as any)?.directory || directory, linkedAt: (v as any)?.linkedAt || Date.now() })
|
|
187
|
+
n++
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return n
|
|
191
|
+
} catch { return 0 }
|
|
192
|
+
}
|
|
193
|
+
function saveMdLinksForDirectory(markerPath: string, directory: string) {
|
|
194
|
+
try {
|
|
195
|
+
const out: Record<string, { file: string; directory: string; linkedAt: number }> = {}
|
|
196
|
+
for (const [ses, meta] of mdLinks) {
|
|
197
|
+
if (meta.directory === directory) out[ses] = meta
|
|
198
|
+
}
|
|
199
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true })
|
|
200
|
+
fs.writeFileSync(markerPath, JSON.stringify({ version: 1, links: out }), "utf-8")
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
function extractHookSessionID(...candidates: any[]): string | undefined {
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
if (typeof c === "string" && c.length > 0) return c
|
|
206
|
+
}
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
157
209
|
function callout(type: string, title: string, bodyLines: string[]) {
|
|
158
210
|
const lines = [`> [!${type}] ${title}`]
|
|
159
211
|
for (const line of bodyLines) lines.push(line.length === 0 ? ">" : `> ${line}`)
|
|
@@ -202,7 +254,8 @@ function answerCalloutAsk(details: any): string {
|
|
|
202
254
|
return callout("example", "Answer", body)
|
|
203
255
|
}
|
|
204
256
|
async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
|
|
205
|
-
|
|
257
|
+
const mdFile = getMdFile(sessionID)
|
|
258
|
+
if (!mdFile || !sessionID) return 0
|
|
206
259
|
try {
|
|
207
260
|
const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
|
|
208
261
|
const data: any = res?.data ?? res
|
|
@@ -286,15 +339,16 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
|
|
|
286
339
|
}
|
|
287
340
|
}
|
|
288
341
|
if (blocks.length) {
|
|
342
|
+
const mdFile2 = getMdFile(sessionID) || mdFile
|
|
289
343
|
let current = ""
|
|
290
|
-
try { if (fs.existsSync(
|
|
344
|
+
try { if (fs.existsSync(mdFile2)) current = fs.readFileSync(mdFile2, "utf-8") } catch {}
|
|
291
345
|
// If file empty, overwrite; else append with separator (preserve user notes)
|
|
292
346
|
if (current.trim().length === 0) {
|
|
293
|
-
fs.writeFileSync(
|
|
347
|
+
fs.writeFileSync(mdFile2, blocks.join("\n\n") + "\n", "utf-8")
|
|
294
348
|
} else {
|
|
295
349
|
// Avoid duplicating if already contains same session text
|
|
296
350
|
const prefix = current.trim().length > 0 ? "\n\n" : ""
|
|
297
|
-
fs.writeFileSync(
|
|
351
|
+
fs.writeFileSync(mdFile2, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
|
|
298
352
|
}
|
|
299
353
|
}
|
|
300
354
|
return blocks.length
|
|
@@ -445,23 +499,22 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
445
499
|
// Plugin definition
|
|
446
500
|
// ────────────────────────────────────────────────────────────────────────────
|
|
447
501
|
const server: Plugin = async ({ client, directory }) => {
|
|
448
|
-
//
|
|
502
|
+
// 1-1-1: restore session->file links for this directory (same session resumes, different session stays silent)
|
|
449
503
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json")
|
|
450
504
|
try {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (data?.file && fs.existsSync(data.file)) mdLogFile = data.file
|
|
454
|
-
}
|
|
505
|
+
const n = loadMdLinks(markerPath, directory)
|
|
506
|
+
if (n) slog("md-log links restored", n, markerPath)
|
|
455
507
|
} catch {}
|
|
456
508
|
|
|
457
509
|
// Session-scoped visual state (one per plugin instance; subagents get separate plugin instances per session, so isolation is natural)
|
|
458
510
|
let mermaidSession: { workDir: string; bodyPath: string } | null = null
|
|
459
511
|
let svgSession: { workDir: string; bodyPath: string } | null = null
|
|
460
512
|
|
|
461
|
-
// md-log dedup state
|
|
513
|
+
// md-log dedup state keyed by session to avoid cross-session suppression (1-1-1)
|
|
462
514
|
const loggedTextPartIds = new Set<string>()
|
|
463
515
|
const loggedToolCallIds = new Set<string>()
|
|
464
516
|
const messageIdToRole = new Map<string, string>()
|
|
517
|
+
const mdKey = (ses: string | undefined, id: string) => `${ses || "?"}:${id}`
|
|
465
518
|
|
|
466
519
|
// ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
|
|
467
520
|
function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
|
|
@@ -704,19 +757,23 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
704
757
|
const dk = !!r?.dontKnow
|
|
705
758
|
const ok = !dk && si.length === (j.correctIndices||[]).length && si.every((i:number)=>cs.has(i))
|
|
706
759
|
const note = r?.note ? `\nNote: ${r.note}` : ""
|
|
707
|
-
if (
|
|
760
|
+
if (getMdFile(j.sessionID)) {
|
|
708
761
|
const details = { status: "completed" as const, answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note }
|
|
709
|
-
|
|
762
|
+
const sesJ = j.sessionID as string
|
|
763
|
+
const fJ = getMdFile(sesJ)!
|
|
764
|
+
void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
|
|
710
765
|
}
|
|
711
766
|
return dk ? `[quiz answered] "${j.question}" -> I don't know.\nCorrect: ${cstr}\nExplanation: ${j.explanation}${note}` : `[quiz answered] "${j.question}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${cstr}\nExplanation: ${j.explanation}${note}`
|
|
712
767
|
} else if (j.type === "quiz_batch") {
|
|
713
768
|
const results = (r as any)?.results || []
|
|
714
|
-
if (
|
|
769
|
+
if (getMdFile(j.sessionID)) {
|
|
715
770
|
for (let i = 0; i < (j.quizzes||[]).length; i++) {
|
|
716
771
|
const qq = j.quizzes[i]
|
|
717
772
|
const x = results[i] || {}
|
|
718
773
|
const details = { status: "completed" as const, answers: x.answers || [], correct: !!x.correct, correctIndices: qq.correctIndices || [], explanation: qq.explanation || "", dontKnow: !!x.dontKnow, note: x.note }
|
|
719
|
-
|
|
774
|
+
const sesJ = j.sessionID as string
|
|
775
|
+
const fJ = getMdFile(sesJ)!
|
|
776
|
+
void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
|
|
720
777
|
}
|
|
721
778
|
}
|
|
722
779
|
const lines = (j.quizzes || []).map((qq:any, i:number) => {
|
|
@@ -756,9 +813,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
756
813
|
await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } })
|
|
757
814
|
},
|
|
758
815
|
|
|
759
|
-
"chat.message": async (
|
|
760
|
-
if (!mdLogFile) return
|
|
816
|
+
"chat.message": async (input, output) => {
|
|
761
817
|
try {
|
|
818
|
+
const ses = extractHookSessionID((input as any)?.sessionID, (output as any)?.message?.sessionID)
|
|
819
|
+
const mdFile = getMdFile(ses)
|
|
820
|
+
if (!mdFile || !ses) return
|
|
762
821
|
const msg: any = (output as any).message
|
|
763
822
|
const parts: any[] = (output as any).parts ?? []
|
|
764
823
|
let text = ""
|
|
@@ -770,25 +829,31 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
770
829
|
// Skip system-injected quiz/batch answer prompts — they are mirrored as beautiful callouts via watchAndInject, not as plain user quotes
|
|
771
830
|
if (/^\[(quiz|quiz_batch|question) (answered|cancelled)\]/i.test(text) || text.startsWith("[quiz answered]") || text.startsWith("[quiz_batch answered]") || text.startsWith("[question answered]")) return
|
|
772
831
|
const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`
|
|
773
|
-
|
|
774
|
-
loggedTextPartIds.
|
|
775
|
-
|
|
832
|
+
const mkey = mdKey(ses, mid)
|
|
833
|
+
if (loggedTextPartIds.has(mkey)) return
|
|
834
|
+
loggedTextPartIds.add(mkey)
|
|
835
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, userBlock(text)))
|
|
776
836
|
} catch {}
|
|
777
837
|
},
|
|
778
838
|
"experimental.text.complete": async (input, output) => {
|
|
779
|
-
if (!mdLogFile) return
|
|
780
839
|
try {
|
|
840
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
841
|
+
const mdFile = getMdFile(ses)
|
|
842
|
+
if (!mdFile || !ses) return
|
|
781
843
|
const text = (output as any).text?.trim()
|
|
782
844
|
if (!text) return
|
|
783
845
|
const partID = (input as any).partID
|
|
784
|
-
|
|
785
|
-
if (
|
|
786
|
-
|
|
846
|
+
const pkey = partID ? mdKey(ses, partID) : undefined
|
|
847
|
+
if (pkey && loggedTextPartIds.has(pkey)) return
|
|
848
|
+
if (pkey) loggedTextPartIds.add(pkey)
|
|
849
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
787
850
|
} catch {}
|
|
788
851
|
},
|
|
789
852
|
"tool.execute.before": async (input) => {
|
|
790
|
-
if (!mdLogFile) return
|
|
791
853
|
try {
|
|
854
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
855
|
+
const mdFile = getMdFile(ses)
|
|
856
|
+
if (!mdFile || !ses) return
|
|
792
857
|
const toolName = (input as any).tool
|
|
793
858
|
const args = (input as any).args ?? {}
|
|
794
859
|
// Built-in `question` tool is used as fallback when TUI not alive; mirror it.
|
|
@@ -798,53 +863,61 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
798
863
|
const ctx2 = args.details?.trim() || undefined
|
|
799
864
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
800
865
|
const callID = (input as any).callID
|
|
801
|
-
|
|
802
|
-
if (
|
|
803
|
-
if (
|
|
866
|
+
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined
|
|
867
|
+
if (qkey && loggedToolCallIds.has(qkey)) return
|
|
868
|
+
if (qkey) loggedToolCallIds.add(qkey)
|
|
869
|
+
if (q) await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout("Question", q, ctx2, opts)))
|
|
804
870
|
}
|
|
805
871
|
} catch {}
|
|
806
872
|
},
|
|
807
873
|
"tool.execute.after": async (input, output) => {
|
|
808
|
-
if (!mdLogFile) return
|
|
809
874
|
try {
|
|
875
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
876
|
+
const mdFile = getMdFile(ses)
|
|
877
|
+
if (!mdFile || !ses) return
|
|
810
878
|
const toolName = (input as any).tool
|
|
811
879
|
const callID = (input as any).callID
|
|
812
|
-
|
|
880
|
+
const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined
|
|
881
|
+
if (akey && loggedToolCallIds.has(akey)) return
|
|
813
882
|
if (toolName === "question") {
|
|
814
883
|
const meta: any = (output as any).metadata ?? {}
|
|
815
884
|
let answers: any[] = meta.answers ?? []
|
|
816
885
|
if (!answers.length && (output as any).output) answers = []
|
|
817
886
|
const details: any = { answers, status: "completed" }
|
|
818
|
-
await
|
|
819
|
-
if (
|
|
887
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)))
|
|
888
|
+
if (akey) loggedToolCallIds.add(akey)
|
|
820
889
|
}
|
|
821
890
|
} catch {}
|
|
822
891
|
},
|
|
823
|
-
// Mirror session to markdown file (best-effort, mirrors pi's md-log)
|
|
892
|
+
// Mirror session to markdown file (best-effort, mirrors pi's md-log) — 1-1-1 gated
|
|
824
893
|
event: async ({ event }) => {
|
|
825
|
-
if (!mdLogFile) return
|
|
826
894
|
const t = (event as any).type as string
|
|
827
895
|
const props = (event as any).properties ?? {}
|
|
828
896
|
try {
|
|
829
897
|
if (t === "message.updated") {
|
|
830
898
|
const info: any = props.info
|
|
831
899
|
if (info?.id && info?.role) messageIdToRole.set(info.id, info.role)
|
|
900
|
+
return
|
|
832
901
|
} else if (t === "message.part.updated") {
|
|
833
902
|
const part: any = props.part
|
|
834
903
|
const delta: string | undefined = props.delta
|
|
835
904
|
if (!part || !part.id) return
|
|
905
|
+
const ses = extractHookSessionID(part.sessionID, (props as any)?.sessionID, (props.info as any)?.sessionID)
|
|
906
|
+
const mdFile = getMdFile(ses)
|
|
907
|
+
if (!mdFile || !ses) return
|
|
836
908
|
if (part.type === "text") {
|
|
837
909
|
if (part.synthetic || part.ignored) return
|
|
838
910
|
const isFinal = !!(part.time?.end !== undefined) || delta === undefined
|
|
839
911
|
if (!isFinal) return
|
|
840
|
-
|
|
912
|
+
const pkey = mdKey(ses, part.id)
|
|
913
|
+
if (loggedTextPartIds.has(pkey)) return
|
|
841
914
|
const text = (part.text || "").trim()
|
|
842
915
|
if (!text) return
|
|
843
916
|
const role = messageIdToRole.get(part.messageID)
|
|
844
917
|
if (role === "user") return
|
|
845
918
|
// Fallback for assistant when experimental.text.complete not fired
|
|
846
|
-
loggedTextPartIds.add(
|
|
847
|
-
await
|
|
919
|
+
loggedTextPartIds.add(pkey)
|
|
920
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
848
921
|
}
|
|
849
922
|
}
|
|
850
923
|
} catch {}
|
|
@@ -905,6 +978,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
905
978
|
}
|
|
906
979
|
try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
|
|
907
980
|
try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
|
|
981
|
+
const quizSes = (ctx as any).sessionID as string | undefined
|
|
908
982
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
909
983
|
const dk = !!r?.dontKnow
|
|
910
984
|
const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
|
|
@@ -912,7 +986,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
912
986
|
const si = (r?.answers || []).map((a: any) => a.index)
|
|
913
987
|
const ok = !dk && si.length === correctIndices.length && si.every((i: number) => cs.has(i))
|
|
914
988
|
const note = r?.note ? `\nNote: ${r.note}` : ""
|
|
915
|
-
|
|
989
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
990
|
+
if (qf && quizSes) {
|
|
916
991
|
const details = {
|
|
917
992
|
status: "completed" as const,
|
|
918
993
|
answers: r?.answers || [],
|
|
@@ -922,15 +997,18 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
922
997
|
dontKnow: dk,
|
|
923
998
|
note: r?.note,
|
|
924
999
|
}
|
|
925
|
-
void
|
|
1000
|
+
void withMdFileLock(qf, () => appendToMdLogForSession(quizSes, answerCalloutQuiz(details)))
|
|
926
1001
|
}
|
|
927
1002
|
return dk
|
|
928
1003
|
? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
929
1004
|
: `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
930
1005
|
})
|
|
931
|
-
// Always mirror question with TRUE shuffled order (pi: tool_execution_update)
|
|
932
|
-
|
|
933
|
-
|
|
1006
|
+
// Always mirror question with TRUE shuffled order (pi: tool_execution_update) — 1-1-1 gated
|
|
1007
|
+
{
|
|
1008
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1009
|
+
if (qf && quizSes) {
|
|
1010
|
+
try { await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
|
|
1011
|
+
}
|
|
934
1012
|
}
|
|
935
1013
|
if (tuiAlive) {
|
|
936
1014
|
return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
|
|
@@ -950,7 +1028,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
950
1028
|
const trimmed = (raw as string).trim()
|
|
951
1029
|
if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
|
|
952
1030
|
const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
953
|
-
|
|
1031
|
+
{
|
|
1032
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1033
|
+
if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
|
|
1034
|
+
}
|
|
954
1035
|
return msg
|
|
955
1036
|
}
|
|
956
1037
|
const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
|
|
@@ -961,7 +1042,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
961
1042
|
const verdict = correct ? "correctly" : "incorrectly"
|
|
962
1043
|
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
963
1044
|
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: eFixed } })
|
|
964
|
-
|
|
1045
|
+
{
|
|
1046
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1047
|
+
if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
|
|
1048
|
+
}
|
|
965
1049
|
return result
|
|
966
1050
|
}
|
|
967
1051
|
const instruction = [
|
|
@@ -1029,36 +1113,42 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1029
1113
|
const file = path.join(pendingDirPath, `quiz_batch-${id}.json`)
|
|
1030
1114
|
try { fs.writeFileSync(file, JSON.stringify(payload), "utf8"); slog("quiz_batch wrote durably", file, "alive", isAlive) } catch (e) { slog("quiz_batch write failed", String(e)) }
|
|
1031
1115
|
try { await (ctx as any).metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } }) } catch {}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1116
|
+
const batchSes = (ctx as any).sessionID as string | undefined
|
|
1117
|
+
// Mirror each question in batch as a beautiful callout (like single quiz) — 1-1-1 gated
|
|
1118
|
+
{
|
|
1119
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined
|
|
1120
|
+
if (bf && batchSes) {
|
|
1121
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1122
|
+
const q = normalized[i]
|
|
1123
|
+
const label = `Quiz ${i + 1}/${normalized.length}`
|
|
1124
|
+
try { await withMdFileLock(bf, () => appendToMdLogForSession(batchSes, questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o: any) => ({ label: o.label }))))) } catch {}
|
|
1125
|
+
}
|
|
1038
1126
|
}
|
|
1039
1127
|
}
|
|
1040
1128
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
1041
1129
|
const results = r?.results || []
|
|
1042
1130
|
// Mirror each answer as a beautiful callout (like single quiz) — not just plain text
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1131
|
+
{
|
|
1132
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined
|
|
1133
|
+
if (bf && batchSes) {
|
|
1134
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1135
|
+
const q = normalized[i]
|
|
1136
|
+
const x = results[i] || {}
|
|
1137
|
+
const details = {
|
|
1138
|
+
status: "completed" as const,
|
|
1139
|
+
answers: x.answers || [],
|
|
1140
|
+
correct: !!x.correct,
|
|
1141
|
+
correctIndices: q.correctIndices || [],
|
|
1142
|
+
explanation: q.explanation || "",
|
|
1143
|
+
dontKnow: !!x.dontKnow,
|
|
1144
|
+
note: x.note,
|
|
1145
|
+
}
|
|
1146
|
+
// Use same callout helper as single quiz but with batch label context
|
|
1147
|
+
try {
|
|
1148
|
+
// withMdFileLock is async, but watchAndInject buildText is sync — queue without await and let it flush
|
|
1149
|
+
void withMdFileLock(bf, () => appendToMdLogForSession(batchSes, answerCalloutQuiz(details)))
|
|
1150
|
+
} catch {}
|
|
1055
1151
|
}
|
|
1056
|
-
const label = `Quiz ${i + 1}/${normalized.length}`
|
|
1057
|
-
// Use same callout helper as single quiz but with batch label context
|
|
1058
|
-
try {
|
|
1059
|
-
// withMdLock is async, but watchAndInject buildText is sync — queue without await and let it flush
|
|
1060
|
-
void withMdLock(() => appendToMdLog(answerCalloutQuiz(details)))
|
|
1061
|
-
} catch {}
|
|
1062
1152
|
}
|
|
1063
1153
|
}
|
|
1064
1154
|
const lines = results.map((x: any, i: number) => {
|
|
@@ -1076,39 +1166,59 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1076
1166
|
}
|
|
1077
1167
|
}),
|
|
1078
1168
|
|
|
1079
|
-
// ── md_log: link a markdown file
|
|
1169
|
+
// ── md_log: link a markdown file — 1-1-1 session:link:file ──────────
|
|
1080
1170
|
md_log: tool({
|
|
1081
|
-
description: "Mirror
|
|
1171
|
+
description: "Mirror THIS session to a markdown file for comfortable reading in Obsidian. The link is bound 1-1-1 to this sessionID: resuming the same session auto-restores, a different session stays silent until it links its own file. Use `md_unlog` to stop.",
|
|
1082
1172
|
args: {
|
|
1083
1173
|
filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist."),
|
|
1084
1174
|
},
|
|
1085
1175
|
async execute(args, ctx) {
|
|
1176
|
+
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1177
|
+
if (!sessionID) return `md_log error: no sessionID in context — cannot establish 1-1-1 link`
|
|
1086
1178
|
const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath)
|
|
1087
1179
|
if (!fs.existsSync(resolved)) return `File does not exist: ${resolved}`
|
|
1088
1180
|
if (!fs.statSync(resolved).isFile()) return `Not a file: ${resolved}`
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1181
|
+
// Enforce 1-1-1: one file linked to at most one session
|
|
1182
|
+
for (const [ses, meta] of mdLinks) {
|
|
1183
|
+
if (meta.file === resolved && ses !== sessionID) {
|
|
1184
|
+
return `File already linked to session ${ses.slice(0,8)} — 1-1-1 violation. Copy to a new file or md_unlog that session first.`
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() })
|
|
1188
|
+
saveMdLinksForDirectory(markerPath, directory)
|
|
1189
|
+
// Backfill history for this session only
|
|
1092
1190
|
let backfilled = 0
|
|
1191
|
+
try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
|
|
1192
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } })
|
|
1193
|
+
return `Linked: ${resolved} to session ${sessionID.slice(0,8)} — ${backfilled ? `${backfilled} entries backfilled — ` : ""}future messages for THIS session will be mirrored. Other sessions stay silent.`
|
|
1194
|
+
},
|
|
1195
|
+
}),
|
|
1196
|
+
|
|
1197
|
+
md_log_status: tool({
|
|
1198
|
+
description: "Show md-log link status for this session and directory.",
|
|
1199
|
+
args: {},
|
|
1200
|
+
async execute(_args, ctx) {
|
|
1093
1201
|
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
return `Linked: ${resolved} — ${backfilled ? `${backfilled} entries backfilled — ` : ""}future messages will be mirrored. View it rendered in Obsidian for LaTeX/math.`
|
|
1202
|
+
const own = sessionID ? mdLinks.get(sessionID) : undefined
|
|
1203
|
+
let countDir = 0
|
|
1204
|
+
for (const [, meta] of mdLinks) if (meta.directory === directory) countDir++
|
|
1205
|
+
return `session ${sessionID?.slice(0,8) ?? "(none)"} -> ${own?.file ?? "(no link)"} | links in this directory: ${countDir}`
|
|
1099
1206
|
},
|
|
1100
1207
|
}),
|
|
1101
1208
|
|
|
1102
1209
|
md_unlog: tool({
|
|
1103
|
-
description: "Stop mirroring
|
|
1210
|
+
description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
|
|
1104
1211
|
args: {},
|
|
1105
|
-
async execute() {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1212
|
+
async execute(_args, ctx) {
|
|
1213
|
+
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1214
|
+
if (!sessionID) return "No session in context"
|
|
1215
|
+
const meta = mdLinks.get(sessionID)
|
|
1216
|
+
if (!meta) return "No file linked for this session"
|
|
1217
|
+
const name = path.basename(meta.file)
|
|
1218
|
+
mdLinks.delete(sessionID)
|
|
1219
|
+
saveMdLinksForDirectory(markerPath, directory)
|
|
1220
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}`, extra: { sessionID } } })
|
|
1221
|
+
return `Unlinked: ${name} from session ${sessionID.slice(0,8)} (other sessions unaffected)`
|
|
1112
1222
|
},
|
|
1113
1223
|
}),
|
|
1114
1224
|
|