opencode-wiki-historian 0.2.0 → 0.4.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/README.md CHANGED
@@ -6,12 +6,15 @@ opencode-historian 把 wiki.js 的读写、翻译、页型规范、迁移工具
6
6
 
7
7
  功能一览:
8
8
 
9
- * 10 个 `historian_*` 工具,覆盖创建、更新、追加、翻译、搜索、阅读、地图/时间轴、迁移、删除、移动
10
- * Skill v4 随插件自动注入(config hook),无需手动安装 skill 文件
11
- * G1 至 G5 页型契约,每种页型对应专属骨架模板
9
+ * 10 个 `historian_*` 工具,覆盖创建、更新、追加、翻译、搜索、阅读、地图/时间轴/维护报告、迁移、删除、移动
10
+ * Skill v5 随插件自动注入(config hook),无需手动安装 skill 文件
11
+ * G1 至 G6 页型契约,每种页型对应专属骨架模板(v4 新增 G6 操作手册/how-to)
12
12
  * 双语孪生页面(en/zh)自动翻译,翻译引擎可配置
13
- * 页面地图缓存、本地镜像与时间轴聚合视图
14
- * 开工前置查阅回路(reading loop,默认开)+ `/historian-capture` 会话留痕(默认关)
13
+ * 页面地图缓存、本地镜像、时间轴聚合视图与 `maintain` 维护策展报告(light/deep 两档)
14
+ * `options.sections` 路径前缀白名单强制生效:配置非空时越界写入硬拒(`ConfigError`,请求发出前拒绝);`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence` 系统路径恒豁免;默认 `[]` = 不限制
15
+ * `historian_search` 标签过滤:`tags` 1-5 个,`tagsMode` 默认 `all`(服务端 AND)、`any` 为客户端逐标签 fan-out 求并集
16
+ * 顶层 `home` 落地页解锁:仅豁免精确顶层 `home`,其余保留字(login/register/graphql/healthz/_assets/favicon)与嵌套 `home` 照旧拒绝
17
+ * 开工前置查阅回路(reading loop,默认关,双信号启用;v4 advisory:先查索引、update 优先、引页引日期、标注过期)+ `/historian-capture` 会话留痕(默认关)
15
18
  * 试点与评测均通过:pilot 7 页迁移 PASS,eval 7/7 场景首跑全过
16
19
 
17
20
  ## 史官宣言 / The Historian's Manifest
@@ -28,7 +31,7 @@ opencode-historian 把 wiki.js 的读写、翻译、页型规范、迁移工具
28
31
  | 法文 | 中立、非个人化、清晰、精确、易懂、有教学性六种品质;溢美词强制转成事实(用排名、奖项、销量替代"最伟大的"这类断言);耐久过滤:明天就会过时的内容不值得写 |
29
32
  | 俄文 | ХС/ИС 条目门槛(导言普通读者可懂、术语最少、不少于 10 行);二手权威来源优先于一手(АИ);每种观点按影响力分配篇幅、禁止虚假平衡(ВЕС);风格要求精确、简明、无歧义,同时保持内容饱和 |
30
33
 
31
- SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,过程/原因/后果/改进四段式正是 `/historian-capture` 命令的输出协议(`src/index.ts` 的 capture 模板),而"改进项要有负责人和可验证终态"落在了 G1 行动项表格里。
34
+ SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,先证据后结论的证据链四段式(`src/index.ts` 的 `CAPTURE_COMMAND_TEMPLATE` 模板)正是 `/historian-capture` 命令的正文契约:证据链→方法→修复手段→函数级实现,而"改进项要有负责人和可验证终态"落在了 G1 行动项表格里。
32
35
 
33
36
  把这些文化做成插件的直接动因,是一个机构记忆问题:这台机器由一个人加一群 AI agent 操作。会话结束,终端滚动条就没了;上下文压缩,细节就丢了。部署过什么、发生过什么事故、踩过什么坑、做过什么决定,三个月后人和 AI 都无从查起。史官要解决的,就是让这台机器的历史变得可检索。
34
37
 
@@ -42,18 +45,28 @@ SRE postmortem 文化单独值得点名:它把"事件"当一等文档对待,
42
45
 
43
46
  ### 作用 / What it does
44
47
 
45
- 五柱页型契约,加上读写两条自动化回路:
48
+ 六柱页型契约,加上读写两条自动化回路:
46
49
 
47
50
  | 能力 | 机制 | 落点 |
48
51
  |---|---|---|
49
- | 五柱页型 | G1 事件复盘 / G2 对比选型 / G3 清单索引 / G4 概念原理 / G5 现状账本,写前声明页型,套固定骨架、过来源列检查 | `src/templates/genres.ts`,skill Phase 1.5 |
50
- | 双视图 | 地图视图(en/zh 对应关系,Locale/Twin 列)+ 时间轴视图(ISO 周分组,支持 `days` 窗口与 `path` 前缀过滤,人读周表 + 机读 weeks JSON | `historian_map` 的 `action:'show'` / `action:'timeline'` |
52
+ | 六柱页型 | G1 事件复盘 / G2 对比选型 / G3 清单索引 / G4 概念原理 / G5 现状账本 / G6 操作手册,写前声明页型,套固定骨架、过来源列检查 | `src/templates/genres.ts`,skill Phase 1.5 |
53
+ | 三视图 | 地图视图(en/zh 对应关系,Locale/Twin 列)+ 时间轴视图(ISO 周分组,支持 `days` 窗口与 `path` 前缀过滤,人读周表 + 机读 weeks JSON)+ 维护策展报告(`action:'maintain'`:双语缺口、近似重复标题、过期、孤儿候选、标签词表、章节分布;light 档只扫地图行加每 locale 一次 `pages.list`,`deep:true` 逐页读正文补新鲜度标记与 Redirect 存根识别) | `historian_map` 的 `action:'show'` / `'timeline'` / `'maintain'` |
51
54
  | 迁移与评分门禁 | 存量页按骨架重排:dry-run 评分在前,`apply=true` 自动 pre-image 备份;每页写入后过 10 项自检门 | `historian_migrate`,`selfReviewChecklist()` |
52
- | 前置查阅回路 | 向每次请求的 system 提示注入"先查 wiki"指令:动这台机器的部署/历史/坑/决定之前先 `historian_search`、查 timeline、核对 G5 卡的核实日期,引用查过的页面 URL | `readingLoop` 选项(默认 `true`),`src/index.ts` 的 `experimental.chat.system.transform` 钩子 |
55
+ | 前置查阅回路 | 向每次请求的 system 提示注入"先查 wiki"指令(单块合并:追加到最后一个 system 块,绝不产生第二条 system 消息;双信号门控:选项与本机哨兵文件同时到位才注入):v4 advisory 四条——先读 `wiki-index` / `historian_map show` 定位再 `historian_search`(index-first);update 优先于 create;引用页面时带 URL 与日期;发现矛盾/过期在页内标注(supersede 或复核章)而非静默覆盖,G5 账本逐行核对「上次核实于」 | `readingLoop` 选项(默认 false,开启需配置+哨兵双确认)+ 哨兵文件 `~/.config/opencode/historian-reading-loop.json`,`src/index.ts` 的 `experimental.chat.system.transform` 钩子 |
53
56
  | 主动留痕 | `/historian-capture` 命令把当前会话总结成 G1 事件页;开启 `capture.enabled` 后额外在会话空闲时弹一次提醒,仅提醒,绝不自动写页 | `src/index.ts` 的 `config` / `event` 钩子 |
54
57
 
55
58
  G5 现状卡回答"现在跑着什么",timeline 回答"最近两周变了什么"。比如问"`service-a` 现在监听哪个端口",应当命中现状账本里的一行(形如 `example.com:8000`,带上次核实日期与验证命令),而不是某次会话的聊天记录。这两样合起来,wiki 才从文档堆变成可查询的运维账本。
56
59
 
60
+ 知识库要同时满足四种诉求——档案、手册、百科、知识库(速查)。六柱页型加索引层按层分工:
61
+
62
+ | 层诉求 | 回答的问题 | 对应页型 | 承载机制 |
63
+ |---|---|---|---|
64
+ | 档案 | 发生过什么、现在跑着什么 | G1 事件复盘 + G5 现状账本 | G1 时间线带来源列;G5 每行带「上次核实于」+ 验证命令 |
65
+ | 手册 | 怎么把一件事做完 | G6 操作手册 | 目标句式标题、步骤三段(动作+预期结果+失败处置)、上次核实+复核周期 |
66
+ | 百科 | 它是什么、为什么 | G4 概念原理 | 概念定义 → 图示 → 示例 |
67
+ | 知识库(速查) | 该选哪个、去哪查 | G2 对比选型 + G3 清单索引 | 对比表带来源列;可勾选检查项 |
68
+ | 索引层 | 从哪读起 | ——(不是页型) | `wiki-index` 人策展目录 + `historian_map` 的 show/timeline/maintain |
69
+
57
70
  ### 使用方式 / Usage
58
71
 
59
72
  端到端最短路径(适配你自己 wiki 的六步完整指南见 `skills/historian/references/adapting-your-own-wiki.md`):
@@ -74,8 +87,19 @@ G5 现状卡回答"现在跑着什么",timeline 回答"最近两周变了什
74
87
  > `service-a` 今天 OOM 重启,根因是缓存没设上限,已加告警,记下来。
75
88
 
76
89
  → 分诊为事件复盘,声明 G1 → `historian_page_create`(`genre: "G1"`)→ 回报 `http://<your-wiki>:3000/team-notes/<slug>` 与它的 `/zh/` 孪生页。
77
- 4. **检索与整理**:`historian_search` 按主题查;`historian_map` 的 `show` 看双语地图、`timeline`(可选 `days` / `path`)看最近变动;存量页不合规用 `historian_migrate` 先 dry-run 再 apply。
78
- 5. **开关**:严格 OpenAI 兼容后端(如 vLLM,会拒绝多条 system 消息)把 `"readingLoop": false` 关掉;想要空闲留痕提醒就 `"capture": { "enabled": true }`,`/historian-capture` 命令本身与开关无关、始终注册。
90
+ 4. **检索与整理**:`historian_search` 按主题查,可按标签过滤(`tags` 1-5 个,`tagsMode` 默认 `all` 即服务端 AND,`any` 为客户端逐标签 fan-out 并集);`historian_map` 的 `show` 看双语地图、`timeline`(可选 `days` / `path`)看最近变动、`maintain`(可选 `deep:true`)出维护策展报告;存量页不合规用 `historian_migrate` 先 dry-run 再 apply。
91
+ 5. **开关**:reading loop 默认 false,开启需配置+哨兵双确认,两步缺一不可:
92
+
93
+ 1. 插件二元组第二参数写 `"readingLoop": true`:`["opencode-wiki-historian", { "readingLoop": true }]`
94
+ 2. 人工写入本机哨兵文件(agent 不能自我启用):
95
+
96
+ ```bash
97
+ cat > ~/.config/opencode/historian-reading-loop.json <<'EOF'
98
+ {"version":1,"confirmed":true}
99
+ EOF
100
+ ```
101
+
102
+ 任一信号缺失即不注入;配置已开而哨兵缺失时,插件加载期会打一条提示(给出哨兵路径与内容),不会静默失灵。删除哨兵文件即刻回退,无需改配置。想要空闲留痕提醒就 `"capture": { "enabled": true }`,`/historian-capture` 命令本身与开关无关、始终注册。
79
103
 
80
104
  ### 解耦声明 / Decoupling
81
105
 
@@ -131,7 +155,7 @@ opencode run --command historian --message "historian_map show"
131
155
 
132
156
  如果工具列表中出现 `historian_page_create` 等 10 个工具,安装成功。
133
157
 
134
- > **升级提示**:如果你之前使用过 historian v2 的扁平 skill 文件(如 `~/.config/opencode/skills/historian.md`),需要先重命名为 `historian.md.v2-disabled` 或移到别处。插件通过 config hook 自动注入 v4 skill,两个同名 skill 不能共存。
158
+ > **升级提示**:如果你之前使用过 historian v2 的扁平 skill 文件(如 `~/.config/opencode/skills/historian.md`),需要先重命名为 `historian.md.v2-disabled` 或移到别处。插件通过 config hook 自动注入 v5 skill,两个同名 skill 不能共存。
135
159
 
136
160
  ## 配置 / Configuration
137
161
 
@@ -168,14 +192,14 @@ opencode run --command historian --message "historian_map show"
168
192
  | `translate.model` | string | `qwen3.7-plus` | 翻译模型 |
169
193
  | `translate.apiKey` | string | 见下方链 | 翻译 API 密钥 |
170
194
  | `translate.providerKey` | string | 未配置 | jsonc 兜底腿读取的 provider 名;须显式设置才会启用该腿 |
171
- | `sections` | string[] | `[]`(不限制) | 插件可操作的 wiki 路径前缀白名单 |
195
+ | `sections` | string[] | `[]`(不限制) | 写入路径前缀白名单。v4 起强制生效:非空时 create/update/append/delete/move(目标路径) 在发出请求前做 `sectionGuard` 检查,越界返回 `ConfigError`;`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence` 恒豁免;按首路径段匹配、区分大小写 |
172
196
  | `locales` | string[] | `["en", "zh"]` | 启用的语言列表 |
173
- | `readingLoop` | boolean | `true` | 向每次请求注入"先查 wiki"的开工前置查阅 advisory;拒绝多条 system 消息的严格 OpenAI 兼容后端(如 vLLM)须设 `false` |
197
+ | `readingLoop` | boolean | `false` | 开工前置查阅 advisory,向每次请求注入"先查 wiki"提示;默认 false,true 需配置+哨兵双确认(见「使用方式」开关步骤);单块合并追加到最后一个 system 块,绝不产生第二条 system 消息,vLLM 等拒绝多条 system 的严格后端同样安全 |
174
198
  | `capture.enabled` | boolean | `false` | 开启后会话空闲时弹一次 `/historian-capture` 留痕提醒;仅提醒,不自动写页 |
175
199
 
176
200
  `translate.endpoint` 解析链(优先级从高到低):`translate.endpoint` 选项 → 环境变量 `HISTORIAN_TRANSLATE_ENDPOINT` → 未配置。包内**不**内置任何网关地址;未配置时翻译调用直接以 `translate.endpoint not configured` 失败(见降级行为)。
177
201
 
178
- `sections` 默认为空列表 = 不限制路径前缀(任意合法路径可写,实际权限由 wiki.js token 的 page rules 决定)。按机器通过选项传入白名单,例如 `["team-notes", "infra", "ops"]`。
202
+ `sections` 默认为空列表 = 不限制路径前缀(任意合法路径可写,实际权限由 wiki.js token 的 page rules 决定)。按机器通过选项传入白名单,例如 `["team-notes", "infra", "ops"]`。传入非空列表即强制生效(v4):五个写入工具先过 `sectionGuard`(`src/tools/shared.ts`),不在白名单内的路径直接以 `ConfigError` 拒绝、不发任何写请求;报错信息会点名越界的首段并提示把它加进 `sections`。两个细节:匹配按首路径段、区分大小写,`doc` 授权 `doc` 与 `doc/x` 但不授权 `docs/x`;配置项首尾斜杠可省。`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence` 是插件自记账与索引胶水所需,恒豁免。
179
203
 
180
204
  ### API Key 获取优先级
181
205
 
@@ -196,6 +220,13 @@ opencode run --command historian --message "historian_map show"
196
220
 
197
221
  key 缺失时 `ConfigError` 记录一次日志,插件工具全部禁用,opencode 正常启动不受影响。`translate.endpoint` 未配置时双语孪生功能降级为 pending 状态(`twinReason: 'translate.endpoint not configured — ...'`),创建页面只写入请求 locale 的内容,不会发起任何翻译网络请求。
198
222
 
223
+ ### 已知限制
224
+
225
+ | 现象 | 定性 | 说明 |
226
+ |---|---|---|
227
+ | 裸配置未传 `translate` 选项(三段 key 链 `translate.apiKey` → `DASHSCOPE_API_KEY` → `translate.providerKey` 全缺)时插件工具全部禁用 | by design | 翻译腿是写操作的前提,缺 key 时宁可整体禁用也不静默半成品;配置按上方选项全表补齐即恢复。缺配置时的部分降级(工具照常注册、仅翻译调用失败)在议 |
228
+ | 自进化驱动(readingLoop + capture)默认关闭,作者本机也尚未点亮 | 状态如实 | 两个开关缺省均为 `false`(`src/config.ts:100` `DEFAULT_READING_LOOP`、`src/config.ts:102` `DEFAULT_CAPTURE_ENABLED`),readingLoop 还需配置+哨兵双确认。v0.4.0 发布仍待人工 2FA 步骤(`npm publish` 需 OTP),本机启用要等发布落地后进行;回路代码与测试已就绪,但在真正点亮之前本文档不假装它在跑 |
229
+
199
230
  ## wiki.js 前置检查 / Prerequisites
200
231
 
201
232
  安装插件前,确认 wiki.js 实例已完成以下配置。本插件不自动执行任何管理端操作。
@@ -246,11 +277,13 @@ zh: http://<host>/zh/ops/example
246
277
 
247
278
  **时间轴视图**:`historian_map action:'timeline'` 把镜像行按 ISO 周分组(可选 `days` 窗口与 `path` 前缀过滤),输出人读周表 + 机读 `weeks` JSON,回答"最近哪些页面变过"。
248
279
 
280
+ **维护视图**:`historian_map action:'maintain'` 输出策展报告——双语缺口、近似重复标题、过期页面、孤儿/扩散候选、标签词表、章节分布。默认 light 档只扫地图行加每个 locale 一次 `pages.list`;`deep:true` 逐页读正文,补新鲜度标记与 Redirect 存根识别。
281
+
249
282
  ## 写作质量体系 / Quality System
250
283
 
251
- ### G1 至 G5 页型
284
+ ### G1 至 G6 页型
252
285
 
253
- 插件根据内容形态把每页归入五种页型之一,每种有专属骨架模板:
286
+ 插件根据内容形态把每页归入六种页型之一,每种有专属骨架模板:
254
287
 
255
288
  | 页型 | 用途 | 骨架结构 |
256
289
  |---|---|---|
@@ -259,10 +292,11 @@ zh: http://<host>/zh/ops/example
259
292
  | G3 清单 | 操作步骤、检查项 | 可勾选的检查项列表 |
260
293
  | G4 概念 | 架构说明、原理讲解 | 概念定义 → 图示 → 示例 |
261
294
  | G5 现状账本 | 此刻的部署/运行态,回答"现在跑着什么" | 状态块 → 部署物清单(每行带「上次核实于」+ 验证命令)→ 失效策略,禁止叙事正文 |
295
+ | G6 操作手册 | how-to,回答"怎么把一件事做完",目标句式标题 | 前置条件 → 操作步骤(每步含预期结果+失败处置)→ 回退 → 元数据表(上次核实 + 复核周期)→ 相关页面 |
262
296
 
263
297
  ### 10 项自检门
264
298
 
265
- 每页写入后过一遍自检清单(源码 `src/templates/genres.ts` `selfReviewChecklist()`,参考文件 `skills/historian/references/rules.md` + `genres.md`)。dry-run 阶段评 1–8,apply 后评 9–10。genre-specific 条目对不匹配的页型记 N/A=PASS;G5 页的第 4–6 项换成账本变体(`G5_CHECKLIST_VARIANTS`):
299
+ 每页写入后过一遍自检清单(源码 `src/templates/genres.ts` `selfReviewChecklist()`,参考文件 `skills/historian/references/rules.md` + `genres.md`)。dry-run 阶段评 1–8,apply 后评 9–10。genre-specific 条目对不匹配的页型记 N/A=PASS;G5 页的第 4–6 项换成账本变体(`G5_CHECKLIST_VARIANTS`),G6 页换成手册变体(`G6_CHECKLIST_VARIANTS`):第 4 项要求标题是目标句式 "How to X" / "如何/怎么做X",第 5 项要求每条操作步骤三段(动作 + 预期结果 + 失败处置),第 6 项要求元数据表含「上次核实」与「复核周期」行:
266
300
 
267
301
  1. **导言占比 10–15%**:导言 ≈ 正文的 10–15%,每个重要小节在导言至少占一句
268
302
  2. **句长上限**:中文句 ≤20 字、英文句 ≤25 词
@@ -279,6 +313,26 @@ zh: http://<host>/zh/ops/example
279
313
 
280
314
  更细的 20 条写作规则散布在 `skills/historian/references/` 目录下各参考文件中,agent 加载 skill 时自动读取。
281
315
 
316
+ ## 前台 / 后台 / 证据三层 / Three Content Tiers
317
+
318
+ wiki 内容按读者分三层,工具按层执行不同语义(`historian_page_create` 的 `tier` 参数):
319
+
320
+ | 层 | 位置 | 职责 |
321
+ |---|---|---|
322
+ | 前台 (front) | 主题章节的 G1-G6 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容与链接 |
323
+ | 后台 (backstage) | `_meta/` 页 + 本地镜像文件 | 机器记账:page-map 缓存页、迁移 checkpoint、reading loop 哨兵文件;不参与人读正文 |
324
+ | 证据 (evidence) | `_evidence/` | 超 10 行原始件(日志、转写、大 diff)的归宿:单语 en、不发布(匿名访问 404 是 by design),人类页面只链接不复制 |
325
+
326
+ 用法示例:
327
+
328
+ ```jsonc
329
+ // 大段原始材料先落证据页,再在人读页附录里给链接
330
+ historian_page_create({ path: "_evidence/<topic>--<yyyymmdd>", tier: "evidence", content: "<原始件全文>" })
331
+ ```
332
+
333
+ - **软提醒语义(soft advisory)**:写前台页时若内容含超过 30 行的围栏代码块,工具结果附一条 `advisory`,提示把原始件搬到 `_evidence/` 页、正文改放决定性摘录(每段 ≤10 行)+ 证据页链接 + 外部链接(commit/PR/告警)。提醒归提醒,写入永不阻断;证据层页自身不跑这项检查。
334
+ - **镜像与快照页分工**:本地镜像 `~/.config/opencode/historian-map.json` 是活查询的唯一来源(`historian_map show` 直接读它);`_meta/page-map` wiki 页是审计账本,`historian_map refresh` 每次提交一个新修订,wiki 的页面历史即全库变更时间线。
335
+
282
336
  ## wiki.js 用法与定制化 / Usage Guide
283
337
 
284
338
  ### Markdown 速查
@@ -348,7 +402,7 @@ wiki.js GraphQL API 有 9 个常见陷阱。插件在内部处理了每一个(
348
402
 
349
403
  1. 重命名旧 skill 文件:`mv ~/.config/opencode/skills/historian.md ~/.config/opencode/skills/historian.md.v2-disabled`
350
404
  2. 在 `opencode.json[c]` 的 `plugin` 数组中添加 `opencode-wiki-historian`
351
- 3. 重启 opencode,`/historian` 命令可用即表示 v4 skill 已注入
405
+ 3. 重启 opencode,`/historian` 命令可用即表示 v5 skill 已注入
352
406
 
353
407
  ## 运维 / Operations
354
408
 
@@ -370,7 +424,7 @@ harness repo(与本插件仓库同工作区)提供 7 个行为验收场景
370
424
 
371
425
  ```bash
372
426
  npm run build # tsc 编译到 dist/
373
- npm test # vitest run(331 tests, 15 files)
427
+ npm test # vitest run(493 tests, 20 files)
374
428
  npm pack --dry-run # 检查打包文件列表
375
429
  ```
376
430
 
@@ -4,6 +4,7 @@
4
4
  * Kept out of map.ts to hold both modules under the 250 pure-LOC ceiling.
5
5
  */
6
6
  import { classifyGenre } from './templates/genres.js';
7
+ import { INTERNAL_NAMESPACES } from './tools/shared.js';
7
8
  const DAY_MS = 86_400_000;
8
9
  const WEEK_MS = 7 * DAY_MS;
9
10
  /** ISO-8601 week key: the Thursday inside the row's Monday-start week fixes
@@ -42,7 +43,12 @@ export function buildChronology(rows, opts) {
42
43
  const cutoff = opts?.days === undefined
43
44
  ? Number.NEGATIVE_INFINITY
44
45
  : (opts.now ?? new Date()).getTime() - opts.days * DAY_MS;
46
+ // Machine-namespace rows (INTERNAL_NAMESPACES) are dropped before grouping:
47
+ // the ledger page and evidence raw material are not wiki content, and their
48
+ // churn would drown the human timeline. All other namespaces (incl.
49
+ // `_sandbox`) stay visible.
45
50
  const kept = rows
51
+ .filter((r) => !INTERNAL_NAMESPACES.includes(r.path.split('/')[0]))
46
52
  .map((r) => ({ r, t: Date.parse(r.updatedAt) }))
47
53
  .filter((x) => !Number.isNaN(x.t) && x.t >= cutoff)
48
54
  .sort((a, b) => b.t - a.t ||
package/dist/config.d.ts CHANGED
@@ -37,9 +37,12 @@ export interface HistorianOptions {
37
37
  * real authorization gate. Consumers must treat [] as allow-any. */
38
38
  readonly sections: readonly string[];
39
39
  readonly locales: readonly string[];
40
- /** v2 reading-loop gate: true (the shipped default) makes the plugin push a
41
- * consult-the-wiki advisory block into every chat request's system array via
42
- * the experimental.chat.system.transform hook. false = hook is a pure no-op. */
40
+ /** v3 reading-loop gate (double signal): false by default. The plugin pushes
41
+ * a consult-the-wiki advisory block into every chat request's system array
42
+ * via the experimental.chat.system.transform hook ONLY when this option is
43
+ * true AND the local confirmation sentinel agrees (src/loop-state.ts:
44
+ * ~/.config/opencode/historian-reading-loop.json, written by hand).
45
+ * 默认 false,true 需配置+哨兵双确认;either signal missing = pure no-op. */
43
46
  readonly readingLoop: boolean;
44
47
  /** v2 capture gate: reminder-only. When enabled the event hook fires one
45
48
  * TUI toast per idle session nudging the agent/user to run the
@@ -79,8 +82,9 @@ export declare const DEFAULT_TRANSLATE_MODEL = "qwen3.7-plus";
79
82
  /** Empty = no path-prefix restriction (see HistorianOptions.sections). */
80
83
  export declare const DEFAULT_SECTIONS: readonly string[];
81
84
  export declare const DEFAULT_LOCALES: readonly ["en", "zh"];
82
- /** Reading loop is on unless explicitly disabled (plan v2 todo 8). */
83
- export declare const DEFAULT_READING_LOOP = true;
85
+ /** Reading loop is opt-in: activated only when configured true AND the local
86
+ * sentinel confirms it (plan v3 todo 1; see src/loop-state.ts). */
87
+ export declare const DEFAULT_READING_LOOP = false;
84
88
  /** Capture reminders are opt-in (plan v2 todo 9). */
85
89
  export declare const DEFAULT_CAPTURE_ENABLED = false;
86
90
  /**
package/dist/config.js CHANGED
@@ -27,8 +27,9 @@ export const DEFAULT_TRANSLATE_MODEL = 'qwen3.7-plus';
27
27
  /** Empty = no path-prefix restriction (see HistorianOptions.sections). */
28
28
  export const DEFAULT_SECTIONS = [];
29
29
  export const DEFAULT_LOCALES = ['en', 'zh'];
30
- /** Reading loop is on unless explicitly disabled (plan v2 todo 8). */
31
- export const DEFAULT_READING_LOOP = true;
30
+ /** Reading loop is opt-in: activated only when configured true AND the local
31
+ * sentinel confirms it (plan v3 todo 1; see src/loop-state.ts). */
32
+ export const DEFAULT_READING_LOOP = false;
32
33
  /** Capture reminders are opt-in (plan v2 todo 9). */
33
34
  export const DEFAULT_CAPTURE_ENABLED = false;
34
35
  // --- Resolution -------------------------------------------------------------
package/dist/index.d.ts CHANGED
@@ -8,8 +8,11 @@
8
8
  * - config: mutates cfg.skills.paths to ship the bundled skills/ directory
9
9
  * and registers the /historian-capture command (todo 9)
10
10
  * - tool: 10 historian_* tools wired by buildTools(opts)
11
- * - experimental.chat.system.transform: pushes the historian-first reading
12
- * loop advisory onto output.system[] (gated by opts.readingLoop)
11
+ * - experimental.chat.system.transform: merges the historian-first reading
12
+ * loop advisory into the LAST system block (single-block-safe append — a
13
+ * second entry is never added). v3 double gate: 默认 false,true 需配置+哨兵双确认.
14
+ * Both the readingLoop option and the on-machine confirmation sentinel
15
+ * (src/loop-state.ts, re-read per request, never cached) must pass.
13
16
  * - event: on session.idle emits ONE capture reminder toast per session
14
17
  * (gated by opts.capture.enabled; reminder-only — the page write happens
15
18
  * through /historian-capture -> historian_page_create, never here)
package/dist/index.js CHANGED
@@ -8,8 +8,11 @@
8
8
  * - config: mutates cfg.skills.paths to ship the bundled skills/ directory
9
9
  * and registers the /historian-capture command (todo 9)
10
10
  * - tool: 10 historian_* tools wired by buildTools(opts)
11
- * - experimental.chat.system.transform: pushes the historian-first reading
12
- * loop advisory onto output.system[] (gated by opts.readingLoop)
11
+ * - experimental.chat.system.transform: merges the historian-first reading
12
+ * loop advisory into the LAST system block (single-block-safe append — a
13
+ * second entry is never added). v3 double gate: 默认 false,true 需配置+哨兵双确认.
14
+ * Both the readingLoop option and the on-machine confirmation sentinel
15
+ * (src/loop-state.ts, re-read per request, never cached) must pass.
13
16
  * - event: on session.idle emits ONE capture reminder toast per session
14
17
  * (gated by opts.capture.enabled; reminder-only — the page write happens
15
18
  * through /historian-capture -> historian_page_create, never here)
@@ -22,7 +25,9 @@
22
25
  * contract.
23
26
  */
24
27
  import { fileURLToPath } from 'url';
28
+ import { homedir } from 'node:os';
25
29
  import { resolveOptions } from './config.js';
30
+ import { isReadingLoopConfirmed, loopStatePath } from './loop-state.js';
26
31
  import { buildTools } from './tools.js';
27
32
  /** Resolve the absolute path to the bundled skills/ directory. Uses
28
33
  * import.meta.url so it works whether loaded from dist/ (compiled) or src/
@@ -35,28 +40,47 @@ const skillsDir = fileURLToPath(new URL('../skills/', import.meta.url));
35
40
  function unique(items) {
36
41
  return [...new Set(items)];
37
42
  }
38
- /** Reading-loop advisory (plan v2 todo 8): the machine wiki is the
39
- * authoritative institutional memory; consult it before acting, cite URLs.
40
- * Shipped text generic wording only (privacy-audit scans dist). */
43
+ /** Reading-loop advisory (plan v4 todo 8 / D6): the machine wiki is the
44
+ * authoritative institutional memory; consult it index-first before acting,
45
+ * prefer updating over duplicating, cite pages+dates, mark staleness.
46
+ * Hard budget ≤8 lines (context-rot). Shipped text — generic wording only
47
+ * (privacy-audit scans dist). */
41
48
  const READING_LOOP_ADVISORY = [
42
49
  'You have a historian: a wiki.js knowledge base acting as this machine\'s authoritative institutional memory.',
43
- 'Before doing work that touches this machine\'s deployments, history, pitfalls, or decisions, consult it first:',
44
- '- historian_search by topic for relevant pages; historian_map action:"timeline" for what changed recently;',
45
- '- G5 current-state ledger pages answer "what is deployed/running now" check each row\'s verified date before trusting it.',
46
- 'Cite the wiki page URLs you relied on. If you learn something new worth keeping, offer to record it as a page.',
50
+ 'Before doing work that touches this machine\'s deployments, history, pitfalls, or decisions, consult it index-first:',
51
+ '- Read the curated map before blind search: historian_read wiki-index or historian_map action:"show", then historian_search by topic;',
52
+ '- PREFER UPDATE over CREATE: if a page may already exist, historian_read it first, then historian_page_update rather than creating a duplicate;',
53
+ '- CITE evidence: name the wiki page URL and its date whenever you quote it;',
54
+ '- MARK stale rows instead of silently overwriting: supersede or review-stamp contradictions (G5 ledger: check each row\'s verified date).',
55
+ 'If you learn something new worth keeping, offer to record it as a page.',
47
56
  ].join('\n');
48
- /** /historian-capture command (plan v2 todo 9): the always-available manual
49
- * path from "notable session" to "G1 event page" — registered regardless of
50
- * capture.enabled; the enabled-gated toast only nudges toward it. Agent-facing
51
- * instruction text, generic wording only (ships in the tarball). */
57
+ /** /historian-capture command (plan v2 todo 9, upgraded by v4 todo-10 / D2+D3):
58
+ * the always-available manual path from "notable session" to "G1 event page" —
59
+ * trigger list, 四段式 evidence-chain body contract, evidence split, and the
60
+ * draft→review→Active publish flow are the drive surface (idle toast is
61
+ * non-observable; this template text is the contract). Registered regardless
62
+ * of capture.enabled; the enabled-gated toast only nudges toward it.
63
+ * Agent-facing instruction text, generic wording only (ships in the tarball).
64
+ * Context-rot budget ≤~30 lines. */
52
65
  const CAPTURE_COMMAND_DESCRIPTION = '把本次会话记为史官事件页 / record this session as a historian event page';
53
66
  const CAPTURE_COMMAND_TEMPLATE = [
54
- 'Summarize the current session as a historian G1 event page (an append-only record of what happened).',
67
+ 'Summarize the current session as a historian G1 event page only if it produced capture-worthy knowledge.',
55
68
  '',
56
- '1. Draft four sections: 过程/Process (what was done, in order), 原因/Cause (why it was needed), 后果/Consequence (impact, artifacts), 改进/Improvement (follow-ups, preventions).',
69
+ '0. Capture triggers proceed only if at least one matches, else say so and skip writing:',
70
+ ' 事故闭环 incident closed with a root cause | 部署完成 deployment completed | bug修复合入 bugfix merged',
71
+ ' | 探针结论 probe/eval conclusion | 被否决方案 rejected option (record the 否决理由 veto reason).',
72
+ '1. Draft the body in the 四段式 evidence-chain order: 证据链/Evidence (what was observed, artifacts first)',
73
+ ' → 方法/Method (how it was proven) → 修复手段/Fix (what changed, or the decision) → 函数级实现/Implementation (file:symbol detail).',
57
74
  "2. Run historian_map action:'show' to see existing sections, then choose a short factual path under one.",
58
- '3. Save with historian_page_create (genre "G1"); the zh twin is auto-created. If the session only repeated known knowledge, say so and skip writing.',
59
- '4. Echo both page URLs (en + zh) back to the user.',
75
+ '3. Evidence split (G1 appendix contract): store the full 四段 material via historian_page_create with tier:"evidence"',
76
+ ' under `_evidence/`; the main page body stays the cited/summarized form linking to those evidence pages.',
77
+ '4. Carry SRE discipline in the 元数据表 metadata table rows: 影响/impact | 负责人/owner',
78
+ ' | 后续动作/action items (owner + 优先级/priority + verifiable done-state) | 来源类型/source type.',
79
+ '5. Pre-write self-check: create the page (genre "G1", 状态:draft, content included) so the 十项自检 scoring',
80
+ ' advisory runs on the draft; fix its FAIL items via historian_page_update before publishing.',
81
+ '6. Publish flow capture→review→Active: the page starts 状态:draft; once the self-check passes,',
82
+ ' historian_page_update it to Active. The zh twin is auto-created.',
83
+ '7. Echo both page URLs (en + zh) back to the user.',
60
84
  ].join('\n');
61
85
  const CAPTURE_TOAST_MESSAGE = '会话空闲:有值得留存的决定/修复/踩坑就跑 /historian-capture。Session idle — run /historian-capture if it produced decisions, fixes, or pitfalls worth keeping.';
62
86
  async function server(input, options) {
@@ -71,6 +95,14 @@ async function server(input, options) {
71
95
  console.error('[opencode-historian] Failed to resolve plugin options; tools disabled.', err instanceof Error ? err.message : err);
72
96
  return {};
73
97
  }
98
+ // Once per plugin load (never in the per-request hot path): a config-only
99
+ // opt-in leaves the loop dark because this machine has not confirmed it —
100
+ // point at the missing second signal instead of silently no-oping forever.
101
+ if (opts.readingLoop === true && !isReadingLoopConfirmed(homedir())) {
102
+ console.error('[opencode-historian] readingLoop enabled in config but not confirmed on this machine; to activate, create ' +
103
+ loopStatePath(homedir()) +
104
+ ' with {"version":1,"confirmed":true}');
105
+ }
74
106
  const captureReminded = new Set();
75
107
  const hooks = {
76
108
  config: async (cfg) => {
@@ -90,9 +122,19 @@ async function server(input, options) {
90
122
  try {
91
123
  if (opts.readingLoop !== true)
92
124
  return;
125
+ if (!isReadingLoopConfirmed(homedir()))
126
+ return;
93
127
  if (output.system.some((block) => block.includes('historian_search')))
94
128
  return;
95
- output.system.push(READING_LOOP_ADVISORY);
129
+ if (output.system.length === 0) {
130
+ output.system.push(READING_LOOP_ADVISORY);
131
+ }
132
+ else {
133
+ // Single-block-safe merge: a second system entry makes strict chat
134
+ // templates (e.g. vLLM) reject the request with HTTP 400.
135
+ const last = output.system.length - 1;
136
+ output.system[last] += '\n\n' + READING_LOOP_ADVISORY;
137
+ }
96
138
  }
97
139
  catch (err) {
98
140
  // A broken inject must never crash a chat request (plan v2 todo 8).
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Sentinel state for the v3 reading loop (double gate, config + machine).
3
+ *
4
+ * The reading loop activates only when BOTH signals agree: the plugin option
5
+ * `readingLoop: true` (see src/config.ts) and a confirmation sentinel written
6
+ * BY HAND on this machine at '<home>/.config/opencode/historian-reading-loop.json'
7
+ * holding {"version":1,"confirmed":true}. Agent self-enablement is forbidden by
8
+ * design, so the reader here is deliberately tolerant: a missing file, bad
9
+ * JSON, wrong shape, mismatched version, or any read error yields false.
10
+ * This function NEVER throws.
11
+ *
12
+ * The home directory is an injectable parameter so unit tests drive fixtures
13
+ * under tmp dirs; the real ~/.config is never touched (same discipline as
14
+ * src/config.ts / test/config.test.ts).
15
+ */
16
+ /** Sentinel schema version accepted by the reader (strict equality, not truthy). */
17
+ export declare const LOOP_STATE_VERSION = 1;
18
+ /** Path of the human-written reading-loop confirmation sentinel under a home dir. */
19
+ export declare function loopStatePath(home: string): string;
20
+ /**
21
+ * True only when the sentinel exists and parses to an object with
22
+ * version === LOOP_STATE_VERSION and confirmed === true (both strict).
23
+ * Parsed via parseJsonc, so comments in the sentinel are a bonus feature.
24
+ *
25
+ * Pure-sync and intentionally NOT cached: callers re-check per request so a
26
+ * mid-session sentinel write or removal takes effect immediately.
27
+ */
28
+ export declare function isReadingLoopConfirmed(home: string): boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Sentinel state for the v3 reading loop (double gate, config + machine).
3
+ *
4
+ * The reading loop activates only when BOTH signals agree: the plugin option
5
+ * `readingLoop: true` (see src/config.ts) and a confirmation sentinel written
6
+ * BY HAND on this machine at '<home>/.config/opencode/historian-reading-loop.json'
7
+ * holding {"version":1,"confirmed":true}. Agent self-enablement is forbidden by
8
+ * design, so the reader here is deliberately tolerant: a missing file, bad
9
+ * JSON, wrong shape, mismatched version, or any read error yields false.
10
+ * This function NEVER throws.
11
+ *
12
+ * The home directory is an injectable parameter so unit tests drive fixtures
13
+ * under tmp dirs; the real ~/.config is never touched (same discipline as
14
+ * src/config.ts / test/config.test.ts).
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { parseJsonc, isRecord } from './jsonc.js';
19
+ /** Sentinel schema version accepted by the reader (strict equality, not truthy). */
20
+ export const LOOP_STATE_VERSION = 1;
21
+ /** Path of the human-written reading-loop confirmation sentinel under a home dir. */
22
+ export function loopStatePath(home) {
23
+ return join(home, '.config', 'opencode', 'historian-reading-loop.json');
24
+ }
25
+ /**
26
+ * True only when the sentinel exists and parses to an object with
27
+ * version === LOOP_STATE_VERSION and confirmed === true (both strict).
28
+ * Parsed via parseJsonc, so comments in the sentinel are a bonus feature.
29
+ *
30
+ * Pure-sync and intentionally NOT cached: callers re-check per request so a
31
+ * mid-session sentinel write or removal takes effect immediately.
32
+ */
33
+ export function isReadingLoopConfirmed(home) {
34
+ const path = loopStatePath(home);
35
+ let parsed;
36
+ try {
37
+ parsed = parseJsonc(readFileSync(path, 'utf8'), path);
38
+ }
39
+ catch {
40
+ return false; // missing file, unreadable, or malformed JSONC — all mean "not confirmed"
41
+ }
42
+ if (!isRecord(parsed)) {
43
+ return false; // top-level array / string / number / null — wrong shape
44
+ }
45
+ return parsed.version === LOOP_STATE_VERSION && parsed.confirmed === true;
46
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Maintain curation report (plan todo-7, D3 CURATE / D12): deterministic
3
+ * metric sweeps over already-fetched map rows (tier-1 light) plus optional
4
+ * injected page-body reads (deep). The chronology.ts precedent holds here —
5
+ * no client, no fs, no wiki logic: pure functions over MapRow-shaped input and
6
+ * a readBody dependency; the tool layer (tools/local.ts) supplies both.
7
+ *
8
+ * Output is dual-form: a human markdown report (renderMaintainMarkdown) and a
9
+ * machine-readable JSON tail with stable top-level keys (schema
10
+ * 'historian.maintain.v1') so later todos can parse it without re-deriving.
11
+ *
12
+ * allow: SIZE_OK — the plan pins this commit to src/maintain.ts +
13
+ * tools/local.ts + test/maintain.test.ts only, so the metric builder and its
14
+ * markdown renderer ship as one module instead of a third file.
15
+ */
16
+ import type { MapRow } from './map.js';
17
+ import type { Locale } from './wiki/pages.read.js';
18
+ /** A map row optionally enriched with tags (the tool layer joins these from a
19
+ * read-only pages.list pass; the mirror's MapRow carries none). */
20
+ export interface MaintainRow extends MapRow {
21
+ readonly tags?: readonly string[];
22
+ }
23
+ export interface MaintainInput {
24
+ readonly rows: readonly MaintainRow[];
25
+ readonly mapGeneratedAt?: string | null;
26
+ readonly mapStaleSeconds?: number | null;
27
+ }
28
+ export type ReadBodyFn = (path: string, locale: Locale) => Promise<string | null>;
29
+ export interface MaintainOptions {
30
+ readonly now?: Date;
31
+ readonly topN?: number;
32
+ readonly deep?: boolean;
33
+ readonly readBody?: ReadBodyFn;
34
+ }
35
+ export interface DupCluster {
36
+ readonly paths: readonly string[];
37
+ readonly titles: readonly string[];
38
+ }
39
+ export interface StaleEntry {
40
+ readonly path: string;
41
+ readonly updatedAt: string;
42
+ readonly daysOld: number;
43
+ readonly locales: readonly string[];
44
+ }
45
+ export interface RedirectStub {
46
+ readonly path: string;
47
+ readonly locale: Locale;
48
+ readonly target: string;
49
+ }
50
+ export interface MissingStamp {
51
+ readonly path: string;
52
+ readonly locale: Locale;
53
+ readonly genre: string;
54
+ }
55
+ export interface ExpiredReview {
56
+ readonly path: string;
57
+ readonly locale: Locale;
58
+ readonly reviewBy: string;
59
+ readonly daysExpired: number;
60
+ }
61
+ export interface FreshnessScan {
62
+ readonly scanned: number;
63
+ readonly unreadable: number;
64
+ readonly missingLastVerified: readonly MissingStamp[];
65
+ readonly expiredReviewBy: readonly ExpiredReview[];
66
+ }
67
+ export interface MaintainReport {
68
+ readonly schema: typeof MAINTAIN_SCHEMA;
69
+ readonly generatedAt: string;
70
+ readonly rowCount: number;
71
+ readonly mapGeneratedAt: string | null;
72
+ readonly mapStaleSeconds: number | null;
73
+ readonly deep: boolean;
74
+ readonly pages: {
75
+ readonly rows: number;
76
+ readonly paths: number;
77
+ readonly perLocale: Readonly<Record<string, number>>;
78
+ readonly missingTwinPaths: readonly string[];
79
+ };
80
+ readonly duplicates: {
81
+ readonly threshold: number;
82
+ readonly clusters: readonly DupCluster[];
83
+ };
84
+ readonly staleness: {
85
+ readonly topN: number;
86
+ readonly oldest: readonly StaleEntry[];
87
+ };
88
+ readonly diffusion: {
89
+ readonly singleChildDirs: readonly {
90
+ dir: string;
91
+ childPath: string;
92
+ }[];
93
+ };
94
+ readonly rootOrphans: readonly {
95
+ section: string;
96
+ paths: readonly string[];
97
+ }[];
98
+ readonly tags: {
99
+ available: boolean;
100
+ vocabulary: readonly {
101
+ tag: string;
102
+ count: number;
103
+ }[];
104
+ };
105
+ readonly redirects: {
106
+ available: boolean;
107
+ count: number;
108
+ stubs: readonly RedirectStub[];
109
+ };
110
+ readonly sections: readonly {
111
+ section: string;
112
+ paths: number;
113
+ rows: number;
114
+ }[];
115
+ readonly freshness: FreshnessScan | null;
116
+ }
117
+ export declare const MAINTAIN_SCHEMA = "historian.maintain.v1";
118
+ /** Trigram-Jaccard bar for calling two (different-path) titles near-duplicates. */
119
+ export declare const DUP_TITLE_THRESHOLD = 0.75;
120
+ export declare function buildMaintainReport(input: MaintainInput, opts?: MaintainOptions): Promise<MaintainReport>;
121
+ /** Human report + (always) a fenced machine-readable JSON block at the END —
122
+ * the same MaintainReport object the tool envelope carries. */
123
+ export declare function renderMaintainMarkdown(r: MaintainReport): string;