dsh-manage-session 0.4.8 → 0.5.3
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.md +36 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/cordis.patch.yml +6 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +3848 -202
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/index.ts +154 -26
- package/src/client/locales.ts +857 -223
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +54 -5
- package/src/host/history/archive-source.ts +48 -0
- package/src/host/history/archive.ts +56 -1
- package/src/host/history/favorites.ts +71 -0
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
package/lib/index.js
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
4
|
import { homedir } from "node:os";
|
|
3
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
6
|
import zlib from "node:zlib";
|
|
7
|
+
import { mkdir, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
6
8
|
//#region src/config.ts
|
|
7
9
|
/** 缺省值。 */
|
|
8
10
|
const DEFAULT_CONFIG = {
|
|
9
11
|
enabled: true,
|
|
10
12
|
historyFiles: true,
|
|
11
|
-
healthCheck: true
|
|
13
|
+
healthCheck: true,
|
|
14
|
+
search: true
|
|
12
15
|
};
|
|
13
16
|
/** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
|
|
14
17
|
const STEWARD_SETTINGS_NAMESPACE = "session-steward";
|
|
15
|
-
/** host
|
|
18
|
+
/** host 路由前缀(管家子域;与搜索子域的 /switch-search/api 互不干扰)。 */
|
|
16
19
|
const STEWARD_API_PREFIX = "/session-steward/api";
|
|
20
|
+
/** ------------------------------------------------------------------ 搜索子域 */
|
|
21
|
+
/** 搜索子域的历史设置命名空间(存储键稳定承诺,永不迁移)。 */
|
|
22
|
+
const SWITCH_SEARCH_SETTINGS_NAMESPACE = "switch-search";
|
|
23
|
+
/** 搜索子域路由前缀(历史值,浏览器旧 bundle 与快照文件名依赖它,保持不变)。 */
|
|
24
|
+
const SWITCH_API_PREFIX = "/switch-search/api";
|
|
25
|
+
/** 搜索子域缺省值。 */
|
|
26
|
+
const SWITCH_DEFAULT_CONFIG = {
|
|
27
|
+
enabled: true,
|
|
28
|
+
defaultMode: "title",
|
|
29
|
+
autoSync: true,
|
|
30
|
+
syncIntervalMs: 3e4,
|
|
31
|
+
archiveKeep: 2,
|
|
32
|
+
indexDir: ""
|
|
33
|
+
};
|
|
17
34
|
//#endregion
|
|
18
35
|
//#region src/host/history/archive-source.ts
|
|
19
36
|
/**
|
|
@@ -33,11 +50,11 @@ const STEWARD_API_PREFIX = "/session-steward/api";
|
|
|
33
50
|
* 调用方必须提示需要重启 DSH。
|
|
34
51
|
*/
|
|
35
52
|
/** workspace domain 的 DSH 存储中枢候选路径(json 后端)。 */
|
|
36
|
-
function storageFileCandidates() {
|
|
53
|
+
function storageFileCandidates$1() {
|
|
37
54
|
return [join(homedir(), ".dsh", "storages", "workspace.json")];
|
|
38
55
|
}
|
|
39
56
|
/** 解析存储文件 global.archivedSessionIds;内容畸形时抛错。 */
|
|
40
|
-
function readStorageFile(path) {
|
|
57
|
+
function readStorageFile$1(path) {
|
|
41
58
|
const ids = JSON.parse(readFileSync(path, "utf8")).global?.archivedSessionIds;
|
|
42
59
|
if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`);
|
|
43
60
|
return ids.filter((id) => typeof id === "string");
|
|
@@ -65,11 +82,11 @@ function readRegistryIds(registry) {
|
|
|
65
82
|
*/
|
|
66
83
|
function readArchiveSet(registry, searchPaths) {
|
|
67
84
|
const registryIds = readRegistryIds(registry);
|
|
68
|
-
for (const path of searchPaths ?? storageFileCandidates()) {
|
|
85
|
+
for (const path of searchPaths ?? storageFileCandidates$1()) {
|
|
69
86
|
if (!existsSync(path)) continue;
|
|
70
87
|
try {
|
|
71
88
|
return {
|
|
72
|
-
ids: readStorageFile(path),
|
|
89
|
+
ids: readStorageFile$1(path),
|
|
73
90
|
source: "storage-file",
|
|
74
91
|
...registryIds === void 0 ? {} : { registryIds }
|
|
75
92
|
};
|
|
@@ -152,7 +169,7 @@ function editWorkspaceDocument(path, edit, log) {
|
|
|
152
169
|
function pruneArchiveFile(ids, log, searchPaths) {
|
|
153
170
|
const wanted = new Set(ids);
|
|
154
171
|
let lastReason = "workspace storage file not found (searched ~/.dsh/storages/workspace.json)";
|
|
155
|
-
for (const path of searchPaths ?? storageFileCandidates()) {
|
|
172
|
+
for (const path of searchPaths ?? storageFileCandidates$1()) {
|
|
156
173
|
let removed = 0;
|
|
157
174
|
let remaining = 0;
|
|
158
175
|
const outcome = editWorkspaceDocument(path, (document) => {
|
|
@@ -182,6 +199,55 @@ function pruneArchiveFile(ids, log, searchPaths) {
|
|
|
182
199
|
}
|
|
183
200
|
throw new Error(lastReason);
|
|
184
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* 把会话 id 加入存储中枢的 `global.archivedSessionIds`(= 归档,prune 的逆操作)。
|
|
204
|
+
*
|
|
205
|
+
* 与 prune 同一写入口与协议(备份 + 临时文件 + 原子改名);已在集合中的 id
|
|
206
|
+
* 不重复追加。运行中的宿主把集合留在内存里、只在启动时重载 —— 调用方必须
|
|
207
|
+
* 提示需要重启 DSH。
|
|
208
|
+
* @param ids - 要加入归档数组的会话 id。
|
|
209
|
+
* @param log - 可选日志出口。
|
|
210
|
+
* @param searchPaths - 可选候选路径覆盖(测试注入用)。
|
|
211
|
+
* @returns 实际新增数量与操作后的集合总数。
|
|
212
|
+
*/
|
|
213
|
+
function archiveArchiveFile(ids, log, searchPaths) {
|
|
214
|
+
new Set(ids);
|
|
215
|
+
let lastReason = "workspace storage file not found (searched ~/.dsh/storages/workspace.json)";
|
|
216
|
+
for (const path of searchPaths ?? storageFileCandidates$1()) {
|
|
217
|
+
let added = 0;
|
|
218
|
+
let total = 0;
|
|
219
|
+
const outcome = editWorkspaceDocument(path, (document) => {
|
|
220
|
+
const global = document.global;
|
|
221
|
+
const current = global?.archivedSessionIds;
|
|
222
|
+
if (!Array.isArray(current)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`);
|
|
223
|
+
const existing = new Set(current.filter((id) => typeof id === "string"));
|
|
224
|
+
const fresh = ids.filter((id) => !existing.has(id));
|
|
225
|
+
if (fresh.length === 0) {
|
|
226
|
+
added = 0;
|
|
227
|
+
total = existing.size;
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
document.global = {
|
|
231
|
+
...global,
|
|
232
|
+
archivedSessionIds: [...current, ...fresh]
|
|
233
|
+
};
|
|
234
|
+
added = fresh.length;
|
|
235
|
+
total = existing.size + fresh.length;
|
|
236
|
+
return true;
|
|
237
|
+
}, log);
|
|
238
|
+
if (!outcome.ok) {
|
|
239
|
+
lastReason = outcome.reason;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (outcome.changed) log?.(`archive: added ${added} ids; total ${total}`);
|
|
243
|
+
return {
|
|
244
|
+
added,
|
|
245
|
+
total,
|
|
246
|
+
file: outcome.file
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
throw new Error(lastReason);
|
|
250
|
+
}
|
|
185
251
|
//#endregion
|
|
186
252
|
//#region src/host/health/decode.ts
|
|
187
253
|
/**
|
|
@@ -1202,6 +1268,354 @@ function pruneHistory(payload, log, searchPaths) {
|
|
|
1202
1268
|
requiresRestart: true
|
|
1203
1269
|
};
|
|
1204
1270
|
}
|
|
1271
|
+
/** 单次归档的最大 id 数(与 prune/purge 一致)。 */
|
|
1272
|
+
const MAX_ARCHIVE_IDS = 5e3;
|
|
1273
|
+
/**
|
|
1274
|
+
* `session-history-archive`:把会话批量加入官方归档集合(prune 的逆操作)。
|
|
1275
|
+
* 与 prune 同一条纪律:校验入参 → 备份并原子替换存储文件 → 读回校验 →
|
|
1276
|
+
* 要求重启(宿主内存集合只在启动时重载)。会话文件不动、工作区成员表不动
|
|
1277
|
+
* (归档只改可见性,与宿主 archiveSession 的文件语义一致)。
|
|
1278
|
+
* @param payload - `{ sessionIds: string[] }`。
|
|
1279
|
+
* @param log - 可选日志出口。
|
|
1280
|
+
* @param searchPaths - 可选候选路径覆盖(测试注入用)。
|
|
1281
|
+
*/
|
|
1282
|
+
function archiveHistory(payload, log, searchPaths) {
|
|
1283
|
+
const record = payload;
|
|
1284
|
+
if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) return {
|
|
1285
|
+
ok: false,
|
|
1286
|
+
error: "缺少 sessionIds 数组"
|
|
1287
|
+
};
|
|
1288
|
+
const ids = [...new Set(record.sessionIds.filter((id) => typeof id === "string" && id !== ""))];
|
|
1289
|
+
if (ids.length === 0) return {
|
|
1290
|
+
ok: false,
|
|
1291
|
+
error: "sessionIds 无有效值"
|
|
1292
|
+
};
|
|
1293
|
+
if (ids.length > 5e3) return {
|
|
1294
|
+
ok: false,
|
|
1295
|
+
error: `单次最多归档 ${MAX_ARCHIVE_IDS} 个`
|
|
1296
|
+
};
|
|
1297
|
+
let result;
|
|
1298
|
+
try {
|
|
1299
|
+
result = archiveArchiveFile(ids, log, searchPaths);
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
return {
|
|
1302
|
+
ok: false,
|
|
1303
|
+
error: String(err instanceof Error ? err.message : err)
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
const after = readArchiveSet(void 0, searchPaths);
|
|
1307
|
+
if (after.source !== "storage-file") return {
|
|
1308
|
+
ok: false,
|
|
1309
|
+
error: "写入后无法读回存储文件,无法确认归档结果"
|
|
1310
|
+
};
|
|
1311
|
+
const now = new Set(after.ids);
|
|
1312
|
+
const missing = ids.filter((id) => !now.has(id));
|
|
1313
|
+
if (missing.length > 0) return {
|
|
1314
|
+
ok: false,
|
|
1315
|
+
error: `存储文件在写入后仍缺少 ${missing.length} 个目标 id,归档未生效`
|
|
1316
|
+
};
|
|
1317
|
+
log?.(`archive requested ${ids.length}, added ${result.added}, total ${result.total}`);
|
|
1318
|
+
return {
|
|
1319
|
+
ok: true,
|
|
1320
|
+
added: result.added,
|
|
1321
|
+
total: result.total,
|
|
1322
|
+
requiresRestart: true
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
//#endregion
|
|
1326
|
+
//#region src/host/history/favorites.ts
|
|
1327
|
+
/**
|
|
1328
|
+
* 收藏域(R6)—— 插件自有的 JSON 文件,与索引文件/官方存储彻底分离:
|
|
1329
|
+
* 索引会因 schema 重置/影子切换整体更换,收藏不能放里面;官方存储中枢
|
|
1330
|
+
* 只读不写(唯一写方纪律不破)。文件形如
|
|
1331
|
+
* `<dshHome>/storages/session-steward-favorites.json`:
|
|
1332
|
+
* `{ unit: { name, version }, favoriteSessionIds: string[] }`
|
|
1333
|
+
* 原子写(tmp+rename),丢失可重建(收藏只是标记,不是数据)。
|
|
1334
|
+
*/
|
|
1335
|
+
/** 读取收藏集合;文件缺失/畸形一律空数组(收藏只是标记,丢失不致命)。 */
|
|
1336
|
+
function listFavorites(searchPaths) {
|
|
1337
|
+
for (const path of searchPaths ?? []) {
|
|
1338
|
+
if (!existsSync(path)) continue;
|
|
1339
|
+
try {
|
|
1340
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1341
|
+
if (!Array.isArray(parsed.favoriteSessionIds)) return [];
|
|
1342
|
+
return parsed.favoriteSessionIds.filter((id) => typeof id === "string");
|
|
1343
|
+
} catch {
|
|
1344
|
+
return [];
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return [];
|
|
1348
|
+
}
|
|
1349
|
+
/** 设置单条收藏状态(原子写)。 */
|
|
1350
|
+
function setFavoriteState(sessionId, favorite, searchPaths) {
|
|
1351
|
+
const path = searchPaths?.[0];
|
|
1352
|
+
if (path === void 0 || path === "") return {
|
|
1353
|
+
ok: false,
|
|
1354
|
+
error: "缺少收藏文件路径"
|
|
1355
|
+
};
|
|
1356
|
+
let ids = [];
|
|
1357
|
+
if (existsSync(path)) try {
|
|
1358
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1359
|
+
if (Array.isArray(parsed.favoriteSessionIds)) ids = parsed.favoriteSessionIds.filter((id) => typeof id === "string");
|
|
1360
|
+
} catch {}
|
|
1361
|
+
const without = ids.filter((id) => id !== sessionId);
|
|
1362
|
+
const next = favorite ? [...without, sessionId] : without;
|
|
1363
|
+
try {
|
|
1364
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1365
|
+
const tmp = `${path}.fav-tmp`;
|
|
1366
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
1367
|
+
unit: {
|
|
1368
|
+
name: "session-steward-favorites",
|
|
1369
|
+
version: 1
|
|
1370
|
+
},
|
|
1371
|
+
favoriteSessionIds: next
|
|
1372
|
+
}, null, 2)}\n`, "utf8");
|
|
1373
|
+
renameSync(tmp, path);
|
|
1374
|
+
return {
|
|
1375
|
+
ok: true,
|
|
1376
|
+
total: next.length
|
|
1377
|
+
};
|
|
1378
|
+
} catch (err) {
|
|
1379
|
+
return {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
error: String(err instanceof Error ? err.message : err)
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
//#endregion
|
|
1386
|
+
//#region src/host/settings-bridge.ts
|
|
1387
|
+
/**
|
|
1388
|
+
* Legacy 设置桥(宿主半身,≤0.1.5 专用):老宿主的服务端 settings 服务保留
|
|
1389
|
+
* register/get/update 三件套 —— 这是 context-compression legacy 租约(六格
|
|
1390
|
+
* 实证 POST 200 + 持久化)验证过的持久化写路径。0.1.7+ 宿主删掉了 register
|
|
1391
|
+
* (审计 P1-13:API 不存在,不是探测跳过),配置活在 entry-config volatile
|
|
1392
|
+
* 字段里、client configForms 原生可用 —— 本桥在那些线上解析为 undefined,
|
|
1393
|
+
* describe/mutate 方法降级为「显式拒绝写」。
|
|
1394
|
+
*
|
|
1395
|
+
* 两个命名空间**并存且互不迁移**(config.ts 的存储键稳定承诺):
|
|
1396
|
+
* `session-steward`(管家字段)与 `switch-search`(搜索字段)。schema 必须
|
|
1397
|
+
* **纯 plain**(不 withVolatile)—— volatile 节点会被 legacy register 拒收
|
|
1398
|
+
* (`$.enabled expected boolean`,TL beta.18 教训)。
|
|
1399
|
+
*
|
|
1400
|
+
* 同一 settings 服务上重复 apply(HMR/双 loader 行)的防重:Symbol.for 键挂
|
|
1401
|
+
* 在服务对象上(cordis 同服务的所有代理共享该属性,cci 租约同款),首见者
|
|
1402
|
+
* 注册,后来者直接共享。
|
|
1403
|
+
*/
|
|
1404
|
+
const SHARED_LEASE = Symbol.for("dsh-manage-session/legacy-settings-bridge");
|
|
1405
|
+
/** 管家命名空间 schema(纯 plain,无 volatile —— 老线 register 拒收 volatile 节点)。 */
|
|
1406
|
+
const StewardNsSchema = z.object({
|
|
1407
|
+
enabled: z.boolean().default(true),
|
|
1408
|
+
historyFiles: z.boolean().default(true),
|
|
1409
|
+
healthCheck: z.boolean().default(true),
|
|
1410
|
+
search: z.boolean().default(true)
|
|
1411
|
+
});
|
|
1412
|
+
/** 搜索命名空间 schema(同上;存储键与 config.ts 承诺一致)。 */
|
|
1413
|
+
const SwitchNsSchema = z.object({
|
|
1414
|
+
enabled: z.boolean().default(true),
|
|
1415
|
+
defaultMode: z.union(["title", "content"]).default("title"),
|
|
1416
|
+
autoSync: z.boolean().default(true),
|
|
1417
|
+
syncIntervalMs: z.number().default(3e4),
|
|
1418
|
+
archiveKeep: z.number().default(2),
|
|
1419
|
+
indexDir: z.string().default("")
|
|
1420
|
+
});
|
|
1421
|
+
/** settings.get 返回值是否像一份文档(老宿主对未写过的 ns 可能回 undefined)。 */
|
|
1422
|
+
function asDoc(value) {
|
|
1423
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1424
|
+
return value;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* 在老宿主上租约注册两个命名空间;返回**同步创建的可变 holder**,桥面在
|
|
1428
|
+
* `ctx.inject(['settings'], …)` 回调里填入(cordis 作用域注入——settings 服务
|
|
1429
|
+
* 只在回调的 sctx 上可见,`ctx.get('settings')` 在 0.1.0 拿不到;0.1.7+ 无
|
|
1430
|
+
* register,回调即 return,holder 永远空桥 = describe 只读、mutate 显式拒绝)。
|
|
1431
|
+
* 重复 apply 经 Symbol 租约只注册一次;回调体内全部 try/catch——桥的任何
|
|
1432
|
+
* 失败都不允许拖死插件树(0.1.0 实机教训:严格 ctx 上裸属性读即 fatal)。
|
|
1433
|
+
*/
|
|
1434
|
+
function installLegacySettingsBridge(ctx) {
|
|
1435
|
+
const state = {
|
|
1436
|
+
bridge: void 0,
|
|
1437
|
+
revision: 0
|
|
1438
|
+
};
|
|
1439
|
+
const injectFn = ctx.inject;
|
|
1440
|
+
if (typeof injectFn !== "function") return state;
|
|
1441
|
+
try {
|
|
1442
|
+
injectFn.call(ctx, ["settings"], (sctx) => {
|
|
1443
|
+
try {
|
|
1444
|
+
const settings = sctx?.settings;
|
|
1445
|
+
if (settings === void 0 || typeof settings.register !== "function") return;
|
|
1446
|
+
const carrier = settings;
|
|
1447
|
+
if (carrier[SHARED_LEASE] !== true) {
|
|
1448
|
+
settings.register(STEWARD_SETTINGS_NAMESPACE, StewardNsSchema);
|
|
1449
|
+
settings.register(SWITCH_SEARCH_SETTINGS_NAMESPACE, SwitchNsSchema);
|
|
1450
|
+
try {
|
|
1451
|
+
Object.defineProperty(carrier, SHARED_LEASE, {
|
|
1452
|
+
configurable: false,
|
|
1453
|
+
enumerable: false,
|
|
1454
|
+
writable: false,
|
|
1455
|
+
value: true
|
|
1456
|
+
});
|
|
1457
|
+
} catch {}
|
|
1458
|
+
}
|
|
1459
|
+
state.bridge = {
|
|
1460
|
+
get: (ns) => {
|
|
1461
|
+
try {
|
|
1462
|
+
return asDoc(settings.get?.(ns));
|
|
1463
|
+
} catch {
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
},
|
|
1467
|
+
update: async (ns, patch) => {
|
|
1468
|
+
await settings.update?.(ns, patch);
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
console.log(`[dsh-manage-session] legacy settings bridge engaged (${STEWARD_SETTINGS_NAMESPACE} + ${SWITCH_SEARCH_SETTINGS_NAMESPACE})`);
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
console.log(`[dsh-manage-session] legacy settings bridge FAILED (cards degrade to read-only): ${String(err instanceof Error ? err.message : err)}`);
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
} catch {}
|
|
1477
|
+
return state;
|
|
1478
|
+
}
|
|
1479
|
+
function asBoolean(v) {
|
|
1480
|
+
return typeof v === "boolean" ? {
|
|
1481
|
+
ok: true,
|
|
1482
|
+
value: v
|
|
1483
|
+
} : {
|
|
1484
|
+
ok: false,
|
|
1485
|
+
error: "期望布尔值"
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/** 各命名空间允许写入的字段与校验(未列出的字段一律拒绝——桥不是任意写缝)。 */
|
|
1489
|
+
const FIELD_VALIDATORS = {
|
|
1490
|
+
[STEWARD_SETTINGS_NAMESPACE]: {
|
|
1491
|
+
enabled: asBoolean,
|
|
1492
|
+
historyFiles: asBoolean,
|
|
1493
|
+
healthCheck: asBoolean,
|
|
1494
|
+
search: asBoolean
|
|
1495
|
+
},
|
|
1496
|
+
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
1497
|
+
enabled: asBoolean,
|
|
1498
|
+
defaultMode: (v) => v === "title" || v === "content" ? {
|
|
1499
|
+
ok: true,
|
|
1500
|
+
value: v
|
|
1501
|
+
} : {
|
|
1502
|
+
ok: false,
|
|
1503
|
+
error: "defaultMode 只接受 title|content"
|
|
1504
|
+
},
|
|
1505
|
+
autoSync: asBoolean,
|
|
1506
|
+
syncIntervalMs: (v) => {
|
|
1507
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return {
|
|
1508
|
+
ok: false,
|
|
1509
|
+
error: "syncIntervalMs 必须是有限数字"
|
|
1510
|
+
};
|
|
1511
|
+
return {
|
|
1512
|
+
ok: true,
|
|
1513
|
+
value: Math.min(36e5, Math.max(5e3, Math.round(v)))
|
|
1514
|
+
};
|
|
1515
|
+
},
|
|
1516
|
+
archiveKeep: (v) => {
|
|
1517
|
+
if (typeof v !== "number" || !Number.isInteger(v)) return {
|
|
1518
|
+
ok: false,
|
|
1519
|
+
error: "archiveKeep 必须是整数"
|
|
1520
|
+
};
|
|
1521
|
+
return {
|
|
1522
|
+
ok: true,
|
|
1523
|
+
value: Math.min(20, Math.max(0, v))
|
|
1524
|
+
};
|
|
1525
|
+
},
|
|
1526
|
+
indexDir: (v) => {
|
|
1527
|
+
if (typeof v !== "string") return {
|
|
1528
|
+
ok: false,
|
|
1529
|
+
error: "indexDir 必须是字符串"
|
|
1530
|
+
};
|
|
1531
|
+
const trimmed = v.trim();
|
|
1532
|
+
if (trimmed.length > 500) return {
|
|
1533
|
+
ok: false,
|
|
1534
|
+
error: "indexDir 过长(>500 字符)"
|
|
1535
|
+
};
|
|
1536
|
+
return {
|
|
1537
|
+
ok: true,
|
|
1538
|
+
value: trimmed
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
/** 汇总两份命名空间文档:legacy 读盘上文档合并缺省;modern 返回空投影(writable=false)。 */
|
|
1544
|
+
function describeNamespaces(state) {
|
|
1545
|
+
const { bridge } = state;
|
|
1546
|
+
if (bridge !== void 0) return {
|
|
1547
|
+
[STEWARD_SETTINGS_NAMESPACE]: {
|
|
1548
|
+
...DEFAULT_CONFIG,
|
|
1549
|
+
...bridge.get("session-steward") ?? {}
|
|
1550
|
+
},
|
|
1551
|
+
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
1552
|
+
...SWITCH_DEFAULT_CONFIG,
|
|
1553
|
+
...bridge.get("switch-search") ?? {}
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
return {
|
|
1557
|
+
[STEWARD_SETTINGS_NAMESPACE]: {},
|
|
1558
|
+
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {}
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
/** describe 响应。 */
|
|
1562
|
+
function settingsDescribe(state) {
|
|
1563
|
+
return {
|
|
1564
|
+
ok: true,
|
|
1565
|
+
namespaces: describeNamespaces(state),
|
|
1566
|
+
writable: state.bridge !== void 0,
|
|
1567
|
+
revision: state.revision
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
/** 合并写一个命名空间;任何校验/宿主拒绝都显式失败,成功回权威文档。 */
|
|
1571
|
+
async function settingsMutate(state, payload) {
|
|
1572
|
+
if (state.bridge === void 0) return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
error: "本宿主无 legacy settings 服务(0.1.7+ 走 configForms 原生句柄),桥不可写"
|
|
1575
|
+
};
|
|
1576
|
+
const request = payload ?? {};
|
|
1577
|
+
if (typeof request.ns !== "string" || !(request.ns in FIELD_VALIDATORS)) return {
|
|
1578
|
+
ok: false,
|
|
1579
|
+
error: `未知设置命名空间:${String(request.ns)}`
|
|
1580
|
+
};
|
|
1581
|
+
if (request.fields === null || typeof request.fields !== "object" || Array.isArray(request.fields)) return {
|
|
1582
|
+
ok: false,
|
|
1583
|
+
error: "fields 必须是对象"
|
|
1584
|
+
};
|
|
1585
|
+
const validators = FIELD_VALIDATORS[request.ns];
|
|
1586
|
+
const clean = {};
|
|
1587
|
+
for (const [field, value] of Object.entries(request.fields)) {
|
|
1588
|
+
const validate = validators[field];
|
|
1589
|
+
if (validate === void 0) return {
|
|
1590
|
+
ok: false,
|
|
1591
|
+
error: `命名空间 ${request.ns} 不接受字段 "${field}"`
|
|
1592
|
+
};
|
|
1593
|
+
const verdict = validate(value);
|
|
1594
|
+
if (!verdict.ok) return {
|
|
1595
|
+
ok: false,
|
|
1596
|
+
error: `${field}: ${verdict.error}`
|
|
1597
|
+
};
|
|
1598
|
+
clean[field] = verdict.value;
|
|
1599
|
+
}
|
|
1600
|
+
if (Object.keys(clean).length === 0) return {
|
|
1601
|
+
ok: false,
|
|
1602
|
+
error: "fields 为空"
|
|
1603
|
+
};
|
|
1604
|
+
try {
|
|
1605
|
+
await state.bridge.update(request.ns, clean);
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
return {
|
|
1608
|
+
ok: false,
|
|
1609
|
+
error: `宿主拒绝写入:${String(err instanceof Error ? err.message : err)}`
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
state.revision += 1;
|
|
1613
|
+
return {
|
|
1614
|
+
ok: true,
|
|
1615
|
+
namespaces: describeNamespaces(state),
|
|
1616
|
+
revision: state.revision
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1205
1619
|
//#endregion
|
|
1206
1620
|
//#region src/host/health/attribution.ts
|
|
1207
1621
|
/**
|
|
@@ -2334,82 +2748,1477 @@ function scanSessions(options) {
|
|
|
2334
2748
|
...options.onlyProblems === true ? { filtered: true } : {}
|
|
2335
2749
|
};
|
|
2336
2750
|
}
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
/**
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
}
|
|
2346
|
-
/** 运行时配置 schema(与 src/config.ts 的形状保持一致)。0.1.7:volatile 字段即设置表单。 */
|
|
2347
|
-
const Config = z.object({
|
|
2348
|
-
enabled: z.boolean().default(true).volatile(),
|
|
2349
|
-
historyFiles: z.boolean().default(true).volatile(),
|
|
2350
|
-
healthCheck: z.boolean().default(true).volatile()
|
|
2351
|
-
});
|
|
2352
|
-
/** 单次请求体的上限(防御无界读取)。 */
|
|
2353
|
-
const MAX_BODY_BYTES = 16 << 20;
|
|
2354
|
-
/** 归一化 Host authority,无法解析时为 undefined。 */
|
|
2355
|
-
function parseAuthority(authority) {
|
|
2751
|
+
/** Application id marking files owned by this plugin's index (ASCII "SWIS"). */
|
|
2752
|
+
const SWITCH_SEARCH_APPLICATION_ID = 1398229332;
|
|
2753
|
+
/**
|
|
2754
|
+
* Open the raw handle: better-sqlite3 (synchronous, faster statement
|
|
2755
|
+
* dispatch) when the optional dependency is present, node:sqlite otherwise.
|
|
2756
|
+
* Both expose the same prepare/exec/close shape this plugin uses.
|
|
2757
|
+
*/
|
|
2758
|
+
async function openRawHandle(actual) {
|
|
2356
2759
|
try {
|
|
2357
|
-
|
|
2760
|
+
const mod = await import("better-sqlite3");
|
|
2761
|
+
return {
|
|
2762
|
+
db: new (mod.default ?? mod)(actual),
|
|
2763
|
+
driver: "better-sqlite3"
|
|
2764
|
+
};
|
|
2358
2765
|
} catch {
|
|
2359
|
-
|
|
2766
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
2767
|
+
return {
|
|
2768
|
+
db: new DatabaseSync(actual),
|
|
2769
|
+
driver: "node:sqlite"
|
|
2770
|
+
};
|
|
2360
2771
|
}
|
|
2361
2772
|
}
|
|
2362
|
-
/**
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
function
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
return (entryUrl.port === "" ? entryUrl.hostname : entryUrl.host) === hostUrl.host;
|
|
2374
|
-
});
|
|
2375
|
-
}
|
|
2376
|
-
/** 浏览器可信围栏(与 /api 网关行为一致):DNS 重绑定/跨站防御,不是鉴权。 */
|
|
2377
|
-
function isTrustedApiRequest(req, trustedHosts) {
|
|
2378
|
-
const host = req.headers.host;
|
|
2379
|
-
if (host === void 0) return false;
|
|
2380
|
-
const hostUrl = parseAuthority(host);
|
|
2381
|
-
if (hostUrl === void 0) return false;
|
|
2382
|
-
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
2383
|
-
const fetchSite = req.headers["sec-fetch-site"];
|
|
2384
|
-
if (typeof fetchSite === "string" && fetchSite === "cross-site") return false;
|
|
2385
|
-
const origin = req.headers.origin;
|
|
2386
|
-
if (origin === void 0) return true;
|
|
2773
|
+
/**
|
|
2774
|
+
* Open, validate, and initialize one switch-search index file.
|
|
2775
|
+
* Missing directories and files are created; a file that belongs to another
|
|
2776
|
+
* application (including the official session-query index) is refused.
|
|
2777
|
+
* @param path - absolute path to the index file.
|
|
2778
|
+
* @returns initialized database handle owned by the caller, plus the driver.
|
|
2779
|
+
*/
|
|
2780
|
+
async function openIndexDatabase(path) {
|
|
2781
|
+
const actual = resolve(path);
|
|
2782
|
+
await mkdir(dirname(actual), { recursive: true });
|
|
2783
|
+
const { db, driver } = await openRawHandle(actual);
|
|
2387
2784
|
try {
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2785
|
+
const { application_id: applicationId } = db.prepare("PRAGMA application_id").get();
|
|
2786
|
+
const { user_version: version } = db.prepare("PRAGMA user_version").get();
|
|
2787
|
+
if (applicationId === 1146308689) throw new Error(`switch-search: "${actual}" is the official session-query index, refusing to open it`);
|
|
2788
|
+
if (applicationId !== 0 && applicationId !== 1398229332) throw new Error(`switch-search: database at "${actual}" belongs to another application`);
|
|
2789
|
+
if (applicationId === 1398229332 && version !== 5) resetSchema(db);
|
|
2790
|
+
db.exec(`PRAGMA journal_mode = wal`);
|
|
2791
|
+
ensureSchema(db);
|
|
2792
|
+
db.exec(`PRAGMA synchronous = NORMAL`);
|
|
2793
|
+
db.exec(`PRAGMA temp_store = MEMORY`);
|
|
2794
|
+
db.exec(`PRAGMA cache_size = -65536`);
|
|
2795
|
+
return {
|
|
2796
|
+
db,
|
|
2797
|
+
driver
|
|
2798
|
+
};
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
db.close();
|
|
2801
|
+
throw error;
|
|
2391
2802
|
}
|
|
2392
2803
|
}
|
|
2393
|
-
/**
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
2804
|
+
/** Reset an incompatible database in place, preserving the owning application id. */
|
|
2805
|
+
function resetSchema(db) {
|
|
2806
|
+
db.exec(`
|
|
2807
|
+
PRAGMA writable_schema = OFF;
|
|
2808
|
+
PRAGMA journal_mode = delete;
|
|
2809
|
+
`);
|
|
2810
|
+
for (const row of db.prepare(`
|
|
2811
|
+
SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
2812
|
+
`).all()) db.exec(`DROP TABLE IF EXISTS "${row.name.replace(/"/g, "\"\"")}"`);
|
|
2813
|
+
db.exec(`PRAGMA user_version = 0`);
|
|
2404
2814
|
}
|
|
2405
|
-
/**
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2815
|
+
/** Create the persistent schema when absent. */
|
|
2816
|
+
function ensureSchema(db) {
|
|
2817
|
+
db.exec(`
|
|
2818
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
2819
|
+
key TEXT PRIMARY KEY,
|
|
2820
|
+
value TEXT NOT NULL
|
|
2821
|
+
);
|
|
2822
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
2823
|
+
session_id TEXT PRIMARY KEY,
|
|
2824
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
2825
|
+
title TEXT NOT NULL DEFAULT '',
|
|
2826
|
+
cwd TEXT NOT NULL DEFAULT '',
|
|
2827
|
+
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
2828
|
+
indexed_at INTEGER NOT NULL DEFAULT 0,
|
|
2829
|
+
archived INTEGER NOT NULL DEFAULT 0
|
|
2830
|
+
);
|
|
2831
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
2832
|
+
doc_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2833
|
+
session_id TEXT NOT NULL,
|
|
2834
|
+
seq INTEGER NOT NULL,
|
|
2835
|
+
type TEXT NOT NULL,
|
|
2836
|
+
surface TEXT NOT NULL,
|
|
2837
|
+
time INTEGER NOT NULL,
|
|
2838
|
+
text TEXT NOT NULL,
|
|
2839
|
+
index_text TEXT NOT NULL DEFAULT ''
|
|
2840
|
+
);
|
|
2841
|
+
CREATE UNIQUE INDEX IF NOT EXISTS docs_session_seq ON docs(session_id, seq);
|
|
2842
|
+
CREATE INDEX IF NOT EXISTS docs_session ON docs(session_id);
|
|
2843
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
2844
|
+
index_text,
|
|
2845
|
+
content = 'docs',
|
|
2846
|
+
content_rowid = 'doc_id',
|
|
2847
|
+
tokenize = 'unicode61'
|
|
2848
|
+
);
|
|
2849
|
+
`);
|
|
2850
|
+
db.exec(`PRAGMA application_id = ${SWITCH_SEARCH_APPLICATION_ID}`);
|
|
2851
|
+
db.exec(`PRAGMA user_version = 5`);
|
|
2852
|
+
}
|
|
2853
|
+
//#endregion
|
|
2854
|
+
//#region src/host/index/extract.ts
|
|
2855
|
+
/**
|
|
2856
|
+
* 从会话事件流里抽出**最后一帧标题**(`session/title` log-only 事件)。
|
|
2857
|
+
*
|
|
2858
|
+
* 这是标题的基础层:入索引时随事件白拿,不依赖 readTitleSnapshots(后者
|
|
2859
|
+
* 对归档 id 可能整批失败——beta.4 标题全军覆没的根因)。数据形状按历史
|
|
2860
|
+
* 演变兼容三种:`{ title: string }`、`{ title: { title } }`、`{ title: { val } }`。
|
|
2861
|
+
*/
|
|
2862
|
+
function extractTitleFromEvents(events) {
|
|
2863
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
2864
|
+
const event = events[i];
|
|
2865
|
+
if (event === null || typeof event !== "object") continue;
|
|
2866
|
+
if (event.type !== "session/title") continue;
|
|
2867
|
+
const title = event.data?.title;
|
|
2868
|
+
if (typeof title === "string" && title.trim() !== "") return title;
|
|
2869
|
+
if (title !== null && typeof title === "object") {
|
|
2870
|
+
const inner = title;
|
|
2871
|
+
if (typeof inner.title === "string" && inner.title.trim() !== "") return inner.title;
|
|
2872
|
+
if (typeof inner.val === "string" && inner.val.trim() !== "") return inner.val;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
return "";
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* ICU word segmenter shared by index and query paths (Node >= 16 / all evergreen
|
|
2879
|
+
* browsers, zero dependency). 'zh' sensitivity keeps CJK word granularity.
|
|
2880
|
+
*/
|
|
2881
|
+
const SEGMENTER = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter("zh", { granularity: "word" }) : void 0;
|
|
2882
|
+
/**
|
|
2883
|
+
* Space-separate word boundaries so the FTS5 unicode61 tokenizer indexes
|
|
2884
|
+
* words instead of whole CJK runs: the index and query sides must apply the
|
|
2885
|
+
* exact same segmentation for a token to meet its match.
|
|
2886
|
+
* @param text - raw extracted text (or a query term).
|
|
2887
|
+
* @returns text with a single space at every word boundary.
|
|
2888
|
+
*/
|
|
2889
|
+
function segmentForIndex(text) {
|
|
2890
|
+
if (SEGMENTER === void 0) return text;
|
|
2891
|
+
const parts = [];
|
|
2892
|
+
for (const { segment } of SEGMENTER.segment(text)) {
|
|
2893
|
+
const piece = segment.trim();
|
|
2894
|
+
if (piece !== "") parts.push(piece);
|
|
2895
|
+
}
|
|
2896
|
+
return parts.join(" ");
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* Segment one whitespace-delimited query term into FTS5 phrase tokens.
|
|
2900
|
+
* @returns word-like segments, or the raw term when segmentation is unavailable.
|
|
2901
|
+
*/
|
|
2902
|
+
function segmentQueryTerm(term) {
|
|
2903
|
+
if (SEGMENTER === void 0) return [term];
|
|
2904
|
+
const words = [];
|
|
2905
|
+
for (const { segment, isWordLike } of SEGMENTER.segment(term)) {
|
|
2906
|
+
const piece = segment.trim();
|
|
2907
|
+
if (piece !== "" && isWordLike === true) words.push(piece);
|
|
2908
|
+
}
|
|
2909
|
+
return words.length > 0 ? words : [term];
|
|
2910
|
+
}
|
|
2911
|
+
/** Whether a runtime value is a plain record. */
|
|
2912
|
+
function isRecord(value) {
|
|
2913
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2914
|
+
}
|
|
2915
|
+
/** Trimmed text of one string-or-undefined part list, joined by newlines. */
|
|
2916
|
+
function joinText(parts) {
|
|
2917
|
+
return parts.map((part) => typeof part === "string" ? part.trim() : "").filter(Boolean).join("\n");
|
|
2918
|
+
}
|
|
2919
|
+
/** Extracted text of one content block; unknown blocks contribute nothing. */
|
|
2920
|
+
function blockText(block) {
|
|
2921
|
+
if (!isRecord(block)) return [];
|
|
2922
|
+
switch (block["type"]) {
|
|
2923
|
+
case "text": return typeof block["text"] === "string" ? [block["text"]] : [];
|
|
2924
|
+
case "reasoning": return [];
|
|
2925
|
+
case "tool-call": return [block["name"], block["arguments"]].filter((v) => typeof v === "string");
|
|
2926
|
+
case "tool-result": {
|
|
2927
|
+
const content = block["content"];
|
|
2928
|
+
return Array.isArray(content) ? content.flatMap(blockText) : [];
|
|
2929
|
+
}
|
|
2930
|
+
default: return [];
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
/** Text of one message content (array of blocks, or a plain string). */
|
|
2934
|
+
function contentText(content) {
|
|
2935
|
+
if (typeof content === "string") return content.trim();
|
|
2936
|
+
if (!Array.isArray(content)) return "";
|
|
2937
|
+
return joinText(content.flatMap(blockText));
|
|
2938
|
+
}
|
|
2939
|
+
/** Turn-end reason text; completed turns contribute nothing. */
|
|
2940
|
+
function turnEndText(data) {
|
|
2941
|
+
const reason = data["reason"];
|
|
2942
|
+
if (!isRecord(reason)) return "";
|
|
2943
|
+
switch (reason["kind"]) {
|
|
2944
|
+
case "error": {
|
|
2945
|
+
const error = reason["error"];
|
|
2946
|
+
return joinText(["error", isRecord(error) && typeof error["message"] === "string" ? error["message"] : ""]);
|
|
2947
|
+
}
|
|
2948
|
+
case "aborted": return "aborted";
|
|
2949
|
+
case "max-tokens":
|
|
2950
|
+
case "interrupted": return String(reason["kind"]);
|
|
2951
|
+
case "completed": return "";
|
|
2952
|
+
default: return "";
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
/**
|
|
2956
|
+
* Extract searchable semantic text from one raw session event.
|
|
2957
|
+
* @param event - event to inspect.
|
|
2958
|
+
* @returns newline-joined semantic text, or an empty string when non-searchable.
|
|
2959
|
+
*/
|
|
2960
|
+
function extractSessionEventText(event) {
|
|
2961
|
+
const data = isRecord(event.data) ? event.data : {};
|
|
2962
|
+
switch (event.type) {
|
|
2963
|
+
case "user/message": return contentText(data["content"]);
|
|
2964
|
+
case "assistant/message": return contentText((isRecord(data["message"]) ? data["message"] : {})["content"]);
|
|
2965
|
+
case "tool/call": return joinText([data["name"], data["arguments"]].filter((v) => typeof v === "string"));
|
|
2966
|
+
case "tool/result": {
|
|
2967
|
+
const message = isRecord(data["message"]) ? data["message"] : {};
|
|
2968
|
+
const error = isRecord(data["error"]) ? data["error"] : {};
|
|
2969
|
+
return joinText([
|
|
2970
|
+
contentText(message["content"]),
|
|
2971
|
+
typeof error["name"] === "string" ? error["name"] : "",
|
|
2972
|
+
typeof error["code"] === "string" ? error["code"] : ""
|
|
2973
|
+
]);
|
|
2974
|
+
}
|
|
2975
|
+
case "todo/write": return joinText((Array.isArray(data["todos"]) ? data["todos"] : []).flatMap((todo) => {
|
|
2976
|
+
if (!isRecord(todo)) return [];
|
|
2977
|
+
return [todo["status"], todo["content"]].filter((v) => typeof v === "string");
|
|
2978
|
+
}));
|
|
2979
|
+
case "turn/end": return turnEndText(data);
|
|
2980
|
+
case "turn/start":
|
|
2981
|
+
case "step/start":
|
|
2982
|
+
case "step/end":
|
|
2983
|
+
case "assistant/attempt":
|
|
2984
|
+
case "request/header": return "";
|
|
2985
|
+
default: return "";
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
/** Event types whose ops participate in the surface fold. */
|
|
2989
|
+
const SURFACE_ELIGIBLE_TYPES = /* @__PURE__ */ new Set([
|
|
2990
|
+
"user/message",
|
|
2991
|
+
"assistant/message",
|
|
2992
|
+
"tool/call",
|
|
2993
|
+
"tool/result"
|
|
2994
|
+
]);
|
|
2995
|
+
/**
|
|
2996
|
+
* Classify raw-log events into current vs shadowed surface membership.
|
|
2997
|
+
*
|
|
2998
|
+
* Simplified fold of the official `foldSurface`: append events join the
|
|
2999
|
+
* surface, and a `replace` op shadows the declared inclusive seq range plus
|
|
3000
|
+
* removes it from the surface. Validation is intentionally lax — a broken op
|
|
3001
|
+
* degrades to append rather than failing the whole index build.
|
|
3002
|
+
* @param events - complete contiguous raw event log.
|
|
3003
|
+
* @returns seq → surface map; entries absent from the map are log-only.
|
|
3004
|
+
*/
|
|
3005
|
+
function classifySurface(events) {
|
|
3006
|
+
const surface = /* @__PURE__ */ new Map();
|
|
3007
|
+
const nodes = [];
|
|
3008
|
+
for (const event of events) {
|
|
3009
|
+
if (!SURFACE_ELIGIBLE_TYPES.has(event.type)) continue;
|
|
3010
|
+
const op = event.surfaceOp;
|
|
3011
|
+
if (op === "append" || op === void 0) {
|
|
3012
|
+
nodes.push(event.seq);
|
|
3013
|
+
surface.set(event.seq, "current");
|
|
3014
|
+
continue;
|
|
3015
|
+
}
|
|
3016
|
+
if (isRecord(op) && op["op"] === "replace" && typeof op["startSeq"] === "number" && typeof op["endSeq"] === "number") {
|
|
3017
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
3018
|
+
for (const seq of nodes) if (seq >= op["startSeq"] && seq <= op["endSeq"]) shadowed.add(seq);
|
|
3019
|
+
const kept = nodes.filter((seq) => !shadowed.has(seq));
|
|
3020
|
+
nodes.length = 0;
|
|
3021
|
+
nodes.push(...kept, event.seq);
|
|
3022
|
+
for (const seq of shadowed) surface.set(seq, "shadowed");
|
|
3023
|
+
surface.set(event.seq, "current");
|
|
3024
|
+
continue;
|
|
3025
|
+
}
|
|
3026
|
+
nodes.push(event.seq);
|
|
3027
|
+
surface.set(event.seq, "current");
|
|
3028
|
+
}
|
|
3029
|
+
return surface;
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* Project one complete raw log into searchable documents.
|
|
3033
|
+
* @param sessionId - session that owns the log.
|
|
3034
|
+
* @param events - complete contiguous raw event log.
|
|
3035
|
+
* @returns documents in ascending seq order; structural events are omitted.
|
|
3036
|
+
*/
|
|
3037
|
+
function buildIndexDocuments(sessionId, events) {
|
|
3038
|
+
const surfaceBySeq = classifySurface(events);
|
|
3039
|
+
const documents = [];
|
|
3040
|
+
for (const event of events) {
|
|
3041
|
+
const text = extractSessionEventText(event);
|
|
3042
|
+
if (text.length === 0) continue;
|
|
3043
|
+
documents.push({
|
|
3044
|
+
sessionId,
|
|
3045
|
+
seq: event.seq,
|
|
3046
|
+
type: event.type,
|
|
3047
|
+
time: typeof event.time === "number" ? event.time : 0,
|
|
3048
|
+
surface: surfaceBySeq.get(event.seq) ?? "shadowed",
|
|
3049
|
+
text
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
return documents;
|
|
3053
|
+
}
|
|
3054
|
+
//#endregion
|
|
3055
|
+
//#region src/host/index/engine.ts
|
|
3056
|
+
/** Coarse filter → raw event types. */
|
|
3057
|
+
const CONTENT_TYPE_GROUPS = {
|
|
3058
|
+
user: ["user/message"],
|
|
3059
|
+
reply: ["assistant/message"],
|
|
3060
|
+
tool: ["tool/call", "tool/result"]
|
|
3061
|
+
};
|
|
3062
|
+
/** Search weight per event type (message content outranks tool chatter). */
|
|
3063
|
+
const TYPE_WEIGHT = {
|
|
3064
|
+
"user/message": 3,
|
|
3065
|
+
"assistant/message": 3,
|
|
3066
|
+
"tool/call": 1,
|
|
3067
|
+
"tool/result": 1
|
|
3068
|
+
};
|
|
3069
|
+
/** Maximum snippet length in characters, aligned with the official route. */
|
|
3070
|
+
const SNIPPET_CHARS = 240;
|
|
3071
|
+
/** Maximum FTS matches inspected per query before session grouping. */
|
|
3072
|
+
const MATCH_SCAN_LIMIT = 5e3;
|
|
3073
|
+
/**
|
|
3074
|
+
* Sanitize free text into a safe FTS5 query over the segmented index: each
|
|
3075
|
+
* whitespace term is segmented into word tokens, quoted as an adjacent
|
|
3076
|
+
* phrase, and the last token carries a prefix `*` so partial input matches
|
|
3077
|
+
* ("正在搜" hits 正在搜索). Terms AND together.
|
|
3078
|
+
*/
|
|
3079
|
+
function sanitizeFtsQuery(query) {
|
|
3080
|
+
const terms = query.split(/\s+/u).filter(Boolean);
|
|
3081
|
+
if (terms.length === 0) return "";
|
|
3082
|
+
const phrases = [];
|
|
3083
|
+
for (const term of terms) {
|
|
3084
|
+
const words = segmentQueryTerm(term).map((word) => word.replace(/"/g, "\"\"")).filter((word) => word !== "");
|
|
3085
|
+
if (words.length === 0) continue;
|
|
3086
|
+
phrases.push(`"${words.join(" ")}"*`);
|
|
3087
|
+
}
|
|
3088
|
+
return phrases.join(" ");
|
|
3089
|
+
}
|
|
3090
|
+
/** Build a snippet around the first term occurrence, official-route aligned. */
|
|
3091
|
+
function buildSnippet(text, query, max = SNIPPET_CHARS) {
|
|
3092
|
+
const flat = text.replace(/\s+/gu, " ").trim();
|
|
3093
|
+
if (flat.length <= max) return flat;
|
|
3094
|
+
const lower = flat.toLowerCase();
|
|
3095
|
+
const terms = query.toLowerCase().split(/\s+/u).filter(Boolean);
|
|
3096
|
+
let anchor = -1;
|
|
3097
|
+
for (const term of terms) {
|
|
3098
|
+
const at = lower.indexOf(term);
|
|
3099
|
+
if (at >= 0 && (anchor < 0 || at < anchor)) anchor = at;
|
|
3100
|
+
}
|
|
3101
|
+
if (anchor < 0) return `${flat.slice(0, max)}…`;
|
|
3102
|
+
const start = Math.max(0, anchor - Math.floor((max - 3) / 2));
|
|
3103
|
+
const end = Math.min(flat.length, start + max - 3);
|
|
3104
|
+
const head = start > 0 ? "…" : "";
|
|
3105
|
+
const tail = end < flat.length ? "…" : "";
|
|
3106
|
+
return `${head}${flat.slice(start, end)}${tail}`;
|
|
3107
|
+
}
|
|
3108
|
+
/** One open index handle. All mutating calls are synchronous; callers pace
|
|
3109
|
+
* them off the HTTP hot path (background sync / rebuild tasks). */
|
|
3110
|
+
var SwitchIndexEngine = class {
|
|
3111
|
+
options;
|
|
3112
|
+
db;
|
|
3113
|
+
driver = "node:sqlite";
|
|
3114
|
+
inBatch = false;
|
|
3115
|
+
constructor(options) {
|
|
3116
|
+
this.options = options;
|
|
3117
|
+
}
|
|
3118
|
+
/** Which SQLite driver is serving this handle. */
|
|
3119
|
+
get driverLabel() {
|
|
3120
|
+
return this.driver;
|
|
3121
|
+
}
|
|
3122
|
+
/**
|
|
3123
|
+
* Run one write inside the current batched transaction, or its own
|
|
3124
|
+
* IMMEDIATE transaction when not batching (nested calls join the batch).
|
|
3125
|
+
*/
|
|
3126
|
+
withWriteTx(fn) {
|
|
3127
|
+
const db = this.requireDb();
|
|
3128
|
+
if (this.inBatch) return fn();
|
|
3129
|
+
db.exec("BEGIN IMMEDIATE");
|
|
3130
|
+
this.inBatch = true;
|
|
3131
|
+
try {
|
|
3132
|
+
const result = fn();
|
|
3133
|
+
db.exec("COMMIT");
|
|
3134
|
+
return result;
|
|
3135
|
+
} catch (error) {
|
|
3136
|
+
try {
|
|
3137
|
+
db.exec("ROLLBACK");
|
|
3138
|
+
} catch {}
|
|
3139
|
+
throw error;
|
|
3140
|
+
} finally {
|
|
3141
|
+
this.inBatch = false;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3145
|
+
* Run one function as a single batched transaction (one fsync checkpoint):
|
|
3146
|
+
* upserts inside it join via withWriteTx instead of opening their own.
|
|
3147
|
+
*/
|
|
3148
|
+
runBatched(fn) {
|
|
3149
|
+
return this.withWriteTx(fn);
|
|
3150
|
+
}
|
|
3151
|
+
/** Whether the handle is open. */
|
|
3152
|
+
get isOpen() {
|
|
3153
|
+
return this.db !== void 0;
|
|
3154
|
+
}
|
|
3155
|
+
/** Open (creating or migrating) the index file. Idempotent. */
|
|
3156
|
+
async open() {
|
|
3157
|
+
if (this.db !== void 0) return;
|
|
3158
|
+
const opened = await openIndexDatabase(this.options.path);
|
|
3159
|
+
this.db = opened.db;
|
|
3160
|
+
this.driver = opened.driver;
|
|
3161
|
+
}
|
|
3162
|
+
/** Close the handle. Idempotent. */
|
|
3163
|
+
close() {
|
|
3164
|
+
this.db?.close();
|
|
3165
|
+
this.db = void 0;
|
|
3166
|
+
}
|
|
3167
|
+
/** Remove one session's FTS entries for external-content bookkeeping. */
|
|
3168
|
+
deleteSessionFts(db, sessionId) {
|
|
3169
|
+
const existing = db.prepare("SELECT doc_id, index_text FROM docs WHERE session_id = ?").all(sessionId);
|
|
3170
|
+
const deleteFts = db.prepare(`
|
|
3171
|
+
INSERT INTO docs_fts(docs_fts, rowid, index_text) VALUES ('delete', ?, ?)
|
|
3172
|
+
`);
|
|
3173
|
+
for (const row of existing) deleteFts.run(Number(row.doc_id), row.index_text);
|
|
3174
|
+
}
|
|
3175
|
+
/** Insert or replace one session's documents and header row. */
|
|
3176
|
+
upsertSession(input) {
|
|
3177
|
+
const db = this.requireDb();
|
|
3178
|
+
const documents = buildIndexDocuments(input.sessionId, input.events);
|
|
3179
|
+
this.withWriteTx(() => {
|
|
3180
|
+
this.deleteSessionFts(db, input.sessionId);
|
|
3181
|
+
db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
|
|
3182
|
+
const insertDoc = db.prepare(`
|
|
3183
|
+
INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
|
|
3184
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3185
|
+
`);
|
|
3186
|
+
const insertFts = db.prepare("INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)");
|
|
3187
|
+
for (const doc of documents) {
|
|
3188
|
+
const indexText = segmentForIndex(doc.text);
|
|
3189
|
+
const result = insertDoc.run(doc.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText);
|
|
3190
|
+
insertFts.run(Number(result.lastInsertRowid), indexText);
|
|
3191
|
+
}
|
|
3192
|
+
db.prepare(`
|
|
3193
|
+
INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
|
|
3194
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3195
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3196
|
+
version = excluded.version,
|
|
3197
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
3198
|
+
cwd = excluded.cwd,
|
|
3199
|
+
updated_at = excluded.updated_at,
|
|
3200
|
+
indexed_at = excluded.indexed_at
|
|
3201
|
+
`).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* Write an archived session's header row without any document content.
|
|
3206
|
+
*
|
|
3207
|
+
* @deprecated 旧"归档即软删"语义的遗物,合并包 R1 已改为归档保留 docs;仅剩
|
|
3208
|
+
* 旧快照兼容路径可落 header 行。新代码一律 upsertSession + setArchived。
|
|
3209
|
+
*/
|
|
3210
|
+
upsertArchivedHeader(input) {
|
|
3211
|
+
const db = this.requireDb();
|
|
3212
|
+
this.withWriteTx(() => {
|
|
3213
|
+
this.deleteSessionFts(db, input.sessionId);
|
|
3214
|
+
db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
|
|
3215
|
+
db.prepare(`
|
|
3216
|
+
INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
|
|
3217
|
+
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
3218
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3219
|
+
version = excluded.version,
|
|
3220
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
3221
|
+
cwd = excluded.cwd,
|
|
3222
|
+
updated_at = excluded.updated_at,
|
|
3223
|
+
indexed_at = excluded.indexed_at,
|
|
3224
|
+
archived = 1
|
|
3225
|
+
`).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now());
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
/**
|
|
3229
|
+
* Apply the official archive set: flip the archived flag both ways.
|
|
3230
|
+
*
|
|
3231
|
+
* 合表复用语义(R1):归档**保留全部 docs 与 FTS**——正文在归档态不可变
|
|
3232
|
+
* (归档门只拒新回合),翻转只是检索域成员资格的变化;恢复也不再需要重灌
|
|
3233
|
+
* (旧实现的 version = -1 强制重读随"归档即删 docs"一并废除)。
|
|
3234
|
+
*/
|
|
3235
|
+
setArchived(archivedIds) {
|
|
3236
|
+
const db = this.requireDb();
|
|
3237
|
+
this.withWriteTx(() => {
|
|
3238
|
+
const rows = db.prepare("SELECT session_id, archived FROM sessions").all();
|
|
3239
|
+
for (const row of rows) {
|
|
3240
|
+
const shouldBe = archivedIds.has(row.session_id) ? 1 : 0;
|
|
3241
|
+
if (row.archived !== shouldBe) db.prepare("UPDATE sessions SET archived = ? WHERE session_id = ?").run(shouldBe, row.session_id);
|
|
3242
|
+
}
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Flip one session's archived flag (steward write-side linkage). No-op when
|
|
3247
|
+
* the row does not exist — flag state on an unindexed session is meaningless.
|
|
3248
|
+
*/
|
|
3249
|
+
setArchivedOne(sessionId, archived) {
|
|
3250
|
+
const db = this.requireDb();
|
|
3251
|
+
this.withWriteTx(() => {
|
|
3252
|
+
db.prepare("UPDATE sessions SET archived = ? WHERE session_id = ?").run(archived ? 1 : 0, sessionId);
|
|
3253
|
+
});
|
|
3254
|
+
}
|
|
3255
|
+
/** One session's stored documents, ascending seq (snapshot export face). */
|
|
3256
|
+
exportSessionDocs(sessionId) {
|
|
3257
|
+
return this.requireDb().prepare(`
|
|
3258
|
+
SELECT seq, type, surface, time, text FROM docs WHERE session_id = ? ORDER BY seq
|
|
3259
|
+
`).all(sessionId);
|
|
3260
|
+
}
|
|
3261
|
+
/**
|
|
3262
|
+
* Insert or replace one session from already-extracted documents
|
|
3263
|
+
* (snapshot import face; no re-extraction, what was exported is restored;
|
|
3264
|
+
* segmentation is recomputed for the current index format).
|
|
3265
|
+
*/
|
|
3266
|
+
importSessionDocs(input) {
|
|
3267
|
+
const db = this.requireDb();
|
|
3268
|
+
this.withWriteTx(() => {
|
|
3269
|
+
this.deleteSessionFts(db, input.sessionId);
|
|
3270
|
+
db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
|
|
3271
|
+
const insertDoc = db.prepare(`
|
|
3272
|
+
INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
|
|
3273
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3274
|
+
`);
|
|
3275
|
+
const insertFts = db.prepare("INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)");
|
|
3276
|
+
for (const doc of input.docs) {
|
|
3277
|
+
const indexText = segmentForIndex(doc.text);
|
|
3278
|
+
const result = insertDoc.run(input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText);
|
|
3279
|
+
insertFts.run(Number(result.lastInsertRowid), indexText);
|
|
3280
|
+
}
|
|
3281
|
+
db.prepare(`
|
|
3282
|
+
INSERT INTO sessions (session_id, version, title, updated_at, indexed_at, archived)
|
|
3283
|
+
VALUES (?, ?, ?, 0, ?, ?)
|
|
3284
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3285
|
+
version = excluded.version,
|
|
3286
|
+
title = excluded.title,
|
|
3287
|
+
indexed_at = excluded.indexed_at,
|
|
3288
|
+
archived = excluded.archived
|
|
3289
|
+
`).run(input.sessionId, input.version, input.title ?? "", Date.now(), input.archived === true ? 1 : 0);
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
/** Remove one session and its documents entirely. */
|
|
3293
|
+
removeSession(sessionId) {
|
|
3294
|
+
const db = this.requireDb();
|
|
3295
|
+
this.withWriteTx(() => {
|
|
3296
|
+
this.deleteSessionFts(db, sessionId);
|
|
3297
|
+
db.prepare("DELETE FROM docs WHERE session_id = ?").run(sessionId);
|
|
3298
|
+
db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
/** Update only a session's header row (title backfill), keeping documents. */
|
|
3302
|
+
updateSessionHeader(input) {
|
|
3303
|
+
const db = this.requireDb();
|
|
3304
|
+
if (db.prepare("SELECT version FROM sessions WHERE session_id = ?").get(input.sessionId) === void 0) return;
|
|
3305
|
+
db.prepare(`
|
|
3306
|
+
UPDATE sessions SET
|
|
3307
|
+
title = CASE WHEN ? != '' THEN ? ELSE title END,
|
|
3308
|
+
cwd = CASE WHEN ? != '' THEN ? ELSE cwd END,
|
|
3309
|
+
updated_at = CASE WHEN ? > 0 THEN ? ELSE updated_at END
|
|
3310
|
+
WHERE session_id = ?
|
|
3311
|
+
`).run(input.title ?? "", input.title ?? "", input.cwd ?? "", input.cwd ?? "", input.updatedAt ?? 0, input.updatedAt ?? 0, input.sessionId);
|
|
3312
|
+
}
|
|
3313
|
+
/** One indexed session row, or undefined. */
|
|
3314
|
+
getSession(sessionId) {
|
|
3315
|
+
const row = this.requireDb().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
3316
|
+
return row === void 0 ? void 0 : rowToSession(row);
|
|
3317
|
+
}
|
|
3318
|
+
/** All indexed sessions (active AND archived), newest first — the title
|
|
3319
|
+
* corpus and the manage console both read this; the `archived` flag rides
|
|
3320
|
+
* each row so clients filter locally. */
|
|
3321
|
+
listIndexedSessions() {
|
|
3322
|
+
return this.requireDb().prepare("SELECT * FROM sessions ORDER BY updated_at DESC").all().map(rowToSession);
|
|
3323
|
+
}
|
|
3324
|
+
/** Archived (soft-deleted) sessions, newest first — the archive viewer face. */
|
|
3325
|
+
listArchived() {
|
|
3326
|
+
return this.requireDb().prepare("SELECT * FROM sessions WHERE archived = 1 ORDER BY updated_at DESC").all().map(rowToSession);
|
|
3327
|
+
}
|
|
3328
|
+
/** Number of indexed sessions (active + archived — the whole corpus). */
|
|
3329
|
+
countSessions() {
|
|
3330
|
+
const row = this.requireDb().prepare("SELECT COUNT(*) AS n FROM sessions").get();
|
|
3331
|
+
return Number(row.n);
|
|
3332
|
+
}
|
|
3333
|
+
/** Number of archived (soft-deleted) sessions. */
|
|
3334
|
+
countArchived() {
|
|
3335
|
+
const row = this.requireDb().prepare("SELECT COUNT(*) AS n FROM sessions WHERE archived = 1").get();
|
|
3336
|
+
return Number(row.n);
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Run one session-grouped full-text search.
|
|
3340
|
+
*
|
|
3341
|
+
* One statement: the FTS match is bounded by rank in a subquery (its rowid
|
|
3342
|
+
* aligns with docs.doc_id), then the type/surface filters join in — no
|
|
3343
|
+
* second round-trip, no large IN parameter lists.
|
|
3344
|
+
* @param request - query text, coarse type filter, page size, ordering.
|
|
3345
|
+
* @returns hits ordered by `sortBy` (relevance by default).
|
|
3346
|
+
*/
|
|
3347
|
+
search(request) {
|
|
3348
|
+
const db = this.requireDb();
|
|
3349
|
+
const match = sanitizeFtsQuery(request.query);
|
|
3350
|
+
if (match === "") return [];
|
|
3351
|
+
const limit = Math.min(Math.max(1, request.limit ?? 20), 100);
|
|
3352
|
+
const types = resolveTypes(request.types);
|
|
3353
|
+
const placeholders = types.map(() => "?").join(", ");
|
|
3354
|
+
const archivedClause = request.archived === "active" ? "AND s.archived = 0" : request.archived === "archived" ? "AND s.archived = 1" : "";
|
|
3355
|
+
const dateClauses = [];
|
|
3356
|
+
const dateParams = [];
|
|
3357
|
+
if (typeof request.from === "number" && Number.isFinite(request.from)) {
|
|
3358
|
+
dateClauses.push("AND s.updated_at >= ?");
|
|
3359
|
+
dateParams.push(request.from);
|
|
3360
|
+
}
|
|
3361
|
+
if (typeof request.to === "number" && Number.isFinite(request.to)) {
|
|
3362
|
+
dateClauses.push("AND s.updated_at < ?");
|
|
3363
|
+
dateParams.push(request.to);
|
|
3364
|
+
}
|
|
3365
|
+
const dateClause = dateClauses.join(" ");
|
|
3366
|
+
const docs = db.prepare(`
|
|
3367
|
+
SELECT d.doc_id AS docId, d.session_id AS sessionId, d.seq, d.type, d.time, d.text,
|
|
3368
|
+
s.title, s.updated_at AS updatedAt, f.rank AS ftsRank
|
|
3369
|
+
FROM (
|
|
3370
|
+
SELECT rowid, rank FROM docs_fts WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?
|
|
3371
|
+
) f
|
|
3372
|
+
JOIN docs d ON d.doc_id = f.rowid
|
|
3373
|
+
JOIN sessions s ON s.session_id = d.session_id
|
|
3374
|
+
WHERE d.type IN (${placeholders}) AND d.surface = 'current' ${archivedClause} ${dateClause}
|
|
3375
|
+
`).all(match, MATCH_SCAN_LIMIT, ...types, ...dateParams);
|
|
3376
|
+
const bestBySession = /* @__PURE__ */ new Map();
|
|
3377
|
+
for (const doc of docs) {
|
|
3378
|
+
const weight = TYPE_WEIGHT[doc.type] ?? 1;
|
|
3379
|
+
const score = -Number(doc.ftsRank) * weight;
|
|
3380
|
+
const best = bestBySession.get(doc.sessionId);
|
|
3381
|
+
if (best === void 0 || score > best.score) bestBySession.set(doc.sessionId, {
|
|
3382
|
+
doc,
|
|
3383
|
+
score
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
const grouped = [...bestBySession.values()];
|
|
3387
|
+
if (request.sortBy === "time") grouped.sort((a, b) => b.doc.updatedAt - a.doc.updatedAt || b.score - a.score);
|
|
3388
|
+
else grouped.sort((a, b) => b.score - a.score);
|
|
3389
|
+
return grouped.slice(0, limit).map(({ doc }) => ({
|
|
3390
|
+
sessionId: doc.sessionId,
|
|
3391
|
+
title: doc.title,
|
|
3392
|
+
seq: doc.seq,
|
|
3393
|
+
type: doc.type,
|
|
3394
|
+
time: doc.time,
|
|
3395
|
+
updatedAt: Number(doc.updatedAt ?? 0),
|
|
3396
|
+
snippet: buildSnippet(doc.text, request.query)
|
|
3397
|
+
}));
|
|
3398
|
+
}
|
|
3399
|
+
requireDb() {
|
|
3400
|
+
if (this.db === void 0) throw new Error("switch-search: index engine is not open");
|
|
3401
|
+
return this.db;
|
|
3402
|
+
}
|
|
3403
|
+
};
|
|
3404
|
+
/** Materialize the coarse filter into raw event types (absent → user+reply). */
|
|
3405
|
+
function resolveTypes(types) {
|
|
3406
|
+
if (types === void 0 || types.length === 0) return ["user/message", "assistant/message"];
|
|
3407
|
+
const picked = /* @__PURE__ */ new Set();
|
|
3408
|
+
for (const entry of types) if (entry === "user" || entry === "reply" || entry === "tool") picked.add(entry);
|
|
3409
|
+
else return [
|
|
3410
|
+
"user/message",
|
|
3411
|
+
"assistant/message",
|
|
3412
|
+
"tool/call",
|
|
3413
|
+
"tool/result"
|
|
3414
|
+
];
|
|
3415
|
+
if (picked.size === 0) return ["user/message", "assistant/message"];
|
|
3416
|
+
return [...picked].flatMap((entry) => [...CONTENT_TYPE_GROUPS[entry]]);
|
|
3417
|
+
}
|
|
3418
|
+
/** Map one raw sessions row onto the public face. */
|
|
3419
|
+
function rowToSession(row) {
|
|
3420
|
+
return {
|
|
3421
|
+
sessionId: String(row["session_id"]),
|
|
3422
|
+
version: Number(row["version"]),
|
|
3423
|
+
title: String(row["title"] ?? ""),
|
|
3424
|
+
cwd: String(row["cwd"] ?? ""),
|
|
3425
|
+
updatedAt: Number(row["updated_at"] ?? 0),
|
|
3426
|
+
indexedAt: Number(row["indexed_at"] ?? 0),
|
|
3427
|
+
archived: Number(row["archived"] ?? 0) === 1
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
3430
|
+
//#endregion
|
|
3431
|
+
//#region src/host/index/sync.ts
|
|
3432
|
+
/** 标题折叠的分块大小(一次 readTitleSnapshots 的 id 数上限,防整批一坏全坏)。 */
|
|
3433
|
+
const TITLE_CHUNK = 50;
|
|
3434
|
+
/**
|
|
3435
|
+
* One watermark syncer bound to one open engine. `poll()` is re-entrant-safe:
|
|
3436
|
+
* overlapping calls collapse into the running pass.
|
|
3437
|
+
*/
|
|
3438
|
+
var SwitchWatermarkSync = class {
|
|
3439
|
+
engine;
|
|
3440
|
+
sessionQuery;
|
|
3441
|
+
readArchiveSource;
|
|
3442
|
+
log;
|
|
3443
|
+
readSessionFromFile;
|
|
3444
|
+
readProjectionTitle;
|
|
3445
|
+
running;
|
|
3446
|
+
state = {
|
|
3447
|
+
state: "idle",
|
|
3448
|
+
lastSyncAt: 0,
|
|
3449
|
+
indexed: 0,
|
|
3450
|
+
total: 0,
|
|
3451
|
+
updated: 0,
|
|
3452
|
+
failures: []
|
|
3453
|
+
};
|
|
3454
|
+
/**
|
|
3455
|
+
* @param readSessionFromFile - 可选的**文件级兜底读取器**(管家 health 侧的
|
|
3456
|
+
* 多帧 zstd 读取器):sessionQuery.readSession 对某些会话(典型:归档会话,
|
|
3457
|
+
* 或服务面退化)失败时改读转录文件。返回 undefined 表示文件也不可用。
|
|
3458
|
+
*/
|
|
3459
|
+
constructor(engine, sessionQuery, readArchiveSource, log, readSessionFromFile, readProjectionTitle) {
|
|
3460
|
+
this.engine = engine;
|
|
3461
|
+
this.sessionQuery = sessionQuery;
|
|
3462
|
+
this.readArchiveSource = readArchiveSource;
|
|
3463
|
+
this.log = log;
|
|
3464
|
+
this.readSessionFromFile = readSessionFromFile;
|
|
3465
|
+
this.readProjectionTitle = readProjectionTitle;
|
|
3466
|
+
}
|
|
3467
|
+
/**
|
|
3468
|
+
* 读取一个会话的日志:服务面优先,失败落文件兜底。两条路都失败时抛最后
|
|
3469
|
+
* 一个错误,由调用方按会话隔离记失败。
|
|
3470
|
+
*/
|
|
3471
|
+
async readSessionLog(header) {
|
|
3472
|
+
try {
|
|
3473
|
+
return await this.sessionQuery.readSession(header.id);
|
|
3474
|
+
} catch (serviceError) {
|
|
3475
|
+
if (this.readSessionFromFile === void 0) throw serviceError;
|
|
3476
|
+
const fromFile = await this.readSessionFromFile(header.id);
|
|
3477
|
+
if (fromFile !== void 0) {
|
|
3478
|
+
this.log?.(`session ${header.id}: sessionQuery read failed, served from transcript file (${String(serviceError instanceof Error ? serviceError.message : serviceError)})`);
|
|
3479
|
+
return fromFile;
|
|
3480
|
+
}
|
|
3481
|
+
throw serviceError;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
/** Current progress snapshot (cloned). */
|
|
3485
|
+
snapshot() {
|
|
3486
|
+
return {
|
|
3487
|
+
...this.state,
|
|
3488
|
+
failures: [...this.state.failures]
|
|
3489
|
+
};
|
|
3490
|
+
}
|
|
3491
|
+
/**
|
|
3492
|
+
* Fold titles for an explicit id set without running a full pass.
|
|
3493
|
+
*
|
|
3494
|
+
* Used by the `session/title` event listener so a rename lands immediately
|
|
3495
|
+
* rather than at the next poll. It deliberately leaves watermarks alone: a
|
|
3496
|
+
* title-only refresh can never make the index claim content it has not read,
|
|
3497
|
+
* and the next poll still re-ingests the session off its bumped version.
|
|
3498
|
+
* Safe for unknown ids — the header write is a no-op when no row exists.
|
|
3499
|
+
* @param sessionIds - sessions whose titles should be re-folded.
|
|
3500
|
+
*/
|
|
3501
|
+
async refreshTitles(sessionIds) {
|
|
3502
|
+
await this.backfillTitles(sessionIds);
|
|
3503
|
+
}
|
|
3504
|
+
/**
|
|
3505
|
+
* Run one incremental pass (or await the running one).
|
|
3506
|
+
* @returns the state after the pass completes.
|
|
3507
|
+
*/
|
|
3508
|
+
poll() {
|
|
3509
|
+
if (this.running !== void 0) return this.running;
|
|
3510
|
+
this.running = this.runPass().finally(() => {
|
|
3511
|
+
this.running = void 0;
|
|
3512
|
+
});
|
|
3513
|
+
return this.running;
|
|
3514
|
+
}
|
|
3515
|
+
async runPass() {
|
|
3516
|
+
this.state.state = "syncing";
|
|
3517
|
+
const passStart = Date.now();
|
|
3518
|
+
try {
|
|
3519
|
+
const records = await this.sessionQuery.listSessions();
|
|
3520
|
+
this.state.total = records.length;
|
|
3521
|
+
const archiveSource = this.readArchiveSource?.();
|
|
3522
|
+
const archivedSet = new Set(archiveSource?.archivedSessionIds ?? []);
|
|
3523
|
+
this.engine.setArchived(archivedSet);
|
|
3524
|
+
const failures = [];
|
|
3525
|
+
let updated = 0;
|
|
3526
|
+
const changedIds = [];
|
|
3527
|
+
for (const record of records) {
|
|
3528
|
+
const header = record.header;
|
|
3529
|
+
const existing = this.engine.getSession(header.id);
|
|
3530
|
+
if (existing !== void 0 && existing.version === header.version) continue;
|
|
3531
|
+
changedIds.push(header.id);
|
|
3532
|
+
try {
|
|
3533
|
+
const log = await this.readSessionLog(header);
|
|
3534
|
+
this.engine.upsertSession({
|
|
3535
|
+
sessionId: header.id,
|
|
3536
|
+
version: header.version,
|
|
3537
|
+
cwd: log.session.cwd ?? header.cwd ?? "",
|
|
3538
|
+
updatedAt: log.session.createdAt ?? header.createdAt ?? 0,
|
|
3539
|
+
title: extractTitleFromEvents(log.events),
|
|
3540
|
+
events: log.events,
|
|
3541
|
+
archived: archivedSet.has(header.id)
|
|
3542
|
+
});
|
|
3543
|
+
updated += 1;
|
|
3544
|
+
} catch (err) {
|
|
3545
|
+
failures.push({
|
|
3546
|
+
sessionId: header.id,
|
|
3547
|
+
error: String(err instanceof Error ? err.message : err)
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
for (const archivedId of archivedSet) {
|
|
3552
|
+
if (this.engine.getSession(archivedId) !== void 0) continue;
|
|
3553
|
+
try {
|
|
3554
|
+
const log = await this.readSessionLog({
|
|
3555
|
+
id: archivedId,
|
|
3556
|
+
version: -1
|
|
3557
|
+
});
|
|
3558
|
+
this.engine.upsertSession({
|
|
3559
|
+
sessionId: archivedId,
|
|
3560
|
+
version: log.session.version,
|
|
3561
|
+
cwd: log.session.cwd ?? "",
|
|
3562
|
+
updatedAt: log.session.createdAt ?? 0,
|
|
3563
|
+
title: extractTitleFromEvents(log.events),
|
|
3564
|
+
events: log.events,
|
|
3565
|
+
archived: true
|
|
3566
|
+
});
|
|
3567
|
+
updated += 1;
|
|
3568
|
+
} catch (err) {
|
|
3569
|
+
failures.push({
|
|
3570
|
+
sessionId: archivedId,
|
|
3571
|
+
error: `archived: ${String(err instanceof Error ? err.message : err)}`
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
const corpusIds = new Set(records.map((record) => record.header.id));
|
|
3576
|
+
for (const indexed of this.engine.listIndexedSessions()) if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) this.engine.removeSession(indexed.sessionId);
|
|
3577
|
+
await this.backfillTitles(changedIds);
|
|
3578
|
+
const emptyTitles = this.engine.listIndexedSessions().filter((session) => session.title.trim() === "").map((session) => session.sessionId).slice(0, 100);
|
|
3579
|
+
if (emptyTitles.length > 0) {
|
|
3580
|
+
await this.backfillTitles(emptyTitles);
|
|
3581
|
+
let filled = 0;
|
|
3582
|
+
for (const id of emptyTitles) {
|
|
3583
|
+
const row = this.engine.getSession(id);
|
|
3584
|
+
if (row !== void 0 && row.title.trim() !== "") continue;
|
|
3585
|
+
const fromProjection = this.readProjectionTitle?.(id) ?? "";
|
|
3586
|
+
if (fromProjection.trim() !== "") {
|
|
3587
|
+
this.engine.updateSessionHeader({
|
|
3588
|
+
sessionId: id,
|
|
3589
|
+
title: fromProjection
|
|
3590
|
+
});
|
|
3591
|
+
filled += 1;
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
if (filled > 0) this.log?.(`title sweep: ${filled}/${emptyTitles.length} filled from projection cache`);
|
|
3595
|
+
}
|
|
3596
|
+
this.state.updated = updated;
|
|
3597
|
+
this.state.failures = failures;
|
|
3598
|
+
this.state.indexed = this.engine.countSessions();
|
|
3599
|
+
this.state.lastSyncAt = Date.now();
|
|
3600
|
+
this.state.state = "idle";
|
|
3601
|
+
this.state.error = void 0;
|
|
3602
|
+
this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
|
|
3603
|
+
} catch (err) {
|
|
3604
|
+
this.state.state = "error";
|
|
3605
|
+
this.state.error = String(err instanceof Error ? err.message : err);
|
|
3606
|
+
this.log?.(`sync pass FAILED: ${this.state.error}`);
|
|
3607
|
+
}
|
|
3608
|
+
return this.snapshot();
|
|
3609
|
+
}
|
|
3610
|
+
/**
|
|
3611
|
+
* Fold latest titles for changed sessions into the index header rows.
|
|
3612
|
+
*
|
|
3613
|
+
* beta.4 教训:整批一次调用,任何一个坏 id(典型:归档 id)让整个 promise
|
|
3614
|
+
* reject,catch 一吞就是**全部**标题丢失。改为分块 + 块失败时逐 id 重试 ——
|
|
3615
|
+
* 单点坏 id 最多损失它自己的精修标题(入索引时的 extractTitleFromEvents
|
|
3616
|
+
* 基础层仍然在)。
|
|
3617
|
+
*/
|
|
3618
|
+
async backfillTitles(sessionIds) {
|
|
3619
|
+
const readTitles = this.sessionQuery.readTitleSnapshots;
|
|
3620
|
+
if (readTitles === void 0 || sessionIds.length === 0) return;
|
|
3621
|
+
const ids = [...new Set(sessionIds)];
|
|
3622
|
+
const foldOne = (observation) => {
|
|
3623
|
+
if (observation.status !== "fulfilled" || observation.value === void 0) return;
|
|
3624
|
+
const title = observation.value.title?.title;
|
|
3625
|
+
if (typeof title === "string" && title.trim().length > 0) this.engine.updateSessionHeader({
|
|
3626
|
+
sessionId: observation.value.session.id,
|
|
3627
|
+
title
|
|
3628
|
+
});
|
|
3629
|
+
};
|
|
3630
|
+
for (let start = 0; start < ids.length; start += TITLE_CHUNK) {
|
|
3631
|
+
const chunk = ids.slice(start, start + TITLE_CHUNK);
|
|
3632
|
+
try {
|
|
3633
|
+
(await readTitles(chunk)).forEach(foldOne);
|
|
3634
|
+
} catch {
|
|
3635
|
+
for (const id of chunk) try {
|
|
3636
|
+
(await readTitles([id])).forEach(foldOne);
|
|
3637
|
+
} catch {}
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
};
|
|
3642
|
+
//#endregion
|
|
3643
|
+
//#region src/host/index/archive-source.ts
|
|
3644
|
+
/**
|
|
3645
|
+
* Official archive-set source resolution.
|
|
3646
|
+
*
|
|
3647
|
+
* Primary: the in-process `workspaceRegistry` service (the same fact the UI
|
|
3648
|
+
* filters by). Fallback: read the canonical storage hub file directly — the
|
|
3649
|
+
* workspace domain persists `archivedSessionIds` under the `global` segment
|
|
3650
|
+
* (storage-json: `~/.dsh/storages/workspace.json`; storage-sqlite variant
|
|
3651
|
+
* exists but the JSON fallback file is what stock web profiles ship).
|
|
3652
|
+
* Resolution order is decided per read; failures degrade to "no archive set"
|
|
3653
|
+
* and are reported through the diagnostics face.
|
|
3654
|
+
*
|
|
3655
|
+
* READ-ONLY by contract. The archive set is WRITTEN by dsh-manage-session
|
|
3656
|
+
* (会话管家 → 病案室); this package only consumes it to keep archived sessions
|
|
3657
|
+
* out of the index. Format contract: `dsh-归档文件格式契约-20260914.md`.
|
|
3658
|
+
*/
|
|
3659
|
+
/** DSH storage hub candidates for the workspace domain (json backend). */
|
|
3660
|
+
function storageFileCandidates() {
|
|
3661
|
+
return [join(homedir(), ".dsh", "storages", "workspace.json")];
|
|
3662
|
+
}
|
|
3663
|
+
/** Parse the storage hub file's global.archivedSessionIds; throw on malformed content. */
|
|
3664
|
+
function readStorageFile(path) {
|
|
3665
|
+
const ids = JSON.parse(readFileSync(path, "utf8")).global?.archivedSessionIds;
|
|
3666
|
+
if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`);
|
|
3667
|
+
return ids.filter((id) => typeof id === "string");
|
|
3668
|
+
}
|
|
3669
|
+
/**
|
|
3670
|
+
* Resolve the official archive set once.
|
|
3671
|
+
* @param registry - lazy workspaceRegistry face (may be absent or throw).
|
|
3672
|
+
* @returns the archive ids plus which source served them.
|
|
3673
|
+
*/
|
|
3674
|
+
function readArchiveSet$1(registry) {
|
|
3675
|
+
if (registry !== void 0) try {
|
|
3676
|
+
const ids = registry.archivedSessionIds;
|
|
3677
|
+
if (Array.isArray(ids)) return {
|
|
3678
|
+
ids,
|
|
3679
|
+
source: "registry"
|
|
3680
|
+
};
|
|
3681
|
+
} catch {}
|
|
3682
|
+
for (const path of storageFileCandidates()) {
|
|
3683
|
+
if (!existsSync(path)) continue;
|
|
3684
|
+
try {
|
|
3685
|
+
return {
|
|
3686
|
+
ids: readStorageFile(path),
|
|
3687
|
+
source: "storage-file"
|
|
3688
|
+
};
|
|
3689
|
+
} catch {}
|
|
3690
|
+
}
|
|
3691
|
+
return {
|
|
3692
|
+
ids: [],
|
|
3693
|
+
source: "none"
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
/** Build the lazy source face the syncer expects, with diagnostics capture. */
|
|
3697
|
+
function createArchiveSource(getRegistry) {
|
|
3698
|
+
let last = {
|
|
3699
|
+
source: "none",
|
|
3700
|
+
ids: 0
|
|
3701
|
+
};
|
|
3702
|
+
return {
|
|
3703
|
+
read: () => {
|
|
3704
|
+
const read = readArchiveSet$1(getRegistry());
|
|
3705
|
+
last = {
|
|
3706
|
+
source: read.source,
|
|
3707
|
+
ids: read.ids.length
|
|
3708
|
+
};
|
|
3709
|
+
return read;
|
|
3710
|
+
},
|
|
3711
|
+
diagnostics: () => ({ ...last })
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
//#endregion
|
|
3715
|
+
//#region src/host/index/peers.ts
|
|
3716
|
+
/**
|
|
3717
|
+
* Presence probe for the peer plugin that owns the archived-session domain.
|
|
3718
|
+
*
|
|
3719
|
+
* Why this exists
|
|
3720
|
+
* ---------------
|
|
3721
|
+
* This package *reads* the official archive set — archived sessions are
|
|
3722
|
+
* excluded from the index — but it no longer *owns* archiving: browsing and
|
|
3723
|
+
* disposing of archived sessions moved to `dsh-manage-session`. The settings
|
|
3724
|
+
* card went on reporting a bare archived count, with no pointer to the plugin
|
|
3725
|
+
* that can act on it. A number the user cannot act on, next to a capability
|
|
3726
|
+
* that lives somewhere else, reads as a broken feature.
|
|
3727
|
+
*
|
|
3728
|
+
* The pointer has to say different things depending on whether the owner is
|
|
3729
|
+
* actually installed, and only the host half can answer that: the client
|
|
3730
|
+
* bundle cannot resolve another package, and the slot registry exposes no
|
|
3731
|
+
* "is anything registered under this id" query.
|
|
3732
|
+
*
|
|
3733
|
+
* Three states, never two. A probe that cannot run must not be reported as
|
|
3734
|
+
* "missing" — that would tell the user to install something they already have.
|
|
3735
|
+
*/
|
|
3736
|
+
/** The package that owns the archived-session domain. */
|
|
3737
|
+
const STEWARD_PACKAGE = "dsh-manage-session";
|
|
3738
|
+
/**
|
|
3739
|
+
* Probe whether a package resolves from this plugin's own module graph.
|
|
3740
|
+
*
|
|
3741
|
+
* The bundle lives at `<profile>/node_modules/dsh-search-index/lib/index.mjs`,
|
|
3742
|
+
* so resolution runs against `<profile>/node_modules` — exactly where a
|
|
3743
|
+
* profile dependency lands, and therefore the same place the host would load
|
|
3744
|
+
* the peer from.
|
|
3745
|
+
*
|
|
3746
|
+
* @param name - the package name to resolve.
|
|
3747
|
+
* @param base - resolution base; defaults to this module's own URL, i.e. the
|
|
3748
|
+
* plugin's install directory. Injectable so the classification can be tested
|
|
3749
|
+
* against a fixture that fails in a way this checkout cannot produce.
|
|
3750
|
+
* @returns `installed` when it resolves; `missing` only for a genuine
|
|
3751
|
+
* module-not-found; `unknown` for every other failure, because an
|
|
3752
|
+
* unanswerable probe is not evidence of absence.
|
|
3753
|
+
*/
|
|
3754
|
+
function probePeer(name, base = import.meta.url) {
|
|
3755
|
+
try {
|
|
3756
|
+
createRequire(base).resolve(name);
|
|
3757
|
+
return "installed";
|
|
3758
|
+
} catch (err) {
|
|
3759
|
+
const code = err?.code;
|
|
3760
|
+
return code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND" ? "missing" : "unknown";
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
/**
|
|
3764
|
+
* Probe whether {@link STEWARD_PACKAGE} is installed alongside this plugin.
|
|
3765
|
+
*
|
|
3766
|
+
* @returns the resolution state, never a throw.
|
|
3767
|
+
*/
|
|
3768
|
+
function detectSteward() {
|
|
3769
|
+
return probePeer(STEWARD_PACKAGE);
|
|
3770
|
+
}
|
|
3771
|
+
//#endregion
|
|
3772
|
+
//#region src/host/index/rebuild.ts
|
|
3773
|
+
/**
|
|
3774
|
+
* Non-destructive index rebuild ("整理索引") for the independent index.
|
|
3775
|
+
*
|
|
3776
|
+
* A shadow index file is built from scratch beside the active one; the active
|
|
3777
|
+
* engine keeps serving queries the whole time. When the shadow is complete the
|
|
3778
|
+
* swap is three synchronous renames (active → archive, shadow → active), then
|
|
3779
|
+
* the engine reopens. Old archives are kept (bounded) and stay readable.
|
|
3780
|
+
*/
|
|
3781
|
+
/** Default layout names. */
|
|
3782
|
+
const DEFAULT_INDEX_LAYOUT = {
|
|
3783
|
+
dir: ".",
|
|
3784
|
+
active: "index.sqlite",
|
|
3785
|
+
building: "index.building.sqlite",
|
|
3786
|
+
archivePrefix: "index.archive-"
|
|
3787
|
+
};
|
|
3788
|
+
/**
|
|
3789
|
+
* Inspect the index directory for half-built leftovers from an abnormally
|
|
3790
|
+
* terminated rebuild and recover:
|
|
3791
|
+
* - shadow present + active present: the build never finished — the shadow
|
|
3792
|
+
* is garbage (the active index kept serving) and is discarded.
|
|
3793
|
+
* - shadow present + active missing: the crash hit the rename window — the
|
|
3794
|
+
* newest archive is restored as the active index, the shadow discarded.
|
|
3795
|
+
* Runs at host activation, before the engine opens (opening would create a
|
|
3796
|
+
* fresh empty active file and mask the swap-window case).
|
|
3797
|
+
*/
|
|
3798
|
+
async function recoverIndex(layout, log) {
|
|
3799
|
+
const actions = [];
|
|
3800
|
+
await mkdir(layout.dir, { recursive: true });
|
|
3801
|
+
const activePath = join(layout.dir, layout.active);
|
|
3802
|
+
const buildingPath = join(layout.dir, layout.building);
|
|
3803
|
+
const activeExists = existsSync(activePath);
|
|
3804
|
+
if (!existsSync(buildingPath)) return actions;
|
|
3805
|
+
const discardShadow = async () => {
|
|
3806
|
+
for (const suffix of [
|
|
3807
|
+
"",
|
|
3808
|
+
"-wal",
|
|
3809
|
+
"-shm"
|
|
3810
|
+
]) await rm(`${buildingPath}${suffix}`, { force: true });
|
|
3811
|
+
};
|
|
3812
|
+
if (activeExists) {
|
|
3813
|
+
await discardShadow();
|
|
3814
|
+
actions.push(`discarded stale shadow index (a previous rebuild did not finish; the active index was never at risk)`);
|
|
3815
|
+
} else {
|
|
3816
|
+
const archives = await listArchives(layout);
|
|
3817
|
+
if (archives.length > 0) {
|
|
3818
|
+
const newest = archives[archives.length - 1];
|
|
3819
|
+
await rename(join(layout.dir, newest), activePath);
|
|
3820
|
+
actions.push(`active index was missing (crash during the swap window); restored "${newest}" as the active index`);
|
|
3821
|
+
} else actions.push("no active index and no archive: the first build crashed mid-way; starting from a fresh index");
|
|
3822
|
+
await discardShadow();
|
|
3823
|
+
}
|
|
3824
|
+
for (const action of actions) log?.(`index recovery: ${action}`);
|
|
3825
|
+
return actions;
|
|
3826
|
+
}
|
|
3827
|
+
/** List existing archive files, oldest first. */
|
|
3828
|
+
async function listArchives(layout) {
|
|
3829
|
+
if (!existsSync(layout.dir)) return [];
|
|
3830
|
+
return (await readdir(layout.dir)).filter((name) => name.startsWith(layout.archivePrefix) && name.endsWith(".sqlite")).sort();
|
|
3831
|
+
}
|
|
3832
|
+
/** Sessions per batched transaction (one fsync checkpoint per chunk). */
|
|
3833
|
+
const REBUILD_CHUNK = 50;
|
|
3834
|
+
async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, onProgress, archiveSource, hooks) {
|
|
3835
|
+
const startedMs = Date.now();
|
|
3836
|
+
let docsWritten = 0;
|
|
3837
|
+
const emit = () => {
|
|
3838
|
+
hooks?.onState?.({ ...state });
|
|
3839
|
+
};
|
|
3840
|
+
const rate = () => {
|
|
3841
|
+
const secs = Math.max(.001, (Date.now() - startedMs) / 1e3);
|
|
3842
|
+
return `${(state.done / secs).toFixed(1)} sess/s`;
|
|
3843
|
+
};
|
|
3844
|
+
const eta = () => {
|
|
3845
|
+
if (state.total <= 0 || state.done === 0) return "?";
|
|
3846
|
+
const secs = (Date.now() - startedMs) / 1e3;
|
|
3847
|
+
return `${Math.max(0, Math.round((state.total - state.done) / (state.done / secs)))}s`;
|
|
3848
|
+
};
|
|
3849
|
+
const state = {
|
|
3850
|
+
state: "building",
|
|
3851
|
+
done: 0,
|
|
3852
|
+
total: 0,
|
|
3853
|
+
startedAt: Date.now(),
|
|
3854
|
+
finishedAt: 0,
|
|
3855
|
+
failures: []
|
|
3856
|
+
};
|
|
3857
|
+
try {
|
|
3858
|
+
hooks?.log?.(`rebuild started: dir=${layout.dir} keep=${keepArchives}`);
|
|
3859
|
+
emit();
|
|
3860
|
+
await mkdir(layout.dir, { recursive: true });
|
|
3861
|
+
const buildingPath = join(layout.dir, layout.building);
|
|
3862
|
+
if (existsSync(buildingPath)) await unlink(buildingPath);
|
|
3863
|
+
const shadow = new SwitchIndexEngine({ path: buildingPath });
|
|
3864
|
+
await shadow.open();
|
|
3865
|
+
try {
|
|
3866
|
+
const records = await sessionQuery.listSessions();
|
|
3867
|
+
state.total = records.length;
|
|
3868
|
+
hooks?.log?.(`rebuild corpus listed: ${state.total} sessions (archived included, full content)`);
|
|
3869
|
+
emit();
|
|
3870
|
+
const archivedSet = new Set(archiveSource?.()?.archivedSessionIds ?? []);
|
|
3871
|
+
const readLog = async (header) => {
|
|
3872
|
+
let log;
|
|
3873
|
+
try {
|
|
3874
|
+
log = await sessionQuery.readSession(header.id);
|
|
3875
|
+
} catch (serviceError) {
|
|
3876
|
+
if (hooks?.readSessionFromFile === void 0) throw serviceError;
|
|
3877
|
+
const fromFile = await hooks.readSessionFromFile(header.id);
|
|
3878
|
+
if (fromFile === void 0) throw serviceError;
|
|
3879
|
+
log = fromFile;
|
|
3880
|
+
}
|
|
3881
|
+
return {
|
|
3882
|
+
sessionId: header.id,
|
|
3883
|
+
version: log.session.version,
|
|
3884
|
+
title: extractTitleFromEvents(log.events),
|
|
3885
|
+
cwd: log.session.cwd ?? "",
|
|
3886
|
+
updatedAt: log.session.createdAt ?? 0,
|
|
3887
|
+
archived: archivedSet.has(header.id),
|
|
3888
|
+
events: log.events
|
|
3889
|
+
};
|
|
3890
|
+
};
|
|
3891
|
+
for (let i = 0; i < records.length; i += REBUILD_CHUNK) {
|
|
3892
|
+
const chunk = records.slice(i, i + REBUILD_CHUNK);
|
|
3893
|
+
const chunkStart = Date.now();
|
|
3894
|
+
const reads = [];
|
|
3895
|
+
for (const record of chunk) try {
|
|
3896
|
+
reads.push(await readLog(record.header));
|
|
3897
|
+
} catch (err) {
|
|
3898
|
+
state.failures.push({
|
|
3899
|
+
sessionId: record.header.id,
|
|
3900
|
+
error: String(err instanceof Error ? err.message : err)
|
|
3901
|
+
});
|
|
3902
|
+
}
|
|
3903
|
+
try {
|
|
3904
|
+
shadow.runBatched(() => {
|
|
3905
|
+
for (const item of reads) {
|
|
3906
|
+
shadow.upsertSession(item);
|
|
3907
|
+
docsWritten += item.events.length;
|
|
3908
|
+
}
|
|
3909
|
+
});
|
|
3910
|
+
} catch (err) {
|
|
3911
|
+
hooks?.log?.(`rebuild chunk txn failed, replaying individually: ${String(err instanceof Error ? err.message : err)}`);
|
|
3912
|
+
for (const item of reads) try {
|
|
3913
|
+
shadow.upsertSession(item);
|
|
3914
|
+
docsWritten += item.events.length;
|
|
3915
|
+
} catch (e2) {
|
|
3916
|
+
state.failures.push({
|
|
3917
|
+
sessionId: item.sessionId,
|
|
3918
|
+
error: String(e2 instanceof Error ? e2.message : e2)
|
|
3919
|
+
});
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
state.done = Math.min(state.total, i + chunk.length);
|
|
3923
|
+
onProgress?.(state.done, state.total);
|
|
3924
|
+
emit();
|
|
3925
|
+
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`);
|
|
3926
|
+
}
|
|
3927
|
+
shadow.setArchived(archivedSet);
|
|
3928
|
+
shadow.close();
|
|
3929
|
+
} catch (error) {
|
|
3930
|
+
shadow.close();
|
|
3931
|
+
await unlink(buildingPath).catch(() => {});
|
|
3932
|
+
throw error;
|
|
3933
|
+
}
|
|
3934
|
+
state.state = "swapping";
|
|
3935
|
+
emit();
|
|
3936
|
+
hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`);
|
|
3937
|
+
const activePath = join(layout.dir, layout.active);
|
|
3938
|
+
if (existsSync(activePath)) {
|
|
3939
|
+
activeEngine.close();
|
|
3940
|
+
await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`));
|
|
3941
|
+
}
|
|
3942
|
+
await rename(buildingPath, activePath);
|
|
3943
|
+
await activeEngine.open();
|
|
3944
|
+
await pruneArchives(layout, keepArchives);
|
|
3945
|
+
state.finishedAt = Date.now();
|
|
3946
|
+
state.state = "idle";
|
|
3947
|
+
emit();
|
|
3948
|
+
hooks?.log?.(`rebuild done: total ${((state.finishedAt - startedMs) / 1e3).toFixed(1)}s, archives pruned to ${keepArchives}`);
|
|
3949
|
+
} catch (err) {
|
|
3950
|
+
state.state = "error";
|
|
3951
|
+
state.error = String(err instanceof Error ? err.message : err);
|
|
3952
|
+
hooks?.log?.(`rebuild FAILED at done=${state.done}: ${state.error}`);
|
|
3953
|
+
emit();
|
|
3954
|
+
if (!activeEngine.isOpen) await activeEngine.open().catch(() => {});
|
|
3955
|
+
}
|
|
3956
|
+
return state;
|
|
3957
|
+
}
|
|
3958
|
+
/** Import doc-level records into the shadow file and swap it in (same swap path). */
|
|
3959
|
+
async function importIntoIndex(activeEngine, layout, records, keepArchives) {
|
|
3960
|
+
const state = {
|
|
3961
|
+
state: "building",
|
|
3962
|
+
done: 0,
|
|
3963
|
+
total: records.length,
|
|
3964
|
+
startedAt: Date.now(),
|
|
3965
|
+
finishedAt: 0,
|
|
3966
|
+
failures: []
|
|
3967
|
+
};
|
|
3968
|
+
try {
|
|
3969
|
+
await mkdir(layout.dir, { recursive: true });
|
|
3970
|
+
const buildingPath = join(layout.dir, layout.building);
|
|
3971
|
+
if (existsSync(buildingPath)) await unlink(buildingPath);
|
|
3972
|
+
const shadow = new SwitchIndexEngine({ path: buildingPath });
|
|
3973
|
+
await shadow.open();
|
|
3974
|
+
try {
|
|
3975
|
+
for (const record of records) {
|
|
3976
|
+
try {
|
|
3977
|
+
shadow.importSessionDocs({
|
|
3978
|
+
sessionId: record.sessionId,
|
|
3979
|
+
version: record.version,
|
|
3980
|
+
title: record.title ?? "",
|
|
3981
|
+
archived: record.archived === true,
|
|
3982
|
+
docs: record.docs
|
|
3983
|
+
});
|
|
3984
|
+
} catch (err) {
|
|
3985
|
+
state.failures.push({
|
|
3986
|
+
sessionId: record.sessionId,
|
|
3987
|
+
error: String(err instanceof Error ? err.message : err)
|
|
3988
|
+
});
|
|
3989
|
+
}
|
|
3990
|
+
state.done += 1;
|
|
3991
|
+
}
|
|
3992
|
+
shadow.close();
|
|
3993
|
+
} catch (error) {
|
|
3994
|
+
shadow.close();
|
|
3995
|
+
await unlink(buildingPath).catch(() => {});
|
|
3996
|
+
throw error;
|
|
3997
|
+
}
|
|
3998
|
+
state.state = "swapping";
|
|
3999
|
+
const activePath = join(layout.dir, layout.active);
|
|
4000
|
+
if (existsSync(activePath)) {
|
|
4001
|
+
activeEngine.close();
|
|
4002
|
+
await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`));
|
|
4003
|
+
}
|
|
4004
|
+
await rename(buildingPath, activePath);
|
|
4005
|
+
await activeEngine.open();
|
|
4006
|
+
await pruneArchives(layout, keepArchives);
|
|
4007
|
+
state.finishedAt = Date.now();
|
|
4008
|
+
state.state = "idle";
|
|
4009
|
+
} catch (err) {
|
|
4010
|
+
state.state = "error";
|
|
4011
|
+
state.error = String(err instanceof Error ? err.message : err);
|
|
4012
|
+
if (!activeEngine.isOpen) await activeEngine.open().catch(() => {});
|
|
4013
|
+
}
|
|
4014
|
+
return state;
|
|
4015
|
+
}
|
|
4016
|
+
/** Remove the oldest archives beyond the retention bound. */
|
|
4017
|
+
async function pruneArchives(layout, keep) {
|
|
4018
|
+
const archives = await listArchives(layout);
|
|
4019
|
+
const excess = archives.length - Math.max(0, keep);
|
|
4020
|
+
for (let i = 0; i < excess; i += 1) await unlink(join(layout.dir, archives[i])).catch(() => {});
|
|
4021
|
+
}
|
|
4022
|
+
/**
|
|
4023
|
+
* Resolve the index directory that hosts the independent index files:
|
|
4024
|
+
* an explicit override wins, otherwise a plugin-owned directory under the
|
|
4025
|
+
* user's home (never the official index path).
|
|
4026
|
+
*/
|
|
4027
|
+
function resolveIndexDir(preferred) {
|
|
4028
|
+
if (preferred !== void 0 && preferred.trim() !== "") return preferred;
|
|
4029
|
+
return join(homedir(), ".dsh-switch-search");
|
|
4030
|
+
}
|
|
4031
|
+
//#endregion
|
|
4032
|
+
//#region src/host/index/snapshot.ts
|
|
4033
|
+
/** Render the snapshot header line. */
|
|
4034
|
+
function snapshotHeader() {
|
|
4035
|
+
return JSON.stringify({
|
|
4036
|
+
v: 1,
|
|
4037
|
+
kind: "dsh-switch-search-snapshot",
|
|
4038
|
+
exportedAt: Date.now()
|
|
4039
|
+
});
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Export the whole index (active AND archived) as a JSON Lines string.
|
|
4043
|
+
* 归档行带 archived 标记一起导出——快照是完整备份,不是活跃子集。
|
|
4044
|
+
* @param engine - the open active engine.
|
|
4045
|
+
* @returns the complete snapshot text (header line first).
|
|
4046
|
+
*/
|
|
4047
|
+
function exportSnapshot(engine) {
|
|
4048
|
+
const lines = [snapshotHeader()];
|
|
4049
|
+
for (const session of engine.listIndexedSessions()) lines.push(JSON.stringify({
|
|
4050
|
+
v: 1,
|
|
4051
|
+
sessionId: session.sessionId,
|
|
4052
|
+
version: session.version,
|
|
4053
|
+
title: session.title,
|
|
4054
|
+
...session.archived ? { archived: true } : {},
|
|
4055
|
+
docs: engine.exportSessionDocs(session.sessionId)
|
|
4056
|
+
}));
|
|
4057
|
+
return `${lines.join("\n")}\n`;
|
|
4058
|
+
}
|
|
4059
|
+
/**
|
|
4060
|
+
* Parse a snapshot's JSON Lines text into importable records.
|
|
4061
|
+
* The header line and any malformed line are skipped, not fatal.
|
|
4062
|
+
* @param text - raw snapshot text.
|
|
4063
|
+
* @returns importable records and how many lines were skipped.
|
|
4064
|
+
*/
|
|
4065
|
+
function parseSnapshot(text) {
|
|
4066
|
+
const records = [];
|
|
4067
|
+
let skipped = 0;
|
|
4068
|
+
for (const line of text.split(/\r?\n/)) {
|
|
4069
|
+
if (line.trim() === "") continue;
|
|
4070
|
+
let value;
|
|
4071
|
+
try {
|
|
4072
|
+
value = JSON.parse(line);
|
|
4073
|
+
} catch {
|
|
4074
|
+
skipped += 1;
|
|
4075
|
+
continue;
|
|
4076
|
+
}
|
|
4077
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
4078
|
+
skipped += 1;
|
|
4079
|
+
continue;
|
|
4080
|
+
}
|
|
4081
|
+
const record = value;
|
|
4082
|
+
if (record["kind"] === "dsh-switch-search-snapshot") continue;
|
|
4083
|
+
const sessionId = record["sessionId"];
|
|
4084
|
+
const rawDocs = record["docs"];
|
|
4085
|
+
if (typeof sessionId !== "string" || sessionId === "" || !Array.isArray(rawDocs)) {
|
|
4086
|
+
skipped += 1;
|
|
4087
|
+
continue;
|
|
4088
|
+
}
|
|
4089
|
+
const docs = [];
|
|
4090
|
+
let docsValid = true;
|
|
4091
|
+
for (const rawDoc of rawDocs) {
|
|
4092
|
+
if (typeof rawDoc !== "object" || rawDoc === null || typeof rawDoc.seq !== "number" || typeof rawDoc.type !== "string" || typeof rawDoc.text !== "string") {
|
|
4093
|
+
docsValid = false;
|
|
4094
|
+
break;
|
|
4095
|
+
}
|
|
4096
|
+
const doc = rawDoc;
|
|
4097
|
+
docs.push({
|
|
4098
|
+
seq: doc.seq,
|
|
4099
|
+
type: doc.type,
|
|
4100
|
+
surface: typeof doc.surface === "string" ? doc.surface : "current",
|
|
4101
|
+
time: typeof doc.time === "number" ? doc.time : 0,
|
|
4102
|
+
text: doc.text
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
if (!docsValid) {
|
|
4106
|
+
skipped += 1;
|
|
4107
|
+
continue;
|
|
4108
|
+
}
|
|
4109
|
+
records.push({
|
|
4110
|
+
sessionId,
|
|
4111
|
+
version: typeof record["version"] === "number" ? record["version"] : 0,
|
|
4112
|
+
title: typeof record["title"] === "string" ? record["title"] : void 0,
|
|
4113
|
+
archived: record["archived"] === true,
|
|
4114
|
+
docs
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
return {
|
|
4118
|
+
records,
|
|
4119
|
+
skipped
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
//#endregion
|
|
4123
|
+
//#region src/index.ts
|
|
4124
|
+
/** 本插件声明的宿主服务(与 toggle 相同的注入面)。0.2.1-a.2 起 webRuntime 更名 webStartup,读取面走双名回退链。 */
|
|
4125
|
+
const inject = ["webServer", "webStartup"];
|
|
4126
|
+
/** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
|
|
4127
|
+
function readVolatileValue(value) {
|
|
4128
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
|
|
4129
|
+
return value;
|
|
4130
|
+
}
|
|
4131
|
+
/**
|
|
4132
|
+
* `.volatile()` 探针回退(preset-manager 的 volatile breakpoint 同款):宿主
|
|
4133
|
+
* schemastery 只在 0.1.7+ 线提供 `.volatile()`,老线上该方法是 undefined ——
|
|
4134
|
+
* 在模块加载期链式调用直接 TypeError,插件整包起不来。这里探测到才链,探测
|
|
4135
|
+
* 不到就返回普通字段(老线的配置只来自组合入口,无设置表单,行为正确)。
|
|
4136
|
+
*/
|
|
4137
|
+
function withVolatile(field) {
|
|
4138
|
+
if (field !== null && typeof field === "object" && typeof field.volatile === "function") return field.volatile();
|
|
4139
|
+
return field;
|
|
4140
|
+
}
|
|
4141
|
+
/** 运行时配置 schema(与 src/config.ts 的形状保持一致)。0.1.7+:volatile 字段即设置表单。 */
|
|
4142
|
+
const Config = z.object({
|
|
4143
|
+
enabled: withVolatile(z.boolean().default(true)),
|
|
4144
|
+
historyFiles: withVolatile(z.boolean().default(true)),
|
|
4145
|
+
healthCheck: withVolatile(z.boolean().default(true)),
|
|
4146
|
+
search: withVolatile(z.boolean().default(true)),
|
|
4147
|
+
defaultMode: withVolatile(z.union(["title", "content"]).default("title")),
|
|
4148
|
+
autoSync: withVolatile(z.boolean().default(true)),
|
|
4149
|
+
syncIntervalMs: withVolatile(z.number().default(3e4)),
|
|
4150
|
+
archiveKeep: withVolatile(z.number().default(2)),
|
|
4151
|
+
indexDir: withVolatile(z.string().default(""))
|
|
4152
|
+
});
|
|
4153
|
+
/** 单次请求体的上限(防御无界读取)。 */
|
|
4154
|
+
const MAX_BODY_BYTES = 16 << 20;
|
|
4155
|
+
/** content-search 单次返回的默认会话数上限。 */
|
|
4156
|
+
const DEFAULT_LIMIT = 20;
|
|
4157
|
+
/** 独立索引目录的环境变量覆盖。 */
|
|
4158
|
+
const INDEX_DIR_ENV = "DSH_SWITCH_SEARCH_DIR";
|
|
4159
|
+
/** 改名/自动标题落盘的 log-only 事件(追加进日志,会推 version,水位轮询本可迟到一轮兜住)。 */
|
|
4160
|
+
const TITLE_EVENT_TYPE = "session/title";
|
|
4161
|
+
/** 改名风暴合并为一次标题折叠(自动标题一轮会连发多条)。 */
|
|
4162
|
+
const TITLE_FLUSH_MS = 250;
|
|
4163
|
+
/** 归一化 Host authority,无法解析时为 undefined。 */
|
|
4164
|
+
function parseAuthority(authority) {
|
|
4165
|
+
try {
|
|
4166
|
+
return new URL(`http://${authority}`);
|
|
4167
|
+
} catch {
|
|
4168
|
+
return;
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
/** 主机名是否本机回环。 */
|
|
4172
|
+
function isLoopbackHostname(hostname) {
|
|
4173
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
4174
|
+
const parts = hostname.split(".");
|
|
4175
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
4176
|
+
}
|
|
4177
|
+
/** Host 是否命中 trustedHosts(精确或省略端口)。 */
|
|
4178
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
4179
|
+
return trustedHosts.some((entry) => {
|
|
4180
|
+
const entryUrl = parseAuthority(entry);
|
|
4181
|
+
if (entryUrl === void 0) return false;
|
|
4182
|
+
return (entryUrl.port === "" ? entryUrl.hostname : entryUrl.host) === hostUrl.host;
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
/** 浏览器可信围栏(与 /api 网关行为一致):DNS 重绑定/跨站防御,不是鉴权。 */
|
|
4186
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
4187
|
+
const host = req.headers.host;
|
|
4188
|
+
if (host === void 0) return false;
|
|
4189
|
+
const hostUrl = parseAuthority(host);
|
|
4190
|
+
if (hostUrl === void 0) return false;
|
|
4191
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
4192
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
4193
|
+
if (typeof fetchSite === "string" && fetchSite === "cross-site") return false;
|
|
4194
|
+
const origin = req.headers.origin;
|
|
4195
|
+
if (origin === void 0) return true;
|
|
4196
|
+
try {
|
|
4197
|
+
return new URL(origin).host === hostUrl.host;
|
|
4198
|
+
} catch {
|
|
4199
|
+
return false;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
/** 读原始请求体(有界)。 */
|
|
4203
|
+
async function readRawBody(req) {
|
|
4204
|
+
const chunks = [];
|
|
4205
|
+
let total = 0;
|
|
4206
|
+
for await (const chunk of req) {
|
|
4207
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
4208
|
+
total += buffer.length;
|
|
4209
|
+
if (total > MAX_BODY_BYTES) throw new Error("request body too large");
|
|
4210
|
+
chunks.push(buffer);
|
|
4211
|
+
}
|
|
4212
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
4213
|
+
}
|
|
4214
|
+
/** 读 JSON 请求体(空体视为 {})。 */
|
|
4215
|
+
async function readJsonBody(req) {
|
|
4216
|
+
const text = await readRawBody(req);
|
|
4217
|
+
if (text.trim() === "") return {};
|
|
4218
|
+
try {
|
|
4219
|
+
return JSON.parse(text);
|
|
4220
|
+
} catch {
|
|
4221
|
+
throw new Error("request body is not JSON");
|
|
2413
4222
|
}
|
|
2414
4223
|
}
|
|
2415
4224
|
/** 写 JSON 响应。 */
|
|
@@ -2418,6 +4227,14 @@ function writeJson(res, status, body) {
|
|
|
2418
4227
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
2419
4228
|
res.end(text);
|
|
2420
4229
|
}
|
|
4230
|
+
/** 写原始文本响应。 */
|
|
4231
|
+
function writeRaw(res, status, contentType, body) {
|
|
4232
|
+
res.writeHead(status, {
|
|
4233
|
+
"content-type": contentType,
|
|
4234
|
+
"cache-control": "no-cache"
|
|
4235
|
+
});
|
|
4236
|
+
res.end(body);
|
|
4237
|
+
}
|
|
2421
4238
|
/** 会话 id 形状校验(拒绝任意字符串进入路径拼接)。 */
|
|
2422
4239
|
function asSessionId(value) {
|
|
2423
4240
|
if (typeof value !== "string") return void 0;
|
|
@@ -2436,8 +4253,11 @@ const MAX_SCAN_LIMIT = 200;
|
|
|
2436
4253
|
*/
|
|
2437
4254
|
const HISTORY_METHODS = [
|
|
2438
4255
|
"session-history-list",
|
|
4256
|
+
"session-history-archive",
|
|
2439
4257
|
"session-history-prune",
|
|
2440
|
-
"session-history-purge"
|
|
4258
|
+
"session-history-purge",
|
|
4259
|
+
"session-history-favorites-list",
|
|
4260
|
+
"session-history-favorite-set"
|
|
2441
4261
|
];
|
|
2442
4262
|
const HEALTH_METHODS = [
|
|
2443
4263
|
"session-health-status",
|
|
@@ -2446,34 +4266,508 @@ const HEALTH_METHODS = [
|
|
|
2446
4266
|
"session-health-repair",
|
|
2447
4267
|
"session-health-source-migrate"
|
|
2448
4268
|
];
|
|
4269
|
+
/** 搜索索引子域方法(`/switch-search/api`;index-export/import 走原始体,其余 JSON)。 */
|
|
4270
|
+
const INDEX_METHODS = [
|
|
4271
|
+
"list-sessions",
|
|
4272
|
+
"content-search",
|
|
4273
|
+
"search-status",
|
|
4274
|
+
"index-status",
|
|
4275
|
+
"index-rebuild",
|
|
4276
|
+
"index-export",
|
|
4277
|
+
"index-import"
|
|
4278
|
+
];
|
|
2449
4279
|
/** 依据开关判定某方法是否启用。 */
|
|
2450
4280
|
function methodEnabled(method, config) {
|
|
2451
4281
|
if (HISTORY_METHODS.includes(method)) return config.historyFiles !== false;
|
|
2452
4282
|
if (HEALTH_METHODS.includes(method)) return config.healthCheck !== false;
|
|
4283
|
+
if (INDEX_METHODS.includes(method)) return config.search !== false;
|
|
2453
4284
|
return false;
|
|
2454
4285
|
}
|
|
4286
|
+
/** Fold titles for a set of sessions into a sessionId → title map. */
|
|
4287
|
+
async function titleMap(sessionQuery, sessionIds) {
|
|
4288
|
+
if (sessionIds.length === 0) return /* @__PURE__ */ new Map();
|
|
4289
|
+
const observations = await sessionQuery.readTitleSnapshots([...new Set(sessionIds)]);
|
|
4290
|
+
const map = /* @__PURE__ */ new Map();
|
|
4291
|
+
for (const observation of observations) {
|
|
4292
|
+
if (observation.status !== "fulfilled" || observation.value === void 0) continue;
|
|
4293
|
+
const title = observation.value.title?.title;
|
|
4294
|
+
if (typeof title === "string" && title.trim().length > 0) map.set(observation.value.session.id, title);
|
|
4295
|
+
}
|
|
4296
|
+
return map;
|
|
4297
|
+
}
|
|
4298
|
+
/** list-sessions: the title-search corpus (index-served, live fallback). */
|
|
4299
|
+
async function listSessions(srt) {
|
|
4300
|
+
const index = srt.index;
|
|
4301
|
+
const sessionQuery = srt.sessionQuery;
|
|
4302
|
+
if (index.engine.isOpen && index.engine.countSessions() > 0) {
|
|
4303
|
+
if (sessionQuery !== void 0 && index.sync.snapshot().state !== "syncing") index.sync.poll().catch(() => {});
|
|
4304
|
+
return {
|
|
4305
|
+
ok: true,
|
|
4306
|
+
items: index.engine.listIndexedSessions().map((session) => ({
|
|
4307
|
+
sessionId: session.sessionId,
|
|
4308
|
+
title: session.title,
|
|
4309
|
+
cwd: session.cwd,
|
|
4310
|
+
updatedAt: session.updatedAt,
|
|
4311
|
+
archived: session.archived
|
|
4312
|
+
}))
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
if (sessionQuery === void 0) return {
|
|
4316
|
+
ok: false,
|
|
4317
|
+
error: "sessionQuery 服务不可用,且独立索引尚未建立"
|
|
4318
|
+
};
|
|
4319
|
+
try {
|
|
4320
|
+
const records = await sessionQuery.listSessions();
|
|
4321
|
+
const titles = await titleMap(sessionQuery, records.map((record) => record.header.id));
|
|
4322
|
+
return {
|
|
4323
|
+
ok: true,
|
|
4324
|
+
items: records.map((record) => ({
|
|
4325
|
+
sessionId: record.header.id,
|
|
4326
|
+
title: titles.get(record.header.id) ?? "",
|
|
4327
|
+
cwd: record.header.cwd ?? "",
|
|
4328
|
+
updatedAt: record.header.createdAt,
|
|
4329
|
+
archived: false
|
|
4330
|
+
}))
|
|
4331
|
+
};
|
|
4332
|
+
} catch (err) {
|
|
4333
|
+
return {
|
|
4334
|
+
ok: false,
|
|
4335
|
+
error: String(err instanceof Error ? err.message : err)
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
/**
|
|
4340
|
+
* content-search: session-grouped hits from the independent index.
|
|
4341
|
+
* `sortBy: 'time'` orders by session recency (`updatedAt`), anything else by
|
|
4342
|
+
* relevance; the host orders before truncating so the page is honest.
|
|
4343
|
+
*/
|
|
4344
|
+
async function contentSearch(srt, payload) {
|
|
4345
|
+
const record = payload;
|
|
4346
|
+
const query = typeof record?.query === "string" ? record.query.trim() : "";
|
|
4347
|
+
if (query === "") return {
|
|
4348
|
+
ok: false,
|
|
4349
|
+
error: "缺少 query"
|
|
4350
|
+
};
|
|
4351
|
+
const archived = record?.archived === "active" ? "active" : record?.archived === "archived" ? "archived" : "all";
|
|
4352
|
+
const fromMs = dayBoundaryMs(record?.from);
|
|
4353
|
+
const toMs = record?.to === void 0 ? void 0 : dayBoundaryMs(record?.to, true);
|
|
4354
|
+
const requestedLimit = typeof record?.limit === "number" && Number.isSafeInteger(record.limit) ? record.limit : DEFAULT_LIMIT;
|
|
4355
|
+
const limit = Math.min(Math.max(1, requestedLimit), 100);
|
|
4356
|
+
const sortBy = record?.sortBy === "time" ? "time" : "relevance";
|
|
4357
|
+
let types;
|
|
4358
|
+
if (Array.isArray(record?.types) && record.types.length > 0) types = record.types.filter((entry) => entry === "all" || entry === "user" || entry === "reply" || entry === "tool");
|
|
4359
|
+
else types = ["user", "reply"];
|
|
4360
|
+
const index = srt.index;
|
|
4361
|
+
if (index.engine.isOpen === false) return {
|
|
4362
|
+
ok: false,
|
|
4363
|
+
error: "独立索引未就绪:请在面板或设置中先建立索引(整理索引)"
|
|
4364
|
+
};
|
|
4365
|
+
try {
|
|
4366
|
+
return {
|
|
4367
|
+
ok: true,
|
|
4368
|
+
items: index.engine.search({
|
|
4369
|
+
query,
|
|
4370
|
+
types,
|
|
4371
|
+
limit,
|
|
4372
|
+
sortBy,
|
|
4373
|
+
archived,
|
|
4374
|
+
from: fromMs,
|
|
4375
|
+
to: toMs
|
|
4376
|
+
})
|
|
4377
|
+
};
|
|
4378
|
+
} catch (err) {
|
|
4379
|
+
return {
|
|
4380
|
+
ok: false,
|
|
4381
|
+
error: String(err instanceof Error ? err.message : err)
|
|
4382
|
+
};
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
/** search-status: probe the independent index readiness and progress. */
|
|
4386
|
+
async function searchStatus(srt) {
|
|
4387
|
+
const index = srt.index;
|
|
4388
|
+
const sync = index.sync.snapshot();
|
|
4389
|
+
return {
|
|
4390
|
+
ok: true,
|
|
4391
|
+
available: index.engine.isOpen && index.engine.countSessions() > 0,
|
|
4392
|
+
reason: index.engine.isOpen ? void 0 : "not-open",
|
|
4393
|
+
indexing: sync.state === "syncing",
|
|
4394
|
+
archivedSessions: index.engine.countArchived(),
|
|
4395
|
+
archive: index.archiveReader.diagnostics(),
|
|
4396
|
+
sync,
|
|
4397
|
+
rebuild: index.rebuild
|
|
4398
|
+
};
|
|
4399
|
+
}
|
|
4400
|
+
/** index-status: full lifecycle surface for the settings row. */
|
|
4401
|
+
async function indexStatus(srt) {
|
|
4402
|
+
const index = srt.index;
|
|
4403
|
+
const sync = index.sync.snapshot();
|
|
4404
|
+
let indexed = sync.indexed;
|
|
4405
|
+
if (index.engine.isOpen) indexed = index.engine.countSessions();
|
|
4406
|
+
return {
|
|
4407
|
+
ok: true,
|
|
4408
|
+
available: index.engine.isOpen && indexed > 0,
|
|
4409
|
+
archivedSessions: index.engine.isOpen ? index.engine.countArchived() : 0,
|
|
4410
|
+
steward: detectSteward(),
|
|
4411
|
+
driver: index.engine.driverLabel,
|
|
4412
|
+
archive: index.archiveReader.diagnostics(),
|
|
4413
|
+
dir: index.layout.dir,
|
|
4414
|
+
archives: await listArchives(index.layout).catch(() => []),
|
|
4415
|
+
sync: {
|
|
4416
|
+
...sync,
|
|
4417
|
+
indexed
|
|
4418
|
+
},
|
|
4419
|
+
rebuild: index.rebuild
|
|
4420
|
+
};
|
|
4421
|
+
}
|
|
4422
|
+
/**
|
|
4423
|
+
* index-rebuild: start the non-destructive 整理 (shadow build → atomic swap →
|
|
4424
|
+
* archives). Responds immediately; progress rides index-status.
|
|
4425
|
+
*/
|
|
4426
|
+
async function indexRebuild(srt) {
|
|
4427
|
+
const index = srt.index;
|
|
4428
|
+
if (index.rebuild.state === "building" || index.rebuild.state === "swapping") return {
|
|
4429
|
+
ok: false,
|
|
4430
|
+
error: "整理已在进行中"
|
|
4431
|
+
};
|
|
4432
|
+
const sessionQuery = srt.sessionQuery;
|
|
4433
|
+
if (sessionQuery === void 0 || sessionQuery.readSession === void 0) return {
|
|
4434
|
+
ok: false,
|
|
4435
|
+
error: "sessionQuery 服务不可用,无法读取会话日志"
|
|
4436
|
+
};
|
|
4437
|
+
const config = srt.config();
|
|
4438
|
+
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep);
|
|
4439
|
+
rebuildIndex(index.engine, index.layout, {
|
|
4440
|
+
listSessions: () => sessionQuery.listSessions(),
|
|
4441
|
+
readSession: async (sessionId) => {
|
|
4442
|
+
const snapshot = await sessionQuery.readSession(sessionId);
|
|
4443
|
+
return {
|
|
4444
|
+
session: snapshot.session,
|
|
4445
|
+
events: snapshot.events
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
}, keepArchives, void 0, srt.registry, {
|
|
4449
|
+
log: srt.log,
|
|
4450
|
+
onState: (live) => {
|
|
4451
|
+
index.rebuild = live;
|
|
4452
|
+
},
|
|
4453
|
+
readSessionFromFile: srt.readSessionFromFile
|
|
4454
|
+
}).then((state) => {
|
|
4455
|
+
index.rebuild = state;
|
|
4456
|
+
}).catch((err) => {
|
|
4457
|
+
index.rebuild = {
|
|
4458
|
+
state: "error",
|
|
4459
|
+
done: 0,
|
|
4460
|
+
total: 0,
|
|
4461
|
+
startedAt: Date.now(),
|
|
4462
|
+
finishedAt: 0,
|
|
4463
|
+
failures: [],
|
|
4464
|
+
error: String(err instanceof Error ? err.message : err)
|
|
4465
|
+
};
|
|
4466
|
+
});
|
|
4467
|
+
index.rebuild = {
|
|
4468
|
+
state: "building",
|
|
4469
|
+
done: 0,
|
|
4470
|
+
total: 0,
|
|
4471
|
+
startedAt: Date.now(),
|
|
4472
|
+
finishedAt: 0,
|
|
4473
|
+
failures: []
|
|
4474
|
+
};
|
|
4475
|
+
return {
|
|
4476
|
+
ok: true,
|
|
4477
|
+
started: true
|
|
4478
|
+
};
|
|
4479
|
+
}
|
|
4480
|
+
/**
|
|
4481
|
+
* Tombs for the two methods the search package used to own before the
|
|
4482
|
+
* steward merge. A stale client bundle (browser refresh does not reload the
|
|
4483
|
+
* host half) must fail LOUDLY and be told where the feature went — a silent
|
|
4484
|
+
* 404 would read as "archiving is broken".
|
|
4485
|
+
*/
|
|
4486
|
+
const MOVED_TO_HISTORY = {
|
|
4487
|
+
"list-archived": "session-history-list",
|
|
4488
|
+
"archive-prune": "session-history-prune"
|
|
4489
|
+
};
|
|
4490
|
+
/** Build the explicit "moved" error body for a tombstoned method. */
|
|
4491
|
+
function movedToHistory(method) {
|
|
4492
|
+
const replacement = MOVED_TO_HISTORY[method];
|
|
4493
|
+
return {
|
|
4494
|
+
ok: false,
|
|
4495
|
+
error: `"${method}" 已并入会话管家 history 子域:请改用 POST ${STEWARD_API_PREFIX}/${replacement}`
|
|
4496
|
+
};
|
|
4497
|
+
}
|
|
4498
|
+
/** index-export: dump the active index as JSON Lines. */
|
|
4499
|
+
async function indexExport(srt, res) {
|
|
4500
|
+
const index = srt.index;
|
|
4501
|
+
if (index.engine.isOpen === false) {
|
|
4502
|
+
writeJson(res, 200, {
|
|
4503
|
+
ok: false,
|
|
4504
|
+
error: "独立索引未就绪"
|
|
4505
|
+
});
|
|
4506
|
+
return;
|
|
4507
|
+
}
|
|
4508
|
+
writeRaw(res, 200, "application/x-ndjson; charset=utf-8", exportSnapshot(index.engine));
|
|
4509
|
+
}
|
|
4510
|
+
/** index-import: parse a JSON Lines snapshot and swap it in as the active index. */
|
|
4511
|
+
async function indexImport(srt, text) {
|
|
4512
|
+
const index = srt.index;
|
|
4513
|
+
if (index.rebuild.state === "building" || index.rebuild.state === "swapping") return {
|
|
4514
|
+
ok: false,
|
|
4515
|
+
error: "整理/导入已在进行中"
|
|
4516
|
+
};
|
|
4517
|
+
if (text.includes("\"snapshot\"")) try {
|
|
4518
|
+
const envelope = JSON.parse(text);
|
|
4519
|
+
if (typeof envelope.snapshot === "string") text = envelope.snapshot;
|
|
4520
|
+
} catch {}
|
|
4521
|
+
const parsed = parseSnapshot(text);
|
|
4522
|
+
if (parsed.records.length === 0) return {
|
|
4523
|
+
ok: false,
|
|
4524
|
+
error: `快照无可导入会话(跳过 ${parsed.skipped} 行)`
|
|
4525
|
+
};
|
|
4526
|
+
const config = srt.config();
|
|
4527
|
+
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep);
|
|
4528
|
+
importIntoIndex(index.engine, index.layout, parsed.records, keepArchives).then((state) => {
|
|
4529
|
+
index.rebuild = state;
|
|
4530
|
+
}).catch((err) => {
|
|
4531
|
+
index.rebuild = {
|
|
4532
|
+
state: "error",
|
|
4533
|
+
done: 0,
|
|
4534
|
+
total: parsed.records.length,
|
|
4535
|
+
startedAt: Date.now(),
|
|
4536
|
+
finishedAt: 0,
|
|
4537
|
+
failures: [],
|
|
4538
|
+
error: String(err instanceof Error ? err.message : err)
|
|
4539
|
+
};
|
|
4540
|
+
});
|
|
4541
|
+
index.rebuild = {
|
|
4542
|
+
state: "building",
|
|
4543
|
+
done: 0,
|
|
4544
|
+
total: parsed.records.length,
|
|
4545
|
+
startedAt: Date.now(),
|
|
4546
|
+
finishedAt: 0,
|
|
4547
|
+
failures: []
|
|
4548
|
+
};
|
|
4549
|
+
return {
|
|
4550
|
+
ok: true,
|
|
4551
|
+
started: true,
|
|
4552
|
+
sessions: parsed.records.length,
|
|
4553
|
+
skipped: parsed.skipped
|
|
4554
|
+
};
|
|
4555
|
+
}
|
|
4556
|
+
/** JSON 面的搜索子域方法分发(index-export/import 走原始体,在路由层特判)。 */
|
|
4557
|
+
async function handleIndexMethod(method, payload, srt) {
|
|
4558
|
+
if (method === "list-sessions") return await listSessions(srt);
|
|
4559
|
+
if (method === "content-search") return await contentSearch(srt, payload);
|
|
4560
|
+
if (method === "search-status") return await searchStatus(srt);
|
|
4561
|
+
if (method === "index-status") return await indexStatus(srt);
|
|
4562
|
+
if (method === "index-rebuild") return await indexRebuild(srt);
|
|
4563
|
+
return {
|
|
4564
|
+
ok: false,
|
|
4565
|
+
error: `未知的 switch-search API 方法 "${method}"`
|
|
4566
|
+
};
|
|
4567
|
+
}
|
|
4568
|
+
/** 逐 id 串行调用官方内存方法;失败按会话隔离收集(AM 批量语义)。 */
|
|
4569
|
+
async function runMemoryArchiveOps(registry, ids, op) {
|
|
4570
|
+
const fn = op === "archive" ? registry.archiveSession : registry.unarchiveSession;
|
|
4571
|
+
const succeeded = [];
|
|
4572
|
+
const failures = [];
|
|
4573
|
+
for (const id of ids) try {
|
|
4574
|
+
await fn.call(registry, id);
|
|
4575
|
+
succeeded.push(id);
|
|
4576
|
+
} catch (err) {
|
|
4577
|
+
failures.push({
|
|
4578
|
+
sessionId: id,
|
|
4579
|
+
reason: String(err instanceof Error ? err.message : err)
|
|
4580
|
+
});
|
|
4581
|
+
}
|
|
4582
|
+
return {
|
|
4583
|
+
succeeded,
|
|
4584
|
+
failures
|
|
4585
|
+
};
|
|
4586
|
+
}
|
|
4587
|
+
/** 收藏文件候选(与管家其余域同一 dshHome 纪律)。 */
|
|
4588
|
+
function favoritesPathsFor(dshHome) {
|
|
4589
|
+
return [join(dshHome, "storages", "session-steward-favorites.json")];
|
|
4590
|
+
}
|
|
4591
|
+
/** 本地日界(YYYY-MM-DD → epoch ms;nextDay 为排他上界)。非法返回 undefined。 */
|
|
4592
|
+
function dayBoundaryMs(value, nextDay = false) {
|
|
4593
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return void 0;
|
|
4594
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
4595
|
+
const date = new Date(year, month - 1, day);
|
|
4596
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return void 0;
|
|
4597
|
+
if (nextDay) date.setDate(date.getDate() + 1);
|
|
4598
|
+
return date.getTime();
|
|
4599
|
+
}
|
|
4600
|
+
/** 从 payload 抽合法 sessionIds(与 prune/purge 的入参纪律一致);不合法返回 undefined。 */
|
|
4601
|
+
function validSessionIds(value) {
|
|
4602
|
+
if (!Array.isArray(value) || value.length === 0) return void 0;
|
|
4603
|
+
const ids = [...new Set(value.filter((id) => typeof id === "string" && id !== ""))];
|
|
4604
|
+
return ids.length > 0 ? ids : void 0;
|
|
4605
|
+
}
|
|
2455
4606
|
/**
|
|
2456
|
-
*
|
|
4607
|
+
* 处理一次管家子域 API 调用(导出以便单测直接驱动,不需要起 HTTP)。
|
|
2457
4608
|
* @param method - 路由方法名。
|
|
2458
4609
|
* @param payload - 已解析的请求体。
|
|
2459
4610
|
* @param runtime - 运行时依赖。
|
|
2460
4611
|
*/
|
|
4612
|
+
/** 现代宿主的只读投影:把运行时配置拆成两命名空间形状(describe 用)。 */
|
|
4613
|
+
function configSnapshotProjection(config) {
|
|
4614
|
+
return {
|
|
4615
|
+
[STEWARD_SETTINGS_NAMESPACE]: {
|
|
4616
|
+
enabled: config.enabled,
|
|
4617
|
+
historyFiles: config.historyFiles,
|
|
4618
|
+
healthCheck: config.healthCheck,
|
|
4619
|
+
search: config.search
|
|
4620
|
+
},
|
|
4621
|
+
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
4622
|
+
enabled: config.enabled,
|
|
4623
|
+
defaultMode: config.defaultMode ?? SWITCH_DEFAULT_CONFIG.defaultMode,
|
|
4624
|
+
autoSync: config.autoSync ?? SWITCH_DEFAULT_CONFIG.autoSync,
|
|
4625
|
+
syncIntervalMs: config.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
|
|
4626
|
+
archiveKeep: config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
|
|
4627
|
+
indexDir: config.indexDir ?? SWITCH_DEFAULT_CONFIG.indexDir
|
|
4628
|
+
}
|
|
4629
|
+
};
|
|
4630
|
+
}
|
|
2461
4631
|
async function handleMethod(method, payload, runtime) {
|
|
2462
4632
|
const config = runtime.config();
|
|
2463
|
-
|
|
4633
|
+
const settingsMethods = method === "settings-describe" || method === "settings-mutate";
|
|
4634
|
+
if (![
|
|
4635
|
+
...HISTORY_METHODS,
|
|
4636
|
+
...HEALTH_METHODS,
|
|
4637
|
+
"settings-describe",
|
|
4638
|
+
"settings-mutate"
|
|
4639
|
+
].includes(method)) return {
|
|
2464
4640
|
ok: false,
|
|
2465
4641
|
error: `未知的 session-steward API 方法 "${method}"`
|
|
2466
4642
|
};
|
|
2467
|
-
if (!methodEnabled(method, config)) return {
|
|
4643
|
+
if (!settingsMethods && !methodEnabled(method, config)) return {
|
|
2468
4644
|
ok: false,
|
|
2469
4645
|
error: `子域已关闭(${HISTORY_METHODS.includes(method) ? "historyFiles" : "healthCheck"}=false):${method} 未注册`
|
|
2470
4646
|
};
|
|
4647
|
+
if (method === "settings-describe") {
|
|
4648
|
+
const state = runtime.settingsBridge;
|
|
4649
|
+
if (state !== void 0 && state.bridge !== void 0) return settingsDescribe(state);
|
|
4650
|
+
return {
|
|
4651
|
+
ok: true,
|
|
4652
|
+
namespaces: configSnapshotProjection(config),
|
|
4653
|
+
writable: false,
|
|
4654
|
+
revision: state?.revision ?? 0
|
|
4655
|
+
};
|
|
4656
|
+
}
|
|
4657
|
+
if (method === "settings-mutate") return await settingsMutate(runtime.settingsBridge ?? {
|
|
4658
|
+
bridge: void 0,
|
|
4659
|
+
revision: 0
|
|
4660
|
+
}, payload);
|
|
2471
4661
|
if (method === "session-history-list") return await listHistory(runtime.registry, void 0, storagePathsFor(runtime.dshHome), runtime.dshHome);
|
|
2472
|
-
if (method === "session-history-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
4662
|
+
if (method === "session-history-archive") {
|
|
4663
|
+
const ids = validSessionIds((payload ?? {}).sessionIds);
|
|
4664
|
+
if (ids === void 0) return {
|
|
4665
|
+
ok: false,
|
|
4666
|
+
error: "缺少 sessionIds 数组"
|
|
4667
|
+
};
|
|
4668
|
+
const mem = runtime.registry();
|
|
4669
|
+
if (mem?.archiveSession !== void 0) {
|
|
4670
|
+
const before = new Set(mem.archivedSessionIds ?? []);
|
|
4671
|
+
const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, "archive");
|
|
4672
|
+
runtime.index?.onArchive(succeeded);
|
|
4673
|
+
const total = (mem.archivedSessionIds ?? []).length;
|
|
4674
|
+
return {
|
|
4675
|
+
ok: true,
|
|
4676
|
+
added: ids.filter((id) => !before.has(id)).length,
|
|
4677
|
+
total,
|
|
4678
|
+
requiresRestart: false,
|
|
4679
|
+
...failures.length === 0 ? {} : { failures }
|
|
4680
|
+
};
|
|
4681
|
+
}
|
|
4682
|
+
const result = archiveHistory(payload, runtime.log, storagePathsFor(runtime.dshHome));
|
|
4683
|
+
if (result.ok) runtime.index?.onArchive(ids);
|
|
4684
|
+
return result;
|
|
4685
|
+
}
|
|
4686
|
+
if (method === "session-history-prune") {
|
|
4687
|
+
const ids = validSessionIds((payload ?? {}).sessionIds);
|
|
4688
|
+
const mem = runtime.registry();
|
|
4689
|
+
if (mem?.unarchiveSession !== void 0 && ids !== void 0) {
|
|
4690
|
+
const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, "unarchive");
|
|
4691
|
+
runtime.index?.onUnarchive(succeeded);
|
|
4692
|
+
const remaining = (mem.archivedSessionIds ?? []).length;
|
|
4693
|
+
return {
|
|
4694
|
+
ok: true,
|
|
4695
|
+
removed: succeeded.length,
|
|
4696
|
+
remaining,
|
|
4697
|
+
requiresRestart: false,
|
|
4698
|
+
...failures.length === 0 ? {} : { failures }
|
|
4699
|
+
};
|
|
4700
|
+
}
|
|
4701
|
+
const result = pruneHistory(payload, runtime.log, storagePathsFor(runtime.dshHome));
|
|
4702
|
+
if (result.ok && ids !== void 0) runtime.index?.onUnarchive(ids);
|
|
4703
|
+
return result;
|
|
4704
|
+
}
|
|
4705
|
+
if (method === "session-history-purge") {
|
|
4706
|
+
const ids = validSessionIds((payload ?? {}).sessionIds);
|
|
4707
|
+
if (ids === void 0) return {
|
|
4708
|
+
ok: false,
|
|
4709
|
+
error: "缺少 sessionIds 数组"
|
|
4710
|
+
};
|
|
4711
|
+
const mem = runtime.registry();
|
|
4712
|
+
if (typeof mem?.deleteSession === "function") {
|
|
4713
|
+
mem.dshHome = runtime.dshHome;
|
|
4714
|
+
const succeeded = [];
|
|
4715
|
+
const failures = [];
|
|
4716
|
+
const usage = locateSessionUsage(ids, runtime.dshHome);
|
|
4717
|
+
let freedBytes = 0;
|
|
4718
|
+
for (const id of ids) {
|
|
4719
|
+
freedBytes += usage.get(id)?.bytes ?? 0;
|
|
4720
|
+
freedBytes += usage.get(id)?.cacheBytes ?? 0;
|
|
4721
|
+
}
|
|
4722
|
+
for (const id of ids) try {
|
|
4723
|
+
const outcome = await mem.deleteSession(id);
|
|
4724
|
+
if (outcome.ok) succeeded.push(id);
|
|
4725
|
+
else failures.push({
|
|
4726
|
+
sessionId: id,
|
|
4727
|
+
reason: outcome.error ?? "删除失败"
|
|
4728
|
+
});
|
|
4729
|
+
} catch (err) {
|
|
4730
|
+
failures.push({
|
|
4731
|
+
sessionId: id,
|
|
4732
|
+
reason: String(err instanceof Error ? err.message : err)
|
|
4733
|
+
});
|
|
4734
|
+
}
|
|
4735
|
+
runtime.index?.onPurged(succeeded);
|
|
4736
|
+
return {
|
|
4737
|
+
ok: true,
|
|
4738
|
+
purged: succeeded.length,
|
|
4739
|
+
freedBytes,
|
|
4740
|
+
requiresRestart: false,
|
|
4741
|
+
...failures.length === 0 ? {} : { failures }
|
|
4742
|
+
};
|
|
4743
|
+
}
|
|
4744
|
+
const result = purgeHistory(payload, runtime.log, {
|
|
4745
|
+
dshHome: runtime.dshHome,
|
|
4746
|
+
searchPaths: storagePathsFor(runtime.dshHome)
|
|
4747
|
+
});
|
|
4748
|
+
if (result.ok && ids !== void 0) {
|
|
4749
|
+
const failed = new Set((result.failures ?? []).map((f) => f.sessionId));
|
|
4750
|
+
runtime.index?.onPurged(ids.filter((id) => !failed.has(id)));
|
|
4751
|
+
}
|
|
4752
|
+
return result;
|
|
4753
|
+
}
|
|
4754
|
+
if (method === "session-history-favorites-list") return {
|
|
4755
|
+
ok: true,
|
|
4756
|
+
favorites: listFavorites(favoritesPathsFor(runtime.dshHome))
|
|
4757
|
+
};
|
|
4758
|
+
if (method === "session-history-favorite-set") {
|
|
4759
|
+
const request = payload ?? {};
|
|
4760
|
+
const sessionId = asSessionId(request.sessionId);
|
|
4761
|
+
if (sessionId === void 0) return {
|
|
4762
|
+
ok: false,
|
|
4763
|
+
error: "缺少合法的 sessionId"
|
|
4764
|
+
};
|
|
4765
|
+
if (typeof request.favorite !== "boolean") return {
|
|
4766
|
+
ok: false,
|
|
4767
|
+
error: "缺少 favorite 布尔值"
|
|
4768
|
+
};
|
|
4769
|
+
return setFavoriteState(sessionId, request.favorite, favoritesPathsFor(runtime.dshHome));
|
|
4770
|
+
}
|
|
2477
4771
|
if (method === "session-health-status") return {
|
|
2478
4772
|
ok: true,
|
|
2479
4773
|
namespace: STEWARD_SETTINGS_NAMESPACE,
|
|
@@ -2627,15 +4921,21 @@ async function handleMethod(method, payload, runtime) {
|
|
|
2627
4921
|
};
|
|
2628
4922
|
}
|
|
2629
4923
|
/**
|
|
2630
|
-
*
|
|
2631
|
-
* @param ctx - host 插件上下文(webServer / webRuntime
|
|
4924
|
+
* 插件主体:装配运行时、挂载两条 fenced 路由与索引生命周期。
|
|
4925
|
+
* @param ctx - host 插件上下文(webServer / webStartup(旧名 webRuntime)/ 可选 sessionQuery、workspaceRegistry)。
|
|
2632
4926
|
* @param config - 组合条目(0.1.7:`.volatile()` 字段为 live ref)。
|
|
2633
4927
|
*/
|
|
2634
4928
|
function apply(ctx, config = {}) {
|
|
2635
4929
|
const current = () => ({
|
|
2636
4930
|
enabled: readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled,
|
|
2637
4931
|
historyFiles: readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles,
|
|
2638
|
-
healthCheck: readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck
|
|
4932
|
+
healthCheck: readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck,
|
|
4933
|
+
search: readVolatileValue(config.search) ?? DEFAULT_CONFIG.search,
|
|
4934
|
+
defaultMode: readVolatileValue(config.defaultMode) ?? SWITCH_DEFAULT_CONFIG.defaultMode,
|
|
4935
|
+
autoSync: readVolatileValue(config.autoSync) ?? SWITCH_DEFAULT_CONFIG.autoSync,
|
|
4936
|
+
syncIntervalMs: readVolatileValue(config.syncIntervalMs) ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
|
|
4937
|
+
archiveKeep: readVolatileValue(config.archiveKeep) ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
|
|
4938
|
+
indexDir: readVolatileValue(config.indexDir) ?? SWITCH_DEFAULT_CONFIG.indexDir
|
|
2639
4939
|
});
|
|
2640
4940
|
const log = (message) => {
|
|
2641
4941
|
try {
|
|
@@ -2645,9 +4945,9 @@ function apply(ctx, config = {}) {
|
|
|
2645
4945
|
const homeEnv = process.env["DSH_HOME"];
|
|
2646
4946
|
const resolvedHome = homeEnv !== void 0 && homeEnv !== "" ? homeEnv : join(homedir(), ".dsh");
|
|
2647
4947
|
const webServer = ctx.webServer;
|
|
2648
|
-
const webRuntime = ctx.webRuntime;
|
|
4948
|
+
const webRuntime = ctx.webStartup ?? ctx.get?.("webRuntime");
|
|
2649
4949
|
if (webServer === void 0 || webRuntime === void 0) {
|
|
2650
|
-
log("webServer / webRuntime 不可用:不注册路由");
|
|
4950
|
+
log("webServer / webStartup(webRuntime) 不可用:不注册路由");
|
|
2651
4951
|
return;
|
|
2652
4952
|
}
|
|
2653
4953
|
let ownerIndex;
|
|
@@ -2676,17 +4976,170 @@ function apply(ctx, config = {}) {
|
|
|
2676
4976
|
const registry = () => ctx.get("workspaceRegistry");
|
|
2677
4977
|
const runtime = {
|
|
2678
4978
|
config: () => current(),
|
|
4979
|
+
settingsBridge: installLegacySettingsBridge(ctx),
|
|
2679
4980
|
dshHome: resolvedHome,
|
|
2680
4981
|
registry,
|
|
2681
4982
|
projectionStateFor,
|
|
2682
4983
|
attribute: attributor,
|
|
2683
4984
|
log,
|
|
2684
|
-
cache: new HealthCache()
|
|
4985
|
+
cache: new HealthCache(),
|
|
4986
|
+
index: {
|
|
4987
|
+
onArchive: (ids) => {
|
|
4988
|
+
if (!engine.isOpen) return;
|
|
4989
|
+
try {
|
|
4990
|
+
for (const id of ids) engine.setArchivedOne(id, true);
|
|
4991
|
+
} catch (err) {
|
|
4992
|
+
log(`index onArchive failed: ${String(err instanceof Error ? err.message : err)}`);
|
|
4993
|
+
}
|
|
4994
|
+
},
|
|
4995
|
+
onUnarchive: (ids) => {
|
|
4996
|
+
if (!engine.isOpen) return;
|
|
4997
|
+
try {
|
|
4998
|
+
for (const id of ids) engine.setArchivedOne(id, false);
|
|
4999
|
+
} catch (err) {
|
|
5000
|
+
log(`index onUnarchive failed: ${String(err instanceof Error ? err.message : err)}`);
|
|
5001
|
+
}
|
|
5002
|
+
},
|
|
5003
|
+
onPurged: (ids) => {
|
|
5004
|
+
if (!engine.isOpen) return;
|
|
5005
|
+
try {
|
|
5006
|
+
for (const id of ids) engine.removeSession(id);
|
|
5007
|
+
} catch (err) {
|
|
5008
|
+
log(`index onPurged failed: ${String(err instanceof Error ? err.message : err)}`);
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
5011
|
+
}
|
|
5012
|
+
};
|
|
5013
|
+
const initial = current();
|
|
5014
|
+
const sessionQuery = ctx.get("sessionQuery");
|
|
5015
|
+
const archiveReader = createArchiveSource(() => ctx.get("workspaceRegistry"));
|
|
5016
|
+
const indexLayout = {
|
|
5017
|
+
...DEFAULT_INDEX_LAYOUT,
|
|
5018
|
+
dir: resolveIndexDir(initial.indexDir || process.env[INDEX_DIR_ENV])
|
|
5019
|
+
};
|
|
5020
|
+
const engine = new SwitchIndexEngine({ path: `${indexLayout.dir}/${indexLayout.active}` });
|
|
5021
|
+
const readSessionFromFile = async (sessionId) => {
|
|
5022
|
+
try {
|
|
5023
|
+
const logPath = findSessionLog(sessionId, resolvedHome);
|
|
5024
|
+
if (logPath === void 0) return void 0;
|
|
5025
|
+
const decoded = decodeSessionLogFile(logPath);
|
|
5026
|
+
return {
|
|
5027
|
+
session: {
|
|
5028
|
+
id: sessionId,
|
|
5029
|
+
version: 0
|
|
5030
|
+
},
|
|
5031
|
+
events: decoded.events.map((event) => ({
|
|
5032
|
+
seq: event.seq,
|
|
5033
|
+
type: event.type,
|
|
5034
|
+
time: event.time,
|
|
5035
|
+
surfaceOp: event.surfaceOp,
|
|
5036
|
+
data: event.data
|
|
5037
|
+
}))
|
|
5038
|
+
};
|
|
5039
|
+
} catch (err) {
|
|
5040
|
+
log(`file fallback read failed for ${sessionId}: ${String(err instanceof Error ? err.message : err)}`);
|
|
5041
|
+
return;
|
|
5042
|
+
}
|
|
5043
|
+
};
|
|
5044
|
+
const readProjectionTitle = (sessionId) => {
|
|
5045
|
+
try {
|
|
5046
|
+
const path = join(resolvedHome, "storages", "session_projcache", "sessions", `${sessionId}.json`);
|
|
5047
|
+
if (!existsSync(path)) return "";
|
|
5048
|
+
const value = JSON.parse(readFileSync(path, "utf8")).record?.rows?.title?.val;
|
|
5049
|
+
return typeof value === "string" ? value : "";
|
|
5050
|
+
} catch {
|
|
5051
|
+
return "";
|
|
5052
|
+
}
|
|
5053
|
+
};
|
|
5054
|
+
const indexState = {
|
|
5055
|
+
engine,
|
|
5056
|
+
archiveReader,
|
|
5057
|
+
sync: new SwitchWatermarkSync(engine, {
|
|
5058
|
+
listSessions: () => sessionQuery?.listSessions() ?? Promise.resolve([]),
|
|
5059
|
+
readSession: async (sessionId) => {
|
|
5060
|
+
if (sessionQuery?.readSession === void 0) throw new Error("sessionQuery.readSession 不可用");
|
|
5061
|
+
return sessionQuery.readSession(sessionId);
|
|
5062
|
+
},
|
|
5063
|
+
readTitleSnapshots: sessionQuery === void 0 ? void 0 : (ids) => sessionQuery.readTitleSnapshots(ids)
|
|
5064
|
+
}, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle),
|
|
5065
|
+
layout: indexLayout,
|
|
5066
|
+
rebuild: {
|
|
5067
|
+
state: "idle",
|
|
5068
|
+
done: 0,
|
|
5069
|
+
total: 0,
|
|
5070
|
+
startedAt: 0,
|
|
5071
|
+
finishedAt: 0,
|
|
5072
|
+
failures: []
|
|
5073
|
+
}
|
|
5074
|
+
};
|
|
5075
|
+
const srt = {
|
|
5076
|
+
sessionQuery,
|
|
5077
|
+
index: indexState,
|
|
5078
|
+
config: () => current(),
|
|
5079
|
+
registry: () => ({ archivedSessionIds: archiveReader.read().ids }),
|
|
5080
|
+
log,
|
|
5081
|
+
readSessionFromFile
|
|
2685
5082
|
};
|
|
2686
|
-
|
|
5083
|
+
const initialConfig = current();
|
|
5084
|
+
if (initialConfig.enabled === false) {
|
|
2687
5085
|
log("enabled=false:不注册任何路由");
|
|
2688
5086
|
return;
|
|
2689
5087
|
}
|
|
5088
|
+
let syncTimer;
|
|
5089
|
+
const scheduleSync = (intervalMs) => {
|
|
5090
|
+
if (syncTimer !== void 0) clearInterval(syncTimer);
|
|
5091
|
+
if (intervalMs <= 0) return;
|
|
5092
|
+
syncTimer = setInterval(() => {
|
|
5093
|
+
const latest = current();
|
|
5094
|
+
if (latest.enabled === false || latest.search === false || latest.autoSync === false) return;
|
|
5095
|
+
indexState.sync.poll().catch(() => {});
|
|
5096
|
+
}, Math.max(5e3, intervalMs));
|
|
5097
|
+
};
|
|
5098
|
+
let pendingTitleIds = /* @__PURE__ */ new Set();
|
|
5099
|
+
let titleTimer;
|
|
5100
|
+
const flushPendingTitles = () => {
|
|
5101
|
+
titleTimer = void 0;
|
|
5102
|
+
const ids = [...pendingTitleIds];
|
|
5103
|
+
pendingTitleIds = /* @__PURE__ */ new Set();
|
|
5104
|
+
if (ids.length === 0) return;
|
|
5105
|
+
indexState.sync.refreshTitles(ids).catch(() => {});
|
|
5106
|
+
};
|
|
5107
|
+
ctx.effect(() => {
|
|
5108
|
+
const bus = ctx;
|
|
5109
|
+
if (typeof bus.on !== "function") return () => {};
|
|
5110
|
+
try {
|
|
5111
|
+
return bus.on("session/event", (session, event) => {
|
|
5112
|
+
if (event?.type !== TITLE_EVENT_TYPE) return;
|
|
5113
|
+
const latest = current();
|
|
5114
|
+
if (latest.enabled === false || latest.search === false || latest.autoSync === false) return;
|
|
5115
|
+
const id = session?.id;
|
|
5116
|
+
if (typeof id !== "string" || id === "") return;
|
|
5117
|
+
pendingTitleIds.add(id);
|
|
5118
|
+
if (titleTimer !== void 0) return;
|
|
5119
|
+
titleTimer = setTimeout(flushPendingTitles, TITLE_FLUSH_MS);
|
|
5120
|
+
});
|
|
5121
|
+
} catch {
|
|
5122
|
+
return () => {};
|
|
5123
|
+
}
|
|
5124
|
+
}, "dsh-manage-session: realtime titles");
|
|
5125
|
+
if (initialConfig.search !== false) (async () => {
|
|
5126
|
+
try {
|
|
5127
|
+
const recovered = await recoverIndex(indexLayout, log);
|
|
5128
|
+
if (recovered.length > 0) log(`index recovery applied ${recovered.length} fix(es)`);
|
|
5129
|
+
} catch (err) {
|
|
5130
|
+
log(`index recovery failed: ${String(err instanceof Error ? err.message : err)}`);
|
|
5131
|
+
}
|
|
5132
|
+
await engine.open().catch(() => {});
|
|
5133
|
+
log(`index open: driver=${engine.driverLabel} dir=${indexLayout.dir}`);
|
|
5134
|
+
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");
|
|
5135
|
+
if (initialConfig.autoSync !== false) await indexState.sync.poll().catch(() => {});
|
|
5136
|
+
scheduleSync(initialConfig.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs);
|
|
5137
|
+
})();
|
|
5138
|
+
ctx.effect(() => () => {
|
|
5139
|
+
if (syncTimer !== void 0) clearInterval(syncTimer);
|
|
5140
|
+
if (titleTimer !== void 0) clearTimeout(titleTimer);
|
|
5141
|
+
engine.close();
|
|
5142
|
+
}, "dsh-manage-session: index lifecycle");
|
|
2690
5143
|
ctx.effect(() => webServer.register({
|
|
2691
5144
|
kind: "prefix",
|
|
2692
5145
|
path: STEWARD_API_PREFIX,
|
|
@@ -2725,6 +5178,64 @@ function apply(ctx, config = {}) {
|
|
|
2725
5178
|
}
|
|
2726
5179
|
}
|
|
2727
5180
|
}), "dsh-manage-session: /session-steward/api route");
|
|
5181
|
+
ctx.effect(() => webServer.register({
|
|
5182
|
+
kind: "prefix",
|
|
5183
|
+
path: SWITCH_API_PREFIX,
|
|
5184
|
+
handler: async (req, res) => {
|
|
5185
|
+
if (!isTrustedApiRequest(req, webRuntime.trustedHosts)) {
|
|
5186
|
+
writeJson(res, 403, {
|
|
5187
|
+
ok: false,
|
|
5188
|
+
error: "forbidden"
|
|
5189
|
+
});
|
|
5190
|
+
return;
|
|
5191
|
+
}
|
|
5192
|
+
if (req.method !== "POST") {
|
|
5193
|
+
writeJson(res, 405, {
|
|
5194
|
+
ok: false,
|
|
5195
|
+
error: "method not allowed"
|
|
5196
|
+
});
|
|
5197
|
+
return;
|
|
5198
|
+
}
|
|
5199
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
5200
|
+
const prefix = `${SWITCH_API_PREFIX}/`;
|
|
5201
|
+
const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : void 0;
|
|
5202
|
+
if (method === void 0 || method === "" || method.includes("/")) {
|
|
5203
|
+
writeJson(res, 404, {
|
|
5204
|
+
ok: false,
|
|
5205
|
+
error: "unknown switch-search API method"
|
|
5206
|
+
});
|
|
5207
|
+
return;
|
|
5208
|
+
}
|
|
5209
|
+
if (method in MOVED_TO_HISTORY) {
|
|
5210
|
+
writeJson(res, 410, movedToHistory(method));
|
|
5211
|
+
return;
|
|
5212
|
+
}
|
|
5213
|
+
try {
|
|
5214
|
+
if (methodEnabled(method, current()) === false) {
|
|
5215
|
+
writeJson(res, 200, {
|
|
5216
|
+
ok: false,
|
|
5217
|
+
error: `子域已关闭(search=false):${method} 未注册`
|
|
5218
|
+
});
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
if (method === "index-export") {
|
|
5222
|
+
await indexExport(srt, res);
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
if (method === "index-import") {
|
|
5226
|
+
const text = await readRawBody(req);
|
|
5227
|
+
writeJson(res, 200, await indexImport(srt, text));
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5230
|
+
writeJson(res, 200, await handleIndexMethod(method, await readJsonBody(req), srt));
|
|
5231
|
+
} catch (err) {
|
|
5232
|
+
writeJson(res, 400, {
|
|
5233
|
+
ok: false,
|
|
5234
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5235
|
+
});
|
|
5236
|
+
}
|
|
5237
|
+
}
|
|
5238
|
+
}), "dsh-manage-session: /switch-search/api route");
|
|
2728
5239
|
}
|
|
2729
5240
|
//#endregion
|
|
2730
|
-
export { Config, DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, V4_HOST_MIN, apply, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, dirSize, discoverSessions, editWorkspaceDocument, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleMethod, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, prescribe, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor };
|
|
5241
|
+
export { Config, DEFAULT_CONFIG, DEFAULT_INDEX_LAYOUT, HEALTH_METHODS, HISTORY_METHODS, HealthCache, INDEX_METHODS, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, SWITCH_API_PREFIX, SWITCH_DEFAULT_CONFIG, SwitchIndexEngine, SwitchWatermarkSync, V4_HOST_MIN, apply, archiveArchiveFile, archiveHistory, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createArchiveSource, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, detectSteward, dirSize, discoverSessions, editWorkspaceDocument, exportSnapshot, extractTitleFromEvents, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleIndexMethod, handleMethod, importIntoIndex, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, parseSnapshot, prescribe, probePeer, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, rebuildIndex, recoverIndex, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor };
|