@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
package/dist/harness/packages.js
CHANGED
|
@@ -949,13 +949,18 @@ export async function syncGit(source, ref, gitDir, options = {}) {
|
|
|
949
949
|
return target;
|
|
950
950
|
}
|
|
951
951
|
/**
|
|
952
|
-
* The
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
952
|
+
* The `.ts` skip warning (spec §9.4): a `pi.extensions` entry ending in
|
|
953
|
+
* `.ts` cannot load under plain node (the build spec removed the `.ts`
|
|
954
|
+
* entry fallback) — it is skipped, keeping the package's skills loadable.
|
|
955
|
+
* ONE line per skipped entry, per discovery.
|
|
956
|
+
*
|
|
957
|
+
* The wording says `pi` MANIFEST rather than "pi extension" on purpose:
|
|
958
|
+
* the `pi` key is read only to LOCATE a file. Sigrun does not implement
|
|
959
|
+
* pi's extension API, so this skip is not "pi extensions don't work here";
|
|
960
|
+
* it is "this package's entry is unimportable by plain node".
|
|
956
961
|
*/
|
|
957
962
|
function warnTsPiExtension(entry) {
|
|
958
|
-
console.warn(`pi
|
|
963
|
+
console.warn(`pi manifest: skipping TypeScript entry ${entry} (not loadable under node); loading the package's skills only`);
|
|
959
964
|
}
|
|
960
965
|
/** Read one package root's manifest (absent/unreadable → undefined). */
|
|
961
966
|
function readDiscoveryManifest(pkgRoot) {
|
|
@@ -979,9 +984,9 @@ function manifestStringArray(value) {
|
|
|
979
984
|
}
|
|
980
985
|
/**
|
|
981
986
|
* A package root's manifest skill dirs (spec §9.4): `sigrun.skills`
|
|
982
|
-
* (string[]) else pi
|
|
983
|
-
* so no warning), each resolved against the package root, keeping
|
|
984
|
-
* EXISTING directories. No manifest, no field, or nothing existing → [].
|
|
987
|
+
* (string[]) else the `pi`-shaped `pi.skills` (string[] — same SKILL.md
|
|
988
|
+
* format, so no warning), each resolved against the package root, keeping
|
|
989
|
+
* only EXISTING directories. No manifest, no field, or nothing existing → [].
|
|
985
990
|
*/
|
|
986
991
|
export function manifestSkills(pkgRoot) {
|
|
987
992
|
const manifest = readDiscoveryManifest(pkgRoot);
|
|
@@ -1035,7 +1040,7 @@ export function manifestEntry(pkgRoot) {
|
|
|
1035
1040
|
}
|
|
1036
1041
|
return join(pkgRoot, entry);
|
|
1037
1042
|
}
|
|
1038
|
-
console.warn(`pi
|
|
1043
|
+
console.warn(`pi manifest: no loadable entry in ${pkgRoot} (all entries are TypeScript); loading the package's skills only`);
|
|
1039
1044
|
}
|
|
1040
1045
|
return undefined;
|
|
1041
1046
|
}
|
|
@@ -37,6 +37,28 @@ export class SeamError extends Error {
|
|
|
37
37
|
* boot-built store trio (one per boot, from the resolved roots); the
|
|
38
38
|
* host scopes it to its identity's slug — `rekey` re-scopes in place.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* The pre-bind forwarder for the curation surface's SYNC store members
|
|
42
|
+
* (`skills(user)`, `overlays(user)`): returns the store handle at once;
|
|
43
|
+
* every method call awaits the bridge first (the shared pending promise
|
|
44
|
+
* carries the bind deadline). Method names are read off the resolved
|
|
45
|
+
* store at call time, so the forwarder stays shape-stable.
|
|
46
|
+
*/
|
|
47
|
+
function syncStoreForwarder(pending, member, user) {
|
|
48
|
+
return new Proxy({}, {
|
|
49
|
+
get(_t, method) {
|
|
50
|
+
return async (...args) => {
|
|
51
|
+
const resolvedHost = (await pending);
|
|
52
|
+
const store = resolvedHost[member](user);
|
|
53
|
+
const fn = store[method];
|
|
54
|
+
if (typeof fn !== "function") {
|
|
55
|
+
throw new Error(`curation: store ${member}(${user}) method ${String(method)} is not callable`);
|
|
56
|
+
}
|
|
57
|
+
return fn.apply(store, [...args]);
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
40
62
|
export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools, services) {
|
|
41
63
|
const interactionKinds = new Set(kinds.interaction ?? []);
|
|
42
64
|
const ambientKinds = new Set(kinds.ambient ?? []);
|
|
@@ -79,10 +101,31 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
79
101
|
const bridgePromise = new Promise((resolve) => {
|
|
80
102
|
bridgeReleased = resolve;
|
|
81
103
|
});
|
|
82
|
-
|
|
104
|
+
// The pre-bind bound: a bridge call that waits PAST this long fails
|
|
105
|
+
// loud instead of hanging forever (the jobs-task fake-clock hang's
|
|
106
|
+
// lesson). Production never sees it — `createServer` binds the bridge
|
|
107
|
+
// at boot, long inside the bound — but a serverless embed that never
|
|
108
|
+
// builds a server would otherwise await an eternally pending promise.
|
|
109
|
+
const bridgeDeadlineMs = 60_000;
|
|
110
|
+
const hostBridge = async (member) => {
|
|
83
111
|
await bind.harness();
|
|
84
|
-
return
|
|
112
|
+
return await Promise.race([
|
|
113
|
+
bridgePromise,
|
|
114
|
+
new Promise((_, reject) => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
reject(new Error(`seam host: ${member} — the bridge never bound after ${bridgeDeadlineMs}ms; is createServer running? (caller: ${identity.name})`));
|
|
117
|
+
}, bridgeDeadlineMs);
|
|
118
|
+
// The rejection timer never keeps the process alive.
|
|
119
|
+
if (typeof timer === "object" && "unref" in timer)
|
|
120
|
+
timer.unref();
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
85
123
|
};
|
|
124
|
+
// The curation surface's settled bridge host (curator P6/A9): memoized
|
|
125
|
+
// HERE — in the host closure — so every `host.curation` getter call (a
|
|
126
|
+
// FRESH proxy per read) shares one settled reference. See the getter's
|
|
127
|
+
// own comment for the sync-member discipline this serves.
|
|
128
|
+
let curationHost;
|
|
86
129
|
return {
|
|
87
130
|
get stores() {
|
|
88
131
|
return { ...stores, user: userStores };
|
|
@@ -273,7 +316,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
273
316
|
// re-keyed current slug, read at call time).
|
|
274
317
|
conversations: {
|
|
275
318
|
async create(opts = {}) {
|
|
276
|
-
const bridge = await hostBridge();
|
|
319
|
+
const bridge = await hostBridge("conversations.create");
|
|
277
320
|
return bridge.conversations.create({
|
|
278
321
|
...(opts.owner === undefined ? {} : { owner: opts.owner }),
|
|
279
322
|
...(opts.sharing === undefined ? {} : { sharing: opts.sharing }),
|
|
@@ -284,90 +327,160 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
284
327
|
});
|
|
285
328
|
},
|
|
286
329
|
async submit(id, input, opts) {
|
|
287
|
-
const bridge = await hostBridge();
|
|
330
|
+
const bridge = await hostBridge("conversations.submit");
|
|
288
331
|
return bridge.conversations.submit(id, input, opts);
|
|
289
332
|
},
|
|
290
333
|
async watch(id, listener) {
|
|
291
|
-
const bridge = await hostBridge();
|
|
334
|
+
const bridge = await hostBridge("conversations.watch");
|
|
292
335
|
return bridge.conversations.watch(id, listener);
|
|
293
336
|
},
|
|
294
337
|
async list(opts) {
|
|
295
|
-
const bridge = await hostBridge();
|
|
338
|
+
const bridge = await hostBridge("conversations.list");
|
|
296
339
|
return bridge.conversations.list(opts);
|
|
297
340
|
},
|
|
298
341
|
async meta(id) {
|
|
299
|
-
const bridge = await hostBridge();
|
|
342
|
+
const bridge = await hostBridge("conversations.meta");
|
|
300
343
|
return bridge.conversations.meta(id);
|
|
301
344
|
},
|
|
302
345
|
// The C4a ops: straight delegation (the bridge resolves the id and
|
|
303
346
|
// runs the op its HTTP twin runs).
|
|
304
347
|
async abort(id) {
|
|
305
|
-
return (await hostBridge()).conversations.abort(id);
|
|
348
|
+
return (await hostBridge("conversations.abort")).conversations.abort(id);
|
|
306
349
|
},
|
|
307
350
|
async configure(id, opts) {
|
|
308
|
-
return (await hostBridge()).conversations.configure(id, opts);
|
|
351
|
+
return (await hostBridge("conversations.configure")).conversations.configure(id, opts);
|
|
309
352
|
},
|
|
310
353
|
async compact(id) {
|
|
311
|
-
return (await hostBridge()).conversations.compact(id);
|
|
354
|
+
return (await hostBridge("conversations.compact")).conversations.compact(id);
|
|
312
355
|
},
|
|
313
356
|
async fork(id, opts) {
|
|
314
|
-
return (await hostBridge()).conversations.fork(id, opts);
|
|
357
|
+
return (await hostBridge("conversations.fork")).conversations.fork(id, opts);
|
|
315
358
|
},
|
|
316
359
|
async patchMeta(id, patch) {
|
|
317
|
-
return (await hostBridge()).conversations.patchMeta(id, patch);
|
|
360
|
+
return (await hostBridge("conversations.patchMeta")).conversations.patchMeta(id, patch);
|
|
318
361
|
},
|
|
319
362
|
// The C4b ops: same straight delegation.
|
|
320
363
|
async upload(id, file) {
|
|
321
|
-
return (await hostBridge()).conversations.upload(id, file);
|
|
364
|
+
return (await hostBridge("conversations.upload")).conversations.upload(id, file);
|
|
322
365
|
},
|
|
323
366
|
async readFile(id, path) {
|
|
324
|
-
return (await hostBridge()).conversations.readFile(id, path);
|
|
367
|
+
return (await hostBridge("conversations.readFile")).conversations.readFile(id, path);
|
|
325
368
|
},
|
|
326
369
|
},
|
|
327
370
|
users: {
|
|
328
371
|
async list() {
|
|
329
|
-
return (await hostBridge()).users.list();
|
|
372
|
+
return (await hostBridge("users.list")).users.list();
|
|
330
373
|
},
|
|
331
374
|
async get(user) {
|
|
332
|
-
return (await hostBridge()).users.get(user);
|
|
375
|
+
return (await hostBridge("users.get")).users.get(user);
|
|
333
376
|
},
|
|
334
377
|
},
|
|
335
378
|
accounts: {
|
|
336
379
|
async lookup(provider, subject) {
|
|
337
|
-
return (await hostBridge()).accounts.lookup(provider, subject);
|
|
380
|
+
return (await hostBridge("accounts.lookup")).accounts.lookup(provider, subject);
|
|
338
381
|
},
|
|
339
382
|
async link(provider, subject, user, via) {
|
|
340
|
-
return (await hostBridge()).accounts.link(provider, subject, user, via);
|
|
383
|
+
return (await hostBridge("accounts.link")).accounts.link(provider, subject, user, via);
|
|
341
384
|
},
|
|
342
385
|
async unlink(provider, subject) {
|
|
343
|
-
return (await hostBridge()).accounts.unlink(provider, subject);
|
|
386
|
+
return (await hostBridge("accounts.unlink")).accounts.unlink(provider, subject);
|
|
344
387
|
},
|
|
345
388
|
async listForUser(user) {
|
|
346
|
-
return (await hostBridge()).accounts.listForUser(user);
|
|
389
|
+
return (await hostBridge("accounts.listForUser")).accounts.listForUser(user);
|
|
347
390
|
},
|
|
348
391
|
},
|
|
349
392
|
access: {
|
|
350
393
|
async accessOf(principal, resource) {
|
|
351
|
-
return (await hostBridge()).access.accessOf(principal, resource);
|
|
394
|
+
return (await hostBridge("access.accessOf")).access.accessOf(principal, resource);
|
|
352
395
|
},
|
|
353
396
|
},
|
|
354
397
|
identity: {
|
|
355
398
|
async actingUser(conversationId) {
|
|
356
|
-
return (await hostBridge()).identity.actingUser(conversationId);
|
|
399
|
+
return (await hostBridge("identity.actingUser")).identity.actingUser(conversationId);
|
|
357
400
|
},
|
|
358
401
|
},
|
|
402
|
+
// Curator P6 (A9): the boot-built curation host through the bridge —
|
|
403
|
+
// the lazy member discipline (a factory-body call waits for the
|
|
404
|
+
// bridge; a post-bind call resolves at once). Every member of the
|
|
405
|
+
// curation surface is a function, so a Proxy that forwards each
|
|
406
|
+
// property read (and call) to the RESOLVED bridge host implements
|
|
407
|
+
// the whole surface with the same wait semantics as the async
|
|
408
|
+
// delegations above. Members that return SYNCHRONOUS store objects
|
|
409
|
+
// (`skills(user)`, `overlays(user)` — store handles whose own methods
|
|
410
|
+
// are async) must come back UNAWAITED: an `async` wrapper would
|
|
411
|
+
// return a Promise of the store, and the caller's sync `.list()` —
|
|
412
|
+
// the shape the view and the targets use — would read undefined
|
|
413
|
+
// (D1's e2e found the view rendering 500 on exactly this). The
|
|
414
|
+
// settled host is memoized in the HOST closure (not the getter —
|
|
415
|
+
// every `host.curation` read returns a FRESH proxy, and a per-proxy
|
|
416
|
+
// memo would reset on each read, putting the async forwarder back
|
|
417
|
+
// in front of sync members on the second read).
|
|
418
|
+
get curation() {
|
|
419
|
+
if (curationHost !== undefined)
|
|
420
|
+
return curationHost;
|
|
421
|
+
const pending = hostBridge("curation").then((bridge) => {
|
|
422
|
+
curationHost = bridge.curation;
|
|
423
|
+
return bridge.curation;
|
|
424
|
+
});
|
|
425
|
+
// SAFETY: the Proxy target satisfies the structural member shapes
|
|
426
|
+
// because every forwarded call is an `apply` trap on the real host's
|
|
427
|
+
// own member; the cast only bridges the contract's named members.
|
|
428
|
+
return new Proxy({}, {
|
|
429
|
+
get(_target, property) {
|
|
430
|
+
// The settled-bridge fast path: the member itself, bound — a
|
|
431
|
+
// synchronous member (`skills`) returns its store synchronously,
|
|
432
|
+
// an async one keeps returning its promise. The property read is
|
|
433
|
+
// STILL a function either way (the surface has no data members),
|
|
434
|
+
// so callers observe the contract shape unchanged.
|
|
435
|
+
if (curationHost !== undefined) {
|
|
436
|
+
const member = curationHost[property];
|
|
437
|
+
if (typeof member !== "function") {
|
|
438
|
+
throw new Error(`curation: member ${String(property)} is not callable`);
|
|
439
|
+
}
|
|
440
|
+
return member.bind(curationHost);
|
|
441
|
+
}
|
|
442
|
+
// The pre-bind window: every member becomes a forwarder that
|
|
443
|
+
// waits for the bridge (the factory-body discipline). The
|
|
444
|
+
// surface's SYNC store members — `skills(user)` and
|
|
445
|
+
// `overlays(user)` return store handles whose own methods are
|
|
446
|
+
// async (the shape the view and the steps call: `.list()` with
|
|
447
|
+
// no await on the store itself) — must come back as a STORE,
|
|
448
|
+
// not a promise of one (the eval's idle pass hit exactly this:
|
|
449
|
+
// the archive step read `.list` off a promise). A lazy store
|
|
450
|
+
// whose methods each await the bridge preserves the
|
|
451
|
+
// sync-return contract while keeping the wait: the caller
|
|
452
|
+
// gets the store immediately and its first method call
|
|
453
|
+
// resolves once the bridge lands (or rejects at the bind
|
|
454
|
+
// deadline through the shared pending promise).
|
|
455
|
+
if (property === "skills" || property === "overlays") {
|
|
456
|
+
return (user) => syncStoreForwarder(pending, property, user);
|
|
457
|
+
}
|
|
458
|
+
return async (...args) => {
|
|
459
|
+
const resolvedHost = await pending;
|
|
460
|
+
// SAFETY: the property is one of the contract's own function
|
|
461
|
+
// members (the surface has no data members); the bound call is
|
|
462
|
+
// the real host's, typed at the call site.
|
|
463
|
+
const member = resolvedHost[property];
|
|
464
|
+
if (typeof member !== "function") {
|
|
465
|
+
throw new Error(`curation: member ${String(property)} is not callable`);
|
|
466
|
+
}
|
|
467
|
+
return member.apply(resolvedHost, [...args]);
|
|
468
|
+
};
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
},
|
|
359
472
|
// Gateway C4b: straight delegation (the bridge owns both bodies).
|
|
360
473
|
models: {
|
|
361
474
|
async list() {
|
|
362
|
-
return (await hostBridge()).models.list();
|
|
475
|
+
return (await hostBridge("models.list")).models.list();
|
|
363
476
|
},
|
|
364
477
|
},
|
|
365
478
|
commands: {
|
|
366
479
|
async list() {
|
|
367
|
-
return (await hostBridge()).commands.list();
|
|
480
|
+
return (await hostBridge("commands.list")).commands.list();
|
|
368
481
|
},
|
|
369
482
|
async run(conversationId, name, args, opts) {
|
|
370
|
-
return (await hostBridge()).commands.run(conversationId, name, args, opts);
|
|
483
|
+
return (await hostBridge("commands.run")).commands.run(conversationId, name, args, opts);
|
|
371
484
|
},
|
|
372
485
|
},
|
|
373
486
|
// The surfaces seam (UI-blocks U3 Task 4): `changed` awaits the
|
|
@@ -385,7 +498,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
385
498
|
// production (nothing sets it).
|
|
386
499
|
globalThis.__sigrunSurfaceChanged?.(host.name);
|
|
387
500
|
};
|
|
388
|
-
void hostBridge()
|
|
501
|
+
void hostBridge("surfaces.changed")
|
|
389
502
|
.then((bridge) => {
|
|
390
503
|
ran();
|
|
391
504
|
bridge.surfaces.changed(identity.name, id, key, update);
|
package/dist/harness/settings.js
CHANGED
|
@@ -368,8 +368,8 @@ function validate(value) {
|
|
|
368
368
|
if (builtins === undefined) {
|
|
369
369
|
throw new SettingsValidationError("builtinExtensions", "must be an object");
|
|
370
370
|
}
|
|
371
|
-
checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch", "gateway", "oauth"]);
|
|
372
|
-
for (const key of ["codemode", "mcp", "toolSearch", "gateway", "oauth"]) {
|
|
371
|
+
checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch", "gateway", "oauth", "curator"]);
|
|
372
|
+
for (const key of ["codemode", "mcp", "toolSearch", "gateway", "oauth", "curator"]) {
|
|
373
373
|
if (builtins[key] !== undefined && typeof builtins[key] !== "boolean") {
|
|
374
374
|
throw new SettingsValidationError(`builtinExtensions.${key}`, "must be a boolean");
|
|
375
375
|
}
|
package/dist/harness/tools.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { Type } from "@earendil-works/pi-ai";
|
|
30
30
|
import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-durable/tools";
|
|
31
31
|
import { defineExtension, defineTool } from "@earendil-works/pi-durable";
|
|
32
|
+
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
34
|
// Coding tools (read/write/edit/bash)
|
|
34
35
|
// ---------------------------------------------------------------------------
|
|
@@ -77,11 +78,80 @@ export const imageReadTool = defineTool({
|
|
|
77
78
|
return { output: [image] };
|
|
78
79
|
},
|
|
79
80
|
});
|
|
81
|
+
/**
|
|
82
|
+
* Wrap the read tool with learned-root usage counting. The wrapper
|
|
83
|
+
* resolves the conversation owner through the api's document reader
|
|
84
|
+
* (the SessionMeta doc — the A3 pattern), and the read's absolute path
|
|
85
|
+
* against the owner's learned root; a path inside it (a SKILL.md or a
|
|
86
|
+
* support file) bumps `read` for the owning skill. The wrapped result
|
|
87
|
+
* passes through unchanged.
|
|
88
|
+
*/
|
|
89
|
+
export function countReadUsage(seam, base = imageReadTool) {
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
execute: async (args, api, context) => {
|
|
93
|
+
const result = await base.execute(args, api, context);
|
|
94
|
+
void countLearnedRead(seam, args.path, api, context);
|
|
95
|
+
return result;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Bump `read` for a learned-root path, when it lands inside one. */
|
|
100
|
+
async function countLearnedRead(seam, path, api, context) {
|
|
101
|
+
try {
|
|
102
|
+
const meta = await api.snapshot(SessionMetaDoc, api.conversationId, context);
|
|
103
|
+
const owner = meta?.owner;
|
|
104
|
+
const counters = seam.countersOf(owner);
|
|
105
|
+
const root = seam.learnedRootOf(owner);
|
|
106
|
+
if (counters === undefined || root === undefined || api.env === undefined)
|
|
107
|
+
return;
|
|
108
|
+
const resolved = await api.env.absolutePath(path, context);
|
|
109
|
+
if (!resolved.ok)
|
|
110
|
+
return;
|
|
111
|
+
const skill = learnedSkillOf(resolved.value, root);
|
|
112
|
+
if (skill !== undefined) {
|
|
113
|
+
counters.bump(skill, "read");
|
|
114
|
+
void counters.flush();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Counting is advisory: no read ever fails because usage telemetry
|
|
119
|
+
// could not resolve the owner or the path.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The `skills/<name>/…` segment of a path inside a learned root; `undefined` outside. */
|
|
123
|
+
function learnedSkillOf(absolute, root) {
|
|
124
|
+
if (absolute === root || !absolute.startsWith(`${root}/`))
|
|
125
|
+
return undefined;
|
|
126
|
+
const rest = absolute.slice(root.length + 1).split("/");
|
|
127
|
+
const name = rest[0];
|
|
128
|
+
if (name === undefined || name === "")
|
|
129
|
+
return undefined;
|
|
130
|
+
return name;
|
|
131
|
+
}
|
|
80
132
|
/** `read`, `write`, `edit`, `bash` — core-installed for every conversation. */
|
|
81
133
|
export const codingToolsExtension = defineExtension({
|
|
82
134
|
name: "sigrun.coding-tools",
|
|
83
135
|
tools: [imageReadTool, createWriteTool(), createEditTool(), createBashTool()],
|
|
84
136
|
});
|
|
137
|
+
/**
|
|
138
|
+
* The coding tools with learned-root usage counting (curator A6, P4):
|
|
139
|
+
* with a {@link ReadUsageSeam} the `read` tool is wrapped so reads
|
|
140
|
+
* inside the conversation owner's learned root bump the owner's
|
|
141
|
+
* counters. Without one, the plain surface — the boot default until
|
|
142
|
+
* the curation wiring hands both seam halves.
|
|
143
|
+
*/
|
|
144
|
+
export function createCodingToolsExtension(options) {
|
|
145
|
+
return defineExtension({
|
|
146
|
+
name: "sigrun.coding-tools",
|
|
147
|
+
tools: [
|
|
148
|
+
options === undefined ? imageReadTool : countReadUsage(options),
|
|
149
|
+
createWriteTool(),
|
|
150
|
+
createEditTool(),
|
|
151
|
+
createBashTool(),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
85
155
|
// ---------------------------------------------------------------------------
|
|
86
156
|
// Search tools (find/grep/ls) — pure TS over ExecutionEnv
|
|
87
157
|
// ---------------------------------------------------------------------------
|
package/dist/jobs/fire.js
CHANGED
|
@@ -39,6 +39,7 @@ import { parseModelSpec } from "../config.js";
|
|
|
39
39
|
import { expandInput } from "../commands/expand.js";
|
|
40
40
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
41
41
|
import { noteThen } from "../server/turns/submit-turn.js";
|
|
42
|
+
import { recordAuthor } from "../curation/attribution.js";
|
|
42
43
|
import { JobRunDoc, patchJob, readJob } from "./docs.js";
|
|
43
44
|
/**
|
|
44
45
|
* Fire one job under `fireKey`. `fireAt` is the epoch ms the key pins
|
|
@@ -122,7 +123,9 @@ async function jobPromptText(deps, conversationId, prompt, context) {
|
|
|
122
123
|
if (conversation === undefined)
|
|
123
124
|
return prompt;
|
|
124
125
|
const env = await conversationEnv(deps, conversation);
|
|
125
|
-
|
|
126
|
+
// Curator A3: the learned root is the conversation owner's.
|
|
127
|
+
const meta = await deps.harness.snapshot(SessionMetaDoc, conversationId, context);
|
|
128
|
+
const expansion = await expandInput([{ type: "text", text: prompt }], deps.commandExpansion, env, context, meta?.owner);
|
|
126
129
|
const part = expansion.parts[0];
|
|
127
130
|
return part !== undefined && part.type === "text" ? part.text : prompt;
|
|
128
131
|
}
|
|
@@ -175,11 +178,21 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
175
178
|
}, context);
|
|
176
179
|
}
|
|
177
180
|
const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
|
|
178
|
-
await noteThen(deps.harness, conversation,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
await noteThen(deps.harness, conversation,
|
|
182
|
+
// Attribution (curator P1): the job's input is authored by the
|
|
183
|
+
// job's owner — `byRequest` before the admission, `bySubmission`
|
|
184
|
+
// after, exactly like submitOp (an ownerless job records nothing).
|
|
185
|
+
async () => {
|
|
186
|
+
const requestId = `job:${fireKey}`;
|
|
187
|
+
await recordAuthor(conversation, { requestId }, record.owner, context);
|
|
188
|
+
const submission = await conversation.submit({
|
|
189
|
+
type: "input",
|
|
190
|
+
content: [{ type: "text", text: prompt }],
|
|
191
|
+
requestId,
|
|
192
|
+
}, context);
|
|
193
|
+
await recordAuthor(conversation, { submissionId: submission.id }, record.owner, context);
|
|
194
|
+
return submission;
|
|
195
|
+
}, context);
|
|
183
196
|
return { outcome: "running", conversationId };
|
|
184
197
|
}
|
|
185
198
|
// conversation: "new" — the run record + conversation in ONE commit.
|
|
@@ -237,11 +250,20 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
237
250
|
return { outcome: "blocked" };
|
|
238
251
|
}
|
|
239
252
|
const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
|
|
240
|
-
await noteThen(deps.harness, conversation,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
253
|
+
await noteThen(deps.harness, conversation,
|
|
254
|
+
// Attribution (curator P1), the twin of the fixed-conversation site
|
|
255
|
+
// above: the job's owner authors the job's input.
|
|
256
|
+
async () => {
|
|
257
|
+
const requestId = `job:${fireKey}`;
|
|
258
|
+
await recordAuthor(conversation, { requestId }, record.owner, context);
|
|
259
|
+
const submission = await conversation.submit({
|
|
260
|
+
type: "input",
|
|
261
|
+
content: [{ type: "text", text: prompt }],
|
|
262
|
+
requestId,
|
|
263
|
+
}, context);
|
|
264
|
+
await recordAuthor(conversation, { submissionId: submission.id }, record.owner, context);
|
|
265
|
+
return submission;
|
|
266
|
+
}, context);
|
|
245
267
|
return { outcome: "running", conversationId };
|
|
246
268
|
}
|
|
247
269
|
/** `<job name> · <YYYY-MM-DD HH:mm in the job's timezone>` (the plan's name). */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core-side job skill-reference reader (curator P6, A9): the skill
|
|
3
|
+
* names a user's job PROMPTS reference — `/skill:name` tokens in every
|
|
4
|
+
* prompt action's text, from job records the durable scan sees (api,
|
|
5
|
+
* file, and extension sources alike), ENABLED AND PAUSED alike.
|
|
6
|
+
*
|
|
7
|
+
* The curator's diet step (Part B) needs this as the "job depends on
|
|
8
|
+
* this skill" signal: archiving a skill a paused job still references
|
|
9
|
+
* would break the job the moment it is re-enabled, so paused jobs count
|
|
10
|
+
* exactly as much as live ones.
|
|
11
|
+
*
|
|
12
|
+
* Pure read: one storage scan over the `sigrun.job` family (the same
|
|
13
|
+
* `scanJobs` primitive the jobs service's own reads use), filtered by
|
|
14
|
+
* owner, then a text scan for `/skill:name` tokens. No mutation, no
|
|
15
|
+
* task enumeration.
|
|
16
|
+
*/
|
|
17
|
+
import { scanJobs } from "./docs.js";
|
|
18
|
+
/** The `/skill:name` token grammar (the expansion path's own prefix). */
|
|
19
|
+
const SKILL_TOKEN = /(?:^|\s)\/skill:([A-Za-z0-9][A-Za-z0-9._-]*)/g;
|
|
20
|
+
/**
|
|
21
|
+
* Every skill name the user's job prompts reference (`/skill:name`
|
|
22
|
+
* tokens), paused jobs included. A user with no jobs (or an unknown
|
|
23
|
+
* user) reads an empty set — the scan is the truth, never a directory
|
|
24
|
+
* check.
|
|
25
|
+
*/
|
|
26
|
+
export async function jobSkillRefs(storage, user, ctx) {
|
|
27
|
+
const jobs = await scanJobs(storage, ctx);
|
|
28
|
+
const refs = new Set();
|
|
29
|
+
for (const { record } of jobs) {
|
|
30
|
+
if (record.owner !== user)
|
|
31
|
+
continue;
|
|
32
|
+
const action = record.spec.action;
|
|
33
|
+
if (action.kind !== "prompt")
|
|
34
|
+
continue;
|
|
35
|
+
for (const match of action.prompt.matchAll(SKILL_TOKEN)) {
|
|
36
|
+
refs.add(match[1]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return refs;
|
|
40
|
+
}
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
4
4
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
5
|
import { agentEnv } from "../harness/agent-env.js";
|
|
6
|
+
import { learnedSkillsDir } from "../curation/paths.js";
|
|
6
7
|
import { revalidate, validateGrantPath } from "./grants.js";
|
|
7
8
|
function canonical(path) {
|
|
8
9
|
try {
|
|
@@ -179,6 +180,16 @@ export function buildUserPolicy(record, ctx) {
|
|
|
179
180
|
continue;
|
|
180
181
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
181
182
|
}
|
|
183
|
+
// Curator A3: the owner's learned skills root exists and is granted
|
|
184
|
+
// read-only below. Created here (the designed empty case is an absent
|
|
185
|
+
// dir), validated like every other per-user path before entering
|
|
186
|
+
// policy — never another user's root.
|
|
187
|
+
const learned = learnedSkillsDir(userArea);
|
|
188
|
+
mkdirSync(learned, { recursive: true, mode: 0o700 });
|
|
189
|
+
const learnedValidation = validateUserPath(learned, record, ctx.managed);
|
|
190
|
+
if (!learnedValidation.ok) {
|
|
191
|
+
return invalidReason(learned, learnedValidation.reason);
|
|
192
|
+
}
|
|
182
193
|
const workspaceValidation = validateUserPath(requestedWorkspace, record, ctx.managed);
|
|
183
194
|
if (!workspaceValidation.ok) {
|
|
184
195
|
return invalidReason(requestedWorkspace, workspaceValidation.reason);
|
|
@@ -243,6 +254,9 @@ export function buildUserPolicy(record, ctx) {
|
|
|
243
254
|
...moduleTrees.paths,
|
|
244
255
|
defaultPackageConfig(ctx.managed.coreRoot),
|
|
245
256
|
...grants.flatMap((grant) => (grant.access === "ro" ? [grant.path] : [])),
|
|
257
|
+
// Curator A3: read access to the owner's own learned skills (support
|
|
258
|
+
// files are read with ordinary file tools); writes go through P3.
|
|
259
|
+
learnedValidation.realpath,
|
|
246
260
|
]);
|
|
247
261
|
const env = agentEnv();
|
|
248
262
|
delete env.SSH_AUTH_SOCK;
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* The surface's unnoted change records fold into `content` first and
|
|
14
14
|
* `notedThrough` advances BEFORE the §8.3 note step runs — so the turn
|
|
15
15
|
* sends no separate note for this surface, while changes on OTHER
|
|
16
|
-
* surfaces still note. `requestId` makes a double click one turn.
|
|
16
|
+
* surfaces still note. `requestId` makes a double click one turn. The
|
|
17
|
+
* acting `user` rides through to `submitOp` as the answer's author
|
|
18
|
+
* (curator P1).
|
|
17
19
|
*/
|
|
18
20
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
21
|
import { toText } from "@sigrun/shared/blocks";
|
|
@@ -30,14 +32,15 @@ import { BlocksStateDoc } from "../../harness/blocks/state.js";
|
|
|
30
32
|
* `skipNoteFor` (this surface notes no further this turn) and the
|
|
31
33
|
* answer entry. The route owns access gating and actionId dedup; this
|
|
32
34
|
* caller runs only for a located, write-accessible block whose handler
|
|
33
|
-
* starts a turn.
|
|
35
|
+
* starts a turn. `user` is the acting user (curator P1): recorded as the
|
|
36
|
+
* answer input's author, `undefined` for an unidentified caller.
|
|
34
37
|
*
|
|
35
38
|
* The fold and the `notedThrough` advance run in ONE commit BEFORE the
|
|
36
39
|
* note step (§8.4: "The surface's unnoted change records are folded into
|
|
37
40
|
* `content` first, and `notedThrough` advances, before the §8.3 note
|
|
38
41
|
* step runs"), so the answer turn never double-reports its surface.
|
|
39
42
|
*/
|
|
40
|
-
export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId) {
|
|
43
|
+
export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId, user) {
|
|
41
44
|
const { opened } = ctx;
|
|
42
45
|
const block = await readChatBlock(opened.harness, conversationId, entryId);
|
|
43
46
|
if (block === undefined) {
|
|
@@ -80,6 +83,9 @@ export async function answerBlock(ctx, conversationId, entryId, action, payload,
|
|
|
80
83
|
}, BACKGROUND_CONTEXT);
|
|
81
84
|
await submitOp(ctx, conversation, folded.content, {
|
|
82
85
|
...(actionId === undefined ? {} : { requestId: `blocks:${actionId}` }),
|
|
86
|
+
// Attribution (curator P1): the answering user authors the answer's
|
|
87
|
+
// input turn.
|
|
88
|
+
...(user === undefined ? {} : { user }),
|
|
83
89
|
skipNoteFor: entryId,
|
|
84
90
|
entry: {
|
|
85
91
|
kind: BlocksAnswerEntry.kind,
|