@sigrun/sigrun 0.8.0 → 0.9.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 +35 -87
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +268 -3
- package/dist/harness/boot.js +113 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +243 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +136 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/server/conversation-ops.js +31 -0
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +53 -1
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +58 -0
- package/node_modules/@sigrun/shared/package.json +6 -2
- package/package.json +6 -6
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/assets/index-DGIrLC6F.js +55 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B3uMNF59.css +0 -1
- package/webui/dist/assets/index-BXsCyod4.js +0 -55
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive tools (ask-user Task 3, spec §6): the helpers that hide an
|
|
3
|
+
* extension's `interactiveTools` — tool names that need a human present —
|
|
4
|
+
* from every NON-INTERACTIVE run core creates.
|
|
5
|
+
*
|
|
6
|
+
* Core owns the definition of a non-interactive conversation (spec §6):
|
|
7
|
+
* scheduled job run conversations and the curator's internal runs. In
|
|
8
|
+
* those conversations core leaves interactive tools out of the offered
|
|
9
|
+
* tools at creation time by removing them from the ENABLED-tools filter
|
|
10
|
+
* (pi-durable `AgentDoc.tools`): a removal there makes the tool neither
|
|
11
|
+
* offered to the model NOR reachable from nested `executeTool()` calls
|
|
12
|
+
* (the nested resolution reads the same enabled set), which no
|
|
13
|
+
* `modelTools` narrowing could give.
|
|
14
|
+
*
|
|
15
|
+
* Fork reviews deliberately do NOT pass through here — they copy the
|
|
16
|
+
* parent's stored selection for byte-identical prompt prefixes (cache
|
|
17
|
+
* parity), and the curation gate blocks an interactive call instead.
|
|
18
|
+
*
|
|
19
|
+
* Jobs that fire into an EXISTING conversation are not covered by this
|
|
20
|
+
* creation-time filter either (the conversation predates the fire);
|
|
21
|
+
* the tool stays listed and must guard its own execute with
|
|
22
|
+
* `host.jobs.isJobRun` (spec §6's option A).
|
|
23
|
+
*/
|
|
24
|
+
import { AgentDoc, LiveDoc } from "@earendil-works/pi-durable";
|
|
25
|
+
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
26
|
+
/**
|
|
27
|
+
* Remove every name in `names` from the conversation's stored
|
|
28
|
+
* `AgentDoc.tools` enabled filter, preserving the stored shape:
|
|
29
|
+
* - unset → `{remove: [...names].sort()}`;
|
|
30
|
+
* - an object → `remove ∪ names` (sorted);
|
|
31
|
+
* - an array → the array without the names.
|
|
32
|
+
*
|
|
33
|
+
* A no-op when `names` is empty (the no-interactive-extension deploy
|
|
34
|
+
* writes nothing). Runs inside an existing commit (the callers are the
|
|
35
|
+
* creating commits and the server create op's post-create stamp commit);
|
|
36
|
+
* document access only — no table reads.
|
|
37
|
+
*/
|
|
38
|
+
export async function excludeInteractiveTools(tx, conversationId, names) {
|
|
39
|
+
if (names.size === 0)
|
|
40
|
+
return;
|
|
41
|
+
const agent = await tx.doc(AgentDoc, conversationId);
|
|
42
|
+
const stored = agent.tools;
|
|
43
|
+
if (stored === undefined) {
|
|
44
|
+
agent.tools = { remove: [...names].sort() };
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(stored)) {
|
|
48
|
+
agent.tools = stored.filter((name) => !names.has(name));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
agent.tools = {
|
|
52
|
+
remove: [...new Set([...stored.remove, ...names])].sort(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether the conversation's CURRENT run is job-driven (ask-user Task 3,
|
|
57
|
+
* spec §6): true when the session meta carries a `jobId` (a
|
|
58
|
+
* `conversation: "new"` run — the stamp persists past the run's settle,
|
|
59
|
+
* which is fine: every run of that conversation is a job run), or any
|
|
60
|
+
* input of the conversation's current run (`LiveDoc` `run.inputs`,
|
|
61
|
+
* present exactly while busy) resolves to a submission whose requestId
|
|
62
|
+
* carries the job prefix (`job:<fireKey>`, stamped by `jobs/fire.ts` on
|
|
63
|
+
* BOTH fire shapes — that branch covers a job firing into an EXISTING
|
|
64
|
+
* conversation, which carries no `jobId`). Interactive tools call this in
|
|
65
|
+
* execute and refuse instead of waiting on a human.
|
|
66
|
+
*/
|
|
67
|
+
export async function isJobRunConversation(harness, conversationId, context) {
|
|
68
|
+
const meta = await harness.snapshot(SessionMetaDoc, conversationId, context);
|
|
69
|
+
if (meta?.jobId !== undefined)
|
|
70
|
+
return true;
|
|
71
|
+
const live = await harness.snapshot(LiveDoc, conversationId, context);
|
|
72
|
+
for (const id of live?.run?.inputs ?? []) {
|
|
73
|
+
const submission = await harness.submission(id, context);
|
|
74
|
+
if (submission === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
const record = await submission.status(context);
|
|
77
|
+
if (record.requestId?.startsWith("job:"))
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Filter `list` to the names outside `names` (a curator run's `allow`
|
|
84
|
+
* list losing its interactive members). Never fails — stripping must not
|
|
85
|
+
* let installing an interactive-tool extension break unrelated curation
|
|
86
|
+
* targets — but warns ONCE per call naming the removed tools and `where`
|
|
87
|
+
* (an all-interactive list then still hits the existing
|
|
88
|
+
* non-empty-`allow` check, loudly). Returns a fresh array in every case
|
|
89
|
+
* (the tracker requires alias-free roots).
|
|
90
|
+
*/
|
|
91
|
+
export function withoutInteractive(list, names, warn, where) {
|
|
92
|
+
const removed = list.filter((name) => names.has(name));
|
|
93
|
+
if (removed.length === 0)
|
|
94
|
+
return [...list];
|
|
95
|
+
warn(`interactive tools [${removed.join(", ")}] removed from the ${where} allow list — they need a human present`);
|
|
96
|
+
return list.filter((name) => !names.has(name));
|
|
97
|
+
}
|
package/dist/harness/loader.js
CHANGED
|
@@ -92,7 +92,9 @@ import { fileURLToPath } from "node:url";
|
|
|
92
92
|
import { satisfies, validRange } from "semver";
|
|
93
93
|
import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
|
|
94
94
|
import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
|
|
95
|
+
import { Compile } from "typebox/compile";
|
|
95
96
|
import { slugifyName } from "./stores.js";
|
|
97
|
+
import { PUBLISHED_HOOK_NAME, PUBLISHED_HOOK_NAME_MAX_CHARS, } from "./published-hooks.js";
|
|
96
98
|
import { wrapExtensionCurationTools } from "../curation/wrap.js";
|
|
97
99
|
/** Every lifecycle field name, in validation order (mirrors the contract doc). */
|
|
98
100
|
const LIFECYCLE_FIELDS = [
|
|
@@ -142,6 +144,29 @@ function validateKinds(value) {
|
|
|
142
144
|
}
|
|
143
145
|
return null;
|
|
144
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Validate the `interactiveTools` declaration (ask-user Task 3, spec §6):
|
|
149
|
+
* an array of non-empty strings, every one naming a tool the extension's
|
|
150
|
+
* OWN `extension.tools` registers (hiding a tool the extension never
|
|
151
|
+
* registered is a declaration bug — it quarantines, loudly).
|
|
152
|
+
*/
|
|
153
|
+
function validateInteractiveTools(value, extension) {
|
|
154
|
+
if (value === undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
if (!Array.isArray(value)) {
|
|
157
|
+
throw new Error("interactiveTools must be an array of strings");
|
|
158
|
+
}
|
|
159
|
+
if (value.some((name) => typeof name !== "string" || name.length === 0)) {
|
|
160
|
+
throw new Error("interactiveTools must be an array of non-empty strings");
|
|
161
|
+
}
|
|
162
|
+
const registered = new Set(extension?.tools?.map((tool) => tool.name) ?? []);
|
|
163
|
+
for (const name of value) {
|
|
164
|
+
if (!registered.has(name)) {
|
|
165
|
+
throw new Error(`interactiveTools names "${name}", which this extension does not register`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
145
170
|
/**
|
|
146
171
|
* The lifecycle event names a worker's `wakeOn` may declare (memory-seam
|
|
147
172
|
* Task 5): `harnessShutdown` is excluded — workers stop BEFORE the shutdown
|
|
@@ -262,6 +287,122 @@ function validateNoticeActions(value) {
|
|
|
262
287
|
}
|
|
263
288
|
return actions;
|
|
264
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Validate a factory result's `publishedHooks` declaration (knowledge C7).
|
|
292
|
+
* Absent → undefined (no hooks). Present but malformed → throw; the loader
|
|
293
|
+
* quarantines the extension with the thrown message (fail-loud: an
|
|
294
|
+
* extension that THINKS it published a hook must not silently lose it).
|
|
295
|
+
* Per entry: a string `name` matching {@link PUBLISHED_HOOK_NAME} (and the
|
|
296
|
+
* length bound), no intra-extension duplicates, `input` an object, `run` a
|
|
297
|
+
* function — plus a `Compile` of both schemas, which is what makes the
|
|
298
|
+
* registry build's compile-throw unreachable in production.
|
|
299
|
+
*/
|
|
300
|
+
function validatePublishedHooks(value) {
|
|
301
|
+
if (value === undefined)
|
|
302
|
+
return undefined;
|
|
303
|
+
if (!Array.isArray(value)) {
|
|
304
|
+
throw new Error("extension factory returned a publishedHooks field that is not an array");
|
|
305
|
+
}
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const entry of value) {
|
|
308
|
+
const hook = entry;
|
|
309
|
+
const name = hook?.name;
|
|
310
|
+
if (typeof name !== "string" ||
|
|
311
|
+
name.length > PUBLISHED_HOOK_NAME_MAX_CHARS ||
|
|
312
|
+
!PUBLISHED_HOOK_NAME.test(name)) {
|
|
313
|
+
throw new Error(`extension factory returned a published hook with an invalid name ${JSON.stringify(name)} (must match ${PUBLISHED_HOOK_NAME.source}, ≤${PUBLISHED_HOOK_NAME_MAX_CHARS} chars)`);
|
|
314
|
+
}
|
|
315
|
+
if (seen.has(name)) {
|
|
316
|
+
throw new Error(`extension factory returned a duplicate published hook name "${name}"`);
|
|
317
|
+
}
|
|
318
|
+
seen.add(name);
|
|
319
|
+
if (typeof hook?.input !== "object" ||
|
|
320
|
+
hook.input === null ||
|
|
321
|
+
Array.isArray(hook.input)) {
|
|
322
|
+
throw new Error(`extension factory returned a published hook "${name}" whose input is not a schema object`);
|
|
323
|
+
}
|
|
324
|
+
if (hook.output !== undefined && (typeof hook.output !== "object" || hook.output === null)) {
|
|
325
|
+
throw new Error(`extension factory returned a published hook "${name}" whose output is not a schema object`);
|
|
326
|
+
}
|
|
327
|
+
if (typeof hook?.run !== "function") {
|
|
328
|
+
throw new Error(`extension factory returned a published hook "${name}" whose run is not a function`);
|
|
329
|
+
}
|
|
330
|
+
// Compile ONCE here: a schema that fails to compile quarantines the
|
|
331
|
+
// extension at load, so the registry build never sees one.
|
|
332
|
+
try {
|
|
333
|
+
Compile(hook.input);
|
|
334
|
+
if (hook.output !== undefined)
|
|
335
|
+
Compile(hook.output);
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
throw new Error(`extension factory returned a published hook "${name}" whose schema fails to compile: ${err instanceof Error ? err.message : String(err)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
/** The decision-provider id grammar (warden Task 6): also the auth.json key. */
|
|
344
|
+
const DECISION_PROVIDER_ID = /^[a-z][a-z0-9-]*$/;
|
|
345
|
+
/** The decision-provider id length bound. */
|
|
346
|
+
const DECISION_PROVIDER_ID_MAX_CHARS = 64;
|
|
347
|
+
/** The decision-model id grammar (the `"<provider>/<model>"` spec's second half). */
|
|
348
|
+
const DECISION_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
349
|
+
/** The `credentialEnv` grammar: a process-env NAME. */
|
|
350
|
+
const DECISION_CREDENTIAL_ENV = /^[A-Z][A-Z0-9_]*$/;
|
|
351
|
+
/**
|
|
352
|
+
* Validate a factory result's `decisionProviders` declaration (warden
|
|
353
|
+
* Task 6). Absent → undefined (no contributions). Present but malformed
|
|
354
|
+
* → throw; the loader quarantines the extension with the thrown message
|
|
355
|
+
* (fail-loud: an extension that THINKS it contributed a decision
|
|
356
|
+
* provider must not silently lose it — the providers/health class).
|
|
357
|
+
* Per entry: a string `id` matching {@link DECISION_PROVIDER_ID} (≤64
|
|
358
|
+
* chars), at least one model whose ids match {@link DECISION_MODEL_ID}
|
|
359
|
+
* with no intra-provider duplicates, `evaluate` a function, `readiness`
|
|
360
|
+
* absent or a function, and `credentialEnv` absent or a NAME.
|
|
361
|
+
*/
|
|
362
|
+
function validateDecisionProviders(value) {
|
|
363
|
+
if (value === undefined)
|
|
364
|
+
return undefined;
|
|
365
|
+
if (!Array.isArray(value)) {
|
|
366
|
+
throw new Error("extension factory returned a decisionProviders field that is not an array");
|
|
367
|
+
}
|
|
368
|
+
for (const entry of value) {
|
|
369
|
+
const provider = entry;
|
|
370
|
+
if (typeof provider?.id !== "string" ||
|
|
371
|
+
provider.id.length > DECISION_PROVIDER_ID_MAX_CHARS ||
|
|
372
|
+
!DECISION_PROVIDER_ID.test(provider.id)) {
|
|
373
|
+
throw new Error(`extension factory returned a decision provider with an invalid id ${JSON.stringify(provider?.id)} (must match ${DECISION_PROVIDER_ID.source}, ≤${DECISION_PROVIDER_ID_MAX_CHARS} chars)`);
|
|
374
|
+
}
|
|
375
|
+
if (!Array.isArray(provider.models) ||
|
|
376
|
+
provider.models.length === 0) {
|
|
377
|
+
throw new Error(`extension factory returned a decision provider "${provider.id}" with no models`);
|
|
378
|
+
}
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
for (const model of provider.models) {
|
|
381
|
+
const modelId = model?.id;
|
|
382
|
+
if (typeof modelId !== "string" ||
|
|
383
|
+
!DECISION_MODEL_ID.test(modelId)) {
|
|
384
|
+
throw new Error(`extension factory returned a decision provider "${provider.id}" with an invalid model id ${JSON.stringify(modelId)} (must match ${DECISION_MODEL_ID.source})`);
|
|
385
|
+
}
|
|
386
|
+
if (seen.has(modelId)) {
|
|
387
|
+
throw new Error(`extension factory returned a decision provider "${provider.id}" with a duplicate model id "${modelId}"`);
|
|
388
|
+
}
|
|
389
|
+
seen.add(modelId);
|
|
390
|
+
}
|
|
391
|
+
if (typeof provider.evaluate !== "function") {
|
|
392
|
+
throw new Error(`extension factory returned a decision provider "${provider.id}" whose evaluate is not a function`);
|
|
393
|
+
}
|
|
394
|
+
if (provider.readiness !== undefined &&
|
|
395
|
+
typeof provider.readiness !== "function") {
|
|
396
|
+
throw new Error(`extension factory returned a decision provider "${provider.id}" whose readiness is not a function`);
|
|
397
|
+
}
|
|
398
|
+
if (provider.credentialEnv !== undefined &&
|
|
399
|
+
(typeof provider.credentialEnv !== "string" ||
|
|
400
|
+
!DECISION_CREDENTIAL_ENV.test(provider.credentialEnv))) {
|
|
401
|
+
throw new Error(`extension factory returned a decision provider "${provider.id}" with an invalid credentialEnv ${JSON.stringify(provider.credentialEnv)} (must match ${DECISION_CREDENTIAL_ENV.source})`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return value;
|
|
405
|
+
}
|
|
265
406
|
function validateCurationTargets(value) {
|
|
266
407
|
if (value === undefined)
|
|
267
408
|
return undefined;
|
|
@@ -290,6 +431,29 @@ function validateCurationTargets(value) {
|
|
|
290
431
|
typeof target.maintain !== "function") {
|
|
291
432
|
throw new Error(`extension factory returned a curation target "${name}" whose maintain is not a function`);
|
|
292
433
|
}
|
|
434
|
+
// C5: readTools — an array of non-empty strings DISJOINT from
|
|
435
|
+
// tools (a read tool is never wrapped; a name on both lists is a
|
|
436
|
+
// contradiction). The registered-by-the-declaring-extension check
|
|
437
|
+
// lives in the load loop (no registry here).
|
|
438
|
+
const readTools = target?.readTools;
|
|
439
|
+
if (readTools !== undefined) {
|
|
440
|
+
if (!Array.isArray(readTools) ||
|
|
441
|
+
readTools.some((tool) => typeof tool !== "string" || tool.length === 0)) {
|
|
442
|
+
throw new Error(`extension factory returned a curation target "${name}" whose readTools is not an array of non-empty strings`);
|
|
443
|
+
}
|
|
444
|
+
const overlap = readTools.find((tool) => tools.includes(tool));
|
|
445
|
+
if (overlap !== undefined) {
|
|
446
|
+
throw new Error(`extension factory returned a curation target "${name}" whose readTools names "${overlap}" — a tool is either listed (wrapped) or read (never wrapped), never both`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (target?.wantsReview !== undefined &&
|
|
450
|
+
typeof target.wantsReview !== "function") {
|
|
451
|
+
throw new Error(`extension factory returned a curation target "${name}" whose wantsReview is not a function`);
|
|
452
|
+
}
|
|
453
|
+
if (target?.reviewed !== undefined &&
|
|
454
|
+
typeof target.reviewed !== "function") {
|
|
455
|
+
throw new Error(`extension factory returned a curation target "${name}" whose reviewed is not a function`);
|
|
456
|
+
}
|
|
293
457
|
targets.push(entry);
|
|
294
458
|
}
|
|
295
459
|
return targets;
|
|
@@ -518,6 +682,18 @@ function validateSurfaces(value) {
|
|
|
518
682
|
typeof def.sharedData !== "boolean") {
|
|
519
683
|
throw new Error(`extension factory returned a surface "${def.id}" whose sharedData is not a boolean`);
|
|
520
684
|
}
|
|
685
|
+
if (def?.perConversation !== undefined &&
|
|
686
|
+
typeof def.perConversation !== "boolean") {
|
|
687
|
+
throw new Error(`extension factory returned a surface "${def.id}" whose perConversation is not a boolean`);
|
|
688
|
+
}
|
|
689
|
+
// The flag is a PANEL property: a view cannot be attached to a
|
|
690
|
+
// conversation, so declaring it there is a declaration the host
|
|
691
|
+
// would silently ignore — fail loud. (The settings-section list is
|
|
692
|
+
// validated separately by `validateSettingsSections`, which does
|
|
693
|
+
// not read this flag.)
|
|
694
|
+
if (def?.perConversation !== undefined && expected !== "panel") {
|
|
695
|
+
throw new Error(`extension factory returned a surface "${def.id}" declares perConversation but is not a panel`);
|
|
696
|
+
}
|
|
521
697
|
if (def?.actions !== undefined) {
|
|
522
698
|
if (typeof def.actions !== "object" ||
|
|
523
699
|
def.actions === null ||
|
|
@@ -1209,6 +1385,9 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1209
1385
|
throw new Error("extension factory returned a perUserTools field that is not a boolean");
|
|
1210
1386
|
}
|
|
1211
1387
|
const providers = validateProviders(candidate.providers);
|
|
1388
|
+
// Decision providers (warden Task 6): validated with the other
|
|
1389
|
+
// contribution fields, BEFORE the contributor-only check reads them.
|
|
1390
|
+
const decisionProviders = validateDecisionProviders(candidate.decisionProviders);
|
|
1212
1391
|
const loginProviders = validateLoginProviders(candidate.loginProviders);
|
|
1213
1392
|
const webuiBundles = validateWebuiBundles(candidate.webuiBundles);
|
|
1214
1393
|
const jobs = validateJobs(candidate.jobs);
|
|
@@ -1237,7 +1416,8 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1237
1416
|
loginProviders === undefined &&
|
|
1238
1417
|
webuiBundles === undefined &&
|
|
1239
1418
|
channels === undefined &&
|
|
1240
|
-
userServices === undefined
|
|
1419
|
+
userServices === undefined &&
|
|
1420
|
+
decisionProviders === undefined) {
|
|
1241
1421
|
return undefined;
|
|
1242
1422
|
}
|
|
1243
1423
|
if (typeof candidate.name !== "string" || candidate.name.length === 0) {
|
|
@@ -1271,6 +1451,11 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1271
1451
|
validateKinds(candidate.ambientKinds);
|
|
1272
1452
|
if (kindsProblem !== null)
|
|
1273
1453
|
throw new Error(kindsProblem);
|
|
1454
|
+
// Interactive tools (ask-user Task 3, spec §6): an array of non-empty
|
|
1455
|
+
// strings, each naming a tool the extension's OWN `extension.tools`
|
|
1456
|
+
// registers — hiding a tool the extension never registered is a
|
|
1457
|
+
// declaration bug that must quarantine, never silently no-op.
|
|
1458
|
+
const interactiveTools = validateInteractiveTools(candidate.interactiveTools, extension);
|
|
1274
1459
|
// The settings-change hook (U4 §13.2): present-but-not-a-function is
|
|
1275
1460
|
// the same contract violation class as the lifecycle fields — an
|
|
1276
1461
|
// extension that THINKS it observes settings changes must not
|
|
@@ -1288,6 +1473,12 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1288
1473
|
// Notice actions (notices Task 3): a plain object of name → handler,
|
|
1289
1474
|
// every name grammar-checked; a malformed entry quarantines.
|
|
1290
1475
|
const noticeActions = validateNoticeActions(candidate.noticeActions);
|
|
1476
|
+
// Published hooks (knowledge C7): validated HERE (before the slug
|
|
1477
|
+
// exists — ownership and shadowing are registry concerns, never
|
|
1478
|
+
// load-failures); the declarations attribute to the slug at
|
|
1479
|
+
// collection. A malformed entry or a schema that fails to compile
|
|
1480
|
+
// quarantines the extension.
|
|
1481
|
+
const publishedHooks = validatePublishedHooks(candidate.publishedHooks);
|
|
1291
1482
|
return {
|
|
1292
1483
|
...(candidate.perUserTools === undefined
|
|
1293
1484
|
? {}
|
|
@@ -1324,12 +1515,15 @@ async function resolvedFromFactory(factory, hostContext) {
|
|
|
1324
1515
|
...(candidate.interactionKinds === undefined
|
|
1325
1516
|
? {}
|
|
1326
1517
|
: { interactionKinds: candidate.interactionKinds }),
|
|
1518
|
+
...(interactiveTools === undefined ? {} : { interactiveTools }),
|
|
1327
1519
|
...(candidate.ambientKinds === undefined
|
|
1328
1520
|
? {}
|
|
1329
1521
|
: { ambientKinds: candidate.ambientKinds }),
|
|
1330
1522
|
...(workers === undefined ? {} : { workers }),
|
|
1331
1523
|
...(curationTargets === undefined ? {} : { curationTargets }),
|
|
1332
1524
|
...(noticeActions === undefined ? {} : { noticeActions }),
|
|
1525
|
+
...(publishedHooks === undefined ? {} : { publishedHooks }),
|
|
1526
|
+
...(decisionProviders === undefined ? {} : { decisionProviders }),
|
|
1333
1527
|
...(providers === undefined ? {} : { providers }),
|
|
1334
1528
|
...(loginProviders === undefined ? {} : { loginProviders }),
|
|
1335
1529
|
...(webuiBundles === undefined ? {} : { webuiBundles }),
|
|
@@ -1529,10 +1723,21 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1529
1723
|
// these into the ONE CurationRegistry (src/curation/targets.ts).
|
|
1530
1724
|
const curationBindings = [];
|
|
1531
1725
|
const noticeActionBindings = [];
|
|
1726
|
+
// Published hooks (knowledge C7): flat declarations, in load order,
|
|
1727
|
+
// slug-attributed with the built-in flag — boot folds them into the
|
|
1728
|
+
// registry AFTER `loadExtensions` returns (ownership: configured before
|
|
1729
|
+
// built-ins, first per name wins — the registry's own ordering rule).
|
|
1730
|
+
const publishedHookDeclarations = [];
|
|
1731
|
+
// Decision-provider declarations (warden Task 6), slug-attributed —
|
|
1732
|
+
// boot folds them into the ONE DecisionRegistry.
|
|
1733
|
+
const decisionProviderDeclarations = [];
|
|
1532
1734
|
const userServiceBindings = [];
|
|
1533
1735
|
// Jobs Task 3: declared jobs, attributed to the extension slug (the
|
|
1534
1736
|
// `ext:<slug>:<name>` id's namespace).
|
|
1535
1737
|
const jobBindings = [];
|
|
1738
|
+
// Interactive tool names (ask-user Task 3): the flat union over loaded
|
|
1739
|
+
// extensions (boot folds it into the one ReadonlySet on OpenedHarness).
|
|
1740
|
+
const interactiveTools = [];
|
|
1536
1741
|
// HTTP routes (W5 Task 2), attributed to the slug like the job bindings.
|
|
1537
1742
|
const routeBindings = [];
|
|
1538
1743
|
// Slash commands (W5 Task 3), attributed the same way.
|
|
@@ -1821,6 +2026,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1821
2026
|
if (unregistered !== undefined) {
|
|
1822
2027
|
throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
|
|
1823
2028
|
}
|
|
2029
|
+
// C5: the same check for readTools (never wrapped, never fed
|
|
2030
|
+
// to the wrap feed — only registered, so the served filter and
|
|
2031
|
+
// the review gate see a real tool).
|
|
2032
|
+
const unregisteredRead = (target.readTools ?? []).find((tool) => !registered.has(tool));
|
|
2033
|
+
if (unregisteredRead !== undefined) {
|
|
2034
|
+
throw new Error(`extension "${slug}" declares curation target "${target.name}" listing read tool "${unregisteredRead}" it does not register`);
|
|
2035
|
+
}
|
|
1824
2036
|
// A tool names ONE target (A8): two targets — across any
|
|
1825
2037
|
// extensions — listing the same tool leave `byTool`
|
|
1826
2038
|
// first-wins arbitrary and the shadowed target's undos
|
|
@@ -1936,6 +2148,26 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1936
2148
|
actions: resolved.noticeActions,
|
|
1937
2149
|
});
|
|
1938
2150
|
}
|
|
2151
|
+
// Published hooks (knowledge C7): collected AFTER the install
|
|
2152
|
+
// — a quarantined extension never reaches here, so every
|
|
2153
|
+
// declaration in the list comes from a LOADED extension.
|
|
2154
|
+
for (const hook of resolved.publishedHooks ?? []) {
|
|
2155
|
+
publishedHookDeclarations.push({
|
|
2156
|
+
extension: slug,
|
|
2157
|
+
builtin: builtin === true,
|
|
2158
|
+
hook,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
// Decision providers (warden Task 6): collected AFTER the install
|
|
2162
|
+
// — a quarantined extension never reaches here, so every
|
|
2163
|
+
// declaration comes from a LOADED extension. The slug (not the
|
|
2164
|
+
// declared contribution id) is the attribution.
|
|
2165
|
+
for (const contribution of resolved.decisionProviders ?? []) {
|
|
2166
|
+
decisionProviderDeclarations.push({
|
|
2167
|
+
extension: slug,
|
|
2168
|
+
contribution,
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
1939
2171
|
if (resolved.curationTargets !== undefined) {
|
|
1940
2172
|
curationBindings.push({
|
|
1941
2173
|
extension: slug,
|
|
@@ -1951,6 +2183,13 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
1951
2183
|
if (resolved.jobs !== undefined) {
|
|
1952
2184
|
jobBindings.push({ extension: slug, jobs: resolved.jobs });
|
|
1953
2185
|
}
|
|
2186
|
+
// Interactive tools (ask-user Task 3): collected AFTER the
|
|
2187
|
+
// install, slug-attributed — a quarantined extension never
|
|
2188
|
+
// reaches here, so the union boot folds comes from LOADED
|
|
2189
|
+
// extensions only.
|
|
2190
|
+
if (resolved.interactiveTools !== undefined) {
|
|
2191
|
+
interactiveTools.push(...resolved.interactiveTools);
|
|
2192
|
+
}
|
|
1954
2193
|
if (resolved.userServices !== undefined) {
|
|
1955
2194
|
userServiceBindings.push({
|
|
1956
2195
|
extension: slug,
|
|
@@ -2067,7 +2306,10 @@ export async function loadExtensions(specifiers, registry, options = {}) {
|
|
|
2067
2306
|
workerBindings,
|
|
2068
2307
|
curationBindings,
|
|
2069
2308
|
noticeActionBindings,
|
|
2309
|
+
publishedHookDeclarations,
|
|
2310
|
+
decisionProviderDeclarations,
|
|
2070
2311
|
jobBindings,
|
|
2312
|
+
interactiveTools,
|
|
2071
2313
|
routeBindings,
|
|
2072
2314
|
commandBindings,
|
|
2073
2315
|
notifierBindings,
|
package/dist/harness/packages.js
CHANGED
|
@@ -84,6 +84,13 @@ export const HOST_RUNTIME_MODULES = [
|
|
|
84
84
|
"@earendil-works/pi-ai",
|
|
85
85
|
"@earendil-works/pi-durable",
|
|
86
86
|
"@earendil-works/chord",
|
|
87
|
+
// @sigrun/ext-codemode imports CodemodeSandbox at RUNTIME. Core does not
|
|
88
|
+
// depend on pi-codemode (it arrives transitively via pi-durable), so a
|
|
89
|
+
// packages-dir install of the extension could not resolve it — the
|
|
90
|
+
// extension quarantined with "Cannot find package
|
|
91
|
+
// '@earendil-works/pi-codemode'". Linked like the other Earendil host
|
|
92
|
+
// modules so the extension resolves the copy the host actually runs.
|
|
93
|
+
"@earendil-works/pi-codemode",
|
|
87
94
|
"typebox",
|
|
88
95
|
// The shared workspace is host-shipped too (wire + access contracts
|
|
89
96
|
// every extension family type against; @sigrun/recall is the first
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 1 (knowledge plan): the published-hook registry — the pure core of
|
|
3
|
+
* the extension-to-extension call seam. Given the loader's declarations
|
|
4
|
+
* (configured extensions first, then built-ins; first per name owns), it
|
|
5
|
+
* builds a registry whose `call` validates the payload against the
|
|
6
|
+
* provider's compiled input schema, resolves the acting-user identity,
|
|
7
|
+
* runs the provider under a linked abort controller plus a deadline, and
|
|
8
|
+
* bounds payload size, result size, and recursion depth. `call` NEVER
|
|
9
|
+
* throws; validators compile ONCE here at build, and a schema that fails
|
|
10
|
+
* `Compile` throws FROM THE BUILD (Task 2 turns that into a load-time
|
|
11
|
+
* quarantine, so the build never sees one in production).
|
|
12
|
+
*/
|
|
13
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
import { Compile } from "typebox/compile";
|
|
15
|
+
/** Legal hook names: dotted lowercase segments (`memory.recall`). */
|
|
16
|
+
export const PUBLISHED_HOOK_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
17
|
+
/** Hook names are also bounded in length. */
|
|
18
|
+
export const PUBLISHED_HOOK_NAME_MAX_CHARS = 128;
|
|
19
|
+
/** Maximum hook-call nesting before `failed/too-deep`. */
|
|
20
|
+
export const PUBLISHED_HOOK_MAX_DEPTH = 4;
|
|
21
|
+
/** Payload and result size cap (1 MiB of JSON). */
|
|
22
|
+
export const PUBLISHED_HOOK_MAX_BYTES = 1_048_576;
|
|
23
|
+
const depthStorage = new AsyncLocalStorage();
|
|
24
|
+
function errorMessage(err) {
|
|
25
|
+
return err instanceof Error ? err.message : String(err);
|
|
26
|
+
}
|
|
27
|
+
function describeErrors(validator, value) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const err of validator.Errors(value)) {
|
|
30
|
+
out.push({ path: err.instancePath, message: err.message ?? "invalid" });
|
|
31
|
+
}
|
|
32
|
+
return out.length > 0 ? out : [{ path: "", message: "invalid" }];
|
|
33
|
+
}
|
|
34
|
+
export function buildPublishedHookRegistry(declarations, deps) {
|
|
35
|
+
// Ownership: the declarations arrive in loader order; configured
|
|
36
|
+
// extensions sort BEFORE built-ins regardless of arrival order, and
|
|
37
|
+
// the first per name owns it. Shadowed providers record under the
|
|
38
|
+
// owner (configured losers BEFORE built-in losers) and each loser
|
|
39
|
+
// warns once.
|
|
40
|
+
const ordered = [...declarations].sort((a, b) => Number(a.builtin) - Number(b.builtin));
|
|
41
|
+
const owners = new Map();
|
|
42
|
+
const shadowedBy = new Map();
|
|
43
|
+
for (const declaration of ordered) {
|
|
44
|
+
const hook = declaration.hook;
|
|
45
|
+
const existing = owners.get(hook.name);
|
|
46
|
+
if (existing) {
|
|
47
|
+
const owner = existing.declaration.extension;
|
|
48
|
+
const loser = declaration.extension;
|
|
49
|
+
const advice = declaration.builtin
|
|
50
|
+
? `replace the built-in ${loser} by package name, or remove ${owner}`
|
|
51
|
+
: "reorder extensions[] to switch";
|
|
52
|
+
deps.warn(`published hook "${hook.name}": owned by ${owner}, shadowed ${loser} — ${advice}`);
|
|
53
|
+
const list = shadowedBy.get(hook.name);
|
|
54
|
+
if (list)
|
|
55
|
+
list.push(loser);
|
|
56
|
+
else
|
|
57
|
+
shadowedBy.set(hook.name, [loser]);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
owners.set(hook.name, {
|
|
61
|
+
declaration,
|
|
62
|
+
input: Compile(hook.input),
|
|
63
|
+
output: hook.output !== undefined ? Compile(hook.output) : undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function call(caller, name, payload, opts = {}) {
|
|
67
|
+
// Depth is checked BEFORE ownership lookup so a nested call under
|
|
68
|
+
// an unknown name still reports too-deep rather than absent.
|
|
69
|
+
const depth = depthStorage.getStore() ?? 0;
|
|
70
|
+
if (depth >= PUBLISHED_HOOK_MAX_DEPTH) {
|
|
71
|
+
return {
|
|
72
|
+
status: "failed",
|
|
73
|
+
provider: owners.get(name)?.declaration.extension ?? "?",
|
|
74
|
+
reason: "too-deep",
|
|
75
|
+
error: `published hook depth ${depth + 1} exceeds ${PUBLISHED_HOOK_MAX_DEPTH}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const entry = owners.get(name);
|
|
79
|
+
if (!entry)
|
|
80
|
+
return { status: "absent" };
|
|
81
|
+
const provider = entry.declaration.extension;
|
|
82
|
+
let serialized;
|
|
83
|
+
try {
|
|
84
|
+
serialized = JSON.stringify(payload);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
status: "rejected",
|
|
89
|
+
provider,
|
|
90
|
+
errors: [{ path: "", message: `payload is not JSON-serializable: ${errorMessage(err)}` }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (serialized.length > PUBLISHED_HOOK_MAX_BYTES) {
|
|
94
|
+
return {
|
|
95
|
+
status: "rejected",
|
|
96
|
+
provider,
|
|
97
|
+
errors: [
|
|
98
|
+
{
|
|
99
|
+
path: "",
|
|
100
|
+
message: `payload is ${serialized.length} bytes; the cap is ${PUBLISHED_HOOK_MAX_BYTES}`,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!entry.input.Check(payload)) {
|
|
106
|
+
return { status: "rejected", provider, errors: describeErrors(entry.input, payload) };
|
|
107
|
+
}
|
|
108
|
+
let user;
|
|
109
|
+
if (opts.conversationId !== undefined) {
|
|
110
|
+
user = await deps.actingUser(opts.conversationId);
|
|
111
|
+
if (opts.user !== undefined && opts.user !== user) {
|
|
112
|
+
return {
|
|
113
|
+
status: "rejected",
|
|
114
|
+
provider,
|
|
115
|
+
errors: [
|
|
116
|
+
{
|
|
117
|
+
path: "user",
|
|
118
|
+
message: `caller declares user "${opts.user}" but the conversation's acting user is "${user ?? "unknown"}"`,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
user = opts.user;
|
|
126
|
+
}
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
let timedOut = false;
|
|
129
|
+
let callerAborted = false;
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
timedOut = true;
|
|
132
|
+
controller.abort();
|
|
133
|
+
}, deps.deadlineMs);
|
|
134
|
+
const onCallerAbort = () => {
|
|
135
|
+
callerAborted = true;
|
|
136
|
+
controller.abort();
|
|
137
|
+
};
|
|
138
|
+
opts.abortSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
139
|
+
const hookCall = {
|
|
140
|
+
caller,
|
|
141
|
+
user,
|
|
142
|
+
conversationId: opts.conversationId,
|
|
143
|
+
abortSignal: controller.signal,
|
|
144
|
+
};
|
|
145
|
+
let value;
|
|
146
|
+
try {
|
|
147
|
+
value = await depthStorage.run(depth + 1, () => entry.declaration.hook.run(payload, hookCall));
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
const reason = timedOut ? "timeout" : callerAborted ? "aborted" : "threw";
|
|
151
|
+
return { status: "failed", provider, reason, error: errorMessage(err) };
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
opts.abortSignal?.removeEventListener("abort", onCallerAbort);
|
|
156
|
+
}
|
|
157
|
+
let resultJson;
|
|
158
|
+
try {
|
|
159
|
+
resultJson = JSON.stringify(value);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
return {
|
|
163
|
+
status: "failed",
|
|
164
|
+
provider,
|
|
165
|
+
reason: "too-large",
|
|
166
|
+
error: `result is not JSON-serializable: ${errorMessage(err)}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (resultJson.length > PUBLISHED_HOOK_MAX_BYTES) {
|
|
170
|
+
return {
|
|
171
|
+
status: "failed",
|
|
172
|
+
provider,
|
|
173
|
+
reason: "too-large",
|
|
174
|
+
error: `result is ${resultJson.length} bytes; the cap is ${PUBLISHED_HOOK_MAX_BYTES}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (entry.output && !entry.output.Check(value)) {
|
|
178
|
+
const first = describeErrors(entry.output, value)[0];
|
|
179
|
+
return {
|
|
180
|
+
status: "failed",
|
|
181
|
+
provider,
|
|
182
|
+
reason: "bad-output",
|
|
183
|
+
error: `result fails the declared output schema at ${first?.path ?? ""}: ${first?.message ?? "invalid"}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { status: "ok", provider, value };
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
get(name) {
|
|
190
|
+
const entry = owners.get(name);
|
|
191
|
+
if (!entry)
|
|
192
|
+
return undefined;
|
|
193
|
+
return {
|
|
194
|
+
provider: entry.declaration.extension,
|
|
195
|
+
input: entry.declaration.hook.input,
|
|
196
|
+
output: entry.declaration.hook.output,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
call,
|
|
200
|
+
listing() {
|
|
201
|
+
return [...owners.entries()].map(([name, entry]) => ({
|
|
202
|
+
name,
|
|
203
|
+
provider: entry.declaration.extension,
|
|
204
|
+
shadowed: shadowedBy.get(name) ?? [],
|
|
205
|
+
}));
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|