dsh-diagnostic-tutor 0.1.0 → 0.2.0

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.
Files changed (126) hide show
  1. package/README.md +316 -79
  2. package/lib/api.js +364 -78
  3. package/lib/api.js.map +1 -1
  4. package/lib/block-types.js +25 -0
  5. package/lib/block-types.js.map +1 -0
  6. package/lib/client.js +9952 -821
  7. package/lib/client.js.map +1 -1
  8. package/lib/diagnostics.js +170 -0
  9. package/lib/diagnostics.js.map +1 -0
  10. package/lib/execution-key.js +91 -0
  11. package/lib/execution-key.js.map +1 -0
  12. package/lib/handoff.js +44 -1
  13. package/lib/handoff.js.map +1 -1
  14. package/lib/index.js +87 -51
  15. package/lib/index.js.map +1 -1
  16. package/lib/insights.js +82 -0
  17. package/lib/insights.js.map +1 -0
  18. package/lib/interactions.js +70 -0
  19. package/lib/interactions.js.map +1 -0
  20. package/lib/journal.js +122 -0
  21. package/lib/journal.js.map +1 -0
  22. package/lib/lesson-markdown.js +72 -0
  23. package/lib/lesson-markdown.js.map +1 -0
  24. package/lib/lesson.js +152 -3
  25. package/lib/lesson.js.map +1 -1
  26. package/lib/math.js +126 -0
  27. package/lib/math.js.map +1 -0
  28. package/lib/path-api.js +140 -0
  29. package/lib/path-api.js.map +1 -0
  30. package/lib/path-runtime.js +361 -0
  31. package/lib/path-runtime.js.map +1 -0
  32. package/lib/path-tools.js +107 -0
  33. package/lib/path-tools.js.map +1 -0
  34. package/lib/path.js +179 -0
  35. package/lib/path.js.map +1 -0
  36. package/lib/plan-tools.js +89 -0
  37. package/lib/plan-tools.js.map +1 -0
  38. package/lib/plan.js +82 -0
  39. package/lib/plan.js.map +1 -0
  40. package/lib/project-session.js +48 -0
  41. package/lib/project-session.js.map +1 -0
  42. package/lib/prompt.js +4 -19
  43. package/lib/prompt.js.map +1 -1
  44. package/lib/review-sync.js +51 -0
  45. package/lib/review-sync.js.map +1 -0
  46. package/lib/review-tools.js +115 -0
  47. package/lib/review-tools.js.map +1 -0
  48. package/lib/review.js +103 -0
  49. package/lib/review.js.map +1 -0
  50. package/lib/source-schema.js +24 -0
  51. package/lib/source-schema.js.map +1 -0
  52. package/lib/source-tools.js +61 -0
  53. package/lib/source-tools.js.map +1 -0
  54. package/lib/sources.js +78 -0
  55. package/lib/sources.js.map +1 -0
  56. package/lib/state.js +353 -47
  57. package/lib/state.js.map +1 -1
  58. package/lib/tools.js +620 -123
  59. package/lib/tools.js.map +1 -1
  60. package/lib/types/api.d.ts +10 -3
  61. package/lib/types/api.d.ts.map +1 -1
  62. package/lib/types/block-types.d.ts +12 -0
  63. package/lib/types/block-types.d.ts.map +1 -0
  64. package/lib/types/contract.d.ts +81 -7
  65. package/lib/types/contract.d.ts.map +1 -1
  66. package/lib/types/diagnostics.d.ts +95 -0
  67. package/lib/types/diagnostics.d.ts.map +1 -0
  68. package/lib/types/execution-key.d.ts +59 -0
  69. package/lib/types/execution-key.d.ts.map +1 -0
  70. package/lib/types/handoff.d.ts +2 -2
  71. package/lib/types/handoff.d.ts.map +1 -1
  72. package/lib/types/index.d.ts +19 -14
  73. package/lib/types/index.d.ts.map +1 -1
  74. package/lib/types/insights.d.ts +57 -0
  75. package/lib/types/insights.d.ts.map +1 -0
  76. package/lib/types/interactions.d.ts +23 -0
  77. package/lib/types/interactions.d.ts.map +1 -0
  78. package/lib/types/journal.d.ts +57 -0
  79. package/lib/types/journal.d.ts.map +1 -0
  80. package/lib/types/lesson-markdown.d.ts +11 -0
  81. package/lib/types/lesson-markdown.d.ts.map +1 -0
  82. package/lib/types/lesson.d.ts +697 -5
  83. package/lib/types/lesson.d.ts.map +1 -1
  84. package/lib/types/math.d.ts +50 -0
  85. package/lib/types/math.d.ts.map +1 -0
  86. package/lib/types/path-api.d.ts +5 -0
  87. package/lib/types/path-api.d.ts.map +1 -0
  88. package/lib/types/path-runtime.d.ts +1558 -0
  89. package/lib/types/path-runtime.d.ts.map +1 -0
  90. package/lib/types/path-tools.d.ts +4 -0
  91. package/lib/types/path-tools.d.ts.map +1 -0
  92. package/lib/types/path.d.ts +744 -0
  93. package/lib/types/path.d.ts.map +1 -0
  94. package/lib/types/plan-tools.d.ts +4 -0
  95. package/lib/types/plan-tools.d.ts.map +1 -0
  96. package/lib/types/plan.d.ts +53 -0
  97. package/lib/types/plan.d.ts.map +1 -0
  98. package/lib/types/project-session.d.ts +33 -0
  99. package/lib/types/project-session.d.ts.map +1 -0
  100. package/lib/types/prompt.d.ts.map +1 -1
  101. package/lib/types/review-sync.d.ts +17 -0
  102. package/lib/types/review-sync.d.ts.map +1 -0
  103. package/lib/types/review-tools.d.ts +4 -0
  104. package/lib/types/review-tools.d.ts.map +1 -0
  105. package/lib/types/review.d.ts +65 -0
  106. package/lib/types/review.d.ts.map +1 -0
  107. package/lib/types/source-schema.d.ts +31 -0
  108. package/lib/types/source-schema.d.ts.map +1 -0
  109. package/lib/types/source-tools.d.ts +4 -0
  110. package/lib/types/source-tools.d.ts.map +1 -0
  111. package/lib/types/sources.d.ts +33 -0
  112. package/lib/types/sources.d.ts.map +1 -0
  113. package/lib/types/state.d.ts +607 -23
  114. package/lib/types/state.d.ts.map +1 -1
  115. package/lib/types/tools.d.ts +3 -1
  116. package/lib/types/tools.d.ts.map +1 -1
  117. package/lib/types/udt.d.ts +20 -24
  118. package/lib/types/udt.d.ts.map +1 -1
  119. package/lib/types/version.d.ts +11 -0
  120. package/lib/types/version.d.ts.map +1 -0
  121. package/lib/types/vocabulary.d.ts +2 -2
  122. package/lib/udt.js +72 -19
  123. package/lib/udt.js.map +1 -1
  124. package/lib/version.js +26 -0
  125. package/lib/version.js.map +1 -0
  126. package/package.json +27 -22
package/lib/tools.js CHANGED
@@ -18,9 +18,37 @@
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools';
19
19
  import { MAX_NODES_PER_UPDATE, acceptNewNodes, recordEvidence, setNodeState } from './diagnosis.js';
20
20
  import { withLesson } from './handoff.js';
21
- import { BLOCK_SCHEMAS, BlockSchema, LESSON_ORIGINS, MAX_BLOCKS_PER_LESSON, MAX_BLOCKS_PER_UPDATE, LessonSchema, } from './lesson.js';
22
- import { newNode } from './state.js';
21
+ import { BLOCK_SCHEMAS, KnownBlockSchema, LESSON_ORIGINS, MAX_BLOCKS_PER_LESSON, MAX_BLOCKS_PER_UPDATE, MAX_LESSON_BYTES, LessonSchema, } from './lesson.js';
22
+ import { renderMathFragments, renderTexToMathML } from './math.js';
23
+ import { blocksMarkdown } from './lesson-markdown.js';
24
+ import { AnswerSubmissionSchema, recordBlockResponse } from './interactions.js';
25
+ import { newNode, PROJECT_BINDINGS, PROJECT_SELECTIONS } from './state.js';
23
26
  import { EVIDENCE_KINDS, NODE_RELATIONS, NODE_STATES, READINESS_OUTCOMES, targetRequirement, } from './vocabulary.js';
27
+ import { planningTools } from './path-tools.js';
28
+ import { sourceTools } from './source-tools.js';
29
+ import { planTools } from './plan-tools.js';
30
+ import { reviewTools } from './review-tools.js';
31
+ import { syncLessonFlashcards } from './review-sync.js';
32
+ import { activeAttempt } from './path.js';
33
+ import { requireAttempt, writeCourseTeaching } from './path-runtime.js';
34
+ /**
35
+ * The conversation a tool call belongs to.
36
+ *
37
+ * `udt_decide_next` is called by the model, which has no idea which
38
+ * conversation it is running in — but the harness does: every execution carries
39
+ * the agent on whose behalf it runs, and an agent is session-backed, so
40
+ * `agent.id` IS the session id. Without this the decision would be stored
41
+ * course-wide and one conversation's decision would overwrite another's, which
42
+ * is the bug this keying exists to fix.
43
+ *
44
+ * @param exec - the execution context the registry hands the tool.
45
+ * @returns the session id, or undefined when the call has no agent.
46
+ */
47
+ function sessionOf(exec) {
48
+ const agent = exec?.agent;
49
+ const id = agent?.id;
50
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
51
+ }
24
52
  /* -------------------------------------------------------------------------- */
25
53
  /* Helpers */
26
54
  /* -------------------------------------------------------------------------- */
@@ -63,16 +91,29 @@ function nowIso() {
63
91
  function violationMessage(violations) {
64
92
  return violations.map((entry) => `${entry.code}: ${entry.message}`).join(' | ');
65
93
  }
66
- /** Resolve the course a call refers to, defaulting to the learner's active goal. */
67
- function resolveCourseId(state, requested) {
68
- const courseId = requested ?? state.readLearner().activeCourseId;
94
+ /** Resolve an explicit shared course or this conversation's selected project. */
95
+ function resolveCourseId(state, requested, project) {
96
+ const courseId = requested ?? project.course?.id;
69
97
  if (!courseId) {
70
- throw new Error('no course given and the learner has no active goal; call udt_goal_create first or pass courseId');
98
+ throw new Error('no course given and this conversation has no selected project; call udt_goal_create for a new goal or pass courseId explicitly');
71
99
  }
72
100
  if (!state.readCourse(courseId))
73
101
  throw new Error(`no course "${courseId}"`);
74
102
  return courseId;
75
103
  }
104
+ /** A tool write cannot silently work outside the conversation's selected project. */
105
+ function requireProjectMatch(project, courseId) {
106
+ if (project.course !== undefined && project.course.id !== courseId) {
107
+ throw new Error(`this conversation selected course "${project.course.id}"; cannot write course "${courseId}" without selecting it first`);
108
+ }
109
+ }
110
+ const PROJECT_BINDING_PROPERTIES = {
111
+ projectAdopted: { type: 'boolean', required: true, description: 'Whether this write newly selected the project for this conversation.' },
112
+ projectBinding: {
113
+ type: 'string', required: true, enum: [...PROJECT_BINDINGS],
114
+ description: 'adopted | already-selected | not-selected | missing-session-id. Shared data can be stored without a conversation binding.',
115
+ },
116
+ };
76
117
  /**
77
118
  * Project the state snapshot onto the tool's canonical output.
78
119
  *
@@ -83,8 +124,9 @@ function resolveCourseId(state, requested) {
83
124
  * location and version are internal diagnostics and must not become text a
84
125
  * model can echo back to a learner.
85
126
  */
86
- function statusValue(state) {
127
+ function statusValue(state, sessionId) {
87
128
  const snapshot = state.snapshot();
129
+ const project = state.resolveProject(sessionId);
88
130
  const value = {
89
131
  domain: snapshot.domain,
90
132
  version: snapshot.version,
@@ -92,18 +134,30 @@ function statusValue(state) {
92
134
  courseCount: snapshot.courseCount,
93
135
  nodeCount: snapshot.nodeCount,
94
136
  courses: snapshot.courses.map((course) => ({ ...course })),
137
+ selection: project.selection,
138
+ sessionAvailable: project.sessionAvailable,
95
139
  };
96
- const { preferredLanguage, mode, activeCourseId } = snapshot.learner;
140
+ if (project.course) {
141
+ const path = state.readPath(project.course.id);
142
+ const run = sessionId ? state.readCourseRun(project.course.id, sessionId) : undefined;
143
+ if (path || run)
144
+ value.learningPathContext = JSON.stringify({
145
+ tool: 'udt_path_get', courseId: project.course.id, revision: path?.revision ?? 0,
146
+ briefRevision: path?.briefRevision ?? 0, acceptedVersion: path?.acceptedVersion ?? null,
147
+ activeAttempt: (() => { const entry = activeAttempt(run); return entry ? { id: entry.id, itemId: entry.itemId, version: entry.version, nodeId: entry.nodeId, status: entry.status } : null; })(),
148
+ });
149
+ }
150
+ const { preferredLanguage, mode } = snapshot.learner;
97
151
  if (preferredLanguage !== undefined)
98
152
  value.preferredLanguage = preferredLanguage;
99
153
  if (mode !== undefined)
100
154
  value.mode = mode;
101
- if (activeCourseId !== undefined)
102
- value.activeCourseId = activeCourseId;
155
+ if (project.course !== undefined)
156
+ value.courseId = project.course.id;
103
157
  // The focus is how the tutor learns which node the learner asked to work on.
104
158
  // It is reported here rather than in a tool of its own: it is one fact about
105
159
  // the runtime, not a separate concern.
106
- const focused = state.activeFocus();
160
+ const focused = sessionId === undefined ? undefined : state.activeFocus(sessionId);
107
161
  if (focused !== undefined) {
108
162
  value.focus = {
109
163
  courseId: focused.course.id,
@@ -115,8 +169,17 @@ function statusValue(state) {
115
169
  }
116
170
  // A recommendation the learner has not acted on yet. Reported so the tutor
117
171
  // can see its own last decision instead of re-deciding from scratch.
118
- const courseId = focused?.course.id ?? state.readLearner().activeCourseId;
119
- const pending = courseId === undefined ? undefined : state.latestNextStep(courseId);
172
+ //
173
+ // Read from the focus RECORD, not from the active focus: a decision that
174
+ // names a target ends the focus, so keying this off `activeFocus` would hide
175
+ // the recommendation in exactly the case it matters most — the tutor having
176
+ // just said where to go next.
177
+ const focusRecord = sessionId === undefined || project.course === undefined
178
+ ? undefined
179
+ : state.readFocus(sessionId, project.course.id);
180
+ const pending = sessionId === undefined || focusRecord?.nextStepId === undefined
181
+ ? undefined
182
+ : state.readNextStep(sessionId, focusRecord.nextStepId);
120
183
  if (pending !== undefined) {
121
184
  const from = state.readNode(pending.fromNodeId);
122
185
  const target = pending.targetNodeId === undefined ? undefined : state.readNode(pending.targetNodeId);
@@ -141,6 +204,11 @@ function udtStatusTool(state) {
141
204
  'initialized, the learner profile, the registered learning goals and how many diagnosis-map nodes exist. ' +
142
205
  'Call this before starting a session to see whether there is existing state to continue from, and after ' +
143
206
  'changing state to confirm what was stored. ' +
207
+ '`courses`, counts and preferences are learner-shared; `selection`, `courseId`, focus and next step belong only to this conversation. ' +
208
+ '`none` means no project selected; `new` means the learner asked to start a new project: use udt_goal_create for that goal, ' +
209
+ 'and do not reuse a historical project as the current one. `project` names the selected course. ' +
210
+ 'If sessionAvailable is false, conversation binding is unavailable. ' +
211
+ 'If learningPathContext exists, read udt_path_get to continue goal setup, assessment, route or active planned teaching. ' +
144
212
  'Read-only: it records no teaching decision.',
145
213
  parameters: {},
146
214
  output: {
@@ -171,12 +239,15 @@ function udtStatusTool(state) {
171
239
  },
172
240
  },
173
241
  },
242
+ learningPathContext: { type: 'string', description: 'Stored curriculum/attempt context as JSON. Read udt_path_get before planned teaching, even when no node focus exists.' },
174
243
  preferredLanguage: { type: 'string', description: 'Learner preference, when recorded.' },
175
244
  mode: {
176
245
  type: 'string',
177
246
  description: 'Teaching mode preference: auto | zero-base | standard | advanced.',
178
247
  },
179
- activeCourseId: { type: 'string', description: 'The learning goal currently in focus.' },
248
+ selection: { type: 'string', required: true, enum: [...PROJECT_SELECTIONS], description: 'This conversation\'s project selection: none | new | project.' },
249
+ sessionAvailable: { type: 'boolean', required: true, description: 'Whether this tool call has a reliable conversation id.' },
250
+ courseId: { type: 'string', description: 'Only present for selection=project: this conversation\'s selected course.' },
180
251
  focus: {
181
252
  type: 'object',
182
253
  additionalProperties: false,
@@ -214,11 +285,13 @@ function udtStatusTool(state) {
214
285
  render: (_args, value) => [
215
286
  {
216
287
  type: 'text',
217
- text: `Learning runtime: domain "${value.domain}" v${value.version}, ${value.courseCount} goal(s), ${value.nodeCount} map node(s), ${value.initialized ? 'learner state present' : 'first run (no learner state yet)'}.`,
288
+ // Native tool messages carry render content, not the execute value.
289
+ // The tutor must see the conversation selection and usable ids.
290
+ text: JSON.stringify(value),
218
291
  },
219
292
  ],
220
293
  },
221
- execute: () => Promise.resolve(statusValue(state)),
294
+ execute: (_args, exec) => Promise.resolve(statusValue(state, sessionOf(exec))),
222
295
  presentCall: () => ({
223
296
  card: 'generic',
224
297
  title: 'Read learning-runtime status',
@@ -241,6 +314,10 @@ function udtStatusTool(state) {
241
314
  * active. Pausing is reversible and never touches the other goal's map.
242
315
  */
243
316
  function udtGoalCreateTool(state) {
317
+ // ID allocation must see the preceding creation's course, and activation
318
+ // must finish before another creation pauses it. A failed write releases
319
+ // the queue so later calls can still create goals.
320
+ let goalWrites = Promise.resolve();
244
321
  return defineTool({
245
322
  name: 'udt_goal_create',
246
323
  description: 'Record a learning goal and start its diagnosis map. ' +
@@ -248,7 +325,10 @@ function udtGoalCreateTool(state) {
248
325
  'This creates ONLY the goal and the single root node of the map — it never generates a course outline, ' +
249
326
  'syllabus or list of topics. Further nodes are added later, one at a time, as diagnosis reveals ' +
250
327
  'prerequisites or blockers (see udt_map_update). ' +
251
- 'Creating a goal focuses it and pauses any previously active goal.',
328
+ 'Creating a goal marks it active in the shared library and pauses any previously active goal. ' +
329
+ 'It selects this conversation\'s project only for none/new selection; an existing project choice is preserved. ' +
330
+ 'Read projectBinding in the result to confirm whether the conversation is bound. ' +
331
+ 'Then read udt_path_get. Interactively clarify purpose, observable target outcomes, time horizon and budget before initial diagnosis and a proposed course route. Planning has its own tools; never put an undiagnosed syllabus into the diagnosis map.',
252
332
  parameters: {
253
333
  title: {
254
334
  type: 'string',
@@ -278,54 +358,65 @@ function udtGoalCreateTool(state) {
278
358
  description: 'Goals that were active and are now paused.',
279
359
  items: { type: 'string' },
280
360
  },
361
+ ...PROJECT_BINDING_PROPERTIES,
281
362
  },
282
363
  },
283
364
  render: (_args, value) => [
284
365
  {
285
366
  type: 'text',
286
- text: `Goal recorded: "${value.title}" (${value.courseId}). Map starts with its root node only.`,
367
+ text: JSON.stringify(value),
287
368
  },
288
369
  ],
289
370
  },
290
- execute: async (args) => {
371
+ execute: async (args, exec) => {
291
372
  const { title, goal } = args;
292
373
  const now = nowIso();
293
- const taken = new Set(state.listCourses().map((course) => course.id));
294
- const courseId = allocateId(slugify(title), taken);
295
- const previouslyActive = state
296
- .listCourses()
297
- .filter((course) => course.status === 'active' && course.id !== courseId)
298
- .map((course) => course.id);
299
- await state.writeCourse({
300
- id: courseId,
301
- title,
302
- goal,
303
- status: 'active',
304
- createdAt: now,
305
- updatedAt: now,
306
- });
307
- // Focus it (this pauses the others) and point the learner at it.
308
- await state.activateCourse(courseId, now);
309
- const goalNode = newNode({
310
- id: `${courseId}:goal`,
311
- courseId,
312
- title,
313
- relation: 'goal',
314
- // The goal node carries the learner's statement as evidence, yet stays
315
- // `unconfirmed`: a goal is the frame of the map, not a mastery claim.
316
- evidence: [{ kind: 'goal-stated', at: now, note: goal }],
317
- now,
374
+ const sessionId = sessionOf(exec);
375
+ const project = state.resolveProject(sessionId);
376
+ // Capture the conversation choice before waiting: a choice made while
377
+ // this call is queued must invalidate its eventual adoption too.
378
+ const result = goalWrites.then(async () => {
379
+ const taken = new Set(state.listCourses().map((course) => course.id));
380
+ const courseId = allocateId(slugify(title), taken);
381
+ const previouslyActive = state
382
+ .listCourses()
383
+ .filter((course) => course.status === 'active' && course.id !== courseId)
384
+ .map((course) => course.id);
385
+ await state.writeCourse({
386
+ id: courseId,
387
+ title,
388
+ goal,
389
+ status: 'active',
390
+ createdAt: now,
391
+ updatedAt: now,
392
+ });
393
+ // Focus it (this pauses the others) and point the learner at it.
394
+ await state.activateCourse(courseId, now);
395
+ const goalNode = newNode({
396
+ id: `${courseId}:goal`,
397
+ courseId,
398
+ title,
399
+ relation: 'goal',
400
+ // The goal node carries the learner's statement as evidence, yet stays
401
+ // `unconfirmed`: a goal is the frame of the map, not a mastery claim.
402
+ evidence: [{ kind: 'goal-stated', at: now, note: goal }],
403
+ now,
404
+ });
405
+ await state.writeNode(goalNode);
406
+ const binding = await state.adoptProject(sessionId, courseId, now, project, true);
407
+ return {
408
+ courseId,
409
+ title,
410
+ goal,
411
+ status: 'active',
412
+ createdAt: now,
413
+ goalNodeId: goalNode.id,
414
+ pausedCourseIds: previouslyActive,
415
+ ...binding,
416
+ };
318
417
  });
319
- await state.writeNode(goalNode);
320
- return {
321
- courseId,
322
- title,
323
- goal,
324
- status: 'active',
325
- createdAt: now,
326
- goalNodeId: goalNode.id,
327
- pausedCourseIds: previouslyActive,
328
- };
418
+ goalWrites = result.catch(() => undefined);
419
+ return result;
329
420
  },
330
421
  presentCall: (args) => ({
331
422
  card: 'generic',
@@ -345,9 +436,9 @@ function udtMapGetTool(state) {
345
436
  'recorded against it. Use this to decide the next diagnostic step. ' +
346
437
  'A node state is one of: unconfirmed (no evidence yet), explained, practiced, checked, weak, blocked, ' +
347
438
  'confirmed. Nothing here is a progress percentage or a score. ' +
348
- 'Omit courseId to read the learner\'s active goal.',
439
+ 'Omit courseId to read this conversation\'s selected project. An explicit courseId reads shared data without selecting it.',
349
440
  parameters: {
350
- courseId: { type: 'string', description: 'Goal to read; defaults to the active goal.' },
441
+ courseId: { type: 'string', description: 'Goal to read; defaults only to this conversation\'s selected project.' },
351
442
  },
352
443
  output: {
353
444
  schema: {
@@ -400,14 +491,12 @@ function udtMapGetTool(state) {
400
491
  render: (_args, value) => [
401
492
  {
402
493
  type: 'text',
403
- text: `Map for "${value.title}": ${value.nodes.length} node(s) — ${value.nodes
404
- .map((node) => `${node.title} [${node.state}]`)
405
- .join(', ')}`,
494
+ text: JSON.stringify(value),
406
495
  },
407
496
  ],
408
497
  },
409
- execute: (args) => {
410
- const courseId = resolveCourseId(state, args.courseId);
498
+ execute: (args, exec) => {
499
+ const courseId = resolveCourseId(state, args.courseId, state.resolveProject(sessionOf(exec)));
411
500
  const map = state.readMap(courseId);
412
501
  if (!map)
413
502
  throw new Error(`no course "${courseId}"`);
@@ -451,11 +540,14 @@ function udtMapUpdateTool(state) {
451
540
  '(part-of | prerequisite | related | goal). Nodes are born "unconfirmed" unless you pass `state`.\n' +
452
541
  '• "set-state" — move one node to a new state. "confirmed" is REFUSED unless the node already carries a ' +
453
542
  'check or transfer evidence entry; explanation or practice alone never confirms.\n' +
454
- '• "add-evidence" — append one observation to a node. Record what happened, not how good it was.\n' +
543
+ '• "add-evidence" — append one observation to a node. Record what happened, not how good it was. ' +
544
+ 'During an active planned lesson, supply its explicit attemptId and bound nodeId; the runtime stamps the conversation/attempt provenance. Stale attempts are refused.\n' +
455
545
  'A single call may add at most 8 nodes and a map holds at most 40. Never use this to plant a whole ' +
456
- 'syllabus: add only what the current diagnosis justifies.',
546
+ 'syllabus: add only what the current diagnosis justifies. ' +
547
+ 'An explicit courseId/nodeId write may select the project only when this conversation has no selection. ' +
548
+ 'Historical writes never replace new/project selection; check projectBinding in the result.',
457
549
  parameters: {
458
- courseId: { type: 'string', description: 'Goal to update; defaults to the active goal.' },
550
+ courseId: { type: 'string', description: 'Goal to update; defaults to this conversation\'s selected project, or the explicitly named node\'s course.' },
459
551
  op: {
460
552
  type: 'string',
461
553
  required: true,
@@ -476,7 +568,8 @@ function udtMapUpdateTool(state) {
476
568
  },
477
569
  },
478
570
  },
479
- nodeId: { type: 'string', description: 'For op=set-state / add-evidence: the target node.' },
571
+ nodeId: { type: 'string', description: 'For op=set-state / add-evidence: the target node. Planned evidence must use the active attempt\'s bound node.' },
572
+ attemptId: { type: 'string', description: 'For op=add-evidence during planned learning: required active attemptId from udt_path_get. Provenance is stamped by the runtime.' },
480
573
  state: { type: 'string', enum: [...NODE_STATES], description: 'For op=set-state: the new state.' },
481
574
  evidenceKind: {
482
575
  type: 'string',
@@ -508,20 +601,24 @@ function udtMapUpdateTool(state) {
508
601
  state: { type: 'string', description: 'The node\'s state after this call.' },
509
602
  evidenceCount: { type: 'integer', description: 'Evidence entries on the node after this call.' },
510
603
  nodeCount: { type: 'integer', required: true, description: 'Total nodes in the course after this call.' },
604
+ ...PROJECT_BINDING_PROPERTIES,
511
605
  },
512
606
  },
513
607
  render: (_args, value) => [
514
608
  {
515
609
  type: 'text',
516
- text: value.op === 'add-nodes'
517
- ? `Added ${value.addedNodeIds.length} node(s); the map now has ${value.nodeCount}.`
518
- : `Node ${value.nodeId ?? ''} is now "${value.state ?? ''}" with ${value.evidenceCount ?? 0} evidence entr(ies).`,
610
+ text: JSON.stringify(value),
519
611
  },
520
612
  ],
521
613
  },
522
- execute: async (args) => {
614
+ execute: async (args, exec) => {
523
615
  const input = args;
524
- const courseId = resolveCourseId(state, input.courseId);
616
+ const sessionId = sessionOf(exec);
617
+ const project = state.resolveProject(sessionId);
618
+ const nodeCourseId = input.op === 'add-nodes' || input.nodeId === undefined
619
+ ? undefined : state.readNode(input.nodeId)?.courseId;
620
+ const courseId = resolveCourseId(state, input.courseId ?? nodeCourseId, project);
621
+ requireProjectMatch(project, courseId);
525
622
  const now = input.at ?? nowIso();
526
623
  const existing = state.listNodes(courseId);
527
624
  if (input.op === 'add-nodes') {
@@ -562,6 +659,7 @@ function udtMapUpdateTool(state) {
562
659
  op: input.op,
563
660
  addedNodeIds: structured.map((node) => node.id),
564
661
  nodeCount: existing.length + structured.length,
662
+ ...await state.adoptProject(sessionId, courseId, now, project),
565
663
  };
566
664
  }
567
665
  // Both remaining ops target exactly one node.
@@ -591,6 +689,7 @@ function udtMapUpdateTool(state) {
591
689
  state: stored.state,
592
690
  evidenceCount: stored.evidence.length,
593
691
  nodeCount: existing.length,
692
+ ...await state.adoptProject(sessionId, courseId, now, project),
594
693
  };
595
694
  }
596
695
  // op === 'add-evidence'
@@ -602,13 +701,23 @@ function udtMapUpdateTool(state) {
602
701
  ...(input.evidenceNote === undefined ? {} : { note: input.evidenceNote }),
603
702
  ...(input.readiness === undefined ? {} : { readiness: input.readiness }),
604
703
  };
605
- const result = recordEvidence(node, evidence, {
606
- now,
607
- ...(input.state === undefined ? {} : { state: input.state }),
704
+ const stored = await state.mutateNode(courseId, nodeId, (current) => {
705
+ requireProjectMatch(state.resolveProject(sessionId), courseId);
706
+ const planned = sessionId ? activeAttempt(state.readCourseRun(courseId, sessionId)) : undefined;
707
+ if (planned && ['requested', 'working'].includes(planned.status) && !input.attemptId)
708
+ throw new Error('planned-evidence-needs-attemptId: read udt_path_get and bind its diagnosed node');
709
+ const attempt = input.attemptId ? requireAttempt(state, courseId, sessionId, input.attemptId) : undefined;
710
+ if (attempt && attempt.nodeId !== current.id)
711
+ throw new Error('bind-the-node-before-recording-planned-evidence');
712
+ const trusted = attempt ? { ...evidence, sessionId: sessionId, attemptId: attempt.id } : evidence;
713
+ const result = recordEvidence(current, trusted, {
714
+ now,
715
+ ...(input.state === undefined ? {} : { state: input.state }),
716
+ });
717
+ if (!result.ok)
718
+ throw new Error(violationMessage(result.violations));
719
+ return result.node;
608
720
  });
609
- if (!result.ok)
610
- throw new Error(violationMessage(result.violations));
611
- const stored = await state.writeNode(result.node);
612
721
  return {
613
722
  courseId,
614
723
  op: input.op,
@@ -617,6 +726,7 @@ function udtMapUpdateTool(state) {
617
726
  state: stored.state,
618
727
  evidenceCount: stored.evidence.length,
619
728
  nodeCount: existing.length,
729
+ ...await state.adoptProject(sessionId, courseId, now, project),
620
730
  };
621
731
  },
622
732
  presentCall: (args) => ({
@@ -645,6 +755,17 @@ const BLOCK_PARAM_SPEC = {
645
755
  additionalProperties: false,
646
756
  properties: {
647
757
  id: { type: 'string', required: true },
758
+ sourceRefs: {
759
+ type: 'array',
760
+ items: {
761
+ type: 'object',
762
+ additionalProperties: false,
763
+ properties: {
764
+ sourceId: { type: 'string', required: true },
765
+ locator: { type: 'string', required: true },
766
+ },
767
+ },
768
+ },
648
769
  type: { type: 'string', required: true, enum: ['text'] },
649
770
  content: {
650
771
  type: 'object',
@@ -658,6 +779,17 @@ const BLOCK_PARAM_SPEC = {
658
779
  additionalProperties: false,
659
780
  properties: {
660
781
  id: { type: 'string', required: true },
782
+ sourceRefs: {
783
+ type: 'array',
784
+ items: {
785
+ type: 'object',
786
+ additionalProperties: false,
787
+ properties: {
788
+ sourceId: { type: 'string', required: true },
789
+ locator: { type: 'string', required: true },
790
+ },
791
+ },
792
+ },
661
793
  type: { type: 'string', required: true, enum: ['example'] },
662
794
  content: {
663
795
  type: 'object',
@@ -675,6 +807,17 @@ const BLOCK_PARAM_SPEC = {
675
807
  additionalProperties: false,
676
808
  properties: {
677
809
  id: { type: 'string', required: true },
810
+ sourceRefs: {
811
+ type: 'array',
812
+ items: {
813
+ type: 'object',
814
+ additionalProperties: false,
815
+ properties: {
816
+ sourceId: { type: 'string', required: true },
817
+ locator: { type: 'string', required: true },
818
+ },
819
+ },
820
+ },
678
821
  type: { type: 'string', required: true, enum: ['diagram'] },
679
822
  content: {
680
823
  type: 'object',
@@ -692,6 +835,17 @@ const BLOCK_PARAM_SPEC = {
692
835
  additionalProperties: false,
693
836
  properties: {
694
837
  id: { type: 'string', required: true },
838
+ sourceRefs: {
839
+ type: 'array',
840
+ items: {
841
+ type: 'object',
842
+ additionalProperties: false,
843
+ properties: {
844
+ sourceId: { type: 'string', required: true },
845
+ locator: { type: 'string', required: true },
846
+ },
847
+ },
848
+ },
695
849
  type: { type: 'string', required: true, enum: ['check'] },
696
850
  content: {
697
851
  type: 'object',
@@ -704,6 +858,200 @@ const BLOCK_PARAM_SPEC = {
704
858
  },
705
859
  },
706
860
  },
861
+ {
862
+ type: 'object',
863
+ additionalProperties: false,
864
+ properties: {
865
+ id: { type: 'string', required: true },
866
+ sourceRefs: {
867
+ type: 'array',
868
+ items: {
869
+ type: 'object',
870
+ additionalProperties: false,
871
+ properties: {
872
+ sourceId: { type: 'string', required: true },
873
+ locator: { type: 'string', required: true },
874
+ },
875
+ },
876
+ },
877
+ type: { type: 'string', required: true, enum: ['formula'] },
878
+ content: {
879
+ type: 'object',
880
+ additionalProperties: false,
881
+ properties: {
882
+ tex: { type: 'string', required: true, description: 'TeX source without delimiters.' },
883
+ display: { type: 'boolean' },
884
+ caption: { type: 'string' },
885
+ },
886
+ },
887
+ },
888
+ },
889
+ {
890
+ type: 'object',
891
+ additionalProperties: false,
892
+ properties: {
893
+ id: { type: 'string', required: true },
894
+ sourceRefs: {
895
+ type: 'array',
896
+ items: {
897
+ type: 'object',
898
+ additionalProperties: false,
899
+ properties: {
900
+ sourceId: { type: 'string', required: true },
901
+ locator: { type: 'string', required: true },
902
+ },
903
+ },
904
+ },
905
+ type: { type: 'string', required: true, enum: ['code'] },
906
+ content: {
907
+ type: 'object',
908
+ additionalProperties: false,
909
+ properties: {
910
+ language: { type: 'string' },
911
+ source: { type: 'string', required: true },
912
+ caption: { type: 'string' },
913
+ },
914
+ },
915
+ },
916
+ },
917
+ {
918
+ type: 'object',
919
+ additionalProperties: false,
920
+ properties: {
921
+ id: { type: 'string', required: true },
922
+ sourceRefs: {
923
+ type: 'array',
924
+ items: {
925
+ type: 'object',
926
+ additionalProperties: false,
927
+ properties: {
928
+ sourceId: { type: 'string', required: true },
929
+ locator: { type: 'string', required: true },
930
+ },
931
+ },
932
+ },
933
+ type: { type: 'string', required: true, enum: ['quiz'] },
934
+ content: {
935
+ type: 'object',
936
+ additionalProperties: false,
937
+ properties: {
938
+ title: { type: 'string' },
939
+ questions: {
940
+ type: 'array', required: true,
941
+ items: {
942
+ type: 'object', additionalProperties: false,
943
+ properties: {
944
+ id: { type: 'string', required: true },
945
+ sourceRefs: {
946
+ type: 'array',
947
+ items: {
948
+ type: 'object',
949
+ additionalProperties: false,
950
+ properties: {
951
+ sourceId: { type: 'string', required: true },
952
+ locator: { type: 'string', required: true },
953
+ },
954
+ },
955
+ },
956
+ kind: { type: 'string', required: true, enum: ['mcq', 'short'] },
957
+ prompt: { type: 'string', required: true },
958
+ options: { type: 'array', items: { type: 'string' } },
959
+ answer: { type: 'string' },
960
+ rubric: { type: 'string' },
961
+ explanation: { type: 'string' },
962
+ },
963
+ },
964
+ },
965
+ },
966
+ },
967
+ },
968
+ },
969
+ {
970
+ type: 'object',
971
+ additionalProperties: false,
972
+ properties: {
973
+ id: { type: 'string', required: true },
974
+ sourceRefs: {
975
+ type: 'array',
976
+ items: {
977
+ type: 'object',
978
+ additionalProperties: false,
979
+ properties: {
980
+ sourceId: { type: 'string', required: true },
981
+ locator: { type: 'string', required: true },
982
+ },
983
+ },
984
+ },
985
+ type: { type: 'string', required: true, enum: ['flashcard'] },
986
+ content: {
987
+ type: 'object',
988
+ additionalProperties: false,
989
+ properties: {
990
+ front: { type: 'string', required: true },
991
+ back: { type: 'string', required: true },
992
+ tags: { type: 'array', items: { type: 'string' } },
993
+ },
994
+ },
995
+ },
996
+ },
997
+ {
998
+ type: 'object',
999
+ additionalProperties: false,
1000
+ properties: {
1001
+ id: { type: 'string', required: true },
1002
+ sourceRefs: {
1003
+ type: 'array',
1004
+ items: {
1005
+ type: 'object',
1006
+ additionalProperties: false,
1007
+ properties: {
1008
+ sourceId: { type: 'string', required: true },
1009
+ locator: { type: 'string', required: true },
1010
+ },
1011
+ },
1012
+ },
1013
+ type: { type: 'string', required: true, enum: ['resource'] },
1014
+ content: {
1015
+ type: 'object',
1016
+ additionalProperties: false,
1017
+ properties: {
1018
+ title: { type: 'string', required: true },
1019
+ sourceId: { type: 'string' },
1020
+ locator: { type: 'string' },
1021
+ url: { type: 'string' },
1022
+ note: { type: 'string' },
1023
+ },
1024
+ },
1025
+ },
1026
+ },
1027
+ {
1028
+ type: 'object',
1029
+ additionalProperties: false,
1030
+ properties: {
1031
+ id: { type: 'string', required: true },
1032
+ sourceRefs: {
1033
+ type: 'array',
1034
+ items: {
1035
+ type: 'object',
1036
+ additionalProperties: false,
1037
+ properties: {
1038
+ sourceId: { type: 'string', required: true },
1039
+ locator: { type: 'string', required: true },
1040
+ },
1041
+ },
1042
+ },
1043
+ type: { type: 'string', required: true, enum: ['comparison'] },
1044
+ content: {
1045
+ type: 'object',
1046
+ additionalProperties: false,
1047
+ properties: {
1048
+ columns: { type: 'array', required: true, items: { type: 'string' } },
1049
+ rows: { type: 'array', required: true, items: { type: 'array', items: { type: 'string' } } },
1050
+ caption: { type: 'string' },
1051
+ },
1052
+ },
1053
+ },
1054
+ },
707
1055
  ],
708
1056
  };
709
1057
  /**
@@ -718,6 +1066,54 @@ const BLOCK_PARAM_SPEC = {
718
1066
  * and the next unit follows. `replace` exists for revising a unit that was
719
1067
  * wrong, not for regenerating a course.
720
1068
  */
1069
+ /**
1070
+ * Attach host-rendered MathML to the two block types that carry formulas.
1071
+ *
1072
+ * This runs before `BlockSchema.parse`, so an invalid formula block is refused
1073
+ * with the block index, while a bad inline expression in a text block is left
1074
+ * as source for the client's styled fallback.
1075
+ */
1076
+ function prepareMath(candidate, index) {
1077
+ if (candidate === null || typeof candidate !== 'object')
1078
+ return candidate;
1079
+ const block = candidate;
1080
+ if (block.type === 'text' && block.content !== null && typeof block.content === 'object') {
1081
+ const content = block.content;
1082
+ if (typeof content.md !== 'string')
1083
+ return candidate;
1084
+ const math = renderMathFragments(content.md);
1085
+ const { math: _old, ...rest } = content;
1086
+ return { ...block, content: math.length === 0 ? rest : { ...rest, math } };
1087
+ }
1088
+ if (block.type === 'formula' && block.content !== null && typeof block.content === 'object') {
1089
+ const content = block.content;
1090
+ if (typeof content.tex !== 'string')
1091
+ return candidate;
1092
+ const mathml = renderTexToMathML(content.tex, content.display === true);
1093
+ return { ...block, content: { ...content, mathml } };
1094
+ }
1095
+ return candidate;
1096
+ }
1097
+ /**
1098
+ * Source references are cross-table facts: the block schema can only check
1099
+ * their shape, so the write boundary checks that every cited source exists.
1100
+ * A dangling citation is refused rather than rendered as a broken footnote.
1101
+ */
1102
+ function assertSources(state, block, index) {
1103
+ const references = block.sourceRefs ?? [];
1104
+ for (const reference of references) {
1105
+ const source = state.readSource(reference.sourceId);
1106
+ if (source === undefined || source.status !== 'ready') {
1107
+ throw new Error(`block[${index}] references unknown source "${reference.sourceId}" — add it with udt_source_add first`);
1108
+ }
1109
+ }
1110
+ if (block.type === 'resource' && block.content.sourceId !== undefined) {
1111
+ const source = state.readSource(block.content.sourceId);
1112
+ if (source === undefined || source.status !== 'ready') {
1113
+ throw new Error(`block[${index}] references unknown source "${block.content.sourceId}" — add it with udt_source_add first`);
1114
+ }
1115
+ }
1116
+ }
721
1117
  function udtLessonUpdateTool(state) {
722
1118
  return defineTool({
723
1119
  name: 'udt_lesson_update',
@@ -725,19 +1121,26 @@ function udtLessonUpdateTool(state) {
725
1121
  'The blocks appear in the panel beside the diagnosis map, so the learner reads them while ' +
726
1122
  'answering in the chat — write for that surface, not as a chat message.\n' +
727
1123
  'Block types: **text** {md}, **example** {title, steps[], takeaway?}, ' +
728
- '**diagram** {format: ascii|mermaid, spec, caption?}, **check** {prompt, expect?, hint?}.\n' +
1124
+ '**diagram** {format: ascii|mermaid, spec, caption?}, **check** {prompt, expect?, hint?}, ' +
1125
+ '**formula** {tex, display?, caption?}, **code** {language?, source, caption?}, ' +
1126
+ '**quiz** {title?, questions[]}, **flashcard** {front, back, tags?}, ' +
1127
+ '**resource** {title, sourceId?, locator?, url?, note?}, **comparison** {columns[], rows[][], caption?}.\n' +
729
1128
  `At most ${MAX_BLOCKS_PER_UPDATE} blocks per call and ${MAX_BLOCKS_PER_LESSON} per lesson; a teaching unit is a few ` +
730
1129
  'blocks, not a chapter.\n' +
731
1130
  '**Write your first unit as soon as you know it** rather than composing the whole thing first: ' +
732
1131
  'the blocks appear in the learner\'s surface the moment you send them, so a short first unit ' +
733
1132
  'turns a silent wait into visible progress. Send more later with append.\n' +
734
1133
  'Default mode "append" adds to the current lesson; use "replace" only to correct what is there.\n' +
735
- 'A check block has no input box: the surface displays the question and the learner answers in the chat.',
1134
+ 'A check block has no input box: the surface displays the question and the learner answers in the chat. ' +
1135
+ 'An explicit nodeId write may select its project only when this conversation has no selection. ' +
1136
+ 'It never replaces new/project selection; check projectBinding in the result. ' +
1137
+ 'For planned teaching, read udt_path_get, explicitly bind its node with udt_course_run_update and supply active attemptId. Content is saved per conversation/attempt with history; stale attempts are rejected.',
736
1138
  parameters: {
737
1139
  nodeId: {
738
1140
  type: 'string',
739
- description: 'Node to write for. Omit to use the learner\'s current focus (see udt_status).',
1141
+ description: 'Node to write for. Omit to use this conversation\'s current focus (see udt_status).',
740
1142
  },
1143
+ attemptId: { type: 'string', description: 'Required for planned teaching: active attemptId from udt_path_get.' },
741
1144
  title: { type: 'string', description: 'Lesson title; defaults to the node title.' },
742
1145
  mode: {
743
1146
  type: 'string',
@@ -762,34 +1165,40 @@ function udtLessonUpdateTool(state) {
762
1165
  mode: { type: 'string', required: true },
763
1166
  blockCount: { type: 'integer', required: true, description: 'Blocks in the lesson after this call.' },
764
1167
  addedCount: { type: 'integer', required: true },
1168
+ reviewCardCount: { type: 'integer', required: true, description: 'Flashcards now linked to review cards.' },
1169
+ markdown: { type: 'string', required: true, description: 'Plain Markdown projection for harnesses without the panel.' },
765
1170
  origin: { type: 'string', required: true },
1171
+ ...PROJECT_BINDING_PROPERTIES,
766
1172
  },
767
1173
  },
768
1174
  render: (_args, value) => [
769
1175
  {
770
1176
  type: 'text',
771
- text: `Learning surface updated: ${value.addedCount} block(s) ${value.mode === 'replace' ? 'replacing' : 'added to'} the lesson for "${value.nodeId}" (now ${value.blockCount}).`,
1177
+ text: JSON.stringify(value),
772
1178
  },
773
1179
  ],
774
1180
  },
775
- execute: async (args) => {
1181
+ execute: async (args, exec) => {
776
1182
  const input = args;
777
1183
  const mode = input.mode ?? 'append';
778
- const focused = state.activeFocus();
779
- const courseId = focused?.course.id ?? state.readLearner().activeCourseId;
780
- if (courseId === undefined) {
781
- throw new Error('no active course; record a goal and start learning on a node first');
782
- }
783
- const nodeId = input.nodeId ?? focused?.node.id;
1184
+ const sessionId = sessionOf(exec);
1185
+ const project = state.resolveProject(sessionId);
1186
+ const focused = sessionId === undefined ? undefined : state.activeFocus(sessionId);
1187
+ const planned = sessionId && project.course ? activeAttempt(state.readCourseRun(project.course.id, sessionId)) : undefined;
1188
+ if (planned && ['requested', 'working'].includes(planned.status) && !input.attemptId)
1189
+ throw new Error('planned-lesson-needs-attemptId: read udt_path_get and bind its diagnosed node');
1190
+ const attempt = input.attemptId && project.course ? requireAttempt(state, project.course.id, sessionId, input.attemptId) : undefined;
1191
+ if (input.attemptId && !attempt)
1192
+ throw new Error('no-active-course-attempt');
1193
+ const nodeId = input.nodeId ?? attempt?.nodeId ?? focused?.node.id;
784
1194
  if (nodeId === undefined) {
785
1195
  throw new Error('no nodeId given and no learning focus is active; call udt_status to see the focus');
786
1196
  }
787
1197
  const node = state.readNode(nodeId);
788
1198
  if (node === undefined)
789
1199
  throw new Error(`no node "${nodeId}"`);
790
- if (node.courseId !== courseId) {
791
- throw new Error(`node "${nodeId}" belongs to course "${node.courseId}", not "${courseId}"`);
792
- }
1200
+ const courseId = resolveCourseId(state, node.courseId, project);
1201
+ requireProjectMatch(project, courseId);
793
1202
  if (!Array.isArray(input.blocks) || input.blocks.length === 0) {
794
1203
  throw new Error('`blocks` must be a non-empty array');
795
1204
  }
@@ -798,9 +1207,11 @@ function udtLessonUpdateTool(state) {
798
1207
  'Write one teaching unit, not a chapter.');
799
1208
  }
800
1209
  // Strict, and it names the offending block: a model that sent one bad
801
- // block should not have to guess which.
802
- const parsed = input.blocks.map((candidate, index) => {
803
- const result = BlockSchema.safeParse(candidate);
1210
+ // block should not have to guess which. Host-owned MathML is generated
1211
+ // here so the model never supplies markup the client would trust.
1212
+ const prepared = input.blocks.map((candidate, index) => prepareMath(candidate, index));
1213
+ const parsed = prepared.map((candidate, index) => {
1214
+ const result = KnownBlockSchema.safeParse(candidate);
804
1215
  if (!result.success) {
805
1216
  const issue = result.error.issues[0];
806
1217
  const where = issue === undefined ? '' : `${issue.path.join('.') || '(root)'}: ${issue.message}`;
@@ -808,31 +1219,44 @@ function udtLessonUpdateTool(state) {
808
1219
  }
809
1220
  return result.data;
810
1221
  });
811
- const now = nowIso();
812
- const existing = state.lessonForNode(nodeId);
813
- const blocks = mode === 'replace' ? parsed : [...(existing?.blocks ?? []), ...parsed];
814
- if (blocks.length > MAX_BLOCKS_PER_LESSON) {
815
- throw new Error(`a lesson holds at most ${MAX_BLOCKS_PER_LESSON} blocks; this would make ${blocks.length}. ` +
816
- 'Replace the lesson or start a new node.');
1222
+ parsed.forEach((block, index) => assertSources(state, block, index));
1223
+ if (Buffer.byteLength(JSON.stringify(parsed), 'utf8') > MAX_LESSON_BYTES) {
1224
+ throw new Error(`blocks are too large; the whole unit must stay under ${MAX_LESSON_BYTES} bytes`);
817
1225
  }
818
- const lesson = LessonSchema.parse({
819
- id: existing?.id ?? `${nodeId}:lesson`,
820
- courseId,
821
- nodeId,
822
- title: input.title ?? existing?.title ?? node.title,
823
- blocks,
824
- // Real teaching, written by the tutor — never the v0.0.4 scaffold.
825
- origin: 'tutor',
826
- createdAt: existing?.createdAt ?? now,
827
- updatedAt: now,
828
- });
829
- await state.writeLesson(lesson);
1226
+ const buildLesson = (existing) => {
1227
+ const blocks = mode === 'replace' ? parsed : [...(existing?.blocks ?? []), ...parsed];
1228
+ if (blocks.length > MAX_BLOCKS_PER_LESSON) {
1229
+ throw new Error(`a lesson holds at most ${MAX_BLOCKS_PER_LESSON} blocks; this would make ${blocks.length}. ` +
1230
+ 'Replace the lesson or start a new node.');
1231
+ }
1232
+ const now = nowIso();
1233
+ return LessonSchema.parse({
1234
+ id: existing?.id ?? (attempt ? `${attempt.id}:${nodeId}:lesson` : `${nodeId}:lesson`),
1235
+ courseId,
1236
+ nodeId,
1237
+ title: input.title ?? existing?.title ?? state.readNode(nodeId).title,
1238
+ blocks,
1239
+ origin: 'tutor',
1240
+ createdAt: existing?.createdAt ?? now,
1241
+ updatedAt: now,
1242
+ });
1243
+ };
1244
+ const lesson = attempt && sessionId
1245
+ ? await writeCourseTeaching(state, courseId, sessionId, attempt.id, nodeId, buildLesson)
1246
+ : await state.mutateLesson(courseId, nodeId, (existing) => {
1247
+ requireProjectMatch(state.resolveProject(sessionId), courseId);
1248
+ const current = sessionId ? activeAttempt(state.readCourseRun(courseId, sessionId)) : undefined;
1249
+ if (current && ['requested', 'working'].includes(current.status))
1250
+ throw new Error('planned-lesson-needs-attemptId: read udt_path_get and bind its diagnosed node');
1251
+ return buildLesson(existing);
1252
+ });
1253
+ const now = lesson.updatedAt;
830
1254
  // The tutor has produced teaching for this node: the handoff that was
831
1255
  // waiting on it is now ready, and its timing chain closes.
832
1256
  // Atomic and monotonic: a lesson can only move a handoff forward, never
833
1257
  // back, so a late activity event cannot undo it.
834
- if (state.readHandoff(nodeId) !== undefined) {
835
- await state.updateHandoff(nodeId, (current) => withLesson(current, now));
1258
+ if (sessionId !== undefined && state.readHandoff(sessionId, nodeId) !== undefined) {
1259
+ await state.updateHandoff(sessionId, nodeId, (current) => withLesson(current, now));
836
1260
  }
837
1261
  return {
838
1262
  lessonId: lesson.id,
@@ -841,7 +1265,10 @@ function udtLessonUpdateTool(state) {
841
1265
  mode,
842
1266
  blockCount: lesson.blocks.length,
843
1267
  addedCount: parsed.length,
1268
+ reviewCardCount: (await syncLessonFlashcards(state, { courseId, nodeId, lesson, now })).length,
1269
+ markdown: blocksMarkdown(lesson.blocks),
844
1270
  origin: lesson.origin,
1271
+ ...await state.adoptProject(sessionId, courseId, now, project),
845
1272
  };
846
1273
  },
847
1274
  presentCall: (args) => ({
@@ -853,6 +1280,59 @@ function udtLessonUpdateTool(state) {
853
1280
  });
854
1281
  }
855
1282
  /* -------------------------------------------------------------------------- */
1283
+ /* udt_quiz_update */
1284
+ /* -------------------------------------------------------------------------- */
1285
+ /**
1286
+ * Record a learner's quiz/flashcard submission. This is storage, not judgement:
1287
+ * it never changes a node state, never marks mastery and never advances a lesson.
1288
+ * The tutor can read the submission through udt_path_get and decide what to say.
1289
+ */
1290
+ function udtQuizUpdateTool(state) {
1291
+ return defineTool({
1292
+ name: 'udt_quiz_update',
1293
+ description: 'Record the learner\'s answers for a quiz or flashcard block on the active course attempt. ' +
1294
+ 'This only records what was submitted; it does NOT grade, does not change a node state, and does not advance the lesson. ' +
1295
+ 'Use the active attemptId from udt_path_get; stale or inactive attempts are refused. ' +
1296
+ 'For a quiz, answer each question id with one of the offered options (or free text for a short question). ' +
1297
+ 'For a flashcard, answer with the card block id and value again | got-it.',
1298
+ parameters: {
1299
+ attemptId: { type: 'string', required: true },
1300
+ blockId: { type: 'string', required: true },
1301
+ answers: {
1302
+ type: 'array', required: true,
1303
+ items: {
1304
+ type: 'object', additionalProperties: false,
1305
+ properties: {
1306
+ questionId: { type: 'string', required: true },
1307
+ value: { type: 'string', required: true },
1308
+ },
1309
+ },
1310
+ },
1311
+ },
1312
+ output: { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
1313
+ execute: async (args, exec) => {
1314
+ const input = args;
1315
+ const sessionId = sessionOf(exec);
1316
+ if (sessionId === undefined)
1317
+ throw new Error('quiz-write-requires-a-conversation');
1318
+ const project = state.resolveProject(sessionId);
1319
+ if (project.course === undefined)
1320
+ throw new Error('no-selected-project: open a project first');
1321
+ const path = state.readPath(project.course.id);
1322
+ if (path === undefined)
1323
+ throw new Error('no-learning-path: ask for a route first');
1324
+ const submission = AnswerSubmissionSchema.parse(input);
1325
+ const view = await recordBlockResponse(state, {
1326
+ courseId: project.course.id,
1327
+ sessionId,
1328
+ expectedRevision: path.revision,
1329
+ ...submission,
1330
+ });
1331
+ return JSON.parse(JSON.stringify(view));
1332
+ },
1333
+ });
1334
+ }
1335
+ /* -------------------------------------------------------------------------- */
856
1336
  /* udt_decide_next */
857
1337
  /* -------------------------------------------------------------------------- */
858
1338
  /**
@@ -928,12 +1408,16 @@ function udtDecideNextTool(state) {
928
1408
  },
929
1409
  ],
930
1410
  },
931
- execute: async (args) => {
1411
+ execute: async (args, exec) => {
932
1412
  const input = args;
933
- const focused = state.activeFocus();
934
- const courseId = focused?.course.id ?? state.readLearner().activeCourseId;
1413
+ const sessionId = sessionOf(exec);
1414
+ if (sessionId === undefined) {
1415
+ throw new Error('this call has no conversation; a decision must be recorded in one');
1416
+ }
1417
+ const focused = state.activeFocus(sessionId);
1418
+ const courseId = state.resolveProject(sessionId).course?.id;
935
1419
  if (courseId === undefined) {
936
- throw new Error('no active course; record a goal and start learning on a node first');
1420
+ throw new Error('this conversation has no selected project; record a goal or select a project and start learning on a node first');
937
1421
  }
938
1422
  const fromNodeId = input.fromNodeId ?? focused?.node.id;
939
1423
  if (fromNodeId === undefined) {
@@ -957,6 +1441,7 @@ function udtDecideNextTool(state) {
957
1441
  ...(input.targetNodeId === undefined ? {} : { targetNodeId: input.targetNodeId }),
958
1442
  reason: input.reason,
959
1443
  now: nowIso(),
1444
+ sessionId,
960
1445
  });
961
1446
  return {
962
1447
  fromNodeId: nextStep.fromNodeId,
@@ -982,13 +1467,25 @@ function udtDecideNextTool(state) {
982
1467
  *
983
1468
  * @param host - the context slice carrying the tools registry.
984
1469
  * @param state - the open persistence handle the tools read from and write to.
1470
+ * @returns how many tools were registered, so the loader log cannot drift from
1471
+ * the registry itself (it used to say "4" while nine were registered).
985
1472
  */
986
1473
  export function registerTools(host, state) {
987
- host.tools.register(udtStatusTool(state));
988
- host.tools.register(udtGoalCreateTool(state));
989
- host.tools.register(udtMapGetTool(state));
990
- host.tools.register(udtMapUpdateTool(state));
991
- host.tools.register(udtLessonUpdateTool(state));
992
- host.tools.register(udtDecideNextTool(state));
1474
+ const core = [
1475
+ udtStatusTool(state),
1476
+ udtGoalCreateTool(state),
1477
+ udtMapGetTool(state),
1478
+ udtMapUpdateTool(state),
1479
+ udtLessonUpdateTool(state),
1480
+ udtQuizUpdateTool(state),
1481
+ udtDecideNextTool(state),
1482
+ ];
1483
+ const planning = planningTools(state);
1484
+ const sources = sourceTools(state);
1485
+ const plan = planTools(state);
1486
+ const review = reviewTools(state);
1487
+ for (const tool of [...core, ...planning, ...sources, ...plan, ...review])
1488
+ host.tools.register(tool);
1489
+ return core.length + planning.length + sources.length + plan.length + review.length;
993
1490
  }
994
1491
  //# sourceMappingURL=tools.js.map