@sigrun/sigrun 0.4.2 → 0.6.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 (68) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +63 -33
  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 +336 -1
  21. package/dist/harness/boot.js +236 -13
  22. package/dist/harness/builtins.js +19 -12
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/identity/primary-admin.js +19 -0
  25. package/dist/harness/loader.js +114 -1
  26. package/dist/harness/naming.js +6 -0
  27. package/dist/harness/packages.js +14 -9
  28. package/dist/harness/reserved-sections.js +4 -0
  29. package/dist/harness/seam-host.js +154 -26
  30. package/dist/harness/sections/context-files.js +6 -3
  31. package/dist/harness/sections/job-state.js +24 -0
  32. package/dist/harness/settings.js +2 -2
  33. package/dist/harness/tools.js +70 -0
  34. package/dist/harness/workspace.js +30 -3
  35. package/dist/jobs/fire.js +37 -14
  36. package/dist/jobs/index.js +34 -7
  37. package/dist/jobs/reconcile.js +9 -0
  38. package/dist/jobs/skills-refs.js +40 -0
  39. package/dist/jobs/state-dir.js +16 -0
  40. package/dist/jobs/transfer.js +14 -0
  41. package/dist/sandbox/policy.js +27 -4
  42. package/dist/serve.js +5 -3
  43. package/dist/server/blocks/answers.js +9 -3
  44. package/dist/server/conversation-ops.js +34 -3
  45. package/dist/server/helpers.js +1 -1
  46. package/dist/server/host-bridge.js +31 -1
  47. package/dist/server/notifications.js +108 -16
  48. package/dist/server/routes/jobs.js +31 -0
  49. package/dist/server/routes/meta.js +8 -3
  50. package/dist/server/routes/submissions.js +5 -0
  51. package/dist/server/routes/surfaces.js +3 -1
  52. package/dist/server/routes/users.js +7 -0
  53. package/dist/server/schema-fixture.js +168 -0
  54. package/dist/server/session-meta.js +12 -10
  55. package/dist/settings/core-sections.js +18 -0
  56. package/dist/settings/registry.js +12 -0
  57. package/dist/skills/index.js +88 -19
  58. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -1
  59. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  60. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  61. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  62. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  63. package/node_modules/@sigrun/shared/package.json +1 -1
  64. package/package.json +9 -7
  65. package/webui/dist/assets/index-BR0di0OG.css +1 -0
  66. package/webui/dist/assets/{index-DWmsAQl2.js → index-BzrA1T4g.js} +5 -5
  67. package/webui/dist/index.html +2 -2
  68. package/webui/dist/assets/index-h7zDY9vD.css +0 -1
@@ -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)
@@ -949,13 +949,18 @@ export async function syncGit(source, ref, gitDir, options = {}) {
949
949
  return target;
950
950
  }
951
951
  /**
952
- * The pi-compat `.ts` skip warning (spec §9.4): a `pi.extensions` entry
953
- * ending in `.ts` cannot load under plain node (the build spec removed
954
- * the `.ts` entry fallback) — it is skipped, keeping the package's
955
- * skills loadable. ONE line per skipped entry, per discovery.
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-compat: skipping TypeScript pi extension ${entry} (not loadable under node); loading the package's skills only`);
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-compat `pi.skills` (string[] — same SKILL.md format,
983
- * so no warning), each resolved against the package root, keeping only
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-compat: no loadable pi extension in ${pkgRoot} (all entries are TypeScript); loading the package's skills only`);
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
  }
@@ -21,6 +21,7 @@ export const RESERVED_CORE_SECTION_KEYS = [
21
21
  "skills",
22
22
  "tool-guidance",
23
23
  "execution-guidance",
24
+ "job-state",
24
25
  ];
25
26
  /**
26
27
  * Core-owned directory names directly under a store root (multi-user M1).
@@ -31,6 +32,7 @@ export const RESERVED_CORE_SECTION_KEYS = [
31
32
  */
32
33
  export const RESERVED_EXTENSION_SLUGS = [
33
34
  "users",
35
+ "jobs",
34
36
  "workspace",
35
37
  "models",
36
38
  "sandbox",
@@ -38,6 +40,8 @@ export const RESERVED_EXTENSION_SLUGS = [
38
40
  "user-settings",
39
41
  "user-secrets",
40
42
  "skills",
43
+ "versions",
44
+ "archive",
41
45
  "prompts",
42
46
  "packages",
43
47
  "git",
@@ -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
- const hostBridge = async () => {
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 bridgePromise;
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,103 +316,188 @@ 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 }),
280
323
  ...(opts.name === undefined ? {} : { name: opts.name }),
324
+ ...(opts.job === undefined ? {} : { job: opts.job }),
281
325
  ...(opts.agent === undefined ? {} : { agent: opts.agent }),
282
326
  // The source default: the CALLING extension's slug (spec §9.5).
283
327
  source: opts.source ?? identity.name,
284
328
  });
285
329
  },
286
330
  async submit(id, input, opts) {
287
- const bridge = await hostBridge();
331
+ const bridge = await hostBridge("conversations.submit");
288
332
  return bridge.conversations.submit(id, input, opts);
289
333
  },
290
334
  async watch(id, listener) {
291
- const bridge = await hostBridge();
335
+ const bridge = await hostBridge("conversations.watch");
292
336
  return bridge.conversations.watch(id, listener);
293
337
  },
294
338
  async list(opts) {
295
- const bridge = await hostBridge();
339
+ const bridge = await hostBridge("conversations.list");
296
340
  return bridge.conversations.list(opts);
297
341
  },
298
342
  async meta(id) {
299
- const bridge = await hostBridge();
343
+ const bridge = await hostBridge("conversations.meta");
300
344
  return bridge.conversations.meta(id);
301
345
  },
346
+ async entries(id, query, limit, cursor) {
347
+ return (await hostBridge("conversations.entries")).conversations.entries(id, query, limit, cursor);
348
+ },
349
+ async entry(id, entryId) {
350
+ return (await hostBridge("conversations.entry")).conversations.entry(id, entryId);
351
+ },
302
352
  // The C4a ops: straight delegation (the bridge resolves the id and
303
353
  // runs the op its HTTP twin runs).
304
354
  async abort(id) {
305
- return (await hostBridge()).conversations.abort(id);
355
+ return (await hostBridge("conversations.abort")).conversations.abort(id);
306
356
  },
307
357
  async configure(id, opts) {
308
- return (await hostBridge()).conversations.configure(id, opts);
358
+ return (await hostBridge("conversations.configure")).conversations.configure(id, opts);
309
359
  },
310
360
  async compact(id) {
311
- return (await hostBridge()).conversations.compact(id);
361
+ return (await hostBridge("conversations.compact")).conversations.compact(id);
312
362
  },
313
363
  async fork(id, opts) {
314
- return (await hostBridge()).conversations.fork(id, opts);
364
+ return (await hostBridge("conversations.fork")).conversations.fork(id, opts);
315
365
  },
316
366
  async patchMeta(id, patch) {
317
- return (await hostBridge()).conversations.patchMeta(id, patch);
367
+ return (await hostBridge("conversations.patchMeta")).conversations.patchMeta(id, patch);
318
368
  },
319
369
  // The C4b ops: same straight delegation.
320
370
  async upload(id, file) {
321
- return (await hostBridge()).conversations.upload(id, file);
371
+ return (await hostBridge("conversations.upload")).conversations.upload(id, file);
322
372
  },
323
373
  async readFile(id, path) {
324
- return (await hostBridge()).conversations.readFile(id, path);
374
+ return (await hostBridge("conversations.readFile")).conversations.readFile(id, path);
375
+ },
376
+ },
377
+ jobs: {
378
+ async get(id) {
379
+ return (await hostBridge("jobs.get")).jobs.get(id);
325
380
  },
326
381
  },
327
382
  users: {
328
383
  async list() {
329
- return (await hostBridge()).users.list();
384
+ return (await hostBridge("users.list")).users.list();
330
385
  },
331
386
  async get(user) {
332
- return (await hostBridge()).users.get(user);
387
+ return (await hostBridge("users.get")).users.get(user);
333
388
  },
334
389
  },
335
390
  accounts: {
336
391
  async lookup(provider, subject) {
337
- return (await hostBridge()).accounts.lookup(provider, subject);
392
+ return (await hostBridge("accounts.lookup")).accounts.lookup(provider, subject);
338
393
  },
339
394
  async link(provider, subject, user, via) {
340
- return (await hostBridge()).accounts.link(provider, subject, user, via);
395
+ return (await hostBridge("accounts.link")).accounts.link(provider, subject, user, via);
341
396
  },
342
397
  async unlink(provider, subject) {
343
- return (await hostBridge()).accounts.unlink(provider, subject);
398
+ return (await hostBridge("accounts.unlink")).accounts.unlink(provider, subject);
344
399
  },
345
400
  async listForUser(user) {
346
- return (await hostBridge()).accounts.listForUser(user);
401
+ return (await hostBridge("accounts.listForUser")).accounts.listForUser(user);
347
402
  },
348
403
  },
349
404
  access: {
350
405
  async accessOf(principal, resource) {
351
- return (await hostBridge()).access.accessOf(principal, resource);
406
+ return (await hostBridge("access.accessOf")).access.accessOf(principal, resource);
352
407
  },
353
408
  },
354
409
  identity: {
355
410
  async actingUser(conversationId) {
356
- return (await hostBridge()).identity.actingUser(conversationId);
411
+ return (await hostBridge("identity.actingUser")).identity.actingUser(conversationId);
357
412
  },
358
413
  },
414
+ // Curator P6 (A9): the boot-built curation host through the bridge —
415
+ // the lazy member discipline (a factory-body call waits for the
416
+ // bridge; a post-bind call resolves at once). Every member of the
417
+ // curation surface is a function, so a Proxy that forwards each
418
+ // property read (and call) to the RESOLVED bridge host implements
419
+ // the whole surface with the same wait semantics as the async
420
+ // delegations above. Members that return SYNCHRONOUS store objects
421
+ // (`skills(user)`, `overlays(user)` — store handles whose own methods
422
+ // are async) must come back UNAWAITED: an `async` wrapper would
423
+ // return a Promise of the store, and the caller's sync `.list()` —
424
+ // the shape the view and the targets use — would read undefined
425
+ // (D1's e2e found the view rendering 500 on exactly this). The
426
+ // settled host is memoized in the HOST closure (not the getter —
427
+ // every `host.curation` read returns a FRESH proxy, and a per-proxy
428
+ // memo would reset on each read, putting the async forwarder back
429
+ // in front of sync members on the second read).
430
+ get curation() {
431
+ if (curationHost !== undefined)
432
+ return curationHost;
433
+ const pending = hostBridge("curation").then((bridge) => {
434
+ curationHost = bridge.curation;
435
+ return bridge.curation;
436
+ });
437
+ // SAFETY: the Proxy target satisfies the structural member shapes
438
+ // because every forwarded call is an `apply` trap on the real host's
439
+ // own member; the cast only bridges the contract's named members.
440
+ return new Proxy({}, {
441
+ get(_target, property) {
442
+ // The settled-bridge fast path: the member itself, bound — a
443
+ // synchronous member (`skills`) returns its store synchronously,
444
+ // an async one keeps returning its promise. The property read is
445
+ // STILL a function either way (the surface has no data members),
446
+ // so callers observe the contract shape unchanged.
447
+ if (curationHost !== undefined) {
448
+ const member = curationHost[property];
449
+ if (typeof member !== "function") {
450
+ throw new Error(`curation: member ${String(property)} is not callable`);
451
+ }
452
+ return member.bind(curationHost);
453
+ }
454
+ // The pre-bind window: every member becomes a forwarder that
455
+ // waits for the bridge (the factory-body discipline). The
456
+ // surface's SYNC store members — `skills(user)` and
457
+ // `overlays(user)` return store handles whose own methods are
458
+ // async (the shape the view and the steps call: `.list()` with
459
+ // no await on the store itself) — must come back as a STORE,
460
+ // not a promise of one (the eval's idle pass hit exactly this:
461
+ // the archive step read `.list` off a promise). A lazy store
462
+ // whose methods each await the bridge preserves the
463
+ // sync-return contract while keeping the wait: the caller
464
+ // gets the store immediately and its first method call
465
+ // resolves once the bridge lands (or rejects at the bind
466
+ // deadline through the shared pending promise).
467
+ if (property === "skills" || property === "overlays") {
468
+ return (user) => syncStoreForwarder(pending, property, user);
469
+ }
470
+ return async (...args) => {
471
+ const resolvedHost = await pending;
472
+ // SAFETY: the property is one of the contract's own function
473
+ // members (the surface has no data members); the bound call is
474
+ // the real host's, typed at the call site.
475
+ const member = resolvedHost[property];
476
+ if (typeof member !== "function") {
477
+ throw new Error(`curation: member ${String(property)} is not callable`);
478
+ }
479
+ return member.apply(resolvedHost, [...args]);
480
+ };
481
+ },
482
+ });
483
+ },
359
484
  // Gateway C4b: straight delegation (the bridge owns both bodies).
360
485
  models: {
361
486
  async list() {
362
- return (await hostBridge()).models.list();
487
+ return (await hostBridge("models.list")).models.list();
363
488
  },
364
489
  },
365
490
  commands: {
366
491
  async list() {
367
- return (await hostBridge()).commands.list();
492
+ return (await hostBridge("commands.list")).commands.list();
368
493
  },
369
494
  async run(conversationId, name, args, opts) {
370
- return (await hostBridge()).commands.run(conversationId, name, args, opts);
495
+ return (await hostBridge("commands.run")).commands.run(conversationId, name, args, opts);
371
496
  },
372
497
  },
498
+ async notify(input) {
499
+ return (await hostBridge("notify")).notify(identity.name, input);
500
+ },
373
501
  // The surfaces seam (UI-blocks U3 Task 4): `changed` awaits the
374
502
  // bridge (so a factory-body call waits, never resolves early) and
375
503
  // delegates to the server-side SurfaceService — the rev bump + the
@@ -385,7 +513,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
385
513
  // production (nothing sets it).
386
514
  globalThis.__sigrunSurfaceChanged?.(host.name);
387
515
  };
388
- void hostBridge()
516
+ void hostBridge("surfaces.changed")
389
517
  .then((bridge) => {
390
518
  ran();
391
519
  bridge.surfaces.changed(identity.name, id, key, update);
@@ -45,10 +45,13 @@ async function readContextFile(input, fileName, context) {
45
45
  /**
46
46
  * The static `identity` section body: runtime facts only. No tool claims
47
47
  * (the system prompt already declares the actually-offered tools per
48
- * request) and no model name (it would re-emit this section on every
49
- * mid-conversation model swap, defeating the minimal-patch design).
48
+ * request), no model name (it would re-emit this section on every
49
+ * mid-conversation model swap, defeating the minimal-patch design), and
50
+ * no NAME: what the agent is called is `SOUL.md` territory, since a user
51
+ * may choose a different name for their own agent. Durability is runtime
52
+ * truth, not identity copy — it stays out of this section.
50
53
  */
51
- export const IDENTITY_TEXT = "You are sigrun, an AI assistant running as a persistent server built on the pi-durable harness. Conversations are durable: they survive restarts and can span channels (web UI, terminal UI, HTTP API). Multiple conversations may be active at once; each is independent.";
54
+ export const IDENTITY_TEXT = "You are a small but extensible multi-user AI assistant built on the pi-durable harness. Conversations can span channels (web UI, terminal UI, HTTP API), and multiple independent conversations may be active at once.";
52
55
  /**
53
56
  * The persona sigrun ships when no `SOUL.md` exists. A present `SOUL.md`
54
57
  * replaces it wholesale; the default is never merged in.
@@ -0,0 +1,24 @@
1
+ /** The persistent state folder shared by every run of one job. */
2
+ import { defineExtension, section } from "@earendil-works/pi-durable";
3
+ import { jobStateDir } from "../../jobs/state-dir.js";
4
+ import { SessionMetaDoc } from "../../server/session-meta.js";
5
+ /** Name a new-conversation job run's persistent, per-owner state folder. */
6
+ export function createJobStateExtension(deps) {
7
+ return defineExtension({
8
+ name: "sigrun.job-state",
9
+ sections: [
10
+ section("job-state", async (input, context) => {
11
+ const meta = await input.read.snapshot(SessionMetaDoc, input.conversationId, context);
12
+ if (meta?.jobId === undefined)
13
+ return undefined;
14
+ const job = await deps.job(meta.jobId);
15
+ if (job === undefined)
16
+ return undefined;
17
+ const root = deps.jobsRoot(meta.owner);
18
+ if (root === undefined)
19
+ return undefined;
20
+ return `Job state: this conversation is a run of job "${job.name}" (${meta.jobId}). Its state folder is ${jobStateDir(root, meta.jobId)} — create it if missing. Use it to keep notes between runs (for example, when an issue was first seen). Other runs of this job use the same folder; write files atomically.`;
21
+ }),
22
+ ],
23
+ });
24
+ }
@@ -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
  }