opencode-wiki-historian 0.3.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 +34 -17
- package/dist/index.js +34 -16
- package/dist/maintain.d.ts +123 -0
- package/dist/maintain.js +352 -0
- package/dist/migrate-score.d.ts +2 -1
- package/dist/migrate-score.js +58 -5
- 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 +101 -0
- package/dist/tools/create.js +47 -7
- package/dist/tools/local.js +49 -2
- package/dist/tools/mutate.js +10 -3
- package/dist/tools/read.js +112 -4
- package/dist/tools/shared.d.ts +50 -1
- package/dist/tools/shared.js +119 -0
- package/dist/tools/write.js +7 -1
- 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 +128 -29
- package/skills/historian/references/adapting-your-own-wiki.md +4 -2
- package/skills/historian/references/genres.md +87 -5
- package/skills/historian/references/rules.md +6 -3
- package/skills/historian/references/style.md +11 -1
package/dist/tools/create.js
CHANGED
|
@@ -7,16 +7,16 @@
|
|
|
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 {
|
|
10
|
+
import { listPages, readPage } from '../wiki/pages.read.js';
|
|
11
|
+
import { classifyGenre, genreSkeleton, GENRES } from '../templates/genres.js';
|
|
11
12
|
import { evidenceSkeleton } from '../templates/evidence.js';
|
|
12
|
-
import { enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
|
|
13
|
+
import { checklistAdvisory, collisionAdvisory, enforceTierPath, errEnvelope, frontDumpAdvisory, MACHINE_TIER_NOTE, okJson, pageDeps, sectionRefusalJson, tierMismatchJson, TIERS, urlPair, URL_MANDATE, } from './shared.js';
|
|
13
14
|
const s = tool.schema;
|
|
14
|
-
const GENRES = ['G1', 'G2', 'G3', 'G4', 'G5'];
|
|
15
15
|
const ARGS_SHAPE = {
|
|
16
16
|
path: s.string().describe('Wiki path, e.g. docs/guides/foo (first segment must NOT look like a locale code)'),
|
|
17
17
|
title: s.string().describe('Page title'),
|
|
18
18
|
content: s.string().optional().describe('Page body (markdown). ABSENT → local template mode, nothing written'),
|
|
19
|
-
genre: s.enum(GENRES).optional().describe('Genre hint: G1..
|
|
19
|
+
genre: s.enum(GENRES).optional().describe('Genre hint: G1..G6 (template mode / classification)'),
|
|
20
20
|
locale: s.enum(['en', 'zh']).default('en'),
|
|
21
21
|
isPublished: s.boolean().default(true),
|
|
22
22
|
tags: s.array(s.string()).default([]),
|
|
@@ -25,6 +25,28 @@ const ARGS_SHAPE = {
|
|
|
25
25
|
tier: s.enum(TIERS).default('front').describe('front = bilingual human page; evidence = machine page under _meta/ or _evidence/ (hidden, unpublished, monolingual en)'),
|
|
26
26
|
};
|
|
27
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
|
+
}
|
|
28
50
|
export function makeCreateTool(deps) {
|
|
29
51
|
return tool({
|
|
30
52
|
description: `Create a wiki page: primary locale content plus an optional auto-translated twin. ` +
|
|
@@ -40,6 +62,9 @@ export function makeCreateTool(deps) {
|
|
|
40
62
|
catch (err) {
|
|
41
63
|
return errEnvelope(err);
|
|
42
64
|
}
|
|
65
|
+
const offSections = sectionRefusalJson(args.path, deps.options.sections);
|
|
66
|
+
if (offSections !== null)
|
|
67
|
+
return offSections;
|
|
43
68
|
const mismatch = enforceTierPath(tier, args.path);
|
|
44
69
|
if (mismatch !== null)
|
|
45
70
|
return tierMismatchJson(mismatch);
|
|
@@ -50,6 +75,10 @@ export function makeCreateTool(deps) {
|
|
|
50
75
|
const localeHint = isEvidence && args.locale === 'zh'
|
|
51
76
|
? 'evidence pages are monolingual en — the locale argument was forced to "en"'
|
|
52
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;
|
|
53
82
|
if (args.content === undefined || args.content.trim() === '') {
|
|
54
83
|
if (isEvidence) {
|
|
55
84
|
// Evidence pages are not genre-templated: echo the machine skeleton.
|
|
@@ -70,7 +99,6 @@ export function makeCreateTool(deps) {
|
|
|
70
99
|
...(localeHint === undefined ? {} : { localeHint }),
|
|
71
100
|
});
|
|
72
101
|
}
|
|
73
|
-
const genre = args.genre ?? classifyGenre({ title: args.title, body: args.description ?? '' }).genre;
|
|
74
102
|
return okJson({
|
|
75
103
|
mode: 'template',
|
|
76
104
|
genre,
|
|
@@ -81,6 +109,13 @@ export function makeCreateTool(deps) {
|
|
|
81
109
|
});
|
|
82
110
|
}
|
|
83
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
|
+
});
|
|
84
119
|
const result = await createPage(pageDeps(deps), {
|
|
85
120
|
path: args.path,
|
|
86
121
|
locale,
|
|
@@ -92,7 +127,12 @@ export function makeCreateTool(deps) {
|
|
|
92
127
|
twin: isEvidence ? false : args.twin,
|
|
93
128
|
description: args.description,
|
|
94
129
|
});
|
|
95
|
-
const
|
|
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);
|
|
96
136
|
return okJson({
|
|
97
137
|
mode: 'create',
|
|
98
138
|
path: args.path,
|
|
@@ -104,7 +144,7 @@ export function makeCreateTool(deps) {
|
|
|
104
144
|
urls: urlPair(result),
|
|
105
145
|
...(isEvidence ? { note: MACHINE_TIER_NOTE } : {}),
|
|
106
146
|
...(localeHint === undefined ? {} : { localeHint }),
|
|
107
|
-
...(
|
|
147
|
+
...(advisories.length === 0 ? {} : { advisory: advisories.join('\n') }),
|
|
108
148
|
});
|
|
109
149
|
}
|
|
110
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,11 +56,50 @@ 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({
|
|
@@ -67,7 +109,9 @@ export function makeMapTool(deps) {
|
|
|
67
109
|
`show reads the local mirror (zero writes); refresh rebuilds from the wiki and writes the mirror + cache page ` +
|
|
68
110
|
`(idempotent — the engine upserts via full RMW); timeline groups mirror rows by ISO week (newest first, ` +
|
|
69
111
|
`optional days window + section/path prefix filter) into a human markdown table + machine-readable weeks JSON. ` +
|
|
70
|
-
|
|
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}.`,
|
|
71
115
|
args: MAP_ARGS,
|
|
72
116
|
execute: async (raw) => {
|
|
73
117
|
const args = MapArgsSchema.parse(raw);
|
|
@@ -92,6 +136,9 @@ export function makeMapTool(deps) {
|
|
|
92
136
|
urls: reportUrls(deps.options.baseUrl, CACHE_PATH, 'en'),
|
|
93
137
|
});
|
|
94
138
|
}
|
|
139
|
+
if (args.action === 'maintain') {
|
|
140
|
+
return okJson(await runMaintain(deps, mapDeps, snapshot, args.deep ?? false));
|
|
141
|
+
}
|
|
95
142
|
return okJson({
|
|
96
143
|
action: 'show',
|
|
97
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) {
|
package/dist/tools/shared.d.ts
CHANGED
|
@@ -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,51 @@ 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;
|
package/dist/tools/shared.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
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';
|
|
12
14
|
/** Engine deps for one operation; client resolved lazily at use time. */
|
|
13
15
|
export function pageDeps(deps) {
|
|
14
16
|
return { client: deps.getClient(), options: deps.options, translate: deps.translate };
|
|
@@ -185,3 +187,120 @@ export function frontDumpAdvisory(tier, content) {
|
|
|
185
187
|
return (`content contains a ${lines}-line fenced block; per contract, move raw material to a ` +
|
|
186
188
|
`tier:"evidence" page under _evidence/ and link it from the human page (SYN-16)`);
|
|
187
189
|
}
|
|
190
|
+
// --- Create-path collision advisory (v4 todo 3) --------------------------------
|
|
191
|
+
/** Title equality for duplicate detection: trim, collapse internal whitespace,
|
|
192
|
+
* casefold — 'LLM Eval' == ' llm\neval '. */
|
|
193
|
+
function normalizeTitle(title) {
|
|
194
|
+
return title.trim().replace(/\s+/gu, ' ').toLocaleLowerCase();
|
|
195
|
+
}
|
|
196
|
+
/** Number of duplicate paths shown verbatim before the overflow count. */
|
|
197
|
+
const COLLISION_DUPE_DISPLAY_LIMIT = 3;
|
|
198
|
+
/** Advisory-only duplicate detector for historian_page_create:
|
|
199
|
+
* (a) the exact target (path, locale) already exists → prefer
|
|
200
|
+
* historian_page_update, with the page URL;
|
|
201
|
+
* (b) the same normalized title lives on a DIFFERENT non-machine path →
|
|
202
|
+
* 疑似重复 … 先读再写, with each path's URLs (first 3, then a count).
|
|
203
|
+
* Pure over its inputs and advisory-only: it NEVER throws and NEVER blocks —
|
|
204
|
+
* a same-path other-locale twin is not a duplicate, evidence-tier writes skip
|
|
205
|
+
* (b) (raw-material pages legitimately echo human titles), and machine
|
|
206
|
+
* namespaces (_meta/, _evidence/) never surface as duplicates. */
|
|
207
|
+
export function collisionAdvisory(input) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
if (input.exists) {
|
|
210
|
+
const urls = reportUrls(input.baseUrl, input.path, input.locale);
|
|
211
|
+
parts.push(`path exists — '${input.path}' (${input.locale}) already holds a page; ` +
|
|
212
|
+
`prefer historian_page_update to amend it; ${urls[input.locale]}`);
|
|
213
|
+
}
|
|
214
|
+
const norm = normalizeTitle(input.title);
|
|
215
|
+
if (input.tier === 'front' && norm !== '') {
|
|
216
|
+
const dupes = input.inventory.filter((row) => row.path !== input.path && !isInternalPath(row.path) && normalizeTitle(row.title) === norm);
|
|
217
|
+
const paths = [...new Set(dupes.map((row) => row.path))];
|
|
218
|
+
if (paths.length > 0) {
|
|
219
|
+
const shown = paths.slice(0, COLLISION_DUPE_DISPLAY_LIMIT).map((p) => {
|
|
220
|
+
const urls = reportUrls(input.baseUrl, p, input.locale);
|
|
221
|
+
return `${p} (en=${urls.en} zh=${urls.zh})`;
|
|
222
|
+
});
|
|
223
|
+
const overflow = paths.length - shown.length;
|
|
224
|
+
parts.push(`疑似重复: title matches other path(s) ${shown.join('; ')}` +
|
|
225
|
+
(overflow > 0 ? ` +${overflow} more` : '') +
|
|
226
|
+
` — 先读再写 (historian_read the existing page, prefer historian_page_update over a new twin)`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return parts.length === 0 ? null : parts.join('\n');
|
|
230
|
+
}
|
|
231
|
+
// --- Create-path pre-write checklist gate (v4 todo 4) --------------------------
|
|
232
|
+
/** Failing items needed to speak up: 1-2 stragglers are noise, 3+ is a draft
|
|
233
|
+
* worth flagging. Threshold per the plan (todo 4). */
|
|
234
|
+
const CHECKLIST_FAIL_TRIGGER = 3;
|
|
235
|
+
/** The zh short name of a checklist item: its label up to the first
|
|
236
|
+
* full-width/latin colon or bracket — '导言占比 10–15%'. */
|
|
237
|
+
function itemShortName(label) {
|
|
238
|
+
const cut = (label.split(/[::((]/u, 1)[0] ?? label).trim();
|
|
239
|
+
return cut === '' ? label.trim() : cut;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Pre-write self-check advisory (plan todo 4): score the draft with the SAME
|
|
243
|
+
* 10-item gate the migrate pipeline uses (scoreChecklist — no duplicated
|
|
244
|
+
* scoring logic), purely locally, zero network. 3+ failing items produce a
|
|
245
|
+
* hint naming them; items 9-10 are 'deferred' pre-write by the scorer's own
|
|
246
|
+
* contract (migrate-score.ts) and can never count as fail. Informational
|
|
247
|
+
* only — the write always proceeds; evidence tier is exempt (callers pass
|
|
248
|
+
* front only: raw material is not a genre page).
|
|
249
|
+
*/
|
|
250
|
+
export function checklistAdvisory(genre, draft) {
|
|
251
|
+
const failed = scoreChecklist(genre, draft).filter((v) => v.verdict === 'fail');
|
|
252
|
+
if (failed.length < CHECKLIST_FAIL_TRIGGER)
|
|
253
|
+
return null;
|
|
254
|
+
const labels = new Map(selfReviewChecklist(genre).map((item) => [item.id, item.label]));
|
|
255
|
+
const names = failed.map((v) => `#${v.id} ${itemShortName(labels.get(v.id) ?? '?')}`).join('; ');
|
|
256
|
+
return `自检 ${failed.length}/10 未通过: ${names} (不阻断, 发布前请补齐)`;
|
|
257
|
+
}
|
|
258
|
+
// --- options.sections enforcement (v4 todo 5) ---------------------------------
|
|
259
|
+
/** Config-level write refusal, paralleling PathValidationError's shape. The
|
|
260
|
+
* class NAME is the routing key errEnvelope dispatches on: this shares the
|
|
261
|
+
* 'ConfigError' hint case with jsonc.ConfigError (which adds a code field
|
|
262
|
+
* this pure path rule does not need). */
|
|
263
|
+
export class ConfigError extends Error {
|
|
264
|
+
constructor(message) {
|
|
265
|
+
super(message);
|
|
266
|
+
this.name = 'ConfigError';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** First path segments the plugin maintains for its own bookkeeping — always
|
|
270
|
+
* writable regardless of sections: a topic taxonomy configured for human
|
|
271
|
+
* knowledge must never lock out the home landing page, the wiki-index map,
|
|
272
|
+
* the _sandbox scratch area, the _data store, or the machine namespaces. */
|
|
273
|
+
const SECTION_EXEMPT_SEGMENTS = [
|
|
274
|
+
'home',
|
|
275
|
+
'wiki-index',
|
|
276
|
+
'_sandbox',
|
|
277
|
+
'_data',
|
|
278
|
+
...INTERNAL_NAMESPACES,
|
|
279
|
+
];
|
|
280
|
+
/** Section entry as configured → comparable form (strip slashes/whitespace). */
|
|
281
|
+
function normalizeSection(entry) {
|
|
282
|
+
return entry.trim().replace(/^\/+|\/+$/gu, '');
|
|
283
|
+
}
|
|
284
|
+
/** Pure allow-list check on a write target path: null = allowed, string =
|
|
285
|
+
* refusal message. Empty/undefined allow-list = allow-all (the documented
|
|
286
|
+
* default, config.ts HistorianOptions.sections). Match is segment-wise and
|
|
287
|
+
* case-sensitive: section 'doc' authorizes 'doc' and 'doc/x', never 'docs/x'. */
|
|
288
|
+
export function sectionGuard(path, allowedSections) {
|
|
289
|
+
if (allowedSections === undefined)
|
|
290
|
+
return null;
|
|
291
|
+
const sections = allowedSections.map(normalizeSection).filter((sec) => sec !== '');
|
|
292
|
+
if (sections.length === 0)
|
|
293
|
+
return null;
|
|
294
|
+
const first = path.split('/')[0];
|
|
295
|
+
if (SECTION_EXEMPT_SEGMENTS.includes(first))
|
|
296
|
+
return null;
|
|
297
|
+
if (sections.some((sec) => path === sec || path.startsWith(`${sec}/`)))
|
|
298
|
+
return null;
|
|
299
|
+
return (`section '${first}' is not in the configured sections [${sections.join(', ')}] — ` +
|
|
300
|
+
`write the page under an allowed section or add '${first}' to the plugin's sections option`);
|
|
301
|
+
}
|
|
302
|
+
/** Guard + envelope in one step: null = proceed, ToolResult = refuse. */
|
|
303
|
+
export function sectionRefusalJson(path, allowedSections) {
|
|
304
|
+
const violation = sectionGuard(path, allowedSections);
|
|
305
|
+
return violation === null ? null : errEnvelope(new ConfigError(violation));
|
|
306
|
+
}
|
package/dist/tools/write.js
CHANGED
|
@@ -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, 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,6 +25,9 @@ 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) {
|
|
@@ -116,6 +119,9 @@ export function makeAppendTool(deps) {
|
|
|
116
119
|
if (isEvidence && args.sectionZh !== undefined) {
|
|
117
120
|
return monolingualRefusalJson('historian_page_append', 'sectionZh');
|
|
118
121
|
}
|
|
122
|
+
const offSections = sectionRefusalJson(args.path, deps.options.sections);
|
|
123
|
+
if (offSections !== null)
|
|
124
|
+
return offSections;
|
|
119
125
|
try {
|
|
120
126
|
const appended = await appendSection(pageDeps(deps), args.path, args.locale, args.section);
|
|
121
127
|
let zhStatus;
|
package/dist/wiki/locale.d.ts
CHANGED
|
@@ -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.
|
package/dist/wiki/locale.js
CHANGED
|
@@ -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
|
-
|
|
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