dsh-nerd-mode 0.4.0 → 0.4.4

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
@@ -1,213 +1,223 @@
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 { COMMAND, KEY, fold, init } from './fold.js'
12
- import z from '@deepseek-ai/schemastery'
13
-
14
- export const name = 'dsh-nerd-mode'
15
- export const PACKAGE_NAME = 'dsh-nerd-mode'
16
- export const inject = ['systemPrompt', 'sessionProjections']
17
-
18
- export const Config = z.object({
19
- section: z.string().pattern(/\S/).required(),
20
- recallSessionEnabled: z.boolean().default(false).volatile(),
21
- unseenQuestionCount: z.number().step(1).min(0).max(10).default(3).volatile(),
22
- unseenQuestionType: z.union([
23
- z.const('short-answer'),
24
- z.const('multiple-choice'),
25
- z.const('mixed'),
26
- ]).default('short-answer').volatile(),
27
- })
28
-
29
- const SECTION = 'study:policy'
30
- // Between PLAN_POLICY (500) and TEAM_POLICY (600).
31
- const SECTION_ORDER = 550
32
- const studyPlusDefaultsBySession = new WeakMap()
33
- const directStudyPlusBySession = new WeakMap()
34
- // A PTC skill load leaves no message in the history, so keep its id for later
35
- // turns the way an injected skill message would.
36
- const ptcStudyPlusBySession = new WeakMap()
37
- const SKILL_COMMAND = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
38
-
39
- // shortcut: parse-only schemas, no zod. The projection registry calls `parse`
40
- // and nothing else. Upgrade to zod if the host starts introspecting schemas.
41
- const stateSchema = {
42
- parse: (value) => ({
43
- active: value?.active === true,
44
- running:
45
- value?.running && typeof value.running.commandId === 'string'
46
- ? { commandId: value.running.commandId, wanted: value.running.wanted === true }
47
- : null,
48
- }),
49
- }
50
-
51
- const viewSchema = { parse: (value) => ({ active: value?.active === true }) }
52
-
53
- function studyPlusSettings(config, session, invocationId) {
54
- let defaults = studyPlusDefaultsBySession.get(session)
55
- if (defaults?.invocationId !== invocationId) {
56
- defaults = {
57
- invocationId,
58
- recallSessionEnabled: config.recallSessionEnabled?.get?.() ?? false,
59
- unseenQuestionCount: config.unseenQuestionCount?.get?.() ?? 3,
60
- unseenQuestionType: config.unseenQuestionType?.get?.() ?? 'short-answer',
61
- }
62
- studyPlusDefaultsBySession.set(session, defaults)
63
- }
64
-
65
- return [
66
- '',
67
- '',
68
- 'Study-plus profile defaults for this tracked session:',
69
- `- Recall Session: ${defaults.recallSessionEnabled ? 'enabled' : 'disabled'}.`,
70
- `- Post-lecture questions: ${defaults.unseenQuestionCount}; type: ${defaults.unseenQuestionType}.`,
71
- 'Apply these defaults only during tracked study-plus sessions. Ignore them for ordinary study and all other requests.',
72
- 'An explicit preference in the current /study-plus request overrides its profile default for this session and does not update the profile.',
73
- ].join('\n')
74
- }
75
-
76
- /** Return the message id when the message is a direct /study-plus command, else undefined. */
77
- function directStudyPlusId(message) {
78
- if (message?.source?.kind !== 'user') return undefined
79
- const text = (message.content ?? [])
80
- .filter((block) => block.type === 'text')
81
- .map((block) => block.text)
82
- .join('\n')
83
- return [...text.matchAll(SKILL_COMMAND)].at(-1)?.[2] === 'study-plus' ? message.id : undefined
84
- }
85
-
86
- function studyPlusInvocation(session) {
87
- const directInvocation = directStudyPlusBySession.get(session)
88
- if (directInvocation !== undefined) return directInvocation
89
- const ptcInvocation = ptcStudyPlusBySession.get(session)
90
- if (ptcInvocation !== undefined) return ptcInvocation
91
-
92
- // The host returns the full surface history here, not just this turn. See docs/agents/dsh-session-api.md.
93
- const invocation = session.deriveMessages().findLast((message) => message.source?.kind === 'skill-invocation')
94
- if (invocation?.source.name === 'study-plus') return invocation.id
95
-
96
- const completedCalls = new Set()
97
- let loadedId
98
- let directId
99
- let skillCall
100
- let ptcSkillCall
101
- for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
102
- const event = session.eventAt(seq)
103
- if (event?.type === 'turn/start') break
104
- // A resumed, forked, or seeded log may carry a partial event payload, so
105
- // every nested read here is guarded. One bad record must not fail a turn.
106
- if (event?.type === 'tool/result') {
107
- const result = event.data?.message
108
- if (result !== undefined && result.isError !== true && result.source?.callId !== undefined) {
109
- completedCalls.add(result.source.callId)
110
- }
111
- } else if (event?.type === 'tool/call' && event.data?.name === 'skill') {
112
- try {
113
- if (JSON.parse(event.data.arguments).name === 'study-plus') skillCall = event.data.callId
114
- } catch {}
115
- } else if (event?.type === 'tool/ptc-dispatch' && event.data?.name === 'skill') {
116
- // PTC records a skill call made inside run_code as a dispatch, not as a
117
- // `tool/call`, and the dispatch event itself carries the outcome.
118
- if (event.data.isError !== true && event.data.arguments?.name === 'study-plus') {
119
- ptcSkillCall = event.data.subCallId
120
- }
121
- } else if (event?.type === 'user/message') {
122
- // `user/message` stores the message fields directly in `data`; only
123
- // `tool/result` and `system/message` wrap them under `data.message`.
124
- const message = event.data
125
- if (message?.source?.kind === 'skill-invocation') {
126
- if (message.source.name === 'study-plus') loadedId = message.id
127
- } else {
128
- directId = directStudyPlusId(message) ?? directId
129
- }
130
- }
131
- }
132
-
133
- // A skill load counts only when its tool call completed. A typed command counts at once.
134
- if (skillCall !== undefined) {
135
- return completedCalls.has(skillCall) ? loadedId ?? directId ?? skillCall : undefined
136
- }
137
- if (ptcSkillCall !== undefined) {
138
- ptcStudyPlusBySession.set(session, ptcSkillCall)
139
- return loadedId ?? directId ?? ptcSkillCall
140
- }
141
- return loadedId ?? directId
142
- }
143
-
144
- const projection = {
145
- key: KEY,
146
- stateVersion: 1,
147
- stateSchema,
148
- init,
149
- apply: fold,
150
- wire: {
151
- viewSchema,
152
- view: (state) => ({ active: state.active }),
153
- },
154
- }
155
-
156
- /**
157
- * @param config - `section` is the overlay text, required and non-empty.
158
- */
159
- export function apply(ctx, config) {
160
- const section = config?.section
161
- if (typeof section !== 'string' || section.trim() === '') {
162
- throw new Error('dsh-nerd-mode needs a non-empty string `section`')
163
- }
164
-
165
- ctx.sessionProjections.register(projection)
166
-
167
- ctx.effect(
168
- () => ctx.on('agent/inbox/claimed', ({ agent, message }) => {
169
- directStudyPlusBySession.set(agent.session, directStudyPlusId(message))
170
- }),
171
- 'dsh-nerd-mode: study-plus claim',
172
- )
173
-
174
- ctx.effect(
175
- () =>
176
- ctx.systemPrompt.section({
177
- name: SECTION,
178
- order: SECTION_ORDER,
179
- text: (context) => {
180
- if (context.agent === undefined) return ''
181
- if (!ctx.sessionProjections.stateOf(context.agent.session, KEY).active) return ''
182
- const session = context.agent.session
183
- const invocationId = studyPlusInvocation(session)
184
- if (invocationId === undefined) return section
185
- return section + studyPlusSettings(config ?? {}, session, invocationId)
186
- },
187
- }),
188
- 'dsh-nerd-mode: section',
189
- )
190
-
191
- ctx.inject(['commands'], (commandCtx) => {
192
- commandCtx.commands.register({
193
- name: COMMAND,
194
- description: 'Turn Nerd Mode on or off for this conversation',
195
- input: { hint: '[on|off]' },
196
- handler: ({ agent, rawInput }) => {
197
- const arg = rawInput.trim().toLowerCase()
198
- const active = ctx.sessionProjections.stateOf(agent.session, KEY).active
199
-
200
- if (arg === 'off') {
201
- return { kind: 'success', text: active ? 'Nerd Mode off.' : 'Nerd Mode is already off.' }
202
- }
203
- if (arg !== '' && arg !== 'on') {
204
- return { kind: 'error', text: `Unknown argument "${arg}". Use /nerd-mode on or /nerd-mode off.` }
205
- }
206
- return {
207
- kind: 'success',
208
- text: active ? 'Nerd Mode is already on.' : 'Nerd Mode on. /study guides a concept or a task.',
209
- }
210
- },
211
- })
212
- })
213
- }
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 z from '@deepseek-ai/schemastery'
15
+
16
+ export const name = 'dsh-nerd-mode'
17
+ export const PACKAGE_NAME = 'dsh-nerd-mode'
18
+ export const inject = ['systemPrompt', 'sessionProjections']
19
+
20
+ // The prompt text ships with the package. Keeping it out of the config stops
21
+ // every profile patch from carrying a stale copy of it.
22
+ const BUNDLED_SECTION = readFileSync(
23
+ fileURLToPath(new URL('../assets/section.md', import.meta.url)),
24
+ 'utf8',
25
+ ).trim()
26
+
27
+ export const Config = z.object({
28
+ section: z.string().pattern(/\S/).default(BUNDLED_SECTION),
29
+ recallSessionEnabled: z.boolean().default(false).volatile(),
30
+ unseenQuestionCount: z.number().step(1).min(0).max(10).default(3).volatile(),
31
+ unseenQuestionType: z.union([
32
+ z.const('short-answer'),
33
+ z.const('multiple-choice'),
34
+ z.const('mixed'),
35
+ ]).default('short-answer').volatile(),
36
+ })
37
+
38
+ const SECTION = 'study:policy'
39
+ // Between PLAN_POLICY (500) and TEAM_POLICY (600).
40
+ const SECTION_ORDER = 550
41
+ const studyPlusDefaultsBySession = new WeakMap()
42
+ const directStudyPlusBySession = new WeakMap()
43
+ // A PTC skill load leaves no message in the history, so keep its id for later
44
+ // turns the way an injected skill message would.
45
+ const ptcStudyPlusBySession = new WeakMap()
46
+ const SKILL_COMMAND = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
47
+
48
+ // shortcut: parse-only schemas, no zod. The projection registry calls `parse`
49
+ // and nothing else. Upgrade to zod if the host starts introspecting schemas.
50
+ const stateSchema = {
51
+ parse: (value) => ({
52
+ active: value?.active === true,
53
+ running:
54
+ value?.running && typeof value.running.commandId === 'string'
55
+ ? { commandId: value.running.commandId, wanted: value.running.wanted === true }
56
+ : null,
57
+ }),
58
+ }
59
+
60
+ const viewSchema = { parse: (value) => ({ active: value?.active === true }) }
61
+
62
+ function studyPlusSettings(config, session, invocationId) {
63
+ let defaults = studyPlusDefaultsBySession.get(session)
64
+ if (defaults?.invocationId !== invocationId) {
65
+ defaults = {
66
+ invocationId,
67
+ recallSessionEnabled: config.recallSessionEnabled?.get?.() ?? false,
68
+ unseenQuestionCount: config.unseenQuestionCount?.get?.() ?? 3,
69
+ unseenQuestionType: config.unseenQuestionType?.get?.() ?? 'short-answer',
70
+ }
71
+ studyPlusDefaultsBySession.set(session, defaults)
72
+ }
73
+
74
+ return [
75
+ '',
76
+ '',
77
+ 'Study-plus profile defaults for this tracked session:',
78
+ `- Recall Session: ${defaults.recallSessionEnabled ? 'enabled' : 'disabled'}.`,
79
+ `- Post-lecture questions: ${defaults.unseenQuestionCount}; type: ${defaults.unseenQuestionType}.`,
80
+ 'Apply these defaults only during tracked study-plus sessions. Ignore them for ordinary study and all other requests.',
81
+ 'An explicit preference in the current /study-plus request overrides its profile default for this session and does not update the profile.',
82
+ ].join('\n')
83
+ }
84
+
85
+ /** Return the message id when the message is a direct /study-plus command, else undefined. */
86
+ function directStudyPlusId(message) {
87
+ if (message?.source?.kind !== 'user') return undefined
88
+ const text = (message.content ?? [])
89
+ .filter((block) => block.type === 'text')
90
+ .map((block) => block.text)
91
+ .join('\n')
92
+ return [...text.matchAll(SKILL_COMMAND)].at(-1)?.[2] === 'study-plus' ? message.id : undefined
93
+ }
94
+
95
+ function studyPlusInvocation(session) {
96
+ const directInvocation = directStudyPlusBySession.get(session)
97
+ if (directInvocation !== undefined) return directInvocation
98
+ const ptcInvocation = ptcStudyPlusBySession.get(session)
99
+ if (ptcInvocation !== undefined) return ptcInvocation
100
+
101
+ // The host returns the full surface history here, not just this turn. See docs/agents/dsh-session-api.md.
102
+ const invocation = session.deriveMessages().findLast((message) => message.source?.kind === 'skill-invocation')
103
+ if (invocation?.source.name === 'study-plus') return invocation.id
104
+
105
+ const completedCalls = new Set()
106
+ let loadedId
107
+ let directId
108
+ let skillCall
109
+ let ptcSkillCall
110
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
111
+ const event = session.eventAt(seq)
112
+ if (event?.type === 'turn/start') break
113
+ // A resumed, forked, or seeded log may carry a partial event payload, so
114
+ // every nested read here is guarded. One bad record must not fail a turn.
115
+ if (event?.type === 'tool/result') {
116
+ const result = event.data?.message
117
+ if (result !== undefined && result.isError !== true && result.source?.callId !== undefined) {
118
+ completedCalls.add(result.source.callId)
119
+ }
120
+ } else if (event?.type === 'tool/call' && event.data?.name === 'skill') {
121
+ try {
122
+ if (JSON.parse(event.data.arguments).name === 'study-plus') skillCall = event.data.callId
123
+ } catch {}
124
+ } else if (event?.type === 'tool/ptc-dispatch' && event.data?.name === 'skill') {
125
+ // PTC records a skill call made inside run_code as a dispatch, not as a
126
+ // `tool/call`, and the dispatch event itself carries the outcome.
127
+ if (event.data.isError !== true && event.data.arguments?.name === 'study-plus') {
128
+ ptcSkillCall = event.data.subCallId
129
+ }
130
+ } else if (event?.type === 'user/message') {
131
+ // `user/message` stores the message fields directly in `data`; only
132
+ // `tool/result` and `system/message` wrap them under `data.message`.
133
+ const message = event.data
134
+ if (message?.source?.kind === 'skill-invocation') {
135
+ if (message.source.name === 'study-plus') loadedId = message.id
136
+ } else {
137
+ directId = directStudyPlusId(message) ?? directId
138
+ }
139
+ }
140
+ }
141
+
142
+ // A skill load counts only when its tool call completed. A typed command counts at once.
143
+ if (skillCall !== undefined) {
144
+ return completedCalls.has(skillCall) ? loadedId ?? directId ?? skillCall : undefined
145
+ }
146
+ if (ptcSkillCall !== undefined) {
147
+ ptcStudyPlusBySession.set(session, ptcSkillCall)
148
+ return loadedId ?? directId ?? ptcSkillCall
149
+ }
150
+ return loadedId ?? directId
151
+ }
152
+
153
+ const projection = {
154
+ key: KEY,
155
+ stateVersion: 1,
156
+ stateSchema,
157
+ init,
158
+ apply: fold,
159
+ wire: {
160
+ viewSchema,
161
+ view: (state) => ({ active: state.active }),
162
+ },
163
+ }
164
+
165
+ /**
166
+ * @param config - `section` is an optional overlay text; a missing value uses
167
+ * the bundled `assets/section.md`.
168
+ */
169
+ export function apply(ctx, config) {
170
+ const section = config?.section
171
+ if (typeof section !== 'string' || section.trim() === '') {
172
+ throw new Error('dsh-nerd-mode needs a non-empty string `section`')
173
+ }
174
+
175
+ ctx.sessionProjections.register(projection)
176
+
177
+ ctx.effect(
178
+ () => ctx.on('agent/inbox/claimed', ({ agent, message }) => {
179
+ directStudyPlusBySession.set(agent.session, directStudyPlusId(message))
180
+ }),
181
+ 'dsh-nerd-mode: study-plus claim',
182
+ )
183
+
184
+ ctx.effect(
185
+ () =>
186
+ ctx.systemPrompt.section({
187
+ name: SECTION,
188
+ order: SECTION_ORDER,
189
+ text: (context) => {
190
+ if (context.agent === undefined) return ''
191
+ if (!ctx.sessionProjections.stateOf(context.agent.session, KEY).active) return ''
192
+ const session = context.agent.session
193
+ const invocationId = studyPlusInvocation(session)
194
+ if (invocationId === undefined) return section
195
+ return section + studyPlusSettings(config ?? {}, session, invocationId)
196
+ },
197
+ }),
198
+ 'dsh-nerd-mode: section',
199
+ )
200
+
201
+ ctx.inject(['commands'], (commandCtx) => {
202
+ commandCtx.commands.register({
203
+ name: COMMAND,
204
+ description: 'Turn Nerd Mode on or off for this conversation',
205
+ input: { hint: '[on|off]' },
206
+ handler: ({ agent, rawInput }) => {
207
+ const arg = rawInput.trim().toLowerCase()
208
+ const active = ctx.sessionProjections.stateOf(agent.session, KEY).active
209
+
210
+ if (arg === 'off') {
211
+ return { kind: 'success', text: active ? 'Nerd Mode off.' : 'Nerd Mode is already off.' }
212
+ }
213
+ if (arg !== '' && arg !== 'on') {
214
+ return { kind: 'error', text: `Unknown argument "${arg}". Use /nerd-mode on or /nerd-mode off.` }
215
+ }
216
+ return {
217
+ kind: 'success',
218
+ text: active ? 'Nerd Mode is already on.' : 'Nerd Mode on. /study guides a concept or a task.',
219
+ }
220
+ },
221
+ })
222
+ })
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-nerd-mode",
3
- "version": "0.4.0",
3
+ "version": "0.4.4",
4
4
  "description": "A study switch and study skills for DeepSeek Harness.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,8 +29,6 @@ Write the SVG file first, then talk about it.
29
29
  3. Put one caption sentence after it that states the point the picture makes. Keep the prose around it short; the figure carries the detail.
30
30
  4. Embed the figure in the section whose step it illustrates, not in a gallery at the end.
31
31
 
32
- Inside a `/teach` workspace, write the file to that workspace's `assets/` and reference it from the lesson file instead, with the same relative form.
33
-
34
32
  The Markdown path is resolved against the working directory and served from disk, so it must be a file that exists when the message renders. Write the file before the sentence that shows it. No query string, no fragment, no `data:` URL: the renderer rejects all three and shows the alt text instead. To revise a figure, overwrite the file; if the learner still sees the old picture, write a new name.
35
33
 
36
34
  ## File contract
@@ -48,7 +48,7 @@ For `advanced`:
48
48
 
49
49
  Accuracy rules outrank the level. No level removes a symbol definition, a justification for a derivation step, a stated assumption or approximation, or a bound. No level adds filler, repeated explanation, or a section the concept does not need. The level affects the lecture only.
50
50
 
51
- For a one-off request with no graph, derive the level from the request and the supplied material. When neither states a goal or a background, ask one combined question before teaching: what the learner wants to reach and whether they have met the topic before. State the derived level in one line, then teach. Write no file.
51
+ For a one-off request with no graph, derive the level from the brief the upper layer supplies and from the request and the supplied material. A brief from an upper layer is final: never ask again for a goal or a background it leaves out. A bare topic, or "I want to learn X", states no goal. When no brief arrives and neither the request nor the supplied material states a goal or a background, ask one combined question before teaching: what the learner wants to reach and whether they have met the topic before. State the derived level in one line, then teach. Write no file.
52
52
 
53
53
  ## Format the answer
54
54
 
@@ -73,6 +73,6 @@ The upper layer may hand you an approved concept graph, the relevant supplied ma
73
73
 
74
74
  Default to chat output. If the learner asks to save a lecture or other artifact, save it to the requested path and show the same content in chat when practical.
75
75
 
76
- When an SVG would materially clarify a relationship, create it by default at `diagrams/<topic>/<name>.svg`. Load `diagram` for its visual authoring rules, but use this study-specific path instead of its generic default. Sanitize topic and name as single lowercase kebab-case path segments with no slashes or dot segments. If either is unclear, choose a safe fallback. Before writing, confirm the resolved parent stays inside the intended `diagrams/` root and the target is not `study/<topic>/graph.md`, `state.md`, `log.md`, or `plan.md`. Compute plotted points rather than placing them by eye. Before replacing an SVG with the same name, copy the old file to `<name>.svg.bak`, replacing any older backup.
76
+ When an SVG would materially clarify a relationship, create it by default. When the upper layer scopes a Course, write `study/<course>/diagrams/<name>.svg`. When the guide runs alone, write `diagrams/<name>.svg` and create no folder under `study/`. Load `diagram` for its visual authoring rules, but use this study-specific path instead of its generic default. Sanitize `course` and `name` as single lowercase kebab-case path segments with no slashes or dot segments. If either is unclear, choose a safe fallback. Before writing, confirm the resolved parent stays inside the intended `diagrams/` root and the target is not `study/<course>/graph.md`, `state.md`, `log.md`, or `plan.md`. Compute plotted points rather than placing them by eye. Before replacing an SVG with the same name, copy the old file to `<name>.svg.bak`, replacing any older backup.
77
77
 
78
- Never read or write `study/<topic>/graph.md`, `state.md`, `log.md`, or `plan.md`. Do not read or change tracker state. Study-plus owns the choice of lecture paths and the automatic lecture save during tracked sessions. Study may read or write ordinary artifacts, including lectures, drafts, and figures, at paths the user or the upper layer gives it.
78
+ Never read or write `study/<course>/graph.md`, `state.md`, `log.md`, or `plan.md`. Do not read or change tracker state. Study-plus owns the choice of lecture paths and the automatic lecture save during tracked sessions. Study may read or write ordinary artifacts, including lectures, drafts, and figures, at paths the user or the upper layer gives it.