@sigrun/sigrun 0.4.2 → 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.
- package/CHANGELOG.md +37 -1
- package/README.md +46 -23
- package/dist/commands/expand.js +37 -5
- package/dist/curation/attribution.js +135 -0
- package/dist/curation/deletion.js +32 -0
- package/dist/curation/gate.js +50 -0
- package/dist/curation/host.js +342 -0
- package/dist/curation/internal.js +54 -0
- package/dist/curation/names.js +5 -0
- package/dist/curation/overlays.js +325 -0
- package/dist/curation/paths.js +17 -0
- package/dist/curation/run-doc.js +64 -0
- package/dist/curation/runs.js +579 -0
- package/dist/curation/scan.js +30 -0
- package/dist/curation/skill-tool.js +308 -0
- package/dist/curation/store.js +444 -0
- package/dist/curation/targets.js +77 -0
- package/dist/curation/usage.js +124 -0
- package/dist/curation/wrap.js +212 -0
- package/dist/extension.d.ts +282 -0
- package/dist/harness/boot.js +209 -9
- package/dist/harness/builtins.js +12 -9
- package/dist/harness/event-bus.js +64 -0
- package/dist/harness/loader.js +114 -1
- package/dist/harness/naming.js +6 -0
- package/dist/harness/packages.js +14 -9
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +139 -26
- package/dist/harness/settings.js +2 -2
- package/dist/harness/tools.js +70 -0
- package/dist/jobs/fire.js +33 -11
- package/dist/jobs/skills-refs.js +40 -0
- package/dist/sandbox/policy.js +14 -0
- package/dist/server/blocks/answers.js +9 -3
- package/dist/server/conversation-ops.js +21 -3
- package/dist/server/helpers.js +1 -1
- package/dist/server/host-bridge.js +6 -1
- package/dist/server/notifications.js +6 -0
- package/dist/server/routes/meta.js +8 -3
- package/dist/server/routes/submissions.js +5 -0
- package/dist/server/routes/surfaces.js +3 -1
- package/dist/server/routes/users.js +7 -0
- package/dist/server/schema-fixture.js +168 -0
- package/dist/server/session-meta.js +12 -10
- package/dist/settings/core-sections.js +9 -0
- package/dist/settings/registry.js +6 -0
- package/dist/skills/index.js +88 -19
- package/node_modules/@sigrun/shared/CHANGELOG.md +3 -1
- package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
- package/node_modules/@sigrun/shared/dist/access.js +8 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +9 -7
- package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
- 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
|
+
}
|