dsh-nerd-mode 0.4.5 → 0.5.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.
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { readFileSync } from 'node:fs'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { COMMAND, KEY, fold, init } from './fold.js'
14
+ import { scan } from './study-sessions.js'
14
15
  import z from '@deepseek-ai/schemastery'
15
16
 
16
17
  export const name = 'dsh-nerd-mode'
@@ -38,6 +39,7 @@ export const Config = z.object({
38
39
  const SECTION = 'study:policy'
39
40
  // Between PLAN_POLICY (500) and TEAM_POLICY (600).
40
41
  const SECTION_ORDER = 550
42
+ const STUDY_SESSIONS_PATH = '/dsh-nerd-mode/api/study-sessions'
41
43
  const studyPlusDefaultsBySession = new WeakMap()
42
44
  const directStudyPlusBySession = new WeakMap()
43
45
  // A PTC skill load leaves no message in the history, so keep its id for later
@@ -162,6 +164,53 @@ const projection = {
162
164
  },
163
165
  }
164
166
 
167
+ function sendJson(res, status, body) {
168
+ res.writeHead(status, {
169
+ 'content-type': 'application/json; charset=utf-8',
170
+ 'cache-control': 'no-store',
171
+ })
172
+ res.end(JSON.stringify(body))
173
+ }
174
+
175
+ function cursorOf(req) {
176
+ const match = /[?&]cursor=(\d+)/.exec(req?.url ?? '')
177
+ return match === null ? 0 : Number.parseInt(match[1], 10)
178
+ }
179
+
180
+ /**
181
+ * Build the read-only history route handler.
182
+ *
183
+ * `webServer.register` adds no auth fence, so the connection service decides
184
+ * first: a missing cookie is 401 and a bad Host or Origin is 403.
185
+ *
186
+ * @param options
187
+ * @param options.connection - the host connection service
188
+ * @param options.indexFile - forwarded to `scan`; tests point it at a temp file
189
+ * @param options.sessionsRoot - forwarded to `scan`; tests point it at a temp tree
190
+ * @returns {(req, res) => Promise<void>} replies `{ phase, cursor, done,
191
+ * total, sessions }` with `no-store`, `{ error }` with status 500, or the
192
+ * rejection status
193
+ */
194
+ export function createStudySessionsHandler({ connection, ...scanOptions }) {
195
+ return async function studySessionsRoute(req, res) {
196
+ const rejection = connection.requestRejection(req)
197
+ if (rejection !== undefined) {
198
+ res.writeHead(rejection)
199
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
200
+ return
201
+ }
202
+ if (req.method !== 'GET') {
203
+ sendJson(res, 405, { error: 'Method not allowed.' })
204
+ return
205
+ }
206
+ try {
207
+ sendJson(res, 200, scan({ ...scanOptions, cursor: cursorOf(req) }))
208
+ } catch (error) {
209
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
210
+ }
211
+ }
212
+ }
213
+
165
214
  /**
166
215
  * @param config - `section` is an optional overlay text; a missing value uses
167
216
  * the bundled `assets/section.md`.
@@ -220,4 +269,18 @@ export function apply(ctx, config) {
220
269
  },
221
270
  })
222
271
  })
272
+
273
+ // The web server is optional: a host without it still loads the switch and
274
+ // the prompt section, and the panel shows its failure line.
275
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
276
+ webCtx.effect(
277
+ () =>
278
+ webCtx.webServer.register({
279
+ kind: 'exact',
280
+ path: STUDY_SESSIONS_PATH,
281
+ handler: createStudySessionsHandler({ connection: webCtx.connection }),
282
+ }),
283
+ 'dsh-nerd-mode: study sessions route',
284
+ )
285
+ })
223
286
  }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The Study Sessions history index. It reads the session logs the host writes
3
+ * and caches the two facts the panel needs: whether Nerd Mode was ever on, and
4
+ * whether a completed `/study-plus` invocation appears. Kept free of host
5
+ * imports like `lib/fold.js`, so it runs under plain node. See ADR 0021.
6
+ */
7
+
8
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { zstdDecompressSync } from 'node:zlib'
12
+
13
+ const COMMAND = 'nerd-mode'
14
+ const STUDY_PLUS = 'study-plus'
15
+ const SESSION_FILE = 'session.v4.jsonl.zstd'
16
+ const DEFAULT_INDEX_FILE = join(homedir(), '.dsh', 'study-sessions-index.json')
17
+ const DEFAULT_SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
18
+ const DEFAULT_CHUNK_SIZE = 20
19
+ const MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
20
+ // The token must stand alone: `not/study-plus` and `/study-plussed` are not
21
+ // invocations. This is the same boundary the live command parser uses.
22
+ const DIRECT_COMMAND = /(^|\s)\/study-plus(?=\s|$)/
23
+
24
+ function isRecord(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ }
27
+
28
+ function messageText(message) {
29
+ const content = message?.content
30
+ if (!Array.isArray(content)) return ''
31
+ return content
32
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
33
+ .map((block) => block.text)
34
+ .join('\n')
35
+ }
36
+
37
+ function skillNameOf(argumentText) {
38
+ if (typeof argumentText !== 'string') return undefined
39
+ try {
40
+ return JSON.parse(argumentText)?.name
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read the two history facts from one session's decoded events.
48
+ * @param events - decoded session log events, in log order
49
+ * @returns {{ nerd: boolean, studyPlus: boolean }} `nerd` means Nerd Mode was
50
+ * switched on at least once, `studyPlus` means a completed `/study-plus`
51
+ * invocation appears. Malformed events are ignored.
52
+ */
53
+ export function classify(events) {
54
+ const successfulCommands = new Set()
55
+ const wantedCommands = new Set()
56
+ const skillCalls = new Set()
57
+ const completedCalls = new Set()
58
+ let studyPlus = false
59
+
60
+ for (const event of events) {
61
+ const data = event?.data
62
+ if (event?.type === 'command/run') {
63
+ if (data?.name !== COMMAND) continue
64
+ // A bare /nerd-mode means on, matching lib/fold.js.
65
+ const args = typeof data.args === 'string' ? data.args : ''
66
+ if (args.trim().toLowerCase() !== 'off' && typeof data.commandId === 'string') {
67
+ wantedCommands.add(data.commandId)
68
+ }
69
+ } else if (event?.type === 'command/done') {
70
+ if (data?.kind === 'success' && typeof data.commandId === 'string') {
71
+ successfulCommands.add(data.commandId)
72
+ }
73
+ } else if (event?.type === 'user/message') {
74
+ const source = data?.source
75
+ if (source?.kind === 'skill-invocation') {
76
+ if (source.name === STUDY_PLUS) studyPlus = true
77
+ } else if (source?.kind === 'user' && DIRECT_COMMAND.test(messageText(data))) {
78
+ studyPlus = true
79
+ }
80
+ } else if (event?.type === 'tool/call') {
81
+ if (data?.name === 'skill' && typeof data.callId === 'string' && skillNameOf(data.arguments) === STUDY_PLUS) {
82
+ skillCalls.add(data.callId)
83
+ }
84
+ } else if (event?.type === 'tool/result') {
85
+ const message = data?.message
86
+ if (message?.isError !== true && typeof message?.source?.callId === 'string') {
87
+ completedCalls.add(message.source.callId)
88
+ }
89
+ } else if (event?.type === 'tool/ptc-dispatch') {
90
+ // A skill load inside run_code arrives as a dispatch and carries its own outcome.
91
+ if (data?.name === 'skill' && data?.arguments?.name === STUDY_PLUS && data?.isError !== true) {
92
+ studyPlus = true
93
+ }
94
+ }
95
+ }
96
+
97
+ if (!studyPlus) {
98
+ studyPlus = [...skillCalls].some((callId) => completedCalls.has(callId))
99
+ }
100
+ return {
101
+ nerd: [...wantedCommands].some((commandId) => successfulCommands.has(commandId)),
102
+ studyPlus,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Decode a session log into events.
108
+ *
109
+ * The file is a chain of zstd frames, one per append batch. Whole-file
110
+ * decompression stops at the first frame, which holds only the header, so each
111
+ * frame is decoded on its own.
112
+ *
113
+ * @param file - path to a `session.v4.jsonl.zstd` file
114
+ * @returns the decoded events; malformed frames and lines are dropped
115
+ * @throws when the file itself cannot be read
116
+ */
117
+ export function decodeSessionFile(file) {
118
+ const bytes = readFileSync(file)
119
+ const starts = []
120
+ for (let offset = 0; offset <= bytes.length - MAGIC.length; offset += 1) {
121
+ if (MAGIC.every((byte, index) => bytes[offset + index] === byte)) starts.push(offset)
122
+ }
123
+
124
+ const events = []
125
+ for (let index = 0; index < starts.length; index += 1) {
126
+ let text
127
+ try {
128
+ text = zstdDecompressSync(bytes.subarray(starts[index], starts[index + 1] ?? bytes.length)).toString('utf8')
129
+ } catch {
130
+ // A magic byte sequence can occur inside compressed data, so a failed
131
+ // slice is expected. See docs/agents/dsh-session-api.md.
132
+ continue
133
+ }
134
+ for (const line of text.split('\n')) {
135
+ if (line === '') continue
136
+ try {
137
+ events.push(JSON.parse(line))
138
+ } catch {
139
+ // A crash can leave a partial trailing line. One bad record must not
140
+ // hide the rest of the log.
141
+ }
142
+ }
143
+ }
144
+ return events
145
+ }
146
+
147
+ function readIndex(file) {
148
+ let text
149
+ try {
150
+ text = readFileSync(file, 'utf8')
151
+ } catch (error) {
152
+ if (error?.code === 'ENOENT') return {}
153
+ // A missing index is a first run. Any other failure reaches the panel.
154
+ throw error
155
+ }
156
+ try {
157
+ const index = JSON.parse(text)
158
+ return isRecord(index) ? index : {}
159
+ } catch {
160
+ // A corrupt cache is rebuilt from the logs, not reported as a failure.
161
+ return {}
162
+ }
163
+ }
164
+
165
+ function writeIndex(file, index) {
166
+ mkdirSync(dirname(file), { recursive: true })
167
+ const temp = `${file}.tmp`
168
+ writeFileSync(temp, JSON.stringify(index))
169
+ // Rename keeps the previous index readable if the process dies mid-write.
170
+ renameSync(temp, file)
171
+ }
172
+
173
+ function listSessionFiles(root) {
174
+ let workspaces
175
+ try {
176
+ workspaces = readdirSync(root, { withFileTypes: true })
177
+ } catch (error) {
178
+ if (error?.code === 'ENOENT') return { files: [], complete: true }
179
+ throw error
180
+ }
181
+
182
+ const files = []
183
+ let complete = true
184
+ for (const workspace of workspaces) {
185
+ if (!workspace.isDirectory()) continue
186
+ let sessions
187
+ try {
188
+ sessions = readdirSync(join(root, workspace.name), { withFileTypes: true })
189
+ } catch {
190
+ // One locked workspace must not blank the others, and must not prune them.
191
+ complete = false
192
+ continue
193
+ }
194
+ for (const session of sessions) {
195
+ if (!session.isDirectory()) continue
196
+ const file = join(root, workspace.name, session.name, SESSION_FILE)
197
+ try {
198
+ const stat = statSync(file)
199
+ if (stat.isFile()) {
200
+ files.push({ id: session.name, file, mtimeMs: stat.mtimeMs, size: stat.size })
201
+ }
202
+ } catch {
203
+ // A session folder without a readable log is not a session yet.
204
+ }
205
+ }
206
+ }
207
+ files.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0))
208
+ return { files, complete }
209
+ }
210
+
211
+ /**
212
+ * Classify one chunk of the session tree and persist the index.
213
+ *
214
+ * @param options
215
+ * @param options.cursor - index of the first file in the sorted session list to
216
+ * process; negative and non-integer values read as 0
217
+ * @param options.indexFile - index path; defaults to
218
+ * `~/.dsh/study-sessions-index.json`
219
+ * @param options.sessionsRoot - session tree; defaults to `~/.dsh/sessions`
220
+ * @param options.chunkSize - files to process per call; defaults to 20
221
+ * @param options.decode - decoder override for tests; defaults to
222
+ * `decodeSessionFile`
223
+ * @returns {{ phase: 'pending' | 'ready', cursor: number, done: number,
224
+ * total: number, sessions: Record<string, { studyPlus: boolean }> }} `cursor`
225
+ * is the next call's starting index and equals `total` once `phase` is
226
+ * `ready`. `done` counts files visited so far. A key in `sessions` means Nerd
227
+ * Mode was on at least once for that id.
228
+ * @throws when the index or the session tree cannot be read or written
229
+ */
230
+ export function scan({
231
+ cursor = 0,
232
+ indexFile = DEFAULT_INDEX_FILE,
233
+ sessionsRoot = DEFAULT_SESSIONS_ROOT,
234
+ chunkSize = DEFAULT_CHUNK_SIZE,
235
+ decode = decodeSessionFile,
236
+ } = {}) {
237
+ const { files, complete } = listSessionFiles(sessionsRoot)
238
+ const index = readIndex(indexFile)
239
+ if (complete) {
240
+ const live = new Set(files.map(({ id }) => id))
241
+ for (const id of Object.keys(index)) {
242
+ if (!live.has(id)) delete index[id]
243
+ }
244
+ }
245
+
246
+ const start = Number.isInteger(cursor) && cursor > 0 ? cursor : 0
247
+ const size = Number.isInteger(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_CHUNK_SIZE
248
+ const end = Math.min(start + size, files.length)
249
+ for (let position = start; position < end; position += 1) {
250
+ const { id, file, mtimeMs, size: bytes } = files[position]
251
+ const previous = index[id]
252
+ if (previous?.mtimeMs === mtimeMs && previous?.size === bytes) continue
253
+
254
+ let flags
255
+ try {
256
+ flags = classify(decode(file))
257
+ } catch {
258
+ // An unreadable session keeps its previous record and retries next scan.
259
+ continue
260
+ }
261
+ index[id] = {
262
+ mtimeMs,
263
+ size: bytes,
264
+ // Compaction can drop the original event. A fact that disappeared from a
265
+ // rewritten log must not resurface as a missing session.
266
+ nerd: flags.nerd || previous?.nerd === true,
267
+ studyPlus: flags.studyPlus,
268
+ }
269
+ }
270
+ writeIndex(indexFile, index)
271
+
272
+ const sessions = {}
273
+ for (const [id, entry] of Object.entries(index)) {
274
+ if (entry?.nerd === true) sessions[id] = { studyPlus: entry.studyPlus === true }
275
+ }
276
+ return {
277
+ phase: end >= files.length ? 'ready' : 'pending',
278
+ cursor: end,
279
+ done: end,
280
+ total: files.length,
281
+ sessions,
282
+ }
283
+ }
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "dsh-nerd-mode",
3
- "version": "0.4.5",
4
- "description": "A study switch and study skills for DeepSeek Harness.",
5
- "license": "MIT",
6
- "type": "module",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/bocchii-the-code/dsh-nerd-mode.git"
10
- },
11
- "homepage": "https://github.com/bocchii-the-code/dsh-nerd-mode#readme",
12
- "bugs": {
13
- "url": "https://github.com/bocchii-the-code/dsh-nerd-mode/issues"
14
- },
15
- "main": "./lib/index.js",
16
- "exports": {
17
- ".": "./lib/index.js",
18
- "./client": "./client.js",
19
- "./package.json": "./package.json"
20
- },
21
- "files": [
22
- "lib",
23
- "client.js",
24
- "assets",
25
- "cordis.patch.yml",
26
- "skills",
27
- "README.md",
28
- "README.zh.md",
29
- "NOTICE.md",
30
- "LICENSE"
31
- ],
32
- "dsh": {
33
- "bundle": {
34
- "patch": "./cordis.patch.yml"
35
- },
36
- "client": {
37
- "platform": "web",
38
- "immediately": true,
39
- "inject": [
40
- "@deepseek-ai/dsh-client-ui-conversation"
41
- ]
42
- }
43
- },
44
- "scripts": {
45
- "test": "node --test",
46
- "lint": "eslint .",
47
- "check": "npm run lint && npm test"
48
- },
49
- "peerDependencies": {
50
- "@deepseek-ai/schemastery": "^3.18.5"
51
- },
52
- "engines": {
53
- "node": "^22.19.0 || >=24.0.0"
54
- },
55
- "keywords": [
56
- "dsh-plugin",
57
- "deepseek-harness",
58
- "study",
59
- "tutor",
60
- "agent-skill"
61
- ],
62
- "publishConfig": {
63
- "access": "public"
64
- },
65
- "devDependencies": {
66
- "@deepseek-ai/schemastery": "^3.18.5",
67
- "eslint": "^10.13.0",
68
- "globals": "^17.13.0"
69
- }
70
- }
1
+ {
2
+ "name": "dsh-nerd-mode",
3
+ "version": "0.5.0",
4
+ "description": "A study switch and study skills for DeepSeek Harness.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bocchii-the-code/dsh-nerd-mode.git"
10
+ },
11
+ "homepage": "https://github.com/bocchii-the-code/dsh-nerd-mode#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/bocchii-the-code/dsh-nerd-mode/issues"
14
+ },
15
+ "main": "./lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./client.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "lib",
23
+ "client.js",
24
+ "assets",
25
+ "cordis.patch.yml",
26
+ "skills",
27
+ "README.md",
28
+ "README.zh.md",
29
+ "NOTICE.md",
30
+ "LICENSE"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true,
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-conversation"
41
+ ]
42
+ }
43
+ },
44
+ "scripts": {
45
+ "test": "node --test",
46
+ "lint": "eslint .",
47
+ "check": "npm run lint && npm test"
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/schemastery": "^3.18.5"
51
+ },
52
+ "engines": {
53
+ "node": "^22.19.0 || >=24.0.0"
54
+ },
55
+ "keywords": [
56
+ "dsh-plugin",
57
+ "deepseek-harness",
58
+ "study",
59
+ "tutor",
60
+ "agent-skill"
61
+ ],
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "devDependencies": {
66
+ "@deepseek-ai/schemastery": "^3.18.5",
67
+ "eslint": "^10.13.0",
68
+ "globals": "^17.13.0"
69
+ }
70
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: study
3
- description: Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output.
3
+ description: "[v0.5.0] Teach one concept or help complete one task, using the supplied material as scope and defaulting to chat output."
4
4
  argument-hint: "<concept or task>"
5
5
  ---
6
6
  # Study
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: study-plus
3
- description: Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router.
3
+ description: "[v0.5.0] Run one tracked session against a dated learning record. Load for an explicit command or after the user chooses tracked study in the upper router."
4
4
  argument-hint: "<course-slug>, or empty to continue the current Course"
5
5
  ---
6
6
  # Study Plus
@@ -52,15 +52,15 @@ For a new graph:
52
52
  2. Make a concept table in prerequisite order. Each row has a one-line definition and prerequisite numbers. Keep prerequisite order separate from textbook reading order.
53
53
  3. Use the source's book and chapter hierarchy when present. Otherwise, propose logical chapter groupings.
54
54
  4. Show the full graph and chapter layout in the reply before the approval question, as a Markdown table. Keep the question text short, because it renders as one paragraph: ask whether to approve, edit, or rebuild the graph shown above, and state that approval starts draft generation for every concept, including any concept the learner then skips, and that drafts are not teaching. Do not put the table in the question text. Do not add another approval step.
55
- 5. After approval, run calibration as one `question` call with up to three items: what the learner wants to be able to do after this Course, what they have already read, studied, or used in this area in their own words, and which concepts in the approved graph they know well enough to skip, as a multi-select list. Items 1 and 2 are free text. Ask the goal and background items only when nothing already answers them: not the current request, not the opening question, not the supplied material. Reuse an opening-question answer verbatim. Derive the level. Then write `graph.md`, create `state.md` with every concept `new` and undated except the concepts selected to skip, which are `skipped`, create an empty `log.md`, and generate one draft per concept (section 3). Do not create `plan.md`.
55
+ 5. After approval, run calibration as one question-tool call with up to three items: what the learner wants to be able to do after this Course, what they have already read, studied, or used in this area in their own words, and which concepts in the approved graph they know well enough to skip, as a multi-select list. Items 1 and 2 are free text. Ask the goal and background items only when nothing already answers them: not the current request, not the opening question, not the supplied material. Reuse an opening-question answer verbatim. Derive the level. Then write `graph.md`, create `state.md` with every concept `new` and undated except the concepts selected to skip, which are `skipped`, create an empty `log.md`, and generate one draft per concept (section 3). Do not create `plan.md`.
56
56
 
57
57
  Calibration writes `Goal`, `Background`, and `Teaching level` to `graph.md`. Derive the level from the two answers:
58
58
 
59
- | Answers | Level |
60
- |---|---|
61
- | Most prerequisites are unknown, or the goal is to learn the topic from zero | `beginner` |
59
+ | Answers | Level |
60
+ | ------------------------------------------------------------------------------------------ | ------------ |
61
+ | Most prerequisites are unknown, or the goal is to learn the topic from zero | `beginner` |
62
62
  | Some prerequisites are known, and the goal is to understand the principles or pass an exam | `standard` |
63
- | Most prerequisites are known, and the goal is review, reference, or use in a project | `advanced` |
63
+ | Most prerequisites are known, and the goal is review, reference, or use in a project | `advanced` |
64
64
 
65
65
  When the two answers clearly conflict, you may choose an adjacent level and state the reason in one line. When the learner declines or gives no usable answer, write `Teaching level: standard` and omit the `Goal` and `Background` lines. Each selected concept becomes `skipped`, with no date and no passes.
66
66
 
@@ -136,7 +136,7 @@ If the row was already `in_process`, say that the last session stopped before th
136
136
 
137
137
  **Ask unseen questions.** Use the explicit count or type preference in the current `/study-plus` request when present. Otherwise, use the profile defaults in the top-level Nerd Mode instructions. Plan the configured number of unseen question slots, from 0 to 10. A hint, re-ask, or similar question stays in its current slot and does not add a slot. For each slot, write down the expected answer and criteria for a good answer. Keep both private. A draft never contains them. The criteria test the conclusion and whether each step taken is valid, not the route you had in mind. Ask one question at a time, then wait. One call asks one question. Outside `study-plus`, ignore these preferences.
138
138
 
139
- For `short-answer`, ask the question in the conversation. For `multiple-choice`, ask it as one `question` call with four options and exactly one correct answer; choosing it is sufficient. Give the labels alone, with no description, and shuffle the four positions for each slot. A host without a `question` tool gets the same four options as a numbered text list. A typed answer that names or repeats one option counts as choosing it. Any other typed answer is graded like a short answer. For `mixed`, choose short-answer or multiple-choice independently at random for each planned slot. Do not add question-type details to the session log. When the configured count is zero, skip unseen questions. A passed Teach-back alone is sufficient to pass. With one or more slots, require a passed Teach-back and `ceil(2 × slot count / 3)` correct slots.
139
+ For `short-answer`, ask the question in the conversation. For `multiple-choice`, ask it as one question-tool call with four options and exactly one correct answer; choosing it is sufficient. Give the labels alone, with no description, and shuffle the four positions for each slot. A host without a question tool gets the same four options as a numbered text list. A typed answer that names or repeats one option counts as choosing it. Any other typed answer is graded like a short answer. For `mixed`, choose short-answer or multiple-choice independently at random for each planned slot. Do not add question-type details to the session log. When the configured count is zero, skip unseen questions. A passed Teach-back alone is sufficient to pass. With one or more slots, require a passed Teach-back and `ceil(2 × slot count / 3)` correct slots.
140
140
 
141
141
  A question exists to find the gap, not to stop the session. Nothing in this step blocks the learner.
142
142
 
@@ -13,8 +13,8 @@
13
13
  if (!panels.length) return;
14
14
 
15
15
  var L = book.lang === 'en'
16
- ? { search: 'Search the book', toc: 'Contents', none: 'No match', hits: ' hits', chapter: 'Chapter', section: 'Section', sections: 'sections', menu: 'Chapters', draft: 'Draft' }
17
- : { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第', section: '第', sections: '节', menu: '章节', draft: '草稿' };
16
+ ? { search: 'Search the book', toc: 'Contents', none: 'No match', hits: ' hits', chapter: 'Chapter', sections: 'sections', menu: 'Chapters', draft: 'Draft' }
17
+ : { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第', sections: '节', menu: '章节', draft: '草稿' };
18
18
 
19
19
  var layout = document.querySelector('.am-doc-layout');
20
20
  var body = document.querySelector('.am-doc-body');
@@ -33,10 +33,8 @@
33
33
  : L.chapter + chapter.n + '章 ' + chapter.name;
34
34
  }
35
35
 
36
- function sectionLabel(unit) {
37
- return book.lang === 'en'
38
- ? L.section + ' ' + unit.section
39
- : L.section + unit.section + '节';
36
+ function sectionNumber(unit) {
37
+ return unit.chapter + '.' + unit.section;
40
38
  }
41
39
 
42
40
  function chapterCount(chapter) {
@@ -46,9 +44,7 @@
46
44
  }
47
45
 
48
46
  function sectionTitle(unit) {
49
- return book.lang === 'en'
50
- ? L.section + ' ' + unit.section + '. ' + unit.title
51
- : L.section + unit.section + '节 ' + unit.title;
47
+ return sectionNumber(unit) + ' ' + unit.title;
52
48
  }
53
49
 
54
50
  var style = document.createElement('style');
@@ -70,6 +66,8 @@
70
66
  '.am-doc-body .am-md h3 { font-size: 18px; font-weight: 650; margin-top: 28px; }',
71
67
  '.am-doc-body .am-md h3:first-child { margin-top: 0; }',
72
68
  '.am-doc-body .am-md h4 { font-size: 16px; font-weight: 600; margin-top: 20px; }',
69
+ // am's overflow-x:auto makes overflow-y auto too; a 1px overshoot draws scrollbar arrows on Windows.
70
+ '.am-math-block { overflow-y: hidden; }',
73
71
  '.bk-context { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 8px; font-size: 13px; }',
74
72
  '.bk-context-chapter { color: var(--ink-2); font-weight: 600; }',
75
73
  '.bk-context-section { color: var(--ink-3); white-space: nowrap; }',
@@ -151,7 +149,7 @@
151
149
  chapter.textContent = chapterTitle(g.chapter);
152
150
  var section = document.createElement('span');
153
151
  section.className = 'bk-context-section';
154
- section.innerHTML = esc(sectionLabel(u)) + draftTag(u);
152
+ section.innerHTML = esc(sectionNumber(u)) + draftTag(u);
155
153
  context.appendChild(chapter);
156
154
  context.appendChild(section);
157
155
  var heading = panel.querySelector('.am-panel-head');
@@ -82,9 +82,13 @@ function build() {
82
82
  ...(book.goal ? [`subtitle: ${JSON.stringify(book.goal)}`] : []),
83
83
  '---',
84
84
  '',
85
- book.units.map((unit) => `## ${unit.section}. ${unit.title}\n\n${unit.body}`).join('\n\n'),
85
+ book.units.map((unit) => `## ${unit.chapter}.${unit.section} ${unit.title}\n\n${unit.body}`).join('\n\n'),
86
86
  '',
87
- ].join('\n');
87
+ ].join('\n')
88
+ // am reads $ and $$ math only: a LaTeX display delimiter on its own line
89
+ // would reach the page as a literal bracket, so turn it into a $$ block.
90
+ .replace(/^ {0,3}\\\[\s*$/gm, () => '$$')
91
+ .replace(/^ {0,3}\\\]\s*$/gm, () => '$$');
88
92
  const outPath = join(dir, 'book.html');
89
93
  const backupPath = join(dir, 'book.html.bak');
90
94
  const guardPath = (path, what) => {
@@ -123,7 +127,9 @@ function build() {
123
127
  units: book.units.map(({ concept, title, chapter, section, index, status }) => ({ concept, title, chapter, section, index, status })),
124
128
  };
125
129
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
126
- const client = readFileSync(CLIENT, 'utf8');
130
+ // HTML parsing turns CRLF into LF before the browser checks the CSP hash,
131
+ // so a Windows checkout would otherwise be blocked as the wrong hash.
132
+ const client = readFileSync(CLIENT, 'utf8').replace(/\r\n?/g, '\n');
127
133
  const html = readFileSync(tmpPath, 'utf8');
128
134
  const scriptText = `\n${client}\n`;
129
135
  const hash = createHash('sha256').update(scriptText, 'utf8').digest('base64');