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.
- package/dist/chunk-25OXNFDV.js +133 -0
- package/dist/{chunk-R2DJ7IJ5.js → chunk-7POLZOFY.js} +44 -375
- package/dist/chunk-ABRYBO4T.js +275 -0
- package/dist/{chunk-6X6PPQNI.js → chunk-APKDTEVV.js} +280 -446
- package/dist/{chunk-5MV5SSRM.js → chunk-CKQPUHM2.js} +5 -3
- package/dist/chunk-DOHWNPAZ.js +60 -0
- package/dist/chunk-EE7W3GLA.js +55 -0
- package/dist/chunk-FCA3RRNF.js +60 -0
- package/dist/{chunk-KX5Y3GSX.js → chunk-IR2DI4Y5.js} +5 -3
- package/dist/chunk-K3M6OSUW.js +77 -0
- package/dist/{chunk-WPJHDRTP.js → chunk-KJG2CSYD.js} +12 -57
- package/dist/chunk-LOVLZNLX.js +111 -0
- package/dist/chunk-OSL4J7GF.js +32 -0
- package/dist/{chunk-QU2CKXM4.js → chunk-Q6EZF37H.js} +91 -199
- package/dist/chunk-QESNTTVY.js +50 -0
- package/dist/{chunk-NA3TZ4JN.js → chunk-RR6VDWSI.js} +8 -6
- package/dist/chunk-SKOLE6US.js +126 -0
- package/dist/chunk-TKX6PSWZ.js +344 -0
- package/dist/chunk-ZRGZBAVS.js +98 -0
- package/dist/cli.js +359 -305
- package/dist/{index-projection-6XGESSHV.js → index-projection-UNZG5UJZ.js} +5 -3
- package/dist/journal-5IW3TZ5F.js +13 -0
- package/dist/{managed-write-preflight-JPOBVVSU.js → managed-write-preflight-L7IRQ76C.js} +7 -4
- package/dist/mcp-auth-env-X5MI5IX6.js +25 -0
- package/dist/migrate-RO4BJP5T.js +22 -0
- package/dist/mirror-H5BTBAON.js +151 -0
- package/dist/ready-S7F53Q2D.js +9 -0
- package/dist/runtime-VMFYW2K3.js +20 -0
- package/dist/skillwiki-mcp.js +14 -7
- package/dist/sources-AOYP6ROO.js +10 -0
- package/dist/upload-HTE62TOV.js +14 -0
- package/dist/watcher-MGHQD3UI.js +526 -0
- package/docs/clip-outbox.md +119 -0
- package/package.json +5 -1
- package/skills/.claude-plugin/plugin.json +1 -1
- package/skills/.codex-plugin/plugin.json +1 -1
- package/skills/.cursor-plugin/plugin.json +1 -1
- package/skills/package.json +1 -1
- package/skills/skills/using-skillwiki/SKILL.md +1 -1
- package/skills/skills/wiki-init/SKILL.md +3 -0
- package/skills/using-skillwiki/SKILL.md +1 -1
- package/skills/wiki-init/SKILL.md +3 -0
- package/templates/launchd/com.karlchow.skillwiki-clip-outbox.plist +12 -13
- package/templates/launchd/com.karlchow.skillwiki-mirror-sse.plist +20 -0
- 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.
|
|
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.
|
|
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": {
|