@sigrun/sigrun 0.7.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +78 -59
  2. package/README.md +32 -0
  3. package/dist/config.js +47 -0
  4. package/dist/curation/host.js +125 -21
  5. package/dist/curation/runs.js +15 -4
  6. package/dist/extension.d.ts +331 -4
  7. package/dist/harness/boot.js +114 -3
  8. package/dist/harness/complete.js +127 -0
  9. package/dist/harness/decision-tool.js +93 -0
  10. package/dist/harness/decisions.js +477 -0
  11. package/dist/harness/event-bus.js +12 -0
  12. package/dist/harness/interactions.js +81 -0
  13. package/dist/harness/interactive-tools.js +97 -0
  14. package/dist/harness/loader.js +285 -1
  15. package/dist/harness/packages.js +7 -0
  16. package/dist/harness/published-hooks.js +208 -0
  17. package/dist/harness/seam-host.js +149 -1
  18. package/dist/harness/settings.js +41 -0
  19. package/dist/history/git.js +6 -2
  20. package/dist/jobs/fire.js +10 -0
  21. package/dist/jobs/observe.js +27 -2
  22. package/dist/notices/events.js +8 -0
  23. package/dist/notices/service.js +297 -0
  24. package/dist/notices/store.js +331 -0
  25. package/dist/notices/validate.js +311 -0
  26. package/dist/server/conversation-ops.js +50 -0
  27. package/dist/server/core-views/skills-tree.js +3 -12
  28. package/dist/server/core-views/skills.js +11 -23
  29. package/dist/server/host-bridge.js +62 -1
  30. package/dist/server/index.js +26 -0
  31. package/dist/server/routes/events.js +20 -0
  32. package/dist/server/routes/extensions.js +5 -0
  33. package/dist/server/routes/interactions.js +48 -1
  34. package/dist/server/routes/meta.js +17 -0
  35. package/dist/server/routes/notices.js +94 -0
  36. package/dist/server/routes/providers.js +65 -8
  37. package/dist/server/routes/submissions.js +3 -0
  38. package/dist/server/routes/users.js +4 -0
  39. package/dist/server/surfaces/dedup.js +6 -0
  40. package/dist/server/visibility.js +30 -0
  41. package/dist/settings/registry.js +18 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  43. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  44. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  45. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  46. package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
  47. package/node_modules/@sigrun/shared/dist/notices.js +49 -0
  48. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  49. package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
  50. package/node_modules/@sigrun/shared/package.json +9 -1
  51. package/package.json +7 -7
  52. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  53. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  54. package/webui/dist/index.html +2 -2
  55. package/webui/dist/assets/index-BI5k-Oci.js +0 -55
  56. package/webui/dist/assets/index-EGluyRXk.css +0 -1
@@ -88,6 +88,11 @@ export function registerExtensionRoutes(app, ctx) {
88
88
  ...(def.sharedData === true
89
89
  ? { sharedData: true }
90
90
  : {}),
91
+ // The per-conversation opt-in (sparse: present only
92
+ // when the panel opted in).
93
+ ...(def.perConversation === true
94
+ ? { perConversation: true }
95
+ : {}),
91
96
  })),
92
97
  }),
93
98
  };
@@ -34,7 +34,7 @@
34
34
  */
35
35
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
36
36
  import { AmbientDoc, AmbientOwnersDoc, asAmbientRecord, } from "../../harness/ambient.js";
37
- import { InteractionDoc, InteractionsIndexDoc, answerFromBody, applyAnswer, asInteractionRecord, indexSettled, maskedAnswerValues, } from "../../harness/interactions.js";
37
+ import { InteractionDoc, InteractionsIndexDoc, answerFromBody, applyAnswer, applyDecline, asInteractionRecord, indexSettled, maskedAnswerValues, sweepSecretDelivery, } from "../../harness/interactions.js";
38
38
  import { loadConversation } from "../conversation-handler.js";
39
39
  import { ID_PARAMS_SCHEMA, conversationId } from "../helpers.js";
40
40
  import { accessOf } from "@sigrun/shared/access";
@@ -221,6 +221,53 @@ export function registerInteractionRoutes(app, ctx) {
221
221
  answer: committed.answer,
222
222
  };
223
223
  });
224
+ // The decline route (ask-user Task 1): the answer route's peer — a
225
+ // human declines a DECLINABLE record (status "skipped", declinedVia
226
+ // "button"). Same access (write), same commit structure, same
227
+ // indexSettled + reply-after-commit; every successful decline sweeps
228
+ // the record's unread secret delivery inside the commit (the
229
+ // settleInteraction pattern). 404 unknown / 404 not-pending /
230
+ // 409 not-declinable.
231
+ app.post("/v1/conversations/:id/interactions/:callId/decline", {
232
+ schema: { params: ANSWER_PARAMS_SCHEMA },
233
+ preHandler: loadConversation(ctx, "write"),
234
+ }, async (request, reply) => {
235
+ const { callId } = request.params;
236
+ const conversation = request.conversation;
237
+ const committed = await conversation.commit(async (tx) => {
238
+ const draft = await tx.doc(InteractionDoc, conversation.id, callId, callId);
239
+ // Unknown callId: the doc materializes empty — kind "" never
240
+ // seeded (the answer route's detection).
241
+ if (draft.kind === "") {
242
+ return { found: false };
243
+ }
244
+ const outcome = applyDecline(draft, "button", Date.now(), request.principal.user);
245
+ if (outcome !== "declined") {
246
+ // A loser of the first-wins race never sweeps: the delivery
247
+ // (when there is one) belongs to the winner (the answer
248
+ // route's rule).
249
+ return { found: true, outcome };
250
+ }
251
+ const index = await tx.doc(InteractionsIndexDoc);
252
+ indexSettled(index, conversation.id, callId);
253
+ sweepSecretDelivery(conversation.id, callId);
254
+ return { found: true, outcome };
255
+ }, BACKGROUND_CONTEXT);
256
+ if (!committed.found) {
257
+ return await reply.code(404).send({ error: "no_such_interaction" });
258
+ }
259
+ if (committed.outcome === "not_pending") {
260
+ return await reply.code(404).send({ error: "not_pending" });
261
+ }
262
+ if (committed.outcome === "not_declinable") {
263
+ return await reply.code(409).send({ error: "not_declinable" });
264
+ }
265
+ return {
266
+ callId,
267
+ status: "skipped",
268
+ declinedVia: "button",
269
+ };
270
+ });
224
271
  // ─── Ambient seam ────────────────────────────────────────────────────
225
272
  // The conversation's ambient surfaces, replacement-state reads.
226
273
  app.get("/v1/conversations/:id/ambient", { schema: { params: ID_PARAMS_SCHEMA } }, async (request, reply) => {
@@ -73,6 +73,12 @@ export function registerMetaRoutes(app, ctx) {
73
73
  // The §7.3 shared-data flag: channels refetch
74
74
  // fully when a since fetch answers ops-only.
75
75
  ...(def.sharedData === true ? { sharedData: true } : {}),
76
+ // The per-conversation opt-in: channels attach the
77
+ // panel with the open conversation's id (sparse —
78
+ // present only when the panel opted in).
79
+ ...(def.perConversation === true
80
+ ? { perConversation: true }
81
+ : {}),
76
82
  })),
77
83
  }),
78
84
  };
@@ -174,6 +180,17 @@ export function registerMetaRoutes(app, ctx) {
174
180
  providers: opened.providerCollisions,
175
181
  commands: commandCatalog.collisions,
176
182
  },
183
+ // Knowledge C7: the published-hook registry — one row per OWNED
184
+ // hook name (`provider` is the owner slug; `shadowed` lists the
185
+ // losing providers). Empty when nothing published.
186
+ publishedHooks: opened.publishedHooks(),
187
+ // Warden Task 7: the decision-provider seam projection (the same
188
+ // pure-derivation discipline — the registry's boot-built rows).
189
+ decisions: {
190
+ providers: opened.decisions.providers(),
191
+ default: opened.decisions.resolvedDefault(),
192
+ collisions: opened.decisions.collisions(),
193
+ },
177
194
  };
178
195
  });
179
196
  // The slash-command catalog: the thin metadata list the composer's `/`
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The `/v1/notices` routes (notices Task 4, spec §3/§4): the user's
3
+ * notice pile and its one action dispatch, over the shared
4
+ * `NoticesService` (the store files' one mediation point).
5
+ *
6
+ * Access: every notice id is per-user — a notice only ever exists on
7
+ * its recipient's pile. A foreign / unknown / user-less id is therefore
8
+ * indistinguishable from an absent one: 404 `notice_not_found` on
9
+ * every `:id` route (never a 403 tell), and the user-less principal's
10
+ * GET is the empty list. There is no cross-user selector: an admin
11
+ * asking with another user's id still 404s — the id is only ever
12
+ * addressed through the CALLER's pile.
13
+ *
14
+ * Route params: notice ids are UUID strings (store-assigned), so the
15
+ * schemas declare a string param — never the conversations'
16
+ * `ID_PARAMS_SCHEMA` (integer).
17
+ */
18
+ /**
19
+ * The one notice-id param: a UUID string (the store mints random ids).
20
+ * Declared as a string so Fastify's param validation cannot mask the
21
+ * lookup with an integer-coercion 400.
22
+ */
23
+ const NOTICE_ID_PARAMS_SCHEMA = {
24
+ type: "object",
25
+ properties: { id: { type: "string" } },
26
+ required: ["id"],
27
+ additionalProperties: false,
28
+ };
29
+ /** The action route's params: the notice id plus the action id. */
30
+ const NOTICE_ACTION_PARAMS_SCHEMA = {
31
+ type: "object",
32
+ properties: { id: { type: "string" }, actionId: { type: "string" } },
33
+ required: ["id", "actionId"],
34
+ additionalProperties: false,
35
+ };
36
+ /** The action route's body: the optional client request id (dedup). */
37
+ const NOTICE_ACTION_BODY_SCHEMA = {
38
+ type: "object",
39
+ properties: { requestId: { type: "string" } },
40
+ additionalProperties: false,
41
+ };
42
+ /** The not-found body — shared by every notice route's lookup. */
43
+ const NOT_FOUND = { error: "notice_not_found" };
44
+ /** Register the `/v1/notices` routes. */
45
+ export function registerNoticeRoutes(app, ctx) {
46
+ const { notices } = ctx;
47
+ // The user's own pile (spec §3): newest first, minus any
48
+ // conversation notice whose share was revoked (the service prunes
49
+ // them off disk on the same read). A user-less principal has no
50
+ // pile — the empty list, never an error.
51
+ app.get("/v1/notices", async (request) => {
52
+ const user = request.principal.user;
53
+ if (user === undefined)
54
+ return { notices: [] };
55
+ return { notices: await notices.listForUser(user) };
56
+ });
57
+ // Dismiss one of the caller's notices: 204 when it existed, 404
58
+ // when it did not (foreign / unknown / already dismissed — all the
59
+ // same answer).
60
+ app.post("/v1/notices/:id/dismiss", { schema: { params: NOTICE_ID_PARAMS_SCHEMA } }, async (request, reply) => {
61
+ const user = request.principal.user;
62
+ if (user === undefined) {
63
+ return await reply.code(404).send(NOT_FOUND);
64
+ }
65
+ const { id } = request.params;
66
+ if (!(await notices.dismiss(user, id))) {
67
+ return await reply.code(404).send(NOT_FOUND);
68
+ }
69
+ return await reply.code(204).send();
70
+ });
71
+ // Dismiss everything of the caller's. Idempotent: always 204.
72
+ app.post("/v1/notices/dismiss-all", async (request, reply) => {
73
+ const user = request.principal.user;
74
+ if (user !== undefined) {
75
+ await notices.dismissAll(user);
76
+ }
77
+ return await reply.code(204).send();
78
+ });
79
+ // The one action dispatch (spec §4): the service's dispatch order
80
+ // (rate-limit → dedup → lookup → action find → binding find →
81
+ // handler call) answers the 200/400/404/409/429 union; the route
82
+ // only maps it onto the wire.
83
+ app.post("/v1/notices/:id/actions/:actionId", {
84
+ schema: {
85
+ params: NOTICE_ACTION_PARAMS_SCHEMA,
86
+ body: NOTICE_ACTION_BODY_SCHEMA,
87
+ },
88
+ }, async (request, reply) => {
89
+ const { id, actionId } = request.params;
90
+ const body = (request.body ?? {});
91
+ const answer = await notices.runAction(request.principal, id, actionId, body.requestId);
92
+ return await reply.code(answer.status).send(answer.body);
93
+ });
94
+ }
@@ -87,8 +87,30 @@ function providerReadinessReport(opened) {
87
87
  for (const { id: providerId } of opened.models.getProviders()) {
88
88
  providers.push(readinessEntry(opened, providerId, producersByName));
89
89
  }
90
+ // Warden Task 7: decision providers append as their own entries
91
+ // (`kind: "decision"`, the model ids listed) — a decision provider
92
+ // whose id collides with a chat provider stays SEPARATE (distinct
93
+ // kinds, never merged).
94
+ for (const decision of opened.decisions.providers()) {
95
+ providers.push(decisionEntry(decision));
96
+ }
90
97
  return { providers };
91
98
  }
99
+ /**
100
+ * One decision provider's report entry (warden Task 7): the registry
101
+ * row projected onto the shared entry shape — `models` the count,
102
+ * `modelIds` the bare ids, `owner` the contributing extension.
103
+ */
104
+ function decisionEntry(decision) {
105
+ return {
106
+ provider: decision.id,
107
+ kind: "decision",
108
+ status: decision.status,
109
+ models: decision.models.length,
110
+ modelIds: decision.models,
111
+ owner: decision.extension,
112
+ };
113
+ }
92
114
  /** Register the model-catalog and provider-readiness routes. */
93
115
  export function registerProviderRoutes(app, ctx) {
94
116
  const { opened } = ctx;
@@ -109,6 +131,35 @@ export function registerProviderRoutes(app, ctx) {
109
131
  // serves, so the 404 vocabulary matches it). Key material never
110
132
  // appears in any of these routes — statuses and counts only.
111
133
  const isKnownProvider = (providerId) => opened.models.getProvider(providerId) !== undefined;
134
+ // Warden Task 7: a DECISION-only provider id is equally routable (the
135
+ // registry enumerates its ids). The mutation bodies branch on the
136
+ // kind: chat ids refresh, decision ids re-read — the registry
137
+ // derives readiness per call, so a credential write is visible
138
+ // without a refresh (and the decision seam has no catalog leg to
139
+ // refresh).
140
+ const isDecisionProvider = (providerId) => opened.decisions.providers().some((row) => row.id === providerId);
141
+ const isRoutableProvider = (providerId) => isKnownProvider(providerId) || isDecisionProvider(providerId);
142
+ // The entry back from a mutation: the chat derivation for catalog
143
+ // providers, the decision entry for decision-only ids (a chat AND
144
+ // decision provider sharing an id answer the CHAT entry — the
145
+ // mutation there targets the catalog provider's refresh leg).
146
+ const routableEntry = (providerId) => {
147
+ if (isKnownProvider(providerId)) {
148
+ return readinessEntry(opened, providerId, producersIndex(opened));
149
+ }
150
+ const decision = opened.decisions
151
+ .providers()
152
+ .find((row) => row.id === providerId);
153
+ if (decision === undefined) {
154
+ throw new Error(`provider "${providerId}" is not routable`);
155
+ }
156
+ return decisionEntry(decision);
157
+ };
158
+ // The post-write refresh: chat ids only.
159
+ const refreshAfterWrite = async (providerId) => {
160
+ if (isKnownProvider(providerId))
161
+ await opened.refreshProvider(providerId);
162
+ };
112
163
  // One bounded refresh of the named provider (W3 Task 6, spec §7.6):
113
164
  // the SAME policy the boot/interval/refresh-on-miss paths run — bounded
114
165
  // by the deadline, failures warned (never the route's error), a
@@ -138,7 +189,7 @@ export function registerProviderRoutes(app, ctx) {
138
189
  return await reply.code(403).send({ error: "forbidden" });
139
190
  }
140
191
  const { id } = request.params;
141
- if (!isKnownProvider(id)) {
192
+ if (!isRoutableProvider(id)) {
142
193
  return await reply.code(404).send({ error: "provider_not_found" });
143
194
  }
144
195
  const body = request.body;
@@ -153,9 +204,14 @@ export function registerProviderRoutes(app, ctx) {
153
204
  .code(400)
154
205
  .send({ error: "invalid_field", field: "apiKey" });
155
206
  }
156
- await opened.auth.setApiKey(credentialKey(id), apiKey);
157
- await opened.refreshProvider(id);
158
- return readinessEntry(opened, id, producersIndex(opened));
207
+ // The credential key: chat ids redirect the DEFAULT provider's id
208
+ // to the magic "default" key; decision-only ids use the id
209
+ // verbatim (the magic key names the default CHAT provider, never a
210
+ // decision credential).
211
+ const key = isKnownProvider(id) ? credentialKey(id) : id;
212
+ await opened.auth.setApiKey(key, apiKey);
213
+ await refreshAfterWrite(id);
214
+ return routableEntry(id);
159
215
  });
160
216
  // Remove the provider's credential (admin): store delete of that SAME
161
217
  // key → one refresh → the entry. Restart-free the same way — the
@@ -165,12 +221,13 @@ export function registerProviderRoutes(app, ctx) {
165
221
  return await reply.code(403).send({ error: "forbidden" });
166
222
  }
167
223
  const { id } = request.params;
168
- if (!isKnownProvider(id)) {
224
+ if (!isRoutableProvider(id)) {
169
225
  return await reply.code(404).send({ error: "provider_not_found" });
170
226
  }
171
- await opened.auth.deleteApiKey(credentialKey(id));
172
- await opened.refreshProvider(id);
173
- return readinessEntry(opened, id, producersIndex(opened));
227
+ const key = isKnownProvider(id) ? credentialKey(id) : id;
228
+ await opened.auth.deleteApiKey(key);
229
+ await refreshAfterWrite(id);
230
+ return routableEntry(id);
174
231
  });
175
232
  // Models: the merged chat-model catalog as picker entries (no provider
176
233
  // internals — baseUrl/headers/auth stay server-side). The shared
@@ -52,6 +52,9 @@ export function registerSubmissionRoutes(app, ctx) {
52
52
  ? {}
53
53
  : { user: request.principal.user }),
54
54
  ...(body.whenBusy === undefined ? {} : { whenBusy: body.whenBusy }),
55
+ // Ask-user Task 2: a human's message through the HTTP submit
56
+ // route declines pending declinable questions (spec §5.3).
57
+ declinesQuestions: true,
55
58
  ...(request.query?.waitMs ===
56
59
  undefined
57
60
  ? {}
@@ -272,6 +272,10 @@ export function registerUserRoutes(app, ctx) {
272
272
  // must not load for the recipient. In-flight curator runs fail
273
273
  // closed through the stores' user-exists gate (nothing recreated).
274
274
  await removeUserCuration(ctx.managedPaths.userAreas(user));
275
+ // Notices spec §3.2 rule 8: notices are personal and never
276
+ // transferred. The user area itself stays for the workspace's
277
+ // recipient, so the notices file is removed on its own.
278
+ await ctx.notices.removeUser(user);
275
279
  return await reply.code(204).send();
276
280
  });
277
281
  }
@@ -18,6 +18,12 @@ export const DEDUP_WINDOW_MS = 10 * 60 * 1000;
18
18
  * same-instance duplicate (and `null` when a different instance already
19
19
  * holds the actionId — the caller treats that as a fresh action);
20
20
  * `record` stores the first result.
21
+ *
22
+ * Generic over the stored result type (notices Task 4): the surface
23
+ * actions' default (`ActionResponse`) and the notices service's
24
+ * `NoticeActionResponse` share the `{ok, ...}` result shape but are
25
+ * different unions — the notice dispatch stores its own type through
26
+ * the same mechanics without a cast.
21
27
  */
22
28
  export class ActionDedup {
23
29
  entries = new Map();
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Visibility for an ARBITRARY directory user (notices Task 3): the
3
+ * request-scoped `visibleTo` (routes/interactions.ts) answers for the
4
+ * REQUEST principal; the notices service needs the same answer for the
5
+ * notice's RECIPIENT — usually not the caller at all (a post targeting
6
+ * another user's conversation, or the list read pruning a revoked
7
+ * share).
8
+ *
9
+ * The rule is the same one `visibleTo` runs: `accessOf` over the
10
+ * conversation's SessionMetaDoc snapshot, with the principal projected
11
+ * from the DIRECTORY (stored groups only — never request-supplied
12
+ * claims), absent record → not visible. A userless conversation (no
13
+ * meta) reads as ownerless → visible, exactly like `visibleTo`.
14
+ */
15
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
16
+ import { accessOf } from "@sigrun/shared/access";
17
+ import { storedGroups } from "../harness/identity/groups.js";
18
+ import { SessionMetaDoc } from "./session-meta.js";
19
+ /**
20
+ * Whether `user` (a directory username) can SEE conversation `id`:
21
+ * `accessOf` over the session-meta snapshot with the user's stored
22
+ * groups. `false` when the directory has no record for the name.
23
+ */
24
+ export async function visibleToUser(ctx, id, user) {
25
+ const record = ctx.opened.identity.users.get(user);
26
+ if (record === undefined)
27
+ return false;
28
+ const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT);
29
+ return (accessOf({ user, superuser: false, groups: storedGroups(record) }, meta ?? {}) !== "none");
30
+ }
@@ -133,6 +133,18 @@ export const CORE_SETTING_KEYS = [
133
133
  section: "general",
134
134
  reason: "read per settled run by the auto-namer (src/harness/naming.ts)",
135
135
  },
136
+ {
137
+ key: "decisionModel",
138
+ apply: "live",
139
+ section: "general",
140
+ reason: "read per host.decisions.evaluate call by the decision registry (src/harness/decisions.ts)",
141
+ },
142
+ {
143
+ key: "decisions.tool",
144
+ apply: "live",
145
+ section: "tools",
146
+ reason: "read at each decision_evaluate tool call (default off)",
147
+ },
136
148
  {
137
149
  key: "defaultProvider.baseUrl",
138
150
  apply: "restart",
@@ -332,6 +344,12 @@ export const CORE_SETTING_KEYS = [
332
344
  section: "advanced",
333
345
  reason: "the event bus's queue bound is set at open (boot)",
334
346
  },
347
+ {
348
+ key: "server.decisions.timeoutMs",
349
+ apply: "live",
350
+ section: "providers",
351
+ reason: "the decision registry reads the settings' timeoutMs per evaluate call (src/harness/decisions.ts)",
352
+ },
335
353
  {
336
354
  key: "server.providers.refreshIntervalMinutes",
337
355
  apply: "live",
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.9.0] - 2026-11-05
10
+
11
+ - `InteractionRecord` gains optional `declinable: true` and settled `declinedVia:
12
+ "button" | "message"` (the ask-user decline wire surface — additive, optional).
13
+ - `InteractionDeclined` wire type beside `InteractionAnswered` (the decline route's
14
+ 200 body).
15
+
16
+ ## [0.8.0] - 2026-11-04
17
+
18
+ - New `./notices` subpath: the `UserNotice*` wire types, `NOTICE_LIMITS`, and the
19
+ key/action-id regexes (the client-shared contract for the notices feature).
20
+
9
21
  ## [0.7.0] - 2026-11-04
10
22
 
11
23
  - New `./model-label` subpath: `modelBreadcrumb` collapses every non-leaf path segment of
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The decision-provider wire types (the decision-providers spec): a
3
+ * provider TYPE whose models answer typed questions (noul / choice /
4
+ * score) over a JSON state, for extensions that need small structured
5
+ * judgments rather than a chat completion.
6
+ *
7
+ * TYPE-ONLY module, with NO imports at all: `JsonValue`/`JsonObject` are
8
+ * declared locally exactly the way `shared/src/json.ts` mirrors them, so
9
+ * the shared leaf keeps its zero-import invariant (enforced by
10
+ * test/views-purity.test.ts). The two `DECISION_MAX_*` constants are
11
+ * pure VALUES — wire modules carry "types + pure constants".
12
+ */
13
+ /** A strict-JSON value (local mirror — see shared/src/json.ts). */
14
+ export type DecisionJsonValue = null | boolean | number | string | DecisionJsonValue[] | {
15
+ [key: string]: DecisionJsonValue;
16
+ };
17
+ /** A strict-JSON object (local mirror — see shared/src/json.ts). */
18
+ export type DecisionJsonObject = {
19
+ [key: string]: DecisionJsonValue;
20
+ };
21
+ /** The state a decision question is asked over: a string or strict JSON. */
22
+ export type DecisionState = string | DecisionJsonObject | DecisionJsonValue[];
23
+ /** One typed question. Choice needs at least one label; score at least two levels. */
24
+ export type DecisionQuestion = {
25
+ readonly type: "noul";
26
+ readonly instructions?: DecisionJsonValue;
27
+ readonly criteria?: {
28
+ readonly true?: DecisionJsonValue;
29
+ readonly false?: DecisionJsonValue;
30
+ } | null;
31
+ } | {
32
+ readonly type: "choice";
33
+ readonly instructions?: DecisionJsonValue;
34
+ readonly criteria: Readonly<Record<string, DecisionJsonValue | null>>;
35
+ } | {
36
+ readonly type: "score";
37
+ readonly instructions?: DecisionJsonValue;
38
+ readonly criteria: readonly DecisionJsonValue[];
39
+ };
40
+ /** One evaluation: a state plus 1..32 typed questions. */
41
+ export interface DecisionRequest {
42
+ readonly state: DecisionState;
43
+ readonly questions: Readonly<Record<string, DecisionQuestion>>;
44
+ }
45
+ /** One answer, matching its question's type; probabilities are in [0, 1]. */
46
+ export type DecisionAnswer = {
47
+ readonly type: "noul";
48
+ readonly noul: number;
49
+ } | {
50
+ readonly type: "choice";
51
+ readonly choice: string;
52
+ readonly confidence: number;
53
+ readonly probabilities: Readonly<Record<string, number>>;
54
+ } | {
55
+ readonly type: "score";
56
+ readonly score: number;
57
+ readonly confidence: number;
58
+ readonly probabilities: Readonly<Record<string, number>>;
59
+ readonly legend?: Readonly<Record<string, DecisionJsonValue>>;
60
+ };
61
+ /** The provider's answer set plus optional usage bookkeeping. */
62
+ export interface DecisionAnswers {
63
+ readonly answers: Readonly<Record<string, DecisionAnswer>>;
64
+ readonly usage?: {
65
+ readonly input_tokens: number | null;
66
+ readonly output_tokens: number | null;
67
+ };
68
+ readonly model?: string;
69
+ }
70
+ /** The outcome of one `evaluate` call; NEVER a throw. */
71
+ export type DecisionResult = {
72
+ readonly ok: true;
73
+ readonly model: string;
74
+ readonly answers: Readonly<Record<string, DecisionAnswer>>;
75
+ readonly usage: {
76
+ readonly input_tokens: number | null;
77
+ readonly output_tokens: number | null;
78
+ };
79
+ readonly elapsedMs: number;
80
+ } | {
81
+ readonly ok: false;
82
+ readonly code: "no_model" | "unknown_model" | "invalid_request" | "over_limits" | "not_configured" | "timeout" | "aborted" | "failed" | "bad_answer";
83
+ readonly error: string;
84
+ };
85
+ /** A model's declared limits (narrower than the built-in caps win). */
86
+ export interface DecisionLimits {
87
+ readonly maxQuestions?: number;
88
+ readonly maxInputBytes?: number;
89
+ readonly minChoiceOptions?: number;
90
+ readonly maxScoreLevels?: number;
91
+ }
92
+ /** Built-in cap: at most this many questions per request. */
93
+ export declare const DECISION_MAX_QUESTIONS = 32;
94
+ /** Built-in cap: the JSON state serialized size, in bytes. */
95
+ export declare const DECISION_MAX_BYTES = 65536;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The decision-provider wire types (the decision-providers spec): a
3
+ * provider TYPE whose models answer typed questions (noul / choice /
4
+ * score) over a JSON state, for extensions that need small structured
5
+ * judgments rather than a chat completion.
6
+ *
7
+ * TYPE-ONLY module, with NO imports at all: `JsonValue`/`JsonObject` are
8
+ * declared locally exactly the way `shared/src/json.ts` mirrors them, so
9
+ * the shared leaf keeps its zero-import invariant (enforced by
10
+ * test/views-purity.test.ts). The two `DECISION_MAX_*` constants are
11
+ * pure VALUES — wire modules carry "types + pure constants".
12
+ */
13
+ /** Built-in cap: at most this many questions per request. */
14
+ export const DECISION_MAX_QUESTIONS = 32;
15
+ /** Built-in cap: the JSON state serialized size, in bytes. */
16
+ export const DECISION_MAX_BYTES = 65_536;
@@ -54,6 +54,20 @@ export type InteractionRecord = {
54
54
  * model-asserted. Absent on the naive single-operator path (no user).
55
55
  */
56
56
  readonly answeredByUser?: string;
57
+ /**
58
+ * Decline affordance (ask-user Task 1, spec §5.2): `true` marks the
59
+ * record declinable — a human may skip it without answering (the
60
+ * decline route / `host.declineInteraction`). Absent or omitted means
61
+ * NOT declinable; `false` fails validation (the flag is `true`-only).
62
+ */
63
+ readonly declinable?: true;
64
+ /**
65
+ * How the record was declined (ask-user Task 1/2): "button" via the
66
+ * decline route or host method, "message" via an admitted decline
67
+ * message. Set only on a `skipped` decline; absent on every other
68
+ * terminal status.
69
+ */
70
+ readonly declinedVia?: "button" | "message";
57
71
  /**
58
72
  * The interaction body: a block tree (placement `interaction` — a
59
73
  * root `form` whose `submit` is the answer). Legacy-shaped bodies