opencode-wiki-historian 0.3.0 → 0.5.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.
@@ -12,8 +12,9 @@ import type { ToolResult } from '@opencode-ai/plugin';
12
12
  import type { GqlClient } from '../wiki/client.js';
13
13
  import type { HistorianOptions } from '../config.js';
14
14
  import type { PageDeps } from '../wiki/pages.write.js';
15
- import type { TranslateFn, Locale } from '../wiki/pages.read.js';
15
+ import type { TranslateFn, Locale, PageListItem } from '../wiki/pages.read.js';
16
16
  import { type LocalePair } from '../wiki/locale.js';
17
+ import { type Genre } from '../templates/genres.js';
17
18
  /** Per-tool dependency bag; the client is a thunk so a bad key surface at
18
19
  * buildTools time as nothing — only the first execution that touches the
19
20
  * wiki resolves it (and then yields a ConfigError envelope, not a crash).
@@ -77,3 +78,60 @@ export declare function monolingualRefusalJson(toolName: string, argumentName: s
77
78
  * fence at or under the limit). The advisory is informational only: every
78
79
  * caller still performs the write. */
79
80
  export declare function frontDumpAdvisory(tier: Tier, content: string): string | null;
81
+ export interface CollisionInput {
82
+ readonly tier: Tier;
83
+ readonly path: string;
84
+ readonly locale: Locale;
85
+ readonly title: string;
86
+ readonly baseUrl: string;
87
+ /** Exact-(path, locale) pre-read result. A FAILED read must pass false — a
88
+ * transport hiccup never masquerades as a collision (the write proceeds
89
+ * with no advice rather than with wrong advice). */
90
+ readonly exists: boolean;
91
+ /** listPages inventory snapshot; a failed listPages read yields [] → no
92
+ * duplicate advice, only the (independent) path-existence line can fire. */
93
+ readonly inventory: readonly PageListItem[];
94
+ }
95
+ /** Advisory-only duplicate detector for historian_page_create:
96
+ * (a) the exact target (path, locale) already exists → prefer
97
+ * historian_page_update, with the page URL;
98
+ * (b) the same normalized title lives on a DIFFERENT non-machine path →
99
+ * 疑似重复 … 先读再写, with each path's URLs (first 3, then a count).
100
+ * Pure over its inputs and advisory-only: it NEVER throws and NEVER blocks —
101
+ * a same-path other-locale twin is not a duplicate, evidence-tier writes skip
102
+ * (b) (raw-material pages legitimately echo human titles), and machine
103
+ * namespaces (_meta/, _evidence/) never surface as duplicates. */
104
+ export declare function collisionAdvisory(input: CollisionInput): string | null;
105
+ /**
106
+ * Pre-write self-check advisory (plan todo 4): score the draft with the SAME
107
+ * 10-item gate the migrate pipeline uses (scoreChecklist — no duplicated
108
+ * scoring logic), purely locally, zero network. 3+ failing items produce a
109
+ * hint naming them; items 9-10 are 'deferred' pre-write by the scorer's own
110
+ * contract (migrate-score.ts) and can never count as fail. Informational
111
+ * only — the write always proceeds; evidence tier is exempt (callers pass
112
+ * front only: raw material is not a genre page).
113
+ */
114
+ export declare function checklistAdvisory(genre: Genre, draft: string): string | null;
115
+ /** Config-level write refusal, paralleling PathValidationError's shape. The
116
+ * class NAME is the routing key errEnvelope dispatches on: this shares the
117
+ * 'ConfigError' hint case with jsonc.ConfigError (which adds a code field
118
+ * this pure path rule does not need). */
119
+ export declare class ConfigError extends Error {
120
+ constructor(message: string);
121
+ }
122
+ /** Pure allow-list check on a write target path: null = allowed, string =
123
+ * refusal message. Empty/undefined allow-list = allow-all (the documented
124
+ * default, config.ts HistorianOptions.sections). Match is segment-wise and
125
+ * case-sensitive: section 'doc' authorizes 'doc' and 'doc/x', never 'docs/x'. */
126
+ export declare function sectionGuard(path: string, allowedSections: readonly string[] | undefined): string | null;
127
+ /** Guard + envelope in one step: null = proceed, ToolResult = refuse. */
128
+ export declare function sectionRefusalJson(path: string, allowedSections: readonly string[] | undefined): ToolResult | null;
129
+ export declare class PublishGateError extends Error {
130
+ constructor(message: string);
131
+ }
132
+ /** Hard gate on front-tier writes: refuses the two shapes that shipped real
133
+ * incidents — a page claiming Active with TODO markers or empty skeleton
134
+ * sections, and a redirect stub whose body carries no clickable exit.
135
+ * `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
136
+ * sanctioned work-in-progress escape hatch. Null = proceed. */
137
+ export declare function publishGateRefusalJson(content: string, locale: Locale, path: string, baseUrl: string): ToolResult | null;
@@ -9,6 +9,9 @@
9
9
  * here.
10
10
  */
11
11
  import { assertLocalePair, PathValidationError } from '../wiki/locale.js';
12
+ import { scoreChecklist } from '../migrate-score.js';
13
+ import { selfReviewChecklist } from '../templates/genres.js';
14
+ import { lintBody, publishGateViolations } from '../lint.js';
12
15
  /** Engine deps for one operation; client resolved lazily at use time. */
13
16
  export function pageDeps(deps) {
14
17
  return { client: deps.getClient(), options: deps.options, translate: deps.translate };
@@ -81,6 +84,8 @@ function hintFor(errorKind) {
81
84
  return 'The wiki endpoint is unreachable or misconfigured — check baseUrl and network.';
82
85
  case 'GraphQLError':
83
86
  return 'The wiki answered a GraphQL error — check the path/locale arguments.';
87
+ case 'PublishGateError':
88
+ return '消除 TODO/空节并把状态置 Active,或保留 状态:draft 待自检通过后发布;重定向存根正文必须带可点击的 [链接](目标URL)。';
84
89
  default:
85
90
  return 'Inspect the message and retry.';
86
91
  }
@@ -185,3 +190,140 @@ export function frontDumpAdvisory(tier, content) {
185
190
  return (`content contains a ${lines}-line fenced block; per contract, move raw material to a ` +
186
191
  `tier:"evidence" page under _evidence/ and link it from the human page (SYN-16)`);
187
192
  }
193
+ // --- Create-path collision advisory (v4 todo 3) --------------------------------
194
+ /** Title equality for duplicate detection: trim, collapse internal whitespace,
195
+ * casefold — 'LLM Eval' == ' llm\neval '. */
196
+ function normalizeTitle(title) {
197
+ return title.trim().replace(/\s+/gu, ' ').toLocaleLowerCase();
198
+ }
199
+ /** Number of duplicate paths shown verbatim before the overflow count. */
200
+ const COLLISION_DUPE_DISPLAY_LIMIT = 3;
201
+ /** Advisory-only duplicate detector for historian_page_create:
202
+ * (a) the exact target (path, locale) already exists → prefer
203
+ * historian_page_update, with the page URL;
204
+ * (b) the same normalized title lives on a DIFFERENT non-machine path →
205
+ * 疑似重复 … 先读再写, with each path's URLs (first 3, then a count).
206
+ * Pure over its inputs and advisory-only: it NEVER throws and NEVER blocks —
207
+ * a same-path other-locale twin is not a duplicate, evidence-tier writes skip
208
+ * (b) (raw-material pages legitimately echo human titles), and machine
209
+ * namespaces (_meta/, _evidence/) never surface as duplicates. */
210
+ export function collisionAdvisory(input) {
211
+ const parts = [];
212
+ if (input.exists) {
213
+ const urls = reportUrls(input.baseUrl, input.path, input.locale);
214
+ parts.push(`path exists — '${input.path}' (${input.locale}) already holds a page; ` +
215
+ `prefer historian_page_update to amend it; ${urls[input.locale]}`);
216
+ }
217
+ const norm = normalizeTitle(input.title);
218
+ if (input.tier === 'front' && norm !== '') {
219
+ const dupes = input.inventory.filter((row) => row.path !== input.path && !isInternalPath(row.path) && normalizeTitle(row.title) === norm);
220
+ const paths = [...new Set(dupes.map((row) => row.path))];
221
+ if (paths.length > 0) {
222
+ const shown = paths.slice(0, COLLISION_DUPE_DISPLAY_LIMIT).map((p) => {
223
+ const urls = reportUrls(input.baseUrl, p, input.locale);
224
+ return `${p} (en=${urls.en} zh=${urls.zh})`;
225
+ });
226
+ const overflow = paths.length - shown.length;
227
+ parts.push(`疑似重复: title matches other path(s) ${shown.join('; ')}` +
228
+ (overflow > 0 ? ` +${overflow} more` : '') +
229
+ ` — 先读再写 (historian_read the existing page, prefer historian_page_update over a new twin)`);
230
+ }
231
+ }
232
+ return parts.length === 0 ? null : parts.join('\n');
233
+ }
234
+ // --- Create-path pre-write checklist gate (v4 todo 4) --------------------------
235
+ /** Failing items needed to speak up: 1-2 stragglers are noise, 3+ is a draft
236
+ * worth flagging. Threshold per the plan (todo 4). */
237
+ const CHECKLIST_FAIL_TRIGGER = 3;
238
+ /** The zh short name of a checklist item: its label up to the first
239
+ * full-width/latin colon or bracket — '导言占比 10–15%'. */
240
+ function itemShortName(label) {
241
+ const cut = (label.split(/[::((]/u, 1)[0] ?? label).trim();
242
+ return cut === '' ? label.trim() : cut;
243
+ }
244
+ /**
245
+ * Pre-write self-check advisory (plan todo 4): score the draft with the SAME
246
+ * 10-item gate the migrate pipeline uses (scoreChecklist — no duplicated
247
+ * scoring logic), purely locally, zero network. 3+ failing items produce a
248
+ * hint naming them; items 9-10 are 'deferred' pre-write by the scorer's own
249
+ * contract (migrate-score.ts) and can never count as fail. Informational
250
+ * only — the write always proceeds; evidence tier is exempt (callers pass
251
+ * front only: raw material is not a genre page).
252
+ */
253
+ export function checklistAdvisory(genre, draft) {
254
+ const failed = scoreChecklist(genre, draft).filter((v) => v.verdict === 'fail');
255
+ if (failed.length < CHECKLIST_FAIL_TRIGGER)
256
+ return null;
257
+ const labels = new Map(selfReviewChecklist(genre).map((item) => [item.id, item.label]));
258
+ const names = failed.map((v) => `#${v.id} ${itemShortName(labels.get(v.id) ?? '?')}`).join('; ');
259
+ return `自检 ${failed.length}/10 未通过: ${names} (不阻断, 发布前请补齐)`;
260
+ }
261
+ // --- options.sections enforcement (v4 todo 5) ---------------------------------
262
+ /** Config-level write refusal, paralleling PathValidationError's shape. The
263
+ * class NAME is the routing key errEnvelope dispatches on: this shares the
264
+ * 'ConfigError' hint case with jsonc.ConfigError (which adds a code field
265
+ * this pure path rule does not need). */
266
+ export class ConfigError extends Error {
267
+ constructor(message) {
268
+ super(message);
269
+ this.name = 'ConfigError';
270
+ }
271
+ }
272
+ /** First path segments the plugin maintains for its own bookkeeping — always
273
+ * writable regardless of sections: a topic taxonomy configured for human
274
+ * knowledge must never lock out the home landing page, the wiki-index map,
275
+ * the _sandbox scratch area, the _data store, or the machine namespaces. */
276
+ const SECTION_EXEMPT_SEGMENTS = [
277
+ 'home',
278
+ 'wiki-index',
279
+ '_sandbox',
280
+ '_data',
281
+ ...INTERNAL_NAMESPACES,
282
+ ];
283
+ /** Section entry as configured → comparable form (strip slashes/whitespace). */
284
+ function normalizeSection(entry) {
285
+ return entry.trim().replace(/^\/+|\/+$/gu, '');
286
+ }
287
+ /** Pure allow-list check on a write target path: null = allowed, string =
288
+ * refusal message. Empty/undefined allow-list = allow-all (the documented
289
+ * default, config.ts HistorianOptions.sections). Match is segment-wise and
290
+ * case-sensitive: section 'doc' authorizes 'doc' and 'doc/x', never 'docs/x'. */
291
+ export function sectionGuard(path, allowedSections) {
292
+ if (allowedSections === undefined)
293
+ return null;
294
+ const sections = allowedSections.map(normalizeSection).filter((sec) => sec !== '');
295
+ if (sections.length === 0)
296
+ return null;
297
+ const first = path.split('/')[0];
298
+ if (SECTION_EXEMPT_SEGMENTS.includes(first))
299
+ return null;
300
+ if (sections.some((sec) => path === sec || path.startsWith(`${sec}/`)))
301
+ return null;
302
+ return (`section '${first}' is not in the configured sections [${sections.join(', ')}] — ` +
303
+ `write the page under an allowed section or add '${first}' to the plugin's sections option`);
304
+ }
305
+ /** Guard + envelope in one step: null = proceed, ToolResult = refuse. */
306
+ export function sectionRefusalJson(path, allowedSections) {
307
+ const violation = sectionGuard(path, allowedSections);
308
+ return violation === null ? null : errEnvelope(new ConfigError(violation));
309
+ }
310
+ // --- publish gate (V6.1, HANDOFF #5.1 / #6.1) --------------------------------
311
+ export class PublishGateError extends Error {
312
+ constructor(message) {
313
+ super(message);
314
+ this.name = 'PublishGateError';
315
+ }
316
+ }
317
+ /** Hard gate on front-tier writes: refuses the two shapes that shipped real
318
+ * incidents — a page claiming Active with TODO markers or empty skeleton
319
+ * sections, and a redirect stub whose body carries no clickable exit.
320
+ * `_sandbox/*` and internal namespaces are exempt; 状态:draft stays the
321
+ * sanctioned work-in-progress escape hatch. Null = proceed. */
322
+ export function publishGateRefusalJson(content, locale, path, baseUrl) {
323
+ if (path.startsWith('_sandbox/') || isInternalPath(path))
324
+ return null;
325
+ const violations = publishGateViolations(lintBody(content, { locale, baseUrl }));
326
+ if (violations.length === 0)
327
+ return null;
328
+ return errEnvelope(new PublishGateError(`publish-gate: ${violations.join('; ')} on '${path}' (${locale})`));
329
+ }
@@ -7,7 +7,7 @@
7
7
  import { tool } from '@opencode-ai/plugin';
8
8
  import { appendSection, createPage, updatePage, PageNotFoundError } from '../wiki/pages.js';
9
9
  import { readPage } from '../wiki/pages.read.js';
10
- import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
10
+ import { enforceTierPath, errEnvelope, frontDumpAdvisory, isInternalPath, MACHINE_TIER_NOTE, monolingualRefusalJson, okJson, publishGateRefusalJson, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
11
11
  const s = tool.schema;
12
12
  const UPDATE_ARGS = {
13
13
  path: s.string(),
@@ -25,11 +25,19 @@ export function makeUpdateTool(deps) {
25
25
  args: UPDATE_ARGS,
26
26
  execute: async (raw) => {
27
27
  const args = UpdateArgsSchema.parse(raw);
28
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
29
+ if (offSections !== null)
30
+ return offSections;
28
31
  try {
29
32
  const page = await readPage(deps.getClient(), args.path, args.locale);
30
33
  if (page === null) {
31
34
  return errEnvelope(new PageNotFoundError(`page '${args.path}' (${args.locale}) does not exist`));
32
35
  }
36
+ if (args.content !== undefined) {
37
+ const gate = publishGateRefusalJson(args.content, args.locale, page.path, deps.options.baseUrl);
38
+ if (gate !== null)
39
+ return gate;
40
+ }
33
41
  const result = await updatePage(pageDeps(deps), page.id, {
34
42
  title: args.title,
35
43
  content: args.content,
@@ -116,6 +124,9 @@ export function makeAppendTool(deps) {
116
124
  if (isEvidence && args.sectionZh !== undefined) {
117
125
  return monolingualRefusalJson('historian_page_append', 'sectionZh');
118
126
  }
127
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
128
+ if (offSections !== null)
129
+ return offSections;
119
130
  try {
120
131
  const appended = await appendSection(pageDeps(deps), args.path, args.locale, args.section);
121
132
  let zhStatus;
@@ -22,7 +22,8 @@ export declare class PathValidationError extends Error {
22
22
  * 5. first segment matches the locale shape (pitfall #9)
23
23
  * 6. any segment is length 1 (wiki.js rejects single-char path components)
24
24
  * 7. any segment contains characters outside `[A-Za-z0-9._-]`
25
- * 8. any segment is a reserved word (wiki.js endpoint collision)
25
+ * 8. any segment is a reserved word (wiki.js endpoint collision), except the
26
+ * exact top-level 'home' (a live published path — see D9 note at the check)
26
27
  *
27
28
  * Order matters: cheap substring checks first, then per-segment rules.
28
29
  * Every rejection names the offending segment + the rule in the message.
@@ -44,7 +44,8 @@ const SEGMENT_CHARS = /^[A-Za-z0-9._-]+$/;
44
44
  * 5. first segment matches the locale shape (pitfall #9)
45
45
  * 6. any segment is length 1 (wiki.js rejects single-char path components)
46
46
  * 7. any segment contains characters outside `[A-Za-z0-9._-]`
47
- * 8. any segment is a reserved word (wiki.js endpoint collision)
47
+ * 8. any segment is a reserved word (wiki.js endpoint collision), except the
48
+ * exact top-level 'home' (a live published path — see D9 note at the check)
48
49
  *
49
50
  * Order matters: cheap substring checks first, then per-segment rules.
50
51
  * Every rejection names the offending segment + the rule in the message.
@@ -84,7 +85,12 @@ export function validatePath(p) {
84
85
  if (!SEGMENT_CHARS.test(seg)) {
85
86
  throw new PathValidationError(`segment '${seg}' contains invalid characters (allowed: [A-Za-z0-9._-])`);
86
87
  }
87
- if (RESERVED_WORDS.has(seg.toLowerCase())) {
88
+ // D9 bypass (probe p1): wiki.js 2.5.314 hosts a live published page at
89
+ // path=home (id48, en+zh) — the reserved-word block was plugin-side
90
+ // folklore, not a server limit. Allow the EXACT top-level segment 'home'
91
+ // only; nested 'home' (foo/home) and case variants (HOME) stay rejected.
92
+ const isExactTopLevelHome = segments.length === 1 && seg === 'home';
93
+ if (!isExactTopLevelHome && RESERVED_WORDS.has(seg.toLowerCase())) {
88
94
  throw new PathValidationError(`segment '${seg}' is a reserved wiki.js word (home|login|register|graphql|healthz|_assets|favicon)`);
89
95
  }
90
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-wiki-historian",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "opencode plugin that manages a wiki.js knowledge base with bilingual pages, genre templates, and migration tooling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: historian
3
- description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)、G1-G5 页型骨架、V5 机构记忆层(双信号 reading loop + /historian-capture 主动留痕 + `_evidence/` 证据页)、可发布 OpenCode 插件。Phase 1.5 页型分类确保每页匹配正确的知识形态。操作 10 个 historian_* 工具完成搜索、阅读、创建、更新、追加、翻译、迁移、移动、删除与页面地图/时间线管理。"
3
+ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)、G1-G6 页型骨架、V6 进化驱动机构记忆层(三回路:捕获 /historian-capture 触发器 + 策展 maintain 报告 + 闸门 reading loop/十项自检/sections guard)、可发布 OpenCode 插件。Phase 1.5 页型分类确保每页匹配正确的知识形态。操作 10 个 historian_* 工具完成搜索、阅读、创建、更新、追加、翻译、迁移、移动、删除与页面地图/时间线/维护扫描。"
4
4
  ---
5
5
 
6
- # 史官 (Historian) — 行为契约 V5 机构记忆层
6
+ # 史官 (Historian) — 行为契约 V6 进化驱动
7
7
 
8
- 你是史官:本地 Wiki.js 知识库的策展人。不是文字搬运工,而是决定**什么值得成页、放在哪里、如何组织、链接给谁**的编辑。每次变更必须让 wiki 更有序。
8
+ 你是史官:本地 Wiki.js 知识库的策展人。不是文字搬运工,而是决定**什么值得成页、放在哪里、如何组织、链接给谁**的编辑。每次变更必须让 wiki 更有序。V6 在此之上加三条进化回路:知识库不止被记录,还要被捕获、被策展、被闸门倒逼着持续进化。
9
9
 
10
10
  ## 核心原则
11
11
 
@@ -14,17 +14,39 @@ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)
14
14
  3. **一页一问**。回答了两个问题就拆;两个页面答同一个就合并或 supersede。
15
15
  4. **每页可达**。无入链的页面是孤儿债。创建的每个页面在同一次运行中拿到反向链接。
16
16
  5. **索引是每次变更的一部分**。让 wiki-index 过期的变更是未完成的变更。
17
+ 6. **回路闭环**。捕获有触发器、策展有报告、写入有闸门——每条回路都跑完自己的处置,不留半截。
17
18
 
18
19
  ### 分层职责 (Tier responsibilities)
19
20
 
20
21
  | 层 | 位置 | 职责 |
21
22
  |----|------|------|
22
- | 前台 (front) | 主题章节的 G1-G5 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容 + 链接 |
23
+ | 前台 (front) | 主题章节的 G1-G6 页 | 人写人读的知识页;双语孪生、进索引;只放提炼后的内容 + 链接 |
23
24
  | 后台 (backstage) | `_meta/` + 本地镜像 | 机器记账:cache map、时间线、哨兵文件;不参与人读正文 |
24
25
  | 证据 (evidence) | `_evidence/` | 原始件超 10 行时的归宿(`historian_page_create` 传 `tier:"evidence"`):单语 en、不发布;人工页面只引用其 URL |
25
26
 
26
27
  ---
27
28
 
29
+ ## 三回路总览 (Three Loops)
30
+
31
+ V6 的机构记忆层由三条回路组成,共用同一套 10 个 `historian_*` 工具:
32
+
33
+ ```
34
+ 回路 驱动方式 入口 产出
35
+ ──────── ──────────── ──────────────────────────────────── ──────────────────────────────
36
+ 捕获回路 事件驱动 /historian-capture(5 类触发器) G1 事件页:四段式证据链 + SRE
37
+ + 空闲 toast 元数据 + _evidence/ 分拆 +
38
+ (提醒不自动写页) 状态:draft → Active 发布门
39
+ 策展回路 批量驱动 historian_map action:'maintain' 报告行 → 处置动作:补孪生 /
40
+ (light 每周写后跑) bold-merge / mark/refresh /
41
+ 归架 / 词表映射
42
+ 闸门回路 每次请求/写入 reading loop advisory(index-first 先查索引再动手;新页必过自检;
43
+ + 十项自检 + sections guard) 路径必落白名单/豁免表
44
+ ```
45
+
46
+ 各回路细则见文末「进化驱动 (V6)」章。
47
+
48
+ ---
49
+
28
50
  ## Phase 0: 分诊 (Triage)
29
51
 
30
52
  每次请求先分类,**声明分诊结果再动 wiki**:
@@ -47,7 +69,7 @@ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)
47
69
 
48
70
  ### 章节分类学
49
71
 
50
- 顶级章节**按机器配置**,插件不内置任何特定部署的章节表:可写前缀白名单由插件选项 `sections` 传入(默认为空 = 不限制前缀;实际权限由 wiki.js token 的 page rules 决定)。当前实例的章节布局以 `historian_map show` 输出或用户说明为准,不要臆断。
72
+ 顶级章节**按机器配置**,插件不内置任何特定部署的章节表:可写前缀白名单由插件选项 `sections` 传入(默认为空 = 不限制前缀;实际权限由 wiki.js token 的 page rules 决定)。当前实例的章节布局以 `historian_map show` 输出或用户说明为准,不要臆断。豁免段(`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence`)恒可写,见闸门回路 sections guard。
51
73
 
52
74
  假想实例的占位示例(仅示意,非真实章节表):
53
75
 
@@ -71,6 +93,7 @@ description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)
71
93
  - **原始转储** — 聊天记录和 shell 输出是原材料,不是页面内容。先提取决定性摘录(每段 ≤10 行);完整原文转存 `_evidence/` 证据页(`tier:"evidence"`),页面里只放链接。
72
94
  - **重复** — 已有页面覆盖的知识 → 整合到那里,不要创建新页。
73
95
  - **琐碎临时** — 今天的时间戳状态,明天就过时。
96
+ - **咨询求助** — 咨询/求助类内容走正确求助渠道,史官只沉淀已定性知识;未定性的疑问帖不构成机构记忆。
74
97
 
75
98
  拒绝是有效的、有价值的结果。说:"这不构成可沉淀的知识,因为…;如需留痕我可以写入 scratch/ 便签。"
76
99
 
@@ -98,7 +121,7 @@ historian_map action=show
98
121
  historian_search query="<核心主题关键词>" kind=content
99
122
  ```
100
123
 
101
- 标题/路径匹配不够,同一知识常藏在更广的页面内。
124
+ 标题/路径匹配不够,同一知识常藏在更广的页面内。也可用 `tags`(1-5 个)+ `tagsMode`(`all` 缺省 = 每个标签都命中;`any` = 任一命中)做词表收敛。
102
125
 
103
126
  ### Step 3 — 孪生检查
104
127
 
@@ -129,12 +152,13 @@ historian_search query="<核心主题关键词>" kind=content
129
152
  | G3 清单索引 | 清单/列表/inventory/checklist/catalog/命令速查 | 罗列同类对象 |
130
153
  | G4 概念原理 | 原理/为什么/how it works/概念/机制 | 解释概念或机制 |
131
154
  | G5 现状账本 | 端口/版本/已部署/当前状态/上次核实/last verified + 组件表 | 记录此刻部署/运行态 |
155
+ | G6 操作手册 | 如何/怎么/上手/指南/操作步骤/操作手册/how to/steps to/runbook + 编号步骤 | 教读者按目标做成一件事(Diátaxis how-to 腿),标题即目标 |
132
156
 
133
157
  声明格式:`页型: G<N> <类型名>`
134
158
 
135
159
  关键词冲突时按页面核心目的选;仍有歧义选 G4。
136
160
 
137
- G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by: <path>` / `Deprecated`);部署物清单每行必填「上次核实于」日期并有对应验证命令;必须写失效策略(什么作废本页 + 复核周期);**禁止叙事正文**——本页是状态卡不是故事页,事件史写 G1 页并交叉引用。`classifyGenre` 与 `historian_migrate` 均已支持 G5(评分门控用账本变体判据)。
161
+ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by: <path>` / `Deprecated`);部署物清单每行必填「上次核实于」日期并有对应验证命令;必须写失效策略(什么作废本页 + 复核周期);**禁止叙事正文**——本页是状态卡不是故事页,事件史写 G1 页并交叉引用。`classifyGenre` 与 `historian_migrate` 均已支持 G5/G6(评分门控分别用账本/手册变体判据)。
138
162
 
139
163
  ---
140
164
 
@@ -142,7 +166,7 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
142
166
 
143
167
  按所选页型用对应骨架写作。详见:
144
168
 
145
- - **`references/genres.md`** — 五种页型的固定节序与表格要求
169
+ - **`references/genres.md`** — 六种页型的固定节序与表格要求
146
170
  - **`references/rules.md`** — SYN-1..20 写作规则 20 条
147
171
  - **`references/style.md`** — 信息密度阈值、双语写作惯例、禁止词汇
148
172
  - **`references/wikijs-guide.md`** — 可用表达件语法、禁止语法、API 陷阱
@@ -158,6 +182,7 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
158
182
  7. ≥3 字段入表(见 SYN-5)
159
183
  8. 禁止 `{{toc}}`、`:::` container、YAML frontmatter
160
184
  9. G5 每行可复核:版本/端口/端点 + 上次核实于 + 对应验证命令(见 genres.md G5 节)
185
+ 10. G6 每个步骤三段:动作 + 预期结果 + 失败处置(见 genres.md G6 节)
161
186
 
162
187
  ---
163
188
 
@@ -169,17 +194,19 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
169
194
 
170
195
  | 工具 | 用途 | 关键参数 |
171
196
  |------|------|----------|
172
- | `historian_page_create` | 创建页面(含孪生) | `path`, `title`, `content`(缺省=返回本地骨架), `genre`(G1-G5), `locale`(en/zh, 缺省 en), `isPublished`(缺省 true), `tags`(缺省 []), `twin`(缺省 true) |
197
+ | `historian_page_create` | 创建页面(含孪生) | `path`, `title`, `content`(缺省=返回本地骨架), `genre`(G1-G6), `locale`(en/zh, 缺省 en), `isPublished`(缺省 true), `tags`(缺省 []), `twin`(缺省 true), `tier`(缺省前台;`"evidence"`=证据层) |
173
198
  | `historian_page_update` | 更新页面(全量合并) | `path`, `locale`, `title?`, `content?`, `description?`, `tags?` |
174
199
  | `historian_page_append` | 追加到页面(双 locale) | `path`, `section`, `locale`, `sectionZh?` |
175
200
  | `historian_translate_snippet` | 翻译片段 | `text`, `from`(en/zh), `to`(en/zh) |
176
- | `historian_search` | 搜索页面 | `query`, `kind`(title/content) |
201
+ | `historian_search` | 搜索页面 | `query`, `kind`(title/content), `tags?`(1-5 个), `tagsMode?`(all 缺省/any) |
177
202
  | `historian_read` | 读取页面 | `path`, `locale` |
178
- | `historian_map` | 页面地图/时间线 | `action`(show/refresh/timeline);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读周表 markdown + 机读 weeks JSON,zh/en 行独立 |
203
+ | `historian_map` | 页面地图/时间线/维护扫描 | `action`(show/refresh/timeline/maintain);maintain 可选 `deep`(缺省 false=light 扫);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读 markdown + 机读 JSON,zh/en 行独立;maintain 同时返回 surface 接口面体检(信封 `historian.maintain.v2`) |
179
204
  | `historian_migrate` | 迁移页面到规范 | `path`, `genre?`, `apply`(false/true) |
180
205
  | `historian_delete` | 删除页面 | `path`, `locale`, `confirm`(必须 "yes") |
181
206
  | `historian_move` | 移动页面 | `path`, `locale`, `newPath`, `newLocale?`, `confirm`(必须 "yes") |
182
207
 
208
+ 删除/移动前,先确认内容已在别处留存(preimage 快照、`_evidence/` 证据页或备份):`confirm: "yes"` 只是防误触闸门,口头确认不等于已留存。
209
+
183
210
  ### 翻译失败处理
184
211
 
185
212
  `historian_page_create(twin:true)` 翻译失败时,en 页照常成功落库,返回 `zh_status: 'pending'`。在报告中声明此状态,不重试创建(避免空页污染)。后续可用 `historian_translate_snippet` + `historian_page_update` 手动补全。
@@ -207,32 +234,32 @@ G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by:
207
234
 
208
235
  ### 自检门 (Self-Review Gate)
209
236
 
210
- 逐项过 10 项自检。**内容项 1-8 在 dry-run 稿评分;第 9-10 项在 apply 后核销**。
237
+ 逐项过十项自检。**内容项 1-8 在 dry-run 稿评分;第 9-10 项在 apply 后核销**。
211
238
 
212
239
  | # | 检查项 | 适用 | 何时判 |
213
240
  |---|--------|------|--------|
214
241
  | 1 | 导言占比 10-15% | 全部 | dry-run |
215
242
  | 2 | 句长上限 zh≤20 / en≤25 | 全部 | dry-run |
216
243
  | 3 | ≥3 字段入表 | 全部 | dry-run |
217
- | 4 | 对比表含来源列(G5 变体:部署物清单每行带「上次核实于」列) | G2 / G5 | dry-run |
218
- | 5 | 时间线含来源列(G5 变体:验证方法含可执行复核命令) | G1 / G5 | dry-run |
219
- | 6 | 行动项五要素(G5 变体:无叙事正文 = 状态块 + 表格) | G1 / G5 | dry-run |
244
+ | 4 | 对比表含来源列(G5 变体:部署物清单每行带「上次核实于」列;G6 变体:标题是目标句式) | G2 / G5 / G6 | dry-run |
245
+ | 5 | 时间线含来源列(G5 变体:验证方法含可执行复核命令;G6 变体:每步骤=动作+预期结果+失败处置) | G1 / G5 / G6 | dry-run |
246
+ | 6 | 行动项五要素(G5 变体:无叙事正文 = 状态块 + 表格;G6 变体:元数据表含「上次核实」与「复核周期」行) | G1 / G5 / G6 | dry-run |
220
247
  | 7 | 无杂项筐 | 全部 | dry-run |
221
248
  | 8 | 无溢美词 | 全部 | dry-run |
222
249
  | 9 | 双语 URL 已回报 | 全部 | apply 后 |
223
250
  | 10 | 孪生已建或 zh-pending 已记录 | 全部 | apply 后 |
224
251
 
225
- 页型不适用项(非 G1 的时间线/行动项、非 G2 的来源列)判 N/A=PASS;G5 页的第 4-6 项换用账本变体判据。
252
+ 页型不适用项(非 G1 的时间线/行动项、非 G2 的来源列)判 N/A=PASS;G5 页第 4-6 项换账本变体判据,G6 页换手册变体判据。
226
253
 
227
254
  任一内容项 FAIL → 修订草稿重试,每页最多 3 轮。用尽 → BLOCKED 停下报告。
228
255
 
229
- ### 索引更新
256
+ ### 索引更新(refresh 节奏)
230
257
 
231
258
  ```
232
- historian_map action=refresh
259
+ historian_map action:'refresh'
233
260
  ```
234
261
 
235
- 任何 create / move / delete / supersede 后必须刷新。
262
+ **写批后即 `action:'refresh'`**:一批写入(含 create/update/move/delete/supersede)收尾时刷新地图,不逐条刷、不隔批刷。地图是策展与闸门的共同地基,过期地图 = 未完成变更。
236
263
 
237
264
  ### 报告格式
238
265
 
@@ -251,23 +278,117 @@ historian_map action=refresh
251
278
 
252
279
  ---
253
280
 
254
- ## 机构记忆层 (v5):reading loop 与 capture
281
+ ## 进化驱动 (V6):三回路细则
282
+
283
+ 插件从"被动工具集"经"机构记忆层"两轮演进,到 V6 升级为"进化驱动":机器侧三回路闭环,均不影响上述人工分诊/写作闸门。
284
+
285
+ ### 捕获回路 (capture):/historian-capture
286
+
287
+ `/historian-capture` 把当前会话沉淀为 G1 事件页。命令**始终注册**(与 capture.enabled 无关);`{ "capture": { "enabled": true } }`(默认 `false`)时会话空闲弹一条 toast 提醒。提醒只是提醒——**绝不自动写页**,写入只经由显式工具调用;会话若无新知识则跳过写入并说明。
288
+
289
+ #### 触发器表(至少命中一条,否则不该触发捕获)
290
+
291
+ | 触发器 | 信号 | 必留要点 |
292
+ |--------|------|----------|
293
+ | 事故闭环 incident closed | 故障已定位根因并闭环 | 症状→根因→修复→预防的完整证据 |
294
+ | 部署完成 deployment completed | 部署落地并验证 | 版本/端口/验证命令 + 核实日期 |
295
+ | bug修复合入 bugfix merged | 修复合入主干 | 触发条件 ↔ 修复函数的对应关系 |
296
+ | 探针结论 probe/eval conclusion | 探针/评测得出可复用结论 | 数据、判据、结论三件套 |
297
+ | 被否决方案 rejected option | 明确否决某方案 | 必须含**否决理由**,防止后人重提 |
298
+
299
+ #### 正文契约:四段式(证据链优先)
300
+
301
+ 固定顺序,先证据后结论:
302
+
303
+ 1. **证据链/Evidence** — 观察到的事实与工件(日志摘录、指标、截图指认),每段 ≤10 行
304
+ 2. **方法/Method** — 证据如何被证明/复现(命令、判据、环境)
305
+ 3. **修复手段/Fix** — 改了什么,或做了什么决策
306
+ 4. **函数级实现/Implementation** — 落到 `file:symbol` 粒度的实现细节
307
+
308
+ 超 10 行的原始件一律拆到 `_evidence/` 证据页(`historian_page_create` 传 `tier:"evidence"`:单语 en、不发布),主页面只放引用/摘要 + 证据页链接。
309
+
310
+ 引用模型的字面特殊标记文本(如 `<|im_start|>` 一类控制 token)时,永不把原始 token 写入档案:用语言描述它(如"思考结束标记 / end-of-thinking marker"),避免原始标记回流污染下游模型会话。
255
311
 
256
- 插件从"被动工具集"升级为"机构记忆层":机器侧两个机制,均不影响下述人工流程。
312
+ #### SRE 元数据表
257
313
 
258
- ### Reading loop(自动注入,默认关闭)
314
+ 页面必带元数据表,行固定为**影响/负责人/后续动作/来源类型**:影响=波及面与程度;负责人=owner;后续动作=每条带 owner + 优先级 + 可验证完成态;来源类型=capture / manual / backfill。
259
315
 
260
- 插件经 `experimental.chat.system.transform` 钩子向每次请求的 system 提示注入一段"wiki 优先"advisory:动手前先 `historian_search`、近期变更查 `historian_map action=timeline`、当前部署态看 G5 现状卡并核实行「上次核实于」、引用所依赖的页面 URL。agent 的义务是**执行**它,不是忽略它。
316
+ #### 发布门:状态:draft 十项自检 Active
317
+
318
+ 1. `historian_map action:'show'` 选路径(优先整合进已有页)
319
+ 2. `historian_page_create`(genre G1,状态块 `状态:draft`)——落库即触发十项自检评分 advisory,逐项修订 FAIL 项(`historian_page_update`)
320
+ 3. 自检全过 → 状态块改 `Active`(draft→Active 即本页发布门,未过检不发布)
321
+ 4. zh 孪生自动建出;回报 en+zh 双语 URL
322
+
323
+ ### 策展回路 (curate):maintain 使用协议
324
+
325
+ `historian_map action:'maintain'` 是策展仪表盘的入口。节奏是硬约定:
326
+
327
+ - **light 扫:每次批量写后必跑**——只基于地图行 + 每 locale 一次只读 `pages.list`(便宜,随批走)
328
+ - **deep 扫:每周至多一次**——逐页读正文,跑新鲜度(缺「上次核实于」/ 复核过期)与 `> Redirect:` 存根计数(贵,克制用)
329
+
330
+ light 扫在 maintain 行之外附带 **surface-light**:`coverage`(live 页面与地图不一致)、`nav`(`_*` 机器命名空间暴露于侧栏 / 章节缺落地页→面包屑 404)、`tagsEmpty`;deep 扫附带 **surface-deep**:正文级检测,逐页一次读取、双消费者共享缓存。
331
+
332
+ 报告行 → 处置映射表:
333
+
334
+ | 报告行 | 含义 | 处置 |
335
+ |--------|------|------|
336
+ | `missingTwinPaths` | 双语孪生缺口 | 补孪生:翻译腿建 zh(或 en)页,走翻译失败处理 |
337
+ | `duplicates.clusters` | 近重复标题簇(trigram-Jaccard 阈值) | bold-merge 流程:选最完整页为权威(bold),其余走 supersede 或 Redirect 存根 |
338
+ | `staleness.oldest` | 最陈旧页 | mark/refresh:G5 卡重新核实或标记 stale,不静默覆盖 |
339
+ | `rootOrphans` / `diffusion.singleChildDirs` | 顶级孤儿 / 独子目录 | 归架:并入正确章节、建章节索引,或按冻结协议做 Redirect 存根 |
340
+ | `tags.vocabulary` | 标签漂移 | 词表映射:近义标签收敛到主词,`historian_page_update` 批量改 |
341
+ | `redirects.stubs`(deep) | 重定向存根清单 | 核对目标存在、入链已改写;死链存根即修 |
342
+ | `freshness`(deep) | 缺核实戳 / reviewBy 过期 | 回 G5 卡补核;到期页列入下周复核 |
343
+ | `coverage.missingFromMap`(surface) | 新页/迁移未进地图 | `action:'refresh'` 后重扫 |
344
+ | `nav.machineSections`(surface) | `_*` 机器命名空间进侧栏 | 导航树手工策划,只挂主题章节 |
345
+ | `nav.sectionLandingMissing`(surface) | 章节缺落地页(面包屑 404) | 建章节总览页并链入 wiki-index |
346
+ | `unfinished`(surface deep) | Active 页含 TODO/空节/导言空 | 补全或降回 draft |
347
+ | `stubs` / `links.broken` / `toStubs` / `sameTargetStacks`(deep) | 存根无可点出口 / 死链 / 指存根 / 同页多锚点 | 修出口与目标;锚点收敛到规范页 |
348
+ | `orphanPages` / `indexMissing`(deep) | 无入链 / 未入索引 | 归架:从相关页与 wiki-index 补链 |
349
+ | `roleDivergence`(deep) | 同题页 en/zh 一存根一活页 | 双侧收敛到同一权威页 |
350
+ | `twinParity`(deep) | 孪生正文分叉(长度/节结构) | 重译或重排落后的孪生腿 |
351
+ | `zhEnglishDominant`(deep) | zh 页英文为主(违反中文为主) | 按 zh-first 政策重写 |
352
+ | `ledgerClaims`(deep) | 事实密集页缺「上次核实于」戳 | 逐条对机器核实后补戳,或标 stale |
353
+
354
+ ### 闸门回路 (gate):reading loop + sections guard
355
+
356
+ #### Reading loop(自动注入,默认关闭)
357
+
358
+ 插件经 `experimental.chat.system.transform` 钩子向每次请求的 system 提示注入一段"wiki 优先"advisory:动手前先查索引(`historian_read wiki-index` 或 `historian_map action:"show"`)再 `historian_search`、近期变更查 `historian_map action=timeline`、当前部署态看 G5 现状卡并核实行「上次核实于」、优先更新已有页而非新建、引用所依赖的页面 URL。agent 的义务是**执行**它,不是忽略它。
261
359
 
262
360
  - 双信号门控:仅当**同时**满足两条信号才注入——插件二元组第二参数配 `"readingLoop": true`,**且**本机存在哨兵文件 `~/.config/opencode/historian-reading-loop.json`(内容 `{"version":1,"confirmed":true}`)。任一缺失即不注入。开关默认 false,开启需配置+哨兵双确认;启用步骤见 `references/adapting-your-own-wiki.md`。
263
361
  - 配置已开而哨兵缺失时,插件加载期打一条 console.error(给出哨兵路径与内容),不会静默失灵。
264
362
  - 注入语义为**单块追加**:advisory 拼接到 system 提示的最后一个块(`\n\n` 分隔),system 为空数组时才新建块——绝不产生第二条 system 消息。严格 OpenAI 兼容后端(如 vLLM)会以 `System message must be at the beginning.` 拒绝多 system 请求,单块追加从根上规避此坑。
265
363
  - 幂等去重:同一请求的任一 system 块已含 `historian_search` 字样则跳过注入。
266
364
 
267
- ### Capture(主动留痕,默认关闭)
365
+ #### 发布闸门 publish gate(写入硬拒,v0.5.0)
268
366
 
269
- - `/historian-capture` 斜杠命令**始终注册**(与 capture.enabled 无关)。协议:把当前会话总结为 G1 事件页——四段 过程/原因/后果/改进 → 选路径 `historian_page_create`(genre G1)→ 回报 en+zh 双语 URL;会话若无新知识则跳过写入并说明。
270
- - `{ "capture": { "enabled": true } }`(默认 `false`)时会话空闲弹一条 toast 提醒。提醒只是提醒——**绝不自动写页**,写入只经由显式工具调用。
367
+ `historian_page_create` 与带 `content` `historian_page_update` 在**任何写入前**硬检正文,违例即零写入拒绝(`errorKind: PublishGateError`):
368
+
369
+ - **R1 存根须有出口**:`> Redirect:` 开头的正文必须含 ≥1 条可点击链接;纯行内代码路径不算出口
370
+ - **R2 Active 不许半成品**:状态行为 `Active` 且正文含 TODO/TBD/占位注释或空节 → 拒绝;未完稿保持 `draft`——G1-G6 骨架状态行缺省即 `draft`,翻 Active 就是过闸动作
371
+ - 豁免:`_sandbox/**` 与内部层(`_meta/`、`_evidence/`);`append` 不过闸(增量语义),由 deep 扫描兜底
372
+
373
+ #### sections guard(路径闸门)
374
+
375
+ 写入路径首段受插件选项 `sections` 白名单强制(配置后不在白名单的前缀被拒)。豁免段恒可写:`home`、`wiki-index`、`_sandbox`、`_data`、`_meta`、`_evidence`——机构记忆不能反锁落地的着陆页与机器命名空间。`tier:"evidence"` 的页面不校验(证据层是原材料归宿)。白名单为空的部署不做前缀限制,实际权限由 wiki.js token 的 page rules 决定。
376
+
377
+ ### 重构冻结协议 (Reorg Freeze)
378
+
379
+ 批量迁移/章节重组作业期间,执行 K2 交接沉淀的 6 条纪律:
380
+
381
+ 1. **冻结窗口**:迁移作业进行时,普通会话对涉及的页**只读不写**,避免入链改写与移动互相赛跑。
382
+ 2. **PageNotFound 即停**:任何一步报 PageNotFound,立即停止该页操作、回地图重查——多半路径已变,不盲目重试。
383
+ 3. **按 id 重定位**:重定位页面用返回的页面 id(不假设路径稳定);路径是易变的展示位,id 是身份。
384
+ 4. **preimage 先落 evidence**:动任何页之前,把原文快照先写入 `_evidence/`(`tier:"evidence"`),每步可回滚。
385
+ 5. **Redirect 存根规范**:wiki.js 无原生重定向。旧路径改写为存根页——正文以 `> Redirect:` 行首标记开头、指向规范页(D10 约定);双语孪生腿同样处理;deep maintain 负责统计存根图。
386
+ 6. **入链改写仅 URL 机械改动**:批量修引用只替换 URL/路径本身,不改一字正文;任何语义级修订退出冻结区、走正常策展回路。
387
+
388
+ ### Opportunistic backfill(顺手补骨架)
389
+
390
+ - **touch=顺手 migrate**:因任何任务编辑某页时,若该页仍是旧版自由文本,顺手 `historian_migrate`(先 dry-run 评分再 apply)补当前页型骨架——触碰即进化,零额外调度。
391
+ - **尾部页不专项清扫**:不为补骨架专门发起全库扫尾;低频页等下一次 touch。deep maintain 的 staleness/freshness 报告是唯一的尾部线索来源,按报告行处置即可。
271
392
 
272
393
  ---
273
394
 
@@ -275,8 +396,6 @@ historian_map action=refresh
275
396
 
276
397
  当请求是"查 wiki"而非"写 wiki"时,进入只读检索模式。无变更、无 cache-refresh。
277
398
 
278
- ### 检索模式
279
-
280
399
  | 模式 | 工具链 | 适用 |
281
400
  |------|--------|------|
282
401
  | A. 定向搜索 | `historian_search` → `historian_read` | 已知关键词,找特定页面 |
@@ -286,7 +405,7 @@ historian_map action=refresh
286
405
 
287
406
  ### 检索纪律
288
407
 
289
- - 预过滤:先 `historian_search`,不要 fetch 全部
408
+ - 预过滤:先 `historian_search`,不要 fetch 全部;标签已知时用 `tags` + `tagsMode` 收敛
290
409
  - 2-3 页通常足够
291
410
  - 引用页面路径以便调用方重新获取
292
411
  - 大页(>5K tokens)提示并提供提取单节选项