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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/dist/chronology.d.ts +36 -0
  4. package/dist/chronology.js +67 -0
  5. package/dist/config.d.ts +112 -0
  6. package/dist/config.js +158 -0
  7. package/dist/index.d.ts +31 -0
  8. package/dist/index.js +136 -0
  9. package/dist/jsonc.d.ts +17 -0
  10. package/dist/jsonc.js +131 -0
  11. package/dist/map.d.ts +58 -0
  12. package/dist/map.js +196 -0
  13. package/dist/migrate-apply.d.ts +40 -0
  14. package/dist/migrate-apply.js +144 -0
  15. package/dist/migrate-score.d.ts +29 -0
  16. package/dist/migrate-score.js +267 -0
  17. package/dist/migrate-store.d.ts +52 -0
  18. package/dist/migrate-store.js +77 -0
  19. package/dist/migrate.d.ts +65 -0
  20. package/dist/migrate.js +111 -0
  21. package/dist/templates/genres.d.ts +65 -0
  22. package/dist/templates/genres.js +228 -0
  23. package/dist/templates/skeletons.d.ts +48 -0
  24. package/dist/templates/skeletons.js +558 -0
  25. package/dist/tools/create.d.ts +9 -0
  26. package/dist/tools/create.js +77 -0
  27. package/dist/tools/local.d.ts +10 -0
  28. package/dist/tools/local.js +107 -0
  29. package/dist/tools/mutate.d.ts +11 -0
  30. package/dist/tools/mutate.js +157 -0
  31. package/dist/tools/read.d.ts +9 -0
  32. package/dist/tools/read.js +104 -0
  33. package/dist/tools/shared.d.ts +52 -0
  34. package/dist/tools/shared.js +87 -0
  35. package/dist/tools/write.d.ts +10 -0
  36. package/dist/tools/write.js +148 -0
  37. package/dist/tools.d.ts +23 -0
  38. package/dist/tools.js +43 -0
  39. package/dist/translate.d.ts +44 -0
  40. package/dist/translate.js +207 -0
  41. package/dist/wiki/assets.d.ts +42 -0
  42. package/dist/wiki/assets.js +91 -0
  43. package/dist/wiki/client.d.ts +67 -0
  44. package/dist/wiki/client.js +221 -0
  45. package/dist/wiki/locale.d.ts +66 -0
  46. package/dist/wiki/locale.js +154 -0
  47. package/dist/wiki/pages.d.ts +7 -0
  48. package/dist/wiki/pages.js +7 -0
  49. package/dist/wiki/pages.read.d.ts +114 -0
  50. package/dist/wiki/pages.read.js +114 -0
  51. package/dist/wiki/pages.write.d.ts +109 -0
  52. package/dist/wiki/pages.write.js +201 -0
  53. package/package.json +36 -0
  54. package/skills/historian/SKILL.md +294 -0
  55. package/skills/historian/references/adapting-your-own-wiki.md +53 -0
  56. package/skills/historian/references/genres.md +160 -0
  57. package/skills/historian/references/rules.md +30 -0
  58. package/skills/historian/references/style.md +84 -0
  59. package/skills/historian/references/wikijs-guide.md +87 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * historian_page_update + historian_page_append: engine RMW update and the
3
+ * bilingual append (en append + zh twin handling per the wiki-biling
4
+ * semantics — the twin bootstrap composes engine primitives only:
5
+ * appendSection / createPage / readPage / translate; never reimplemented).
6
+ */
7
+ import { tool } from '@opencode-ai/plugin';
8
+ import { appendSection, createPage, updatePage, PageNotFoundError } from '../wiki/pages.js';
9
+ import { readPage } from '../wiki/pages.read.js';
10
+ import { errEnvelope, okJson, urlPair, URL_MANDATE, pageDeps } from './shared.js';
11
+ const s = tool.schema;
12
+ const UPDATE_ARGS = {
13
+ path: s.string(),
14
+ locale: s.enum(['en', 'zh']).default('en'),
15
+ title: s.string().optional(),
16
+ content: s.string().optional(),
17
+ description: s.string().optional(),
18
+ tags: s.array(s.string()).optional(),
19
+ };
20
+ const UpdateArgsSchema = s.object(UPDATE_ARGS);
21
+ export function makeUpdateTool(deps) {
22
+ return tool({
23
+ description: `Update a wiki page: full read-modify-write — omitted fields are kept unchanged (engine preserves them). ` +
24
+ `Path + locale resolve the page; no raw id. ${URL_MANDATE}.`,
25
+ args: UPDATE_ARGS,
26
+ execute: async (raw) => {
27
+ const args = UpdateArgsSchema.parse(raw);
28
+ try {
29
+ const page = await readPage(deps.getClient(), args.path, args.locale);
30
+ if (page === null) {
31
+ return errEnvelope(new PageNotFoundError(`page '${args.path}' (${args.locale}) does not exist`));
32
+ }
33
+ const result = await updatePage(pageDeps(deps), page.id, {
34
+ title: args.title,
35
+ content: args.content,
36
+ description: args.description,
37
+ tags: args.tags,
38
+ });
39
+ return okJson({
40
+ mode: 'update',
41
+ path: args.path,
42
+ locale: args.locale,
43
+ pageId: result.pageId,
44
+ page: {
45
+ id: result.page.id,
46
+ title: result.page.title,
47
+ description: result.page.description,
48
+ tags: result.page.tags,
49
+ isPublished: result.page.isPublished,
50
+ contentType: result.page.contentType,
51
+ updatedAt: result.page.updatedAt,
52
+ },
53
+ urls: urlPair(result),
54
+ });
55
+ }
56
+ catch (err) {
57
+ return errEnvelope(err);
58
+ }
59
+ },
60
+ });
61
+ }
62
+ async function bootstrapTwin(deps, primary, section, source) {
63
+ const translate = source === 'translate' ? deps.translate : undefined;
64
+ try {
65
+ const title = translate !== undefined ? await translate(primary.title, 'en', 'zh') : primary.title;
66
+ const content = translate !== undefined ? await translate(section, 'en', 'zh') : section;
67
+ await createPage(pageDeps(deps), {
68
+ path: primary.path,
69
+ locale: 'zh',
70
+ title,
71
+ content,
72
+ tags: primary.tags,
73
+ isPublished: primary.isPublished,
74
+ twin: false,
75
+ });
76
+ return { status: 'created' };
77
+ }
78
+ catch (err) {
79
+ return { status: 'pending', note: `zh twin bootstrap failed: ${err instanceof Error ? err.message : String(err)}` };
80
+ }
81
+ }
82
+ const APPEND_ARGS = {
83
+ path: s.string(),
84
+ section: s.string(),
85
+ locale: s.enum(['en', 'zh']).default('en'),
86
+ sectionZh: s.string().optional().describe('Explicit zh section; when absent the zh side falls back to translation/wiring'),
87
+ };
88
+ const AppendArgsSchema = s.object(APPEND_ARGS);
89
+ export function makeAppendTool(deps) {
90
+ return tool({
91
+ description: `Append a section to an existing page (engine append + RMW). For the en page with a MISSING zh twin, ` +
92
+ `the twin is auto-created — from sectionZh when given, else translated when the translator is wired. ` +
93
+ `${URL_MANDATE}.`,
94
+ args: APPEND_ARGS,
95
+ execute: async (raw) => {
96
+ const args = AppendArgsSchema.parse(raw);
97
+ try {
98
+ const appended = await appendSection(pageDeps(deps), args.path, args.locale, args.section);
99
+ let zhStatus;
100
+ let zhNote;
101
+ if (args.locale === 'zh') {
102
+ zhStatus = 'appended';
103
+ zhNote = 'Primary locale is zh — the en twin is untouched (check with historian_read(path, "en")).';
104
+ }
105
+ else if (args.sectionZh !== undefined) {
106
+ const twin = await readPage(deps.getClient(), args.path, 'zh');
107
+ if (twin !== null) {
108
+ await appendSection(pageDeps(deps), args.path, 'zh', args.sectionZh);
109
+ zhStatus = 'appended';
110
+ }
111
+ else {
112
+ const outcome = await bootstrapTwin(deps, appended.page, args.sectionZh, 'explicit');
113
+ zhStatus = outcome.status;
114
+ zhNote = outcome.note;
115
+ }
116
+ }
117
+ else {
118
+ const twin = await readPage(deps.getClient(), args.path, 'zh');
119
+ if (twin !== null) {
120
+ zhStatus = 'exists';
121
+ zhNote = 'zh twin untouched — pass sectionZh to sync it, or call historian_page_append with locale "zh".';
122
+ }
123
+ else if (deps.translate !== undefined) {
124
+ const outcome = await bootstrapTwin(deps, appended.page, args.section, 'translate');
125
+ zhStatus = outcome.status;
126
+ zhNote = outcome.note;
127
+ }
128
+ else {
129
+ zhStatus = 'missing';
130
+ zhNote = 'No zh twin exists and no translator is wired — provide sectionZh to bootstrap it.';
131
+ }
132
+ }
133
+ return okJson({
134
+ mode: 'append',
135
+ path: args.path,
136
+ locale: args.locale,
137
+ pageId: appended.pageId,
138
+ urls: urlPair(appended),
139
+ zhStatus,
140
+ zhNote,
141
+ });
142
+ }
143
+ catch (err) {
144
+ return errEnvelope(err);
145
+ }
146
+ },
147
+ });
148
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * historian_* tool surface (todo 11) — barrel + composition.
3
+ *
4
+ * buildTools wires the 10 custom tools from the engine modules; this file
5
+ * holds no engine logic, only wiring. Per-call construction (no module-level
6
+ * cache — each buildTools call resolves a fresh client/translator), and the
7
+ * client is resolved LAZILY: a missing/invalid wiki key surfaces as a
8
+ * ConfigError envelope on first execution, never as a buildTools crash that
9
+ * would kill plugin registration (todo 12).
10
+ */
11
+ import type { ToolDefinition } from '@opencode-ai/plugin';
12
+ import { type GqlClient } from './wiki/client.js';
13
+ import type { HistorianOptions } from './config.js';
14
+ import type { TranslateFn } from './wiki/pages.read.js';
15
+ export interface BuildDeps {
16
+ readonly client?: GqlClient;
17
+ readonly translate?: TranslateFn;
18
+ readonly fetchImpl?: typeof fetch;
19
+ readonly resultsDir?: string;
20
+ readonly homeDir?: string;
21
+ }
22
+ export type HistorianTools = Readonly<Record<string, ToolDefinition>>;
23
+ export declare function buildTools(opts: HistorianOptions, deps?: BuildDeps): HistorianTools;
package/dist/tools.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * historian_* tool surface (todo 11) — barrel + composition.
3
+ *
4
+ * buildTools wires the 10 custom tools from the engine modules; this file
5
+ * holds no engine logic, only wiring. Per-call construction (no module-level
6
+ * cache — each buildTools call resolves a fresh client/translator), and the
7
+ * client is resolved LAZILY: a missing/invalid wiki key surfaces as a
8
+ * ConfigError envelope on first execution, never as a buildTools crash that
9
+ * would kill plugin registration (todo 12).
10
+ */
11
+ import { homedir } from 'node:os';
12
+ import { createClient } from './wiki/client.js';
13
+ import { makeTranslator } from './translate.js';
14
+ import { makeCreateTool } from './tools/create.js';
15
+ import { makeUpdateTool, makeAppendTool } from './tools/write.js';
16
+ import { makeReadTool, makeSearchTool } from './tools/read.js';
17
+ import { makeTranslateSnippetTool, makeMapTool } from './tools/local.js';
18
+ import { makeDeleteTool, makeMoveTool, makeMigrateTool } from './tools/mutate.js';
19
+ export function buildTools(opts, deps = {}) {
20
+ const homeDir = deps.homeDir ?? homedir();
21
+ // Translator must exist with NO injected deps (production plugin path):
22
+ // gating on deps.fetchImpl degraded twins to pending(translator-not-wired)
23
+ // in production while tests/pilot got a live one — regression-tested.
24
+ const translate = deps.translate ?? makeTranslator(opts, deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {});
25
+ let client;
26
+ const getClient = () => {
27
+ client ??= deps.client ?? createClient(opts, { fetchImpl: deps.fetchImpl, homeDir });
28
+ return client;
29
+ };
30
+ const toolDeps = { getClient, options: opts, translate, fetchImpl: deps.fetchImpl, resultsDir: deps.resultsDir, homeDir };
31
+ return {
32
+ historian_page_create: makeCreateTool(toolDeps),
33
+ historian_page_update: makeUpdateTool(toolDeps),
34
+ historian_page_append: makeAppendTool(toolDeps),
35
+ historian_translate_snippet: makeTranslateSnippetTool(toolDeps),
36
+ historian_search: makeSearchTool(toolDeps),
37
+ historian_read: makeReadTool(toolDeps),
38
+ historian_map: makeMapTool(toolDeps),
39
+ historian_migrate: makeMigrateTool(toolDeps),
40
+ historian_delete: makeDeleteTool(toolDeps),
41
+ historian_move: makeMoveTool(toolDeps),
42
+ };
43
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * DashScope Anthropic-compatible translation engine (replaces wiki-biling.py).
3
+ * Every failure surfaces as a named TranslateError. URL: append /v1/messages
4
+ * unless the endpoint already ends with /v1 or /v1/messages (double-v1 trap,
5
+ * machine-verified). Key redacted from body-derived details; no retry loop
6
+ * (chunked translation is deterministic, each request costs quota).
7
+ */
8
+ import type { HistorianOptions } from './config.js';
9
+ import type { Locale, TranslateFn } from './wiki/pages.read.js';
10
+ export type TranslateErrorCause = 'network' | 'http' | 'malformed' | 'truncated' | 'timeout' | 'config';
11
+ export declare class TranslateError extends Error {
12
+ readonly cause: TranslateErrorCause;
13
+ readonly detail: string;
14
+ constructor(cause: TranslateErrorCause, detail: string);
15
+ }
16
+ export declare const DEFAULT_TRANSLATE_TIMEOUT_MS = 300000;
17
+ export declare function normalizeMessagesUrl(endpoint: string): string;
18
+ /** Split on blank lines into groups of at most `max` chars. Oversized single
19
+ * blocks and fence runs pass through whole (never split inside ``` fences),
20
+ * so rejoining translated groups with '\n\n' reproduces the input structure. */
21
+ export declare function splitMarkdownBlocks(text: string, max: number): string[];
22
+ /** Primary: the first content part when it is a text part. Fallback: join
23
+ * all text-type parts (content[0] may be a tool_use part). Missing/empty
24
+ * text is malformed — never silently translated to ''. */
25
+ export declare function pickResponseText(json: unknown): string;
26
+ /** Machine-consumed routing tokens (DIRECTION, GLOSSARY table) plus the
27
+ * untranslatable-surface rules. Tests pin the tokens, not the prose. */
28
+ export declare function buildSystemPrompt(from: Locale, to: Locale, glossary?: Record<string, string>): string;
29
+ export interface TranslateDeps {
30
+ readonly fetchImpl?: typeof fetch;
31
+ readonly timeoutMs?: number;
32
+ readonly glossary?: Record<string, string>;
33
+ }
34
+ /** One raw Anthropic-compatible messages call: caller-supplied system + user
35
+ * prompt, shared URL/auth/timeout/error-taxonomy with the translation engine.
36
+ * The migrate reformatter (todo 14) reuses this path with its own prompt; a
37
+ * reformat call is never chunked (a restyle must see the whole page). */
38
+ export interface MessagesCall {
39
+ readonly system: string;
40
+ readonly user: string;
41
+ readonly maxTokens?: number;
42
+ }
43
+ export declare function callMessages(opts: HistorianOptions, deps: TranslateDeps | undefined, call: MessagesCall): Promise<string>;
44
+ export declare function makeTranslator(opts: HistorianOptions, deps?: TranslateDeps): TranslateFn;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * DashScope Anthropic-compatible translation engine (replaces wiki-biling.py).
3
+ * Every failure surfaces as a named TranslateError. URL: append /v1/messages
4
+ * unless the endpoint already ends with /v1 or /v1/messages (double-v1 trap,
5
+ * machine-verified). Key redacted from body-derived details; no retry loop
6
+ * (chunked translation is deterministic, each request costs quota).
7
+ */
8
+ import { isRecord } from './jsonc.js';
9
+ export class TranslateError extends Error {
10
+ cause;
11
+ detail;
12
+ constructor(cause, detail) {
13
+ super(`translate ${cause}: ${detail}`);
14
+ this.name = 'TranslateError';
15
+ this.cause = cause;
16
+ this.detail = detail;
17
+ }
18
+ }
19
+ // --- Constants (legacy wiki-biling values, config-driven via opts) ----------
20
+ export const DEFAULT_TRANSLATE_TIMEOUT_MS = 300_000;
21
+ const MAX_CHUNK_CHARS = 4000;
22
+ const SHORT_TEXT_CHARS = 1500;
23
+ const SHORT_MAX_TOKENS = 500;
24
+ const LONG_MAX_TOKENS = 32_000;
25
+ const ANTHROPIC_VERSION = '2023-06-01';
26
+ // --- URL normalization ------------------------------------------------------
27
+ export function normalizeMessagesUrl(endpoint) {
28
+ const base = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint;
29
+ const lower = base.toLowerCase();
30
+ if (lower.endsWith('/v1/messages') || lower.endsWith('/v1'))
31
+ return base;
32
+ return `${base}/v1/messages`;
33
+ }
34
+ // --- Chunking ---------------------------------------------------------------
35
+ const FENCE_LINE = /^\s*```/;
36
+ /** True when the block toggles the fence-open state (odd delimiter count). */
37
+ function togglesFence(block) {
38
+ return block.split('\n').reduce((c, l) => (FENCE_LINE.test(l) ? c + 1 : c), 0) % 2 === 1;
39
+ }
40
+ /** Split on blank lines into groups of at most `max` chars. Oversized single
41
+ * blocks and fence runs pass through whole (never split inside ``` fences),
42
+ * so rejoining translated groups with '\n\n' reproduces the input structure. */
43
+ export function splitMarkdownBlocks(text, max) {
44
+ if (text.length <= max)
45
+ return [text];
46
+ const groups = [];
47
+ let current = [];
48
+ let currentLen = 0;
49
+ let inFence = false;
50
+ const flush = () => {
51
+ if (current.length > 0)
52
+ groups.push(current.join('\n\n'));
53
+ current = [];
54
+ currentLen = 0;
55
+ };
56
+ for (const block of text.split(/\n\s*\n/)) {
57
+ if (inFence) {
58
+ current.push(block);
59
+ currentLen += 2 + block.length;
60
+ if (togglesFence(block)) {
61
+ inFence = false;
62
+ flush();
63
+ }
64
+ continue;
65
+ }
66
+ if (togglesFence(block)) {
67
+ if (currentLen > 0)
68
+ flush();
69
+ inFence = true;
70
+ current.push(block);
71
+ currentLen = block.length;
72
+ continue;
73
+ }
74
+ if (currentLen > 0 && currentLen + 2 + block.length > max)
75
+ flush();
76
+ current.push(block);
77
+ currentLen = currentLen === 0 ? block.length : currentLen + 2 + block.length;
78
+ }
79
+ flush();
80
+ return groups;
81
+ }
82
+ // --- Response parsing -------------------------------------------------------
83
+ /** Primary: the first content part when it is a text part. Fallback: join
84
+ * all text-type parts (content[0] may be a tool_use part). Missing/empty
85
+ * text is malformed — never silently translated to ''. */
86
+ export function pickResponseText(json) {
87
+ if (!isRecord(json))
88
+ throw new TranslateError('malformed', 'response body is not a JSON object');
89
+ const content = json.content;
90
+ if (!Array.isArray(content)) {
91
+ throw new TranslateError('malformed', 'response content is missing or not an array');
92
+ }
93
+ const texts = [];
94
+ for (const part of content) {
95
+ if (isRecord(part) && part.type === 'text' && typeof part.text === 'string') {
96
+ texts.push(part.text);
97
+ }
98
+ }
99
+ if (texts.length === 0) {
100
+ throw new TranslateError('malformed', 'response content has no text parts');
101
+ }
102
+ const out = isRecord(content[0]) && content[0].type === 'text' && typeof content[0].text === 'string'
103
+ ? content[0].text
104
+ : texts.join('');
105
+ if (out === '')
106
+ throw new TranslateError('malformed', 'response text is empty');
107
+ return out;
108
+ }
109
+ // --- System prompt ----------------------------------------------------------
110
+ /** Machine-consumed routing tokens (DIRECTION, GLOSSARY table) plus the
111
+ * untranslatable-surface rules. Tests pin the tokens, not the prose. */
112
+ export function buildSystemPrompt(from, to, glossary) {
113
+ const lines = [
114
+ `DIRECTION: ${from}->${to}`,
115
+ 'Translate only natural prose. Preserve markdown structure exactly: do not translate code blocks, inline code, URLs, HTML tags, .is-* marker classes, table cell structure (pipes, alignment rows), numbers, or IDs.',
116
+ ];
117
+ if (glossary !== undefined) {
118
+ lines.push('GLOSSARY:');
119
+ for (const [term, translation] of Object.entries(glossary)) {
120
+ lines.push(`${term}|${translation}`);
121
+ }
122
+ }
123
+ return lines.join('\n');
124
+ }
125
+ export async function callMessages(opts, deps, call) {
126
+ if (opts.translate.endpoint.trim() === '') {
127
+ throw new TranslateError('config', 'translate.endpoint not configured — set plugin option translate.endpoint ' +
128
+ 'or export HISTORIAN_TRANSLATE_ENDPOINT.');
129
+ }
130
+ const url = normalizeMessagesUrl(opts.translate.endpoint);
131
+ const key = opts.translate.apiKey;
132
+ const fetchImpl = deps?.fetchImpl ?? fetch;
133
+ const timeoutMs = deps?.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS;
134
+ const maxTokens = call.maxTokens ?? LONG_MAX_TOKENS;
135
+ let res;
136
+ try {
137
+ res = await fetchImpl(url, {
138
+ method: 'POST',
139
+ headers: {
140
+ 'x-api-key': key,
141
+ 'anthropic-version': ANTHROPIC_VERSION,
142
+ 'content-type': 'application/json',
143
+ },
144
+ body: JSON.stringify({
145
+ model: opts.translate.model,
146
+ max_tokens: maxTokens,
147
+ system: call.system,
148
+ messages: [{ role: 'user', content: call.user }],
149
+ }),
150
+ signal: AbortSignal.timeout(timeoutMs),
151
+ });
152
+ }
153
+ catch (err) {
154
+ if (isAbortError(err)) {
155
+ throw new TranslateError('timeout', `request to ${url} timed out after ${timeoutMs}ms`);
156
+ }
157
+ throw new TranslateError('network', `request to ${url} failed: ${redact(err instanceof Error ? err.message : String(err), key)}`);
158
+ }
159
+ const rawBody = redact(await readTextSafely(res), key);
160
+ if (res.status < 200 || res.status >= 300) {
161
+ throw new TranslateError('http', `HTTP ${res.status} from ${url}: ${snippetOf(rawBody)}`);
162
+ }
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(rawBody);
166
+ }
167
+ catch {
168
+ throw new TranslateError('malformed', `non-json response from ${url}: ${snippetOf(rawBody)}`);
169
+ }
170
+ if (isRecord(parsed) && parsed.stop_reason === 'max_tokens') {
171
+ throw new TranslateError('truncated', `response stop_reason 'max_tokens' from ${url}`);
172
+ }
173
+ return pickResponseText(parsed);
174
+ }
175
+ export function makeTranslator(opts, deps) {
176
+ const glossary = deps?.glossary;
177
+ return async (text, from, to) => {
178
+ const chunks = splitMarkdownBlocks(text, MAX_CHUNK_CHARS);
179
+ const translated = [];
180
+ for (const chunk of chunks) {
181
+ translated.push(await translateChunk(chunk, from, to));
182
+ }
183
+ return translated.join('\n\n');
184
+ };
185
+ async function translateChunk(chunk, from, to) {
186
+ const maxTokens = chunk.length < SHORT_TEXT_CHARS ? SHORT_MAX_TOKENS : LONG_MAX_TOKENS;
187
+ return callMessages(opts, deps, {
188
+ system: buildSystemPrompt(from, to, glossary),
189
+ user: chunk,
190
+ maxTokens,
191
+ });
192
+ }
193
+ }
194
+ // --- small helpers (client.ts's guard patterns, kept local) -----------------
195
+ function isAbortError(err) {
196
+ return (err !== null && typeof err === 'object' && err.name === 'AbortError');
197
+ }
198
+ const redact = (text, key) => key === '' || !text.includes(key) ? text : text.split(key).join('<redacted>');
199
+ const snippetOf = (text) => text.length <= 200 ? text : `${text.slice(0, 200)}…`;
200
+ async function readTextSafely(res) {
201
+ try {
202
+ return await res.text();
203
+ }
204
+ catch {
205
+ return '';
206
+ }
207
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Asset upload via POST /u (wiki.js multipart endpoint, pitfall #7).
3
+ *
4
+ * Body: FormData, field `mediaUpload` (filename = sanitizeAssetName(filename)).
5
+ * Optional `parent` field carries numeric folderId for folder-scoped uploads.
6
+ * Auth: Bearer token. Api key is INJECTED via `deps.apiKey` — resolution
7
+ * lives in the caller (tools layer, todo 11, via readWikiApiKey), so this
8
+ * module stays network- and config-free (pure + injected, trivially testable).
9
+ *
10
+ * Response JSON: `{type:'success', location}` or `{type:'error', msg}`.
11
+ * Non-JSON and timeouts → AssetUploadError (message names 'timeout').
12
+ */
13
+ /** Server-rejected upload or transport failure. `serverMsg` carries the
14
+ * wiki.js `msg` field on server errors; empty on transport failures. */
15
+ export declare class AssetUploadError extends Error {
16
+ readonly serverMsg: string;
17
+ constructor(message: string, serverMsg?: string);
18
+ }
19
+ /**
20
+ * Lowercase; ` `/`,`/`;`/`#` → `_`; collapse `_+`; strip leading `_`;
21
+ * preserve the extension (last dot group) lowercased. Fallback `'asset'`
22
+ * on empty / degenerate inputs. Invariant: never produces `'..'`.
23
+ * Non-ASCII letters are preserved (only named chars above are replaced).
24
+ */
25
+ export declare function sanitizeAssetName(name: string): string;
26
+ export interface AssetUploadResult {
27
+ readonly url: string;
28
+ readonly name: string;
29
+ readonly size: number;
30
+ }
31
+ export interface UploadDeps {
32
+ readonly baseUrl: string;
33
+ readonly apiKey: string;
34
+ readonly fetchImpl?: typeof fetch;
35
+ readonly timeoutMs?: number;
36
+ }
37
+ export interface UploadFile {
38
+ readonly bytes: Uint8Array | string;
39
+ readonly filename: string;
40
+ }
41
+ /** POST a single file to /u. Returns {url, sanitizedName, byteLength}. */
42
+ export declare function uploadAsset(deps: UploadDeps, file: UploadFile, folderId?: number): Promise<AssetUploadResult>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Asset upload via POST /u (wiki.js multipart endpoint, pitfall #7).
3
+ *
4
+ * Body: FormData, field `mediaUpload` (filename = sanitizeAssetName(filename)).
5
+ * Optional `parent` field carries numeric folderId for folder-scoped uploads.
6
+ * Auth: Bearer token. Api key is INJECTED via `deps.apiKey` — resolution
7
+ * lives in the caller (tools layer, todo 11, via readWikiApiKey), so this
8
+ * module stays network- and config-free (pure + injected, trivially testable).
9
+ *
10
+ * Response JSON: `{type:'success', location}` or `{type:'error', msg}`.
11
+ * Non-JSON and timeouts → AssetUploadError (message names 'timeout').
12
+ */
13
+ // --- Error ------------------------------------------------------------------
14
+ /** Server-rejected upload or transport failure. `serverMsg` carries the
15
+ * wiki.js `msg` field on server errors; empty on transport failures. */
16
+ export class AssetUploadError extends Error {
17
+ serverMsg;
18
+ constructor(message, serverMsg = '') {
19
+ super(message);
20
+ this.name = 'AssetUploadError';
21
+ this.serverMsg = serverMsg;
22
+ }
23
+ }
24
+ // --- sanitizeAssetName ------------------------------------------------------
25
+ /**
26
+ * Lowercase; ` `/`,`/`;`/`#` → `_`; collapse `_+`; strip leading `_`;
27
+ * preserve the extension (last dot group) lowercased. Fallback `'asset'`
28
+ * on empty / degenerate inputs. Invariant: never produces `'..'`.
29
+ * Non-ASCII letters are preserved (only named chars above are replaced).
30
+ */
31
+ export function sanitizeAssetName(name) {
32
+ if (name === '')
33
+ return 'asset';
34
+ const dot = name.lastIndexOf('.');
35
+ const hasExt = dot > 0 && dot < name.length - 1;
36
+ const base = (hasExt ? name.slice(0, dot) : name)
37
+ .toLowerCase()
38
+ .replace(/[ ,;#]/g, '_')
39
+ .replace(/_+/g, '_')
40
+ .replace(/^_+/, '');
41
+ if (base === '' || base.includes('..'))
42
+ return 'asset';
43
+ return hasExt ? `${base}.${name.slice(dot + 1).toLowerCase()}` : base;
44
+ }
45
+ const DEFAULT_TIMEOUT_MS = 30_000;
46
+ function isAbort(err) {
47
+ return err != null && typeof err === 'object' && err.name === 'AbortError';
48
+ }
49
+ /** POST a single file to /u. Returns {url, sanitizedName, byteLength}. */
50
+ export async function uploadAsset(deps, file, folderId) {
51
+ const url = `${deps.baseUrl.replace(/\/+$/, '')}/u`;
52
+ const name = sanitizeAssetName(file.filename);
53
+ const bytes = typeof file.bytes === 'string' ? new TextEncoder().encode(file.bytes) : file.bytes;
54
+ const form = new FormData();
55
+ form.set('mediaUpload', new File([bytes], name));
56
+ if (folderId !== undefined)
57
+ form.set('parent', String(folderId));
58
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
59
+ let res;
60
+ try {
61
+ res = await (deps.fetchImpl ?? fetch)(url, {
62
+ method: 'POST',
63
+ headers: { authorization: `Bearer ${deps.apiKey}` },
64
+ body: form,
65
+ signal: AbortSignal.timeout(timeoutMs),
66
+ });
67
+ }
68
+ catch (err) {
69
+ const detail = isAbort(err) ? `timeout after ${timeoutMs}ms` : err.message;
70
+ throw new AssetUploadError(`asset upload to ${url} ${detail}`);
71
+ }
72
+ const text = await res.text();
73
+ let body;
74
+ try {
75
+ body = JSON.parse(text);
76
+ }
77
+ catch {
78
+ throw new AssetUploadError(`asset upload to ${url} returned non-JSON (HTTP ${res.status})`);
79
+ }
80
+ const type = body?.type;
81
+ if (type === 'success') {
82
+ const loc = body.location;
83
+ return { url: typeof loc === 'string' ? loc : '', name, size: bytes.byteLength };
84
+ }
85
+ if (type === 'error') {
86
+ const msg = body.msg;
87
+ const s = typeof msg === 'string' ? msg : 'asset upload rejected by server';
88
+ throw new AssetUploadError(s, s);
89
+ }
90
+ throw new AssetUploadError(`asset upload to ${url} returned unexpected payload (HTTP ${res.status})`);
91
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * wiki.js GraphQL client with three-layer error discrimination.
3
+ *
4
+ * Every failure from `gql` surfaces as one of four named, structured errors —
5
+ * never a raw fetch/parse exception:
6
+ *
7
+ * 1. HTTP status outside 2xx -> HttpError (401/403 -> PermissionError)
8
+ * 2. Body that is not parseable JSON -> HttpError ('non-json response')
9
+ * 3. Top-level GraphQL `errors[]` -> GraphQLError
10
+ * 4. Payload `responseResult.succeeded === false` -> WikiError (permission-ish
11
+ * errorCode/message -> PermissionError, the class the plan's R-b contract
12
+ * refers to). The responseResult lookup is field-name agnostic: wiki.js
13
+ * wraps every pages.* operation's result key, so we inspect the first key
14
+ * of `data` (pitfall #3).
15
+ *
16
+ * The api key is resolved eagerly at createClient time (ConfigError
17
+ * propagates) and retained in a module-private WeakMap — never an enumerable
18
+ * client field, never embedded in any thrown message (bodies are redacted
19
+ * before they become snippets or GraphQL error text).
20
+ *
21
+ * The responseResult lookup is field-name agnostic — the operation name is
22
+ * never hardcoded. Two nesting shapes occur: the live instance nests it as
23
+ * `data.pages.<op>.responseResult` (PageMutation -> PageResponse), and a flat
24
+ * variant sits as `data.<root-field>.responseResult`. Both are covered.
25
+ */
26
+ import { type HistorianOptions } from '../config.js';
27
+ export declare const DEFAULT_TIMEOUT_MS = 30000;
28
+ export interface RawGraphQLError {
29
+ readonly message: string;
30
+ readonly path?: readonly (string | number)[];
31
+ }
32
+ /** Transport/parse-layer failure: non-2xx status, unparseable or malformed
33
+ * bodies, timeouts (status 0 = no HTTP response was received). */
34
+ export declare class HttpError extends Error {
35
+ readonly status: number;
36
+ readonly bodySnippet: string;
37
+ readonly url: string;
38
+ constructor(message: string, status: number, bodySnippet: string, url: string);
39
+ }
40
+ /** GraphQL-layer failure: the server answered 2xx but reported errors[]. */
41
+ export declare class GraphQLError extends Error {
42
+ readonly rawErrors: readonly RawGraphQLError[];
43
+ constructor(message: string, rawErrors: readonly RawGraphQLError[]);
44
+ }
45
+ /** Wiki.js payload-layer failure: responseResult.succeeded === false. */
46
+ export declare class WikiError extends Error {
47
+ readonly errorCode: string;
48
+ readonly slug: string;
49
+ constructor(errorCode: string, slug: string, message: string);
50
+ }
51
+ /** Permission failures from either layer — HTTP 401/403 or a permission-ish
52
+ * payload errorCode/message. Named per plan R-b (wiki token scope shortage). */
53
+ export declare class PermissionError extends WikiError {
54
+ constructor(errorCode: string, slug: string, message: string);
55
+ }
56
+ export interface GqlClient {
57
+ readonly baseUrl: string;
58
+ readonly fetchImpl: typeof fetch;
59
+ readonly timeoutMs: number;
60
+ }
61
+ export declare function createClient(options: HistorianOptions, deps?: {
62
+ fetchImpl?: typeof fetch;
63
+ timeoutMs?: number;
64
+ env?: NodeJS.ProcessEnv;
65
+ homeDir?: string;
66
+ }): GqlClient;
67
+ export declare function gql<T>(client: GqlClient, query: string, vars: Record<string, unknown>): Promise<T>;