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
@@ -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
+ }