@leon135/opencode-learn 0.0.0-stage → 0.1.1
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/README.md +107 -2
- package/package.json +42 -4
- package/plugins/agents.ts +211 -0
- package/plugins/index.ts +23 -0
- package/plugins/md-link/core.ts +238 -0
- package/plugins/md-link/index.ts +314 -0
- package/plugins/md-link/tui.ts +360 -0
- package/plugins/quiz.ts +722 -0
- package/plugins/skills.ts +46 -0
- package/plugins/tui.ts +17 -0
- package/plugins/viz/common.ts +152 -0
- package/plugins/viz/index.ts +245 -0
- package/plugins/viz/tui.ts +104 -0
- package/skills/teach/SKILL.md +179 -0
- package/skills/visualize/SKILL.md +78 -0
package/plugins/quiz.ts
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learn-quiz — GRADED question pair for the teaching system (v2 port of the
|
|
3
|
+
* pi quiz extension's server side).
|
|
4
|
+
*
|
|
5
|
+
* Two tools, because the v2 TUI displays raw tool-call arguments to the user
|
|
6
|
+
* while a tool runs. Splitting at the answer boundary keeps the correct answer
|
|
7
|
+
* invisible until AFTER the user has answered (pi's exact information flow):
|
|
8
|
+
*
|
|
9
|
+
* quiz_ask — pose the question. Input carries ONLY question/options/etc.
|
|
10
|
+
* Creates a session form (forms API), waits for the answer,
|
|
11
|
+
* stashes the raw selection per-session, returns it ungraded.
|
|
12
|
+
* quiz_grade — called after quiz_ask. Carries correctAnswer + explanation
|
|
13
|
+
* (safe to show now — the user already answered). Grades the
|
|
14
|
+
* stashed selection (exact-set match; "...?" is its own
|
|
15
|
+
* outcome) and returns the verdict + explanation.
|
|
16
|
+
*
|
|
17
|
+
* Transport, as shipped:
|
|
18
|
+
* chat / auto (openchamber_render detected): quiz_ask returns a ready
|
|
19
|
+
* openui-lang program; the model renders it with openchamber_render
|
|
20
|
+
* (awaitAnswer) and relays the chosen letter(s) + any note to
|
|
21
|
+
* quiz_grade (selected, note).
|
|
22
|
+
* form / TUI (auto with no render tool): quiz_ask opens a slim session form
|
|
23
|
+
* — lettered options + a note field, no question text — and returns the
|
|
24
|
+
* full question block for the model to post in the chat.
|
|
25
|
+
*
|
|
26
|
+
* The server plugin context exposes no form API, so forms go through the
|
|
27
|
+
* official OpenCode client (`OpenCode.make` + `Service.discover`):
|
|
28
|
+
* POST /api/session/:sid/form {title, fields:[{key,type,title,description,options}]}
|
|
29
|
+
* GET /api/session/:sid/form/:fid -> {state: {status: pending|answered|cancelled, answer?}}
|
|
30
|
+
* POST /api/session/:sid/form/:fid/reply {answer: {key: value}}
|
|
31
|
+
* DELETE /api/session/:sid/form/:fid (cancel)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { OpenCode } from "@opencode/client";
|
|
35
|
+
import { Service } from "@opencode/client/service";
|
|
36
|
+
import type {
|
|
37
|
+
FormState,
|
|
38
|
+
OpenCodeClient,
|
|
39
|
+
SessionFormCreateInput,
|
|
40
|
+
} from "@opencode/client";
|
|
41
|
+
import type { Plugin } from "@opencode/plugin";
|
|
42
|
+
|
|
43
|
+
const DONT_KNOW = "__dont_know__";
|
|
44
|
+
const POLL_MS = 400;
|
|
45
|
+
const TIMEOUT_MS = 10 * 60_000;
|
|
46
|
+
const CHAT_UI_TOOL = "openchamber_render";
|
|
47
|
+
|
|
48
|
+
type QuizMode = "auto" | "chat" | "form";
|
|
49
|
+
|
|
50
|
+
type QuizOption = {
|
|
51
|
+
label: string;
|
|
52
|
+
value: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Per-session pending quiz state between quiz_ask and quiz_grade. */
|
|
57
|
+
type PendingQuiz = {
|
|
58
|
+
question: string;
|
|
59
|
+
details?: string;
|
|
60
|
+
/** Form pendings carry the selection shape; chat pendings carry "chat". */
|
|
61
|
+
mode: "single-select" | "multi-select" | "chat";
|
|
62
|
+
/** Display order (post-shuffle), 1-based. */
|
|
63
|
+
options: QuizOption[];
|
|
64
|
+
status: "asked" | "answered" | "cancelled" | "timeout";
|
|
65
|
+
selectedIndices: number[];
|
|
66
|
+
dontKnow: boolean;
|
|
67
|
+
note?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const pendingKey = (sessionID: string) => `quiz:pending:${sessionID}`;
|
|
71
|
+
|
|
72
|
+
type StorageJson = Parameters<Plugin.Context["storage"]["set"]>[1];
|
|
73
|
+
|
|
74
|
+
function normalizeOptions(raw: any): QuizOption[] {
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
const out: QuizOption[] = [];
|
|
77
|
+
for (const o of Array.isArray(raw) ? raw : []) {
|
|
78
|
+
const label = String(o?.label ?? "").trim();
|
|
79
|
+
if (!label) continue;
|
|
80
|
+
const value = String(o?.value ?? "").trim() || label;
|
|
81
|
+
if (seen.has(value)) throw new Error(`duplicate option value "${value}"`);
|
|
82
|
+
seen.add(value);
|
|
83
|
+
const description = String(o?.description ?? "").trim() || undefined;
|
|
84
|
+
out.push({ label, value, description });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shuffleOptions(options: QuizOption[]): QuizOption[] {
|
|
90
|
+
const out = [...options];
|
|
91
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
92
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
93
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Multi-select correctAnswer sometimes arrives JSON-stringified; coerce. */
|
|
99
|
+
function coerceCorrectAnswer(correctAnswer: unknown): string[] {
|
|
100
|
+
if (Array.isArray(correctAnswer)) return correctAnswer.map((v) => String(v));
|
|
101
|
+
const s = String(correctAnswer ?? "").trim();
|
|
102
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(s);
|
|
105
|
+
if (Array.isArray(parsed)) return parsed.map((v) => String(v));
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
return s ? [s] : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function optionRef(options: QuizOption[], index: number): string {
|
|
112
|
+
const opt = options[index - 1];
|
|
113
|
+
return `${letterFor(index - 1)}. ${opt ? opt.label : "(unknown)"}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readMode(ctx: Plugin.Context): QuizMode {
|
|
117
|
+
const raw = String(ctx.options?.mode ?? "auto")
|
|
118
|
+
.trim()
|
|
119
|
+
.toLowerCase();
|
|
120
|
+
return raw === "chat" || raw === "form" ? raw : "auto";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A, B, …, Z, AA, AB, … */
|
|
124
|
+
function letterFor(index: number): string {
|
|
125
|
+
let n = index;
|
|
126
|
+
let out = "";
|
|
127
|
+
do {
|
|
128
|
+
out = String.fromCharCode(65 + (n % 26)) + out;
|
|
129
|
+
n = Math.floor(n / 26) - 1;
|
|
130
|
+
} while (n >= 0);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function lettersToIndex(letters: string): number {
|
|
135
|
+
let n = 0;
|
|
136
|
+
for (const ch of letters.toUpperCase()) {
|
|
137
|
+
if (ch < "A" || ch > "Z") return -1;
|
|
138
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
139
|
+
}
|
|
140
|
+
return n - 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildQuestionBlock(
|
|
144
|
+
question: string,
|
|
145
|
+
details: string | undefined,
|
|
146
|
+
): string {
|
|
147
|
+
return details ? `**Q.** ${question}\n\n${details}` : `**Q.** ${question}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The lettered option texts, shared by the chat callout and the form-mode question message. */
|
|
151
|
+
function buildOptionList(options: QuizOption[]): string {
|
|
152
|
+
return options.map((o, i) => `- **${letterFor(i)}.** ${o.label}`).join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildQuizProgram(
|
|
156
|
+
question: string,
|
|
157
|
+
details: string | undefined,
|
|
158
|
+
options: QuizOption[],
|
|
159
|
+
multiSelect: boolean,
|
|
160
|
+
): string {
|
|
161
|
+
// Option texts live in the callout (markdown renders LaTeX there); the buttons are letters.
|
|
162
|
+
const text = [
|
|
163
|
+
question,
|
|
164
|
+
...(details ? [details] : []),
|
|
165
|
+
buildOptionList(options),
|
|
166
|
+
].join("\n\n");
|
|
167
|
+
const choices = options.map((_, i) => letterFor(i));
|
|
168
|
+
choices.push("...?");
|
|
169
|
+
const choiceArgs = multiSelect
|
|
170
|
+
? `${JSON.stringify(choices)}, "", true`
|
|
171
|
+
: JSON.stringify(choices);
|
|
172
|
+
return [
|
|
173
|
+
`note = Callout(${JSON.stringify(text)}, "info", "Quiz")`,
|
|
174
|
+
`choice = Choice("quiz-answer", ${choiceArgs})`,
|
|
175
|
+
"root = Stack([note, choice])",
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildChatAskText(
|
|
180
|
+
question: string,
|
|
181
|
+
details: string | undefined,
|
|
182
|
+
options: QuizOption[],
|
|
183
|
+
multiSelect: boolean,
|
|
184
|
+
): string {
|
|
185
|
+
const questionBlock = buildQuestionBlock(question, details);
|
|
186
|
+
const fallback = options
|
|
187
|
+
.map((o, i) => `${letterFor(i)}. ${o.label}`)
|
|
188
|
+
.join("\n");
|
|
189
|
+
const how = multiSelect
|
|
190
|
+
? "Reply with the letters or values of every correct option (for example: A, C)."
|
|
191
|
+
: "Reply with the letter or value of your answer (for example: A).";
|
|
192
|
+
return [
|
|
193
|
+
questionBlock,
|
|
194
|
+
"",
|
|
195
|
+
"Render with the openchamber_render tool (pass awaitAnswer) if you have it; otherwise present the plain-text question below to the user.",
|
|
196
|
+
"",
|
|
197
|
+
"```openui-lang",
|
|
198
|
+
buildQuizProgram(question, details, options, multiSelect),
|
|
199
|
+
"```",
|
|
200
|
+
"",
|
|
201
|
+
"Plain-text fallback:",
|
|
202
|
+
fallback,
|
|
203
|
+
"...?",
|
|
204
|
+
"",
|
|
205
|
+
`${how} Then call quiz_grade with selected set to the user's answer.`,
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Chat mode: resolve the relayed answer against the shuffled options (values, labels or letters). */
|
|
210
|
+
function resolveSelected(
|
|
211
|
+
pq: PendingQuiz,
|
|
212
|
+
raw: unknown,
|
|
213
|
+
): { indices: number[]; dontKnow: boolean } | { error: string } {
|
|
214
|
+
const values = coerceCorrectAnswer(raw);
|
|
215
|
+
if (values.length === 0) {
|
|
216
|
+
return {
|
|
217
|
+
error:
|
|
218
|
+
"selected is required in chat mode — pass the option value(s) or label(s) the user chose.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const indices: number[] = [];
|
|
223
|
+
let dontKnow = false;
|
|
224
|
+
for (const value of values) {
|
|
225
|
+
const text = value.trim();
|
|
226
|
+
if (!text) continue;
|
|
227
|
+
const normalized = text.toLowerCase().replace(/[’']/g, "'");
|
|
228
|
+
if (
|
|
229
|
+
text === DONT_KNOW ||
|
|
230
|
+
normalized === "...?" ||
|
|
231
|
+
normalized === "i dont know"
|
|
232
|
+
) {
|
|
233
|
+
dontKnow = true;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const exactValue = pq.options.findIndex((o) => o.value === text);
|
|
238
|
+
if (exactValue >= 0) {
|
|
239
|
+
indices.push(exactValue + 1);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const exactLabel = pq.options.findIndex((o) => o.label === text);
|
|
243
|
+
if (exactLabel >= 0) {
|
|
244
|
+
indices.push(exactLabel + 1);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const lower = text.toLowerCase();
|
|
248
|
+
const looseLabel = pq.options.findIndex(
|
|
249
|
+
(o) => o.label.toLowerCase() === lower,
|
|
250
|
+
);
|
|
251
|
+
if (looseLabel >= 0) {
|
|
252
|
+
indices.push(looseLabel + 1);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const letterRun = /^([A-Za-z]+)(.*)$/s.exec(text);
|
|
257
|
+
if (letterRun) {
|
|
258
|
+
const byLetter = lettersToIndex(letterRun[1]);
|
|
259
|
+
if (byLetter >= 0 && byLetter < pq.options.length) {
|
|
260
|
+
const rest = letterRun[2];
|
|
261
|
+
if (rest === "") {
|
|
262
|
+
indices.push(byLetter + 1);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Only a label we generated ("B. <label>") may follow the letter. Anything else
|
|
266
|
+
// (e.g. a space-separated letter list like "B C") is an honest unknown, never a prefix pick.
|
|
267
|
+
const separator = /^\s*([.):-])\s*/.exec(rest);
|
|
268
|
+
if (separator) {
|
|
269
|
+
const remainder = rest.slice(separator[0].length).trim();
|
|
270
|
+
if (
|
|
271
|
+
remainder === "" ||
|
|
272
|
+
remainder.toLowerCase() === pq.options[byLetter].label.toLowerCase()
|
|
273
|
+
) {
|
|
274
|
+
indices.push(byLetter + 1);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const known = pq.options.map((o) => `"${o.value}"`).join(", ");
|
|
282
|
+
return {
|
|
283
|
+
error: `selected "${value}" does not match any quiz option value or label (${known})`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const unique = [...new Set(indices)].sort((a, b) => a - b);
|
|
288
|
+
if (!dontKnow && unique.length === 0) {
|
|
289
|
+
return {
|
|
290
|
+
error:
|
|
291
|
+
"selected is required in chat mode — pass the option value(s) or label(s) the user chose.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { indices: unique, dontKnow };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function setupQuiz(ctx: Plugin.Context): Promise<void> {
|
|
298
|
+
let client: OpenCodeClient | undefined;
|
|
299
|
+
const forms = async () =>
|
|
300
|
+
client ??
|
|
301
|
+
(client = await (async () => {
|
|
302
|
+
const endpoint = await Service.discover();
|
|
303
|
+
if (!endpoint)
|
|
304
|
+
throw new Error("could not discover the running OpenCode server");
|
|
305
|
+
return OpenCode.make({
|
|
306
|
+
baseUrl: endpoint.url,
|
|
307
|
+
headers: Service.headers(endpoint),
|
|
308
|
+
});
|
|
309
|
+
})());
|
|
310
|
+
const storePending = (sessionID: string, value: PendingQuiz) =>
|
|
311
|
+
ctx.storage.set(pendingKey(sessionID), value as unknown as StorageJson);
|
|
312
|
+
|
|
313
|
+
const mode = readMode(ctx);
|
|
314
|
+
const chatUITools = new Map<string, boolean>();
|
|
315
|
+
try {
|
|
316
|
+
if (typeof ctx.session?.hook === "function") {
|
|
317
|
+
await ctx.session.hook("context", (event) => {
|
|
318
|
+
try {
|
|
319
|
+
if (
|
|
320
|
+
!event?.sessionID ||
|
|
321
|
+
!event.tools ||
|
|
322
|
+
typeof event.tools !== "object"
|
|
323
|
+
)
|
|
324
|
+
return;
|
|
325
|
+
chatUITools.set(
|
|
326
|
+
event.sessionID,
|
|
327
|
+
Object.prototype.hasOwnProperty.call(event.tools, CHAT_UI_TOOL),
|
|
328
|
+
);
|
|
329
|
+
} catch {}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} catch {}
|
|
333
|
+
// Unknown (no context observed yet) degrades to chat, like any detection error.
|
|
334
|
+
const hasChatUI = async (sessionID: string): Promise<boolean> =>
|
|
335
|
+
chatUITools.get(sessionID) ?? true;
|
|
336
|
+
|
|
337
|
+
await ctx.tool.transform((tools) => {
|
|
338
|
+
// ── quiz_ask ──────────────────────────────────────────────────────────
|
|
339
|
+
tools.add({
|
|
340
|
+
name: "quiz_ask",
|
|
341
|
+
description:
|
|
342
|
+
"Pose ONE graded quiz question to the user and wait for their answer. Options-only (single-select or " +
|
|
343
|
+
"multi-select); an '...?' choice is added automatically, plus an optional free-text note " +
|
|
344
|
+
"(chat: relay it back via quiz_grade's `note`; TUI form: the form's own note field). " +
|
|
345
|
+
"Returns the user's raw selection UNGRADED — always follow with quiz_grade (correctAnswer + explanation) " +
|
|
346
|
+
"to reveal the verdict. For non-graded questions use the built-in question tool instead.",
|
|
347
|
+
input: {
|
|
348
|
+
type: "object",
|
|
349
|
+
properties: {
|
|
350
|
+
question: {
|
|
351
|
+
type: "string",
|
|
352
|
+
description:
|
|
353
|
+
"The single quiz question to ask. Ask exactly one question per tool call.",
|
|
354
|
+
},
|
|
355
|
+
details: {
|
|
356
|
+
type: "string",
|
|
357
|
+
description:
|
|
358
|
+
"Optional extra context or instructions shown under the question.",
|
|
359
|
+
},
|
|
360
|
+
options: {
|
|
361
|
+
type: "array",
|
|
362
|
+
minItems: 2,
|
|
363
|
+
items: {
|
|
364
|
+
type: "object",
|
|
365
|
+
properties: {
|
|
366
|
+
label: {
|
|
367
|
+
type: "string",
|
|
368
|
+
description: "Display label for the answer option.",
|
|
369
|
+
},
|
|
370
|
+
value: {
|
|
371
|
+
type: "string",
|
|
372
|
+
description:
|
|
373
|
+
"Stable machine-readable value; defaults to the label. quiz_grade references this.",
|
|
374
|
+
},
|
|
375
|
+
description: {
|
|
376
|
+
type: "string",
|
|
377
|
+
description: "Optional extra detail shown below the option.",
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
required: ["label"],
|
|
381
|
+
additionalProperties: false,
|
|
382
|
+
},
|
|
383
|
+
description:
|
|
384
|
+
"The answer options (2 or more). Give each a stable `value`. Never add your own '...?' option — it is added automatically.",
|
|
385
|
+
},
|
|
386
|
+
multiSelect: {
|
|
387
|
+
type: "boolean",
|
|
388
|
+
description:
|
|
389
|
+
"Set to true when more than one option is correct and the user must select all of them.",
|
|
390
|
+
},
|
|
391
|
+
shuffle: {
|
|
392
|
+
type: "boolean",
|
|
393
|
+
description:
|
|
394
|
+
"Default true: options are reordered before display. Set false only when order is meaningful.",
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
required: ["question", "options"],
|
|
398
|
+
additionalProperties: false,
|
|
399
|
+
},
|
|
400
|
+
execute: async (input: any, tctx: any) => {
|
|
401
|
+
try {
|
|
402
|
+
const question = String(input.question ?? "").trim();
|
|
403
|
+
const details = String(input.details ?? "").trim() || undefined;
|
|
404
|
+
const multiSelect = input.multiSelect === true;
|
|
405
|
+
const sessionID =
|
|
406
|
+
typeof tctx?.sessionID === "string" ? tctx.sessionID : "";
|
|
407
|
+
|
|
408
|
+
if (!question)
|
|
409
|
+
return { content: "quiz_ask failed: `question` is required." };
|
|
410
|
+
if (!sessionID)
|
|
411
|
+
return {
|
|
412
|
+
content:
|
|
413
|
+
"quiz_ask failed: no session context (sessionID missing).",
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
let options: QuizOption[];
|
|
417
|
+
try {
|
|
418
|
+
options = normalizeOptions(input.options);
|
|
419
|
+
} catch (e) {
|
|
420
|
+
return { content: `quiz_ask failed: ${(e as Error).message}` };
|
|
421
|
+
}
|
|
422
|
+
if (options.length < 2)
|
|
423
|
+
return {
|
|
424
|
+
content: "quiz_ask failed: at least two options are required.",
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
if (input.shuffle !== false) options = shuffleOptions(options);
|
|
428
|
+
|
|
429
|
+
if (
|
|
430
|
+
mode === "chat" ||
|
|
431
|
+
(mode === "auto" && (await hasChatUI(sessionID)))
|
|
432
|
+
) {
|
|
433
|
+
await storePending(sessionID, {
|
|
434
|
+
question,
|
|
435
|
+
details,
|
|
436
|
+
mode: "chat",
|
|
437
|
+
options,
|
|
438
|
+
status: "asked",
|
|
439
|
+
selectedIndices: [],
|
|
440
|
+
dontKnow: false,
|
|
441
|
+
});
|
|
442
|
+
return {
|
|
443
|
+
content: buildChatAskText(
|
|
444
|
+
question,
|
|
445
|
+
details,
|
|
446
|
+
options,
|
|
447
|
+
multiSelect,
|
|
448
|
+
),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Slim form: letters only — the question text is posted to the chat from the ask result.
|
|
453
|
+
const displayOptions = options.map((_, i) => ({
|
|
454
|
+
label: letterFor(i),
|
|
455
|
+
value: letterFor(i),
|
|
456
|
+
}));
|
|
457
|
+
displayOptions.push({ label: "...?", value: DONT_KNOW });
|
|
458
|
+
|
|
459
|
+
const answerField: SessionFormCreateInput["fields"][number] =
|
|
460
|
+
multiSelect
|
|
461
|
+
? {
|
|
462
|
+
key: "answer",
|
|
463
|
+
type: "multiselect",
|
|
464
|
+
title: "Answer",
|
|
465
|
+
options: displayOptions,
|
|
466
|
+
}
|
|
467
|
+
: {
|
|
468
|
+
key: "answer",
|
|
469
|
+
type: "string",
|
|
470
|
+
title: "Answer",
|
|
471
|
+
options: displayOptions,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const created = await (
|
|
475
|
+
await forms()
|
|
476
|
+
).session.form.create({
|
|
477
|
+
sessionID,
|
|
478
|
+
title: "Quiz — answer the question shown in chat",
|
|
479
|
+
fields: [
|
|
480
|
+
answerField,
|
|
481
|
+
{ key: "note", type: "string", title: "Note (optional)" },
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
const formID = created.id;
|
|
485
|
+
if (!formID)
|
|
486
|
+
return {
|
|
487
|
+
content: `quiz_ask failed: could not create the question form (${JSON.stringify(created).slice(0, 200)})`,
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
491
|
+
let state: FormState | undefined;
|
|
492
|
+
while (Date.now() < deadline) {
|
|
493
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
494
|
+
try {
|
|
495
|
+
state = (
|
|
496
|
+
await (await forms()).session.form.get({ sessionID, formID })
|
|
497
|
+
).state;
|
|
498
|
+
} catch {}
|
|
499
|
+
if (state && state.status !== "pending") break;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!state || state.status === "pending") {
|
|
503
|
+
await (await forms()).session.form
|
|
504
|
+
.cancel({ sessionID, formID })
|
|
505
|
+
.catch(() => {});
|
|
506
|
+
await storePending(sessionID, {
|
|
507
|
+
question,
|
|
508
|
+
details,
|
|
509
|
+
mode: multiSelect ? "multi-select" : "single-select",
|
|
510
|
+
options,
|
|
511
|
+
status: "timeout",
|
|
512
|
+
selectedIndices: [],
|
|
513
|
+
dontKnow: false,
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
content:
|
|
517
|
+
"User did not answer the quiz in time. Call quiz_grade to close it out (or move on).",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (state.status === "cancelled") {
|
|
521
|
+
await storePending(sessionID, {
|
|
522
|
+
question,
|
|
523
|
+
details,
|
|
524
|
+
mode: multiSelect ? "multi-select" : "single-select",
|
|
525
|
+
options,
|
|
526
|
+
status: "cancelled",
|
|
527
|
+
selectedIndices: [],
|
|
528
|
+
dontKnow: false,
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
content:
|
|
532
|
+
"User dismissed the quiz without answering. Call quiz_grade to close it out (or move on).",
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Answered: the form's letters resolve through the same helper chat mode uses.
|
|
537
|
+
const resolved = resolveSelected(
|
|
538
|
+
{
|
|
539
|
+
question,
|
|
540
|
+
details,
|
|
541
|
+
mode: multiSelect ? "multi-select" : "single-select",
|
|
542
|
+
options,
|
|
543
|
+
status: "asked",
|
|
544
|
+
selectedIndices: [],
|
|
545
|
+
dontKnow: false,
|
|
546
|
+
},
|
|
547
|
+
state.answer.answer,
|
|
548
|
+
);
|
|
549
|
+
// The form only offers these letters; anything else counts as no valid selection.
|
|
550
|
+
const selectedIndices = "error" in resolved ? [] : resolved.indices;
|
|
551
|
+
const dontKnow = "error" in resolved ? false : resolved.dontKnow;
|
|
552
|
+
const note = String(state.answer?.note ?? "").trim() || undefined;
|
|
553
|
+
|
|
554
|
+
await storePending(sessionID, {
|
|
555
|
+
question,
|
|
556
|
+
details,
|
|
557
|
+
mode: multiSelect ? "multi-select" : "single-select",
|
|
558
|
+
options,
|
|
559
|
+
status: "answered",
|
|
560
|
+
selectedIndices,
|
|
561
|
+
dontKnow,
|
|
562
|
+
note,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
let text = [
|
|
566
|
+
buildQuestionBlock(question, details),
|
|
567
|
+
buildOptionList(options),
|
|
568
|
+
"The form shows letters only — post the question block above to the chat now so the user can read it.",
|
|
569
|
+
"",
|
|
570
|
+
].join("\n\n");
|
|
571
|
+
if (dontKnow) {
|
|
572
|
+
text +=
|
|
573
|
+
'User selected "I don\'t know" — they did not attempt an answer.';
|
|
574
|
+
} else {
|
|
575
|
+
text += `User selected: ${selectedIndices.map((i) => optionRef(options, i)).join(", ") || "(nothing)"}`;
|
|
576
|
+
}
|
|
577
|
+
if (note) text += `\nUser's note: ${note}`;
|
|
578
|
+
text +=
|
|
579
|
+
"\nNow call quiz_grade with correctAnswer (the option value(s) you intended) and explanation to reveal the verdict.";
|
|
580
|
+
return { content: text };
|
|
581
|
+
} catch (error) {
|
|
582
|
+
return {
|
|
583
|
+
content: `❌ quiz_ask failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// ── quiz_grade ─────────────────────────────────────────────────────────
|
|
590
|
+
tools.add({
|
|
591
|
+
name: "quiz_grade",
|
|
592
|
+
description:
|
|
593
|
+
"Grade the quiz the user just answered via quiz_ask. REQUIRED afterwards: pass correctAnswer (the option " +
|
|
594
|
+
"value(s) you intended — single string or array for multi-select, exact-set match) and explanation (why the " +
|
|
595
|
+
"correct answer is correct — always required). In chat mode also relay the user's answer as `selected` and " +
|
|
596
|
+
"any note as `note`; in form mode both come from the form. Returns right/wrong, the correct answer, the " +
|
|
597
|
+
"user's note, and your explanation; relay all of it to the user. '...?' grades as its own outcome, " +
|
|
598
|
+
"never as wrong.",
|
|
599
|
+
input: {
|
|
600
|
+
type: "object",
|
|
601
|
+
properties: {
|
|
602
|
+
correctAnswer: {
|
|
603
|
+
description:
|
|
604
|
+
'REQUIRED. The correct answer as option value(s) from quiz_ask\'s options. Single-select: one string ("mercury"). Multi-select: array (["belize","niue"]) — exact-set match. Always the value, never a position number.',
|
|
605
|
+
oneOf: [
|
|
606
|
+
{ type: "string" },
|
|
607
|
+
{ type: "array", items: { type: "string" } },
|
|
608
|
+
],
|
|
609
|
+
},
|
|
610
|
+
selected: {
|
|
611
|
+
description:
|
|
612
|
+
"Chat-UI mode only: the user's relayed answer — the option value(s) or label(s) they chose. Single string, or an array for multi-select. Required in chat mode; ignored in form mode.",
|
|
613
|
+
oneOf: [
|
|
614
|
+
{ type: "string" },
|
|
615
|
+
{ type: "array", items: { type: "string" } },
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
note: {
|
|
619
|
+
type: "string",
|
|
620
|
+
description:
|
|
621
|
+
"Chat-UI mode only: the user's free-text note, relayed verbatim from the ask callout. Ignored in form mode — the form has its own note field.",
|
|
622
|
+
},
|
|
623
|
+
explanation: {
|
|
624
|
+
type: "string",
|
|
625
|
+
description:
|
|
626
|
+
"REQUIRED. Why the correct answer is correct — shown to the user after answering.",
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
required: ["correctAnswer", "explanation"],
|
|
630
|
+
additionalProperties: false,
|
|
631
|
+
},
|
|
632
|
+
execute: async (input: any, tctx: any) => {
|
|
633
|
+
try {
|
|
634
|
+
const sessionID =
|
|
635
|
+
typeof tctx?.sessionID === "string" ? tctx.sessionID : "";
|
|
636
|
+
const explanation = String(input.explanation ?? "").trim();
|
|
637
|
+
const pq = sessionID
|
|
638
|
+
? ((await ctx.storage.get(pendingKey(sessionID))) as
|
|
639
|
+
| PendingQuiz
|
|
640
|
+
| undefined)
|
|
641
|
+
: undefined;
|
|
642
|
+
if (!pq)
|
|
643
|
+
return {
|
|
644
|
+
content:
|
|
645
|
+
"quiz_grade failed: no pending quiz — call quiz_ask first.",
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
await ctx.storage.remove(pendingKey(sessionID));
|
|
649
|
+
|
|
650
|
+
let selectedIndices: number[];
|
|
651
|
+
let dontKnow: boolean;
|
|
652
|
+
let note: string | undefined;
|
|
653
|
+
if (pq.mode === "chat") {
|
|
654
|
+
const resolved = resolveSelected(pq, input.selected);
|
|
655
|
+
if ("error" in resolved)
|
|
656
|
+
return { content: `quiz_grade failed: ${resolved.error}` };
|
|
657
|
+
selectedIndices = resolved.indices;
|
|
658
|
+
dontKnow = resolved.dontKnow;
|
|
659
|
+
note = String(input.note ?? "").trim() || pq.note;
|
|
660
|
+
} else {
|
|
661
|
+
if (pq.status !== "answered") {
|
|
662
|
+
return {
|
|
663
|
+
content: `Quiz was ${pq.status === "cancelled" ? "dismissed by the user" : "not answered in time"} — no grade. Correct answer for your reference: ask again later if needed.`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
selectedIndices = pq.selectedIndices;
|
|
667
|
+
dontKnow = pq.dontKnow;
|
|
668
|
+
note = pq.note;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Resolve correctAnswer against the DISPLAY order stashed by quiz_ask.
|
|
672
|
+
const arr = coerceCorrectAnswer(input.correctAnswer);
|
|
673
|
+
if (arr.length === 0)
|
|
674
|
+
return { content: "quiz_grade failed: correctAnswer is required." };
|
|
675
|
+
const byValue = new Map(pq.options.map((o, i) => [o.value, i + 1]));
|
|
676
|
+
const correctIndices: number[] = [];
|
|
677
|
+
for (const raw of arr) {
|
|
678
|
+
const idx = byValue.get(String(raw).trim());
|
|
679
|
+
if (idx === undefined) {
|
|
680
|
+
const known = pq.options.map((o) => `"${o.value}"`).join(", ");
|
|
681
|
+
return {
|
|
682
|
+
content: `quiz_grade failed: correctAnswer "${raw}" does not match any quiz_ask option value (${known})`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
correctIndices.push(idx);
|
|
686
|
+
}
|
|
687
|
+
const uniqCorrect = Array.from(new Set(correctIndices)).sort(
|
|
688
|
+
(a, b) => a - b,
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
const correct =
|
|
692
|
+
!dontKnow &&
|
|
693
|
+
selectedIndices.length === uniqCorrect.length &&
|
|
694
|
+
selectedIndices.every((v, i) => v === uniqCorrect[i]);
|
|
695
|
+
|
|
696
|
+
const selectedStr =
|
|
697
|
+
selectedIndices.map((i) => optionRef(pq.options, i)).join(", ") ||
|
|
698
|
+
"(none)";
|
|
699
|
+
const correctStr = uniqCorrect
|
|
700
|
+
.map((i) => optionRef(pq.options, i))
|
|
701
|
+
.join(", ");
|
|
702
|
+
|
|
703
|
+
let text: string;
|
|
704
|
+
if (dontKnow) {
|
|
705
|
+
text = `User selected "...?" — they did not attempt an answer (a genuine knowledge gap, not a wrong guess).\nCorrect: ${correctStr}`;
|
|
706
|
+
} else {
|
|
707
|
+
text = `User answered ${correct ? "correctly" : "incorrectly"}.\nSelected: ${selectedStr}\nCorrect: ${correctStr}`;
|
|
708
|
+
}
|
|
709
|
+
if (note) text += `\nNote: ${note}`;
|
|
710
|
+
if (explanation) text += `\nExplanation: ${explanation}`;
|
|
711
|
+
text +=
|
|
712
|
+
"\nRelay the verdict, the correct answer, and the explanation to the user now.";
|
|
713
|
+
return { content: text };
|
|
714
|
+
} catch (error) {
|
|
715
|
+
return {
|
|
716
|
+
content: `❌ quiz_grade failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
}
|