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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/assets/calibration.svg +69 -69
- package/assets/nerd.svg +22 -22
- package/assets/section.md +15 -15
- package/client.js +2170 -2225
- package/cordis.patch.yml +24 -24
- package/lib/index.js +838 -286
- package/lib/study-sessions.js +283 -283
- package/package.json +70 -70
- package/skills/convert-documents-to-markdown/SKILL.md +26 -26
- package/skills/diagram/SKILL.md +43 -19
- package/skills/diagram/scripts/check-svg.mjs +184 -0
- package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
- package/skills/humanizer/.claude-plugin/plugin.json +15 -15
- package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
- package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
- package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
- package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
- package/skills/humanizer/.github/workflows/validate.yml +29 -29
- package/skills/humanizer/AGENTS.md +54 -54
- package/skills/humanizer/CHANGELOG.md +116 -116
- package/skills/humanizer/LICENSE +21 -21
- package/skills/humanizer/README.md +206 -206
- package/skills/humanizer/SKILL.md +397 -397
- package/skills/humanizer/agents/openai.yaml +4 -4
- package/skills/humanizer/scripts/validate-package.py +146 -146
- package/skills/study/SKILL.md +81 -78
- package/skills/study-plus/SKILL.md +246 -246
- package/skills/study-plus/scripts/book-client.js +284 -284
- package/skills/study-plus/scripts/book-data.mjs +289 -289
- package/skills/study-plus/scripts/build-book.mjs +155 -155
- package/skills/writing-beats/SKILL.md +66 -66
- package/skills/writing-beats/agents/openai.yaml +5 -5
package/lib/study-sessions.js
CHANGED
|
@@ -1,283 +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
|
-
}
|
|
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
|
+
}
|