@sigrun/sigrun 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +46 -23
  3. package/dist/commands/expand.js +37 -5
  4. package/dist/curation/attribution.js +135 -0
  5. package/dist/curation/deletion.js +32 -0
  6. package/dist/curation/gate.js +50 -0
  7. package/dist/curation/host.js +342 -0
  8. package/dist/curation/internal.js +54 -0
  9. package/dist/curation/names.js +5 -0
  10. package/dist/curation/overlays.js +325 -0
  11. package/dist/curation/paths.js +17 -0
  12. package/dist/curation/run-doc.js +64 -0
  13. package/dist/curation/runs.js +579 -0
  14. package/dist/curation/scan.js +30 -0
  15. package/dist/curation/skill-tool.js +308 -0
  16. package/dist/curation/store.js +444 -0
  17. package/dist/curation/targets.js +77 -0
  18. package/dist/curation/usage.js +124 -0
  19. package/dist/curation/wrap.js +212 -0
  20. package/dist/extension.d.ts +282 -0
  21. package/dist/harness/boot.js +209 -9
  22. package/dist/harness/builtins.js +12 -9
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/loader.js +114 -1
  25. package/dist/harness/naming.js +6 -0
  26. package/dist/harness/packages.js +14 -9
  27. package/dist/harness/reserved-sections.js +2 -0
  28. package/dist/harness/seam-host.js +139 -26
  29. package/dist/harness/settings.js +2 -2
  30. package/dist/harness/tools.js +70 -0
  31. package/dist/jobs/fire.js +33 -11
  32. package/dist/jobs/skills-refs.js +40 -0
  33. package/dist/sandbox/policy.js +14 -0
  34. package/dist/server/blocks/answers.js +9 -3
  35. package/dist/server/conversation-ops.js +21 -3
  36. package/dist/server/helpers.js +1 -1
  37. package/dist/server/host-bridge.js +6 -1
  38. package/dist/server/notifications.js +6 -0
  39. package/dist/server/routes/meta.js +8 -3
  40. package/dist/server/routes/submissions.js +5 -0
  41. package/dist/server/routes/surfaces.js +3 -1
  42. package/dist/server/routes/users.js +7 -0
  43. package/dist/server/schema-fixture.js +168 -0
  44. package/dist/server/session-meta.js +12 -10
  45. package/dist/settings/core-sections.js +9 -0
  46. package/dist/settings/registry.js +6 -0
  47. package/dist/skills/index.js +88 -19
  48. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -1
  49. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  50. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  51. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  52. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  53. package/node_modules/@sigrun/shared/package.json +3 -2
  54. package/package.json +9 -7
  55. package/webui/dist/assets/{index-DWmsAQl2.js → index-BtczbUXo.js} +1 -1
  56. package/webui/dist/index.html +1 -1
@@ -31,15 +31,39 @@ import { MEMORY_SECTION_KEY, noopMemoryExtension } from "./memory-default.js";
31
31
  import { createSeamHost, } from "./seam-host.js";
32
32
  import { contextFilesExtension } from "./sections/context-files.js";
33
33
  import { modelGuidanceExtension } from "./sections/model-guidance.js";
34
- import { createSkillsExtension } from "../skills/index.js";
34
+ import { createSkillsExtension, scanFsRoot } from "../skills/index.js";
35
35
  import { SLASH_COMMANDS } from "../server/commands.js";
36
36
  import { packagePromptRootsOf } from "../commands/templates.js";
37
37
  import { blocksToolsExtension } from "./blocks/tools.js";
38
- import { codingToolsExtension, searchToolsExtension } from "./tools.js";
38
+ import { createCodingToolsExtension, searchToolsExtension } from "./tools.js";
39
39
  import { collectProviders, createProviderMergeSink, } from "./providers/index.js";
40
40
  import { EventHub } from "../server/events/hub.js";
41
- import { providerAuthStatus } from "../server/helpers.js";
41
+ import { providerAuthStatus, storeAgentChange } from "../server/helpers.js";
42
42
  import { bootRefresh, DEFAULT_REFRESH_TIMEOUT_MS, refreshProvider, } from "./providers/refresh.js";
43
+ /**
44
+ * The default per-request stream deadline (curator-eval hardening): the
45
+ * OpenAI SDK waits up to 10 minutes for response HEADERS by default, and
46
+ * a request that never reaches the provider never gets headers — the
47
+ * generation task wedges in phase=request. Five minutes covers long
48
+ * first tokens on slow providers (observed 40-60s on the eval models;
49
+ * reasoning models can run far longer once streaming) because the SDK's
50
+ * timer clears at headers arrival, not stream end.
51
+ */
52
+ export const DEFAULT_STREAM_TIMEOUT_MS = 300_000;
53
+ /**
54
+ * The boot `stream` getter's merge, factored out for the harness-open
55
+ * test: caller seam over file over the default `timeoutMs`, field-wise.
56
+ * Returns `undefined` when nothing is set (the honest empty shape).
57
+ */
58
+ export function mergeStreamSettings(caller, file) {
59
+ const merged = {
60
+ ...(caller ?? file),
61
+ ...(caller === undefined && file?.timeoutMs === undefined
62
+ ? { timeoutMs: DEFAULT_STREAM_TIMEOUT_MS }
63
+ : {}),
64
+ };
65
+ return Object.keys(merged).length > 0 ? merged : undefined;
66
+ }
43
67
  import { startProviderRefresh } from "./providers/scheduler.js";
44
68
  import { fileModelsStore } from "./providers/models-store.js";
45
69
  import { packagesDirOf, selfPackageRoot } from "./packages.js";
@@ -55,6 +79,14 @@ import { probeOsUsers } from "../sandbox/availability.js";
55
79
  import { createAdmissionGuard } from "./identity/users.js";
56
80
  import { sweepAccountLinks } from "./identity/link-sweep.js";
57
81
  import { SessionMetaDoc } from "../server/session-meta.js";
82
+ import { learnedSkillsDir } from "../curation/paths.js";
83
+ import { SkillStore } from "../curation/store.js";
84
+ import { OverlayStore } from "../curation/overlays.js";
85
+ import { UsageCounters } from "../curation/usage.js";
86
+ import { buildCurationRegistry } from "../curation/targets.js";
87
+ import { createCurationHost } from "../curation/host.js";
88
+ import { createCurationToolFeed, wrapExtensionCurationTools, } from "../curation/wrap.js";
89
+ import { createCurationGateExtension, CURATION_GATE_EXTENSION } from "../curation/gate.js";
58
90
  import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
59
91
  import { resolveBuiltins, } from "./builtins.js";
60
92
  import { createRegistry } from "./registry.js";
@@ -346,9 +378,106 @@ export async function openHarness(config, options = {}) {
346
378
  // ones, which `listSkills` includes by design).
347
379
  const skillsOptions = {
348
380
  userDir: join(roots.configDir, "skills"),
381
+ // Curator A9 (P6): the canonical workspace ROOT for overlay keys —
382
+ // the configured default workspace (a conversation with a
383
+ // sub-cwd still matches the root-keyed overlay; the accessor
384
+ // stays live for the config's sake).
385
+ workspaceRoot: () => config.workspace.path,
386
+ // Curator A3 (P2): the conversation owner's learned root —
387
+ // `<dataDir>/users/<owner>/skills`, undefined for ownerless
388
+ // conversations. Discovery resolves the owner per render.
389
+ learnedRoot: (owner) => owner === undefined
390
+ ? undefined
391
+ : learnedSkillsDir(managedPaths.userAreas(owner)),
349
392
  packageRoots: () => collectedSkillRoots,
350
393
  sectionMaxBytes: () => skillsSettings?.read().skills?.sectionMaxBytes ?? 4096,
351
394
  };
395
+ // Curator A6 (P3/P4): the per-user curation wiring — one skill store,
396
+ // one overlay store and one usage counter set per EXISTING user,
397
+ // created lazily on first use and cached. `userExists` is the LIVE
398
+ // directory read (a removed user's factories stop resolving), and
399
+ // `nameTakenElsewhere` scans the host-side roots the harness owns
400
+ // (the user dir and the collected package roots; a conversation's
401
+ // workspace can only be checked through its env, which the store
402
+ // cannot see — the create-refusal there is the A4 store's own).
403
+ const skillStores = new Map();
404
+ const overlayStores = new Map();
405
+ const usageCounters = new Map();
406
+ const curationOf = (user) => {
407
+ if (user === undefined)
408
+ return undefined;
409
+ // Curator A10: cache-FIRST resolution — a store set captured before
410
+ // a user's deletion stays resolvable, so its own `userExists` gate
411
+ // fails every later op with "user removed" (the A4/A5 fail-closed
412
+ // property: an in-flight run's late write never recreates the
413
+ // roots). A never-seen user still needs the live directory read.
414
+ if (skillStores.get(user) === undefined &&
415
+ identity.users.get(user) === undefined) {
416
+ return undefined;
417
+ }
418
+ const userArea = managedPaths.userAreas(user);
419
+ let store = skillStores.get(user);
420
+ if (store === undefined) {
421
+ store = new SkillStore({
422
+ userArea,
423
+ user,
424
+ now: () => clockNow(),
425
+ nameTakenElsewhere: async (name) => {
426
+ for (const skill of scanFsRoot(join(roots.configDir, "skills"), "user dir")) {
427
+ if (skill.name === name)
428
+ return "user dir";
429
+ }
430
+ for (const root of collectedSkillRoots) {
431
+ if (scanFsRoot(root.dir, "package").some((skill) => skill.name === name))
432
+ return root.extension;
433
+ }
434
+ return undefined;
435
+ },
436
+ userExists: () => identity.users.get(user) !== undefined,
437
+ });
438
+ skillStores.set(user, store);
439
+ }
440
+ let overlays = overlayStores.get(user);
441
+ if (overlays === undefined) {
442
+ overlays = new OverlayStore({
443
+ userArea,
444
+ user,
445
+ userExists: () => identity.users.get(user) !== undefined,
446
+ feed: store,
447
+ });
448
+ overlayStores.set(user, overlays);
449
+ }
450
+ let usage = usageCounters.get(user);
451
+ if (usage === undefined) {
452
+ usage = new UsageCounters({
453
+ userArea,
454
+ user,
455
+ now: () => clockNow(),
456
+ // The A4 gate, but silent: the cached counters outlive the user
457
+ // (cache-first resolution), and telemetry is void-and-forget —
458
+ // a removed user's flush drops instead of recreating the
459
+ // deleted curation directory (deletion's fail-closed claim).
460
+ userExists: () => identity.users.get(user) !== undefined,
461
+ });
462
+ usageCounters.set(user, usage);
463
+ }
464
+ return { store, overlays, usage };
465
+ };
466
+ const curationSkillsOptions = {
467
+ ...skillsOptions,
468
+ skillStore: (user) => curationOf(user)?.store,
469
+ overlays: (owner) => curationOf(owner)?.overlays,
470
+ usage: (owner) => curationOf(owner)?.usage,
471
+ };
472
+ // Curator A6 (P4): the read tool counts learned-root reads through the
473
+ // same per-user wiring (load_skill and /skill: count through the
474
+ // skills options above).
475
+ const codingWithUsage = createCodingToolsExtension({
476
+ countersOf: (owner) => curationOf(owner)?.usage,
477
+ learnedRootOf: (owner) => owner === undefined
478
+ ? undefined
479
+ : learnedSkillsDir(managedPaths.userAreas(owner)),
480
+ });
352
481
  const templateOptions = {
353
482
  userDir: join(roots.configDir, "prompts"),
354
483
  packageRoots: () => collectedTemplateRoots,
@@ -371,8 +500,22 @@ export async function openHarness(config, options = {}) {
371
500
  },
372
501
  };
373
502
  registry.install(contextFilesExtension);
374
- registry.install(createSkillsExtension(skillsOptions));
375
- registry.install(codingToolsExtension);
503
+ // Curator A8 (P9): the wrap feed — the mutable set of curation tool
504
+ // names, seeded with the core set and grown by the loader as each
505
+ // extension's curationTargets validate. The skills extension's
506
+ // install below wraps through it (its OWN tools: `skill_manage`);
507
+ // the loader wraps every extension-declared target tool at its
508
+ // install. `isCurationTool` on the built registry (A7) stays the
509
+ // read-side API — the feed is only the write-side wrap oracle.
510
+ const curationFeed = createCurationToolFeed();
511
+ // Curator A6: the skills extension carries the curation wiring
512
+ // (skill_manage + usage counting), and the coding tools count
513
+ // learned-root reads — the same extension NAMES as before, so
514
+ // nothing downstream observes the swap. A8: the install WRAPS the
515
+ // curation tools (skill_manage's registration carries the hint
516
+ // interception; load_skill and the section are untouched).
517
+ registry.install(wrapExtensionCurationTools(createSkillsExtension(curationSkillsOptions), curationFeed));
518
+ registry.install(codingWithUsage);
376
519
  registry.install(searchToolsExtension);
377
520
  // The chat-blocks tools (UI-blocks U5 Task 1, spec §8.1): every agent
378
521
  // can show and update interactive block surfaces in chat — the same
@@ -466,8 +609,11 @@ export async function openHarness(config, options = {}) {
466
609
  const sharedStores = sharedExtensionStores(roots, env, identity.users);
467
610
  const seamHosts = new Map();
468
611
  const seamKinds = new Map();
469
- const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
612
+ const { outcomes: extensionOutcomes, healthProducers: extensionHealth, runSettledBindings, settingsChangedBindings, lifecycleBindings, workerBindings, curationBindings, userServices: userServiceBindings, providerContributors, loginProviders, webuiBundles: extensionWebuiBundles, skillRoots: extensionSkillRoots, jobBindings, routeBindings, commandBindings, notifierBindings, channelBindings, gatewayEngine, surfaces: surfaceBindings, settingsSections: settingsSectionBindings, perUserSlugs: loadedPerUserSlugs, } = await loadExtensions(loaderInputs, registry, {
470
613
  packagesDir,
614
+ // Curator A8 (P9): the wrap feed — the loader grows it with each
615
+ // validated target tool and wraps those tools at their install.
616
+ curationFeed,
471
617
  hostContextFactory: (extensionName, kinds, priorHost) => {
472
618
  // Two-phase, revised for the provisional identity (provider
473
619
  // plugins Task 9 P1): the loader's PRE-import call passes the
@@ -505,6 +651,11 @@ export async function openHarness(config, options = {}) {
505
651
  },
506
652
  });
507
653
  perUserSlugs.push(...loadedPerUserSlugs);
654
+ // The curation-target registry (curator P7): the loader's validated,
655
+ // slug-attributed bindings folded into the ONE registry — the A8
656
+ // wrapping feed (isCurationTool) and the A9 host.curation surface
657
+ // read it. An empty fold is the no-declarer deploy.
658
+ const curation = buildCurationRegistry(curationBindings);
508
659
  for (const binding of userServiceBindings) {
509
660
  userServices.register(binding.extension, binding.services, binding.packageDir);
510
661
  }
@@ -640,6 +791,11 @@ export async function openHarness(config, options = {}) {
640
791
  name: "sigrun.jobs",
641
792
  tasks: [createScheduleTask(jobRuntimeDeps)],
642
793
  });
794
+ // Curator A8 (P6 gate): the hooks-only curator-run gate — installed
795
+ // in the registry but NEVER in the host default selection (the
796
+ // settings getter below filters it like a per-user entry): only A9's
797
+ // producer selects it, in the internal run conversations it creates.
798
+ registry.install(createCurationGateExtension());
643
799
  // The host event hub (W3 Task 4, spec §7.4): ONE per opened harness.
644
800
  // The event bus's commit-derived events subscribe below (after the bus
645
801
  // opens); the provider refresh paths observe readiness here — each
@@ -828,14 +984,26 @@ export async function openHarness(config, options = {}) {
828
984
  return callerSettings?.retry ?? fileSettings.retry;
829
985
  },
830
986
  get stream() {
831
- return callerSettings?.stream ?? fileSettings.stream;
987
+ // The stream-request default (curator-eval hardening): a
988
+ // model request with no explicit timeout can wedge forever —
989
+ // the OpenAI SDK's `timeout` is a headers-arrival deadline and
990
+ // defaults to 10 minutes, but a request that never reaches the
991
+ // provider (undici socket-pool class) never gets headers to
992
+ // time out against, so pi-durable's generation task sits in
993
+ // phase=request indefinitely. `mergeStreamSettings` bounds
994
+ // every request with a default `timeoutMs`; the caller seam
995
+ // and the file each override field-wise.
996
+ return mergeStreamSettings(callerSettings?.stream, fileSettings.stream);
832
997
  },
833
998
  get extensions() {
834
999
  // Per-user entries are core-computed from SessionMeta.owner,
835
1000
  // never part of a host default — including an operator-supplied
836
- // caller list (controller ruling: filter BOTH sources).
1001
+ // caller list (controller ruling: filter BOTH sources). The
1002
+ // curator-run gate (A8) is filtered the same way: it is selected
1003
+ // ONLY in the internal run conversations A9's producer creates.
837
1004
  const selected = callerSettings?.extensions ?? registry.snapshot().installed();
838
- return selected.filter((entry) => !isUserEntryName(entry.name));
1005
+ return selected.filter((entry) => !isUserEntryName(entry.name) &&
1006
+ entry.name !== CURATION_GATE_EXTENSION);
839
1007
  },
840
1008
  toolExecution: callerSettings?.toolExecution,
841
1009
  compaction: {
@@ -920,6 +1088,36 @@ export async function openHarness(config, options = {}) {
920
1088
  // order). The bus's close stops delivery; the unsubscribe is unused.
921
1089
  const now = clockNow;
922
1090
  wireActivity(eventBus, harness, now);
1091
+ // Curator A9 (P6): the ONE curation host — the `host.curation`
1092
+ // surface over the per-user stores (curationOf), the curation
1093
+ // registry, the jobs records and the run driver. The server's host
1094
+ // bridge and every seam host expose the SAME instance (the bridge
1095
+ // carries it; the seam hosts delegate). The digest producer's
1096
+ // default agent is the create op's own store-default change (what
1097
+ // every server-created conversation starts from): the structural
1098
+ // `{settings, registry, providerEnv}` subset `storeAgentChange`
1099
+ // reads, closed over the LIVE references so a settings PATCH
1100
+ // reaches later digests without a restart.
1101
+ // SAFETY: the cast — `storeAgentChange` reads exactly these three
1102
+ // members (settings.read, registry.snapshot, providerEnv) and
1103
+ // nothing else of OpenedHarness; the live closure satisfies them.
1104
+ const curationHost = createCurationHost({
1105
+ harness,
1106
+ registry,
1107
+ storage,
1108
+ curation,
1109
+ defaultModel,
1110
+ stores: curationOf,
1111
+ skillsOptions: {
1112
+ userDir: join(roots.configDir, "skills"),
1113
+ packageRoots: () => collectedSkillRoots,
1114
+ },
1115
+ defaultAgent: () => storeAgentChange({
1116
+ settings: { read: () => settingsStore.read() },
1117
+ registry,
1118
+ providerEnv: effectiveEnv,
1119
+ }, defaultModel),
1120
+ });
923
1121
  const handlersByName = new Map();
924
1122
  for (const binding of runSettledBindings) {
925
1123
  handlersByName.set(binding.extension.name, {
@@ -1176,6 +1374,8 @@ export async function openHarness(config, options = {}) {
1176
1374
  channelBindings,
1177
1375
  gatewayEngine,
1178
1376
  skillRoots: extensionSkillRoots,
1377
+ curation,
1378
+ curationHost,
1179
1379
  commandExpansion,
1180
1380
  eventBus,
1181
1381
  events,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Built-in extensions: codemode, MCP, tool search and OAuth ship WITH
3
- * core — present by default, disableable via settings `builtinExtensions`,
4
- * replaceable by a same-named configured extension.
2
+ * Built-in extensions: codemode, MCP, tool search, OAuth and the curator
3
+ * ship WITH core — present by default, disableable via settings
4
+ * `builtinExtensions`, replaceable by a same-named configured extension.
5
5
  *
6
6
  * The first three mirror pi 1.3.0's built-in tool surface; OAuth adds
7
7
  * sigrun's standard OIDC/OAuth2 login providers without extra setup.
@@ -38,13 +38,15 @@ import { basename, dirname, isAbsolute, resolve } from "node:path";
38
38
  import { fileURLToPath, pathToFileURL } from "node:url";
39
39
  import { parsePackageSpecifier, readPackageName } from "./packages.js";
40
40
  /**
41
- * The five built-ins, in load order (codemode before tool-search: both
41
+ * The six built-ins, in load order (codemode before tool-search: both
42
42
  * wrap pi-durable's own extensions and neither depends on the other, but
43
- * the order pins the tool-collision story should names ever overlap).
44
- * Specifiers are the published package names (Task 3) — npm keys a file:
45
- * dependency by the target's own manifest name, so the key must be the
46
- * name the packages publish under (the dev shape carries `file:` values
47
- * under these same keys until Task 8 publishes exact `0.1.0`s).
43
+ * the order pins the tool-collision story should names ever overlap;
44
+ * curator loads LAST — its loops observe every earlier built-in's
45
+ * contributions). Specifiers are the published package names (Task 3)
46
+ * — npm keys a file: dependency by the target's own manifest name, so
47
+ * the key must be the name the packages publish under (the dev shape
48
+ * carries `file:` values under these same keys until Task 8 publishes
49
+ * exact `0.1.0`s).
48
50
  */
49
51
  export const BUILTIN_EXTENSIONS = [
50
52
  { key: "codemode", specifier: "@sigrun/ext-codemode", slug: "codemode" },
@@ -52,6 +54,7 @@ export const BUILTIN_EXTENSIONS = [
52
54
  { key: "toolSearch", specifier: "@sigrun/ext-tool-search", slug: "tool-search" },
53
55
  { key: "gateway", specifier: "@sigrun/ext-gateway", slug: "gateway" },
54
56
  { key: "oauth", specifier: "@sigrun/oauth", slug: "oauth" },
57
+ { key: "curator", specifier: "@sigrun/ext-curator", slug: "curator" },
55
58
  ];
56
59
  /** The core-anchored require (built-ins resolve from core's own node_modules). */
57
60
  const coreRequire = createRequire(import.meta.url);
@@ -103,6 +103,22 @@ export class EventBus {
103
103
  }
104
104
  #unsubscribe;
105
105
  #harness;
106
+ /**
107
+ * Internal-run detection (curator P5): the SessionMeta `internal` flag,
108
+ * resolved SYNCHRONOUSLY from the commit stream — every SessionMeta
109
+ * document change warms the cache (see the pre-pass in `observe`), so
110
+ * the arms there always find the flag of the conversation the
111
+ * publication belongs to. The marker is written in the conversation's
112
+ * CREATING commit and never changes (`markInternal`), so a cached
113
+ * value never goes stale; `false` is cached too, keeping ordinary
114
+ * traffic at zero extra reads (the bus's no-blocking-work contract
115
+ * forbids a snapshot here).
116
+ */
117
+ #internal = new Map();
118
+ /** Whether `conversationId` is internal per the commit-warmed cache. */
119
+ #isInternal(conversationId) {
120
+ return this.#internal.get(conversationId) === true;
121
+ }
106
122
  #queueMax;
107
123
  #hookDeadlineMs;
108
124
  #idleThresholdMs;
@@ -663,10 +679,37 @@ export class EventBus {
663
679
  try {
664
680
  const lifecycle = [];
665
681
  const forkParents = new Map();
682
+ // Curator P5, PRE-PASS: warm the internal-flag cache off every
683
+ // SessionMeta document change BEFORE any arm below can fire —
684
+ // change order within a publication is unspecified, so the
685
+ // conversation/submission arms must never depend on the SessionMeta
686
+ // change having been seen first (an internal conversation created in
687
+ // THIS commit emits nothing, its `conversation.created` host event
688
+ // included). The plain `document` arm alone: a fork's
689
+ // `document.copy` carries no value to read (and `internal` is never
690
+ // inherited — the child's own first SessionMeta commit re-warms it).
691
+ // The marker is written in the creating commit and never changes
692
+ // (`markInternal`), so a cached value never goes stale; `false` is
693
+ // cached too, keeping ordinary traffic at zero extra reads (the
694
+ // bus's no-blocking-work contract forbids a snapshot here).
695
+ for (const change of publication.changes) {
696
+ if (change.type !== "document")
697
+ continue;
698
+ if (change.record.kind !== SessionMetaDoc.definition.kind)
699
+ continue;
700
+ if (change.conversationId === undefined)
701
+ continue;
702
+ this.#internal.set(change.conversationId, change.value?.internal === true);
703
+ }
666
704
  for (const change of publication.changes) {
667
705
  if (change.type !== "conversation")
668
706
  continue;
669
707
  const record = change.value;
708
+ // Curator P5: an internal conversation emits nothing — not the
709
+ // lifecycle events, not the host event (every owner-keyed path
710
+ // skips internal; the SSE stream's filtering is belt-and-braces).
711
+ if (this.#isInternal(record.id))
712
+ continue;
670
713
  lifecycle.push({
671
714
  type: "conversationCreated",
672
715
  conversationId: record.id,
@@ -690,6 +733,17 @@ export class EventBus {
690
733
  if (change.type !== "document" && change.type !== "document.copy") {
691
734
  continue;
692
735
  }
736
+ // Curator P5: an internal conversation emits nothing on ANY
737
+ // document arm — not the SessionMeta summary diffs, and not the
738
+ // interaction/ambient seam events either (every conversation.*
739
+ // host event and every derived lifecycle is withheld; the flag
740
+ // was warmed by the pre-pass above).
741
+ if (change.conversationId !== undefined &&
742
+ this.#isInternal(change.conversationId)) {
743
+ this.#archivedBasis.delete(change.conversationId);
744
+ this.#metaBasis.delete(change.conversationId);
745
+ continue;
746
+ }
693
747
  const kind = change.record.kind;
694
748
  if (kind !== SessionMetaDoc.definition.kind) {
695
749
  // W3 Task 4: the interaction + ambient seam doc families feed
@@ -958,6 +1012,16 @@ export class EventBus {
958
1012
  const value = change.value;
959
1013
  if (value.type !== "input")
960
1014
  continue;
1015
+ // Curator P5: an internal conversation's submissions are invisible
1016
+ // on every surface the bus feeds — no idle bookkeeping (the run
1017
+ // never arms the idle clock), no run.admitted/run.settled host
1018
+ // events (the SSE stream and the notifications surface see
1019
+ // nothing), and no settle-group enqueueing (no extension
1020
+ // runSettled delivery — the brief's three bus gates in one skip).
1021
+ // The flag was warmed by the pre-pass; `markInternal` runs in the
1022
+ // creating commit, which always precedes any submission.
1023
+ if (this.#isInternal(value.conversationId))
1024
+ continue;
961
1025
  // Idle bookkeeping (Task 4): a PLACED input both stamps the
962
1026
  // admission clock and marks the run live; a terminal input marks
963
1027
  // the run end. Write-type submissions never touch either — a
@@ -93,6 +93,7 @@ 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
95
  import { slugifyName } from "./stores.js";
96
+ import { wrapExtensionCurationTools } from "../curation/wrap.js";
96
97
  /** Every lifecycle field name, in validation order (mirrors the contract doc). */
97
98
  const LIFECYCLE_FIELDS = [
98
99
  "conversationCreated",
@@ -217,6 +218,52 @@ function validateWorkers(value) {
217
218
  * (the §8.2 grammar), and duplicate names within one extension are
218
219
  * rejected (they would collide on the `ext:<slug>:<name>` id).
219
220
  */
221
+ /**
222
+ * Validate a factory result's `curationTargets` declaration (curator
223
+ * P7). Absent → undefined (no targets). Present but malformed →
224
+ * throw; the loader quarantines the extension with the thrown message
225
+ * (fail-loud: an extension that THINKS it declared a curation target
226
+ * must not silently lose it). Per target, validation order: name,
227
+ * duplicate qualified name (checked at collection — the slug is
228
+ * unknown here), tools list, guidance, undo (REQUIRED — no undo, no
229
+ * magic), maintain.
230
+ *
231
+ * The REGISTERED-TOOL check (a target listing a tool its extension
232
+ * does not register) and the cross-extension duplicate qualified id
233
+ * live in the load loop, where the registry and the slug are known.
234
+ */
235
+ function validateCurationTargets(value) {
236
+ if (value === undefined)
237
+ return undefined;
238
+ if (!Array.isArray(value)) {
239
+ throw new Error("extension factory returned a curationTargets field that is not an array");
240
+ }
241
+ const targets = [];
242
+ for (const entry of value) {
243
+ const target = entry;
244
+ const name = target?.name;
245
+ if (typeof name !== "string" || name.length === 0) {
246
+ throw new Error("extension factory returned a curation target without a name");
247
+ }
248
+ const tools = target?.tools;
249
+ if (!Array.isArray(tools) ||
250
+ tools.some((tool) => typeof tool !== "string" || tool.length === 0)) {
251
+ throw new Error(`extension factory returned a curation target "${name}" whose tools is not an array of non-empty strings`);
252
+ }
253
+ if (typeof target?.guidance !== "function") {
254
+ throw new Error(`extension factory returned a curation target "${name}" whose guidance is not a function`);
255
+ }
256
+ if (typeof target?.undo !== "function") {
257
+ throw new Error(`curation target "${name}" has no undo`);
258
+ }
259
+ if (target?.maintain !== undefined &&
260
+ typeof target.maintain !== "function") {
261
+ throw new Error(`extension factory returned a curation target "${name}" whose maintain is not a function`);
262
+ }
263
+ targets.push(entry);
264
+ }
265
+ return targets;
266
+ }
220
267
  function validateUserServices(value) {
221
268
  if (value === undefined)
222
269
  return undefined;
@@ -1204,6 +1251,10 @@ async function resolvedFromFactory(factory, hostContext) {
1204
1251
  }
1205
1252
  // `workers` is a contract key — the candidate read needs no cast.
1206
1253
  const workers = validateWorkers(candidate.workers);
1254
+ // Curation targets (curator P7): validated without the slug (the
1255
+ // qualified-id duplicate check runs in the load loop where the slug
1256
+ // and the already-claimed ids are known).
1257
+ const curationTargets = validateCurationTargets(candidate.curationTargets);
1207
1258
  return {
1208
1259
  ...(candidate.perUserTools === undefined
1209
1260
  ? {}
@@ -1244,6 +1295,7 @@ async function resolvedFromFactory(factory, hostContext) {
1244
1295
  ? {}
1245
1296
  : { ambientKinds: candidate.ambientKinds }),
1246
1297
  ...(workers === undefined ? {} : { workers }),
1298
+ ...(curationTargets === undefined ? {} : { curationTargets }),
1247
1299
  ...(providers === undefined ? {} : { providers }),
1248
1300
  ...(loginProviders === undefined ? {} : { loginProviders }),
1249
1301
  ...(webuiBundles === undefined ? {} : { webuiBundles }),
@@ -1438,6 +1490,10 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1438
1490
  const settingsChangedBindings = [];
1439
1491
  const lifecycleBindings = [];
1440
1492
  const workerBindings = [];
1493
+ // Curation targets (curator P7): slug-attributed declarations, one
1494
+ // entry per loaded extension whose result carried them — boot folds
1495
+ // these into the ONE CurationRegistry (src/curation/targets.ts).
1496
+ const curationBindings = [];
1441
1497
  const userServiceBindings = [];
1442
1498
  // Jobs Task 3: declared jobs, attributed to the extension slug (the
1443
1499
  // `ext:<slug>:<name>` id's namespace).
@@ -1475,6 +1531,11 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1475
1531
  // Slug → declared name, for collision rejection at load (this boot's
1476
1532
  // loaded set only — quarantined extensions never claim a slug).
1477
1533
  const slugs = new Map();
1534
+ // Qualified curation-target ids ("<slug>:<name>", curator P7): the same
1535
+ // claimed-only-by-LOADED pattern as `slugs` — a target id names ONE
1536
+ // target, so a duplicate quarantines the later declarer instead of
1537
+ // shadowing (never last-wins).
1538
+ const curationIds = new Set();
1478
1539
  // Channel id → slug (gateway C1): the same claimed-only-by-LOADED pattern
1479
1540
  // as `slugs`. Chat bindings are keyed by channel id, so a shadowing
1480
1541
  // adapter would inherit another adapter's chats — the collision
@@ -1695,6 +1756,32 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1695
1756
  // install, exactly like the channel-id collision — a second engine
1696
1757
  // leaves no registry entry, no slug claim, and no binding. First
1697
1758
  // loaded wins.
1759
+ // Curation targets (curator P7): the two slug-dependent rules
1760
+ // check HERE — after the slug is final, BEFORE the registry
1761
+ // install (a violation leaves no entry, no slug claim, no
1762
+ // binding — the same point as the one-engine rule below).
1763
+ // A target's tools list must name tools THIS extension
1764
+ // registers (A8's wrapping rewrites registrations — an
1765
+ // unregistered name would wrap nothing); a qualified id
1766
+ // ("<slug>:<name>") names ONE target.
1767
+ if (resolved.curationTargets !== undefined) {
1768
+ const registered = new Set((extension?.tools ?? []).map((tool) => tool.name));
1769
+ // Claim each id AS it validates: an intra-list duplicate
1770
+ // (the same name twice in one result) hits the check on
1771
+ // its SECOND arrival, quarantining before any install.
1772
+ const claimed = new Set();
1773
+ for (const target of resolved.curationTargets) {
1774
+ const id = `${slug}:${target.name}`;
1775
+ if (curationIds.has(id) || claimed.has(id)) {
1776
+ throw new Error(`duplicate curation target id "${id}" — a qualified id names one curation target`);
1777
+ }
1778
+ claimed.add(id);
1779
+ const unregistered = target.tools.find((tool) => !registered.has(tool));
1780
+ if (unregistered !== undefined) {
1781
+ throw new Error(`extension "${slug}" declares curation target "${target.name}" listing tool "${unregistered}" it does not register`);
1782
+ }
1783
+ }
1784
+ }
1698
1785
  if (resolved.gateway !== undefined) {
1699
1786
  if (gatewayEngine !== undefined) {
1700
1787
  throw new Error(`gateway engine already declared by "${gatewayEngine.extension}"`);
@@ -1702,7 +1789,23 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1702
1789
  gatewayEngine = { extension: slug, engine: resolved.gateway };
1703
1790
  }
1704
1791
  if (extension !== undefined) {
1705
- registry.install(extension);
1792
+ // Curator A8 (P9): grow the wrap feed with this extension's
1793
+ // just-validated target tools, then WRAP every curation-tool
1794
+ // registration at the install — its own target tools, plus any
1795
+ // feed name it registers (a non-core `skill_manage`). Growth
1796
+ // lives HERE, after every quarantining check, so the feed
1797
+ // never learns a tool whose extension failed the load. The
1798
+ // bindings below keep pairing with the UNWRAPPED object: they
1799
+ // carry identity, never tools, and the wrap changes only
1800
+ // `execute`.
1801
+ if (options.curationFeed !== undefined &&
1802
+ resolved.curationTargets !== undefined) {
1803
+ for (const target of resolved.curationTargets) {
1804
+ for (const tool of target.tools)
1805
+ options.curationFeed.add(tool);
1806
+ }
1807
+ }
1808
+ registry.install(wrapExtensionCurationTools(extension, options.curationFeed));
1706
1809
  }
1707
1810
  if (resolved.perUserTools === true)
1708
1811
  perUserSlugs.push(slug);
@@ -1773,6 +1876,15 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1773
1876
  workers: resolved.workers,
1774
1877
  });
1775
1878
  }
1879
+ if (resolved.curationTargets !== undefined) {
1880
+ curationBindings.push({
1881
+ extension: slug,
1882
+ targets: resolved.curationTargets,
1883
+ });
1884
+ for (const target of resolved.curationTargets) {
1885
+ curationIds.add(`${slug}:${target.name}`);
1886
+ }
1887
+ }
1776
1888
  if (resolved.jobs !== undefined) {
1777
1889
  jobBindings.push({ extension: slug, jobs: resolved.jobs });
1778
1890
  }
@@ -1890,6 +2002,7 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1890
2002
  settingsChangedBindings,
1891
2003
  lifecycleBindings,
1892
2004
  workerBindings,
2005
+ curationBindings,
1893
2006
  jobBindings,
1894
2007
  routeBindings,
1895
2008
  commandBindings,
@@ -104,6 +104,12 @@ export function createAutoNamer(deps) {
104
104
  // skip sits next to the isTaskOwned check per the plan.
105
105
  if (meta?.source === "cron")
106
106
  return;
107
+ // Internal conversations (curator P5) are never named: they are
108
+ // invisible on every owner-keyed surface, and a name would exist
109
+ // only to be hidden. The bus already withholds the settle event;
110
+ // this is the belt-and-braces read-side gate.
111
+ if (meta?.internal === true)
112
+ return;
107
113
  // An absent doc (never written — reads serve nothing, defaults are
108
114
  // free) is an unnamed conversation with zero attempts: name it.
109
115
  if (meta?.name !== undefined || meta?.autoName !== undefined)