skillwiki 0.10.110 → 0.10.112

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 (45) hide show
  1. package/dist/chunk-25OXNFDV.js +133 -0
  2. package/dist/{chunk-R2DJ7IJ5.js → chunk-7POLZOFY.js} +44 -375
  3. package/dist/chunk-ABRYBO4T.js +275 -0
  4. package/dist/{chunk-6X6PPQNI.js → chunk-APKDTEVV.js} +280 -446
  5. package/dist/{chunk-5MV5SSRM.js → chunk-CKQPUHM2.js} +5 -3
  6. package/dist/chunk-DOHWNPAZ.js +60 -0
  7. package/dist/chunk-EE7W3GLA.js +55 -0
  8. package/dist/chunk-FCA3RRNF.js +60 -0
  9. package/dist/{chunk-KX5Y3GSX.js → chunk-IR2DI4Y5.js} +5 -3
  10. package/dist/chunk-K3M6OSUW.js +77 -0
  11. package/dist/{chunk-WPJHDRTP.js → chunk-KJG2CSYD.js} +12 -57
  12. package/dist/chunk-LOVLZNLX.js +111 -0
  13. package/dist/chunk-OSL4J7GF.js +32 -0
  14. package/dist/{chunk-QU2CKXM4.js → chunk-Q6EZF37H.js} +91 -199
  15. package/dist/chunk-QESNTTVY.js +50 -0
  16. package/dist/{chunk-NA3TZ4JN.js → chunk-RR6VDWSI.js} +8 -6
  17. package/dist/chunk-SKOLE6US.js +126 -0
  18. package/dist/chunk-TKX6PSWZ.js +344 -0
  19. package/dist/chunk-ZRGZBAVS.js +98 -0
  20. package/dist/cli.js +359 -305
  21. package/dist/{index-projection-6XGESSHV.js → index-projection-UNZG5UJZ.js} +5 -3
  22. package/dist/journal-5IW3TZ5F.js +13 -0
  23. package/dist/{managed-write-preflight-JPOBVVSU.js → managed-write-preflight-L7IRQ76C.js} +7 -4
  24. package/dist/mcp-auth-env-X5MI5IX6.js +25 -0
  25. package/dist/migrate-RO4BJP5T.js +22 -0
  26. package/dist/mirror-H5BTBAON.js +151 -0
  27. package/dist/ready-S7F53Q2D.js +9 -0
  28. package/dist/runtime-VMFYW2K3.js +20 -0
  29. package/dist/skillwiki-mcp.js +14 -7
  30. package/dist/sources-AOYP6ROO.js +10 -0
  31. package/dist/upload-HTE62TOV.js +14 -0
  32. package/dist/watcher-MGHQD3UI.js +526 -0
  33. package/docs/clip-outbox.md +119 -0
  34. package/package.json +5 -1
  35. package/skills/.claude-plugin/plugin.json +1 -1
  36. package/skills/.codex-plugin/plugin.json +1 -1
  37. package/skills/.cursor-plugin/plugin.json +1 -1
  38. package/skills/package.json +1 -1
  39. package/skills/skills/using-skillwiki/SKILL.md +1 -1
  40. package/skills/skills/wiki-init/SKILL.md +3 -0
  41. package/skills/using-skillwiki/SKILL.md +1 -1
  42. package/skills/wiki-init/SKILL.md +3 -0
  43. package/templates/launchd/com.karlchow.skillwiki-clip-outbox.plist +12 -13
  44. package/templates/launchd/com.karlchow.skillwiki-mirror-sse.plist +20 -0
  45. package/dist/sources-CLOF5VAT.js +0 -9
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ readJournal,
4
+ upsertJournal,
5
+ writeJournal
6
+ } from "./chunk-FCA3RRNF.js";
7
+ import {
8
+ buildReadyHint
9
+ } from "./chunk-OSL4J7GF.js";
10
+ import {
11
+ readRegular
12
+ } from "./chunk-K3M6OSUW.js";
13
+ import {
14
+ sha256Hex
15
+ } from "./chunk-ZRGZBAVS.js";
16
+ import "./chunk-QESNTTVY.js";
17
+ import "./chunk-EE7W3GLA.js";
18
+ import {
19
+ assertPublicHttpsUrl
20
+ } from "./chunk-LOVLZNLX.js";
21
+ import "./chunk-7POLZOFY.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 resolve2 } from "path";
28
+
29
+ // src/clip-outbox/seal.ts
30
+ import { randomBytes } from "crypto";
31
+ import { mkdir, open } from "fs/promises";
32
+ import { dirname, join, relative as relative2 } 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/localize.ts
112
+ var LocalizeError = class extends Error {
113
+ constructor(message, code = "LOCALIZE_HELD") {
114
+ super(message);
115
+ this.code = code;
116
+ this.name = "LocalizeError";
117
+ }
118
+ code;
119
+ };
120
+ function isInsideDir(root, candidate) {
121
+ const rel = relative(resolve(root), resolve(candidate));
122
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
123
+ }
124
+ async function localizeCapture(markdown, opts, deps = {}) {
125
+ const captureId = opts.captureId ?? randomUUID().replace(/-/g, "").slice(0, 16);
126
+ const refs = parseImageDependencies(markdown, opts.sourceUrl);
127
+ if (refs.length > opts.config.maxImages) {
128
+ throw new LocalizeError(`too many images: ${refs.length}`);
129
+ }
130
+ const fetchImpl = deps.fetch ?? fetch;
131
+ const now = deps.now ?? Date.now;
132
+ const assets = [];
133
+ const seenUrl = /* @__PURE__ */ new Map();
134
+ const replacements = /* @__PURE__ */ new Map();
135
+ const deadline = now() + opts.config.localizeTimeoutMs;
136
+ let bundleBytes = Buffer.byteLength(markdown);
137
+ for (const ref of refs) {
138
+ if (now() > deadline) throw new LocalizeError("localize deadline exceeded");
139
+ let asset = seenUrl.get(ref.target);
140
+ if (!asset) {
141
+ if (ref.local) {
142
+ const rel = localAssetRelPath(ref.target);
143
+ if (!rel) throw new LocalizeError(`unsafe or missing local image: ${ref.target}`);
144
+ const abs = resolve(opts.outboxRoot, rel);
145
+ if (!isInsideDir(resolve(opts.outboxRoot, "raw/assets"), abs)) {
146
+ throw new LocalizeError("local image outside raw/assets");
147
+ }
148
+ const reader = deps.readLocal ?? ((p) => readFile(p));
149
+ const bytes = await reader(abs);
150
+ asset = sealAsset(captureId, bytes, ref.target, opts.config);
151
+ asset.captureRel = rel;
152
+ } else {
153
+ const bytes = await downloadImage(ref.target, opts.config, fetchImpl, deps.lookup);
154
+ asset = sealAsset(captureId, bytes, ref.target, opts.config);
155
+ }
156
+ bundleBytes += asset.bytes.length;
157
+ if (bundleBytes > opts.config.maxBundleBytes) throw new LocalizeError("bundle too large");
158
+ assets.push(asset);
159
+ seenUrl.set(ref.target, asset);
160
+ }
161
+ replacements.set(ref.raw, `![[${asset.captureRel}]]`);
162
+ }
163
+ let rewritten = markdown;
164
+ for (const [raw, embed] of replacements) {
165
+ rewritten = rewritten.replaceAll(raw, embed);
166
+ }
167
+ const indexMarkdown = [
168
+ "---",
169
+ `title: clip assets ${captureId}`,
170
+ "---",
171
+ "",
172
+ ...assets.map((a) => `- ![[${a.captureRel}]]`),
173
+ ""
174
+ ].join("\n");
175
+ bundleBytes += Buffer.byteLength(indexMarkdown);
176
+ if (bundleBytes > opts.config.maxBundleBytes) throw new LocalizeError("bundle too large");
177
+ return { captureId, markdown: rewritten, assets, indexMarkdown };
178
+ }
179
+ function sealAsset(captureId, bytes, source, config) {
180
+ if (bytes.length > config.maxImageBytes) throw new LocalizeError("image too large");
181
+ const kind = sniffImageKind(bytes);
182
+ if (!kind || !config.imageTypes.includes(kind)) throw new LocalizeError("unsupported image type");
183
+ const pixels = pngPixelCount(bytes);
184
+ if (pixels !== void 0 && pixels > config.maxPixels) throw new LocalizeError("image pixel budget exceeded");
185
+ const sha256 = sha256Hex(bytes);
186
+ return {
187
+ captureRel: `raw/assets/${captureId}/${sha256}.${extensionForKind(kind)}`,
188
+ sha256,
189
+ bytes,
190
+ kind,
191
+ source
192
+ };
193
+ }
194
+ async function downloadImage(href, config, fetchImpl, lookup) {
195
+ let current = href;
196
+ for (let hop = 0; hop <= config.maxRedirects; hop++) {
197
+ const guarded = await assertPublicHttpsUrl(current, lookup);
198
+ if (!guarded.ok) {
199
+ throw new LocalizeError(
200
+ hop === 0 ? `blocked image URL: ${guarded.reason}` : `redirect blocked: ${guarded.reason}`
201
+ );
202
+ }
203
+ const res = await fetchImpl(current, {
204
+ method: "GET",
205
+ redirect: "manual",
206
+ headers: { Accept: "image/*" },
207
+ signal: AbortSignal.timeout(config.requestTimeoutMs)
208
+ });
209
+ if (res.status >= 300 && res.status < 400) {
210
+ const loc = res.headers.get("location");
211
+ if (!loc) throw new LocalizeError("redirect without location");
212
+ current = new URL(loc, current).toString();
213
+ continue;
214
+ }
215
+ if (!res.ok || !res.body) throw new LocalizeError(`image HTTP ${res.status}`);
216
+ const chunks = [];
217
+ let total = 0;
218
+ for await (const chunk of res.body) {
219
+ total += chunk.byteLength;
220
+ if (total > config.maxImageBytes) throw new LocalizeError("image too large");
221
+ chunks.push(Buffer.from(chunk));
222
+ }
223
+ return Buffer.concat(chunks);
224
+ }
225
+ throw new LocalizeError("too many redirects");
226
+ }
227
+
228
+ // src/clip-outbox/seal.ts
229
+ var UnstableCapture = class extends Error {
230
+ };
231
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
232
+ function snapshotPath(config, entry, key) {
233
+ if (!/^(raw\/articles\/|raw\/assets\/)[A-Za-z0-9._/-]+$/.test(key) || key.split("/").some((p) => !p || p === "." || p === "..")) throw new Error("unsafe snapshot key");
234
+ return join(config.stateRoot, entry.captureId, entry.snapshotId ?? "legacy", "files", key);
235
+ }
236
+ async function durableFile(root, path, bytes) {
237
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
238
+ const f = await open(path, "wx", 384);
239
+ try {
240
+ await f.writeFile(bytes);
241
+ await f.sync();
242
+ } finally {
243
+ await f.close();
244
+ }
245
+ let dir = dirname(path);
246
+ while (true) {
247
+ const handle = await open(dir, "r");
248
+ try {
249
+ await handle.sync();
250
+ } finally {
251
+ await handle.close();
252
+ }
253
+ if (dir === root) break;
254
+ const parent = dirname(dir);
255
+ if (parent === dir) throw new Error("snapshot directory outside state root");
256
+ dir = parent;
257
+ }
258
+ }
259
+ async function sealCapture(config, entry, deps = {}) {
260
+ entry = { ...entry, snapshotId: randomBytes(8).toString("hex") };
261
+ const source = entry.source;
262
+ const inputs = /* @__PURE__ */ new Map();
263
+ const article = await readRegular(config.root, source, config.maxArticleBytes);
264
+ inputs.set(source, article);
265
+ let sourceUrl;
266
+ const fm = article.toString("utf8").match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
267
+ if (fm) {
268
+ const metadata = yaml.load(fm[1]);
269
+ const url = metadata?.source_url ?? metadata?.source;
270
+ if (typeof url === "string") sourceUrl = url;
271
+ }
272
+ const localized = await localizeCapture(article.toString("utf8"), { config, sourceUrl, outboxRoot: config.root, captureId: entry.captureId }, {
273
+ ...deps,
274
+ readLocal: async (path) => {
275
+ const bytes = await readRegular(config.root, path, config.maxImageBytes);
276
+ inputs.set(path, bytes);
277
+ return bytes;
278
+ }
279
+ });
280
+ await (deps.delay ?? sleep)(config.stableHashMs);
281
+ for (const [path, bytes] of inputs) {
282
+ const next = await readRegular(config.root, path, path === source ? config.maxArticleBytes : config.maxImageBytes);
283
+ if (sha256Hex(bytes) !== sha256Hex(next)) throw new UnstableCapture("capture changed before seal");
284
+ }
285
+ const finalArticle = Buffer.from(localized.markdown);
286
+ if (finalArticle.length > config.maxArticleBytes) throw new Error("localized article too large");
287
+ const objects = new Map(localized.assets.map((a) => [a.captureRel, a.bytes]));
288
+ objects.set(`raw/assets/${entry.captureId}/note.md`, Buffer.from(localized.indexMarkdown));
289
+ objects.set(entry.articleKey, finalArticle);
290
+ if ([...objects.values()].reduce((n, b) => n + b.length, 0) > config.maxBundleBytes) throw new Error("sealed bundle too large");
291
+ await durableFile(config.stateRoot, join(config.stateRoot, entry.captureId, entry.snapshotId, "original.md"), article);
292
+ for (const [key, bytes] of objects) await durableFile(config.stateRoot, snapshotPath(config, entry, key), bytes);
293
+ 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() };
294
+ }
295
+ function admittedArticle(root, path) {
296
+ const rel = relative2(root, path).replaceAll("\\", "/");
297
+ if (!rel.startsWith("raw/articles/") || !rel.endsWith(".md")) return false;
298
+ return rel.split("/").every((part) => part !== "." && part !== ".." && !part.startsWith(".") && !/[\x00-\x1f]/.test(part) && !part.endsWith("~"));
299
+ }
300
+
301
+ // src/clip-outbox/watcher.ts
302
+ var RESCAN_FLAGS = 1 | 2 | 4 | 8 | 32 | 64 | 128 | 131072;
303
+ var nativeEvents = async (root, onEvent) => {
304
+ if (process.platform !== "darwin") throw new Error("clip-outbox watch requires macOS FSEvents");
305
+ const fsevents = createRequire(import.meta.url)("fsevents");
306
+ return fsevents.watch(root, onEvent);
307
+ };
308
+ async function workerLock(root) {
309
+ const path = join2(root, ".skillwiki-clip-worker.lock");
310
+ const owner = `owner-${process.pid}-${randomBytes2(12).toString("hex")}`;
311
+ for (let attempt = 0; attempt < 3; attempt++) {
312
+ try {
313
+ await mkdir2(path, { mode: 448 });
314
+ await writeFile(join2(path, owner), "", { mode: 384, flag: "wx" });
315
+ return async () => {
316
+ await unlink(join2(path, owner));
317
+ await rmdir(path);
318
+ };
319
+ } catch (e) {
320
+ if (e.code !== "EEXIST") throw e;
321
+ const files = await readdir(path);
322
+ if (files.length !== 1 || !/^owner-[0-9]+-[a-f0-9]+$/.test(files[0])) throw new Error("invalid worker lock");
323
+ const pid = Number(files[0].split("-")[1]);
324
+ if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("invalid worker lock");
325
+ try {
326
+ process.kill(pid, 0);
327
+ throw new Error("clip worker already running");
328
+ } catch (error) {
329
+ if (error.code !== "ESRCH") throw error;
330
+ }
331
+ try {
332
+ await unlink(join2(path, files[0]));
333
+ await rmdir(path);
334
+ } catch (error) {
335
+ if (!["ENOENT", "ENOTEMPTY"].includes(error.code ?? "")) throw error;
336
+ }
337
+ }
338
+ }
339
+ throw new Error("clip worker lock unavailable");
340
+ }
341
+ async function startClipWorker(config, deps) {
342
+ const release = await workerLock(config.root);
343
+ let stopEvents;
344
+ let entries = [];
345
+ const timers = /* @__PURE__ */ new Map();
346
+ const queued = /* @__PURE__ */ new Set();
347
+ let chain = Promise.resolve();
348
+ let stopped = false;
349
+ let fallback;
350
+ const save = async (entry) => {
351
+ entry.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
352
+ entries = upsertJournal(entries, entry);
353
+ await writeJournal(config.stateRoot, entries);
354
+ };
355
+ const submit = (fn) => {
356
+ chain = chain.then(async () => {
357
+ if (!stopped) await fn();
358
+ }).catch(() => {
359
+ stopped = true;
360
+ deps.onError?.("WORKER_STOPPED");
361
+ });
362
+ };
363
+ const schedule = (path, delay = config.debounceMs) => {
364
+ if (stopped) return;
365
+ clearTimeout(timers.get(path));
366
+ timers.set(path, setTimeout(() => {
367
+ timers.delete(path);
368
+ if (queued.has(path)) return;
369
+ queued.add(path);
370
+ submit(async () => {
371
+ queued.delete(path);
372
+ await processPath(path);
373
+ });
374
+ }, Math.max(0, delay)));
375
+ };
376
+ const processPath = async (path) => {
377
+ if (!admittedArticle(config.root, path)) return;
378
+ let entry = entries.find((e) => e.source === path);
379
+ if (entry?.state === "conflict") return;
380
+ if (!entry) {
381
+ const captureId = randomBytes2(16).toString("hex");
382
+ const title = basename(path, ".md").replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 100);
383
+ entry = { captureId, source: path, articleKey: `raw/articles/${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${title}-${captureId}.md`, assetKeys: [], sha256: {}, state: "discovered" };
384
+ await save(entry);
385
+ }
386
+ if (entry.sourceSha256) {
387
+ try {
388
+ const input = await readRegular(config.root, path, config.maxArticleBytes);
389
+ if (sha256Hex(input) !== entry.sourceSha256 && entry.reason !== "SEALED_SOURCE_CHANGED") {
390
+ entry.reason = "SEALED_SOURCE_CHANGED";
391
+ await save(entry);
392
+ }
393
+ } catch {
394
+ if (entry.reason !== "SEALED_SOURCE_UNAVAILABLE") {
395
+ entry.reason = "SEALED_SOURCE_UNAVAILABLE";
396
+ await save(entry);
397
+ }
398
+ }
399
+ }
400
+ if (entry.state === "central-visible" || entry.state === "mirrored") return;
401
+ if (entry.state === "held" && entry.sourceSha256) return;
402
+ await deps.validate?.();
403
+ try {
404
+ if (!entry.sourceSha256) {
405
+ entry.state = "preparing";
406
+ await save(entry);
407
+ entry = await (deps.seal ?? sealCapture)(config, entry);
408
+ await save(entry);
409
+ }
410
+ await deps.validate?.();
411
+ if (entry.state !== "notify-pending" && entry.state !== "article-verified") {
412
+ for (const key of [...entry.assetKeys, entry.articleKey]) {
413
+ if (entry.receipts?.[key]) continue;
414
+ const body = await readRegular(join2(config.stateRoot, entry.captureId), snapshotPath(config, entry, key), config.maxBundleBytes);
415
+ if (sha256Hex(body) !== entry.sha256[key] || body.length !== entry.sizes?.[key]) throw new Error("SNAPSHOT_CHANGED");
416
+ await deps.validate?.();
417
+ const receipt = await deps.put(key, body);
418
+ if (receipt.status === "conflict") {
419
+ entry.state = "conflict";
420
+ entry.reason = "REMOTE_CONFLICT";
421
+ await save(entry);
422
+ return;
423
+ }
424
+ entry.receipts = { ...entry.receipts, [key]: receipt.status };
425
+ if (key === entry.articleKey) entry.state = "article-verified";
426
+ else if (entry.assetKeys.every((k) => entry.receipts?.[k])) entry.state = "assets-verified";
427
+ await save(entry);
428
+ }
429
+ }
430
+ entry.state = "notify-pending";
431
+ await save(entry);
432
+ const result = await deps.ready(buildReadyHint(config.vaultId, [...entry.assetKeys, entry.articleKey].map((key) => ({ path: key, sha256: entry.sha256[key] }))));
433
+ if (!result.ok) {
434
+ if ([400, 401, 403, 404, 409, 413].includes(result.status ?? 0)) {
435
+ entry.state = "held";
436
+ entry.reason = `READY_HTTP_${result.status}`;
437
+ await save(entry);
438
+ return;
439
+ }
440
+ throw new Error("READY_RETRY");
441
+ }
442
+ entry.state = "central-visible";
443
+ entry.attempts = 0;
444
+ entry.retryAt = void 0;
445
+ await save(entry);
446
+ } catch (e) {
447
+ entry.attempts = (entry.attempts ?? 0) + 1;
448
+ const status = e.$metadata?.httpStatusCode;
449
+ const unstable = e instanceof UnstableCapture;
450
+ const persistent = [400, 401, 403].includes(status ?? 0) || e instanceof Error && /SNAPSHOT_CHANGED|TOMBSTONE_HOLD|DELETE_INTENT_UNAVAILABLE/.test(e.message);
451
+ entry.reason = unstable ? "INPUT_UNSTABLE" : persistent ? "DELIVERY_HELD" : entry.sourceSha256 ? "DELIVERY_RETRY" : "PREPARATION_HELD";
452
+ if (!entry.sourceSha256 || persistent) entry.state = "held";
453
+ const delay = Math.min(6e4, 1e3 * 2 ** Math.min(entry.attempts - 1, 6)) * (0.8 + (deps.random ?? Math.random)() * 0.2);
454
+ entry.retryAt = persistent ? void 0 : Date.now() + delay;
455
+ await save(entry);
456
+ if (!persistent) schedule(path, unstable ? config.debounceMs : delay);
457
+ }
458
+ };
459
+ const scan = async () => {
460
+ await deps.validate?.();
461
+ const walk = async (dir) => {
462
+ let children;
463
+ try {
464
+ children = await readdir(dir, { withFileTypes: true });
465
+ } catch (e) {
466
+ if (e.code === "ENOENT") return;
467
+ throw e;
468
+ }
469
+ for (const child of children) {
470
+ const path = join2(dir, child.name);
471
+ if (child.isDirectory() && !child.name.startsWith(".")) await walk(path);
472
+ else if (child.isFile() && admittedArticle(config.root, path)) schedule(path);
473
+ }
474
+ };
475
+ await walk(join2(config.root, "raw/articles"));
476
+ };
477
+ let scanQueued = false;
478
+ const requestScan = () => {
479
+ if (scanQueued) return;
480
+ scanQueued = true;
481
+ submit(async () => {
482
+ scanQueued = false;
483
+ await scan();
484
+ });
485
+ };
486
+ try {
487
+ entries = await readJournal(config.stateRoot);
488
+ stopEvents = await (deps.events ?? nativeEvents)(config.root, (path, flags) => {
489
+ if (flags & RESCAN_FLAGS) {
490
+ requestScan();
491
+ return;
492
+ }
493
+ const absolute = resolve2(path);
494
+ if (admittedArticle(config.root, absolute)) schedule(absolute);
495
+ else if (absolute.startsWith(join2(config.root, "raw/assets") + "/")) {
496
+ for (const entry of entries) if (!entry.sourceSha256 && entry.source) schedule(entry.source);
497
+ }
498
+ });
499
+ for (const entry of entries) if (entry.source) schedule(entry.source, Math.max(0, (entry.retryAt ?? Date.now()) - Date.now()));
500
+ await scan();
501
+ if (config.pollFallbackEnabled) fallback = setInterval(requestScan, config.reconcileSeconds * 1e3);
502
+ return {
503
+ entries: () => structuredClone(entries),
504
+ stop: async () => {
505
+ stopped = true;
506
+ for (const timer of timers.values()) clearTimeout(timer);
507
+ timers.clear();
508
+ if (fallback) clearInterval(fallback);
509
+ await stopEvents?.();
510
+ await chain;
511
+ await release();
512
+ }
513
+ };
514
+ } catch (e) {
515
+ stopped = true;
516
+ for (const timer of timers.values()) clearTimeout(timer);
517
+ await stopEvents?.();
518
+ await release();
519
+ throw e;
520
+ }
521
+ }
522
+ export {
523
+ RESCAN_FLAGS,
524
+ nativeEvents,
525
+ startClipWorker
526
+ };
@@ -0,0 +1,119 @@
1
+ # Clip-outbox worker and attended migration
2
+
3
+ The channel remains disabled by default. Installing or updating npm ships the
4
+ worker and launchd template; it does not change `clip_outbox.enabled`, load a
5
+ job, import browser settings, or enable wiki-push. `skillwiki doctor` reports
6
+ **clip channel not configured** until attended setup is complete.
7
+
8
+ After upgrading the CLI, refresh the host plist explicitly:
9
+
10
+ ```sh
11
+ skillwiki clip-outbox install-or-migrate
12
+ skillwiki doctor
13
+ ```
14
+
15
+ This writes or refreshes
16
+ `~/Library/LaunchAgents/com.karlchow.skillwiki-clip-outbox.plist` from the new
17
+ package. It preserves host configuration and does not call launchctl. Existing
18
+ customizations to this dedicated plist are replaced by the packaged template.
19
+ Its arguments use the absolute installed Node executable and npm `skillwiki`
20
+ bin symlink (including `/opt/homebrew/bin/skillwiki`); npm version upgrades keep
21
+ that bin path. WatchPaths expands the configured outbox root. KeepAlive and
22
+ RunAtLoad supervise the worker; WatchPaths only aids wakeup. The actual recursive
23
+ watch is native macOS FSEvents. If Node's installation prefix changes, rerun the
24
+ migration. Installing with `--omit=optional` omits FSEvents and the worker fails
25
+ closed instead of switching to polling.
26
+
27
+ ## Before operator enablement
28
+
29
+ 1. Create/select the dedicated `~/Obsidian/clip-outbox` Obsidian vault, with
30
+ `raw/articles` and `raw/assets`. Never select `~/wiki`. Import the existing
31
+ clipper template separately in every browser profile and confirm scratch
32
+ captures arrive in this outbox. Startup scans discover files already there;
33
+ move historical backlog elsewhere until an attended exact-file selection is
34
+ implemented. Do not copy a historical vault into the dedicated outbox.
35
+ 2. Configure the existing `clip_outbox.*` namespace: `root`, `state_root`,
36
+ `mirror_root`, `ready_url`, `s3_credential_ref`, `ready_credential_ref`, and
37
+ either an inventoried `d8_note_root` or explicit `d8_note_absent=true`.
38
+ Leave `enabled=false` throughout preparation. State lives outside both
39
+ vaults, for example `~/Library/Application Support/SkillWiki/clip-outbox`;
40
+ create its parent first. The resolver enforces disjoint roots and the fixed
41
+ `central` / `seaweed-wiki:cloud/wiki` destination. The mirror freeze marker
42
+ must exist and `vault_sync.push_enabled=false` must remain set, with the
43
+ wiki-push job unloaded.
44
+ 3. Install separately scoped credentials in the operator-owned, mode-0600 host
45
+ file `~/.skillwiki/clip-outbox-credentials.json`. It is a JSON provider map:
46
+ the name referenced by `s3_credential_ref` maps to an object with `endpoint`,
47
+ optional `region` (default `us-east-1`), `accessKeyId`, `secretAccessKey`, and
48
+ optional `sessionToken`; the name referenced by `ready_credential_ref` maps
49
+ to the separately issued file-ingress token string. References may instead
50
+ resolve from service environment variables (S3 value is the same JSON object
51
+ encoded as text). Never place values in a plist, vault page, or command line.
52
+ Launchd does not inherit an interactive shell's exports, so prefer the
53
+ protected file for unattended operation. An MCP token is rejected as the
54
+ file token, including one stored only in the host `.env` file.
55
+ 4. Provision S3 permissions for create-only article/asset writes and hash
56
+ readback, plus read-only access to `wiki/meta/delete-intents/` for the exact
57
+ path and ancestor admission checks. Missing/unknown delete-intent access
58
+ blocks publication. Prove concurrent **If-None-Match: \*** creation on the
59
+ deployed SeaweedFS endpoint: one writer wins, identical retries reuse bytes,
60
+ different bytes cannot replace them. Verify the destination binding and
61
+ independent file-ingress notifier token. Local unit tests are not deployed
62
+ conditional-create evidence.
63
+ 5. After those checks, the operator explicitly sets `clip_outbox.enabled=true`
64
+ using `skillwiki config set`, then explicitly requests loading:
65
+
66
+ ```sh
67
+ skillwiki clip-outbox install-or-migrate --load --conditional-create-proven
68
+ skillwiki doctor
69
+ skillwiki clip-outbox status
70
+ ```
71
+
72
+ `--conditional-create-proven` is an operator attestation of the deployed race
73
+ test, not a test performed by this command. It records a non-secret receipt
74
+ bound to the endpoint and destination outside the vault. Both flags and the
75
+ existing enable setting are required for loading. The foreground
76
+ `skillwiki clip-outbox watch` command requires the same valid configuration
77
+ and recorded evidence. A different endpoint invalidates the receipt.
78
+
79
+ ## Worker behavior and recovery
80
+
81
+ The worker subscribes before one startup journal/root scan. FSEvents coalescing,
82
+ directory-level events, overflow/drop/root-change flags trigger a recovery scan.
83
+ Normal file events use 500 ms quiet debounce and matching complete input hashes
84
+ 500 ms apart. It localizes images with the existing guarded helper, rechecks the
85
+ article and every referenced local binary, and seals exact snapshots outside
86
+ raw. Original input bytes are retained there too; the capture input is not
87
+ rewritten. New article keys include a date and random capture identity; existing
88
+ local asset paths are preserved. The sibling asset `note.md` is included.
89
+
90
+ Sealing enters one serial publication queue immediately. Every asset, including
91
+ the index, is conditionally created and read back for SHA-256 verification before
92
+ the article. Each verified object gets a durable journal receipt. Unknown PUT
93
+ outcomes are inspected before retry. Conflicts and auth/admission failures hold
94
+ the item; transient failures use jittered exponential backoff from about one
95
+ second to at most 60 seconds. No minute/hour timer participates in normal
96
+ publication. The optional hourly root scan is recovery only; disable it with
97
+ `clip_outbox.poll_fallback_enabled=false` for an isolated realtime rehearsal.
98
+
99
+ After article verification, the worker immediately POSTs the v0.10.110 ready
100
+ contract (`vault_id`, `files` with exact paths/hashes) to `/file-ingress/ready`.
101
+ It sends no article or binary content. A failed/unknown hint stays
102
+ `notify-pending`; restart retries that notification without uploading verified
103
+ objects again. Only a verified response accounting for every path records
104
+ `central-visible`. The worker never infers `mirrored`; confirm central pending
105
+ inventory, SSE, exact mirror hashes and image preview separately.
106
+
107
+ `clip-outbox status` lists capture identities, journal states, redacted reason
108
+ codes and attempts. Edits to an already sealed input report
109
+ `SEALED_SOURCE_CHANGED`; the sealed snapshot and published keys remain fixed.
110
+ A corrupt journal or incomplete worker lock stops startup for attended recovery.
111
+ Do not delete a journal/lock while a worker is running. Queue data and receipts
112
+ are retained indefinitely in this slice (there is no automatic pruning), which
113
+ also preserves the required seven-day minimum and all undelivered/conflicted
114
+ items. Disabling the job retains that evidence; it never deletes cloud raw.
115
+
116
+ A successful upload proves S3 storage; a successful ready adoption proves
117
+ central materialization. Mirror freshness and pending-source visibility still
118
+ require the isolated end-to-end rollout checks. Neither this migration nor the
119
+ worker unfreezes the mirror, calls wiki_capture, or runs wiki-push.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.110",
3
+ "version": "0.10.112",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillwiki": "dist/cli.js",
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "dist",
11
11
  "templates",
12
+ "docs",
12
13
  "skills",
13
14
  "README.md"
14
15
  ],
@@ -42,5 +43,8 @@
42
43
  },
43
44
  "engines": {
44
45
  "node": ">=20"
46
+ },
47
+ "optionalDependencies": {
48
+ "fsevents": "^2.3.3"
45
49
  }
46
50
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.110",
3
+ "version": "0.10.112",
4
4
  "skills": "./",
5
5
  "description": "Project-aware Karpathy-style knowledge base for Claude Code: 22 prompt-only skills (wiki-*, proj-*, using-skillwiki, skillwiki-mcp, skillwiki-connect) backed by the deterministic `skillwiki` CLI.",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillwiki",
3
- "version": "0.10.110",
3
+ "version": "0.10.112",
4
4
  "description": "Project-aware Karpathy-style knowledge base for Codex with 22 prompt-only skills backed by the deterministic skillwiki CLI.",
5
5
  "author": {
6
6
  "name": "karlorz",