dsh-manage-session 0.5.7 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.ja.md +33 -229
  2. package/CHANGELOG.ko.md +33 -229
  3. package/CHANGELOG.md +33 -363
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +41 -46
  10. package/README.ja.md +33 -57
  11. package/README.ko.md +32 -55
  12. package/README.md +64 -64
  13. package/cordis.patch.yml +19 -16
  14. package/dsh.plugin.json +4 -4
  15. package/lib/client.js +661 -3082
  16. package/lib/index.d.ts +79 -970
  17. package/lib/index.js +249 -2669
  18. package/locale/de.json +2 -2
  19. package/locale/en.json +2 -2
  20. package/locale/es.json +2 -2
  21. package/locale/fr.json +2 -2
  22. package/locale/it.json +2 -2
  23. package/locale/ja.json +2 -2
  24. package/locale/ko.json +2 -2
  25. package/locale/ru.json +2 -2
  26. package/locale/zh.json +2 -2
  27. package/package.json +15 -25
  28. package/screenshots.json +3 -3
  29. package/src/client/bridge-scope.ts +202 -213
  30. package/src/client/card.tsx +154 -154
  31. package/src/client/health-panel.tsx +49 -49
  32. package/src/client/host-api.ts +72 -6
  33. package/src/client/index.ts +594 -503
  34. package/src/client/locales.ts +893 -893
  35. package/src/client/manage-locales.ts +178 -0
  36. package/src/client/{search/manage.tsx → manage.tsx} +430 -404
  37. package/src/client/panel.tsx +131 -129
  38. package/src/client/session-menu-legacy.ts +2 -3
  39. package/src/client/session-menu.tsx +5 -5
  40. package/src/config.ts +43 -99
  41. package/src/host/health/attribution.ts +118 -118
  42. package/src/host/health/lossless.ts +128 -128
  43. package/src/host/health/repair.ts +2 -2
  44. package/src/host/health/source-kind.ts +350 -350
  45. package/src/host/history/archive-source.ts +250 -250
  46. package/src/host/history/archive.ts +249 -249
  47. package/src/host/history/favorites.ts +71 -71
  48. package/src/host/history/purge.ts +370 -370
  49. package/src/host/history/tombstones.ts +3 -3
  50. package/src/host/registry/archive-registry.ts +317 -317
  51. package/src/host/settings-bridge.ts +222 -264
  52. package/src/index.ts +66 -694
  53. package/src/client/search/card.tsx +0 -563
  54. package/src/client/search/host-api.ts +0 -213
  55. package/src/client/search/locales.ts +0 -861
  56. package/src/client/search/locate.ts +0 -330
  57. package/src/client/search/panel.tsx +0 -940
  58. package/src/client/search/platform.ts +0 -136
  59. package/src/host/index/archive-source.ts +0 -90
  60. package/src/host/index/drs-source.ts +0 -211
  61. package/src/host/index/engine.ts +0 -699
  62. package/src/host/index/extract.ts +0 -272
  63. package/src/host/index/peers.ts +0 -62
  64. package/src/host/index/rebuild.ts +0 -432
  65. package/src/host/index/schema.ts +0 -136
  66. package/src/host/index/snapshot.ts +0 -135
  67. package/src/host/index/sync.ts +0 -413
package/lib/index.js CHANGED
@@ -1,41 +1,21 @@
1
- import { createRequire } from "node:module";
2
1
  import z from "@deepseek-ai/schemastery";
3
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
2
  import { homedir } from "node:os";
5
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
5
  import zlib from "node:zlib";
7
- import { mkdir, readdir, rename, rm, unlink } from "node:fs/promises";
8
- //#region src/config.ts
6
+ // src/config.ts
9
7
  /** 缺省值。 */
10
8
  const DEFAULT_CONFIG = {
11
9
  enabled: true,
12
10
  historyFiles: true,
13
11
  healthCheck: true,
14
- search: true,
15
12
  directDelete: false
16
13
  };
17
14
  /** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
18
- const STEWARD_SETTINGS_NAMESPACE = "session-steward";
19
- /** host 路由前缀(管家子域;与搜索子域的 /switch-search/api 互不干扰)。 */
20
- const STEWARD_API_PREFIX = "/session-steward/api";
21
- /** ------------------------------------------------------------------ 搜索子域 */
22
- /** 搜索子域的历史设置命名空间(存储键稳定承诺,永不迁移)。 */
23
- const SWITCH_SEARCH_SETTINGS_NAMESPACE = "switch-search";
24
- /** 搜索子域路由前缀(历史值,浏览器旧 bundle 与快照文件名依赖它,保持不变)。 */
25
- const SWITCH_API_PREFIX = "/switch-search/api";
26
- /** 搜索子域缺省值。 */
27
- const SWITCH_DEFAULT_CONFIG = {
28
- enabled: true,
29
- defaultMode: "title",
30
- autoSync: true,
31
- syncIntervalMs: 3e4,
32
- archiveKeep: 2,
33
- indexDir: "",
34
- corpusSource: "native",
35
- drsBaseUrl: ""
36
- };
37
- //#endregion
38
- //#region src/host/history/archive-source.ts
15
+ const STEWARD_SETTINGS_NAMESPACE = "manage-session";
16
+ /** host 路由前缀(管理 + 体检,方法名一律 `session-*`)。 */
17
+ const STEWARD_API_PREFIX = "/manage-session/api";
18
+ // src/host/history/archive-source.ts
39
19
  /**
40
20
  * 官方归档集合(archive set)的读取与清理 —— 自 dsh-session-search-toggle
41
21
  * `src/host/archive-source.ts` **逐字迁入**(只依赖 node:fs/os/path),行为与迁移前等价。
@@ -53,11 +33,11 @@ const SWITCH_DEFAULT_CONFIG = {
53
33
  * 调用方必须提示需要重启 DSH。
54
34
  */
55
35
  /** workspace domain 的 DSH 存储中枢候选路径(json 后端)。 */
56
- function storageFileCandidates$1() {
36
+ function storageFileCandidates() {
57
37
  return [join(homedir(), ".dsh", "storages", "workspace.json")];
58
38
  }
59
39
  /** 解析存储文件 global.archivedSessionIds;内容畸形时抛错。 */
60
- function readStorageFile$1(path) {
40
+ function readStorageFile(path) {
61
41
  const ids = JSON.parse(readFileSync(path, "utf8")).global?.archivedSessionIds;
62
42
  if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`);
63
43
  return ids.filter((id) => typeof id === "string");
@@ -85,11 +65,11 @@ function readRegistryIds(registry) {
85
65
  */
86
66
  function readArchiveSet(registry, searchPaths) {
87
67
  const registryIds = readRegistryIds(registry);
88
- for (const path of searchPaths ?? storageFileCandidates$1()) {
68
+ for (const path of searchPaths ?? storageFileCandidates()) {
89
69
  if (!existsSync(path)) continue;
90
70
  try {
91
71
  return {
92
- ids: readStorageFile$1(path),
72
+ ids: readStorageFile(path),
93
73
  source: "storage-file",
94
74
  ...registryIds === void 0 ? {} : { registryIds }
95
75
  };
@@ -172,7 +152,7 @@ function editWorkspaceDocument(path, edit, log) {
172
152
  function pruneArchiveFile(ids, log, searchPaths) {
173
153
  const wanted = new Set(ids);
174
154
  let lastReason = "workspace storage file not found (searched ~/.dsh/storages/workspace.json)";
175
- for (const path of searchPaths ?? storageFileCandidates$1()) {
155
+ for (const path of searchPaths ?? storageFileCandidates()) {
176
156
  let removed = 0;
177
157
  let remaining = 0;
178
158
  const outcome = editWorkspaceDocument(path, (document) => {
@@ -216,7 +196,7 @@ function pruneArchiveFile(ids, log, searchPaths) {
216
196
  function archiveArchiveFile(ids, log, searchPaths) {
217
197
  new Set(ids);
218
198
  let lastReason = "workspace storage file not found (searched ~/.dsh/storages/workspace.json)";
219
- for (const path of searchPaths ?? storageFileCandidates$1()) {
199
+ for (const path of searchPaths ?? storageFileCandidates()) {
220
200
  let added = 0;
221
201
  let total = 0;
222
202
  const outcome = editWorkspaceDocument(path, (document) => {
@@ -251,8 +231,7 @@ function archiveArchiveFile(ids, log, searchPaths) {
251
231
  }
252
232
  throw new Error(lastReason);
253
233
  }
254
- //#endregion
255
- //#region src/host/health/decode.ts
234
+ // src/host/health/decode.ts
256
235
  /**
257
236
  * 会话日志读取链路(`log-integrity` gate 与 `lossless-json` gate 的共同底座)。
258
237
  *
@@ -525,8 +504,7 @@ function findOpenStep(events) {
525
504
  }
526
505
  return open;
527
506
  }
528
- //#endregion
529
- //#region src/host/health/source-kind.ts
507
+ // src/host/health/source-kind.ts
530
508
  /**
531
509
  * 插件署名格式(source kind)体检门与转换。
532
510
  *
@@ -802,10 +780,9 @@ function readPlaintext(logPath) {
802
780
  }
803
781
  /** 备份文件的后缀约定(purge/清理工具可据此识别并一并处置)。 */
804
782
  const SOURCE_MIGRATE_BACKUP_SUFFIX = ".pre-sourcemigrate-";
805
- //#endregion
806
- //#region src/host/history/purge.ts
783
+ // src/host/history/purge.ts
807
784
  /**
808
- * 归档文件清理(**真删除**)—— 会话管家唯一的破坏性操作。
785
+ * 归档文件清理(**真删除**)—— 会话管理唯一的破坏性操作。
809
786
  *
810
787
  * 与「取消归档状态」(`pruneArchiveFile`)是**两件事**,刻意分开:
811
788
  *
@@ -1131,8 +1108,7 @@ function purgeHistory(payload, log, options) {
1131
1108
  ...failures.length === 0 ? {} : { failures }
1132
1109
  };
1133
1110
  }
1134
- //#endregion
1135
- //#region src/host/history/archive.ts
1111
+ // src/host/history/archive.ts
1136
1112
  /**
1137
1113
  * 会话历史文件(归档)域的服务端处理。
1138
1114
  *
@@ -1325,13 +1301,12 @@ function archiveHistory(payload, log, searchPaths) {
1325
1301
  requiresRestart: true
1326
1302
  };
1327
1303
  }
1328
- //#endregion
1329
- //#region src/host/history/favorites.ts
1304
+ // src/host/history/favorites.ts
1330
1305
  /**
1331
1306
  * 收藏域(R6)—— 插件自有的 JSON 文件,与索引文件/官方存储彻底分离:
1332
1307
  * 索引会因 schema 重置/影子切换整体更换,收藏不能放里面;官方存储中枢
1333
1308
  * 只读不写(唯一写方纪律不破)。文件形如
1334
- * `<dshHome>/storages/session-steward-favorites.json`:
1309
+ * `<dshHome>/storages/manage-session-favorites.json`:
1335
1310
  * `{ unit: { name, version }, favoriteSessionIds: string[] }`
1336
1311
  * 原子写(tmp+rename),丢失可重建(收藏只是标记,不是数据)。
1337
1312
  */
@@ -1368,7 +1343,7 @@ function setFavoriteState(sessionId, favorite, searchPaths) {
1368
1343
  const tmp = `${path}.fav-tmp`;
1369
1344
  writeFileSync(tmp, `${JSON.stringify({
1370
1345
  unit: {
1371
- name: "session-steward-favorites",
1346
+ name: "manage-session-favorites",
1372
1347
  version: 1
1373
1348
  },
1374
1349
  favoriteSessionIds: next
@@ -1385,61 +1360,7 @@ function setFavoriteState(sessionId, favorite, searchPaths) {
1385
1360
  };
1386
1361
  }
1387
1362
  }
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
1442
- //#region src/host/settings-bridge.ts
1363
+ // src/host/settings-bridge.ts
1443
1364
  /**
1444
1365
  * Legacy 设置桥(宿主半身,≤0.1.5 专用):老宿主的服务端 settings 服务保留
1445
1366
  * register/get/update 三件套 —— 这是 context-compression legacy 租约(六格
@@ -1448,8 +1369,8 @@ function appendRetiredTombstones(sessionIds, opts) {
1448
1369
  * 字段里、client configForms 原生可用 —— 本桥在那些线上解析为 undefined,
1449
1370
  * describe/mutate 方法降级为「显式拒绝写」。
1450
1371
  *
1451
- * 两个命名空间**并存且互不迁移**(config.ts 的存储键稳定承诺):
1452
- * `session-steward`(管家字段)与 `switch-search`(搜索字段)。schema 必须
1372
+ * 设置命名空间只有一个:`manage-session`(搜索子域已拆回 dsh-search-index,
1373
+ * 搜索字段与命名空间不在本包)。schema 必须
1453
1374
  * **纯 plain**(不 withVolatile)—— volatile 节点会被 legacy register 拒收
1454
1375
  * (`$.enabled expected boolean`,TL beta.18 教训)。
1455
1376
  *
@@ -1458,23 +1379,13 @@ function appendRetiredTombstones(sessionIds, opts) {
1458
1379
  * 注册,后来者直接共享。
1459
1380
  */
1460
1381
  const SHARED_LEASE = Symbol.for("dsh-manage-session/legacy-settings-bridge");
1461
- /** 管家命名空间 schema(纯 plain,无 volatile —— 老线 register 拒收 volatile 节点)。 */
1382
+ /** 管理命名空间 schema(纯 plain,无 volatile —— 老线 register 拒收 volatile 节点)。 */
1462
1383
  const StewardNsSchema = z.object({
1463
1384
  enabled: z.boolean().default(true),
1464
1385
  historyFiles: z.boolean().default(true),
1465
1386
  healthCheck: z.boolean().default(true),
1466
- search: z.boolean().default(true),
1467
1387
  directDelete: z.boolean().default(false)
1468
1388
  });
1469
- /** 搜索命名空间 schema(同上;存储键与 config.ts 承诺一致)。 */
1470
- const SwitchNsSchema = z.object({
1471
- enabled: z.boolean().default(true),
1472
- defaultMode: z.union(["title", "content"]).default("title"),
1473
- autoSync: z.boolean().default(true),
1474
- syncIntervalMs: z.number().default(3e4),
1475
- archiveKeep: z.number().default(2),
1476
- indexDir: z.string().default("")
1477
- });
1478
1389
  /** settings.get 返回值是否像一份文档(老宿主对未写过的 ns 可能回 undefined)。 */
1479
1390
  function asDoc(value) {
1480
1391
  if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
@@ -1503,7 +1414,6 @@ function installLegacySettingsBridge(ctx) {
1503
1414
  const carrier = settings;
1504
1415
  if (carrier[SHARED_LEASE] !== true) {
1505
1416
  settings.register(STEWARD_SETTINGS_NAMESPACE, StewardNsSchema);
1506
- settings.register(SWITCH_SEARCH_SETTINGS_NAMESPACE, SwitchNsSchema);
1507
1417
  try {
1508
1418
  Object.defineProperty(carrier, SHARED_LEASE, {
1509
1419
  configurable: false,
@@ -1525,7 +1435,6 @@ function installLegacySettingsBridge(ctx) {
1525
1435
  await settings.update?.(ns, patch);
1526
1436
  }
1527
1437
  };
1528
- console.log(`[dsh-manage-session] legacy settings bridge engaged (${STEWARD_SETTINGS_NAMESPACE} + ${SWITCH_SEARCH_SETTINGS_NAMESPACE})`);
1529
1438
  } catch (err) {
1530
1439
  console.log(`[dsh-manage-session] legacy settings bridge FAILED (cards degrade to read-only): ${String(err instanceof Error ? err.message : err)}`);
1531
1440
  }
@@ -1543,89 +1452,25 @@ function asBoolean(v) {
1543
1452
  };
1544
1453
  }
1545
1454
  /** 各命名空间允许写入的字段与校验(未列出的字段一律拒绝——桥不是任意写缝)。 */
1546
- const FIELD_VALIDATORS = {
1547
- [STEWARD_SETTINGS_NAMESPACE]: {
1548
- enabled: asBoolean,
1549
- historyFiles: asBoolean,
1550
- healthCheck: asBoolean,
1551
- search: asBoolean,
1552
- directDelete: asBoolean
1553
- },
1554
- [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
1555
- enabled: asBoolean,
1556
- defaultMode: (v) => v === "title" || v === "content" ? {
1557
- ok: true,
1558
- value: v
1559
- } : {
1560
- ok: false,
1561
- error: "defaultMode 只接受 title|content"
1562
- },
1563
- autoSync: asBoolean,
1564
- syncIntervalMs: (v) => {
1565
- if (typeof v !== "number" || !Number.isFinite(v)) return {
1566
- ok: false,
1567
- error: "syncIntervalMs 必须是有限数字"
1568
- };
1569
- return {
1570
- ok: true,
1571
- value: Math.min(36e5, Math.max(5e3, Math.round(v)))
1572
- };
1573
- },
1574
- archiveKeep: (v) => {
1575
- if (typeof v !== "number" || !Number.isInteger(v)) return {
1576
- ok: false,
1577
- error: "archiveKeep 必须是整数"
1578
- };
1579
- return {
1580
- ok: true,
1581
- value: Math.min(20, Math.max(0, v))
1582
- };
1583
- },
1584
- indexDir: (v) => {
1585
- if (typeof v !== "string") return {
1586
- ok: false,
1587
- error: "indexDir 必须是字符串"
1588
- };
1589
- const trimmed = v.trim();
1590
- if (trimmed.length > 500) return {
1591
- ok: false,
1592
- error: "indexDir 过长(>500 字符)"
1593
- };
1594
- return {
1595
- ok: true,
1596
- value: trimmed
1597
- };
1598
- }
1599
- }
1600
- };
1455
+ const FIELD_VALIDATORS = { [STEWARD_SETTINGS_NAMESPACE]: {
1456
+ enabled: asBoolean,
1457
+ historyFiles: asBoolean,
1458
+ healthCheck: asBoolean,
1459
+ directDelete: asBoolean
1460
+ } };
1601
1461
  /** 汇总两份命名空间文档:legacy 读盘上文档合并缺省;modern 返回空投影(writable=false)。 */
1602
1462
  function describeNamespaces(state) {
1603
1463
  const { bridge } = state;
1604
- if (bridge !== void 0) return {
1605
- [STEWARD_SETTINGS_NAMESPACE]: {
1606
- ...DEFAULT_CONFIG,
1607
- ...bridge.get("session-steward") ?? {}
1608
- },
1609
- [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
1610
- ...SWITCH_DEFAULT_CONFIG,
1611
- ...bridge.get("switch-search") ?? {}
1612
- }
1613
- };
1614
- return {
1615
- [STEWARD_SETTINGS_NAMESPACE]: {},
1616
- [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {}
1617
- };
1464
+ if (bridge !== void 0) return { [STEWARD_SETTINGS_NAMESPACE]: {
1465
+ ...DEFAULT_CONFIG,
1466
+ ...bridge.get("manage-session") ?? {}
1467
+ } };
1468
+ return { [STEWARD_SETTINGS_NAMESPACE]: {} };
1618
1469
  }
1619
1470
  function bridgeOverlay(state) {
1620
1471
  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
- };
1472
+ if (bridge === void 0) return { steward: {} };
1473
+ return { steward: bridge.get("manage-session") ?? {} };
1629
1474
  }
1630
1475
  /** describe 响应。 */
1631
1476
  function settingsDescribe(state) {
@@ -1685,8 +1530,7 @@ async function settingsMutate(state, payload) {
1685
1530
  revision: state.revision
1686
1531
  };
1687
1532
  }
1688
- //#endregion
1689
- //#region src/host/health/attribution.ts
1533
+ // src/host/health/attribution.ts
1690
1534
  /**
1691
1535
  * 归属:把投影 key 指回产出它的插件包。
1692
1536
  *
@@ -1796,8 +1640,7 @@ function createAttributor(index) {
1796
1640
  };
1797
1641
  };
1798
1642
  }
1799
- //#endregion
1800
- //#region src/host/health/cache.ts
1643
+ // src/host/health/cache.ts
1801
1644
  /** 进程内体检缓存:分批扫描过程中累积,走完一遍语料才成型。 */
1802
1645
  var HealthCache = class {
1803
1646
  ready;
@@ -1865,8 +1708,7 @@ var HealthCache = class {
1865
1708
  this.pending = void 0;
1866
1709
  }
1867
1710
  };
1868
- //#endregion
1869
- //#region src/host/health/generation.ts
1711
+ // src/host/health/generation.ts
1870
1712
  /**
1871
1713
  * 会话日志的「代次」文件名契约(对齐宿主 session 操作核心)。
1872
1714
  *
@@ -2045,8 +1887,7 @@ function sessionPriority(facts) {
2045
1887
  if (facts.staging.length > 0) return "high";
2046
1888
  return (facts.current?.version ?? 0) > 0 ? "high" : "normal";
2047
1889
  }
2048
- //#endregion
2049
- //#region src/host/health/lossless.ts
1890
+ // src/host/health/lossless.ts
2050
1891
  /** 单个值是否可无损 JSON 序列化。 */
2051
1892
  function isLossless(value) {
2052
1893
  return firstLosslessViolation(value) === void 0;
@@ -2194,8 +2035,7 @@ function isPlainArray(value) {
2194
2035
  const prototype = Object.getPrototypeOf(value);
2195
2036
  return prototype === null || prototype === Array.prototype;
2196
2037
  }
2197
- //#endregion
2198
- //#region src/host/health/gates.ts
2038
+ // src/host/health/gates.ts
2199
2039
  /**
2200
2040
  * 五个体检 gate(每个可独立测试)与报告聚合。
2201
2041
  *
@@ -2514,8 +2354,7 @@ function buildSessionReport(context) {
2514
2354
  generatedAt: (context.now ?? Date.now)()
2515
2355
  };
2516
2356
  }
2517
- //#endregion
2518
- //#region src/host/health/repair.ts
2357
+ // src/host/health/repair.ts
2519
2358
  /**
2520
2359
  * 处方(repair):**只做三件可逆的事**,且每一件都先备份/可回退。
2521
2360
  *
@@ -2608,7 +2447,7 @@ function prescribe(report, dshHome) {
2608
2447
  } else if (pkg === "core") lines.push("# 1) 产出方是内置包,请升级 DSH 本体后在 issue 中附上体检报告");
2609
2448
  else lines.push("# 1) 归属未知:请人工核对宿主日志里 \"not lossless JSON\" 前的投影 key");
2610
2449
  lines.push("# 2) 隔离陈旧投影缓存记录(可逆,会提示重启宿主重折叠)");
2611
- lines.push(`# POST /session-steward/api/session-health-repair {"sessionId":"${report.sessionId}"}`);
2450
+ lines.push(`# POST /manage-session/api/session-health-repair {"sessionId":"${report.sessionId}"}`);
2612
2451
  lines.push("# 3) 重启宿主后重跑体检,确认 lossless-json 转 ok");
2613
2452
  return lines;
2614
2453
  }
@@ -2616,7 +2455,7 @@ function prescribe(report, dshHome) {
2616
2455
  if (isActionable(cache?.level)) {
2617
2456
  lines.push(`# 投影缓存异常:${cache?.evidence ?? "未知"}`);
2618
2457
  lines.push("# 1) 先隔离陈旧记录,再重启宿主让其重折叠");
2619
- lines.push(`# POST /session-steward/api/session-health-repair {"sessionId":"${report.sessionId}"}`);
2458
+ lines.push(`# POST /manage-session/api/session-health-repair {"sessionId":"${report.sessionId}"}`);
2620
2459
  }
2621
2460
  if (gate("cold-read")?.level === "fail") lines.push("# 会话仍处于 open step:请等待宿主结算(不要硬改日志),必要时重启宿主后重新体检");
2622
2461
  if (lines.length === 0) {
@@ -2672,8 +2511,7 @@ function assessRepair(input) {
2672
2511
  residual
2673
2512
  };
2674
2513
  }
2675
- //#endregion
2676
- //#region src/host/health/scan.ts
2514
+ // src/host/health/scan.ts
2677
2515
  /**
2678
2516
  * 批量体检:枚举会话日志、跑门、给出汇总(供 `session-health-scan` 使用)。
2679
2517
  *
@@ -2817,2206 +2655,215 @@ function scanSessions(options) {
2817
2655
  ...options.onlyProblems === true ? { filtered: true } : {}
2818
2656
  };
2819
2657
  }
2820
- /** Application id marking files owned by this plugin's index (ASCII "SWIS"). */
2821
- const SWITCH_SEARCH_APPLICATION_ID = 1398229332;
2822
- /**
2823
- * Open the raw handle: better-sqlite3 (synchronous, faster statement
2824
- * dispatch) when the optional dependency is present, node:sqlite otherwise.
2825
- * Both expose the same prepare/exec/close shape this plugin uses.
2826
- */
2827
- async function openRawHandle(actual) {
2828
- try {
2829
- const mod = await import("better-sqlite3");
2830
- return {
2831
- db: new (mod.default ?? mod)(actual),
2832
- driver: "better-sqlite3"
2833
- };
2834
- } catch {
2835
- const { DatabaseSync } = await import("node:sqlite");
2836
- return {
2837
- db: new DatabaseSync(actual),
2838
- driver: "node:sqlite"
2839
- };
2840
- }
2658
+ // src/index.ts
2659
+ /** 本插件声明的宿主服务(与 toggle 相同的注入面)。0.2.1-a.2 起 webRuntime 更名 webStartup,读取面走双名回退链。 */
2660
+ const inject = ["webServer", "webStartup"];
2661
+ /** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
2662
+ function readVolatileValue(value) {
2663
+ if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
2664
+ return value;
2841
2665
  }
2842
2666
  /**
2843
- * Open, validate, and initialize one switch-search index file.
2844
- * Missing directories and files are created; a file that belongs to another
2845
- * application (including the official session-query index) is refused.
2846
- * @param path - absolute path to the index file.
2847
- * @returns initialized database handle owned by the caller, plus the driver.
2667
+ * `.volatile()` 探针回退(preset-manager 的 volatile breakpoint 同款):宿主
2668
+ * schemastery 只在 0.1.7+ 线提供 `.volatile()`,老线上该方法是 undefined ——
2669
+ * 在模块加载期链式调用直接 TypeError,插件整包起不来。这里探测到才链,探测
2670
+ * 不到就返回普通字段(老线的配置只来自组合入口,无设置表单,行为正确)。
2848
2671
  */
2849
- async function openIndexDatabase(path) {
2850
- const actual = resolve(path);
2851
- await mkdir(dirname(actual), { recursive: true });
2852
- const { db, driver } = await openRawHandle(actual);
2672
+ function withVolatile(field) {
2673
+ if (field !== null && typeof field === "object" && typeof field.volatile === "function") return field.volatile();
2674
+ return field;
2675
+ }
2676
+ /** 运行时配置 schema(与 src/config.ts 的形状保持一致)。0.1.7+:volatile 字段即设置表单。 */
2677
+ const Config = z.object({
2678
+ enabled: withVolatile(z.boolean().default(true)),
2679
+ historyFiles: withVolatile(z.boolean().default(true)),
2680
+ healthCheck: withVolatile(z.boolean().default(true)),
2681
+ directDelete: withVolatile(z.boolean().default(false))
2682
+ });
2683
+ /** 单次请求体的上限(防御无界读取)。 */
2684
+ const MAX_BODY_BYTES = 16 << 20;
2685
+ /** 归一化 Host authority,无法解析时为 undefined。 */
2686
+ function parseAuthority(authority) {
2853
2687
  try {
2854
- const { application_id: applicationId } = db.prepare("PRAGMA application_id").get();
2855
- const { user_version: version } = db.prepare("PRAGMA user_version").get();
2856
- if (applicationId === 1146308689) throw new Error(`switch-search: "${actual}" is the official session-query index, refusing to open it`);
2857
- if (applicationId !== 0 && applicationId !== 1398229332) throw new Error(`switch-search: database at "${actual}" belongs to another application`);
2858
- if (applicationId === 1398229332 && version !== 5) resetSchema(db);
2859
- db.exec(`PRAGMA journal_mode = wal`);
2860
- ensureSchema(db);
2861
- db.exec(`PRAGMA synchronous = NORMAL`);
2862
- db.exec(`PRAGMA temp_store = MEMORY`);
2863
- db.exec(`PRAGMA cache_size = -65536`);
2864
- return {
2865
- db,
2866
- driver
2867
- };
2868
- } catch (error) {
2869
- db.close();
2870
- throw error;
2688
+ return new URL(`http://${authority}`);
2689
+ } catch {
2690
+ return;
2871
2691
  }
2872
2692
  }
2873
- /** Reset an incompatible database in place, preserving the owning application id. */
2874
- function resetSchema(db) {
2875
- db.exec(`
2876
- PRAGMA writable_schema = OFF;
2877
- PRAGMA journal_mode = delete;
2878
- `);
2879
- for (const row of db.prepare(`
2880
- SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
2881
- `).all()) db.exec(`DROP TABLE IF EXISTS "${row.name.replace(/"/g, "\"\"")}"`);
2882
- db.exec(`PRAGMA user_version = 0`);
2883
- }
2884
- /** Create the persistent schema when absent. */
2885
- function ensureSchema(db) {
2886
- db.exec(`
2887
- CREATE TABLE IF NOT EXISTS meta (
2888
- key TEXT PRIMARY KEY,
2889
- value TEXT NOT NULL
2890
- );
2891
- CREATE TABLE IF NOT EXISTS sessions (
2892
- session_id TEXT PRIMARY KEY,
2893
- version INTEGER NOT NULL DEFAULT 0,
2894
- title TEXT NOT NULL DEFAULT '',
2895
- cwd TEXT NOT NULL DEFAULT '',
2896
- updated_at INTEGER NOT NULL DEFAULT 0,
2897
- indexed_at INTEGER NOT NULL DEFAULT 0,
2898
- archived INTEGER NOT NULL DEFAULT 0
2899
- );
2900
- CREATE TABLE IF NOT EXISTS docs (
2901
- doc_id INTEGER PRIMARY KEY AUTOINCREMENT,
2902
- session_id TEXT NOT NULL,
2903
- seq INTEGER NOT NULL,
2904
- type TEXT NOT NULL,
2905
- surface TEXT NOT NULL,
2906
- time INTEGER NOT NULL,
2907
- text TEXT NOT NULL,
2908
- index_text TEXT NOT NULL DEFAULT ''
2909
- );
2910
- CREATE UNIQUE INDEX IF NOT EXISTS docs_session_seq ON docs(session_id, seq);
2911
- CREATE INDEX IF NOT EXISTS docs_session ON docs(session_id);
2912
- CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
2913
- index_text,
2914
- content = 'docs',
2915
- content_rowid = 'doc_id',
2916
- tokenize = 'unicode61'
2917
- );
2918
- `);
2919
- db.exec(`PRAGMA application_id = ${SWITCH_SEARCH_APPLICATION_ID}`);
2920
- db.exec(`PRAGMA user_version = 5`);
2693
+ /** 主机名是否本机回环。 */
2694
+ function isLoopbackHostname(hostname) {
2695
+ if (hostname === "localhost" || hostname === "[::1]") return true;
2696
+ const parts = hostname.split(".");
2697
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
2921
2698
  }
2922
- //#endregion
2923
- //#region src/host/index/extract.ts
2924
- /**
2925
- * 从会话事件流里抽出**最后一帧标题**(`session/title` log-only 事件)。
2926
- *
2927
- * 这是标题的基础层:入索引时随事件白拿,不依赖 readTitleSnapshots(后者
2928
- * 对归档 id 可能整批失败——beta.4 标题全军覆没的根因)。数据形状按历史
2929
- * 演变兼容三种:`{ title: string }`、`{ title: { title } }`、`{ title: { val } }`。
2930
- */
2931
- function extractTitleFromEvents(events) {
2932
- for (let i = events.length - 1; i >= 0; i -= 1) {
2933
- const event = events[i];
2934
- if (event === null || typeof event !== "object") continue;
2935
- if (event.type !== "session/title") continue;
2936
- const title = event.data?.title;
2937
- if (typeof title === "string" && title.trim() !== "") return title;
2938
- if (title !== null && typeof title === "object") {
2939
- const inner = title;
2940
- if (typeof inner.title === "string" && inner.title.trim() !== "") return inner.title;
2941
- if (typeof inner.val === "string" && inner.val.trim() !== "") return inner.val;
2942
- }
2943
- }
2944
- return "";
2699
+ /** Host 是否命中 trustedHosts(精确或省略端口)。 */
2700
+ function isTrustedAuthority(hostUrl, trustedHosts) {
2701
+ return trustedHosts.some((entry) => {
2702
+ const entryUrl = parseAuthority(entry);
2703
+ if (entryUrl === void 0) return false;
2704
+ return (entryUrl.port === "" ? entryUrl.hostname : entryUrl.host) === hostUrl.host;
2705
+ });
2945
2706
  }
2946
- /**
2947
- * ICU word segmenter shared by index and query paths (Node >= 16 / all evergreen
2948
- * browsers, zero dependency). 'zh' sensitivity keeps CJK word granularity.
2949
- */
2950
- const SEGMENTER = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter("zh", { granularity: "word" }) : void 0;
2951
- /**
2952
- * Space-separate word boundaries so the FTS5 unicode61 tokenizer indexes
2953
- * words instead of whole CJK runs: the index and query sides must apply the
2954
- * exact same segmentation for a token to meet its match.
2955
- * @param text - raw extracted text (or a query term).
2956
- * @returns text with a single space at every word boundary.
2957
- */
2958
- function segmentForIndex(text) {
2959
- if (SEGMENTER === void 0) return text;
2960
- const parts = [];
2961
- for (const { segment } of SEGMENTER.segment(text)) {
2962
- const piece = segment.trim();
2963
- if (piece !== "") parts.push(piece);
2707
+ /** 浏览器可信围栏(与 /api 网关行为一致):DNS 重绑定/跨站防御,不是鉴权。 */
2708
+ function isTrustedApiRequest(req, trustedHosts) {
2709
+ const host = req.headers.host;
2710
+ if (host === void 0) return false;
2711
+ const hostUrl = parseAuthority(host);
2712
+ if (hostUrl === void 0) return false;
2713
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
2714
+ const fetchSite = req.headers["sec-fetch-site"];
2715
+ if (typeof fetchSite === "string" && fetchSite === "cross-site") return false;
2716
+ const origin = req.headers.origin;
2717
+ if (origin === void 0) return true;
2718
+ try {
2719
+ return new URL(origin).host === hostUrl.host;
2720
+ } catch {
2721
+ return false;
2964
2722
  }
2965
- return parts.join(" ");
2966
2723
  }
2967
- /**
2968
- * Segment one whitespace-delimited query term into FTS5 phrase tokens.
2969
- * @returns word-like segments, or the raw term when segmentation is unavailable.
2970
- */
2971
- function segmentQueryTerm(term) {
2972
- if (SEGMENTER === void 0) return [term];
2973
- const words = [];
2974
- for (const { segment, isWordLike } of SEGMENTER.segment(term)) {
2975
- const piece = segment.trim();
2976
- if (piece !== "" && isWordLike === true) words.push(piece);
2724
+ /** 读原始请求体(有界)。 */
2725
+ async function readRawBody(req) {
2726
+ const chunks = [];
2727
+ let total = 0;
2728
+ for await (const chunk of req) {
2729
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2730
+ total += buffer.length;
2731
+ if (total > MAX_BODY_BYTES) throw new Error("request body too large");
2732
+ chunks.push(buffer);
2977
2733
  }
2978
- return words.length > 0 ? words : [term];
2979
- }
2980
- /** Whether a runtime value is a plain record. */
2981
- function isRecord(value) {
2982
- return typeof value === "object" && value !== null && !Array.isArray(value);
2983
- }
2984
- /** Trimmed text of one string-or-undefined part list, joined by newlines. */
2985
- function joinText(parts) {
2986
- return parts.map((part) => typeof part === "string" ? part.trim() : "").filter(Boolean).join("\n");
2734
+ return Buffer.concat(chunks).toString("utf8");
2987
2735
  }
2988
- /** Extracted text of one content block; unknown blocks contribute nothing. */
2989
- function blockText(block) {
2990
- if (!isRecord(block)) return [];
2991
- switch (block["type"]) {
2992
- case "text": return typeof block["text"] === "string" ? [block["text"]] : [];
2993
- case "reasoning": return [];
2994
- case "tool-call": return [block["name"], block["arguments"]].filter((v) => typeof v === "string");
2995
- case "tool-result": {
2996
- const content = block["content"];
2997
- return Array.isArray(content) ? content.flatMap(blockText) : [];
2998
- }
2999
- default: return [];
2736
+ /** 读 JSON 请求体(空体视为 {})。 */
2737
+ async function readJsonBody(req) {
2738
+ const text = await readRawBody(req);
2739
+ if (text.trim() === "") return {};
2740
+ try {
2741
+ return JSON.parse(text);
2742
+ } catch {
2743
+ throw new Error("request body is not JSON");
3000
2744
  }
3001
2745
  }
3002
- /** Text of one message content (array of blocks, or a plain string). */
3003
- function contentText(content) {
3004
- if (typeof content === "string") return content.trim();
3005
- if (!Array.isArray(content)) return "";
3006
- return joinText(content.flatMap(blockText));
2746
+ /** 写 JSON 响应。 */
2747
+ function writeJson(res, status, body) {
2748
+ const text = JSON.stringify(body);
2749
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
2750
+ res.end(text);
3007
2751
  }
3008
- /** Turn-end reason text; completed turns contribute nothing. */
3009
- function turnEndText(data) {
3010
- const reason = data["reason"];
3011
- if (!isRecord(reason)) return "";
3012
- switch (reason["kind"]) {
3013
- case "error": {
3014
- const error = reason["error"];
3015
- return joinText(["error", isRecord(error) && typeof error["message"] === "string" ? error["message"] : ""]);
3016
- }
3017
- case "aborted": return "aborted";
3018
- case "max-tokens":
3019
- case "interrupted": return String(reason["kind"]);
3020
- case "completed": return "";
3021
- default: return "";
3022
- }
2752
+ /** 会话 id 形状校验(拒绝任意字符串进入路径拼接)。 */
2753
+ function asSessionId(value) {
2754
+ if (typeof value !== "string") return void 0;
2755
+ const trimmed = value.trim();
2756
+ if (trimmed === "" || trimmed.length > 200) return void 0;
2757
+ if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return void 0;
2758
+ return trimmed;
3023
2759
  }
2760
+ /** 单会话体检的最大扫描上限。 */
2761
+ const MAX_SCAN_LIMIT = 200;
3024
2762
  /**
3025
- * Extract searchable semantic text from one raw session event.
3026
- * @param event - event to inspect.
3027
- * @returns newline-joined semantic text, or an empty string when non-searchable.
2763
+ * 支持的路由方法(按子域分组;用于对外声明与测试断言)。
2764
+ *
2765
+ * `session-history-prune` 与 `session-history-purge` 是**两件事**,不可合并:
2766
+ * prune = 取消归档状态(可逆,会话回到侧边栏);purge = 清理归档文件(不可逆,真删实体)。
3028
2767
  */
3029
- function extractSessionEventText(event) {
3030
- const data = isRecord(event.data) ? event.data : {};
3031
- switch (event.type) {
3032
- case "user/message": return contentText(data["content"]);
3033
- case "assistant/message": return contentText((isRecord(data["message"]) ? data["message"] : {})["content"]);
3034
- case "tool/call": return joinText([data["name"], data["arguments"]].filter((v) => typeof v === "string"));
3035
- case "tool/result": {
3036
- const message = isRecord(data["message"]) ? data["message"] : {};
3037
- const error = isRecord(data["error"]) ? data["error"] : {};
3038
- return joinText([
3039
- contentText(message["content"]),
3040
- typeof error["name"] === "string" ? error["name"] : "",
3041
- typeof error["code"] === "string" ? error["code"] : ""
3042
- ]);
3043
- }
3044
- case "todo/write": return joinText((Array.isArray(data["todos"]) ? data["todos"] : []).flatMap((todo) => {
3045
- if (!isRecord(todo)) return [];
3046
- return [todo["status"], todo["content"]].filter((v) => typeof v === "string");
3047
- }));
3048
- case "turn/end": return turnEndText(data);
3049
- case "turn/start":
3050
- case "step/start":
3051
- case "step/end":
3052
- case "assistant/attempt":
3053
- case "request/header": return "";
3054
- default: return "";
2768
+ const HISTORY_METHODS = [
2769
+ "session-history-list",
2770
+ "session-history-archive",
2771
+ "session-history-prune",
2772
+ "session-history-purge",
2773
+ "session-history-favorites-list",
2774
+ "session-history-favorite-set"
2775
+ ];
2776
+ const HEALTH_METHODS = [
2777
+ "session-health-status",
2778
+ "session-health-scan",
2779
+ "session-health-session",
2780
+ "session-health-repair",
2781
+ "session-health-source-migrate"
2782
+ ];
2783
+ /** 依据开关判定某方法是否启用。 */
2784
+ function methodEnabled(method, config) {
2785
+ if (method === "session-catalog") return config.enabled !== false;
2786
+ if (HISTORY_METHODS.includes(method)) return config.historyFiles !== false;
2787
+ if (HEALTH_METHODS.includes(method)) return config.healthCheck !== false;
2788
+ return false;
2789
+ }
2790
+ /** Fold titles for a set of sessions into a sessionId → title map. */
2791
+ async function titleMap(sessionQuery, sessionIds) {
2792
+ if (sessionIds.length === 0) return /* @__PURE__ */ new Map();
2793
+ const observations = await sessionQuery.readTitleSnapshots([...new Set(sessionIds)]);
2794
+ const map = /* @__PURE__ */ new Map();
2795
+ for (const observation of observations) {
2796
+ if (observation.status !== "fulfilled" || observation.value === void 0) continue;
2797
+ const title = observation.value.title?.title;
2798
+ if (typeof title === "string" && title.trim().length > 0) map.set(observation.value.session.id, title);
3055
2799
  }
2800
+ return map;
3056
2801
  }
3057
- /** Event types whose ops participate in the surface fold. */
3058
- const SURFACE_ELIGIBLE_TYPES = /* @__PURE__ */ new Set([
3059
- "user/message",
3060
- "assistant/message",
3061
- "tool/call",
3062
- "tool/result"
3063
- ]);
3064
- /**
3065
- * Classify raw-log events into current vs shadowed surface membership.
3066
- *
3067
- * Simplified fold of the official `foldSurface`: append events join the
3068
- * surface, and a `replace` op shadows the declared inclusive seq range plus
3069
- * removes it from the surface. Validation is intentionally lax — a broken op
3070
- * degrades to append rather than failing the whole index build.
3071
- * @param events - complete contiguous raw event log.
3072
- * @returns seq → surface map; entries absent from the map are log-only.
3073
- */
3074
- function classifySurface(events) {
3075
- const surface = /* @__PURE__ */ new Map();
3076
- const nodes = [];
3077
- for (const event of events) {
3078
- if (!SURFACE_ELIGIBLE_TYPES.has(event.type)) continue;
3079
- const op = event.surfaceOp;
3080
- if (op === "append" || op === void 0) {
3081
- nodes.push(event.seq);
3082
- surface.set(event.seq, "current");
3083
- continue;
3084
- }
3085
- if (isRecord(op) && op["op"] === "replace" && typeof op["startSeq"] === "number" && typeof op["endSeq"] === "number") {
3086
- const shadowed = /* @__PURE__ */ new Set();
3087
- for (const seq of nodes) if (seq >= op["startSeq"] && seq <= op["endSeq"]) shadowed.add(seq);
3088
- const kept = nodes.filter((seq) => !shadowed.has(seq));
3089
- nodes.length = 0;
3090
- nodes.push(...kept, event.seq);
3091
- for (const seq of shadowed) surface.set(seq, "shadowed");
3092
- surface.set(event.seq, "current");
3093
- continue;
2802
+ /** session-catalog: 管理台语料(活跃 + 归档同表,无自有索引、无 DRS)。 */
2803
+ async function listSessions(runtime) {
2804
+ const sessionQuery = runtime.sessionQuery;
2805
+ if (sessionQuery === void 0) return {
2806
+ ok: false,
2807
+ error: "sessionQuery 服务不可用,无法列出会话"
2808
+ };
2809
+ const archivedIds = new Set(readArchiveSet(runtime.registry(), storagePathsFor(runtime.dshHome)).ids);
2810
+ try {
2811
+ const records = await sessionQuery.listSessions();
2812
+ const titles = await titleMap(sessionQuery, records.map((record) => record.header.id));
2813
+ const seen = /* @__PURE__ */ new Set();
2814
+ const items = records.map((record) => {
2815
+ seen.add(record.header.id);
2816
+ return {
2817
+ sessionId: record.header.id,
2818
+ title: titles.get(record.header.id) ?? "",
2819
+ cwd: record.header.cwd ?? "",
2820
+ updatedAt: record.header.createdAt,
2821
+ archived: archivedIds.has(record.header.id)
2822
+ };
2823
+ });
2824
+ for (const sessionId of archivedIds) {
2825
+ if (seen.has(sessionId)) continue;
2826
+ items.push({
2827
+ sessionId,
2828
+ title: "",
2829
+ cwd: "",
2830
+ updatedAt: 0,
2831
+ archived: true
2832
+ });
3094
2833
  }
3095
- nodes.push(event.seq);
3096
- surface.set(event.seq, "current");
2834
+ return {
2835
+ ok: true,
2836
+ items
2837
+ };
2838
+ } catch (err) {
2839
+ return {
2840
+ ok: false,
2841
+ error: String(err instanceof Error ? err.message : err)
2842
+ };
3097
2843
  }
3098
- return surface;
3099
2844
  }
3100
- /**
3101
- * Project one complete raw log into searchable documents.
3102
- * @param sessionId - session that owns the log.
3103
- * @param events - complete contiguous raw event log.
3104
- * @returns documents in ascending seq order; structural events are omitted.
3105
- */
3106
- function buildIndexDocuments(sessionId, events) {
3107
- const surfaceBySeq = classifySurface(events);
3108
- const documents = [];
3109
- for (const event of events) {
3110
- const text = extractSessionEventText(event);
3111
- if (text.length === 0) continue;
3112
- documents.push({
3113
- sessionId,
3114
- seq: event.seq,
3115
- type: event.type,
3116
- time: typeof event.time === "number" ? event.time : 0,
3117
- surface: surfaceBySeq.get(event.seq) ?? "shadowed",
3118
- text
2845
+ /** 逐 id 串行调用官方内存方法;失败按会话隔离收集(AM 批量语义)。 */
2846
+ async function runMemoryArchiveOps(registry, ids, op) {
2847
+ const fn = op === "archive" ? registry.archiveSession : registry.unarchiveSession;
2848
+ const succeeded = [];
2849
+ const failures = [];
2850
+ for (const id of ids) try {
2851
+ await fn.call(registry, id);
2852
+ succeeded.push(id);
2853
+ } catch (err) {
2854
+ failures.push({
2855
+ sessionId: id,
2856
+ reason: String(err instanceof Error ? err.message : err)
3119
2857
  });
3120
2858
  }
3121
- return documents;
3122
- }
3123
- //#endregion
3124
- //#region src/host/index/engine.ts
3125
- /** Coarse filter → raw event types. */
3126
- const CONTENT_TYPE_GROUPS = {
3127
- user: ["user/message"],
3128
- reply: ["assistant/message"],
3129
- tool: ["tool/call", "tool/result"]
3130
- };
3131
- /** Search weight per event type (message content outranks tool chatter). */
3132
- const TYPE_WEIGHT = {
3133
- "user/message": 3,
3134
- "assistant/message": 3,
3135
- "tool/call": 1,
3136
- "tool/result": 1
3137
- };
3138
- /** Maximum snippet length in characters, aligned with the official route. */
3139
- const SNIPPET_CHARS = 240;
3140
- /** Maximum FTS matches inspected per query before session grouping. */
3141
- const MATCH_SCAN_LIMIT = 5e3;
3142
- /**
3143
- * Sanitize free text into a safe FTS5 query over the segmented index: each
3144
- * whitespace term is segmented into word tokens, quoted as an adjacent
3145
- * phrase, and the last token carries a prefix `*` so partial input matches
3146
- * ("正在搜" hits 正在搜索). Terms AND together.
3147
- */
3148
- function sanitizeFtsQuery(query) {
3149
- const terms = query.split(/\s+/u).filter(Boolean);
3150
- if (terms.length === 0) return "";
3151
- const phrases = [];
3152
- for (const term of terms) {
3153
- const words = segmentQueryTerm(term).map((word) => word.replace(/"/g, "\"\"")).filter((word) => word !== "");
3154
- if (words.length === 0) continue;
3155
- phrases.push(`"${words.join(" ")}"*`);
3156
- }
3157
- return phrases.join(" ");
3158
- }
3159
- /** Build a snippet around the first term occurrence, official-route aligned. */
3160
- function buildSnippet(text, query, max = SNIPPET_CHARS) {
3161
- const flat = text.replace(/\s+/gu, " ").trim();
3162
- if (flat.length <= max) return flat;
3163
- const lower = flat.toLowerCase();
3164
- const terms = query.toLowerCase().split(/\s+/u).filter(Boolean);
3165
- let anchor = -1;
3166
- for (const term of terms) {
3167
- const at = lower.indexOf(term);
3168
- if (at >= 0 && (anchor < 0 || at < anchor)) anchor = at;
3169
- }
3170
- if (anchor < 0) return `${flat.slice(0, max)}…`;
3171
- const start = Math.max(0, anchor - Math.floor((max - 3) / 2));
3172
- const end = Math.min(flat.length, start + max - 3);
3173
- const head = start > 0 ? "…" : "";
3174
- const tail = end < flat.length ? "…" : "";
3175
- return `${head}${flat.slice(start, end)}${tail}`;
3176
- }
3177
- /** One open index handle. All mutating calls are synchronous; callers pace
3178
- * them off the HTTP hot path (background sync / rebuild tasks). */
3179
- var SwitchIndexEngine = class {
3180
- options;
3181
- db;
3182
- driver = "node:sqlite";
3183
- inBatch = false;
3184
- constructor(options) {
3185
- this.options = options;
3186
- }
3187
- /** Which SQLite driver is serving this handle. */
3188
- get driverLabel() {
3189
- return this.driver;
3190
- }
3191
- /**
3192
- * Run one write inside the current batched transaction, or its own
3193
- * IMMEDIATE transaction when not batching (nested calls join the batch).
3194
- */
3195
- withWriteTx(fn) {
3196
- const db = this.requireDb();
3197
- if (this.inBatch) return fn();
3198
- db.exec("BEGIN IMMEDIATE");
3199
- this.inBatch = true;
3200
- try {
3201
- const result = fn();
3202
- db.exec("COMMIT");
3203
- return result;
3204
- } catch (error) {
3205
- try {
3206
- db.exec("ROLLBACK");
3207
- } catch {}
3208
- throw error;
3209
- } finally {
3210
- this.inBatch = false;
3211
- }
3212
- }
3213
- /**
3214
- * Run one function as a single batched transaction (one fsync checkpoint):
3215
- * upserts inside it join via withWriteTx instead of opening their own.
3216
- */
3217
- runBatched(fn) {
3218
- return this.withWriteTx(fn);
3219
- }
3220
- /** Whether the handle is open. */
3221
- get isOpen() {
3222
- return this.db !== void 0;
3223
- }
3224
- /** Open (creating or migrating) the index file. Idempotent. */
3225
- async open() {
3226
- if (this.db !== void 0) return;
3227
- const opened = await openIndexDatabase(this.options.path);
3228
- this.db = opened.db;
3229
- this.driver = opened.driver;
3230
- }
3231
- /** Close the handle. Idempotent. */
3232
- close() {
3233
- this.db?.close();
3234
- this.db = void 0;
3235
- }
3236
- /** Remove one session's FTS entries for external-content bookkeeping. */
3237
- deleteSessionFts(db, sessionId) {
3238
- const existing = db.prepare("SELECT doc_id, index_text FROM docs WHERE session_id = ?").all(sessionId);
3239
- const deleteFts = db.prepare(`
3240
- INSERT INTO docs_fts(docs_fts, rowid, index_text) VALUES ('delete', ?, ?)
3241
- `);
3242
- for (const row of existing) deleteFts.run(Number(row.doc_id), row.index_text);
3243
- }
3244
- /** Insert or replace one session's documents and header row. */
3245
- upsertSession(input) {
3246
- const db = this.requireDb();
3247
- const documents = buildIndexDocuments(input.sessionId, input.events);
3248
- return this.withWriteTx(() => {
3249
- this.deleteSessionFts(db, input.sessionId);
3250
- db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
3251
- const insertDoc = db.prepare(`
3252
- INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
3253
- VALUES (?, ?, ?, ?, ?, ?, ?)
3254
- `);
3255
- const insertFts = db.prepare("INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)");
3256
- for (const doc of documents) {
3257
- const indexText = segmentForIndex(doc.text);
3258
- const result = insertDoc.run(doc.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText);
3259
- insertFts.run(Number(result.lastInsertRowid), indexText);
3260
- }
3261
- db.prepare(`
3262
- INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
3263
- VALUES (?, ?, ?, ?, ?, ?, ?)
3264
- ON CONFLICT(session_id) DO UPDATE SET
3265
- version = excluded.version,
3266
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
3267
- cwd = excluded.cwd,
3268
- updated_at = excluded.updated_at,
3269
- indexed_at = excluded.indexed_at
3270
- `).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
3271
- return documents.length;
3272
- });
3273
- }
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
- /**
3302
- * Write an archived session's header row without any document content.
3303
- *
3304
- * @deprecated 旧"归档即软删"语义的遗物,合并包 R1 已改为归档保留 docs;仅剩
3305
- * 旧快照兼容路径可落 header 行。新代码一律 upsertSession + setArchived。
3306
- */
3307
- upsertArchivedHeader(input) {
3308
- const db = this.requireDb();
3309
- this.withWriteTx(() => {
3310
- this.deleteSessionFts(db, input.sessionId);
3311
- db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
3312
- db.prepare(`
3313
- INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
3314
- VALUES (?, ?, ?, ?, ?, ?, 1)
3315
- ON CONFLICT(session_id) DO UPDATE SET
3316
- version = excluded.version,
3317
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
3318
- cwd = excluded.cwd,
3319
- updated_at = excluded.updated_at,
3320
- indexed_at = excluded.indexed_at,
3321
- archived = 1
3322
- `).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now());
3323
- });
3324
- }
3325
- /**
3326
- * Apply the official archive set: flip the archived flag both ways.
3327
- *
3328
- * 合表复用语义(R1):归档**保留全部 docs 与 FTS**——正文在归档态不可变
3329
- * (归档门只拒新回合),翻转只是检索域成员资格的变化;恢复也不再需要重灌
3330
- * (旧实现的 version = -1 强制重读随"归档即删 docs"一并废除)。
3331
- */
3332
- setArchived(archivedIds) {
3333
- const db = this.requireDb();
3334
- this.withWriteTx(() => {
3335
- const rows = db.prepare("SELECT session_id, archived FROM sessions").all();
3336
- for (const row of rows) {
3337
- const shouldBe = archivedIds.has(row.session_id) ? 1 : 0;
3338
- if (row.archived !== shouldBe) db.prepare("UPDATE sessions SET archived = ? WHERE session_id = ?").run(shouldBe, row.session_id);
3339
- }
3340
- });
3341
- }
3342
- /**
3343
- * Flip one session's archived flag (steward write-side linkage). No-op when
3344
- * the row does not exist — flag state on an unindexed session is meaningless.
3345
- */
3346
- setArchivedOne(sessionId, archived) {
3347
- const db = this.requireDb();
3348
- this.withWriteTx(() => {
3349
- db.prepare("UPDATE sessions SET archived = ? WHERE session_id = ?").run(archived ? 1 : 0, sessionId);
3350
- });
3351
- }
3352
- /** One session's stored documents, ascending seq (snapshot export face). */
3353
- exportSessionDocs(sessionId) {
3354
- return this.requireDb().prepare(`
3355
- SELECT seq, type, surface, time, text FROM docs WHERE session_id = ? ORDER BY seq
3356
- `).all(sessionId);
3357
- }
3358
- /**
3359
- * Insert or replace one session from already-extracted documents
3360
- * (snapshot import face; no re-extraction, what was exported is restored;
3361
- * segmentation is recomputed for the current index format).
3362
- */
3363
- importSessionDocs(input) {
3364
- const db = this.requireDb();
3365
- this.withWriteTx(() => {
3366
- this.deleteSessionFts(db, input.sessionId);
3367
- db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
3368
- const insertDoc = db.prepare(`
3369
- INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
3370
- VALUES (?, ?, ?, ?, ?, ?, ?)
3371
- `);
3372
- const insertFts = db.prepare("INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)");
3373
- for (const doc of input.docs) {
3374
- const indexText = segmentForIndex(doc.text);
3375
- const result = insertDoc.run(input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText);
3376
- insertFts.run(Number(result.lastInsertRowid), indexText);
3377
- }
3378
- db.prepare(`
3379
- INSERT INTO sessions (session_id, version, title, updated_at, indexed_at, archived)
3380
- VALUES (?, ?, ?, 0, ?, ?)
3381
- ON CONFLICT(session_id) DO UPDATE SET
3382
- version = excluded.version,
3383
- title = excluded.title,
3384
- indexed_at = excluded.indexed_at,
3385
- archived = excluded.archived
3386
- `).run(input.sessionId, input.version, input.title ?? "", Date.now(), input.archived === true ? 1 : 0);
3387
- });
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
- }
3448
- /** Remove one session and its documents entirely. */
3449
- removeSession(sessionId) {
3450
- const db = this.requireDb();
3451
- this.withWriteTx(() => {
3452
- this.deleteSessionFts(db, sessionId);
3453
- db.prepare("DELETE FROM docs WHERE session_id = ?").run(sessionId);
3454
- db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
3455
- });
3456
- }
3457
- /** Update only a session's header row (title backfill), keeping documents. */
3458
- updateSessionHeader(input) {
3459
- const db = this.requireDb();
3460
- if (db.prepare("SELECT version FROM sessions WHERE session_id = ?").get(input.sessionId) === void 0) return;
3461
- db.prepare(`
3462
- UPDATE sessions SET
3463
- title = CASE WHEN ? != '' THEN ? ELSE title END,
3464
- cwd = CASE WHEN ? != '' THEN ? ELSE cwd END,
3465
- updated_at = CASE WHEN ? > 0 THEN ? ELSE updated_at END
3466
- WHERE session_id = ?
3467
- `).run(input.title ?? "", input.title ?? "", input.cwd ?? "", input.cwd ?? "", input.updatedAt ?? 0, input.updatedAt ?? 0, input.sessionId);
3468
- }
3469
- /** One indexed session row, or undefined. */
3470
- getSession(sessionId) {
3471
- const row = this.requireDb().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
3472
- return row === void 0 ? void 0 : rowToSession(row);
3473
- }
3474
- /** All indexed sessions (active AND archived), newest first — the title
3475
- * corpus and the manage console both read this; the `archived` flag rides
3476
- * each row so clients filter locally. */
3477
- listIndexedSessions() {
3478
- return this.requireDb().prepare("SELECT * FROM sessions ORDER BY updated_at DESC").all().map(rowToSession);
3479
- }
3480
- /** Archived (soft-deleted) sessions, newest first — the archive viewer face. */
3481
- listArchived() {
3482
- return this.requireDb().prepare("SELECT * FROM sessions WHERE archived = 1 ORDER BY updated_at DESC").all().map(rowToSession);
3483
- }
3484
- /** Number of indexed sessions (active + archived — the whole corpus). */
3485
- countSessions() {
3486
- const row = this.requireDb().prepare("SELECT COUNT(*) AS n FROM sessions").get();
3487
- return Number(row.n);
3488
- }
3489
- /** Number of archived (soft-deleted) sessions. */
3490
- countArchived() {
3491
- const row = this.requireDb().prepare("SELECT COUNT(*) AS n FROM sessions WHERE archived = 1").get();
3492
- return Number(row.n);
3493
- }
3494
- /**
3495
- * Run one session-grouped full-text search.
3496
- *
3497
- * One statement: the FTS match is bounded by rank in a subquery (its rowid
3498
- * aligns with docs.doc_id), then the type/surface filters join in — no
3499
- * second round-trip, no large IN parameter lists.
3500
- * @param request - query text, coarse type filter, page size, ordering.
3501
- * @returns hits ordered by `sortBy` (relevance by default).
3502
- */
3503
- search(request) {
3504
- const db = this.requireDb();
3505
- const match = sanitizeFtsQuery(request.query);
3506
- if (match === "") return [];
3507
- const limit = Math.min(Math.max(1, request.limit ?? 20), 100);
3508
- const types = resolveTypes(request.types);
3509
- const placeholders = types.map(() => "?").join(", ");
3510
- const archivedClause = request.archived === "active" ? "AND s.archived = 0" : request.archived === "archived" ? "AND s.archived = 1" : "";
3511
- const dateClauses = [];
3512
- const dateParams = [];
3513
- if (typeof request.from === "number" && Number.isFinite(request.from)) {
3514
- dateClauses.push("AND s.updated_at >= ?");
3515
- dateParams.push(request.from);
3516
- }
3517
- if (typeof request.to === "number" && Number.isFinite(request.to)) {
3518
- dateClauses.push("AND s.updated_at < ?");
3519
- dateParams.push(request.to);
3520
- }
3521
- const dateClause = dateClauses.join(" ");
3522
- const docs = db.prepare(`
3523
- SELECT d.doc_id AS docId, d.session_id AS sessionId, d.seq, d.type, d.time, d.text,
3524
- s.title, s.updated_at AS updatedAt, f.rank AS ftsRank
3525
- FROM (
3526
- SELECT rowid, rank FROM docs_fts WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?
3527
- ) f
3528
- JOIN docs d ON d.doc_id = f.rowid
3529
- JOIN sessions s ON s.session_id = d.session_id
3530
- WHERE d.type IN (${placeholders}) AND d.surface = 'current' ${archivedClause} ${dateClause}
3531
- `).all(match, MATCH_SCAN_LIMIT, ...types, ...dateParams);
3532
- const bestBySession = /* @__PURE__ */ new Map();
3533
- for (const doc of docs) {
3534
- const weight = TYPE_WEIGHT[doc.type] ?? 1;
3535
- const score = -Number(doc.ftsRank) * weight;
3536
- const best = bestBySession.get(doc.sessionId);
3537
- if (best === void 0 || score > best.score) bestBySession.set(doc.sessionId, {
3538
- doc,
3539
- score
3540
- });
3541
- }
3542
- const grouped = [...bestBySession.values()];
3543
- if (request.sortBy === "time") grouped.sort((a, b) => b.doc.updatedAt - a.doc.updatedAt || b.score - a.score);
3544
- else grouped.sort((a, b) => b.score - a.score);
3545
- return grouped.slice(0, limit).map(({ doc }) => ({
3546
- sessionId: doc.sessionId,
3547
- title: doc.title,
3548
- seq: doc.seq,
3549
- type: doc.type,
3550
- time: doc.time,
3551
- updatedAt: Number(doc.updatedAt ?? 0),
3552
- snippet: buildSnippet(doc.text, request.query)
3553
- }));
3554
- }
3555
- requireDb() {
3556
- if (this.db === void 0) throw new Error("switch-search: index engine is not open");
3557
- return this.db;
3558
- }
3559
- };
3560
- /** Materialize the coarse filter into raw event types (absent → user+reply). */
3561
- function resolveTypes(types) {
3562
- if (types === void 0 || types.length === 0) return ["user/message", "assistant/message"];
3563
- const picked = /* @__PURE__ */ new Set();
3564
- for (const entry of types) if (entry === "user" || entry === "reply" || entry === "tool") picked.add(entry);
3565
- else return [
3566
- "user/message",
3567
- "assistant/message",
3568
- "tool/call",
3569
- "tool/result"
3570
- ];
3571
- if (picked.size === 0) return ["user/message", "assistant/message"];
3572
- return [...picked].flatMap((entry) => [...CONTENT_TYPE_GROUPS[entry]]);
3573
- }
3574
- /** Map one raw sessions row onto the public face. */
3575
- function rowToSession(row) {
3576
- return {
3577
- sessionId: String(row["session_id"]),
3578
- version: Number(row["version"]),
3579
- title: String(row["title"] ?? ""),
3580
- cwd: String(row["cwd"] ?? ""),
3581
- updatedAt: Number(row["updated_at"] ?? 0),
3582
- indexedAt: Number(row["indexed_at"] ?? 0),
3583
- archived: Number(row["archived"] ?? 0) === 1
3584
- };
3585
- }
3586
- //#endregion
3587
- //#region src/host/index/sync.ts
3588
- /** 标题折叠的分块大小(一次 readTitleSnapshots 的 id 数上限,防整批一坏全坏)。 */
3589
- const TITLE_CHUNK = 50;
3590
- /**
3591
- * One watermark syncer bound to one open engine. `poll()` is re-entrant-safe:
3592
- * overlapping calls collapse into the running pass.
3593
- */
3594
- var SwitchWatermarkSync = class SwitchWatermarkSync {
3595
- engine;
3596
- sessionQuery;
3597
- readArchiveSource;
3598
- log;
3599
- readSessionFromFile;
3600
- readProjectionTitle;
3601
- drsSource;
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
- }
3612
- state = {
3613
- state: "idle",
3614
- lastSyncAt: 0,
3615
- indexed: 0,
3616
- total: 0,
3617
- updated: 0,
3618
- failures: [],
3619
- hollow: 0
3620
- };
3621
- /**
3622
- * @param readSessionFromFile - 可选的**文件级兜底读取器**(管家 health 侧的
3623
- * 多帧 zstd 读取器):sessionQuery.readSession 对某些会话(典型:归档会话,
3624
- * 或服务面退化)失败时改读转录文件。返回 undefined 表示文件也不可用。
3625
- */
3626
- constructor(engine, sessionQuery, readArchiveSource, log, readSessionFromFile, readProjectionTitle, drsSource) {
3627
- this.engine = engine;
3628
- this.sessionQuery = sessionQuery;
3629
- this.readArchiveSource = readArchiveSource;
3630
- this.log = log;
3631
- this.readSessionFromFile = readSessionFromFile;
3632
- this.readProjectionTitle = readProjectionTitle;
3633
- this.drsSource = drsSource;
3634
- }
3635
- /**
3636
- * 读取一个会话的日志:服务面优先,失败落文件兜底。两条路都失败时抛最后
3637
- * 一个错误,由调用方按会话隔离记失败。
3638
- */
3639
- async readSessionLog(header) {
3640
- try {
3641
- return await this.sessionQuery.readSession(header.id);
3642
- } catch (serviceError) {
3643
- if (this.readSessionFromFile === void 0) throw serviceError;
3644
- const fromFile = await this.readSessionFromFile(header.id);
3645
- if (fromFile !== void 0) {
3646
- this.log?.(`session ${header.id}: sessionQuery read failed, served from transcript file (${String(serviceError instanceof Error ? serviceError.message : serviceError)})`);
3647
- return fromFile;
3648
- }
3649
- throw serviceError;
3650
- }
3651
- }
3652
- /** Current progress snapshot (cloned). */
3653
- snapshot() {
3654
- return {
3655
- ...this.state,
3656
- failures: [...this.state.failures]
3657
- };
3658
- }
3659
- /**
3660
- * Fold titles for an explicit id set without running a full pass.
3661
- *
3662
- * Used by the `session/title` event listener so a rename lands immediately
3663
- * rather than at the next poll. It deliberately leaves watermarks alone: a
3664
- * title-only refresh can never make the index claim content it has not read,
3665
- * and the next poll still re-ingests the session off its bumped version.
3666
- * Safe for unknown ids — the header write is a no-op when no row exists.
3667
- * @param sessionIds - sessions whose titles should be re-folded.
3668
- */
3669
- async refreshTitles(sessionIds) {
3670
- await this.backfillTitles(sessionIds);
3671
- }
3672
- /**
3673
- * Run one incremental pass (or await the running one).
3674
- * @returns the state after the pass completes.
3675
- */
3676
- poll() {
3677
- if (this.running !== void 0) return this.running;
3678
- this.running = this.runPass().finally(() => {
3679
- this.running = void 0;
3680
- });
3681
- return this.running;
3682
- }
3683
- async runPass() {
3684
- this.state.state = "syncing";
3685
- const passStart = Date.now();
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
- }
3692
- const records = await this.sessionQuery.listSessions();
3693
- this.state.total = records.length;
3694
- const archiveSource = this.readArchiveSource?.();
3695
- const archivedSet = new Set(archiveSource?.archivedSessionIds ?? []);
3696
- this.engine.setArchived(archivedSet);
3697
- const failures = [];
3698
- let updated = 0;
3699
- const changedIds = [];
3700
- for (const record of records) {
3701
- const header = record.header;
3702
- const existing = this.engine.getSession(header.id);
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;
3705
- changedIds.push(header.id);
3706
- try {
3707
- const log = await this.readSessionLog(header);
3708
- if (this.engine.upsertSession({
3709
- sessionId: header.id,
3710
- version: header.version,
3711
- cwd: log.session.cwd ?? header.cwd ?? "",
3712
- updatedAt: log.session.createdAt ?? header.createdAt ?? 0,
3713
- title: extractTitleFromEvents(log.events),
3714
- events: log.events,
3715
- archived: archivedSet.has(header.id)
3716
- }) === 0) this.confirmedEmpty.add(emptyKey);
3717
- else this.confirmedEmpty.delete(emptyKey);
3718
- updated += 1;
3719
- } catch (err) {
3720
- failures.push({
3721
- sessionId: header.id,
3722
- error: String(err instanceof Error ? err.message : err)
3723
- });
3724
- }
3725
- }
3726
- for (const archivedId of archivedSet) {
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;
3729
- try {
3730
- const log = await this.readSessionLog({
3731
- id: archivedId,
3732
- version: -1
3733
- });
3734
- const produced = this.engine.upsertSession({
3735
- sessionId: archivedId,
3736
- version: log.session.version,
3737
- cwd: log.session.cwd ?? "",
3738
- updatedAt: log.session.createdAt ?? 0,
3739
- title: extractTitleFromEvents(log.events),
3740
- events: log.events,
3741
- archived: true
3742
- });
3743
- const archivedKey = SwitchWatermarkSync.emptyKey(archivedId, log.session.version);
3744
- if (produced === 0) this.confirmedEmpty.add(archivedKey);
3745
- else this.confirmedEmpty.delete(archivedKey);
3746
- updated += 1;
3747
- } catch (err) {
3748
- failures.push({
3749
- sessionId: archivedId,
3750
- error: `archived: ${String(err instanceof Error ? err.message : err)}`
3751
- });
3752
- }
3753
- }
3754
- const corpusIds = new Set(records.map((record) => record.header.id));
3755
- for (const indexed of this.engine.listIndexedSessions()) if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) this.engine.removeSession(indexed.sessionId);
3756
- await this.backfillTitles(changedIds);
3757
- const emptyTitles = this.engine.listIndexedSessions().filter((session) => session.title.trim() === "").map((session) => session.sessionId).slice(0, 100);
3758
- if (emptyTitles.length > 0) {
3759
- await this.backfillTitles(emptyTitles);
3760
- let filled = 0;
3761
- for (const id of emptyTitles) {
3762
- const row = this.engine.getSession(id);
3763
- if (row !== void 0 && row.title.trim() !== "") continue;
3764
- const fromProjection = this.readProjectionTitle?.(id) ?? "";
3765
- if (fromProjection.trim() !== "") {
3766
- this.engine.updateSessionHeader({
3767
- sessionId: id,
3768
- title: fromProjection
3769
- });
3770
- filled += 1;
3771
- }
3772
- }
3773
- if (filled > 0) this.log?.(`title sweep: ${filled}/${emptyTitles.length} filled from projection cache`);
3774
- }
3775
- this.state.updated = updated;
3776
- this.state.failures = failures;
3777
- this.state.indexed = this.engine.countSessions();
3778
- this.state.hollow = this.engine.listHollowSessions().length;
3779
- this.state.lastSyncAt = Date.now();
3780
- this.state.state = "idle";
3781
- this.state.error = void 0;
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}`);
3783
- } catch (err) {
3784
- this.state.state = "error";
3785
- this.state.error = String(err instanceof Error ? err.message : err);
3786
- this.log?.(`sync pass FAILED: ${this.state.error}`);
3787
- }
3788
- return this.snapshot();
3789
- }
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
- /**
3840
- * Fold latest titles for changed sessions into the index header rows.
3841
- *
3842
- * beta.4 教训:整批一次调用,任何一个坏 id(典型:归档 id)让整个 promise
3843
- * reject,catch 一吞就是**全部**标题丢失。改为分块 + 块失败时逐 id 重试 ——
3844
- * 单点坏 id 最多损失它自己的精修标题(入索引时的 extractTitleFromEvents
3845
- * 基础层仍然在)。
3846
- */
3847
- async backfillTitles(sessionIds) {
3848
- const readTitles = this.sessionQuery.readTitleSnapshots;
3849
- if (readTitles === void 0 || sessionIds.length === 0) return;
3850
- const ids = [...new Set(sessionIds)];
3851
- const foldOne = (observation) => {
3852
- if (observation.status !== "fulfilled" || observation.value === void 0) return;
3853
- const title = observation.value.title?.title;
3854
- if (typeof title === "string" && title.trim().length > 0) this.engine.updateSessionHeader({
3855
- sessionId: observation.value.session.id,
3856
- title
3857
- });
3858
- };
3859
- for (let start = 0; start < ids.length; start += TITLE_CHUNK) {
3860
- const chunk = ids.slice(start, start + TITLE_CHUNK);
3861
- try {
3862
- (await readTitles(chunk)).forEach(foldOne);
3863
- } catch {
3864
- for (const id of chunk) try {
3865
- (await readTitles([id])).forEach(foldOne);
3866
- } catch {}
3867
- }
3868
- }
3869
- }
3870
- };
3871
- //#endregion
3872
- //#region src/host/index/archive-source.ts
3873
- /**
3874
- * Official archive-set source resolution.
3875
- *
3876
- * Primary: the in-process `workspaceRegistry` service (the same fact the UI
3877
- * filters by). Fallback: read the canonical storage hub file directly — the
3878
- * workspace domain persists `archivedSessionIds` under the `global` segment
3879
- * (storage-json: `~/.dsh/storages/workspace.json`; storage-sqlite variant
3880
- * exists but the JSON fallback file is what stock web profiles ship).
3881
- * Resolution order is decided per read; failures degrade to "no archive set"
3882
- * and are reported through the diagnostics face.
3883
- *
3884
- * READ-ONLY by contract. The archive set is WRITTEN by dsh-manage-session
3885
- * (会话管家 → 病案室); this package only consumes it to keep archived sessions
3886
- * out of the index. Format contract: `dsh-归档文件格式契约-20260914.md`.
3887
- */
3888
- /** DSH storage hub candidates for the workspace domain (json backend). */
3889
- function storageFileCandidates() {
3890
- return [join(homedir(), ".dsh", "storages", "workspace.json")];
3891
- }
3892
- /** Parse the storage hub file's global.archivedSessionIds; throw on malformed content. */
3893
- function readStorageFile(path) {
3894
- const ids = JSON.parse(readFileSync(path, "utf8")).global?.archivedSessionIds;
3895
- if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`);
3896
- return ids.filter((id) => typeof id === "string");
3897
- }
3898
- /**
3899
- * Resolve the official archive set once.
3900
- * @param registry - lazy workspaceRegistry face (may be absent or throw).
3901
- * @returns the archive ids plus which source served them.
3902
- */
3903
- function readArchiveSet$1(registry) {
3904
- if (registry !== void 0) try {
3905
- const ids = registry.archivedSessionIds;
3906
- if (Array.isArray(ids)) return {
3907
- ids,
3908
- source: "registry"
3909
- };
3910
- } catch {}
3911
- for (const path of storageFileCandidates()) {
3912
- if (!existsSync(path)) continue;
3913
- try {
3914
- return {
3915
- ids: readStorageFile(path),
3916
- source: "storage-file"
3917
- };
3918
- } catch {}
3919
- }
3920
- return {
3921
- ids: [],
3922
- source: "none"
3923
- };
3924
- }
3925
- /** Build the lazy source face the syncer expects, with diagnostics capture. */
3926
- function createArchiveSource(getRegistry) {
3927
- let last = {
3928
- source: "none",
3929
- ids: 0
3930
- };
3931
- return {
3932
- read: () => {
3933
- const read = readArchiveSet$1(getRegistry());
3934
- last = {
3935
- source: read.source,
3936
- ids: read.ids.length
3937
- };
3938
- return read;
3939
- },
3940
- diagnostics: () => ({ ...last })
3941
- };
3942
- }
3943
- //#endregion
3944
- //#region src/host/index/peers.ts
3945
- /**
3946
- * Presence probe for the peer plugin that owns the archived-session domain.
3947
- *
3948
- * Why this exists
3949
- * ---------------
3950
- * This package *reads* the official archive set — archived sessions are
3951
- * excluded from the index — but it no longer *owns* archiving: browsing and
3952
- * disposing of archived sessions moved to `dsh-manage-session`. The settings
3953
- * card went on reporting a bare archived count, with no pointer to the plugin
3954
- * that can act on it. A number the user cannot act on, next to a capability
3955
- * that lives somewhere else, reads as a broken feature.
3956
- *
3957
- * The pointer has to say different things depending on whether the owner is
3958
- * actually installed, and only the host half can answer that: the client
3959
- * bundle cannot resolve another package, and the slot registry exposes no
3960
- * "is anything registered under this id" query.
3961
- *
3962
- * Three states, never two. A probe that cannot run must not be reported as
3963
- * "missing" — that would tell the user to install something they already have.
3964
- */
3965
- /** The package that owns the archived-session domain. */
3966
- const STEWARD_PACKAGE = "dsh-manage-session";
3967
- /**
3968
- * Probe whether a package resolves from this plugin's own module graph.
3969
- *
3970
- * The bundle lives at `<profile>/node_modules/dsh-search-index/lib/index.mjs`,
3971
- * so resolution runs against `<profile>/node_modules` — exactly where a
3972
- * profile dependency lands, and therefore the same place the host would load
3973
- * the peer from.
3974
- *
3975
- * @param name - the package name to resolve.
3976
- * @param base - resolution base; defaults to this module's own URL, i.e. the
3977
- * plugin's install directory. Injectable so the classification can be tested
3978
- * against a fixture that fails in a way this checkout cannot produce.
3979
- * @returns `installed` when it resolves; `missing` only for a genuine
3980
- * module-not-found; `unknown` for every other failure, because an
3981
- * unanswerable probe is not evidence of absence.
3982
- */
3983
- function probePeer(name, base = import.meta.url) {
3984
- try {
3985
- createRequire(base).resolve(name);
3986
- return "installed";
3987
- } catch (err) {
3988
- const code = err?.code;
3989
- return code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND" ? "missing" : "unknown";
3990
- }
3991
- }
3992
- /**
3993
- * Probe whether {@link STEWARD_PACKAGE} is installed alongside this plugin.
3994
- *
3995
- * @returns the resolution state, never a throw.
3996
- */
3997
- function detectSteward() {
3998
- return probePeer(STEWARD_PACKAGE);
3999
- }
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
4140
- //#region src/host/index/rebuild.ts
4141
- /**
4142
- * Non-destructive index rebuild ("整理索引") for the independent index.
4143
- *
4144
- * A shadow index file is built from scratch beside the active one; the active
4145
- * engine keeps serving queries the whole time. When the shadow is complete the
4146
- * swap is three synchronous renames (active → archive, shadow → active), then
4147
- * the engine reopens. Old archives are kept (bounded) and stay readable.
4148
- */
4149
- /** Default layout names. */
4150
- const DEFAULT_INDEX_LAYOUT = {
4151
- dir: ".",
4152
- active: "index.sqlite",
4153
- building: "index.building.sqlite",
4154
- archivePrefix: "index.archive-"
4155
- };
4156
- /**
4157
- * Inspect the index directory for half-built leftovers from an abnormally
4158
- * terminated rebuild and recover:
4159
- * - shadow present + active present: the build never finished — the shadow
4160
- * is garbage (the active index kept serving) and is discarded.
4161
- * - shadow present + active missing: the crash hit the rename window — the
4162
- * newest archive is restored as the active index, the shadow discarded.
4163
- * Runs at host activation, before the engine opens (opening would create a
4164
- * fresh empty active file and mask the swap-window case).
4165
- */
4166
- async function recoverIndex(layout, log) {
4167
- const actions = [];
4168
- await mkdir(layout.dir, { recursive: true });
4169
- const activePath = join(layout.dir, layout.active);
4170
- const buildingPath = join(layout.dir, layout.building);
4171
- const activeExists = existsSync(activePath);
4172
- if (!existsSync(buildingPath)) return actions;
4173
- const discardShadow = async () => {
4174
- for (const suffix of [
4175
- "",
4176
- "-wal",
4177
- "-shm"
4178
- ]) await rm(`${buildingPath}${suffix}`, { force: true });
4179
- };
4180
- if (activeExists) {
4181
- await discardShadow();
4182
- actions.push(`discarded stale shadow index (a previous rebuild did not finish; the active index was never at risk)`);
4183
- } else {
4184
- const archives = await listArchives(layout);
4185
- if (archives.length > 0) {
4186
- const newest = archives[archives.length - 1];
4187
- await rename(join(layout.dir, newest), activePath);
4188
- actions.push(`active index was missing (crash during the swap window); restored "${newest}" as the active index`);
4189
- } else actions.push("no active index and no archive: the first build crashed mid-way; starting from a fresh index");
4190
- await discardShadow();
4191
- }
4192
- for (const action of actions) log?.(`index recovery: ${action}`);
4193
- return actions;
4194
- }
4195
- /** List existing archive files, oldest first. */
4196
- async function listArchives(layout) {
4197
- if (!existsSync(layout.dir)) return [];
4198
- return (await readdir(layout.dir)).filter((name) => name.startsWith(layout.archivePrefix) && name.endsWith(".sqlite")).sort();
4199
- }
4200
- /** Sessions per batched transaction (one fsync checkpoint per chunk). */
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
- }
4259
- async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, onProgress, archiveSource, hooks) {
4260
- const startedMs = Date.now();
4261
- let docsWritten = 0;
4262
- const emit = () => {
4263
- hooks?.onState?.({ ...state });
4264
- };
4265
- const rate = () => {
4266
- const secs = Math.max(.001, (Date.now() - startedMs) / 1e3);
4267
- return `${(state.done / secs).toFixed(1)} sess/s`;
4268
- };
4269
- const eta = () => {
4270
- if (state.total <= 0 || state.done === 0) return "?";
4271
- const secs = (Date.now() - startedMs) / 1e3;
4272
- return `${Math.max(0, Math.round((state.total - state.done) / (state.done / secs)))}s`;
4273
- };
4274
- const state = {
4275
- state: "building",
4276
- done: 0,
4277
- total: 0,
4278
- startedAt: Date.now(),
4279
- finishedAt: 0,
4280
- failures: []
4281
- };
4282
- try {
4283
- hooks?.log?.(`rebuild started: dir=${layout.dir} keep=${keepArchives}`);
4284
- emit();
4285
- await mkdir(layout.dir, { recursive: true });
4286
- const buildingPath = join(layout.dir, layout.building);
4287
- if (existsSync(buildingPath)) await unlink(buildingPath);
4288
- const shadow = new SwitchIndexEngine({ path: buildingPath });
4289
- await shadow.open();
4290
- try {
4291
- const records = await sessionQuery.listSessions();
4292
- state.total = records.length;
4293
- hooks?.log?.(`rebuild corpus listed: ${state.total} sessions (archived included, full content)`);
4294
- emit();
4295
- const archivedSet = new Set(archiveSource?.()?.archivedSessionIds ?? []);
4296
- const readLog = async (header) => {
4297
- let log;
4298
- try {
4299
- log = await sessionQuery.readSession(header.id);
4300
- } catch (serviceError) {
4301
- if (hooks?.readSessionFromFile === void 0) throw serviceError;
4302
- const fromFile = await hooks.readSessionFromFile(header.id);
4303
- if (fromFile === void 0) throw serviceError;
4304
- log = fromFile;
4305
- }
4306
- return {
4307
- sessionId: header.id,
4308
- version: log.session.version,
4309
- title: extractTitleFromEvents(log.events),
4310
- cwd: log.session.cwd ?? "",
4311
- updatedAt: log.session.createdAt ?? 0,
4312
- archived: archivedSet.has(header.id),
4313
- events: log.events
4314
- };
4315
- };
4316
- for (let i = 0; i < records.length; i += REBUILD_CHUNK) {
4317
- const chunk = records.slice(i, i + REBUILD_CHUNK);
4318
- const chunkStart = Date.now();
4319
- const reads = [];
4320
- for (const record of chunk) try {
4321
- reads.push(await readLog(record.header));
4322
- } catch (err) {
4323
- state.failures.push({
4324
- sessionId: record.header.id,
4325
- error: String(err instanceof Error ? err.message : err)
4326
- });
4327
- }
4328
- try {
4329
- shadow.runBatched(() => {
4330
- for (const item of reads) {
4331
- shadow.upsertSession(item);
4332
- docsWritten += item.events.length;
4333
- }
4334
- });
4335
- } catch (err) {
4336
- hooks?.log?.(`rebuild chunk txn failed, replaying individually: ${String(err instanceof Error ? err.message : err)}`);
4337
- for (const item of reads) try {
4338
- shadow.upsertSession(item);
4339
- docsWritten += item.events.length;
4340
- } catch (e2) {
4341
- state.failures.push({
4342
- sessionId: item.sessionId,
4343
- error: String(e2 instanceof Error ? e2.message : e2)
4344
- });
4345
- }
4346
- }
4347
- state.done = Math.min(state.total, i + chunk.length);
4348
- onProgress?.(state.done, state.total);
4349
- emit();
4350
- hooks?.log?.(`rebuild ${state.done}/${state.total} (${Math.round(state.done / Math.max(1, state.total) * 100)}%) ${rate()} elapsed ${Math.round((Date.now() - startedMs) / 1e3)}s eta ${eta()} chunk ${Date.now() - chunkStart}ms`);
4351
- }
4352
- shadow.setArchived(archivedSet);
4353
- shadow.close();
4354
- } catch (error) {
4355
- shadow.close();
4356
- await unlink(buildingPath).catch(() => {});
4357
- throw error;
4358
- }
4359
- state.state = "swapping";
4360
- emit();
4361
- hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`);
4362
- await swapShadowIntoActive(activeEngine, layout, hooks?.log);
4363
- await pruneArchives(layout, keepArchives);
4364
- state.finishedAt = Date.now();
4365
- state.state = "idle";
4366
- emit();
4367
- hooks?.log?.(`rebuild done: total ${((state.finishedAt - startedMs) / 1e3).toFixed(1)}s, archives pruned to ${keepArchives}`);
4368
- } catch (err) {
4369
- state.state = "error";
4370
- state.error = String(err instanceof Error ? err.message : err);
4371
- hooks?.log?.(`rebuild FAILED at done=${state.done}: ${state.error}`);
4372
- emit();
4373
- if (!activeEngine.isOpen) await activeEngine.open().catch(() => {});
4374
- }
4375
- return state;
4376
- }
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) {
4380
- const state = {
4381
- state: "building",
4382
- done: 0,
4383
- total: records.length,
4384
- startedAt: Date.now(),
4385
- finishedAt: 0,
4386
- failures: []
4387
- };
4388
- try {
4389
- await mkdir(layout.dir, { recursive: true });
4390
- const buildingPath = join(layout.dir, layout.building);
4391
- if (existsSync(buildingPath)) await unlink(buildingPath);
4392
- const shadow = new SwitchIndexEngine({ path: buildingPath });
4393
- await shadow.open();
4394
- try {
4395
- for (const record of records) {
4396
- try {
4397
- if (record.docs.length === 0) log?.(`snapshot import: session ${record.sessionId} has zero docs (hollow header, sync will backfill)`);
4398
- shadow.importSessionDocs({
4399
- sessionId: record.sessionId,
4400
- version: record.version,
4401
- title: record.title ?? "",
4402
- archived: record.archived === true,
4403
- docs: record.docs
4404
- });
4405
- } catch (err) {
4406
- state.failures.push({
4407
- sessionId: record.sessionId,
4408
- error: String(err instanceof Error ? err.message : err)
4409
- });
4410
- }
4411
- state.done += 1;
4412
- }
4413
- shadow.close();
4414
- } catch (error) {
4415
- shadow.close();
4416
- await unlink(buildingPath).catch(() => {});
4417
- throw error;
4418
- }
4419
- state.state = "swapping";
4420
- await swapShadowIntoActive(activeEngine, layout, log);
4421
- await pruneArchives(layout, keepArchives);
4422
- state.finishedAt = Date.now();
4423
- state.state = "idle";
4424
- } catch (err) {
4425
- state.state = "error";
4426
- state.error = String(err instanceof Error ? err.message : err);
4427
- if (!activeEngine.isOpen) await activeEngine.open().catch(() => {});
4428
- }
4429
- return state;
4430
- }
4431
- /** Remove the oldest archives beyond the retention bound (sidecars too). */
4432
- async function pruneArchives(layout, keep) {
4433
- const archives = await listArchives(layout);
4434
- const excess = archives.length - Math.max(0, keep);
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
- }
4439
- }
4440
- /**
4441
- * Resolve the index directory that hosts the independent index files:
4442
- * an explicit override wins, otherwise a plugin-owned directory under the
4443
- * user's home (never the official index path).
4444
- */
4445
- function resolveIndexDir(preferred) {
4446
- if (preferred !== void 0 && preferred.trim() !== "") return preferred;
4447
- return join(homedir(), ".dsh-switch-search");
4448
- }
4449
- //#endregion
4450
- //#region src/host/index/snapshot.ts
4451
- /** Render the snapshot header line. */
4452
- function snapshotHeader() {
4453
- return JSON.stringify({
4454
- v: 1,
4455
- kind: "dsh-switch-search-snapshot",
4456
- exportedAt: Date.now()
4457
- });
4458
- }
4459
- /**
4460
- * Export the whole index (active AND archived) as a JSON Lines string.
4461
- * 归档行带 archived 标记一起导出——快照是完整备份,不是活跃子集。
4462
- * @param engine - the open active engine.
4463
- * @returns the complete snapshot text (header line first).
4464
- */
4465
- function exportSnapshot(engine) {
4466
- const lines = [snapshotHeader()];
4467
- for (const session of engine.listIndexedSessions()) lines.push(JSON.stringify({
4468
- v: 1,
4469
- sessionId: session.sessionId,
4470
- version: session.version,
4471
- title: session.title,
4472
- ...session.archived ? { archived: true } : {},
4473
- docs: engine.exportSessionDocs(session.sessionId)
4474
- }));
4475
- return `${lines.join("\n")}\n`;
4476
- }
4477
- /**
4478
- * Parse a snapshot's JSON Lines text into importable records.
4479
- * The header line and any malformed line are skipped, not fatal.
4480
- * @param text - raw snapshot text.
4481
- * @returns importable records and how many lines were skipped.
4482
- */
4483
- function parseSnapshot(text) {
4484
- const records = [];
4485
- let skipped = 0;
4486
- for (const line of text.split(/\r?\n/)) {
4487
- if (line.trim() === "") continue;
4488
- let value;
4489
- try {
4490
- value = JSON.parse(line);
4491
- } catch {
4492
- skipped += 1;
4493
- continue;
4494
- }
4495
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
4496
- skipped += 1;
4497
- continue;
4498
- }
4499
- const record = value;
4500
- if (record["kind"] === "dsh-switch-search-snapshot") continue;
4501
- const sessionId = record["sessionId"];
4502
- const rawDocs = record["docs"];
4503
- if (typeof sessionId !== "string" || sessionId === "" || !Array.isArray(rawDocs)) {
4504
- skipped += 1;
4505
- continue;
4506
- }
4507
- const docs = [];
4508
- let docsValid = true;
4509
- for (const rawDoc of rawDocs) {
4510
- if (typeof rawDoc !== "object" || rawDoc === null || typeof rawDoc.seq !== "number" || typeof rawDoc.type !== "string" || typeof rawDoc.text !== "string") {
4511
- docsValid = false;
4512
- break;
4513
- }
4514
- const doc = rawDoc;
4515
- docs.push({
4516
- seq: doc.seq,
4517
- type: doc.type,
4518
- surface: typeof doc.surface === "string" ? doc.surface : "current",
4519
- time: typeof doc.time === "number" ? doc.time : 0,
4520
- text: doc.text
4521
- });
4522
- }
4523
- if (!docsValid) {
4524
- skipped += 1;
4525
- continue;
4526
- }
4527
- records.push({
4528
- sessionId,
4529
- version: typeof record["version"] === "number" ? record["version"] : 0,
4530
- title: typeof record["title"] === "string" ? record["title"] : void 0,
4531
- archived: record["archived"] === true,
4532
- docs
4533
- });
4534
- }
4535
- return {
4536
- records,
4537
- skipped
4538
- };
4539
- }
4540
- //#endregion
4541
- //#region src/index.ts
4542
- /** 本插件声明的宿主服务(与 toggle 相同的注入面)。0.2.1-a.2 起 webRuntime 更名 webStartup,读取面走双名回退链。 */
4543
- const inject = ["webServer", "webStartup"];
4544
- /** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
4545
- function readVolatileValue(value) {
4546
- if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
4547
- return value;
4548
- }
4549
- /**
4550
- * `.volatile()` 探针回退(preset-manager 的 volatile breakpoint 同款):宿主
4551
- * schemastery 只在 0.1.7+ 线提供 `.volatile()`,老线上该方法是 undefined ——
4552
- * 在模块加载期链式调用直接 TypeError,插件整包起不来。这里探测到才链,探测
4553
- * 不到就返回普通字段(老线的配置只来自组合入口,无设置表单,行为正确)。
4554
- */
4555
- function withVolatile(field) {
4556
- if (field !== null && typeof field === "object" && typeof field.volatile === "function") return field.volatile();
4557
- return field;
4558
- }
4559
- /** 运行时配置 schema(与 src/config.ts 的形状保持一致)。0.1.7+:volatile 字段即设置表单。 */
4560
- const Config = z.object({
4561
- enabled: withVolatile(z.boolean().default(true)),
4562
- historyFiles: withVolatile(z.boolean().default(true)),
4563
- healthCheck: withVolatile(z.boolean().default(true)),
4564
- search: withVolatile(z.boolean().default(true)),
4565
- directDelete: withVolatile(z.boolean().default(false)),
4566
- defaultMode: withVolatile(z.union(["title", "content"]).default("title")),
4567
- autoSync: withVolatile(z.boolean().default(true)),
4568
- syncIntervalMs: withVolatile(z.number().default(3e4)),
4569
- archiveKeep: withVolatile(z.number().default(2)),
4570
- indexDir: withVolatile(z.string().default("")),
4571
- corpusSource: withVolatile(z.union(["native", "drs"]).default("drs")),
4572
- drsBaseUrl: withVolatile(z.string().default(""))
4573
- });
4574
- /** 单次请求体的上限(防御无界读取)。 */
4575
- const MAX_BODY_BYTES = 16 << 20;
4576
- /** content-search 单次返回的默认会话数上限。 */
4577
- const DEFAULT_LIMIT = 20;
4578
- /** 独立索引目录的环境变量覆盖。 */
4579
- const INDEX_DIR_ENV = "DSH_SWITCH_SEARCH_DIR";
4580
- /** 改名/自动标题落盘的 log-only 事件(追加进日志,会推 version,水位轮询本可迟到一轮兜住)。 */
4581
- const TITLE_EVENT_TYPE = "session/title";
4582
- /** 改名风暴合并为一次标题折叠(自动标题一轮会连发多条)。 */
4583
- const TITLE_FLUSH_MS = 250;
4584
- /** 归一化 Host authority,无法解析时为 undefined。 */
4585
- function parseAuthority(authority) {
4586
- try {
4587
- return new URL(`http://${authority}`);
4588
- } catch {
4589
- return;
4590
- }
4591
- }
4592
- /** 主机名是否本机回环。 */
4593
- function isLoopbackHostname(hostname) {
4594
- if (hostname === "localhost" || hostname === "[::1]") return true;
4595
- const parts = hostname.split(".");
4596
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
4597
- }
4598
- /** Host 是否命中 trustedHosts(精确或省略端口)。 */
4599
- function isTrustedAuthority(hostUrl, trustedHosts) {
4600
- return trustedHosts.some((entry) => {
4601
- const entryUrl = parseAuthority(entry);
4602
- if (entryUrl === void 0) return false;
4603
- return (entryUrl.port === "" ? entryUrl.hostname : entryUrl.host) === hostUrl.host;
4604
- });
4605
- }
4606
- /** 浏览器可信围栏(与 /api 网关行为一致):DNS 重绑定/跨站防御,不是鉴权。 */
4607
- function isTrustedApiRequest(req, trustedHosts) {
4608
- const host = req.headers.host;
4609
- if (host === void 0) return false;
4610
- const hostUrl = parseAuthority(host);
4611
- if (hostUrl === void 0) return false;
4612
- if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
4613
- const fetchSite = req.headers["sec-fetch-site"];
4614
- if (typeof fetchSite === "string" && fetchSite === "cross-site") return false;
4615
- const origin = req.headers.origin;
4616
- if (origin === void 0) return true;
4617
- try {
4618
- return new URL(origin).host === hostUrl.host;
4619
- } catch {
4620
- return false;
4621
- }
4622
- }
4623
- /** 读原始请求体(有界)。 */
4624
- async function readRawBody(req) {
4625
- const chunks = [];
4626
- let total = 0;
4627
- for await (const chunk of req) {
4628
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4629
- total += buffer.length;
4630
- if (total > MAX_BODY_BYTES) throw new Error("request body too large");
4631
- chunks.push(buffer);
4632
- }
4633
- return Buffer.concat(chunks).toString("utf8");
4634
- }
4635
- /** 读 JSON 请求体(空体视为 {})。 */
4636
- async function readJsonBody(req) {
4637
- const text = await readRawBody(req);
4638
- if (text.trim() === "") return {};
4639
- try {
4640
- return JSON.parse(text);
4641
- } catch {
4642
- throw new Error("request body is not JSON");
4643
- }
4644
- }
4645
- /** 写 JSON 响应。 */
4646
- function writeJson(res, status, body) {
4647
- const text = JSON.stringify(body);
4648
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
4649
- res.end(text);
4650
- }
4651
- /** 写原始文本响应。 */
4652
- function writeRaw(res, status, contentType, body) {
4653
- res.writeHead(status, {
4654
- "content-type": contentType,
4655
- "cache-control": "no-cache"
4656
- });
4657
- res.end(body);
4658
- }
4659
- /** 会话 id 形状校验(拒绝任意字符串进入路径拼接)。 */
4660
- function asSessionId(value) {
4661
- if (typeof value !== "string") return void 0;
4662
- const trimmed = value.trim();
4663
- if (trimmed === "" || trimmed.length > 200) return void 0;
4664
- if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return void 0;
4665
- return trimmed;
4666
- }
4667
- /** 单会话体检的最大扫描上限。 */
4668
- const MAX_SCAN_LIMIT = 200;
4669
- /**
4670
- * 支持的路由方法(按子域分组;用于对外声明与测试断言)。
4671
- *
4672
- * `session-history-prune` 与 `session-history-purge` 是**两件事**,不可合并:
4673
- * prune = 取消归档状态(可逆,会话回到侧边栏);purge = 清理归档文件(不可逆,真删实体)。
4674
- */
4675
- const HISTORY_METHODS = [
4676
- "session-history-list",
4677
- "session-history-archive",
4678
- "session-history-prune",
4679
- "session-history-purge",
4680
- "session-history-favorites-list",
4681
- "session-history-favorite-set"
4682
- ];
4683
- const HEALTH_METHODS = [
4684
- "session-health-status",
4685
- "session-health-scan",
4686
- "session-health-session",
4687
- "session-health-repair",
4688
- "session-health-source-migrate"
4689
- ];
4690
- /** 搜索索引子域方法(`/switch-search/api`;index-export/import 走原始体,其余 JSON)。 */
4691
- const INDEX_METHODS = [
4692
- "list-sessions",
4693
- "content-search",
4694
- "search-status",
4695
- "index-status",
4696
- "index-rebuild",
4697
- "index-export",
4698
- "index-import"
4699
- ];
4700
- /** 依据开关判定某方法是否启用。 */
4701
- function methodEnabled(method, config) {
4702
- if (HISTORY_METHODS.includes(method)) return config.historyFiles !== false;
4703
- if (HEALTH_METHODS.includes(method)) return config.healthCheck !== false;
4704
- if (INDEX_METHODS.includes(method)) return config.search !== false;
4705
- return false;
4706
- }
4707
- /** Fold titles for a set of sessions into a sessionId → title map. */
4708
- async function titleMap(sessionQuery, sessionIds) {
4709
- if (sessionIds.length === 0) return /* @__PURE__ */ new Map();
4710
- const observations = await sessionQuery.readTitleSnapshots([...new Set(sessionIds)]);
4711
- const map = /* @__PURE__ */ new Map();
4712
- for (const observation of observations) {
4713
- if (observation.status !== "fulfilled" || observation.value === void 0) continue;
4714
- const title = observation.value.title?.title;
4715
- if (typeof title === "string" && title.trim().length > 0) map.set(observation.value.session.id, title);
4716
- }
4717
- return map;
4718
- }
4719
- /** list-sessions: the title-search corpus (index-served, live fallback). */
4720
- async function listSessions(srt) {
4721
- const index = srt.index;
4722
- const sessionQuery = srt.sessionQuery;
4723
- if (index.engine.isOpen && index.engine.countSessions() > 0) {
4724
- if (sessionQuery !== void 0 && index.sync.snapshot().state !== "syncing") index.sync.poll().catch(() => {});
4725
- return {
4726
- ok: true,
4727
- items: index.engine.listIndexedSessions().map((session) => ({
4728
- sessionId: session.sessionId,
4729
- title: session.title,
4730
- cwd: session.cwd,
4731
- updatedAt: session.updatedAt,
4732
- archived: session.archived
4733
- }))
4734
- };
4735
- }
4736
- if (sessionQuery === void 0) return {
4737
- ok: false,
4738
- error: "sessionQuery 服务不可用,且独立索引尚未建立"
4739
- };
4740
- try {
4741
- const records = await sessionQuery.listSessions();
4742
- const titles = await titleMap(sessionQuery, records.map((record) => record.header.id));
4743
- return {
4744
- ok: true,
4745
- items: records.map((record) => ({
4746
- sessionId: record.header.id,
4747
- title: titles.get(record.header.id) ?? "",
4748
- cwd: record.header.cwd ?? "",
4749
- updatedAt: record.header.createdAt,
4750
- archived: false
4751
- }))
4752
- };
4753
- } catch (err) {
4754
- return {
4755
- ok: false,
4756
- error: String(err instanceof Error ? err.message : err)
4757
- };
4758
- }
4759
- }
4760
- /**
4761
- * content-search: session-grouped hits from the independent index.
4762
- * `sortBy: 'time'` orders by session recency (`updatedAt`), anything else by
4763
- * relevance; the host orders before truncating so the page is honest.
4764
- */
4765
- async function contentSearch(srt, payload, requestHost = "") {
4766
- const record = payload;
4767
- const query = typeof record?.query === "string" ? record.query.trim() : "";
4768
- if (query === "") return {
4769
- ok: false,
4770
- error: "缺少 query"
4771
- };
4772
- const archived = record?.archived === "active" ? "active" : record?.archived === "archived" ? "archived" : "all";
4773
- const fromMs = dayBoundaryMs(record?.from);
4774
- const toMs = record?.to === void 0 ? void 0 : dayBoundaryMs(record?.to, true);
4775
- const requestedLimit = typeof record?.limit === "number" && Number.isSafeInteger(record.limit) ? record.limit : DEFAULT_LIMIT;
4776
- const limit = Math.min(Math.max(1, requestedLimit), 100);
4777
- const sortBy = record?.sortBy === "time" ? "time" : "relevance";
4778
- let types;
4779
- if (Array.isArray(record?.types) && record.types.length > 0) types = record.types.filter((entry) => entry === "all" || entry === "user" || entry === "reply" || entry === "tool");
4780
- else types = ["user", "reply"];
4781
- const kinds = resolveTypes(types);
4782
- try {
4783
- return {
4784
- ok: true,
4785
- items: await srt.drsSource.searchGrouped({
4786
- query,
4787
- kinds,
4788
- archived,
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 }
4795
- })
4796
- };
4797
- } catch (err) {
4798
- return {
4799
- ok: false,
4800
- error: `dsh-reference-session-collector 搜索面不可用(硬依赖):${String(err instanceof Error ? err.message : err)}`
4801
- };
4802
- }
4803
- }
4804
- /** search-status: probe the independent index readiness and progress. */
4805
- async function searchStatus(srt, requestHost = "") {
4806
- const index = srt.index;
4807
- const sync = index.sync.snapshot();
4808
- const drsUp = await srt.drsSource.probe(requestHost);
4809
- return {
4810
- ok: true,
4811
- available: drsUp,
4812
- reason: drsUp ? void 0 : "drs-unavailable",
4813
- indexing: sync.state === "syncing",
4814
- archivedSessions: index.engine.countArchived(),
4815
- archive: index.archiveReader.diagnostics(),
4816
- sync,
4817
- rebuild: index.rebuild
4818
- };
4819
- }
4820
- /** index-status: full lifecycle surface for the settings row. */
4821
- async function indexStatus(srt) {
4822
- const index = srt.index;
4823
- const sync = index.sync.snapshot();
4824
- let indexed = sync.indexed;
4825
- if (index.engine.isOpen) indexed = index.engine.countSessions();
4826
- return {
4827
- ok: true,
4828
- available: index.engine.isOpen && indexed > 0,
4829
- archivedSessions: index.engine.isOpen ? index.engine.countArchived() : 0,
4830
- steward: detectSteward(),
4831
- driver: index.engine.driverLabel,
4832
- archive: index.archiveReader.diagnostics(),
4833
- dir: index.layout.dir,
4834
- directDelete: srt.config().directDelete === true,
4835
- archives: await listArchives(index.layout).catch(() => []),
4836
- sync: {
4837
- ...sync,
4838
- indexed
4839
- },
4840
- rebuild: index.rebuild
4841
- };
4842
- }
4843
- /**
4844
- * index-rebuild: start the non-destructive 整理 (shadow build → atomic swap →
4845
- * archives). Responds immediately; progress rides index-status.
4846
- */
4847
- async function indexRebuild(srt) {
4848
- const index = srt.index;
4849
- if (index.rebuild.state === "building" || index.rebuild.state === "swapping") return {
4850
- ok: false,
4851
- error: "整理已在进行中"
4852
- };
4853
- const sessionQuery = srt.sessionQuery;
4854
- if (sessionQuery === void 0 || sessionQuery.readSession === void 0) return {
4855
- ok: false,
4856
- error: "sessionQuery 服务不可用,无法读取会话日志"
4857
- };
4858
- const config = srt.config();
4859
- const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep);
4860
- rebuildIndex(index.engine, index.layout, {
4861
- listSessions: () => sessionQuery.listSessions(),
4862
- readSession: async (sessionId) => {
4863
- const snapshot = await sessionQuery.readSession(sessionId);
4864
- return {
4865
- session: snapshot.session,
4866
- events: snapshot.events
4867
- };
4868
- }
4869
- }, keepArchives, void 0, srt.registry, {
4870
- log: srt.log,
4871
- onState: (live) => {
4872
- index.rebuild = live;
4873
- },
4874
- readSessionFromFile: srt.readSessionFromFile
4875
- }).then((state) => {
4876
- index.rebuild = state;
4877
- }).catch((err) => {
4878
- index.rebuild = {
4879
- state: "error",
4880
- done: 0,
4881
- total: 0,
4882
- startedAt: Date.now(),
4883
- finishedAt: 0,
4884
- failures: [],
4885
- error: String(err instanceof Error ? err.message : err)
4886
- };
4887
- });
4888
- index.rebuild = {
4889
- state: "building",
4890
- done: 0,
4891
- total: 0,
4892
- startedAt: Date.now(),
4893
- finishedAt: 0,
4894
- failures: []
4895
- };
4896
- return {
4897
- ok: true,
4898
- started: true
4899
- };
4900
- }
4901
- /**
4902
- * Tombs for the two methods the search package used to own before the
4903
- * steward merge. A stale client bundle (browser refresh does not reload the
4904
- * host half) must fail LOUDLY and be told where the feature went — a silent
4905
- * 404 would read as "archiving is broken".
4906
- */
4907
- const MOVED_TO_HISTORY = {
4908
- "list-archived": "session-history-list",
4909
- "archive-prune": "session-history-prune"
4910
- };
4911
- /** Build the explicit "moved" error body for a tombstoned method. */
4912
- function movedToHistory(method) {
4913
- const replacement = MOVED_TO_HISTORY[method];
4914
- return {
4915
- ok: false,
4916
- error: `"${method}" 已并入会话管家 history 子域:请改用 POST ${STEWARD_API_PREFIX}/${replacement}`
4917
- };
4918
- }
4919
- /** index-export: dump the active index as JSON Lines. */
4920
- async function indexExport(srt, res) {
4921
- const index = srt.index;
4922
- if (index.engine.isOpen === false) {
4923
- writeJson(res, 200, {
4924
- ok: false,
4925
- error: "独立索引未就绪"
4926
- });
4927
- return;
4928
- }
4929
- writeRaw(res, 200, "application/x-ndjson; charset=utf-8", exportSnapshot(index.engine));
4930
- }
4931
- /** index-import: parse a JSON Lines snapshot and swap it in as the active index. */
4932
- async function indexImport(srt, text) {
4933
- const index = srt.index;
4934
- if (index.rebuild.state === "building" || index.rebuild.state === "swapping") return {
4935
- ok: false,
4936
- error: "整理/导入已在进行中"
4937
- };
4938
- if (text.includes("\"snapshot\"")) try {
4939
- const envelope = JSON.parse(text);
4940
- if (typeof envelope.snapshot === "string") text = envelope.snapshot;
4941
- } catch {}
4942
- const parsed = parseSnapshot(text);
4943
- if (parsed.records.length === 0) return {
4944
- ok: false,
4945
- error: `快照无可导入会话(跳过 ${parsed.skipped} 行)`
4946
- };
4947
- const config = srt.config();
4948
- const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep);
4949
- importIntoIndex(index.engine, index.layout, parsed.records, keepArchives, srt.log).then((state) => {
4950
- index.rebuild = state;
4951
- }).catch((err) => {
4952
- index.rebuild = {
4953
- state: "error",
4954
- done: 0,
4955
- total: parsed.records.length,
4956
- startedAt: Date.now(),
4957
- finishedAt: 0,
4958
- failures: [],
4959
- error: String(err instanceof Error ? err.message : err)
4960
- };
4961
- });
4962
- index.rebuild = {
4963
- state: "building",
4964
- done: 0,
4965
- total: parsed.records.length,
4966
- startedAt: Date.now(),
4967
- finishedAt: 0,
4968
- failures: []
4969
- };
4970
- return {
4971
- ok: true,
4972
- started: true,
4973
- sessions: parsed.records.length,
4974
- skipped: parsed.skipped
4975
- };
4976
- }
4977
- /** JSON 面的搜索子域方法分发(index-export/import 走原始体,在路由层特判)。 */
4978
- async function handleIndexMethod(method, payload, srt, requestHost = "") {
4979
- if (method === "list-sessions") return await listSessions(srt);
4980
- if (method === "content-search") return await contentSearch(srt, payload, requestHost);
4981
- if (method === "search-status") return await searchStatus(srt, requestHost);
4982
- if (method === "index-status") return await indexStatus(srt);
4983
- if (method === "index-rebuild") return await indexRebuild(srt);
4984
- return {
4985
- ok: false,
4986
- error: `未知的 switch-search API 方法 "${method}"`
4987
- };
4988
- }
4989
- /** 逐 id 串行调用官方内存方法;失败按会话隔离收集(AM 批量语义)。 */
4990
- async function runMemoryArchiveOps(registry, ids, op) {
4991
- const fn = op === "archive" ? registry.archiveSession : registry.unarchiveSession;
4992
- const succeeded = [];
4993
- const failures = [];
4994
- for (const id of ids) try {
4995
- await fn.call(registry, id);
4996
- succeeded.push(id);
4997
- } catch (err) {
4998
- failures.push({
4999
- sessionId: id,
5000
- reason: String(err instanceof Error ? err.message : err)
5001
- });
5002
- }
5003
- return {
5004
- succeeded,
5005
- failures
5006
- };
2859
+ return {
2860
+ succeeded,
2861
+ failures
2862
+ };
5007
2863
  }
5008
2864
  /** 收藏文件候选(与管家其余域同一 dshHome 纪律)。 */
5009
2865
  function favoritesPathsFor(dshHome) {
5010
- return [join(dshHome, "storages", "session-steward-favorites.json")];
5011
- }
5012
- /** 本地日界(YYYY-MM-DD → epoch ms;nextDay 为排他上界)。非法返回 undefined。 */
5013
- function dayBoundaryMs(value, nextDay = false) {
5014
- if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return void 0;
5015
- const [year, month, day] = value.split("-").map(Number);
5016
- const date = new Date(year, month - 1, day);
5017
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return void 0;
5018
- if (nextDay) date.setDate(date.getDate() + 1);
5019
- return date.getTime();
2866
+ return [join(dshHome, "storages", "manage-session-favorites.json")];
5020
2867
  }
5021
2868
  /** 从 payload 抽合法 sessionIds(与 prune/purge 的入参纪律一致);不合法返回 undefined。 */
5022
2869
  function validSessionIds(value) {
@@ -5030,25 +2877,14 @@ function validSessionIds(value) {
5030
2877
  * @param payload - 已解析的请求体。
5031
2878
  * @param runtime - 运行时依赖。
5032
2879
  */
5033
- /** 现代宿主的只读投影:把运行时配置拆成两命名空间形状(describe 用)。 */
2880
+ /** 现代宿主的只读投影:把运行时配置按命名空间形状输出(describe 用)。 */
5034
2881
  function configSnapshotProjection(config) {
5035
- return {
5036
- [STEWARD_SETTINGS_NAMESPACE]: {
5037
- enabled: config.enabled,
5038
- historyFiles: config.historyFiles,
5039
- healthCheck: config.healthCheck,
5040
- search: config.search,
5041
- directDelete: config.directDelete
5042
- },
5043
- [SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
5044
- enabled: config.enabled,
5045
- defaultMode: config.defaultMode ?? SWITCH_DEFAULT_CONFIG.defaultMode,
5046
- autoSync: config.autoSync ?? SWITCH_DEFAULT_CONFIG.autoSync,
5047
- syncIntervalMs: config.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
5048
- archiveKeep: config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
5049
- indexDir: config.indexDir ?? SWITCH_DEFAULT_CONFIG.indexDir
5050
- }
5051
- };
2882
+ return { [STEWARD_SETTINGS_NAMESPACE]: {
2883
+ enabled: config.enabled,
2884
+ historyFiles: config.historyFiles,
2885
+ healthCheck: config.healthCheck,
2886
+ directDelete: config.directDelete
2887
+ } };
5052
2888
  }
5053
2889
  async function handleMethod(method, payload, runtime) {
5054
2890
  const config = runtime.config();
@@ -5056,11 +2892,12 @@ async function handleMethod(method, payload, runtime) {
5056
2892
  if (![
5057
2893
  ...HISTORY_METHODS,
5058
2894
  ...HEALTH_METHODS,
2895
+ "session-catalog",
5059
2896
  "settings-describe",
5060
2897
  "settings-mutate"
5061
2898
  ].includes(method)) return {
5062
2899
  ok: false,
5063
- error: `未知的 session-steward API 方法 "${method}"`
2900
+ error: `未知的 manage-session API 方法 "${method}"`
5064
2901
  };
5065
2902
  if (!settingsMethods && !methodEnabled(method, config)) return {
5066
2903
  ok: false,
@@ -5081,6 +2918,7 @@ async function handleMethod(method, payload, runtime) {
5081
2918
  revision: 0
5082
2919
  }, payload);
5083
2920
  if (method === "session-history-list") return await listHistory(runtime.registry, void 0, storagePathsFor(runtime.dshHome), runtime.dshHome);
2921
+ if (method === "session-catalog") return await listSessions(runtime);
5084
2922
  if (method === "session-history-archive") {
5085
2923
  const ids = validSessionIds((payload ?? {}).sessionIds);
5086
2924
  if (ids === void 0) return {
@@ -5091,7 +2929,6 @@ async function handleMethod(method, payload, runtime) {
5091
2929
  if (mem?.archiveSession !== void 0) {
5092
2930
  const before = new Set(mem.archivedSessionIds ?? []);
5093
2931
  const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, "archive");
5094
- runtime.index?.onArchive(succeeded);
5095
2932
  const total = (mem.archivedSessionIds ?? []).length;
5096
2933
  return {
5097
2934
  ok: true,
@@ -5101,16 +2938,13 @@ async function handleMethod(method, payload, runtime) {
5101
2938
  ...failures.length === 0 ? {} : { failures }
5102
2939
  };
5103
2940
  }
5104
- const result = archiveHistory(payload, runtime.log, storagePathsFor(runtime.dshHome));
5105
- if (result.ok) runtime.index?.onArchive(ids);
5106
- return result;
2941
+ return archiveHistory(payload, runtime.log, storagePathsFor(runtime.dshHome));
5107
2942
  }
5108
2943
  if (method === "session-history-prune") {
5109
2944
  const ids = validSessionIds((payload ?? {}).sessionIds);
5110
2945
  const mem = runtime.registry();
5111
2946
  if (mem?.unarchiveSession !== void 0 && ids !== void 0) {
5112
2947
  const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, "unarchive");
5113
- runtime.index?.onUnarchive(succeeded);
5114
2948
  const remaining = (mem.archivedSessionIds ?? []).length;
5115
2949
  return {
5116
2950
  ok: true,
@@ -5120,9 +2954,7 @@ async function handleMethod(method, payload, runtime) {
5120
2954
  ...failures.length === 0 ? {} : { failures }
5121
2955
  };
5122
2956
  }
5123
- const result = pruneHistory(payload, runtime.log, storagePathsFor(runtime.dshHome));
5124
- if (result.ok && ids !== void 0) runtime.index?.onUnarchive(ids);
5125
- return result;
2957
+ return pruneHistory(payload, runtime.log, storagePathsFor(runtime.dshHome));
5126
2958
  }
5127
2959
  if (method === "session-history-purge") {
5128
2960
  const ids = validSessionIds((payload ?? {}).sessionIds);
@@ -5154,12 +2986,6 @@ async function handleMethod(method, payload, runtime) {
5154
2986
  reason: String(err instanceof Error ? err.message : err)
5155
2987
  });
5156
2988
  }
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
- });
5163
2989
  return {
5164
2990
  ok: true,
5165
2991
  purged: succeeded.length,
@@ -5168,21 +2994,10 @@ async function handleMethod(method, payload, runtime) {
5168
2994
  ...failures.length === 0 ? {} : { failures }
5169
2995
  };
5170
2996
  }
5171
- const result = purgeHistory(payload, runtime.log, {
2997
+ return purgeHistory(payload, runtime.log, {
5172
2998
  dshHome: runtime.dshHome,
5173
2999
  searchPaths: storagePathsFor(runtime.dshHome)
5174
3000
  });
5175
- if (result.ok && ids !== void 0) {
5176
- const failed = new Set((result.failures ?? []).map((f) => f.sessionId));
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
- });
5184
- }
5185
- return result;
5186
3001
  }
5187
3002
  if (method === "session-history-favorites-list") return {
5188
3003
  ok: true,
@@ -5208,7 +3023,8 @@ async function handleMethod(method, payload, runtime) {
5208
3023
  switches: {
5209
3024
  enabled: config.enabled,
5210
3025
  historyFiles: config.historyFiles,
5211
- healthCheck: config.healthCheck
3026
+ healthCheck: config.healthCheck,
3027
+ directDelete: config.directDelete
5212
3028
  },
5213
3029
  historyMethods: [...HISTORY_METHODS],
5214
3030
  healthMethods: [...HEALTH_METHODS],
@@ -5360,29 +3176,18 @@ async function handleMethod(method, payload, runtime) {
5360
3176
  */
5361
3177
  function apply(ctx, config = {}) {
5362
3178
  const current = () => {
5363
- const { steward, search } = bridgeOverlay(settingsBridge);
3179
+ const { steward } = bridgeOverlay(settingsBridge);
5364
3180
  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
3181
  return {
5369
3182
  enabled: bool(steward, "enabled", readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled),
5370
3183
  historyFiles: bool(steward, "historyFiles", readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles),
5371
3184
  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)
3185
+ directDelete: bool(steward, "directDelete", readVolatileValue(config.directDelete) ?? DEFAULT_CONFIG.directDelete)
5381
3186
  };
5382
3187
  };
5383
3188
  const log = (message) => {
5384
3189
  try {
5385
- ctx.logger?.info?.(`[session-steward] ${message}`);
3190
+ ctx.logger?.info?.(`[manage-session] ${message}`);
5386
3191
  } catch {}
5387
3192
  };
5388
3193
  const homeEnv = process.env["DSH_HOME"];
@@ -5427,174 +3232,8 @@ function apply(ctx, config = {}) {
5427
3232
  attribute: attributor,
5428
3233
  log,
5429
3234
  cache: new HealthCache(),
5430
- index: {
5431
- onArchive: (ids) => {
5432
- if (!engine.isOpen) return;
5433
- try {
5434
- for (const id of ids) engine.setArchivedOne(id, true);
5435
- } catch (err) {
5436
- log(`index onArchive failed: ${String(err instanceof Error ? err.message : err)}`);
5437
- }
5438
- },
5439
- onUnarchive: (ids) => {
5440
- if (!engine.isOpen) return;
5441
- try {
5442
- for (const id of ids) engine.setArchivedOne(id, false);
5443
- } catch (err) {
5444
- log(`index onUnarchive failed: ${String(err instanceof Error ? err.message : err)}`);
5445
- }
5446
- },
5447
- onPurged: (ids) => {
5448
- if (!engine.isOpen) return;
5449
- try {
5450
- for (const id of ids) engine.removeSession(id);
5451
- } catch (err) {
5452
- log(`index onPurged failed: ${String(err instanceof Error ? err.message : err)}`);
5453
- }
5454
- }
5455
- },
5456
- tombstoneDir: () => {
5457
- const dir = current().indexDir;
5458
- return dir !== "" ? dir : defaultTombstoneDir();
5459
- }
5460
- };
5461
- const initial = current();
5462
- const sessionQuery = ctx.get("sessionQuery");
5463
- const archiveReader = createArchiveSource(() => ctx.get("workspaceRegistry"));
5464
- const indexLayout = {
5465
- ...DEFAULT_INDEX_LAYOUT,
5466
- dir: resolveIndexDir(initial.indexDir || process.env[INDEX_DIR_ENV])
5467
- };
5468
- const engine = new SwitchIndexEngine({ path: `${indexLayout.dir}/${indexLayout.active}` });
5469
- const readSessionFromFile = async (sessionId) => {
5470
- try {
5471
- const logPath = findSessionLog(sessionId, resolvedHome);
5472
- if (logPath === void 0) return void 0;
5473
- const decoded = decodeSessionLogFile(logPath);
5474
- return {
5475
- session: {
5476
- id: sessionId,
5477
- version: 0
5478
- },
5479
- events: decoded.events.map((event) => ({
5480
- seq: event.seq,
5481
- type: event.type,
5482
- time: event.time,
5483
- surfaceOp: event.surfaceOp,
5484
- data: event.data
5485
- }))
5486
- };
5487
- } catch (err) {
5488
- log(`file fallback read failed for ${sessionId}: ${String(err instanceof Error ? err.message : err)}`);
5489
- return;
5490
- }
5491
- };
5492
- const readProjectionTitle = (sessionId) => {
5493
- try {
5494
- const path = join(resolvedHome, "storages", "session_projcache", "sessions", `${sessionId}.json`);
5495
- if (!existsSync(path)) return "";
5496
- const value = JSON.parse(readFileSync(path, "utf8")).record?.rows?.title?.val;
5497
- return typeof value === "string" ? value : "";
5498
- } catch {
5499
- return "";
5500
- }
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
- });
5508
- const indexState = {
5509
- engine,
5510
- archiveReader,
5511
- sync: new SwitchWatermarkSync(engine, {
5512
- listSessions: () => sessionQuery?.listSessions() ?? Promise.resolve([]),
5513
- readSession: async (sessionId) => {
5514
- if (sessionQuery?.readSession === void 0) throw new Error("sessionQuery.readSession 不可用");
5515
- return sessionQuery.readSession(sessionId);
5516
- },
5517
- readTitleSnapshots: sessionQuery === void 0 ? void 0 : (ids) => sessionQuery.readTitleSnapshots(ids)
5518
- }, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle, initial.corpusSource === "native" ? void 0 : drsSource),
5519
- layout: indexLayout,
5520
- rebuild: {
5521
- state: "idle",
5522
- done: 0,
5523
- total: 0,
5524
- startedAt: 0,
5525
- finishedAt: 0,
5526
- failures: []
5527
- }
5528
- };
5529
- const srt = {
5530
- sessionQuery,
5531
- index: indexState,
5532
- drsSource,
5533
- config: () => current(),
5534
- registry: () => ({ archivedSessionIds: archiveReader.read().ids }),
5535
- log,
5536
- readSessionFromFile
5537
- };
5538
- const initialConfig = current();
5539
- if (initialConfig.enabled === false) {
5540
- log("enabled=false:不注册任何路由");
5541
- return;
5542
- }
5543
- let syncTimer;
5544
- const scheduleSync = (intervalMs) => {
5545
- if (syncTimer !== void 0) clearInterval(syncTimer);
5546
- if (intervalMs <= 0) return;
5547
- syncTimer = setInterval(() => {
5548
- const latest = current();
5549
- if (latest.enabled === false || latest.search === false || latest.autoSync === false) return;
5550
- indexState.sync.poll().catch(() => {});
5551
- }, Math.max(5e3, intervalMs));
3235
+ sessionQuery: ctx.get("sessionQuery")
5552
3236
  };
5553
- let pendingTitleIds = /* @__PURE__ */ new Set();
5554
- let titleTimer;
5555
- const flushPendingTitles = () => {
5556
- titleTimer = void 0;
5557
- const ids = [...pendingTitleIds];
5558
- pendingTitleIds = /* @__PURE__ */ new Set();
5559
- if (ids.length === 0) return;
5560
- indexState.sync.refreshTitles(ids).catch(() => {});
5561
- };
5562
- ctx.effect(() => {
5563
- const bus = ctx;
5564
- if (typeof bus.on !== "function") return () => {};
5565
- try {
5566
- return bus.on("session/event", (session, event) => {
5567
- if (event?.type !== TITLE_EVENT_TYPE) return;
5568
- const latest = current();
5569
- if (latest.enabled === false || latest.search === false || latest.autoSync === false) return;
5570
- const id = session?.id;
5571
- if (typeof id !== "string" || id === "") return;
5572
- pendingTitleIds.add(id);
5573
- if (titleTimer !== void 0) return;
5574
- titleTimer = setTimeout(flushPendingTitles, TITLE_FLUSH_MS);
5575
- });
5576
- } catch {
5577
- return () => {};
5578
- }
5579
- }, "dsh-manage-session: realtime titles");
5580
- if (initialConfig.search !== false) (async () => {
5581
- try {
5582
- const recovered = await recoverIndex(indexLayout, log);
5583
- if (recovered.length > 0) log(`index recovery applied ${recovered.length} fix(es)`);
5584
- } catch (err) {
5585
- log(`index recovery failed: ${String(err instanceof Error ? err.message : err)}`);
5586
- }
5587
- await engine.open().catch(() => {});
5588
- log(`index open: driver=${engine.driverLabel} dir=${indexLayout.dir}`);
5589
- if (engine.driverLabel === "node:sqlite") log("tip: optional speedup not active — approve the better-sqlite3 build (add \"better-sqlite3@*: true\" under allowBuilds in the profile pnpm-workspace.yaml, then reinstall) to speed up index rebuilds; everything works without it");
5590
- if (initialConfig.autoSync !== false) await indexState.sync.poll().catch(() => {});
5591
- scheduleSync(initialConfig.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs);
5592
- })();
5593
- ctx.effect(() => () => {
5594
- if (syncTimer !== void 0) clearInterval(syncTimer);
5595
- if (titleTimer !== void 0) clearTimeout(titleTimer);
5596
- engine.close();
5597
- }, "dsh-manage-session: index lifecycle");
5598
3237
  ctx.effect(() => webServer.register({
5599
3238
  kind: "prefix",
5600
3239
  path: STEWARD_API_PREFIX,
@@ -5619,7 +3258,7 @@ function apply(ctx, config = {}) {
5619
3258
  if (method === void 0 || method === "" || method.includes("/")) {
5620
3259
  writeJson(res, 404, {
5621
3260
  ok: false,
5622
- error: `unknown session-steward API method`
3261
+ error: `unknown manage-session API method`
5623
3262
  });
5624
3263
  return;
5625
3264
  }
@@ -5632,65 +3271,6 @@ function apply(ctx, config = {}) {
5632
3271
  });
5633
3272
  }
5634
3273
  }
5635
- }), "dsh-manage-session: /session-steward/api route");
5636
- ctx.effect(() => webServer.register({
5637
- kind: "prefix",
5638
- path: SWITCH_API_PREFIX,
5639
- handler: async (req, res) => {
5640
- if (!isTrustedApiRequest(req, webRuntime.trustedHosts)) {
5641
- writeJson(res, 403, {
5642
- ok: false,
5643
- error: "forbidden"
5644
- });
5645
- return;
5646
- }
5647
- if (req.method !== "POST") {
5648
- writeJson(res, 405, {
5649
- ok: false,
5650
- error: "method not allowed"
5651
- });
5652
- return;
5653
- }
5654
- const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
5655
- const prefix = `${SWITCH_API_PREFIX}/`;
5656
- const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : void 0;
5657
- if (method === void 0 || method === "" || method.includes("/")) {
5658
- writeJson(res, 404, {
5659
- ok: false,
5660
- error: "unknown switch-search API method"
5661
- });
5662
- return;
5663
- }
5664
- if (method in MOVED_TO_HISTORY) {
5665
- writeJson(res, 410, movedToHistory(method));
5666
- return;
5667
- }
5668
- try {
5669
- if (methodEnabled(method, current()) === false) {
5670
- writeJson(res, 200, {
5671
- ok: false,
5672
- error: `子域已关闭(search=false):${method} 未注册`
5673
- });
5674
- return;
5675
- }
5676
- if (method === "index-export") {
5677
- await indexExport(srt, res);
5678
- return;
5679
- }
5680
- if (method === "index-import") {
5681
- const text = await readRawBody(req);
5682
- writeJson(res, 200, await indexImport(srt, text));
5683
- return;
5684
- }
5685
- writeJson(res, 200, await handleIndexMethod(method, await readJsonBody(req), srt, String(req.headers.host ?? "")));
5686
- } catch (err) {
5687
- writeJson(res, 400, {
5688
- ok: false,
5689
- error: err instanceof Error ? err.message : String(err)
5690
- });
5691
- }
5692
- }
5693
- }), "dsh-manage-session: /switch-search/api route");
3274
+ }), "dsh-manage-session: /manage-session/api route");
5694
3275
  }
5695
- //#endregion
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 };
3276
+ export { Config, DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, V4_HOST_MIN, apply, archiveArchiveFile, archiveHistory, 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 };