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.
- package/CHANGELOG.ja.md +33 -229
- package/CHANGELOG.ko.md +33 -229
- package/CHANGELOG.md +33 -363
- package/INSTALL.ja.md +164 -164
- package/INSTALL.ko.md +164 -164
- package/INSTALL.md +164 -164
- package/INSTALL.zh.md +164 -164
- package/LICENSE +21 -21
- package/README.en.md +41 -46
- package/README.ja.md +33 -57
- package/README.ko.md +32 -55
- package/README.md +64 -64
- package/cordis.patch.yml +19 -16
- package/dsh.plugin.json +4 -4
- package/lib/client.js +661 -3082
- package/lib/index.d.ts +79 -970
- package/lib/index.js +249 -2669
- package/locale/de.json +2 -2
- package/locale/en.json +2 -2
- package/locale/es.json +2 -2
- package/locale/fr.json +2 -2
- package/locale/it.json +2 -2
- package/locale/ja.json +2 -2
- package/locale/ko.json +2 -2
- package/locale/ru.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +15 -25
- package/screenshots.json +3 -3
- package/src/client/bridge-scope.ts +202 -213
- package/src/client/card.tsx +154 -154
- package/src/client/health-panel.tsx +49 -49
- package/src/client/host-api.ts +72 -6
- package/src/client/index.ts +594 -503
- package/src/client/locales.ts +893 -893
- package/src/client/manage-locales.ts +178 -0
- package/src/client/{search/manage.tsx → manage.tsx} +430 -404
- package/src/client/panel.tsx +131 -129
- package/src/client/session-menu-legacy.ts +2 -3
- package/src/client/session-menu.tsx +5 -5
- package/src/config.ts +43 -99
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/repair.ts +2 -2
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -250
- package/src/host/history/archive.ts +249 -249
- package/src/host/history/favorites.ts +71 -71
- package/src/host/history/purge.ts +370 -370
- package/src/host/history/tombstones.ts +3 -3
- package/src/host/registry/archive-registry.ts +317 -317
- package/src/host/settings-bridge.ts +222 -264
- package/src/index.ts +66 -694
- package/src/client/search/card.tsx +0 -563
- package/src/client/search/host-api.ts +0 -213
- package/src/client/search/locales.ts +0 -861
- package/src/client/search/locate.ts +0 -330
- package/src/client/search/panel.tsx +0 -940
- package/src/client/search/platform.ts +0 -136
- package/src/host/index/archive-source.ts +0 -90
- package/src/host/index/drs-source.ts +0 -211
- package/src/host/index/engine.ts +0 -699
- package/src/host/index/extract.ts +0 -272
- package/src/host/index/peers.ts +0 -62
- package/src/host/index/rebuild.ts +0 -432
- package/src/host/index/schema.ts +0 -136
- package/src/host/index/snapshot.ts +0 -135
- 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
|
-
|
|
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
|
|
19
|
-
/** host
|
|
20
|
-
const STEWARD_API_PREFIX = "/session
|
|
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
|
|
36
|
+
function storageFileCandidates() {
|
|
57
37
|
return [join(homedir(), ".dsh", "storages", "workspace.json")];
|
|
58
38
|
}
|
|
59
39
|
/** 解析存储文件 global.archivedSessionIds;内容畸形时抛错。 */
|
|
60
|
-
function readStorageFile
|
|
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
|
|
68
|
+
for (const path of searchPaths ?? storageFileCandidates()) {
|
|
89
69
|
if (!existsSync(path)) continue;
|
|
90
70
|
try {
|
|
91
71
|
return {
|
|
92
|
-
ids: readStorageFile
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
*
|
|
1452
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
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
|
-
*
|
|
2844
|
-
*
|
|
2845
|
-
*
|
|
2846
|
-
*
|
|
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
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
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
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
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
|
-
/**
|
|
2874
|
-
function
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
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
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
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
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
const
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
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
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
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
|
|
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
|
-
/**
|
|
2989
|
-
function
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
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
|
-
/**
|
|
3003
|
-
function
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
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
|
-
/**
|
|
3009
|
-
function
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
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
|
-
*
|
|
3026
|
-
*
|
|
3027
|
-
*
|
|
2763
|
+
* 支持的路由方法(按子域分组;用于对外声明与测试断言)。
|
|
2764
|
+
*
|
|
2765
|
+
* `session-history-prune` 与 `session-history-purge` 是**两件事**,不可合并:
|
|
2766
|
+
* prune = 取消归档状态(可逆,会话回到侧边栏);purge = 清理归档文件(不可逆,真删实体)。
|
|
3028
2767
|
*/
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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
|
-
/**
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
const
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
-
|
|
3096
|
-
|
|
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
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
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
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
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-
|
|
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
|
-
/**
|
|
2880
|
+
/** 现代宿主的只读投影:把运行时配置按命名空间形状输出(describe 用)。 */
|
|
5034
2881
|
function configSnapshotProjection(config) {
|
|
5035
|
-
return {
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 };
|