dsh-llm-fallbacks 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.
@@ -154,24 +154,42 @@ function recordLine(record: Record<string, unknown>, subject: string): string {
154
154
  * frame holds EXACTLY the header line, the second (when the log has events)
155
155
  * holds the event batch.
156
156
  *
157
+ * The event batch is folded one line at a time into a byte chunk list: each
158
+ * event's JSON string is dropped as soon as its chunk exists, so those strings
159
+ * never accumulate. The chunk list is then concatenated ONCE for the compressor,
160
+ * so the batch's whole plaintext DOES exist at that moment — as that single
161
+ * contiguous buffer, which is the batch's peak (the per-record chunks it was built
162
+ * from are still referenced by the list while it lives). What the fold avoids is a
163
+ * SECOND copy of the batch, not every copy: no `join('')` string is built beside
164
+ * the chunks. The bytes are the same ones a `join('')` + encode would produce.
165
+ *
157
166
  * @param headerRecord released current header record.
158
167
  * @param eventRecords released current event records, in log order.
159
168
  * @returns the complete container bytes.
160
169
  */
161
170
  export function encodeZstdFrames(
162
171
  headerRecord: Record<string, unknown>,
163
- eventRecords: readonly Record<string, unknown>[],
172
+ eventRecords: Iterable<Record<string, unknown>>,
164
173
  ): Buffer {
165
174
  const frames = [compressFrame(recordLine(headerRecord, 'session header'))]
166
- if (eventRecords.length > 0) {
167
- const batch = eventRecords.map((record, index) => recordLine(record, `session event ${index}`)).join('')
168
- frames.push(compressFrame(batch))
175
+ const batch: Buffer[] = []
176
+ let index = 0
177
+ for (const record of eventRecords) {
178
+ batch.push(Buffer.from(recordLine(record, `session event ${index}`), 'utf8'))
179
+ index += 1
169
180
  }
181
+ if (batch.length > 0) frames.push(compressFrame(Buffer.concat(batch)))
170
182
  return Buffer.concat(frames)
171
183
  }
172
184
 
173
185
  /**
174
- * Decode every frame of one concatenated container, in file order.
186
+ * Decode every frame of one concatenated container, in file order, ONE FRAME AT A
187
+ * TIME: the consumer receives frame N's plaintext and decides when to ask for frame
188
+ * N+1, so this generator holds ONE frame's plaintext at a time and never two. That
189
+ * bounds the FRAME, not the session: the event batch is a single frame, so it IS the
190
+ * session's whole event plaintext (see {@link MAX_FRAME_PLAINTEXT_BYTES}). The frame
191
+ * STRUCTURE (the scanner) is still walked before the first frame's plaintext is
192
+ * produced, so a torn or corrupt container is refused up front.
175
193
  *
176
194
  * Bounded: the scanner reports each frame's DECLARED content size, a declaration
177
195
  * above {@link MAX_FRAME_PLAINTEXT_BYTES} is refused before any decompression, and
@@ -180,10 +198,10 @@ export function encodeZstdFrames(
180
198
  * coded exit), never an out-of-memory crash.
181
199
  *
182
200
  * @param bytes container bytes (an original log or a published generation).
183
- * @returns the plaintext of each frame.
201
+ * @yields the plaintext of each frame, in file order.
184
202
  */
185
- export function decodeZstdFrames(bytes: Buffer): string[] {
186
- return scanZstdFrames(bytes).map(({ start, end, declaredBytes }, index) => {
203
+ export function* decodeZstdFrameTexts(bytes: Buffer): Generator<string> {
204
+ for (const [index, { start, end, declaredBytes }] of scanZstdFrames(bytes).entries()) {
187
205
  if (declaredBytes !== null && declaredBytes > MAX_FRAME_PLAINTEXT_BYTES) {
188
206
  throw new Error(
189
207
  `refusing to decompress Zstandard frame ${index} at byte ${start}: it declares ${declaredBytes} bytes of `
@@ -206,13 +224,26 @@ export function decodeZstdFrames(bytes: Buffer): string[] {
206
224
  + `${plaintext.length}`,
207
225
  )
208
226
  }
209
- return plaintext.toString('utf8')
210
- })
227
+ yield plaintext.toString('utf8')
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Materializing convenience over {@link decodeZstdFrameTexts}: every frame's
233
+ * plaintext as one array. Specification code uses it as the independent read-back
234
+ * oracle; nothing on the tool's own path needs the whole container in memory, so
235
+ * production code consumes the generator instead.
236
+ *
237
+ * @param bytes container bytes.
238
+ * @returns the plaintext of each frame, in file order.
239
+ */
240
+ export function decodeZstdFrames(bytes: Buffer): string[] {
241
+ return [...decodeZstdFrameTexts(bytes)]
211
242
  }
212
243
 
213
244
  /** Compress one frame with the released writer's checksummed options. */
214
- function compressFrame(text: string): Buffer {
215
- return zstdCompressSync(Buffer.from(text, 'utf8'), ZSTD_CHECKSUM_OPTIONS)
245
+ function compressFrame(text: string | Buffer): Buffer {
246
+ return zstdCompressSync(typeof text === 'string' ? Buffer.from(text, 'utf8') : text, ZSTD_CHECKSUM_OPTIONS)
216
247
  }
217
248
 
218
249
  /**
@@ -296,8 +327,16 @@ function sizeToNumber(buffer: Buffer, offset: number, width: number): number | n
296
327
  * Canonical successor filename for one generation (SSOT:
297
328
  * `generationLogFilename` in `@deepseek-ai/dsh-session-persistence-jsonl`):
298
329
  * version 0 keeps the suffix-only name, later generations carry `v<N>`.
330
+ *
331
+ * Exported because this is the ONE implementation of the rule: the CLI's
332
+ * report-mode existing-successor probe resolves the same name through this
333
+ * function, which it receives from `runRepair`'s dynamic import of this module
334
+ * (a static import would defeat the `node:zlib` zstd runtime probe that must run
335
+ * first). `publishSuccessor` derives its target from the RESTORED header's
336
+ * version, the CLI's probe from the resolved catalog's declared
337
+ * `currentVersion` — see the probe's comment in `repair-session-logs.ts`.
299
338
  */
300
- function successorFilename(version: number): string {
339
+ export function successorFilename(version: number): string {
301
340
  return version === 0 ? 'session.jsonl.zstd' : `session.v${version}.jsonl.zstd`
302
341
  }
303
342
 
@@ -362,9 +401,15 @@ export async function publishSuccessor(
362
401
  throw new Error(`refusing to publish the successor generation over its own source: ${logPath}`)
363
402
  }
364
403
 
404
+ // The released encoder's records are handed to `encodeZstdFrames` one at a time
405
+ // (lazily): materializing them all first would hold a second copy of the session
406
+ // alongside the restored artifact it was built from.
407
+ const encodedEvents = function* (): Generator<Record<string, unknown>> {
408
+ for (const event of artifact.events) yield handle.catalog.encodeCurrentEvent(event)
409
+ }
365
410
  const bytes = encodeZstdFrames(
366
411
  handle.catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount),
367
- artifact.events.map((event) => handle.catalog.encodeCurrentEvent(event)),
412
+ encodedEvents(),
368
413
  )
369
414
 
370
415
  const temporaryPath = join(dirname(logPath), `session.repair.${randomBytes(6).toString('hex')}.jsonl.zstd.tmp`)
@@ -474,19 +519,27 @@ function verifyGeneration(
474
519
  bytes: Buffer,
475
520
  expectedGeneration: number,
476
521
  ): ReleasedArtifact {
477
- const [headerFrame, ...eventFrames] = decodeZstdFrames(bytes)
478
- if (headerFrame === undefined) throw new Error('published generation carries no Zstandard frame')
479
- if (headerFrame.indexOf('\n') !== headerFrame.length - 1) {
480
- throw new Error('published generation frame 1 is not exactly one header line')
481
- }
482
- const restore = catalog.createRestore(JSON.parse(headerFrame.slice(0, -1)), {
483
- recovery: 'strict',
484
- validation: 'current',
485
- })
486
- for (const line of eventFrames.join('').split('\n')) {
487
- if (line.length === 0) continue
488
- restore.decodeRow(JSON.parse(line))
522
+ // The staged container is read back frame by frame: no array of every frame's
523
+ // plaintext, no joined event batch and no split of it — each frame's lines are
524
+ // decoded as that frame is decompressed.
525
+ let restore: ReturnType<ReleasedCatalog['createRestore']> | null = null
526
+ for (const frame of decodeZstdFrameTexts(bytes)) {
527
+ if (restore === null) {
528
+ if (frame.indexOf('\n') !== frame.length - 1) {
529
+ throw new Error('published generation frame 1 is not exactly one header line')
530
+ }
531
+ restore = catalog.createRestore(JSON.parse(frame.slice(0, -1)), {
532
+ recovery: 'strict',
533
+ validation: 'current',
534
+ })
535
+ continue
536
+ }
537
+ for (const line of frame.split('\n')) {
538
+ if (line.length === 0) continue
539
+ restore.decodeRow(JSON.parse(line))
540
+ }
489
541
  }
542
+ if (restore === null) throw new Error('published generation carries no Zstandard frame')
490
543
  const artifact = restore.finish()
491
544
  if (artifact.header.version !== expectedGeneration) {
492
545
  throw new Error(