dsh-shared-kb 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YG
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,242 @@
1
- # Temporary Holding Version
1
+ # dsh-shared-kb
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
+ DeepSeek Harness 知识库中枢:**指定一个物理地址,每个库一个独立子文件夹**,原始资料与派生分块都落在库里;自带 **SQLite FTS5 全文索引 + 向量语义检索**,用租约与 revision CAS 支持**多人多机同时读、写入与索引重建**。
4
+
5
+ - 包名:`dsh-shared-kb`
6
+ - 插件 id:`shared-kb`
7
+ - 工作台:`/shared-kb/`
8
+ - 工具前缀:`kb_*`
9
+
10
+ ---
11
+
12
+ ## 一、它解决什么
13
+
14
+ `dsh-knowledge-wiki` 这类知识库插件把「资料 → 分块 → 索引」放在**单机、单进程**的假设下:
15
+
16
+ - 索引和资料在同一个目录,两台机器同时用会互相破坏索引;
17
+ - 清单靠一把本机文件锁串行化,跨机器形同虚设;
18
+ - 「后写覆盖先写」没有任何检测,两个人同时改一个库会**静默丢数据**。
19
+
20
+ 本插件把这些假设逐条拆掉:
21
+
22
+ | 单机假设 | 本插件的做法 |
23
+ |---|---|
24
+ | 索引与资料同目录 | **资料在共享盘,索引在每台机器本地**(原因见第四节) |
25
+ | 本机文件锁足够 | **目录原子创建租约** + 心跳,跨机器可见、可回收 |
26
+ | 后写覆盖先写 | **revision 单调递增 + CAS**,冲突返回 `REV_CONFLICT` 而不是静默覆盖 |
27
+ | 一套向量模型 | provider 身份写进索引 meta,换模型自动整体重建,绝不混用 |
28
+ | 索引坏了要重建全库 | 每篇文档记 version,**增量补**,别人加的资料第一次检索就到 |
29
+
30
+ ---
31
+
32
+ ## 二、目录布局
33
+
34
+ `kbRoot` 指向任意物理地址(本地盘、已挂载盘符、UNC 共享、NAS 挂载点):
35
+
36
+ ```
37
+ <kbRoot>/
38
+ .hub/
39
+ hub.json 根标记 + schema 版本 + 归属信息
40
+ machines/<machineId>.json 每台机器一条心跳(诊断「现在谁在用」)
41
+ <库名>/
42
+ kb.json 库元数据(描述/只读/写入策略/默认检索参数)
43
+ manifest.json 文档清单:唯一真相,rev 单调递增,CAS 写入
44
+ events.jsonl 操作审计(只增不改,谁在何时改了什么)
45
+ schema.md 本库维护规范(人写,插件永不覆盖)
46
+ index.md 自动生成的目录
47
+ log.md 自动生成的操作日志
48
+ sources/ 原始资料(只读,导入后不再修改)
49
+ markdown/<docId>/
50
+ meta.json 单篇元数据
51
+ <docId>.md 合并版全文
52
+ part-0001.md … 分块(精读/喂模型用)
53
+ history/ 历史正文版本(覆盖前自动留)
54
+ notes/ 笔记 / 编译页(带 YAML frontmatter)
55
+ exports/ 导出产物
56
+ snapshots/ 库级快照
57
+ .locks/ 跨机器租约(<scope>.lease/owner.json)
58
+ ```
59
+
60
+ 索引**不在**库里:
61
+
62
+ ```
63
+ ~/.dsh/shared-kb/indexes/<根目录哈希>/kb-<库名>.sqlite
64
+ ```
65
+
66
+ 同一个物理根目录在每个使用者机器上各有一份索引,互不干扰。
67
+
68
+ ---
69
+
70
+ ## 三、多人同时用同一个库:三道防线
71
+
72
+ ### 3.1 租约(lease)—— 让并发写有序
73
+
74
+ `mkdir` 在所有主流文件系统(含 SMB/NFS)上是**原子**的:恰好一个调用方成功,其余 `EEXIST`。租约就是基于这个原语:
75
+
76
+ 1. `mkdir <库>/.locks/<scope>.lease` 成功者获得锁;
77
+ 2. 锁内写 `owner.json`(machine / pid / token / heartbeatAt);
78
+ 3. 持有者每 5 秒刷新 `heartbeatAt`;
79
+ 4. 释放时先比对 token 再删,防止「删掉别人刚抢到的锁」。
80
+
81
+ **回收判据分两类,不能混用**:
82
+
83
+ | 同机 | 异机 |
84
+ |---|---|
85
+ | PID 是硬证据:进程不在了,立刻可回收(需越过 300ms 建锁保护期) | 只能靠心跳:超过 `staleMs` 未刷新才认为失联 |
86
+
87
+ 为什么不用文件锁(flock / LockFileEx):SMB 上各实现语义不一,有的静默降级成无锁,出问题时表现为「两个进程都以为自己拿到了锁」——数据损坏且无迹可查。
88
+
89
+ ### 3.2 revision CAS —— 让「后写覆盖先写」不可能发生
90
+
91
+ 清单带 `rev`(整数单调递增,不用时间戳:多机时钟偏移会让时间戳倒退,无法比较先后)。
92
+
93
+ 每次写入可以声明「我基于哪一版」:
94
+
95
+ ```
96
+ expectedRev = 12 → 提交时清单 rev 若已变成 13,立刻返回 REV_CONFLICT
97
+ 并回传当前 rev 与冲突文档,让上层重读后再提交
98
+ ```
99
+
100
+ 不传 `expectedRev` 时,读-改-写在**同一把租约内**完成,等价于「自动取最新」。两条路都不允许后写静默覆盖先写。
101
+
102
+ ### 3.3 原子写重试 —— 让共享盘上的写入真的落地
103
+
104
+ 这是 Windows + 共享盘的必备件,不是可选项:
105
+
106
+ - POSIX 上 `rename(tmp, target)` 是覆盖式原子替换,基本不会失败;
107
+ - **Windows 上**目标文件若正被另一进程打开(杀毒扫描、预览、另一个进程在 read/stat),`rename` 返回 `EPERM` / `EACCES` / `EBUSY`。
108
+
109
+ 多进程同时写清单时这个窗口必然出现。裸 `rename` 的表现是「随机丢条目」——测试里真实复现过(8 个并发进程偶发只写入 6–7 篇)。因此所有覆盖式写入都走 `lib/core/atomic.js`:指数退避 + 抖动的有限重试,最终失败给出可诊断错误。
110
+
111
+ ---
112
+
113
+ ## 四、为什么索引不进共享盘
114
+
115
+ 在 SMB / UNC / NAS 上放一个多进程同时写的 SQLite 文件是灾难:
116
+
117
+ - SQLite 的 WAL 依赖共享内存映射与 `fcntl` 锁,SMB 实现普遍不支持或语义不一致;
118
+ - 典型后果:索引静默损坏、打不开、或被标记 malformed 后每次打开都要重建;
119
+ - 更糟的是**损坏是静默的**,表现为「检索结果变少」,而不是报错。
120
+
121
+ 所以:**资料在共享盘(唯一真相),索引在每台机器本地(可再生)**。
122
+
123
+ 好处不止是避免损坏:
124
+
125
+ - 索引坏了删掉重建,不影响任何人;
126
+ - 每台机器可以按自己的 CPU/内存决定要不要建向量索引;
127
+ - 别人在另一台机器导的资料,我这边第一次检索时按 `version` 增量补齐,不重建全库。
128
+
129
+ ---
130
+
131
+ ## 五、检索
132
+
133
+ ### 5.1 混合检索(默认)
134
+
135
+ | 通道 | 强项 | 弱项 |
136
+ |---|---|---|
137
+ | 全文 BM25 | 标准号、型号、条款号、专有名词的**精确**命中 | 同义改写抓不到 |
138
+ | 向量语义 | 同义改写、自然语言提问 | 编号/型号不敏感 |
139
+
140
+ 融合用 **RRF(Reciprocal Rank Fusion)**:只看排名不看分数,不需要把 BM25 的「越小越好」与余弦的「越大越好」凑到同一量纲,也不会因为某一路分数尺度不同而让另一路失效(k=60)。
141
+
142
+ 额外一层「字面加分」:命中查询里罕见 token 时给极小固定加分,用于处理 RRF 后的并列。
143
+
144
+ `mode` 可选 `hybrid`(默认)/ `fulltext` / `semantic`。
145
+
146
+ ### 5.2 中文分词
147
+
148
+ FTS5 的 `unicode61` 不认识中文(整段连续汉字算一个 token,搜「知识库」命中不了)。本插件的做法是**预分词后存进普通列**,而不是用 trigram:
149
+
150
+ - `Intl.Segmenter` 词级切分(Node 内置,无第三方词典)
151
+ - 汉字连续段额外产出**二元组**(解决「识库」这类子串查询)
152
+ - 英文词 + 去分隔符压紧形式(`GB/T` → `gbt`)
153
+ - 数字串数字面量(`19963.2` 可查)
154
+
155
+ 索引与查询复用同一函数,不会「索引里有、查不到」。
156
+
157
+ ### 5.3 语义 provider
158
+
159
+ | 模式 | 说明 |
160
+ |---|---|
161
+ | `auto`(默认) | 优先 onnx,不可用回退 builtin |
162
+ | `onnx` | `onnxruntime-node` + `@huggingface/tokenizers` 加载 BGE 类中文模型(可选 peer) |
163
+ | `remote` | OpenAI 兼容 `/v1/embeddings`(自备 key) |
164
+ | `builtin` | 零依赖哈希嵌入(character n-gram + feature hashing),无模型文件时的兜底 |
165
+
166
+ provider 名与维度写进索引 meta;换了模型或维度变化 → 索引**整体重建**,绝不把两套向量混在一个索引里。
167
+
168
+ ---
169
+
170
+ ## 六、工具
171
+
172
+ | 工具 | 用途 |
173
+ |---|---|
174
+ | `skb_status` | 根目录、索引位置、provider、机器心跳、各库索引是否落后、租约占用 |
175
+ | `skb_list` | 列库 / 列某库文档(过滤、分页、含回收站) |
176
+ | `skb_create` | 建库(自动生成目录骨架与 `schema.md` 模板) |
177
+ | `skb_import` | 导入文件或目录(pdf/docx/xlsx/pptx/epub/md/txt/csv/html),幂等 |
178
+ | `skb_query` | 混合检索,返回命中块(标题/章节/页码/片段)与按文档聚合视图 |
179
+ | `skb_read` | 精读:`outline` / 数字块 / `all`(直接读磁盘,不依赖索引) |
180
+ | `skb_note` | 写/更新笔记(同标题=更新,不另起「续」) |
181
+ | `skb_notes` | 列笔记 |
182
+ | `skb_doc` | 改元数据 / 软删 / 恢复 / 彻底删 / 历史版本 / 恢复版本 |
183
+ | `skb_kb` | 改库元数据 / 重命名 / 删库 / 快照 / 审计 / 刷新目录 / 租约管理 |
184
+ | `skb_reindex` | 增量同步或强制重建索引 |
185
+ | `skb_page` | PDF 按页渲染成 PNG(核对公式、图纸、版式) |
186
+
187
+ ---
188
+
189
+ ## 七、工作台
190
+
191
+ `/shared-kb/` 提供:库列表(含索引落后标记)、检索(可切模式)、文档列表(含回收站)、导入(服务器路径 / 上传)、笔记编辑、审计与租约查看、重建索引与快照。
192
+
193
+ 安全:所有请求过**信任围栏**(`webRuntime.trustedHosts`;拿不到列表时只放行回环),响应带 `nosniff` / `no-referrer` / `SAMEORIGIN`;静态文件路径做目录穿越校验。
194
+
195
+ ---
196
+
197
+ ## 八、配置
198
+
199
+ | 键 | 默认 | 说明 |
200
+ |---|---|---|
201
+ | `kbRoot` | `~/.dsh/shared-kb` | 知识库物理根目录(可指向共享盘) |
202
+ | `defaultKb` | `default` | 未指定 kb 时的默认库 |
203
+ | `semanticProvider` | `auto` | `auto` / `onnx` / `remote` / `builtin` |
204
+ | `semanticModelDir` | 空 | ONNX 模型目录 |
205
+ | `remoteBaseUrl` / `remoteApiKey` / `remoteModel` / `remoteDim` | 空 / 1024 | 远端 embedding |
206
+ | `builtinDim` | 512 | 兜底嵌入维度 |
207
+ | `partChars` / `overlapChars` | 1200 / 150 | 分块大小与重叠 |
208
+ | `indexCacheRoot` | 空 | 本机索引缓存根(默认 `~/.dsh/shared-kb/indexes/<hash>`) |
209
+ | `historyKeep` | 10 | 每篇保留多少历史版本 |
210
+ | `maxImportBytes` | 512 MiB | 单文件上限 |
211
+ | `autoIndex` | true | 导入后与检索前自动增量同步 |
212
+ | `webEnabled` | true | 是否挂载 `/shared-kb` |
213
+
214
+ ---
215
+
216
+ ## 九、测试
217
+
218
+ ```bash
219
+ node test/all.mjs # 全部套件
220
+ node test/smoke.mjs # 核心链路(建库/导入/幂等/检索/精读/笔记/CAS/租约/布局)
221
+ node test/tools.mjs # 工具层(12 个工具的真实接线与错误路径)
222
+ node test/concurrency.mjs # 多进程并发(真 fork 子进程,含 SIGKILL 回收租约)
223
+ node test/web.mjs # Web 层(真 http server,含信任围栏与目录穿越)
224
+ node test/syntax.mjs # 全量语法检查
225
+ ```
226
+
227
+ 多进程并发测试覆盖:8 进程同时导入、同文件并发导入幂等、同一 `expectedRev` 竞争、被杀进程的租约回收、跨进程检索可见性、两套独立索引共存。
228
+
229
+ ---
230
+
231
+ ## 十、已知边界
232
+
233
+ - **彻底正确性依赖 CAS,不依赖锁**:共享盘上不存在绝对正确的分布式锁,本插件的锁是「失败可见、可自愈、竞争窗口极小」,真正的写入正确性由 revision CAS 兜底。
234
+ - **向量检索是全表扫描**:知识库规模(几千到几万块)下毫秒级;超过约 20 万块建议分库,而不是硬扛。
235
+ - **OCR 不在本插件内**:PDF 只取文字层,扫描页会给出警告并可用 `skb_page` 查看原图;需要 OCR 请先用 `dsh-knowledge-wiki` 或外部工具把扫描件转成文字版。
236
+ - **`builtin` 嵌入不是真语义模型**:它是让检索链路在无模型时仍可用的兜底,语义能力有限;要真语义请配 onnx 或 remote。
237
+
238
+ ---
239
+
240
+ ## 许可
241
+
242
+ MIT
@@ -0,0 +1,11 @@
1
+ # dsh-shared-kb bundle patch。
2
+ #
3
+ # 只插入自己这一行,不覆盖任何官方或第三方插件。用户可在 profile 的
4
+ # cordis.patch.yml 里覆盖任意 config 键,但不要再加一个 id 为
5
+ # shared-kb 的 insert(loader id 重复会导致启动失败)。
6
+ - insert:
7
+ - id: shared-kb
8
+ name: dsh-shared-kb
9
+ config:
10
+ kbRoot: ~/.dsh/shared-kb
11
+ defaultKb: default
package/lib/client.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 客户端注入半:在会话侧栏/输入区挂一个「打开知识库中枢」入口。
3
+ *
4
+ * 说明:DSH 的 client 插件要由宿主 ModuleLoader 加载(window.__ModuleLoader__)。
5
+ * 这里只做最小可用:注册一个命令式入口,点击后新开 /shared-kb/ 页面。
6
+ * 不依赖 React,避免不同宿主版本的 react 版本冲突。
7
+ */
8
+ (() => {
9
+ "use strict";
10
+ const boot = typeof window !== "undefined" ? window.__DSH_BOOT__ : undefined;
11
+ const rows = boot && Array.isArray(boot.entries) ? boot.entries : [];
12
+ const known = new Set(rows.map((row) => row && row.id));
13
+ const CANDIDATES = ["shared-kb", "dsh-shared-kb"];
14
+ const id = CANDIDATES.find((candidate) => known.has(candidate)) || CANDIDATES[0];
15
+
16
+ if (typeof window === "undefined" || !window.__ModuleLoader__) return;
17
+
18
+ window.__ModuleLoader__.load({
19
+ id,
20
+ factory: () => {
21
+ const open = () => {
22
+ try {
23
+ window.open("/shared-kb/", "_blank", "noopener");
24
+ } catch {
25
+ /* 弹窗被拦时静默 */
26
+ }
27
+ };
28
+
29
+ /**
30
+ * 注册入口。宿主不同版本提供的注册方式不同,这里按能力探测,
31
+ * 全部失败也不抛错(工作台可以直接输 URL 访问)。
32
+ */
33
+ const register = (api) => {
34
+ if (!api) return;
35
+ if (typeof api.command === "function") {
36
+ api.command({
37
+ id: "shared-kb.open",
38
+ title: "打开知识库中枢",
39
+ run: open,
40
+ });
41
+ }
42
+ if (typeof api.slashCommand === "function") {
43
+ api.slashCommand({
44
+ name: "kb",
45
+ description: "打开知识库中枢(/shared-kb/)",
46
+ run: open,
47
+ });
48
+ }
49
+ };
50
+
51
+ return {
52
+ apply(ctx) {
53
+ register(ctx);
54
+ register(ctx?.commands);
55
+ register(ctx?.ui);
56
+ },
57
+ open,
58
+ };
59
+ },
60
+ });
61
+ })();
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 原子写 + 跨平台重试。
3
+ *
4
+ * 为什么单独抽一个模块:
5
+ *
6
+ * POSIX 上 rename(tmp, target) 是"覆盖式原子替换",基本不会失败(除了 EXDEV)。
7
+ * Windows 上不是:目标文件如果正被另一个进程以不带 FILE_SHARE_DELETE 的
8
+ * 方式打开(杀毒扫描、资源管理器预览、我们自己的另一个进程在 read/stat),
9
+ * rename 会返回 EPERM / EACCES / EBUSY。
10
+ *
11
+ * 知识库放在共享盘、多进程同时写清单时,这个窗口是必然存在的,
12
+ * 不是"偶发故障"。因此所有覆盖式写入都必须带有限重试 + 随机抖动,
13
+ * 并在最终失败时给出可诊断的错误(哪个文件、试了几次、最后一次什么错)。
14
+ *
15
+ * 重试策略:指数退避 + 抖动,总时长约 1–2 秒 —— 太短撑不过一次杀毒扫描,
16
+ * 太长会在真的死锁时让用户干等。
17
+ */
18
+ import { randomBytes } from "node:crypto";
19
+ import { rename, rm, writeFile } from "node:fs/promises";
20
+
21
+ /** Windows 上 rename 覆盖会被拒的错误码;POSIX 上这些基本不出现。 */
22
+ const TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY", "EEXIST"]);
23
+
24
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ function backoff(i) {
27
+ return Math.min(300, 8 * 2 ** Math.min(i, 6)) + Math.floor(Math.random() * 40);
28
+ }
29
+
30
+ /**
31
+ * 覆盖式原子写:先写同目录临时文件,再 rename 到目标。
32
+ *
33
+ * @param {string} target 目标路径
34
+ * @param {string|Buffer} data 内容
35
+ * @param {{attempts?: number, tmpSuffix?: string, encoding?: BufferEncoding}} [options]
36
+ */
37
+ export async function writeFileAtomic(target, data, options = {}) {
38
+ const attempts = Math.max(1, Number(options.attempts) || 12);
39
+ const suffix = options.tmpSuffix ?? `${process.pid}.${randomBytes(4).toString("hex")}`;
40
+ const tmp = `${target}.${suffix}.tmp`;
41
+ const writeOptions = typeof data === "string" ? { encoding: options.encoding ?? "utf8" } : undefined;
42
+
43
+ await writeFile(tmp, data, writeOptions);
44
+ let lastError = null;
45
+ for (let i = 0; i < attempts; i += 1) {
46
+ try {
47
+ await rename(tmp, target);
48
+ return { path: target, attempts: i + 1 };
49
+ } catch (error) {
50
+ lastError = error;
51
+ if (!TRANSIENT.has(error?.code)) break;
52
+ await sleep(backoff(i));
53
+ }
54
+ }
55
+
56
+ await rm(tmp, { force: true }).catch(() => {});
57
+ const wrapped = new Error(
58
+ `原子写覆盖失败(${attempts} 次重试后):${String(lastError?.code ?? "unknown")} ${target}` +
59
+ `;最后一次错误:${String(lastError?.message ?? lastError)}`
60
+ );
61
+ wrapped.code = lastError?.code ?? "ATOMIC_WRITE_FAILED";
62
+ wrapped.path = target;
63
+ wrapped.attempts = attempts;
64
+ throw wrapped;
65
+ }
66
+
67
+ /** 只做 rename 的覆盖(调用方已写好 tmp),带同样的重试。 */
68
+ export async function renameWithRetry(from, to, options = {}) {
69
+ const attempts = Math.max(1, Number(options.attempts) || 12);
70
+ let lastError = null;
71
+ for (let i = 0; i < attempts; i += 1) {
72
+ try {
73
+ await rename(from, to);
74
+ return { attempts: i + 1 };
75
+ } catch (error) {
76
+ lastError = error;
77
+ if (!TRANSIENT.has(error?.code)) break;
78
+ await sleep(backoff(i));
79
+ }
80
+ }
81
+ await rm(from, { force: true }).catch(() => {});
82
+ const wrapped = new Error(
83
+ `重命名失败(${attempts} 次重试后):${String(lastError?.code ?? "unknown")} ${from} -> ${to}`
84
+ );
85
+ wrapped.code = lastError?.code ?? "RENAME_FAILED";
86
+ throw wrapped;
87
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 错误类型:所有对外抛出的错误都带机器码,方便工具层与 HTTP 层统一映射。
3
+ * 不用 throw new Error("...") 混着抛,否则调用方只能靠字符串匹配。
4
+ */
5
+ export class HubError extends Error {
6
+ /**
7
+ * @param {string} code 机器码,例如 KB_NOT_FOUND / REV_CONFLICT / LEASE_HELD
8
+ * @param {string} message 给人看的中文说明
9
+ * @param {{ status?: number, details?: unknown, retryAfterMs?: number }} [options]
10
+ */
11
+ constructor(code, message, options = {}) {
12
+ super(message);
13
+ this.name = "HubError";
14
+ this.code = code;
15
+ this.status = options.status ?? 400;
16
+ this.details = options.details;
17
+ this.retryAfterMs = options.retryAfterMs;
18
+ }
19
+
20
+ toJSON() {
21
+ return {
22
+ code: this.code,
23
+ message: this.message,
24
+ ...(this.details === undefined ? {} : { details: this.details }),
25
+ ...(this.retryAfterMs === undefined ? {} : { retryAfterMs: this.retryAfterMs }),
26
+ };
27
+ }
28
+ }
29
+
30
+ export const notFound = (what) => new HubError("NOT_FOUND", `${what}不存在`, { status: 404 });
31
+ export const badRequest = (message, details) => new HubError("BAD_REQUEST", message, { status: 400, details });
32
+ export const conflict = (message, details) => new HubError("REV_CONFLICT", message, { status: 409, details });
33
+
34
+ /** 把任意异常压成 { code, message } —— 日志与工具输出都用这个口径。 */
35
+ export function describeError(error) {
36
+ if (error instanceof HubError) return error.toJSON();
37
+ const code = typeof error?.code === "string" && /^[A-Z_]+$/.test(error.code) ? error.code : "INTERNAL";
38
+ return { code, message: String(error?.message ?? error) };
39
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 中枢级配置与机器身份。
3
+ *
4
+ * 为什么需要「机器身份」:知识库可能放在网络共享盘上(SMB/UNC/NAS),
5
+ * 多台机器同时挂载同一个库。要知道「现在是谁在写」「这把锁是不是我一个
6
+ * 已经死掉的进程留下的」,必须能区分机器。PID 只在单机内唯一。
7
+ *
8
+ * hub.json 放在 <root>/.hub/ 下,是整个知识库根目录的元数据:
9
+ * - schemaVersion:布局版本,用于将来迁移
10
+ * - machines:机器注册表,每台机器一条心跳,用于诊断「谁在用这个库」
11
+ * - 同时也是「这是一个知识库根目录」的标记文件
12
+ */
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { hostname, platform, arch, release } from "node:os";
15
+ import { mkdir, readFile, readdir, unlink } from "node:fs/promises";
16
+ import { writeFileAtomic } from "./atomic.js";
17
+ import { join } from "node:path";
18
+ import { hubDir } from "./paths.js";
19
+
20
+ export const HUB_SCHEMA = 1;
21
+
22
+ /** 稳定的机器 id:hostname + platform + arch 派生,同一台机器恒定。 */
23
+ export function machineId() {
24
+ const seed = `${hostname()}|${platform()}|${arch()}`;
25
+ return createHash("sha256").update(seed).digest("hex").slice(0, 12);
26
+ }
27
+
28
+ export function machineLabel() {
29
+ let who = "unknown";
30
+ try {
31
+ who = String(process.env.USERNAME ?? process.env.USER ?? "user");
32
+ } catch {
33
+ /* ignore */
34
+ }
35
+ return `${hostname()} / ${platform()} ${release()} / ${who}`;
36
+ }
37
+
38
+ function hubFilePath(root) {
39
+ return join(hubDir(root), "hub.json");
40
+ }
41
+
42
+ function machinesDir(root) {
43
+ return join(hubDir(root), "machines");
44
+ }
45
+
46
+ /**
47
+ * 原子写 JSON。底层走 writeFileAtomic(带 Windows EPERM 重试):
48
+ * 共享盘上多进程同时刷新心跳/清单时 rename 覆盖会被占用打回,
49
+ * 这里不能自己实现一遍裸 rename。
50
+ */
51
+ export async function writeJsonAtomic(path, value) {
52
+ await writeFileAtomic(path, JSON.stringify(value, null, 2), { encoding: "utf8" });
53
+ }
54
+
55
+ async function readJsonSafe(path) {
56
+ try {
57
+ return JSON.parse(await readFile(path, "utf8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 确保知识库根目录存在并带上 .hub 标记。
65
+ * 返回 { root, created, hub }。
66
+ */
67
+ export async function ensureHub(root) {
68
+ const dir = hubDir(root);
69
+ await mkdir(dir, { recursive: true });
70
+ await mkdir(machinesDir(root), { recursive: true });
71
+ const path = hubFilePath(root);
72
+ const existing = await readJsonSafe(path);
73
+ if (existing && typeof existing === "object") {
74
+ if (!existing.schemaVersion || existing.schemaVersion < HUB_SCHEMA) {
75
+ const upgraded = { ...existing, schemaVersion: HUB_SCHEMA, upgradedAt: new Date().toISOString() };
76
+ await writeJsonAtomic(path, upgraded);
77
+ return { root, created: false, hub: upgraded };
78
+ }
79
+ return { root, created: false, hub: existing };
80
+ }
81
+ const hub = {
82
+ schemaVersion: HUB_SCHEMA,
83
+ createdAt: new Date().toISOString(),
84
+ // 放置根目录的「归属信息」只是展示用,不参与鉴权。
85
+ createdBy: { machine: machineId(), label: machineLabel() },
86
+ // 索引默认策略:本机缓存(不改的话索引永远不进共享盘)
87
+ indexLocation: "local",
88
+ };
89
+ await writeJsonAtomic(path, hub);
90
+ return { root, created: true, hub };
91
+ }
92
+
93
+ export async function readHub(root) {
94
+ return (await readJsonSafe(hubFilePath(root))) ?? null;
95
+ }
96
+
97
+ /** 心跳:写本机一条记录,供 skb_status 展示「现在有谁在用」。 */
98
+ export async function heartbeat(root, extra = {}) {
99
+ const dir = machinesDir(root);
100
+ await mkdir(dir, { recursive: true });
101
+ const id = machineId();
102
+ const path = join(dir, `${id}.json`);
103
+ const prev = (await readJsonSafe(path)) ?? {};
104
+ const seen = Number(prev.seenCount) || 0;
105
+ const record = {
106
+ machine: id,
107
+ label: machineLabel(),
108
+ hostname: hostname(),
109
+ platform: `${platform()} ${release()}`,
110
+ pid: process.pid,
111
+ firstSeenAt: prev.firstSeenAt ?? new Date().toISOString(),
112
+ lastSeenAt: new Date().toISOString(),
113
+ seenCount: seen + 1,
114
+ ...extra,
115
+ };
116
+ await writeJsonAtomic(path, record);
117
+ return record;
118
+ }
119
+
120
+ /** 列出已知机器;超过 staleMs 未心跳的标记为 stale(不删,保留历史)。 */
121
+ export async function listMachines(root, { staleMs = 10 * 60 * 1000 } = {}) {
122
+ const dir = machinesDir(root);
123
+ let names = [];
124
+ try {
125
+ names = await readdir(dir);
126
+ } catch {
127
+ return [];
128
+ }
129
+ const now = Date.now();
130
+ const out = [];
131
+ for (const name of names) {
132
+ if (!name.endsWith(".json")) continue;
133
+ const record = await readJsonSafe(join(dir, name));
134
+ if (!record) continue;
135
+ const age = now - Date.parse(record.lastSeenAt ?? 0);
136
+ out.push({ ...record, ageMs: Number.isFinite(age) ? age : null, stale: !Number.isFinite(age) || age > staleMs });
137
+ }
138
+ out.sort((a, b) => String(b.lastSeenAt).localeCompare(String(a.lastSeenAt)));
139
+ return out;
140
+ }
141
+
142
+ /** 清理本机自己的历史记录(仅用于测试与重置)。 */
143
+ export async function dropMachine(root, id = machineId()) {
144
+ await unlink(join(machinesDir(root), `${id}.json`)).catch(() => {});
145
+ }
146
+
147
+ /** 本机索引缓存根:与知识库根目录一一对应,用 root 路径的哈希隔离。 */
148
+ export function localIndexCacheRoot(home, root) {
149
+ const key = createHash("sha256").update(root, "utf8").digest("hex").slice(0, 16);
150
+ return join(home, "indexes", key);
151
+ }