dsh-manage-session 0.2.3 → 0.4.8

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
@@ -183,6 +183,557 @@ function pruneArchiveFile(ids, log, searchPaths) {
183
183
  throw new Error(lastReason);
184
184
  }
185
185
  //#endregion
186
+ //#region src/host/health/decode.ts
187
+ /**
188
+ * 会话日志读取链路(`log-integrity` gate 与 `lossless-json` gate 的共同底座)。
189
+ *
190
+ * 链路与 `_tools/dsh-session-invariant-repro.mjs` 的 `decodeSessionLog` 一致:
191
+ * zstd 多帧扫描(撕裂尾帧用 ZSTD_e_flush 抢救)→ 逐行 JSON.parse
192
+ * → provenance 展开(storage 形态的 seq ranges)→ `decodeStorageRecord` 解包
193
+ * (`text-chunks` / `reasoning-chunks` / `tool-call-chunks` 展开回 `assistant/chunk`)
194
+ * → seq 连续性校验(提交区出现缺口即停止并按 prefix 语义处理)。
195
+ *
196
+ * 解码器优先使用宿主真实的 `@deepseek-ai/dsh-session`(运行时按需软加载,零硬依赖);
197
+ * 不可用时使用本文件内的等价实现(同格式:dt 为**时间**增量、成员数 = dt.length + 1;
198
+ * provenance 存储形态为 `[[start,end],…]` 区间编码)。二者的差异会被显式记入
199
+ * `decoder` 字段,禁止静默回退。
200
+ */
201
+ /** zstd 帧魔数(0xFD2FB528 的小端读取值)。 */
202
+ const ZSTD_MAGIC = 4247762216;
203
+ /** 打包行类型(读路径必须解包)。 */
204
+ const PACKED_ROWS = /* @__PURE__ */ new Set([
205
+ "text-chunks",
206
+ "reasoning-chunks",
207
+ "tool-call-chunks"
208
+ ]);
209
+ /** 扫描拼接的 zstd 多帧;返回完整帧范围与撕裂尾帧起点。 */
210
+ function scanZstdFrames(buffer) {
211
+ const frames = [];
212
+ let offset = 0;
213
+ while (offset < buffer.length) {
214
+ const start = offset;
215
+ if (buffer.length - offset < 4) return {
216
+ frames,
217
+ tornStart: start
218
+ };
219
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return {
220
+ frames,
221
+ tornStart: start
222
+ };
223
+ offset += 4;
224
+ if (offset === buffer.length) return {
225
+ frames,
226
+ tornStart: start
227
+ };
228
+ const descriptor = buffer.readUInt8(offset);
229
+ offset += 1;
230
+ if ((descriptor & 24) !== 0) return {
231
+ frames,
232
+ tornStart: start
233
+ };
234
+ const contentSizeFlag = descriptor >>> 6;
235
+ const singleSegment = (descriptor & 32) !== 0;
236
+ const checksum = (descriptor & 4) !== 0;
237
+ const dictionaryFlag = descriptor & 3;
238
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
239
+ const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
240
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
241
+ if (buffer.length - offset < remainingHeaderBytes) return {
242
+ frames,
243
+ tornStart: start
244
+ };
245
+ offset += remainingHeaderBytes;
246
+ for (;;) {
247
+ if (buffer.length - offset < 3) return {
248
+ frames,
249
+ tornStart: start
250
+ };
251
+ const blockHeader = buffer.readUIntLE(offset, 3);
252
+ offset += 3;
253
+ const lastBlock = (blockHeader & 1) !== 0;
254
+ const blockType = blockHeader >>> 1 & 3;
255
+ const blockSize = blockHeader >>> 3;
256
+ if (blockType === 3) return {
257
+ frames,
258
+ tornStart: start
259
+ };
260
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
261
+ if (buffer.length - offset < payloadBytes) return {
262
+ frames,
263
+ tornStart: start
264
+ };
265
+ offset += payloadBytes;
266
+ if (lastBlock) break;
267
+ }
268
+ if (checksum) {
269
+ if (buffer.length - offset < 4) return {
270
+ frames,
271
+ tornStart: start
272
+ };
273
+ offset += 4;
274
+ }
275
+ frames.push({
276
+ start,
277
+ end: offset
278
+ });
279
+ }
280
+ return { frames };
281
+ }
282
+ /** 存储形态的 provenance(`[[start,end],…]` 区间编码或直接 seq 数组)展开为递增 seq 列表。 */
283
+ function decodeSeqRangesStorage(value, currentSeq) {
284
+ if (!Array.isArray(value)) throw new Error("sourceEventSeqs must be an array when present");
285
+ const out = [];
286
+ for (const entry of value) {
287
+ if (typeof entry === "number") {
288
+ out.push(entry);
289
+ continue;
290
+ }
291
+ if (Array.isArray(entry) && entry.length === 2) {
292
+ const [start, end] = entry;
293
+ if (typeof start !== "number" || typeof end !== "number" || end < start) throw new Error("sourceEventSeqs range is malformed");
294
+ for (let seq = start; seq <= end; seq += 1) out.push(seq);
295
+ continue;
296
+ }
297
+ throw new Error("sourceEventSeqs entry is neither a seq nor a [start,end] range");
298
+ }
299
+ for (const seq of out) if (!Number.isSafeInteger(seq) || seq < 0 || seq >= currentSeq) throw new Error(`sourceEventSeqs must reference earlier events: ${seq} >= ${currentSeq}`);
300
+ return out;
301
+ }
302
+ /** 本地等价实现:把一行存储记录解包为事件(打包行展开为 assistant/chunk)。 */
303
+ function decodeRecordLocal(value) {
304
+ if (typeof value !== "object" || value === null) return [value];
305
+ const record = value;
306
+ const type = record["type"];
307
+ if (typeof type !== "string" || !PACKED_ROWS.has(type)) {
308
+ const seq = record["seq"];
309
+ if (typeof seq === "number" && (!Number.isSafeInteger(seq) || seq < 0)) throw new Error(`session event seq ${String(seq)} is not a non-negative safe integer`);
310
+ return [record];
311
+ }
312
+ const data = record["data"];
313
+ if (data === void 0) throw new Error(`malformed ${type} storage row: data must be an object`);
314
+ const members = type === "tool-call-chunks" ? data["args"] : data["texts"];
315
+ if (!Array.isArray(members) || members.length === 0 || members.some((entry) => typeof entry !== "string")) throw new Error(`malformed ${type} storage row: payload must be a non-empty string array`);
316
+ const dt = data["dt"];
317
+ if (!Array.isArray(dt) || dt.some((gap) => !Number.isSafeInteger(gap))) throw new Error(`malformed ${type} storage row: dt must be an array of safe integers`);
318
+ if (dt.length !== members.length - 1) throw new Error(`malformed ${type} storage row: dt length ${dt.length} does not match ${members.length} members`);
319
+ const seq0 = record["seq0"];
320
+ const time0 = record["time0"];
321
+ if (typeof seq0 !== "number" || typeof time0 !== "number") throw new Error(`malformed ${type} storage row: seq0/time0 must be numbers`);
322
+ const turn = data["turn"];
323
+ const step = data["step"];
324
+ const index = data["index"];
325
+ const out = [];
326
+ let time = time0;
327
+ for (let k = 0; k < members.length; k += 1) {
328
+ if (k > 0) time += dt[k - 1];
329
+ let chunk;
330
+ if (type === "text-chunks") chunk = {
331
+ type: "text-delta",
332
+ index,
333
+ text: members[k]
334
+ };
335
+ else if (type === "reasoning-chunks") chunk = {
336
+ type: "reasoning-delta",
337
+ index,
338
+ text: members[k]
339
+ };
340
+ else chunk = {
341
+ type: "tool-call-delta",
342
+ index,
343
+ id: typeof data["id"] === "string" ? data["id"] : "",
344
+ ...typeof data["name"] === "string" ? { name: data["name"] } : {},
345
+ argumentsDelta: members[k]
346
+ };
347
+ out.push({
348
+ type: "assistant/chunk",
349
+ seq: seq0 + k,
350
+ time,
351
+ data: {
352
+ turn,
353
+ step,
354
+ chunk
355
+ }
356
+ });
357
+ }
358
+ return out;
359
+ }
360
+ /**
361
+ * 读取一个会话日志文件(zip 帧扫描 + 解包 + seq 连续性)。
362
+ * @param file - 当前代日志的绝对路径(由代次解析得出,不假定具体文件名)。
363
+ * @param bytes - 文件内容(调用方读取,便于测试注入)。
364
+ * @param decoders - 可选官方解码器(缺省用本地等价实现)。
365
+ * @returns 事件、统计与问题清单。
366
+ */
367
+ function decodeSessionLogBytes(file, bytes, decoders) {
368
+ const { frames, tornStart } = scanZstdFrames(bytes);
369
+ const parts = [];
370
+ const frameIssues = [];
371
+ for (const frame of frames) try {
372
+ parts.push(zlib.zstdDecompressSync(bytes.subarray(frame.start, frame.end)));
373
+ } catch (err) {
374
+ frameIssues.push(`frame@${frame.start}: ${String(err instanceof Error ? err.message : err)}`);
375
+ }
376
+ let recoveredFromTorn = 0;
377
+ if (tornStart !== void 0) try {
378
+ const recovered = zlib.zstdDecompressSync(bytes.subarray(tornStart), { finishFlush: zlib.constants.ZSTD_e_flush });
379
+ recoveredFromTorn = recovered.length;
380
+ parts.push(recovered);
381
+ } catch {}
382
+ const lines = Buffer.concat(parts).toString("utf8").split("\n").filter((line) => line.trim() !== "");
383
+ const issues = frameIssues.map((why, index) => ({
384
+ line: index + 1,
385
+ why
386
+ }));
387
+ const decodeRecord = decoders?.decodeStorageRecord ?? decodeRecordLocal;
388
+ const events = [];
389
+ for (let index = 1; index < lines.length; index += 1) {
390
+ const line = lines[index];
391
+ let decoded;
392
+ try {
393
+ const parsed = JSON.parse(line);
394
+ decoded = decodeRecord(parsed["sourceEventSeqs"] === void 0 ? parsed : {
395
+ ...parsed,
396
+ sourceEventSeqs: decoders?.decodeSeqRanges !== void 0 && typeof parsed["seq"] === "number" ? decoders.decodeSeqRanges(parsed["sourceEventSeqs"], parsed["seq"]) : decodeSeqRangesStorage(parsed["sourceEventSeqs"], Number(parsed["seq"] ?? 0))
397
+ });
398
+ } catch (err) {
399
+ issues.push({
400
+ line: index + 1,
401
+ why: `decode failed: ${String(err instanceof Error ? err.message : err)}`
402
+ });
403
+ break;
404
+ }
405
+ const rowStart = events.length;
406
+ let gapped = false;
407
+ for (const event of decoded) {
408
+ if (event.seq !== events.length) {
409
+ issues.push({
410
+ line: index + 1,
411
+ why: `seq gap (expected ${events.length}, got ${event.seq})`
412
+ });
413
+ events.length = rowStart;
414
+ gapped = true;
415
+ break;
416
+ }
417
+ events.push(event);
418
+ }
419
+ if (gapped) break;
420
+ }
421
+ const openStep = findOpenStep(events);
422
+ let header;
423
+ const headerLine = lines[0];
424
+ if (headerLine !== void 0) try {
425
+ const parsed = JSON.parse(headerLine);
426
+ if (typeof parsed === "object" && parsed !== null) header = parsed;
427
+ } catch {}
428
+ return {
429
+ events,
430
+ frames: frames.length,
431
+ ...tornStart === void 0 ? {} : { tornStart },
432
+ recoveredFromTorn,
433
+ issues,
434
+ decoder: decoders === void 0 ? "local" : "official",
435
+ ...openStep === void 0 ? {} : { openStep },
436
+ ...header === void 0 ? {} : { header }
437
+ };
438
+ }
439
+ /** 从文件读取并解码。 */
440
+ function decodeSessionLogFile(file, decoders) {
441
+ return decodeSessionLogBytes(file, readFileSync(file), decoders);
442
+ }
443
+ /** 尾部未收尾的 open step(有 step/start 无对应 step/end)。 */
444
+ function findOpenStep(events) {
445
+ let open;
446
+ for (const event of events) {
447
+ const data = event.data;
448
+ if (event.type === "step/start" && typeof data?.turn === "number" && typeof data?.step === "number") {
449
+ open = {
450
+ turn: data.turn,
451
+ step: data.step
452
+ };
453
+ continue;
454
+ }
455
+ if (event.type === "step/end") open = void 0;
456
+ }
457
+ return open;
458
+ }
459
+ //#endregion
460
+ //#region src/host/health/source-kind.ts
461
+ /**
462
+ * 插件署名格式(source kind)体检门与转换。
463
+ *
464
+ * ## 背景:会话格式 v4 的署名要求
465
+ *
466
+ * 宿主 0.1.7-rc.1 起(会话格式 v4)废弃裸 `source: { kind: 'plugin', plugin: '<name>' }`
467
+ * 的署名方式,要求每条插件写入的消息使用生产者自有 kind(`plugin:<name>`)。
468
+ * 校验失败时**整轮失败**(SessionFormatError),不是丢一条消息。
469
+ * 修复模式(与已适配的 aegis、dsh-better-sidebar 一致):
470
+ * `{ kind: 'plugin', plugin: '<name>' }` → `{ kind: 'plugin:<name>' }`(删除 plugin 字段)。
471
+ *
472
+ * ## 版本依赖表(各格式代次依赖的宿主固定版本与处置策略)
473
+ *
474
+ * | 格式代次 | 宿主版本线 | 旧署名行是否合法 | 处置策略 |
475
+ * |---|---|---|---|
476
+ * | v1 | 未考证(早期线) | 合法 | 不动(leave-asis) |
477
+ * | v2 | 未考证 | 合法 | 不动 |
478
+ * | v3 | 0.1.6 时代(待考证) | 合法 | 不动:宿主 v3→v4 迁移负责,插件越权改写属过度操作 |
479
+ * | v4 | ≥ 0.1.7-rc.1(已实锤)| **拒绝**(SessionFormatError) | 检测 + 可选转换(本模块) |
480
+ *
481
+ * 判定依据是**日志 header 的 version 字段**(`decode.ts` 已解析为 `SessionLogRead.header`),
482
+ * 不猜文件名、不猜宿主安装版本:同一份磁盘语料可能混有多条版本线的产物,
483
+ * 每份日志按它自己的 header 代次走对应的策略线。
484
+ *
485
+ * ## 红线(与 repair.ts 的红线关系)
486
+ *
487
+ * repair.ts 的红线是「禁止改写会话日志」。本模块的转换是**经用户显式触发的唯一例外**:
488
+ * 只改 `source.kind` / `source.plugin` 两个署名字段,其余字节原样保留;改前整文件备份;
489
+ * 日志完整性不合格(撕裂尾帧 / 解码失败 / seq 缺口)或代次不在 v4 线时**拒绝执行**。
490
+ * 这三道闸缺一不可,否则就退化成「静默改历史数据」。
491
+ */
492
+ /** v4 线起点的宿主版本(会话格式 v4 引入 producer-owned source kind)。 */
493
+ const V4_HOST_MIN = "0.1.7-rc.1";
494
+ /** 从解码后的日志提取旧署名事实(只读)。 */
495
+ function readSourceKindFacts(log) {
496
+ const byPlugin = {};
497
+ const examples = [];
498
+ for (const event of log.events) {
499
+ const source = event.data?.source;
500
+ if (source === void 0 || typeof source !== "object" || source.kind !== "plugin" || typeof source.plugin !== "string" || source.plugin === "") continue;
501
+ byPlugin[source.plugin] = (byPlugin[source.plugin] ?? 0) + 1;
502
+ if (examples.length < 5) examples.push({
503
+ seq: event.seq,
504
+ plugin: source.plugin
505
+ });
506
+ }
507
+ const headerVersion = typeof log.header?.["version"] === "number" ? log.header["version"] : void 0;
508
+ return {
509
+ legacyCount: Object.values(byPlugin).reduce((sum, n) => sum + n, 0),
510
+ byPlugin,
511
+ examples,
512
+ ...headerVersion === void 0 ? {} : { headerVersion }
513
+ };
514
+ }
515
+ /**
516
+ * gate 5:插件署名格式(v4 线要求 producer-owned kind)。
517
+ *
518
+ * 档位判据(有据才抬档):
519
+ * - header.version ≥ 4 且观测到旧署名行 → `warn`:v4 校验拒绝这种署名;存量行让
520
+ * 未适配插件的读回判断(`kind === 'plugin' && plugin === name`)失配,转换可消除;
521
+ * - header.version < 4 → `ok`:v3 及更早线旧行合法,宿主迁移负责,**不动**(防过度操作);
522
+ * - header.version 无从读取 → `skipped`:无从判定该走哪条版本线。
523
+ * @param facts - 旧署名事实;undefined 表示未提供(无日志)。
524
+ */
525
+ function gateSourceKind(facts) {
526
+ if (facts === void 0) return {
527
+ id: "source-kind",
528
+ level: "skipped",
529
+ evidence: "未提供日志读取结果,本门无从判定"
530
+ };
531
+ const version = facts.headerVersion;
532
+ const plugins = Object.entries(facts.byPlugin);
533
+ const detail = {
534
+ headerVersion: version,
535
+ legacyCount: facts.legacyCount,
536
+ byPlugin: facts.byPlugin,
537
+ examples: facts.examples,
538
+ v4HostMin: V4_HOST_MIN
539
+ };
540
+ if (version === void 0) return {
541
+ id: "source-kind",
542
+ level: "skipped",
543
+ evidence: "日志 header 无 version 字段,无从判定格式代次线",
544
+ detail
545
+ };
546
+ if (version < 4) return {
547
+ id: "source-kind",
548
+ level: "ok",
549
+ evidence: `格式代次 v${version}(v4 之前的版本线):旧署名行合法,宿主 v3→v4 迁移负责,不做转换` + (plugins.length > 0 ? `(观测到 ${plugins.reduce((s, [, n]) => s + n, 0)} 行旧署名,按策略不动)` : ""),
550
+ detail
551
+ };
552
+ if (plugins.length === 0) return {
553
+ id: "source-kind",
554
+ level: "ok",
555
+ evidence: `格式代次 v${version}:全部插件署名均为 producer-owned kind(plugin:<name>)`,
556
+ detail
557
+ };
558
+ const summary = plugins.map(([name, count]) => `${name}×${count}`).join("、");
559
+ return {
560
+ id: "source-kind",
561
+ level: "warn",
562
+ evidence: `格式代次 v${version} 但存在 ${facts.legacyCount} 行旧署名(${summary}):宿主 v4 拒绝该形态,未适配插件读回判断也会失配;可执行署名转换`,
563
+ detail
564
+ };
565
+ }
566
+ /**
567
+ * 从旧署名对象构造新署名:`{ kind: 'plugin', plugin: name, …rest }` → `{ kind: 'plugin:name', …rest }`。
568
+ * 非 legacy(缺 plugin 字段 / kind 不符)返回 undefined,调用方跳过该行。
569
+ *
570
+ * ⚠️ 只对**第三方**生产者名成立(第四轮审计 ST1):宿主 `producerKind`
571
+ * (dsh-session-format-v3-to-v4@0.1.7-rc.2 lib/index.js:88-96)对第一方名走改名表
572
+ * 或同名裸 kind,本函数无条件加前缀会对那 30 个名字写出宿主永不产出的 kind。
573
+ * 第一方名由 `FIRST_PARTY_RENAMED_PRODUCERS` / `FIRST_PARTY_SAME_NAME_PRODUCERS`
574
+ * 判定,`migrateSessionSourceKind` 命中即跳过并如实上报(宿主迁移职权,不代转换)。
575
+ */
576
+ function migrateLegacySource(source) {
577
+ if (typeof source !== "object" || source === null) return void 0;
578
+ const record = source;
579
+ if (record["kind"] !== "plugin" || typeof record["plugin"] !== "string" || record["plugin"] === "") return void 0;
580
+ const { plugin, ...rest } = record;
581
+ return {
582
+ ...rest,
583
+ kind: `plugin:${plugin}`
584
+ };
585
+ }
586
+ /**
587
+ * 宿主 v3→v4 迁移会**改名**的第一方生产者(出处:dsh-session-format-v3-to-v4@
588
+ * 0.1.7-rc.2 lib/index.js:51-57 `RENAMED_PRODUCERS`,逐字照录)。
589
+ */
590
+ const FIRST_PARTY_RENAMED_PRODUCERS = Object.freeze({
591
+ "compact": "compact-checkpoint",
592
+ "tools-code-mode": "ptc-mode",
593
+ "tools-ptc": "ptc-mode",
594
+ "dsh-compaction-basic": "compact-basic",
595
+ "@deepseek-ai/dsh-system-prompt": "runtime-context"
596
+ });
597
+ /**
598
+ * 宿主迁移后**保留同名裸 kind** 的第一方生产者(出处:同上 lib/index.js:59-84
599
+ * `RELEASED_SAME_NAME_PRODUCERS`,逐字照录;注释明言限第一方)。
600
+ * 注:`@deepseek-ai/dsh-system-prompt` 还有 role 敏感分支(role=system →
601
+ * `system-prompt`),steward 无 role 上下文、结构性不可复刻——这正是第一方名
602
+ * 一律跳过、交还宿主迁移的核心理由之一。
603
+ */
604
+ const FIRST_PARTY_SAME_NAME_PRODUCERS = /* @__PURE__ */ new Set([
605
+ "agent-instructions",
606
+ "session-reference",
607
+ "team-message",
608
+ "goal",
609
+ "skill-invocation",
610
+ "skill-catalog",
611
+ "coordinator",
612
+ "subagent-report",
613
+ "subagent-settled",
614
+ "webhook",
615
+ "agent-message",
616
+ "model-selection",
617
+ "plan-mode",
618
+ "time-context",
619
+ "tmux-context",
620
+ "user-approval",
621
+ "repeat-tool-reminder",
622
+ "tool-cordis",
623
+ "cordis-host-runner",
624
+ "tool-goal",
625
+ "tool-jobs",
626
+ "hooks-codex",
627
+ "hooks-claude-code",
628
+ "schedule",
629
+ "dsh-session-title-llm"
630
+ ]);
631
+ /** 是否第一方生产者名(宿主迁移职权,steward 不代转换——防归因静默损坏)。 */
632
+ function isFirstPartyLegacyProducer(plugin) {
633
+ return Object.hasOwn(FIRST_PARTY_RENAMED_PRODUCERS, plugin) || FIRST_PARTY_SAME_NAME_PRODUCERS.has(plugin);
634
+ }
635
+ /**
636
+ * 转换一个会话日志里的旧署名行(v4 线专用;先备份,只动署名字段)。
637
+ *
638
+ * 执行闸(任一不满足即拒绝,不写盘):
639
+ * 1. 日志可整体解码且无完整性问题(撕裂尾帧 / 解码失败 / seq 缺口);
640
+ * 2. header.version ≥ 4(v3 及更早的旧行按契约由宿主迁移,不动);
641
+ * 3. 确有旧署名行(没有则空手而归,不做无谓重压缩)。
642
+ * @param sessionId - 会话 id(用于结果标注)。
643
+ * @param logPath - 当前代日志路径(必须 .zstd 结尾)。
644
+ * @param log - 已解码的日志(复用体检结果,闸 1/2 在其上判定)。
645
+ * @param dshHome - DSH home(备份不依赖它,参数保留与其它处置一致的面)。
646
+ * @param now - 时间源(备份后缀,测试可控)。
647
+ */
648
+ function migrateSessionSourceKind(sessionId, logPath, log, dshHome, now = Date.now) {
649
+ const failure = (error) => ({
650
+ ok: false,
651
+ sessionId,
652
+ path: logPath,
653
+ error
654
+ });
655
+ if (!logPath.endsWith(".zstd")) return failure(`非 zstd 产物,拒绝处理:${logPath}`);
656
+ if (log.issues.length > 0) return failure(`日志存在 ${log.issues.length} 条完整性问题,拒绝改写(先解决 log-integrity 门)`);
657
+ if (log.tornStart !== void 0) return failure("存在撕裂尾帧,拒绝改写(先让宿主完成提交或处置 log-integrity 门)");
658
+ const headerVersion = typeof log.header?.["version"] === "number" ? log.header["version"] : void 0;
659
+ if (headerVersion === void 0) return failure("日志 header 无 version 字段,无法确认格式代次线,拒绝改写");
660
+ if (headerVersion < 4) return failure(`格式代次 v${headerVersion}:旧署名在该版本线合法,宿主迁移负责,不做转换(防过度操作)`);
661
+ let original;
662
+ try {
663
+ original = readPlaintext(logPath);
664
+ } catch (err) {
665
+ return failure(`读取日志失败:${String(err instanceof Error ? err.message : err)}`);
666
+ }
667
+ const lines = original.split("\n");
668
+ let changedRows = 0;
669
+ const byPlugin = {};
670
+ const skippedFirstParty = {};
671
+ const rewritten = lines.map((line, index) => {
672
+ if (index === 0 || line.trim() === "") return line;
673
+ let parsed;
674
+ try {
675
+ parsed = JSON.parse(line);
676
+ } catch {
677
+ return line;
678
+ }
679
+ if (typeof parsed !== "object" || parsed === null) return line;
680
+ const record = parsed;
681
+ const data = record["data"];
682
+ if (data === void 0 || typeof data !== "object") return line;
683
+ const legacy = data["source"];
684
+ if (legacy === void 0 || typeof legacy !== "object" || legacy["kind"] !== "plugin" || typeof legacy["plugin"] !== "string" || legacy["plugin"] === "") return line;
685
+ const plugin = legacy["plugin"];
686
+ if (isFirstPartyLegacyProducer(plugin)) {
687
+ skippedFirstParty[plugin] = (skippedFirstParty[plugin] ?? 0) + 1;
688
+ return line;
689
+ }
690
+ const migrated = migrateLegacySource(legacy);
691
+ if (migrated === void 0) return line;
692
+ data["source"] = migrated;
693
+ changedRows += 1;
694
+ byPlugin[plugin] = (byPlugin[plugin] ?? 0) + 1;
695
+ return JSON.stringify(record);
696
+ });
697
+ if (changedRows === 0) return {
698
+ ok: true,
699
+ sessionId,
700
+ path: logPath,
701
+ changedRows: 0,
702
+ byPlugin: {},
703
+ ...Object.values(skippedFirstParty).reduce((sum, n) => sum + n, 0) > 0 ? { skippedFirstParty } : {}
704
+ };
705
+ const backup = `${logPath}.pre-sourcemigrate-${now()}`;
706
+ try {
707
+ copyFileSync(logPath, backup);
708
+ const compressed = zlib.zstdCompressSync(Buffer.from(rewritten.join("\n"), "utf8"));
709
+ const tmp = `${logPath}.sourcemigrate-tmp`;
710
+ writeFileSync(tmp, compressed);
711
+ renameSync(tmp, logPath);
712
+ return {
713
+ ok: true,
714
+ sessionId,
715
+ path: logPath,
716
+ backup,
717
+ changedRows,
718
+ byPlugin,
719
+ ...Object.keys(skippedFirstParty).length > 0 ? { skippedFirstParty } : {}
720
+ };
721
+ } catch (err) {
722
+ return failure(`转换落盘失败(原文件未动,备份${existsSync(backup) ? "已生成" : "未生成"}):${String(err instanceof Error ? err.message : err)}`);
723
+ }
724
+ }
725
+ /** 读出整份 zstd 日志的明文;失败时抛出(调用方拒绝改写并如实上报)。 */
726
+ function readPlaintext(logPath) {
727
+ const bytes = readFileSync(logPath);
728
+ const { frames, tornStart } = scanZstdFrames(bytes);
729
+ if (tornStart !== void 0) throw new Error("存在撕裂尾帧,拒绝改写");
730
+ const parts = [];
731
+ for (const frame of frames) parts.push(zlib.zstdDecompressSync(bytes.subarray(frame.start, frame.end)));
732
+ return Buffer.concat(parts).toString("utf8");
733
+ }
734
+ /** 备份文件的后缀约定(purge/清理工具可据此识别并一并处置)。 */
735
+ const SOURCE_MIGRATE_BACKUP_SUFFIX = ".pre-sourcemigrate-";
736
+ //#endregion
186
737
  //#region src/host/history/purge.ts
187
738
  /**
188
739
  * 归档文件清理(**真删除**)—— 会话管家唯一的破坏性操作。
@@ -208,6 +759,39 @@ function pruneArchiveFile(ids, log, searchPaths) {
208
759
  * 执行顺序是**先改文件、后删实体**:改文件是原子的且可整体中止(失败则一个字节都没删);
209
760
  * 反过来先删实体再改文件,中途失败会留下僵尸 id。
210
761
  */
762
+ /** 是否署名转换备份文件(`*.pre-sourcemigrate-<ts>`)。 */
763
+ function isSourceMigrateBackupName(name) {
764
+ return name.includes(SOURCE_MIGRATE_BACKUP_SUFFIX);
765
+ }
766
+ /**
767
+ * 列出一个转录目录里的署名转换备份(只扫顶层:备份与日志同目录落盘)。
768
+ * @returns 备份绝对路径与合计字节数;目录不存在时为空。
769
+ */
770
+ function backupFilesIn(dir) {
771
+ const files = [];
772
+ let bytes = 0;
773
+ let entries;
774
+ try {
775
+ entries = readdirSync(dir, { withFileTypes: true });
776
+ } catch {
777
+ return {
778
+ files,
779
+ bytes
780
+ };
781
+ }
782
+ for (const entry of entries) {
783
+ if (!entry.isFile() || !isSourceMigrateBackupName(entry.name)) continue;
784
+ const path = join(dir, entry.name);
785
+ files.push(path);
786
+ try {
787
+ bytes += statSync(path).size;
788
+ } catch {}
789
+ }
790
+ return {
791
+ files,
792
+ bytes
793
+ };
794
+ }
211
795
  /** 转录根目录(`<dshHome>/sessions`)。 */
212
796
  function sessionsRootFor(dshHome) {
213
797
  return join(dshHome, "sessions");
@@ -299,12 +883,17 @@ function locateSessionUsage(ids, dshHome) {
299
883
  const usage = {
300
884
  sessionId,
301
885
  bytes: 0,
886
+ backupBytes: 0,
887
+ backupCount: 0,
302
888
  cacheBytes: 0
303
889
  };
304
890
  const dir = dirs.get(key);
305
891
  if (dir !== void 0) {
306
892
  usage.dir = dir;
307
893
  usage.bytes = dirSize(dir);
894
+ const backups = backupFilesIn(dir);
895
+ usage.backupBytes = backups.bytes;
896
+ usage.backupCount = backups.files.length;
308
897
  }
309
898
  for (const name of [`${sessionId}.json`, `${key}.json`]) {
310
899
  const candidate = join(cacheRoot, name);
@@ -412,6 +1001,7 @@ function purgeHistory(payload, log, options) {
412
1001
  const failures = [];
413
1002
  let purged = 0;
414
1003
  let freedBytes = 0;
1004
+ let backupsRemoved = 0;
415
1005
  for (const sessionId of ids) {
416
1006
  const entry = usage.get(sessionId);
417
1007
  let missing = 0;
@@ -424,6 +1014,7 @@ function purgeHistory(payload, log, options) {
424
1014
  });
425
1015
  continue;
426
1016
  }
1017
+ backupsRemoved += entry.backupCount;
427
1018
  try {
428
1019
  rmSync(entry.dir, {
429
1020
  recursive: true,
@@ -461,11 +1052,12 @@ function purgeHistory(payload, log, options) {
461
1052
  purged++;
462
1053
  freedBytes += entryFreed;
463
1054
  }
464
- log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; failures ${failures.length}`);
1055
+ log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; backups ${backupsRemoved}; failures ${failures.length}`);
465
1056
  return {
466
1057
  ok: true,
467
1058
  purged,
468
1059
  freedBytes,
1060
+ backupsRemoved,
469
1061
  requiresRestart: true,
470
1062
  ...failures.length === 0 ? {} : { failures }
471
1063
  };
@@ -529,7 +1121,8 @@ async function listHistory(getRegistry, query, searchPaths, dshHome) {
529
1121
  cwd: "",
530
1122
  updatedAt: 0,
531
1123
  bytes: 0,
532
- cacheBytes: 0
1124
+ cacheBytes: 0,
1125
+ backupBytes: 0
533
1126
  };
534
1127
  });
535
1128
  if (dshHome !== void 0 && dshHome !== "" && items.length > 0) try {
@@ -539,6 +1132,7 @@ async function listHistory(getRegistry, query, searchPaths, dshHome) {
539
1132
  if (entry === void 0) continue;
540
1133
  item.bytes = entry.bytes;
541
1134
  item.cacheBytes = entry.cacheBytes;
1135
+ item.backupBytes = entry.backupBytes;
542
1136
  }
543
1137
  } catch {}
544
1138
  const fileIds = new Set(ids);
@@ -732,328 +1326,241 @@ var HealthCache = class {
732
1326
  /** 开始一轮分批扫描(客户端以 `offset: 0` 发起时调用)。 */
733
1327
  begin(total) {
734
1328
  this.pending = {
735
- total: Math.max(0, Math.floor(total)),
736
- findings: [],
737
- visited: 0
738
- };
739
- }
740
- /**
741
- * 追加一批扫描结果。
742
- *
743
- * 只有累积的已访问数走满语料总数才成型;语言中途断掉(关面板、报错)不会留下
744
- * 半份缓存冒充完整结果。
745
- * @param findings - 本批的非 ok 报告。
746
- * @param scanned - 本批**已访问**的会话数(不是命中数)。
747
- * @param now - 时间源(测试可控)。
748
- * @returns 本批追加后缓存是否刚好成型。
749
- */
750
- append(findings, scanned, now = Date.now) {
751
- const pending = this.pending;
752
- if (pending === void 0) return false;
753
- pending.findings.push(...findings);
754
- pending.visited += Math.max(0, Math.floor(scanned));
755
- if (pending.visited < pending.total) return false;
756
- this.ready = {
757
- findings: pending.findings,
758
- total: pending.total,
759
- generatedAt: now()
760
- };
761
- this.pending = void 0;
762
- return true;
763
- }
764
- /**
765
- * 单条就地回写(单会话体检 / 可逆处置后调用)。
766
- *
767
- * 这是缓存的**关键收益**:处置本就重算了 `after` 报告,把它写回即可,
768
- * 不必为了刷新一行而重扫整个语料。
769
- * @param report - 重算出的单会话报告。
770
- * @returns 是否真的改动了缓存(无缓存时为 false)。
771
- */
772
- patch(report) {
773
- const ready = this.ready;
774
- if (ready === void 0) return false;
775
- const index = ready.findings.findIndex((entry) => entry.sessionId === report.sessionId);
776
- if (report.level === "ok") {
777
- if (index < 0) return false;
778
- ready.findings.splice(index, 1);
779
- return true;
780
- }
781
- if (index >= 0) ready.findings[index] = report;
782
- else ready.findings.push(report);
783
- return true;
784
- }
785
- /** 丢弃缓存与未完成的累积。 */
786
- clear() {
787
- this.ready = void 0;
788
- this.pending = void 0;
789
- }
790
- };
791
- //#endregion
792
- //#region src/host/health/decode.ts
793
- /**
794
- * 会话日志读取链路(`log-integrity` gate 与 `lossless-json` gate 的共同底座)。
795
- *
796
- * 链路与 `_tools/dsh-session-invariant-repro.mjs` 的 `decodeSessionLog` 一致:
797
- * zstd 多帧扫描(撕裂尾帧用 ZSTD_e_flush 抢救)→ 逐行 JSON.parse
798
- * → provenance 展开(storage 形态的 seq ranges)→ `decodeStorageRecord` 解包
799
- * (`text-chunks` / `reasoning-chunks` / `tool-call-chunks` 展开回 `assistant/chunk`)
800
- * → seq 连续性校验(提交区出现缺口即停止并按 prefix 语义处理)。
801
- *
802
- * 解码器优先使用宿主真实的 `@deepseek-ai/dsh-session`(运行时按需软加载,零硬依赖);
803
- * 不可用时使用本文件内的等价实现(同格式:dt 为**时间**增量、成员数 = dt.length + 1;
804
- * provenance 存储形态为 `[[start,end],…]` 区间编码)。二者的差异会被显式记入
805
- * `decoder` 字段,禁止静默回退。
806
- */
807
- /** zstd 帧魔数(0xFD2FB528 的小端读取值)。 */
808
- const ZSTD_MAGIC = 4247762216;
809
- /** 打包行类型(读路径必须解包)。 */
810
- const PACKED_ROWS = /* @__PURE__ */ new Set([
811
- "text-chunks",
812
- "reasoning-chunks",
813
- "tool-call-chunks"
814
- ]);
815
- /** 扫描拼接的 zstd 多帧;返回完整帧范围与撕裂尾帧起点。 */
816
- function scanZstdFrames(buffer) {
817
- const frames = [];
818
- let offset = 0;
819
- while (offset < buffer.length) {
820
- const start = offset;
821
- if (buffer.length - offset < 4) return {
822
- frames,
823
- tornStart: start
824
- };
825
- if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return {
826
- frames,
827
- tornStart: start
828
- };
829
- offset += 4;
830
- if (offset === buffer.length) return {
831
- frames,
832
- tornStart: start
833
- };
834
- const descriptor = buffer.readUInt8(offset);
835
- offset += 1;
836
- if ((descriptor & 24) !== 0) return {
837
- frames,
838
- tornStart: start
839
- };
840
- const contentSizeFlag = descriptor >>> 6;
841
- const singleSegment = (descriptor & 32) !== 0;
842
- const checksum = (descriptor & 4) !== 0;
843
- const dictionaryFlag = descriptor & 3;
844
- const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
845
- const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
846
- const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
847
- if (buffer.length - offset < remainingHeaderBytes) return {
848
- frames,
849
- tornStart: start
850
- };
851
- offset += remainingHeaderBytes;
852
- for (;;) {
853
- if (buffer.length - offset < 3) return {
854
- frames,
855
- tornStart: start
856
- };
857
- const blockHeader = buffer.readUIntLE(offset, 3);
858
- offset += 3;
859
- const lastBlock = (blockHeader & 1) !== 0;
860
- const blockType = blockHeader >>> 1 & 3;
861
- const blockSize = blockHeader >>> 3;
862
- if (blockType === 3) return {
863
- frames,
864
- tornStart: start
865
- };
866
- const payloadBytes = blockType === 1 ? 1 : blockSize;
867
- if (buffer.length - offset < payloadBytes) return {
868
- frames,
869
- tornStart: start
870
- };
871
- offset += payloadBytes;
872
- if (lastBlock) break;
873
- }
874
- if (checksum) {
875
- if (buffer.length - offset < 4) return {
876
- frames,
877
- tornStart: start
878
- };
879
- offset += 4;
880
- }
881
- frames.push({
882
- start,
883
- end: offset
884
- });
885
- }
886
- return { frames };
887
- }
888
- /** 存储形态的 provenance(`[[start,end],…]` 区间编码或直接 seq 数组)展开为递增 seq 列表。 */
889
- function decodeSeqRangesStorage(value, currentSeq) {
890
- if (!Array.isArray(value)) throw new Error("sourceEventSeqs must be an array when present");
891
- const out = [];
892
- for (const entry of value) {
893
- if (typeof entry === "number") {
894
- out.push(entry);
895
- continue;
896
- }
897
- if (Array.isArray(entry) && entry.length === 2) {
898
- const [start, end] = entry;
899
- if (typeof start !== "number" || typeof end !== "number" || end < start) throw new Error("sourceEventSeqs range is malformed");
900
- for (let seq = start; seq <= end; seq += 1) out.push(seq);
901
- continue;
902
- }
903
- throw new Error("sourceEventSeqs entry is neither a seq nor a [start,end] range");
904
- }
905
- for (const seq of out) if (!Number.isSafeInteger(seq) || seq < 0 || seq >= currentSeq) throw new Error(`sourceEventSeqs must reference earlier events: ${seq} >= ${currentSeq}`);
906
- return out;
907
- }
908
- /** 本地等价实现:把一行存储记录解包为事件(打包行展开为 assistant/chunk)。 */
909
- function decodeRecordLocal(value) {
910
- if (typeof value !== "object" || value === null) return [value];
911
- const record = value;
912
- const type = record["type"];
913
- if (typeof type !== "string" || !PACKED_ROWS.has(type)) {
914
- const seq = record["seq"];
915
- if (typeof seq === "number" && (!Number.isSafeInteger(seq) || seq < 0)) throw new Error(`session event seq ${String(seq)} is not a non-negative safe integer`);
916
- return [record];
917
- }
918
- const data = record["data"];
919
- if (data === void 0) throw new Error(`malformed ${type} storage row: data must be an object`);
920
- const members = type === "tool-call-chunks" ? data["args"] : data["texts"];
921
- if (!Array.isArray(members) || members.length === 0 || members.some((entry) => typeof entry !== "string")) throw new Error(`malformed ${type} storage row: payload must be a non-empty string array`);
922
- const dt = data["dt"];
923
- if (!Array.isArray(dt) || dt.some((gap) => !Number.isSafeInteger(gap))) throw new Error(`malformed ${type} storage row: dt must be an array of safe integers`);
924
- if (dt.length !== members.length - 1) throw new Error(`malformed ${type} storage row: dt length ${dt.length} does not match ${members.length} members`);
925
- const seq0 = record["seq0"];
926
- const time0 = record["time0"];
927
- if (typeof seq0 !== "number" || typeof time0 !== "number") throw new Error(`malformed ${type} storage row: seq0/time0 must be numbers`);
928
- const turn = data["turn"];
929
- const step = data["step"];
930
- const index = data["index"];
931
- const out = [];
932
- let time = time0;
933
- for (let k = 0; k < members.length; k += 1) {
934
- if (k > 0) time += dt[k - 1];
935
- let chunk;
936
- if (type === "text-chunks") chunk = {
937
- type: "text-delta",
938
- index,
939
- text: members[k]
940
- };
941
- else if (type === "reasoning-chunks") chunk = {
942
- type: "reasoning-delta",
943
- index,
944
- text: members[k]
1329
+ total: Math.max(0, Math.floor(total)),
1330
+ findings: [],
1331
+ visited: 0
945
1332
  };
946
- else chunk = {
947
- type: "tool-call-delta",
948
- index,
949
- id: typeof data["id"] === "string" ? data["id"] : "",
950
- ...typeof data["name"] === "string" ? { name: data["name"] } : {},
951
- argumentsDelta: members[k]
1333
+ }
1334
+ /**
1335
+ * 追加一批扫描结果。
1336
+ *
1337
+ * 只有累积的已访问数走满语料总数才成型;语言中途断掉(关面板、报错)不会留下
1338
+ * 半份缓存冒充完整结果。
1339
+ * @param findings - 本批的非 ok 报告。
1340
+ * @param scanned - 本批**已访问**的会话数(不是命中数)。
1341
+ * @param now - 时间源(测试可控)。
1342
+ * @returns 本批追加后缓存是否刚好成型。
1343
+ */
1344
+ append(findings, scanned, now = Date.now) {
1345
+ const pending = this.pending;
1346
+ if (pending === void 0) return false;
1347
+ pending.findings.push(...findings);
1348
+ pending.visited += Math.max(0, Math.floor(scanned));
1349
+ if (pending.visited < pending.total) return false;
1350
+ this.ready = {
1351
+ findings: pending.findings,
1352
+ total: pending.total,
1353
+ generatedAt: now()
952
1354
  };
953
- out.push({
954
- type: "assistant/chunk",
955
- seq: seq0 + k,
956
- time,
957
- data: {
958
- turn,
959
- step,
960
- chunk
961
- }
962
- });
1355
+ this.pending = void 0;
1356
+ return true;
963
1357
  }
964
- return out;
1358
+ /**
1359
+ * 单条就地回写(单会话体检 / 可逆处置后调用)。
1360
+ *
1361
+ * 这是缓存的**关键收益**:处置本就重算了 `after` 报告,把它写回即可,
1362
+ * 不必为了刷新一行而重扫整个语料。
1363
+ * @param report - 重算出的单会话报告。
1364
+ * @returns 是否真的改动了缓存(无缓存时为 false)。
1365
+ */
1366
+ patch(report) {
1367
+ const ready = this.ready;
1368
+ if (ready === void 0) return false;
1369
+ const index = ready.findings.findIndex((entry) => entry.sessionId === report.sessionId);
1370
+ if (report.level === "ok") {
1371
+ if (index < 0) return false;
1372
+ ready.findings.splice(index, 1);
1373
+ return true;
1374
+ }
1375
+ if (index >= 0) ready.findings[index] = report;
1376
+ else ready.findings.push(report);
1377
+ return true;
1378
+ }
1379
+ /** 丢弃缓存与未完成的累积。 */
1380
+ clear() {
1381
+ this.ready = void 0;
1382
+ this.pending = void 0;
1383
+ }
1384
+ };
1385
+ //#endregion
1386
+ //#region src/host/health/generation.ts
1387
+ /**
1388
+ * 会话日志的「代次」文件名契约(对齐宿主 session 操作核心)。
1389
+ *
1390
+ * 一个会话目录里可以同时存在多份日志产物:
1391
+ *
1392
+ * session.jsonl.zstd 历史代 0(无版本号)
1393
+ * session.v<N>.jsonl.zstd 当前代 N(N ≥ 1)
1394
+ * session.migration.<token>.jsonl.zstd.tmp 迁移暂存(当前代的发布源)
1395
+ *
1396
+ * **当前代由目录实测的最高代次决定,不硬编码 `v3`;日志名由代次推导,不硬编码
1397
+ * `session.jsonl.zstd`。** 硬编码 v0 正是本插件此前「看不见任何已发布当前代的
1398
+ * 会话」的病根:实测 449 个会话目录中有 61 个只留当前代,旧发现逻辑对它们一条
1399
+ * 都发现不了,因而既扫不到也修不了。
1400
+ *
1401
+ * 宿主侧的代次发布顺序是「写暂存 → fsync → 校验 → 原子发布成当前代」,所以
1402
+ * **当前代次非 0 等价于「这份日志是从 TMP 里移出来的」**;目录内仍有暂存残留
1403
+ * 则是发布源仍在原地的物证。本模块据此给出处置优先级。
1404
+ *
1405
+ * 语法逐条对齐宿主包,改动它们必须同步这里(`tests/generation.spec.ts` 复刻了
1406
+ * 宿主的全部拒绝用例,宿主放宽/收紧时该组测试会失败):
1407
+ * - `@deepseek-ai/dsh-session-format/src/filename.ts`
1408
+ * `CANONICAL_LOG_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u`
1409
+ * - `@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts` 的压缩后缀
1410
+ * - `@deepseek-ai/dsh-session-persistence-jsonl/src/generation.ts:725`
1411
+ * 暂存名 `session.migration.${randomToken()}${suffix}.tmp`(token 为 16 位小写十六进制)
1412
+ */
1413
+ /**
1414
+ * 规范日志名的语法(不含压缩后缀)。
1415
+ *
1416
+ * 与宿主 `session-format/src/filename.ts` 逐字一致:版本 0 不带版本号,
1417
+ * 后续代次必须是小写数字且无前导零;`.v0`、`.v01`、`.V1`、`session.v1.backup.jsonl`
1418
+ * 都不识别为规范代次。
1419
+ */
1420
+ const CANONICAL_LOG_BASENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u;
1421
+ /**
1422
+ * 迁移暂存名的语法(对齐 `generation.ts:725` 的写入名)。
1423
+ *
1424
+ * token 的位数与字符集是宿主内部细节,这里只固定**形状**
1425
+ * `session.migration.<hex>.jsonl[.zstd].tmp`,宿主换 token 长度时本插件不会失明。
1426
+ */
1427
+ const MIGRATION_STAGING_NAME = /^session\.migration\.[0-9a-f]+\.jsonl(?:\.zstd)?\.tmp$/u;
1428
+ function compressionSuffix(compression) {
1429
+ return compression === "zstd" ? ".zstd" : "";
965
1430
  }
966
1431
  /**
967
- * 读取一个会话日志文件(zip 帧扫描 + 解包 + seq 连续性)。
968
- * @param file - `session.jsonl.zstd` 绝对路径。
969
- * @param bytes - 文件内容(调用方读取,便于测试注入)。
970
- * @param decoders - 可选官方解码器(缺省用本地等价实现)。
971
- * @returns 事件、统计与问题清单。
1432
+ * 某代次的规范日志文件名。
1433
+ * @param version - 非负安全整数的格式代次。
1434
+ * @param compression - 物理编码(缺省 zstd)。
1435
+ * @returns 该代次在会话目录内的文件名。
972
1436
  */
973
- function decodeSessionLogBytes(file, bytes, decoders) {
974
- const { frames, tornStart } = scanZstdFrames(bytes);
975
- const parts = [];
976
- const frameIssues = [];
977
- for (const frame of frames) try {
978
- parts.push(zlib.zstdDecompressSync(bytes.subarray(frame.start, frame.end)));
979
- } catch (err) {
980
- frameIssues.push(`frame@${frame.start}: ${String(err instanceof Error ? err.message : err)}`);
1437
+ function generationLogFilename(version, compression = "zstd") {
1438
+ if (!Number.isSafeInteger(version) || version < 0) throw new Error(`session log generation must be a non-negative safe integer, got ${String(version)}`);
1439
+ return `${version === 0 ? "session.jsonl" : `session.v${version}.jsonl`}${compressionSuffix(compression)}`;
1440
+ }
1441
+ /**
1442
+ * 把一个规范日志名解析回代次。
1443
+ *
1444
+ * 与宿主 `parseGenerationLogFilename` 同语义:非规范名(临时、大写、前导零、
1445
+ * `.v0`、别的压缩后缀)一律返回 undefined,**不猜**。
1446
+ * @param filename - 会话目录里的一个文件名。
1447
+ * @param compression - 该目录使用的物理编码(缺省 zstd)。
1448
+ * @returns 其格式代次,或名字不构成规范代次时的 undefined。
1449
+ */
1450
+ function parseGenerationLogFilename(filename, compression = "zstd") {
1451
+ const suffix = compressionSuffix(compression);
1452
+ if (!filename.endsWith(suffix)) return void 0;
1453
+ const match = CANONICAL_LOG_BASENAME.exec(filename.slice(0, filename.length - suffix.length));
1454
+ if (match === null) return void 0;
1455
+ if (match[1] === void 0) return 0;
1456
+ const version = Number(match[1]);
1457
+ return Number.isSafeInteger(version) ? version : void 0;
1458
+ }
1459
+ /**
1460
+ * 两种压缩编码都试一遍,判定该名字是不是某代的规范产物。
1461
+ *
1462
+ * 发现路径不能假设部署里的 `compression` 配置:配置在 profile 侧,插件读不到,
1463
+ * 而日志就在磁盘上。因此按名字本身分类,编码作为结果一并带回。
1464
+ * @param filename - 会话目录里的一个文件名。
1465
+ * @returns 代次与编码,或该名字不是规范代次时的 undefined。
1466
+ */
1467
+ function classifyGenerationFilename(filename) {
1468
+ const zstd = parseGenerationLogFilename(filename, "zstd");
1469
+ if (zstd !== void 0) return {
1470
+ version: zstd,
1471
+ compression: "zstd"
1472
+ };
1473
+ const none = parseGenerationLogFilename(filename, "none");
1474
+ if (none !== void 0) return {
1475
+ version: none,
1476
+ compression: "none"
1477
+ };
1478
+ }
1479
+ /**
1480
+ * 该文件名是否是迁移暂存(当前代的发布源)。
1481
+ * @param filename - 会话目录里的一个文件名。
1482
+ * @returns 是否形如 `session.migration.<token>.jsonl[.zstd].tmp`。
1483
+ */
1484
+ function isMigrationStagingFilename(filename) {
1485
+ return MIGRATION_STAGING_NAME.test(filename);
1486
+ }
1487
+ /**
1488
+ * 读取一个会话目录的代次事实(只读,不碰任何文件内容)。
1489
+ * @param dir - 会话目录的绝对路径。
1490
+ * @returns 规范产物(按代次升序)、当前代、历史代与暂存残留。
1491
+ */
1492
+ function readSessionGenerations(dir) {
1493
+ const canonical = [];
1494
+ const staging = [];
1495
+ let entries;
1496
+ try {
1497
+ entries = readdirSync(dir, { withFileTypes: true });
1498
+ } catch {
1499
+ return {
1500
+ canonical: [],
1501
+ staging: [],
1502
+ legacyOnly: false
1503
+ };
981
1504
  }
982
- let recoveredFromTorn = 0;
983
- if (tornStart !== void 0) try {
984
- const recovered = zlib.zstdDecompressSync(bytes.subarray(tornStart), { finishFlush: zlib.constants.ZSTD_e_flush });
985
- recoveredFromTorn = recovered.length;
986
- parts.push(recovered);
987
- } catch {}
988
- const lines = Buffer.concat(parts).toString("utf8").split("\n").filter((line) => line.trim() !== "");
989
- const issues = frameIssues.map((why, index) => ({
990
- line: index + 1,
991
- why
992
- }));
993
- const decodeRecord = decoders?.decodeStorageRecord ?? decodeRecordLocal;
994
- const events = [];
995
- for (let index = 1; index < lines.length; index += 1) {
996
- const line = lines[index];
997
- let decoded;
1505
+ for (const entry of entries) {
1506
+ if (!entry.isFile()) continue;
1507
+ const path = join(dir, entry.name);
1508
+ let bytes;
1509
+ let mtimeMs;
998
1510
  try {
999
- const parsed = JSON.parse(line);
1000
- decoded = decodeRecord(parsed["sourceEventSeqs"] === void 0 ? parsed : {
1001
- ...parsed,
1002
- sourceEventSeqs: decoders?.decodeSeqRanges !== void 0 && typeof parsed["seq"] === "number" ? decoders.decodeSeqRanges(parsed["sourceEventSeqs"], parsed["seq"]) : decodeSeqRangesStorage(parsed["sourceEventSeqs"], Number(parsed["seq"] ?? 0))
1003
- });
1004
- } catch (err) {
1005
- issues.push({
1006
- line: index + 1,
1007
- why: `decode failed: ${String(err instanceof Error ? err.message : err)}`
1008
- });
1009
- break;
1511
+ const stat = statSync(path);
1512
+ bytes = stat.size;
1513
+ mtimeMs = stat.mtimeMs;
1514
+ } catch {
1515
+ continue;
1010
1516
  }
1011
- const rowStart = events.length;
1012
- let gapped = false;
1013
- for (const event of decoded) {
1014
- if (event.seq !== events.length) {
1015
- issues.push({
1016
- line: index + 1,
1017
- why: `seq gap (expected ${events.length}, got ${event.seq})`
1018
- });
1019
- events.length = rowStart;
1020
- gapped = true;
1021
- break;
1022
- }
1023
- events.push(event);
1517
+ const artifact = {
1518
+ name: entry.name,
1519
+ path,
1520
+ bytes,
1521
+ mtimeMs
1522
+ };
1523
+ const generation = classifyGenerationFilename(entry.name);
1524
+ if (generation !== void 0) {
1525
+ canonical.push({
1526
+ ...artifact,
1527
+ version: generation.version,
1528
+ compression: generation.compression
1529
+ });
1530
+ continue;
1024
1531
  }
1025
- if (gapped) break;
1532
+ if (isMigrationStagingFilename(entry.name)) staging.push(artifact);
1026
1533
  }
1027
- const openStep = findOpenStep(events);
1534
+ canonical.sort((left, right) => left.version - right.version || left.mtimeMs - right.mtimeMs);
1535
+ staging.sort((left, right) => left.mtimeMs - right.mtimeMs);
1536
+ const current = canonical.at(-1);
1537
+ const legacy = canonical.find((artifact) => artifact.version === 0);
1028
1538
  return {
1029
- events,
1030
- frames: frames.length,
1031
- ...tornStart === void 0 ? {} : { tornStart },
1032
- recoveredFromTorn,
1033
- issues,
1034
- decoder: decoders === void 0 ? "local" : "official",
1035
- ...openStep === void 0 ? {} : { openStep }
1539
+ canonical,
1540
+ staging,
1541
+ ...current === void 0 ? {} : { current },
1542
+ ...legacy === void 0 ? {} : { legacy },
1543
+ legacyOnly: canonical.length > 0 && current?.version === 0
1036
1544
  };
1037
1545
  }
1038
- /** 从文件读取并解码。 */
1039
- function decodeSessionLogFile(file, decoders) {
1040
- return decodeSessionLogBytes(file, readFileSync(file), decoders);
1546
+ /** 目录内全部已识别产物的最新 mtime。 */
1547
+ function latestArtifactMtime(facts) {
1548
+ const times = [...facts.canonical, ...facts.staging].map((artifact) => artifact.mtimeMs);
1549
+ return times.length === 0 ? void 0 : Math.max(...times);
1041
1550
  }
1042
- /** 尾部未收尾的 open step(有 step/start 无对应 step/end)。 */
1043
- function findOpenStep(events) {
1044
- let open;
1045
- for (const event of events) {
1046
- const data = event.data;
1047
- if (event.type === "step/start" && typeof data?.turn === "number" && typeof data?.step === "number") {
1048
- open = {
1049
- turn: data.turn,
1050
- step: data.step
1051
- };
1052
- continue;
1053
- }
1054
- if (event.type === "step/end") open = void 0;
1055
- }
1056
- return open;
1551
+ /**
1552
+ * 该会话的处置优先级。
1553
+ *
1554
+ * `high` 的判据就是「从 TMP 移出来」本身:当前代次非 0(当前代是经
1555
+ * `session.migration.*.tmp` 暂存发布出来的),或目录内仍有暂存残留。
1556
+ * 代次 0 的历史代是宿主按契约保留的未发布会话,旧发现逻辑读的就是它 —— 这类
1557
+ * 会话既没有发布痕迹、也没有被读错,故为 `normal`。
1558
+ * @param facts - 会话目录的代次事实。
1559
+ * @returns 处置优先级。
1560
+ */
1561
+ function sessionPriority(facts) {
1562
+ if (facts.staging.length > 0) return "high";
1563
+ return (facts.current?.version ?? 0) > 0 ? "high" : "normal";
1057
1564
  }
1058
1565
  //#endregion
1059
1566
  //#region src/host/health/lossless.ts
@@ -1207,7 +1714,7 @@ function isPlainArray(value) {
1207
1714
  //#endregion
1208
1715
  //#region src/host/health/gates.ts
1209
1716
  /**
1210
- * 四个体检 gate(每个可独立测试)与报告聚合。
1717
+ * 五个体检 gate(每个可独立测试)与报告聚合。
1211
1718
  *
1212
1719
  * 统一输出形状:`{ id, level, evidence, attribution, detail }`;
1213
1720
  * 聚合结果 `SessionHealthReport` 供「体检 → 处方 → 出院」三步流程使用。
@@ -1246,6 +1753,65 @@ function readTailFacts(log) {
1246
1753
  assistantAfterLastUser
1247
1754
  };
1248
1755
  }
1756
+ /**
1757
+ * gate 0:代次事实(当前代是历史代还是已发布代、有没有暂存残留)。
1758
+ *
1759
+ * 存在的理由:一个会话目录里可以有多份日志产物,而**读哪一份**决定了后面所有
1760
+ * 门的结论。早期版本固定读 `session.jsonl.zstd`,于是对已发布当前代的会话要么
1761
+ * 读错(读历史代那份,12MB 全量回放)、要么完全发现不了。本门把这个前提显式化成
1762
+ * 可判定的证据,档位判据只取「有据可依」的三条:
1763
+ * - 没有任何规范产物(只剩暂存)→ `fail`:当前代尚未发布;
1764
+ * - 有暂存残留 → `warn`:发布源仍在原地(发布完成未清,或尚未发布);
1765
+ * - 文件名代次与 header 代次不一致 → `warn`:发布不变量被破坏。
1766
+ * 历史代与当前代并存是宿主**契约要求**(已提交代次不删不改),故记 `ok` 并写进
1767
+ * 证据,不抬档 —— 抬档会把 34 个完全正常的会话变成噪声。
1768
+ * @param facts - 会话目录的代次事实;缺省表示无从判定。
1769
+ * @param log - 已解码的日志(用于取 header.version 交叉核对)。
1770
+ */
1771
+ function gateGeneration(facts, log) {
1772
+ if (facts === void 0) return {
1773
+ id: "generation",
1774
+ level: "skipped",
1775
+ evidence: "未提供代次事实,本门无从判定"
1776
+ };
1777
+ const current = facts.current;
1778
+ const headerVersion = typeof log?.header?.["version"] === "number" ? log.header["version"] : void 0;
1779
+ const detail = {
1780
+ currentVersion: current?.version,
1781
+ currentName: current?.name,
1782
+ currentCompression: current?.compression,
1783
+ canonicalVersions: facts.canonical.map((artifact) => artifact.version),
1784
+ staging: facts.staging.map((artifact) => artifact.name),
1785
+ legacyOnly: facts.legacyOnly,
1786
+ priority: sessionPriority(facts),
1787
+ ...headerVersion === void 0 ? {} : { headerVersion }
1788
+ };
1789
+ if (current === void 0) return {
1790
+ id: "generation",
1791
+ level: "fail",
1792
+ evidence: `目录内没有任何规范代次产物,仅剩 ${facts.staging.length} 份迁移暂存:当前代尚未从暂存发布`,
1793
+ detail
1794
+ };
1795
+ if (facts.staging.length > 0) return {
1796
+ id: "generation",
1797
+ level: "warn",
1798
+ evidence: `当前代 v${current.version}(${current.name})旁留有 ${facts.staging.length} 份迁移暂存残留:` + facts.staging.map((artifact) => artifact.name).join("、"),
1799
+ detail
1800
+ };
1801
+ if (headerVersion !== void 0 && headerVersion !== current.version) return {
1802
+ id: "generation",
1803
+ level: "warn",
1804
+ evidence: `文件名代次与日志头代次不一致:${current.name} 名为 v${current.version},header.version=${headerVersion}`,
1805
+ detail
1806
+ };
1807
+ const note = facts.legacyOnly ? ";目录内只有历史代,宿主首次写访问时才发布当前代" : facts.legacy === void 0 ? "" : ";历史代 v0 按契约保留未删";
1808
+ return {
1809
+ id: "generation",
1810
+ level: "ok",
1811
+ evidence: `当前代 v${current.version}(${current.name})${note}`,
1812
+ detail
1813
+ };
1814
+ }
1249
1815
  /** gate 1:日志完整性(可解码、seq 连续、无撕裂尾帧)。 */
1250
1816
  function gateLogIntegrity(log) {
1251
1817
  if (log.issues.length > 0) {
@@ -1438,10 +2004,11 @@ function gateColdRead(facts) {
1438
2004
  detail
1439
2005
  };
1440
2006
  }
1441
- /** 聚合四门结果为一份报告。 */
2007
+ /** 聚合全部 gate 结果为一份报告。 */
1442
2008
  function buildSessionReport(context) {
1443
2009
  const log = context.log ?? (context.logPath === void 0 ? void 0 : decodeSessionLogFile(context.logPath));
1444
2010
  const gates = [];
2011
+ gates.push(gateGeneration(context.generations, log));
1445
2012
  if (log === void 0) gates.push({
1446
2013
  id: "log-integrity",
1447
2014
  level: "fail",
@@ -1453,11 +2020,13 @@ function buildSessionReport(context) {
1453
2020
  gates.push(gateProjectionCache(readProjectionCache(context.sessionId, tail.lastSeq, context.dshHome)));
1454
2021
  gates.push(gateLosslessJson(context.projectionState, context.attribute));
1455
2022
  gates.push(gateColdRead(tail));
2023
+ gates.push(gateSourceKind(readSourceKindFacts(log)));
1456
2024
  }
1457
2025
  const level = gates.some((gate) => gate.level === "fail") ? "fail" : gates.some((gate) => gate.level === "warn") ? "warn" : "ok";
1458
2026
  return {
1459
2027
  sessionId: context.sessionId,
1460
2028
  level,
2029
+ priority: context.generations === void 0 ? "normal" : sessionPriority(context.generations),
1461
2030
  gates,
1462
2031
  generatedAt: (context.now ?? Date.now)()
1463
2032
  };
@@ -1537,6 +2106,13 @@ function quarantineProjectionCache(sessionId, dshHome, now = Date.now) {
1537
2106
  function prescribe(report, dshHome) {
1538
2107
  const lines = [];
1539
2108
  const gate = (id) => report.gates.find((entry) => entry.id === id);
2109
+ const generation = gate("generation");
2110
+ if (generation?.level === "fail" || generation?.level === "warn") {
2111
+ lines.push(`# 代次异常:${generation.evidence}`);
2112
+ const currentVersion = generation.detail?.["currentVersion"];
2113
+ lines.push(typeof currentVersion === "number" ? `# 当前代 v${currentVersion}:本插件不改会话日志,暂存残留的发布/隔离需用会话代次工具处理` : "# 目录内只有迁移暂存、还没有规范产物:需先用会话代次工具发布当前代,或重启宿主触发发布");
2114
+ if (report.priority === "high") lines.push("# 该会话优先级为 high(当前代由暂存发布而来),建议先处置它");
2115
+ }
1540
2116
  const lossless = gate("lossless-json");
1541
2117
  if (lossless?.level === "fail") {
1542
2118
  const pkg = lossless.attribution?.package ?? "unknown";
@@ -1562,10 +2138,15 @@ function prescribe(report, dshHome) {
1562
2138
  if (gate("cold-read")?.level === "fail") lines.push("# 会话仍处于 open step:请等待宿主结算(不要硬改日志),必要时重启宿主后重新体检");
1563
2139
  if (lines.length === 0) {
1564
2140
  const unjudged = report.gates.filter((entry) => entry.level === "skipped");
1565
- lines.push(unjudged.length === 0 ? "# 四门全绿:无需处置" : `# 无可处置项(${unjudged.length} 门无从判定,非异常):${unjudged.map((entry) => entry.id).join(" / ")}`);
2141
+ lines.push(unjudged.length === 0 ? "# 全部检查通过:无需处置" : `# 无可处置项(${unjudged.length} 门无从判定,非异常):${unjudged.map((entry) => entry.id).join(" / ")}`);
1566
2142
  }
1567
2143
  return lines;
1568
2144
  }
2145
+ /** 残留门的可读归因:区分「本插件有能力处置」与「按红线只能等 / 交给别的工具」。 */
2146
+ function residualNote(residual) {
2147
+ if (residual.some((entry) => entry.id === "generation")) return "其中 generation 来自会话目录的代次产物(暂存残留或当前代尚未发布),本插件不改会话日志,需用会话代次工具处置。";
2148
+ return "这类异常来自会话日志或宿主运行态,本插件不改会话日志,请等待宿主结算后重新体检。";
2149
+ }
1569
2150
  /**
1570
2151
  * 判定一次处置的结果,并给出人读说明。
1571
2152
  *
@@ -1585,7 +2166,7 @@ function assessRepair(input) {
1585
2166
  const residualIds = residual.map((entry) => entry.id).join(" / ");
1586
2167
  if (before.level === "ok") return {
1587
2168
  verdict: "nothing-to-do",
1588
- explanation: "四门全绿,无需处置",
2169
+ explanation: "全部检查通过,无需处置",
1589
2170
  residual: []
1590
2171
  };
1591
2172
  if (repair.ok) return residual.length === 0 ? {
@@ -1594,12 +2175,12 @@ function assessRepair(input) {
1594
2175
  residual: []
1595
2176
  } : {
1596
2177
  verdict: "repaired-with-residual",
1597
- explanation: `已隔离投影缓存记录;但仍有与该缓存无关的异常:${residualIds}。这类异常来自会话日志或宿主运行态,本插件不改会话日志,请等待宿主结算后重新体检。`,
2178
+ explanation: `已隔离投影缓存记录;但仍有与该缓存无关的异常:${residualIds}。${residualNote(residual)}`,
1598
2179
  residual
1599
2180
  };
1600
2181
  if (!isActionable(before.gates.find((entry) => entry.id === "projection-cache")?.level)) return {
1601
2182
  verdict: "not-applicable",
1602
- explanation: residualIds === "" ? "当前异常无可逆处置项" : `当前异常无可逆处置项(投影缓存无可隔离记录);异常来自 ${residualIds},本插件不改会话日志,请等待宿主结算后重新体检。`,
2183
+ explanation: residualIds === "" ? "当前异常无可逆处置项" : `当前异常无可逆处置项(投影缓存无可隔离记录);异常来自 ${residualIds},${residualNote(residual)}`,
1603
2184
  residual
1604
2185
  };
1605
2186
  return {
@@ -1611,17 +2192,36 @@ function assessRepair(input) {
1611
2192
  //#endregion
1612
2193
  //#region src/host/health/scan.ts
1613
2194
  /**
1614
- * 批量体检:枚举会话日志、跑四门、给出汇总(供 `session-health-scan` 使用)。
2195
+ * 批量体检:枚举会话日志、跑门、给出汇总(供 `session-health-scan` 使用)。
1615
2196
  *
1616
2197
  * 只读;不激活任何 Agent,也不写任何会话数据。为控制成本,默认限制单次扫描的
1617
2198
  * 会话数与并发度,并支持 `limit` / `since` 过滤。
2199
+ *
2200
+ * **发现按代次而不是按固定文件名。** 一个会话目录的当前日志名由目录实测的最高
2201
+ * 代次决定(见 `generation.ts`):历史代是 `session.jsonl.zstd`,已发布的当前代
2202
+ * 是 `session.v<N>.jsonl.zstd`。早期版本在这里写死 `session.jsonl.zstd`,于是
2203
+ * 61 个只留当前代的会话**一条都发现不了** —— 既扫不到,`findSessionLog` 也返回
2204
+ * undefined,连 `session-health-repair` 都会先报「未找到会话日志」。
1618
2205
  */
1619
- /** 会话根目录(`<dshHome>/sessions/<project>/<sessionId>/session.jsonl.zstd`)。 */
2206
+ /** 会话根目录(`<dshHome>/sessions/<project>/<sessionId>/`)。 */
1620
2207
  function sessionsRoot(dshHome) {
1621
2208
  return join(dshHome ?? join(homedir(), ".dsh"), "sessions");
1622
2209
  }
1623
- /** 枚举全部会话日志(按 mtime 倒序),带可选上限。 */
1624
- function discoverSessions(dshHome, limit = 200) {
2210
+ /**
2211
+ * 语料枚举上限。
2212
+ *
2213
+ * 这是防御无界遍历的**边界**,不是策略:早期版本写 200,实测语料 449 个会话时
2214
+ * 它静默截掉了一半(体检进度分母恒为 200,末 249 个会话永远扫不到)。
2215
+ */
2216
+ const DISCOVERY_LIMIT = 1e5;
2217
+ /**
2218
+ * 枚举全部会话(按 mtime 倒序),带可选上限。
2219
+ *
2220
+ * 「是会话目录」的判据是**目录里有规范代次产物或迁移暂存**,不要求任何具体文件名。
2221
+ * @param dshHome - DSH home(缺省 ~/.dsh)。
2222
+ * @param limit - 返回上限(缺省 `DISCOVERY_LIMIT`)。
2223
+ */
2224
+ function discoverSessions(dshHome, limit = DISCOVERY_LIMIT) {
1625
2225
  const root = sessionsRoot(dshHome);
1626
2226
  const out = [];
1627
2227
  if (!existsSync(root)) return out;
@@ -1636,30 +2236,23 @@ function discoverSessions(dshHome, limit = 200) {
1636
2236
  continue;
1637
2237
  }
1638
2238
  for (const entry of entries) {
2239
+ if (!entry.isDirectory()) continue;
1639
2240
  const path = join(dir, entry.name);
1640
- if (entry.isDirectory()) {
1641
- const candidate = join(path, "session.jsonl.zstd");
1642
- if (existsSync(candidate)) {
1643
- let stats;
1644
- try {
1645
- const stat = statSync(candidate);
1646
- stats = {
1647
- mtimeMs: stat.mtimeMs,
1648
- size: stat.size
1649
- };
1650
- } catch {
1651
- continue;
1652
- }
1653
- out.push({
1654
- sessionId: entry.name,
1655
- logPath: candidate,
1656
- updatedAt: stats.mtimeMs,
1657
- bytes: stats.size
1658
- });
1659
- continue;
1660
- }
2241
+ const generations = readSessionGenerations(path);
2242
+ if (generations.canonical.length === 0 && generations.staging.length === 0) {
1661
2243
  stack.push(path);
2244
+ continue;
1662
2245
  }
2246
+ const current = generations.current;
2247
+ out.push({
2248
+ sessionId: entry.name,
2249
+ dir: path,
2250
+ ...current === void 0 ? {} : { logPath: current.path },
2251
+ generations,
2252
+ priority: sessionPriority(generations),
2253
+ updatedAt: latestArtifactMtime(generations) ?? 0,
2254
+ bytes: current?.bytes ?? 0
2255
+ });
1663
2256
  }
1664
2257
  }
1665
2258
  out.sort((left, right) => right.updatedAt - left.updatedAt);
@@ -1668,19 +2261,28 @@ function discoverSessions(dshHome, limit = 200) {
1668
2261
  /**
1669
2262
  * 语料总数:只做目录枚举,**不跑体检**。
1670
2263
  *
1671
- * 用于判断缓存是否已过期——枚举很便宜,而重扫要解 zstd、跑四门。
2264
+ * 用于判断缓存是否已过期——枚举很便宜,而重扫要解 zstd、跑门。
1672
2265
  * 正因为两者代价差着量级,「对账」才不构成缓存失效策略本身。
1673
2266
  * @param dshHome - DSH home(缺省 ~/.dsh)。
1674
2267
  */
1675
2268
  function countCorpus(dshHome) {
1676
2269
  return discoverSessions(dshHome, DISCOVERY_LIMIT).length;
1677
2270
  }
1678
- /** 定位一个会话的日志路径(跨工程目录查找)。 */
2271
+ /** 按 id 定位一个会话(跨工程目录查找)。 */
2272
+ function findSession(sessionId, dshHome) {
2273
+ return discoverSessions(dshHome, DISCOVERY_LIMIT).find((session) => session.sessionId === sessionId);
2274
+ }
2275
+ /**
2276
+ * 定位一个会话的**当前代**日志路径(跨工程目录查找)。
2277
+ *
2278
+ * 返回 undefined 有两种含义,调用方必须区分:会话不存在,或会话存在但尚未发布
2279
+ * 当前代(只有迁移暂存)。需要区分时用 `findSession`。
2280
+ * @param sessionId - 会话 id。
2281
+ * @param dshHome - DSH home(缺省 ~/.dsh)。
2282
+ */
1679
2283
  function findSessionLog(sessionId, dshHome) {
1680
- return discoverSessions(dshHome, 1e5).find((session) => session.sessionId === sessionId)?.logPath;
2284
+ return findSession(sessionId, dshHome)?.logPath;
1681
2285
  }
1682
- /** 语料枚举上限(进度分母的来源;与 route 侧 MAX_SCAN_LIMIT 同量级)。 */
1683
- const DISCOVERY_LIMIT = 200;
1684
2286
  /** 未显式传 limit 时的单批会话数。 */
1685
2287
  const DEFAULT_BATCH_LIMIT = 50;
1686
2288
  /**
@@ -1689,6 +2291,9 @@ const DEFAULT_BATCH_LIMIT = 50;
1689
2291
  * 语料先整体列出(按 mtime 倒序,≤ DISCOVERY_LIMIT),再按 `[offset, offset+limit)`
1690
2292
  * 切片扫描。这样客户端可以用小批次连续调用、自己累计真实进度,而宿主保持无状态
1691
2293
  * ——不必把同步循环改成异步,也不必新增进度轮询端点。
2294
+ *
2295
+ * 批内**命中顺序**按处置优先级排(`high` 在前,稳定排序)。分批切片仍按 mtime,
2296
+ * 所以 offset/total 的算术不受影响:只有同一批里的呈现顺序变了。
1692
2297
  * @param options - dshHome / 批大小 / 批起点 / 是否只返回非 ok / 归属查询 / 热态状态提供者。
1693
2298
  */
1694
2299
  function scanSessions(options) {
@@ -1710,7 +2315,8 @@ function scanSessions(options) {
1710
2315
  const report = buildSessionReport({
1711
2316
  sessionId: session.sessionId,
1712
2317
  ...options.dshHome === void 0 ? {} : { dshHome: options.dshHome },
1713
- logPath: session.logPath,
2318
+ ...session.logPath === void 0 ? {} : { logPath: session.logPath },
2319
+ generations: session.generations,
1714
2320
  ...projectionState === void 0 ? {} : { projectionState },
1715
2321
  ...options.attribute === void 0 ? {} : { attribute: options.attribute },
1716
2322
  ...options.now === void 0 ? {} : { now: options.now }
@@ -1718,6 +2324,7 @@ function scanSessions(options) {
1718
2324
  if (options.onlyProblems === true && report.level === "ok") continue;
1719
2325
  findings.push(report);
1720
2326
  }
2327
+ findings.sort((left, right) => left.priority === right.priority ? 0 : left.priority === "high" ? -1 : 1);
1721
2328
  return {
1722
2329
  ok: true,
1723
2330
  scanned: batch.length,
@@ -1731,28 +2338,17 @@ function scanSessions(options) {
1731
2338
  //#region src/index.ts
1732
2339
  /** 本插件声明的宿主服务(与 toggle 相同的注入面)。 */
1733
2340
  const inject = ["webServer", "webRuntime"];
1734
- /** 运行时配置 schema(与 src/config.ts 的形状保持一致)。 */
2341
+ /** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
2342
+ function readVolatileValue(value) {
2343
+ if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
2344
+ return value;
2345
+ }
2346
+ /** 运行时配置 schema(与 src/config.ts 的形状保持一致)。0.1.7:volatile 字段即设置表单。 */
1735
2347
  const Config = z.object({
1736
- enabled: z.boolean().default(true),
1737
- historyFiles: z.boolean().default(true),
1738
- healthCheck: z.boolean().default(true)
2348
+ enabled: z.boolean().default(true).volatile(),
2349
+ historyFiles: z.boolean().default(true).volatile(),
2350
+ healthCheck: z.boolean().default(true).volatile()
1739
2351
  });
1740
- /**
1741
- * 官方 `installSettingsSection` 的内联等价:通过 settings 服务注册命名空间、
1742
- * 以组合入口作为 `base` 层、并保持运行时来源实时(与 toggle / thinking-levels 同范式)。
1743
- */
1744
- function installSettingsSection(ctx, ns, schema, entry, hooks) {
1745
- ctx.inject(["settings"], (sctx) => {
1746
- const scope = sctx.settings.register(ns, schema, { base: entry });
1747
- hooks.setSource(() => scope.get());
1748
- hooks.onChange();
1749
- sctx.effect(() => () => {
1750
- hooks.setSource(() => entry);
1751
- hooks.onChange();
1752
- });
1753
- scope.watch(() => hooks.onChange());
1754
- });
1755
- }
1756
2352
  /** 单次请求体的上限(防御无界读取)。 */
1757
2353
  const MAX_BODY_BYTES = 16 << 20;
1758
2354
  /** 归一化 Host authority,无法解析时为 undefined。 */
@@ -1847,7 +2443,8 @@ const HEALTH_METHODS = [
1847
2443
  "session-health-status",
1848
2444
  "session-health-scan",
1849
2445
  "session-health-session",
1850
- "session-health-repair"
2446
+ "session-health-repair",
2447
+ "session-health-source-migrate"
1851
2448
  ];
1852
2449
  /** 依据开关判定某方法是否启用。 */
1853
2450
  function methodEnabled(method, config) {
@@ -1935,8 +2532,8 @@ async function handleMethod(method, payload, runtime) {
1935
2532
  ok: false,
1936
2533
  error: "缺少合法的 sessionId"
1937
2534
  };
1938
- const logPath = findSessionLog(sessionId, runtime.dshHome);
1939
- if (logPath === void 0) return {
2535
+ const session = findSession(sessionId, runtime.dshHome);
2536
+ if (session === void 0) return {
1940
2537
  ok: false,
1941
2538
  error: `未找到会话日志:${sessionId}`
1942
2539
  };
@@ -1944,7 +2541,8 @@ async function handleMethod(method, payload, runtime) {
1944
2541
  const reportFor = () => buildSessionReport({
1945
2542
  sessionId,
1946
2543
  dshHome: runtime.dshHome,
1947
- logPath,
2544
+ ...session.logPath === void 0 ? {} : { logPath: session.logPath },
2545
+ generations: session.generations,
1948
2546
  ...projectionState === void 0 ? {} : { projectionState },
1949
2547
  attribute: runtime.attribute
1950
2548
  });
@@ -1957,6 +2555,31 @@ async function handleMethod(method, payload, runtime) {
1957
2555
  prescriptions: prescribe(report, runtime.dshHome)
1958
2556
  };
1959
2557
  }
2558
+ if (method === "session-health-source-migrate") {
2559
+ if (session.logPath === void 0) return {
2560
+ ok: false,
2561
+ error: "会话尚未发布当前代日志,无从转换(先用会话代次工具发布)"
2562
+ };
2563
+ const log = decodeSessionLogFile(session.logPath);
2564
+ const outcome = migrateSessionSourceKind(sessionId, session.logPath, log, runtime.dshHome);
2565
+ const after = buildSessionReport({
2566
+ sessionId,
2567
+ dshHome: runtime.dshHome,
2568
+ logPath: session.logPath,
2569
+ generations: session.generations,
2570
+ ...projectionState === void 0 ? {} : { projectionState },
2571
+ attribute: runtime.attribute
2572
+ });
2573
+ runtime.cache?.patch(after);
2574
+ runtime.log(`source-kind migrate ${sessionId}: ok=${outcome.ok} changed=${outcome.changedRows ?? 0}` + (outcome.error === void 0 ? "" : ` error=${outcome.error}`));
2575
+ return {
2576
+ ok: outcome.ok,
2577
+ outcome,
2578
+ after,
2579
+ report: after,
2580
+ error: outcome.error
2581
+ };
2582
+ }
1960
2583
  const before = reportFor();
1961
2584
  if (before.level === "ok") {
1962
2585
  runtime.cache?.patch(before);
@@ -1979,7 +2602,7 @@ async function handleMethod(method, payload, runtime) {
1979
2602
  verdict: clean.verdict,
1980
2603
  explanation: clean.explanation,
1981
2604
  residual: clean.residual,
1982
- prescriptions: ["# 四门全绿:无需处置"]
2605
+ prescriptions: ["# 全部检查通过:无需处置"]
1983
2606
  };
1984
2607
  }
1985
2608
  const repair = quarantineProjectionCache(sessionId, runtime.dshHome);
@@ -2004,19 +2627,15 @@ async function handleMethod(method, payload, runtime) {
2004
2627
  };
2005
2628
  }
2006
2629
  /**
2007
- * 插件主体:注册设置命名空间、装配运行时、挂载 fenced 路由。
2008
- * @param ctx - host 插件上下文(webServer / webRuntime / 可选 settings、sessionQuery、sessions、sessionProjections)。
2630
+ * 插件主体:装配运行时、挂载 fenced 路由。
2631
+ * @param ctx - host 插件上下文(webServer / webRuntime / 可选 sessionQuery、sessions、sessionProjections)。
2632
+ * @param config - 组合条目(0.1.7:`.volatile()` 字段为 live ref)。
2009
2633
  */
2010
- function apply(ctx) {
2011
- let current = () => DEFAULT_CONFIG;
2012
- installSettingsSection(ctx, STEWARD_SETTINGS_NAMESPACE, Config, DEFAULT_CONFIG, {
2013
- setSource: (source) => {
2014
- current = () => ({
2015
- ...DEFAULT_CONFIG,
2016
- ...source()
2017
- });
2018
- },
2019
- onChange: () => {}
2634
+ function apply(ctx, config = {}) {
2635
+ const current = () => ({
2636
+ enabled: readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled,
2637
+ historyFiles: readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles,
2638
+ healthCheck: readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck
2020
2639
  });
2021
2640
  const log = (message) => {
2022
2641
  try {
@@ -2108,4 +2727,4 @@ function apply(ctx) {
2108
2727
  }), "dsh-manage-session: /session-steward/api route");
2109
2728
  }
2110
2729
  //#endregion
2111
- export { DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, apply, assessRepair, buildProjectionOwnerIndex, buildSessionReport, countCorpus, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, dirSize, discoverSessions, editWorkspaceDocument, findSessionLog, firstLosslessViolation, gateColdRead, gateLogIntegrity, gateLosslessJson, gateProjectionCache, handleMethod, indexSessionDirs, inject, isLossless, isSafeChild, listHistory, locateSessionUsage, methodEnabled, prescribe, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readTailFacts, scanSessions, scanZstdFrames, sessionsRootFor };
2730
+ export { Config, DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, V4_HOST_MIN, apply, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, dirSize, discoverSessions, editWorkspaceDocument, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleMethod, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, prescribe, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor };