@bojackduy/opencode-learn 1.2.1 → 1.2.3
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 +302 -141
- package/dist/tui.js +14 -14
- package/package.json +2 -2
- package/plugins/learn.ts +266 -106
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")
|
|
168
|
+
} catch {}
|
|
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")
|
|
155
201
|
} catch {}
|
|
156
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}`)
|
|
@@ -192,17 +244,37 @@ function answerCalloutQuiz(details: any): string {
|
|
|
192
244
|
if (details?.explanation) { body.push(""); for (const line of String(details.explanation).split("\n")) body.push(line) }
|
|
193
245
|
return callout(type, title, body)
|
|
194
246
|
}
|
|
247
|
+
function answerCalloutQuestion(questions: any[], answers: string[][]): string {
|
|
248
|
+
const qs = Array.isArray(questions) ? questions : []
|
|
249
|
+
const ans = Array.isArray(answers) ? answers : []
|
|
250
|
+
if (!qs.length) {
|
|
251
|
+
const flat = ans.map(a => Array.isArray(a) ? a.join(", ") : String(a ?? "")).filter(Boolean)
|
|
252
|
+
return callout("example", "Answer", flat.length ? flat : ["(no answer)"])
|
|
253
|
+
}
|
|
254
|
+
const body = qs.map((q: any, i: number) => {
|
|
255
|
+
const header = q?.header || `Q${i + 1}`
|
|
256
|
+
const sel: string[] = Array.isArray(ans[i]) ? ans[i] as string[] : []
|
|
257
|
+
return `${header}: ${sel.length ? sel.join(", ") : "(no answer)"}`
|
|
258
|
+
})
|
|
259
|
+
return callout("example", "Answer", body)
|
|
260
|
+
}
|
|
195
261
|
function answerCalloutAsk(details: any): string {
|
|
196
262
|
const status = details?.status
|
|
197
263
|
if (status === "cancelled") return callout("warning", "Question — cancelled", ["(user skipped)"])
|
|
198
264
|
if (status === "unavailable") return callout("warning", "Question — unavailable", [details?.message || ""])
|
|
199
265
|
const answers: any[] = details?.answers || []
|
|
200
|
-
|
|
266
|
+
// Native opencode shape: string[][] (per-question selected labels)
|
|
267
|
+
if (answers.length && (answers as any[]).every(a => Array.isArray(a))) {
|
|
268
|
+
const questions: any[] = details?.questions || []
|
|
269
|
+
return answerCalloutQuestion(questions, answers as string[][])
|
|
270
|
+
}
|
|
271
|
+
const body: string[] = answers.map((a) => { if (a.type === "other") return `Other: ${a.label}`; if (a.type === "text") return a.label; if (typeof a === "string") return a; return `${a.index}. ${a.label}` })
|
|
201
272
|
if (body.length === 0) body.push("(no answer)")
|
|
202
273
|
return callout("example", "Answer", body)
|
|
203
274
|
}
|
|
204
275
|
async function backfillMdLog(client: any, sessionID: string, directory: string): Promise<number> {
|
|
205
|
-
|
|
276
|
+
const mdFile = getMdFile(sessionID)
|
|
277
|
+
if (!mdFile || !sessionID) return 0
|
|
206
278
|
try {
|
|
207
279
|
const res: any = await client.session.messages({ path: { id: sessionID }, query: { directory } })
|
|
208
280
|
const data: any = res?.data ?? res
|
|
@@ -257,6 +329,23 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
|
|
|
257
329
|
}
|
|
258
330
|
continue
|
|
259
331
|
}
|
|
332
|
+
if (toolName === "question" && Array.isArray((input as any).questions)) {
|
|
333
|
+
const qs: any[] = (input as any).questions
|
|
334
|
+
if (st.status === "pending" || st.status === "running") {
|
|
335
|
+
qs.forEach((q: any, i: number) => {
|
|
336
|
+
if (!q?.question) return
|
|
337
|
+
blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []))
|
|
338
|
+
})
|
|
339
|
+
} else if (st.status === "completed") {
|
|
340
|
+
qs.forEach((q: any, i: number) => {
|
|
341
|
+
if (!q?.question) return
|
|
342
|
+
blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []))
|
|
343
|
+
})
|
|
344
|
+
const ans = Array.isArray(meta.answers) ? meta.answers : []
|
|
345
|
+
blocks.push(answerCalloutAsk({ answers: ans, questions: qs, status: "completed" }))
|
|
346
|
+
}
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
260
349
|
if (st.status === "pending" || st.status === "running") {
|
|
261
350
|
if (input.question) {
|
|
262
351
|
const opts = Array.isArray(input.options) ? input.options : []
|
|
@@ -286,15 +375,16 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
|
|
|
286
375
|
}
|
|
287
376
|
}
|
|
288
377
|
if (blocks.length) {
|
|
378
|
+
const mdFile2 = getMdFile(sessionID) || mdFile
|
|
289
379
|
let current = ""
|
|
290
|
-
try { if (fs.existsSync(
|
|
380
|
+
try { if (fs.existsSync(mdFile2)) current = fs.readFileSync(mdFile2, "utf-8") } catch {}
|
|
291
381
|
// If file empty, overwrite; else append with separator (preserve user notes)
|
|
292
382
|
if (current.trim().length === 0) {
|
|
293
|
-
fs.writeFileSync(
|
|
383
|
+
fs.writeFileSync(mdFile2, blocks.join("\n\n") + "\n", "utf-8")
|
|
294
384
|
} else {
|
|
295
385
|
// Avoid duplicating if already contains same session text
|
|
296
386
|
const prefix = current.trim().length > 0 ? "\n\n" : ""
|
|
297
|
-
fs.writeFileSync(
|
|
387
|
+
fs.writeFileSync(mdFile2, current + prefix + blocks.join("\n\n") + "\n", "utf-8")
|
|
298
388
|
}
|
|
299
389
|
}
|
|
300
390
|
return blocks.length
|
|
@@ -445,23 +535,22 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
445
535
|
// Plugin definition
|
|
446
536
|
// ────────────────────────────────────────────────────────────────────────────
|
|
447
537
|
const server: Plugin = async ({ client, directory }) => {
|
|
448
|
-
//
|
|
538
|
+
// 1-1-1: restore session->file links for this directory (same session resumes, different session stays silent)
|
|
449
539
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json")
|
|
450
540
|
try {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (data?.file && fs.existsSync(data.file)) mdLogFile = data.file
|
|
454
|
-
}
|
|
541
|
+
const n = loadMdLinks(markerPath, directory)
|
|
542
|
+
if (n) slog("md-log links restored", n, markerPath)
|
|
455
543
|
} catch {}
|
|
456
544
|
|
|
457
545
|
// Session-scoped visual state (one per plugin instance; subagents get separate plugin instances per session, so isolation is natural)
|
|
458
546
|
let mermaidSession: { workDir: string; bodyPath: string } | null = null
|
|
459
547
|
let svgSession: { workDir: string; bodyPath: string } | null = null
|
|
460
548
|
|
|
461
|
-
// md-log dedup state
|
|
549
|
+
// md-log dedup state keyed by session to avoid cross-session suppression (1-1-1)
|
|
462
550
|
const loggedTextPartIds = new Set<string>()
|
|
463
551
|
const loggedToolCallIds = new Set<string>()
|
|
464
552
|
const messageIdToRole = new Map<string, string>()
|
|
553
|
+
const mdKey = (ses: string | undefined, id: string) => `${ses || "?"}:${id}`
|
|
465
554
|
|
|
466
555
|
// ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
|
|
467
556
|
function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
|
|
@@ -704,19 +793,23 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
704
793
|
const dk = !!r?.dontKnow
|
|
705
794
|
const ok = !dk && si.length === (j.correctIndices||[]).length && si.every((i:number)=>cs.has(i))
|
|
706
795
|
const note = r?.note ? `\nNote: ${r.note}` : ""
|
|
707
|
-
if (
|
|
796
|
+
if (getMdFile(j.sessionID)) {
|
|
708
797
|
const details = { status: "completed" as const, answers: r?.answers || [], correct: ok, correctIndices: j.correctIndices || [], explanation: j.explanation, dontKnow: dk, note: r?.note }
|
|
709
|
-
|
|
798
|
+
const sesJ = j.sessionID as string
|
|
799
|
+
const fJ = getMdFile(sesJ)!
|
|
800
|
+
void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
|
|
710
801
|
}
|
|
711
802
|
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
803
|
} else if (j.type === "quiz_batch") {
|
|
713
804
|
const results = (r as any)?.results || []
|
|
714
|
-
if (
|
|
805
|
+
if (getMdFile(j.sessionID)) {
|
|
715
806
|
for (let i = 0; i < (j.quizzes||[]).length; i++) {
|
|
716
807
|
const qq = j.quizzes[i]
|
|
717
808
|
const x = results[i] || {}
|
|
718
809
|
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
|
-
|
|
810
|
+
const sesJ = j.sessionID as string
|
|
811
|
+
const fJ = getMdFile(sesJ)!
|
|
812
|
+
void withMdFileLock(fJ, () => appendToMdLogForSession(sesJ, answerCalloutQuiz(details)))
|
|
720
813
|
}
|
|
721
814
|
}
|
|
722
815
|
const lines = (j.quizzes || []).map((qq:any, i:number) => {
|
|
@@ -756,9 +849,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
756
849
|
await client.app.log({ body: { service: "learn", level: "info", message: "learn plugin initialized", extra: { directory } } })
|
|
757
850
|
},
|
|
758
851
|
|
|
759
|
-
"chat.message": async (
|
|
760
|
-
if (!mdLogFile) return
|
|
852
|
+
"chat.message": async (input, output) => {
|
|
761
853
|
try {
|
|
854
|
+
const ses = extractHookSessionID((input as any)?.sessionID, (output as any)?.message?.sessionID)
|
|
855
|
+
const mdFile = getMdFile(ses)
|
|
856
|
+
if (!mdFile || !ses) return
|
|
762
857
|
const msg: any = (output as any).message
|
|
763
858
|
const parts: any[] = (output as any).parts ?? []
|
|
764
859
|
let text = ""
|
|
@@ -770,81 +865,109 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
770
865
|
// Skip system-injected quiz/batch answer prompts — they are mirrored as beautiful callouts via watchAndInject, not as plain user quotes
|
|
771
866
|
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
867
|
const mid = msg?.id ? `msg:${msg.id}` : `chat:${Date.now()}`
|
|
773
|
-
|
|
774
|
-
loggedTextPartIds.
|
|
775
|
-
|
|
868
|
+
const mkey = mdKey(ses, mid)
|
|
869
|
+
if (loggedTextPartIds.has(mkey)) return
|
|
870
|
+
loggedTextPartIds.add(mkey)
|
|
871
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, userBlock(text)))
|
|
776
872
|
} catch {}
|
|
777
873
|
},
|
|
778
874
|
"experimental.text.complete": async (input, output) => {
|
|
779
|
-
if (!mdLogFile) return
|
|
780
875
|
try {
|
|
876
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
877
|
+
const mdFile = getMdFile(ses)
|
|
878
|
+
if (!mdFile || !ses) return
|
|
781
879
|
const text = (output as any).text?.trim()
|
|
782
880
|
if (!text) return
|
|
783
881
|
const partID = (input as any).partID
|
|
784
|
-
|
|
785
|
-
if (
|
|
786
|
-
|
|
882
|
+
const pkey = partID ? mdKey(ses, partID) : undefined
|
|
883
|
+
if (pkey && loggedTextPartIds.has(pkey)) return
|
|
884
|
+
if (pkey) loggedTextPartIds.add(pkey)
|
|
885
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
787
886
|
} catch {}
|
|
788
887
|
},
|
|
789
|
-
"tool.execute.before": async (input) => {
|
|
790
|
-
if (!mdLogFile) return
|
|
888
|
+
"tool.execute.before": async (input, output) => {
|
|
791
889
|
try {
|
|
890
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
891
|
+
const mdFile = getMdFile(ses)
|
|
892
|
+
if (!mdFile || !ses) return
|
|
792
893
|
const toolName = (input as any).tool
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
// `
|
|
894
|
+
// NOTE: per Hooks type, before-hook args live in output.args (input only has tool/sessionID/callID).
|
|
895
|
+
const args = (output as any)?.args ?? (input as any).args ?? {}
|
|
896
|
+
// Native `question` tool shape: {questions: [{question, header, options, multiple}]}
|
|
796
897
|
if (toolName === "question") {
|
|
797
|
-
const q = args.question || args.header || ""
|
|
798
|
-
const ctx2 = args.details?.trim() || undefined
|
|
799
|
-
const opts = Array.isArray(args.options) ? args.options : []
|
|
800
898
|
const callID = (input as any).callID
|
|
801
|
-
|
|
802
|
-
if (
|
|
803
|
-
if (
|
|
899
|
+
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined
|
|
900
|
+
if (qkey && loggedToolCallIds.has(qkey)) return
|
|
901
|
+
if (qkey) loggedToolCallIds.add(qkey)
|
|
902
|
+
const qs: any[] = Array.isArray(args.questions) ? args.questions
|
|
903
|
+
: (args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [])
|
|
904
|
+
for (let i = 0; i < qs.length; i++) {
|
|
905
|
+
const q = qs[i]
|
|
906
|
+
if (!q?.question) continue
|
|
907
|
+
const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question")
|
|
908
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])))
|
|
909
|
+
}
|
|
804
910
|
}
|
|
805
911
|
} catch {}
|
|
806
912
|
},
|
|
807
913
|
"tool.execute.after": async (input, output) => {
|
|
808
|
-
if (!mdLogFile) return
|
|
809
914
|
try {
|
|
915
|
+
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
916
|
+
const mdFile = getMdFile(ses)
|
|
917
|
+
if (!mdFile || !ses) return
|
|
810
918
|
const toolName = (input as any).tool
|
|
811
919
|
const callID = (input as any).callID
|
|
812
|
-
|
|
920
|
+
const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined
|
|
921
|
+
if (akey && loggedToolCallIds.has(akey)) return
|
|
813
922
|
if (toolName === "question") {
|
|
814
|
-
const meta: any = (output as any)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
923
|
+
const meta: any = (output as any)?.metadata ?? {}
|
|
924
|
+
const inArgs: any = (input as any)?.args ?? {}
|
|
925
|
+
const qs: any[] = Array.isArray(inArgs.questions) ? inArgs.questions : []
|
|
926
|
+
let answers: any = meta.answers
|
|
927
|
+
if (!Array.isArray(answers) || !answers.length) {
|
|
928
|
+
const outText = typeof (output as any)?.output === "string" ? ((output as any).output as string).trim() : ""
|
|
929
|
+
if (outText) {
|
|
930
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])))
|
|
931
|
+
if (akey) loggedToolCallIds.add(akey)
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
answers = []
|
|
935
|
+
}
|
|
936
|
+
const details: any = { answers, questions: qs, status: "completed" }
|
|
937
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)))
|
|
938
|
+
if (akey) loggedToolCallIds.add(akey)
|
|
820
939
|
}
|
|
821
940
|
} catch {}
|
|
822
941
|
},
|
|
823
|
-
// Mirror session to markdown file (best-effort, mirrors pi's md-log)
|
|
942
|
+
// Mirror session to markdown file (best-effort, mirrors pi's md-log) — 1-1-1 gated
|
|
824
943
|
event: async ({ event }) => {
|
|
825
|
-
if (!mdLogFile) return
|
|
826
944
|
const t = (event as any).type as string
|
|
827
945
|
const props = (event as any).properties ?? {}
|
|
828
946
|
try {
|
|
829
947
|
if (t === "message.updated") {
|
|
830
948
|
const info: any = props.info
|
|
831
949
|
if (info?.id && info?.role) messageIdToRole.set(info.id, info.role)
|
|
950
|
+
return
|
|
832
951
|
} else if (t === "message.part.updated") {
|
|
833
952
|
const part: any = props.part
|
|
834
953
|
const delta: string | undefined = props.delta
|
|
835
954
|
if (!part || !part.id) return
|
|
955
|
+
const ses = extractHookSessionID(part.sessionID, (props as any)?.sessionID, (props.info as any)?.sessionID)
|
|
956
|
+
const mdFile = getMdFile(ses)
|
|
957
|
+
if (!mdFile || !ses) return
|
|
836
958
|
if (part.type === "text") {
|
|
837
959
|
if (part.synthetic || part.ignored) return
|
|
838
960
|
const isFinal = !!(part.time?.end !== undefined) || delta === undefined
|
|
839
961
|
if (!isFinal) return
|
|
840
|
-
|
|
962
|
+
const pkey = mdKey(ses, part.id)
|
|
963
|
+
if (loggedTextPartIds.has(pkey)) return
|
|
841
964
|
const text = (part.text || "").trim()
|
|
842
965
|
if (!text) return
|
|
843
966
|
const role = messageIdToRole.get(part.messageID)
|
|
844
967
|
if (role === "user") return
|
|
845
968
|
// Fallback for assistant when experimental.text.complete not fired
|
|
846
|
-
loggedTextPartIds.add(
|
|
847
|
-
await
|
|
969
|
+
loggedTextPartIds.add(pkey)
|
|
970
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
848
971
|
}
|
|
849
972
|
}
|
|
850
973
|
} catch {}
|
|
@@ -905,6 +1028,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
905
1028
|
}
|
|
906
1029
|
try { fs.writeFileSync(pendingPath, JSON.stringify(payload), "utf8"); slog("quiz wrote durably", pendingPath, "alive", tuiAlive) } catch (e) { slog("quiz write failed", String(e)) }
|
|
907
1030
|
try { await (ctx as any).metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } }) } catch {}
|
|
1031
|
+
const quizSes = (ctx as any).sessionID as string | undefined
|
|
908
1032
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
909
1033
|
const dk = !!r?.dontKnow
|
|
910
1034
|
const sel = (r?.answers || []).map((a: any) => `${a.index}. ${a.label}`).join(", ") || "(none)"
|
|
@@ -912,7 +1036,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
912
1036
|
const si = (r?.answers || []).map((a: any) => a.index)
|
|
913
1037
|
const ok = !dk && si.length === correctIndices.length && si.every((i: number) => cs.has(i))
|
|
914
1038
|
const note = r?.note ? `\nNote: ${r.note}` : ""
|
|
915
|
-
|
|
1039
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1040
|
+
if (qf && quizSes) {
|
|
916
1041
|
const details = {
|
|
917
1042
|
status: "completed" as const,
|
|
918
1043
|
answers: r?.answers || [],
|
|
@@ -922,15 +1047,18 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
922
1047
|
dontKnow: dk,
|
|
923
1048
|
note: r?.note,
|
|
924
1049
|
}
|
|
925
|
-
void
|
|
1050
|
+
void withMdFileLock(qf, () => appendToMdLogForSession(quizSes, answerCalloutQuiz(details)))
|
|
926
1051
|
}
|
|
927
1052
|
return dk
|
|
928
1053
|
? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
929
1054
|
: `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.\nCorrect: ${correctStr}\nExplanation: ${eFixed}${note}`
|
|
930
1055
|
})
|
|
931
|
-
// Always mirror question with TRUE shuffled order (pi: tool_execution_update)
|
|
932
|
-
|
|
933
|
-
|
|
1056
|
+
// Always mirror question with TRUE shuffled order (pi: tool_execution_update) — 1-1-1 gated
|
|
1057
|
+
{
|
|
1058
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1059
|
+
if (qf && quizSes) {
|
|
1060
|
+
try { await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label }))))) } catch {}
|
|
1061
|
+
}
|
|
934
1062
|
}
|
|
935
1063
|
if (tuiAlive) {
|
|
936
1064
|
return `[quiz displayed in TUI — waiting for your answer in the popup. I'll continue once you respond.]`
|
|
@@ -950,7 +1078,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
950
1078
|
const trimmed = (raw as string).trim()
|
|
951
1079
|
if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
|
|
952
1080
|
const msg = `User selected "I don't know" — genuine gap, not a guess.\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
953
|
-
|
|
1081
|
+
{
|
|
1082
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1083
|
+
if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout("question", "Quiz — I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])))
|
|
1084
|
+
}
|
|
954
1085
|
return msg
|
|
955
1086
|
}
|
|
956
1087
|
const nums = trimmed.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n) && n >= 1 && n <= options.length)
|
|
@@ -961,7 +1092,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
961
1092
|
const verdict = correct ? "correctly" : "incorrectly"
|
|
962
1093
|
const result = `User answered ${verdict}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}\nExplanation: ${eFixed}`
|
|
963
1094
|
;(ctx as any).metadata?.({ title: correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", metadata: { correct, correctIndices, explanation: eFixed } })
|
|
964
|
-
|
|
1095
|
+
{
|
|
1096
|
+
const qf = quizSes ? getMdFile(quizSes) : undefined
|
|
1097
|
+
if (qf && quizSes) await withMdFileLock(qf, () => appendToMdLogForSession(quizSes, callout(correct ? "success" : "failure", correct ? "Quiz — correct ✓" : "Quiz — incorrect ✗", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])))
|
|
1098
|
+
}
|
|
965
1099
|
return result
|
|
966
1100
|
}
|
|
967
1101
|
const instruction = [
|
|
@@ -1029,36 +1163,42 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1029
1163
|
const file = path.join(pendingDirPath, `quiz_batch-${id}.json`)
|
|
1030
1164
|
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
1165
|
try { await (ctx as any).metadata?.({ title: `Quiz batch ${normalized.length}`, metadata: { pendingId: id } }) } catch {}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1166
|
+
const batchSes = (ctx as any).sessionID as string | undefined
|
|
1167
|
+
// Mirror each question in batch as a beautiful callout (like single quiz) — 1-1-1 gated
|
|
1168
|
+
{
|
|
1169
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined
|
|
1170
|
+
if (bf && batchSes) {
|
|
1171
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1172
|
+
const q = normalized[i]
|
|
1173
|
+
const label = `Quiz ${i + 1}/${normalized.length}`
|
|
1174
|
+
try { await withMdFileLock(bf, () => appendToMdLogForSession(batchSes, questionCallout(label, q.question, q.details?.trim() || undefined, q.options.map((o: any) => ({ label: o.label }))))) } catch {}
|
|
1175
|
+
}
|
|
1038
1176
|
}
|
|
1039
1177
|
}
|
|
1040
1178
|
watchAndInject(client, directory, id, (ctx as any).sessionID, (r: any) => {
|
|
1041
1179
|
const results = r?.results || []
|
|
1042
1180
|
// 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
|
-
|
|
1181
|
+
{
|
|
1182
|
+
const bf = batchSes ? getMdFile(batchSes) : undefined
|
|
1183
|
+
if (bf && batchSes) {
|
|
1184
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1185
|
+
const q = normalized[i]
|
|
1186
|
+
const x = results[i] || {}
|
|
1187
|
+
const details = {
|
|
1188
|
+
status: "completed" as const,
|
|
1189
|
+
answers: x.answers || [],
|
|
1190
|
+
correct: !!x.correct,
|
|
1191
|
+
correctIndices: q.correctIndices || [],
|
|
1192
|
+
explanation: q.explanation || "",
|
|
1193
|
+
dontKnow: !!x.dontKnow,
|
|
1194
|
+
note: x.note,
|
|
1195
|
+
}
|
|
1196
|
+
// Use same callout helper as single quiz but with batch label context
|
|
1197
|
+
try {
|
|
1198
|
+
// withMdFileLock is async, but watchAndInject buildText is sync — queue without await and let it flush
|
|
1199
|
+
void withMdFileLock(bf, () => appendToMdLogForSession(batchSes, answerCalloutQuiz(details)))
|
|
1200
|
+
} catch {}
|
|
1055
1201
|
}
|
|
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
1202
|
}
|
|
1063
1203
|
}
|
|
1064
1204
|
const lines = results.map((x: any, i: number) => {
|
|
@@ -1076,39 +1216,59 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1076
1216
|
}
|
|
1077
1217
|
}),
|
|
1078
1218
|
|
|
1079
|
-
// ── md_log: link a markdown file
|
|
1219
|
+
// ── md_log: link a markdown file — 1-1-1 session:link:file ──────────
|
|
1080
1220
|
md_log: tool({
|
|
1081
|
-
description: "Mirror
|
|
1221
|
+
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
1222
|
args: {
|
|
1083
1223
|
filepath: tool.schema.string().describe("Existing markdown file to link (relative to worktree or absolute). Must exist."),
|
|
1084
1224
|
},
|
|
1085
1225
|
async execute(args, ctx) {
|
|
1226
|
+
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1227
|
+
if (!sessionID) return `md_log error: no sessionID in context — cannot establish 1-1-1 link`
|
|
1086
1228
|
const resolved = path.isAbsolute(args.filepath) ? args.filepath : path.resolve(ctx.directory, args.filepath)
|
|
1087
1229
|
if (!fs.existsSync(resolved)) return `File does not exist: ${resolved}`
|
|
1088
1230
|
if (!fs.statSync(resolved).isFile()) return `Not a file: ${resolved}`
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1231
|
+
// Enforce 1-1-1: one file linked to at most one session
|
|
1232
|
+
for (const [ses, meta] of mdLinks) {
|
|
1233
|
+
if (meta.file === resolved && ses !== sessionID) {
|
|
1234
|
+
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.`
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
mdLinks.set(sessionID, { file: resolved, directory, linkedAt: Date.now() })
|
|
1238
|
+
saveMdLinksForDirectory(markerPath, directory)
|
|
1239
|
+
// Backfill history for this session only
|
|
1092
1240
|
let backfilled = 0
|
|
1241
|
+
try { backfilled = await backfillMdLog(client, sessionID, directory) } catch (e) { slog("backfill error", String(e)) }
|
|
1242
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log linked: ${resolved}`, extra: { file: resolved, backfilled, sessionID } } })
|
|
1243
|
+
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.`
|
|
1244
|
+
},
|
|
1245
|
+
}),
|
|
1246
|
+
|
|
1247
|
+
md_log_status: tool({
|
|
1248
|
+
description: "Show md-log link status for this session and directory.",
|
|
1249
|
+
args: {},
|
|
1250
|
+
async execute(_args, ctx) {
|
|
1093
1251
|
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.`
|
|
1252
|
+
const own = sessionID ? mdLinks.get(sessionID) : undefined
|
|
1253
|
+
let countDir = 0
|
|
1254
|
+
for (const [, meta] of mdLinks) if (meta.directory === directory) countDir++
|
|
1255
|
+
return `session ${sessionID?.slice(0,8) ?? "(none)"} -> ${own?.file ?? "(no link)"} | links in this directory: ${countDir}`
|
|
1099
1256
|
},
|
|
1100
1257
|
}),
|
|
1101
1258
|
|
|
1102
1259
|
md_unlog: tool({
|
|
1103
|
-
description: "Stop mirroring
|
|
1260
|
+
description: "Stop mirroring THIS session to its markdown file (other sessions unaffected).",
|
|
1104
1261
|
args: {},
|
|
1105
|
-
async execute() {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1262
|
+
async execute(_args, ctx) {
|
|
1263
|
+
const sessionID = (ctx as any).sessionID as string | undefined
|
|
1264
|
+
if (!sessionID) return "No session in context"
|
|
1265
|
+
const meta = mdLinks.get(sessionID)
|
|
1266
|
+
if (!meta) return "No file linked for this session"
|
|
1267
|
+
const name = path.basename(meta.file)
|
|
1268
|
+
mdLinks.delete(sessionID)
|
|
1269
|
+
saveMdLinksForDirectory(markerPath, directory)
|
|
1270
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `md-log unlinked: ${name}`, extra: { sessionID } } })
|
|
1271
|
+
return `Unlinked: ${name} from session ${sessionID.slice(0,8)} (other sessions unaffected)`
|
|
1112
1272
|
},
|
|
1113
1273
|
}),
|
|
1114
1274
|
|