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 +75 -21
- package/dist/chronology.js +6 -0
- package/dist/config.d.ts +9 -5
- package/dist/config.js +3 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +60 -18
- package/dist/loop-state.d.ts +28 -0
- package/dist/loop-state.js +46 -0
- package/dist/maintain.d.ts +123 -0
- package/dist/maintain.js +352 -0
- package/dist/map.d.ts +7 -1
- package/dist/map.js +17 -3
- package/dist/migrate-score.d.ts +2 -1
- package/dist/migrate-score.js +58 -5
- package/dist/templates/evidence.d.ts +28 -0
- package/dist/templates/evidence.js +34 -0
- package/dist/templates/genres.d.ts +3 -3
- package/dist/templates/genres.js +32 -5
- package/dist/templates/skeletons.d.ts +7 -0
- package/dist/templates/skeletons.js +105 -4
- package/dist/tools/create.js +90 -12
- package/dist/tools/local.js +51 -3
- package/dist/tools/mutate.js +10 -3
- package/dist/tools/read.js +112 -4
- package/dist/tools/shared.d.ts +77 -1
- package/dist/tools/shared.js +219 -0
- package/dist/tools/write.js +37 -2
- package/dist/wiki/locale.d.ts +2 -1
- package/dist/wiki/locale.js +8 -2
- package/package.json +1 -1
- package/skills/historian/SKILL.md +140 -31
- package/skills/historian/references/adapting-your-own-wiki.md +17 -5
- package/skills/historian/references/genres.md +98 -5
- package/skills/historian/references/rules.md +7 -4
- package/skills/historian/references/style.md +11 -1
|
@@ -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
|
-
<!--
|
|
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
|
-
<!--
|
|
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. -->`;
|
package/dist/tools/create.js
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
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..
|
|
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
|
-
|
|
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
|
|
46
|
-
skeleton: genreSkeleton(genre,
|
|
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
|
|
121
|
+
locale,
|
|
54
122
|
title: args.title,
|
|
55
123
|
content: args.content,
|
|
56
|
-
tags: args.tags,
|
|
57
|
-
isPublished: args.isPublished,
|
|
58
|
-
|
|
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
|
|
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) {
|
package/dist/tools/local.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/tools/mutate.js
CHANGED
|
@@ -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');
|
package/dist/tools/read.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|