dsh-diagnostic-tutor 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -35
- package/lib/api.js +186 -78
- package/lib/api.js.map +1 -1
- package/lib/client.js +1896 -559
- package/lib/client.js.map +1 -1
- package/lib/execution-key.js +91 -0
- package/lib/execution-key.js.map +1 -0
- package/lib/handoff.js +44 -1
- package/lib/handoff.js.map +1 -1
- package/lib/index.js +13 -39
- package/lib/index.js.map +1 -1
- package/lib/state.js +206 -41
- package/lib/state.js.map +1 -1
- package/lib/tools.js +166 -85
- package/lib/tools.js.map +1 -1
- package/lib/types/api.d.ts +3 -3
- package/lib/types/api.d.ts.map +1 -1
- package/lib/types/contract.d.ts +48 -6
- package/lib/types/contract.d.ts.map +1 -1
- package/lib/types/execution-key.d.ts +59 -0
- package/lib/types/execution-key.d.ts.map +1 -0
- package/lib/types/handoff.d.ts.map +1 -1
- package/lib/types/index.d.ts +4 -5
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/state.d.ts +91 -14
- package/lib/types/state.d.ts.map +1 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/udt.d.ts +20 -24
- package/lib/types/udt.d.ts.map +1 -1
- package/lib/udt.js +72 -19
- package/lib/udt.js.map +1 -1
- package/package.json +24 -21
package/README.md
CHANGED
|
@@ -11,9 +11,14 @@ learn interactively, and move to the next best step.
|
|
|
11
11
|
|
|
12
12
|
## Status
|
|
13
13
|
|
|
14
|
-
`v0.1.
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
`v0.1.1` — the current stable release. Conversation-scoped projects, focus,
|
|
15
|
+
next steps and handoffs keep each learning session separate, while maps and
|
|
16
|
+
lessons remain shared. The Learning UI now offers one next action and reports
|
|
17
|
+
its actual state, with working scroll containers in docked and expanded views.
|
|
18
|
+
|
|
19
|
+
Install the prebuilt package with
|
|
20
|
+
`dsh plugin --profile web add dsh-diagnostic-tutor@0.1.1` — no clone, no
|
|
21
|
+
`pnpm install`, no build step.
|
|
17
22
|
|
|
18
23
|
The learning loop works end to end: a real goal in your own words, a diagnosis
|
|
19
24
|
map grown one node at a time, a tutor-written lesson, a check answered in the
|
|
@@ -126,13 +131,11 @@ library plus fonts and CSS, and the client bundle is a single JavaScript file
|
|
|
126
131
|
with nowhere to serve those from. This is the most visible rough edge in any
|
|
127
132
|
STEM lesson.
|
|
128
133
|
|
|
129
|
-
**Skill presence
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`cannot tell` rather than guessing, and the panel stays quiet. If lessons never
|
|
135
|
-
appear, check the skill first.
|
|
134
|
+
**Skill presence requires a live Agent.** The runtime checks the open
|
|
135
|
+
conversation's Agent scope on each overview, including its workspace. Before an
|
|
136
|
+
Agent exists, or while discovery is empty or incomplete, it reports `cannot tell`
|
|
137
|
+
and the panel stays quiet. Polling picks up later registration and Agent changes.
|
|
138
|
+
If lessons never appear, check the skill first.
|
|
136
139
|
|
|
137
140
|
**Storage is one document.** Every record lives in
|
|
138
141
|
`<dsh-home>/storages/udt.json`. It is readable, copyable and easy to export, and
|
|
@@ -154,6 +157,7 @@ matrix as load-bearing, not decoration.
|
|
|
154
157
|
|
|
155
158
|
| This plugin | Verified against DSH | Node |
|
|
156
159
|
| --- | --- | --- |
|
|
160
|
+
| `0.1.1` | `0.2.1-rc.2`, `0.2.0-rc.2` (test suite also on `0.1.7-alpha.2`) | `^22.19.0 \|\| >=24.0.0` |
|
|
157
161
|
| `0.1.0` | `0.1.7-alpha.2` (also composed under `0.1.5-rc.1`) | `^22.19.0 \|\| >=24.0.0` |
|
|
158
162
|
|
|
159
163
|
Rules this repository enforces mechanically:
|
|
@@ -165,7 +169,10 @@ Rules this repository enforces mechanically:
|
|
|
165
169
|
second runtime copy can be resolved. Enforced by `verbatimModuleSyntax`, and
|
|
166
170
|
verified on the built artifact (it contains zero imports).
|
|
167
171
|
- **No `instanceof` across package boundaries.** Take runtime objects from `ctx`.
|
|
168
|
-
- Peer ranges
|
|
172
|
+
- Peer ranges name the release lines this plugin was verified on. DSH ships
|
|
173
|
+
prereleases, and npm's semver only admits a prerelease when its line is named
|
|
174
|
+
in the range — so the compatibility matrix above, not the range floor, is the
|
|
175
|
+
contract. Dev dependencies pin one exact cohort for the type baseline.
|
|
169
176
|
|
|
170
177
|
---
|
|
171
178
|
|
|
@@ -175,7 +182,7 @@ Rules this repository enforces mechanically:
|
|
|
175
182
|
|
|
176
183
|
| | |
|
|
177
184
|
| --- | --- |
|
|
178
|
-
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | verified against `0.1
|
|
185
|
+
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | verified against `0.2.1-rc.2` |
|
|
179
186
|
| the [Universal Diagnostic Tutor](https://github.com/SenmuuuuW/universal-diagnostic-tutor-skill) skill | the teaching brain. Without it the runtime loads, records and displays state, but **no lesson is ever written** |
|
|
180
187
|
| Node | `^22.19.0 \|\| >=24.0.0` |
|
|
181
188
|
|
|
@@ -227,7 +234,7 @@ learn?*.
|
|
|
227
234
|
|
|
228
235
|
```sh
|
|
229
236
|
npm pack
|
|
230
|
-
dsh plugin --profile web add "$PWD/dsh-diagnostic-tutor-0.1.
|
|
237
|
+
dsh plugin --profile web add "$PWD/dsh-diagnostic-tutor-0.1.1.tgz"
|
|
231
238
|
```
|
|
232
239
|
|
|
233
240
|
**What gets installed is prebuilt.** The package ships `lib/` — the host half
|
|
@@ -258,10 +265,30 @@ With the json backend you get exactly one document, `<storage root>/udt.json`
|
|
|
258
265
|
|
|
259
266
|
| Slot | Holds |
|
|
260
267
|
| --- | --- |
|
|
261
|
-
| `global` | the learner singleton — preferences,
|
|
268
|
+
| `global` | the learner singleton — preferences, `initializedAt`, and the legacy shared `activeCourseId` marker |
|
|
262
269
|
| `tables.courses` | one record per learning goal, in the learner's own words |
|
|
263
270
|
| `tables.nodes` | the diagnosis map: `id`, `courseId`, `title`, `parentId?`, `relation`, `state`, `evidence[]` |
|
|
264
271
|
|
|
272
|
+
Courses, maps, lessons and evidence are shared by the learner's conversations.
|
|
273
|
+
Project selection, focus, next step and handoff belong to one conversation,
|
|
274
|
+
identified by its session ID. The legacy `activeCourseId` remains in storage for
|
|
275
|
+
compatibility; it does not select a project for a conversation. A fresh
|
|
276
|
+
conversation starts with the library, even when historical projects exist.
|
|
277
|
+
|
|
278
|
+
| Conversation selection | Project binding |
|
|
279
|
+
| --- | --- |
|
|
280
|
+
| `none` | A new goal, or a validated write explicitly naming a course or node in this conversation, may select its project. Reading shared data does not select it. |
|
|
281
|
+
| `new` | The learner pressed **Start something new**. Only a new goal created here may bind; historical map or lesson writes leave this choice intact. |
|
|
282
|
+
| `project` | The selected course is the default for tools. Creating another goal does not replace it; writes to another course require selecting that course first. |
|
|
283
|
+
|
|
284
|
+
`udt_status` reports this conversation's `selection` and, only for `project`,
|
|
285
|
+
its `courseId`. Goal, map and lesson writes return `projectAdopted` and
|
|
286
|
+
`projectBinding`: `adopted`, `already-selected`, `not-selected`, or
|
|
287
|
+
`missing-session-id`. A choice made while a write is pending takes precedence
|
|
288
|
+
over that write's adoption. Without a reliable session ID, status reports
|
|
289
|
+
`sessionAvailable: false`; explicit shared writes can still be stored, but their
|
|
290
|
+
result is `missing-session-id` and they claim no conversation binding.
|
|
291
|
+
|
|
265
292
|
Two deliberate rules:
|
|
266
293
|
|
|
267
294
|
- **zod is the contract.** Every record is validated at the durable boundary, so
|
|
@@ -294,13 +321,13 @@ route around them:
|
|
|
294
321
|
|
|
295
322
|
### Tools
|
|
296
323
|
|
|
297
|
-
|
|
324
|
+
Six, and none of them decides anything about teaching.
|
|
298
325
|
|
|
299
326
|
| Tool | Does |
|
|
300
327
|
| --- | --- |
|
|
301
|
-
| `udt_status` | reports
|
|
302
|
-
| `udt_goal_create` | records a goal and plants the map root
|
|
303
|
-
| `udt_map_get` | reads the map with each node's relation, state and evidence |
|
|
328
|
+
| `udt_status` | reports shared goals and counts, plus this conversation's selection, focus and pending next step |
|
|
329
|
+
| `udt_goal_create` | records a goal and plants only the map root; may also select it for this conversation |
|
|
330
|
+
| `udt_map_get` | reads the selected project's map, or an explicitly named shared course, with each node's relation, state and evidence |
|
|
304
331
|
| `udt_map_update` | `add-nodes` · `set-state` · `add-evidence` |
|
|
305
332
|
| `udt_lesson_update` | writes teaching into the learning surface as blocks |
|
|
306
333
|
| `udt_decide_next` | records where the learner should go next, and why |
|
|
@@ -394,19 +421,20 @@ conversation rather than at load.
|
|
|
394
421
|
|
|
395
422
|
### Teaching-brain detection
|
|
396
423
|
|
|
397
|
-
|
|
398
|
-
Universal Diagnostic Tutor skill is
|
|
399
|
-
|
|
400
|
-
|
|
424
|
+
Each overview asks the platform's own Agent and Skill registries whether the
|
|
425
|
+
Universal Diagnostic Tutor skill is available in the requesting session's live
|
|
426
|
+
Agent scope and cwd. No path is hardcoded, no skill root is assumed, and nothing
|
|
427
|
+
is copied. Missing services and incomplete discovery return an inconclusive
|
|
428
|
+
status; complete scoped discovery can establish presence or absence.
|
|
401
429
|
|
|
402
430
|
Compatibility is probed by **capability**, not by a version string: the skill's
|
|
403
431
|
maintenance contract permits only `name` and `description` in frontmatter, so
|
|
404
432
|
it cannot declare a version. The result carries a short content digest as a
|
|
405
433
|
version hint.
|
|
406
434
|
|
|
407
|
-
|
|
408
|
-
output. The
|
|
409
|
-
|
|
435
|
+
Paths, fingerprints and compatibility details stay **internal**, absent from
|
|
436
|
+
every tool output and overview. The overview exposes only availability. The skill
|
|
437
|
+
forbids naming its files, versions or repository in learner-facing text.
|
|
410
438
|
|
|
411
439
|
### How the two halves agree
|
|
412
440
|
|
|
@@ -492,18 +520,61 @@ Machine Learning [goal, unconfirmed]
|
|
|
492
520
|
└─ Python [prerequisite, unconfirmed]
|
|
493
521
|
```
|
|
494
522
|
|
|
523
|
+
Two query parameters reach the states that are otherwise awkward to produce on
|
|
524
|
+
demand — `?scenario=` (`first-use`, `map`, `handoff`, `next`, `stay`, `noskill`,
|
|
525
|
+
`long`, `scroll`, `library`, `new`) and `?surface=tab` for the docked right-sidebar
|
|
526
|
+
tab. The scenario switch is in the page's toolbar, and the theme toggle sets the same
|
|
527
|
+
`body[data-ds-dark-theme]` attribute DSH does. The page carries the real DSH
|
|
528
|
+
design tokens for the same reason: with hardcoded fallbacks in their place, a
|
|
529
|
+
token that resolves to near-white in dark mode looks perfectly fine locally.
|
|
530
|
+
|
|
495
531
|
To capture the panel from a *live* profile instead:
|
|
496
532
|
|
|
497
533
|
```sh
|
|
498
534
|
pnpm screenshot "<dsh-url-with-token>" preview/dsh-ui.png
|
|
499
535
|
```
|
|
500
536
|
|
|
537
|
+
### Auditing the presentation
|
|
538
|
+
|
|
539
|
+
```sh
|
|
540
|
+
pnpm preview # in one shell
|
|
541
|
+
pnpm audit:ui http://127.0.0.1:8391/?scenario=next # in another
|
|
542
|
+
pnpm audit:ui "<dsh-url-with-token>" /tmp/audit --surface=tab
|
|
543
|
+
pnpm check:scroll http://127.0.0.1:8391/ /tmp/udt-scroll-check
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
The scroll regression requires local Chrome and runs against the built bundle.
|
|
547
|
+
The preview uses a clipped, fixed-height slot like DSH, so the whole document
|
|
548
|
+
cannot scroll to mask an unscrollable plugin. The check sends real wheel events
|
|
549
|
+
and verifies bottom content remains visible in the tab, expanded tab, stacked
|
|
550
|
+
panel, all three wide-panel panes, and long library/welcome views, in both themes.
|
|
551
|
+
Only fixture data is used; no live learner state is changed. The ordinary visual
|
|
552
|
+
audit alone does **not** verify scrolling.
|
|
553
|
+
|
|
554
|
+
The visual audit measures, at each width this project targets and in both themes: the contrast
|
|
555
|
+
of every control's own text against its own fill, class names in the DOM that no
|
|
556
|
+
rule matches, how many controls carry primary weight in one view, tap targets
|
|
557
|
+
under 28px, type under 10.5px, and anything spilling or clipped. It prints a
|
|
558
|
+
table, writes `findings.json`, and saves screenshots — it does not fail the run,
|
|
559
|
+
so a human can disagree with it.
|
|
560
|
+
|
|
561
|
+
That is not a substitute for looking. It is what makes looking affordable: the
|
|
562
|
+
docked tab's invisible primary button and the dark-mode 1.05:1 controls were
|
|
563
|
+
both found by it, in a live profile, in seconds.
|
|
564
|
+
|
|
565
|
+
What the surfaces look like now, captured from a live profile:
|
|
566
|
+
|
|
567
|
+
| | light | dark |
|
|
568
|
+
| --- | --- | --- |
|
|
569
|
+
| full panel |  |  |
|
|
570
|
+
| docked tab |  |  |
|
|
571
|
+
|
|
501
572
|
## Development
|
|
502
573
|
|
|
503
574
|
```sh
|
|
504
575
|
pnpm install
|
|
505
576
|
pnpm typecheck # tsc --noEmit (host and client)
|
|
506
|
-
pnpm test #
|
|
577
|
+
pnpm test # 455 tests: unit, guard, DOM, render, real composition
|
|
507
578
|
pnpm build # tsc -> lib/ (host) + tsdown -> lib/client.js
|
|
508
579
|
```
|
|
509
580
|
|
|
@@ -571,18 +642,19 @@ panel opens, the map and the lesson surface render, and `GET /export` and
|
|
|
571
642
|
`POST /reset` work. What does not happen is teaching — nothing writes a lesson,
|
|
572
643
|
because nothing is making teaching decisions.
|
|
573
644
|
|
|
574
|
-
**
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
645
|
+
**Availability is checked for the conversation you have open.** On every
|
|
646
|
+
`GET /overview?sessionId=…`, the plugin resolves that session's live Agent from
|
|
647
|
+
DSH's Agent registry and asks the Skill registry for a complete snapshot using
|
|
648
|
+
`{ scope: agent, cwd: agent.session.header.cwd }`. A root catalog containing
|
|
649
|
+
Office skills cannot establish whether that Agent has a tutor. Services are
|
|
650
|
+
resolved for each request, so delayed registration, removal and preset changes
|
|
651
|
+
are picked up by the panel's existing polling. The runtime reports three states:
|
|
580
652
|
|
|
581
653
|
| `teachingBrain` | meaning | what the panel does |
|
|
582
654
|
| --- | --- | --- |
|
|
583
|
-
| `true` | found | nothing |
|
|
584
|
-
| `false` |
|
|
585
|
-
| `null` |
|
|
655
|
+
| `true` | found and model-invocable in the live Agent scope | nothing |
|
|
656
|
+
| `false` | unavailable after complete scoped discovery with entries | says a tutor is needed |
|
|
657
|
+
| `null` | no live Agent, empty/incomplete discovery, or unsupported/unavailable service | says nothing |
|
|
586
658
|
|
|
587
659
|
An earlier revision treated "not in my catalog" as "not installed" and showed a
|
|
588
660
|
notice on that basis. In a web profile that was a confident, wrong answer — the
|
package/lib/api.js
CHANGED
|
@@ -102,24 +102,104 @@ function stringField(body, key) {
|
|
|
102
102
|
/* Handler */
|
|
103
103
|
/* -------------------------------------------------------------------------- */
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
105
|
+
* The focus this conversation is working on, or nothing.
|
|
106
|
+
*
|
|
107
|
+
* Learning state belongs to a conversation, not to a course. The course and its
|
|
108
|
+
* map and its lessons are shared knowledge and stay visible to everyone; where
|
|
109
|
+
* *this* conversation has got to is not. A focus carrying a different
|
|
110
|
+
* conversation's id — or none at all, which means it predates the field — is
|
|
111
|
+
* not this panel's to show, so it reports nothing rather than someone else's
|
|
112
|
+
* lesson under the learner's chat.
|
|
113
|
+
*
|
|
114
|
+
* @param state - the runtime.
|
|
115
|
+
* @param courseId - the course being viewed.
|
|
116
|
+
* @param sessionId - the conversation asking, when it said.
|
|
117
|
+
* @returns the focus record, or undefined.
|
|
107
118
|
*/
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
function focusBelongingTo(state, courseId, sessionId) {
|
|
120
|
+
// No conversation named means no conversation's state: guessing is what let
|
|
121
|
+
// one conversation's lesson appear under another's chat.
|
|
122
|
+
if (sessionId === undefined || sessionId.length === 0)
|
|
123
|
+
return undefined;
|
|
124
|
+
return state.readFocus(sessionId, courseId);
|
|
125
|
+
}
|
|
126
|
+
/** The project a conversation has open, if any. */
|
|
127
|
+
function projectFor(state, sessionId) {
|
|
128
|
+
return state.resolveProject(sessionId).course;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Every Learning Project, with the numbers the library shows on a card.
|
|
132
|
+
*
|
|
133
|
+
* Learner-level, like the maps and lessons themselves: this is what the
|
|
134
|
+
* learner has, not what this conversation is doing with it.
|
|
135
|
+
*/
|
|
136
|
+
function libraryOf(state) {
|
|
115
137
|
return state
|
|
116
138
|
.listCourses()
|
|
117
|
-
.
|
|
118
|
-
|
|
139
|
+
.map((course) => {
|
|
140
|
+
const nodes = state.listNodes(course.id);
|
|
141
|
+
return {
|
|
142
|
+
id: course.id,
|
|
143
|
+
title: course.title,
|
|
144
|
+
goal: course.goal,
|
|
145
|
+
confirmed: nodes.filter((node) => node.state === 'confirmed').length,
|
|
146
|
+
total: nodes.length,
|
|
147
|
+
updatedAt: course.updatedAt,
|
|
148
|
+
};
|
|
149
|
+
})
|
|
150
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
151
|
+
}
|
|
152
|
+
async function handleOverview(state, deps, sessionId, res) {
|
|
153
|
+
// Availability belongs to the requesting conversation and can change while
|
|
154
|
+
// the plugin stays loaded. A failed observation is inconclusive.
|
|
155
|
+
let teachingBrain = null;
|
|
156
|
+
try {
|
|
157
|
+
teachingBrain = typeof deps.teachingBrain === 'function'
|
|
158
|
+
? await deps.teachingBrain(sessionId)
|
|
159
|
+
: deps.teachingBrain ?? null;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// The runtime remains readable if discovery is temporarily unavailable.
|
|
163
|
+
}
|
|
164
|
+
const { selection, course } = state.resolveProject(sessionId);
|
|
165
|
+
const library = libraryOf(state);
|
|
166
|
+
if (course === undefined) {
|
|
167
|
+
// No project open in this conversation. That is the library, not an error,
|
|
168
|
+
// and not an empty runtime: the projects it lists are real and shared.
|
|
169
|
+
sendJson(res, 200, {
|
|
170
|
+
ok: true,
|
|
171
|
+
selection,
|
|
172
|
+
course: null,
|
|
173
|
+
nodes: [],
|
|
174
|
+
focus: null,
|
|
175
|
+
nextStep: null,
|
|
176
|
+
handoff: null,
|
|
177
|
+
teachingBrain,
|
|
178
|
+
lessonCount: state.lessonCount(),
|
|
179
|
+
library,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const mine = focusBelongingTo(state, course.id, sessionId);
|
|
184
|
+
sendJson(res, 200, {
|
|
185
|
+
ok: true,
|
|
186
|
+
selection,
|
|
187
|
+
course: courseView(course),
|
|
188
|
+
nodes: state.listNodes(course.id).map(nodeView),
|
|
189
|
+
focus: mine === undefined ? null : focusOf(state, sessionId ?? '', course.id),
|
|
190
|
+
// A decision is attached to a focus, so it follows the same rule.
|
|
191
|
+
nextStep: mine === undefined ? null : nextStepOf(state, sessionId ?? '', course.id),
|
|
192
|
+
teachingBrain,
|
|
193
|
+
// The handoff for whichever node is focused, so a reload or a restart
|
|
194
|
+
// rebuilds the progress line instead of showing a blank wait.
|
|
195
|
+
handoff: mine === undefined ? null : handoffFor(state, sessionId ?? '', mine.nodeId, Date.now()),
|
|
196
|
+
lessonCount: state.lessonCount(),
|
|
197
|
+
library,
|
|
198
|
+
});
|
|
119
199
|
}
|
|
120
200
|
/** The active focus for one course, joined with its node, as a view. */
|
|
121
|
-
function focusOf(state, courseId) {
|
|
122
|
-
const focus = state.readFocus(courseId);
|
|
201
|
+
function focusOf(state, sessionId, courseId) {
|
|
202
|
+
const focus = state.readFocus(sessionId, courseId);
|
|
123
203
|
if (focus === undefined || focus.status !== 'active')
|
|
124
204
|
return null;
|
|
125
205
|
const node = state.readNode(focus.nodeId);
|
|
@@ -135,11 +215,11 @@ function focusOf(state, courseId) {
|
|
|
135
215
|
* fresh record without `nextStepId`, so acting on a recommendation clears it
|
|
136
216
|
* without anything having to remember that it did.
|
|
137
217
|
*/
|
|
138
|
-
function nextStepOf(state, courseId) {
|
|
139
|
-
const focus = state.readFocus(courseId);
|
|
218
|
+
function nextStepOf(state, sessionId, courseId) {
|
|
219
|
+
const focus = state.readFocus(sessionId, courseId);
|
|
140
220
|
if (focus?.nextStepId === undefined)
|
|
141
221
|
return null;
|
|
142
|
-
const step = state.readNextStep(focus.nextStepId);
|
|
222
|
+
const step = state.readNextStep(sessionId, focus.nextStepId);
|
|
143
223
|
if (step === undefined)
|
|
144
224
|
return null;
|
|
145
225
|
const from = state.readNode(step.fromNodeId);
|
|
@@ -154,55 +234,63 @@ function nextStepOf(state, courseId) {
|
|
|
154
234
|
createdAt: step.createdAt,
|
|
155
235
|
};
|
|
156
236
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
237
|
+
/**
|
|
238
|
+
* The focus this conversation is working on, or nothing.
|
|
239
|
+
*
|
|
240
|
+
* Learning state belongs to a conversation, not to a course. The course and its
|
|
241
|
+
* map and its lessons are shared knowledge and stay visible to everyone; where
|
|
242
|
+
* *this* conversation has got to is not. A focus carrying a different
|
|
243
|
+
* conversation's id — or none at all, which means it predates the field — is
|
|
244
|
+
* not this panel's to show, so it reports nothing rather than someone else's
|
|
245
|
+
* lesson under the learner's chat.
|
|
246
|
+
*
|
|
247
|
+
* @param state - the runtime.
|
|
248
|
+
* @param courseId - the course being viewed.
|
|
249
|
+
* @param sessionId - the conversation asking, when it said.
|
|
250
|
+
* @returns the focus record, or undefined.
|
|
251
|
+
*/
|
|
252
|
+
/**
|
|
253
|
+
* `POST /project { sessionId, courseId? }` — choose what this conversation learns.
|
|
254
|
+
*
|
|
255
|
+
* The choice is the conversation's, and that is the whole point of the route:
|
|
256
|
+
* the projects are shared, but opening one is a decision a conversation makes
|
|
257
|
+
* and keeps. Sending no `courseId` selects "start something new", which is a
|
|
258
|
+
* deliberate state — it stops the library being shown and stops any project
|
|
259
|
+
* being opened, until the tutor creates one here.
|
|
260
|
+
*
|
|
261
|
+
* Selecting never fabricates execution state. There is no focus until the
|
|
262
|
+
* learner starts a node, so switching projects shows "no learning focus yet"
|
|
263
|
+
* rather than pretending the previous project's position carried over.
|
|
264
|
+
*/
|
|
265
|
+
async function handleSelectProject(state, req, res) {
|
|
266
|
+
const body = await readJsonBody(req);
|
|
267
|
+
if (body === undefined)
|
|
268
|
+
return fail(res, 400, 'bad-body');
|
|
269
|
+
const sessionId = stringField(body, 'sessionId');
|
|
270
|
+
if (sessionId === undefined)
|
|
271
|
+
return fail(res, 400, 'missing-session-id');
|
|
272
|
+
const courseId = stringField(body, 'courseId');
|
|
273
|
+
if (courseId !== undefined && state.readCourse(courseId) === undefined) {
|
|
274
|
+
return fail(res, 404, 'no-such-course');
|
|
175
275
|
}
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
});
|
|
276
|
+
const record = await state.selectProject(sessionId, courseId, new Date().toISOString());
|
|
277
|
+
sendJson(res, 200, { ok: true, project: record });
|
|
191
278
|
}
|
|
192
279
|
/** `GET /lesson?nodeId=` — the lesson the tutor has written for a node, if any. */
|
|
193
|
-
function handleLesson(state,
|
|
194
|
-
|
|
195
|
-
|
|
280
|
+
function handleLesson(state, nodeId, res) {
|
|
281
|
+
// Resolved from the node, not from the caller's project: a lesson belongs to
|
|
282
|
+
// the learner, and asking for one should not depend on what this conversation
|
|
283
|
+
// happens to have open.
|
|
196
284
|
const node = state.readNode(nodeId);
|
|
197
|
-
if (node === undefined || node.courseId
|
|
285
|
+
if (node === undefined || state.readCourse(node.courseId) === undefined) {
|
|
198
286
|
return fail(res, 404, 'no-such-node');
|
|
287
|
+
}
|
|
199
288
|
sendJson(res, 200, { ok: true, lesson: state.lessonForNode(nodeId) ?? null });
|
|
200
289
|
}
|
|
201
|
-
function handleNode(state,
|
|
202
|
-
if (course === undefined)
|
|
203
|
-
return fail(res, 404, 'no-course');
|
|
290
|
+
function handleNode(state, nodeId, res) {
|
|
204
291
|
const node = state.readNode(nodeId);
|
|
205
|
-
|
|
292
|
+
const course = node === undefined ? undefined : state.readCourse(node.courseId);
|
|
293
|
+
if (node === undefined || course === undefined)
|
|
206
294
|
return fail(res, 404, 'no-such-node');
|
|
207
295
|
const parent = node.parentId === undefined ? undefined : state.readNode(node.parentId);
|
|
208
296
|
const children = state
|
|
@@ -229,8 +317,8 @@ function handleNode(state, course, nodeId, res) {
|
|
|
229
317
|
/** How long a handoff is left alone before another request is treated as a retry. */
|
|
230
318
|
const HANDOFF_DEDUPE_MS = 3000;
|
|
231
319
|
/** Build the handoff view, which is what a surface renders. */
|
|
232
|
-
function handoffFor(state, nodeId, nowMs) {
|
|
233
|
-
const record = state.readHandoff(nodeId);
|
|
320
|
+
function handoffFor(state, sessionId, nodeId, nowMs) {
|
|
321
|
+
const record = state.readHandoff(sessionId, nodeId);
|
|
234
322
|
if (record === undefined)
|
|
235
323
|
return null;
|
|
236
324
|
return handoffView(record, nowMs, state.lessonForNode(nodeId) !== undefined);
|
|
@@ -251,9 +339,7 @@ function handoffFor(state, nodeId, nowMs) {
|
|
|
251
339
|
* returns it untouched, which is what makes a double click harmless; a request
|
|
252
340
|
* after it has gone quiet is a retry and bumps the attempt count.
|
|
253
341
|
*/
|
|
254
|
-
async function handleStartFocus(state, deps,
|
|
255
|
-
if (course === undefined)
|
|
256
|
-
return fail(res, 404, 'no-course');
|
|
342
|
+
async function handleStartFocus(state, deps, req, res) {
|
|
257
343
|
const body = await readJsonBody(req);
|
|
258
344
|
if (body === undefined)
|
|
259
345
|
return fail(res, 400, 'bad-body');
|
|
@@ -261,38 +347,51 @@ async function handleStartFocus(state, deps, course, req, res) {
|
|
|
261
347
|
if (nodeId === undefined)
|
|
262
348
|
return fail(res, 400, 'missing-node-id');
|
|
263
349
|
const sessionId = stringField(body, 'sessionId');
|
|
350
|
+
// Required: a focus that cannot say whose it is cannot be shown to anyone,
|
|
351
|
+
// so accepting one would create state nobody could ever see.
|
|
352
|
+
if (sessionId === undefined)
|
|
353
|
+
return fail(res, 400, 'missing-session-id');
|
|
354
|
+
// The course comes from the NODE, not from what this conversation had open.
|
|
355
|
+
// Starting a node in a project is choosing that project — the other
|
|
356
|
+
// direction is the one that must not happen, and does not: selecting a
|
|
357
|
+
// project never invents a focus.
|
|
264
358
|
const node = state.readNode(nodeId);
|
|
265
|
-
|
|
359
|
+
const course = node === undefined ? undefined : state.readCourse(node.courseId);
|
|
360
|
+
if (node === undefined || course === undefined)
|
|
266
361
|
return fail(res, 404, 'no-such-node');
|
|
362
|
+
const chosen = state.readProject(sessionId);
|
|
363
|
+
if (chosen?.courseId !== course.id) {
|
|
364
|
+
await state.selectProject(sessionId, course.id, new Date().toISOString());
|
|
365
|
+
}
|
|
267
366
|
const now = new Date();
|
|
268
|
-
const previous = state.readHandoff(nodeId);
|
|
367
|
+
const previous = state.readHandoff(sessionId, nodeId);
|
|
269
368
|
// Double-click protection: the same target, still in flight and recent, is
|
|
270
369
|
// the same handoff. Not an error — just nothing new to do.
|
|
271
370
|
if (previous !== undefined &&
|
|
272
371
|
previous.status !== 'failed' &&
|
|
273
372
|
now.getTime() - Date.parse(previous.requestedAt) < HANDOFF_DEDUPE_MS) {
|
|
274
|
-
const focus = state.readFocus(course.id);
|
|
373
|
+
const focus = state.readFocus(sessionId, course.id);
|
|
275
374
|
sendJson(res, 200, {
|
|
276
375
|
ok: true,
|
|
277
376
|
focus: focus === undefined ? null : focusView(focus, node),
|
|
278
377
|
prompted: previous.promptedAt !== undefined,
|
|
279
|
-
handoff: handoffFor(state, nodeId, now.getTime()),
|
|
378
|
+
handoff: handoffFor(state, sessionId, nodeId, now.getTime()),
|
|
280
379
|
deduped: true,
|
|
281
380
|
});
|
|
282
381
|
return;
|
|
283
382
|
}
|
|
284
383
|
let handoff = beginHandoff({
|
|
285
384
|
courseId: course.id,
|
|
286
|
-
fromNodeId: previous?.fromNodeId ?? state.readFocus(course.id)?.nodeId ?? nodeId,
|
|
385
|
+
fromNodeId: previous?.fromNodeId ?? state.readFocus(sessionId, course.id)?.nodeId ?? nodeId,
|
|
287
386
|
targetNodeId: nodeId,
|
|
288
387
|
now: now.toISOString(),
|
|
289
|
-
|
|
388
|
+
sessionId,
|
|
290
389
|
...(previous === undefined ? {} : { previous }),
|
|
291
390
|
});
|
|
292
391
|
await state.writeHandoff(handoff);
|
|
293
392
|
let focus;
|
|
294
393
|
try {
|
|
295
|
-
focus = await state.startFocus(course.id, nodeId, now.toISOString());
|
|
394
|
+
focus = await state.startFocus(course.id, nodeId, now.toISOString(), sessionId);
|
|
296
395
|
}
|
|
297
396
|
catch (error) {
|
|
298
397
|
// Covers an unknown node and a node from another course alike.
|
|
@@ -310,7 +409,7 @@ async function handleStartFocus(state, deps, course, req, res) {
|
|
|
310
409
|
focus: focusView(focus, node),
|
|
311
410
|
prompted: outcome.prompted,
|
|
312
411
|
...(outcome.reason === undefined ? {} : { promptReason: outcome.reason }),
|
|
313
|
-
handoff: handoffFor(state, nodeId, Date.now()),
|
|
412
|
+
handoff: handoffFor(state, sessionId, nodeId, Date.now()),
|
|
314
413
|
});
|
|
315
414
|
}
|
|
316
415
|
/**
|
|
@@ -350,11 +449,14 @@ async function handleObserved(state, req, res) {
|
|
|
350
449
|
const nodeId = stringField(body, 'nodeId');
|
|
351
450
|
if (nodeId === undefined)
|
|
352
451
|
return fail(res, 400, 'missing-node-id');
|
|
353
|
-
const
|
|
452
|
+
const sessionId = stringField(body, 'sessionId');
|
|
453
|
+
if (sessionId === undefined)
|
|
454
|
+
return fail(res, 400, 'missing-session-id');
|
|
455
|
+
const record = state.readHandoff(sessionId, nodeId);
|
|
354
456
|
if (record === undefined)
|
|
355
457
|
return fail(res, 404, 'no-handoff');
|
|
356
458
|
await state.writeHandoff(withObserved(record, new Date().toISOString()));
|
|
357
|
-
sendJson(res, 200, { ok: true, handoff: handoffFor(state, nodeId, Date.now()) });
|
|
459
|
+
sendJson(res, 200, { ok: true, handoff: handoffFor(state, sessionId, nodeId, Date.now()) });
|
|
358
460
|
}
|
|
359
461
|
/**
|
|
360
462
|
* Build the request handler.
|
|
@@ -368,9 +470,15 @@ export function createApiHandler(deps) {
|
|
|
368
470
|
const method = req.method ?? 'GET';
|
|
369
471
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
370
472
|
const route = url.pathname.slice(API_PREFIX.length) || '/';
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
473
|
+
// Read per request, not once: a conversation's project is its own, and the
|
|
474
|
+
// route that changes it must not be answered from a value captured before.
|
|
475
|
+
const asker = url.searchParams.get('sessionId') ?? undefined;
|
|
476
|
+
const course = projectFor(state, asker);
|
|
477
|
+
if (method === 'GET' && route === '/overview') {
|
|
478
|
+
return handleOverview(state, deps, url.searchParams.get('sessionId') ?? undefined, res);
|
|
479
|
+
}
|
|
480
|
+
if (method === 'POST' && route === '/project')
|
|
481
|
+
return await handleSelectProject(state, req, res);
|
|
374
482
|
if (method === 'GET' && route === '/export')
|
|
375
483
|
return handleExport(state, res);
|
|
376
484
|
if (method === 'POST' && route === '/reset')
|
|
@@ -379,16 +487,16 @@ export function createApiHandler(deps) {
|
|
|
379
487
|
const nodeId = url.searchParams.get('id');
|
|
380
488
|
if (nodeId === null || nodeId.length === 0)
|
|
381
489
|
return fail(res, 400, 'missing-id');
|
|
382
|
-
return handleNode(state,
|
|
490
|
+
return handleNode(state, nodeId, res);
|
|
383
491
|
}
|
|
384
492
|
if (method === 'GET' && route === '/lesson') {
|
|
385
493
|
const nodeId = url.searchParams.get('nodeId');
|
|
386
494
|
if (nodeId === null || nodeId.length === 0)
|
|
387
495
|
return fail(res, 400, 'missing-node-id');
|
|
388
|
-
return handleLesson(state,
|
|
496
|
+
return handleLesson(state, nodeId, res);
|
|
389
497
|
}
|
|
390
498
|
if (method === 'POST' && route === '/focus') {
|
|
391
|
-
return handleStartFocus(state, deps,
|
|
499
|
+
return handleStartFocus(state, deps, req, res);
|
|
392
500
|
}
|
|
393
501
|
if (method === 'POST' && route === '/handoff/observed') {
|
|
394
502
|
return handleObserved(state, req, res);
|