oasis_test_v2 0.0.0 → 0.2.1

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.
@@ -0,0 +1,1486 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic Outline-backed LLM Wiki tool.
4
+ * No LLM decisions live here: paths, immutability, hashes, plans and lint are program rules.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { lookup } from "node:dns/promises";
9
+ import { readFile } from "node:fs/promises";
10
+ import { request as httpRequest } from "node:http";
11
+ import { request as httpsRequest } from "node:https";
12
+ import { BlockList, isIP } from "node:net";
13
+ import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+
16
+ const DEFAULT_TIMEOUT_MS = 30_000;
17
+ const MAX_SOURCE_BYTES = 5 * 1024 * 1024;
18
+ const MAX_WECHAT_IMAGE_BYTES = 4 * 1024 * 1024;
19
+ const MAX_WECHAT_IMAGES = 20;
20
+ const MAX_WECHAT_TOTAL_IMAGE_BYTES = 24 * 1024 * 1024;
21
+ const META_FENCE = "yaml";
22
+ const CONTENT_HASH_PENDING = "pending";
23
+ const USAGE = "Usage: outline-kb <command> [arguments] [--collection ID] [--root ID]. Run outline-kb <command> --help for the exact contract.";
24
+ const WRITE_COMMANDS = new Set(["init", "create", "update", "append", "import-file", "import-url", "import-wechat", "apply"]);
25
+ const QUERY_COMMANDS = new Set(["status", "search", "read", "read-many", "search-and-read"]);
26
+ const INGEST_FORBIDDEN_COMMANDS = new Set(["init", "create", "update", "append", "apply", "lint"]);
27
+ const MAINTENANCE_ALLOWED_COMMANDS = new Set(["status", "validate-maintenance-plan"]);
28
+ const WECHAT_ARTICLE_HOSTS = new Set(["mp.weixin.qq.com", "weixin.qq.com"]);
29
+ const WECHAT_FETCH_HEADERS = Object.freeze({
30
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36",
31
+ accept: "text/html,application/xhtml+xml",
32
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.7",
33
+ });
34
+ const PLAN_SCHEMA = {
35
+ $schema: "https://json-schema.org/draft/2020-12/schema",
36
+ type: "object",
37
+ required: ["jobId", "operations"],
38
+ additionalProperties: false,
39
+ properties: {
40
+ jobId: { type: "string", minLength: 1 },
41
+ sourceHash: { type: "string" },
42
+ operations: {
43
+ type: "array",
44
+ items: {
45
+ oneOf: [
46
+ { type: "object", required: ["type", "path", "contentFile"], properties: { type: { const: "create" }, path: { type: "string" }, contentFile: { type: "string" } }, additionalProperties: false },
47
+ { type: "object", required: ["type", "path", "contentFile", "expectedUpdatedAt"], properties: { type: { const: "update" }, path: { type: "string" }, contentFile: { type: "string" }, expectedUpdatedAt: { type: "string" } }, additionalProperties: false },
48
+ { type: "object", required: ["type", "path", "contentFile"], properties: { type: { const: "append" }, path: { const: "log" }, contentFile: { type: "string" } }, additionalProperties: false },
49
+ ],
50
+ },
51
+ },
52
+ },
53
+ };
54
+ const PLAN_EXAMPLE = {
55
+ jobId: "knowledge-job-20260806-001",
56
+ operations: [
57
+ { type: "create", path: "wiki/synthesis/example", contentFile: "synthesis.md" },
58
+ { type: "update", path: "wiki/index", contentFile: "index.md", expectedUpdatedAt: "2026-08-06T00:00:00.000Z" },
59
+ { type: "append", path: "log", contentFile: "log.md" },
60
+ ],
61
+ };
62
+ const MAINTENANCE_PLAN_SCHEMA = {
63
+ $schema: "https://json-schema.org/draft/2020-12/schema",
64
+ type: "object",
65
+ required: ["jobId", "operations"],
66
+ additionalProperties: false,
67
+ properties: {
68
+ jobId: { type: "string", minLength: 1 },
69
+ sourceHash: { type: "string" },
70
+ operations: {
71
+ type: "array",
72
+ items: {
73
+ oneOf: [
74
+ { type: "object", required: ["type", "path", "contentFile"], properties: { type: { const: "create" }, path: { type: "string" }, contentFile: { type: "string" } }, additionalProperties: false },
75
+ { type: "object", required: ["type", "path", "contentFile"], properties: { type: { const: "update" }, path: { type: "string" }, contentFile: { type: "string" } }, additionalProperties: false },
76
+ ],
77
+ },
78
+ },
79
+ },
80
+ };
81
+ const MAINTENANCE_PLAN_EXAMPLE = {
82
+ jobId: "knowledge-maintenance-20260831-001",
83
+ operations: [
84
+ { type: "create", path: "wiki/concepts/example", contentFile: "concept.md" },
85
+ { type: "update", path: "wiki/synthesis/existing", contentFile: "synthesis.md" },
86
+ ],
87
+ };
88
+ const HELP_FAILURE_EXAMPLE = { ok: false, error: { code: "USAGE", message: "missing required argument" } };
89
+ const PLAN_FAILURE_EXAMPLES = [
90
+ { ok: false, error: { code: "PLAN_FILE_NOT_FOUND", message: "Plan file not found", details: { path: "/workspace/kb-plan/plan.json" } } },
91
+ { ok: false, error: { code: "PLAN_JSON_INVALID", message: "Plan file contains invalid JSON", details: { path: "/workspace/kb-plan/plan.json" } } },
92
+ { ok: false, error: { code: "CONTENT_FILE_NOT_FOUND", message: "Plan content file not found", details: { path: "/workspace/kb-plan/source-note.md", contentFile: "source-note.md", operationIndex: 0, operationType: "create", operationPath: "wiki/sources/example" } } },
93
+ { ok: false, error: { code: "CONTENT_FILE_UNREADABLE", message: "Plan content file is unreadable", details: { path: "/workspace/kb-plan/source-note.md", contentFile: "source-note.md", operationIndex: 0, operationType: "create", operationPath: "wiki/sources/example" } } },
94
+ ];
95
+ const COMMAND_HELP = {
96
+ status: { usage: "outline-kb status", description: "Verify the injected Outline identity." },
97
+ init: { usage: "outline-kb init --collection ID [--root ID]", description: "Create missing fixed wiki pages. Use only during setup or explicit repair." },
98
+ tree: { usage: "outline-kb tree [PATH] --collection ID [--root ID]", description: "List the configured root tree. This is a full-tree operation." },
99
+ search: { usage: "outline-kb search QUERY --limit 10 [--under PATH] --collection ID [--root ID]", description: "Return scoped top-K search metadata without page bodies." },
100
+ read: { usage: "outline-kb read PATH_OR_ID --collection ID [--root ID]", description: "Read one scoped page." },
101
+ "read-many": { usage: "outline-kb read-many REF [REF...] --collection ID [--root ID]", description: "Read multiple scoped pages with one tree snapshot." },
102
+ "search-and-read": { usage: "outline-kb search-and-read QUERY --limit 8 [--under PATH] --collection ID [--root ID]", description: "Search scoped top-K results and batch-read their bodies with one tree snapshot." },
103
+ create: { usage: "outline-kb create PATH --file CONTENT.md --collection ID [--root ID]", description: "Create one validated wiki page." },
104
+ update: { usage: "outline-kb update PATH --file CONTENT.md --expected-updated-at ISO --collection ID [--root ID]", description: "CAS-update one non-raw page." },
105
+ append: { usage: "outline-kb append log --file LOG.md --job-id ID --collection ID [--root ID]", description: "Idempotently append to the maintenance log." },
106
+ "import-file": { usage: "outline-kb import-file FILE --path raw/PATH [--text-file EXTRACTED.md] [--content-type TYPE] --collection ID [--root ID]", description: "Import Markdown/TXT/PDF as immutable raw evidence; PDF requires extracted text." },
107
+ "import-url": { usage: "outline-kb import-url URL --path raw/PATH --collection ID [--root ID]", description: "Fetch a public URL and import immutable raw evidence. WeChat article URLs are routed to the dedicated parser automatically." },
108
+ "import-wechat": { usage: "outline-kb import-wechat WECHAT_URL --path raw/PATH --collection ID [--root ID]", description: "Capture a WeChat Official Account article with #js_content and publisher metadata validation." },
109
+ "validate-plan": { usage: "outline-kb validate-plan PLAN.json --collection ID [--root ID]", description: "Validate local files, plan schema and every CAS precondition without writing.", pathResolution: "Every contentFile is resolved relative to the PLAN.json directory.", planSchema: PLAN_SCHEMA, example: PLAN_EXAMPLE, failureExamples: PLAN_FAILURE_EXAMPLES },
110
+ "validate-maintenance-plan": { usage: "outline-kb validate-maintenance-plan PLAN.json", description: "Validate local maintenance Candidate files without reading or writing Outline. Available only in the system-owned network-maintenance profile.", pathResolution: "Every contentFile is resolved relative to the PLAN.json directory.", planSchema: MAINTENANCE_PLAN_SCHEMA, example: MAINTENANCE_PLAN_EXAMPLE, failureExamples: PLAN_FAILURE_EXAMPLES },
111
+ apply: { usage: "outline-kb apply PLAN.json --collection ID [--root ID]", description: "Preflight all local files and the complete plan, apply it, then return deterministic lint.", pathResolution: "Every contentFile is resolved relative to the PLAN.json directory.", planSchema: PLAN_SCHEMA, example: PLAN_EXAMPLE, failureExamples: PLAN_FAILURE_EXAMPLES },
112
+ lint: { usage: "outline-kb lint --collection ID [--root ID]", description: "Full-library deterministic health check. Do not run inside Query." },
113
+ };
114
+
115
+ function successExample(command) {
116
+ if (command === "search" || command === "read-many" || command === "search-and-read" || command === "tree") {
117
+ return { ok: true, documents: [{ id: "document-id", title: "Example", path: "wiki/concepts/example", url: "https://outline.example/doc/example", updatedAt: "2026-08-06T00:00:00.000Z" }] };
118
+ }
119
+ if (command === "read") return { ok: true, document: { id: "document-id", title: "Example", path: "wiki/concepts/example", url: "https://outline.example/doc/example", text: "..." } };
120
+ if (command === "validate-plan") return { ok: true, jobId: PLAN_EXAMPLE.jobId, sourceHash: null, operations: PLAN_EXAMPLE.operations };
121
+ if (command === "validate-maintenance-plan") return { ok: true, jobId: MAINTENANCE_PLAN_EXAMPLE.jobId, sourceHash: null, operations: MAINTENANCE_PLAN_EXAMPLE.operations };
122
+ if (command === "apply") return { ok: true, jobId: PLAN_EXAMPLE.jobId, results: [], lint: { ok: true, errors: [], warnings: [] }, status: "succeeded" };
123
+ return { ok: true };
124
+ }
125
+
126
+ const privateAddresses = new BlockList();
127
+ for (const [network, prefix] of [
128
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
129
+ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
130
+ ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
131
+ ]) privateAddresses.addSubnet(network, prefix, "ipv4");
132
+ for (const [network, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) {
133
+ privateAddresses.addSubnet(network, prefix, "ipv6");
134
+ }
135
+
136
+ export class OutlineKbError extends Error {
137
+ constructor(code, message, details) {
138
+ super(message);
139
+ this.name = "OutlineKbError";
140
+ this.code = code;
141
+ this.details = details;
142
+ }
143
+ }
144
+
145
+ function cleanBaseUrl(value) {
146
+ const url = new URL(value);
147
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
148
+ throw new OutlineKbError("INVALID_BASE_URL", "Outline base URL must use http or https");
149
+ }
150
+ return url.toString().replace(/\/$/, "");
151
+ }
152
+
153
+ function isPrivateAddress(address, family) {
154
+ const type = family === 6 ? "ipv6" : "ipv4";
155
+ if (type === "ipv6" && address.toLowerCase().startsWith("::ffff:")) return true;
156
+ return privateAddresses.check(address, type);
157
+ }
158
+
159
+ async function resolvePublicAddress(hostname) {
160
+ const host = hostname.replace(/^\[|\]$/g, "");
161
+ const literalFamily = isIP(host);
162
+ const addresses = literalFamily ? [{ address: host, family: literalFamily }] : await lookup(host, { all: true, verbatim: true });
163
+ if (addresses.length === 0) throw new OutlineKbError("URL_FETCH", `Source host did not resolve: ${hostname}`);
164
+ if (addresses.some(({ address, family }) => isPrivateAddress(address, family))) {
165
+ throw new OutlineKbError("UNSAFE_SOURCE_URL", `Source URL resolves to a private or local address: ${hostname}`);
166
+ }
167
+ return addresses[0];
168
+ }
169
+
170
+ /** Fetch public web text with DNS pinning, redirect re-validation and a hard response-size cap. */
171
+ export async function fetchPublicText(input, { maxRedirects = 5, maxBytes = MAX_SOURCE_BYTES, headers = {} } = {}) {
172
+ let current = new URL(input);
173
+ for (let redirect = 0; redirect <= maxRedirects; redirect++) {
174
+ if (current.protocol !== "http:" && current.protocol !== "https:") {
175
+ throw new OutlineKbError("INVALID_SOURCE_URL", "Source URL must use http or https");
176
+ }
177
+ if (current.username || current.password) throw new OutlineKbError("UNSAFE_SOURCE_URL", "Source URL must not contain credentials");
178
+ const resolved = await resolvePublicAddress(current.hostname);
179
+ const response = await new Promise((resolveResponse, rejectResponse) => {
180
+ const transport = current.protocol === "https:" ? httpsRequest : httpRequest;
181
+ const request = transport(current, {
182
+ method: "GET",
183
+ headers: { "user-agent": "Oasis-Outline-KB/1", accept: "text/html,text/plain,application/xhtml+xml", "accept-encoding": "identity", ...headers },
184
+ lookup(_hostname, options, callback) {
185
+ if (options?.all) callback(null, [resolved]);
186
+ else callback(null, resolved.address, resolved.family);
187
+ },
188
+ }, (incoming) => {
189
+ const status = incoming.statusCode ?? 0;
190
+ if (status >= 300 && status < 400 && incoming.headers.location) {
191
+ incoming.resume();
192
+ resolveResponse({ redirect: new URL(incoming.headers.location, current).toString() });
193
+ return;
194
+ }
195
+ if (status < 200 || status >= 300) {
196
+ incoming.resume();
197
+ rejectResponse(new OutlineKbError("URL_FETCH", `URL fetch failed (${status})`));
198
+ return;
199
+ }
200
+ const contentType = String(incoming.headers["content-type"] ?? "").toLowerCase();
201
+ if (contentType && !contentType.startsWith("text/") && !contentType.startsWith("application/xhtml+xml")) {
202
+ incoming.resume();
203
+ rejectResponse(new OutlineKbError("UNSUPPORTED_URL_CONTENT", `URL returned unsupported content type: ${contentType}`));
204
+ return;
205
+ }
206
+ const chunks = [];
207
+ let size = 0;
208
+ incoming.on("data", (chunk) => {
209
+ size += chunk.length;
210
+ if (size > maxBytes) incoming.destroy(new OutlineKbError("URL_TOO_LARGE", `URL response exceeds ${maxBytes} bytes`));
211
+ else chunks.push(chunk);
212
+ });
213
+ incoming.on("end", () => resolveResponse({ text: Buffer.concat(chunks).toString("utf8") }));
214
+ incoming.on("error", rejectResponse);
215
+ });
216
+ request.setTimeout(DEFAULT_TIMEOUT_MS, () => request.destroy(new OutlineKbError("URL_FETCH", "URL fetch timed out")));
217
+ request.on("error", rejectResponse);
218
+ request.end();
219
+ });
220
+ if (response.redirect) {
221
+ if (redirect === maxRedirects) throw new OutlineKbError("URL_FETCH", "URL redirect limit exceeded");
222
+ current = new URL(response.redirect);
223
+ continue;
224
+ }
225
+ return { url: current.toString(), text: response.text };
226
+ }
227
+ throw new OutlineKbError("URL_FETCH", "URL redirect limit exceeded");
228
+ }
229
+
230
+ /** Fetch one public binary asset with the same SSRF and redirect boundary as page capture. */
231
+ export async function fetchPublicBinary(input, { maxRedirects = 5, maxBytes = MAX_WECHAT_IMAGE_BYTES, headers = {} } = {}) {
232
+ let current = new URL(input);
233
+ for (let redirect = 0; redirect <= maxRedirects; redirect++) {
234
+ if (current.protocol !== "http:" && current.protocol !== "https:") throw new OutlineKbError("INVALID_SOURCE_URL", "Source URL must use http or https");
235
+ if (current.username || current.password) throw new OutlineKbError("UNSAFE_SOURCE_URL", "Source URL must not contain credentials");
236
+ const resolved = await resolvePublicAddress(current.hostname);
237
+ const response = await new Promise((resolveResponse, rejectResponse) => {
238
+ const transport = current.protocol === "https:" ? httpsRequest : httpRequest;
239
+ const request = transport(current, {
240
+ method: "GET",
241
+ headers: { "user-agent": WECHAT_FETCH_HEADERS["user-agent"], accept: "image/*", "accept-encoding": "identity", referer: "https://mp.weixin.qq.com/", ...headers },
242
+ lookup(_hostname, options, callback) {
243
+ if (options?.all) callback(null, [resolved]);
244
+ else callback(null, resolved.address, resolved.family);
245
+ },
246
+ }, (incoming) => {
247
+ const status = incoming.statusCode ?? 0;
248
+ if (status >= 300 && status < 400 && incoming.headers.location) {
249
+ incoming.resume();
250
+ resolveResponse({ redirect: new URL(incoming.headers.location, current).toString() });
251
+ return;
252
+ }
253
+ if (status < 200 || status >= 300) {
254
+ incoming.resume();
255
+ rejectResponse(new OutlineKbError("WECHAT_IMAGE_FETCH", `WeChat image fetch failed (${status})`));
256
+ return;
257
+ }
258
+ const contentType = String(incoming.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
259
+ if (contentType && !contentType.startsWith("image/")) {
260
+ incoming.resume();
261
+ rejectResponse(new OutlineKbError("WECHAT_IMAGE_FETCH", `WeChat image returned unsupported content type: ${contentType}`));
262
+ return;
263
+ }
264
+ const chunks = [];
265
+ let size = 0;
266
+ incoming.on("data", (chunk) => {
267
+ size += chunk.length;
268
+ if (size > maxBytes) incoming.destroy(new OutlineKbError("WECHAT_IMAGE_TOO_LARGE", `WeChat image exceeds ${maxBytes} bytes`));
269
+ else chunks.push(chunk);
270
+ });
271
+ incoming.on("end", () => resolveResponse({ bytes: Buffer.concat(chunks), contentType: contentType || "application/octet-stream" }));
272
+ incoming.on("error", rejectResponse);
273
+ });
274
+ request.setTimeout(DEFAULT_TIMEOUT_MS, () => request.destroy(new OutlineKbError("WECHAT_IMAGE_FETCH", "WeChat image fetch timed out")));
275
+ request.on("error", rejectResponse);
276
+ request.end();
277
+ });
278
+ if (response.redirect) {
279
+ if (redirect === maxRedirects) throw new OutlineKbError("URL_FETCH", "URL redirect limit exceeded");
280
+ current = new URL(response.redirect);
281
+ continue;
282
+ }
283
+ if (!response.bytes?.byteLength) throw new OutlineKbError("WECHAT_IMAGE_FETCH", "WeChat image response was empty");
284
+ return { url: current.toString(), bytes: response.bytes, contentType: response.contentType };
285
+ }
286
+ throw new OutlineKbError("URL_FETCH", "URL redirect limit exceeded");
287
+ }
288
+
289
+ function decodeHtmlEntities(value) {
290
+ const named = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: '"' };
291
+ return String(value ?? "").replace(/&(#(?:x[0-9a-f]+|\d+)|[a-z]+);/gi, (_match, entity) => {
292
+ const lower = entity.toLowerCase();
293
+ if (lower.startsWith("#")) {
294
+ const codePoint = Number.parseInt(lower.slice(lower.startsWith("#x") ? 2 : 1), lower.startsWith("#x") ? 16 : 10);
295
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : _match;
296
+ }
297
+ return named[lower] ?? _match;
298
+ });
299
+ }
300
+
301
+ function normalizeWechatText(value) {
302
+ return decodeHtmlEntities(String(value ?? "").replace(/<br\s*\/?\s*>/gi, "\n").replace(/<[^>]+>/g, " "))
303
+ .replace(/[\u200b\ufeff]/g, "")
304
+ .replace(/[ \t\r\f\v]+/g, " ")
305
+ .replace(/ *\n */g, "\n")
306
+ .trim();
307
+ }
308
+
309
+ function escapePattern(value) {
310
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
311
+ }
312
+
313
+ function elementInnerHtmlById(page, id) {
314
+ const pattern = new RegExp(`<([a-z][\\w:-]*)\\b[^>]*\\bid\\s*=\\s*(["'])${escapePattern(id)}\\2[^>]*>`, "i");
315
+ const opening = pattern.exec(page);
316
+ if (!opening) return "";
317
+ const tag = opening[1];
318
+ const start = opening.index + opening[0].length;
319
+ const tokens = new RegExp(`<\\/?${escapePattern(tag)}\\b[^>]*>`, "gi");
320
+ tokens.lastIndex = start;
321
+ let depth = 1;
322
+ for (let token = tokens.exec(page); token; token = tokens.exec(page)) {
323
+ if (/^<\//.test(token[0])) depth--;
324
+ else if (!/\/>$/.test(token[0])) depth++;
325
+ if (depth === 0) return page.slice(start, token.index);
326
+ }
327
+ return "";
328
+ }
329
+
330
+ function jsStringValue(page, names) {
331
+ for (const name of names) {
332
+ const match = new RegExp(`(?:var\\s+)?${escapePattern(name)}\\s*=\\s*(?:htmlDecode\\(\\s*)?(["'])((?:\\\\.|(?!\\1)[\\s\\S])*)\\1(?:\\s*\\))?`, "i").exec(page);
333
+ if (!match) continue;
334
+ return decodeHtmlEntities(match[2]
335
+ .replace(/\\x([0-9a-f]{2})/gi, (_all, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
336
+ .replace(/\\u([0-9a-f]{4})/gi, (_all, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
337
+ .replace(/\\\//g, "/")
338
+ .replace(/\\(["'])/g, "$1"));
339
+ }
340
+ return "";
341
+ }
342
+
343
+ function attributeValue(tag, name) {
344
+ const match = new RegExp(`\\b${escapePattern(name)}\\s*=\\s*(?:(["'])([\\s\\S]*?)\\1|([^\\s>]+))`, "i").exec(tag);
345
+ return decodeHtmlEntities(match?.[2] ?? match?.[3] ?? "").trim();
346
+ }
347
+
348
+ export function isWeChatArticleUrl(input) {
349
+ try {
350
+ const url = new URL(input);
351
+ return (url.protocol === "http:" || url.protocol === "https:") && WECHAT_ARTICLE_HOSTS.has(url.hostname.toLowerCase());
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+
357
+ export function parseWeChatArticle(input, sourceUrl) {
358
+ if (!isWeChatArticleUrl(sourceUrl)) throw new OutlineKbError("INVALID_WECHAT_URL", "URL must point to a supported WeChat article host");
359
+ const page = String(input ?? "");
360
+ const contentHtml = elementInnerHtmlById(page, "js_content");
361
+ if (!contentHtml) throw new OutlineKbError("WECHAT_ARTICLE_INVALID", "WeChat article body #js_content was not found; the page may be a challenge or deleted article");
362
+ const bodyText = normalizeWechatText(contentHtml.replace(/<script\b[\s\S]*?<\/script>/gi, "").replace(/<style\b[\s\S]*?<\/style>/gi, ""));
363
+ if (bodyText.length < 200) throw new OutlineKbError("WECHAT_ARTICLE_INVALID", "WeChat article body is too short; the page may be a challenge or deleted article");
364
+
365
+ const title = normalizeWechatText(elementInnerHtmlById(page, "activity-name")) || jsStringValue(page, ["msg_title"]) || "未命名微信文章";
366
+ const account = normalizeWechatText(elementInnerHtmlById(page, "js_name")) || jsStringValue(page, ["nickname"]) || "未知公众号";
367
+ const explicitAuthor = normalizeWechatText(elementInnerHtmlById(page, "js_author_name")) || jsStringValue(page, ["author"]);
368
+ const author = explicitAuthor || account;
369
+ const timestamp = /(?:var\s+)?(?:create_time|ct)\s*=\s*["']?(\d{9,13})/i.exec(page)?.[1];
370
+ const publishedDate = timestamp ? new Date(Number(timestamp) < 1_000_000_000_000 ? Number(timestamp) * 1000 : Number(timestamp)) : null;
371
+ const publishedAt = publishedDate && !Number.isNaN(publishedDate.getTime()) ? publishedDate.toISOString() : "";
372
+ const images = [];
373
+ const prepared = contentHtml
374
+ .replace(/<script\b[\s\S]*?<\/script>/gi, "")
375
+ .replace(/<style\b[\s\S]*?<\/style>/gi, "")
376
+ .replace(/<([a-z][\w:-]*)\b[^>]*style\s*=\s*(["'])[^"']*display\s*:\s*none[^"']*\2[^>]*>[\s\S]*?<\/\1>/gi, "")
377
+ .replace(/<img\b[^>]*>/gi, (tag) => {
378
+ const url = attributeValue(tag, "data-src") || attributeValue(tag, "data-original") || attributeValue(tag, "src");
379
+ if (!/^https?:\/\//i.test(url)) return "";
380
+ const alt = attributeValue(tag, "alt") || `正文图片 ${images.length + 1}`;
381
+ images.push({ url, alt });
382
+ return `\n\n![${alt.replace(/[\[\]]/g, "")}](${url})\n\n`;
383
+ });
384
+ const markdownBody = decodeHtmlEntities(htmlToMarkdown(prepared));
385
+ if (normalizeWechatText(markdownBody).length < 200) throw new OutlineKbError("WECHAT_ARTICLE_INVALID", "WeChat article Markdown extraction produced insufficient content");
386
+ const publishedLine = publishedAt ? new Date(publishedAt).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false }) : "未知";
387
+ const markdown = [
388
+ `# ${title}`,
389
+ "",
390
+ "> 微信公众号文章抓取",
391
+ `> 公众号/作者:${account} / ${author}`,
392
+ `> 发布时间:${publishedLine}`,
393
+ `> 原文:${sourceUrl}`,
394
+ "",
395
+ markdownBody,
396
+ ].join("\n").trim();
397
+ return { title, account, author, authorInferred: !explicitAuthor, publishedAt, markdown, images };
398
+ }
399
+
400
+ function scalar(value) {
401
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
402
+ if (Array.isArray(value)) return JSON.stringify(value);
403
+ if (value === null || value === undefined) return "null";
404
+ const text = String(value);
405
+ return /^[A-Za-z0-9_.:/@+-]+$/.test(text) ? text : JSON.stringify(text);
406
+ }
407
+
408
+ function parseScalar(value) {
409
+ const text = value.trim();
410
+ if (text === "null") return null;
411
+ if (text === "true") return true;
412
+ if (text === "false") return false;
413
+ if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
414
+ if ((text.startsWith("[") && text.endsWith("]")) || (text.startsWith('"') && text.endsWith('"'))) {
415
+ try { return JSON.parse(text); } catch { /* return literal below */ }
416
+ }
417
+ return text;
418
+ }
419
+
420
+ /** Outline rewrites traditional YAML frontmatter; fenced YAML survives Markdown round-trips. */
421
+ export function encodeWikiDocument(metadata, body = "") {
422
+ const lines = Object.entries(metadata).map(([key, value]) => `${key}: ${scalar(value)}`);
423
+ return `\`\`\`${META_FENCE}\n${lines.join("\n")}\n\`\`\`\n\n${String(body)}`;
424
+ }
425
+
426
+ export function parseWikiDocument(text) {
427
+ const match = String(text ?? "").match(/^```yaml\n([\s\S]*?)\n```(?:\n+|$)([\s\S]*)$/);
428
+ if (!match) return { metadata: null, body: String(text ?? "") };
429
+ const metadata = {};
430
+ for (const line of match[1].split("\n")) {
431
+ if (!line.trim() || line.trimStart().startsWith("#")) continue;
432
+ const index = line.indexOf(":");
433
+ if (index <= 0) continue;
434
+ metadata[line.slice(0, index).trim()] = parseScalar(line.slice(index + 1));
435
+ }
436
+ return { metadata, body: match[2] ?? "" };
437
+ }
438
+
439
+ export function contentHash(input) {
440
+ const bytes = typeof input === "string" || Buffer.isBuffer(input) ? input : Buffer.from(input);
441
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
442
+ }
443
+
444
+ function expectedType(path) {
445
+ const fixed = {
446
+ raw: "directory",
447
+ wiki: "directory",
448
+ "wiki/index": "index",
449
+ "wiki/overview": "overview",
450
+ "wiki/sources": "source-index",
451
+ "wiki/open-questions": "open-questions",
452
+ "wiki/concepts": "directory",
453
+ "wiki/synthesis": "directory",
454
+ log: "log",
455
+ };
456
+ if (fixed[path]) return fixed[path];
457
+ if (/^raw\/.+/.test(path)) return "raw";
458
+ if (/^wiki\/sources\/.+/.test(path)) return "source-note";
459
+ if (/^wiki\/concepts\/.+/.test(path)) return "concept";
460
+ if (/^wiki\/synthesis\/.+/.test(path)) return "synthesis";
461
+ return null;
462
+ }
463
+
464
+ function metadataIssues(path, text) {
465
+ const parsed = parseWikiDocument(text);
466
+ if (!parsed.metadata) return [{ code: "MISSING_METADATA", path }];
467
+ const metadata = parsed.metadata;
468
+ const issues = [];
469
+ const type = expectedType(path);
470
+ if (type && metadata.type !== type) issues.push({ code: "TYPE_MISMATCH", path, expected: type, actual: metadata.type ?? null });
471
+ const requireString = (field) => {
472
+ if (typeof metadata[field] !== "string" || !metadata[field].trim()) {
473
+ issues.push({ code: "MISSING_REQUIRED_METADATA", path, field });
474
+ }
475
+ };
476
+ const requireArray = (field) => {
477
+ if (!Array.isArray(metadata[field]) || metadata[field].length === 0) {
478
+ issues.push({ code: "MISSING_REQUIRED_METADATA", path, field });
479
+ }
480
+ };
481
+ if (type === "raw") {
482
+ requireString("source_kind");
483
+ requireString("source_hash");
484
+ requireString("content_hash");
485
+ if (typeof metadata.content_hash === "string" && contentHash(parsed.body) !== metadata.content_hash) {
486
+ issues.push({ code: "RAW_CONTENT_HASH_MISMATCH", path, expected: metadata.content_hash, actual: contentHash(parsed.body) });
487
+ }
488
+ }
489
+ if (type === "source-note") {
490
+ requireString("raw_ref");
491
+ requireString("ingested");
492
+ requireArray("topics");
493
+ }
494
+ if (type === "concept" || type === "synthesis") {
495
+ requireString("topic");
496
+ requireString("updated");
497
+ requireArray("sources");
498
+ }
499
+ if ((type === "overview" || type === "source-index") && (!Number.isInteger(metadata.source_count) || metadata.source_count < 0)) {
500
+ issues.push({ code: "MISSING_REQUIRED_METADATA", path, field: "source_count" });
501
+ }
502
+ if (type === "index") requireString("updated");
503
+ if (type === "log" && metadata.append_only !== true) issues.push({ code: "MISSING_REQUIRED_METADATA", path, field: "append_only" });
504
+ return issues;
505
+ }
506
+
507
+ function assertDocument(path, text) {
508
+ const issue = metadataIssues(path, text)[0];
509
+ if (issue) throw new OutlineKbError(issue.code, `${path} has invalid fenced metadata`, issue);
510
+ }
511
+
512
+ function assertRawPath(pathInput) {
513
+ const path = normalizePath(pathInput);
514
+ if (!path.startsWith("raw/")) throw new OutlineKbError("RAW_PATH_REQUIRED", `Import path must be under raw/: ${path}`);
515
+ return path;
516
+ }
517
+
518
+ function normalizeApiDocument(raw, baseUrl) {
519
+ const value = raw?.document ?? raw;
520
+ if (!value || typeof value !== "object") return null;
521
+ const relativeUrl = value.url ?? value.publishedUrl ?? null;
522
+ return {
523
+ ...value,
524
+ id: value.id ?? value.documentId ?? null,
525
+ title: value.title ?? "",
526
+ text: value.text ?? "",
527
+ parentDocumentId: value.parentDocumentId ?? null,
528
+ updatedAt: value.updatedAt ?? null,
529
+ url: relativeUrl ? new URL(relativeUrl, `${baseUrl}/`).toString() : null,
530
+ };
531
+ }
532
+
533
+ export class OutlineApiClient {
534
+ constructor({ baseUrl, apiToken, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
535
+ if (!apiToken) throw new OutlineKbError("MISSING_TOKEN", "Outline API token is required");
536
+ this.baseUrl = cleanBaseUrl(baseUrl);
537
+ this.apiToken = apiToken;
538
+ this.fetchImpl = fetchImpl;
539
+ this.timeoutMs = timeoutMs;
540
+ }
541
+
542
+ async call(endpoint, payload = {}) {
543
+ let response;
544
+ try {
545
+ response = await this.fetchImpl(`${this.baseUrl}/api/${endpoint}`, {
546
+ method: "POST",
547
+ headers: { authorization: `Bearer ${this.apiToken}`, "content-type": "application/json" },
548
+ body: JSON.stringify(payload),
549
+ signal: AbortSignal.timeout(this.timeoutMs),
550
+ });
551
+ } catch (error) {
552
+ throw new OutlineKbError("OUTLINE_NETWORK", `Outline ${endpoint} request failed`, { cause: String(error) });
553
+ }
554
+ let body;
555
+ try { body = await response.json(); } catch { body = null; }
556
+ if (!response.ok || body?.ok === false) {
557
+ const message = body?.message ?? body?.error ?? `HTTP ${response.status}`;
558
+ throw new OutlineKbError("OUTLINE_API", `Outline ${endpoint} failed: ${message}`, {
559
+ endpoint,
560
+ status: response.status,
561
+ error: body?.error,
562
+ });
563
+ }
564
+ return body;
565
+ }
566
+
567
+ async authInfo() {
568
+ return this.call("auth.info", {});
569
+ }
570
+
571
+ async listDocuments(collectionId) {
572
+ const documents = [];
573
+ let offset = 0;
574
+ for (;;) {
575
+ const body = await this.call("documents.list", { collectionId, limit: 100, offset });
576
+ for (const raw of body?.data ?? []) {
577
+ const document = normalizeApiDocument(raw, this.baseUrl);
578
+ if (document?.id) documents.push(document);
579
+ }
580
+ const total = Number(body?.pagination?.total ?? documents.length);
581
+ offset += Number(body?.pagination?.limit ?? 100);
582
+ if (documents.length >= total || !(body?.pagination?.nextPath)) break;
583
+ }
584
+ return documents;
585
+ }
586
+
587
+ async info(id) {
588
+ const body = await this.call("documents.info", { id });
589
+ const document = normalizeApiDocument(body?.data, this.baseUrl);
590
+ if (!document?.id) throw new OutlineKbError("INVALID_RESPONSE", "Outline documents.info returned no document");
591
+ return document;
592
+ }
593
+
594
+ async create({ title, text, collectionId, parentDocumentId }) {
595
+ const body = await this.call("documents.create", {
596
+ title,
597
+ text,
598
+ collectionId,
599
+ ...(parentDocumentId ? { parentDocumentId } : {}),
600
+ publish: true,
601
+ });
602
+ const document = normalizeApiDocument(body?.data, this.baseUrl);
603
+ if (!document?.id) throw new OutlineKbError("INVALID_RESPONSE", "Outline documents.create returned no document");
604
+ return document;
605
+ }
606
+
607
+ async update({ id, text, title }) {
608
+ const body = await this.call("documents.update", { id, text, ...(title ? { title } : {}), publish: true });
609
+ const document = normalizeApiDocument(body?.data, this.baseUrl);
610
+ if (!document?.id) throw new OutlineKbError("INVALID_RESPONSE", "Outline documents.update returned no document");
611
+ return document;
612
+ }
613
+
614
+ async search(query, collectionId) {
615
+ const body = await this.call("documents.search", { query, collectionId });
616
+ return (body?.data ?? []).map((raw) => normalizeApiDocument(raw, this.baseUrl)).filter(Boolean);
617
+ }
618
+
619
+ async uploadAttachment({ documentId, filePath, contentType }) {
620
+ const file = await readFile(filePath);
621
+ const name = basename(filePath);
622
+ return this.uploadAttachmentData({ documentId, name, bytes: file, contentType });
623
+ }
624
+
625
+ async uploadAttachmentData({ documentId, name, bytes, contentType }) {
626
+ const created = await this.call("attachments.create", {
627
+ name,
628
+ documentId,
629
+ contentType,
630
+ size: bytes.byteLength,
631
+ });
632
+ const data = created?.data ?? {};
633
+ if (!data.uploadUrl) throw new OutlineKbError("ATTACHMENT_UNSUPPORTED", "Outline did not return an attachment upload URL");
634
+ const form = new FormData();
635
+ for (const [key, value] of Object.entries(data.form ?? {})) form.append(key, String(value));
636
+ form.append("file", new Blob([bytes], { type: contentType }), name);
637
+ const upload = await this.fetchImpl(new URL(data.uploadUrl, `${this.baseUrl}/`), {
638
+ method: "POST",
639
+ body: form,
640
+ signal: AbortSignal.timeout(this.timeoutMs),
641
+ });
642
+ if (!upload.ok) throw new OutlineKbError("ATTACHMENT_UPLOAD", `Outline attachment upload failed (${upload.status})`);
643
+ return data.attachment ?? { name, url: data.url ?? null };
644
+ }
645
+ }
646
+
647
+ function normalizePath(input) {
648
+ const value = String(input ?? "").trim().replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/");
649
+ if (!value || value.split("/").some((part) => !part || part === "." || part === "..")) {
650
+ throw new OutlineKbError("INVALID_PATH", `Invalid logical path: ${input}`);
651
+ }
652
+ return value;
653
+ }
654
+
655
+ function isUuid(value) {
656
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
657
+ }
658
+
659
+ async function mapConcurrent(values, limit, mapper) {
660
+ const results = new Array(values.length);
661
+ let next = 0;
662
+ const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
663
+ for (;;) {
664
+ const index = next++;
665
+ if (index >= values.length) return;
666
+ results[index] = await mapper(values[index], index);
667
+ }
668
+ });
669
+ await Promise.all(workers);
670
+ return results;
671
+ }
672
+
673
+ export class OutlineWiki {
674
+ constructor(client, { collectionId, rootDocumentId = null, fetchUrl = fetchPublicText, fetchBinary = fetchPublicBinary }) {
675
+ if (!collectionId) throw new OutlineKbError("MISSING_COLLECTION", "collectionId is required");
676
+ this.client = client;
677
+ this.collectionId = collectionId;
678
+ this.rootDocumentId = rootDocumentId || null;
679
+ this.fetchUrl = fetchUrl;
680
+ this.fetchBinary = fetchBinary;
681
+ }
682
+
683
+ async snapshot() {
684
+ const documents = await this.client.listDocuments(this.collectionId);
685
+ const byId = new Map(documents.map((document) => [document.id, document]));
686
+ const pathById = new Map();
687
+ const buildPath = (document, seen = new Set()) => {
688
+ if (pathById.has(document.id)) return pathById.get(document.id);
689
+ if (seen.has(document.id)) throw new OutlineKbError("TREE_CYCLE", `Outline document tree has a cycle at ${document.id}`);
690
+ seen.add(document.id);
691
+ if (document.id === this.rootDocumentId) {
692
+ pathById.set(document.id, "");
693
+ return "";
694
+ }
695
+ const parent = document.parentDocumentId ? byId.get(document.parentDocumentId) : null;
696
+ if (this.rootDocumentId && !parent) return null;
697
+ const parentPath = parent ? buildPath(parent, seen) : "";
698
+ if (parentPath === null) return null;
699
+ const path = [parentPath, document.title].filter(Boolean).join("/");
700
+ pathById.set(document.id, path);
701
+ return path;
702
+ };
703
+ const byPath = new Map();
704
+ const scopedById = new Map();
705
+ for (const document of documents) {
706
+ const path = buildPath(document);
707
+ if (path === null) continue;
708
+ const scoped = { ...document, path };
709
+ scopedById.set(document.id, scoped);
710
+ const aliases = path === "" ? [document.title] : [path];
711
+ for (const alias of aliases) {
712
+ if (byPath.has(alias)) throw new OutlineKbError("AMBIGUOUS_PATH", `Duplicate logical path: ${alias}`);
713
+ byPath.set(alias, scoped);
714
+ }
715
+ }
716
+ return { documents: [...scopedById.values()], byPath, byId, scopedById };
717
+ }
718
+
719
+ async resolve(ref, snapshot = null) {
720
+ const tree = snapshot ?? await this.snapshot();
721
+ if (isUuid(ref)) {
722
+ const document = tree.scopedById.get(ref);
723
+ if (!document) throw new OutlineKbError("NOT_FOUND", `No document ${ref} in the configured knowledge root`);
724
+ return document;
725
+ }
726
+ const path = normalizePath(ref);
727
+ const document = tree.byPath.get(path);
728
+ if (!document) throw new OutlineKbError("NOT_FOUND", `No Outline document at logical path ${path}`);
729
+ return document;
730
+ }
731
+
732
+ async tree(under = null) {
733
+ const snapshot = await this.snapshot();
734
+ const prefix = under ? `${normalizePath(under)}/` : "";
735
+ return snapshot.documents
736
+ .filter((document) => !under || document.path === normalizePath(under) || document.path.startsWith(prefix))
737
+ .sort((a, b) => a.path.localeCompare(b.path));
738
+ }
739
+
740
+ async read(ref, snapshot = null) {
741
+ const document = await this.resolve(ref, snapshot);
742
+ return { ...(await this.client.info(document.id)), path: document.path };
743
+ }
744
+
745
+ async readMany(refs, snapshot = null) {
746
+ const tree = snapshot ?? await this.snapshot();
747
+ const resolved = [];
748
+ const seen = new Set();
749
+ for (const ref of refs) {
750
+ const document = await this.resolve(ref, tree);
751
+ if (seen.has(document.id)) continue;
752
+ seen.add(document.id);
753
+ resolved.push(document);
754
+ }
755
+ return Promise.all(resolved.map(async (document) => ({ ...(await this.client.info(document.id)), path: document.path })));
756
+ }
757
+
758
+ async create(pathInput, text, snapshot = null) {
759
+ const path = normalizePath(pathInput);
760
+ assertDocument(path, text);
761
+ const tree = snapshot ?? await this.snapshot();
762
+ const existing = tree.byPath.get(path);
763
+ if (existing) {
764
+ const current = await this.client.info(existing.id);
765
+ if (current.text.trim() === text.trim()) return { status: "existing", document: current };
766
+ throw new OutlineKbError("ALREADY_EXISTS", `${path} already exists with different content`);
767
+ }
768
+ const parts = path.split("/");
769
+ const title = parts.pop();
770
+ const parentPath = parts.join("/");
771
+ const parentDocumentId = parentPath
772
+ ? (await this.resolve(parentPath, tree)).id
773
+ : this.rootDocumentId;
774
+ const document = await this.client.create({ title, text, collectionId: this.collectionId, parentDocumentId });
775
+ const scoped = { ...document, path };
776
+ tree.documents.push(scoped);
777
+ tree.byPath.set(path, scoped);
778
+ tree.byId.set(document.id, document);
779
+ tree.scopedById.set(document.id, scoped);
780
+ return {
781
+ status: "created",
782
+ document,
783
+ };
784
+ }
785
+
786
+ async update(pathInput, text, expectedUpdatedAt, snapshot = null) {
787
+ const path = normalizePath(pathInput);
788
+ if (path === "raw" || path.startsWith("raw/")) throw new OutlineKbError("RAW_IMMUTABLE", `Cannot update immutable path ${path}`);
789
+ if (path === "log") throw new OutlineKbError("LOG_APPEND_ONLY", "log can only be changed through append");
790
+ assertDocument(path, text);
791
+ if (!expectedUpdatedAt) throw new OutlineKbError("MISSING_PRECONDITION", `${path} update requires expectedUpdatedAt`);
792
+ const current = await this.read(path, snapshot);
793
+ if (current.text.trim() === text.trim()) return { status: "unchanged", document: current };
794
+ if (current.updatedAt !== expectedUpdatedAt) {
795
+ throw new OutlineKbError("UPDATE_CONFLICT", `${path} changed after the plan was created`, {
796
+ expectedUpdatedAt,
797
+ actualUpdatedAt: current.updatedAt,
798
+ });
799
+ }
800
+ return { status: "updated", document: await this.client.update({ id: current.id, text }) };
801
+ }
802
+
803
+ async appendLog(fragment, jobId, snapshot = null) {
804
+ if (!jobId) throw new OutlineKbError("MISSING_JOB_ID", "append log requires jobId");
805
+ const current = await this.read("log", snapshot);
806
+ const marker = `<!-- job:${jobId} -->`;
807
+ if (current.text.includes(marker)) return { status: "existing", document: current };
808
+ const text = `${current.text.trimEnd()}\n\n${marker}\n${fragment.trim()}\n`;
809
+ return { status: "appended", document: await this.client.update({ id: current.id, text }) };
810
+ }
811
+
812
+ async init() {
813
+ const definitions = [
814
+ ["raw", { type: "directory" }, "# 原始来源\n\n只创建新版本,不覆盖已有内容。"],
815
+ ["wiki", { type: "directory" }, "# Wiki"],
816
+ ["wiki/index", { type: "index", updated: new Date().toISOString().slice(0, 10) }, "# 知识库索引\n\n- [全局综述](kb://wiki/overview)\n- [来源](kb://wiki/sources)\n- [概念](kb://wiki/concepts)\n- [综合](kb://wiki/synthesis)\n- [待研究问题](kb://wiki/open-questions)"],
817
+ ["wiki/overview", { type: "overview", source_count: 0 }, "# 全局综述"],
818
+ ["wiki/sources", { type: "source-index", source_count: 0 }, "# 来源目录"],
819
+ ["wiki/open-questions", { type: "open-questions" }, "# 待研究问题"],
820
+ ["wiki/concepts", { type: "directory" }, "# 概念"],
821
+ ["wiki/synthesis", { type: "directory" }, "# 综合"],
822
+ ["log", { type: "log", append_only: true }, "# 维护日志"],
823
+ ];
824
+ const tree = await this.snapshot();
825
+ const results = new Map(await Promise.all(definitions
826
+ .filter(([path]) => tree.byPath.has(path))
827
+ .map(async ([path]) => [path, { path, status: "existing", document: await this.read(path, tree) }])));
828
+ for (const [path, metadata, body] of definitions) {
829
+ if (tree.byPath.has(path)) continue;
830
+ const parts = path.split("/");
831
+ const title = parts.pop();
832
+ const parentPath = parts.join("/");
833
+ const parentDocumentId = parentPath ? (await this.resolve(parentPath, tree)).id : this.rootDocumentId;
834
+ const document = await this.client.create({
835
+ title,
836
+ text: encodeWikiDocument(metadata, body),
837
+ collectionId: this.collectionId,
838
+ parentDocumentId,
839
+ });
840
+ const scoped = { ...document, path };
841
+ tree.documents.push(scoped);
842
+ tree.byPath.set(path, scoped);
843
+ tree.byId.set(document.id, document);
844
+ tree.scopedById.set(document.id, scoped);
845
+ results.set(path, { path, status: "created", document });
846
+ }
847
+ return definitions.map(([path]) => results.get(path));
848
+ }
849
+
850
+ async search(query, under = null, limit = 10, snapshot = null) {
851
+ const matches = await this.client.search(query, this.collectionId);
852
+ const tree = snapshot ?? await this.snapshot();
853
+ const pathOf = new Map(tree.documents.map((document) => [document.id, document.path]));
854
+ const prefix = under ? `${normalizePath(under)}/` : null;
855
+ const explicitRaw = under ? (normalizePath(under) === "raw" || normalizePath(under).startsWith("raw/")) : false;
856
+ return matches
857
+ .map((document) => {
858
+ const path = pathOf.get(document.id);
859
+ return path === undefined ? null : { ...document, path };
860
+ })
861
+ .filter((document) => document
862
+ && (explicitRaw || !(document.path === "raw" || document.path.startsWith("raw/")))
863
+ && (!under || document.path === normalizePath(under) || document.path.startsWith(prefix)))
864
+ .slice(0, Math.max(1, Math.min(Number(limit) || 10, 20)));
865
+ }
866
+
867
+ async searchAndRead(query, { under = null, limit = 8 } = {}) {
868
+ const tree = await this.snapshot();
869
+ const matches = await this.search(query, under, limit, tree);
870
+ return this.readMany(matches.map((document) => document.id), tree);
871
+ }
872
+
873
+ async finalizeRawImport(path, document) {
874
+ const current = await this.client.info(document.id);
875
+ const parsed = parseWikiDocument(current.text);
876
+ const actualHash = contentHash(parsed.body);
877
+ const pending = parsed.metadata?.content_hash_state === CONTENT_HASH_PENDING;
878
+ if (!pending) {
879
+ if (parsed.metadata?.content_hash === actualHash) return current;
880
+ throw new OutlineKbError("RAW_CONTENT_HASH_MISMATCH", `${path} content changed after import`, {
881
+ path,
882
+ expected: parsed.metadata?.content_hash ?? null,
883
+ actual: actualHash,
884
+ });
885
+ }
886
+
887
+ const { content_hash_state: _state, ...metadata } = parsed.metadata;
888
+ const updated = await this.client.update({
889
+ id: current.id,
890
+ text: encodeWikiDocument({ ...metadata, content_hash: actualHash }, parsed.body),
891
+ });
892
+ const verified = await this.client.info(updated.id);
893
+ const verifiedParsed = parseWikiDocument(verified.text);
894
+ const verifiedHash = contentHash(verifiedParsed.body);
895
+ if (verifiedParsed.metadata?.content_hash !== verifiedHash) {
896
+ throw new OutlineKbError("RAW_CONTENT_HASH_UNSTABLE", `${path} did not stabilize after Outline Markdown normalization`, {
897
+ path,
898
+ expected: verifiedParsed.metadata?.content_hash ?? null,
899
+ actual: verifiedHash,
900
+ });
901
+ }
902
+ return verified;
903
+ }
904
+
905
+ async createRaw(path, metadata, body) {
906
+ const created = await this.create(path, encodeWikiDocument({
907
+ ...metadata,
908
+ content_hash: contentHash(body),
909
+ content_hash_state: CONTENT_HASH_PENDING,
910
+ }, body));
911
+ return { ...created, document: await this.finalizeRawImport(path, created.document) };
912
+ }
913
+
914
+ async createWechatRaw(path, metadata, article, assets) {
915
+ const pendingMetadata = {
916
+ ...metadata,
917
+ image_archived_count: 0,
918
+ content_hash: contentHash(article.markdown),
919
+ content_hash_state: CONTENT_HASH_PENDING,
920
+ };
921
+ const created = await this.create(path, encodeWikiDocument(pendingMetadata, article.markdown));
922
+ let body = article.markdown;
923
+ const attachments = [];
924
+ let failed = 0;
925
+ const archiveErrors = [];
926
+ for (const [index, asset] of assets.entries()) {
927
+ try {
928
+ const extension = ({ "image/gif": ".gif", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp" })[asset.contentType] ?? ".bin";
929
+ const name = `wechat-${String(index + 1).padStart(2, "0")}-${contentHash(asset.image.url).slice(0, 10)}${extension}`;
930
+ const attachment = await this.client.uploadAttachmentData({ documentId: created.document.id, name, bytes: asset.bytes, contentType: asset.contentType });
931
+ const attachmentUrl = attachment?.url ? new URL(attachment.url, `${this.client.baseUrl}/`).toString() : "";
932
+ if (!attachmentUrl) throw new OutlineKbError("ATTACHMENT_UNSUPPORTED", "Outline attachment response did not contain a URL");
933
+ body = body.split(`](${asset.image.url})`).join(`](${attachmentUrl})`);
934
+ attachments.push({ name, url: attachmentUrl, sourceUrl: asset.image.url });
935
+ } catch (error) {
936
+ failed++;
937
+ archiveErrors.push(error instanceof Error ? error.message : String(error));
938
+ }
939
+ }
940
+ const finalizedMetadata = {
941
+ ...metadata,
942
+ image_archived_count: attachments.length,
943
+ ...(failed ? { image_archive_failed: failed } : {}),
944
+ content_hash: contentHash(body),
945
+ content_hash_state: CONTENT_HASH_PENDING,
946
+ };
947
+ const updated = await this.client.update({ id: created.document.id, text: encodeWikiDocument(finalizedMetadata, body) });
948
+ return {
949
+ ...created,
950
+ document: await this.finalizeRawImport(path, updated),
951
+ attachments,
952
+ ...(failed ? { warnings: [{ code: "WECHAT_IMAGE_ARCHIVE_PARTIAL", message: `${failed} WeChat images could not be archived; original URLs were retained`, errors: archiveErrors }] } : {}),
953
+ };
954
+ }
955
+
956
+ async importFile(filePath, rawPath, { textFile = null, contentType = null } = {}) {
957
+ const path = assertRawPath(rawPath);
958
+ const bytes = await readFile(filePath);
959
+ const hash = contentHash(bytes);
960
+ const extension = extname(filePath).toLowerCase();
961
+ if (![".md", ".txt", ".pdf"].includes(extension)) {
962
+ throw new OutlineKbError("UNSUPPORTED_FILE_TYPE", "V1 only supports Markdown, TXT and PDF files");
963
+ }
964
+ const tree = await this.snapshot();
965
+ for (const document of tree.documents.filter((item) => item.path.startsWith("raw/"))) {
966
+ const current = await this.client.info(document.id);
967
+ if (parseWikiDocument(current.text).metadata?.source_hash === hash) {
968
+ return { status: "existing", sourceHash: hash, document: await this.finalizeRawImport(document.path, current), path: document.path };
969
+ }
970
+ }
971
+ const type = contentType ?? (extension === ".pdf" ? "application/pdf" : "text/plain");
972
+ let sourceText;
973
+ if (extension === ".pdf") {
974
+ if (!textFile) throw new OutlineKbError("PDF_TEXT_REQUIRED", "PDF import requires --text-file with extracted Markdown");
975
+ sourceText = await readFile(textFile, "utf8");
976
+ } else {
977
+ sourceText = bytes.toString("utf8");
978
+ }
979
+ const metadata = {
980
+ type: "raw",
981
+ source_kind: extension === ".pdf" ? "pdf" : "file",
982
+ source_name: basename(filePath),
983
+ source_hash: hash,
984
+ imported_at: new Date().toISOString(),
985
+ };
986
+ const created = await this.createRaw(path, metadata, sourceText);
987
+ let attachment = null;
988
+ if (extension === ".pdf") {
989
+ attachment = await this.client.uploadAttachment({ documentId: created.document.id, filePath, contentType: type });
990
+ }
991
+ return { ...created, sourceHash: hash, attachment, searchableText: extension !== ".pdf" || Boolean(textFile) };
992
+ }
993
+
994
+ async importUrl(urlInput, rawPath) {
995
+ const path = assertRawPath(rawPath);
996
+ const parsedUrl = new URL(urlInput);
997
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
998
+ throw new OutlineKbError("INVALID_SOURCE_URL", "Source URL must use http or https");
999
+ }
1000
+ const url = parsedUrl.toString();
1001
+ const wechat = isWeChatArticleUrl(url);
1002
+ const fetched = await this.fetchUrl(url, wechat ? { headers: WECHAT_FETCH_HEADERS } : undefined);
1003
+ const html = fetched.text;
1004
+ const hash = contentHash(html);
1005
+ const tree = await this.snapshot();
1006
+ for (const document of tree.documents.filter((item) => item.path.startsWith("raw/"))) {
1007
+ const current = await this.client.info(document.id);
1008
+ const metadata = parseWikiDocument(current.text).metadata;
1009
+ if (metadata?.source_hash === hash || (wechat && metadata?.source_url === url)) {
1010
+ return { status: "existing", sourceHash: metadata.source_hash ?? hash, document: await this.finalizeRawImport(document.path, current), path: document.path };
1011
+ }
1012
+ }
1013
+ const article = wechat ? parseWeChatArticle(html, url) : null;
1014
+ const markdown = article?.markdown ?? htmlToMarkdown(html);
1015
+ const uniqueImages = article?.images.filter((image, index, items) => items.findIndex((item) => item.url === image.url) === index) ?? [];
1016
+ let imageBytes = 0;
1017
+ const assets = article
1018
+ ? await mapConcurrent(uniqueImages.slice(0, MAX_WECHAT_IMAGES), 4, async (image) => {
1019
+ const fetchedImage = await this.fetchBinary(image.url);
1020
+ imageBytes += fetchedImage.bytes.byteLength;
1021
+ if (imageBytes > MAX_WECHAT_TOTAL_IMAGE_BYTES) {
1022
+ throw new OutlineKbError("WECHAT_IMAGES_TOO_LARGE", `WeChat images exceed ${MAX_WECHAT_TOTAL_IMAGE_BYTES} total bytes`);
1023
+ }
1024
+ return { image, ...fetchedImage };
1025
+ })
1026
+ : [];
1027
+ const metadata = {
1028
+ type: "raw",
1029
+ source_kind: wechat ? "wechat" : "url",
1030
+ source_url: url,
1031
+ ...(fetched.url !== url ? { resolved_url: fetched.url } : {}),
1032
+ ...(article ? {
1033
+ source_title: article.title,
1034
+ source_account: article.account,
1035
+ source_author: article.author,
1036
+ ...(article.authorInferred ? { source_author_inferred: true } : {}),
1037
+ ...(article.publishedAt ? { published_at: article.publishedAt } : {}),
1038
+ image_count: article.images.length,
1039
+ ...(uniqueImages.length > MAX_WECHAT_IMAGES ? { image_archive_skipped: uniqueImages.length - MAX_WECHAT_IMAGES } : {}),
1040
+ } : {}),
1041
+ source_hash: hash,
1042
+ fetched_at: new Date().toISOString(),
1043
+ };
1044
+ const imported = article
1045
+ ? await this.createWechatRaw(path, metadata, article, assets)
1046
+ : await this.createRaw(path, metadata, markdown);
1047
+ return {
1048
+ ...imported,
1049
+ sourceHash: hash,
1050
+ ...(article ? { sourceKind: "wechat", article: { title: article.title, account: article.account, author: article.author, authorInferred: article.authorInferred, publishedAt: article.publishedAt, imageCount: article.images.length } } : {}),
1051
+ };
1052
+ }
1053
+
1054
+ async importWeChatUrl(url, rawPath) {
1055
+ if (!isWeChatArticleUrl(url)) throw new OutlineKbError("INVALID_WECHAT_URL", "import-wechat requires a WeChat article URL");
1056
+ return this.importUrl(url, rawPath);
1057
+ }
1058
+
1059
+ async validatePlan(plan, planFile = null, snapshot = null, preflight = null) {
1060
+ const local = preflight ?? await preflightPlanFiles(plan, planFile);
1061
+ const tree = snapshot ?? await this.snapshot();
1062
+ const availablePaths = new Set(tree.byPath.keys());
1063
+ const validated = [];
1064
+ for (const operation of local.operations) {
1065
+ const path = operation.path;
1066
+ if (operation.type === "create") {
1067
+ const parentPath = path.split("/").slice(0, -1).join("/");
1068
+ if (parentPath && !availablePaths.has(parentPath)) {
1069
+ throw new OutlineKbError("INVALID_PLAN", `create parent must exist or be created earlier in the plan: ${parentPath}`);
1070
+ }
1071
+ availablePaths.add(path);
1072
+ }
1073
+ const content = operation.content;
1074
+ if (operation.type === "create" && tree.byPath.has(path)) {
1075
+ const current = await this.client.info(tree.byPath.get(path).id);
1076
+ if (current.text.trim() !== content.trim()) throw new OutlineKbError("ALREADY_EXISTS", `${path} exists with different content`);
1077
+ }
1078
+ if (operation.type === "update") {
1079
+ if (!operation.expectedUpdatedAt) throw new OutlineKbError("MISSING_PRECONDITION", `${path} update requires expectedUpdatedAt`);
1080
+ const listed = await this.resolve(path, tree);
1081
+ const current = await this.client.info(listed.id);
1082
+ if (current.text.trim() !== content.trim() && current.updatedAt !== operation.expectedUpdatedAt) {
1083
+ throw new OutlineKbError("UPDATE_CONFLICT", `${path} changed after plan creation`, {
1084
+ expectedUpdatedAt: operation.expectedUpdatedAt,
1085
+ actualUpdatedAt: current.updatedAt,
1086
+ });
1087
+ }
1088
+ }
1089
+ validated.push({ ...operation, path, content });
1090
+ }
1091
+ return { jobId: local.jobId, sourceHash: local.sourceHash, operations: validated };
1092
+ }
1093
+
1094
+ async applyPlan(plan, planFile = null, preflight = null) {
1095
+ const local = preflight ?? await preflightPlanFiles(plan, planFile);
1096
+ const tree = await this.snapshot();
1097
+ const validated = await this.validatePlan(plan, planFile, tree, local);
1098
+ const results = [];
1099
+ for (const operation of validated.operations) {
1100
+ if (operation.type === "create") results.push({ path: operation.path, ...(await this.create(operation.path, operation.content, tree)) });
1101
+ if (operation.type === "update") results.push({ path: operation.path, ...(await this.update(operation.path, operation.content, operation.expectedUpdatedAt, tree)) });
1102
+ if (operation.type === "append") results.push({ path: operation.path, ...(await this.appendLog(operation.content, validated.jobId, tree)) });
1103
+ }
1104
+ const fullLint = await this.lint(tree);
1105
+ const changedPaths = validated.operations.map((operation) => operation.path);
1106
+ const changesKnowledge = changedPaths.some((path) => path === "raw" || path.startsWith("raw/") || path === "wiki" || path.startsWith("wiki/"));
1107
+ const changesSources = changedPaths.some((path) => path.startsWith("wiki/sources/"));
1108
+ const touchesCurrentChange = (issue) => {
1109
+ const issuePath = typeof issue?.path === "string" ? normalizePath(issue.path) : "";
1110
+ if (!issuePath) return true;
1111
+ if (changesKnowledge && issue.code === "MISSING_REQUIRED") return true;
1112
+ if (changesSources && issue.code === "SOURCE_COUNT_MISMATCH") return true;
1113
+ return changedPaths.some((path) => path === issuePath || path.startsWith(`${issuePath}/`) || issuePath.startsWith(`${path}/`));
1114
+ };
1115
+ const errors = fullLint.errors.filter(touchesCurrentChange);
1116
+ const healthErrors = fullLint.errors.filter((issue) => !touchesCurrentChange(issue));
1117
+ const lint = { ...fullLint, ok: errors.length === 0, errors, ...(healthErrors.length ? { healthErrors } : {}) };
1118
+ return { jobId: validated.jobId, results, lint, status: lint.ok ? "succeeded" : "needs_repair" };
1119
+ }
1120
+
1121
+ async lint(snapshot = null) {
1122
+ const tree = snapshot ?? await this.snapshot();
1123
+ const errors = [];
1124
+ const warnings = [];
1125
+ const required = ["raw", "wiki", "wiki/index", "wiki/overview", "wiki/sources", "wiki/open-questions", "wiki/concepts", "wiki/synthesis", "log"];
1126
+ for (const path of required) if (!tree.byPath.has(path)) errors.push({ code: "MISSING_REQUIRED", path });
1127
+
1128
+ const scopedDocuments = tree.documents.filter((document) => document.path !== "");
1129
+ const loadedDocuments = await mapConcurrent(scopedDocuments, 8, async (document) => {
1130
+ const current = await this.client.info(document.id);
1131
+ return { ...current, path: document.path, parsed: parseWikiDocument(current.text) };
1132
+ });
1133
+ const loaded = new Map(loadedDocuments.map((document) => [document.path, document]));
1134
+ for (const document of loaded.values()) {
1135
+ errors.push(...metadataIssues(document.path, document.text));
1136
+ for (const marker of ["citation-needed", "conflict", "stale"]) {
1137
+ if (document.text.includes(marker)) warnings.push({ code: marker.toUpperCase().replace("-", "_"), path: document.path });
1138
+ }
1139
+ for (const ref of linkRefs(document.text)) {
1140
+ if (!tree.byPath.has(ref)) errors.push({ code: "BROKEN_LINK", path: document.path, target: ref });
1141
+ }
1142
+ }
1143
+
1144
+ const raws = [...loaded.values()].filter((document) => /^raw\/.+/.test(document.path));
1145
+ const sources = [...loaded.values()].filter((document) => /^wiki\/sources\/.+/.test(document.path));
1146
+ const sourceByRaw = new Map();
1147
+ for (const source of sources) {
1148
+ const rawRef = source.parsed.metadata?.raw_ref;
1149
+ if (typeof rawRef !== "string" || !loaded.has(rawRef)) {
1150
+ errors.push({ code: "SOURCE_RAW_MISSING", path: source.path, target: rawRef ?? null });
1151
+ } else if (sourceByRaw.has(rawRef)) {
1152
+ errors.push({ code: "DUPLICATE_SOURCE_NOTE", path: source.path, target: rawRef });
1153
+ } else {
1154
+ sourceByRaw.set(rawRef, source.path);
1155
+ if (!linkRefs(source.text).includes(rawRef)) errors.push({ code: "SOURCE_RAW_LINK_MISSING", path: source.path, target: rawRef });
1156
+ }
1157
+ }
1158
+ for (const raw of raws) if (!sourceByRaw.has(raw.path)) errors.push({ code: "RAW_WITHOUT_SOURCE_NOTE", path: raw.path });
1159
+
1160
+ const concepts = [...loaded.values()].filter((document) => /^wiki\/(?:concepts|synthesis)\/.+/.test(document.path));
1161
+ const citedSources = new Set();
1162
+ const topics = new Map();
1163
+ for (const document of concepts) {
1164
+ const refs = document.parsed.metadata?.sources;
1165
+ if (!Array.isArray(refs) || refs.length === 0) errors.push({ code: "GENERATED_WITHOUT_SOURCE", path: document.path });
1166
+ for (const ref of Array.isArray(refs) ? refs : []) {
1167
+ if (typeof ref !== "string" || !/^wiki\/sources\/.+/.test(ref)) errors.push({ code: "SOURCE_REF_INVALID", path: document.path, target: ref });
1168
+ else if (!loaded.has(ref)) errors.push({ code: "SOURCE_REF_MISSING", path: document.path, target: ref });
1169
+ else citedSources.add(ref);
1170
+ }
1171
+ const topic = document.parsed.metadata?.topic;
1172
+ if (typeof topic === "string") {
1173
+ if (topics.has(topic)) errors.push({ code: "DUPLICATE_TOPIC", path: document.path, other: topics.get(topic), topic });
1174
+ else topics.set(topic, document.path);
1175
+ }
1176
+ }
1177
+ for (const source of sources) if (!citedSources.has(source.path)) errors.push({ code: "SOURCE_NOT_IN_CONCEPT", path: source.path });
1178
+
1179
+ const expectedCount = sources.length;
1180
+ for (const path of ["wiki/sources", "wiki/overview"]) {
1181
+ const actual = loaded.get(path)?.parsed.metadata?.source_count;
1182
+ if (actual !== expectedCount) errors.push({ code: "SOURCE_COUNT_MISMATCH", path, expected: expectedCount, actual: actual ?? null });
1183
+ }
1184
+
1185
+ const conceptPages = [...loaded.values()].filter((document) => /^wiki\/concepts\/.+/.test(document.path));
1186
+ for (let left = 0; left < conceptPages.length; left++) {
1187
+ for (let right = left + 1; right < conceptPages.length; right++) {
1188
+ const a = conceptPages[left];
1189
+ const b = conceptPages[right];
1190
+ const score = titleSimilarity(a.title, b.title);
1191
+ if (score >= 0.8) warnings.push({ code: "SIMILAR_CONCEPT_TITLE", path: a.path, other: b.path, score: Number(score.toFixed(2)) });
1192
+ }
1193
+ }
1194
+
1195
+ const reachable = new Set(["wiki/index"]);
1196
+ const queue = ["wiki/index"];
1197
+ while (queue.length) {
1198
+ const path = queue.shift();
1199
+ const document = loaded.get(path);
1200
+ if (!document) continue;
1201
+ const refs = [...linkRefs(document.text), ...(Array.isArray(document.parsed.metadata?.sources) ? document.parsed.metadata.sources : [])];
1202
+ if (typeof document.parsed.metadata?.raw_ref === "string") refs.push(document.parsed.metadata.raw_ref);
1203
+ for (const ref of refs) if (!reachable.has(ref) && loaded.has(ref)) { reachable.add(ref); queue.push(ref); }
1204
+ }
1205
+ for (const document of loaded.values()) {
1206
+ if (["raw", "wiki", "wiki/concepts", "wiki/synthesis", "log"].includes(document.path)) continue;
1207
+ if (!reachable.has(document.path)) errors.push({ code: "UNREACHABLE", path: document.path });
1208
+ }
1209
+ return { ok: errors.length === 0, files: loaded.size, rawCount: raws.length, sourceCount: sources.length, errors, warnings };
1210
+ }
1211
+ }
1212
+
1213
+ function linkRefs(text) {
1214
+ const refs = [];
1215
+ const pattern = /\]\(kb:\/\/([^\s)]+)\)/g;
1216
+ for (const match of String(text ?? "").matchAll(pattern)) {
1217
+ try { refs.push(decodeURIComponent(match[1])); }
1218
+ catch { refs.push(match[1]); }
1219
+ }
1220
+ return refs;
1221
+ }
1222
+
1223
+ function titleSimilarity(left, right) {
1224
+ const a = String(left ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
1225
+ const b = String(right ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
1226
+ if (a.length < 4 || b.length < 4 || a === b) return a === b && a.length >= 4 ? 1 : 0;
1227
+ const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
1228
+ for (let i = 1; i <= a.length; i++) {
1229
+ const current = [i];
1230
+ for (let j = 1; j <= b.length; j++) {
1231
+ current[j] = Math.min(
1232
+ current[j - 1] + 1,
1233
+ previous[j] + 1,
1234
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
1235
+ );
1236
+ }
1237
+ previous.splice(0, previous.length, ...current);
1238
+ }
1239
+ return 1 - previous[b.length] / Math.max(a.length, b.length);
1240
+ }
1241
+
1242
+ function htmlToMarkdown(html) {
1243
+ return String(html)
1244
+ .replace(/<script\b[\s\S]*?<\/script>/gi, "")
1245
+ .replace(/<style\b[\s\S]*?<\/style>/gi, "")
1246
+ .replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, "# $1\n\n")
1247
+ .replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, "## $1\n\n")
1248
+ .replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, "### $1\n\n")
1249
+ .replace(/<h4\b[^>]*>([\s\S]*?)<\/h4>/gi, "#### $1\n\n")
1250
+ .replace(/<h5\b[^>]*>([\s\S]*?)<\/h5>/gi, "#### $1\n\n")
1251
+ .replace(/<h6\b[^>]*>([\s\S]*?)<\/h6>/gi, "#### $1\n\n")
1252
+ .replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, "> $1\n\n")
1253
+ .replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n")
1254
+ .replace(/<br\s*\/?\s*>/gi, "\n")
1255
+ .replace(/<\/p>/gi, "\n\n")
1256
+ .replace(/<[^>]+>/g, "")
1257
+ .replace(/&nbsp;/g, " ")
1258
+ .replace(/&amp;/g, "&")
1259
+ .replace(/&lt;/g, "<")
1260
+ .replace(/&gt;/g, ">")
1261
+ .replace(/&quot;/g, '"')
1262
+ .replace(/&#39;/g, "'")
1263
+ .replace(/\n{3,}/g, "\n\n")
1264
+ .trim();
1265
+ }
1266
+
1267
+ function operationFileDetails(fullPath, contentFile, operation, operationIndex, error = null) {
1268
+ return {
1269
+ path: fullPath,
1270
+ contentFile,
1271
+ operationIndex,
1272
+ operationType: operation?.type ?? null,
1273
+ operationPath: operation?.path ?? null,
1274
+ ...(error?.code ? { fsCode: error.code } : {}),
1275
+ };
1276
+ }
1277
+
1278
+ async function readPlanFile(planFile) {
1279
+ const full = resolve(planFile);
1280
+ let text;
1281
+ try {
1282
+ text = await readFile(full, "utf8");
1283
+ } catch (error) {
1284
+ const code = error?.code === "ENOENT" ? "PLAN_FILE_NOT_FOUND" : "PLAN_FILE_UNREADABLE";
1285
+ const message = code === "PLAN_FILE_NOT_FOUND" ? `Plan file not found: ${full}` : `Plan file is unreadable: ${full}`;
1286
+ throw new OutlineKbError(code, message, { path: full, ...(error?.code ? { fsCode: error.code } : {}) });
1287
+ }
1288
+ try {
1289
+ return { path: full, plan: JSON.parse(text) };
1290
+ } catch (error) {
1291
+ throw new OutlineKbError("PLAN_JSON_INVALID", `Plan file contains invalid JSON: ${full}`, {
1292
+ path: full,
1293
+ parseMessage: error instanceof Error ? error.message : String(error),
1294
+ });
1295
+ }
1296
+ }
1297
+
1298
+ async function readPlanContent(contentFile, planFile, operation, operationIndex) {
1299
+ if (!contentFile || typeof contentFile !== "string") throw new OutlineKbError("INVALID_PLAN", "operation requires contentFile");
1300
+ const base = planFile ? dirname(resolve(planFile)) : process.cwd();
1301
+ const full = resolve(base, contentFile);
1302
+ const rel = relative(base, full);
1303
+ if (rel.startsWith("..") || isAbsolute(rel)) {
1304
+ throw new OutlineKbError("INVALID_PLAN", `contentFile escapes plan directory: ${contentFile}`, operationFileDetails(full, contentFile, operation, operationIndex));
1305
+ }
1306
+ try {
1307
+ return await readFile(full, "utf8");
1308
+ } catch (error) {
1309
+ const code = error?.code === "ENOENT" ? "CONTENT_FILE_NOT_FOUND" : "CONTENT_FILE_UNREADABLE";
1310
+ const message = code === "CONTENT_FILE_NOT_FOUND" ? `Plan content file not found: ${full}` : `Plan content file is unreadable: ${full}`;
1311
+ throw new OutlineKbError(code, message, operationFileDetails(full, contentFile, operation, operationIndex, error));
1312
+ }
1313
+ }
1314
+
1315
+ async function preflightPlanFiles(plan, planFile = null, options = {}) {
1316
+ if (!plan || typeof plan !== "object" || Array.isArray(plan) || !Array.isArray(plan.operations)) {
1317
+ throw new OutlineKbError("INVALID_PLAN", "Plan must contain an operations array");
1318
+ }
1319
+ if (!plan.jobId || typeof plan.jobId !== "string") throw new OutlineKbError("INVALID_PLAN", "Plan requires jobId");
1320
+ const seen = new Set();
1321
+ const operations = [];
1322
+ const allowedOperationTypes = options.allowedOperationTypes ?? new Set(["create", "update", "append"]);
1323
+ for (let index = 0; index < plan.operations.length; index++) {
1324
+ const operation = plan.operations[index];
1325
+ if (!operation || !allowedOperationTypes.has(operation.type)) {
1326
+ throw new OutlineKbError("INVALID_PLAN", `Unsupported operation at index ${index}`);
1327
+ }
1328
+ const path = normalizePath(operation.path);
1329
+ if (seen.has(path)) throw new OutlineKbError("INVALID_PLAN", `Plan contains more than one operation for ${path}`);
1330
+ seen.add(path);
1331
+ if (operation.type === "update" && (path === "raw" || path.startsWith("raw/"))) {
1332
+ throw new OutlineKbError("RAW_IMMUTABLE", `Plan cannot update ${path}`);
1333
+ }
1334
+ if (path === "log" && operation.type !== "append") throw new OutlineKbError("LOG_APPEND_ONLY", "Plan may only append log");
1335
+ if (operation.type === "append" && path !== "log") throw new OutlineKbError("INVALID_PLAN", "append is only supported for log");
1336
+ const content = await readPlanContent(operation.contentFile, planFile, operation, index);
1337
+ if (operation.type !== "append" && options.validateMetadata !== false) assertDocument(path, content);
1338
+ operations.push({ ...operation, path, content });
1339
+ }
1340
+ return { jobId: plan.jobId, sourceHash: plan.sourceHash ?? null, operations };
1341
+ }
1342
+
1343
+ function parseArgs(argv) {
1344
+ const positional = [];
1345
+ const flags = new Map();
1346
+ for (let index = 0; index < argv.length; index++) {
1347
+ const value = argv[index];
1348
+ if (!value.startsWith("--")) { positional.push(value); continue; }
1349
+ const key = value.slice(2);
1350
+ const next = argv[index + 1];
1351
+ if (next === undefined || next.startsWith("--")) flags.set(key, "true");
1352
+ else { flags.set(key, next); index++; }
1353
+ }
1354
+ return { positional, flags };
1355
+ }
1356
+
1357
+ function need(value, message) {
1358
+ if (!value) throw new OutlineKbError("USAGE", message);
1359
+ return value;
1360
+ }
1361
+
1362
+ function positiveLimit(value, fallback) {
1363
+ if (value === undefined) return fallback;
1364
+ const parsed = Number(value);
1365
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 20) {
1366
+ throw new OutlineKbError("USAGE", "--limit must be an integer from 1 to 20");
1367
+ }
1368
+ return parsed;
1369
+ }
1370
+
1371
+ function publicDocument(document) {
1372
+ if (!document) return document;
1373
+ const { id, title, path, url, parentDocumentId, updatedAt, text } = document;
1374
+ return { id, title, ...(path ? { path } : {}), url, parentDocumentId, updatedAt, ...(text !== undefined ? { text } : {}) };
1375
+ }
1376
+
1377
+ async function run(argv, env = process.env) {
1378
+ const { positional, flags } = parseArgs(argv);
1379
+ const command = positional.shift();
1380
+ if (flags.has("help") || command === "help" || command === "-h") {
1381
+ const help = command && command !== "help" && command !== "-h" ? COMMAND_HELP[command] : null;
1382
+ return help
1383
+ ? { ok: true, command, ...help, successExample: successExample(command), failureExample: HELP_FAILURE_EXAMPLE }
1384
+ : { ok: true, usage: USAGE, commands: Object.keys(COMMAND_HELP), failureExample: HELP_FAILURE_EXAMPLE };
1385
+ }
1386
+ if (env.OUTLINE_KB_QUERY_MODE === "1" && !QUERY_COMMANDS.has(command)) {
1387
+ throw new OutlineKbError("QUERY_COMMAND_DISABLED", `outline-kb ${command ?? "<missing>"} is disabled for a knowledge query`);
1388
+ }
1389
+ if (env.OUTLINE_KB_READ_ONLY === "1" && WRITE_COMMANDS.has(command)) {
1390
+ throw new OutlineKbError("READ_ONLY", `outline-kb ${command} is disabled for a read-only knowledge query`);
1391
+ }
1392
+ if (env.OUTLINE_KB_INGEST_MODE === "1" && INGEST_FORBIDDEN_COMMANDS.has(command)) {
1393
+ throw new OutlineKbError("INGEST_COMMAND_DISABLED", `outline-kb ${command} is disabled until the ingest Candidate is approved`);
1394
+ }
1395
+ if (command === "validate-maintenance-plan" && env.OUTLINE_KB_MAINTENANCE_MODE !== "1") {
1396
+ throw new OutlineKbError("MAINTENANCE_MODE_REQUIRED", "validate-maintenance-plan is only available to a system-owned knowledge maintenance session");
1397
+ }
1398
+ if (env.OUTLINE_KB_MAINTENANCE_MODE === "1" && !MAINTENANCE_ALLOWED_COMMANDS.has(command)) {
1399
+ throw new OutlineKbError("MAINTENANCE_COMMAND_DISABLED", `outline-kb ${command ?? "<missing>"} is disabled for a knowledge maintenance Candidate session`);
1400
+ }
1401
+ let planInput = null;
1402
+ if (command === "validate-plan" || command === "validate-maintenance-plan" || command === "apply") {
1403
+ const loaded = await readPlanFile(need(positional[0], `${command} requires a JSON plan file`));
1404
+ const systemManagedMetadata = command === "validate-maintenance-plan" || (command === "validate-plan"
1405
+ && env.OUTLINE_KB_INGEST_MODE === "1"
1406
+ && env.OASIS_KNOWLEDGE_SYSTEM_METADATA === "1");
1407
+ planInput = {
1408
+ ...loaded,
1409
+ preflight: await preflightPlanFiles(loaded.plan, loaded.path, {
1410
+ validateMetadata: !systemManagedMetadata,
1411
+ ...(command === "validate-maintenance-plan" ? { allowedOperationTypes: new Set(["create", "update"]) } : {}),
1412
+ }),
1413
+ };
1414
+ }
1415
+ if (command === "validate-maintenance-plan") {
1416
+ return { ok: true, ...planInput.preflight };
1417
+ }
1418
+ const client = new OutlineApiClient({ baseUrl: need(env.OUTLINE_BASE_URL, "OUTLINE_BASE_URL is required"), apiToken: need(env.OUTLINE_API_TOKEN, "OUTLINE_API_TOKEN is required") });
1419
+ if (command === "status") {
1420
+ const info = await client.authInfo();
1421
+ const user = info?.data?.user ?? info?.data ?? {};
1422
+ return { ok: true, identity: user.name ?? user.email ?? user.id ?? "outline-api", baseUrl: client.baseUrl };
1423
+ }
1424
+ const collectionId = flags.get("collection") ?? env.OUTLINE_COLLECTION_ID;
1425
+ const rootDocumentId = flags.get("root") ?? env.OUTLINE_ROOT_DOCUMENT_ID ?? null;
1426
+ const wiki = new OutlineWiki(client, { collectionId: need(collectionId, "--collection is required"), rootDocumentId });
1427
+ if (command === "init") return { ok: true, results: (await wiki.init()).map((item) => ({ ...item, document: publicDocument(item.document) })) };
1428
+ if (command === "tree") return { ok: true, documents: (await wiki.tree(positional[0] ?? null)).map(publicDocument) };
1429
+ if (command === "search") return { ok: true, documents: (await wiki.search(need(positional[0], "search requires a query"), flags.get("under") ?? null, positiveLimit(flags.get("limit"), 10))).map(publicDocument) };
1430
+ if (command === "read") return { ok: true, document: publicDocument(await wiki.read(need(positional[0], "read requires a path or document id"))) };
1431
+ if (command === "read-many") {
1432
+ if (positional.length === 0) throw new OutlineKbError("USAGE", "read-many requires one or more paths or document ids");
1433
+ return { ok: true, documents: (await wiki.readMany(positional)).map(publicDocument) };
1434
+ }
1435
+ if (command === "search-and-read") return { ok: true, documents: (await wiki.searchAndRead(need(positional[0], "search-and-read requires a query"), { under: flags.get("under") ?? null, limit: positiveLimit(flags.get("limit"), 8) })).map(publicDocument) };
1436
+ if (command === "create") {
1437
+ const result = await wiki.create(need(positional[0], "create requires a path"), await readFile(need(flags.get("file"), "create requires --file"), "utf8"));
1438
+ return { ok: true, ...result, document: publicDocument(result.document) };
1439
+ }
1440
+ if (command === "update") {
1441
+ const result = await wiki.update(need(positional[0], "update requires a path"), await readFile(need(flags.get("file"), "update requires --file"), "utf8"), flags.get("expected-updated-at"));
1442
+ return { ok: true, ...result, document: publicDocument(result.document) };
1443
+ }
1444
+ if (command === "append") {
1445
+ const path = need(positional[0], "append requires log");
1446
+ if (path !== "log") throw new OutlineKbError("USAGE", "append only supports log");
1447
+ const result = await wiki.appendLog(await readFile(need(flags.get("file"), "append requires --file"), "utf8"), need(flags.get("job-id"), "append requires --job-id"));
1448
+ return { ok: true, ...result, document: publicDocument(result.document) };
1449
+ }
1450
+ if (command === "import-file") {
1451
+ const result = await wiki.importFile(need(positional[0], "import-file requires a file"), need(flags.get("path"), "import-file requires --path"), { textFile: flags.get("text-file") ?? null, contentType: flags.get("content-type") ?? null });
1452
+ return { ok: true, ...result, document: publicDocument(result.document) };
1453
+ }
1454
+ if (command === "import-url") {
1455
+ const result = await wiki.importUrl(need(positional[0], "import-url requires a URL"), need(flags.get("path"), "import-url requires --path"));
1456
+ return { ok: true, ...result, document: publicDocument(result.document) };
1457
+ }
1458
+ if (command === "import-wechat") {
1459
+ const result = await wiki.importWeChatUrl(need(positional[0], "import-wechat requires a WeChat article URL"), need(flags.get("path"), "import-wechat requires --path"));
1460
+ return { ok: true, ...result, document: publicDocument(result.document) };
1461
+ }
1462
+ if (command === "validate-plan" || command === "apply") {
1463
+ const { path: file, plan, preflight } = planInput;
1464
+ const result = command === "apply" ? await wiki.applyPlan(plan, file, preflight) : await wiki.validatePlan(plan, file, null, preflight);
1465
+ return { ok: command === "apply" ? result.lint.ok : true, ...result };
1466
+ }
1467
+ if (command === "lint") return await wiki.lint();
1468
+ throw new OutlineKbError("USAGE", USAGE);
1469
+ }
1470
+
1471
+ export async function main(argv = process.argv.slice(2)) {
1472
+ try {
1473
+ const result = await run(argv);
1474
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1475
+ if (result?.ok === false) process.exitCode = 2;
1476
+ } catch (error) {
1477
+ const known = error instanceof OutlineKbError;
1478
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code: known ? error.code : "INTERNAL", message: known ? error.message : "outline-kb failed", ...(known && error.details ? { details: error.details } : {}) } })}\n`);
1479
+ process.exitCode = 1;
1480
+ }
1481
+ }
1482
+
1483
+ const invoked = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
1484
+ if (import.meta.url === invoked) await main();
1485
+
1486
+ export { htmlToMarkdown, linkRefs, normalizePath, run };