opencode-wiki-historian 0.2.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/LICENSE +21 -0
- package/README.md +381 -0
- package/dist/chronology.d.ts +36 -0
- package/dist/chronology.js +67 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.js +158 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +136 -0
- package/dist/jsonc.d.ts +17 -0
- package/dist/jsonc.js +131 -0
- package/dist/map.d.ts +58 -0
- package/dist/map.js +196 -0
- package/dist/migrate-apply.d.ts +40 -0
- package/dist/migrate-apply.js +144 -0
- package/dist/migrate-score.d.ts +29 -0
- package/dist/migrate-score.js +267 -0
- package/dist/migrate-store.d.ts +52 -0
- package/dist/migrate-store.js +77 -0
- package/dist/migrate.d.ts +65 -0
- package/dist/migrate.js +111 -0
- package/dist/templates/genres.d.ts +65 -0
- package/dist/templates/genres.js +228 -0
- package/dist/templates/skeletons.d.ts +48 -0
- package/dist/templates/skeletons.js +558 -0
- package/dist/tools/create.d.ts +9 -0
- package/dist/tools/create.js +77 -0
- package/dist/tools/local.d.ts +10 -0
- package/dist/tools/local.js +107 -0
- package/dist/tools/mutate.d.ts +11 -0
- package/dist/tools/mutate.js +157 -0
- package/dist/tools/read.d.ts +9 -0
- package/dist/tools/read.js +104 -0
- package/dist/tools/shared.d.ts +52 -0
- package/dist/tools/shared.js +87 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.js +148 -0
- package/dist/tools.d.ts +23 -0
- package/dist/tools.js +43 -0
- package/dist/translate.d.ts +44 -0
- package/dist/translate.js +207 -0
- package/dist/wiki/assets.d.ts +42 -0
- package/dist/wiki/assets.js +91 -0
- package/dist/wiki/client.d.ts +67 -0
- package/dist/wiki/client.js +221 -0
- package/dist/wiki/locale.d.ts +66 -0
- package/dist/wiki/locale.js +154 -0
- package/dist/wiki/pages.d.ts +7 -0
- package/dist/wiki/pages.js +7 -0
- package/dist/wiki/pages.read.d.ts +114 -0
- package/dist/wiki/pages.read.js +114 -0
- package/dist/wiki/pages.write.d.ts +109 -0
- package/dist/wiki/pages.write.js +201 -0
- package/package.json +36 -0
- package/skills/historian/SKILL.md +294 -0
- package/skills/historian/references/adapting-your-own-wiki.md +53 -0
- package/skills/historian/references/genres.md +160 -0
- package/skills/historian/references/rules.md +30 -0
- package/skills/historian/references/style.md +84 -0
- package/skills/historian/references/wikijs-guide.md +87 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page WRITE operations: create (with optional twin), update (full
|
|
3
|
+
* read-modify-write), append, move, delete.
|
|
4
|
+
*
|
|
5
|
+
* Split from pages.ts (read ops + shared types live in pages.read.ts) to keep
|
|
6
|
+
* every source file under the 250-LOC ceiling. Field names are LIVE-
|
|
7
|
+
* INTROSPECTED on the running wiki.js instance, never guessed. Pitfalls this
|
|
8
|
+
* module encodes:
|
|
9
|
+
*
|
|
10
|
+
* #1 update() wipes any field the payload omits → every mutable field is
|
|
11
|
+
* echoed from the read and merged with the patch
|
|
12
|
+
* #2 tags is required on update → always present in the payload
|
|
13
|
+
* #4 create() rejects empty content → pre-checked client-side
|
|
14
|
+
* #5 path moves go through move(id, destinationPath, destinationLocale),
|
|
15
|
+
* never update(path:)
|
|
16
|
+
* #8 create()'s response id is unreliable → authoritative id always comes
|
|
17
|
+
* from a follow-up readPage lookup
|
|
18
|
+
* live mutation fields are scriptCss/scriptJs (not styleCss/styleJs)
|
|
19
|
+
* live mutation RESPONSES crash on page.locale ('Cannot return null for
|
|
20
|
+
* non-nullable field Page.locale.' — wiki.js 2.5.314) while the side effect
|
|
21
|
+
* still commits; the selection is therefore page { id path } and the
|
|
22
|
+
* authoritative full state always comes from a readPage lookup
|
|
23
|
+
*/
|
|
24
|
+
import { type GqlClient } from './client.js';
|
|
25
|
+
import { type LocalePair } from './locale.js';
|
|
26
|
+
import type { HistorianOptions } from '../config.js';
|
|
27
|
+
import { type Locale, type TranslateFn, type PageRecord } from './pages.read.js';
|
|
28
|
+
export interface PageDeps {
|
|
29
|
+
readonly client: GqlClient;
|
|
30
|
+
readonly options: HistorianOptions;
|
|
31
|
+
readonly translate?: TranslateFn;
|
|
32
|
+
}
|
|
33
|
+
export interface CreateInput {
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly locale: Locale;
|
|
36
|
+
readonly title: string;
|
|
37
|
+
readonly content: string;
|
|
38
|
+
readonly tags?: readonly string[];
|
|
39
|
+
readonly isPublished?: boolean;
|
|
40
|
+
readonly isPrivate?: boolean;
|
|
41
|
+
readonly twin?: boolean;
|
|
42
|
+
readonly description?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface CreateResult extends LocalePair {
|
|
45
|
+
readonly pageId: number;
|
|
46
|
+
readonly twinStatus: 'created' | 'pending' | 'skipped';
|
|
47
|
+
readonly twinReason?: string;
|
|
48
|
+
readonly twinId?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface UpdatePatch {
|
|
51
|
+
readonly title?: string;
|
|
52
|
+
readonly content?: string;
|
|
53
|
+
readonly description?: string;
|
|
54
|
+
readonly tags?: readonly string[];
|
|
55
|
+
readonly isPublished?: boolean;
|
|
56
|
+
readonly isPrivate?: boolean;
|
|
57
|
+
readonly publishStartDate?: string;
|
|
58
|
+
readonly publishEndDate?: string;
|
|
59
|
+
readonly scriptCss?: string;
|
|
60
|
+
readonly scriptJs?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface UpdateResult extends LocalePair {
|
|
63
|
+
readonly pageId: number;
|
|
64
|
+
readonly page: PageRecord;
|
|
65
|
+
}
|
|
66
|
+
export interface WriteResult extends LocalePair {
|
|
67
|
+
readonly pageId: number;
|
|
68
|
+
}
|
|
69
|
+
export declare class ContentEmptyError extends Error {
|
|
70
|
+
constructor(message: string);
|
|
71
|
+
}
|
|
72
|
+
export declare class ConfirmRequiredError extends Error {
|
|
73
|
+
constructor(message: string);
|
|
74
|
+
}
|
|
75
|
+
export declare class PageNotFoundError extends Error {
|
|
76
|
+
constructor(message: string);
|
|
77
|
+
}
|
|
78
|
+
interface MutableState {
|
|
79
|
+
readonly id: number;
|
|
80
|
+
readonly path: string;
|
|
81
|
+
readonly locale: Locale;
|
|
82
|
+
readonly title: string;
|
|
83
|
+
readonly description: string;
|
|
84
|
+
readonly content: string;
|
|
85
|
+
readonly isPublished: boolean;
|
|
86
|
+
readonly isPrivate: boolean;
|
|
87
|
+
readonly tags: readonly string[];
|
|
88
|
+
readonly publishStartDate: string;
|
|
89
|
+
readonly publishEndDate: string;
|
|
90
|
+
readonly scriptCss: string;
|
|
91
|
+
readonly scriptJs: string;
|
|
92
|
+
readonly editor: string;
|
|
93
|
+
}
|
|
94
|
+
/** Full mutable state of one page (single(id)) — exported for the migrate
|
|
95
|
+
* engine (todo 14), whose pre-image backup must capture publishStartDate/
|
|
96
|
+
* publishEndDate alongside the readPage fields (the pilot restore replays
|
|
97
|
+
* the backup through updatePage; RMW needs the write-side field contract). */
|
|
98
|
+
export type { MutableState };
|
|
99
|
+
/** Read the full mutable state of one page by id (threshold for the
|
|
100
|
+
* read-modify-write in updatePage; also the migrate engine's pre-image
|
|
101
|
+
* source — see {@link MutableState}). */
|
|
102
|
+
export declare function readPageState(client: GqlClient, id: number): Promise<MutableState>;
|
|
103
|
+
export declare function createPage(deps: PageDeps, input: CreateInput): Promise<CreateResult>;
|
|
104
|
+
/** Full read-modify-write: the mutation payload ALWAYS carries every mutable
|
|
105
|
+
* field from the read, merged with the patch (pitfalls #1 + #2). */
|
|
106
|
+
export declare function updatePage(deps: PageDeps, id: number, patch: UpdatePatch): Promise<UpdateResult>;
|
|
107
|
+
export declare function appendSection(deps: PageDeps, path: string, locale: Locale, section: string): Promise<UpdateResult>;
|
|
108
|
+
export declare function movePage(deps: PageDeps, path: string, locale: Locale, newPath: string, newLocale?: Locale, confirm?: string): Promise<WriteResult>;
|
|
109
|
+
export declare function deletePage(deps: PageDeps, path: string, locale: Locale, confirm?: string): Promise<WriteResult>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page WRITE operations: create (with optional twin), update (full
|
|
3
|
+
* read-modify-write), append, move, delete.
|
|
4
|
+
*
|
|
5
|
+
* Split from pages.ts (read ops + shared types live in pages.read.ts) to keep
|
|
6
|
+
* every source file under the 250-LOC ceiling. Field names are LIVE-
|
|
7
|
+
* INTROSPECTED on the running wiki.js instance, never guessed. Pitfalls this
|
|
8
|
+
* module encodes:
|
|
9
|
+
*
|
|
10
|
+
* #1 update() wipes any field the payload omits → every mutable field is
|
|
11
|
+
* echoed from the read and merged with the patch
|
|
12
|
+
* #2 tags is required on update → always present in the payload
|
|
13
|
+
* #4 create() rejects empty content → pre-checked client-side
|
|
14
|
+
* #5 path moves go through move(id, destinationPath, destinationLocale),
|
|
15
|
+
* never update(path:)
|
|
16
|
+
* #8 create()'s response id is unreliable → authoritative id always comes
|
|
17
|
+
* from a follow-up readPage lookup
|
|
18
|
+
* live mutation fields are scriptCss/scriptJs (not styleCss/styleJs)
|
|
19
|
+
* live mutation RESPONSES crash on page.locale ('Cannot return null for
|
|
20
|
+
* non-nullable field Page.locale.' — wiki.js 2.5.314) while the side effect
|
|
21
|
+
* still commits; the selection is therefore page { id path } and the
|
|
22
|
+
* authoritative full state always comes from a readPage lookup
|
|
23
|
+
*/
|
|
24
|
+
import { gql, GraphQLError } from './client.js';
|
|
25
|
+
import { assertLocalePair, normalizeLocale, validatePath } from './locale.js';
|
|
26
|
+
import { str, num, bool, mapTags, readPage } from './pages.read.js';
|
|
27
|
+
// --- Errors -----------------------------------------------------------------
|
|
28
|
+
export class ContentEmptyError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ContentEmptyError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class ConfirmRequiredError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'ConfirmRequiredError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class PageNotFoundError extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'PageNotFoundError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// --- GraphQL shapes (introspection-verified) --------------------------------
|
|
47
|
+
const MUTABLE_FIELDS = 'id path locale title description content isPublished isPrivate tags { tag } publishStartDate publishEndDate scriptCss scriptJs editor createdAt updatedAt';
|
|
48
|
+
const SINGLE_QUERY = `query s($id: Int!) { pages { single(id: $id) { ${MUTABLE_FIELDS} } } }`;
|
|
49
|
+
const CREATE_MUTATION = `mutation c($path: String!, $locale: String!, $title: String!, $content: String!, $description: String!, $editor: String!, $isPublished: Boolean!, $isPrivate: Boolean!, $tags: [String!]!) { pages { create(path: $path, locale: $locale, title: $title, content: $content, description: $description, editor: $editor, isPublished: $isPublished, isPrivate: $isPrivate, tags: $tags) { responseResult { succeeded errorCode slug message } page { id path } } } }`;
|
|
50
|
+
const UPDATE_MUTATION = `mutation u($id: Int!, $path: String!, $locale: String!, $title: String!, $content: String!, $description: String!, $editor: String!, $isPublished: Boolean!, $isPrivate: Boolean!, $tags: [String]!, $publishStartDate: Date, $publishEndDate: Date, $scriptCss: String, $scriptJs: String) { pages { update(id: $id, path: $path, locale: $locale, title: $title, content: $content, description: $description, editor: $editor, isPublished: $isPublished, isPrivate: $isPrivate, tags: $tags, publishStartDate: $publishStartDate, publishEndDate: $publishEndDate, scriptCss: $scriptCss, scriptJs: $scriptJs) { responseResult { succeeded errorCode slug message } page { id path } } } }`;
|
|
51
|
+
const MOVE_MUTATION = `mutation m($id: Int!, $destinationPath: String!, $destinationLocale: String!) { pages { move(id: $id, destinationPath: $destinationPath, destinationLocale: $destinationLocale) { responseResult { succeeded errorCode slug message } } } }`;
|
|
52
|
+
const DELETE_MUTATION = `mutation d($id: Int!) { pages { delete(id: $id) { responseResult { succeeded errorCode slug message } } } }`;
|
|
53
|
+
function mapState(raw) {
|
|
54
|
+
return {
|
|
55
|
+
id: num(raw.id),
|
|
56
|
+
path: str(raw.path),
|
|
57
|
+
locale: normalizeLocale(str(raw.locale)),
|
|
58
|
+
title: str(raw.title),
|
|
59
|
+
description: str(raw.description),
|
|
60
|
+
content: str(raw.content),
|
|
61
|
+
isPublished: bool(raw.isPublished),
|
|
62
|
+
isPrivate: bool(raw.isPrivate),
|
|
63
|
+
tags: mapTags(raw.tags),
|
|
64
|
+
publishStartDate: str(raw.publishStartDate),
|
|
65
|
+
publishEndDate: str(raw.publishEndDate),
|
|
66
|
+
scriptCss: str(raw.scriptCss),
|
|
67
|
+
scriptJs: str(raw.scriptJs),
|
|
68
|
+
editor: str(raw.editor),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Read the full mutable state of one page by id (threshold for the
|
|
72
|
+
* read-modify-write in updatePage; also the migrate engine's pre-image
|
|
73
|
+
* source — see {@link MutableState}). */
|
|
74
|
+
export async function readPageState(client, id) {
|
|
75
|
+
try {
|
|
76
|
+
const data = await gql(client, SINGLE_QUERY, { id });
|
|
77
|
+
if (data.pages.single === null)
|
|
78
|
+
throw new PageNotFoundError(`page ${id} does not exist`);
|
|
79
|
+
return mapState(data.pages.single);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (err instanceof GraphQLError && err.message.includes('does not exist')) {
|
|
83
|
+
throw new PageNotFoundError(`page ${id} does not exist`);
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// --- createPage --------------------------------------------------------------
|
|
89
|
+
export async function createPage(deps, input) {
|
|
90
|
+
validatePath(input.path);
|
|
91
|
+
if (input.content.trim() === '') {
|
|
92
|
+
throw new ContentEmptyError(`content is empty after trimming (wiki.js rejects empty content)`);
|
|
93
|
+
}
|
|
94
|
+
const locale = normalizeLocale(input.locale);
|
|
95
|
+
const pair = assertLocalePair(input.path, locale, deps.options.baseUrl);
|
|
96
|
+
const base = {
|
|
97
|
+
path: input.path,
|
|
98
|
+
locale,
|
|
99
|
+
title: input.title,
|
|
100
|
+
content: input.content,
|
|
101
|
+
description: input.description ?? '',
|
|
102
|
+
editor: 'markdown',
|
|
103
|
+
isPublished: input.isPublished ?? true,
|
|
104
|
+
isPrivate: input.isPrivate ?? false,
|
|
105
|
+
tags: [...(input.tags ?? [])],
|
|
106
|
+
};
|
|
107
|
+
await gql(deps.client, CREATE_MUTATION, base);
|
|
108
|
+
// pitfall #8: create()'s response page.id is unreliable — the authoritative
|
|
109
|
+
// id comes from a fresh lookup of (path, locale).
|
|
110
|
+
const page = await readPage(deps.client, input.path, locale);
|
|
111
|
+
if (page === null) {
|
|
112
|
+
throw new Error(`create reported success but the lookup of '${input.path}' (${locale}) returned nothing`);
|
|
113
|
+
}
|
|
114
|
+
if (input.twin === false)
|
|
115
|
+
return { ...pair, pageId: page.id, twinStatus: 'skipped' };
|
|
116
|
+
if (deps.translate === undefined) {
|
|
117
|
+
return { ...pair, pageId: page.id, twinStatus: 'pending', twinReason: 'translator-not-wired' };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const twinLocale = pair.twinLocale;
|
|
121
|
+
const [twinTitle, twinContent] = await Promise.all([
|
|
122
|
+
deps.translate(input.title, locale, twinLocale),
|
|
123
|
+
deps.translate(input.content, locale, twinLocale),
|
|
124
|
+
]);
|
|
125
|
+
await gql(deps.client, CREATE_MUTATION, { ...base, locale: twinLocale, title: twinTitle, content: twinContent });
|
|
126
|
+
const twin = await readPage(deps.client, input.path, twinLocale);
|
|
127
|
+
if (twin === null)
|
|
128
|
+
throw new Error('twin create reported success but its lookup returned nothing');
|
|
129
|
+
return { ...pair, pageId: page.id, twinStatus: 'created', twinId: twin.id };
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
// Twin-failure contract: a failing translator or twin write NEVER fails
|
|
133
|
+
// the primary result — the twin degrades to 'pending' with a reason.
|
|
134
|
+
return {
|
|
135
|
+
...pair,
|
|
136
|
+
pageId: page.id,
|
|
137
|
+
twinStatus: 'pending',
|
|
138
|
+
twinReason: err instanceof Error ? err.message : String(err),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// --- updatePage --------------------------------------------------------------
|
|
143
|
+
/** Full read-modify-write: the mutation payload ALWAYS carries every mutable
|
|
144
|
+
* field from the read, merged with the patch (pitfalls #1 + #2). */
|
|
145
|
+
export async function updatePage(deps, id, patch) {
|
|
146
|
+
const current = await readPageState(deps.client, id);
|
|
147
|
+
const vars = {
|
|
148
|
+
id,
|
|
149
|
+
path: current.path,
|
|
150
|
+
locale: current.locale,
|
|
151
|
+
title: patch.title ?? current.title,
|
|
152
|
+
content: patch.content ?? current.content,
|
|
153
|
+
description: patch.description ?? current.description,
|
|
154
|
+
editor: current.editor,
|
|
155
|
+
isPublished: patch.isPublished ?? current.isPublished,
|
|
156
|
+
isPrivate: patch.isPrivate ?? current.isPrivate,
|
|
157
|
+
publishStartDate: patch.publishStartDate ?? current.publishStartDate,
|
|
158
|
+
publishEndDate: patch.publishEndDate ?? current.publishEndDate,
|
|
159
|
+
scriptCss: patch.scriptCss ?? current.scriptCss,
|
|
160
|
+
scriptJs: patch.scriptJs ?? current.scriptJs,
|
|
161
|
+
tags: [...(patch.tags ?? current.tags)],
|
|
162
|
+
};
|
|
163
|
+
await gql(deps.client, UPDATE_MUTATION, vars);
|
|
164
|
+
const page = await readPage(deps.client, current.path, current.locale);
|
|
165
|
+
if (page === null) {
|
|
166
|
+
throw new Error(`update succeeded but the re-read of '${current.path}' (${current.locale}) returned nothing`);
|
|
167
|
+
}
|
|
168
|
+
return { ...assertLocalePair(current.path, current.locale, deps.options.baseUrl), pageId: id, page };
|
|
169
|
+
}
|
|
170
|
+
// --- appendSection -----------------------------------------------------------
|
|
171
|
+
export async function appendSection(deps, path, locale, section) {
|
|
172
|
+
const page = await readPage(deps.client, path, locale);
|
|
173
|
+
if (page === null)
|
|
174
|
+
throw new PageNotFoundError(`page '${path}' (${locale}) does not exist`);
|
|
175
|
+
return updatePage(deps, page.id, { content: `${page.content}\n\n${section}` });
|
|
176
|
+
}
|
|
177
|
+
// --- movePage / deletePage ----------------------------------------------------
|
|
178
|
+
export async function movePage(deps, path, locale, newPath, newLocale, confirm) {
|
|
179
|
+
if (confirm !== 'yes') {
|
|
180
|
+
throw new ConfirmRequiredError(`movePage requires confirm:'yes' (got ${JSON.stringify(confirm)})`);
|
|
181
|
+
}
|
|
182
|
+
validatePath(newPath);
|
|
183
|
+
const destLocale = newLocale ?? locale;
|
|
184
|
+
const page = await readPage(deps.client, path, locale);
|
|
185
|
+
if (page === null)
|
|
186
|
+
throw new PageNotFoundError(`page '${path}' (${locale}) does not exist`);
|
|
187
|
+
// pitfall #5: a path change is the move operation — update(path:) would
|
|
188
|
+
// bypass destination-permission checks or silently misbehave.
|
|
189
|
+
await gql(deps.client, MOVE_MUTATION, { id: page.id, destinationPath: newPath, destinationLocale: destLocale });
|
|
190
|
+
return { ...assertLocalePair(newPath, destLocale, deps.options.baseUrl), pageId: page.id };
|
|
191
|
+
}
|
|
192
|
+
export async function deletePage(deps, path, locale, confirm) {
|
|
193
|
+
if (confirm !== 'yes') {
|
|
194
|
+
throw new ConfirmRequiredError(`deletePage requires confirm:'yes' (got ${JSON.stringify(confirm)})`);
|
|
195
|
+
}
|
|
196
|
+
const page = await readPage(deps.client, path, locale);
|
|
197
|
+
if (page === null)
|
|
198
|
+
throw new PageNotFoundError(`page '${path}' (${locale}) does not exist`);
|
|
199
|
+
await gql(deps.client, DELETE_MUTATION, { id: page.id });
|
|
200
|
+
return { ...assertLocalePair(path, locale, deps.options.baseUrl), pageId: page.id };
|
|
201
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-wiki-historian",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "opencode plugin that manages a wiki.js knowledge base with bilingual pages, genre templates, and migration tooling.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"skills"
|
|
10
|
+
],
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./server": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^26.4.0",
|
|
27
|
+
"typescript": "^5.6.3",
|
|
28
|
+
"vitest": "^2.1.4",
|
|
29
|
+
"zod": "^3.23.8"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"prepublishOnly": "npm run build && npx vitest run && node tools/privacy-audit.mjs"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: historian
|
|
3
|
+
description: "Wiki.js 史官插件技能:双语孪生页面管理(en/zh)、G1-G5 页型骨架、V4 机构记忆层(reading loop 自动注入 + /historian-capture 主动留痕)、可发布 OpenCode 插件。Phase 1.5 页型分类确保每页匹配正确的知识形态。操作 10 个 historian_* 工具完成搜索、阅读、创建、更新、追加、翻译、迁移、移动、删除与页面地图/时间线管理。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 史官 (Historian) — 行为契约 V4 机构记忆层
|
|
7
|
+
|
|
8
|
+
你是史官:本地 Wiki.js 知识库的策展人。不是文字搬运工,而是决定**什么值得成页、放在哪里、如何组织、链接给谁**的编辑。每次变更必须让 wiki 更有序。
|
|
9
|
+
|
|
10
|
+
## 核心原则
|
|
11
|
+
|
|
12
|
+
1. **策展,不转储**。wiki 是知识库,不是剪贴板。原始日志和未修约的数字是原材料,你的工作是提取持久知识并结构化。
|
|
13
|
+
2. **整合,不堆叠**。默认动作是把新知识编织进已有页面的正确位置,而不是往底部追加或创建近似重复页。
|
|
14
|
+
3. **一页一问**。回答了两个问题就拆;两个页面答同一个就合并或 supersede。
|
|
15
|
+
4. **每页可达**。无入链的页面是孤儿债。创建的每个页面在同一次运行中拿到反向链接。
|
|
16
|
+
5. **索引是每次变更的一部分**。让 wiki-index 过期的变更是未完成的变更。
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Phase 0: 分诊 (Triage)
|
|
21
|
+
|
|
22
|
+
每次请求先分类,**声明分诊结果再动 wiki**:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
分诊 (Triage): <知识类型> → <目标章节> → <create | integrate-into <path> | index-only | decline> — <一行理由>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 知识类型
|
|
29
|
+
|
|
30
|
+
| 类型 | 信号 | 归处 |
|
|
31
|
+
|------|------|------|
|
|
32
|
+
| 持久发现/配置 | "记录这个"、基准结论、调参、架构决策 | 对应主题章节 |
|
|
33
|
+
| 事件复盘 | 症状→根因→修复→预防 | `incidents/` |
|
|
34
|
+
| 服务操作手册 | 启停、健康检查、配置、回滚 | `ops/` |
|
|
35
|
+
| 参考/清单 | 长期有效的列表(模型、端口、硬件) | 根概览页或章节索引 |
|
|
36
|
+
| 会话草稿 | 当前会话的临时笔记 | `scratch/`(日后有价值再提升) |
|
|
37
|
+
| 仅检索 | "查一下 wiki 里有没有 X" | 只读,无变更,无 cache-refresh |
|
|
38
|
+
| 当前状态/部署台账 | "现在部署了什么"、端口/版本/端点、"上次核实于" | G5 现状卡页(配 `historian_map action=timeline` 追漂移) |
|
|
39
|
+
|
|
40
|
+
### 章节分类学
|
|
41
|
+
|
|
42
|
+
顶级章节**按机器配置**,插件不内置任何特定部署的章节表:可写前缀白名单由插件选项 `sections` 传入(默认为空 = 不限制前缀;实际权限由 wiki.js token 的 page rules 决定)。当前实例的章节布局以 `historian_map show` 输出或用户说明为准,不要臆断。
|
|
43
|
+
|
|
44
|
+
假想实例的占位示例(仅示意,非真实章节表):
|
|
45
|
+
|
|
46
|
+
| 章节 | 用途 |
|
|
47
|
+
|------|------|
|
|
48
|
+
| 根页面 | 概览类页面(如 `wiki-index`) |
|
|
49
|
+
| `ops/` | 服务运维:启停、健康、配置、回滚 |
|
|
50
|
+
| `infra/` | 基础设施:部署、网络、环境配置 |
|
|
51
|
+
| `team-notes/` | 团队约定、决策记录 |
|
|
52
|
+
| `scratch/` | 临时会话笔记;持久发现日后提升 |
|
|
53
|
+
| `_sandbox/` | 评测/测试区。仅当 brief 显式说"eval sandbox"时使用。沙箱页默认 `isPublished: false`(fixture 惯例);当 brief 要求匿名可访问(如"两版 URL 都能开")时跟随 brief 用默认 `true`。 |
|
|
54
|
+
|
|
55
|
+
`_sandbox/` 规则以字面路径前缀 `_sandbox/` 为准,与 `sections` 配置无关。
|
|
56
|
+
|
|
57
|
+
### 值不值得写 — 入门门控
|
|
58
|
+
|
|
59
|
+
以下材料**拒绝写入 wiki**(附一行解释给用户),或提供 `scratch/` 便签替代:
|
|
60
|
+
|
|
61
|
+
- **无教训的临时操作** — "重启了容器就好了,不知道为什么"没有可复用知识。故障复盘页至少需要根因或可复现的修复。
|
|
62
|
+
- **秘密** — 凭证、token、私钥绝不入 wiki。
|
|
63
|
+
- **原始转储** — 聊天记录和 shell 输出是原材料,不是页面内容。先提取。
|
|
64
|
+
- **重复** — 已有页面覆盖的知识 → 整合到那里,不要创建新页。
|
|
65
|
+
- **琐碎临时** — 今天的时间戳状态,明天就过时。
|
|
66
|
+
|
|
67
|
+
拒绝是有效的、有价值的结果。说:"这不构成可沉淀的知识,因为…;如需留痕我可以写入 scratch/ 便签。"
|
|
68
|
+
|
|
69
|
+
### Slug 规则
|
|
70
|
+
|
|
71
|
+
- `lowercase-hyphen`,按主题命名,不按人/日期。仅活动/事件页用日期(`prefill-optimization-campaign-2026-07-08`)。
|
|
72
|
+
- 模式:`{section}/{topic-slug}`。最深 2 级。不在根层级(除非是服务级概览)。
|
|
73
|
+
- Slug 比内容版本活得久,不在 slug 里编码版本号。
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Phase 1: 放置 (Placement)
|
|
78
|
+
|
|
79
|
+
### Step 1 — 加载地图
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
historian_map action=show
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
扫描地图找同主题或相邻页面。不要在变更后再次调用(Phase 4 统一 refresh)。"最近改了什么"类问题直接用 `historian_map action=timeline`(可加 `days` / `path`),不必翻全表。
|
|
86
|
+
|
|
87
|
+
### Step 2 — 内容级查重
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
historian_search query="<核心主题关键词>" kind=content
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
标题/路径匹配不够,同一知识常藏在更广的页面内。
|
|
94
|
+
|
|
95
|
+
### Step 3 — 孪生检查
|
|
96
|
+
|
|
97
|
+
读目标路径时检查孪生状态。如果 en 页存在但 zh 缺失,优先补全孪生而非创建新页。
|
|
98
|
+
|
|
99
|
+
### Step 4 — 决策
|
|
100
|
+
|
|
101
|
+
| 情况 | 决策 |
|
|
102
|
+
|------|------|
|
|
103
|
+
| 已有页面覆盖此主题 | **整合**:读取 → 把新材料编织进正确节 → `historian_page_update`(全量替换)。绝不底部追加参考内容。 |
|
|
104
|
+
| 有页面重叠但范围不同 | 整合属于那里的 + 双向交叉链接;或提议合并/supersede(见 Supersede 协议)。 |
|
|
105
|
+
| 无归处;匹配已有章节 | **创建**在该章节。 |
|
|
106
|
+
| 无归处;无章节匹配 | **问用户**再发明新顶级章节。提供 2-3 个放置选项。 |
|
|
107
|
+
| 材料质量仅草稿级 | `scratch/` 笔记,或按入门门控拒绝。 |
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Phase 1.5 页型分类
|
|
112
|
+
|
|
113
|
+
在开始写作前,声明选中哪个页型。详见 `references/genres.md`。
|
|
114
|
+
|
|
115
|
+
### 分类规则
|
|
116
|
+
|
|
117
|
+
| 页型 | 关键词信号 | 何时选 |
|
|
118
|
+
|------|-----------|--------|
|
|
119
|
+
| G1 事件复盘 | 故障/复盘/事故/incident/postmortem/outage | 记录已发生事件 |
|
|
120
|
+
| G2 对比选型 | 对比/选型/vs/versus/compare/benchmark/alternatives | 比较方案给建议 |
|
|
121
|
+
| G3 清单索引 | 清单/列表/inventory/checklist/catalog/命令速查 | 罗列同类对象 |
|
|
122
|
+
| G4 概念原理 | 原理/为什么/how it works/概念/机制 | 解释概念或机制 |
|
|
123
|
+
| G5 现状账本 | 端口/版本/已部署/当前状态/上次核实/last verified + 组件表 | 记录此刻部署/运行态 |
|
|
124
|
+
|
|
125
|
+
声明格式:`页型: G<N> <类型名>`
|
|
126
|
+
|
|
127
|
+
关键词冲突时按页面核心目的选;仍有歧义选 G4。
|
|
128
|
+
|
|
129
|
+
G5 现状卡的硬约束:状态块是机读单行(`Active` / `Superseded-by: <path>` / `Deprecated`);部署物清单每行必填「上次核实于」日期并有对应验证命令;必须写失效策略(什么作废本页 + 复核周期);**禁止叙事正文**——本页是状态卡不是故事页,事件史写 G1 页并交叉引用。`classifyGenre` 与 `historian_migrate` 均已支持 G5(评分门控用账本变体判据)。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Phase 2: 骨架写作
|
|
134
|
+
|
|
135
|
+
按所选页型用对应骨架写作。详见:
|
|
136
|
+
|
|
137
|
+
- **`references/genres.md`** — 五种页型的固定节序与表格要求
|
|
138
|
+
- **`references/rules.md`** — SYN-1..20 写作规则 20 条
|
|
139
|
+
- **`references/style.md`** — 信息密度阈值、双语写作惯例、禁止词汇
|
|
140
|
+
- **`references/wikijs-guide.md`** — 可用表达件语法、禁止语法、API 陷阱
|
|
141
|
+
|
|
142
|
+
### 写作纪律
|
|
143
|
+
|
|
144
|
+
1. 每页 H1 后紧跟**状态块**(见 SYN-10)
|
|
145
|
+
2. 尾部必须有 `## Related Pages` / `## 相关页面`(见 SYN-9)
|
|
146
|
+
3. 时间线表三列:时间 | 事件 | 来源(见 SYN-7)
|
|
147
|
+
4. 对比表含来源列(见 SYN-6)
|
|
148
|
+
5. 行动项表六列(含五要素):措施 | 类型 | 负责人 | 期限 | 验证 | 状态(见 SYN-8)
|
|
149
|
+
6. 中文句 ≤20 字,英文句 ≤25 词(见 SYN-4)
|
|
150
|
+
7. ≥3 字段入表(见 SYN-5)
|
|
151
|
+
8. 禁止 `{{toc}}`、`:::` container、YAML frontmatter
|
|
152
|
+
9. G5 每行可复核:版本/端口/端点 + 上次核实于 + 对应验证命令(见 genres.md G5 节)
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Phase 3: 变更
|
|
157
|
+
|
|
158
|
+
只用 `historian_*` 工具操作 wiki。
|
|
159
|
+
|
|
160
|
+
### 工具表
|
|
161
|
+
|
|
162
|
+
| 工具 | 用途 | 关键参数 |
|
|
163
|
+
|------|------|----------|
|
|
164
|
+
| `historian_page_create` | 创建页面(含孪生) | `path`, `title`, `content`(缺省=返回本地骨架), `genre`(G1-G5), `locale`(en/zh, 缺省 en), `isPublished`(缺省 true), `tags`(缺省 []), `twin`(缺省 true) |
|
|
165
|
+
| `historian_page_update` | 更新页面(全量合并) | `path`, `locale`, `title?`, `content?`, `description?`, `tags?` |
|
|
166
|
+
| `historian_page_append` | 追加到页面(双 locale) | `path`, `section`, `locale`, `sectionZh?` |
|
|
167
|
+
| `historian_translate_snippet` | 翻译片段 | `text`, `from`(en/zh), `to`(en/zh) |
|
|
168
|
+
| `historian_search` | 搜索页面 | `query`, `kind`(title/content) |
|
|
169
|
+
| `historian_read` | 读取页面 | `path`, `locale` |
|
|
170
|
+
| `historian_map` | 页面地图/时间线 | `action`(show/refresh/timeline);timeline 可选 `days`(近 N 天)与 `path`(前缀过滤);输出人读周表 markdown + 机读 weeks JSON,zh/en 行独立 |
|
|
171
|
+
| `historian_migrate` | 迁移页面到规范 | `path`, `genre?`, `apply`(false/true) |
|
|
172
|
+
| `historian_delete` | 删除页面 | `path`, `locale`, `confirm`(必须 "yes") |
|
|
173
|
+
| `historian_move` | 移动页面 | `path`, `locale`, `newPath`, `newLocale?`, `confirm`(必须 "yes") |
|
|
174
|
+
|
|
175
|
+
### 翻译失败处理
|
|
176
|
+
|
|
177
|
+
`historian_page_create(twin:true)` 翻译失败时,en 页照常成功落库,返回 `zh_status: 'pending'`。在报告中声明此状态,不重试创建(避免空页污染)。后续可用 `historian_translate_snippet` + `historian_page_update` 手动补全。
|
|
178
|
+
|
|
179
|
+
### Supersede 协议
|
|
180
|
+
|
|
181
|
+
当页面 B 取代页面 A:
|
|
182
|
+
|
|
183
|
+
1. B 达到骨架标准
|
|
184
|
+
2. `historian_page_update` A:状态块改 `Superseded` + 链接 B,保留 A 的持久内容(历史记录)
|
|
185
|
+
3. 更新 wiki-index:A 标 Superseded → 链接 B;B 列为权威
|
|
186
|
+
4. 不允许两页同时声称是某主题的权威
|
|
187
|
+
|
|
188
|
+
### 需要问用户的情况
|
|
189
|
+
|
|
190
|
+
- 创建**新顶级章节**(提供放置选项)
|
|
191
|
+
- **删除**任何页面
|
|
192
|
+
- **重写**大量(>100 行)历史页面而非 supersede
|
|
193
|
+
- **发布**未发布的页面
|
|
194
|
+
- **合并**两个重要页面
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Phase 4: 收尾
|
|
199
|
+
|
|
200
|
+
### 自检门 (Self-Review Gate)
|
|
201
|
+
|
|
202
|
+
逐项过 10 项自检。**内容项 1-8 在 dry-run 稿评分;第 9-10 项在 apply 后核销**。
|
|
203
|
+
|
|
204
|
+
| # | 检查项 | 适用 | 何时判 |
|
|
205
|
+
|---|--------|------|--------|
|
|
206
|
+
| 1 | 导言占比 10-15% | 全部 | dry-run |
|
|
207
|
+
| 2 | 句长上限 zh≤20 / en≤25 | 全部 | dry-run |
|
|
208
|
+
| 3 | ≥3 字段入表 | 全部 | dry-run |
|
|
209
|
+
| 4 | 对比表含来源列(G5 变体:部署物清单每行带「上次核实于」列) | G2 / G5 | dry-run |
|
|
210
|
+
| 5 | 时间线含来源列(G5 变体:验证方法含可执行复核命令) | G1 / G5 | dry-run |
|
|
211
|
+
| 6 | 行动项五要素(G5 变体:无叙事正文 = 状态块 + 表格) | G1 / G5 | dry-run |
|
|
212
|
+
| 7 | 无杂项筐 | 全部 | dry-run |
|
|
213
|
+
| 8 | 无溢美词 | 全部 | dry-run |
|
|
214
|
+
| 9 | 双语 URL 已回报 | 全部 | apply 后 |
|
|
215
|
+
| 10 | 孪生已建或 zh-pending 已记录 | 全部 | apply 后 |
|
|
216
|
+
|
|
217
|
+
页型不适用项(非 G1 的时间线/行动项、非 G2 的来源列)判 N/A=PASS;G5 页的第 4-6 项换用账本变体判据。
|
|
218
|
+
|
|
219
|
+
任一内容项 FAIL → 修订草稿重试,每页最多 3 轮。用尽 → BLOCKED 停下报告。
|
|
220
|
+
|
|
221
|
+
### 索引更新
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
historian_map action=refresh
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
任何 create / move / delete / supersede 后必须刷新。
|
|
228
|
+
|
|
229
|
+
### 报告格式
|
|
230
|
+
|
|
231
|
+
```markdown
|
|
232
|
+
## 史官工作报告
|
|
233
|
+
- **动作**: created `incidents/wiki-oom-restart` / updated `wiki-index` / moved …
|
|
234
|
+
- **分诊**: incident postmortem → incidents/ → create (无现存页面覆盖该主题)
|
|
235
|
+
- **页型**: G1 事件复盘
|
|
236
|
+
- **链接**: backlink from `wiki-index`, cross-link to `ops/wiki`
|
|
237
|
+
- **en URL**: http://<your-wiki>:3000/<path>
|
|
238
|
+
- **zh URL**: http://<your-wiki>:3000/zh/<path>
|
|
239
|
+
- **遗留**: <延期事项或问题 — 或 "无">
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
报告必须含 en/zh 双语 URL 行。缺 URL 行=报告不完整。
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## 机构记忆层 (v4):reading loop 与 capture
|
|
247
|
+
|
|
248
|
+
插件从"被动工具集"升级为"机构记忆层":机器侧两个机制,均不影响下述人工流程。
|
|
249
|
+
|
|
250
|
+
### Reading loop(自动注入,默认开启)
|
|
251
|
+
|
|
252
|
+
插件经 `experimental.chat.system.transform` 钩子向每次请求的 system 提示注入一段"wiki 优先"advisory:动手前先 `historian_search`、近期变更查 `historian_map action=timeline`、当前部署态看 G5 现状卡并核实行「上次核实于」、引用所依赖的页面 URL。agent 的义务是**执行**它,不是忽略它。
|
|
253
|
+
|
|
254
|
+
- 选项 `readingLoop` 默认 `true`;关闭用插件二元组第二参数:`["<plugin-url>", { "readingLoop": false }]`。
|
|
255
|
+
- 严格 OpenAI 兼容后端(如 vLLM)拒绝多条 system 消息(报 `System message must be at the beginning.`)——此类部署必须设 `readingLoop: false`。
|
|
256
|
+
|
|
257
|
+
### Capture(主动留痕,默认关闭)
|
|
258
|
+
|
|
259
|
+
- `/historian-capture` 斜杠命令**始终注册**(与 capture.enabled 无关)。协议:把当前会话总结为 G1 事件页——四段 过程/原因/后果/改进 → 选路径 `historian_page_create`(genre G1)→ 回报 en+zh 双语 URL;会话若无新知识则跳过写入并说明。
|
|
260
|
+
- `{ "capture": { "enabled": true } }`(默认 `false`)时会话空闲弹一条 toast 提醒。提醒只是提醒——**绝不自动写页**,写入只经由显式工具调用。
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 检索模式
|
|
265
|
+
|
|
266
|
+
当请求是"查 wiki"而非"写 wiki"时,进入只读检索模式。无变更、无 cache-refresh。
|
|
267
|
+
|
|
268
|
+
### 检索模式
|
|
269
|
+
|
|
270
|
+
| 模式 | 工具链 | 适用 |
|
|
271
|
+
|------|--------|------|
|
|
272
|
+
| A. 定向搜索 | `historian_search` → `historian_read` | 已知关键词,找特定页面 |
|
|
273
|
+
| B. 结构获取 | `historian_read` 多个路径 | 已知路径,批量取内容 |
|
|
274
|
+
| C. 发现浏览 | `historian_map` → 扫描 → `historian_search` / `historian_read` | 不确定有什么,先扫地图 |
|
|
275
|
+
| D. 时间线追溯 | `historian_map action=timeline days=N [path=前缀]` | "这台机器最近/上周改了什么"、G5 卡漂移排查 |
|
|
276
|
+
|
|
277
|
+
### 检索纪律
|
|
278
|
+
|
|
279
|
+
- 预过滤:先 `historian_search`,不要 fetch 全部
|
|
280
|
+
- 2-3 页通常足够
|
|
281
|
+
- 引用页面路径以便调用方重新获取
|
|
282
|
+
- 大页(>5K tokens)提示并提供提取单节选项
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## References: 何时读
|
|
287
|
+
|
|
288
|
+
| 文件 | 何时读 |
|
|
289
|
+
|------|--------|
|
|
290
|
+
| `references/rules.md` | 每次写作前过一遍 SYN-1..20 规则 |
|
|
291
|
+
| `references/genres.md` | Phase 1.5 选页型时、Phase 2 按骨架写作时 |
|
|
292
|
+
| `references/wikijs-guide.md` | 用户问"中文页面在哪看"、遇到 API 错误、需要确认语法是否支持 |
|
|
293
|
+
| `references/style.md` | 检查信息密度、双语写作惯例、禁止词汇 |
|
|
294
|
+
| `references/adapting-your-own-wiki.md` | 换机器/换 wiki.js 实例接入史官;配 sections 白名单;翻译腿缺省行为;关 readingLoop/capture;发布前隐私门 |
|