dsh-diagnostic-tutor 0.1.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +658 -0
  3. package/cordis.patch.yml +23 -0
  4. package/lib/api.js +413 -0
  5. package/lib/api.js.map +1 -0
  6. package/lib/client.js +2029 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/contract.js +14 -0
  9. package/lib/contract.js.map +1 -0
  10. package/lib/diagnosis.js +224 -0
  11. package/lib/diagnosis.js.map +1 -0
  12. package/lib/handoff.js +194 -0
  13. package/lib/handoff.js.map +1 -0
  14. package/lib/index.js +186 -0
  15. package/lib/index.js.map +1 -0
  16. package/lib/lesson.js +285 -0
  17. package/lib/lesson.js.map +1 -0
  18. package/lib/prompt.js +96 -0
  19. package/lib/prompt.js.map +1 -0
  20. package/lib/state.js +500 -0
  21. package/lib/state.js.map +1 -0
  22. package/lib/tools.js +994 -0
  23. package/lib/tools.js.map +1 -0
  24. package/lib/trust-fence.js +101 -0
  25. package/lib/trust-fence.js.map +1 -0
  26. package/lib/types/api.d.ts +62 -0
  27. package/lib/types/api.d.ts.map +1 -0
  28. package/lib/types/contract.d.ts +147 -0
  29. package/lib/types/contract.d.ts.map +1 -0
  30. package/lib/types/diagnosis.d.ts +116 -0
  31. package/lib/types/diagnosis.d.ts.map +1 -0
  32. package/lib/types/handoff.d.ts +141 -0
  33. package/lib/types/handoff.d.ts.map +1 -0
  34. package/lib/types/index.d.ts +71 -0
  35. package/lib/types/index.d.ts.map +1 -0
  36. package/lib/types/lesson.d.ts +295 -0
  37. package/lib/types/lesson.d.ts.map +1 -0
  38. package/lib/types/prompt.d.ts +85 -0
  39. package/lib/types/prompt.d.ts.map +1 -0
  40. package/lib/types/state.d.ts +627 -0
  41. package/lib/types/state.d.ts.map +1 -0
  42. package/lib/types/tools.d.ts +38 -0
  43. package/lib/types/tools.d.ts.map +1 -0
  44. package/lib/types/trust-fence.d.ts +53 -0
  45. package/lib/types/trust-fence.d.ts.map +1 -0
  46. package/lib/types/udt.d.ts +95 -0
  47. package/lib/types/udt.d.ts.map +1 -0
  48. package/lib/types/vocabulary.d.ts +162 -0
  49. package/lib/types/vocabulary.d.ts.map +1 -0
  50. package/lib/udt.js +141 -0
  51. package/lib/udt.js.map +1 -0
  52. package/lib/vocabulary.js +182 -0
  53. package/lib/vocabulary.js.map +1 -0
  54. package/package.json +104 -0
package/lib/tools.js ADDED
@@ -0,0 +1,994 @@
1
+ /**
2
+ * Model-facing tools.
3
+ *
4
+ * v0.0.3 registers four, and the restraint is the architecture: this plugin
5
+ * owns **state, artifacts and presentation**, while every teaching decision
6
+ * belongs to the Universal Diagnostic Tutor skill. So each tool here either
7
+ * records what was observed or reports what is stored. None of them decides
8
+ * what to teach, how to explain it, or when to advance.
9
+ *
10
+ * That is also why the rules in `diagnosis.ts` are enforced *here* rather than
11
+ * negotiated with the caller: the runtime's job is to make unverified mastery
12
+ * and curriculum dumps impossible to store, and then get out of the way.
13
+ *
14
+ * As in `state.ts`, `defineTool` is a *pure builder* (it compiles the parameter
15
+ * DSL into JSON Schema and returns a plain descriptor), so it is
16
+ * value-imported. The registry itself is taken from `ctx`.
17
+ */
18
+ import { defineTool } from '@deepseek-ai/dsh-tools';
19
+ import { MAX_NODES_PER_UPDATE, acceptNewNodes, recordEvidence, setNodeState } from './diagnosis.js';
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';
23
+ import { EVIDENCE_KINDS, NODE_RELATIONS, NODE_STATES, READINESS_OUTCOMES, targetRequirement, } from './vocabulary.js';
24
+ /* -------------------------------------------------------------------------- */
25
+ /* Helpers */
26
+ /* -------------------------------------------------------------------------- */
27
+ /**
28
+ * Turn a human title into a stable, readable id fragment.
29
+ *
30
+ * Unicode-aware on purpose. An ASCII-only slug silently destroyed every
31
+ * non-Latin title: "机器学习入门" collapsed to the fallback, and "ML / 机器学习"
32
+ * lost the Chinese half entirely — so a learner working in Chinese got ids
33
+ * like `node:goal`. Keeping letters and digits from any script means the id
34
+ * still reads like the thing it names. Storage keys are plain strings, so a
35
+ * non-ASCII id is fine on the medium.
36
+ */
37
+ function slugify(text) {
38
+ const slug = text
39
+ .toLowerCase()
40
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ // Slice by code point so a surrogate pair is never cut in half.
43
+ const capped = [...slug].slice(0, 40).join('').replace(/-+$/g, '');
44
+ // Titles made only of punctuation or emoji still need a usable id.
45
+ return capped.length > 0 ? capped : 'node';
46
+ }
47
+ /** Allocate an id that does not collide with `taken`. */
48
+ function allocateId(base, taken) {
49
+ if (!taken.has(base))
50
+ return base;
51
+ for (let suffix = 2; suffix < 1000; suffix += 1) {
52
+ const candidate = `${base}-${suffix}`;
53
+ if (!taken.has(candidate))
54
+ return candidate;
55
+ }
56
+ throw new Error(`could not allocate a unique id for "${base}"`);
57
+ }
58
+ /** ISO timestamp; single place so tests can reason about formatting. */
59
+ function nowIso() {
60
+ return new Date().toISOString();
61
+ }
62
+ /** Render rule violations as a message the calling model can act on. */
63
+ function violationMessage(violations) {
64
+ return violations.map((entry) => `${entry.code}: ${entry.message}`).join(' | ');
65
+ }
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;
69
+ if (!courseId) {
70
+ throw new Error('no course given and the learner has no active goal; call udt_goal_create first or pass courseId');
71
+ }
72
+ if (!state.readCourse(courseId))
73
+ throw new Error(`no course "${courseId}"`);
74
+ return courseId;
75
+ }
76
+ /**
77
+ * Project the state snapshot onto the tool's canonical output.
78
+ *
79
+ * Optional fields are omitted rather than set to `undefined`: the value must
80
+ * be lossless JSON, and an explicit `undefined` is not.
81
+ *
82
+ * Note what is absent: nothing about the detected teaching brain. Skill
83
+ * location and version are internal diagnostics and must not become text a
84
+ * model can echo back to a learner.
85
+ */
86
+ function statusValue(state) {
87
+ const snapshot = state.snapshot();
88
+ const value = {
89
+ domain: snapshot.domain,
90
+ version: snapshot.version,
91
+ initialized: snapshot.initialized,
92
+ courseCount: snapshot.courseCount,
93
+ nodeCount: snapshot.nodeCount,
94
+ courses: snapshot.courses.map((course) => ({ ...course })),
95
+ };
96
+ const { preferredLanguage, mode, activeCourseId } = snapshot.learner;
97
+ if (preferredLanguage !== undefined)
98
+ value.preferredLanguage = preferredLanguage;
99
+ if (mode !== undefined)
100
+ value.mode = mode;
101
+ if (activeCourseId !== undefined)
102
+ value.activeCourseId = activeCourseId;
103
+ // The focus is how the tutor learns which node the learner asked to work on.
104
+ // It is reported here rather than in a tool of its own: it is one fact about
105
+ // the runtime, not a separate concern.
106
+ const focused = state.activeFocus();
107
+ if (focused !== undefined) {
108
+ value.focus = {
109
+ courseId: focused.course.id,
110
+ nodeId: focused.node.id,
111
+ nodeTitle: focused.node.title,
112
+ nodeState: focused.node.state,
113
+ startedAt: focused.focus.startedAt,
114
+ };
115
+ }
116
+ // A recommendation the learner has not acted on yet. Reported so the tutor
117
+ // 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);
120
+ if (pending !== undefined) {
121
+ const from = state.readNode(pending.fromNodeId);
122
+ const target = pending.targetNodeId === undefined ? undefined : state.readNode(pending.targetNodeId);
123
+ value.pendingNextStep = {
124
+ action: pending.action,
125
+ // Ids, not just titles: a model must never have to guess an identifier
126
+ // from a display string, and titles are not unique across a map.
127
+ fromNodeId: pending.fromNodeId,
128
+ fromNodeTitle: from?.title ?? pending.fromNodeId,
129
+ reason: pending.reason,
130
+ ...(target === undefined
131
+ ? {}
132
+ : { targetNodeId: pending.targetNodeId, targetNodeTitle: target.title }),
133
+ };
134
+ }
135
+ return value;
136
+ }
137
+ function udtStatusTool(state) {
138
+ return defineTool({
139
+ name: 'udt_status',
140
+ description: 'Report the state of the learning runtime: the storage domain in use, whether learner state has been ' +
141
+ 'initialized, the learner profile, the registered learning goals and how many diagnosis-map nodes exist. ' +
142
+ 'Call this before starting a session to see whether there is existing state to continue from, and after ' +
143
+ 'changing state to confirm what was stored. ' +
144
+ 'Read-only: it records no teaching decision.',
145
+ parameters: {},
146
+ output: {
147
+ schema: {
148
+ type: 'object',
149
+ additionalProperties: false,
150
+ properties: {
151
+ domain: { type: 'string', required: true, description: 'Storage domain name.' },
152
+ version: { type: 'integer', required: true, description: 'Domain schema version.' },
153
+ initialized: {
154
+ type: 'boolean',
155
+ required: true,
156
+ description: 'Whether learner state has ever been written. False means a first run.',
157
+ },
158
+ courseCount: { type: 'integer', required: true },
159
+ nodeCount: { type: 'integer', required: true, description: 'Diagnosis-map nodes across all goals.' },
160
+ courses: {
161
+ type: 'array',
162
+ required: true,
163
+ description: 'Registered learning goals.',
164
+ items: {
165
+ type: 'object',
166
+ additionalProperties: false,
167
+ properties: {
168
+ id: { type: 'string', required: true },
169
+ title: { type: 'string', required: true },
170
+ status: { type: 'string', required: true, description: 'active | paused | archived' },
171
+ },
172
+ },
173
+ },
174
+ preferredLanguage: { type: 'string', description: 'Learner preference, when recorded.' },
175
+ mode: {
176
+ type: 'string',
177
+ description: 'Teaching mode preference: auto | zero-base | standard | advanced.',
178
+ },
179
+ activeCourseId: { type: 'string', description: 'The learning goal currently in focus.' },
180
+ focus: {
181
+ type: 'object',
182
+ additionalProperties: false,
183
+ description: 'The node the learner pressed Start learning on, when there is one. Read this to know what to teach.',
184
+ properties: {
185
+ courseId: { type: 'string', required: true },
186
+ nodeId: { type: 'string', required: true },
187
+ nodeTitle: { type: 'string', required: true },
188
+ nodeState: { type: 'string', required: true },
189
+ startedAt: { type: 'string', required: true },
190
+ },
191
+ },
192
+ pendingNextStep: {
193
+ type: 'object',
194
+ additionalProperties: false,
195
+ description: 'A decision the learner has not acted on yet. Present it again rather than deciding afresh.',
196
+ properties: {
197
+ action: { type: 'string', required: true },
198
+ fromNodeId: {
199
+ type: 'string',
200
+ required: true,
201
+ description: 'The node id the decision was made from — pass this, never the title.',
202
+ },
203
+ fromNodeTitle: { type: 'string', required: true },
204
+ targetNodeId: {
205
+ type: 'string',
206
+ description: 'The node id it sends the learner to. Absent means stay on fromNodeId.',
207
+ },
208
+ targetNodeTitle: { type: 'string' },
209
+ reason: { type: 'string', required: true },
210
+ },
211
+ },
212
+ },
213
+ },
214
+ render: (_args, value) => [
215
+ {
216
+ 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)'}.`,
218
+ },
219
+ ],
220
+ },
221
+ execute: () => Promise.resolve(statusValue(state)),
222
+ presentCall: () => ({
223
+ card: 'generic',
224
+ title: 'Read learning-runtime status',
225
+ kind: 'other',
226
+ rawInput: {},
227
+ }),
228
+ });
229
+ }
230
+ /* -------------------------------------------------------------------------- */
231
+ /* udt_goal_create */
232
+ /* -------------------------------------------------------------------------- */
233
+ /**
234
+ * Create a learning goal.
235
+ *
236
+ * Deliberately does **not** generate any material: it records the goal in the
237
+ * learner's own words and plants the single root node of the map. Everything
238
+ * else appears later, one diagnosis at a time.
239
+ *
240
+ * Creating a goal also focuses it, pausing whichever goal was previously
241
+ * active. Pausing is reversible and never touches the other goal's map.
242
+ */
243
+ function udtGoalCreateTool(state) {
244
+ return defineTool({
245
+ name: 'udt_goal_create',
246
+ description: 'Record a learning goal and start its diagnosis map. ' +
247
+ 'Put the learner\'s own words in `goal`; use `title` for a short label. ' +
248
+ 'This creates ONLY the goal and the single root node of the map — it never generates a course outline, ' +
249
+ 'syllabus or list of topics. Further nodes are added later, one at a time, as diagnosis reveals ' +
250
+ 'prerequisites or blockers (see udt_map_update). ' +
251
+ 'Creating a goal focuses it and pauses any previously active goal.',
252
+ parameters: {
253
+ title: {
254
+ type: 'string',
255
+ required: true,
256
+ description: 'Short label for the goal, e.g. "Machine Learning".',
257
+ },
258
+ goal: {
259
+ type: 'string',
260
+ required: true,
261
+ description: 'The learning goal in the learner\'s own words.',
262
+ },
263
+ },
264
+ output: {
265
+ schema: {
266
+ type: 'object',
267
+ additionalProperties: false,
268
+ properties: {
269
+ courseId: { type: 'string', required: true, description: 'Use this as courseId in later calls.' },
270
+ title: { type: 'string', required: true },
271
+ goal: { type: 'string', required: true },
272
+ status: { type: 'string', required: true },
273
+ createdAt: { type: 'string', required: true },
274
+ goalNodeId: { type: 'string', required: true, description: 'Root node of the diagnosis map.' },
275
+ pausedCourseIds: {
276
+ type: 'array',
277
+ required: true,
278
+ description: 'Goals that were active and are now paused.',
279
+ items: { type: 'string' },
280
+ },
281
+ },
282
+ },
283
+ render: (_args, value) => [
284
+ {
285
+ type: 'text',
286
+ text: `Goal recorded: "${value.title}" (${value.courseId}). Map starts with its root node only.`,
287
+ },
288
+ ],
289
+ },
290
+ execute: async (args) => {
291
+ const { title, goal } = args;
292
+ 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,
318
+ });
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
+ };
329
+ },
330
+ presentCall: (args) => ({
331
+ card: 'generic',
332
+ title: `Record goal: ${args.title ?? ''}`,
333
+ kind: 'other',
334
+ rawInput: args,
335
+ }),
336
+ });
337
+ }
338
+ /* -------------------------------------------------------------------------- */
339
+ /* udt_map_get */
340
+ /* -------------------------------------------------------------------------- */
341
+ function udtMapGetTool(state) {
342
+ return defineTool({
343
+ name: 'udt_map_get',
344
+ description: 'Read the diagnosis map of one learning goal: every node with its relation, its state and the evidence ' +
345
+ 'recorded against it. Use this to decide the next diagnostic step. ' +
346
+ 'A node state is one of: unconfirmed (no evidence yet), explained, practiced, checked, weak, blocked, ' +
347
+ 'confirmed. Nothing here is a progress percentage or a score. ' +
348
+ 'Omit courseId to read the learner\'s active goal.',
349
+ parameters: {
350
+ courseId: { type: 'string', description: 'Goal to read; defaults to the active goal.' },
351
+ },
352
+ output: {
353
+ schema: {
354
+ type: 'object',
355
+ additionalProperties: false,
356
+ properties: {
357
+ courseId: { type: 'string', required: true },
358
+ title: { type: 'string', required: true },
359
+ goal: { type: 'string', required: true },
360
+ status: { type: 'string', required: true },
361
+ nodes: {
362
+ type: 'array',
363
+ required: true,
364
+ items: {
365
+ type: 'object',
366
+ additionalProperties: false,
367
+ properties: {
368
+ nodeId: {
369
+ type: 'string',
370
+ required: true,
371
+ description: 'The node id — pass this to udt_map_update, udt_lesson_update and udt_decide_next.',
372
+ },
373
+ title: { type: 'string', required: true, description: 'Display text. Never pass this as an id.' },
374
+ relation: {
375
+ type: 'string',
376
+ required: true,
377
+ description: 'goal | part-of | prerequisite | related',
378
+ },
379
+ state: { type: 'string', required: true },
380
+ parentNodeId: { type: 'string' },
381
+ evidence: {
382
+ type: 'array',
383
+ required: true,
384
+ items: {
385
+ type: 'object',
386
+ additionalProperties: false,
387
+ properties: {
388
+ kind: { type: 'string', required: true },
389
+ at: { type: 'string', required: true },
390
+ note: { type: 'string' },
391
+ readiness: { type: 'string' },
392
+ },
393
+ },
394
+ },
395
+ },
396
+ },
397
+ },
398
+ },
399
+ },
400
+ render: (_args, value) => [
401
+ {
402
+ type: 'text',
403
+ text: `Map for "${value.title}": ${value.nodes.length} node(s) — ${value.nodes
404
+ .map((node) => `${node.title} [${node.state}]`)
405
+ .join(', ')}`,
406
+ },
407
+ ],
408
+ },
409
+ execute: (args) => {
410
+ const courseId = resolveCourseId(state, args.courseId);
411
+ const map = state.readMap(courseId);
412
+ if (!map)
413
+ throw new Error(`no course "${courseId}"`);
414
+ return Promise.resolve({
415
+ courseId: map.course.id,
416
+ title: map.course.title,
417
+ goal: map.course.goal,
418
+ status: map.course.status,
419
+ nodes: map.nodes.map((node) => ({
420
+ // Named `nodeId`, not `id`: every tool that takes a node argument
421
+ // spells it `nodeId`, so the model reads the identifier it will pass
422
+ // rather than translating between two spellings.
423
+ nodeId: node.id,
424
+ title: node.title,
425
+ relation: node.relation,
426
+ state: node.state,
427
+ ...(node.parentId === undefined ? {} : { parentNodeId: node.parentId }),
428
+ evidence: node.evidence.map((entry) => ({
429
+ kind: entry.kind,
430
+ at: entry.at,
431
+ ...(entry.note === undefined ? {} : { note: entry.note }),
432
+ ...(entry.readiness === undefined ? {} : { readiness: entry.readiness }),
433
+ })),
434
+ })),
435
+ });
436
+ },
437
+ presentCall: () => ({ card: 'generic', title: 'Read diagnosis map', kind: 'other', rawInput: {} }),
438
+ });
439
+ }
440
+ /* -------------------------------------------------------------------------- */
441
+ /* udt_map_update */
442
+ /* -------------------------------------------------------------------------- */
443
+ /** The three write operations, kept explicit so each has one meaning. */
444
+ const MAP_OPS = ['add-nodes', 'set-state', 'add-evidence'];
445
+ function udtMapUpdateTool(state) {
446
+ return defineTool({
447
+ name: 'udt_map_update',
448
+ description: 'Grow or annotate the diagnosis map. Pick ONE `op`:\n' +
449
+ '• "add-nodes" — add nodes that diagnosis has actually revealed. Every node needs `parentId` pointing at ' +
450
+ 'an existing node (or omit it only for a course goal). Give `title` and `relation` ' +
451
+ '(part-of | prerequisite | related | goal). Nodes are born "unconfirmed" unless you pass `state`.\n' +
452
+ '• "set-state" — move one node to a new state. "confirmed" is REFUSED unless the node already carries a ' +
453
+ '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' +
455
+ '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.',
457
+ parameters: {
458
+ courseId: { type: 'string', description: 'Goal to update; defaults to the active goal.' },
459
+ op: {
460
+ type: 'string',
461
+ required: true,
462
+ enum: [...MAP_OPS],
463
+ description: 'add-nodes | set-state | add-evidence',
464
+ },
465
+ nodes: {
466
+ type: 'array',
467
+ description: 'For op=add-nodes: the nodes to add.',
468
+ items: {
469
+ type: 'object',
470
+ additionalProperties: false,
471
+ properties: {
472
+ title: { type: 'string', required: true },
473
+ relation: { type: 'string', required: true, enum: [...NODE_RELATIONS] },
474
+ parentId: { type: 'string' },
475
+ state: { type: 'string', enum: [...NODE_STATES] },
476
+ },
477
+ },
478
+ },
479
+ nodeId: { type: 'string', description: 'For op=set-state / add-evidence: the target node.' },
480
+ state: { type: 'string', enum: [...NODE_STATES], description: 'For op=set-state: the new state.' },
481
+ evidenceKind: {
482
+ type: 'string',
483
+ enum: [...EVIDENCE_KINDS],
484
+ description: 'For op=add-evidence: what kind of observation this is.',
485
+ },
486
+ evidenceNote: { type: 'string', description: 'For op=add-evidence: short note in the learner\'s terms.' },
487
+ readiness: {
488
+ type: 'string',
489
+ enum: [...READINESS_OUTCOMES],
490
+ description: 'For op=add-evidence: the readiness outcome, when the observation supports one.',
491
+ },
492
+ at: { type: 'string', description: 'ISO timestamp; defaults to now.' },
493
+ },
494
+ output: {
495
+ schema: {
496
+ type: 'object',
497
+ additionalProperties: false,
498
+ properties: {
499
+ courseId: { type: 'string', required: true },
500
+ op: { type: 'string', required: true },
501
+ addedNodeIds: {
502
+ type: 'array',
503
+ required: true,
504
+ description: 'Ids of nodes added by this call (empty for other ops).',
505
+ items: { type: 'string' },
506
+ },
507
+ nodeId: { type: 'string', description: 'Node touched by set-state / add-evidence.' },
508
+ state: { type: 'string', description: 'The node\'s state after this call.' },
509
+ evidenceCount: { type: 'integer', description: 'Evidence entries on the node after this call.' },
510
+ nodeCount: { type: 'integer', required: true, description: 'Total nodes in the course after this call.' },
511
+ },
512
+ },
513
+ render: (_args, value) => [
514
+ {
515
+ 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).`,
519
+ },
520
+ ],
521
+ },
522
+ execute: async (args) => {
523
+ const input = args;
524
+ const courseId = resolveCourseId(state, input.courseId);
525
+ const now = input.at ?? nowIso();
526
+ const existing = state.listNodes(courseId);
527
+ if (input.op === 'add-nodes') {
528
+ const requested = input.nodes ?? [];
529
+ if (requested.length === 0) {
530
+ throw new Error('op=add-nodes requires a non-empty `nodes` array');
531
+ }
532
+ if (requested.length > MAX_NODES_PER_UPDATE) {
533
+ throw new Error(`at most ${MAX_NODES_PER_UPDATE} nodes may be added per call, received ${requested.length}`);
534
+ }
535
+ // Ids are derived from titles and allocated against the whole course,
536
+ // so a caller never has to invent one and cannot collide.
537
+ const taken = new Set(existing.map((node) => node.id));
538
+ const course = state.readCourse(courseId);
539
+ if (!course)
540
+ throw new Error(`no course "${courseId}"`);
541
+ const structured = requested.map((spec) => {
542
+ const id = allocateId(`${courseId}:${slugify(spec.title)}`, taken);
543
+ taken.add(id);
544
+ const node = newNode({
545
+ id,
546
+ courseId,
547
+ title: spec.title,
548
+ relation: spec.relation,
549
+ ...(spec.parentId === undefined ? {} : { parentId: spec.parentId }),
550
+ ...(spec.state === undefined ? {} : { state: spec.state }),
551
+ now,
552
+ });
553
+ return node;
554
+ });
555
+ const accepted = acceptNewNodes(existing, structured);
556
+ if (!accepted.ok)
557
+ throw new Error(violationMessage(accepted.violations));
558
+ for (const node of structured)
559
+ await state.writeNode(node);
560
+ return {
561
+ courseId,
562
+ op: input.op,
563
+ addedNodeIds: structured.map((node) => node.id),
564
+ nodeCount: existing.length + structured.length,
565
+ };
566
+ }
567
+ // Both remaining ops target exactly one node.
568
+ const nodeId = input.nodeId;
569
+ if (!nodeId)
570
+ throw new Error(`op=${input.op} requires \`nodeId\``);
571
+ const node = state.readNode(nodeId);
572
+ if (!node)
573
+ throw new Error(`no node "${nodeId}"`);
574
+ if (node.courseId !== courseId) {
575
+ throw new Error(`node "${nodeId}" belongs to course "${node.courseId}", not "${courseId}"`);
576
+ }
577
+ if (input.op === 'set-state') {
578
+ if (!input.state)
579
+ throw new Error('op=set-state requires `state`');
580
+ // Same rule as every other write, so a bare assertion still cannot
581
+ // promote a node to `confirmed`.
582
+ const result = setNodeState(node, input.state, now);
583
+ if (!result.ok)
584
+ throw new Error(violationMessage(result.violations));
585
+ const stored = await state.writeNode(result.node);
586
+ return {
587
+ courseId,
588
+ op: input.op,
589
+ addedNodeIds: [],
590
+ nodeId: stored.id,
591
+ state: stored.state,
592
+ evidenceCount: stored.evidence.length,
593
+ nodeCount: existing.length,
594
+ };
595
+ }
596
+ // op === 'add-evidence'
597
+ if (!input.evidenceKind)
598
+ throw new Error('op=add-evidence requires `evidenceKind`');
599
+ const evidence = {
600
+ kind: input.evidenceKind,
601
+ at: now,
602
+ ...(input.evidenceNote === undefined ? {} : { note: input.evidenceNote }),
603
+ ...(input.readiness === undefined ? {} : { readiness: input.readiness }),
604
+ };
605
+ const result = recordEvidence(node, evidence, {
606
+ now,
607
+ ...(input.state === undefined ? {} : { state: input.state }),
608
+ });
609
+ if (!result.ok)
610
+ throw new Error(violationMessage(result.violations));
611
+ const stored = await state.writeNode(result.node);
612
+ return {
613
+ courseId,
614
+ op: input.op,
615
+ addedNodeIds: [],
616
+ nodeId: stored.id,
617
+ state: stored.state,
618
+ evidenceCount: stored.evidence.length,
619
+ nodeCount: existing.length,
620
+ };
621
+ },
622
+ presentCall: (args) => ({
623
+ card: 'generic',
624
+ title: `Map update: ${args.op ?? ''}`,
625
+ kind: 'other',
626
+ rawInput: args,
627
+ }),
628
+ });
629
+ }
630
+ /* -------------------------------------------------------------------------- */
631
+ /* udt_lesson_update */
632
+ /* -------------------------------------------------------------------------- */
633
+ /**
634
+ * The block envelope, expressed precisely enough for the model to fill in.
635
+ *
636
+ * A `oneOf` over the four shapes rather than an open object: the model sees
637
+ * exactly which `content` belongs to which `type`, and the registry rejects a
638
+ * mismatched pair before `execute` ever runs. Zod validates the same shapes
639
+ * again on the way in, with messages that name the offending block.
640
+ */
641
+ const BLOCK_PARAM_SPEC = {
642
+ oneOf: [
643
+ {
644
+ type: 'object',
645
+ additionalProperties: false,
646
+ properties: {
647
+ id: { type: 'string', required: true },
648
+ type: { type: 'string', required: true, enum: ['text'] },
649
+ content: {
650
+ type: 'object',
651
+ additionalProperties: false,
652
+ properties: { md: { type: 'string', required: true, description: 'Markdown; math as \\(...\\) or \\[...\\].' } },
653
+ },
654
+ },
655
+ },
656
+ {
657
+ type: 'object',
658
+ additionalProperties: false,
659
+ properties: {
660
+ id: { type: 'string', required: true },
661
+ type: { type: 'string', required: true, enum: ['example'] },
662
+ content: {
663
+ type: 'object',
664
+ additionalProperties: false,
665
+ properties: {
666
+ title: { type: 'string', required: true },
667
+ steps: { type: 'array', required: true, items: { type: 'string' } },
668
+ takeaway: { type: 'string' },
669
+ },
670
+ },
671
+ },
672
+ },
673
+ {
674
+ type: 'object',
675
+ additionalProperties: false,
676
+ properties: {
677
+ id: { type: 'string', required: true },
678
+ type: { type: 'string', required: true, enum: ['diagram'] },
679
+ content: {
680
+ type: 'object',
681
+ additionalProperties: false,
682
+ properties: {
683
+ format: { type: 'string', required: true, enum: ['ascii', 'mermaid'] },
684
+ spec: { type: 'string', required: true },
685
+ caption: { type: 'string' },
686
+ },
687
+ },
688
+ },
689
+ },
690
+ {
691
+ type: 'object',
692
+ additionalProperties: false,
693
+ properties: {
694
+ id: { type: 'string', required: true },
695
+ type: { type: 'string', required: true, enum: ['check'] },
696
+ content: {
697
+ type: 'object',
698
+ additionalProperties: false,
699
+ properties: {
700
+ prompt: { type: 'string', required: true },
701
+ expect: { type: 'string', enum: ['reasoning', 'answer'] },
702
+ hint: { type: 'string' },
703
+ },
704
+ },
705
+ },
706
+ },
707
+ ],
708
+ };
709
+ /**
710
+ * Write this turn's teaching into the learning surface.
711
+ *
712
+ * The tutor decides the content; the runtime decides what may be stored. That
713
+ * split is why this tool validates hard and caps sizes: a lesson that arrives
714
+ * malformed, oversized, or bound to a node that does not exist is refused with
715
+ * a message naming the problem, and nothing is written.
716
+ *
717
+ * `append` is the default because teaching accumulates — a check is answered
718
+ * and the next unit follows. `replace` exists for revising a unit that was
719
+ * wrong, not for regenerating a course.
720
+ */
721
+ function udtLessonUpdateTool(state) {
722
+ return defineTool({
723
+ name: 'udt_lesson_update',
724
+ description: 'Write teaching content into the learner\'s Learning Surface as structured blocks.\n' +
725
+ 'The blocks appear in the panel beside the diagnosis map, so the learner reads them while ' +
726
+ 'answering in the chat — write for that surface, not as a chat message.\n' +
727
+ 'Block types: **text** {md}, **example** {title, steps[], takeaway?}, ' +
728
+ '**diagram** {format: ascii|mermaid, spec, caption?}, **check** {prompt, expect?, hint?}.\n' +
729
+ `At most ${MAX_BLOCKS_PER_UPDATE} blocks per call and ${MAX_BLOCKS_PER_LESSON} per lesson; a teaching unit is a few ` +
730
+ 'blocks, not a chapter.\n' +
731
+ '**Write your first unit as soon as you know it** rather than composing the whole thing first: ' +
732
+ 'the blocks appear in the learner\'s surface the moment you send them, so a short first unit ' +
733
+ 'turns a silent wait into visible progress. Send more later with append.\n' +
734
+ '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.',
736
+ parameters: {
737
+ nodeId: {
738
+ type: 'string',
739
+ description: 'Node to write for. Omit to use the learner\'s current focus (see udt_status).',
740
+ },
741
+ title: { type: 'string', description: 'Lesson title; defaults to the node title.' },
742
+ mode: {
743
+ type: 'string',
744
+ enum: ['append', 'replace'],
745
+ description: 'append (default) adds blocks; replace overwrites the lesson.',
746
+ },
747
+ blocks: {
748
+ type: 'array',
749
+ required: true,
750
+ description: 'The blocks to write, in reading order.',
751
+ items: BLOCK_PARAM_SPEC,
752
+ },
753
+ },
754
+ output: {
755
+ schema: {
756
+ type: 'object',
757
+ additionalProperties: false,
758
+ properties: {
759
+ lessonId: { type: 'string', required: true },
760
+ nodeId: { type: 'string', required: true },
761
+ courseId: { type: 'string', required: true },
762
+ mode: { type: 'string', required: true },
763
+ blockCount: { type: 'integer', required: true, description: 'Blocks in the lesson after this call.' },
764
+ addedCount: { type: 'integer', required: true },
765
+ origin: { type: 'string', required: true },
766
+ },
767
+ },
768
+ render: (_args, value) => [
769
+ {
770
+ 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}).`,
772
+ },
773
+ ],
774
+ },
775
+ execute: async (args) => {
776
+ const input = args;
777
+ 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;
784
+ if (nodeId === undefined) {
785
+ throw new Error('no nodeId given and no learning focus is active; call udt_status to see the focus');
786
+ }
787
+ const node = state.readNode(nodeId);
788
+ if (node === undefined)
789
+ 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
+ }
793
+ if (!Array.isArray(input.blocks) || input.blocks.length === 0) {
794
+ throw new Error('`blocks` must be a non-empty array');
795
+ }
796
+ if (input.blocks.length > MAX_BLOCKS_PER_UPDATE) {
797
+ throw new Error(`at most ${MAX_BLOCKS_PER_UPDATE} blocks per call, received ${input.blocks.length}. ` +
798
+ 'Write one teaching unit, not a chapter.');
799
+ }
800
+ // 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);
804
+ if (!result.success) {
805
+ const issue = result.error.issues[0];
806
+ const where = issue === undefined ? '' : `${issue.path.join('.') || '(root)'}: ${issue.message}`;
807
+ throw new Error(`block[${index}] is not a valid block — ${where}`);
808
+ }
809
+ return result.data;
810
+ });
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.');
817
+ }
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);
830
+ // The tutor has produced teaching for this node: the handoff that was
831
+ // waiting on it is now ready, and its timing chain closes.
832
+ // Atomic and monotonic: a lesson can only move a handoff forward, never
833
+ // back, so a late activity event cannot undo it.
834
+ if (state.readHandoff(nodeId) !== undefined) {
835
+ await state.updateHandoff(nodeId, (current) => withLesson(current, now));
836
+ }
837
+ return {
838
+ lessonId: lesson.id,
839
+ nodeId,
840
+ courseId,
841
+ mode,
842
+ blockCount: lesson.blocks.length,
843
+ addedCount: parsed.length,
844
+ origin: lesson.origin,
845
+ };
846
+ },
847
+ presentCall: (args) => ({
848
+ card: 'generic',
849
+ title: `Write ${args.blocks?.length ?? 0} learning block(s)`,
850
+ kind: 'other',
851
+ rawInput: args,
852
+ }),
853
+ });
854
+ }
855
+ /* -------------------------------------------------------------------------- */
856
+ /* udt_decide_next */
857
+ /* -------------------------------------------------------------------------- */
858
+ /**
859
+ * Record the teaching decision about where the learner goes next.
860
+ *
861
+ * This tool exists because the plugin must not choose. The runtime knows which
862
+ * nodes exist and which evidence supports what; it does not know whether a
863
+ * learner is ready to move on, whether a gap is worth repairing here, or
864
+ * whether something newly diagnosed should come first. The tutor does, and this
865
+ * is where it says so.
866
+ *
867
+ * What the runtime does with the answer is structural only: it validates that
868
+ * the nodes named exist and belong to the course, that the outcome and the
869
+ * target agree, stores the recommendation, and turns the focus over if the
870
+ * decision is a move. It never fills in a target the tutor left out.
871
+ *
872
+ * `reason` is required because the panel shows it verbatim. A recommendation
873
+ * the learner cannot read is a jump, not a next step.
874
+ */
875
+ function udtDecideNextTool(state) {
876
+ return defineTool({
877
+ name: 'udt_decide_next',
878
+ description: 'Record your decision about what this learner should do after the current node.\n' +
879
+ 'Use your own readiness vocabulary for `action` — advance | advance-with-caution | review-first | ' +
880
+ 'step-down | more-practice | diagnose-again — and let it decide the shape:\n' +
881
+ '• advance, advance-with-caution, step-down — MUST name `targetNodeId` (a move).\n' +
882
+ '• more-practice, diagnose-again — MUST NOT name one (the learner stays here).\n' +
883
+ '• review-first — may name one (go back to it) or omit it (review here).\n' +
884
+ 'The focus ends when you name a target, and stays open when you do not. Nothing moves on its own: ' +
885
+ 'the learner sees your `reason` and chooses whether to continue.\n' +
886
+ 'If the next step is a prerequisite you just diagnosed, add it to the map first with udt_map_update, ' +
887
+ 'then name it here.\n' +
888
+ 'Write `reason` for the learner — one or two plain sentences saying why this is the next thing.',
889
+ parameters: {
890
+ fromNodeId: {
891
+ type: 'string',
892
+ description: 'The node just worked on. Omit to use the current focus.',
893
+ },
894
+ action: {
895
+ type: 'string',
896
+ required: true,
897
+ enum: [...READINESS_OUTCOMES],
898
+ description: 'Your readiness outcome: advance | advance-with-caution | review-first | step-down | more-practice | diagnose-again.',
899
+ },
900
+ targetNodeId: {
901
+ type: 'string',
902
+ description: 'Where to go. Required for a move; omit to stay on this node.',
903
+ },
904
+ reason: {
905
+ type: 'string',
906
+ required: true,
907
+ description: 'Why this is the next step, in one or two plain sentences the learner will read.',
908
+ },
909
+ },
910
+ output: {
911
+ schema: {
912
+ type: 'object',
913
+ additionalProperties: false,
914
+ properties: {
915
+ fromNodeId: { type: 'string', required: true },
916
+ action: { type: 'string', required: true },
917
+ focusEnded: { type: 'boolean', required: true, description: 'Whether the focus was turned over.' },
918
+ targetNodeId: { type: 'string', description: 'The named target, when there is one.' },
919
+ reason: { type: 'string', required: true },
920
+ },
921
+ },
922
+ render: (_args, value) => [
923
+ {
924
+ type: 'text',
925
+ text: value.focusEnded
926
+ ? `Decision recorded: ${value.action} → next node "${value.targetNodeId}". The learner chooses when to continue.`
927
+ : `Decision recorded: ${value.action} → stay on "${value.fromNodeId}".`,
928
+ },
929
+ ],
930
+ },
931
+ execute: async (args) => {
932
+ const input = args;
933
+ const focused = state.activeFocus();
934
+ const courseId = focused?.course.id ?? state.readLearner().activeCourseId;
935
+ if (courseId === undefined) {
936
+ throw new Error('no active course; record a goal and start learning on a node first');
937
+ }
938
+ const fromNodeId = input.fromNodeId ?? focused?.node.id;
939
+ if (fromNodeId === undefined) {
940
+ throw new Error('no fromNodeId given and no learning focus is active; call udt_status to see the focus');
941
+ }
942
+ // The runtime checks that the decision is well formed; it does not check
943
+ // that it is wise, and it never supplies a missing target.
944
+ const requirement = targetRequirement(input.action);
945
+ if (requirement === 'required' && input.targetNodeId === undefined) {
946
+ throw new Error(`action "${input.action}" is a move and must name targetNodeId. ` +
947
+ 'If the learner should stay here, use more-practice or diagnose-again.');
948
+ }
949
+ if (requirement === 'forbidden' && input.targetNodeId !== undefined) {
950
+ throw new Error(`action "${input.action}" means staying on this node, so targetNodeId must be omitted. ` +
951
+ 'Use advance, advance-with-caution, step-down or review-first to name a target.');
952
+ }
953
+ const { nextStep, focus } = await state.decideNext({
954
+ courseId,
955
+ fromNodeId,
956
+ action: input.action,
957
+ ...(input.targetNodeId === undefined ? {} : { targetNodeId: input.targetNodeId }),
958
+ reason: input.reason,
959
+ now: nowIso(),
960
+ });
961
+ return {
962
+ fromNodeId: nextStep.fromNodeId,
963
+ action: nextStep.action,
964
+ focusEnded: focus.status === 'ended',
965
+ ...(nextStep.targetNodeId === undefined ? {} : { targetNodeId: nextStep.targetNodeId }),
966
+ reason: nextStep.reason,
967
+ };
968
+ },
969
+ presentCall: (args) => ({
970
+ card: 'generic',
971
+ title: `Decide next: ${args.action ?? ''}`,
972
+ kind: 'other',
973
+ rawInput: args,
974
+ }),
975
+ });
976
+ }
977
+ /* -------------------------------------------------------------------------- */
978
+ /* Registration */
979
+ /* -------------------------------------------------------------------------- */
980
+ /**
981
+ * Register this plugin's tools on the host's registry.
982
+ *
983
+ * @param host - the context slice carrying the tools registry.
984
+ * @param state - the open persistence handle the tools read from and write to.
985
+ */
986
+ 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));
993
+ }
994
+ //# sourceMappingURL=tools.js.map