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/api.js CHANGED
@@ -18,7 +18,13 @@
18
18
  */
19
19
  import { beginHandoff, handoffView, withFocusRecorded, withObserved, withPromptFailure, withPrompted, } from './handoff.js';
20
20
  import { focusPromptText } from './prompt.js';
21
+ import { resolveLearningSession } from './project-session.js';
22
+ import { buildDiagnostics } from './diagnostics.js';
21
23
  import { guarded } from './trust-fence.js';
24
+ import { handlePathApi } from './path-api.js';
25
+ import { buildIcs, sortTasks, taskView } from './plan.js';
26
+ import { buildInsights } from './insights.js';
27
+ import { REVIEW_RATINGS, dueCards, scheduleReview } from './review.js';
22
28
  /** Largest request body accepted, in bytes. */
23
29
  export const MAX_BODY_BYTES = 64 * 1024;
24
30
  /** Route prefix owned by this plugin. */
@@ -102,24 +108,122 @@ function stringField(body, key) {
102
108
  /* Handler */
103
109
  /* -------------------------------------------------------------------------- */
104
110
  /**
105
- * Which course the panel should show: the learner's active goal, else the most
106
- * recently updated one. Read-only — the panel never changes focus implicitly.
111
+ * The focus this conversation is working on, or nothing.
112
+ *
113
+ * Learning state belongs to a conversation, not to a course. The course and its
114
+ * map and its lessons are shared knowledge and stay visible to everyone; where
115
+ * *this* conversation has got to is not. A focus carrying a different
116
+ * conversation's id — or none at all, which means it predates the field — is
117
+ * not this panel's to show, so it reports nothing rather than someone else's
118
+ * lesson under the learner's chat.
119
+ *
120
+ * @param state - the runtime.
121
+ * @param courseId - the course being viewed.
122
+ * @param sessionId - the conversation asking, when it said.
123
+ * @returns the focus record, or undefined.
107
124
  */
108
- function currentCourse(state) {
109
- const activeId = state.readLearner().activeCourseId;
110
- if (activeId !== undefined) {
111
- const active = state.readCourse(activeId);
112
- if (active !== undefined)
113
- return active;
114
- }
125
+ function focusBelongingTo(state, courseId, sessionId) {
126
+ // No conversation named means no conversation's state: guessing is what let
127
+ // one conversation's lesson appear under another's chat.
128
+ if (sessionId === undefined || sessionId.length === 0)
129
+ return undefined;
130
+ return state.readFocus(sessionId, courseId);
131
+ }
132
+ /** The project a conversation has open, if any. */
133
+ function projectFor(state, sessionId) {
134
+ return state.resolveProject(sessionId).course;
135
+ }
136
+ /**
137
+ * Every Learning Project, with the numbers the library shows on a card.
138
+ *
139
+ * Learner-level, like the maps and lessons themselves: this is what the
140
+ * learner has, not what this conversation is doing with it.
141
+ */
142
+ function libraryOf(state) {
115
143
  return state
116
144
  .listCourses()
117
- .slice()
118
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
145
+ .map((course) => {
146
+ const nodes = state.listNodes(course.id);
147
+ const learningPath = state.readPath(course.id);
148
+ const status = learningPath?.acceptedVersion != null
149
+ ? 'accepted'
150
+ : learningPath !== undefined && (learningPath.brief !== null || learningPath.assessment !== null || learningPath.curricula.length > 0)
151
+ ? 'active'
152
+ : 'legacy';
153
+ return {
154
+ id: course.id,
155
+ title: course.title,
156
+ goal: course.goal,
157
+ confirmed: nodes.filter((node) => node.state === 'confirmed').length,
158
+ total: nodes.length,
159
+ updatedAt: course.updatedAt,
160
+ status,
161
+ acceptedVersion: learningPath?.acceptedVersion ?? null,
162
+ };
163
+ })
164
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
165
+ }
166
+ function reviewView(card) {
167
+ return {
168
+ id: card.id, front: card.front, back: card.back, dueAt: card.dueAt,
169
+ ...(card.courseId === undefined ? {} : { courseId: card.courseId }),
170
+ ...(card.nodeId === undefined ? {} : { nodeId: card.nodeId }),
171
+ ...(card.sourceRefs === undefined ? {} : { sourceRefs: card.sourceRefs }),
172
+ };
173
+ }
174
+ async function handleOverview(state, deps, sessionId, res) {
175
+ // Availability belongs to the requesting conversation and can change while
176
+ // the plugin stays loaded. A failed observation is inconclusive.
177
+ let teachingBrain = null;
178
+ try {
179
+ teachingBrain = typeof deps.teachingBrain === 'function'
180
+ ? await deps.teachingBrain(sessionId)
181
+ : deps.teachingBrain ?? null;
182
+ }
183
+ catch {
184
+ // The runtime remains readable if discovery is temporarily unavailable.
185
+ }
186
+ const { selection, course } = state.resolveProject(sessionId);
187
+ const library = libraryOf(state);
188
+ if (course === undefined) {
189
+ // No project open in this conversation. That is the library, not an error,
190
+ // and not an empty runtime: the projects it lists are real and shared.
191
+ sendJson(res, 200, {
192
+ ok: true,
193
+ selection,
194
+ course: null,
195
+ nodes: [],
196
+ focus: null,
197
+ nextStep: null,
198
+ handoff: null,
199
+ teachingBrain,
200
+ lessonCount: state.lessonCount(),
201
+ tasks: sortTasks(state.listTasks()).map(taskView),
202
+ library,
203
+ });
204
+ return;
205
+ }
206
+ const mine = focusBelongingTo(state, course.id, sessionId);
207
+ sendJson(res, 200, {
208
+ ok: true,
209
+ selection,
210
+ course: courseView(course),
211
+ nodes: state.listNodes(course.id).map(nodeView),
212
+ focus: mine === undefined ? null : focusOf(state, sessionId ?? '', course.id),
213
+ // A decision is attached to a focus, so it follows the same rule.
214
+ nextStep: mine === undefined ? null : nextStepOf(state, sessionId ?? '', course.id),
215
+ teachingBrain,
216
+ // The handoff for whichever node is focused, so a reload or a restart
217
+ // rebuilds the progress line instead of showing a blank wait.
218
+ handoff: mine === undefined ? null : handoffFor(state, sessionId ?? '', mine.nodeId, Date.now()),
219
+ lessonCount: state.lessonCount(),
220
+ tasks: sortTasks(state.listTasks()).map(taskView),
221
+ library,
222
+ });
119
223
  }
120
224
  /** The active focus for one course, joined with its node, as a view. */
121
- function focusOf(state, courseId) {
122
- const focus = state.readFocus(courseId);
225
+ function focusOf(state, sessionId, courseId) {
226
+ const focus = state.readFocus(sessionId, courseId);
123
227
  if (focus === undefined || focus.status !== 'active')
124
228
  return null;
125
229
  const node = state.readNode(focus.nodeId);
@@ -135,11 +239,11 @@ function focusOf(state, courseId) {
135
239
  * fresh record without `nextStepId`, so acting on a recommendation clears it
136
240
  * without anything having to remember that it did.
137
241
  */
138
- function nextStepOf(state, courseId) {
139
- const focus = state.readFocus(courseId);
242
+ function nextStepOf(state, sessionId, courseId) {
243
+ const focus = state.readFocus(sessionId, courseId);
140
244
  if (focus?.nextStepId === undefined)
141
245
  return null;
142
- const step = state.readNextStep(focus.nextStepId);
246
+ const step = state.readNextStep(sessionId, focus.nextStepId);
143
247
  if (step === undefined)
144
248
  return null;
145
249
  const from = state.readNode(step.fromNodeId);
@@ -154,55 +258,63 @@ function nextStepOf(state, courseId) {
154
258
  createdAt: step.createdAt,
155
259
  };
156
260
  }
157
- function handleOverview(state, deps, res) {
158
- const course = currentCourse(state);
159
- if (course === undefined) {
160
- // An empty state is a normal state, not an error: the panel says so. The
161
- // shape must still be complete — an omitted field reads as `undefined`,
162
- // and a surface that checks `teachingBrain === false` would never fire in
163
- // the one state it exists for.
164
- sendJson(res, 200, {
165
- ok: true,
166
- course: null,
167
- nodes: [],
168
- focus: null,
169
- nextStep: null,
170
- handoff: null,
171
- teachingBrain: deps.teachingBrain ?? null,
172
- lessonCount: 0,
173
- });
174
- return;
261
+ /**
262
+ * The focus this conversation is working on, or nothing.
263
+ *
264
+ * Learning state belongs to a conversation, not to a course. The course and its
265
+ * map and its lessons are shared knowledge and stay visible to everyone; where
266
+ * *this* conversation has got to is not. A focus carrying a different
267
+ * conversation's id — or none at all, which means it predates the field — is
268
+ * not this panel's to show, so it reports nothing rather than someone else's
269
+ * lesson under the learner's chat.
270
+ *
271
+ * @param state - the runtime.
272
+ * @param courseId - the course being viewed.
273
+ * @param sessionId - the conversation asking, when it said.
274
+ * @returns the focus record, or undefined.
275
+ */
276
+ /**
277
+ * `POST /project { sessionId, courseId? }` — choose what this conversation learns.
278
+ *
279
+ * The choice is the conversation's, and that is the whole point of the route:
280
+ * the projects are shared, but opening one is a decision a conversation makes
281
+ * and keeps. Sending no `courseId` selects "start something new", which is a
282
+ * deliberate state — it stops the library being shown and stops any project
283
+ * being opened, until the tutor creates one here.
284
+ *
285
+ * Selecting never fabricates execution state. There is no focus until the
286
+ * learner starts a node, so switching projects shows "no learning focus yet"
287
+ * rather than pretending the previous project's position carried over.
288
+ */
289
+ async function handleSelectProject(state, req, res) {
290
+ const body = await readJsonBody(req);
291
+ if (body === undefined)
292
+ return fail(res, 400, 'bad-body');
293
+ const sessionId = stringField(body, 'sessionId');
294
+ if (sessionId === undefined)
295
+ return fail(res, 400, 'missing-session-id');
296
+ const courseId = stringField(body, 'courseId');
297
+ if (courseId !== undefined && state.readCourse(courseId) === undefined) {
298
+ return fail(res, 404, 'no-such-course');
175
299
  }
176
- sendJson(res, 200, {
177
- ok: true,
178
- course: courseView(course),
179
- nodes: state.listNodes(course.id).map(nodeView),
180
- focus: focusOf(state, course.id),
181
- nextStep: nextStepOf(state, course.id),
182
- teachingBrain: deps.teachingBrain ?? null,
183
- // The handoff for whichever node is focused, so a reload or a restart
184
- // rebuilds the progress line instead of showing a blank wait.
185
- handoff: (() => {
186
- const focus = state.readFocus(course.id);
187
- return focus === undefined ? null : handoffFor(state, focus.nodeId, Date.now());
188
- })(),
189
- lessonCount: state.lessonCount(),
190
- });
300
+ const record = await state.selectProject(sessionId, courseId, new Date().toISOString());
301
+ sendJson(res, 200, { ok: true, project: record });
191
302
  }
192
303
  /** `GET /lesson?nodeId=` — the lesson the tutor has written for a node, if any. */
193
- function handleLesson(state, course, nodeId, res) {
194
- if (course === undefined)
195
- return fail(res, 404, 'no-course');
304
+ function handleLesson(state, nodeId, res) {
305
+ // Resolved from the node, not from the caller's project: a lesson belongs to
306
+ // the learner, and asking for one should not depend on what this conversation
307
+ // happens to have open.
196
308
  const node = state.readNode(nodeId);
197
- if (node === undefined || node.courseId !== course.id)
309
+ if (node === undefined || state.readCourse(node.courseId) === undefined) {
198
310
  return fail(res, 404, 'no-such-node');
311
+ }
199
312
  sendJson(res, 200, { ok: true, lesson: state.lessonForNode(nodeId) ?? null });
200
313
  }
201
- function handleNode(state, course, nodeId, res) {
202
- if (course === undefined)
203
- return fail(res, 404, 'no-course');
314
+ function handleNode(state, nodeId, res) {
204
315
  const node = state.readNode(nodeId);
205
- if (node === undefined || node.courseId !== course.id)
316
+ const course = node === undefined ? undefined : state.readCourse(node.courseId);
317
+ if (node === undefined || course === undefined)
206
318
  return fail(res, 404, 'no-such-node');
207
319
  const parent = node.parentId === undefined ? undefined : state.readNode(node.parentId);
208
320
  const children = state
@@ -229,8 +341,8 @@ function handleNode(state, course, nodeId, res) {
229
341
  /** How long a handoff is left alone before another request is treated as a retry. */
230
342
  const HANDOFF_DEDUPE_MS = 3000;
231
343
  /** Build the handoff view, which is what a surface renders. */
232
- function handoffFor(state, nodeId, nowMs) {
233
- const record = state.readHandoff(nodeId);
344
+ function handoffFor(state, sessionId, nodeId, nowMs) {
345
+ const record = state.readHandoff(sessionId, nodeId);
234
346
  if (record === undefined)
235
347
  return null;
236
348
  return handoffView(record, nowMs, state.lessonForNode(nodeId) !== undefined);
@@ -251,9 +363,7 @@ function handoffFor(state, nodeId, nowMs) {
251
363
  * returns it untouched, which is what makes a double click harmless; a request
252
364
  * after it has gone quiet is a retry and bumps the attempt count.
253
365
  */
254
- async function handleStartFocus(state, deps, course, req, res) {
255
- if (course === undefined)
256
- return fail(res, 404, 'no-course');
366
+ async function handleStartFocus(state, deps, req, res) {
257
367
  const body = await readJsonBody(req);
258
368
  if (body === undefined)
259
369
  return fail(res, 400, 'bad-body');
@@ -261,38 +371,51 @@ async function handleStartFocus(state, deps, course, req, res) {
261
371
  if (nodeId === undefined)
262
372
  return fail(res, 400, 'missing-node-id');
263
373
  const sessionId = stringField(body, 'sessionId');
374
+ // Required: a focus that cannot say whose it is cannot be shown to anyone,
375
+ // so accepting one would create state nobody could ever see.
376
+ if (sessionId === undefined)
377
+ return fail(res, 400, 'missing-session-id');
378
+ // The course comes from the NODE, not from what this conversation had open.
379
+ // Starting a node in a project is choosing that project — the other
380
+ // direction is the one that must not happen, and does not: selecting a
381
+ // project never invents a focus.
264
382
  const node = state.readNode(nodeId);
265
- if (node === undefined || node.courseId !== course.id)
383
+ const course = node === undefined ? undefined : state.readCourse(node.courseId);
384
+ if (node === undefined || course === undefined)
266
385
  return fail(res, 404, 'no-such-node');
386
+ const chosen = state.readProject(sessionId);
387
+ if (chosen?.courseId !== course.id) {
388
+ await state.selectProject(sessionId, course.id, new Date().toISOString());
389
+ }
267
390
  const now = new Date();
268
- const previous = state.readHandoff(nodeId);
391
+ const previous = state.readHandoff(sessionId, nodeId);
269
392
  // Double-click protection: the same target, still in flight and recent, is
270
393
  // the same handoff. Not an error — just nothing new to do.
271
394
  if (previous !== undefined &&
272
395
  previous.status !== 'failed' &&
273
396
  now.getTime() - Date.parse(previous.requestedAt) < HANDOFF_DEDUPE_MS) {
274
- const focus = state.readFocus(course.id);
397
+ const focus = state.readFocus(sessionId, course.id);
275
398
  sendJson(res, 200, {
276
399
  ok: true,
277
400
  focus: focus === undefined ? null : focusView(focus, node),
278
401
  prompted: previous.promptedAt !== undefined,
279
- handoff: handoffFor(state, nodeId, now.getTime()),
402
+ handoff: handoffFor(state, sessionId, nodeId, now.getTime()),
280
403
  deduped: true,
281
404
  });
282
405
  return;
283
406
  }
284
407
  let handoff = beginHandoff({
285
408
  courseId: course.id,
286
- fromNodeId: previous?.fromNodeId ?? state.readFocus(course.id)?.nodeId ?? nodeId,
409
+ fromNodeId: previous?.fromNodeId ?? state.readFocus(sessionId, course.id)?.nodeId ?? nodeId,
287
410
  targetNodeId: nodeId,
288
411
  now: now.toISOString(),
289
- ...(sessionId === undefined ? {} : { sessionId }),
412
+ sessionId,
290
413
  ...(previous === undefined ? {} : { previous }),
291
414
  });
292
415
  await state.writeHandoff(handoff);
293
416
  let focus;
294
417
  try {
295
- focus = await state.startFocus(course.id, nodeId, now.toISOString());
418
+ focus = await state.startFocus(course.id, nodeId, now.toISOString(), sessionId);
296
419
  }
297
420
  catch (error) {
298
421
  // Covers an unknown node and a node from another course alike.
@@ -310,7 +433,7 @@ async function handleStartFocus(state, deps, course, req, res) {
310
433
  focus: focusView(focus, node),
311
434
  prompted: outcome.prompted,
312
435
  ...(outcome.reason === undefined ? {} : { promptReason: outcome.reason }),
313
- handoff: handoffFor(state, nodeId, Date.now()),
436
+ handoff: handoffFor(state, sessionId, nodeId, Date.now()),
314
437
  });
315
438
  }
316
439
  /**
@@ -350,11 +473,68 @@ async function handleObserved(state, req, res) {
350
473
  const nodeId = stringField(body, 'nodeId');
351
474
  if (nodeId === undefined)
352
475
  return fail(res, 400, 'missing-node-id');
353
- const record = state.readHandoff(nodeId);
476
+ const sessionId = stringField(body, 'sessionId');
477
+ if (sessionId === undefined)
478
+ return fail(res, 400, 'missing-session-id');
479
+ const record = state.readHandoff(sessionId, nodeId);
354
480
  if (record === undefined)
355
481
  return fail(res, 404, 'no-handoff');
356
482
  await state.writeHandoff(withObserved(record, new Date().toISOString()));
357
- sendJson(res, 200, { ok: true, handoff: handoffFor(state, nodeId, Date.now()) });
483
+ sendJson(res, 200, { ok: true, handoff: handoffFor(state, sessionId, nodeId, Date.now()) });
484
+ }
485
+ /**
486
+ * `GET /preferences` — the learner's interface language, if one is saved.
487
+ *
488
+ * The value lives on the learner singleton rather than in the browser so it
489
+ * survives DSH restarts (the web port changes, and browser storage does not
490
+ * follow it) and applies to every conversation. It is a display preference
491
+ * only: nothing about the diagnosis, teaching or stored evidence changes.
492
+ */
493
+ function handlePreferences(state, res) {
494
+ const language = state.readLearner().preferredLanguage;
495
+ sendJson(res, 200, {
496
+ ok: true,
497
+ language: language === 'en' || language === 'zh' ? language : null,
498
+ });
499
+ }
500
+ /** One of the two languages this build ships copy for. */
501
+ const LANGUAGES = ['en', 'zh'];
502
+ /**
503
+ * `POST /preferences/language { language }` — remember the interface language.
504
+ *
505
+ * Validated here rather than trusted from the body: an unknown value would
506
+ * leave the UI without copy after the next load, so it is rejected instead of
507
+ * stored.
508
+ */
509
+ async function handleSetLanguage(state, req, res) {
510
+ const body = await readJsonBody(req);
511
+ if (body === undefined)
512
+ return fail(res, 400, 'bad-body');
513
+ const language = body.language;
514
+ if (typeof language !== 'string' || !LANGUAGES.includes(language)) {
515
+ return fail(res, 400, 'unsupported-language');
516
+ }
517
+ const now = new Date().toISOString();
518
+ await state.updateLearner({ preferredLanguage: language }, now);
519
+ sendJson(res, 200, { ok: true, language });
520
+ }
521
+ /**
522
+ * `GET /project/session?courseId=&sessionId=` — which conversation resumes a project.
523
+ *
524
+ * A project is shared, but where it was last studied is not. The browser asks
525
+ * this before "entering learning" so continuing a project opens the
526
+ * conversation that actually holds its focus/run instead of rebinding the
527
+ * project to whichever conversation happens to be on screen. Read-only: no
528
+ * selection is written by answering it.
529
+ */
530
+ function handleProjectSession(state, url, res) {
531
+ const courseId = url.searchParams.get('courseId');
532
+ if (courseId === null || courseId.length === 0)
533
+ return fail(res, 400, 'missing-course-id');
534
+ const resolved = resolveLearningSession(state, courseId, url.searchParams.get('sessionId') ?? undefined);
535
+ if (resolved === null)
536
+ return fail(res, 404, 'no-such-course');
537
+ sendJson(res, 200, { ok: true, sessionId: resolved.sessionId, reason: resolved.reason });
358
538
  }
359
539
  /**
360
540
  * Build the request handler.
@@ -368,31 +548,137 @@ export function createApiHandler(deps) {
368
548
  const method = req.method ?? 'GET';
369
549
  const url = new URL(req.url ?? '/', 'http://localhost');
370
550
  const route = url.pathname.slice(API_PREFIX.length) || '/';
371
- const course = currentCourse(state);
372
- if (method === 'GET' && route === '/overview')
373
- return handleOverview(state, deps, res);
551
+ if (await handlePathApi(route, req, res, deps, url))
552
+ return;
553
+ // Read per request, not once: a conversation's project is its own, and the
554
+ // route that changes it must not be answered from a value captured before.
555
+ const asker = url.searchParams.get('sessionId') ?? undefined;
556
+ const course = projectFor(state, asker);
557
+ if (method === 'GET' && route === '/overview') {
558
+ return handleOverview(state, deps, url.searchParams.get('sessionId') ?? undefined, res);
559
+ }
560
+ if (method === 'GET' && route === '/project/session')
561
+ return handleProjectSession(state, url, res);
562
+ if (method === 'GET' && route === '/diagnostics') {
563
+ if (deps.journal === undefined)
564
+ return fail(res, 503, 'diagnostics-disabled');
565
+ try {
566
+ sendJson(res, 200, await buildDiagnostics(state, deps.journal, { ...(deps.pluginVersion === undefined ? {} : { pluginVersion: deps.pluginVersion }) }));
567
+ }
568
+ catch (cause) {
569
+ deps.onEvent?.('diagnostics.error', { error: cause.message.slice(0, 240) });
570
+ return fail(res, 500, 'diagnostics-unavailable');
571
+ }
572
+ return;
573
+ }
574
+ if (method === 'POST' && route === '/project')
575
+ return await handleSelectProject(state, req, res);
374
576
  if (method === 'GET' && route === '/export')
375
577
  return handleExport(state, res);
376
578
  if (method === 'POST' && route === '/reset')
377
579
  return await handleReset(state, res);
580
+ if (method === 'POST' && route === '/plan/task') {
581
+ const input = await readJsonBody(req);
582
+ const taskId = input === undefined ? undefined : stringField(input, 'taskId');
583
+ if (taskId === undefined)
584
+ return fail(res, 400, 'missing-task-id');
585
+ const task = state.readTask(taskId);
586
+ if (task === undefined)
587
+ return fail(res, 404, 'no-such-task');
588
+ const status = input?.status === 'open' || input?.status === 'archived' ? input.status : 'done';
589
+ await state.writeTask({ ...task, status, updatedAt: new Date().toISOString() });
590
+ return sendJson(res, 200, { ok: true, tasks: sortTasks(state.listTasks()).map(taskView) });
591
+ }
592
+ if (method === 'GET' && route === '/insights') {
593
+ const courseId = url.searchParams.get('courseId') ?? course?.id;
594
+ if (courseId === undefined || courseId.length === 0)
595
+ return fail(res, 404, 'no-such-course');
596
+ const target = state.readCourse(courseId);
597
+ if (target === undefined)
598
+ return fail(res, 404, 'no-such-course');
599
+ return sendJson(res, 200, buildInsights(state, target, new Date().toISOString()));
600
+ }
601
+ if (method === 'POST' && route === '/review/undo') {
602
+ const input = await readJsonBody(req);
603
+ const cardId = input === undefined ? undefined : stringField(input, 'cardId');
604
+ if (cardId === undefined)
605
+ return fail(res, 400, 'missing-card-id');
606
+ const card = state.readReviewCard(cardId);
607
+ if (card === undefined)
608
+ return fail(res, 404, 'no-such-review-card');
609
+ const log = state.listReviewLog().filter((entry) => entry.cardId === cardId).sort((left, right) => left.at.localeCompare(right.at)).at(-1);
610
+ if (log === undefined || log.previousIntervalDays === undefined || log.previousEase === undefined || log.previousReps === undefined || log.previousLapses === undefined)
611
+ return fail(res, 409, 'cannot-undo');
612
+ const restored = {
613
+ ...card, dueAt: log.previousDue, intervalDays: log.previousIntervalDays, ease: log.previousEase,
614
+ reps: log.previousReps, lapses: log.previousLapses, updatedAt: new Date().toISOString(),
615
+ };
616
+ await state.writeReviewCard(restored);
617
+ await state.deleteReviewLog(log.id);
618
+ return sendJson(res, 200, { ok: true, card: reviewView(restored), remaining: dueCards(state.listReviewCards()).length });
619
+ }
620
+ if (method === 'GET' && route === '/review') {
621
+ const cards = dueCards(state.listReviewCards()).slice(0, 20).map(reviewView);
622
+ return sendJson(res, 200, { ok: true, cards, count: cards.length });
623
+ }
624
+ if (method === 'POST' && route === '/review/grade') {
625
+ const input = await readJsonBody(req);
626
+ const cardId = input === undefined ? undefined : stringField(input, 'cardId');
627
+ const rating = input === undefined ? undefined : stringField(input, 'rating');
628
+ if (cardId === undefined)
629
+ return fail(res, 400, 'missing-card-id');
630
+ if (rating === undefined || !REVIEW_RATINGS.includes(rating))
631
+ return fail(res, 400, 'invalid-rating');
632
+ const card = state.readReviewCard(cardId);
633
+ if (card === undefined)
634
+ return fail(res, 404, 'no-such-review-card');
635
+ const graded = scheduleReview(card, rating);
636
+ await state.writeReviewCard(graded.card);
637
+ await state.writeReviewLog(graded.log);
638
+ return sendJson(res, 200, { ok: true, card: reviewView(graded.card), remaining: dueCards(state.listReviewCards()).length });
639
+ }
640
+ if (method === 'GET' && route === '/plan/ics') {
641
+ const calendar = buildIcs(state.listTasks(), new Date());
642
+ res.statusCode = 200;
643
+ res.setHeader('content-type', 'text/calendar; charset=utf-8');
644
+ res.setHeader('content-disposition', 'attachment; filename="dsh-diagnostic-tutor-plan.ics"');
645
+ res.setHeader('cache-control', 'no-store');
646
+ res.end(calendar);
647
+ return;
648
+ }
649
+ if (method === 'GET' && route === '/source') {
650
+ const sourceId = url.searchParams.get('id');
651
+ if (sourceId === null || sourceId.length === 0)
652
+ return fail(res, 400, 'missing-id');
653
+ const source = state.readSource(sourceId);
654
+ if (source === undefined)
655
+ return fail(res, 404, 'no-such-source');
656
+ return sendJson(res, 200, { ok: true, source });
657
+ }
378
658
  if (method === 'GET' && route === '/node') {
379
659
  const nodeId = url.searchParams.get('id');
380
660
  if (nodeId === null || nodeId.length === 0)
381
661
  return fail(res, 400, 'missing-id');
382
- return handleNode(state, course, nodeId, res);
662
+ return handleNode(state, nodeId, res);
383
663
  }
384
664
  if (method === 'GET' && route === '/lesson') {
385
665
  const nodeId = url.searchParams.get('nodeId');
386
666
  if (nodeId === null || nodeId.length === 0)
387
667
  return fail(res, 400, 'missing-node-id');
388
- return handleLesson(state, course, nodeId, res);
668
+ return handleLesson(state, nodeId, res);
389
669
  }
390
670
  if (method === 'POST' && route === '/focus') {
391
- return handleStartFocus(state, deps, course, req, res);
671
+ return handleStartFocus(state, deps, req, res);
392
672
  }
393
673
  if (method === 'POST' && route === '/handoff/observed') {
394
674
  return handleObserved(state, req, res);
395
675
  }
676
+ if (method === 'GET' && route === '/preferences') {
677
+ return handlePreferences(state, res);
678
+ }
679
+ if (method === 'POST' && route === '/preferences/language') {
680
+ return await handleSetLanguage(state, req, res);
681
+ }
396
682
  return fail(res, method === 'GET' || method === 'POST' ? 404 : 405, 'no-such-route');
397
683
  };
398
684
  }