dsh-session-index-lau 0.0.0-stage → 2.4.1

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/README.md CHANGED
@@ -1,3 +1,317 @@
1
- # Temporary Holding Version
1
+ # dsh-session-index-lau
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > DSH(DeepSeek Harness)+ WorkBuddy 的**全会话索引与跨会话记忆** MCP 插件。
4
+ > 拉取仓库 → 双击安装 → 之后全自动。零第三方依赖,源会话文件全程只读。
5
+
6
+ | 项目 | 值 |
7
+ |---|---|
8
+ | 版本 | **v2.4.1** |
9
+ | 运行要求 | Node **≥ 22.15**(需要 `zlib.zstdDecompressSync`) |
10
+ | MCP 能力 | **13 个工具** / 4 个提示词 / 8 个资源 |
11
+ | 第三方依赖 | **0**(只用 Node 标准库) |
12
+ | 完整手册 | [`操作使用手册.html`](./操作使用手册.html)(单页 14 章,导航 / 搜索 / 一键复制) |
13
+
14
+ ---
15
+
16
+ ## 它解决什么问题
17
+
18
+ DSH 的会话存在 `~/.dsh/sessions/` 下,是**上千个独立 zstd 帧拼接**的 `session.v4.jsonl.zstd`,单文件可达 20 MB。
19
+ 想在新会话里回顾旧结论,只能整包解压翻全文——慢,而且极耗 token。
20
+
21
+ 本插件把全部会话**预先索引成结构化记忆单元**(会话卡:提问、结论、涉及路径、工具、关键词、血缘),
22
+ 之后一次工具调用就能拿到结论速览,用关键词只取命中片段,**不必再把几十 MB 历史灌进上下文**。
23
+
24
+ ---
25
+
26
+ ## 安装
27
+
28
+ ### 方式 A:DSH 插件页(推荐 · 零命令行)
29
+
30
+ 1. 打开 DSH → **插件页**
31
+ 2. 粘贴仓库地址:`https://github.com/Lau-Jin/DSH`
32
+ 3. 点安装,按提示**完全退出 DSH 再打开**(最小化无效)
33
+
34
+ 装完就是可用状态,不用改配置、不用跑脚本、不用管 Node 路径。「已安装」列表与详情页会显示插件图标(`icon.svg`,卡片 + 放大镜)。
35
+
36
+ > 地址带不带 `.git` 都能装:不带时 pnpm 直接走 codeload 下 tarball,**不依赖本机 git**;
37
+ > 带上 `.git` 会先做一次 `git ls-remote` 解析版本。网络抖动时前者更稳。
38
+ > 实测这两条路都能装成(本机约 80–100 秒,主要是 GitHub 往返延迟,包本身只有 83 KB)。
39
+
40
+ **它为什么能自己找到入口**——插件包里的 `cordis.patch.yml` 会随 bundle 自动应用,挂两行:
41
+
42
+ | 行 id | 包 | 作用 |
43
+ |---|---|---|
44
+ | `session-index-host` | `dsh-session-index-lau` | 宿主半身(`lib/index.mjs`):用 `import.meta.url` 解析**真实安装目录**,把 `server.mjs` 绝对路径写进 `~/.dsh/session-index/server-path.txt` |
45
+ | `mcp-session-index` | `@deepseek-ai/dsh-mcp-client` | MCP stdio 客户端:13 工具 / 4 提示词 / 8 资源 |
46
+
47
+ 那行配置里的 `command`、`args`、`DSH_HOME` 三处都是 `!!js` 表达式,**在 DSH 加载配置时求值**(不是安装时),
48
+ 按「显式环境变量 → 宿主记下的路径文件 → profile 标准安装位置」依次探测。
49
+ 所以仓库放哪、pnpm 装到哪、换了 Node 版本,都不需要改文件。
50
+
51
+ 宿主行排在前、MCP 行排在后,路径文件先落盘,MCP 侧首次 spawn 就能命中——**一次重启即生效**。
52
+
53
+ ### 方式 B:clone + 一键脚本(想自己控制目录 / 离线分发)
54
+
55
+ ```bash
56
+ git clone https://github.com/Lau-Jin/DSH.git
57
+ cd DSH
58
+ ```
59
+
60
+ 然后:
61
+
62
+ - **Windows**:双击 `install.cmd`
63
+ - **macOS / Linux / Git Bash**:`./install.sh`
64
+
65
+ `install.mjs` 会先判断 profile 是否已经以 bundle 方式注册了本插件:
66
+
67
+ - **已注册**(你从插件页装过)→ 不再写 profile 配置,只把残留的旧配置块清掉,避免出现两条同 id 的 MCP 行;
68
+ - **未注册**(纯 clone 用户)→ 往 `~/.dsh/profiles/desktop/cordis.patch.yml` 写一个带标记的配置块。
69
+
70
+ 两种情况都会:写好 `server-path.txt`、装 Skill、生成可分发包。
71
+
72
+ ### 方式 C:手动加 MCP(不推荐,仅排障用)
73
+
74
+ DSH「MCP 与技能」→ MCP 里添加 stdio server:
75
+
76
+ | 字段 | 值 |
77
+ |---|---|
78
+ | 名称 | `session-index` |
79
+ | command | `node` 的绝对路径(需 ≥ 22.15) |
80
+ | args | `<仓库目录>/server.mjs` |
81
+
82
+ ### 前置条件
83
+
84
+ 1. **Node ≥ 22.15** —— 需要 `zlib.zstdDecompressSync`。插件页安装时会自动挑 DSH 自带的 Node(`~/.dsh/dsh-runtimes/*/dependencies/node`),通常不用你操心。
85
+ 2. 方式 B 需要 **Git**;仓库已转公开,无需 SSH key。
86
+
87
+ ### 安装脚本做了什么
88
+
89
+ `install.cmd` / `install.sh` 只是包装,实际调用 `node install.mjs`:
90
+
91
+ 1. 解析可用的 Node(含 zstd 支持校验)
92
+ 2. 判断安装方式(bundle / patch),必要时**备份**并写入 `cordis.patch.yml` 的标记块
93
+ 3. 写 `~/.dsh/session-index/server-path.txt`,记下 MCP 入口绝对路径
94
+ 4. 安装 Skill 到 `~/.dsh/skills/session-index/SKILL.md`
95
+ 5. 自动生成插件包与技能包到 `dist/`
96
+ 6. 打印 6 项校验结果
97
+
98
+ 幂等:重复运行不会产生重复条目;**更新后重跑会保留你手工改过的 env 值**(除非显式加 `--reset-env`)。
99
+
100
+ ---
101
+
102
+ ## 插件页装不上?先跑环境体检
103
+
104
+ DSH 插件页装任何东西,底层都是一次 `pnpm install`。装不上时提示常常被归类成
105
+ 「没有写入权限,无法安装」,但**真实原因通常在 pnpm 那一步**。装上本插件后可以直接体检:
106
+
107
+ ```bash
108
+ node install.mjs --doctor
109
+ ```
110
+
111
+ 七项检查,每项给出结论与修法(只读,不改任何文件):
112
+
113
+ | 检查 | 会导致的现象 |
114
+ |---|---|
115
+ | Node 支持 zstd | 索引建不起来 |
116
+ | git 全局 URL 重写 | 走第三方镜像时 codeload 返回 403 / 认证失败 |
117
+ | pnpm `fetch-timeout` | 超过 60000 会让 pnpm 11 直接抛 `UND_ERR_INVALID_ARG`,**所有安装失败** |
118
+ | 远端 git 依赖 | 每次安装都要重下,大仓库必然超时 |
119
+ | lockfile 残留 `git+ssh` | 仓库已删或缺 SSH key 时必失败 |
120
+ | bundle 是否注册 | 插件没被 DSH 加载 |
121
+ | 索引状态 | 装完但没建索引 |
122
+
123
+ ### 三个已确认的坑
124
+
125
+ 1. **`fetch-timeout` 有硬上限。** pnpm 11.28 + undici 在 `fetch-timeout > 60000` 时直接报
126
+ `UND_ERR_INVALID_ARG`(实测 60000 正常、65000 就失败)。调大超时不仅没用,还会让所有安装全挂。
127
+ 2. **大 tarball 装不完。** GitHub 直连在本机约 70–350 KB/s,28 MB 的包需要数分钟,
128
+ 永远超不过 60 秒上限,于是 `UND_ERR_DESTROYED` 反复重试直到失败。
129
+ 正解不是调超时,而是**把这类依赖本地化**:复制到本地目录,依赖写成 `link:`,
130
+ 之后安装完全离线(实测整份 profile 8 秒装完)。
131
+ 3. **`insteadOf` 镜像规则会连坐。** `url.https://<镜像>/https://github.com/.insteadOf`
132
+ 会重写所有 GitHub HTTPS 地址,而镜像一般不代理 `codeload.github.com`(返回 403)。
133
+ 直连可用时建议去掉:
134
+ ```bash
135
+ git config --global --get-regexp "url\..*\.insteadof" # 先看有没有
136
+ git config --global --unset-all "url.https://<镜像前缀>.insteadOf"
137
+ ```
138
+
139
+ > 本次修复的完整做法(复制包 → `link:` → 离线重建 lockfile)见
140
+ > `操作使用手册.html` 第 13 章「排障」。
141
+
142
+ ---
143
+
144
+ ## 装上之后是自动的
145
+
146
+ | 行为 | 说明 |
147
+ |---|---|
148
+ | 首次建索引 | server 启动时后台子进程构建,**不阻塞 MCP 握手**;未就绪时工具会提示稍候 |
149
+ | 新会话识别 | `fs.watch` 递归监听 DSH 与 WorkBuddy 会话目录,写入停止约 3.5 秒后增量纳入 |
150
+ | 定时兜底 | 每 60 秒增量刷新,只处理 size/mtime 变化的文件 |
151
+ | 安静期保护 | 已有索引的活跃会话需静止 45 秒才重建,避免反复解压正在写入的大文件 |
152
+ | 客户端通知 | 索引更新后发 `notifications/resources/list_changed`,客户端无需重启即可感知 |
153
+ | 自愈 | 索引缺失或损坏时,下次启动自动重建 |
154
+
155
+ 本机实测(31 个会话):全量构建 **约 10 秒**、索引 **约 12 MB**(源文件 55 MB+);
156
+ 增量刷新 **约 0.85 秒**,无变化时 **约 30 ms**;新会话从写入到可检索 **约 70 ms**。
157
+
158
+ ---
159
+
160
+ ## 工具速览
161
+
162
+ 推荐调用链:`status → brief → related/thread → search → get`,逐级放大,避免一上来灌全文。
163
+
164
+ | 工具 | 用途 |
165
+ |---|---|
166
+ | `session_index_status` | 索引与自动维护状态(含监听、下次刷新倒计时) |
167
+ | `session_index_brief` | 跨会话结论速览;带 `query` 只取相关主题 |
168
+ | `session_index_list` | 会话清单(标题 / 时间 / 工作区 / 应用) |
169
+ | `session_index_related` | 相关会话 + 关联理由(共享路径 / 关键词 / 工具 / 血缘 / 同工作区) |
170
+ | `session_index_thread` | 父子血缘链(compact 上游、subagent 下游、同源兄弟) |
171
+ | `session_index_search` | 语义检索,**只返回命中片段**;默认排除系统注入噪声 |
172
+ | `session_index_get` | 先看会话骨架,再按 `seq` 精确取原文 |
173
+ | `session_index_remember` | 写入长期记忆(类型 / 标签 / 重要度) |
174
+ | `session_index_recall` | 召回长期记忆(时间衰减排序) |
175
+ | `session_index_forget` | 归档或删除记忆(默认归档) |
176
+ | `session_index_refresh` | 手动增量刷新 |
177
+ | `session_index_package` | 重新生成分发包 |
178
+ | `session_index_vectors` | 可选的向量索引管理 |
179
+
180
+ 另提供提示词 `session-context` / `resume-topic` / `catch-up` / `quickstart`,
181
+ 资源 `session-index://guide`、`://plugin.json`、`://status`、`://brief`、`://memory` 等。
182
+ `initialize` 会返回 `instructions`,客户端不读外部文档也能正确上手。
183
+
184
+ **搜索模式**:`semantic`(默认,本地同义词扩展 + BM25,零 token)、`keyword`(精确 AND)、
185
+ `hybrid` / `vector`(需配置 embedding,未配置自动降级为本地语义检索)。
186
+
187
+ ---
188
+
189
+ ## 更新 / 校验 / 回滚
190
+
191
+ ```bash
192
+ # Windows
193
+ update.cmd # git pull + 重装(保留 env 自定义)
194
+
195
+ # macOS / Linux
196
+ ./update.sh
197
+ ```
198
+
199
+ ```bash
200
+ node install.mjs --doctor # 环境体检(7 项,装不上时先跑这个)
201
+ node install.mjs --verify # 6 项状态校验
202
+ node install.mjs --dry-run # 预览改动,不写文件
203
+ node install.mjs --uninstall # 移除配置块与自动装的 Skill(先备份)
204
+ node install.mjs --reset-env # 用默认值覆盖 env 自定义
205
+ node smoke.mjs # 66 项端到端自测
206
+ ```
207
+
208
+ ---
209
+
210
+ ## 分发包(离线 / 无 git 环境)
211
+
212
+ `install.mjs` 每次运行会自动生成:
213
+
214
+ | 文件 | 内容 | 用途 |
215
+ |---|---|---|
216
+ | `dist/dsh-session-index-lau-v2.4.1.zip` | 全部源码 + 清单 + 文档 + 手册 + 脚本 + `MANIFEST.json` + `INSTALL.md` | 完整分发,解压后双击 `install.cmd` |
217
+ | `dist/session-index-skill-v2.4.1.zip` | 根 `SKILL.md` + 说明 | 直接导入 DSH「MCP 与技能」 |
218
+
219
+ 包内**排除**:索引分片 `*.jsonl.gz`、`meta/cards/notes/vectors.json`、备份与临时文件、已有 zip。
220
+ 已验证包内无任何会话数据与密钥,可安全分发。也可随时 `node pack.mjs` 重打,或调 MCP 工具 `session_index_package`。
221
+
222
+ ---
223
+
224
+ ## 配置项
225
+
226
+ 全部通过配置块的 `env` 设置。改哪里取决于安装方式:
227
+
228
+ - **插件页安装**:改插件包内的 `cordis.patch.yml`(`<profile>/node_modules/dsh-session-index-lau/cordis.patch.yml`),改完重启 DSH;
229
+ - **clone + 脚本**:改 `~/.dsh/profiles/desktop/cordis.patch.yml` 里 `>>> session-index` 标记块内的 `env`,重跑 `node install.mjs` 会保留你写的值。
230
+
231
+ | 变量 | 默认 | 说明 |
232
+ |---|---|---|
233
+ | `SESSION_INDEX_AUTO` | `1` | 总开关,`0` 关闭后台自动维护 |
234
+ | `SESSION_INDEX_AUTO_INTERVAL_MS` | `60000` | 定时增量刷新间隔(最小 15000) |
235
+ | `SESSION_INDEX_AUTO_QUIET_MS` | `45000` | 已有会话的静止期(活跃时不重建) |
236
+ | `SESSION_INDEX_WATCH` | `1` | 文件事件监听开关 |
237
+ | `SESSION_INDEX_WATCH_DEBOUNCE_MS` | `3500` | 写入事件防抖 |
238
+ | `SESSION_INDEX_WATCH_QUIET_MS` | `6000` | 监听路径的静止期 |
239
+ | `SESSION_INDEX_APPS` | `all` | 只索引 `dsh` 或 `workbuddy` |
240
+ | `SESSION_INDEX_DIR` | `~/.dsh/session-index` | 索引产物目录 |
241
+ | `SESSION_INDEX_EMBED_URL` / `_MODEL` / `_KEY` | 空 | 可选,OpenAI 兼容 embedding;不配则纯本地 |
242
+
243
+ > 改完这些值后**不要加 `--reset-env`**;正常重跑安装会保留它们。
244
+
245
+ ---
246
+
247
+ ## 目录结构
248
+
249
+ ```text
250
+ dsh-session-index-lau/ # 仓库根 = 插件根
251
+ ├── server.mjs # MCP stdio server(13 工具 / prompts / resources)
252
+ ├── cordis.patch.yml # bundle patch:挂宿主行 + MCP 行(含 !!js 自解析路径)
253
+ ├── build-index.mjs # 索引构建(多帧 zstd → 可检索记录 + 会话卡)
254
+ ├── install.mjs # 安装 / 校验 / 卸载 / 自动打包
255
+ ├── pack.mjs # 打包 CLI(零依赖 ZIP writer)
256
+ ├── smoke.mjs # 66 项端到端自测
257
+ ├── install.cmd / install.sh # 一键安装(双击 / 执行)
258
+ ├── update.cmd / update.sh # 更新(git pull + 重装)
259
+ ├── icon.svg # 插件页图标(package.json 的 icon 字段指向它)
260
+ ├── plugin.json # 机器可读能力清单(也通过 MCP 资源暴露)
261
+ ├── package.json # 元信息 + dsh.bundle.patch + dsh.plugin 声明
262
+ ├── 操作使用手册.html # 自包含单页手册(14 章)
263
+ ├── lib/
264
+ │ ├── index.mjs # 宿主半身:解析自身安装路径(ESM,供 bundle 挂载)
265
+ │ ├── reader.mjs # 多帧 zstd 读取
266
+ │ ├── extract.mjs # 事件 → 文本记录(含系统注入降噪)
267
+ │ ├── cards.mjs # 会话卡 / 关联度 / 血缘
268
+ │ ├── index-store.mjs # 分片存储与增量签名
269
+ │ ├── source-workbuddy.mjs # WorkBuddy 会话适配器
270
+ │ ├── semantic.mjs # 同义词扩展 + BM25
271
+ │ ├── embedding.mjs # 可选向量检索
272
+ │ ├── notes.mjs # 长期记忆库
273
+ │ ├── auto.mjs # 自动建索引 / 定时刷新
274
+ │ ├── watch.mjs # 文件事件监听
275
+ │ └── pack.mjs # ZIP writer
276
+ ├── skill/SKILL.md # 自动接入用技能
277
+ └── dist/ # 自动生成的分发包(git 忽略)
278
+ ```
279
+
280
+ 索引产物写入 `~/.dsh/session-index/`,**与仓库目录分离**,所以换 clone 位置不会丢索引。
281
+
282
+ ---
283
+
284
+ ## 隐私与安全
285
+
286
+ - **源会话只读**:任何操作都不修改 `~/.dsh/sessions/` 与 `~/.workbuddy/projects/` 下的原始文件。
287
+ - **配置写前备份**:每次改 `cordis.patch.yml` 都先复制为 `.bak-<时间戳>`。
288
+ - **不入库**:索引、长期记忆、备份、验证产物均在 `.gitignore` 中排除。
289
+ - **索引含会话原文**:`~/.dsh/session-index/` 是会话全文的本地副本,**不要打包外传**;分发请用 `dist/` 里的 zip(已自动排除)。
290
+
291
+ ---
292
+
293
+ ## 卸载
294
+
295
+ - **插件页安装的**:插件页里禁用或卸载 `dsh-session-index-lau`,重启 DSH 即可(MCP 行随 bundle 一起消失)。
296
+ - **clone + 脚本安装的**:`node install.mjs --uninstall`,移除配置块与自动安装的 Skill(先备份)。
297
+
298
+ 两种情况下索引目录 `~/.dsh/session-index/` 都保留(不影响原始会话),需要时手动删除。
299
+
300
+ ---
301
+
302
+ ## 变更记录
303
+
304
+ | 版本 | 变更 |
305
+ |---|---|
306
+ | **v2.4.1** | 新增 `install.mjs --doctor` **环境体检**(7 项,只读),覆盖插件页安装失败的四个真实成因:git URL 重写镜像、pnpm `fetch-timeout` 超上限、远端大 tarball、lockfile 残留 `git+ssh`。README 增加「插件页装不上」排障章节。 |
307
+ | **v2.4.0** | 支持 **DSH 插件页直接安装**:新增 `dsh.bundle.patch` 声明与 `cordis.patch.yml`(含 `!!js` 自解析表达式),新增宿主半身 `lib/index.mjs`。安装路径由运行时探测,不再依赖手工配置;`install.mjs` 自动识别 bundle 模式,避免与插件页安装重复注册。仓库转公开。 |
308
+ | v2.3.0 | 仓库收敛为单插件,仓库根即插件根;新增 `install.cmd/sh`、`update.cmd/sh`;重装保留手工改过的 env。 |
309
+ | v2.2.0 | 新增文件事件驱动的免重启识别(`fs.watch` + 静止期)、MCP 变更通知。 |
310
+ | v2.1.0 | 新增跨会话关联 / 血缘链 / 长期记忆(`remember` / `recall` / `forget`)。 |
311
+ | v2.0.0 | 从「全文检索」重构为「会话卡 + 结构化速览」。 |
312
+
313
+ ---
314
+
315
+ ## 许可
316
+
317
+ MIT
@@ -0,0 +1,319 @@
1
+ /**
2
+ * 多源全会话增量索引构建器(DSH + WorkBuddy + 跨会话会话卡)。
3
+ *
4
+ * 用法:
5
+ * node build-index.mjs # 增量:只重建 size/mtime 变化的会话
6
+ * node build-index.mjs --force # 全量重建
7
+ * node build-index.mjs --apps dsh # 只索引 DSH(也可 workbuddy / dsh,workbuddy)
8
+ * node build-index.mjs --quiet 45000 # 跳过最近 45 秒内仍在写入的活跃会话
9
+ * node build-index.mjs --json # JSON 输出统计(供自动守护进程解析)
10
+ *
11
+ * 环境变量:
12
+ * DSH_HOME DSH 主目录,默认 ~/.dsh
13
+ * SESSION_INDEX_DIR 索引输出目录,默认 $DSH_HOME/session-index
14
+ * WORKBUDDY_HOME WorkBuddy 主目录,默认 ~/.workbuddy
15
+ * WORKBUDDY_PROJECTS_DIR WorkBuddy 会话目录,默认 $WORKBUDDY_HOME/projects
16
+ * SESSION_INDEX_APPS 要索引的源,默认 dsh,workbuddy
17
+ *
18
+ * 要求 Node >= 22.15(zlib.zstdDecompressSync)。所有源会话文件只读,绝不修改。
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ import { readSessionEvents, assertZstdSupport } from './lib/reader.mjs';
26
+ import { extractRecord, deriveTitle, isHeader } from './lib/extract.mjs';
27
+ import { buildCard } from './lib/cards.mjs';
28
+ import { workbuddyPaths, findWorkbuddySessions, readWorkbuddySession } from './lib/source-workbuddy.mjs';
29
+ import {
30
+ loadMeta,
31
+ saveMeta,
32
+ loadCards,
33
+ saveCards,
34
+ saveRecords,
35
+ readRecords,
36
+ signatureOf,
37
+ indexSizeBytes,
38
+ } from './lib/index-store.mjs';
39
+
40
+ export function defaultPaths(env = process.env) {
41
+ const dshHome = env.DSH_HOME || path.join(os.homedir(), '.dsh');
42
+ const wb = workbuddyPaths(env);
43
+ return {
44
+ dshHome,
45
+ sessionsRoot: env.DSH_SESSIONS_DIR || path.join(dshHome, 'sessions'),
46
+ indexDir: env.SESSION_INDEX_DIR || path.join(dshHome, 'session-index'),
47
+ workbuddyProjectsDir: wb.projectsDir,
48
+ };
49
+ }
50
+
51
+ function findDshSessions(root, maxDepth = 3) {
52
+ const out = [];
53
+ const walk = (dir, depth) => {
54
+ if (depth > maxDepth) return;
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ } catch {
59
+ return;
60
+ }
61
+ for (const e of entries) {
62
+ const p = path.join(dir, e.name);
63
+ if (e.isDirectory()) walk(p, depth + 1);
64
+ else if (e.isFile() && e.name.endsWith('.jsonl.zstd')) out.push(p);
65
+ }
66
+ };
67
+ walk(root, 0);
68
+ return out.sort();
69
+ }
70
+
71
+ /** DSH 源:分帧 zstd → 统一 header/records。 */
72
+ function readDshSession(file, onWarning) {
73
+ let headerEv = null;
74
+ const records = [];
75
+ let eventCount = 0;
76
+ for (const ev of readSessionEvents(file, { onWarning })) {
77
+ eventCount += 1;
78
+ if (isHeader(ev)) headerEv = ev;
79
+ const rec = extractRecord(ev);
80
+ if (rec) records.push(rec);
81
+ }
82
+ const sourceId =
83
+ (headerEv && typeof headerEv.id === 'string' && headerEv.id) || path.basename(path.dirname(file));
84
+ return {
85
+ header: {
86
+ id: sourceId,
87
+ sourceId,
88
+ app: 'dsh',
89
+ cwd: (headerEv && typeof headerEv.cwd === 'string' && headerEv.cwd) || path.basename(path.dirname(path.dirname(file))),
90
+ title: deriveTitle(records),
91
+ createdAt: Number.isFinite(headerEv?.createdAt) ? headerEv.createdAt : 0,
92
+ updatedAt: records.length ? records[records.length - 1].ts : 0,
93
+ parentSession: headerEv?.parentSession || null,
94
+ },
95
+ records,
96
+ eventCount,
97
+ warnings: [],
98
+ };
99
+ }
100
+
101
+ function parseArgs(argv) {
102
+ const opts = { force: false, json: false, sessions: null, index: null, apps: null, quietMs: 0 };
103
+ for (let i = 0; i < argv.length; i++) {
104
+ const a = argv[i];
105
+ if (a === '--force') opts.force = true;
106
+ else if (a === '--json') opts.json = true;
107
+ else if (a === '--sessions' && argv[i + 1]) opts.sessions = argv[++i];
108
+ else if (a === '--index' && argv[i + 1]) opts.index = argv[++i];
109
+ else if (a === '--apps' && argv[i + 1]) opts.apps = argv[++i];
110
+ else if (a === '--quiet' && argv[i + 1]) opts.quietMs = Number(argv[++i]) || 0;
111
+ }
112
+ return opts;
113
+ }
114
+
115
+ /**
116
+ * 构建(或增量刷新)多源索引 + 会话卡。
117
+ * @param {object} options quietMs>0 时跳过最近仍在写入的活跃会话,避免自动刷新反复解压
118
+ */
119
+ export function buildIndex(options = {}) {
120
+ assertZstdSupport();
121
+ const defaults = defaultPaths();
122
+ const sessionsRoot = options.sessionsRoot || defaults.sessionsRoot;
123
+ const indexDir = options.indexDir || defaults.indexDir;
124
+ const workbuddyProjectsDir = options.workbuddyProjectsDir || defaults.workbuddyProjectsDir;
125
+ const apps = String(options.apps || process.env.SESSION_INDEX_APPS || 'dsh,workbuddy')
126
+ .split(',')
127
+ .map((s) => s.trim())
128
+ .filter(Boolean);
129
+ const force = Boolean(options.force);
130
+ const quietMs = Math.max(0, Number(options.quietMs) || 0);
131
+ const warnings = [];
132
+ const started = Date.now();
133
+
134
+ if (!fs.existsSync(sessionsRoot) && apps.includes('dsh')) {
135
+ throw new Error(`DSH 会话目录不存在: ${sessionsRoot}`);
136
+ }
137
+ fs.mkdirSync(indexDir, { recursive: true });
138
+
139
+ const sources = [];
140
+ if (apps.includes('dsh')) sources.push({ app: 'dsh', files: findDshSessions(sessionsRoot) });
141
+ if (apps.includes('workbuddy')) {
142
+ sources.push({ app: 'workbuddy', files: findWorkbuddySessions(workbuddyProjectsDir) });
143
+ }
144
+
145
+ const meta = loadMeta(indexDir, sessionsRoot);
146
+ const cardsDoc = loadCards(indexDir);
147
+ const seen = new Set();
148
+ const stats = {
149
+ apps,
150
+ byApp: {},
151
+ sessionsFound: sources.reduce((n, s) => n + s.files.length, 0),
152
+ rebuilt: 0,
153
+ skipped: 0,
154
+ deferred: 0,
155
+ records: 0,
156
+ events: 0,
157
+ cardsBuilt: 0,
158
+ warnings,
159
+ };
160
+
161
+ for (const src of sources) {
162
+ for (const file of src.files) {
163
+ let stat;
164
+ try {
165
+ stat = fs.statSync(file);
166
+ } catch {
167
+ warnings.push(`无法读取 stat: ${file}`);
168
+ continue;
169
+ }
170
+ const sig = signatureOf(stat);
171
+ const candidateId =
172
+ src.app === 'dsh' ? path.basename(path.dirname(file)) : `wb:${path.basename(file, '.jsonl')}`;
173
+ const cached = meta.sessions[candidateId];
174
+ const cachedFile = cached && path.join(indexDir, cached.file);
175
+
176
+ // 静止期只用于「已有索引且正在写入」的会话,避免反复解压同一文件。
177
+ // 全新会话必须立即纳入:它写完后不会再有文件事件,若被跳过就永远进不了索引。
178
+ if (!force && quietMs > 0 && cached && Date.now() - stat.mtimeMs < quietMs) {
179
+ seen.add(candidateId);
180
+ stats.deferred += 1;
181
+ stats.records += cached.records || 0;
182
+ continue;
183
+ }
184
+
185
+ if (
186
+ !force &&
187
+ cached &&
188
+ cached.signature === sig &&
189
+ cached.sourcePath === file &&
190
+ cachedFile &&
191
+ fs.existsSync(cachedFile)
192
+ ) {
193
+ seen.add(candidateId);
194
+ stats.skipped += 1;
195
+ stats.records += cached.records || 0;
196
+ if (!cardsDoc.cards[candidateId]) {
197
+ try {
198
+ cardsDoc.cards[candidateId] = buildCard(cached, readRecords(indexDir, cached));
199
+ stats.cardsBuilt += 1;
200
+ } catch (err) {
201
+ warnings.push(`会话卡补建失败 ${candidateId}: ${err.message}`);
202
+ }
203
+ }
204
+ continue;
205
+ }
206
+
207
+ let parsed;
208
+ try {
209
+ parsed =
210
+ src.app === 'dsh'
211
+ ? readDshSession(file, (m) => warnings.push(m))
212
+ : readWorkbuddySession(file);
213
+ } catch (err) {
214
+ warnings.push(`解析失败,已跳过 ${file}: ${err.message}`);
215
+ continue;
216
+ }
217
+ for (const w of parsed.warnings || []) warnings.push(w);
218
+
219
+ const sessionId = parsed.header.id || candidateId;
220
+ const saved = saveRecords(indexDir, sessionId, parsed.records);
221
+ const sessionMeta = {
222
+ id: sessionId,
223
+ app: src.app,
224
+ sourceId: parsed.header.sourceId || sessionId,
225
+ workspace: parsed.header.cwd,
226
+ cwd: parsed.header.cwd,
227
+ title: parsed.header.title || deriveTitle(parsed.records),
228
+ createdAt: Math.round(parsed.header.createdAt || stat.birthtimeMs),
229
+ updatedAt: Math.round(parsed.header.updatedAt || stat.mtimeMs),
230
+ parentSession: parsed.header.parentSession || null,
231
+ sourcePath: file,
232
+ signature: sig,
233
+ sourceBytes: stat.size,
234
+ events: parsed.eventCount,
235
+ records: parsed.records.length,
236
+ indexBytes: saved.bytes,
237
+ file: saved.file,
238
+ };
239
+ meta.sessions[sessionId] = sessionMeta;
240
+ try {
241
+ cardsDoc.cards[sessionId] = buildCard(sessionMeta, parsed.records);
242
+ stats.cardsBuilt += 1;
243
+ } catch (err) {
244
+ warnings.push(`会话卡生成失败 ${sessionId}: ${err.message}`);
245
+ }
246
+
247
+ seen.add(sessionId);
248
+ if (sessionId !== candidateId && meta.sessions[candidateId]) {
249
+ delete meta.sessions[candidateId];
250
+ delete cardsDoc.cards[candidateId];
251
+ }
252
+ stats.rebuilt += 1;
253
+ stats.events += parsed.eventCount;
254
+ stats.records += parsed.records.length;
255
+ }
256
+ }
257
+
258
+ const orphans = Object.keys(meta.sessions).filter((id) => !seen.has(id));
259
+ for (const id of orphans) {
260
+ delete meta.sessions[id]; // 只从目录移除,不删除任何磁盘文件
261
+ delete cardsDoc.cards[id];
262
+ }
263
+ for (const s of Object.values(meta.sessions)) {
264
+ const app = s.app || 'dsh';
265
+ stats.byApp[app] = (stats.byApp[app] || 0) + 1;
266
+ }
267
+
268
+ meta.builtAt = new Date().toISOString();
269
+ meta.sourceRoot = sessionsRoot;
270
+ meta.indexDir = indexDir;
271
+ meta.apps = apps;
272
+ saveMeta(indexDir, meta);
273
+
274
+ cardsDoc.version = 1;
275
+ cardsDoc.builtAt = meta.builtAt;
276
+ saveCards(indexDir, cardsDoc);
277
+
278
+ stats.orphansRemovedFromMeta = orphans.length;
279
+ stats.sessionsIndexed = Object.keys(meta.sessions).length;
280
+ stats.cardsIndexed = Object.keys(cardsDoc.cards).length;
281
+ stats.indexBytes = indexSizeBytes(indexDir);
282
+ stats.elapsedMs = Date.now() - started;
283
+ stats.indexDir = indexDir;
284
+ stats.sourceRoot = sessionsRoot;
285
+ stats.workbuddyProjectsDir = workbuddyProjectsDir;
286
+ return stats;
287
+ }
288
+
289
+ const isCli =
290
+ process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
291
+
292
+ if (isCli) {
293
+ const opts = parseArgs(process.argv.slice(2));
294
+ try {
295
+ const result = buildIndex({
296
+ sessionsRoot: opts.sessions,
297
+ indexDir: opts.index,
298
+ apps: opts.apps,
299
+ force: opts.force,
300
+ quietMs: opts.quietMs,
301
+ });
302
+ if (opts.json) {
303
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
304
+ } else {
305
+ const mb = (n) => (n / 1024 / 1024).toFixed(2) + ' MB';
306
+ console.log(
307
+ `索引完成:会话 ${result.sessionsIndexed} 个 ${JSON.stringify(result.byApp)}` +
308
+ `(重建 ${result.rebuilt} / 跳过 ${result.skipped} / 延迟活跃 ${result.deferred}),记录 ${result.records} 条,` +
309
+ `会话卡 ${result.cardsIndexed} 张(新建 ${result.cardsBuilt}),` +
310
+ `索引体积 ${mb(result.indexBytes)},耗时 ${result.elapsedMs} ms`
311
+ );
312
+ if (result.warnings.length) console.log(`警告 ${result.warnings.length} 条:`, result.warnings.slice(0, 3));
313
+ console.log(`索引目录:${result.indexDir}`);
314
+ }
315
+ } catch (err) {
316
+ console.error('索引构建失败:' + err.message);
317
+ process.exitCode = 1;
318
+ }
319
+ }