@danypops/papyrus 0.59.1 → 0.59.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.59.1",
3
+ "version": "0.59.3",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "files": ["src", "README.md"],
59
59
  "dependencies": {
60
- "@danypops/vehicle-client": "^0.10.1",
60
+ "@danypops/vehicle-client": "^0.10.3",
61
61
  "@danypops/vehicle-core": "^0.17.1",
62
- "@danypops/vehicle-server": "^0.24.5",
62
+ "@danypops/vehicle-server": "^0.25.2",
63
63
  "@stricli/core": "^1.3.0"
64
64
  }
65
65
  }
@@ -38,6 +38,8 @@ const openCommand = buildCommand({
38
38
  optionsJson?: string[];
39
39
  optionsMode?: string;
40
40
  optionDescriptionsJson?: string[];
41
+ correctOptionsJson?: string[];
42
+ explanation?: string;
41
43
  },
42
44
  ) {
43
45
  await call.call(this, "discuss.open", {
@@ -50,6 +52,8 @@ const openCommand = buildCommand({
50
52
  options: flags.optionsJson,
51
53
  options_mode: flags.optionsMode,
52
54
  option_descriptions: flags.optionDescriptionsJson,
55
+ correct_options: flags.correctOptionsJson,
56
+ explanation: flags.explanation,
53
57
  });
54
58
  },
55
59
  parameters: {
@@ -81,6 +85,20 @@ const openCommand = buildCommand({
81
85
  placeholder: "json",
82
86
  optional: true,
83
87
  },
88
+ correctOptionsJson: {
89
+ brief: "JSON string array of correct options (one or more, verbatim from options) -- makes this a graded quiz",
90
+ kind: "parsed",
91
+ parse: parseStringArray,
92
+ placeholder: "json",
93
+ optional: true,
94
+ },
95
+ explanation: {
96
+ brief: "Quiz explanation, required alongside correctOptionsJson, always shown after grading",
97
+ kind: "parsed",
98
+ parse: String,
99
+ placeholder: "text",
100
+ optional: true,
101
+ },
84
102
  },
85
103
  },
86
104
  docs: { brief: "Open a new discussion" },
@@ -96,6 +114,8 @@ const replyCommand = buildCommand({
96
114
  optionsJson?: string[];
97
115
  optionsMode?: string;
98
116
  optionDescriptionsJson?: string[];
117
+ correctOptionsJson?: string[];
118
+ explanation?: string;
99
119
  },
100
120
  id: string,
101
121
  ) {
@@ -107,6 +127,8 @@ const replyCommand = buildCommand({
107
127
  options: flags.optionsJson,
108
128
  options_mode: flags.optionsMode,
109
129
  option_descriptions: flags.optionDescriptionsJson,
130
+ correct_options: flags.correctOptionsJson,
131
+ explanation: flags.explanation,
110
132
  });
111
133
  },
112
134
  parameters: {
@@ -135,6 +157,20 @@ const replyCommand = buildCommand({
135
157
  placeholder: "json",
136
158
  optional: true,
137
159
  },
160
+ correctOptionsJson: {
161
+ brief: "JSON string array of correct options (one or more, verbatim from options) -- makes this a graded quiz",
162
+ kind: "parsed",
163
+ parse: parseStringArray,
164
+ placeholder: "json",
165
+ optional: true,
166
+ },
167
+ explanation: {
168
+ brief: "Quiz explanation, required alongside correctOptionsJson, always shown after grading",
169
+ kind: "parsed",
170
+ parse: String,
171
+ placeholder: "text",
172
+ optional: true,
173
+ },
138
174
  },
139
175
  positional: { kind: "tuple", parameters: [{ brief: "Discussion id", parse: String, placeholder: "id" }] },
140
176
  },
package/src/constants.ts CHANGED
@@ -17,7 +17,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
17
17
  export const DAEMON_UNIT_NAME = "papyrus.service";
18
18
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
19
19
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
20
- export const SQLITE_SCHEMA_VERSION = 29;
20
+ export const SQLITE_SCHEMA_VERSION = 30;
21
21
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
22
22
 
23
23
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -243,6 +243,25 @@ export const DISCUSSION_OPTION_MAX_LENGTH = 200;
243
243
  export const DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH = 240;
244
244
  // Below this, a binary yes/no choice is often self-evident and a description would just pad it.
245
245
  export const DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT = 3;
246
+ /**
247
+ * A posed choice becomes a graded quiz/assessment once correct_options + explanation are given
248
+ * alongside options/optionsMode. correct_options is one or more entries drawn verbatim from
249
+ * options (exact text match, the same identity scheme `selected` already uses -- never an index
250
+ * or the display-only A/B/C/... label). A "single" quiz must have exactly one correct option
251
+ * (the participant can only pick one); a "multi" quiz allows several, graded correct iff the
252
+ * selected set exactly equals the correct set (no partial credit). explanation is mandatory --
253
+ * always shown after grading, especially when wrong, so a wrong answer still teaches something.
254
+ */
255
+ export const DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS = 2_000;
256
+ /**
257
+ * Display-only labels (A, B, C, ...) assigned to a quiz's options by position, computed on the
258
+ * fly wherever a quiz is rendered -- never persisted, never part of a quiz's addressing scheme.
259
+ * Single-letter labels only support up to 26 options; DISCUSSION_OPTIONS_MAX_COUNT (10) already
260
+ * sits comfortably under that ceiling, so there is deliberately no AA/AB double-letter overflow
261
+ * scheme -- raising DISCUSSION_OPTIONS_MAX_COUNT past 26 would need one added at the same time.
262
+ */
263
+ export const DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT = 26;
264
+
246
265
  /** Bounds for the generic graph projection protocol (external bounded contexts). */
247
266
  export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
248
267
  export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
@@ -344,6 +363,12 @@ export function dbPath(): string {
344
363
  return `${xdg}/papyrus/papyrus.db`;
345
364
  }
346
365
 
366
+ /** $XDG_DATA_HOME/papyrus/metrics.sqlite -- vehicle-server's own tool/operation usage metrics store, sibling of dbPath() above. */
367
+ export function metricsPath(): string {
368
+ const xdg = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`;
369
+ return `${xdg}/papyrus/metrics.sqlite`;
370
+ }
371
+
347
372
  /**
348
373
  * The initial status a newly created artifact of a kind gets when no caller-supplied
349
374
  * status is given. This must be an explicit, named mapping — never derived from row order
@@ -3,9 +3,12 @@ import { join } from "node:path";
3
3
  import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
4
4
  import { readLaunchProvenance } from "@danypops/vehicle-server/daemon";
5
5
  import { diagnoseDaemon, openDaemonLifecycleLog } from "@danypops/vehicle-server/daemon-lifecycle";
6
+ import { openVehicleMetricsStore } from "@danypops/vehicle-server/metrics";
7
+ import { createVehicleMetricsMiddleware } from "@danypops/vehicle-server/metrics-middleware";
8
+ import { registerVehicleMetricsOperations } from "@danypops/vehicle-server/metrics-operations";
6
9
  import { acquireDaemonLock, releaseDaemonLock } from "@danypops/vehicle-server/paths";
7
10
  import { PushChannel } from "@danypops/vehicle-server/push-channel";
8
- import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, WAL_CHECKPOINT_INTERVAL_MS } from "../constants.ts";
11
+ import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, metricsPath, WAL_CHECKPOINT_INTERVAL_MS } from "../constants.ts";
9
12
  import { logEvent, logger } from "../log/log.ts";
10
13
  import { createApp, createPapyrusService } from "../service.ts";
11
14
  import {
@@ -69,6 +72,14 @@ export async function serveMain(): Promise<void> {
69
72
  const startedAt = new Date().toISOString();
70
73
  const token = loadOrCreateToken(stateDir);
71
74
  const service = createPapyrusService(dbPath());
75
+ // Records how often each real operation is invoked (server-side, every caller) plus, via
76
+ // metrics.recordClientEvent, client-observed Vehicle Shell meta-tool calls -- see
77
+ // @danypops/vehicle-server's own metrics README section. Wired directly onto the same registry
78
+ // every real papyrus operation is already registered on, so it's discoverable through the exact
79
+ // same tools_list/tools_man path as any other operation.
80
+ const vehicleMetrics = openVehicleMetricsStore(metricsPath());
81
+ service.vehicle.useExecutionMiddleware(createVehicleMetricsMiddleware(vehicleMetrics, "papyrus"));
82
+ registerVehicleMetricsOperations(service.vehicle, vehicleMetrics, "papyrus");
72
83
  const pushChannel = new PushChannel({ token });
73
84
  const app = createApp({
74
85
  service,
@@ -106,6 +117,7 @@ export async function serveMain(): Promise<void> {
106
117
  });
107
118
  if (!server.port) {
108
119
  service.close();
120
+ vehicleMetrics.close();
109
121
  releaseDaemonLock(lockPath);
110
122
  throw new Error("Papyrus daemon failed to bind a listener");
111
123
  }
@@ -168,6 +180,7 @@ export async function serveMain(): Promise<void> {
168
180
  }
169
181
  releaseDaemonLock(lockPath);
170
182
  service.close();
183
+ vehicleMetrics.close();
171
184
  // .finally() re-throws rather than handling a rejection -- catching it first turns a bare
172
185
  // unhandled-rejection warning into a real, queryable shutdown-failure log line.
173
186
  void recordLifecycle("stopped", signal)
package/src/db.ts CHANGED
@@ -307,6 +307,16 @@ CREATE TABLE IF NOT EXISTS discussion_rounds (
307
307
  options_mode TEXT,
308
308
  selected TEXT,
309
309
  option_descriptions TEXT,
310
+ -- Quiz: quiz marks a posing round (safe, public); quiz_correct_options/quiz_explanation are the
311
+ -- hidden answer (never selected by any general-purpose query, only resolvePendingQuizAnswer's
312
+ -- own dedicated one); quiz_result_* is the graded outcome on the answering round (public, safe
313
+ -- once submission has already happened). See domain/discussion.ts.
314
+ quiz INTEGER,
315
+ quiz_correct_options TEXT,
316
+ quiz_explanation TEXT,
317
+ quiz_result_correct INTEGER,
318
+ quiz_result_correct_options TEXT,
319
+ quiz_result_explanation TEXT,
310
320
  UNIQUE (discussion_id, round_number)
311
321
  );
312
322
  CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
@@ -1008,6 +1018,35 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
1008
1018
  `);
1009
1019
  },
1010
1020
  },
1021
+ {
1022
+ version: 30,
1023
+ name: "discuss-quiz",
1024
+ // See domain/discussion.ts. Nullable, purely additive columns, same pattern as version 16/19's
1025
+ // own discuss-options/discuss-option-descriptions migrations -- a round with no quiz (the
1026
+ // overwhelming majority) simply stores NULL in all six. quiz_correct_options/quiz_explanation
1027
+ // are the hidden answer (a posing round) -- deliberately never selected by discussion_rounds'
1028
+ // general-purpose read queries, only by SQLiteDiscussionRoundStore's own dedicated
1029
+ // resolvePendingQuizAnswer. quiz_result_* is the graded outcome (an answering round, safe to
1030
+ // read generally since it only exists after submission). Guarded per-column (SQLite has no
1031
+ // `ADD COLUMN IF NOT EXISTS`), the same already-bootstrapped-fixture concern version 9's comment
1032
+ // covers.
1033
+ up: (db) => {
1034
+ const existing = new Set(
1035
+ (db.prepare("PRAGMA table_info(discussion_rounds)").all() as Array<{ name: string }>).map((row) => row.name),
1036
+ );
1037
+ const columns = [
1038
+ ["quiz", "INTEGER"],
1039
+ ["quiz_correct_options", "TEXT"],
1040
+ ["quiz_explanation", "TEXT"],
1041
+ ["quiz_result_correct", "INTEGER"],
1042
+ ["quiz_result_correct_options", "TEXT"],
1043
+ ["quiz_result_explanation", "TEXT"],
1044
+ ] as const;
1045
+ for (const [column, type] of columns) {
1046
+ if (!existing.has(column)) db.exec(`ALTER TABLE discussion_rounds ADD COLUMN ${column} ${type}`);
1047
+ }
1048
+ },
1049
+ },
1011
1050
  ];
1012
1051
 
1013
1052
  /**
@@ -5,4 +5,11 @@ export interface DiscussionRoundStore {
5
5
  append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound;
6
6
  list(query: DiscussionRoundQuery): DiscussionRound[];
7
7
  count(discussionId: string): number;
8
+ /**
9
+ * Reads back a quiz's hidden correct-answer + explanation, for grading only -- the one deliberate
10
+ * hole in "never expose the answer", scoped to discussion-service.ts's reply() alone. Returns
11
+ * undefined when that round never posed a quiz (defensive; discussion-service.ts only calls this
12
+ * when extra.discussion.pendingIsQuiz is already true, so this should always resolve in practice).
13
+ */
14
+ resolvePendingQuizAnswer(discussionId: string, roundNumber: number): { correctOptions: string[]; explanation: string } | undefined;
8
15
  }
@@ -12,13 +12,16 @@ import {
12
12
  DISCUSSION_SUBTYPE,
13
13
  type DiscussionExtra,
14
14
  type DiscussionOptionsMode,
15
+ type DiscussionQuizResult,
15
16
  type DiscussionRound,
17
+ gradeQuizAnswer,
16
18
  isDiscussionArtifact,
17
19
  readDiscussionExtra,
18
20
  validateDeferReason,
19
21
  validateDiscussionActor,
20
22
  validateDiscussionContent,
21
23
  validateDiscussionOptions,
24
+ validateDiscussionQuiz,
22
25
  validateSelectedOptions,
23
26
  validateSettlement,
24
27
  } from "./discussion.ts";
@@ -38,17 +41,25 @@ export interface OpenDiscussionInput {
38
41
  optionsMode?: DiscussionOptionsMode;
39
42
  /** Index-aligned with options -- see domain/discussion.ts's pendingOptionDescriptions. */
40
43
  optionDescriptions?: string[];
44
+ /** Turns the posed choice into a graded quiz -- both required together, alongside options/optionsMode.
45
+ * See domain/discussion.ts's validateDiscussionQuiz. */
46
+ quizCorrectOptions?: string[];
47
+ quizExplanation?: string;
41
48
  }
42
49
 
43
50
  export interface ReplyInput {
44
51
  actor: string;
45
52
  content: string;
46
- /** Answers the Discussion's currently pending posed choice, if any; validated against it. */
53
+ /** Answers the Discussion's currently pending posed choice, if any; validated against it. If the
54
+ * pending choice was a quiz, this is also graded automatically -- see the round's own quizResult. */
47
55
  selected?: string[];
48
56
  /** Poses a new choice on this same round, replacing whatever was previously pending. */
49
57
  options?: string[];
50
58
  optionsMode?: DiscussionOptionsMode;
51
59
  optionDescriptions?: string[];
60
+ /** Turns the newly-posed choice into a graded quiz -- both required together. */
61
+ quizCorrectOptions?: string[];
62
+ quizExplanation?: string;
52
63
  }
53
64
 
54
65
  export interface DiscussionAndRounds {
@@ -72,20 +83,50 @@ export class Discussions {
72
83
  return readDiscussionExtra(discussion.extra);
73
84
  }
74
85
 
75
- /** Validates a freshly-posed choice; undefined when neither field is given (nothing posed), since both/neither is the only valid shape. */
86
+ /**
87
+ * Validates a freshly-posed choice; undefined when nothing is posed at all (neither options nor
88
+ * quiz fields given), since both/neither is the only valid shape. correct_options/explanation
89
+ * turn the choice into a graded quiz -- both required together, and only once options/optionsMode
90
+ * are also given.
91
+ */
76
92
  private validatePosedOptions(
77
93
  options: string[] | undefined,
78
94
  optionsMode: DiscussionOptionsMode | undefined,
79
95
  optionDescriptions: string[] | undefined,
80
- ): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } | undefined {
81
- if (options === undefined && optionsMode === undefined) return undefined;
82
- return validateDiscussionOptions(options ?? [], optionsMode ?? "", optionDescriptions);
96
+ quizCorrectOptions: string[] | undefined,
97
+ quizExplanation: string | undefined,
98
+ ):
99
+ | {
100
+ options: string[];
101
+ mode: DiscussionOptionsMode;
102
+ optionDescriptions?: string[];
103
+ quiz?: { correctOptions: string[]; explanation: string };
104
+ }
105
+ | undefined {
106
+ if (options === undefined && optionsMode === undefined) {
107
+ if (quizCorrectOptions !== undefined || quizExplanation !== undefined) {
108
+ throw new Error("correct_options/explanation require options and options_mode to also be posed");
109
+ }
110
+ return undefined;
111
+ }
112
+ const posed = validateDiscussionOptions(options ?? [], optionsMode ?? "", optionDescriptions);
113
+ if (quizCorrectOptions === undefined && quizExplanation === undefined) return posed;
114
+ if (quizCorrectOptions === undefined || quizExplanation === undefined) {
115
+ throw new Error("correct_options and explanation must both be given to pose a quiz, or neither");
116
+ }
117
+ return { ...posed, quiz: validateDiscussionQuiz(posed.options, posed.mode, quizCorrectOptions, quizExplanation) };
83
118
  }
84
119
 
85
120
  open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
86
121
  const actor = validateDiscussionActor(input.actor);
87
122
  const content = validateDiscussionContent(input.content);
88
- const posed = this.validatePosedOptions(input.options, input.optionsMode, input.optionDescriptions);
123
+ const posed = this.validatePosedOptions(
124
+ input.options,
125
+ input.optionsMode,
126
+ input.optionDescriptions,
127
+ input.quizCorrectOptions,
128
+ input.quizExplanation,
129
+ );
89
130
  return this.artifacts.atomic(() => {
90
131
  const discussion = this.artifacts.create(
91
132
  {
@@ -104,6 +145,7 @@ export class Discussions {
104
145
  pendingOptions: posed.options,
105
146
  pendingOptionsMode: posed.mode,
106
147
  ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
148
+ ...(posed.quiz ? { pendingIsQuiz: true, pendingQuizRoundNumber: 1 } : {}),
107
149
  }
108
150
  : {}),
109
151
  },
@@ -122,6 +164,9 @@ export class Discussions {
122
164
  options: posed.options,
123
165
  optionsMode: posed.mode,
124
166
  ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
167
+ ...(posed.quiz
168
+ ? { quiz: true, quizCorrectOptions: posed.quiz.correctOptions, quizExplanation: posed.quiz.explanation }
169
+ : {}),
125
170
  }
126
171
  : {}),
127
172
  },
@@ -135,7 +180,13 @@ export class Discussions {
135
180
  reply(discussionId: string, input: ReplyInput, context?: ArtifactEventContext): DiscussionAndRounds {
136
181
  const validActor = validateDiscussionActor(input.actor);
137
182
  const validContent = validateDiscussionContent(input.content);
138
- const posed = this.validatePosedOptions(input.options, input.optionsMode, input.optionDescriptions);
183
+ const posed = this.validatePosedOptions(
184
+ input.options,
185
+ input.optionsMode,
186
+ input.optionDescriptions,
187
+ input.quizCorrectOptions,
188
+ input.quizExplanation,
189
+ );
139
190
  return this.artifacts.atomic(() => {
140
191
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
141
192
  const state = this.extra(discussion);
@@ -144,6 +195,24 @@ export class Discussions {
144
195
  throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
145
196
  const selected =
146
197
  input.selected !== undefined ? validateSelectedOptions(input.selected, state.pendingOptions, state.pendingOptionsMode) : undefined;
198
+ // Grading happens right here, inside this same transaction: the hidden answer is read via
199
+ // resolvePendingQuizAnswer (the one deliberate hole in "never expose the quiz's answer",
200
+ // scoped to exactly this call) and immediately folded into a safe, already-graded quizResult --
201
+ // nothing upstream of this method ever sees the raw correct-options set for an unanswered quiz.
202
+ const quizResult: DiscussionQuizResult | undefined =
203
+ selected && state.pendingIsQuiz && state.pendingQuizRoundNumber !== undefined
204
+ ? (() => {
205
+ const hidden = this.rounds.resolvePendingQuizAnswer(discussionId, state.pendingQuizRoundNumber as number);
206
+ if (!hidden) {
207
+ throw new DiscussionError(`discussion "${discussionId}" has no recorded quiz answer for its pending round`);
208
+ }
209
+ return {
210
+ correct: gradeQuizAnswer(selected, hidden.correctOptions),
211
+ correctOptions: hidden.correctOptions,
212
+ explanation: hidden.explanation,
213
+ };
214
+ })()
215
+ : undefined;
147
216
  const nextRound = state.roundCount + 1;
148
217
  const round = this.rounds.append(
149
218
  {
@@ -156,21 +225,28 @@ export class Discussions {
156
225
  options: posed.options,
157
226
  optionsMode: posed.mode,
158
227
  ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
228
+ ...(posed.quiz
229
+ ? { quiz: true, quizCorrectOptions: posed.quiz.correctOptions, quizExplanation: posed.quiz.explanation }
230
+ : {}),
159
231
  }
160
232
  : {}),
161
233
  ...(selected ? { selected } : {}),
234
+ ...(quizResult ? { quizResult } : {}),
162
235
  },
163
236
  new Date().toISOString(),
164
237
  );
165
238
  // Whenever this round answers the pending choice OR poses a new one, the base must drop ALL
166
- // three pending* fields first -- otherwise a re-pose that omits descriptions this time would
239
+ // pending* fields first -- otherwise a re-pose that omits descriptions this time would
167
240
  // leave a stale pendingOptionDescriptions array (sized for the OLD options) spread through
168
- // unchanged, no longer aligned 1:1 with the new pendingOptions. Only a plain reply that
169
- // neither answers nor re-poses leaves the existing pending state untouched.
241
+ // unchanged, no longer aligned 1:1 with the new pendingOptions (and likewise for a stale
242
+ // pendingIsQuiz/pendingQuizRoundNumber pointing at a round that's no longer pending). Only a
243
+ // plain reply that neither answers nor re-poses leaves the existing pending state untouched.
170
244
  const {
171
245
  pendingOptions: _clearedOptions,
172
246
  pendingOptionsMode: _clearedMode,
173
247
  pendingOptionDescriptions: _clearedDescriptions,
248
+ pendingIsQuiz: _clearedIsQuiz,
249
+ pendingQuizRoundNumber: _clearedQuizRoundNumber,
174
250
  ...withoutPending
175
251
  } = state;
176
252
  const nextState = {
@@ -181,6 +257,7 @@ export class Discussions {
181
257
  pendingOptions: posed.options,
182
258
  pendingOptionsMode: posed.mode,
183
259
  ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
260
+ ...(posed.quiz ? { pendingIsQuiz: true, pendingQuizRoundNumber: nextRound } : {}),
184
261
  }
185
262
  : {}),
186
263
  };
@@ -25,6 +25,8 @@ import {
25
25
  DISCUSSION_OPTION_MAX_LENGTH,
26
26
  DISCUSSION_OPTIONS_MAX_COUNT,
27
27
  DISCUSSION_OPTIONS_MIN_COUNT,
28
+ DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS,
29
+ DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT,
28
30
  DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS,
29
31
  DISCUSSION_SETTLEMENT_MAX_CHARACTERS,
30
32
  } from "../constants.ts";
@@ -55,9 +57,25 @@ export interface DiscussionExtra {
55
57
  * "no description for this one". Purely descriptive metadata: selection/validation only ever
56
58
  * matches against pendingOptions itself, never against this array. */
57
59
  pendingOptionDescriptions?: string[];
60
+ /** True when the currently pending posed choice is a graded quiz -- safe to expose (announces
61
+ * "this will be graded", never the answer itself, which never lives in extra.discussion at all;
62
+ * see AppendDiscussionRound's own comment for where the real answer is kept instead). */
63
+ pendingIsQuiz?: boolean;
64
+ /** The round number that posed the currently pending quiz -- lets reply() look up its hidden
65
+ * answer in O(1) via DiscussionRoundStore.resolvePendingQuizAnswer, without scanning history. */
66
+ pendingQuizRoundNumber?: number;
58
67
  }
59
68
 
60
- /** One append-only round of a Discussion -- opening statement is round 1. options/optionsMode/selected are the historical record of what was posed/picked in this specific round (extra.discussion.pendingOptions is the separate, mutable "what's unanswered right now" cache). */
69
+ /**
70
+ * One append-only round of a Discussion -- opening statement is round 1. options/optionsMode/selected
71
+ * are the historical record of what was posed/picked in this specific round (extra.discussion.pendingOptions
72
+ * is the separate, mutable "what's unanswered right now" cache).
73
+ *
74
+ * quiz/quizResult are the two quiz-safe fields: `quiz: true` marks a posing round as graded (never
75
+ * the correct answer itself -- that never appears on this type, by construction, see
76
+ * AppendDiscussionRound); `quizResult` appears only on the round that actually answered a pending
77
+ * quiz, once grading has already happened -- safe to reveal at that point since submission is done.
78
+ */
61
79
  export interface DiscussionRound {
62
80
  id: number;
63
81
  discussionId: string;
@@ -70,6 +88,19 @@ export interface DiscussionRound {
70
88
  /** Index-aligned with options -- see DiscussionExtra.pendingOptionDescriptions. */
71
89
  optionDescriptions?: string[];
72
90
  selected?: string[];
91
+ /** True when this round posed a graded quiz alongside options/optionsMode. */
92
+ quiz?: boolean;
93
+ /** Present only on the round that answered a pending quiz -- the graded outcome, safe to show
94
+ * because the participant has already submitted by the time this exists. */
95
+ quizResult?: DiscussionQuizResult;
96
+ }
97
+
98
+ /** The graded outcome of one quiz submission, attached to the answering round. */
99
+ export interface DiscussionQuizResult {
100
+ correct: boolean;
101
+ /** The full correct set, drawn verbatim from the options that were offered. */
102
+ correctOptions: string[];
103
+ explanation: string;
73
104
  }
74
105
 
75
106
  export interface AppendDiscussionRound {
@@ -81,6 +112,20 @@ export interface AppendDiscussionRound {
81
112
  optionsMode?: DiscussionOptionsMode;
82
113
  optionDescriptions?: string[];
83
114
  selected?: string[];
115
+ /** Marks this round as posing a graded quiz -- paired with quizCorrectOptions/quizExplanation below. */
116
+ quiz?: boolean;
117
+ /**
118
+ * Write-only: the quiz's actual answer, persisted for grading but deliberately absent from
119
+ * DiscussionRound (the read-side type) -- a store implementation must never select these columns
120
+ * into anything it returns from list()/append()'s own return value. Only
121
+ * DiscussionRoundStore.resolvePendingQuizAnswer may ever read them back, from within the same
122
+ * reply() transaction that performs grading.
123
+ */
124
+ quizCorrectOptions?: string[];
125
+ quizExplanation?: string;
126
+ /** The already-graded outcome to persist on an answering round -- computed by discussion-service.ts
127
+ * before this reaches the store, never derived by the store itself. */
128
+ quizResult?: DiscussionQuizResult;
84
129
  }
85
130
 
86
131
  export interface DiscussionRoundQuery {
@@ -150,7 +195,48 @@ export function validateDiscussionOptions(
150
195
  };
151
196
  }
152
197
 
153
- /** Validates an answer against the Discussion's currently pending posed choice, if any. */
198
+ /**
199
+ * Validates a quiz's correct-answer + explanation against the options it's layered onto (already
200
+ * validated by validateDiscussionOptions). correctOptions is one or more entries drawn verbatim
201
+ * from options -- exact text match, the same identity scheme `selected` uses. A "single" quiz
202
+ * (the participant can only pick one) must have exactly one correct option; a "multi" quiz may
203
+ * have several. explanation is mandatory -- always shown after grading, never optional.
204
+ */
205
+ export function validateDiscussionQuiz(
206
+ options: string[],
207
+ mode: DiscussionOptionsMode,
208
+ correctOptions: string[],
209
+ explanation: string,
210
+ ): { correctOptions: string[]; explanation: string } {
211
+ if (correctOptions.length === 0) throw new Error("quiz correct_options must not be empty");
212
+ if (new Set(correctOptions).size !== correctOptions.length) throw new Error("quiz correct_options must not repeat an entry");
213
+ const unknown = correctOptions.filter((entry) => !options.includes(entry));
214
+ if (unknown.length > 0) throw new Error(`quiz correct_options must be among the offered options: ${unknown.join(", ")}`);
215
+ if (mode === "single" && correctOptions.length !== 1) {
216
+ throw new Error('a "single" quiz must have exactly one correct_option');
217
+ }
218
+ const validExplanation = boundedString(explanation, "explanation", DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS);
219
+ return { correctOptions: [...correctOptions], explanation: validExplanation };
220
+ }
221
+
222
+ /** Correct iff the participant's selection exactly matches the quiz's correct set -- no partial credit. */
223
+ export function gradeQuizAnswer(selected: string[], correctOptions: string[]): boolean {
224
+ return selected.length === correctOptions.length && selected.every((entry) => correctOptions.includes(entry));
225
+ }
226
+
227
+ /**
228
+ * Display-only label for a quiz option by its position (A, B, C, ...) -- never persisted, never
229
+ * part of a quiz's addressing scheme (options/correct_options/selected all match by exact text).
230
+ * Single letters only: see DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT's own comment for why that's
231
+ * always enough given the enforced DISCUSSION_OPTIONS_MAX_COUNT.
232
+ */
233
+ export function quizOptionLabel(index: number): string {
234
+ if (index < 0 || index >= DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT) {
235
+ throw new Error(`quiz option index ${index} has no single-letter label (max ${DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT} options)`);
236
+ }
237
+ return String.fromCharCode(65 + index);
238
+ }
239
+
154
240
  export function validateSelectedOptions(
155
241
  selected: string[],
156
242
  pendingOptions: string[] | undefined,
@@ -202,6 +288,15 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
202
288
  throw new Error("invalid Discussion pendingOptionDescriptions: must align 1:1 with pendingOptions");
203
289
  }
204
290
  }
291
+ const pendingIsQuiz = record.pendingIsQuiz;
292
+ if (pendingIsQuiz !== undefined && typeof pendingIsQuiz !== "boolean") throw new Error("invalid Discussion pendingIsQuiz");
293
+ const pendingQuizRoundNumber = record.pendingQuizRoundNumber;
294
+ if (
295
+ pendingQuizRoundNumber !== undefined &&
296
+ (typeof pendingQuizRoundNumber !== "number" || !Number.isInteger(pendingQuizRoundNumber) || pendingQuizRoundNumber < 1)
297
+ ) {
298
+ throw new Error("invalid Discussion pendingQuizRoundNumber");
299
+ }
205
300
  return {
206
301
  state: state as DiscussionState,
207
302
  roundCount,
@@ -211,5 +306,7 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
211
306
  ...(pendingOptions !== undefined ? { pendingOptions: pendingOptions as string[] } : {}),
212
307
  ...(pendingOptionsMode !== undefined ? { pendingOptionsMode: pendingOptionsMode as DiscussionOptionsMode } : {}),
213
308
  ...(pendingOptionDescriptions !== undefined ? { pendingOptionDescriptions: pendingOptionDescriptions as string[] } : {}),
309
+ ...(pendingIsQuiz !== undefined ? { pendingIsQuiz } : {}),
310
+ ...(pendingQuizRoundNumber !== undefined ? { pendingQuizRoundNumber } : {}),
214
311
  };
215
312
  }
@@ -3,6 +3,7 @@ import type { Db } from "../db.ts";
3
3
  import {
4
4
  type AppendDiscussionRound,
5
5
  type DiscussionOptionsMode,
6
+ type DiscussionQuizResult,
6
7
  type DiscussionRound,
7
8
  type DiscussionRoundQuery,
8
9
  validateDiscussionActor,
@@ -11,6 +12,10 @@ import {
11
12
  } from "../discussion/discussion.ts";
12
13
  import type { DiscussionRoundStore } from "../discussion/discussion-round-store.ts";
13
14
 
15
+ // Deliberately excludes quiz_correct_options/quiz_explanation: those are the hidden columns only
16
+ // resolvePendingQuizAnswer's own dedicated, narrower query ever selects. This row type -- and the
17
+ // general-purpose SELECT below that populates it -- structurally cannot leak a quiz's answer,
18
+ // because the shape flowing through mapRow() never carries it in the first place.
14
19
  interface DiscussionRoundRow {
15
20
  id: number;
16
21
  discussion_id: string;
@@ -22,9 +27,21 @@ interface DiscussionRoundRow {
22
27
  options_mode: string | null;
23
28
  selected: string | null;
24
29
  option_descriptions: string | null;
30
+ quiz: number | null;
31
+ quiz_result_correct: number | null;
32
+ quiz_result_correct_options: string | null;
33
+ quiz_result_explanation: string | null;
25
34
  }
26
35
 
27
36
  function mapRow(row: DiscussionRoundRow): DiscussionRound {
37
+ const quizResult: DiscussionQuizResult | undefined =
38
+ row.quiz_result_correct !== null
39
+ ? {
40
+ correct: row.quiz_result_correct === 1,
41
+ correctOptions: JSON.parse(row.quiz_result_correct_options ?? "[]") as string[],
42
+ explanation: row.quiz_result_explanation ?? "",
43
+ }
44
+ : undefined;
28
45
  return {
29
46
  id: row.id,
30
47
  discussionId: row.discussion_id,
@@ -36,6 +53,8 @@ function mapRow(row: DiscussionRoundRow): DiscussionRound {
36
53
  ...(row.options_mode !== null ? { optionsMode: row.options_mode as DiscussionOptionsMode } : {}),
37
54
  ...(row.selected !== null ? { selected: JSON.parse(row.selected) as string[] } : {}),
38
55
  ...(row.option_descriptions !== null ? { optionDescriptions: JSON.parse(row.option_descriptions) as string[] } : {}),
56
+ ...(row.quiz === 1 ? { quiz: true } : {}),
57
+ ...(quizResult ? { quizResult } : {}),
39
58
  };
40
59
  }
41
60
 
@@ -52,10 +71,18 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
52
71
  round.options !== undefined || round.optionsMode !== undefined
53
72
  ? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "", round.optionDescriptions)
54
73
  : undefined;
74
+ // quizCorrectOptions/quizExplanation are NOT re-validated here -- discussion-service.ts already
75
+ // ran validateDiscussionQuiz before this reaches the store, same trust boundary as `selected`
76
+ // above. quizResult (the already-graded outcome) is likewise computed upstream, never here.
55
77
  const result = this.db
56
78
  .prepare(`
57
- INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected, option_descriptions)
58
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
79
+ INSERT INTO discussion_rounds (
80
+ discussion_id, round_number, actor, content, occurred_at, event_schema_version,
81
+ options, options_mode, selected, option_descriptions,
82
+ quiz, quiz_correct_options, quiz_explanation,
83
+ quiz_result_correct, quiz_result_correct_options, quiz_result_explanation
84
+ )
85
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59
86
  `)
60
87
  .run(
61
88
  round.discussionId,
@@ -67,6 +94,12 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
67
94
  posed ? posed.mode : null,
68
95
  round.selected !== undefined ? JSON.stringify(round.selected) : null,
69
96
  posed?.optionDescriptions ? JSON.stringify(posed.optionDescriptions) : null,
97
+ round.quiz ? 1 : null,
98
+ round.quizCorrectOptions !== undefined ? JSON.stringify(round.quizCorrectOptions) : null,
99
+ round.quizExplanation ?? null,
100
+ round.quizResult !== undefined ? (round.quizResult.correct ? 1 : 0) : null,
101
+ round.quizResult !== undefined ? JSON.stringify(round.quizResult.correctOptions) : null,
102
+ round.quizResult?.explanation ?? null,
70
103
  );
71
104
  return {
72
105
  id: Number(result.lastInsertRowid),
@@ -78,6 +111,10 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
78
111
  ...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
79
112
  ...(posed?.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
80
113
  ...(round.selected !== undefined ? { selected: [...round.selected] } : {}),
114
+ ...(round.quiz ? { quiz: true as const } : {}),
115
+ ...(round.quizResult !== undefined
116
+ ? { quizResult: { ...round.quizResult, correctOptions: [...round.quizResult.correctOptions] } }
117
+ : {}),
81
118
  };
82
119
  }
83
120
 
@@ -85,7 +122,8 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
85
122
  const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
86
123
  const rows = this.db
87
124
  .prepare(`
88
- SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions
125
+ SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions,
126
+ quiz, quiz_result_correct, quiz_result_correct_options, quiz_result_explanation
89
127
  FROM discussion_rounds
90
128
  WHERE discussion_id = ? AND round_number > ?
91
129
  ORDER BY round_number ASC
@@ -98,4 +136,14 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
98
136
  count(discussionId: string): number {
99
137
  return (this.db.prepare("SELECT COUNT(*) AS c FROM discussion_rounds WHERE discussion_id = ?").get(discussionId) as { c: number }).c;
100
138
  }
139
+
140
+ // The one place quiz_correct_options/quiz_explanation are ever read back -- a narrow, dedicated
141
+ // query naming exactly those two hidden columns, deliberately separate from list()'s own SELECT.
142
+ resolvePendingQuizAnswer(discussionId: string, roundNumber: number): { correctOptions: string[]; explanation: string } | undefined {
143
+ const row = this.db
144
+ .prepare("SELECT quiz_correct_options, quiz_explanation FROM discussion_rounds WHERE discussion_id = ? AND round_number = ?")
145
+ .get(discussionId, roundNumber) as { quiz_correct_options: string | null; quiz_explanation: string | null } | undefined;
146
+ if (!row || row.quiz_correct_options === null || row.quiz_explanation === null) return undefined;
147
+ return { correctOptions: JSON.parse(row.quiz_correct_options) as string[], explanation: row.quiz_explanation };
148
+ }
101
149
  }
@@ -17,8 +17,13 @@ import type { VehicleContentBlock } from "@danypops/vehicle-core";
17
17
  import type { VehicleRegistry } from "@danypops/vehicle-server";
18
18
  import type { Artifact } from "../artifact/artifact.ts";
19
19
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
20
- import { DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH, DISCUSSION_OPTION_MAX_LENGTH, DISCUSSION_OPTIONS_MAX_COUNT } from "../constants.ts";
21
- import { DISCUSSION_SUBTYPE, type DiscussionRound } from "../discussion/discussion.ts";
20
+ import {
21
+ DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH,
22
+ DISCUSSION_OPTION_MAX_LENGTH,
23
+ DISCUSSION_OPTIONS_MAX_COUNT,
24
+ DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS,
25
+ } from "../constants.ts";
26
+ import { DISCUSSION_SUBTYPE, type DiscussionRound, quizOptionLabel } from "../discussion/discussion.ts";
22
27
  import type { DiscussionAndRounds, Discussions } from "../discussion/discussion-service.ts";
23
28
  import { discussOperations } from "../modules/discuss.ts";
24
29
  import {
@@ -43,6 +48,19 @@ function roundsTranscript(rounds: readonly DiscussionRound[]): string {
43
48
  return rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || " (no rounds)";
44
49
  }
45
50
 
51
+ /** Lettered rendering of a quiz round's options (A, B, C, ...) -- never shown for a plain, non-quiz
52
+ * posed choice, which keeps its existing comma-joined presentation unchanged (backward compatible). */
53
+ function formatQuizOptions(options: readonly string[]): string {
54
+ return options.map((option, index) => `${quizOptionLabel(index)}. ${option}`).join(" ");
55
+ }
56
+
57
+ /** Appended to reply's own content block once a submission answered a pending quiz -- always states
58
+ * correct/incorrect and always includes the explanation (never optional, especially when wrong). */
59
+ function formatQuizVerdict(result: { correct: boolean; correctOptions: string[]; explanation: string }): string {
60
+ const verdict = result.correct ? "✅ Correct!" : `❌ Incorrect -- correct answer(s): ${result.correctOptions.join(", ")}.`;
61
+ return `${verdict} ${result.explanation}`;
62
+ }
63
+
46
64
  /**
47
65
  * Resolves a discussion's id from either an explicit id or its exact title.
48
66
  * Discussions.list() has no project-scoping concept at all (unlike Tasks),
@@ -129,6 +147,12 @@ const optionsUnionSchema = { type: "array" } as const;
129
147
  */
130
148
  const OPTION_BOUNDS_TEXT = `Each option is at most ${DISCUSSION_OPTION_MAX_LENGTH} characters (up to ${DISCUSSION_OPTIONS_MAX_COUNT} total); each description is at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters.`;
131
149
 
150
+ /**
151
+ * Quiz support layered onto the same options/options_mode a plain posed choice already uses.
152
+ * Interpolates the real, enforced explanation bound the same way OPTION_BOUNDS_TEXT does for options.
153
+ */
154
+ const QUIZ_BOUNDS_TEXT = `To make it a graded quiz/knowledge assessment instead of a plain posed choice, also pass correct_options (one or more entries drawn verbatim from options -- exact text, not an index or display letter) + explanation (REQUIRED, at most ${DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS} characters, always shown after grading -- especially when wrong). A "single" quiz needs exactly one correct option; "multi" allows several, graded correct iff the reply's selected set exactly matches (no partial credit). Options display as lettered choices (A, B, C, ...) once a quiz is posed.`;
155
+
132
156
  export function registerDiscussVehicleOperations(registry: VehicleRegistry, discussions: Discussions, artifacts: ArtifactStore): void {
133
157
  const moduleOperations = new Map(discussOperations(discussions).map((op) => [op.name, op]));
134
158
  const call = <Output>(name: string, input: Record<string, unknown>): Output => moduleOperations.get(name)!.execute(input) as Output;
@@ -163,7 +187,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
163
187
 
164
188
  define(
165
189
  "open",
166
- `Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). ${OPTION_BOUNDS_TEXT} Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
190
+ `Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). ${OPTION_BOUNDS_TEXT} ${QUIZ_BOUNDS_TEXT} Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
167
191
  "local-write",
168
192
  {
169
193
  title: stringProp,
@@ -176,6 +200,8 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
176
200
  options: optionsUnionSchema,
177
201
  options_mode: { type: "string", enum: ["single", "multi"] },
178
202
  option_descriptions: arrayProp,
203
+ correct_options: arrayProp,
204
+ explanation: stringProp,
179
205
  live: boolProp,
180
206
  source: stringProp,
181
207
  session_id: stringProp,
@@ -189,13 +215,15 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
189
215
  },
190
216
  (raw) => {
191
217
  const result = raw as DiscussionAndRounds;
192
- return { ...result, content: [contentBlock(`Opened discussion ${artifactLine(result.discussion)}`)] };
218
+ const posed = result.rounds[0];
219
+ const quizNote = posed?.quiz && posed.options ? `\n${formatQuizOptions(posed.options)}` : "";
220
+ return { ...result, content: [contentBlock(`Opened discussion ${artifactLine(result.discussion)}${quizNote}`)] };
193
221
  },
194
222
  );
195
223
 
196
224
  define(
197
225
  "reply",
198
- `Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via \`selected\` (validated against it), or poses a new choice via options/options_mode. ${OPTION_BOUNDS_TEXT} Prefer \`name\` over \`id\`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
226
+ `Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via \`selected\` (validated against it) -- if that choice was a quiz, the answer is graded automatically and the verdict + explanation come back in this same call. Or poses a new choice via options/options_mode. ${OPTION_BOUNDS_TEXT} ${QUIZ_BOUNDS_TEXT} Prefer \`name\` over \`id\`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
199
227
  "local-write",
200
228
  {
201
229
  id: stringProp,
@@ -206,6 +234,8 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
206
234
  options: optionsUnionSchema,
207
235
  options_mode: { type: "string", enum: ["single", "multi"] },
208
236
  option_descriptions: arrayProp,
237
+ correct_options: arrayProp,
238
+ explanation: stringProp,
209
239
  live: boolProp,
210
240
  source: stringProp,
211
241
  session_id: stringProp,
@@ -218,9 +248,12 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
218
248
  },
219
249
  (raw) => {
220
250
  const result = raw as DiscussionAndRounds;
251
+ const answered = result.rounds[0];
252
+ const posed = answered?.quiz && answered.options ? `\n${formatQuizOptions(answered.options)}` : "";
253
+ const verdict = answered?.quizResult ? `\n${formatQuizVerdict(answered.quizResult)}` : "";
221
254
  return {
222
255
  ...result,
223
- content: [contentBlock(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`)],
256
+ content: [contentBlock(`Round ${answered?.roundNumber} added to "${result.discussion.title}"${posed}${verdict}`)],
224
257
  };
225
258
  },
226
259
  );
package/src/index.ts CHANGED
@@ -17,7 +17,13 @@ export {
17
17
  type VehicleClientTarget,
18
18
  } from "./client.ts";
19
19
  export * from "./constants.ts";
20
- export { DISCUSSION_SUBTYPE, type DiscussionRound, readDiscussionExtra } from "./discussion/discussion.ts";
20
+ export {
21
+ DISCUSSION_SUBTYPE,
22
+ type DiscussionQuizResult,
23
+ type DiscussionRound,
24
+ quizOptionLabel,
25
+ readDiscussionExtra,
26
+ } from "./discussion/discussion.ts";
21
27
  export type { DiscussionAndRounds } from "./discussion/discussion-service.ts";
22
28
  export type { DisplayGraph, DisplayGraphEdge, DisplayGraphNode, RenderedGraph } from "./display-graph/display-graph.ts";
23
29
  export type { GraphRenderer } from "./display-graph/graph-renderer.ts";
@@ -76,6 +76,8 @@ export function discussOperations(discussions: Discussions): OperationDefinition
76
76
  options: optionalStringArray(input, "options"),
77
77
  optionsMode: optionsMode(input),
78
78
  optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
79
+ quizCorrectOptions: optionalStringArray(input, "correct_options") ?? optionalStringArray(input, "correctOptions"),
80
+ quizExplanation: optionalString(input, "explanation"),
79
81
  },
80
82
  eventContext(input),
81
83
  ),
@@ -90,6 +92,8 @@ export function discussOperations(discussions: Discussions): OperationDefinition
90
92
  options: optionalStringArray(input, "options"),
91
93
  optionsMode: optionsMode(input),
92
94
  optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
95
+ quizCorrectOptions: optionalStringArray(input, "correct_options") ?? optionalStringArray(input, "correctOptions"),
96
+ quizExplanation: optionalString(input, "explanation"),
93
97
  },
94
98
  eventContext(input),
95
99
  ),