dsh-manage-session 0.5.4 → 0.5.7

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
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import zlib from "node:zlib";
@@ -11,7 +11,8 @@ const DEFAULT_CONFIG = {
11
11
  enabled: true,
12
12
  historyFiles: true,
13
13
  healthCheck: true,
14
- search: true
14
+ search: true,
15
+ directDelete: false
15
16
  };
16
17
  /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
17
18
  const STEWARD_SETTINGS_NAMESPACE = "session-steward";
@@ -29,7 +30,9 @@ const SWITCH_DEFAULT_CONFIG = {
29
30
  autoSync: true,
30
31
  syncIntervalMs: 3e4,
31
32
  archiveKeep: 2,
32
- indexDir: ""
33
+ indexDir: "",
34
+ corpusSource: "native",
35
+ drsBaseUrl: ""
33
36
  };
34
37
  //#endregion
35
38
  //#region src/host/history/archive-source.ts
@@ -1383,6 +1386,59 @@ function setFavoriteState(sessionId, favorite, searchPaths) {
1383
1386
  }
1384
1387
  }
1385
1388
  //#endregion
1389
+ //#region src/host/history/tombstones.ts
1390
+ /**
1391
+ * 退场墓碑台账(2026-10-20 用户定标):任何 purge 成功路径都往
1392
+ * `~/.dsh-switch-search/retired-tombstones.jsonl` 追加一行
1393
+ * `{sessionId, retiredAt, source}`,作为离线清残脚本
1394
+ * (scripts/cleanup-retired.mjs)的驱动清单。
1395
+ *
1396
+ * 为什么需要:宿主 `registry.deleteSession` 与 `purgeHistory` 各自覆盖 4 处
1397
+ * 实体(转录目录/projcache/workspace.json 两处 id),但任一路径中途失败、
1398
+ * 索引联动(onPurged)进程崩溃、或 collector 同步轮未跑,都会留下残行/残
1399
+ * 目录。墓碑 = 「这个 id 已被判退场」的持久凭证,DSH 关闭后脚本据此兜底
1400
+ * 清扫,不靠现场推断。
1401
+ *
1402
+ * 纪律:
1403
+ * - **append-only、best-effort**:写台账失败绝不影响删除结果返回(try/catch
1404
+ * 吞错并 log),台账是副账不是主账;
1405
+ * - 每行一个 JSON(JSONL),读侧跳过畸形行——半行(进程崩在 append 中间)
1406
+ * 不致命;
1407
+ * - 幂等消费:脚本按 id 集合去重,重复墓碑无害;台账只增不删(体积可忽略,
1408
+ * 每行 ~120B)。
1409
+ */
1410
+ /** 台账文件名(固定;脚本以默认路径发现)。 */
1411
+ const TOMBSTONE_FILE = "retired-tombstones.jsonl";
1412
+ /** 默认台账目录 = 索引缺省落点(与 rebuild.ts 的 ~/.dsh-switch-search 同根)。 */
1413
+ function defaultTombstoneDir() {
1414
+ return join(homedir(), ".dsh-switch-search");
1415
+ }
1416
+ /**
1417
+ * 追加一批墓碑(一次 purge 成功的 id 集)。best-effort:目录不可建/盘不可写
1418
+ * 只经 `log` 报一行,绝不抛——删除动作的成败判定先于本调用完成。
1419
+ * @returns 实际写出的行数(0 = 静默失败或空入参)。
1420
+ */
1421
+ function appendRetiredTombstones(sessionIds, opts) {
1422
+ const ids = [...new Set(sessionIds.filter((id) => typeof id === "string" && id !== ""))];
1423
+ if (ids.length === 0) return 0;
1424
+ const dir = opts.dir ?? defaultTombstoneDir();
1425
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
1426
+ try {
1427
+ mkdirSync(dir, { recursive: true });
1428
+ const stamp = now().toISOString();
1429
+ const lines = ids.map((id) => JSON.stringify({
1430
+ sessionId: id,
1431
+ retiredAt: stamp,
1432
+ source: opts.source
1433
+ }) + "\n");
1434
+ appendFileSync(join(dir, TOMBSTONE_FILE), lines.join(""), "utf8");
1435
+ return lines.length;
1436
+ } catch (err) {
1437
+ opts.log?.(`tombstone append failed (deletion itself succeeded): ${String(err instanceof Error ? err.message : err)}`);
1438
+ return 0;
1439
+ }
1440
+ }
1441
+ //#endregion
1386
1442
  //#region src/host/settings-bridge.ts
1387
1443
  /**
1388
1444
  * Legacy 设置桥(宿主半身,≤0.1.5 专用):老宿主的服务端 settings 服务保留
@@ -1407,7 +1463,8 @@ const StewardNsSchema = z.object({
1407
1463
  enabled: z.boolean().default(true),
1408
1464
  historyFiles: z.boolean().default(true),
1409
1465
  healthCheck: z.boolean().default(true),
1410
- search: z.boolean().default(true)
1466
+ search: z.boolean().default(true),
1467
+ directDelete: z.boolean().default(false)
1411
1468
  });
1412
1469
  /** 搜索命名空间 schema(同上;存储键与 config.ts 承诺一致)。 */
1413
1470
  const SwitchNsSchema = z.object({
@@ -1491,7 +1548,8 @@ const FIELD_VALIDATORS = {
1491
1548
  enabled: asBoolean,
1492
1549
  historyFiles: asBoolean,
1493
1550
  healthCheck: asBoolean,
1494
- search: asBoolean
1551
+ search: asBoolean,
1552
+ directDelete: asBoolean
1495
1553
  },
1496
1554
  [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
1497
1555
  enabled: asBoolean,
@@ -1558,6 +1616,17 @@ function describeNamespaces(state) {
1558
1616
  [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {}
1559
1617
  };
1560
1618
  }
1619
+ function bridgeOverlay(state) {
1620
+ const { bridge } = state;
1621
+ if (bridge === void 0) return {
1622
+ steward: {},
1623
+ search: {}
1624
+ };
1625
+ return {
1626
+ steward: bridge.get("session-steward") ?? {},
1627
+ search: bridge.get("switch-search") ?? {}
1628
+ };
1629
+ }
1561
1630
  /** describe 响应。 */
1562
1631
  function settingsDescribe(state) {
1563
1632
  return {
@@ -3176,7 +3245,7 @@ var SwitchIndexEngine = class {
3176
3245
  upsertSession(input) {
3177
3246
  const db = this.requireDb();
3178
3247
  const documents = buildIndexDocuments(input.sessionId, input.events);
3179
- this.withWriteTx(() => {
3248
+ return this.withWriteTx(() => {
3180
3249
  this.deleteSessionFts(db, input.sessionId);
3181
3250
  db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
3182
3251
  const insertDoc = db.prepare(`
@@ -3199,9 +3268,37 @@ var SwitchIndexEngine = class {
3199
3268
  updated_at = excluded.updated_at,
3200
3269
  indexed_at = excluded.indexed_at
3201
3270
  `).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
3271
+ return documents.length;
3202
3272
  });
3203
3273
  }
3204
3274
  /**
3275
+ * 该会话是否已有正文(issue #1:hollow 行判据)。
3276
+ * LIMIT 1 探测命中 docs_session 索引,每轮 poll 对每个未变更会话的开销可忽略。
3277
+ */
3278
+ hasSessionDocs(sessionId) {
3279
+ return this.requireDb().prepare("SELECT 1 FROM docs WHERE session_id = ? LIMIT 1").get(sessionId) !== void 0;
3280
+ }
3281
+ /**
3282
+ * 「有表头无正文」的会话 id(升序)—— hollow 体检面。补录后仍 hollow 的行
3283
+ * 包含无可索引文本的真空会话(合法终态,见 sync 的 confirmedEmpty 缓存)。
3284
+ */
3285
+ listHollowSessions() {
3286
+ return this.requireDb().prepare(`
3287
+ SELECT s.session_id FROM sessions s
3288
+ WHERE NOT EXISTS (SELECT 1 FROM docs d WHERE d.session_id = s.session_id)
3289
+ ORDER BY s.session_id
3290
+ `).all().map((row) => row.session_id);
3291
+ }
3292
+ /**
3293
+ * `PRAGMA integrity_check` 首行:'ok' 或损坏报告串。swap 后完整性闸门用。
3294
+ */
3295
+ integrityCheck() {
3296
+ const row = this.requireDb().prepare("PRAGMA integrity_check").get();
3297
+ if (row === void 0) return "no result";
3298
+ const first = Object.values(row)[0];
3299
+ return String(first ?? "no result");
3300
+ }
3301
+ /**
3205
3302
  * Write an archived session's header row without any document content.
3206
3303
  *
3207
3304
  * @deprecated 旧"归档即软删"语义的遗物,合并包 R1 已改为归档保留 docs;仅剩
@@ -3289,6 +3386,65 @@ var SwitchIndexEngine = class {
3289
3386
  `).run(input.sessionId, input.version, input.title ?? "", Date.now(), input.archived === true ? 1 : 0);
3290
3387
  });
3291
3388
  }
3389
+ /**
3390
+ * Insert or replace one session from corpus-source documents (T7.1 DRS
3391
+ * 语料源):docs 已由语料源抽好(surface 字符串),切分仍由本引擎重算。
3392
+ * 与 importSessionDocs 的三点差异,均为检索排序/水印所需:cwd 与
3393
+ * updated_at 直写(日期过滤/时间排序键);标题空串保留旧行(upsertSession
3394
+ * 同款 CASE——DRS 对老会话可能给不出标题,不能抹掉既有精修);归档旗标
3395
+ * 冲突时不动(翻转仍归 setArchived)。
3396
+ * @returns 落库 docs 数(0 = 该会话无可索引文本,合法终态)。
3397
+ */
3398
+ upsertSessionDocs(input) {
3399
+ const db = this.requireDb();
3400
+ return this.withWriteTx(() => {
3401
+ this.deleteSessionFts(db, input.sessionId);
3402
+ db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
3403
+ const insertDoc = db.prepare(`
3404
+ INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
3405
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3406
+ `);
3407
+ const insertFts = db.prepare("INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)");
3408
+ for (const doc of input.docs) {
3409
+ const indexText = segmentForIndex(doc.text);
3410
+ const result = insertDoc.run(input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText);
3411
+ insertFts.run(Number(result.lastInsertRowid), indexText);
3412
+ }
3413
+ db.prepare(`
3414
+ INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
3415
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3416
+ ON CONFLICT(session_id) DO UPDATE SET
3417
+ version = excluded.version,
3418
+ title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
3419
+ cwd = excluded.cwd,
3420
+ updated_at = excluded.updated_at,
3421
+ indexed_at = excluded.indexed_at
3422
+ `).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
3423
+ return input.docs.length;
3424
+ });
3425
+ }
3426
+ /**
3427
+ * 会话头直写(DRS 直连时代的同步轮专用,T7.3):只维护 sessions 元数据
3428
+ * (水位/标题/目录/末时间/归档),**不碰 docs/docs_fts**——内容检索
3429
+ * 已直连 dsh-reference-session-collector(op=search&group=session),本地正文索引
3430
+ * 退役,存量 docs 行自然老化不再新增。空标题保留旧行(同 upsertSessionDocs)。
3431
+ */
3432
+ upsertSessionHead(input) {
3433
+ const db = this.requireDb();
3434
+ this.withWriteTx(() => {
3435
+ db.prepare(`
3436
+ INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
3437
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3438
+ ON CONFLICT(session_id) DO UPDATE SET
3439
+ version = excluded.version,
3440
+ title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
3441
+ cwd = excluded.cwd,
3442
+ updated_at = excluded.updated_at,
3443
+ indexed_at = excluded.indexed_at,
3444
+ archived = excluded.archived
3445
+ `).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
3446
+ });
3447
+ }
3292
3448
  /** Remove one session and its documents entirely. */
3293
3449
  removeSession(sessionId) {
3294
3450
  const db = this.requireDb();
@@ -3435,34 +3591,46 @@ const TITLE_CHUNK = 50;
3435
3591
  * One watermark syncer bound to one open engine. `poll()` is re-entrant-safe:
3436
3592
  * overlapping calls collapse into the running pass.
3437
3593
  */
3438
- var SwitchWatermarkSync = class {
3594
+ var SwitchWatermarkSync = class SwitchWatermarkSync {
3439
3595
  engine;
3440
3596
  sessionQuery;
3441
3597
  readArchiveSource;
3442
3598
  log;
3443
3599
  readSessionFromFile;
3444
3600
  readProjectionTitle;
3601
+ drsSource;
3445
3602
  running;
3603
+ /**
3604
+ * 本进程已确认「重读过且产出仍为 0 docs」的会话(键 id@version)。
3605
+ * 无可索引文本是合法终态——没有这份缓存,hollow 补录判据会把真空会话
3606
+ * 每轮完整重读一次(热循环)。宿主重启后缓存归零,最多各重读一次。
3607
+ */
3608
+ confirmedEmpty = /* @__PURE__ */ new Set();
3609
+ static emptyKey(sessionId, version) {
3610
+ return `${sessionId}@${String(version)}`;
3611
+ }
3446
3612
  state = {
3447
3613
  state: "idle",
3448
3614
  lastSyncAt: 0,
3449
3615
  indexed: 0,
3450
3616
  total: 0,
3451
3617
  updated: 0,
3452
- failures: []
3618
+ failures: [],
3619
+ hollow: 0
3453
3620
  };
3454
3621
  /**
3455
3622
  * @param readSessionFromFile - 可选的**文件级兜底读取器**(管家 health 侧的
3456
3623
  * 多帧 zstd 读取器):sessionQuery.readSession 对某些会话(典型:归档会话,
3457
3624
  * 或服务面退化)失败时改读转录文件。返回 undefined 表示文件也不可用。
3458
3625
  */
3459
- constructor(engine, sessionQuery, readArchiveSource, log, readSessionFromFile, readProjectionTitle) {
3626
+ constructor(engine, sessionQuery, readArchiveSource, log, readSessionFromFile, readProjectionTitle, drsSource) {
3460
3627
  this.engine = engine;
3461
3628
  this.sessionQuery = sessionQuery;
3462
3629
  this.readArchiveSource = readArchiveSource;
3463
3630
  this.log = log;
3464
3631
  this.readSessionFromFile = readSessionFromFile;
3465
3632
  this.readProjectionTitle = readProjectionTitle;
3633
+ this.drsSource = drsSource;
3466
3634
  }
3467
3635
  /**
3468
3636
  * 读取一个会话的日志:服务面优先,失败落文件兜底。两条路都失败时抛最后
@@ -3516,6 +3684,11 @@ var SwitchWatermarkSync = class {
3516
3684
  this.state.state = "syncing";
3517
3685
  const passStart = Date.now();
3518
3686
  try {
3687
+ if (this.drsSource !== void 0) try {
3688
+ return await this.runDrsPass(passStart);
3689
+ } catch (drsError) {
3690
+ this.log?.(`DRS corpus pass failed, falling back to native sessionQuery: ${String(drsError instanceof Error ? drsError.message : drsError)}`);
3691
+ }
3519
3692
  const records = await this.sessionQuery.listSessions();
3520
3693
  this.state.total = records.length;
3521
3694
  const archiveSource = this.readArchiveSource?.();
@@ -3527,11 +3700,12 @@ var SwitchWatermarkSync = class {
3527
3700
  for (const record of records) {
3528
3701
  const header = record.header;
3529
3702
  const existing = this.engine.getSession(header.id);
3530
- if (existing !== void 0 && existing.version === header.version) continue;
3703
+ const emptyKey = SwitchWatermarkSync.emptyKey(header.id, header.version);
3704
+ if (existing !== void 0 && existing.version === header.version && (this.confirmedEmpty.has(emptyKey) || this.engine.hasSessionDocs(header.id))) continue;
3531
3705
  changedIds.push(header.id);
3532
3706
  try {
3533
3707
  const log = await this.readSessionLog(header);
3534
- this.engine.upsertSession({
3708
+ if (this.engine.upsertSession({
3535
3709
  sessionId: header.id,
3536
3710
  version: header.version,
3537
3711
  cwd: log.session.cwd ?? header.cwd ?? "",
@@ -3539,7 +3713,8 @@ var SwitchWatermarkSync = class {
3539
3713
  title: extractTitleFromEvents(log.events),
3540
3714
  events: log.events,
3541
3715
  archived: archivedSet.has(header.id)
3542
- });
3716
+ }) === 0) this.confirmedEmpty.add(emptyKey);
3717
+ else this.confirmedEmpty.delete(emptyKey);
3543
3718
  updated += 1;
3544
3719
  } catch (err) {
3545
3720
  failures.push({
@@ -3549,13 +3724,14 @@ var SwitchWatermarkSync = class {
3549
3724
  }
3550
3725
  }
3551
3726
  for (const archivedId of archivedSet) {
3552
- if (this.engine.getSession(archivedId) !== void 0) continue;
3727
+ const existingArchived = this.engine.getSession(archivedId);
3728
+ if (existingArchived !== void 0 && (this.confirmedEmpty.has(SwitchWatermarkSync.emptyKey(archivedId, existingArchived.version)) || this.engine.hasSessionDocs(archivedId))) continue;
3553
3729
  try {
3554
3730
  const log = await this.readSessionLog({
3555
3731
  id: archivedId,
3556
3732
  version: -1
3557
3733
  });
3558
- this.engine.upsertSession({
3734
+ const produced = this.engine.upsertSession({
3559
3735
  sessionId: archivedId,
3560
3736
  version: log.session.version,
3561
3737
  cwd: log.session.cwd ?? "",
@@ -3564,6 +3740,9 @@ var SwitchWatermarkSync = class {
3564
3740
  events: log.events,
3565
3741
  archived: true
3566
3742
  });
3743
+ const archivedKey = SwitchWatermarkSync.emptyKey(archivedId, log.session.version);
3744
+ if (produced === 0) this.confirmedEmpty.add(archivedKey);
3745
+ else this.confirmedEmpty.delete(archivedKey);
3567
3746
  updated += 1;
3568
3747
  } catch (err) {
3569
3748
  failures.push({
@@ -3596,10 +3775,11 @@ var SwitchWatermarkSync = class {
3596
3775
  this.state.updated = updated;
3597
3776
  this.state.failures = failures;
3598
3777
  this.state.indexed = this.engine.countSessions();
3778
+ this.state.hollow = this.engine.listHollowSessions().length;
3599
3779
  this.state.lastSyncAt = Date.now();
3600
3780
  this.state.state = "idle";
3601
3781
  this.state.error = void 0;
3602
- this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
3782
+ this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} hollow=${this.state.hollow} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
3603
3783
  } catch (err) {
3604
3784
  this.state.state = "error";
3605
3785
  this.state.error = String(err instanceof Error ? err.message : err);
@@ -3608,6 +3788,55 @@ var SwitchWatermarkSync = class {
3608
3788
  return this.snapshot();
3609
3789
  }
3610
3790
  /**
3791
+ * DRS 语料轮(T7.1):语料头从 DRS `op=sessions` 分页拉取,水位
3792
+ * diff(version=artifact_mtime_ms)后按会话 `op=session&fields=search`
3793
+ * 取检索臂 docs 直插引擎(upsertSessionDocs)。归档集合改用 DRS 行的
3794
+ * archived 旗标(DRS sync 本就只读跟随 workspace.json,同源);标题直接
3795
+ * 采 DRS 行(投影侧同一 extractTitleFromEvents 视界),不再走快照精修。
3796
+ * 每会话失败隔离语义与原生轮一致;无可索引文本仍进 confirmedEmpty 缓存。
3797
+ */
3798
+ async runDrsPass(passStart) {
3799
+ const source = this.drsSource;
3800
+ if (source === void 0) throw new Error("DRS corpus source unavailable");
3801
+ const heads = await source.listSessions();
3802
+ this.state.total = heads.length;
3803
+ const archivedSet = new Set(heads.filter((head) => head.archived).map((head) => head.sessionId));
3804
+ this.engine.setArchived(archivedSet);
3805
+ const failures = [];
3806
+ let updated = 0;
3807
+ for (const head of heads) {
3808
+ const existing = this.engine.getSession(head.sessionId);
3809
+ if (existing !== void 0 && existing.version === head.version) continue;
3810
+ try {
3811
+ this.engine.upsertSessionHead({
3812
+ sessionId: head.sessionId,
3813
+ version: head.version,
3814
+ title: head.title,
3815
+ cwd: head.cwd,
3816
+ updatedAt: head.updatedAt,
3817
+ archived: head.archived
3818
+ });
3819
+ updated += 1;
3820
+ } catch (err) {
3821
+ failures.push({
3822
+ sessionId: head.sessionId,
3823
+ error: `drs: ${String(err instanceof Error ? err.message : err)}`
3824
+ });
3825
+ }
3826
+ }
3827
+ const corpusIds = new Set(heads.map((head) => head.sessionId));
3828
+ for (const indexed of this.engine.listIndexedSessions()) if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) this.engine.removeSession(indexed.sessionId);
3829
+ this.state.updated = updated;
3830
+ this.state.failures = failures;
3831
+ this.state.indexed = this.engine.countSessions();
3832
+ this.state.hollow = this.engine.listHollowSessions().length;
3833
+ this.state.lastSyncAt = Date.now();
3834
+ this.state.state = "idle";
3835
+ this.state.error = void 0;
3836
+ this.log?.(`sync pass(DRS): scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} hollow=${this.state.hollow} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
3837
+ return this.snapshot();
3838
+ }
3839
+ /**
3611
3840
  * Fold latest titles for changed sessions into the index header rows.
3612
3841
  *
3613
3842
  * beta.4 教训:整批一次调用,任何一个坏 id(典型:归档 id)让整个 promise
@@ -3769,6 +3998,145 @@ function detectSteward() {
3769
3998
  return probePeer(STEWARD_PACKAGE);
3770
3999
  }
3771
4000
  //#endregion
4001
+ //#region src/host/index/drs-source.ts
4002
+ /**
4003
+ * DRS 语料源:dsh-reference-session-collector 投影库的 HTTP 只读客户端(T7.1)。
4004
+ *
4005
+ * 语料契约 = DRS 单条 fenced 路由 `GET /api/dsh-reference-session-collector`:
4006
+ * - `op=sessions&offset=&limit=` → `{ok, total, rows:[sessions 行]}`(分页);
4007
+ * - `op=session&session=<id>&fields=search` → `{ok, session, rows:[检索臂]}`。
4008
+ *
4009
+ * 字段对照(T7.0,DRS wire snake → steward 引擎列):
4010
+ * sessions.version ← artifact_mtime_ms(水位代理:日志重写必 bump mtime);
4011
+ * sessions.updated_at ← last_time_ms;title/cwd/archived ← 同名列;
4012
+ * docs.type ← kind;docs.surface ← on_surface(1='current'/0='shadowed');
4013
+ * docs.time ← time;docs.text ← search_text;index_text 由本引擎
4014
+ * segmentForIndex 重算(自有切分面不动)。
4015
+ *
4016
+ * 自调用信任面:DRS 的 Host 门是精确匹配(trustedHosts 全小写比对),本客户
4017
+ * 端可带 hostHeader 显式回显首个 trustedHosts 条目,规避「条目省略端口而
4018
+ * URL 带端口」的精确匹配失配;loopback + 无 trustedHosts 的老宿主天然放行。
4019
+ */
4020
+ /** DRS API 路径(与 dsh-reference-session-collector 的 DRS_API_PATH 字面一致;不跨包 import)。 */
4021
+ const DRS_API_PATH = "/api/dsh-reference-session-collector";
4022
+ /**
4023
+ * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)>
4024
+ * `http://127.0.0.1:8080`(宿主默认口;trustedHosts 缺席的老线 DRS 门本就放行)。
4025
+ */
4026
+ function resolveDrsBaseUrl(explicit, trustedHosts) {
4027
+ const trimmed = explicit?.trim() ?? "";
4028
+ const pick = trimmed !== "" ? trimmed : trustedHosts?.find((entry) => entry.trim() !== "")?.trim();
4029
+ if (pick === void 0 || pick === "") return "http://127.0.0.1:8080";
4030
+ return /^https?:\/\//u.test(pick) ? pick : `http://${pick}`;
4031
+ }
4032
+ var DrsCorpusSource = class DrsCorpusSource {
4033
+ options;
4034
+ fetchImpl;
4035
+ constructor(options) {
4036
+ this.options = options;
4037
+ this.fetchImpl = options.fetchImpl ?? fetch;
4038
+ }
4039
+ /** 一个 GET 的 JSON 包络(ok!==true 或非 2xx 一律抛,由调用方回退 native)。 */
4040
+ async getJson(query, atHost) {
4041
+ const url = `${(atHost !== void 0 && atHost !== "" ? `http://${atHost}` : this.options.baseUrl).replace(/\/+$/u, "")}${DRS_API_PATH}${query}`;
4042
+ let response;
4043
+ try {
4044
+ response = await this.fetchImpl(url, { headers: {
4045
+ accept: "application/json",
4046
+ ...this.options.hostHeader === void 0 ? {} : { host: this.options.hostHeader }
4047
+ } });
4048
+ } catch (err) {
4049
+ throw new Error(`DRS ${query}: ${String(err instanceof Error ? err.message : err)}`);
4050
+ }
4051
+ if (!response.ok) throw new Error(`DRS ${query} → HTTP ${String(response.status)}`);
4052
+ const body = await response.json();
4053
+ if (typeof body !== "object" || body === null) throw new Error(`DRS ${query}: 响应非 JSON 对象`);
4054
+ const record = body;
4055
+ if (record["ok"] !== true) throw new Error(`DRS ${query}: ok=false(${String(record["error"] ?? "unknown")})`);
4056
+ return record;
4057
+ }
4058
+ static headOf(row, fallbackId) {
4059
+ return {
4060
+ sessionId: String(row["session_id"] ?? fallbackId),
4061
+ version: Number(row["artifact_mtime_ms"] ?? 0),
4062
+ title: String(row["title"] ?? ""),
4063
+ cwd: String(row["cwd"] ?? ""),
4064
+ updatedAt: Number(row["last_time_ms"] ?? 0),
4065
+ archived: Number(row["archived"] ?? 0) === 1
4066
+ };
4067
+ }
4068
+ /** 全量语料头(内部翻页;rows 空或凑满 total 即止)。 */
4069
+ async listSessions() {
4070
+ const heads = [];
4071
+ let offset = 0;
4072
+ for (;;) {
4073
+ const page = await this.getJson(`?op=sessions&offset=${String(offset)}&limit=${String(500)}`);
4074
+ const rows = Array.isArray(page["rows"]) ? page["rows"] : [];
4075
+ for (const row of rows) {
4076
+ const head = DrsCorpusSource.headOf(row, "");
4077
+ if (head.sessionId !== "") heads.push(head);
4078
+ }
4079
+ offset += rows.length;
4080
+ const total = Number(page["total"] ?? heads.length);
4081
+ if (rows.length === 0 || offset >= total) break;
4082
+ }
4083
+ return heads;
4084
+ }
4085
+ /** 一个会话的检索臂 docs;DRS 无此会话(session:null)返回 undefined。 */
4086
+ async readSession(sessionId) {
4087
+ const body = await this.getJson(`?op=session&session=${encodeURIComponent(sessionId)}&fields=search`);
4088
+ if (body["session"] === null || body["session"] === void 0) return void 0;
4089
+ return {
4090
+ head: DrsCorpusSource.headOf(body["session"], sessionId),
4091
+ docs: (Array.isArray(body["rows"]) ? body["rows"] : []).map((row) => ({
4092
+ seq: Number(row["seq"] ?? 0),
4093
+ type: String(row["kind"] ?? ""),
4094
+ surface: Number(row["on_surface"] ?? 1) === 1 ? "current" : "shadowed",
4095
+ time: Number(row["time"] ?? 0),
4096
+ text: String(row["search_text"] ?? "")
4097
+ }))
4098
+ };
4099
+ }
4100
+ /**
4101
+ * 分组内容搜索直连臂(op=search&group=session,DRS surface.searchGrouped 同构):
4102
+ * 会话分组/加权/过滤/锚定节选全部在 DRS 侧完成,本方法只搬 wire →
4103
+ * 引擎命中形状(SwitchSearchHit 字段同名)。失败(网络/非 2xx/包络)直抛,
4104
+ * 调用侧按硬依赖语义显式报错——**不回退本地索引**。
4105
+ */
4106
+ async searchGrouped(request) {
4107
+ const params = new URLSearchParams();
4108
+ params.set("op", "search");
4109
+ params.set("group", "session");
4110
+ params.set("q", request.query);
4111
+ if (request.kinds !== void 0 && request.kinds.length > 0) params.set("kinds", request.kinds.join(","));
4112
+ if (request.archived !== void 0) params.set("archived", request.archived);
4113
+ if (request.from !== void 0) params.set("from", String(request.from));
4114
+ if (request.to !== void 0) params.set("to", String(request.to));
4115
+ if (request.sortBy === "time") params.set("sort", "time");
4116
+ if (request.limit !== void 0) params.set("limit", String(request.limit));
4117
+ if (request.expand === true) params.set("expand", "1");
4118
+ const body = await this.getJson(`?${params.toString()}`, request.atHost);
4119
+ return (Array.isArray(body["hits"]) ? body["hits"] : []).map((row) => ({
4120
+ sessionId: String(row["session_id"] ?? ""),
4121
+ title: String(row["title"] ?? ""),
4122
+ seq: Number(row["seq"] ?? 0),
4123
+ type: String(row["type"] ?? ""),
4124
+ time: Number(row["time"] ?? 0),
4125
+ updatedAt: Number(row["updated_at"] ?? 0),
4126
+ snippet: String(row["snippet"] ?? "")
4127
+ }));
4128
+ }
4129
+ /** 可性探针(op=status):硬依赖预检/降级提示用;失败返回 false。 */
4130
+ async probe(atHost) {
4131
+ try {
4132
+ await this.getJson("?op=status", atHost);
4133
+ return true;
4134
+ } catch {
4135
+ return false;
4136
+ }
4137
+ }
4138
+ };
4139
+ //#endregion
3772
4140
  //#region src/host/index/rebuild.ts
3773
4141
  /**
3774
4142
  * Non-destructive index rebuild ("整理索引") for the independent index.
@@ -3831,6 +4199,63 @@ async function listArchives(layout) {
3831
4199
  }
3832
4200
  /** Sessions per batched transaction (one fsync checkpoint per chunk). */
3833
4201
  const REBUILD_CHUNK = 50;
4202
+ /**
4203
+ * 主文件旁的 WAL sidecar(`-wal`/`-shm`)随主文件一并迁走;无目的地时
4204
+ * (孤儿残留)直接 force rm。rename 失败降级删除——sidecar 清理永远不能
4205
+ * 阻断 swap 本身。
4206
+ */
4207
+ async function moveOrDeleteSidecars(basePath, destBasePath) {
4208
+ for (const suffix of ["-wal", "-shm"]) {
4209
+ const src = basePath + suffix;
4210
+ if (!existsSync(src)) continue;
4211
+ if (destBasePath !== null) try {
4212
+ await rename(src, destBasePath + suffix);
4213
+ continue;
4214
+ } catch {}
4215
+ await rm(src, { force: true }).catch(() => {});
4216
+ }
4217
+ }
4218
+ /**
4219
+ * The swap window: synchronous renames only; queries fail only inside it.
4220
+ *
4221
+ * issue #1 评论的损坏根因:只搬主文件时,旧库残留的 -wal/-shm 会被同名顶替
4222
+ * 的新库当自己的 WAL 重放,页号错配 → malformed。因此 sidecar 必须随主文件
4223
+ * 一并迁 archive(干净 close 本会 checkpoint 自删,这里是异常关闭的兜底)。
4224
+ * swap 后跑 integrity_check:非 ok 则把损坏库改名 .corrupt、从刚落的 archive
4225
+ * 回滚重开,并抛错让调用方置 error(把「索引损坏」降级为「回到上次快照」)。
4226
+ * 导出供测试直接驱动 swap 窗口(多连接场景在 Windows 上 rename 必 EBUSY,
4227
+ * 无法经 rebuildIndex 全链路确定性复现)。
4228
+ */
4229
+ async function swapShadowIntoActive(activeEngine, layout, log) {
4230
+ const activePath = join(layout.dir, layout.active);
4231
+ const buildingPath = join(layout.dir, layout.building);
4232
+ let archivePath = null;
4233
+ if (existsSync(activePath)) {
4234
+ activeEngine.close();
4235
+ archivePath = join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`);
4236
+ await rename(activePath, archivePath);
4237
+ await moveOrDeleteSidecars(activePath, archivePath);
4238
+ } else await moveOrDeleteSidecars(activePath, null);
4239
+ await rename(buildingPath, activePath);
4240
+ await activeEngine.open();
4241
+ const integrity = activeEngine.integrityCheck();
4242
+ if (integrity === "ok") return;
4243
+ log?.(`post-swap integrity FAILED (${integrity}); rolling back to the newest archive`);
4244
+ activeEngine.close();
4245
+ const corruptBase = join(layout.dir, `index.corrupt-${Date.now()}.sqlite`);
4246
+ try {
4247
+ await rename(activePath, corruptBase);
4248
+ await moveOrDeleteSidecars(activePath, corruptBase);
4249
+ if (archivePath !== null) {
4250
+ await rename(archivePath, activePath);
4251
+ for (const suffix of ["-wal", "-shm"]) if (existsSync(archivePath + suffix)) await rename(archivePath + suffix, activePath + suffix).catch(() => {});
4252
+ await activeEngine.open();
4253
+ }
4254
+ } catch (rollbackErr) {
4255
+ log?.(`swap rollback itself failed: ${String(rollbackErr instanceof Error ? rollbackErr.message : rollbackErr)}`);
4256
+ }
4257
+ throw new Error(`switch-search: post-swap integrity check failed: ${integrity}`);
4258
+ }
3834
4259
  async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, onProgress, archiveSource, hooks) {
3835
4260
  const startedMs = Date.now();
3836
4261
  let docsWritten = 0;
@@ -3934,13 +4359,7 @@ async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, on
3934
4359
  state.state = "swapping";
3935
4360
  emit();
3936
4361
  hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`);
3937
- const activePath = join(layout.dir, layout.active);
3938
- if (existsSync(activePath)) {
3939
- activeEngine.close();
3940
- await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`));
3941
- }
3942
- await rename(buildingPath, activePath);
3943
- await activeEngine.open();
4362
+ await swapShadowIntoActive(activeEngine, layout, hooks?.log);
3944
4363
  await pruneArchives(layout, keepArchives);
3945
4364
  state.finishedAt = Date.now();
3946
4365
  state.state = "idle";
@@ -3955,8 +4374,9 @@ async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, on
3955
4374
  }
3956
4375
  return state;
3957
4376
  }
3958
- /** Import doc-level records into the shadow file and swap it in (same swap path). */
3959
- async function importIntoIndex(activeEngine, layout, records, keepArchives) {
4377
+ /** Import doc-level records into the shadow file and swap it in (same swap path).
4378
+ * @param log - 可选日志汇(空 docs 记录告警,issue #1 写入侧可观测)。 */
4379
+ async function importIntoIndex(activeEngine, layout, records, keepArchives, log) {
3960
4380
  const state = {
3961
4381
  state: "building",
3962
4382
  done: 0,
@@ -3974,6 +4394,7 @@ async function importIntoIndex(activeEngine, layout, records, keepArchives) {
3974
4394
  try {
3975
4395
  for (const record of records) {
3976
4396
  try {
4397
+ if (record.docs.length === 0) log?.(`snapshot import: session ${record.sessionId} has zero docs (hollow header, sync will backfill)`);
3977
4398
  shadow.importSessionDocs({
3978
4399
  sessionId: record.sessionId,
3979
4400
  version: record.version,
@@ -3996,13 +4417,7 @@ async function importIntoIndex(activeEngine, layout, records, keepArchives) {
3996
4417
  throw error;
3997
4418
  }
3998
4419
  state.state = "swapping";
3999
- const activePath = join(layout.dir, layout.active);
4000
- if (existsSync(activePath)) {
4001
- activeEngine.close();
4002
- await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`));
4003
- }
4004
- await rename(buildingPath, activePath);
4005
- await activeEngine.open();
4420
+ await swapShadowIntoActive(activeEngine, layout, log);
4006
4421
  await pruneArchives(layout, keepArchives);
4007
4422
  state.finishedAt = Date.now();
4008
4423
  state.state = "idle";
@@ -4013,11 +4428,14 @@ async function importIntoIndex(activeEngine, layout, records, keepArchives) {
4013
4428
  }
4014
4429
  return state;
4015
4430
  }
4016
- /** Remove the oldest archives beyond the retention bound. */
4431
+ /** Remove the oldest archives beyond the retention bound (sidecars too). */
4017
4432
  async function pruneArchives(layout, keep) {
4018
4433
  const archives = await listArchives(layout);
4019
4434
  const excess = archives.length - Math.max(0, keep);
4020
- for (let i = 0; i < excess; i += 1) await unlink(join(layout.dir, archives[i])).catch(() => {});
4435
+ for (let i = 0; i < excess; i += 1) {
4436
+ await unlink(join(layout.dir, archives[i])).catch(() => {});
4437
+ await moveOrDeleteSidecars(join(layout.dir, archives[i]), null);
4438
+ }
4021
4439
  }
4022
4440
  /**
4023
4441
  * Resolve the index directory that hosts the independent index files:
@@ -4144,11 +4562,14 @@ const Config = z.object({
4144
4562
  historyFiles: withVolatile(z.boolean().default(true)),
4145
4563
  healthCheck: withVolatile(z.boolean().default(true)),
4146
4564
  search: withVolatile(z.boolean().default(true)),
4565
+ directDelete: withVolatile(z.boolean().default(false)),
4147
4566
  defaultMode: withVolatile(z.union(["title", "content"]).default("title")),
4148
4567
  autoSync: withVolatile(z.boolean().default(true)),
4149
4568
  syncIntervalMs: withVolatile(z.number().default(3e4)),
4150
4569
  archiveKeep: withVolatile(z.number().default(2)),
4151
- indexDir: withVolatile(z.string().default(""))
4570
+ indexDir: withVolatile(z.string().default("")),
4571
+ corpusSource: withVolatile(z.union(["native", "drs"]).default("drs")),
4572
+ drsBaseUrl: withVolatile(z.string().default(""))
4152
4573
  });
4153
4574
  /** 单次请求体的上限(防御无界读取)。 */
4154
4575
  const MAX_BODY_BYTES = 16 << 20;
@@ -4341,7 +4762,7 @@ async function listSessions(srt) {
4341
4762
  * `sortBy: 'time'` orders by session recency (`updatedAt`), anything else by
4342
4763
  * relevance; the host orders before truncating so the page is honest.
4343
4764
  */
4344
- async function contentSearch(srt, payload) {
4765
+ async function contentSearch(srt, payload, requestHost = "") {
4345
4766
  const record = payload;
4346
4767
  const query = typeof record?.query === "string" ? record.query.trim() : "";
4347
4768
  if (query === "") return {
@@ -4357,39 +4778,38 @@ async function contentSearch(srt, payload) {
4357
4778
  let types;
4358
4779
  if (Array.isArray(record?.types) && record.types.length > 0) types = record.types.filter((entry) => entry === "all" || entry === "user" || entry === "reply" || entry === "tool");
4359
4780
  else types = ["user", "reply"];
4360
- const index = srt.index;
4361
- if (index.engine.isOpen === false) return {
4362
- ok: false,
4363
- error: "独立索引未就绪:请在面板或设置中先建立索引(整理索引)"
4364
- };
4781
+ const kinds = resolveTypes(types);
4365
4782
  try {
4366
4783
  return {
4367
4784
  ok: true,
4368
- items: index.engine.search({
4785
+ items: await srt.drsSource.searchGrouped({
4369
4786
  query,
4370
- types,
4371
- limit,
4372
- sortBy,
4787
+ kinds,
4373
4788
  archived,
4374
- from: fromMs,
4375
- to: toMs
4789
+ sortBy,
4790
+ limit,
4791
+ ...record?.expand === true ? { expand: true } : {},
4792
+ ...requestHost === "" ? {} : { atHost: requestHost },
4793
+ ...fromMs === void 0 ? {} : { from: fromMs },
4794
+ ...toMs === void 0 ? {} : { to: toMs }
4376
4795
  })
4377
4796
  };
4378
4797
  } catch (err) {
4379
4798
  return {
4380
4799
  ok: false,
4381
- error: String(err instanceof Error ? err.message : err)
4800
+ error: `dsh-reference-session-collector 搜索面不可用(硬依赖):${String(err instanceof Error ? err.message : err)}`
4382
4801
  };
4383
4802
  }
4384
4803
  }
4385
4804
  /** search-status: probe the independent index readiness and progress. */
4386
- async function searchStatus(srt) {
4805
+ async function searchStatus(srt, requestHost = "") {
4387
4806
  const index = srt.index;
4388
4807
  const sync = index.sync.snapshot();
4808
+ const drsUp = await srt.drsSource.probe(requestHost);
4389
4809
  return {
4390
4810
  ok: true,
4391
- available: index.engine.isOpen && index.engine.countSessions() > 0,
4392
- reason: index.engine.isOpen ? void 0 : "not-open",
4811
+ available: drsUp,
4812
+ reason: drsUp ? void 0 : "drs-unavailable",
4393
4813
  indexing: sync.state === "syncing",
4394
4814
  archivedSessions: index.engine.countArchived(),
4395
4815
  archive: index.archiveReader.diagnostics(),
@@ -4411,6 +4831,7 @@ async function indexStatus(srt) {
4411
4831
  driver: index.engine.driverLabel,
4412
4832
  archive: index.archiveReader.diagnostics(),
4413
4833
  dir: index.layout.dir,
4834
+ directDelete: srt.config().directDelete === true,
4414
4835
  archives: await listArchives(index.layout).catch(() => []),
4415
4836
  sync: {
4416
4837
  ...sync,
@@ -4525,7 +4946,7 @@ async function indexImport(srt, text) {
4525
4946
  };
4526
4947
  const config = srt.config();
4527
4948
  const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep);
4528
- importIntoIndex(index.engine, index.layout, parsed.records, keepArchives).then((state) => {
4949
+ importIntoIndex(index.engine, index.layout, parsed.records, keepArchives, srt.log).then((state) => {
4529
4950
  index.rebuild = state;
4530
4951
  }).catch((err) => {
4531
4952
  index.rebuild = {
@@ -4554,10 +4975,10 @@ async function indexImport(srt, text) {
4554
4975
  };
4555
4976
  }
4556
4977
  /** JSON 面的搜索子域方法分发(index-export/import 走原始体,在路由层特判)。 */
4557
- async function handleIndexMethod(method, payload, srt) {
4978
+ async function handleIndexMethod(method, payload, srt, requestHost = "") {
4558
4979
  if (method === "list-sessions") return await listSessions(srt);
4559
- if (method === "content-search") return await contentSearch(srt, payload);
4560
- if (method === "search-status") return await searchStatus(srt);
4980
+ if (method === "content-search") return await contentSearch(srt, payload, requestHost);
4981
+ if (method === "search-status") return await searchStatus(srt, requestHost);
4561
4982
  if (method === "index-status") return await indexStatus(srt);
4562
4983
  if (method === "index-rebuild") return await indexRebuild(srt);
4563
4984
  return {
@@ -4616,7 +5037,8 @@ function configSnapshotProjection(config) {
4616
5037
  enabled: config.enabled,
4617
5038
  historyFiles: config.historyFiles,
4618
5039
  healthCheck: config.healthCheck,
4619
- search: config.search
5040
+ search: config.search,
5041
+ directDelete: config.directDelete
4620
5042
  },
4621
5043
  [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
4622
5044
  enabled: config.enabled,
@@ -4733,6 +5155,11 @@ async function handleMethod(method, payload, runtime) {
4733
5155
  });
4734
5156
  }
4735
5157
  runtime.index?.onPurged(succeeded);
5158
+ appendRetiredTombstones(succeeded, {
5159
+ ...runtime.tombstoneDir === void 0 ? {} : { dir: runtime.tombstoneDir() },
5160
+ source: "steward-purge-memory",
5161
+ log: runtime.log
5162
+ });
4736
5163
  return {
4737
5164
  ok: true,
4738
5165
  purged: succeeded.length,
@@ -4747,7 +5174,13 @@ async function handleMethod(method, payload, runtime) {
4747
5174
  });
4748
5175
  if (result.ok && ids !== void 0) {
4749
5176
  const failed = new Set((result.failures ?? []).map((f) => f.sessionId));
4750
- runtime.index?.onPurged(ids.filter((id) => !failed.has(id)));
5177
+ const gone = ids.filter((id) => !failed.has(id));
5178
+ runtime.index?.onPurged(gone);
5179
+ appendRetiredTombstones(gone, {
5180
+ ...runtime.tombstoneDir === void 0 ? {} : { dir: runtime.tombstoneDir() },
5181
+ source: "steward-purge-fallback",
5182
+ log: runtime.log
5183
+ });
4751
5184
  }
4752
5185
  return result;
4753
5186
  }
@@ -4926,17 +5359,27 @@ async function handleMethod(method, payload, runtime) {
4926
5359
  * @param config - 组合条目(0.1.7:`.volatile()` 字段为 live ref)。
4927
5360
  */
4928
5361
  function apply(ctx, config = {}) {
4929
- const current = () => ({
4930
- enabled: readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled,
4931
- historyFiles: readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles,
4932
- healthCheck: readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck,
4933
- search: readVolatileValue(config.search) ?? DEFAULT_CONFIG.search,
4934
- defaultMode: readVolatileValue(config.defaultMode) ?? SWITCH_DEFAULT_CONFIG.defaultMode,
4935
- autoSync: readVolatileValue(config.autoSync) ?? SWITCH_DEFAULT_CONFIG.autoSync,
4936
- syncIntervalMs: readVolatileValue(config.syncIntervalMs) ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
4937
- archiveKeep: readVolatileValue(config.archiveKeep) ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
4938
- indexDir: readVolatileValue(config.indexDir) ?? SWITCH_DEFAULT_CONFIG.indexDir
4939
- });
5362
+ const current = () => {
5363
+ const { steward, search } = bridgeOverlay(settingsBridge);
5364
+ const bool = (doc, key, v) => typeof doc[key] === "boolean" ? doc[key] : v;
5365
+ const num = (doc, key, v) => typeof doc[key] === "number" && Number.isFinite(doc[key]) ? doc[key] : v;
5366
+ const str = (doc, key, v) => typeof doc[key] === "string" ? doc[key] : v;
5367
+ const mode = (v) => search["defaultMode"] === "title" || search["defaultMode"] === "content" ? search["defaultMode"] : v;
5368
+ return {
5369
+ enabled: bool(steward, "enabled", readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled),
5370
+ historyFiles: bool(steward, "historyFiles", readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles),
5371
+ healthCheck: bool(steward, "healthCheck", readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck),
5372
+ search: bool(steward, "search", readVolatileValue(config.search) ?? DEFAULT_CONFIG.search),
5373
+ directDelete: bool(steward, "directDelete", readVolatileValue(config.directDelete) ?? DEFAULT_CONFIG.directDelete),
5374
+ defaultMode: mode(readVolatileValue(config.defaultMode) ?? SWITCH_DEFAULT_CONFIG.defaultMode),
5375
+ autoSync: bool(search, "autoSync", readVolatileValue(config.autoSync) ?? SWITCH_DEFAULT_CONFIG.autoSync),
5376
+ syncIntervalMs: num(search, "syncIntervalMs", readVolatileValue(config.syncIntervalMs) ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs),
5377
+ archiveKeep: num(search, "archiveKeep", readVolatileValue(config.archiveKeep) ?? SWITCH_DEFAULT_CONFIG.archiveKeep),
5378
+ indexDir: str(search, "indexDir", readVolatileValue(config.indexDir) ?? SWITCH_DEFAULT_CONFIG.indexDir),
5379
+ corpusSource: readVolatileValue(config.corpusSource) ?? SWITCH_DEFAULT_CONFIG.corpusSource,
5380
+ drsBaseUrl: str(search, "drsBaseUrl", readVolatileValue(config.drsBaseUrl) ?? SWITCH_DEFAULT_CONFIG.drsBaseUrl)
5381
+ };
5382
+ };
4940
5383
  const log = (message) => {
4941
5384
  try {
4942
5385
  ctx.logger?.info?.(`[session-steward] ${message}`);
@@ -4974,9 +5417,10 @@ function apply(ctx, config = {}) {
4974
5417
  }
4975
5418
  };
4976
5419
  const registry = () => ctx.get("workspaceRegistry");
5420
+ const settingsBridge = installLegacySettingsBridge(ctx);
4977
5421
  const runtime = {
4978
5422
  config: () => current(),
4979
- settingsBridge: installLegacySettingsBridge(ctx),
5423
+ settingsBridge,
4980
5424
  dshHome: resolvedHome,
4981
5425
  registry,
4982
5426
  projectionStateFor,
@@ -5008,6 +5452,10 @@ function apply(ctx, config = {}) {
5008
5452
  log(`index onPurged failed: ${String(err instanceof Error ? err.message : err)}`);
5009
5453
  }
5010
5454
  }
5455
+ },
5456
+ tombstoneDir: () => {
5457
+ const dir = current().indexDir;
5458
+ return dir !== "" ? dir : defaultTombstoneDir();
5011
5459
  }
5012
5460
  };
5013
5461
  const initial = current();
@@ -5051,6 +5499,12 @@ function apply(ctx, config = {}) {
5051
5499
  return "";
5052
5500
  }
5053
5501
  };
5502
+ const trustedHosts = webRuntime?.trustedHosts;
5503
+ const drsSource = new DrsCorpusSource({
5504
+ baseUrl: resolveDrsBaseUrl(initial.drsBaseUrl, trustedHosts),
5505
+ hostHeader: trustedHosts !== void 0 && trustedHosts.length > 0 ? trustedHosts[0] : void 0,
5506
+ log
5507
+ });
5054
5508
  const indexState = {
5055
5509
  engine,
5056
5510
  archiveReader,
@@ -5061,7 +5515,7 @@ function apply(ctx, config = {}) {
5061
5515
  return sessionQuery.readSession(sessionId);
5062
5516
  },
5063
5517
  readTitleSnapshots: sessionQuery === void 0 ? void 0 : (ids) => sessionQuery.readTitleSnapshots(ids)
5064
- }, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle),
5518
+ }, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle, initial.corpusSource === "native" ? void 0 : drsSource),
5065
5519
  layout: indexLayout,
5066
5520
  rebuild: {
5067
5521
  state: "idle",
@@ -5075,6 +5529,7 @@ function apply(ctx, config = {}) {
5075
5529
  const srt = {
5076
5530
  sessionQuery,
5077
5531
  index: indexState,
5532
+ drsSource,
5078
5533
  config: () => current(),
5079
5534
  registry: () => ({ archivedSessionIds: archiveReader.read().ids }),
5080
5535
  log,
@@ -5227,7 +5682,7 @@ function apply(ctx, config = {}) {
5227
5682
  writeJson(res, 200, await indexImport(srt, text));
5228
5683
  return;
5229
5684
  }
5230
- writeJson(res, 200, await handleIndexMethod(method, await readJsonBody(req), srt));
5685
+ writeJson(res, 200, await handleIndexMethod(method, await readJsonBody(req), srt, String(req.headers.host ?? "")));
5231
5686
  } catch (err) {
5232
5687
  writeJson(res, 400, {
5233
5688
  ok: false,
@@ -5238,4 +5693,4 @@ function apply(ctx, config = {}) {
5238
5693
  }), "dsh-manage-session: /switch-search/api route");
5239
5694
  }
5240
5695
  //#endregion
5241
- export { Config, DEFAULT_CONFIG, DEFAULT_INDEX_LAYOUT, HEALTH_METHODS, HISTORY_METHODS, HealthCache, INDEX_METHODS, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, SWITCH_API_PREFIX, SWITCH_DEFAULT_CONFIG, SwitchIndexEngine, SwitchWatermarkSync, V4_HOST_MIN, apply, archiveArchiveFile, archiveHistory, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createArchiveSource, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, detectSteward, dirSize, discoverSessions, editWorkspaceDocument, exportSnapshot, extractTitleFromEvents, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleIndexMethod, handleMethod, importIntoIndex, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, parseSnapshot, prescribe, probePeer, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, rebuildIndex, recoverIndex, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor };
5696
+ export { Config, DEFAULT_CONFIG, DEFAULT_INDEX_LAYOUT, HEALTH_METHODS, HISTORY_METHODS, HealthCache, INDEX_METHODS, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, SWITCH_API_PREFIX, SWITCH_DEFAULT_CONFIG, SwitchIndexEngine, SwitchWatermarkSync, V4_HOST_MIN, apply, archiveArchiveFile, archiveHistory, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createArchiveSource, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, detectSteward, dirSize, discoverSessions, editWorkspaceDocument, exportSnapshot, extractTitleFromEvents, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleIndexMethod, handleMethod, importIntoIndex, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, parseSnapshot, prescribe, probePeer, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, rebuildIndex, recoverIndex, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor, swapShadowIntoActive };