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/README.md +10 -5
- package/README.zh.md +10 -5
- package/client.js +491 -52
- package/lib/index.js +63 -0
- package/lib/study-sessions.js +283 -0
- package/package.json +70 -70
- package/skills/study/SKILL.md +1 -1
- package/skills/study-plus/SKILL.md +7 -7
- package/skills/study-plus/scripts/book-client.js +8 -10
- package/skills/study-plus/scripts/build-book.mjs +9 -3
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
|
-
"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
|
+
}
|
package/skills/study/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
60
|
-
|
|
61
|
-
| Most prerequisites are unknown, or the goal is to learn the topic from zero
|
|
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
|
|
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
|
|
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',
|
|
17
|
-
: { search: '搜索全书', toc: '目录', none: '没有匹配', hits: ' 处', chapter: '第',
|
|
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
|
|
37
|
-
return
|
|
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
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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');
|