@bojackduy/opencode-learn 1.2.2 → 1.2.4
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 +166 -31
- package/dist/tui.js +47 -27
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +33 -8
- package/plugins/learn.ts +144 -25
package/dist/server.js
CHANGED
|
@@ -317,6 +317,20 @@ function answerCalloutQuiz(details) {
|
|
|
317
317
|
}
|
|
318
318
|
return callout(type, title, body);
|
|
319
319
|
}
|
|
320
|
+
function answerCalloutQuestion(questions, answers) {
|
|
321
|
+
const qs = Array.isArray(questions) ? questions : [];
|
|
322
|
+
const ans = Array.isArray(answers) ? answers : [];
|
|
323
|
+
if (!qs.length) {
|
|
324
|
+
const flat = ans.map((a) => Array.isArray(a) ? a.join(", ") : String(a ?? "")).filter(Boolean);
|
|
325
|
+
return callout("example", "Answer", flat.length ? flat : ["(no answer)"]);
|
|
326
|
+
}
|
|
327
|
+
const body = qs.map((q, i) => {
|
|
328
|
+
const header = q?.header || `Q${i + 1}`;
|
|
329
|
+
const sel = Array.isArray(ans[i]) ? ans[i] : [];
|
|
330
|
+
return `${header}: ${sel.length ? sel.join(", ") : "(no answer)"}`;
|
|
331
|
+
});
|
|
332
|
+
return callout("example", "Answer", body);
|
|
333
|
+
}
|
|
320
334
|
function answerCalloutAsk(details) {
|
|
321
335
|
const status = details?.status;
|
|
322
336
|
if (status === "cancelled")
|
|
@@ -324,11 +338,17 @@ function answerCalloutAsk(details) {
|
|
|
324
338
|
if (status === "unavailable")
|
|
325
339
|
return callout("warning", "Question \u2014 unavailable", [details?.message || ""]);
|
|
326
340
|
const answers = details?.answers || [];
|
|
341
|
+
if (answers.length && answers.every((a) => Array.isArray(a))) {
|
|
342
|
+
const questions = details?.questions || [];
|
|
343
|
+
return answerCalloutQuestion(questions, answers);
|
|
344
|
+
}
|
|
327
345
|
const body = answers.map((a) => {
|
|
328
346
|
if (a.type === "other")
|
|
329
347
|
return `Other: ${a.label}`;
|
|
330
348
|
if (a.type === "text")
|
|
331
349
|
return a.label;
|
|
350
|
+
if (typeof a === "string")
|
|
351
|
+
return a;
|
|
332
352
|
return `${a.index}. ${a.label}`;
|
|
333
353
|
});
|
|
334
354
|
if (body.length === 0)
|
|
@@ -401,6 +421,25 @@ async function backfillMdLog(client, sessionID, directory) {
|
|
|
401
421
|
}
|
|
402
422
|
continue;
|
|
403
423
|
}
|
|
424
|
+
if (toolName === "question" && Array.isArray(input.questions)) {
|
|
425
|
+
const qs = input.questions;
|
|
426
|
+
if (st.status === "pending" || st.status === "running") {
|
|
427
|
+
qs.forEach((q, i) => {
|
|
428
|
+
if (!q?.question)
|
|
429
|
+
return;
|
|
430
|
+
blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []));
|
|
431
|
+
});
|
|
432
|
+
} else if (st.status === "completed") {
|
|
433
|
+
qs.forEach((q, i) => {
|
|
434
|
+
if (!q?.question)
|
|
435
|
+
return;
|
|
436
|
+
blocks.push(questionCallout(q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question"), q.question, undefined, q.options ?? []));
|
|
437
|
+
});
|
|
438
|
+
const ans = Array.isArray(meta.answers) ? meta.answers : [];
|
|
439
|
+
blocks.push(answerCalloutAsk({ answers: ans, questions: qs, status: "completed" }));
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
404
443
|
if (st.status === "pending" || st.status === "running") {
|
|
405
444
|
if (input.question) {
|
|
406
445
|
const opts = Array.isArray(input.options) ? input.options : [];
|
|
@@ -492,26 +531,57 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
492
531
|
}
|
|
493
532
|
const dir = pendingDir(directory);
|
|
494
533
|
const respPath = path.join(dir, `response-${id}.json`);
|
|
495
|
-
const
|
|
496
|
-
|
|
534
|
+
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
|
|
535
|
+
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
|
|
536
|
+
const closeWatcher = () => {
|
|
537
|
+
const w = activeWatchers.get(id);
|
|
538
|
+
if (w) {
|
|
539
|
+
try {
|
|
540
|
+
w.close();
|
|
541
|
+
} catch {}
|
|
542
|
+
activeWatchers.delete(id);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const fire = async (attempt = 0) => {
|
|
497
546
|
try {
|
|
498
|
-
|
|
547
|
+
fs.renameSync(respPath, claimPath);
|
|
499
548
|
} catch {
|
|
549
|
+
closeWatcher();
|
|
500
550
|
return;
|
|
501
551
|
}
|
|
552
|
+
let data;
|
|
502
553
|
try {
|
|
503
|
-
fs.
|
|
504
|
-
} catch {
|
|
505
|
-
|
|
506
|
-
|
|
554
|
+
data = JSON.parse(fs.readFileSync(claimPath, "utf8"));
|
|
555
|
+
} catch (e) {
|
|
556
|
+
if (attempt < 5) {
|
|
557
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
558
|
+
try {
|
|
559
|
+
fs.renameSync(claimPath, respPath);
|
|
560
|
+
} catch {}
|
|
561
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
562
|
+
return fire(attempt + 1);
|
|
563
|
+
}
|
|
564
|
+
slog("watchAndInject UNREADABLE response, parked", id, String(e).slice(0, 160));
|
|
507
565
|
try {
|
|
508
|
-
|
|
566
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`));
|
|
509
567
|
} catch {}
|
|
510
|
-
|
|
568
|
+
closeWatcher();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
let text;
|
|
572
|
+
let effectiveSessionID;
|
|
573
|
+
try {
|
|
574
|
+
slog("watchAndInject fire", id, JSON.stringify(data).slice(0, 400));
|
|
575
|
+
effectiveSessionID = data?.sessionID || sessionID;
|
|
576
|
+
text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
slog("watchAndInject buildText ERROR, parked", id, String(e).slice(0, 200));
|
|
579
|
+
try {
|
|
580
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`));
|
|
581
|
+
} catch {}
|
|
582
|
+
closeWatcher();
|
|
583
|
+
return;
|
|
511
584
|
}
|
|
512
|
-
slog("watchAndInject fire", id, JSON.stringify(data).slice(0, 400));
|
|
513
|
-
const effectiveSessionID = data?.sessionID || sessionID;
|
|
514
|
-
const text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result);
|
|
515
585
|
const sdkCall = async (method, ...argsList) => {
|
|
516
586
|
let firstErr;
|
|
517
587
|
for (const args of argsList) {
|
|
@@ -537,35 +607,60 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
537
607
|
];
|
|
538
608
|
slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0, 300));
|
|
539
609
|
let ok = false;
|
|
610
|
+
let firstErr;
|
|
540
611
|
if (client?.session?.promptAsync) {
|
|
541
612
|
try {
|
|
542
613
|
await sdkCall(client.session.promptAsync.bind(client.session), ...shapes);
|
|
543
614
|
ok = true;
|
|
544
|
-
} catch {
|
|
615
|
+
} catch (e) {
|
|
616
|
+
firstErr = e;
|
|
617
|
+
}
|
|
545
618
|
}
|
|
546
619
|
if (!ok && client?.session?.prompt) {
|
|
547
620
|
try {
|
|
548
621
|
await sdkCall(client.session.prompt.bind(client.session), ...shapes);
|
|
549
622
|
ok = true;
|
|
623
|
+
} catch (e) {
|
|
624
|
+
firstErr = firstErr || e;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!ok) {
|
|
628
|
+
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0, 200));
|
|
629
|
+
try {
|
|
630
|
+
fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`));
|
|
631
|
+
} catch {}
|
|
632
|
+
try {
|
|
633
|
+
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
|
|
550
634
|
} catch {}
|
|
635
|
+
closeWatcher();
|
|
636
|
+
return;
|
|
551
637
|
}
|
|
552
638
|
try {
|
|
553
|
-
|
|
639
|
+
fs.unlinkSync(claimPath);
|
|
640
|
+
} catch {}
|
|
641
|
+
for (const p of pendingCandidates)
|
|
642
|
+
try {
|
|
643
|
+
fs.unlinkSync(p);
|
|
644
|
+
} catch {}
|
|
645
|
+
closeWatcher();
|
|
646
|
+
slog("watchAndInject consumed", id, effectiveSessionID);
|
|
647
|
+
try {
|
|
648
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `injected into ${effectiveSessionID}`, extra: { id } } });
|
|
554
649
|
} catch {}
|
|
555
650
|
};
|
|
556
|
-
if (fs.existsSync(respPath)) {
|
|
557
|
-
slog("watchAndInject fast-path", id);
|
|
558
|
-
fire();
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
651
|
try {
|
|
562
652
|
const w = fs.watch(dir, (_e, filename) => {
|
|
563
|
-
if (filename === `response-${id}.json`
|
|
653
|
+
if (filename === `response-${id}.json`)
|
|
564
654
|
fire();
|
|
565
655
|
});
|
|
566
656
|
w.on("error", () => {});
|
|
567
657
|
activeWatchers.set(id, w);
|
|
568
658
|
} catch {}
|
|
659
|
+
if (fs.existsSync(respPath)) {
|
|
660
|
+
slog("watchAndInject fast-path", id);
|
|
661
|
+
fire();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
569
664
|
}
|
|
570
665
|
var server = async ({ client, directory }) => {
|
|
571
666
|
const markerPath = path.join(directory, ".opencode", "learn-md-log.json");
|
|
@@ -823,6 +918,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
823
918
|
try {
|
|
824
919
|
const dir = pendingDir(directory);
|
|
825
920
|
if (fs.existsSync(dir)) {
|
|
921
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.includes(".claim-") && x.endsWith(".json"))) {
|
|
922
|
+
try {
|
|
923
|
+
const m = f.match(/^response-(.+)\.claim-.*\.json$/);
|
|
924
|
+
if (!m)
|
|
925
|
+
continue;
|
|
926
|
+
const rid = m[1];
|
|
927
|
+
const target = path.join(dir, `response-${rid}.json`);
|
|
928
|
+
const age = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs;
|
|
929
|
+
if (age > 30000 && !fs.existsSync(target)) {
|
|
930
|
+
fs.renameSync(path.join(dir, f), target);
|
|
931
|
+
slog("watchAndInject requeued stale claim", rid);
|
|
932
|
+
}
|
|
933
|
+
} catch {}
|
|
934
|
+
}
|
|
826
935
|
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
827
936
|
try {
|
|
828
937
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
@@ -879,6 +988,19 @@ Explanation: ${j.explanation}${note}`;
|
|
|
879
988
|
}
|
|
880
989
|
} catch {}
|
|
881
990
|
}
|
|
991
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("response-") && x.endsWith(".json") && !x.includes(".claim-") && !x.includes(".poisoned") && !x.includes(".failed") && !x.includes(".orphaned"))) {
|
|
992
|
+
try {
|
|
993
|
+
const m = f.match(/^response-(.+)\.json$/);
|
|
994
|
+
if (!m)
|
|
995
|
+
continue;
|
|
996
|
+
const rid = m[1];
|
|
997
|
+
const hasPending = fs.existsSync(path.join(dir, `quiz-${rid}.json`)) || fs.existsSync(path.join(dir, `quiz_batch-${rid}.json`));
|
|
998
|
+
if (!hasPending) {
|
|
999
|
+
fs.renameSync(path.join(dir, f), path.join(dir, `response-${rid}.orphaned-${Date.now()}.json`));
|
|
1000
|
+
slog("watchAndInject orphaned response parked (no pending context)", rid);
|
|
1001
|
+
}
|
|
1002
|
+
} catch {}
|
|
1003
|
+
}
|
|
882
1004
|
}
|
|
883
1005
|
} catch {}
|
|
884
1006
|
return {
|
|
@@ -943,26 +1065,29 @@ Explanation: ${j.explanation}${note}`;
|
|
|
943
1065
|
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))));
|
|
944
1066
|
} catch {}
|
|
945
1067
|
},
|
|
946
|
-
"tool.execute.before": async (input) => {
|
|
1068
|
+
"tool.execute.before": async (input, output) => {
|
|
947
1069
|
try {
|
|
948
1070
|
const ses = extractHookSessionID(input?.sessionID);
|
|
949
1071
|
const mdFile = getMdFile(ses);
|
|
950
1072
|
if (!mdFile || !ses)
|
|
951
1073
|
return;
|
|
952
1074
|
const toolName = input.tool;
|
|
953
|
-
const args = input.args ?? {};
|
|
1075
|
+
const args = output?.args ?? input.args ?? {};
|
|
954
1076
|
if (toolName === "question") {
|
|
955
|
-
const q = args.question || args.header || "";
|
|
956
|
-
const ctx2 = args.details?.trim() || undefined;
|
|
957
|
-
const opts = Array.isArray(args.options) ? args.options : [];
|
|
958
1077
|
const callID = input.callID;
|
|
959
1078
|
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined;
|
|
960
1079
|
if (qkey && loggedToolCallIds.has(qkey))
|
|
961
1080
|
return;
|
|
962
1081
|
if (qkey)
|
|
963
1082
|
loggedToolCallIds.add(qkey);
|
|
964
|
-
|
|
965
|
-
|
|
1083
|
+
const qs = Array.isArray(args.questions) ? args.questions : args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [];
|
|
1084
|
+
for (let i = 0;i < qs.length; i++) {
|
|
1085
|
+
const q = qs[i];
|
|
1086
|
+
if (!q?.question)
|
|
1087
|
+
continue;
|
|
1088
|
+
const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question");
|
|
1089
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])));
|
|
1090
|
+
}
|
|
966
1091
|
}
|
|
967
1092
|
} catch {}
|
|
968
1093
|
},
|
|
@@ -978,11 +1103,21 @@ Explanation: ${j.explanation}${note}`;
|
|
|
978
1103
|
if (akey && loggedToolCallIds.has(akey))
|
|
979
1104
|
return;
|
|
980
1105
|
if (toolName === "question") {
|
|
981
|
-
const meta = output
|
|
982
|
-
|
|
983
|
-
|
|
1106
|
+
const meta = output?.metadata ?? {};
|
|
1107
|
+
const inArgs = input?.args ?? {};
|
|
1108
|
+
const qs = Array.isArray(inArgs.questions) ? inArgs.questions : [];
|
|
1109
|
+
let answers = meta.answers;
|
|
1110
|
+
if (!Array.isArray(answers) || !answers.length) {
|
|
1111
|
+
const outText = typeof output?.output === "string" ? output.output.trim() : "";
|
|
1112
|
+
if (outText) {
|
|
1113
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])));
|
|
1114
|
+
if (akey)
|
|
1115
|
+
loggedToolCallIds.add(akey);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
984
1118
|
answers = [];
|
|
985
|
-
|
|
1119
|
+
}
|
|
1120
|
+
const details = { answers, questions: qs, status: "completed" };
|
|
986
1121
|
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)));
|
|
987
1122
|
if (akey)
|
|
988
1123
|
loggedToolCallIds.add(akey);
|
package/dist/tui.js
CHANGED
|
@@ -237,6 +237,17 @@ function ensureDir(dir) {
|
|
|
237
237
|
});
|
|
238
238
|
} catch {}
|
|
239
239
|
}
|
|
240
|
+
function writeJsonAtomic(filePath, data) {
|
|
241
|
+
try {
|
|
242
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
243
|
+
fs.writeFileSync(tmp, JSON.stringify(data), "utf8");
|
|
244
|
+
fs.renameSync(tmp, filePath);
|
|
245
|
+
} catch {
|
|
246
|
+
try {
|
|
247
|
+
fs.writeFileSync(filePath, JSON.stringify(data), "utf8");
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
240
251
|
function decodeQuizText(s) {
|
|
241
252
|
if (!s || typeof s !== "string")
|
|
242
253
|
return s;
|
|
@@ -391,7 +402,7 @@ function QuizDialog(props) {
|
|
|
391
402
|
timestamp: Date.now(),
|
|
392
403
|
sessionID: props.request.sessionID || routeSessionID
|
|
393
404
|
};
|
|
394
|
-
|
|
405
|
+
writeJsonAtomic(path.join(pDir, `classify-${props.request.id}.json`), pendingClassify);
|
|
395
406
|
tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50));
|
|
396
407
|
const respPath = path.join(pDir, `classify-response-${props.request.id}.json`);
|
|
397
408
|
let attempts = 0;
|
|
@@ -1673,7 +1684,7 @@ function QuizBatchDialog(props) {
|
|
|
1673
1684
|
timestamp: Date.now(),
|
|
1674
1685
|
sessionID: props.request.sessionID || routeSessionID
|
|
1675
1686
|
};
|
|
1676
|
-
|
|
1687
|
+
writeJsonAtomic(path.join(pDir, `classify-${cid}.json`), pendingClassify);
|
|
1677
1688
|
tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50));
|
|
1678
1689
|
const respPath = path.join(pDir, `classify-response-${cid}.json`);
|
|
1679
1690
|
let attempts = 0;
|
|
@@ -2642,7 +2653,13 @@ var tui = async (api) => {
|
|
|
2642
2653
|
} catch {
|
|
2643
2654
|
return null;
|
|
2644
2655
|
}
|
|
2645
|
-
}).filter(Boolean)
|
|
2656
|
+
}).filter(Boolean).filter((x) => {
|
|
2657
|
+
try {
|
|
2658
|
+
return !fs.existsSync(path.join(pendingDir, `response-${x.j.id}.json`));
|
|
2659
|
+
} catch {
|
|
2660
|
+
return true;
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2646
2663
|
const pick = matching.find((x) => x.j.sessionID === curSid) || matching.find((x) => !x.j.sessionID);
|
|
2647
2664
|
if (!pick)
|
|
2648
2665
|
return;
|
|
@@ -2682,15 +2699,15 @@ var tui = async (api) => {
|
|
|
2682
2699
|
currentBySession.set(curSid, current);
|
|
2683
2700
|
const done = async (result) => {
|
|
2684
2701
|
const respPath = path.join(pendingDir, `response-${data.id}.json`);
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
}
|
|
2702
|
+
const answerId = data.id;
|
|
2703
|
+
tlog("learn-tui answered", data.type, answerId, JSON.stringify(result).slice(0, 160));
|
|
2704
|
+
writeJsonAtomic(respPath, {
|
|
2705
|
+
id: data.id,
|
|
2706
|
+
type: data.type,
|
|
2707
|
+
result,
|
|
2708
|
+
sessionID: data.sessionID,
|
|
2709
|
+
at: Date.now()
|
|
2710
|
+
});
|
|
2694
2711
|
try {
|
|
2695
2712
|
const sessionID = data.sessionID;
|
|
2696
2713
|
let injectText = "";
|
|
@@ -2780,24 +2797,30 @@ var tui = async (api) => {
|
|
|
2780
2797
|
}
|
|
2781
2798
|
}
|
|
2782
2799
|
} catch {}
|
|
2783
|
-
try {
|
|
2784
|
-
fs.unlinkSync(full);
|
|
2785
|
-
} catch {}
|
|
2786
2800
|
api.ui.dialog.clear();
|
|
2787
2801
|
currentBySession.delete(curSid);
|
|
2788
2802
|
setTimeout(processPending, 150);
|
|
2803
|
+
setTimeout(() => {
|
|
2804
|
+
try {
|
|
2805
|
+
if (fs.existsSync(respPath)) {
|
|
2806
|
+
tlog("learn-tui response NOT consumed, re-touching", answerId);
|
|
2807
|
+
try {
|
|
2808
|
+
writeJsonAtomic(respPath, JSON.parse(fs.readFileSync(respPath, "utf8")));
|
|
2809
|
+
} catch {}
|
|
2810
|
+
}
|
|
2811
|
+
} catch {}
|
|
2812
|
+
}, 8000);
|
|
2789
2813
|
};
|
|
2790
2814
|
const cancel = async () => {
|
|
2791
2815
|
const respPath = path.join(pendingDir, `response-${data.id}.json`);
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
} catch {}
|
|
2816
|
+
tlog("learn-tui cancelled", data.type, data.id);
|
|
2817
|
+
writeJsonAtomic(respPath, {
|
|
2818
|
+
id: data.id,
|
|
2819
|
+
type: data.type,
|
|
2820
|
+
cancelled: true,
|
|
2821
|
+
sessionID: data.sessionID,
|
|
2822
|
+
at: Date.now()
|
|
2823
|
+
});
|
|
2801
2824
|
try {
|
|
2802
2825
|
const sid = data.sessionID;
|
|
2803
2826
|
if (sid) {
|
|
@@ -2833,9 +2856,6 @@ var tui = async (api) => {
|
|
|
2833
2856
|
} catch {}
|
|
2834
2857
|
}
|
|
2835
2858
|
} catch {}
|
|
2836
|
-
try {
|
|
2837
|
-
fs.unlinkSync(full);
|
|
2838
|
-
} catch {}
|
|
2839
2859
|
api.ui.dialog.clear();
|
|
2840
2860
|
currentBySession.delete(curSid);
|
|
2841
2861
|
setTimeout(processPending, 150);
|
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": "1.2.
|
|
4
|
+
"version": "1.2.4",
|
|
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": "AGPL-3.0-or-later",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -53,6 +53,14 @@ import { tmpdir } from "node:os"
|
|
|
53
53
|
const TUI_LOG = path.join(tmpdir(), "learn-tui.log")
|
|
54
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 {} }
|
|
55
55
|
function ensureDir(dir: string) { try { fs.mkdirSync(dir, { recursive: true }) } catch {} }
|
|
56
|
+
function writeJsonAtomic(filePath: string, data: any) {
|
|
57
|
+
// Atomic handoff: readers never observe partial JSON (tmp + rename is atomic on POSIX)
|
|
58
|
+
try {
|
|
59
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
61
|
+
fs.renameSync(tmp, filePath)
|
|
62
|
+
} catch { try { fs.writeFileSync(filePath, JSON.stringify(data), "utf8") } catch {} }
|
|
63
|
+
}
|
|
56
64
|
function decodeQuizText(s: string): string {
|
|
57
65
|
if (!s || typeof s !== "string") return s
|
|
58
66
|
if (!s.includes("\\")) return s
|
|
@@ -184,7 +192,7 @@ function QuizDialog(props: {
|
|
|
184
192
|
const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
|
|
185
193
|
const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
|
|
186
194
|
const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
|
|
187
|
-
|
|
195
|
+
writeJsonAtomic(path.join(pDir, `classify-${props.request.id}.json`), pendingClassify)
|
|
188
196
|
tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50))
|
|
189
197
|
// Poll for classify-response
|
|
190
198
|
const respPath = path.join(pDir, `classify-response-${props.request.id}.json`)
|
|
@@ -609,7 +617,7 @@ function QuizBatchDialog(props: {
|
|
|
609
617
|
const cid = `${props.request.id}-${idx()}`
|
|
610
618
|
const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
|
|
611
619
|
const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
|
|
612
|
-
|
|
620
|
+
writeJsonAtomic(path.join(pDir, `classify-${cid}.json`), pendingClassify)
|
|
613
621
|
tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
|
|
614
622
|
const respPath = path.join(pDir, `classify-response-${cid}.json`)
|
|
615
623
|
let attempts = 0
|
|
@@ -778,8 +786,11 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
778
786
|
if (api.ui.dialog.open) return
|
|
779
787
|
let files: string[] = []
|
|
780
788
|
try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
|
|
781
|
-
// Session-distinct: only show pending for current session
|
|
782
|
-
|
|
789
|
+
// Session-distinct: only show pending for current session.
|
|
790
|
+
// Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
|
|
791
|
+
const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean).filter(x => {
|
|
792
|
+
try { return !fs.existsSync(path.join(pendingDir, `response-${x!.j.id}.json`)) } catch { return true }
|
|
793
|
+
}) as Array<{f: string, j: any}>
|
|
783
794
|
const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
|
|
784
795
|
if (!pick) return
|
|
785
796
|
const file = pick.f
|
|
@@ -803,7 +814,9 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
803
814
|
currentBySession.set(curSid, current)
|
|
804
815
|
const done = async (result: any) => {
|
|
805
816
|
const respPath = path.join(pendingDir, `response-${data!.id}.json`)
|
|
806
|
-
|
|
817
|
+
const answerId = data!.id
|
|
818
|
+
tlog("learn-tui answered", (data as any).type, answerId, JSON.stringify(result).slice(0, 160))
|
|
819
|
+
writeJsonAtomic(respPath, { id: data!.id, type: data!.type, result, sessionID: (data as any).sessionID, at: Date.now() })
|
|
807
820
|
// Non-blocking wake: inject answer as new user prompt so agent continues (no timeout, no polling waste)
|
|
808
821
|
try {
|
|
809
822
|
const sessionID = (data as any).sessionID
|
|
@@ -862,14 +875,26 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
862
875
|
}
|
|
863
876
|
}
|
|
864
877
|
} catch {}
|
|
865
|
-
|
|
878
|
+
// Server owns lifecycle: it unlinks the pending file after successful inject.
|
|
879
|
+
// Do NOT delete pending here — deleting it destroys recovery context if the server hasn't consumed the response yet.
|
|
866
880
|
api.ui.dialog.clear()
|
|
867
881
|
currentBySession.delete(curSid)
|
|
868
882
|
setTimeout(processPending, 150)
|
|
883
|
+
// Watchdog: if the response is still unconsumed after 8s, re-touch to retrigger the server watch + log loudly.
|
|
884
|
+
// Safe: server claims via atomic rename, so a rewrite can never cause double-inject.
|
|
885
|
+
setTimeout(() => {
|
|
886
|
+
try {
|
|
887
|
+
if (fs.existsSync(respPath)) {
|
|
888
|
+
tlog("learn-tui response NOT consumed, re-touching", answerId)
|
|
889
|
+
try { writeJsonAtomic(respPath, JSON.parse(fs.readFileSync(respPath, "utf8"))) } catch {}
|
|
890
|
+
}
|
|
891
|
+
} catch {}
|
|
892
|
+
}, 8000)
|
|
869
893
|
}
|
|
870
894
|
const cancel = async () => {
|
|
871
895
|
const respPath = path.join(pendingDir, `response-${data!.id}.json`)
|
|
872
|
-
|
|
896
|
+
tlog("learn-tui cancelled", (data as any).type, data!.id)
|
|
897
|
+
writeJsonAtomic(respPath, { id: data!.id, type: data!.type, cancelled: true, sessionID: (data as any).sessionID, at: Date.now() })
|
|
873
898
|
try {
|
|
874
899
|
const sid = (data as any).sessionID
|
|
875
900
|
if (sid) {
|
|
@@ -885,7 +910,7 @@ export const tui: TuiPlugin = async (api) => {
|
|
|
885
910
|
} catch {}
|
|
886
911
|
}
|
|
887
912
|
} catch {}
|
|
888
|
-
|
|
913
|
+
// Server owns lifecycle: it unlinks the pending file after consuming the cancelled response.
|
|
889
914
|
api.ui.dialog.clear()
|
|
890
915
|
currentBySession.delete(curSid)
|
|
891
916
|
setTimeout(processPending, 150)
|
package/plugins/learn.ts
CHANGED
|
@@ -244,12 +244,31 @@ function answerCalloutQuiz(details: any): string {
|
|
|
244
244
|
if (details?.explanation) { body.push(""); for (const line of String(details.explanation).split("\n")) body.push(line) }
|
|
245
245
|
return callout(type, title, body)
|
|
246
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
|
+
}
|
|
247
261
|
function answerCalloutAsk(details: any): string {
|
|
248
262
|
const status = details?.status
|
|
249
263
|
if (status === "cancelled") return callout("warning", "Question — cancelled", ["(user skipped)"])
|
|
250
264
|
if (status === "unavailable") return callout("warning", "Question — unavailable", [details?.message || ""])
|
|
251
265
|
const answers: any[] = details?.answers || []
|
|
252
|
-
|
|
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}` })
|
|
253
272
|
if (body.length === 0) body.push("(no answer)")
|
|
254
273
|
return callout("example", "Answer", body)
|
|
255
274
|
}
|
|
@@ -310,6 +329,23 @@ async function backfillMdLog(client: any, sessionID: string, directory: string):
|
|
|
310
329
|
}
|
|
311
330
|
continue
|
|
312
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
|
+
}
|
|
313
349
|
if (st.status === "pending" || st.status === "running") {
|
|
314
350
|
if (input.question) {
|
|
315
351
|
const opts = Array.isArray(input.options) ? input.options : []
|
|
@@ -446,14 +482,39 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
446
482
|
if (!sessionID) { slog("watchAndInject no sessionID", id); return }
|
|
447
483
|
const dir = pendingDir(directory)
|
|
448
484
|
const respPath = path.join(dir, `response-${id}.json`)
|
|
449
|
-
const
|
|
485
|
+
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
|
|
486
|
+
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
|
|
487
|
+
const closeWatcher = () => { const w = activeWatchers.get(id); if (w) { try { w.close() } catch {}; activeWatchers.delete(id) } }
|
|
488
|
+
const fire = async (attempt = 0): Promise<void> => {
|
|
489
|
+
// Atomic single-consumer claim: exactly one process proceeds (fixes double-inject across processes)
|
|
490
|
+
try { fs.renameSync(respPath, claimPath) } catch { closeWatcher(); return } // already claimed/consumed → stand down
|
|
450
491
|
let data: any
|
|
451
|
-
try {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
492
|
+
try {
|
|
493
|
+
data = JSON.parse(fs.readFileSync(claimPath, "utf8"))
|
|
494
|
+
} catch (e) {
|
|
495
|
+
if (attempt < 5) {
|
|
496
|
+
await new Promise(r => setTimeout(r, 120))
|
|
497
|
+
try { fs.renameSync(claimPath, respPath) } catch {}
|
|
498
|
+
await new Promise(r => setTimeout(r, 60))
|
|
499
|
+
return fire(attempt + 1)
|
|
500
|
+
}
|
|
501
|
+
slog("watchAndInject UNREADABLE response, parked", id, String(e).slice(0,160))
|
|
502
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`)) } catch {}
|
|
503
|
+
closeWatcher()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
let text: string
|
|
507
|
+
let effectiveSessionID: string
|
|
508
|
+
try {
|
|
509
|
+
slog("watchAndInject fire", id, JSON.stringify(data).slice(0,400))
|
|
510
|
+
effectiveSessionID = (data as any)?.sessionID || sessionID
|
|
511
|
+
text = data?.cancelled ? `[cancelled] user dismissed the popup for ${id}` : buildText(data.result)
|
|
512
|
+
} catch (e) {
|
|
513
|
+
slog("watchAndInject buildText ERROR, parked", id, String(e).slice(0,200))
|
|
514
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.poisoned-${Date.now()}.json`)) } catch {}
|
|
515
|
+
closeWatcher()
|
|
516
|
+
return
|
|
517
|
+
}
|
|
457
518
|
// opencode-loop sdk.js:24 — SDK returns {data,error}, it does NOT throw. Must inspect .error.
|
|
458
519
|
const sdkCall = async (method: any, ...argsList: any[]) => {
|
|
459
520
|
let firstErr: any
|
|
@@ -476,23 +537,39 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
476
537
|
]
|
|
477
538
|
slog("watchAndInject injecting", id, effectiveSessionID, text.slice(0,300))
|
|
478
539
|
let ok = false
|
|
540
|
+
let firstErr: any
|
|
479
541
|
// loopd host-adapter.ts:100 — promptAsync wakes the session (fire-and-forget turn)
|
|
480
542
|
if (client?.session?.promptAsync) {
|
|
481
|
-
try { await sdkCall(client.session.promptAsync.bind(client.session), ...shapes); ok = true } catch {}
|
|
543
|
+
try { await sdkCall(client.session.promptAsync.bind(client.session), ...shapes); ok = true } catch (e) { firstErr = e }
|
|
482
544
|
}
|
|
483
545
|
if (!ok && client?.session?.prompt) {
|
|
484
|
-
try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch {}
|
|
546
|
+
try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch (e) { firstErr = firstErr || e }
|
|
485
547
|
}
|
|
548
|
+
if (!ok) {
|
|
549
|
+
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0,200))
|
|
550
|
+
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
|
|
551
|
+
try {
|
|
552
|
+
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
|
|
553
|
+
} catch {}
|
|
554
|
+
closeWatcher()
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
// Success: server owns lifecycle — consume claim + pending (TUI no longer deletes pending)
|
|
558
|
+
try { fs.unlinkSync(claimPath) } catch {}
|
|
559
|
+
for (const p of pendingCandidates) try { fs.unlinkSync(p) } catch {}
|
|
560
|
+
closeWatcher()
|
|
561
|
+
slog("watchAndInject consumed", id, effectiveSessionID)
|
|
486
562
|
try {
|
|
487
|
-
await client.app.log({ body: { service: "learn", level:
|
|
563
|
+
await client.app.log({ body: { service: "learn", level: "info", message: `injected into ${effectiveSessionID}`, extra: { id } } })
|
|
488
564
|
} catch {}
|
|
489
565
|
}
|
|
490
|
-
if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
|
|
491
566
|
try {
|
|
492
|
-
const w = fs.watch(dir, (_e, filename) => { if (filename === `response-${id}.json`
|
|
567
|
+
const w = fs.watch(dir, (_e, filename) => { if (filename === `response-${id}.json`) void fire() })
|
|
493
568
|
w.on("error", () => {})
|
|
494
569
|
activeWatchers.set(id, w)
|
|
495
570
|
} catch {}
|
|
571
|
+
// Recheck after arming to close the event gap (claim makes double-fire safe)
|
|
572
|
+
if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
|
|
496
573
|
}
|
|
497
574
|
|
|
498
575
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -744,6 +821,21 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
744
821
|
try {
|
|
745
822
|
const dir = pendingDir(directory)
|
|
746
823
|
if (fs.existsSync(dir)) {
|
|
824
|
+
// 1) Requeue stale claims from consumers that died mid-flight (>30s old, no live response)
|
|
825
|
+
for (const f of fs.readdirSync(dir).filter(x => x.includes(".claim-") && x.endsWith(".json"))) {
|
|
826
|
+
try {
|
|
827
|
+
const m = f.match(/^response-(.+)\.claim-.*\.json$/)
|
|
828
|
+
if (!m) continue
|
|
829
|
+
const rid = m[1]!
|
|
830
|
+
const target = path.join(dir, `response-${rid}.json`)
|
|
831
|
+
const age = Date.now() - fs.statSync(path.join(dir, f)).mtimeMs
|
|
832
|
+
if (age > 30000 && !fs.existsSync(target)) {
|
|
833
|
+
fs.renameSync(path.join(dir, f), target)
|
|
834
|
+
slog("watchAndInject requeued stale claim", rid)
|
|
835
|
+
}
|
|
836
|
+
} catch {}
|
|
837
|
+
}
|
|
838
|
+
// 2) Re-arm pending quizzes (fast-path inside watchAndInject consumes any waiting response)
|
|
747
839
|
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
748
840
|
try {
|
|
749
841
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))
|
|
@@ -793,6 +885,19 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
793
885
|
}
|
|
794
886
|
} catch {}
|
|
795
887
|
}
|
|
888
|
+
// 3) Park legacy orphans: response with no pending context (pre-fix leftovers) — can't rebuild inject text, park loudly instead of rotting silently
|
|
889
|
+
for (const f of fs.readdirSync(dir).filter(x => x.startsWith("response-") && x.endsWith(".json") && !x.includes(".claim-") && !x.includes(".poisoned") && !x.includes(".failed") && !x.includes(".orphaned"))) {
|
|
890
|
+
try {
|
|
891
|
+
const m = f.match(/^response-(.+)\.json$/)
|
|
892
|
+
if (!m) continue
|
|
893
|
+
const rid = m[1]!
|
|
894
|
+
const hasPending = fs.existsSync(path.join(dir, `quiz-${rid}.json`)) || fs.existsSync(path.join(dir, `quiz_batch-${rid}.json`))
|
|
895
|
+
if (!hasPending) {
|
|
896
|
+
fs.renameSync(path.join(dir, f), path.join(dir, `response-${rid}.orphaned-${Date.now()}.json`))
|
|
897
|
+
slog("watchAndInject orphaned response parked (no pending context)", rid)
|
|
898
|
+
}
|
|
899
|
+
} catch {}
|
|
900
|
+
}
|
|
796
901
|
}
|
|
797
902
|
} catch {}
|
|
798
903
|
|
|
@@ -849,24 +954,28 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
849
954
|
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, assistantBlock(stripSkillBlocks(text))))
|
|
850
955
|
} catch {}
|
|
851
956
|
},
|
|
852
|
-
"tool.execute.before": async (input) => {
|
|
957
|
+
"tool.execute.before": async (input, output) => {
|
|
853
958
|
try {
|
|
854
959
|
const ses = extractHookSessionID((input as any)?.sessionID)
|
|
855
960
|
const mdFile = getMdFile(ses)
|
|
856
961
|
if (!mdFile || !ses) return
|
|
857
962
|
const toolName = (input as any).tool
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
// `
|
|
963
|
+
// NOTE: per Hooks type, before-hook args live in output.args (input only has tool/sessionID/callID).
|
|
964
|
+
const args = (output as any)?.args ?? (input as any).args ?? {}
|
|
965
|
+
// Native `question` tool shape: {questions: [{question, header, options, multiple}]}
|
|
861
966
|
if (toolName === "question") {
|
|
862
|
-
const q = args.question || args.header || ""
|
|
863
|
-
const ctx2 = args.details?.trim() || undefined
|
|
864
|
-
const opts = Array.isArray(args.options) ? args.options : []
|
|
865
967
|
const callID = (input as any).callID
|
|
866
968
|
const qkey = callID ? mdKey(ses, `q:${callID}`) : undefined
|
|
867
969
|
if (qkey && loggedToolCallIds.has(qkey)) return
|
|
868
970
|
if (qkey) loggedToolCallIds.add(qkey)
|
|
869
|
-
|
|
971
|
+
const qs: any[] = Array.isArray(args.questions) ? args.questions
|
|
972
|
+
: (args.question ? [{ question: args.question, header: args.header, options: args.options ?? [] }] : [])
|
|
973
|
+
for (let i = 0; i < qs.length; i++) {
|
|
974
|
+
const q = qs[i]
|
|
975
|
+
if (!q?.question) continue
|
|
976
|
+
const label = q.header || (qs.length > 1 ? `Question ${i + 1}/${qs.length}` : "Question")
|
|
977
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, questionCallout(label, q.question, undefined, q.options ?? [])))
|
|
978
|
+
}
|
|
870
979
|
}
|
|
871
980
|
} catch {}
|
|
872
981
|
},
|
|
@@ -880,10 +989,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
880
989
|
const akey = callID ? mdKey(ses, `answer:${callID}`) : undefined
|
|
881
990
|
if (akey && loggedToolCallIds.has(akey)) return
|
|
882
991
|
if (toolName === "question") {
|
|
883
|
-
const meta: any = (output as any)
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
992
|
+
const meta: any = (output as any)?.metadata ?? {}
|
|
993
|
+
const inArgs: any = (input as any)?.args ?? {}
|
|
994
|
+
const qs: any[] = Array.isArray(inArgs.questions) ? inArgs.questions : []
|
|
995
|
+
let answers: any = meta.answers
|
|
996
|
+
if (!Array.isArray(answers) || !answers.length) {
|
|
997
|
+
const outText = typeof (output as any)?.output === "string" ? ((output as any).output as string).trim() : ""
|
|
998
|
+
if (outText) {
|
|
999
|
+
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, callout("example", "Answer", [outText.slice(0, 500)])))
|
|
1000
|
+
if (akey) loggedToolCallIds.add(akey)
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
answers = []
|
|
1004
|
+
}
|
|
1005
|
+
const details: any = { answers, questions: qs, status: "completed" }
|
|
887
1006
|
await withMdFileLock(mdFile, () => appendToMdLogForSession(ses, answerCalloutAsk(details)))
|
|
888
1007
|
if (akey) loggedToolCallIds.add(akey)
|
|
889
1008
|
}
|