@sigrun/sigrun 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +46 -23
  3. package/dist/commands/expand.js +37 -5
  4. package/dist/curation/attribution.js +135 -0
  5. package/dist/curation/deletion.js +32 -0
  6. package/dist/curation/gate.js +50 -0
  7. package/dist/curation/host.js +342 -0
  8. package/dist/curation/internal.js +54 -0
  9. package/dist/curation/names.js +5 -0
  10. package/dist/curation/overlays.js +325 -0
  11. package/dist/curation/paths.js +17 -0
  12. package/dist/curation/run-doc.js +64 -0
  13. package/dist/curation/runs.js +579 -0
  14. package/dist/curation/scan.js +30 -0
  15. package/dist/curation/skill-tool.js +308 -0
  16. package/dist/curation/store.js +444 -0
  17. package/dist/curation/targets.js +77 -0
  18. package/dist/curation/usage.js +124 -0
  19. package/dist/curation/wrap.js +212 -0
  20. package/dist/extension.d.ts +282 -0
  21. package/dist/harness/boot.js +209 -9
  22. package/dist/harness/builtins.js +12 -9
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/loader.js +114 -1
  25. package/dist/harness/naming.js +6 -0
  26. package/dist/harness/packages.js +14 -9
  27. package/dist/harness/reserved-sections.js +2 -0
  28. package/dist/harness/seam-host.js +139 -26
  29. package/dist/harness/settings.js +2 -2
  30. package/dist/harness/tools.js +70 -0
  31. package/dist/jobs/fire.js +33 -11
  32. package/dist/jobs/skills-refs.js +40 -0
  33. package/dist/sandbox/policy.js +14 -0
  34. package/dist/server/blocks/answers.js +9 -3
  35. package/dist/server/conversation-ops.js +21 -3
  36. package/dist/server/helpers.js +1 -1
  37. package/dist/server/host-bridge.js +6 -1
  38. package/dist/server/notifications.js +6 -0
  39. package/dist/server/routes/meta.js +8 -3
  40. package/dist/server/routes/submissions.js +5 -0
  41. package/dist/server/routes/surfaces.js +3 -1
  42. package/dist/server/routes/users.js +7 -0
  43. package/dist/server/schema-fixture.js +168 -0
  44. package/dist/server/session-meta.js +12 -10
  45. package/dist/settings/core-sections.js +9 -0
  46. package/dist/settings/registry.js +6 -0
  47. package/dist/skills/index.js +88 -19
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -1
  49. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  50. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  51. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  52. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  53. package/node_modules/@sigrun/shared/package.json +3 -2
  54. package/package.json +9 -7
  55. package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
  56. package/webui/dist/index.html +1 -1
@@ -0,0 +1,444 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { join, dirname } from "node:path";
4
+ import { curationDir, learnedSkillsDir } from "./paths.js";
5
+ import { validSkillName } from "./names.js";
6
+ import { scanContent } from "./scan.js";
7
+ const VERSIONS = 10;
8
+ const FEED_ENTRIES = 500;
9
+ const SUPPORT_ROOTS = ["references", "templates", "scripts", "assets"];
10
+ const locks = new Map();
11
+ async function withUserLock(user, fn) {
12
+ const previous = locks.get(user) ?? Promise.resolve();
13
+ const current = previous.then(fn, fn);
14
+ const tail = current.then(() => undefined, () => undefined);
15
+ locks.set(user, tail);
16
+ try {
17
+ return await current;
18
+ }
19
+ finally {
20
+ if (locks.get(user) === tail)
21
+ locks.delete(user);
22
+ }
23
+ }
24
+ function requireUser(options) {
25
+ if (!options.userExists())
26
+ throw new Error("user removed");
27
+ }
28
+ function skillName(name) {
29
+ if (!validSkillName(name))
30
+ throw new Error(`invalid skill name: ${name}`);
31
+ }
32
+ function supportFile(file) {
33
+ if (file.includes("\\") || file.startsWith("/") || file.includes("\0")) {
34
+ throw new Error("support file must be a relative path");
35
+ }
36
+ const parts = file.split("/");
37
+ if (parts.length < 2 || !SUPPORT_ROOTS.includes(parts[0]) || parts.some((part) => part === "" || part === "." || part === "..")) {
38
+ throw new Error("support file must be under references/, templates/, scripts/ or assets/");
39
+ }
40
+ return join(...parts);
41
+ }
42
+ function frontmatter(text) {
43
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
44
+ if (match === null)
45
+ throw new Error("SKILL.md frontmatter is required");
46
+ const fields = {};
47
+ for (const line of match[1].split(/\r?\n/)) {
48
+ const field = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
49
+ if (field !== null)
50
+ fields[field[1]] = field[2].trim().replace(/^(["'])(.*)\1$/, "$2");
51
+ }
52
+ if (!fields.name)
53
+ throw new Error("SKILL.md frontmatter name is required");
54
+ if (!fields.description)
55
+ throw new Error("SKILL.md frontmatter description is required");
56
+ return { name: fields.name, description: fields.description };
57
+ }
58
+ function checkContent(text) {
59
+ const result = scanContent(text);
60
+ if (!result.ok)
61
+ throw new Error(`content scan refused line ${result.line}: ${result.rule}`);
62
+ }
63
+ async function exists(path) {
64
+ try {
65
+ await lstat(path);
66
+ return true;
67
+ }
68
+ catch (error) {
69
+ if (error.code === "ENOENT")
70
+ return false;
71
+ throw error;
72
+ }
73
+ }
74
+ async function copyTree(source, target) {
75
+ const info = await lstat(source);
76
+ if (info.isSymbolicLink())
77
+ throw new Error(`symlinks are not allowed: ${source}`);
78
+ if (info.isDirectory()) {
79
+ await mkdir(target, { recursive: true });
80
+ for (const entry of await readdir(source))
81
+ await copyTree(join(source, entry), join(target, entry));
82
+ }
83
+ else if (info.isFile()) {
84
+ await cp(source, target);
85
+ }
86
+ else
87
+ throw new Error(`unsupported file type: ${source}`);
88
+ }
89
+ async function assertNoSymlinks(root, file) {
90
+ const parts = file.split("/");
91
+ let path = root;
92
+ for (const part of parts) {
93
+ path = join(path, part);
94
+ try {
95
+ if ((await lstat(path)).isSymbolicLink())
96
+ throw new Error("symlinks are not allowed in skill files");
97
+ }
98
+ catch (error) {
99
+ if (error.code === "ENOENT")
100
+ return;
101
+ throw error;
102
+ }
103
+ }
104
+ }
105
+ async function readState(dir) {
106
+ try {
107
+ const value = JSON.parse(await readFile(join(dir, "state.json"), "utf8"));
108
+ return { pins: value.pins ?? {}, archived: value.archived ?? [], createdAt: value.createdAt ?? {} };
109
+ }
110
+ catch (error) {
111
+ if (error.code === "ENOENT")
112
+ return { pins: {}, archived: [], createdAt: {} };
113
+ throw error;
114
+ }
115
+ }
116
+ async function writeState(dir, state) {
117
+ await mkdir(dir, { recursive: true });
118
+ await writeFileAtomic(join(dir, "state.json"), `${JSON.stringify(state, null, 2)}\n`);
119
+ }
120
+ async function writeFileAtomic(path, text) {
121
+ const temp = `${path}.${randomUUID()}.tmp`;
122
+ await writeFile(temp, text);
123
+ await rename(temp, path);
124
+ }
125
+ async function readFeed(dir) {
126
+ try {
127
+ const text = await readFile(join(dir, "feed.jsonl"), "utf8");
128
+ return text.split("\n").filter(Boolean).map((line) => JSON.parse(line)).reverse();
129
+ }
130
+ catch (error) {
131
+ if (error.code === "ENOENT")
132
+ return [];
133
+ throw error;
134
+ }
135
+ }
136
+ async function nextVersion(dir, name) {
137
+ const root = join(dir, "versions", name);
138
+ let entries = [];
139
+ try {
140
+ entries = await readdir(root);
141
+ }
142
+ catch (error) {
143
+ if (error.code !== "ENOENT")
144
+ throw error;
145
+ }
146
+ const highest = entries.reduce((max, entry) => Math.max(max, /^v(\d+)$/.exec(entry)?.[1] === undefined ? 0 : Number(/^v(\d+)$/.exec(entry)[1])), 0);
147
+ return `v${highest + 1}`;
148
+ }
149
+ async function versionLocation(version) {
150
+ try {
151
+ return (await readFile(join(version, ".location"), "utf8")).trim() === "archive" ? "archive" : "skills";
152
+ }
153
+ catch (error) {
154
+ if (error.code === "ENOENT")
155
+ return undefined;
156
+ throw error;
157
+ }
158
+ }
159
+ export class SkillStore {
160
+ options;
161
+ root;
162
+ skills;
163
+ archive;
164
+ constructor(options) {
165
+ this.options = options;
166
+ this.root = curationDir(options.userArea);
167
+ this.skills = learnedSkillsDir(options.userArea);
168
+ this.archive = join(this.root, "archive");
169
+ }
170
+ async list() {
171
+ requireUser(this.options);
172
+ const state = await readState(this.root);
173
+ const names = new Set();
174
+ for (const root of [this.skills, join(this.root, "archive")]) {
175
+ try {
176
+ for (const entry of await readdir(root, { withFileTypes: true })) {
177
+ if (entry.isDirectory() && validSkillName(entry.name))
178
+ names.add(entry.name);
179
+ }
180
+ }
181
+ catch (error) {
182
+ if (error.code !== "ENOENT")
183
+ throw error;
184
+ }
185
+ }
186
+ const result = [];
187
+ for (const name of [...names].sort()) {
188
+ const archived = state.archived.includes(name);
189
+ const path = join(archived ? join(this.root, "archive") : this.skills, name, "SKILL.md");
190
+ try {
191
+ const text = await readFile(path, "utf8");
192
+ const meta = frontmatter(text);
193
+ result.push({ name, description: meta.description, archived, pinned: state.pins[name] === true, bytes: Buffer.byteLength(text), createdAt: state.createdAt[name] ?? (await stat(path)).birthtime.toISOString() });
194
+ }
195
+ catch (error) {
196
+ if (error.code !== "ENOENT")
197
+ throw error;
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+ async view(name, file = "SKILL.md") {
203
+ requireUser(this.options);
204
+ skillName(name);
205
+ const path = file === "SKILL.md" ? file : supportFile(file);
206
+ await assertNoSymlinks(join(this.skills, name), path);
207
+ return readFile(join(this.skills, name, path), "utf8");
208
+ }
209
+ async apply(name, change, meta) {
210
+ requireUser(this.options);
211
+ return withUserLock(this.options.user, async () => {
212
+ requireUser(this.options);
213
+ skillName(name);
214
+ const state = await readState(this.root);
215
+ const archived = state.archived.includes(name);
216
+ const root = join(archived ? this.archive : this.skills, name);
217
+ if (change.kind === "create") {
218
+ if (await exists(root))
219
+ throw new Error(`skill already exists: ${name}`);
220
+ const taken = await this.options.nameTakenElsewhere(name);
221
+ if (taken !== undefined)
222
+ throw new Error(`skill name ${name} is already used by ${taken}; use overlay instead`);
223
+ checkContent(change.content);
224
+ if (frontmatter(change.content).name !== name)
225
+ throw new Error(`frontmatter name must match the directory ${name}`);
226
+ await mkdir(root, { recursive: true });
227
+ await writeFile(join(root, "SKILL.md"), change.content);
228
+ state.createdAt[name] = this.options.now().toISOString();
229
+ return this.commit(name, "create", meta, state, undefined, root);
230
+ }
231
+ if (!(await exists(root)))
232
+ throw new Error(`unknown skill: ${name}`);
233
+ if (change.kind === "archive") {
234
+ if (archived)
235
+ throw new Error(`skill is archived: ${name}`);
236
+ return this.commit(name, "archive", meta, state, root, join(this.root, "archive", name), change.absorbedInto);
237
+ }
238
+ if (change.kind === "restore") {
239
+ if (!archived)
240
+ throw new Error(`skill is not archived: ${name}`);
241
+ const version = change.version === undefined ? undefined : join(this.root, "versions", name, change.version);
242
+ if (version !== undefined && !(await exists(version)))
243
+ throw new Error(`unknown version: ${change.version}`);
244
+ return this.commit(name, "restore", meta, state, root, join(this.skills, name), undefined, version);
245
+ }
246
+ if (archived)
247
+ throw new Error(`skill is archived: ${name}`);
248
+ if (change.kind !== "patch" && change.kind !== "write_file" && change.kind !== "remove_file")
249
+ throw new Error("unsupported skill change");
250
+ return this.commit(name, change.kind, meta, state, root, root, undefined, undefined, async () => {
251
+ if (change.kind === "patch") {
252
+ const file = change.file === undefined ? "SKILL.md" : supportFile(change.file);
253
+ await assertNoSymlinks(root, file);
254
+ const oldText = await readFile(join(root, file), "utf8");
255
+ const first = oldText.indexOf(change.old);
256
+ if (first < 0 || oldText.indexOf(change.old, first + 1) >= 0 || change.old === "")
257
+ throw new Error("patch old text must be a unique span");
258
+ const text = oldText.slice(0, first) + change.new + oldText.slice(first + change.old.length);
259
+ checkContent(text);
260
+ if (file === "SKILL.md" && frontmatter(text).name !== name)
261
+ throw new Error(`frontmatter name must match the directory ${name}`);
262
+ await writeFile(join(root, file), text);
263
+ }
264
+ else if (change.kind === "write_file") {
265
+ const file = supportFile(change.file);
266
+ await assertNoSymlinks(root, file);
267
+ checkContent(change.content);
268
+ await mkdir(join(root, file, ".."), { recursive: true });
269
+ await writeFile(join(root, file), change.content);
270
+ }
271
+ else {
272
+ const file = supportFile(change.file);
273
+ await assertNoSymlinks(root, file);
274
+ await rm(join(root, file));
275
+ }
276
+ });
277
+ });
278
+ }
279
+ async pin(name, pinned) {
280
+ requireUser(this.options);
281
+ await withUserLock(this.options.user, async () => {
282
+ requireUser(this.options);
283
+ skillName(name);
284
+ const state = await readState(this.root);
285
+ if (!(await this.list()).some((skill) => skill.name === name))
286
+ throw new Error(`unknown skill: ${name}`);
287
+ state.pins[name] = pinned;
288
+ await writeState(this.root, state);
289
+ await this.appendFeed({ id: randomUUID(), at: this.options.now().toISOString(), actor: "user", action: pinned ? "pin" : "unpin", skill: name });
290
+ });
291
+ }
292
+ async feed(limit, before) {
293
+ requireUser(this.options);
294
+ const entries = await readFeed(this.root);
295
+ const start = before === undefined ? 0 : entries.findIndex((entry) => entry.id === before);
296
+ if (start < 0)
297
+ return [];
298
+ return entries.slice(start + (before === undefined ? 0 : 1), start + (before === undefined ? 0 : 1) + Math.max(0, limit));
299
+ }
300
+ async undo(entryIds) {
301
+ requireUser(this.options);
302
+ return withUserLock(this.options.user, async () => {
303
+ requireUser(this.options);
304
+ const all = await readFeed(this.root);
305
+ const selected = all.filter((entry) => entryIds.includes(entry.id) && entry.before !== undefined);
306
+ const earliest = new Map();
307
+ for (const entry of selected) {
308
+ const previous = earliest.get(entry.skill);
309
+ if (previous === undefined || all.indexOf(entry) > all.indexOf(previous))
310
+ earliest.set(entry.skill, entry);
311
+ }
312
+ // The create-undo companion: an undone CREATE has no before
313
+ // snapshot (nothing existed) — reverting it means REMOVING the
314
+ // skill (archive, never hard delete; restorable from versions).
315
+ const createdOnly = all.filter((entry) => entryIds.includes(entry.id) && entry.before === undefined && entry.action === "create");
316
+ for (const entry of createdOnly) {
317
+ if (!earliest.has(entry.skill))
318
+ earliest.set(entry.skill, entry);
319
+ }
320
+ const result = [];
321
+ const state = await readState(this.root);
322
+ for (const entry of earliest.values()) {
323
+ if (entry.before === undefined) {
324
+ // Undo of a create: archive the skill (the no-delete
325
+ // rule — `restore` brings it back from versions).
326
+ if (state.archived.includes(entry.skill))
327
+ continue;
328
+ const from = join(this.skills, entry.skill);
329
+ if (!(await exists(from)))
330
+ continue;
331
+ const next = await this.commit(entry.skill, "undo", { actor: "user" }, state, from, join(this.root, "archive", entry.skill));
332
+ result.push(next);
333
+ continue;
334
+ }
335
+ const current = join(state.archived.includes(entry.skill) ? this.archive : this.skills, entry.skill);
336
+ const previous = join(this.root, "versions", entry.skill, entry.before);
337
+ if (!(await exists(previous)) || !(await exists(current)))
338
+ continue;
339
+ const location = await versionLocation(previous);
340
+ if (location === undefined)
341
+ throw new Error(`unknown version location: ${entry.before}`);
342
+ const destination = join(location === "archive" ? this.archive : this.skills, entry.skill);
343
+ const next = await this.commit(entry.skill, "undo", { actor: "user" }, state, current, destination, undefined, previous);
344
+ result.push(next);
345
+ }
346
+ return result;
347
+ });
348
+ }
349
+ /**
350
+ * Append one feed entry verbatim — the A5 overlay seam. OverlayStore
351
+ * owns its files but records every change in the SAME per-user feed
352
+ * (action `"overlay"`), so retention, the per-user lock and the P8
353
+ * view hold for overlay changes too. Entry ids/at values are the
354
+ * caller's.
355
+ */
356
+ async appendFeedEntry(entry) {
357
+ requireUser(this.options);
358
+ await withUserLock(this.options.user, async () => {
359
+ requireUser(this.options);
360
+ await this.appendFeed(entry);
361
+ });
362
+ }
363
+ /**
364
+ * Persists a change: snapshots the before state, applies the mutation,
365
+ * snapshots the after state, records the feed entry and runs retention.
366
+ * Every version directory carries a `.location` file recording where its
367
+ * content lived (skills or archive) when the snapshot was taken, so undo
368
+ * can pick the right destination and retention removes the metadata with
369
+ * the snapshot it belongs to.
370
+ */
371
+ async commit(name, action, meta, state, beforeRoot, afterRoot, absorbedInto, restoreFrom, mutate) {
372
+ const versionRoot = join(this.root, "versions", name);
373
+ let before;
374
+ if (beforeRoot !== undefined && await exists(beforeRoot)) {
375
+ before = await nextVersion(this.root, name);
376
+ await copyTree(beforeRoot, join(versionRoot, before));
377
+ await writeFile(join(versionRoot, before, ".location"), beforeRoot.startsWith(this.archive + "/") ? "archive" : "skills");
378
+ }
379
+ if (mutate !== undefined)
380
+ await mutate();
381
+ if (action === "archive") {
382
+ await mkdir(this.archive, { recursive: true });
383
+ await rename(beforeRoot, afterRoot);
384
+ state.archived = [...new Set([...state.archived, name])];
385
+ }
386
+ else if (action === "restore") {
387
+ await mkdir(this.skills, { recursive: true });
388
+ if (restoreFrom === undefined) {
389
+ await rename(beforeRoot, afterRoot);
390
+ }
391
+ else {
392
+ const staged = await this.stageVersion(restoreFrom);
393
+ await rm(beforeRoot, { recursive: true, force: true });
394
+ await rename(staged, afterRoot);
395
+ }
396
+ state.archived = state.archived.filter((item) => item !== name);
397
+ }
398
+ else if (action === "undo") {
399
+ // A create-undo (no restoreFrom): the move alone archives the
400
+ // skill; an edit-undo stages the before-snapshot into place.
401
+ if (restoreFrom !== undefined) {
402
+ const staged = await this.stageVersion(restoreFrom);
403
+ await rm(beforeRoot, { recursive: true, force: true });
404
+ await mkdir(dirname(afterRoot), { recursive: true });
405
+ await rename(staged, afterRoot);
406
+ }
407
+ else {
408
+ await mkdir(dirname(afterRoot), { recursive: true });
409
+ await rename(beforeRoot, afterRoot);
410
+ }
411
+ if (afterRoot.startsWith(this.archive + "/"))
412
+ state.archived = [...new Set([...state.archived, name])];
413
+ else
414
+ state.archived = state.archived.filter((item) => item !== name);
415
+ }
416
+ let after;
417
+ if (action !== "archive") {
418
+ after = await nextVersion(this.root, name);
419
+ const current = afterRoot ?? beforeRoot;
420
+ await copyTree(current, join(versionRoot, after));
421
+ await writeFile(join(versionRoot, after, ".location"), state.archived.includes(name) ? "archive" : "skills");
422
+ }
423
+ await writeState(this.root, state);
424
+ const entry = { id: randomUUID(), at: this.options.now().toISOString(), actor: meta.actor, action, skill: name, ...(absorbedInto === undefined && meta.reason === undefined ? {} : { reason: absorbedInto ?? meta.reason }), ...(meta.reviewId === undefined ? {} : { reviewId: meta.reviewId }), ...(meta.conversationId === undefined ? {} : { conversationId: meta.conversationId }), ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) };
425
+ await this.appendFeed(entry);
426
+ const versions = (await readdir(versionRoot)).filter((entry) => /^v\d+$/.test(entry)).sort((a, b) => Number(b.slice(1)) - Number(a.slice(1)));
427
+ for (const old of versions.slice(VERSIONS))
428
+ await rm(join(versionRoot, old), { recursive: true, force: true });
429
+ return entry;
430
+ }
431
+ /** Copies a version snapshot to a staging directory, minus the `.location` metadata file. */
432
+ async stageVersion(version) {
433
+ const staged = join(this.root, `.staged-${randomUUID()}`);
434
+ await copyTree(version, staged);
435
+ await rm(join(staged, ".location"), { force: true });
436
+ return staged;
437
+ }
438
+ async appendFeed(entry) {
439
+ await mkdir(this.root, { recursive: true });
440
+ const entries = (await readFeed(this.root)).reverse();
441
+ entries.push(entry);
442
+ await writeFileAtomic(join(this.root, "feed.jsonl"), entries.slice(-FEED_ENTRIES).map((item) => JSON.stringify(item)).join("\n") + "\n");
443
+ }
444
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The curation-target registry (curator P7, spec §6.5): the boot-built
3
+ * view over every loaded extension's `curationTargets` declaration.
4
+ *
5
+ * The loader validates each declaration (a target without `undo`, a
6
+ * tool the extension does not register, or a duplicate qualified id
7
+ * quarantines the WHOLE extension before anything is collected); boot
8
+ * folds the surviving bindings into ONE {@link CurationRegistry} and
9
+ * exposes it on the opened harness.
10
+ *
11
+ * `isCurationTool` is the A8 wrapping feed: `true` for `skill_manage`
12
+ * and every LISTED target tool, `false` for everything else — an
13
+ * extension's unlisted direct write tools (a "remember this" path the
14
+ * extension owns itself) are never intercepted, never gated, never
15
+ * undone by the curator.
16
+ */
17
+ /**
18
+ * The registered tool names that are curation tools by core ownership.
19
+ * Exported for the A8 wrap feed's seed (the feed starts with the core
20
+ * set and grows as the loader validates target tools).
21
+ */
22
+ export const CORE_CURATION_TOOLS = new Set([
23
+ "skill_manage",
24
+ ]);
25
+ /** The boot-built registry over the loaded curation targets. */
26
+ export class CurationRegistry {
27
+ #targets;
28
+ #tools;
29
+ constructor(targets) {
30
+ this.#targets = [...targets];
31
+ const tools = new Set(CORE_CURATION_TOOLS);
32
+ for (const target of this.#targets) {
33
+ for (const tool of target.tools)
34
+ tools.add(tool);
35
+ }
36
+ this.#tools = tools;
37
+ }
38
+ /** Every registered target, in load order. */
39
+ targets() {
40
+ return this.#targets;
41
+ }
42
+ /** The target a tool belongs to, or `undefined` when it is none. */
43
+ byTool(tool) {
44
+ return this.#targets.find((target) => target.tools.includes(tool));
45
+ }
46
+ /**
47
+ * Whether a tool is a curation tool: `skill_manage` and every listed
48
+ * target tool — the A8 wrapping feed. An extension's unlisted tools
49
+ * are never curation tools.
50
+ */
51
+ isCurationTool(tool) {
52
+ return this.#tools.has(tool);
53
+ }
54
+ }
55
+ /**
56
+ * Fold per-extension curation-target bindings into one registry. The
57
+ * loader quarantined every invalid declaration, so this pass only
58
+ * attributes: each target gains its qualified id (`<slug>:<name>`) and
59
+ * the extension slug. A duplicate qualified id here is a loader bug
60
+ * (the load-time check already rejected it) — it fails loud rather
61
+ * than shadowing.
62
+ */
63
+ export function buildCurationRegistry(bindings) {
64
+ const targets = [];
65
+ const seen = new Set();
66
+ for (const binding of bindings) {
67
+ for (const target of binding.targets) {
68
+ const id = `${binding.extension}:${target.name}`;
69
+ if (seen.has(id)) {
70
+ throw new Error(`duplicate curation target id "${id}" — the loader already rejected this; a second arrival is a boot bug`);
71
+ }
72
+ seen.add(id);
73
+ targets.push({ ...target, id, extension: binding.extension });
74
+ }
75
+ }
76
+ return new CurationRegistry(targets);
77
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Usage telemetry (curator P4, spec §6.4): per-user counters of how the
3
+ * owner's skills are USED — `load_skill` calls, `/skill:` expansions
4
+ * and core `read`-tool reads inside the learned root. The curator's
5
+ * ranking input (what to keep, what to archive: a skill nobody loads is
6
+ * a candidate; a `load_skill`-heavy, `read`-light skill is healthy).
7
+ *
8
+ * Storage: `curationDir/usage.json`, one `{skill: {load, expand, read,
9
+ * lastUsed}}` record per skill, written at most once per 10 s per user
10
+ * (the data is advisory — no reason to hammer the disk on every load).
11
+ * `bump` accumulates in memory; `flush` merges the pending bumps over
12
+ * the file's current contents and writes, so a fresh process (a restart,
13
+ * a second server) keeps counting from what was flushed.
14
+ *
15
+ * Known gap (documented): `bash cat` of a learned file does NOT count —
16
+ * the shell path never sees a skill-shaped read. Only the three
17
+ * model-facing seams bump.
18
+ */
19
+ import { readFile, rename, writeFile } from "node:fs/promises";
20
+ import { randomUUID } from "node:crypto";
21
+ import { mkdir } from "node:fs/promises";
22
+ import { join } from "node:path";
23
+ import { curationDir } from "./paths.js";
24
+ /** The flush interval: writes at most once per 10 s per user. */
25
+ const FLUSH_INTERVAL_MS = 10_000;
26
+ const KINDS = ["load", "expand", "read"];
27
+ /**
28
+ * The per-user usage counters. `bump` is in-memory bookkeeping; `flush`
29
+ * persists to `curationDir/usage.json`, a no-op inside the 10 s window
30
+ * since the last successful write. The counting seams bump and flush
31
+ * opportunistically (the throttle keeps it cheap); a process exit loses
32
+ * at most the last window's advisory data — accepted.
33
+ */
34
+ export class UsageCounters {
35
+ options;
36
+ /** Bumps since the last successful flush (per skill). */
37
+ pending = new Map();
38
+ /** The last flushed state, as far as this instance knows. */
39
+ flushed = {};
40
+ lastFlush;
41
+ constructor(options) {
42
+ this.options = options;
43
+ }
44
+ /** Count one use of one skill. */
45
+ bump(skill, kind) {
46
+ const at = this.now().toISOString();
47
+ const current = this.pending.get(skill);
48
+ const next = current === undefined
49
+ ? { load: 0, expand: 0, read: 0, lastUsed: at }
50
+ : { ...current, lastUsed: at };
51
+ next[kind] += 1;
52
+ this.pending.set(skill, next);
53
+ }
54
+ /** The skill's most recent use, ISO 8601; `undefined` when never. */
55
+ lastUsed(skill) {
56
+ void this.options.user;
57
+ const pending = this.pending.get(skill);
58
+ if (pending !== undefined)
59
+ return pending.lastUsed;
60
+ return this.flushed[skill]?.lastUsed;
61
+ }
62
+ /**
63
+ * Persist the pending bumps, merged over the file's CURRENT contents
64
+ * (a concurrent or earlier process's counts survive). A no-op inside
65
+ * the 10 s window since the last successful write.
66
+ */
67
+ async flush() {
68
+ const at = this.now().getTime();
69
+ if (this.lastFlush !== undefined &&
70
+ at - this.lastFlush < FLUSH_INTERVAL_MS) {
71
+ return;
72
+ }
73
+ // Fail closed for a removed user (deletion's Review Focus): the
74
+ // cached counters stay resolvable after `removeUserCuration`, so an
75
+ // in-flight seam's late flush would recreate the deleted directory
76
+ // as orphan state. Drop SILENTLY — telemetry must never throw into
77
+ // the void-and-forget counting seams — including the
78
+ // deadline-forced path. The gate sits AFTER the in-memory throttle
79
+ // (it reads the live directory synchronously, and the seams call
80
+ // flush opportunistically on every counted read — the cheap check
81
+ // must come first) but BEFORE any disk op, so nothing lands for a
82
+ // user who is gone. A dropped flush neither clears `pending` nor
83
+ // arms `lastFlush`: a later call re-checks the gate, so if the user
84
+ // somehow exists again the counting simply resumes.
85
+ if (this.options.userExists !== undefined && !this.options.userExists()) {
86
+ return;
87
+ }
88
+ const disk = await this.readDisk();
89
+ const merged = { ...disk };
90
+ for (const [skill, bumps] of this.pending) {
91
+ const previous = disk[skill];
92
+ const record = { ...(previous ?? {}) };
93
+ for (const kind of KINDS) {
94
+ if (bumps[kind] > 0) {
95
+ record[kind] = (previous?.[kind] ?? 0) + bumps[kind];
96
+ }
97
+ }
98
+ record.lastUsed = bumps.lastUsed;
99
+ merged[skill] = record;
100
+ }
101
+ const path = join(curationDir(this.options.userArea), "usage.json");
102
+ await mkdir(join(path, ".."), { recursive: true });
103
+ const temp = `${path}.${randomUUID()}.tmp`;
104
+ await writeFile(temp, `${JSON.stringify(merged, null, 2)}\n`);
105
+ await rename(temp, path);
106
+ this.pending.clear();
107
+ this.flushed = merged;
108
+ this.lastFlush = at;
109
+ }
110
+ now() {
111
+ return this.options.now?.() ?? new Date();
112
+ }
113
+ /** Read the file's current contents; `{}` when absent or unreadable. */
114
+ async readDisk() {
115
+ try {
116
+ const text = await readFile(join(curationDir(this.options.userArea), "usage.json"), "utf8");
117
+ const parsed = JSON.parse(text);
118
+ return parsed;
119
+ }
120
+ catch {
121
+ return {};
122
+ }
123
+ }
124
+ }