dsh-nerd-mode 0.5.0 → 0.5.2

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 (34) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/assets/calibration.svg +69 -69
  4. package/assets/nerd.svg +22 -22
  5. package/assets/section.md +15 -15
  6. package/client.js +2170 -2225
  7. package/cordis.patch.yml +24 -24
  8. package/lib/index.js +838 -286
  9. package/lib/study-sessions.js +283 -283
  10. package/package.json +70 -70
  11. package/skills/convert-documents-to-markdown/SKILL.md +26 -26
  12. package/skills/diagram/SKILL.md +43 -19
  13. package/skills/diagram/scripts/check-svg.mjs +184 -0
  14. package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
  15. package/skills/humanizer/.claude-plugin/plugin.json +15 -15
  16. package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
  17. package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
  18. package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
  19. package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
  20. package/skills/humanizer/.github/workflows/validate.yml +29 -29
  21. package/skills/humanizer/AGENTS.md +54 -54
  22. package/skills/humanizer/CHANGELOG.md +116 -116
  23. package/skills/humanizer/LICENSE +21 -21
  24. package/skills/humanizer/README.md +206 -206
  25. package/skills/humanizer/SKILL.md +397 -397
  26. package/skills/humanizer/agents/openai.yaml +4 -4
  27. package/skills/humanizer/scripts/validate-package.py +146 -146
  28. package/skills/study/SKILL.md +81 -78
  29. package/skills/study-plus/SKILL.md +246 -246
  30. package/skills/study-plus/scripts/book-client.js +284 -284
  31. package/skills/study-plus/scripts/book-data.mjs +289 -289
  32. package/skills/study-plus/scripts/build-book.mjs +155 -155
  33. package/skills/writing-beats/SKILL.md +66 -66
  34. package/skills/writing-beats/agents/openai.yaml +5 -5
package/lib/index.js CHANGED
@@ -1,286 +1,838 @@
1
- /**
2
- * Nerd Mode: a per-session switch. While it is on, one prompt section joins
3
- * every request and the `study` skill becomes the default answer shape. State
4
- * folds from the session log, so it survives compaction, resume, and fork. The
5
- * tool set never changes.
6
- *
7
- * The section registers at the profile level, not inside a preset, so it
8
- * reaches every agent in every preset. See docs/adr/0008.
9
- */
10
-
11
- import { readFileSync } from 'node:fs'
12
- import { fileURLToPath } from 'node:url'
13
- import { COMMAND, KEY, fold, init } from './fold.js'
14
- import { scan } from './study-sessions.js'
15
- import z from '@deepseek-ai/schemastery'
16
-
17
- export const name = 'dsh-nerd-mode'
18
- export const PACKAGE_NAME = 'dsh-nerd-mode'
19
- export const inject = ['systemPrompt', 'sessionProjections']
20
-
21
- // The prompt text ships with the package. Keeping it out of the config stops
22
- // every profile patch from carrying a stale copy of it.
23
- const BUNDLED_SECTION = readFileSync(
24
- fileURLToPath(new URL('../assets/section.md', import.meta.url)),
25
- 'utf8',
26
- ).trim()
27
-
28
- export const Config = z.object({
29
- section: z.string().pattern(/\S/).default(BUNDLED_SECTION),
30
- recallSessionEnabled: z.boolean().default(false).volatile(),
31
- unseenQuestionCount: z.number().step(1).min(0).max(10).default(3).volatile(),
32
- unseenQuestionType: z.union([
33
- z.const('short-answer'),
34
- z.const('multiple-choice'),
35
- z.const('mixed'),
36
- ]).default('short-answer').volatile(),
37
- })
38
-
39
- const SECTION = 'study:policy'
40
- // Between PLAN_POLICY (500) and TEAM_POLICY (600).
41
- const SECTION_ORDER = 550
42
- const STUDY_SESSIONS_PATH = '/dsh-nerd-mode/api/study-sessions'
43
- const studyPlusDefaultsBySession = new WeakMap()
44
- const directStudyPlusBySession = new WeakMap()
45
- // A PTC skill load leaves no message in the history, so keep its id for later
46
- // turns the way an injected skill message would.
47
- const ptcStudyPlusBySession = new WeakMap()
48
- const SKILL_COMMAND = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
49
-
50
- // shortcut: parse-only schemas, no zod. The projection registry calls `parse`
51
- // and nothing else. Upgrade to zod if the host starts introspecting schemas.
52
- const stateSchema = {
53
- parse: (value) => ({
54
- active: value?.active === true,
55
- running:
56
- value?.running && typeof value.running.commandId === 'string'
57
- ? { commandId: value.running.commandId, wanted: value.running.wanted === true }
58
- : null,
59
- }),
60
- }
61
-
62
- const viewSchema = { parse: (value) => ({ active: value?.active === true }) }
63
-
64
- function studyPlusSettings(config, session, invocationId) {
65
- let defaults = studyPlusDefaultsBySession.get(session)
66
- if (defaults?.invocationId !== invocationId) {
67
- defaults = {
68
- invocationId,
69
- recallSessionEnabled: config.recallSessionEnabled?.get?.() ?? false,
70
- unseenQuestionCount: config.unseenQuestionCount?.get?.() ?? 3,
71
- unseenQuestionType: config.unseenQuestionType?.get?.() ?? 'short-answer',
72
- }
73
- studyPlusDefaultsBySession.set(session, defaults)
74
- }
75
-
76
- return [
77
- '',
78
- '',
79
- 'Study-plus profile defaults for this tracked session:',
80
- `- Recall Session: ${defaults.recallSessionEnabled ? 'enabled' : 'disabled'}.`,
81
- `- Post-lecture questions: ${defaults.unseenQuestionCount}; type: ${defaults.unseenQuestionType}.`,
82
- 'Apply these defaults only during tracked study-plus sessions. Ignore them for ordinary study and all other requests.',
83
- 'An explicit preference in the current /study-plus request overrides its profile default for this session and does not update the profile.',
84
- ].join('\n')
85
- }
86
-
87
- /** Return the message id when the message is a direct /study-plus command, else undefined. */
88
- function directStudyPlusId(message) {
89
- if (message?.source?.kind !== 'user') return undefined
90
- const text = (message.content ?? [])
91
- .filter((block) => block.type === 'text')
92
- .map((block) => block.text)
93
- .join('\n')
94
- return [...text.matchAll(SKILL_COMMAND)].at(-1)?.[2] === 'study-plus' ? message.id : undefined
95
- }
96
-
97
- function studyPlusInvocation(session) {
98
- const directInvocation = directStudyPlusBySession.get(session)
99
- if (directInvocation !== undefined) return directInvocation
100
- const ptcInvocation = ptcStudyPlusBySession.get(session)
101
- if (ptcInvocation !== undefined) return ptcInvocation
102
-
103
- // The host returns the full surface history here, not just this turn. See docs/agents/dsh-session-api.md.
104
- const invocation = session.deriveMessages().findLast((message) => message.source?.kind === 'skill-invocation')
105
- if (invocation?.source.name === 'study-plus') return invocation.id
106
-
107
- const completedCalls = new Set()
108
- let loadedId
109
- let directId
110
- let skillCall
111
- let ptcSkillCall
112
- for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
113
- const event = session.eventAt(seq)
114
- if (event?.type === 'turn/start') break
115
- // A resumed, forked, or seeded log may carry a partial event payload, so
116
- // every nested read here is guarded. One bad record must not fail a turn.
117
- if (event?.type === 'tool/result') {
118
- const result = event.data?.message
119
- if (result !== undefined && result.isError !== true && result.source?.callId !== undefined) {
120
- completedCalls.add(result.source.callId)
121
- }
122
- } else if (event?.type === 'tool/call' && event.data?.name === 'skill') {
123
- try {
124
- if (JSON.parse(event.data.arguments).name === 'study-plus') skillCall = event.data.callId
125
- } catch {}
126
- } else if (event?.type === 'tool/ptc-dispatch' && event.data?.name === 'skill') {
127
- // PTC records a skill call made inside run_code as a dispatch, not as a
128
- // `tool/call`, and the dispatch event itself carries the outcome.
129
- if (event.data.isError !== true && event.data.arguments?.name === 'study-plus') {
130
- ptcSkillCall = event.data.subCallId
131
- }
132
- } else if (event?.type === 'user/message') {
133
- // `user/message` stores the message fields directly in `data`; only
134
- // `tool/result` and `system/message` wrap them under `data.message`.
135
- const message = event.data
136
- if (message?.source?.kind === 'skill-invocation') {
137
- if (message.source.name === 'study-plus') loadedId = message.id
138
- } else {
139
- directId = directStudyPlusId(message) ?? directId
140
- }
141
- }
142
- }
143
-
144
- // A skill load counts only when its tool call completed. A typed command counts at once.
145
- if (skillCall !== undefined) {
146
- return completedCalls.has(skillCall) ? loadedId ?? directId ?? skillCall : undefined
147
- }
148
- if (ptcSkillCall !== undefined) {
149
- ptcStudyPlusBySession.set(session, ptcSkillCall)
150
- return loadedId ?? directId ?? ptcSkillCall
151
- }
152
- return loadedId ?? directId
153
- }
154
-
155
- const projection = {
156
- key: KEY,
157
- stateVersion: 1,
158
- stateSchema,
159
- init,
160
- apply: fold,
161
- wire: {
162
- viewSchema,
163
- view: (state) => ({ active: state.active }),
164
- },
165
- }
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
-
214
- /**
215
- * @param config - `section` is an optional overlay text; a missing value uses
216
- * the bundled `assets/section.md`.
217
- */
218
- export function apply(ctx, config) {
219
- const section = config?.section
220
- if (typeof section !== 'string' || section.trim() === '') {
221
- throw new Error('dsh-nerd-mode needs a non-empty string `section`')
222
- }
223
-
224
- ctx.sessionProjections.register(projection)
225
-
226
- ctx.effect(
227
- () => ctx.on('agent/inbox/claimed', ({ agent, message }) => {
228
- directStudyPlusBySession.set(agent.session, directStudyPlusId(message))
229
- }),
230
- 'dsh-nerd-mode: study-plus claim',
231
- )
232
-
233
- ctx.effect(
234
- () =>
235
- ctx.systemPrompt.section({
236
- name: SECTION,
237
- order: SECTION_ORDER,
238
- text: (context) => {
239
- if (context.agent === undefined) return ''
240
- if (!ctx.sessionProjections.stateOf(context.agent.session, KEY).active) return ''
241
- const session = context.agent.session
242
- const invocationId = studyPlusInvocation(session)
243
- if (invocationId === undefined) return section
244
- return section + studyPlusSettings(config ?? {}, session, invocationId)
245
- },
246
- }),
247
- 'dsh-nerd-mode: section',
248
- )
249
-
250
- ctx.inject(['commands'], (commandCtx) => {
251
- commandCtx.commands.register({
252
- name: COMMAND,
253
- description: 'Turn Nerd Mode on or off for this conversation',
254
- input: { hint: '[on|off]' },
255
- handler: ({ agent, rawInput }) => {
256
- const arg = rawInput.trim().toLowerCase()
257
- const active = ctx.sessionProjections.stateOf(agent.session, KEY).active
258
-
259
- if (arg === 'off') {
260
- return { kind: 'success', text: active ? 'Nerd Mode off.' : 'Nerd Mode is already off.' }
261
- }
262
- if (arg !== '' && arg !== 'on') {
263
- return { kind: 'error', text: `Unknown argument "${arg}". Use /nerd-mode on or /nerd-mode off.` }
264
- }
265
- return {
266
- kind: 'success',
267
- text: active ? 'Nerd Mode is already on.' : 'Nerd Mode on. /study guides a concept or a task.',
268
- }
269
- },
270
- })
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
- })
286
- }
1
+ /**
2
+ * Nerd Mode: a per-session switch. While it is on, one prompt section joins
3
+ * every request and the `study` skill becomes the default answer shape. State
4
+ * folds from the session log, so it survives compaction, resume, and fork. The
5
+ * tool set never changes.
6
+ *
7
+ * The section registers at the profile level, not inside a preset, so it
8
+ * reaches every agent in every preset. See docs/adr/0008.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process'
12
+ import { readFileSync } from 'node:fs'
13
+ import { join, sep } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { COMMAND, KEY, fold, init } from './fold.js'
16
+ import { scan } from './study-sessions.js'
17
+ import z from '@deepseek-ai/schemastery'
18
+
19
+ export const name = 'dsh-nerd-mode'
20
+ export const PACKAGE_NAME = 'dsh-nerd-mode'
21
+ export const inject = ['systemPrompt', 'sessionProjections']
22
+
23
+ // The prompt text ships with the package. Keeping it out of the config stops
24
+ // every profile patch from carrying a stale copy of it.
25
+ const BUNDLED_SECTION = readFileSync(
26
+ fileURLToPath(new URL('../assets/section.md', import.meta.url)),
27
+ 'utf8',
28
+ ).trim()
29
+
30
+ export const Config = z.object({
31
+ section: z.string().pattern(/\S/).default(BUNDLED_SECTION),
32
+ recallSessionEnabled: z.boolean().default(false).volatile(),
33
+ unseenQuestionCount: z.number().step(1).min(0).max(10).default(3).volatile(),
34
+ unseenQuestionType: z.union([
35
+ z.const('short-answer'),
36
+ z.const('multiple-choice'),
37
+ z.const('mixed'),
38
+ ]).default('short-answer').volatile(),
39
+ })
40
+
41
+ const SECTION = 'study:policy'
42
+ // Between PLAN_POLICY (500) and TEAM_POLICY (600).
43
+ const SECTION_ORDER = 550
44
+ const STUDY_SESSIONS_PATH = '/dsh-nerd-mode/api/study-sessions'
45
+ const UPDATE_PATH = '/dsh-nerd-mode/api/update'
46
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/
47
+ const REGISTRIES = ['https://registry.npmjs.org/', 'https://registry.npmmirror.com/']
48
+ const REGISTRY_TIMEOUT_MS = 10_000
49
+ const UPDATE_CACHE_MS = 60_000
50
+ const BODY_LIMIT = 1024
51
+ const INSTALL_TIMEOUT_MS = 300_000
52
+ const OFFICIAL_REPO = 'bocchii-the-code/dsh-nerd-mode'
53
+ const GITHUB_SPEC = `github:${OFFICIAL_REPO}`
54
+ const RAW_BASE = 'https://raw.githubusercontent.com/'
55
+ // A commit, a tag, or a semver range never moves on its own.
56
+ const PINNED_REF = /^(?:[0-9a-f]{7,40}|v?\d+\.\d+(?:\.\d+)*(?:[-+].*)?|semver:.*)$/i
57
+
58
+ // This copy's version cannot change while the module is loaded.
59
+ const PACKAGE_VERSION = JSON.parse(
60
+ readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
61
+ ).version
62
+ const studyPlusDefaultsBySession = new WeakMap()
63
+ const directStudyPlusBySession = new WeakMap()
64
+ // A PTC skill load leaves no message in the history, so keep its id for later
65
+ // turns the way an injected skill message would.
66
+ const ptcStudyPlusBySession = new WeakMap()
67
+ const SKILL_COMMAND = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
68
+
69
+ // shortcut: parse-only schemas, no zod. The projection registry calls `parse`
70
+ // and nothing else. Upgrade to zod if the host starts introspecting schemas.
71
+ const stateSchema = {
72
+ parse: (value) => ({
73
+ active: value?.active === true,
74
+ running:
75
+ value?.running && typeof value.running.commandId === 'string'
76
+ ? { commandId: value.running.commandId, wanted: value.running.wanted === true }
77
+ : null,
78
+ }),
79
+ }
80
+
81
+ const viewSchema = { parse: (value) => ({ active: value?.active === true }) }
82
+
83
+ function studyPlusSettings(config, session, invocationId) {
84
+ let defaults = studyPlusDefaultsBySession.get(session)
85
+ if (defaults?.invocationId !== invocationId) {
86
+ defaults = {
87
+ invocationId,
88
+ recallSessionEnabled: config.recallSessionEnabled?.get?.() ?? false,
89
+ unseenQuestionCount: config.unseenQuestionCount?.get?.() ?? 3,
90
+ unseenQuestionType: config.unseenQuestionType?.get?.() ?? 'short-answer',
91
+ }
92
+ studyPlusDefaultsBySession.set(session, defaults)
93
+ }
94
+
95
+ return [
96
+ '',
97
+ '',
98
+ 'Study-plus profile defaults for this tracked session:',
99
+ `- Recall Session: ${defaults.recallSessionEnabled ? 'enabled' : 'disabled'}.`,
100
+ `- Post-lecture questions: ${defaults.unseenQuestionCount}; type: ${defaults.unseenQuestionType}.`,
101
+ 'Apply these defaults only during tracked study-plus sessions. Ignore them for ordinary study and all other requests.',
102
+ 'An explicit preference in the current /study-plus request overrides its profile default for this session and does not update the profile.',
103
+ ].join('\n')
104
+ }
105
+
106
+ /** Return the message id when the message is a direct /study-plus command, else undefined. */
107
+ function directStudyPlusId(message) {
108
+ if (message?.source?.kind !== 'user') return undefined
109
+ const text = (message.content ?? [])
110
+ .filter((block) => block.type === 'text')
111
+ .map((block) => block.text)
112
+ .join('\n')
113
+ return [...text.matchAll(SKILL_COMMAND)].at(-1)?.[2] === 'study-plus' ? message.id : undefined
114
+ }
115
+
116
+ function studyPlusInvocation(session) {
117
+ const directInvocation = directStudyPlusBySession.get(session)
118
+ if (directInvocation !== undefined) return directInvocation
119
+ const ptcInvocation = ptcStudyPlusBySession.get(session)
120
+ if (ptcInvocation !== undefined) return ptcInvocation
121
+
122
+ // The host returns the full surface history here, not just this turn. See docs/agents/dsh-session-api.md.
123
+ const invocation = session.deriveMessages().findLast((message) => message.source?.kind === 'skill-invocation')
124
+ if (invocation?.source.name === 'study-plus') return invocation.id
125
+
126
+ const completedCalls = new Set()
127
+ let loadedId
128
+ let directId
129
+ let skillCall
130
+ let ptcSkillCall
131
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
132
+ const event = session.eventAt(seq)
133
+ if (event?.type === 'turn/start') break
134
+ // A resumed, forked, or seeded log may carry a partial event payload, so
135
+ // every nested read here is guarded. One bad record must not fail a turn.
136
+ if (event?.type === 'tool/result') {
137
+ const result = event.data?.message
138
+ if (result !== undefined && result.isError !== true && result.source?.callId !== undefined) {
139
+ completedCalls.add(result.source.callId)
140
+ }
141
+ } else if (event?.type === 'tool/call' && event.data?.name === 'skill') {
142
+ try {
143
+ if (JSON.parse(event.data.arguments).name === 'study-plus') skillCall = event.data.callId
144
+ } catch {}
145
+ } else if (event?.type === 'tool/ptc-dispatch' && event.data?.name === 'skill') {
146
+ // PTC records a skill call made inside run_code as a dispatch, not as a
147
+ // `tool/call`, and the dispatch event itself carries the outcome.
148
+ if (event.data.isError !== true && event.data.arguments?.name === 'study-plus') {
149
+ ptcSkillCall = event.data.subCallId
150
+ }
151
+ } else if (event?.type === 'user/message') {
152
+ // `user/message` stores the message fields directly in `data`; only
153
+ // `tool/result` and `system/message` wrap them under `data.message`.
154
+ const message = event.data
155
+ if (message?.source?.kind === 'skill-invocation') {
156
+ if (message.source.name === 'study-plus') loadedId = message.id
157
+ } else {
158
+ directId = directStudyPlusId(message) ?? directId
159
+ }
160
+ }
161
+ }
162
+
163
+ // A skill load counts only when its tool call completed. A typed command counts at once.
164
+ if (skillCall !== undefined) {
165
+ return completedCalls.has(skillCall) ? loadedId ?? directId ?? skillCall : undefined
166
+ }
167
+ if (ptcSkillCall !== undefined) {
168
+ ptcStudyPlusBySession.set(session, ptcSkillCall)
169
+ return loadedId ?? directId ?? ptcSkillCall
170
+ }
171
+ return loadedId ?? directId
172
+ }
173
+
174
+ const projection = {
175
+ key: KEY,
176
+ stateVersion: 1,
177
+ stateSchema,
178
+ init,
179
+ apply: fold,
180
+ wire: {
181
+ viewSchema,
182
+ view: (state) => ({ active: state.active }),
183
+ },
184
+ }
185
+
186
+ function sendJson(res, status, body) {
187
+ res.writeHead(status, {
188
+ 'content-type': 'application/json; charset=utf-8',
189
+ 'cache-control': 'no-store',
190
+ })
191
+ res.end(JSON.stringify(body))
192
+ }
193
+
194
+ function cursorOf(req) {
195
+ const match = /[?&]cursor=(\d+)/.exec(req?.url ?? '')
196
+ return match === null ? 0 : Number.parseInt(match[1], 10)
197
+ }
198
+
199
+ /**
200
+ * Build the read-only history route handler.
201
+ *
202
+ * `webServer.register` adds no auth fence, so the connection service decides
203
+ * first: a missing cookie is 401 and a bad Host or Origin is 403.
204
+ *
205
+ * @param options
206
+ * @param options.connection - the host connection service
207
+ * @param options.indexFile - forwarded to `scan`; tests point it at a temp file
208
+ * @param options.sessionsRoot - forwarded to `scan`; tests point it at a temp tree
209
+ * @returns {(req, res) => Promise<void>} replies `{ phase, cursor, done,
210
+ * total, sessions }` with `no-store`, `{ error }` with status 500, or the
211
+ * rejection status
212
+ */
213
+ export function createStudySessionsHandler({ connection, ...scanOptions }) {
214
+ return async function studySessionsRoute(req, res) {
215
+ const rejection = connection.requestRejection(req)
216
+ if (rejection !== undefined) {
217
+ res.writeHead(rejection)
218
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
219
+ return
220
+ }
221
+ if (req.method !== 'GET') {
222
+ sendJson(res, 405, { error: 'Method not allowed.' })
223
+ return
224
+ }
225
+ try {
226
+ sendJson(res, 200, scan({ ...scanOptions, cursor: cursorOf(req) }))
227
+ } catch (error) {
228
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Return the parsed `package.json` under `root`, or undefined when it is absent.
235
+ *
236
+ * Args:
237
+ * root: a directory that may hold a manifest.
238
+ * Returns:
239
+ * The parsed object, or undefined when the file is missing or not JSON.
240
+ */
241
+ function manifestOf(root) {
242
+ try {
243
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
244
+ } catch {
245
+ return undefined
246
+ }
247
+ }
248
+
249
+ /** Return the manifest's dependency spec for this package, if any. */
250
+ function dependencySpec(manifest) {
251
+ return manifest?.dependencies?.[PACKAGE_NAME] ?? manifest?.devDependencies?.[PACKAGE_NAME]
252
+ }
253
+
254
+ /**
255
+ * Read the repository and ref from a GitHub dependency spec.
256
+ *
257
+ * Args:
258
+ * spec: a manifest value, possibly undefined or not a string.
259
+ * Returns:
260
+ * `{ repo, ref, pinned }`, or null when the spec names another source.
261
+ * `repo` is a lowercase `owner/repo`; `ref` is null when the spec tracks the
262
+ * default branch; `pinned` is true for a commit, a tag, or a semver range.
263
+ */
264
+ export function githubTarget(spec) {
265
+ if (typeof spec !== 'string') return null
266
+ const [source, ref = null] = spec.split('#')
267
+ const patterns = [
268
+ /^github:([^/]+\/[^/]+?)(?:\.git)?$/i,
269
+ /^(?!@)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
270
+ /^git\+https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i,
271
+ /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i,
272
+ /^git\+ssh:\/\/git@github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i,
273
+ /^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i,
274
+ ]
275
+ for (const pattern of patterns) {
276
+ const match = pattern.exec(source)
277
+ if (match === null) continue
278
+ return {
279
+ repo: match[1].toLowerCase(),
280
+ ref,
281
+ pinned: ref !== null && PINNED_REF.test(ref),
282
+ }
283
+ }
284
+ return null
285
+ }
286
+
287
+ /**
288
+ * Classify a manifest spec as an npm, github, or local install.
289
+ *
290
+ * Args:
291
+ * spec: the manifest value, possibly undefined or not a string.
292
+ * Returns:
293
+ * `link` for a missing or `link:` spec, `github` for a GitHub spec, and
294
+ * `npm` for everything else.
295
+ */
296
+ function sourceOf(spec) {
297
+ if (typeof spec !== 'string' || spec.startsWith('link:')) return 'link'
298
+ if (githubTarget(spec) !== null) return 'github'
299
+ if (spec.startsWith('git+') || spec.startsWith('git@') || spec.endsWith('.git')) return 'github'
300
+ return 'npm'
301
+ }
302
+
303
+ /**
304
+ * Return the profile root that holds this copy, or null when none lists it.
305
+ *
306
+ * pnpm nests the real package under `node_modules/.pnpm`, so the last
307
+ * `node_modules` segment is not always the profile. Walk the segments from
308
+ * last to first and take the first root whose manifest names the package.
309
+ */
310
+ function profileRoot() {
311
+ const segments = fileURLToPath(import.meta.url).split(/[\\/]/)
312
+ for (let index = segments.length - 1; index > 0; index -= 1) {
313
+ if (segments[index] !== 'node_modules') continue
314
+ const root = segments.slice(0, index).join(sep)
315
+ if (dependencySpec(manifestOf(root)) !== undefined) return root
316
+ }
317
+ return null
318
+ }
319
+
320
+ /**
321
+ * Report this copy's version, install source, and manifest spec.
322
+ *
323
+ * Args:
324
+ * profile: the profile root, or null when the module sits outside one.
325
+ * Returns:
326
+ * `{ installedVersion, source, spec }`; a missing profile or manifest entry
327
+ * reads as a `link` install with no spec. The route keeps `spec` server-side.
328
+ */
329
+ function readStatus(profile) {
330
+ const spec = profile === null ? undefined : dependencySpec(manifestOf(profile))
331
+ const source = profile === null ? 'link' : sourceOf(spec)
332
+ return { installedVersion: PACKAGE_VERSION, source, spec }
333
+ }
334
+
335
+ /**
336
+ * Read the request body as UTF-8, refusing anything over 1024 bytes.
337
+ *
338
+ * Args:
339
+ * req: the HTTP request stream.
340
+ * Returns:
341
+ * The body text.
342
+ * Raises:
343
+ * Error when the body grows past the limit.
344
+ */
345
+ async function readBody(req) {
346
+ let size = 0
347
+ const chunks = []
348
+ for await (const chunk of req) {
349
+ size += chunk.length
350
+ if (size > BODY_LIMIT) throw new Error('The request body is too large.')
351
+ chunks.push(Buffer.from(chunk))
352
+ }
353
+ return Buffer.concat(chunks).toString('utf8')
354
+ }
355
+
356
+ /**
357
+ * Ask the npm registry for the newest published version, then the mirror.
358
+ *
359
+ * Args:
360
+ * fetchImpl: fetch implementation; tests replace it to stay offline.
361
+ * Returns:
362
+ * `{ latestVersion, registry, error }`. A failure in both registries keeps
363
+ * `latestVersion` and `registry` null and sets `error`.
364
+ */
365
+ async function checkRegistries(fetchImpl) {
366
+ let reason = 'the request failed'
367
+ for (const registry of REGISTRIES) {
368
+ try {
369
+ const response = await fetchImpl(`${registry}${PACKAGE_NAME}`, {
370
+ headers: { accept: 'application/json' },
371
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
372
+ })
373
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
374
+ const latest = (await response.json())?.['dist-tags']?.latest
375
+ if (typeof latest !== 'string' || !VERSION_PATTERN.test(latest)) {
376
+ throw new Error('the registry returned no version')
377
+ }
378
+ return { latestVersion: latest, registry, error: null }
379
+ } catch (error) {
380
+ reason = error instanceof Error ? error.message : String(error)
381
+ }
382
+ }
383
+ return {
384
+ latestVersion: null,
385
+ registry: null,
386
+ error: `No npm registry answered with a version: ${reason}`,
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Read the newest version from the official GitHub repository.
392
+ *
393
+ * Args:
394
+ * target: `{ repo, ref }` from `githubTarget`; the repo must be official.
395
+ * fetchImpl: fetch implementation; tests replace it to stay offline.
396
+ * Returns:
397
+ * `{ latestVersion, registry, error }`. A failure keeps the first two null
398
+ * and sets `error`.
399
+ */
400
+ async function checkGithub(target, fetchImpl) {
401
+ try {
402
+ const response = await fetchImpl(`${RAW_BASE}${target.repo}/${target.ref ?? 'HEAD'}/package.json`, {
403
+ headers: { accept: 'application/json' },
404
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
405
+ })
406
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
407
+ const latest = (await response.json())?.version
408
+ if (typeof latest !== 'string' || !VERSION_PATTERN.test(latest)) {
409
+ throw new Error('the repository returned no version')
410
+ }
411
+ return { latestVersion: latest, registry: RAW_BASE, error: null }
412
+ } catch (error) {
413
+ return {
414
+ latestVersion: null,
415
+ registry: null,
416
+ error: `The repository did not answer with a version: ${error instanceof Error ? error.message : String(error)}`,
417
+ }
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Run one pnpm command in the profile.
423
+ *
424
+ * Args:
425
+ * profile: the profile root, or null when there is nowhere to install.
426
+ * args: the pnpm arguments, built by the caller; no request text reaches
427
+ * here unless it already passed the version check or is the manifest spec.
428
+ * Returns:
429
+ * A promise that resolves on exit code 0. It rejects with an Error on a
430
+ * missing profile, spawn failure, non-zero exit, or a 300 s timeout. The
431
+ * message carries the stderr tail when pnpm wrote one.
432
+ */
433
+ function runPnpm(profile, args) {
434
+ if (profile === null) {
435
+ return Promise.reject(new Error('This copy has no profile directory to install into.'))
436
+ }
437
+ return new Promise((resolve, reject) => {
438
+ // Windows cannot start the pnpm `.cmd` shim without a command processor,
439
+ // so keep `shell: false` and hand cmd.exe the fixed argument list.
440
+ const [command, prefix] =
441
+ process.platform === 'win32'
442
+ ? [process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm']]
443
+ : ['pnpm', []]
444
+ let child
445
+ try {
446
+ child = spawn(command, [...prefix, ...args], {
447
+ cwd: profile,
448
+ shell: false,
449
+ windowsHide: true,
450
+ })
451
+ } catch (error) {
452
+ reject(error)
453
+ return
454
+ }
455
+
456
+ let settled = false
457
+ let stderr = ''
458
+ let timer
459
+ const finish = (error) => {
460
+ if (settled) return
461
+ settled = true
462
+ clearTimeout(timer)
463
+ if (error === undefined) resolve()
464
+ else reject(error)
465
+ }
466
+
467
+ timer = setTimeout(() => {
468
+ // shortcut: the kill reaches cmd.exe only, upgrade to a tree kill if
469
+ // orphaned pnpm children keep running.
470
+ child.kill()
471
+ finish(new Error(`pnpm did not finish within ${INSTALL_TIMEOUT_MS / 1000} s.`))
472
+ }, INSTALL_TIMEOUT_MS)
473
+ child.stderr?.on('data', (chunk) => {
474
+ stderr = (stderr + chunk).slice(-500)
475
+ })
476
+ child.on('error', (error) => finish(new Error(`Could not start pnpm: ${error.message}`)))
477
+ child.on('close', (code) => {
478
+ if (code === 0) {
479
+ finish()
480
+ return
481
+ }
482
+ const tail = stderr.trim()
483
+ finish(new Error(tail === '' ? `pnpm exited with code ${code}.` : tail))
484
+ })
485
+ })
486
+ }
487
+
488
+ /**
489
+ * Install one exact registry version, pinned in the manifest.
490
+ *
491
+ * Args:
492
+ * profile: the profile root.
493
+ * version: a string already checked against `1.2.3`.
494
+ * Returns:
495
+ * The promise from `runPnpm`.
496
+ */
497
+ function installVersion(profile, version) {
498
+ return runPnpm(profile, ['add', `${PACKAGE_NAME}@${version}`, '--save-exact'])
499
+ }
500
+
501
+ /**
502
+ * Re-resolve the existing GitHub spec to the newest commit of its ref.
503
+ *
504
+ * Args:
505
+ * profile: the profile root.
506
+ * spec: the manifest spec, unchanged.
507
+ * Returns:
508
+ * The promise from `runPnpm`.
509
+ */
510
+ function installGithub(profile, spec) {
511
+ return runPnpm(profile, ['add', spec])
512
+ }
513
+
514
+ /**
515
+ * Read the version that landed in the profile, if pnpm wrote one.
516
+ *
517
+ * Args:
518
+ * profile: the profile root, or null.
519
+ * Returns:
520
+ * A `major.minor.patch` string, or null when the file is absent or unreadable.
521
+ */
522
+ function installedOnDisk(profile) {
523
+ if (profile === null) return null
524
+ const version = manifestOf(join(profile, 'node_modules', PACKAGE_NAME))?.version
525
+ return typeof version === 'string' && VERSION_PATTERN.test(version) ? version : null
526
+ }
527
+
528
+ /** Turn an unexpected throw into the shared failure shape. */
529
+ function failedCheck(error) {
530
+ return { latestVersion: null, registry: null, error: error instanceof Error ? error.message : String(error) }
531
+ }
532
+
533
+ /**
534
+ * Compare two plain `major.minor.patch` versions.
535
+ *
536
+ * Args:
537
+ * left: a version string.
538
+ * right: a version string.
539
+ * Returns:
540
+ * A positive number when `left` is newer, a negative number when `right` is,
541
+ * and 0 when they match or either one is not plain `major.minor.patch`.
542
+ */
543
+ function compareVersions(left, right) {
544
+ if (!VERSION_PATTERN.test(String(left ?? '')) || !VERSION_PATTERN.test(String(right ?? ''))) return 0
545
+ const first = left.split('.').map(Number)
546
+ const second = right.split('.').map(Number)
547
+ for (let at = 0; at < 3; at += 1) {
548
+ if (first[at] !== second[at]) return first[at] - second[at]
549
+ }
550
+ return 0
551
+ }
552
+
553
+ /**
554
+ * Choose the newest version across the two channels.
555
+ *
556
+ * Args:
557
+ * current: `{ source }` for the installed copy.
558
+ * npm: `{ latestVersion, registry, error }`.
559
+ * github: the same shape.
560
+ * Returns:
561
+ * `{ latestVersion, channel, registry, error }`. A tie stays on the installed
562
+ * channel so an update does not change the spec for nothing. When both
563
+ * channels fail, `error` carries both messages.
564
+ */
565
+ function pickNewest(current, npm, github) {
566
+ if (npm.latestVersion === null && github.latestVersion === null) {
567
+ const reasons = [npm.error, github.error].filter((text) => typeof text === 'string')
568
+ return {
569
+ latestVersion: null,
570
+ channel: null,
571
+ registry: null,
572
+ error: reasons.length === 0 ? 'No source answered with a version.' : reasons.join(' '),
573
+ }
574
+ }
575
+ const order = compareVersions(github.latestVersion, npm.latestVersion)
576
+ const useGithub =
577
+ github.latestVersion !== null &&
578
+ (npm.latestVersion === null || order > 0 || (order === 0 && current.source === 'github'))
579
+ const winner = useGithub ? github : npm
580
+ return {
581
+ latestVersion: winner.latestVersion,
582
+ channel: useGithub ? 'github' : 'npm',
583
+ registry: winner.registry,
584
+ error: null,
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Build the update route handler.
590
+ *
591
+ * `webServer.register` adds no auth fence, so the connection service decides
592
+ * first: a missing cookie is 401 and a bad Host or Origin is 403. The method
593
+ * check follows.
594
+ *
595
+ * GET replies 200 with `{ installedVersion, source, latestVersion, channel,
596
+ * registry, error, github }`. `latestVersion` is the higher of the npm and
597
+ * official-repository versions, and `channel` says which one answered
598
+ * (`npm` or `github`). `github` is `{ repo, ref, pinned, official }` for a
599
+ * GitHub install and null otherwise. A link install, a pinned ref, and a fork
600
+ * return nulls. A failed check keeps the 200 and sets `error`.
601
+ *
602
+ * POST replies 200 with `{ installedVersion }`, 400 `{ error }` for a body
603
+ * that is not JSON or, on the npm channel, carries no `1.2.3` version. It
604
+ * replies 409 `{ error }` for a source that cannot update or a run already in
605
+ * progress, and 500 `{ error }` when pnpm fails, exits non-zero, or times out.
606
+ * `channel` in the body picks the install source; a missing or unknown value
607
+ * keeps the installed source.
608
+ *
609
+ * Args:
610
+ * options: `connection` is required. Every other key replaces the real work
611
+ * so tests stay offline and never run pnpm.
612
+ * options.connection: the host connection service.
613
+ * options.profile: the profile root holding `package.json`; null reads as a
614
+ * `link` install. Defaults to the root found above this module.
615
+ * options.status: returns `{ installedVersion, source, spec }`.
616
+ * options.fetchImpl: fetch used for the registry or repository check.
617
+ * options.installNpm: installs one registry version.
618
+ * options.installGit: re-resolves the GitHub spec.
619
+ * options.readDisk: reads the version that landed in the profile.
620
+ * Returns:
621
+ * `(req, res) => Promise<void>`, which writes the reply itself. No promise
622
+ * rejection leaves the handler.
623
+ */
624
+ export function createUpdateHandler({
625
+ connection,
626
+ profile = profileRoot(),
627
+ status = () => readStatus(profile),
628
+ fetchImpl = fetch,
629
+ installNpm = (version) => installVersion(profile, version),
630
+ installGit = (spec) => installGithub(profile, spec),
631
+ readDisk = () => installedOnDisk(profile),
632
+ } = {}) {
633
+ // One cache per route: a module-wide cache would leak between test handlers
634
+ // and keep a stale answer for the next 60 s after an install.
635
+ let cached
636
+ let busy = false
637
+
638
+ function githubInfo(current) {
639
+ if (current.source !== 'github') return null
640
+ const target = githubTarget(current.spec)
641
+ if (target === null) return { repo: null, ref: null, pinned: false, official: false }
642
+ return {
643
+ repo: target.repo,
644
+ ref: target.ref,
645
+ pinned: target.pinned,
646
+ official: target.repo === OFFICIAL_REPO,
647
+ }
648
+ }
649
+
650
+ async function releaseState(force, current) {
651
+ if (!force && cached !== undefined && Date.now() - cached.at < UPDATE_CACHE_MS) {
652
+ return cached.value
653
+ }
654
+ const target = current.source === 'github' ? githubTarget(current.spec) : null
655
+ // A link copy, a pinned ref, and a fork are deliberate choices: none moves.
656
+ if (
657
+ (current.source !== 'npm' && current.source !== 'github') ||
658
+ (current.source === 'github' && (target === null || target.repo !== OFFICIAL_REPO || target.pinned))
659
+ ) {
660
+ const value = { latestVersion: null, channel: null, registry: null, error: null }
661
+ cached = { at: Date.now(), value }
662
+ return value
663
+ }
664
+ const githubSide = current.source === 'github' ? target : { repo: OFFICIAL_REPO, ref: null }
665
+ const [npm, github] = await Promise.all([
666
+ checkRegistries(fetchImpl).catch(failedCheck),
667
+ checkGithub(githubSide, fetchImpl).catch(failedCheck),
668
+ ])
669
+ const value = pickNewest(current, npm, github)
670
+ cached = { at: Date.now(), value }
671
+ return value
672
+ }
673
+
674
+ return async function updateRoute(req, res) {
675
+ const rejection = connection.requestRejection(req)
676
+ if (rejection !== undefined) {
677
+ res.writeHead(rejection)
678
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
679
+ return
680
+ }
681
+
682
+ if (req.method === 'GET') {
683
+ const current = status()
684
+ const release = await releaseState(/[?&]force=1(?:&|$)/.test(req.url ?? ''), current)
685
+ const { spec, ...visible } = current
686
+ sendJson(res, 200, { ...visible, ...release, github: githubInfo(current) })
687
+ return
688
+ }
689
+
690
+ if (req.method !== 'POST') {
691
+ sendJson(res, 405, { error: 'Method not allowed.' })
692
+ return
693
+ }
694
+
695
+ let body
696
+ try {
697
+ body = JSON.parse(await readBody(req))
698
+ } catch (error) {
699
+ sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
700
+ return
701
+ }
702
+ const current = status()
703
+ if (current.source !== 'npm' && current.source !== 'github') {
704
+ sendJson(res, 409, { error: 'This copy cannot be updated from this page.' })
705
+ return
706
+ }
707
+ const target = current.source === 'github' ? githubTarget(current.spec) : null
708
+ if (current.source === 'github' && (target === null || target.repo !== OFFICIAL_REPO)) {
709
+ sendJson(res, 409, { error: 'This copy comes from another repository.' })
710
+ return
711
+ }
712
+ if (current.source === 'github' && target.pinned) {
713
+ sendJson(res, 409, { error: 'This copy is pinned to an exact ref.' })
714
+ return
715
+ }
716
+ const channel = body?.channel === 'github' || body?.channel === 'npm' ? body.channel : current.source
717
+ if (channel === 'npm') {
718
+ const version = body?.version
719
+ if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) {
720
+ sendJson(res, 400, { error: 'The version must look like 1.2.3.' })
721
+ return
722
+ }
723
+ if (busy) {
724
+ sendJson(res, 409, { error: 'An update is already running.' })
725
+ return
726
+ }
727
+ busy = true
728
+ try {
729
+ await installNpm(version)
730
+ sendJson(res, 200, { installedVersion: version })
731
+ } catch (error) {
732
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
733
+ } finally {
734
+ busy = false
735
+ }
736
+ return
737
+ }
738
+ if (busy) {
739
+ sendJson(res, 409, { error: 'An update is already running.' })
740
+ return
741
+ }
742
+ busy = true
743
+ try {
744
+ await installGit(current.source === 'github' ? current.spec : GITHUB_SPEC)
745
+ const requested = typeof body?.version === 'string' && VERSION_PATTERN.test(body.version) ? body.version : null
746
+ sendJson(res, 200, { installedVersion: readDisk() ?? requested ?? current.installedVersion })
747
+ } catch (error) {
748
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
749
+ } finally {
750
+ busy = false
751
+ }
752
+ }
753
+ }
754
+
755
+ /**
756
+ * @param config - `section` is an optional overlay text; a missing value uses
757
+ * the bundled `assets/section.md`.
758
+ */
759
+ export function apply(ctx, config) {
760
+
761
+ const section = config?.section
762
+ if (typeof section !== 'string' || section.trim() === '') {
763
+ throw new Error('dsh-nerd-mode needs a non-empty string `section`')
764
+ }
765
+
766
+ ctx.sessionProjections.register(projection)
767
+
768
+ ctx.effect(
769
+ () => ctx.on('agent/inbox/claimed', ({ agent, message }) => {
770
+ directStudyPlusBySession.set(agent.session, directStudyPlusId(message))
771
+ }),
772
+ 'dsh-nerd-mode: study-plus claim',
773
+ )
774
+
775
+ ctx.effect(
776
+ () =>
777
+ ctx.systemPrompt.section({
778
+ name: SECTION,
779
+ order: SECTION_ORDER,
780
+ text: (context) => {
781
+ if (context.agent === undefined) return ''
782
+ if (!ctx.sessionProjections.stateOf(context.agent.session, KEY).active) return ''
783
+ const session = context.agent.session
784
+ const invocationId = studyPlusInvocation(session)
785
+ if (invocationId === undefined) return section
786
+ return section + studyPlusSettings(config ?? {}, session, invocationId)
787
+ },
788
+ }),
789
+ 'dsh-nerd-mode: section',
790
+ )
791
+
792
+ ctx.inject(['commands'], (commandCtx) => {
793
+ commandCtx.commands.register({
794
+ name: COMMAND,
795
+ description: 'Turn Nerd Mode on or off for this conversation',
796
+ input: { hint: '[on|off]' },
797
+ handler: ({ agent, rawInput }) => {
798
+ const arg = rawInput.trim().toLowerCase()
799
+ const active = ctx.sessionProjections.stateOf(agent.session, KEY).active
800
+
801
+ if (arg === 'off') {
802
+ return { kind: 'success', text: active ? 'Nerd Mode off.' : 'Nerd Mode is already off.' }
803
+ }
804
+ if (arg !== '' && arg !== 'on') {
805
+ return { kind: 'error', text: `Unknown argument "${arg}". Use /nerd-mode on or /nerd-mode off.` }
806
+ }
807
+ return {
808
+ kind: 'success',
809
+ text: active ? 'Nerd Mode is already on.' : 'Nerd Mode on. /study guides a concept or a task.',
810
+ }
811
+ },
812
+ })
813
+ })
814
+
815
+ // The web server is optional: a host without it still loads the switch and
816
+ // the prompt section, and the panel shows its failure line.
817
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
818
+ webCtx.effect(
819
+ () =>
820
+ webCtx.webServer.register({
821
+ kind: 'exact',
822
+ path: STUDY_SESSIONS_PATH,
823
+ handler: createStudySessionsHandler({ connection: webCtx.connection }),
824
+ }),
825
+ 'dsh-nerd-mode: study sessions route',
826
+ )
827
+
828
+ webCtx.effect(
829
+ () =>
830
+ webCtx.webServer.register({
831
+ kind: 'exact',
832
+ path: UPDATE_PATH,
833
+ handler: createUpdateHandler({ connection: webCtx.connection }),
834
+ }),
835
+ 'dsh-nerd-mode: update route',
836
+ )
837
+ })
838
+ }