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
package/dist/config.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration model for the opencode-historian plugin.
|
|
3
|
+
*
|
|
4
|
+
* The plugin keeps a zero-runtime-dependency surface: no zod, no bun, only
|
|
5
|
+
* node builtins. Secrets are resolved from explicit sources (raw plugin
|
|
6
|
+
* options, env vars, the machine's opencode.jsonc) and never from the network.
|
|
7
|
+
*
|
|
8
|
+
* Home directory and env are injectable parameters on every resolver so unit
|
|
9
|
+
* tests drive fixtures in tmp dirs — the real ~/.config is never touched by
|
|
10
|
+
* the suite (see test/config.test.ts).
|
|
11
|
+
*/
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { parseJsonc, isRecord } from './jsonc.js';
|
|
15
|
+
import { ConfigError } from './jsonc.js';
|
|
16
|
+
// Contract re-exports: consumers (client.ts, translate.ts) import these from
|
|
17
|
+
// './config.js'.
|
|
18
|
+
export { ConfigError } from './jsonc.js';
|
|
19
|
+
// --- Defaults (single source of truth for the plan's contract) --------------
|
|
20
|
+
// Machine-agnostic by contract: the shipped package carries no deployment's
|
|
21
|
+
// section taxonomy, translation gateway URL, or provider layout.
|
|
22
|
+
export const DEFAULT_BASE_URL = 'http://localhost:3000';
|
|
23
|
+
export const DEFAULT_API_KEY_PATH = '~/.wikijs-api-key';
|
|
24
|
+
/** Env leg of the endpoint chain: raw option → this env var → unset (''). */
|
|
25
|
+
export const ENV_TRANSLATE_ENDPOINT = 'HISTORIAN_TRANSLATE_ENDPOINT';
|
|
26
|
+
export const DEFAULT_TRANSLATE_MODEL = 'qwen3.7-plus';
|
|
27
|
+
/** Empty = no path-prefix restriction (see HistorianOptions.sections). */
|
|
28
|
+
export const DEFAULT_SECTIONS = [];
|
|
29
|
+
export const DEFAULT_LOCALES = ['en', 'zh'];
|
|
30
|
+
/** Reading loop is on unless explicitly disabled (plan v2 todo 8). */
|
|
31
|
+
export const DEFAULT_READING_LOOP = true;
|
|
32
|
+
/** Capture reminders are opt-in (plan v2 todo 9). */
|
|
33
|
+
export const DEFAULT_CAPTURE_ENABLED = false;
|
|
34
|
+
// --- Resolution -------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Resolve raw plugin options against defaults, env vars and the machine's
|
|
37
|
+
* `~/.config/opencode/opencode.jsonc` into a fully typed HistorianOptions.
|
|
38
|
+
*
|
|
39
|
+
* Translation key priority (highest wins):
|
|
40
|
+
* 1. raw.translate.apiKey
|
|
41
|
+
* 2. env DASHSCOPE_API_KEY
|
|
42
|
+
* 3. jsonc provider[translate.providerKey].options.apiKey — only when
|
|
43
|
+
* `translate.providerKey` is set explicitly (no default provider name)
|
|
44
|
+
* 4. throw ConfigError('missing translation key')
|
|
45
|
+
*
|
|
46
|
+
* Translation endpoint priority: raw.translate.endpoint →
|
|
47
|
+
* env HISTORIAN_TRANSLATE_ENDPOINT → unset (''). There is no baked-in
|
|
48
|
+
* gateway URL; an unset endpoint degrades translate calls to
|
|
49
|
+
* TranslateError('config') (twins go pending, see translate.ts).
|
|
50
|
+
*
|
|
51
|
+
* There is deliberately NO anthropic fallback leg (removed by plan — the
|
|
52
|
+
* provider layout varies per machine; the jsonc leg is opt-in via
|
|
53
|
+
* translate.providerKey).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveOptions(raw, env = process.env, homeDir = homedir()) {
|
|
56
|
+
const providerKey = raw.translate?.providerKey ?? '';
|
|
57
|
+
const translateApiKey = resolveTranslationApiKey(raw, env, homeDir, providerKey);
|
|
58
|
+
return {
|
|
59
|
+
baseUrl: raw.baseUrl ?? DEFAULT_BASE_URL,
|
|
60
|
+
apiKeyPath: raw.apiKeyPath ?? DEFAULT_API_KEY_PATH,
|
|
61
|
+
translate: {
|
|
62
|
+
endpoint: raw.translate?.endpoint ?? env[ENV_TRANSLATE_ENDPOINT] ?? '',
|
|
63
|
+
model: raw.translate?.model ?? DEFAULT_TRANSLATE_MODEL,
|
|
64
|
+
apiKey: translateApiKey,
|
|
65
|
+
providerKey,
|
|
66
|
+
},
|
|
67
|
+
sections: raw.sections ?? DEFAULT_SECTIONS,
|
|
68
|
+
locales: raw.locales ?? DEFAULT_LOCALES,
|
|
69
|
+
readingLoop: raw.readingLoop ?? DEFAULT_READING_LOOP,
|
|
70
|
+
capture: { enabled: raw.capture?.enabled ?? DEFAULT_CAPTURE_ENABLED },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function resolveTranslationApiKey(raw, env, homeDir, providerKey) {
|
|
74
|
+
const rawKey = raw.translate?.apiKey;
|
|
75
|
+
if (isNonEmpty(rawKey))
|
|
76
|
+
return rawKey;
|
|
77
|
+
const envKey = env.DASHSCOPE_API_KEY;
|
|
78
|
+
if (isNonEmpty(envKey))
|
|
79
|
+
return envKey;
|
|
80
|
+
if (providerKey !== '') {
|
|
81
|
+
const jsoncKey = readProviderApiKeyFromJsonc(homeDir, providerKey);
|
|
82
|
+
if (isNonEmpty(jsoncKey))
|
|
83
|
+
return jsoncKey;
|
|
84
|
+
}
|
|
85
|
+
const jsoncHint = providerKey !== ''
|
|
86
|
+
? `add provider["${providerKey}"].options.apiKey`
|
|
87
|
+
: `set translate.providerKey and add the key to provider["<name>"].options.apiKey`;
|
|
88
|
+
throw new ConfigError('missing-translation-key', `Missing translation api key: set plugin option translate.apiKey, ` +
|
|
89
|
+
`export DASHSCOPE_API_KEY, or ${jsoncHint} ` +
|
|
90
|
+
`in ${opencodeJsoncPath(homeDir)}.`);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the wiki.js api key. Priority: key file (path from options.apiKeyPath,
|
|
94
|
+
* `~` expanded) first; env WIKIJS_API_KEY as fallback; ConfigError otherwise.
|
|
95
|
+
* The key file content is trimmed (a trailing newline is common).
|
|
96
|
+
*/
|
|
97
|
+
export function readWikiApiKey(options, env = process.env, homeDir = homedir()) {
|
|
98
|
+
const expandedPath = expandHome(options.apiKeyPath, homeDir);
|
|
99
|
+
let fileKey;
|
|
100
|
+
try {
|
|
101
|
+
fileKey = readFileSync(expandedPath, 'utf8').trim();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
fileKey = undefined; // fall through to env leg; the thrown error names the path
|
|
105
|
+
}
|
|
106
|
+
if (isNonEmpty(fileKey))
|
|
107
|
+
return fileKey;
|
|
108
|
+
const envKey = env.WIKIJS_API_KEY;
|
|
109
|
+
if (isNonEmpty(envKey))
|
|
110
|
+
return envKey;
|
|
111
|
+
throw new ConfigError('missing-wiki-api-key', `Missing wiki.js api key: create '${expandedPath}' containing the token ` +
|
|
112
|
+
'(one line, trimmed on read) or export WIKIJS_API_KEY.');
|
|
113
|
+
}
|
|
114
|
+
// --- jsonc source leg -------------------------------------------------------
|
|
115
|
+
function opencodeJsoncPath(homeDir) {
|
|
116
|
+
return `${homeDir}/.config/opencode/opencode.jsonc`;
|
|
117
|
+
}
|
|
118
|
+
/** Read provider[providerKey].options.apiKey from the machine config.
|
|
119
|
+
* Missing file or missing key -> undefined (a later leg decides); malformed
|
|
120
|
+
* file -> ConfigError. Commented-out apiKey lines never survive stripping. */
|
|
121
|
+
function readProviderApiKeyFromJsonc(homeDir, providerKey) {
|
|
122
|
+
const path = opencodeJsoncPath(homeDir);
|
|
123
|
+
let content;
|
|
124
|
+
try {
|
|
125
|
+
content = readFileSync(path, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined; // no config file -> simply not a key source
|
|
129
|
+
}
|
|
130
|
+
const parsed = parseJsonc(content, path);
|
|
131
|
+
if (!isRecord(parsed)) {
|
|
132
|
+
throw new ConfigError('invalid-jsonc', `Invalid opencode config '${path}': top-level value must be a JSON object.`);
|
|
133
|
+
}
|
|
134
|
+
const provider = parsed.provider;
|
|
135
|
+
if (!isRecord(provider))
|
|
136
|
+
return undefined; // no providers at all -> not a key source
|
|
137
|
+
const chosen = provider[providerKey];
|
|
138
|
+
if (!isRecord(chosen))
|
|
139
|
+
return undefined;
|
|
140
|
+
const options = chosen.options;
|
|
141
|
+
if (!isRecord(options))
|
|
142
|
+
return undefined;
|
|
143
|
+
const apiKey = options.apiKey;
|
|
144
|
+
return typeof apiKey === 'string' && apiKey.trim() !== '' ? apiKey : undefined;
|
|
145
|
+
}
|
|
146
|
+
// --- small helpers ----------------------------------------------------------
|
|
147
|
+
function isNonEmpty(value) {
|
|
148
|
+
return value !== undefined && value.trim() !== '';
|
|
149
|
+
}
|
|
150
|
+
/** Expand a leading `~` in a path against homeDir. Only `~` and `~/...` are
|
|
151
|
+
* supported (no `~user` forms). */
|
|
152
|
+
function expandHome(p, homeDir) {
|
|
153
|
+
if (p === '~')
|
|
154
|
+
return homeDir;
|
|
155
|
+
if (p.startsWith('~/'))
|
|
156
|
+
return `${homeDir}/${p.slice(2)}`;
|
|
157
|
+
return p;
|
|
158
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-historian plugin entry (todo 12).
|
|
3
|
+
*
|
|
4
|
+
* Exports the V1 plugin object shape verified by the oracle:
|
|
5
|
+
* export default { id: string, server: (input, options?) => Promise<Hooks> }
|
|
6
|
+
*
|
|
7
|
+
* The server() hook resolves plugin options and returns:
|
|
8
|
+
* - config: mutates cfg.skills.paths to ship the bundled skills/ directory
|
|
9
|
+
* and registers the /historian-capture command (todo 9)
|
|
10
|
+
* - tool: 10 historian_* tools wired by buildTools(opts)
|
|
11
|
+
* - experimental.chat.system.transform: pushes the historian-first reading
|
|
12
|
+
* loop advisory onto output.system[] (gated by opts.readingLoop)
|
|
13
|
+
* - event: on session.idle emits ONE capture reminder toast per session
|
|
14
|
+
* (gated by opts.capture.enabled; reminder-only — the page write happens
|
|
15
|
+
* through /historian-capture -> historian_page_create, never here)
|
|
16
|
+
*
|
|
17
|
+
* Defensive: if resolveOptions throws (e.g. missing translate key), we catch
|
|
18
|
+
* at the server() boundary, log once to console.error, and return hooks with
|
|
19
|
+
* empty tools. The plugin registration itself must not crash opencode startup;
|
|
20
|
+
* individual tool calls will fail with a clear error if invoked without valid
|
|
21
|
+
* options. This matches the plan's "fail with clear one-time console.error"
|
|
22
|
+
* contract.
|
|
23
|
+
*/
|
|
24
|
+
import type { PluginInput, PluginOptions, Hooks } from '@opencode-ai/plugin';
|
|
25
|
+
type ServerFn = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>;
|
|
26
|
+
interface PluginExport {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly server: ServerFn;
|
|
29
|
+
}
|
|
30
|
+
declare const plugin: PluginExport;
|
|
31
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-historian plugin entry (todo 12).
|
|
3
|
+
*
|
|
4
|
+
* Exports the V1 plugin object shape verified by the oracle:
|
|
5
|
+
* export default { id: string, server: (input, options?) => Promise<Hooks> }
|
|
6
|
+
*
|
|
7
|
+
* The server() hook resolves plugin options and returns:
|
|
8
|
+
* - config: mutates cfg.skills.paths to ship the bundled skills/ directory
|
|
9
|
+
* and registers the /historian-capture command (todo 9)
|
|
10
|
+
* - tool: 10 historian_* tools wired by buildTools(opts)
|
|
11
|
+
* - experimental.chat.system.transform: pushes the historian-first reading
|
|
12
|
+
* loop advisory onto output.system[] (gated by opts.readingLoop)
|
|
13
|
+
* - event: on session.idle emits ONE capture reminder toast per session
|
|
14
|
+
* (gated by opts.capture.enabled; reminder-only — the page write happens
|
|
15
|
+
* through /historian-capture -> historian_page_create, never here)
|
|
16
|
+
*
|
|
17
|
+
* Defensive: if resolveOptions throws (e.g. missing translate key), we catch
|
|
18
|
+
* at the server() boundary, log once to console.error, and return hooks with
|
|
19
|
+
* empty tools. The plugin registration itself must not crash opencode startup;
|
|
20
|
+
* individual tool calls will fail with a clear error if invoked without valid
|
|
21
|
+
* options. This matches the plan's "fail with clear one-time console.error"
|
|
22
|
+
* contract.
|
|
23
|
+
*/
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
import { resolveOptions } from './config.js';
|
|
26
|
+
import { buildTools } from './tools.js';
|
|
27
|
+
/** Resolve the absolute path to the bundled skills/ directory. Uses
|
|
28
|
+
* import.meta.url so it works whether loaded from dist/ (compiled) or src/
|
|
29
|
+
* (dev). The skills/ directory may not exist yet (todo 13 creates it) — that
|
|
30
|
+
* is plan-sanctioned; the config hook must not throw on a nonexistent dir,
|
|
31
|
+
* opencode will simply skip it. */
|
|
32
|
+
const skillsDir = fileURLToPath(new URL('../skills/', import.meta.url));
|
|
33
|
+
/** Deduplicate an array of strings preserving first-occurrence order. Small
|
|
34
|
+
* local helper — no reason to pull a dependency for a 3-liner. */
|
|
35
|
+
function unique(items) {
|
|
36
|
+
return [...new Set(items)];
|
|
37
|
+
}
|
|
38
|
+
/** Reading-loop advisory (plan v2 todo 8): the machine wiki is the
|
|
39
|
+
* authoritative institutional memory; consult it before acting, cite URLs.
|
|
40
|
+
* Shipped text — generic wording only (privacy-audit scans dist). */
|
|
41
|
+
const READING_LOOP_ADVISORY = [
|
|
42
|
+
'You have a historian: a wiki.js knowledge base acting as this machine\'s authoritative institutional memory.',
|
|
43
|
+
'Before doing work that touches this machine\'s deployments, history, pitfalls, or decisions, consult it first:',
|
|
44
|
+
'- historian_search by topic for relevant pages; historian_map action:"timeline" for what changed recently;',
|
|
45
|
+
'- G5 current-state ledger pages answer "what is deployed/running now" — check each row\'s verified date before trusting it.',
|
|
46
|
+
'Cite the wiki page URLs you relied on. If you learn something new worth keeping, offer to record it as a page.',
|
|
47
|
+
].join('\n');
|
|
48
|
+
/** /historian-capture command (plan v2 todo 9): the always-available manual
|
|
49
|
+
* path from "notable session" to "G1 event page" — registered regardless of
|
|
50
|
+
* capture.enabled; the enabled-gated toast only nudges toward it. Agent-facing
|
|
51
|
+
* instruction text, generic wording only (ships in the tarball). */
|
|
52
|
+
const CAPTURE_COMMAND_DESCRIPTION = '把本次会话记为史官事件页 / record this session as a historian event page';
|
|
53
|
+
const CAPTURE_COMMAND_TEMPLATE = [
|
|
54
|
+
'Summarize the current session as a historian G1 event page (an append-only record of what happened).',
|
|
55
|
+
'',
|
|
56
|
+
'1. Draft four sections: 过程/Process (what was done, in order), 原因/Cause (why it was needed), 后果/Consequence (impact, artifacts), 改进/Improvement (follow-ups, preventions).',
|
|
57
|
+
"2. Run historian_map action:'show' to see existing sections, then choose a short factual path under one.",
|
|
58
|
+
'3. Save with historian_page_create (genre "G1"); the zh twin is auto-created. If the session only repeated known knowledge, say so and skip writing.',
|
|
59
|
+
'4. Echo both page URLs (en + zh) back to the user.',
|
|
60
|
+
].join('\n');
|
|
61
|
+
const CAPTURE_TOAST_MESSAGE = '会话空闲:有值得留存的决定/修复/踩坑就跑 /historian-capture。Session idle — run /historian-capture if it produced decisions, fixes, or pitfalls worth keeping.';
|
|
62
|
+
async function server(input, options) {
|
|
63
|
+
let opts;
|
|
64
|
+
try {
|
|
65
|
+
opts = resolveOptions((options ?? {}));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// Defensive: log once and return empty hooks. The plugin must not crash
|
|
69
|
+
// opencode startup if configuration is incomplete. Individual tool calls
|
|
70
|
+
// would fail here anyway since buildTools requires full HistorianOptions.
|
|
71
|
+
console.error('[opencode-historian] Failed to resolve plugin options; tools disabled.', err instanceof Error ? err.message : err);
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
const captureReminded = new Set();
|
|
75
|
+
const hooks = {
|
|
76
|
+
config: async (cfg) => {
|
|
77
|
+
const cfgWithSkills = cfg;
|
|
78
|
+
cfgWithSkills.skills ??= {};
|
|
79
|
+
cfgWithSkills.skills.paths = unique([...(cfgWithSkills.skills.paths ?? []), skillsDir]);
|
|
80
|
+
cfg.command ??= {};
|
|
81
|
+
// ??= — a user-defined /historian-capture in their own config wins;
|
|
82
|
+
// the plugin only supplies the default.
|
|
83
|
+
cfg.command['historian-capture'] ??= {
|
|
84
|
+
description: CAPTURE_COMMAND_DESCRIPTION,
|
|
85
|
+
template: CAPTURE_COMMAND_TEMPLATE,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
tool: buildTools(opts),
|
|
89
|
+
'experimental.chat.system.transform': async (_input, output) => {
|
|
90
|
+
try {
|
|
91
|
+
if (opts.readingLoop !== true)
|
|
92
|
+
return;
|
|
93
|
+
if (output.system.some((block) => block.includes('historian_search')))
|
|
94
|
+
return;
|
|
95
|
+
output.system.push(READING_LOOP_ADVISORY);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// A broken inject must never crash a chat request (plan v2 todo 8).
|
|
99
|
+
console.error('[opencode-historian] reading-loop advisory skipped:', err instanceof Error ? err.message : err);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
event: async ({ event }) => {
|
|
103
|
+
try {
|
|
104
|
+
if (opts.capture.enabled !== true)
|
|
105
|
+
return;
|
|
106
|
+
if (event.type !== 'session.idle')
|
|
107
|
+
return;
|
|
108
|
+
// Feature-detect recordable work: session.idle only fires on a
|
|
109
|
+
// busy→idle transition (a session that never ran a prompt never goes
|
|
110
|
+
// idle), and each session is reminded at most once per plugin load.
|
|
111
|
+
const sessionID = event.properties.sessionID;
|
|
112
|
+
if (captureReminded.has(sessionID))
|
|
113
|
+
return;
|
|
114
|
+
captureReminded.add(sessionID);
|
|
115
|
+
await input.client.tui.showToast({
|
|
116
|
+
body: {
|
|
117
|
+
title: '史官 / historian',
|
|
118
|
+
message: CAPTURE_TOAST_MESSAGE,
|
|
119
|
+
variant: 'info',
|
|
120
|
+
duration: 15000,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// A failed reminder must never break the event stream (plan v2 todo 9).
|
|
126
|
+
console.error('[opencode-historian] capture reminder skipped:', err instanceof Error ? err.message : err);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return hooks;
|
|
131
|
+
}
|
|
132
|
+
const plugin = {
|
|
133
|
+
id: 'opencode-historian',
|
|
134
|
+
server,
|
|
135
|
+
};
|
|
136
|
+
export default plugin;
|
package/dist/jsonc.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONC parsing for opencode's `~/.config/opencode/opencode.jsonc`.
|
|
3
|
+
*
|
|
4
|
+
* String-aware comment stripping — a `//` or `/*` inside a quoted string
|
|
5
|
+
* (e.g. "https://...") is literal, never a comment. Malformed input always
|
|
6
|
+
* surfaces as ConfigError, never a raw SyntaxError.
|
|
7
|
+
*/
|
|
8
|
+
export type ConfigErrorCode = 'missing-translation-key' | 'invalid-jsonc' | 'missing-wiki-api-key';
|
|
9
|
+
/** Structured configuration error. `code` lets callers branch programmatically;
|
|
10
|
+
* `message` is written for a human to act on. Never throw bare strings. */
|
|
11
|
+
export declare class ConfigError extends Error {
|
|
12
|
+
readonly code: ConfigErrorCode;
|
|
13
|
+
constructor(code: ConfigErrorCode, message: string);
|
|
14
|
+
}
|
|
15
|
+
/** Parse JSONC into an unknown value. `path` is used in error messages. */
|
|
16
|
+
export declare function parseJsonc(content: string, path: string): unknown;
|
|
17
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
package/dist/jsonc.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONC parsing for opencode's `~/.config/opencode/opencode.jsonc`.
|
|
3
|
+
*
|
|
4
|
+
* String-aware comment stripping — a `//` or `/*` inside a quoted string
|
|
5
|
+
* (e.g. "https://...") is literal, never a comment. Malformed input always
|
|
6
|
+
* surfaces as ConfigError, never a raw SyntaxError.
|
|
7
|
+
*/
|
|
8
|
+
/** Structured configuration error. `code` lets callers branch programmatically;
|
|
9
|
+
* `message` is written for a human to act on. Never throw bare strings. */
|
|
10
|
+
export class ConfigError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'ConfigError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Parse JSONC into an unknown value. `path` is used in error messages. */
|
|
19
|
+
export function parseJsonc(content, path) {
|
|
20
|
+
const stripped = stripComments(content, path);
|
|
21
|
+
const withCommasStripped = stripTrailingCommas(stripped, path);
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(withCommasStripped);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
throw new ConfigError('invalid-jsonc', `Invalid opencode config '${path}': JSON parse failed (${err.message}).`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Character scanner (not regex) so comment markers inside strings stay literal. */
|
|
30
|
+
function stripComments(content, path) {
|
|
31
|
+
let out = '';
|
|
32
|
+
let inString = false;
|
|
33
|
+
let inLineComment = false;
|
|
34
|
+
let inBlockComment = false;
|
|
35
|
+
for (let i = 0; i < content.length; i++) {
|
|
36
|
+
const c = content[i];
|
|
37
|
+
const next = content[i + 1];
|
|
38
|
+
if (inLineComment) {
|
|
39
|
+
if (c === '\n') {
|
|
40
|
+
inLineComment = false;
|
|
41
|
+
out += c;
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (inBlockComment) {
|
|
46
|
+
if (c === '*' && next === '/') {
|
|
47
|
+
inBlockComment = false;
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (inString) {
|
|
53
|
+
out += c;
|
|
54
|
+
if (c === '\\' && next !== undefined) {
|
|
55
|
+
out += next;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
else if (c === '"') {
|
|
59
|
+
inString = false;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (c === '"') {
|
|
64
|
+
inString = true;
|
|
65
|
+
out += c;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (c === '/' && next === '/') {
|
|
69
|
+
inLineComment = true;
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (c === '/' && next === '*') {
|
|
74
|
+
inBlockComment = true;
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
out += c;
|
|
79
|
+
}
|
|
80
|
+
if (inBlockComment) {
|
|
81
|
+
throw new ConfigError('invalid-jsonc', `Invalid opencode config '${path}': unterminated block comment.`);
|
|
82
|
+
}
|
|
83
|
+
if (inString) {
|
|
84
|
+
throw new ConfigError('invalid-jsonc', `Invalid opencode config '${path}': unterminated string.`);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Drop commas that trail an object/array entry: `[1,2,]` -> `[1,2]`. */
|
|
89
|
+
function stripTrailingCommas(content, path) {
|
|
90
|
+
let out = '';
|
|
91
|
+
let inString = false;
|
|
92
|
+
for (let i = 0; i < content.length; i++) {
|
|
93
|
+
const c = content[i];
|
|
94
|
+
if (inString) {
|
|
95
|
+
out += c;
|
|
96
|
+
if (c === '\\' && content[i + 1] !== undefined) {
|
|
97
|
+
out += content[i + 1];
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
else if (c === '"') {
|
|
101
|
+
inString = false;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === '"') {
|
|
106
|
+
inString = true;
|
|
107
|
+
out += c;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (c === ',') {
|
|
111
|
+
let j = i + 1;
|
|
112
|
+
while (j < content.length &&
|
|
113
|
+
(content[j] === ' ' || content[j] === '\t' || content[j] === '\n' || content[j] === '\r')) {
|
|
114
|
+
j++;
|
|
115
|
+
}
|
|
116
|
+
if (content[j] === '}' || content[j] === ']') {
|
|
117
|
+
continue; // drop the trailing comma
|
|
118
|
+
}
|
|
119
|
+
out += c;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
out += c;
|
|
123
|
+
}
|
|
124
|
+
if (inString) {
|
|
125
|
+
throw new ConfigError('invalid-jsonc', `Invalid opencode config '${path}': unterminated string.`);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
export function isRecord(value) {
|
|
130
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
131
|
+
}
|
package/dist/map.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware page map (todo 10): full cross-locale inventory with en/zh twin
|
|
3
|
+
* pairing, rendered as the markdown `_meta/page-map` cache page + local mirror.
|
|
4
|
+
*/
|
|
5
|
+
import type { HistorianOptions } from './config.js';
|
|
6
|
+
import type { GqlClient } from './wiki/client.js';
|
|
7
|
+
import { type Locale } from './wiki/pages.read.js';
|
|
8
|
+
export interface MapDeps {
|
|
9
|
+
readonly client: GqlClient;
|
|
10
|
+
readonly options: HistorianOptions;
|
|
11
|
+
}
|
|
12
|
+
export interface MapRow {
|
|
13
|
+
readonly id: number;
|
|
14
|
+
readonly locale: Locale;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly updatedAt: string;
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly twinUrl: string | null;
|
|
20
|
+
readonly twinId: number | null;
|
|
21
|
+
}
|
|
22
|
+
export interface MapStats {
|
|
23
|
+
readonly rows: number;
|
|
24
|
+
readonly paths: number;
|
|
25
|
+
readonly perLocale: Readonly<Record<string, number>>;
|
|
26
|
+
readonly missingTwinPaths: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export interface PageMap {
|
|
29
|
+
readonly rows: readonly MapRow[];
|
|
30
|
+
readonly stats: MapStats;
|
|
31
|
+
}
|
|
32
|
+
export interface MapSnapshot extends PageMap {
|
|
33
|
+
readonly generatedAt: string | null;
|
|
34
|
+
readonly staleSeconds: number | null;
|
|
35
|
+
}
|
|
36
|
+
export declare const CACHE_PATH = "_meta/page-map";
|
|
37
|
+
export declare const HEADER_ROW = "| ID | Locale | Path | Title | View URL | Twin | Updated At |";
|
|
38
|
+
export declare function mirrorPath(home: string): string;
|
|
39
|
+
/** Full inventory: every page of every configured locale (only private-
|
|
40
|
+
* namespace pages are excluded — they are not anonymously reachable), twins
|
|
41
|
+
* paired by exact path, rows sorted by path then locale. */
|
|
42
|
+
export declare function buildPageMap(deps: MapDeps): Promise<PageMap>;
|
|
43
|
+
export declare function renderMapMarkdown(rows: readonly MapRow[]): string;
|
|
44
|
+
export interface RefreshOptions {
|
|
45
|
+
readonly now?: Date;
|
|
46
|
+
readonly homeDir?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface RefreshResult {
|
|
49
|
+
readonly stats: MapStats;
|
|
50
|
+
readonly cacheUrl: string;
|
|
51
|
+
}
|
|
52
|
+
/** Rebuild + write cycle: fresh map → local mirror → `_meta/page-map` upsert.
|
|
53
|
+
* An existing cache page is patched content-only — updatePage is a full RMW,
|
|
54
|
+
* so the machine-fact isPrivate/isPublished/tags survive (pitfall #1). */
|
|
55
|
+
export declare function refreshMapCache(deps: MapDeps, opts?: RefreshOptions): Promise<RefreshResult>;
|
|
56
|
+
/** Read the local mirror with staleness in whole seconds; absent or damaged
|
|
57
|
+
* mirror → a live build (read-only — no cache page write, no mirror write). */
|
|
58
|
+
export declare function getMap(deps: MapDeps, homeDir?: string): Promise<MapSnapshot>;
|