@sigrun/ext-gateway 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  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
- ## [Unreleased]
8
+ ## [0.1.17] - 2026-11-05
9
+ ### Fixed
10
+ - `@earendil-works/pi-ai` is declared as a peer. `dist/tools.js` imports
11
+ `Type` from it at runtime but the manifest listed only chord and
12
+ pi-durable, so a standalone install (outside a sigrun host that links
13
+ the module) could not resolve it. The host link masked this in
14
+ practice; the declaration now matches the import.
15
+
16
+ ## [0.1.18] - 2026-11-05
17
+
18
+ - Decline button and declined outcomes in chat: a declinable interaction record renders
19
+ a danger-styled Decline button (any plan kind; budgeted against the channel's
20
+ `buttons.max`, falling back to a numbered list with just the Decline row at the
21
+ boundary), `declineFromChat` (write-access re-checked, terminal peek,
22
+ `host.declineInteraction`), declinedVia-aware settlement edits, and the
23
+ `Reply here to decline.` line for no-button channels. Companion to sigrun-core's
24
+ ask-user release line; the `@sigrun/shared` peer gains the 0.9.0 line.
25
+
9
26
  ## [Unreleased]
10
27
 
11
28
  ## [0.1.15] - 2026-11-04
package/README.md CHANGED
@@ -171,6 +171,18 @@ long-text field accepts a reply to its prompt. Other forms, including
171
171
  all forms containing secrets, point to the webui instead of collecting
172
172
  values over chat.
173
173
 
174
+ A record the owning extension marked `declinable` also carries a Decline
175
+ button: a danger-styled row minted independently of the answer plan (a
176
+ webui plan gets just the Decline row after its pointer; a choice plan
177
+ gets the option rows followed by the Decline row; when the options plus
178
+ the Decline row would exceed the channel's button limit, the choice
179
+ renders as a numbered list with just the Decline row). On a channel without
180
+ button capability the prompt instead says `Reply here to decline.` — a
181
+ plain reply that declines is core's message-decline (no gateway code).
182
+ Decline taps take the same write-access re-check as answers and settle
183
+ the record `skipped` with `declinedVia: "button"`; outcome lines read
184
+ `declined by alice` (button) or `declined by alice (replied in chat)`.
185
+
174
186
  Button taps, numbered replies, and reply-to text answers require a linked
175
187
  sender with current write access to the conversation. Numbered answers
176
188
  match only when exactly one choice interaction is pending in that chat.
package/dist/engine.js CHANGED
@@ -45,7 +45,7 @@ import { hashId } from "./log.js";
45
45
  import { collectAttachments } from "./inbound/media.js";
46
46
  import { PromptStore } from "./interactions/prompts.js";
47
47
  import { createInteractionNotifier, onInteractionSettled } from "./interactions/chat.js";
48
- import { answerFromChat, createInteractionAnswerStage } from "./interactions/answer.js";
48
+ import { answerFromChat, createInteractionAnswerStage, declineFromChat } from "./interactions/answer.js";
49
49
  import { receiptForInteraction } from "./interactions/receipts.js";
50
50
  /** The engine's own timer defaults (the real world). */
51
51
  function defaultTimers() {
@@ -456,6 +456,7 @@ export function createEngine(host, deps = {}) {
456
456
  streams,
457
457
  channelOf,
458
458
  interactionAnswer: async (answer) => await answerFromChat(interactionDeps(), answer),
459
+ interactionDecline: async (target) => await declineFromChat(interactionDeps(), target),
459
460
  stages: {
460
461
  interactionAnswer: createInteractionAnswerStage({
461
462
  ...interactionDeps(),
@@ -96,6 +96,12 @@ export interface PipelineDeps {
96
96
  readonly values: Record<string, JsonValue>;
97
97
  readonly user: string | undefined;
98
98
  }) => Promise<string>;
99
+ /** Ask-user Task 6: the write-gated decline seam (Decline button taps). */
100
+ readonly interactionDecline?: (target: {
101
+ readonly conversationId: number;
102
+ readonly callId: string;
103
+ readonly user: string | undefined;
104
+ }) => Promise<string>;
99
105
  /** Cross-channel lookup for `/pair` approval notices. Stand-alone
100
106
  * pipelines fall back to their own adapter. */
101
107
  readonly channelOf?: (channel: string) => ChannelContribution | undefined;
@@ -528,6 +528,17 @@ export function createPipeline(deps) {
528
528
  if (action === undefined)
529
529
  return;
530
530
  const tapper = await access.identities.userFor(config, channelId, event.sender.id);
531
+ if (action.kind === "interaction-decline") {
532
+ if (deps.interactionDecline === undefined)
533
+ return;
534
+ const reply = await deps.interactionDecline({
535
+ conversationId: action.conversationId,
536
+ callId: action.callId,
537
+ user: tapper?.user,
538
+ });
539
+ await sendNotice(event.chat, reply, undefined);
540
+ return;
541
+ }
531
542
  if (action.kind === "interaction") {
532
543
  if (deps.interactionAnswer === undefined)
533
544
  return;
@@ -27,6 +27,11 @@ export type TokenAction = {
27
27
  /** The root-form choice field (U6 records are not decision-specific). */
28
28
  readonly field?: string;
29
29
  readonly optionId: string;
30
+ } | {
31
+ /** Decline a declinable interaction (ask-user Task 6): a human's "no". */
32
+ readonly kind: "interaction-decline";
33
+ readonly conversationId: number;
34
+ readonly callId: string;
30
35
  };
31
36
  /** The button-token table, one instance per engine. */
32
37
  export declare class ButtonTokens {
@@ -25,6 +25,12 @@ export interface ChatAnswer {
25
25
  }
26
26
  /** Answer after proving the named chat user can still write the conversation. */
27
27
  export declare function answerFromChat(deps: AnswerDeps, answer: ChatAnswer): Promise<string>;
28
+ /** Decline a declinable interaction (the write-access + terminal guards mirror answerFromChat). */
29
+ export declare function declineFromChat(deps: Pick<AnswerDeps, "host" | "context">, target: {
30
+ conversationId: number;
31
+ callId: string;
32
+ user: string | undefined;
33
+ }): Promise<string>;
28
34
  /** One shared human-readable outcome for settlement edits and stale taps. */
29
35
  export declare function outcomeOf(record: {
30
36
  readonly status: string;
@@ -32,6 +38,7 @@ export declare function outcomeOf(record: {
32
38
  readonly values: Readonly<Record<string, JsonValue>>;
33
39
  };
34
40
  readonly answeredByUser?: string;
41
+ readonly declinedVia?: "button" | "message";
35
42
  }): string;
36
43
  export declare function summarizeValues(values: Readonly<Record<string, JsonValue>>): string;
37
44
  /** Stage 8: consume a numbered or reply-to answer, else continue admission. */
@@ -34,12 +34,35 @@ export async function answerFromChat(deps, answer) {
34
34
  }
35
35
  return `Answered: ${summarizeValues(answer.values)}.`;
36
36
  }
37
+ /** Decline a declinable interaction (the write-access + terminal guards mirror answerFromChat). */
38
+ export async function declineFromChat(deps, target) {
39
+ if (target.user === undefined)
40
+ return "You do not have write access to this conversation.";
41
+ const meta = await deps.host.conversations.meta(target.conversationId);
42
+ const access = await deps.host.access.accessOf({ user: target.user }, { owner: meta.owner, sharing: meta.sharing });
43
+ if (access !== "write")
44
+ return "You do not have write access to this conversation.";
45
+ const context = deps.context ?? { abortSignal: new AbortController().signal };
46
+ const record = await deps.host.peekInteraction(target.conversationId, target.callId, context);
47
+ if (record !== undefined && TERMINAL.has(record.status)) {
48
+ return `Already ${outcomeOf(record)}.`;
49
+ }
50
+ if (record === undefined)
51
+ return "That interaction is no longer available.";
52
+ const declined = await deps.host.declineInteraction(target.conversationId, target.callId, context, { user: target.user });
53
+ if (!declined)
54
+ return "Already settled.";
55
+ return "Declined.";
56
+ }
37
57
  /** One shared human-readable outcome for settlement edits and stale taps. */
38
58
  export function outcomeOf(record) {
39
59
  const who = record.answeredByUser === undefined ? "" : ` by ${record.answeredByUser}`;
40
60
  if (record.status === "answered") {
41
61
  return `answered${who}: ${summarizeValues(record.answer?.values ?? {})}`;
42
62
  }
63
+ if (record.status === "skipped" && record.declinedVia !== undefined) {
64
+ return `declined${who}${record.declinedVia === "message" ? " (replied in chat)" : ""}`;
65
+ }
43
66
  return `${record.status}${who}`;
44
67
  }
45
68
  export function summarizeValues(values) {
@@ -3,7 +3,7 @@
3
3
  * durable ledger entries; delivery receipts populate PromptStore refs so
4
4
  * settlement can edit the exact platform messages.
5
5
  */
6
- import { summarizeValues } from "./answer.js";
6
+ import { outcomeOf } from "./answer.js";
7
7
  import { planInteraction, renderInteraction, truncateInteractionText } from "./render.js";
8
8
  /** The gateway notifier: interaction.pending plus the ordinary home notifier. */
9
9
  export function createInteractionNotifier(deps) {
@@ -68,7 +68,7 @@ async function notifyInteraction(event, deps) {
68
68
  conversationId: event.conversationId,
69
69
  conversationName: (await deps.host.conversations.meta(event.conversationId)).name ?? `Conversation ${event.conversationId}`,
70
70
  ...(event.url === undefined ? {} : { link: event.url }),
71
- issue: (action) => deps.tokens.issue(action.kind === "interaction"
71
+ issue: (action) => deps.tokens.issue(action.kind === "interaction" || action.kind === "interaction-decline"
72
72
  ? { ...action, conversationId: event.conversationId }
73
73
  : action),
74
74
  });
@@ -120,14 +120,18 @@ export async function onInteractionSettled(event, deps, context) {
120
120
  }
121
121
  /** The outcome summary the settlement edit/notice carries. */
122
122
  function outcomeText(event) {
123
- const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
124
- // SAFETY: the stored answer's values are validated JsonValue (the
125
- // record's answer shape); the payload types them loosely because
126
- // the contract cannot import the json module's runtime shape.
127
- const answerValues = (event.answer ?? {});
128
- return event.status === "answered"
129
- ? `answered${who}: ${summarizeValues(answerValues)}`
130
- : `${event.status}${who}`;
123
+ // Delegates to outcomeOf (ONE wording for live taps and settlement
124
+ // edits); SAFETY: the stored answer's values are validated JsonValue
125
+ // (the record's answer shape) — the payload types them loosely
126
+ // because the contract cannot import the json module's runtime shape.
127
+ return outcomeOf({
128
+ status: event.status,
129
+ ...(event.answer === undefined
130
+ ? {}
131
+ : { answer: { values: event.answer } }),
132
+ ...(event.answeredByUser === undefined ? {} : { answeredByUser: event.answeredByUser }),
133
+ ...(event.declinedVia === undefined ? {} : { declinedVia: event.declinedVia }),
134
+ });
131
135
  }
132
136
  /**
133
137
  * Enqueue settlement edits and per-binding fallback outcome notices
@@ -26,7 +26,7 @@ export interface RenderInteractionOptions {
26
26
  readonly conversationId?: number;
27
27
  /** Mint the opaque transport-private action carried by each button. */
28
28
  readonly issue: (action: TokenAction & {
29
- readonly kind: "interaction";
29
+ readonly kind: "interaction" | "interaction-decline";
30
30
  }) => string;
31
31
  }
32
32
  /** Plan the one answer mechanism the record's root form supports in chat. */
@@ -27,7 +27,11 @@ export function planInteraction(record, caps) {
27
27
  kind: "choice",
28
28
  field: choice.name,
29
29
  options: choice.options,
30
- buttons: choice.options.length <= (caps.buttons?.max ?? 0),
30
+ // The Decline row shares the button budget: a declinable
31
+ // record needs one more slot, so a maxed-out choice falls
32
+ // back to the numbered list (its Decline row still renders).
33
+ buttons: choice.options.length + (record.declinable === true ? 1 : 0) <=
34
+ (caps.buttons?.max ?? 0),
31
35
  };
32
36
  }
33
37
  }
@@ -85,6 +89,28 @@ export function renderInteraction(record, caps, opts) {
85
89
  if (opts.link !== undefined)
86
90
  lines.push(opts.link);
87
91
  }
92
+ // The Decline affordance is minted OUTSIDE the plan (any declinable
93
+ // record, independent of plan.kind): a choice plan appends the row
94
+ // after its option rows; a webui plan gets the row after its pointer.
95
+ // A channel without buttons gets a line — a reply-to decline is
96
+ // core's message-decline (no gateway code).
97
+ if (record.declinable === true) {
98
+ if (caps.buttons !== undefined) {
99
+ const declineRow = [{
100
+ label: truncateLabel("Decline", caps.buttons.labelMax),
101
+ data: opts.issue({
102
+ kind: "interaction-decline",
103
+ conversationId: opts.conversationId ?? 0,
104
+ callId: record.callId,
105
+ }),
106
+ style: "danger",
107
+ }];
108
+ buttons = buttons === undefined ? [declineRow] : [...buttons, declineRow];
109
+ }
110
+ else {
111
+ lines.push("Reply here to decline.");
112
+ }
113
+ }
88
114
  // The body projection is plain text; the capability formatter still
89
115
  // runs so native/plain channels get the same §8.6 treatment as finals.
90
116
  const formatted = renderChunks(lines.join("\n"), caps).join("\n");
package/package.json CHANGED
@@ -1,55 +1,57 @@
1
1
  {
2
- "name": "@sigrun/ext-gateway",
3
- "version": "0.1.16",
4
- "description": "Gateway engine extension for sigrun (pi-durable): chat-transport supervision \u2014 channel adapters, inbound pipeline, delivery",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=22.19.0"
8
- },
9
- "main": "dist/index.js",
10
- "types": "dist/index.d.ts",
11
- "exports": {
12
- ".": {
13
- "types": "./dist/index.d.ts",
14
- "default": "./dist/index.js"
15
- }
16
- },
17
- "files": [
18
- "dist",
19
- "CHANGELOG.md"
20
- ],
21
- "scripts": {
22
- "test": "node scripts/check-manifest.mjs --expect-name @sigrun/ext-gateway && vitest run",
23
- "typecheck": "tsc --noEmit",
24
- "build": "tsc -p tsconfig.build.json",
25
- "prepublishOnly": "npm run build"
26
- },
27
- "peerDependencies": {
28
- "@earendil-works/chord": "1.3.0 || 1.4.0",
29
- "@earendil-works/pi-durable": "1.3.0 || 1.4.0",
30
- "@sigrun/shared": "0.3.0 || 0.4.0 || 0.4.1 || 0.4.2 || 0.5.0 || 0.6.0 || 0.6.1 || 0.7.0 || 0.8.0"
31
- },
32
- "devDependencies": {
33
- "@earendil-works/chord": "1.4.0",
34
- "@earendil-works/pi-durable": "1.4.0",
35
- "@sigrun/shared": "file:../sigrun/shared",
36
- "@sigrun/sigrun": "file:../sigrun",
37
- "@types/node": "^26.6.4",
38
- "tsx": "^4.23.15",
39
- "typescript": "^7.0.2",
40
- "vitest": "^5.0.3"
41
- },
42
- "license": "MIT",
43
- "repository": {
44
- "type": "github",
45
- "url": "git+https://github.com/sigrun-ai/sigrun-ext-gateway.git"
46
- },
47
- "bugs": {
48
- "url": "https://github.com/sigrun-ai/sigrun-ext-gateway/issues"
49
- },
50
- "homepage": "https://github.com/sigrun-ai/sigrun-ext-gateway#readme",
51
- "publishConfig": {
52
- "access": "public",
53
- "provenance": true
54
- }
55
- }
2
+ "name": "@sigrun/ext-gateway",
3
+ "version": "0.1.18",
4
+ "description": "Gateway engine extension for sigrun (pi-durable): chat-transport supervision \u2014 channel adapters, inbound pipeline, delivery",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node scripts/check-manifest.mjs --expect-name @sigrun/ext-gateway && vitest run",
23
+ "typecheck": "tsc --noEmit",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/chord": "1.3.0 || 1.4.0",
29
+ "@earendil-works/pi-ai": "1.3.0 || 1.4.0",
30
+ "@earendil-works/pi-durable": "1.3.0 || 1.4.0",
31
+ "@sigrun/shared": "0.3.0 || 0.4.0 || 0.4.1 || 0.4.2 || 0.5.0 || 0.6.0 || 0.6.1 || 0.7.0 || 0.8.0 || 0.9.0"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/chord": "1.4.0",
35
+ "@earendil-works/pi-ai": "1.4.0",
36
+ "@earendil-works/pi-durable": "1.4.0",
37
+ "@sigrun/shared": "file:../sigrun/shared",
38
+ "@sigrun/sigrun": "file:../sigrun",
39
+ "@types/node": "^26.6.4",
40
+ "tsx": "^4.23.15",
41
+ "typescript": "^7.0.2",
42
+ "vitest": "^5.0.3"
43
+ },
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "github",
47
+ "url": "git+https://github.com/sigrun-ai/sigrun-ext-gateway.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/sigrun-ai/sigrun-ext-gateway/issues"
51
+ },
52
+ "homepage": "https://github.com/sigrun-ai/sigrun-ext-gateway#readme",
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "provenance": true
56
+ }
57
+ }