dsh-manage-session 0.3.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,885 +183,1205 @@ function pruneArchiveFile(ids, log, searchPaths) {
183
183
  throw new Error(lastReason);
184
184
  }
185
185
  //#endregion
186
- //#region src/host/history/purge.ts
187
- /**
188
- * 归档文件清理(**真删除**)—— 会话管家唯一的破坏性操作。
189
- *
190
- * 与「取消归档状态」(`pruneArchiveFile`)是**两件事**,刻意分开:
191
- *
192
- * | | 取消归档状态 | 清理归档文件 |
193
- * |---|---|---|
194
- * | 改什么 | 只改 `global.archivedSessionIds` | 删磁盘实体 **+** 改两处 id |
195
- * | 会话 | 回到侧边栏 | 从世界上消失 |
196
- * | 可逆 | 重启即生效 | 不可逆 |
197
- *
198
- * 「清理归档文件」必须**连带**执行「取消归档状态」:文件删了、id 还留在数组里,
199
- * 就是一条僵尸归档 —— 侧边栏看不见它,一旦被取消归档又会冒出一个打不开的空会话。
200
- *
201
- * 一次清理动 4 处:
202
- * 1. `~/.dsh/sessions/<工作区>/<会话id>/` —— 整个目录(可能含 `session.jsonl.zstd`
203
- * 与旧格式 `session.v3.jsonl.zstd` 两份,所以必须整目录删,不能只删当前格式)
204
- * 2. `~/.dsh/storages/session_projcache/sessions/<会话id>.json` —— 逐条投影缓存
205
- * 3. `workspace.json` → `global.archivedSessionIds`
206
- * 4. `workspace.json` → `tables.workspaces.*.sessionIds`
207
- *
208
- * 执行顺序是**先改文件、后删实体**:改文件是原子的且可整体中止(失败则一个字节都没删);
209
- * 反过来先删实体再改文件,中途失败会留下僵尸 id。
210
- */
211
- /** 转录根目录(`<dshHome>/sessions`)。 */
212
- function sessionsRootFor(dshHome) {
213
- return join(dshHome, "sessions");
214
- }
186
+ //#region src/host/health/decode.ts
215
187
  /**
216
- * 投影缓存(**逐条布局**)根目录。
188
+ * 会话日志读取链路(`log-integrity` gate 与 `lossless-json` gate 的共同底座)。
217
189
  *
218
- * 注意另有一个 `<dshHome>/storages/session_projcache.json`(整份布局)——
219
- * 那是布局迁移留下的**化石**,宿主早已不再写它,本插件也不碰它。
220
- */
221
- function projCacheRootFor(dshHome) {
222
- return join(dshHome, "storages", "session_projcache", "sessions");
223
- }
224
- /**
225
- * 会话 id 的目录名归一化。
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 语义处理)。
226
195
  *
227
- * 归档集合里一律是 `session-<uuid>`,但子会话的目录名是不带前缀的 `<uuid>`,
228
- * 两者必须视为同一个会话,否则清理会「找不到文件」而静默什么都没删。
196
+ * 解码器优先使用宿主真实的 `@deepseek-ai/dsh-session`(运行时按需软加载,零硬依赖);
197
+ * 不可用时使用本文件内的等价实现(同格式:dt 为**时间**增量、成员数 = dt.length + 1;
198
+ * provenance 存储形态为 `[[start,end],…]` 区间编码)。二者的差异会被显式记入
199
+ * `decoder` 字段,禁止静默回退。
229
200
  */
230
- function dirKey(name) {
231
- return name.startsWith("session-") ? name.slice(8) : name;
232
- }
233
- /** 是不是目录(读不到就当不是)。 */
234
- function isDir(path) {
235
- try {
236
- return statSync(path).isDirectory();
237
- } catch {
238
- return false;
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
+ });
239
279
  }
280
+ return { frames };
240
281
  }
241
- /**
242
- * 扫一遍转录根,建立「会话 id → 目录」索引。
243
- *
244
- * 同一个 id 同时存在带前缀与不带前缀的目录时,**优先带 `session-` 前缀的那个**
245
- * (归档集合里的 id 本身带前缀)。
246
- * @param sessionsRoot - `<dshHome>/sessions`。
247
- * @returns 归一化 id → 目录绝对路径。
248
- */
249
- function indexSessionDirs(sessionsRoot) {
250
- const map = /* @__PURE__ */ new Map();
251
- if (!existsSync(sessionsRoot)) return map;
252
- for (const workspace of readdirSync(sessionsRoot)) {
253
- const wsDir = join(sessionsRoot, workspace);
254
- if (!isDir(wsDir)) continue;
255
- for (const name of readdirSync(wsDir)) {
256
- const entry = join(wsDir, name);
257
- if (!isDir(entry)) continue;
258
- const key = dirKey(name);
259
- const existing = map.get(key);
260
- const prefer = name.startsWith("session-") && existing !== void 0 && !basename(existing).startsWith("session-");
261
- if (existing === void 0 || prefer) map.set(key, entry);
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;
262
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");
263
298
  }
264
- return map;
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;
265
301
  }
266
- /** 递归求目录占用字节数(读不到的条目跳过,不抛错)。 */
267
- function dirSize(dir) {
268
- let total = 0;
269
- let entries;
270
- try {
271
- entries = readdirSync(dir, { withFileTypes: true });
272
- } catch {
273
- return 0;
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];
274
311
  }
275
- for (const entry of entries) {
276
- const path = join(dir, entry.name);
277
- if (entry.isDirectory()) total += dirSize(path);
278
- else try {
279
- total += statSync(path).size;
280
- } catch {}
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
+ });
281
357
  }
282
- return total;
358
+ return out;
283
359
  }
284
360
  /**
285
- * 解析一批会话在磁盘上的实体与占用(**只读,不删任何东西**)。
286
- *
287
- * 列表按行显示体积就走这里 —— 体积差异极大(实测单条缓存可达 23 MB、单条转录可达
288
- * 6 MB,也有 0 字节的),报一个总量对用户没有意义。
289
- * @param ids - 会话 id(带不带 `session-` 前缀都接受)。
290
- * @param dshHome - DSH 主目录。
291
- * @returns 会话 id → 实体位置与占用。
292
- */
293
- function locateSessionUsage(ids, dshHome) {
294
- const dirs = indexSessionDirs(sessionsRootFor(dshHome));
295
- const cacheRoot = projCacheRootFor(dshHome);
296
- const out = /* @__PURE__ */ new Map();
297
- for (const sessionId of ids) {
298
- const key = dirKey(sessionId);
299
- const usage = {
300
- sessionId,
301
- bytes: 0,
302
- cacheBytes: 0
303
- };
304
- const dir = dirs.get(key);
305
- if (dir !== void 0) {
306
- usage.dir = dir;
307
- usage.bytes = dirSize(dir);
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;
308
404
  }
309
- for (const name of [`${sessionId}.json`, `${key}.json`]) {
310
- const candidate = join(cacheRoot, name);
311
- if (existsSync(candidate)) {
312
- usage.cacheFile = candidate;
313
- try {
314
- usage.cacheBytes = statSync(candidate).size;
315
- } catch {}
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;
316
415
  break;
317
416
  }
417
+ events.push(event);
318
418
  }
319
- out.set(sessionId, usage);
419
+ if (gapped) break;
320
420
  }
321
- return out;
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
+ };
322
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
323
461
  /**
324
- * 断言目标是 `root` 的**直接子项**(层级也校验)。
462
+ * 插件署名格式(source kind)体检门与转换。
325
463
  *
326
- * 破坏性操作不能只靠「路径拼对了」—— 拼错一层就是删掉整个 sessions 根。
327
- * @param root - 允许的父目录。
328
- * @param target - 待删目标。
329
- * @param depth - 相对 root 的期望层数。
330
- * @returns 是否安全。
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
+ * 这三道闸缺一不可,否则就退化成「静默改历史数据」。
331
491
  */
332
- function isSafeChild(root, target, depth) {
333
- const rel = relative(resolve(root), resolve(target));
334
- if (rel === "" || rel.startsWith("..") || resolve(root, rel) !== resolve(target)) return false;
335
- return rel.split(sep).filter((part) => part !== "").length === depth;
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
+ };
336
514
  }
337
- /** 单次清理的最大 id 数(与 prune 一致)。 */
338
- const MAX_PURGE_IDS = 5e3;
339
515
  /**
340
- * `session-history-purge`:**真删除**归档会话的磁盘实体,并连带取消其归档状态。
341
- * @param payload - `{ sessionIds: string[] }`。
342
- * @param log - 可选日志出口。
343
- * @param options - `dshHome` 必填;`searchPaths` 可选覆盖。
344
- * @returns 清理条数、释放字节数与逐条失败。
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 表示未提供(无日志)。
345
524
  */
346
- function purgeHistory(payload, log, options) {
347
- const dshHome = options?.dshHome;
348
- if (dshHome === void 0 || dshHome === "") return {
349
- ok: false,
350
- error: "缺少 dshHome:破坏性清理拒绝在未知路径上执行"
525
+ function gateSourceKind(facts) {
526
+ if (facts === void 0) return {
527
+ id: "source-kind",
528
+ level: "skipped",
529
+ evidence: "未提供日志读取结果,本门无从判定"
351
530
  };
352
- const record = payload;
353
- if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) return {
354
- ok: false,
355
- error: "缺少 sessionIds 数组"
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
356
545
  };
357
- const ids = [...new Set(record.sessionIds.filter((id) => typeof id === "string" && id !== ""))];
358
- if (ids.length === 0) return {
359
- ok: false,
360
- error: "sessionIds 无有效值"
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
361
551
  };
362
- if (ids.length > 5e3) return {
363
- ok: false,
364
- error: `单次最多清理 ${MAX_PURGE_IDS} 个`
552
+ if (plugins.length === 0) return {
553
+ id: "source-kind",
554
+ level: "ok",
555
+ evidence: `格式代次 v${version}:全部插件署名均为 producer-owned kind(plugin:<name>)`,
556
+ detail
365
557
  };
366
- const wanted = new Set(ids);
367
- const usage = locateSessionUsage(ids, dshHome);
368
- const candidates = options?.searchPaths ?? [join(dshHome, "storages", "workspace.json")];
369
- let edited = false;
370
- let lastReason = `workspace 存储文件不存在(已试:${candidates.join("、")})`;
371
- for (const path of candidates) {
372
- const outcome = editWorkspaceDocument(path, (document) => {
373
- let changed = false;
374
- const global = document.global;
375
- if (Array.isArray(global?.archivedSessionIds)) {
376
- const kept = global.archivedSessionIds.filter((id) => typeof id !== "string" || !wanted.has(id));
377
- if (kept.length !== global.archivedSessionIds.length) {
378
- document.global = {
379
- ...global,
380
- archivedSessionIds: kept
381
- };
382
- changed = true;
383
- }
384
- }
385
- const tables = document.tables;
386
- if (tables?.workspaces !== void 0) for (const [workspaceId, workspace] of Object.entries(tables.workspaces)) {
387
- if (!Array.isArray(workspace?.sessionIds)) continue;
388
- const kept = workspace.sessionIds.filter((id) => typeof id !== "string" || !wanted.has(id));
389
- if (kept.length !== workspace.sessionIds.length) {
390
- tables.workspaces[workspaceId] = {
391
- ...workspace,
392
- sessionIds: kept
393
- };
394
- changed = true;
395
- }
396
- }
397
- return changed;
398
- }, log);
399
- if (!outcome.ok) {
400
- lastReason = outcome.reason;
401
- continue;
402
- }
403
- edited = true;
404
- break;
405
- }
406
- if (!edited) return {
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) => ({
407
650
  ok: false,
408
- error: lastReason
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 } : {}
409
704
  };
410
- const sessionsRoot = sessionsRootFor(dshHome);
411
- const cacheRoot = projCacheRootFor(dshHome);
412
- const failures = [];
413
- let purged = 0;
414
- let freedBytes = 0;
415
- for (const sessionId of ids) {
416
- const entry = usage.get(sessionId);
417
- let missing = 0;
418
- let entryFreed = 0;
419
- if (entry?.dir !== void 0) {
420
- if (!isSafeChild(sessionsRoot, entry.dir, 2)) {
421
- failures.push({
422
- sessionId,
423
- reason: `拒绝删除越界路径:${entry.dir}`
424
- });
425
- continue;
426
- }
427
- try {
428
- rmSync(entry.dir, {
429
- recursive: true,
430
- force: true
431
- });
432
- entryFreed += entry.bytes;
433
- } catch (err) {
434
- failures.push({
435
- sessionId,
436
- reason: `删除转录目录失败:${String(err instanceof Error ? err.message : err)}`
437
- });
438
- continue;
439
- }
440
- } else missing++;
441
- if (entry?.cacheFile !== void 0) {
442
- if (!isSafeChild(cacheRoot, entry.cacheFile, 1)) {
443
- failures.push({
444
- sessionId,
445
- reason: `拒绝删除越界缓存:${entry.cacheFile}`
446
- });
447
- continue;
448
- }
449
- try {
450
- entryFreed += entry.cacheBytes;
451
- rmSync(entry.cacheFile, { force: true });
452
- } catch (err) {
453
- failures.push({
454
- sessionId,
455
- reason: `删除投影缓存失败:${String(err instanceof Error ? err.message : err)}`
456
- });
457
- continue;
458
- }
459
- } else missing++;
460
- if (missing === 2) log?.(`purge: ${sessionId} had no on-disk entity; only the archive state was cleared`);
461
- purged++;
462
- freedBytes += entryFreed;
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)}`);
463
723
  }
464
- log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; failures ${failures.length}`);
465
- return {
466
- ok: true,
467
- purged,
468
- freedBytes,
469
- requiresRestart: true,
470
- ...failures.length === 0 ? {} : { failures }
471
- };
472
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-";
473
736
  //#endregion
474
- //#region src/host/history/archive.ts
737
+ //#region src/host/history/purge.ts
475
738
  /**
476
- * 会话历史文件(归档)域的服务端处理。
739
+ * 归档文件清理(**真删除**)—— 会话管家唯一的破坏性操作。
477
740
  *
478
- * 与 dsh-session-search-toggle 的差异(**刻意且必须记入交付说明**):
479
- * toggle 的 `list-archived` 从它自己的独立索引里列出归档行(`engine.listArchived()`),
480
- * 而索引已随搜索索引包离开本包。因此本包改为从**官方归档集合**列出来源真值
481
- * (`readArchiveSet`:存储文件优先、registry 兜底),标题等元数据为尽力而为:
482
- * 依次尝试 `sessionQuery.readTitleSnapshots` → 投影缓存记录里的 title。
483
- * 列表与 prune **同源**(都认存储文件),否则清理成功后面板不会刷新;
484
- * registry 与文件的差集作为 `pendingRestart` 返回,面板据此提示重启。
485
- * 清理(prune)路径与迁移前**逐字等价**(备份 + 原子替换 + 需重启提示),
486
- * 另加一道写后校验:读回文件确认目标 id 确实消失。
741
+ * 与「取消归档状态」(`pruneArchiveFile`)是**两件事**,刻意分开:
742
+ *
743
+ * | | 取消归档状态 | 清理归档文件 |
744
+ * |---|---|---|
745
+ * | 改什么 | 只改 `global.archivedSessionIds` | 删磁盘实体 **+** 改两处 id |
746
+ * | 会话 | 回到侧边栏 | 从世界上消失 |
747
+ * | 可逆 | 重启即生效 | 不可逆 |
748
+ *
749
+ * 「清理归档文件」必须**连带**执行「取消归档状态」:文件删了、id 还留在数组里,
750
+ * 就是一条僵尸归档 —— 侧边栏看不见它,一旦被取消归档又会冒出一个打不开的空会话。
751
+ *
752
+ * 一次清理动 4 处:
753
+ * 1. `~/.dsh/sessions/<工作区>/<会话id>/` —— 整个目录(可能含 `session.jsonl.zstd`
754
+ * 与旧格式 `session.v3.jsonl.zstd` 两份,所以必须整目录删,不能只删当前格式)
755
+ * 2. `~/.dsh/storages/session_projcache/sessions/<会话id>.json` —— 逐条投影缓存
756
+ * 3. `workspace.json` → `global.archivedSessionIds`
757
+ * 4. `workspace.json` → `tables.workspaces.*.sessionIds`
758
+ *
759
+ * 执行顺序是**先改文件、后删实体**:改文件是原子的且可整体中止(失败则一个字节都没删);
760
+ * 反过来先删实体再改文件,中途失败会留下僵尸 id。
487
761
  */
488
- /** 投影缓存记录里可能存在的标题(同目录第二来源)。 */
489
- function titleFromProjectionCache(sessionId) {
490
- const path = join(homedir(), ".dsh", "storages", "session_projcache", "sessions", `${sessionId}.json`);
491
- if (!existsSync(path)) return "";
492
- try {
493
- const value = JSON.parse(readFileSync(path, "utf8")).record?.rows?.title?.val;
494
- return typeof value === "string" ? value : "";
495
- } catch {
496
- return "";
497
- }
762
+ /** 是否署名转换备份文件(`*.pre-sourcemigrate-<ts>`)。 */
763
+ function isSourceMigrateBackupName(name) {
764
+ return name.includes(SOURCE_MIGRATE_BACKUP_SUFFIX);
498
765
  }
499
766
  /**
500
- * `session-history-list`:列出官方归档集合(尽力附带标题等元数据)。
501
- * @param getRegistry - 惰性 workspaceRegistry 面。
502
- * @param query - 可选标题快照面。
503
- * @param searchPaths - 可选存储文件候选路径(DSH_HOME 非默认值/测试注入)。
504
- * @returns 归档行清单。
767
+ * 列出一个转录目录里的署名转换备份(只扫顶层:备份与日志同目录落盘)。
768
+ * @returns 备份绝对路径与合计字节数;目录不存在时为空。
505
769
  */
506
- async function listHistory(getRegistry, query, searchPaths, dshHome) {
507
- const read = readArchiveSet(getRegistry(), searchPaths);
508
- if (read.source === "none") return {
509
- ok: false,
510
- error: "workspace 存储文件与 workspaceRegistry 均不可用,无法读取归档集合"
511
- };
512
- const ids = [...read.ids];
513
- const titles = /* @__PURE__ */ new Map();
514
- let degraded;
515
- if (query?.readTitleSnapshots !== void 0 && ids.length > 0) try {
516
- const observations = await query.readTitleSnapshots(ids);
517
- ids.forEach((id, index) => {
518
- const title = observations[index]?.value?.title?.title;
519
- if (typeof title === "string" && title !== "") titles.set(id, title);
520
- });
770
+ function backupFilesIn(dir) {
771
+ const files = [];
772
+ let bytes = 0;
773
+ let entries;
774
+ try {
775
+ entries = readdirSync(dir, { withFileTypes: true });
521
776
  } catch {
522
- degraded = "sessionQuery 标题快照不可用,标题回退到投影缓存/空";
523
- }
524
- else if (ids.length > 0) degraded = "sessionQuery 不可用,标题回退到投影缓存/空";
525
- const items = ids.map((sessionId) => {
526
777
  return {
527
- sessionId,
528
- title: titles.get(sessionId) ?? titleFromProjectionCache(sessionId),
529
- cwd: "",
530
- updatedAt: 0,
531
- bytes: 0,
532
- cacheBytes: 0
778
+ files,
779
+ bytes
533
780
  };
534
- });
535
- if (dshHome !== void 0 && dshHome !== "" && items.length > 0) try {
536
- const usage = locateSessionUsage(ids, dshHome);
537
- for (const item of items) {
538
- const entry = usage.get(item.sessionId);
539
- if (entry === void 0) continue;
540
- item.bytes = entry.bytes;
541
- item.cacheBytes = entry.cacheBytes;
542
- }
543
- } catch {}
544
- const fileIds = new Set(ids);
545
- const pendingRestart = (read.registryIds ?? []).filter((id) => !fileIds.has(id)).length;
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
+ }
546
790
  return {
547
- ok: true,
548
- items,
549
- source: read.source,
550
- ...degraded === void 0 ? {} : { degraded },
551
- ...pendingRestart === 0 ? {} : { pendingRestart }
791
+ files,
792
+ bytes
552
793
  };
553
794
  }
554
- /** 按 DSH_HOME 解析存储文件候选(与 archive-source 的默认候选保持同一形状)。 */
555
- function storagePathsFor(dshHome) {
556
- return [join(dshHome, "storages", "workspace.json")];
795
+ /** 转录根目录(`<dshHome>/sessions`)。 */
796
+ function sessionsRootFor(dshHome) {
797
+ return join(dshHome, "sessions");
557
798
  }
558
- /** 单次清理的最大 id 数(与迁移前一致)。 */
559
- const MAX_PRUNE_IDS = 5e3;
560
799
  /**
561
- * `session-history-prune`:从官方归档数组中批量移除会话 id。
562
- * 行为与迁移前等价:校验入参 → 备份并原子替换存储文件 → 返回 removed/remaining,
563
- * 并明确要求重启 DSH(宿主内存中的集合只在启动时重载)。
564
- * @param payload - `{ sessionIds: string[] }`。
565
- * @param log - 可选日志出口。
566
- * @param searchPaths - 可选候选路径覆盖(测试注入用)。
800
+ * 投影缓存(**逐条布局**)根目录。
801
+ *
802
+ * 注意另有一个 `<dshHome>/storages/session_projcache.json`(整份布局)——
803
+ * 那是布局迁移留下的**化石**,宿主早已不再写它,本插件也不碰它。
567
804
  */
568
- function pruneHistory(payload, log, searchPaths) {
569
- const record = payload;
570
- if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) return {
571
- ok: false,
572
- error: "缺少 sessionIds 数组"
573
- };
574
- const ids = [...new Set(record.sessionIds.filter((id) => typeof id === "string" && id !== ""))];
575
- if (ids.length === 0) return {
576
- ok: false,
577
- error: "sessionIds 无有效值"
578
- };
579
- if (ids.length > 5e3) return {
580
- ok: false,
581
- error: `单次最多清理 ${MAX_PRUNE_IDS} 个`
582
- };
583
- let result;
805
+ function projCacheRootFor(dshHome) {
806
+ return join(dshHome, "storages", "session_projcache", "sessions");
807
+ }
808
+ /**
809
+ * 会话 id 的目录名归一化。
810
+ *
811
+ * 归档集合里一律是 `session-<uuid>`,但子会话的目录名是不带前缀的 `<uuid>`,
812
+ * 两者必须视为同一个会话,否则清理会「找不到文件」而静默什么都没删。
813
+ */
814
+ function dirKey(name) {
815
+ return name.startsWith("session-") ? name.slice(8) : name;
816
+ }
817
+ /** 是不是目录(读不到就当不是)。 */
818
+ function isDir(path) {
584
819
  try {
585
- result = pruneArchiveFile(ids, log, searchPaths);
586
- } catch (err) {
587
- return {
588
- ok: false,
589
- error: String(err instanceof Error ? err.message : err)
590
- };
820
+ return statSync(path).isDirectory();
821
+ } catch {
822
+ return false;
591
823
  }
592
- const after = readArchiveSet(void 0, searchPaths);
593
- if (after.source !== "storage-file") return {
594
- ok: false,
595
- error: "写入后无法读回存储文件,无法确认清理结果"
596
- };
597
- const stillThere = new Set(after.ids);
598
- const leftover = ids.filter((id) => stillThere.has(id));
599
- if (leftover.length > 0) return {
600
- ok: false,
601
- error: `存储文件在写入后仍包含 ${leftover.length} 个目标 id,清理未生效`
602
- };
603
- log?.(`archive prune requested ${ids.length}, removed ${result.removed}, remaining ${result.remaining}`);
604
- return {
605
- ok: true,
606
- removed: result.removed,
607
- remaining: result.remaining,
608
- requiresRestart: true
609
- };
610
824
  }
611
- //#endregion
612
- //#region src/host/health/attribution.ts
613
825
  /**
614
- * 归属:把投影 key 指回产出它的插件包。
826
+ * 扫一遍转录根,建立「会话 id → 目录」索引。
615
827
  *
616
- * 做法是静态扫描 profile 的 `node_modules`:`<pkg>/lib/index.js` 里
617
- * `sessionProjections.register({ key: 'xxx' })`(或其定义工厂)会命中
618
- * `key:\s*['"](\w+)['"]` 正则;据此建立 `投影 key → 包名` 索引。
619
- * `@deepseek-ai/*` 视为内置(core);扫不到时如实返回 `unknown`,禁止臆测。
828
+ * 同一个 id 同时存在带前缀与不带前缀的目录时,**优先带 `session-` 前缀的那个**
829
+ * (归档集合里的 id 本身带前缀)。
830
+ * @param sessionsRoot - `<dshHome>/sessions`。
831
+ * @returns 归一化 id → 目录绝对路径。
620
832
  */
621
- /** 默认扫描根:profile 的 node_modules。 */
622
- function defaultProfileNodeModules(dshHome) {
623
- return join(dshHome ?? join(homedir(), ".dsh"), "profiles", "web", "node_modules");
624
- }
625
- /** 收集候选包目录(含 @scope/pkg 一层)。 */
626
- function candidatePackages(root) {
627
- const out = [];
628
- if (!existsSync(root)) return out;
629
- for (const entry of readdirSync(root, { withFileTypes: true })) {
630
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
631
- if (entry.name.startsWith(".")) continue;
632
- const dir = join(root, entry.name);
633
- if (entry.name.startsWith("@")) {
634
- let scoped = [];
635
- try {
636
- scoped = readdirSync(dir);
637
- } catch {
638
- continue;
639
- }
640
- for (const child of scoped) out.push({
641
- name: `${entry.name}/${child}`,
642
- dir: join(dir, child)
643
- });
644
- continue;
833
+ function indexSessionDirs(sessionsRoot) {
834
+ const map = /* @__PURE__ */ new Map();
835
+ if (!existsSync(sessionsRoot)) return map;
836
+ for (const workspace of readdirSync(sessionsRoot)) {
837
+ const wsDir = join(sessionsRoot, workspace);
838
+ if (!isDir(wsDir)) continue;
839
+ for (const name of readdirSync(wsDir)) {
840
+ const entry = join(wsDir, name);
841
+ if (!isDir(entry)) continue;
842
+ const key = dirKey(name);
843
+ const existing = map.get(key);
844
+ const prefer = name.startsWith("session-") && existing !== void 0 && !basename(existing).startsWith("session-");
845
+ if (existing === void 0 || prefer) map.set(key, entry);
645
846
  }
646
- out.push({
647
- name: entry.name,
648
- dir
649
- });
650
847
  }
651
- return out;
848
+ return map;
652
849
  }
653
- /** 单个入口文件的最大扫描字节数(超大 bundle 直接跳过,避免无谓内存与耗时)。 */
654
- const MAX_SCAN_BYTES = 12 << 20;
655
- /** 扫描若干候选入口文件里出现的投影 key。 */
656
- function keysInFile(file) {
657
- if (!existsSync(file)) return [];
658
- let text;
850
+ /** 递归求目录占用字节数(读不到的条目跳过,不抛错)。 */
851
+ function dirSize(dir) {
852
+ let total = 0;
853
+ let entries;
659
854
  try {
660
- if (statSync(file).size > MAX_SCAN_BYTES) return [];
661
- text = readFileSync(file, "utf8");
855
+ entries = readdirSync(dir, { withFileTypes: true });
662
856
  } catch {
663
- return [];
857
+ return 0;
664
858
  }
665
- const keys = /* @__PURE__ */ new Set();
666
- const pattern = /key:\s*['"]([A-Za-z_$][\w$]*)['"]/g;
667
- let match;
668
- while ((match = pattern.exec(text)) !== null) {
669
- const key = match[1];
670
- if (key !== void 0 && key !== "") keys.add(key);
859
+ for (const entry of entries) {
860
+ const path = join(dir, entry.name);
861
+ if (entry.isDirectory()) total += dirSize(path);
862
+ else try {
863
+ total += statSync(path).size;
864
+ } catch {}
671
865
  }
672
- return [...keys];
866
+ return total;
673
867
  }
674
868
  /**
675
- * 建立投影 key → 包 的索引。
676
- * @param nodeModulesRoot - profile 的 node_modules 根。
677
- * @returns 归属记录(同一 key 多包命中时保留首个并排序稳定)。
869
+ * 解析一批会话在磁盘上的实体与占用(**只读,不删任何东西**)。
870
+ *
871
+ * 列表按行显示体积就走这里 —— 体积差异极大(实测单条缓存可达 23 MB、单条转录可达
872
+ * 6 MB,也有 0 字节的),报一个总量对用户没有意义。
873
+ * @param ids - 会话 id(带不带 `session-` 前缀都接受)。
874
+ * @param dshHome - DSH 主目录。
875
+ * @returns 会话 id → 实体位置与占用。
678
876
  */
679
- function buildProjectionOwnerIndex(nodeModulesRoot) {
680
- const records = [];
681
- const seen = /* @__PURE__ */ new Set();
682
- for (const pack of candidatePackages(nodeModulesRoot)) {
683
- const lib = join(pack.dir, "lib");
684
- let entries = [];
685
- try {
686
- if (!statSync(lib).isDirectory()) continue;
687
- entries = readdirSync(lib).filter((name) => name.endsWith(".js"));
688
- } catch {
689
- continue;
877
+ function locateSessionUsage(ids, dshHome) {
878
+ const dirs = indexSessionDirs(sessionsRootFor(dshHome));
879
+ const cacheRoot = projCacheRootFor(dshHome);
880
+ const out = /* @__PURE__ */ new Map();
881
+ for (const sessionId of ids) {
882
+ const key = dirKey(sessionId);
883
+ const usage = {
884
+ sessionId,
885
+ bytes: 0,
886
+ backupBytes: 0,
887
+ backupCount: 0,
888
+ cacheBytes: 0
889
+ };
890
+ const dir = dirs.get(key);
891
+ if (dir !== void 0) {
892
+ usage.dir = dir;
893
+ usage.bytes = dirSize(dir);
894
+ const backups = backupFilesIn(dir);
895
+ usage.backupBytes = backups.bytes;
896
+ usage.backupCount = backups.files.length;
690
897
  }
691
- for (const name of entries) {
692
- const file = join(lib, name);
693
- for (const key of keysInFile(file)) {
694
- if (seen.has(key)) continue;
695
- seen.add(key);
696
- records.push({
697
- key,
698
- package: pack.name,
699
- kind: pack.name.startsWith("@deepseek-ai/") ? "core" : "plugin",
700
- entry: `${pack.name}/lib/${name}`
701
- });
898
+ for (const name of [`${sessionId}.json`, `${key}.json`]) {
899
+ const candidate = join(cacheRoot, name);
900
+ if (existsSync(candidate)) {
901
+ usage.cacheFile = candidate;
902
+ try {
903
+ usage.cacheBytes = statSync(candidate).size;
904
+ } catch {}
905
+ break;
702
906
  }
703
907
  }
908
+ out.set(sessionId, usage);
704
909
  }
705
- return records.sort((left, right) => left.key.localeCompare(right.key));
706
- }
707
- /** 索引查询器。 */
708
- function createAttributor(index) {
709
- const byKey = new Map(index.map((record) => [record.key, record]));
710
- return (projection) => {
711
- const owner = byKey.get(projection);
712
- if (owner === void 0) return {
713
- projection,
714
- package: "unknown"
715
- };
716
- return {
717
- projection,
718
- package: owner.package
719
- };
720
- };
910
+ return out;
721
911
  }
722
- //#endregion
723
- //#region src/host/health/cache.ts
724
- /** 进程内体检缓存:分批扫描过程中累积,走完一遍语料才成型。 */
725
- var HealthCache = class {
726
- ready;
727
- pending;
728
- /** 读当前成型的缓存(未成型则 undefined)。 */
729
- read() {
730
- return this.ready;
731
- }
732
- /** 开始一轮分批扫描(客户端以 `offset: 0` 发起时调用)。 */
733
- begin(total) {
734
- 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
912
  /**
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 语义处理)。
913
+ * 断言目标是 `root` 的**直接子项**(层级也校验)。
801
914
  *
802
- * 解码器优先使用宿主真实的 `@deepseek-ai/dsh-session`(运行时按需软加载,零硬依赖);
803
- * 不可用时使用本文件内的等价实现(同格式:dt 为**时间**增量、成员数 = dt.length + 1;
804
- * provenance 存储形态为 `[[start,end],…]` 区间编码)。二者的差异会被显式记入
805
- * `decoder` 字段,禁止静默回退。
915
+ * 破坏性操作不能只靠「路径拼对了」—— 拼错一层就是删掉整个 sessions 根。
916
+ * @param root - 允许的父目录。
917
+ * @param target - 待删目标。
918
+ * @param depth - 相对 root 的期望层数。
919
+ * @returns 是否安全。
806
920
  */
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 };
921
+ function isSafeChild(root, target, depth) {
922
+ const rel = relative(resolve(root), resolve(target));
923
+ if (rel === "" || rel.startsWith("..") || resolve(root, rel) !== resolve(target)) return false;
924
+ return rel.split(sep).filter((part) => part !== "").length === depth;
887
925
  }
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);
926
+ /** 单次清理的最大 id 数(与 prune 一致)。 */
927
+ const MAX_PURGE_IDS = 5e3;
928
+ /**
929
+ * `session-history-purge`:**真删除**归档会话的磁盘实体,并连带取消其归档状态。
930
+ * @param payload - `{ sessionIds: string[] }`。
931
+ * @param log - 可选日志出口。
932
+ * @param options - `dshHome` 必填;`searchPaths` 可选覆盖。
933
+ * @returns 清理条数、释放字节数与逐条失败。
934
+ */
935
+ function purgeHistory(payload, log, options) {
936
+ const dshHome = options?.dshHome;
937
+ if (dshHome === void 0 || dshHome === "") return {
938
+ ok: false,
939
+ error: "缺少 dshHome:破坏性清理拒绝在未知路径上执行"
940
+ };
941
+ const record = payload;
942
+ if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) return {
943
+ ok: false,
944
+ error: "缺少 sessionIds 数组"
945
+ };
946
+ const ids = [...new Set(record.sessionIds.filter((id) => typeof id === "string" && id !== ""))];
947
+ if (ids.length === 0) return {
948
+ ok: false,
949
+ error: "sessionIds 无有效值"
950
+ };
951
+ if (ids.length > 5e3) return {
952
+ ok: false,
953
+ error: `单次最多清理 ${MAX_PURGE_IDS} 个`
954
+ };
955
+ const wanted = new Set(ids);
956
+ const usage = locateSessionUsage(ids, dshHome);
957
+ const candidates = options?.searchPaths ?? [join(dshHome, "storages", "workspace.json")];
958
+ let edited = false;
959
+ let lastReason = `workspace 存储文件不存在(已试:${candidates.join("、")})`;
960
+ for (const path of candidates) {
961
+ const outcome = editWorkspaceDocument(path, (document) => {
962
+ let changed = false;
963
+ const global = document.global;
964
+ if (Array.isArray(global?.archivedSessionIds)) {
965
+ const kept = global.archivedSessionIds.filter((id) => typeof id !== "string" || !wanted.has(id));
966
+ if (kept.length !== global.archivedSessionIds.length) {
967
+ document.global = {
968
+ ...global,
969
+ archivedSessionIds: kept
970
+ };
971
+ changed = true;
972
+ }
973
+ }
974
+ const tables = document.tables;
975
+ if (tables?.workspaces !== void 0) for (const [workspaceId, workspace] of Object.entries(tables.workspaces)) {
976
+ if (!Array.isArray(workspace?.sessionIds)) continue;
977
+ const kept = workspace.sessionIds.filter((id) => typeof id !== "string" || !wanted.has(id));
978
+ if (kept.length !== workspace.sessionIds.length) {
979
+ tables.workspaces[workspaceId] = {
980
+ ...workspace,
981
+ sessionIds: kept
982
+ };
983
+ changed = true;
984
+ }
985
+ }
986
+ return changed;
987
+ }, log);
988
+ if (!outcome.ok) {
989
+ lastReason = outcome.reason;
901
990
  continue;
902
991
  }
903
- throw new Error("sourceEventSeqs entry is neither a seq nor a [start,end] range");
992
+ edited = true;
993
+ break;
904
994
  }
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;
995
+ if (!edited) return {
996
+ ok: false,
997
+ error: lastReason
998
+ };
999
+ const sessionsRoot = sessionsRootFor(dshHome);
1000
+ const cacheRoot = projCacheRootFor(dshHome);
1001
+ const failures = [];
1002
+ let purged = 0;
1003
+ let freedBytes = 0;
1004
+ let backupsRemoved = 0;
1005
+ for (const sessionId of ids) {
1006
+ const entry = usage.get(sessionId);
1007
+ let missing = 0;
1008
+ let entryFreed = 0;
1009
+ if (entry?.dir !== void 0) {
1010
+ if (!isSafeChild(sessionsRoot, entry.dir, 2)) {
1011
+ failures.push({
1012
+ sessionId,
1013
+ reason: `拒绝删除越界路径:${entry.dir}`
1014
+ });
1015
+ continue;
1016
+ }
1017
+ backupsRemoved += entry.backupCount;
1018
+ try {
1019
+ rmSync(entry.dir, {
1020
+ recursive: true,
1021
+ force: true
1022
+ });
1023
+ entryFreed += entry.bytes;
1024
+ } catch (err) {
1025
+ failures.push({
1026
+ sessionId,
1027
+ reason: `删除转录目录失败:${String(err instanceof Error ? err.message : err)}`
1028
+ });
1029
+ continue;
1030
+ }
1031
+ } else missing++;
1032
+ if (entry?.cacheFile !== void 0) {
1033
+ if (!isSafeChild(cacheRoot, entry.cacheFile, 1)) {
1034
+ failures.push({
1035
+ sessionId,
1036
+ reason: `拒绝删除越界缓存:${entry.cacheFile}`
1037
+ });
1038
+ continue;
1039
+ }
1040
+ try {
1041
+ entryFreed += entry.cacheBytes;
1042
+ rmSync(entry.cacheFile, { force: true });
1043
+ } catch (err) {
1044
+ failures.push({
1045
+ sessionId,
1046
+ reason: `删除投影缓存失败:${String(err instanceof Error ? err.message : err)}`
1047
+ });
1048
+ continue;
1049
+ }
1050
+ } else missing++;
1051
+ if (missing === 2) log?.(`purge: ${sessionId} had no on-disk entity; only the archive state was cleared`);
1052
+ purged++;
1053
+ freedBytes += entryFreed;
1054
+ }
1055
+ log?.(`archive purge: cleared ${purged} of ${ids.length}; freed ${(freedBytes / 1048576).toFixed(1)}MB; backups ${backupsRemoved}; failures ${failures.length}`);
1056
+ return {
1057
+ ok: true,
1058
+ purged,
1059
+ freedBytes,
1060
+ backupsRemoved,
1061
+ requiresRestart: true,
1062
+ ...failures.length === 0 ? {} : { failures }
1063
+ };
907
1064
  }
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];
1065
+ //#endregion
1066
+ //#region src/host/history/archive.ts
1067
+ /**
1068
+ * 会话历史文件(归档)域的服务端处理。
1069
+ *
1070
+ * 与 dsh-session-search-toggle 的差异(**刻意且必须记入交付说明**):
1071
+ * toggle 的 `list-archived` 从它自己的独立索引里列出归档行(`engine.listArchived()`),
1072
+ * 而索引已随搜索索引包离开本包。因此本包改为从**官方归档集合**列出来源真值
1073
+ * (`readArchiveSet`:存储文件优先、registry 兜底),标题等元数据为尽力而为:
1074
+ * 依次尝试 `sessionQuery.readTitleSnapshots` → 投影缓存记录里的 title。
1075
+ * 列表与 prune **同源**(都认存储文件),否则清理成功后面板不会刷新;
1076
+ * registry 与文件的差集作为 `pendingRestart` 返回,面板据此提示重启。
1077
+ * 清理(prune)路径与迁移前**逐字等价**(备份 + 原子替换 + 需重启提示),
1078
+ * 另加一道写后校验:读回文件确认目标 id 确实消失。
1079
+ */
1080
+ /** 投影缓存记录里可能存在的标题(同目录第二来源)。 */
1081
+ function titleFromProjectionCache(sessionId) {
1082
+ const path = join(homedir(), ".dsh", "storages", "session_projcache", "sessions", `${sessionId}.json`);
1083
+ if (!existsSync(path)) return "";
1084
+ try {
1085
+ const value = JSON.parse(readFileSync(path, "utf8")).record?.rows?.title?.val;
1086
+ return typeof value === "string" ? value : "";
1087
+ } catch {
1088
+ return "";
917
1089
  }
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]
1090
+ }
1091
+ /**
1092
+ * `session-history-list`:列出官方归档集合(尽力附带标题等元数据)。
1093
+ * @param getRegistry - 惰性 workspaceRegistry 面。
1094
+ * @param query - 可选标题快照面。
1095
+ * @param searchPaths - 可选存储文件候选路径(DSH_HOME 非默认值/测试注入)。
1096
+ * @returns 归档行清单。
1097
+ */
1098
+ async function listHistory(getRegistry, query, searchPaths, dshHome) {
1099
+ const read = readArchiveSet(getRegistry(), searchPaths);
1100
+ if (read.source === "none") return {
1101
+ ok: false,
1102
+ error: "workspace 存储文件与 workspaceRegistry 均不可用,无法读取归档集合"
1103
+ };
1104
+ const ids = [...read.ids];
1105
+ const titles = /* @__PURE__ */ new Map();
1106
+ let degraded;
1107
+ if (query?.readTitleSnapshots !== void 0 && ids.length > 0) try {
1108
+ const observations = await query.readTitleSnapshots(ids);
1109
+ ids.forEach((id, index) => {
1110
+ const title = observations[index]?.value?.title?.title;
1111
+ if (typeof title === "string" && title !== "") titles.set(id, title);
1112
+ });
1113
+ } catch {
1114
+ degraded = "sessionQuery 标题快照不可用,标题回退到投影缓存/空";
1115
+ }
1116
+ else if (ids.length > 0) degraded = "sessionQuery 不可用,标题回退到投影缓存/空";
1117
+ const items = ids.map((sessionId) => {
1118
+ return {
1119
+ sessionId,
1120
+ title: titles.get(sessionId) ?? titleFromProjectionCache(sessionId),
1121
+ cwd: "",
1122
+ updatedAt: 0,
1123
+ bytes: 0,
1124
+ cacheBytes: 0,
1125
+ backupBytes: 0
945
1126
  };
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]
1127
+ });
1128
+ if (dshHome !== void 0 && dshHome !== "" && items.length > 0) try {
1129
+ const usage = locateSessionUsage(ids, dshHome);
1130
+ for (const item of items) {
1131
+ const entry = usage.get(item.sessionId);
1132
+ if (entry === void 0) continue;
1133
+ item.bytes = entry.bytes;
1134
+ item.cacheBytes = entry.cacheBytes;
1135
+ item.backupBytes = entry.backupBytes;
1136
+ }
1137
+ } catch {}
1138
+ const fileIds = new Set(ids);
1139
+ const pendingRestart = (read.registryIds ?? []).filter((id) => !fileIds.has(id)).length;
1140
+ return {
1141
+ ok: true,
1142
+ items,
1143
+ source: read.source,
1144
+ ...degraded === void 0 ? {} : { degraded },
1145
+ ...pendingRestart === 0 ? {} : { pendingRestart }
1146
+ };
1147
+ }
1148
+ /** 按 DSH_HOME 解析存储文件候选(与 archive-source 的默认候选保持同一形状)。 */
1149
+ function storagePathsFor(dshHome) {
1150
+ return [join(dshHome, "storages", "workspace.json")];
1151
+ }
1152
+ /** 单次清理的最大 id 数(与迁移前一致)。 */
1153
+ const MAX_PRUNE_IDS = 5e3;
1154
+ /**
1155
+ * `session-history-prune`:从官方归档数组中批量移除会话 id。
1156
+ * 行为与迁移前等价:校验入参 → 备份并原子替换存储文件 → 返回 removed/remaining,
1157
+ * 并明确要求重启 DSH(宿主内存中的集合只在启动时重载)。
1158
+ * @param payload - `{ sessionIds: string[] }`。
1159
+ * @param log - 可选日志出口。
1160
+ * @param searchPaths - 可选候选路径覆盖(测试注入用)。
1161
+ */
1162
+ function pruneHistory(payload, log, searchPaths) {
1163
+ const record = payload;
1164
+ if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) return {
1165
+ ok: false,
1166
+ error: "缺少 sessionIds 数组"
1167
+ };
1168
+ const ids = [...new Set(record.sessionIds.filter((id) => typeof id === "string" && id !== ""))];
1169
+ if (ids.length === 0) return {
1170
+ ok: false,
1171
+ error: "sessionIds 无有效值"
1172
+ };
1173
+ if (ids.length > 5e3) return {
1174
+ ok: false,
1175
+ error: `单次最多清理 ${MAX_PRUNE_IDS} 个`
1176
+ };
1177
+ let result;
1178
+ try {
1179
+ result = pruneArchiveFile(ids, log, searchPaths);
1180
+ } catch (err) {
1181
+ return {
1182
+ ok: false,
1183
+ error: String(err instanceof Error ? err.message : err)
952
1184
  };
953
- out.push({
954
- type: "assistant/chunk",
955
- seq: seq0 + k,
956
- time,
957
- data: {
958
- turn,
959
- step,
960
- chunk
1185
+ }
1186
+ const after = readArchiveSet(void 0, searchPaths);
1187
+ if (after.source !== "storage-file") return {
1188
+ ok: false,
1189
+ error: "写入后无法读回存储文件,无法确认清理结果"
1190
+ };
1191
+ const stillThere = new Set(after.ids);
1192
+ const leftover = ids.filter((id) => stillThere.has(id));
1193
+ if (leftover.length > 0) return {
1194
+ ok: false,
1195
+ error: `存储文件在写入后仍包含 ${leftover.length} 个目标 id,清理未生效`
1196
+ };
1197
+ log?.(`archive prune requested ${ids.length}, removed ${result.removed}, remaining ${result.remaining}`);
1198
+ return {
1199
+ ok: true,
1200
+ removed: result.removed,
1201
+ remaining: result.remaining,
1202
+ requiresRestart: true
1203
+ };
1204
+ }
1205
+ //#endregion
1206
+ //#region src/host/health/attribution.ts
1207
+ /**
1208
+ * 归属:把投影 key 指回产出它的插件包。
1209
+ *
1210
+ * 做法是静态扫描 profile 的 `node_modules`:`<pkg>/lib/index.js` 里
1211
+ * `sessionProjections.register({ key: 'xxx' })`(或其定义工厂)会命中
1212
+ * `key:\s*['"](\w+)['"]` 正则;据此建立 `投影 key → 包名` 索引。
1213
+ * `@deepseek-ai/*` 视为内置(core);扫不到时如实返回 `unknown`,禁止臆测。
1214
+ */
1215
+ /** 默认扫描根:profile 的 node_modules。 */
1216
+ function defaultProfileNodeModules(dshHome) {
1217
+ return join(dshHome ?? join(homedir(), ".dsh"), "profiles", "web", "node_modules");
1218
+ }
1219
+ /** 收集候选包目录(含 @scope/pkg 一层)。 */
1220
+ function candidatePackages(root) {
1221
+ const out = [];
1222
+ if (!existsSync(root)) return out;
1223
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
1224
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
1225
+ if (entry.name.startsWith(".")) continue;
1226
+ const dir = join(root, entry.name);
1227
+ if (entry.name.startsWith("@")) {
1228
+ let scoped = [];
1229
+ try {
1230
+ scoped = readdirSync(dir);
1231
+ } catch {
1232
+ continue;
961
1233
  }
1234
+ for (const child of scoped) out.push({
1235
+ name: `${entry.name}/${child}`,
1236
+ dir: join(dir, child)
1237
+ });
1238
+ continue;
1239
+ }
1240
+ out.push({
1241
+ name: entry.name,
1242
+ dir
962
1243
  });
963
1244
  }
964
1245
  return out;
965
1246
  }
1247
+ /** 单个入口文件的最大扫描字节数(超大 bundle 直接跳过,避免无谓内存与耗时)。 */
1248
+ const MAX_SCAN_BYTES = 12 << 20;
1249
+ /** 扫描若干候选入口文件里出现的投影 key。 */
1250
+ function keysInFile(file) {
1251
+ if (!existsSync(file)) return [];
1252
+ let text;
1253
+ try {
1254
+ if (statSync(file).size > MAX_SCAN_BYTES) return [];
1255
+ text = readFileSync(file, "utf8");
1256
+ } catch {
1257
+ return [];
1258
+ }
1259
+ const keys = /* @__PURE__ */ new Set();
1260
+ const pattern = /key:\s*['"]([A-Za-z_$][\w$]*)['"]/g;
1261
+ let match;
1262
+ while ((match = pattern.exec(text)) !== null) {
1263
+ const key = match[1];
1264
+ if (key !== void 0 && key !== "") keys.add(key);
1265
+ }
1266
+ return [...keys];
1267
+ }
966
1268
  /**
967
- * 读取一个会话日志文件(zip 帧扫描 + 解包 + seq 连续性)。
968
- * @param file - 当前代日志的绝对路径(由代次解析得出,不假定具体文件名)。
969
- * @param bytes - 文件内容(调用方读取,便于测试注入)。
970
- * @param decoders - 可选官方解码器(缺省用本地等价实现)。
971
- * @returns 事件、统计与问题清单。
1269
+ * 建立投影 key → 包 的索引。
1270
+ * @param nodeModulesRoot - profile 的 node_modules 根。
1271
+ * @returns 归属记录(同一 key 多包命中时保留首个并排序稳定)。
972
1272
  */
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)}`);
981
- }
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;
1273
+ function buildProjectionOwnerIndex(nodeModulesRoot) {
1274
+ const records = [];
1275
+ const seen = /* @__PURE__ */ new Set();
1276
+ for (const pack of candidatePackages(nodeModulesRoot)) {
1277
+ const lib = join(pack.dir, "lib");
1278
+ let entries = [];
998
1279
  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;
1280
+ if (!statSync(lib).isDirectory()) continue;
1281
+ entries = readdirSync(lib).filter((name) => name.endsWith(".js"));
1282
+ } catch {
1283
+ continue;
1010
1284
  }
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})`
1285
+ for (const name of entries) {
1286
+ const file = join(lib, name);
1287
+ for (const key of keysInFile(file)) {
1288
+ if (seen.has(key)) continue;
1289
+ seen.add(key);
1290
+ records.push({
1291
+ key,
1292
+ package: pack.name,
1293
+ kind: pack.name.startsWith("@deepseek-ai/") ? "core" : "plugin",
1294
+ entry: `${pack.name}/lib/${name}`
1018
1295
  });
1019
- events.length = rowStart;
1020
- gapped = true;
1021
- break;
1022
1296
  }
1023
- events.push(event);
1024
1297
  }
1025
- if (gapped) break;
1026
1298
  }
1027
- const openStep = findOpenStep(events);
1028
- let header;
1029
- const headerLine = lines[0];
1030
- if (headerLine !== void 0) try {
1031
- const parsed = JSON.parse(headerLine);
1032
- if (typeof parsed === "object" && parsed !== null) header = parsed;
1033
- } catch {}
1034
- return {
1035
- events,
1036
- frames: frames.length,
1037
- ...tornStart === void 0 ? {} : { tornStart },
1038
- recoveredFromTorn,
1039
- issues,
1040
- decoder: decoders === void 0 ? "local" : "official",
1041
- ...openStep === void 0 ? {} : { openStep },
1042
- ...header === void 0 ? {} : { header }
1043
- };
1299
+ return records.sort((left, right) => left.key.localeCompare(right.key));
1044
1300
  }
1045
- /** 从文件读取并解码。 */
1046
- function decodeSessionLogFile(file, decoders) {
1047
- return decodeSessionLogBytes(file, readFileSync(file), decoders);
1301
+ /** 索引查询器。 */
1302
+ function createAttributor(index) {
1303
+ const byKey = new Map(index.map((record) => [record.key, record]));
1304
+ return (projection) => {
1305
+ const owner = byKey.get(projection);
1306
+ if (owner === void 0) return {
1307
+ projection,
1308
+ package: "unknown"
1309
+ };
1310
+ return {
1311
+ projection,
1312
+ package: owner.package
1313
+ };
1314
+ };
1048
1315
  }
1049
- /** 尾部未收尾的 open step(有 step/start 无对应 step/end)。 */
1050
- function findOpenStep(events) {
1051
- let open;
1052
- for (const event of events) {
1053
- const data = event.data;
1054
- if (event.type === "step/start" && typeof data?.turn === "number" && typeof data?.step === "number") {
1055
- open = {
1056
- turn: data.turn,
1057
- step: data.step
1058
- };
1059
- continue;
1316
+ //#endregion
1317
+ //#region src/host/health/cache.ts
1318
+ /** 进程内体检缓存:分批扫描过程中累积,走完一遍语料才成型。 */
1319
+ var HealthCache = class {
1320
+ ready;
1321
+ pending;
1322
+ /** 读当前成型的缓存(未成型则 undefined)。 */
1323
+ read() {
1324
+ return this.ready;
1325
+ }
1326
+ /** 开始一轮分批扫描(客户端以 `offset: 0` 发起时调用)。 */
1327
+ begin(total) {
1328
+ this.pending = {
1329
+ total: Math.max(0, Math.floor(total)),
1330
+ findings: [],
1331
+ visited: 0
1332
+ };
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()
1354
+ };
1355
+ this.pending = void 0;
1356
+ return true;
1357
+ }
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;
1060
1374
  }
1061
- if (event.type === "step/end") open = void 0;
1375
+ if (index >= 0) ready.findings[index] = report;
1376
+ else ready.findings.push(report);
1377
+ return true;
1062
1378
  }
1063
- return open;
1064
- }
1379
+ /** 丢弃缓存与未完成的累积。 */
1380
+ clear() {
1381
+ this.ready = void 0;
1382
+ this.pending = void 0;
1383
+ }
1384
+ };
1065
1385
  //#endregion
1066
1386
  //#region src/host/health/generation.ts
1067
1387
  /**
@@ -1700,6 +2020,7 @@ function buildSessionReport(context) {
1700
2020
  gates.push(gateProjectionCache(readProjectionCache(context.sessionId, tail.lastSeq, context.dshHome)));
1701
2021
  gates.push(gateLosslessJson(context.projectionState, context.attribute));
1702
2022
  gates.push(gateColdRead(tail));
2023
+ gates.push(gateSourceKind(readSourceKindFacts(log)));
1703
2024
  }
1704
2025
  const level = gates.some((gate) => gate.level === "fail") ? "fail" : gates.some((gate) => gate.level === "warn") ? "warn" : "ok";
1705
2026
  return {
@@ -2017,28 +2338,17 @@ function scanSessions(options) {
2017
2338
  //#region src/index.ts
2018
2339
  /** 本插件声明的宿主服务(与 toggle 相同的注入面)。 */
2019
2340
  const inject = ["webServer", "webRuntime"];
2020
- /** 运行时配置 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 字段即设置表单。 */
2021
2347
  const Config = z.object({
2022
- enabled: z.boolean().default(true),
2023
- historyFiles: z.boolean().default(true),
2024
- 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()
2025
2351
  });
2026
- /**
2027
- * 官方 `installSettingsSection` 的内联等价:通过 settings 服务注册命名空间、
2028
- * 以组合入口作为 `base` 层、并保持运行时来源实时(与 toggle / thinking-levels 同范式)。
2029
- */
2030
- function installSettingsSection(ctx, ns, schema, entry, hooks) {
2031
- ctx.inject(["settings"], (sctx) => {
2032
- const scope = sctx.settings.register(ns, schema, { base: entry });
2033
- hooks.setSource(() => scope.get());
2034
- hooks.onChange();
2035
- sctx.effect(() => () => {
2036
- hooks.setSource(() => entry);
2037
- hooks.onChange();
2038
- });
2039
- scope.watch(() => hooks.onChange());
2040
- });
2041
- }
2042
2352
  /** 单次请求体的上限(防御无界读取)。 */
2043
2353
  const MAX_BODY_BYTES = 16 << 20;
2044
2354
  /** 归一化 Host authority,无法解析时为 undefined。 */
@@ -2133,7 +2443,8 @@ const HEALTH_METHODS = [
2133
2443
  "session-health-status",
2134
2444
  "session-health-scan",
2135
2445
  "session-health-session",
2136
- "session-health-repair"
2446
+ "session-health-repair",
2447
+ "session-health-source-migrate"
2137
2448
  ];
2138
2449
  /** 依据开关判定某方法是否启用。 */
2139
2450
  function methodEnabled(method, config) {
@@ -2244,6 +2555,31 @@ async function handleMethod(method, payload, runtime) {
2244
2555
  prescriptions: prescribe(report, runtime.dshHome)
2245
2556
  };
2246
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
+ }
2247
2583
  const before = reportFor();
2248
2584
  if (before.level === "ok") {
2249
2585
  runtime.cache?.patch(before);
@@ -2291,19 +2627,15 @@ async function handleMethod(method, payload, runtime) {
2291
2627
  };
2292
2628
  }
2293
2629
  /**
2294
- * 插件主体:注册设置命名空间、装配运行时、挂载 fenced 路由。
2295
- * @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)。
2296
2633
  */
2297
- function apply(ctx) {
2298
- let current = () => DEFAULT_CONFIG;
2299
- installSettingsSection(ctx, STEWARD_SETTINGS_NAMESPACE, Config, DEFAULT_CONFIG, {
2300
- setSource: (source) => {
2301
- current = () => ({
2302
- ...DEFAULT_CONFIG,
2303
- ...source()
2304
- });
2305
- },
2306
- 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
2307
2639
  });
2308
2640
  const log = (message) => {
2309
2641
  try {
@@ -2395,4 +2727,4 @@ function apply(ctx) {
2395
2727
  }), "dsh-manage-session: /session-steward/api route");
2396
2728
  }
2397
2729
  //#endregion
2398
- export { DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, apply, assessRepair, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, dirSize, discoverSessions, editWorkspaceDocument, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, generationLogFilename, handleMethod, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, parseGenerationLogFilename, prescribe, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readTailFacts, scanSessions, scanZstdFrames, sessionPriority, 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 };