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.
@@ -46,3 +46,10 @@ export declare const G4_EN: string;
46
46
  export declare const G5_ZH: string;
47
47
  /** G5 — current-state ledger, en (section-for-section twin of G5_ZH). */
48
48
  export declare const G5_EN: string;
49
+ /** G6 — 操作手册/how-to 页 (goal-titled operational manual), zh. Diátaxis doing
50
+ * leg: a reader with one goal follows the numbered steps to the outcome; every
51
+ * step carries 动作 + 预期结果 + 失败处置. Concepts stay in G4 pages, raw
52
+ * command lists in G3 pages, linked from 相关页面. */
53
+ export declare const G6_ZH: string;
54
+ /** G6 — how-to manual, en (section-for-section twin of G6_ZH). */
55
+ export declare const G6_EN: string;
@@ -106,7 +106,7 @@ export const G1_ZH = `# 页面标题(占位:写完后替换为实际标题
106
106
 
107
107
  ## 附录
108
108
 
109
- <!-- 原始证据:告警截图、日志片段、commit/PR 链接。脚注示例: -->
109
+ <!-- 决定性摘录(每段 ≤10 行)+ 证据页链接(\`_evidence/...\`,historian_page_create tier:"evidence" 建页)+ 外部链接(commit/PR/告警)。脚注示例: -->
110
110
  见脚注[^1]。
111
111
 
112
112
  [^1]: 来源链接(替换为真实出处)
@@ -197,7 +197,7 @@ export const G1_EN = `# Page Title (placeholder: replace with the real title, mu
197
197
 
198
198
  ## Appendix
199
199
 
200
- <!-- Raw evidence: alert screenshots, log excerpts, commit/PR links. Footnote example: -->
200
+ <!-- Decisive excerpts (≤10 lines each) + evidence-page links (\`_evidence/...\`, created via historian_page_create with tier:"evidence") + external links (commits/PRs/alerts). Footnote example: -->
201
201
  See footnote[^1].
202
202
 
203
203
  [^1]: Source link (replace with the real source)
@@ -500,7 +500,7 @@ export const G5_ZH = `# 页面标题(占位:写完后替换为实际标题
500
500
  | --- | --- | --- |
501
501
  | YYYY-MM-DD | 初版快照 | — |
502
502
 
503
- <!-- 追加式小表:只记影响清单行的变更。完整历史写 G1 事件页并互链。 -->
503
+ <!-- 追加式小表:只记影响清单行的变更。完整历史写 G1 事件页并互链;「依据」可指向证据页 URL(\`_evidence/...\`)。 -->
504
504
 
505
505
  ## 相关页面
506
506
 
@@ -551,8 +551,109 @@ export const G5_EN = `# Page Title (placeholder: replace with the real title, mu
551
551
  | --- | --- | --- |
552
552
  | YYYY-MM-DD | initial snapshot | — |
553
553
 
554
- <!-- Append-only mini table for list-affecting changes. Full history lives in G1 event pages. -->
554
+ <!-- Append-only mini table for list-affecting changes. Full history lives in G1 event pages; Evidence may point at an evidence-page URL (\`_evidence/...\`). -->
555
555
 
556
556
  ## Related Pages
557
557
 
558
558
  <!-- List the real page paths that link here and back. Event histories go in G1 pages, cited in Evidence. -->`;
559
+ /** G6 — 操作手册/how-to 页 (goal-titled operational manual), zh. Diátaxis doing
560
+ * leg: a reader with one goal follows the numbered steps to the outcome; every
561
+ * step carries 动作 + 预期结果 + 失败处置. Concepts stay in G4 pages, raw
562
+ * command lists in G3 pages, linked from 相关页面. */
563
+ export const G6_ZH = `# 如何做某事(占位:目标句式标题,须与页面 title 一致)
564
+
565
+ **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
566
+
567
+ **本页回答:** 如何完成某事(占位:写明具体目标)
568
+
569
+ > **提示**
570
+ > 操作手册面向带着目标来的读者:步骤可复制、可执行、可核对。
571
+ > 原理写 G4 概念页,命令清单写 G3 页,从相关页面链过来。
572
+ {.is-info}
573
+
574
+ ## 目标
575
+
576
+ <!-- 1–2 句:完成后读者得到什么结果。成功判据须可观察。 -->
577
+
578
+ ## 前置条件
579
+
580
+ <!-- 权限、版本、依赖、环境变量逐项列出。每项须能当场自检。 -->
581
+
582
+ | 条件 | 检查方法 | 预期结果 |
583
+ | --- | --- | --- |
584
+ | example-service 可达 | \`curl -s http://example.com:8000/health\` | HTTP 200 |
585
+
586
+ ## 操作步骤
587
+
588
+ <!-- 编号步骤,每步三段:动作、预期结果、失败处置。命令须可直接复制。 -->
589
+
590
+ 1. **动作**:<!-- 做什么或运行哪条命令。 -->
591
+ **预期结果**:<!-- 正常时看到的输出或状态。 -->
592
+ **失败处置**:<!-- 未达预期时的补救,或链向 G1/G4 页。 -->
593
+
594
+ ## 回退
595
+
596
+ <!-- 出错后如何恢复原状:撤销命令、备份位置。不可逆操作须前置警告。 -->
597
+
598
+ ## 元数据表
599
+
600
+ | 元数据 | 值 |
601
+ | --- | --- |
602
+ | 状态 | <!-- Active / Superseded-by: <path> / Deprecated --> |
603
+ | 上次核实 | <!-- YYYY-MM-DD,在哪套环境按本页步骤重跑过 --> |
604
+ | 复核周期 | <!-- 如每 90 天,到期重跑本页步骤 --> |
605
+ | 被取代于 | <!-- 新手册路径,无则填 — --> |
606
+ | 来源类型 | <!-- human / agent / imported --> |
607
+
608
+ ## 相关页面
609
+
610
+ <!-- 列出互链的真实页面路径。原理在 G4,清单在 G3,事故史在 G1。 -->`;
611
+ /** G6 — how-to manual, en (section-for-section twin of G6_ZH). */
612
+ export const G6_EN = `# How to Do X (placeholder: goal-titled heading, must match the page title)
613
+
614
+ **状态/Status**: Active <!-- or Superseded-by: <path> / Deprecated --> · **日期/Date**: YYYY-MM-DD
615
+
616
+ **This page answers:** how to finish one concrete task (placeholder: name the goal)
617
+
618
+ > **Tip**
619
+ > A how-to serves readers who arrive with a goal: steps are copy-pasteable and checkable.
620
+ > Concepts belong in G4 pages, raw command lists in G3 pages, linked under Related Pages.
621
+ {.is-info}
622
+
623
+ ## Goal
624
+
625
+ <!-- One or two sentences: the outcome the reader gets. State an observable success test. -->
626
+
627
+ ## Prerequisites
628
+
629
+ <!-- Permissions, versions, dependencies, env vars: one row each. Every row must be self-checkable. -->
630
+
631
+ | Condition | Check | Expected |
632
+ | --- | --- | --- |
633
+ | example-service reachable | \`curl -s http://example.com:8000/health\` | HTTP 200 |
634
+
635
+ ## Steps
636
+
637
+ <!-- Numbered steps, three legs per step: action, expected result, on failure. Commands must be copy-pasteable. -->
638
+
639
+ 1. **Action**: <!-- what to run or change. -->
640
+ **Expected result**: <!-- the output or state that proves success. -->
641
+ **On failure**: <!-- the fix, or a link to the G1/G4 page. -->
642
+
643
+ ## Rollback
644
+
645
+ <!-- How to restore the prior state: revert commands, backup locations. Warn before any irreversible step. -->
646
+
647
+ ## Metadata
648
+
649
+ | Field | Value |
650
+ | --- | --- |
651
+ | Status | <!-- Active / Superseded-by: <path> / Deprecated --> |
652
+ | Last verified | <!-- YYYY-MM-DD and the environment the steps were re-run in --> |
653
+ | Review by | <!-- e.g. every 90 days; re-run the steps when due --> |
654
+ | Superseded by | <!-- path of the newer manual, or — --> |
655
+ | Source kind | <!-- human / agent / imported --> |
656
+
657
+ ## Related Pages
658
+
659
+ <!-- Real page paths that link here and back. Why-it-works lives in G4, checklists in G3, incidents in G1. -->`;
@@ -7,22 +7,46 @@
7
7
  import { tool } from '@opencode-ai/plugin';
8
8
  import { validatePath } from '../wiki/locale.js';
9
9
  import { createPage } from '../wiki/pages.js';
10
- import { classifyGenre, genreSkeleton } from '../templates/genres.js';
11
- import { errEnvelope, okJson, urlPair, URL_MANDATE, pageDeps } from './shared.js';
10
+ import { listPages, readPage } from '../wiki/pages.read.js';
11
+ import { classifyGenre, genreSkeleton, GENRES } from '../templates/genres.js';
12
+ import { evidenceSkeleton } from '../templates/evidence.js';
13
+ import { checklistAdvisory, collisionAdvisory, enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
12
14
  const s = tool.schema;
13
- const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
14
15
  const ARGS_SHAPE = {
15
16
  path: s.string().describe('Wiki path, e.g. docs/guides/foo (first segment must NOT look like a locale code)'),
16
17
  title: s.string().describe('Page title'),
17
18
  content: s.string().optional().describe('Page body (markdown). ABSENT → local template mode, nothing written'),
18
- genre: s.enum(GENRES).optional().describe('Genre hint: G1..G5 (template mode / classification)'),
19
+ genre: s.enum(GENRES).optional().describe('Genre hint: G1..G6 (template mode / classification)'),
19
20
  locale: s.enum(['en', 'zh']).default('en'),
20
21
  isPublished: s.boolean().default(true),
21
22
  tags: s.array(s.string()).default([]),
22
23
  twin: s.boolean().default(true).describe('Auto-create the opposite-locale twin via translation'),
23
24
  description: s.string().optional(),
25
+ tier: s.enum(TIERS).default('front').describe('front = bilingual human page; evidence = machine page under _meta/ or _evidence/ (hidden, unpublished, monolingual en)'),
24
26
  };
25
27
  const ArgsSchema = s.object(ARGS_SHAPE);
28
+ /** Best-effort collision advice for the content branch: read-only pre-checks
29
+ * whose every failure is SWALLOWED — the write proceeds with no advice
30
+ * rather than being blocked or errored by the adviser itself (the
31
+ * "hint, never throw" precedent above). */
32
+ async function collisionAdvice(deps, probe) {
33
+ const client = deps.getClient();
34
+ let exists = false;
35
+ try {
36
+ exists = (await readPage(client, probe.path, probe.locale)) !== null;
37
+ }
38
+ catch {
39
+ /* failed existence read → assume absence: never advise on unknowns */
40
+ }
41
+ let inventory = [];
42
+ try {
43
+ inventory = await listPages(client);
44
+ }
45
+ catch {
46
+ /* failed inventory read → no duplicate advice */
47
+ }
48
+ return collisionAdvisory({ ...probe, exists, inventory });
49
+ }
26
50
  export function makeCreateTool(deps) {
27
51
  return tool({
28
52
  description: `Create a wiki page: primary locale content plus an optional auto-translated twin. ` +
@@ -31,42 +55,96 @@ export function makeCreateTool(deps) {
31
55
  args: ARGS_SHAPE,
32
56
  execute: async (raw) => {
33
57
  const args = ArgsSchema.parse(raw);
58
+ const tier = args.tier;
34
59
  try {
35
60
  validatePath(args.path);
36
61
  }
37
62
  catch (err) {
38
63
  return errEnvelope(err);
39
64
  }
65
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
66
+ if (offSections !== null)
67
+ return offSections;
68
+ const mismatch = enforceTierPath(tier, args.path);
69
+ if (mismatch !== null)
70
+ return tierMismatchJson(mismatch);
71
+ // Evidence pages are monolingual en: a zh locale is FORCED to en with an
72
+ // envelope hint — plan ruling: hint, never throw.
73
+ const isEvidence = tier === 'evidence';
74
+ const locale = isEvidence ? 'en' : args.locale;
75
+ const localeHint = isEvidence && args.locale === 'zh'
76
+ ? 'evidence pages are monolingual en — the locale argument was forced to "en"'
77
+ : undefined;
78
+ // Genre is resolved ONCE, above the template/content fork (v4 todo 4):
79
+ // the template branch feeds it the skeleton, the content branch the
80
+ // pre-write checklist gate. Explicit arg wins over classification.
81
+ const genre = args.genre ?? classifyGenre({ title: args.title, body: args.description ?? '' }).genre;
40
82
  if (args.content === undefined || args.content.trim() === '') {
41
- const genre = args.genre ?? classifyGenre({ title: args.title, body: args.description ?? '' }).genre;
83
+ if (isEvidence) {
84
+ // Evidence pages are not genre-templated: echo the machine skeleton.
85
+ // The source page is unknown at template time, so the fields ship as
86
+ // placeholder hints; the description arg (if given) is the context hint.
87
+ return okJson({
88
+ mode: 'template',
89
+ locale,
90
+ skeleton: evidenceSkeleton({
91
+ sourcePath: '<human-page-path>',
92
+ sourceUrl: '<human-page-url>',
93
+ capturedAt: new Date().toISOString(),
94
+ context: args.description ?? '<one-line context>',
95
+ }),
96
+ note: 'Nothing was written to the wiki (template mode, no content). Paste the raw material verbatim ' +
97
+ 'into the 原文 fence, replace the <human-page-path> / <human-page-url> / <one-line context> ' +
98
+ 'placeholders with the citing human page, then call historian_page_create again with tier:"evidence" and content.',
99
+ ...(localeHint === undefined ? {} : { localeHint }),
100
+ });
101
+ }
42
102
  return okJson({
43
103
  mode: 'template',
44
104
  genre,
45
- locale: args.locale,
46
- skeleton: genreSkeleton(genre, args.locale),
105
+ locale,
106
+ skeleton: genreSkeleton(genre, locale),
47
107
  note: 'Nothing was written to the wiki (template mode, no content). Fill the skeleton and call historian_page_create again with content.',
108
+ ...(localeHint === undefined ? {} : { localeHint }),
48
109
  });
49
110
  }
50
111
  try {
112
+ const collision = await collisionAdvice(deps, {
113
+ tier,
114
+ path: args.path,
115
+ locale,
116
+ title: args.title,
117
+ baseUrl: deps.options.baseUrl,
118
+ });
51
119
  const result = await createPage(pageDeps(deps), {
52
120
  path: args.path,
53
- locale: args.locale,
121
+ locale,
54
122
  title: args.title,
55
123
  content: args.content,
56
- tags: args.tags,
57
- isPublished: args.isPublished,
58
- twin: args.twin,
124
+ tags: isEvidence ? [...new Set([...args.tags, 'evidence'])] : args.tags,
125
+ isPublished: isEvidence ? false : args.isPublished,
126
+ isPrivate: isEvidence,
127
+ twin: isEvidence ? false : args.twin,
59
128
  description: args.description,
60
129
  });
130
+ const advisories = [
131
+ frontDumpAdvisory(tier, args.content),
132
+ collision,
133
+ // Evidence raw material is not a genre page — the gate is front-only.
134
+ isEvidence ? null : checklistAdvisory(genre, args.content),
135
+ ].filter((a) => a !== null);
61
136
  return okJson({
62
137
  mode: 'create',
63
138
  path: args.path,
64
- locale: args.locale,
139
+ locale,
65
140
  pageId: result.pageId,
66
141
  twinStatus: result.twinStatus,
67
142
  twinReason: result.twinReason,
68
143
  twinId: result.twinId,
69
144
  urls: urlPair(result),
145
+ ...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
146
+ ...(localeHint === undefined ? {} : { localeHint }),
147
+ ...(advisories.length === 0 ? {} : { advisory: advisories.join('\n') }),
70
148
  });
71
149
  }
72
150
  catch (err) {
@@ -8,6 +8,9 @@ import { tool } from '@opencode-ai/plugin';
8
8
  import { TranslateError } from '../translate.js';
9
9
  import { buildChronology, filterRowsByPath } from '../chronology.js';
10
10
  import { getMap, refreshMapCache, CACHE_PATH } from '../map.js';
11
+ import { buildMaintainReport, renderMaintainMarkdown } from '../maintain.js';
12
+ import { normalizeLocale, PathValidationError } from '../wiki/locale.js';
13
+ import { listPages, readPage } from '../wiki/pages.read.js';
11
14
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
12
15
  const s = tool.schema;
13
16
  const TRANSLATE_ARGS = {
@@ -53,20 +56,62 @@ export function makeTranslateSnippetTool(deps) {
53
56
  });
54
57
  }
55
58
  const MAP_ARGS = {
56
- action: s.enum(['show', 'refresh', 'timeline']).default('show'),
59
+ action: s.enum(['show', 'refresh', 'timeline', 'maintain']).default('show'),
57
60
  days: s.number().int().positive().optional().describe('timeline: keep only rows updated within the last N days'),
58
61
  path: s.string().optional().describe('timeline: section/path prefix filter (e.g. ops)'),
62
+ deep: s.boolean().optional().describe('maintain: additionally read every page body (freshness stamps + redirect stubs) — one bounded read per row'),
59
63
  };
60
64
  const MapArgsSchema = s.object(MAP_ARGS);
65
+ /** maintain: light tier is map rows + ONE read-only pages.list pass per locale
66
+ * (the mirror's MapRow carries no tags; the list join restores the vocab view);
67
+ * deep additionally reads each body via readPage. Reserved-path pages (e.g.
68
+ * 'home', probe p1) answer null instead of killing the sweep. Read-only. */
69
+ async function runMaintain(deps, mapDeps, snapshot, deep) {
70
+ const client = deps.getClient();
71
+ const tagIndex = new Map();
72
+ const locales = [...new Set(deps.options.locales.map(normalizeLocale))].sort();
73
+ for (const locale of locales) {
74
+ for (const item of await listPages(client, { locale })) {
75
+ tagIndex.set(`${item.locale}\u0000${item.path}`, item.tags);
76
+ }
77
+ }
78
+ const rows = snapshot.rows.map((r) => ({ ...r, tags: tagIndex.get(`${r.locale}\u0000${r.path}`) ?? [] }));
79
+ const readBody = deep
80
+ ? async (path, locale) => {
81
+ try {
82
+ return (await readPage(client, path, locale))?.content ?? null;
83
+ }
84
+ catch (err) {
85
+ // Unreadable page (invalid path / transport) is a scan miss, not a report failure.
86
+ if (err instanceof PathValidationError)
87
+ return null;
88
+ throw err;
89
+ }
90
+ }
91
+ : undefined;
92
+ const report = await buildMaintainReport({ rows, mapGeneratedAt: snapshot.generatedAt, mapStaleSeconds: snapshot.staleSeconds }, { deep, readBody });
93
+ return {
94
+ action: 'maintain',
95
+ deep: report.deep,
96
+ generatedAt: report.generatedAt,
97
+ rowCount: report.rowCount,
98
+ report,
99
+ markdown: renderMaintainMarkdown(report),
100
+ urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
101
+ };
102
+ }
61
103
  // --- historian_map -----------------------------------------------------------
62
104
  export function makeMapTool(deps) {
63
105
  return tool({
64
106
  description: `Inspect (show), rebuild (refresh), or aggregate recent updates (timeline) over the en/zh page map ` +
65
- `with its local mirror + _meta/page-map cache page. ` +
107
+ `with its local mirror + _meta/page-map cache page. Two roles: the local mirror is the live query ` +
108
+ `source for show/timeline; the wiki page is the audit ledger (every refresh commits a new wiki revision). ` +
66
109
  `show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
67
110
  `(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
68
111
  `optional days window + section/path prefix filter) into a human markdown table + machine-readable weeks JSON. ` +
69
- `${URL_MANDATE}.`,
112
+ `maintain runs the read-only curation sweep (twin gap, near-duplicate titles, staleness, diffusion/orphan ` +
113
+ `candidates, tag vocab, section distribution; deep:true adds per-body freshness stamps + redirect stubs) and ` +
114
+ `answers a markdown report with a stable-key JSON tail. ${URL_MANDATE}.`,
70
115
  args: MAP_ARGS,
71
116
  execute: async (raw) => {
72
117
  const args = MapArgsSchema.parse(raw);
@@ -91,6 +136,9 @@ export function makeMapTool(deps) {
91
136
  urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
92
137
  });
93
138
  }
139
+ if (args.action === 'maintain') {
140
+ return okJson(await runMaintain(deps, mapDeps, snapshot, args.deep ?? false));
141
+ }
94
142
  return okJson({
95
143
  action: 'show',
96
144
  generatedAt: snapshot.generatedAt,
@@ -6,12 +6,11 @@
6
6
  */
7
7
  import { tool } from '@opencode-ai/plugin';
8
8
  import { deletePage, movePage } from '../wiki/pages.js';
9
- import { selfReviewChecklist } from '../templates/genres.js';
10
- import { confirmRequiredJson, errEnvelope, okJson, urlPair, URL_MANDATE, pageDeps } from './shared.js';
9
+ import { GENRES, selfReviewChecklist } from '../templates/genres.js';
10
+ import { confirmRequiredJson, errEnvelope, okJson, sectionRefusalJson, urlPair, URL_MANDATE, pageDeps, } from './shared.js';
11
11
  import { reformatPageDraft } from '../migrate.js';
12
12
  import { applyMigration } from '../migrate-apply.js';
13
13
  const s = tool.schema;
14
- const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
15
14
  const DELETE_ARGS = {
16
15
  path: s.string(),
17
16
  locale: s.enum(['en', 'zh']).default('en'),
@@ -28,6 +27,9 @@ export function makeDeleteTool(deps) {
28
27
  const args = DeleteArgsSchema.parse(raw);
29
28
  if (args.confirm !== 'yes')
30
29
  return confirmRequiredJson('historian_delete', args.confirm);
30
+ const offSections = sectionRefusalJson(args.path, deps.options.sections);
31
+ if (offSections !== null)
32
+ return offSections;
31
33
  try {
32
34
  const result = await deletePage(pageDeps(deps), args.path, args.locale, 'yes');
33
35
  return okJson({
@@ -62,6 +64,11 @@ export function makeMoveTool(deps) {
62
64
  const args = MoveArgsSchema.parse(raw);
63
65
  if (args.confirm !== 'yes')
64
66
  return confirmRequiredJson('historian_move', args.confirm);
67
+ // The allow-list guards the DESTINATION (the write target); the source
68
+ // page may legally live somewhere the new configuration no longer admits.
69
+ const offSections = sectionRefusalJson(args.newPath, deps.options.sections);
70
+ if (offSections !== null)
71
+ return offSections;
65
72
  try {
66
73
  const destLocale = args.newLocale ?? args.locale;
67
74
  const result = await movePage(pageDeps(deps), args.path, args.locale, args.newPath, destLocale, 'yes');
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { tool } from '@opencode-ai/plugin';
7
7
  import { normalizeLocale } from '../wiki/locale.js';
8
- import { readPage, searchPages } from '../wiki/pages.js';
8
+ import { listPages, readPage, searchPages } from '../wiki/pages.js';
9
9
  import { errEnvelope, okJson, reportUrls, URL_MANDATE } from './shared.js';
10
10
  const s = tool.schema;
11
11
  const READ_ARGS = {
@@ -56,22 +56,89 @@ export function makeReadTool(deps) {
56
56
  },
57
57
  });
58
58
  }
59
+ const TAG_MODES = ['any', 'all'];
59
60
  const SEARCH_ARGS = {
60
61
  query: s.string(),
61
62
  kind: s.enum(['title', 'content']).default('content').describe('Informational intent; the wiki index covers both title and content'),
63
+ tags: s
64
+ .array(s.string().min(1))
65
+ .min(1)
66
+ .max(5)
67
+ .refine((arr) => arr.every((t) => t.trim().length > 0), {
68
+ message: 'tags entries must be non-blank (whitespace-only rejected)',
69
+ })
70
+ .optional()
71
+ .describe('Filter to pages carrying these tags (1-5). tagsMode "all" (DEFAULT) = EVERY listed tag ' +
72
+ 'must be present on the page — the server $tags mechanism is AND-only. tagsMode "any" = at ' +
73
+ 'LEAST ONE tag matches (client-side per-tag fan-out + union). Entries are trimmed and ' +
74
+ 'deduped; blank entries are rejected. Result rows include each page\'s tags so the agent ' +
75
+ 'can see the live tag vocabulary.'),
76
+ tagsMode: s
77
+ .enum(TAG_MODES)
78
+ .default('all')
79
+ .describe('"all" (default): every tag must match (server-side AND, one request). "any": at least one tag (client-side union).'),
62
80
  };
63
81
  const SearchArgsSchema = s.object(SEARCH_ARGS);
82
+ /** (path, locale) identity for union dedupe and query intersection. The NUL
83
+ * separator cannot appear in a path or locale, so concatenation stays injective. */
84
+ const matchKey = (path, locale) => `${locale}\u0000${path}`;
85
+ /** Keys of the text-query result set, locale-normalized. Rows whose locale is
86
+ * outside the en/zh whitelist never equal a list row (those are always en/zh),
87
+ * so they simply cannot join the intersection. */
88
+ function textKeysOf(resp) {
89
+ const keys = new Set();
90
+ for (const r of resp.results) {
91
+ try {
92
+ keys.add(matchKey(r.path, normalizeLocale(r.locale)));
93
+ }
94
+ catch {
95
+ /* unsupported locale — legacy mapper keeps the row with url:null; the tag
96
+ intersection just ignores it */
97
+ }
98
+ }
99
+ return keys;
100
+ }
101
+ /** tagsMode "any": one list call PER tag, unioned and deduped by (path, locale).
102
+ * Resilience contract: a failed leg is dropped and named in `failed` — no new
103
+ * error class surfaces while ≥1 leg survives; only when EVERY leg fails does
104
+ * the original rejection propagate unchanged (standard error envelope). */
105
+ async function unionByAnyTag(client, tags) {
106
+ const legs = await Promise.allSettled(tags.map((t) => listPages(client, { tags: [t] })));
107
+ const seen = new Set();
108
+ const rows = [];
109
+ const failures = [];
110
+ legs.forEach((leg, i) => {
111
+ if (leg.status === 'fulfilled') {
112
+ for (const row of leg.value) {
113
+ const k = matchKey(row.path, row.locale);
114
+ if (!seen.has(k)) {
115
+ seen.add(k);
116
+ rows.push(row);
117
+ }
118
+ }
119
+ }
120
+ else {
121
+ failures.push({ tag: tags[i], reason: leg.reason });
122
+ }
123
+ });
124
+ if (failures.length === tags.length)
125
+ throw failures[0].reason; // schema guarantees tags.length ≥ 1
126
+ return { rows, failed: failures.map((f) => f.tag) };
127
+ }
64
128
  // --- historian_search --------------------------------------------------------
65
129
  export function makeSearchTool(deps) {
66
130
  return tool({
67
131
  description: `Full-text search over the wiki. kind is informational intent only — the live wiki.js ` +
68
132
  `search indexes title AND content (the engine signature is search(query, path, locale), no field scope). ` +
133
+ `Optional tags filter: tagsMode "all" (DEFAULT) requires EVERY tag on the page (server-side ` +
134
+ `AND); "any" matches AT LEAST ONE tag. Result rows carry each page's tags. ` +
69
135
  `Results carry their en/zh URLs. ${URL_MANDATE}.`,
70
136
  args: SEARCH_ARGS,
71
137
  execute: async (raw) => {
72
138
  const args = SearchArgsSchema.parse(raw);
73
139
  try {
74
- const resp = await searchPages(deps.getClient(), args.query);
140
+ const client = deps.getClient();
141
+ const resp = await searchPages(client, args.query);
75
142
  const results = resp.results.map((r) => {
76
143
  try {
77
144
  const locale = normalizeLocale(r.locale);
@@ -88,12 +155,53 @@ export function makeSearchTool(deps) {
88
155
  return { id: r.id, title: r.title, description: r.description, path: r.path, locale: r.locale, url: null };
89
156
  }
90
157
  });
158
+ if (args.tags === undefined) {
159
+ // Legacy path — no tags given, no tags keys leak into the envelope.
160
+ return okJson({
161
+ query: args.query,
162
+ kind: args.kind,
163
+ totalHits: resp.totalHits,
164
+ suggestions: resp.suggestions,
165
+ results,
166
+ });
167
+ }
168
+ const wanted = [...new Set(args.tags.map((t) => t.trim()))];
169
+ let tagged;
170
+ let tagsFailed;
171
+ switch (args.tagsMode) {
172
+ case 'all':
173
+ // Straight passthrough: ONE request, the server's $tags AND mechanism.
174
+ tagged = await listPages(client, { tags: wanted });
175
+ tagsFailed = [];
176
+ break;
177
+ case 'any': {
178
+ const union = await unionByAnyTag(client, wanted);
179
+ tagged = union.rows;
180
+ tagsFailed = union.failed;
181
+ break;
182
+ }
183
+ }
184
+ const textKeys = textKeysOf(resp);
185
+ const matched = tagged
186
+ .filter((row) => textKeys.has(matchKey(row.path, row.locale)))
187
+ .map((row) => ({
188
+ id: row.id,
189
+ title: row.title,
190
+ description: row.description,
191
+ path: row.path,
192
+ locale: row.locale,
193
+ url: reportUrls(deps.options.baseUrl, row.path, row.locale)[row.locale],
194
+ tags: row.tags,
195
+ }));
91
196
  return okJson({
92
197
  query: args.query,
93
198
  kind: args.kind,
94
- totalHits: resp.totalHits,
199
+ tags: wanted,
200
+ tagsMode: args.tagsMode,
201
+ tagsFailed,
202
+ totalHits: matched.length,
95
203
  suggestions: resp.suggestions,
96
- results,
204
+ results: matched,
97
205
  });
98
206
  }
99
207
  catch (err) {