skillwiki 0.10.110 → 0.10.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/{chunk-5MV5SSRM.js → chunk-5IBLXGLY.js} +5 -3
  2. package/dist/chunk-6OQLBNKR.js +250 -0
  3. package/dist/chunk-GQWCTJTU.js +55 -0
  4. package/dist/{chunk-QU2CKXM4.js → chunk-HBJLFPQP.js} +91 -199
  5. package/dist/chunk-I2J3IJB7.js +54 -0
  6. package/dist/{chunk-WPJHDRTP.js → chunk-I6IRJCUJ.js} +12 -57
  7. package/dist/{chunk-NA3TZ4JN.js → chunk-JQV5KNPT.js} +8 -6
  8. package/dist/chunk-KWEYA3OE.js +60 -0
  9. package/dist/{chunk-KX5Y3GSX.js → chunk-MRPJ7X7W.js} +5 -3
  10. package/dist/{chunk-6X6PPQNI.js → chunk-OJJBZDPR.js} +162 -296
  11. package/dist/chunk-OSL4J7GF.js +32 -0
  12. package/dist/chunk-PYPPG7RF.js +126 -0
  13. package/dist/chunk-QESNTTVY.js +50 -0
  14. package/dist/chunk-TDBUJUYH.js +109 -0
  15. package/dist/{chunk-R2DJ7IJ5.js → chunk-UIEQIJXP.js} +5 -336
  16. package/dist/chunk-X5GL2FCU.js +344 -0
  17. package/dist/chunk-ZRGZBAVS.js +98 -0
  18. package/dist/cli.js +308 -298
  19. package/dist/{index-projection-6XGESSHV.js → index-projection-2K672CBO.js} +5 -3
  20. package/dist/journal-QU5IDXQF.js +13 -0
  21. package/dist/{managed-write-preflight-JPOBVVSU.js → managed-write-preflight-GRSHQYKC.js} +7 -4
  22. package/dist/migrate-PIB4QA54.js +21 -0
  23. package/dist/ready-S7F53Q2D.js +9 -0
  24. package/dist/runtime-GLMI5OCT.js +15 -0
  25. package/dist/skillwiki-mcp.js +12 -7
  26. package/dist/sources-D2MGP246.js +10 -0
  27. package/dist/upload-HTE62TOV.js +14 -0
  28. package/dist/watcher-MHG46J3R.js +616 -0
  29. package/docs/clip-outbox.md +119 -0
  30. package/package.json +5 -1
  31. package/skills/.claude-plugin/plugin.json +1 -1
  32. package/skills/.codex-plugin/plugin.json +1 -1
  33. package/skills/.cursor-plugin/plugin.json +1 -1
  34. package/skills/package.json +1 -1
  35. package/templates/launchd/com.karlchow.skillwiki-clip-outbox.plist +12 -13
  36. package/dist/sources-CLOF5VAT.js +0 -9
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ sha256Hex
4
+ } from "./chunk-ZRGZBAVS.js";
5
+ import {
6
+ buildReadyHint
7
+ } from "./chunk-OSL4J7GF.js";
8
+ import {
9
+ runFetchGuardSync
10
+ } from "./chunk-I2J3IJB7.js";
11
+ import "./chunk-QESNTTVY.js";
12
+ import {
13
+ readJournal,
14
+ upsertJournal,
15
+ writeJournal
16
+ } from "./chunk-KWEYA3OE.js";
17
+ import "./chunk-GQWCTJTU.js";
18
+ import {
19
+ ipv4InCidr,
20
+ isBlockedHost
21
+ } from "./chunk-UIEQIJXP.js";
22
+
23
+ // src/clip-outbox/watcher.ts
24
+ import { createRequire } from "module";
25
+ import { randomBytes as randomBytes2 } from "crypto";
26
+ import { mkdir as mkdir2, readdir, rmdir, unlink, writeFile } from "fs/promises";
27
+ import { basename, join as join2, resolve as resolve3 } from "path";
28
+
29
+ // src/clip-outbox/seal.ts
30
+ import { randomBytes } from "crypto";
31
+ import { mkdir, open as open2 } from "fs/promises";
32
+ import { dirname, join, relative as relative3 } from "path";
33
+ import yaml from "js-yaml";
34
+
35
+ // src/clip-outbox/localize.ts
36
+ import { randomUUID } from "crypto";
37
+ import { readFile } from "fs/promises";
38
+ import { isAbsolute, relative, resolve } from "path";
39
+
40
+ // src/clip-outbox/images.ts
41
+ var MD_IMAGE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
42
+ var HTML_IMAGE = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
43
+ var WIKI_EMBED = /!\[\[([^\]]+)\]\]/g;
44
+ var REF_DEF = /^\[([^\]]+)\]:\s+(\S+)/gm;
45
+ var REF_IMAGE = /!\[([^\]]*)\]\[([^\]]+)\]/g;
46
+ function parseImageDependencies(markdown, sourceUrl) {
47
+ const refs = [];
48
+ const definitions = /* @__PURE__ */ new Map();
49
+ for (const m of markdown.matchAll(REF_DEF)) {
50
+ definitions.set(m[1].toLowerCase(), m[2]);
51
+ }
52
+ for (const m of markdown.matchAll(MD_IMAGE)) {
53
+ refs.push(makeRef(m[0], m[2], "markdown", sourceUrl));
54
+ }
55
+ for (const m of markdown.matchAll(REF_IMAGE)) {
56
+ const href = definitions.get(m[2].toLowerCase());
57
+ if (!href) continue;
58
+ refs.push(makeRef(m[0], href, "markdown", sourceUrl));
59
+ }
60
+ for (const m of markdown.matchAll(HTML_IMAGE)) {
61
+ refs.push(makeRef(m[0], m[1], "html", sourceUrl));
62
+ }
63
+ for (const m of markdown.matchAll(WIKI_EMBED)) {
64
+ refs.push(makeRef(m[0], m[1].split("|")[0].trim(), "wikilink", sourceUrl));
65
+ }
66
+ return refs;
67
+ }
68
+ function makeRef(raw, target, kind, sourceUrl) {
69
+ const trimmed = target.trim();
70
+ const local = kind === "wikilink" || trimmed.startsWith("raw/assets/") || !/^[a-z][a-z0-9+.-]*:/i.test(trimmed);
71
+ let resolved = trimmed;
72
+ if (!local && sourceUrl) {
73
+ try {
74
+ resolved = new URL(trimmed, sourceUrl).toString();
75
+ } catch {
76
+ resolved = trimmed;
77
+ }
78
+ }
79
+ return { raw, kind, target: resolved, local };
80
+ }
81
+ function sniffImageKind(bytes) {
82
+ if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
83
+ return "png";
84
+ }
85
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
86
+ return "jpeg";
87
+ }
88
+ if (bytes.length >= 6 && bytes.subarray(0, 6).toString("ascii") === "GIF87a") return "gif";
89
+ if (bytes.length >= 6 && bytes.subarray(0, 6).toString("ascii") === "GIF89a") return "gif";
90
+ if (bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") {
91
+ return "webp";
92
+ }
93
+ return void 0;
94
+ }
95
+ function extensionForKind(kind) {
96
+ return kind === "jpeg" ? "jpg" : kind;
97
+ }
98
+ function pngPixelCount(bytes) {
99
+ if (sniffImageKind(bytes) !== "png" || bytes.length < 24) return void 0;
100
+ const width = bytes.readUInt32BE(16);
101
+ const height = bytes.readUInt32BE(20);
102
+ return width * height;
103
+ }
104
+ function localAssetRelPath(target) {
105
+ const cleaned = target.replace(/\\/g, "/").replace(/^\.\//, "");
106
+ if (cleaned.startsWith("raw/assets/")) return cleaned;
107
+ if (cleaned.startsWith("/raw/assets/")) return cleaned.slice(1);
108
+ return void 0;
109
+ }
110
+
111
+ // src/clip-outbox/ssrf.ts
112
+ import { isIP } from "net";
113
+ import { lookup as dnsLookup } from "dns/promises";
114
+ var extraBlockedV4 = [
115
+ ["0.0.0.0", 8],
116
+ ["100.64.0.0", 10],
117
+ ["192.0.2.0", 24],
118
+ ["198.18.0.0", 15],
119
+ ["198.51.100.0", 24],
120
+ ["203.0.113.0", 24],
121
+ ["224.0.0.0", 4],
122
+ ["240.0.0.0", 4]
123
+ ];
124
+ function isBlockedAddress(address) {
125
+ if (isBlockedHost(address)) return true;
126
+ const version = isIP(address);
127
+ if (version === 4) {
128
+ return extraBlockedV4.some(([base, prefix]) => ipv4InCidr(address, base, prefix));
129
+ }
130
+ if (version === 6) {
131
+ const lower = address.toLowerCase();
132
+ return lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe80:");
133
+ }
134
+ return false;
135
+ }
136
+ async function assertPublicHttpsUrl(url, lookup = defaultLookup) {
137
+ const guard = runFetchGuardSync({ url });
138
+ if (!guard.result.ok) {
139
+ return { ok: false, reason: guard.result.error };
140
+ }
141
+ let parsed;
142
+ try {
143
+ parsed = new URL(url);
144
+ } catch {
145
+ return { ok: false, reason: "MALFORMED_URL" };
146
+ }
147
+ if (isBlockedAddress(parsed.hostname)) {
148
+ return { ok: false, reason: "HOST_BLOCKED" };
149
+ }
150
+ try {
151
+ const addresses = await lookup(parsed.hostname);
152
+ if (addresses.length === 0) return { ok: false, reason: "DNS_EMPTY" };
153
+ if (addresses.some(isBlockedAddress)) return { ok: false, reason: "HOST_BLOCKED" };
154
+ } catch {
155
+ return { ok: false, reason: "DNS_FAILED" };
156
+ }
157
+ return { ok: true, href: parsed.href };
158
+ }
159
+ async function defaultLookup(hostname) {
160
+ if (isIP(hostname)) return [hostname];
161
+ const v4 = await dnsLookup(hostname, { all: true, verbatim: true });
162
+ return v4.map((row) => row.address);
163
+ }
164
+
165
+ // src/clip-outbox/localize.ts
166
+ var LocalizeError = class extends Error {
167
+ constructor(message, code = "LOCALIZE_HELD") {
168
+ super(message);
169
+ this.code = code;
170
+ this.name = "LocalizeError";
171
+ }
172
+ code;
173
+ };
174
+ function isInsideDir(root, candidate) {
175
+ const rel = relative(resolve(root), resolve(candidate));
176
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
177
+ }
178
+ async function localizeCapture(markdown, opts, deps = {}) {
179
+ const captureId = opts.captureId ?? randomUUID().replace(/-/g, "").slice(0, 16);
180
+ const refs = parseImageDependencies(markdown, opts.sourceUrl);
181
+ if (refs.length > opts.config.maxImages) {
182
+ throw new LocalizeError(`too many images: ${refs.length}`);
183
+ }
184
+ const fetchImpl = deps.fetch ?? fetch;
185
+ const now = deps.now ?? Date.now;
186
+ const assets = [];
187
+ const seenUrl = /* @__PURE__ */ new Map();
188
+ const replacements = /* @__PURE__ */ new Map();
189
+ const deadline = now() + opts.config.localizeTimeoutMs;
190
+ let bundleBytes = Buffer.byteLength(markdown);
191
+ for (const ref of refs) {
192
+ if (now() > deadline) throw new LocalizeError("localize deadline exceeded");
193
+ let asset = seenUrl.get(ref.target);
194
+ if (!asset) {
195
+ if (ref.local) {
196
+ const rel = localAssetRelPath(ref.target);
197
+ if (!rel) throw new LocalizeError(`unsafe or missing local image: ${ref.target}`);
198
+ const abs = resolve(opts.outboxRoot, rel);
199
+ if (!isInsideDir(resolve(opts.outboxRoot, "raw/assets"), abs)) {
200
+ throw new LocalizeError("local image outside raw/assets");
201
+ }
202
+ const reader = deps.readLocal ?? ((p) => readFile(p));
203
+ const bytes = await reader(abs);
204
+ asset = sealAsset(captureId, bytes, ref.target, opts.config);
205
+ asset.captureRel = rel;
206
+ } else {
207
+ const bytes = await downloadImage(ref.target, opts.config, fetchImpl, deps.lookup);
208
+ asset = sealAsset(captureId, bytes, ref.target, opts.config);
209
+ }
210
+ bundleBytes += asset.bytes.length;
211
+ if (bundleBytes > opts.config.maxBundleBytes) throw new LocalizeError("bundle too large");
212
+ assets.push(asset);
213
+ seenUrl.set(ref.target, asset);
214
+ }
215
+ replacements.set(ref.raw, `![[${asset.captureRel}]]`);
216
+ }
217
+ let rewritten = markdown;
218
+ for (const [raw, embed] of replacements) {
219
+ rewritten = rewritten.replaceAll(raw, embed);
220
+ }
221
+ const indexMarkdown = [
222
+ "---",
223
+ `title: clip assets ${captureId}`,
224
+ "---",
225
+ "",
226
+ ...assets.map((a) => `- ![[${a.captureRel}]]`),
227
+ ""
228
+ ].join("\n");
229
+ bundleBytes += Buffer.byteLength(indexMarkdown);
230
+ if (bundleBytes > opts.config.maxBundleBytes) throw new LocalizeError("bundle too large");
231
+ return { captureId, markdown: rewritten, assets, indexMarkdown };
232
+ }
233
+ function sealAsset(captureId, bytes, source, config) {
234
+ if (bytes.length > config.maxImageBytes) throw new LocalizeError("image too large");
235
+ const kind = sniffImageKind(bytes);
236
+ if (!kind || !config.imageTypes.includes(kind)) throw new LocalizeError("unsupported image type");
237
+ const pixels = pngPixelCount(bytes);
238
+ if (pixels !== void 0 && pixels > config.maxPixels) throw new LocalizeError("image pixel budget exceeded");
239
+ const sha256 = sha256Hex(bytes);
240
+ return {
241
+ captureRel: `raw/assets/${captureId}/${sha256}.${extensionForKind(kind)}`,
242
+ sha256,
243
+ bytes,
244
+ kind,
245
+ source
246
+ };
247
+ }
248
+ async function downloadImage(href, config, fetchImpl, lookup) {
249
+ let current = href;
250
+ for (let hop = 0; hop <= config.maxRedirects; hop++) {
251
+ const guarded = await assertPublicHttpsUrl(current, lookup);
252
+ if (!guarded.ok) {
253
+ throw new LocalizeError(
254
+ hop === 0 ? `blocked image URL: ${guarded.reason}` : `redirect blocked: ${guarded.reason}`
255
+ );
256
+ }
257
+ const res = await fetchImpl(current, {
258
+ method: "GET",
259
+ redirect: "manual",
260
+ headers: { Accept: "image/*" },
261
+ signal: AbortSignal.timeout(config.requestTimeoutMs)
262
+ });
263
+ if (res.status >= 300 && res.status < 400) {
264
+ const loc = res.headers.get("location");
265
+ if (!loc) throw new LocalizeError("redirect without location");
266
+ current = new URL(loc, current).toString();
267
+ continue;
268
+ }
269
+ if (!res.ok || !res.body) throw new LocalizeError(`image HTTP ${res.status}`);
270
+ const chunks = [];
271
+ let total = 0;
272
+ for await (const chunk of res.body) {
273
+ total += chunk.byteLength;
274
+ if (total > config.maxImageBytes) throw new LocalizeError("image too large");
275
+ chunks.push(Buffer.from(chunk));
276
+ }
277
+ return Buffer.concat(chunks);
278
+ }
279
+ throw new LocalizeError("too many redirects");
280
+ }
281
+
282
+ // src/clip-outbox/files.ts
283
+ import { constants } from "fs";
284
+ import { lstat, open, realpath } from "fs/promises";
285
+ import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep } from "path";
286
+ async function readRegular(root, path, maxBytes) {
287
+ const rel = relative2(root, path);
288
+ if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute2(rel)) throw new Error("unsafe input path");
289
+ let current = resolve2(root);
290
+ for (const part of ["", ...rel.split(sep)]) {
291
+ current = resolve2(current, part);
292
+ const st = await lstat(current);
293
+ if (st.isSymbolicLink()) throw new Error("symlink input refused");
294
+ if (current !== path && !st.isDirectory()) throw new Error("invalid input parent");
295
+ }
296
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
297
+ try {
298
+ const before = await handle.stat();
299
+ if (!before.isFile() || before.size > maxBytes) throw new Error("input type or size refused");
300
+ const bytes = Buffer.alloc(before.size + 1);
301
+ let bytesRead = 0;
302
+ while (bytesRead < bytes.length) {
303
+ const chunk = await handle.read(bytes, bytesRead, bytes.length - bytesRead, bytesRead);
304
+ if (chunk.bytesRead === 0) break;
305
+ bytesRead += chunk.bytesRead;
306
+ }
307
+ const after = await handle.stat();
308
+ if (await realpath(path) !== path) throw new Error("input path changed during read");
309
+ const leaf = await lstat(path);
310
+ if (leaf.ino !== before.ino || leaf.dev !== before.dev) throw new Error("input identity changed during read");
311
+ if (bytesRead !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) throw new Error("input changed during read");
312
+ return bytes.subarray(0, bytesRead);
313
+ } finally {
314
+ await handle.close();
315
+ }
316
+ }
317
+
318
+ // src/clip-outbox/seal.ts
319
+ var UnstableCapture = class extends Error {
320
+ };
321
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
322
+ function snapshotPath(config, entry, key) {
323
+ if (!/^(raw\/articles\/|raw\/assets\/)[A-Za-z0-9._/-]+$/.test(key) || key.split("/").some((p) => !p || p === "." || p === "..")) throw new Error("unsafe snapshot key");
324
+ return join(config.stateRoot, entry.captureId, entry.snapshotId ?? "legacy", "files", key);
325
+ }
326
+ async function durableFile(root, path, bytes) {
327
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
328
+ const f = await open2(path, "wx", 384);
329
+ try {
330
+ await f.writeFile(bytes);
331
+ await f.sync();
332
+ } finally {
333
+ await f.close();
334
+ }
335
+ let dir = dirname(path);
336
+ while (true) {
337
+ const handle = await open2(dir, "r");
338
+ try {
339
+ await handle.sync();
340
+ } finally {
341
+ await handle.close();
342
+ }
343
+ if (dir === root) break;
344
+ const parent = dirname(dir);
345
+ if (parent === dir) throw new Error("snapshot directory outside state root");
346
+ dir = parent;
347
+ }
348
+ }
349
+ async function sealCapture(config, entry, deps = {}) {
350
+ entry = { ...entry, snapshotId: randomBytes(8).toString("hex") };
351
+ const source = entry.source;
352
+ const inputs = /* @__PURE__ */ new Map();
353
+ const article = await readRegular(config.root, source, config.maxArticleBytes);
354
+ inputs.set(source, article);
355
+ let sourceUrl;
356
+ const fm = article.toString("utf8").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
357
+ if (fm) {
358
+ const metadata = yaml.load(fm[1]);
359
+ const url = metadata?.source_url ?? metadata?.source;
360
+ if (typeof url === "string") sourceUrl = url;
361
+ }
362
+ const localized = await localizeCapture(article.toString("utf8"), { config, sourceUrl, outboxRoot: config.root, captureId: entry.captureId }, {
363
+ ...deps,
364
+ readLocal: async (path) => {
365
+ const bytes = await readRegular(config.root, path, config.maxImageBytes);
366
+ inputs.set(path, bytes);
367
+ return bytes;
368
+ }
369
+ });
370
+ await (deps.delay ?? sleep)(config.stableHashMs);
371
+ for (const [path, bytes] of inputs) {
372
+ const next = await readRegular(config.root, path, path === source ? config.maxArticleBytes : config.maxImageBytes);
373
+ if (sha256Hex(bytes) !== sha256Hex(next)) throw new UnstableCapture("capture changed before seal");
374
+ }
375
+ const finalArticle = Buffer.from(localized.markdown);
376
+ if (finalArticle.length > config.maxArticleBytes) throw new Error("localized article too large");
377
+ const objects = new Map(localized.assets.map((a) => [a.captureRel, a.bytes]));
378
+ objects.set(`raw/assets/${entry.captureId}/note.md`, Buffer.from(localized.indexMarkdown));
379
+ objects.set(entry.articleKey, finalArticle);
380
+ if ([...objects.values()].reduce((n, b) => n + b.length, 0) > config.maxBundleBytes) throw new Error("sealed bundle too large");
381
+ await durableFile(config.stateRoot, join(config.stateRoot, entry.captureId, entry.snapshotId, "original.md"), article);
382
+ for (const [key, bytes] of objects) await durableFile(config.stateRoot, snapshotPath(config, entry, key), bytes);
383
+ return { ...entry, sourceSha256: sha256Hex(article), assetKeys: [...objects.keys()].filter((k) => k !== entry.articleKey), sha256: Object.fromEntries([...objects].map(([k, b]) => [k, sha256Hex(b)])), sizes: Object.fromEntries([...objects].map(([k, b]) => [k, b.length])), receipts: {}, state: "sealed", reason: void 0, attempts: 0, retryAt: void 0, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
384
+ }
385
+ function admittedArticle(root, path) {
386
+ const rel = relative3(root, path).replaceAll("\\", "/");
387
+ if (!rel.startsWith("raw/articles/") || !rel.endsWith(".md")) return false;
388
+ return rel.split("/").every((part) => part !== "." && part !== ".." && !part.startsWith(".") && !/[\x00-\x1f]/.test(part) && !part.endsWith("~"));
389
+ }
390
+
391
+ // src/clip-outbox/watcher.ts
392
+ var RESCAN_FLAGS = 1 | 2 | 4 | 8 | 32 | 64 | 128 | 131072;
393
+ var nativeEvents = async (root, onEvent) => {
394
+ if (process.platform !== "darwin") throw new Error("clip-outbox watch requires macOS FSEvents");
395
+ const fsevents = createRequire(import.meta.url)("fsevents");
396
+ return fsevents.watch(root, onEvent);
397
+ };
398
+ async function workerLock(root) {
399
+ const path = join2(root, ".skillwiki-clip-worker.lock");
400
+ const owner = `owner-${process.pid}-${randomBytes2(12).toString("hex")}`;
401
+ for (let attempt = 0; attempt < 3; attempt++) {
402
+ try {
403
+ await mkdir2(path, { mode: 448 });
404
+ await writeFile(join2(path, owner), "", { mode: 384, flag: "wx" });
405
+ return async () => {
406
+ await unlink(join2(path, owner));
407
+ await rmdir(path);
408
+ };
409
+ } catch (e) {
410
+ if (e.code !== "EEXIST") throw e;
411
+ const files = await readdir(path);
412
+ if (files.length !== 1 || !/^owner-[0-9]+-[a-f0-9]+$/.test(files[0])) throw new Error("invalid worker lock");
413
+ const pid = Number(files[0].split("-")[1]);
414
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("invalid worker lock");
415
+ try {
416
+ process.kill(pid, 0);
417
+ throw new Error("clip worker already running");
418
+ } catch (error) {
419
+ if (error.code !== "ESRCH") throw error;
420
+ }
421
+ try {
422
+ await unlink(join2(path, files[0]));
423
+ await rmdir(path);
424
+ } catch (error) {
425
+ if (!["ENOENT", "ENOTEMPTY"].includes(error.code ?? "")) throw error;
426
+ }
427
+ }
428
+ }
429
+ throw new Error("clip worker lock unavailable");
430
+ }
431
+ async function startClipWorker(config, deps) {
432
+ const release = await workerLock(config.root);
433
+ let stopEvents;
434
+ let entries = [];
435
+ const timers = /* @__PURE__ */ new Map();
436
+ const queued = /* @__PURE__ */ new Set();
437
+ let chain = Promise.resolve();
438
+ let stopped = false;
439
+ let fallback;
440
+ const save = async (entry) => {
441
+ entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
442
+ entries = upsertJournal(entries, entry);
443
+ await writeJournal(config.stateRoot, entries);
444
+ };
445
+ const submit = (fn) => {
446
+ chain = chain.then(async () => {
447
+ if (!stopped) await fn();
448
+ }).catch(() => {
449
+ stopped = true;
450
+ deps.onError?.("WORKER_STOPPED");
451
+ });
452
+ };
453
+ const schedule = (path, delay = config.debounceMs) => {
454
+ if (stopped) return;
455
+ clearTimeout(timers.get(path));
456
+ timers.set(path, setTimeout(() => {
457
+ timers.delete(path);
458
+ if (queued.has(path)) return;
459
+ queued.add(path);
460
+ submit(async () => {
461
+ queued.delete(path);
462
+ await processPath(path);
463
+ });
464
+ }, Math.max(0, delay)));
465
+ };
466
+ const processPath = async (path) => {
467
+ if (!admittedArticle(config.root, path)) return;
468
+ let entry = entries.find((e) => e.source === path);
469
+ if (entry?.state === "conflict") return;
470
+ if (!entry) {
471
+ const captureId = randomBytes2(16).toString("hex");
472
+ const title = basename(path, ".md").replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 100);
473
+ entry = { captureId, source: path, articleKey: `raw/articles/${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${title}-${captureId}.md`, assetKeys: [], sha256: {}, state: "discovered" };
474
+ await save(entry);
475
+ }
476
+ if (entry.sourceSha256) {
477
+ try {
478
+ const input = await readRegular(config.root, path, config.maxArticleBytes);
479
+ if (sha256Hex(input) !== entry.sourceSha256 && entry.reason !== "SEALED_SOURCE_CHANGED") {
480
+ entry.reason = "SEALED_SOURCE_CHANGED";
481
+ await save(entry);
482
+ }
483
+ } catch {
484
+ if (entry.reason !== "SEALED_SOURCE_UNAVAILABLE") {
485
+ entry.reason = "SEALED_SOURCE_UNAVAILABLE";
486
+ await save(entry);
487
+ }
488
+ }
489
+ }
490
+ if (entry.state === "central-visible" || entry.state === "mirrored") return;
491
+ if (entry.state === "held" && entry.sourceSha256) return;
492
+ await deps.validate?.();
493
+ try {
494
+ if (!entry.sourceSha256) {
495
+ entry.state = "preparing";
496
+ await save(entry);
497
+ entry = await (deps.seal ?? sealCapture)(config, entry);
498
+ await save(entry);
499
+ }
500
+ await deps.validate?.();
501
+ if (entry.state !== "notify-pending" && entry.state !== "article-verified") {
502
+ for (const key of [...entry.assetKeys, entry.articleKey]) {
503
+ if (entry.receipts?.[key]) continue;
504
+ const body = await readRegular(join2(config.stateRoot, entry.captureId), snapshotPath(config, entry, key), config.maxBundleBytes);
505
+ if (sha256Hex(body) !== entry.sha256[key] || body.length !== entry.sizes?.[key]) throw new Error("SNAPSHOT_CHANGED");
506
+ await deps.validate?.();
507
+ const receipt = await deps.put(key, body);
508
+ if (receipt.status === "conflict") {
509
+ entry.state = "conflict";
510
+ entry.reason = "REMOTE_CONFLICT";
511
+ await save(entry);
512
+ return;
513
+ }
514
+ entry.receipts = { ...entry.receipts, [key]: receipt.status };
515
+ if (key === entry.articleKey) entry.state = "article-verified";
516
+ else if (entry.assetKeys.every((k) => entry.receipts?.[k])) entry.state = "assets-verified";
517
+ await save(entry);
518
+ }
519
+ }
520
+ entry.state = "notify-pending";
521
+ await save(entry);
522
+ const result = await deps.ready(buildReadyHint(config.vaultId, [...entry.assetKeys, entry.articleKey].map((key) => ({ path: key, sha256: entry.sha256[key] }))));
523
+ if (!result.ok) {
524
+ if ([400, 401, 403, 404, 409, 413].includes(result.status ?? 0)) {
525
+ entry.state = "held";
526
+ entry.reason = `READY_HTTP_${result.status}`;
527
+ await save(entry);
528
+ return;
529
+ }
530
+ throw new Error("READY_RETRY");
531
+ }
532
+ entry.state = "central-visible";
533
+ entry.attempts = 0;
534
+ entry.retryAt = void 0;
535
+ await save(entry);
536
+ } catch (e) {
537
+ entry.attempts = (entry.attempts ?? 0) + 1;
538
+ const status = e.$metadata?.httpStatusCode;
539
+ const unstable = e instanceof UnstableCapture;
540
+ const persistent = [400, 401, 403].includes(status ?? 0) || e instanceof Error && /SNAPSHOT_CHANGED|TOMBSTONE_HOLD|DELETE_INTENT_UNAVAILABLE/.test(e.message);
541
+ entry.reason = unstable ? "INPUT_UNSTABLE" : persistent ? "DELIVERY_HELD" : entry.sourceSha256 ? "DELIVERY_RETRY" : "PREPARATION_HELD";
542
+ if (!entry.sourceSha256 || persistent) entry.state = "held";
543
+ const delay = Math.min(6e4, 1e3 * 2 ** Math.min(entry.attempts - 1, 6)) * (0.8 + (deps.random ?? Math.random)() * 0.2);
544
+ entry.retryAt = persistent ? void 0 : Date.now() + delay;
545
+ await save(entry);
546
+ if (!persistent) schedule(path, unstable ? config.debounceMs : delay);
547
+ }
548
+ };
549
+ const scan = async () => {
550
+ await deps.validate?.();
551
+ const walk = async (dir) => {
552
+ let children;
553
+ try {
554
+ children = await readdir(dir, { withFileTypes: true });
555
+ } catch (e) {
556
+ if (e.code === "ENOENT") return;
557
+ throw e;
558
+ }
559
+ for (const child of children) {
560
+ const path = join2(dir, child.name);
561
+ if (child.isDirectory() && !child.name.startsWith(".")) await walk(path);
562
+ else if (child.isFile() && admittedArticle(config.root, path)) schedule(path);
563
+ }
564
+ };
565
+ await walk(join2(config.root, "raw/articles"));
566
+ };
567
+ let scanQueued = false;
568
+ const requestScan = () => {
569
+ if (scanQueued) return;
570
+ scanQueued = true;
571
+ submit(async () => {
572
+ scanQueued = false;
573
+ await scan();
574
+ });
575
+ };
576
+ try {
577
+ entries = await readJournal(config.stateRoot);
578
+ stopEvents = await (deps.events ?? nativeEvents)(config.root, (path, flags) => {
579
+ if (flags & RESCAN_FLAGS) {
580
+ requestScan();
581
+ return;
582
+ }
583
+ const absolute = resolve3(path);
584
+ if (admittedArticle(config.root, absolute)) schedule(absolute);
585
+ else if (absolute.startsWith(join2(config.root, "raw/assets") + "/")) {
586
+ for (const entry of entries) if (!entry.sourceSha256 && entry.source) schedule(entry.source);
587
+ }
588
+ });
589
+ for (const entry of entries) if (entry.source) schedule(entry.source, Math.max(0, (entry.retryAt ?? Date.now()) - Date.now()));
590
+ await scan();
591
+ if (config.pollFallbackEnabled) fallback = setInterval(requestScan, config.reconcileSeconds * 1e3);
592
+ return {
593
+ entries: () => structuredClone(entries),
594
+ stop: async () => {
595
+ stopped = true;
596
+ for (const timer of timers.values()) clearTimeout(timer);
597
+ timers.clear();
598
+ if (fallback) clearInterval(fallback);
599
+ await stopEvents?.();
600
+ await chain;
601
+ await release();
602
+ }
603
+ };
604
+ } catch (e) {
605
+ stopped = true;
606
+ for (const timer of timers.values()) clearTimeout(timer);
607
+ await stopEvents?.();
608
+ await release();
609
+ throw e;
610
+ }
611
+ }
612
+ export {
613
+ RESCAN_FLAGS,
614
+ nativeEvents,
615
+ startClipWorker
616
+ };