@sigrun/ext-gateway 0.0.0-stage → 0.1.1

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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +111 -0
  38. package/dist/interactions/chat.d.ts +20 -0
  39. package/dist/interactions/chat.js +155 -0
  40. package/dist/interactions/prompts.d.ts +42 -0
  41. package/dist/interactions/prompts.js +96 -0
  42. package/dist/interactions/receipts.d.ts +6 -0
  43. package/dist/interactions/receipts.js +36 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +57 -0
  52. package/dist/outbound/delivery.d.ts +124 -0
  53. package/dist/outbound/delivery.js +387 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +80 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +8 -0
  65. package/dist/session-meta.js +21 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +61 -0
  69. package/dist/state/identities.js +131 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +152 -0
  73. package/dist/state/ledger.js +207 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
@@ -0,0 +1,86 @@
1
+ /**
2
+ * sigrun-ext-gateway: the gateway engine factory (the loader contract).
3
+ *
4
+ * The sigrun extension-package contract: the default export is a factory
5
+ * DECLARING the host parameter (arity 1 — the host rule; a length-0
6
+ * factory with a defaulted first parameter would consume the host as
7
+ * that dependency), returning `{ extension, gateway, routes,
8
+ * commands }`.
9
+ *
10
+ * The extension is a bare `defineExtension({ name: "gateway" })`:
11
+ * the prompt section (§8.10) and the `send_message`/`list_chats` tools
12
+ * (§8.9) land with the tasks that own them. The `gateway` engine is
13
+ * built over the host at factory time (cheap closure wiring — no
14
+ * timers, no I/O; the pi lifecycle rule: the factory registers
15
+ * handlers only, `start` runs after the server listens), the admin
16
+ * status route (Task 2, §8.12) renders the engine's status read, and
17
+ * the `/pair` + `/link` slash commands (Task 4, §8.2) close over the
18
+ * SAME engine-wide access state the pipelines use — a webui approval
19
+ * and a chat-button tap drive the same pairing queue, and an issued
20
+ * link code redeems in the matching channel's pipeline.
21
+ */
22
+ import type { SigrunHost, SigrunExtensionResult } from "@sigrun/sigrun/extension";
23
+ /**
24
+ * The sigrun extension-package factory: builds the engine over the host.
25
+ * One factory call, one engine (the one-engine rule holds at the loader:
26
+ * a second declarer quarantines).
27
+ */
28
+ export default function createGateway(host: SigrunHost): SigrunExtensionResult;
29
+ export { createEngine } from "./engine.js";
30
+ export { collectAttachments, TOO_LARGE_NOTE, VOICE_NOTE, ATTACHMENT_FAILED_NOTE } from "./inbound/media.js";
31
+ export type { MediaDeps, CollectedAttachments } from "./inbound/media.js";
32
+ export { createGatewayTools, RECEIPT_WAIT_MS } from "./tools.js";
33
+ export type { ToolDeps } from "./tools.js";
34
+ export { createChannelSection } from "./section.js";
35
+ export type { ChannelSectionDeps } from "./section.js";
36
+ export type { EngineDeps, EngineInternals } from "./engine.js";
37
+ export { splitMarkdown, stripMarkdown } from "./outbound/markdown.js";
38
+ export { renderChunks } from "./outbound/render.js";
39
+ export type { SerializedOutbound, LedgerEntry, LedgerInput, LedgerKind, LedgerState } from "./state/ledger.js";
40
+ export { Ledger, PRUNE_AFTER_MS } from "./state/ledger.js";
41
+ export { DeliveryWorker, RESENT_MARKER } from "./outbound/delivery.js";
42
+ export type { DeliveryDeps } from "./outbound/delivery.js";
43
+ export { onRunSettled, NO_TEXT_REPLY, STOPPED } from "./outbound/settled.js";
44
+ export { parseTarget, resolveTargets } from "./jobs.js";
45
+ export type { DeliveryTarget, RejectedTarget, ResolvedTarget, ResolveTargetsResult, TargetDeps, } from "./jobs.js";
46
+ export { createNotifier } from "./notify.js";
47
+ export type { NotifyDeps } from "./notify.js";
48
+ export type { SettledDeps, Streams } from "./outbound/settled.js";
49
+ export { StreamRegistry } from "./outbound/stream.js";
50
+ export type { StreamOrigin, StreamRegistryDeps } from "./outbound/stream.js";
51
+ export { Supervisor } from "./supervisor.js";
52
+ export type { ChannelState, ChannelStatus, StatusSink, SupervisorDeps, } from "./supervisor.js";
53
+ export { createStatusRoute } from "./status-route.js";
54
+ export type { GatewayStatus, StatusPairingRow, StatusRead, } from "./status-route.js";
55
+ export { parseGatewayConfig, readConfig, DEFAULT_MEDIA_MAX_BYTES, } from "./config.js";
56
+ export type { ChannelConfig, DmPolicy, GatewayConfig, GroupConfig, } from "./config.js";
57
+ export { JsonFile } from "./state/json-file.js";
58
+ export { bindingKey, BindingStore } from "./state/bindings.js";
59
+ export type { Binding } from "./state/bindings.js";
60
+ export { SeenSet } from "./state/seen.js";
61
+ export { IdentityStore } from "./state/identities.js";
62
+ export type { LinkResult, LinkVia, ResolvedUser } from "./state/identities.js";
63
+ export { buildEnvelope } from "./inbound/envelope.js";
64
+ export { createPipeline } from "./inbound/pipeline.js";
65
+ export type { AccessState, PipelineDeps, PipelineOutcome, PipelineStreams, PipelineStages, Receive, SubmissionOrigin, } from "./inbound/pipeline.js";
66
+ export { AlbumCollector, ALBUM_WINDOW_MS } from "./inbound/albums.js";
67
+ export { ButtonTokens } from "./inbound/tokens.js";
68
+ export type { TokenAction } from "./inbound/tokens.js";
69
+ export { CODE_ALPHABET, newCode, normalizeCode } from "./access/codes.js";
70
+ export { PairingStore, PAIRING_TTL_MS } from "./access/pairing.js";
71
+ export type { PairingRequest, RequestResult } from "./access/pairing.js";
72
+ export { LinkCodes } from "./access/link-codes.js";
73
+ export { LoopGuard } from "./access/loop-guard.js";
74
+ export { runPair } from "./commands/pair.js";
75
+ export type { PairCaller, PairDeps } from "./commands/pair.js";
76
+ export { createLinkCommand, redeemLink } from "./commands/link.js";
77
+ export { GATEWAY_COMMANDS, chatCommandMenu, handleChatCommand, } from "./commands/chat.js";
78
+ export type { ChatCommandContext, ChatCommandDeps, GatewayCommand, } from "./commands/chat.js";
79
+ export type { LinkCommand, LinkCommandDeps, LinkDeps, } from "./commands/link.js";
80
+ export { createLog, hashId } from "./log.js";
81
+ export { planInteraction, renderInteraction } from "./interactions/render.js";
82
+ export type { ChatAnswerPlan, RenderInteractionOptions } from "./interactions/render.js";
83
+ export { PromptStore } from "./interactions/prompts.js";
84
+ export { answerFromChat, createInteractionAnswerStage } from "./interactions/answer.js";
85
+ export { createInteractionNotifier, onInteractionSettled } from "./interactions/chat.js";
86
+ export type { EngineLog } from "./log.js";
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * sigrun-ext-gateway: the gateway engine factory (the loader contract).
3
+ *
4
+ * The sigrun extension-package contract: the default export is a factory
5
+ * DECLARING the host parameter (arity 1 — the host rule; a length-0
6
+ * factory with a defaulted first parameter would consume the host as
7
+ * that dependency), returning `{ extension, gateway, routes,
8
+ * commands }`.
9
+ *
10
+ * The extension is a bare `defineExtension({ name: "gateway" })`:
11
+ * the prompt section (§8.10) and the `send_message`/`list_chats` tools
12
+ * (§8.9) land with the tasks that own them. The `gateway` engine is
13
+ * built over the host at factory time (cheap closure wiring — no
14
+ * timers, no I/O; the pi lifecycle rule: the factory registers
15
+ * handlers only, `start` runs after the server listens), the admin
16
+ * status route (Task 2, §8.12) renders the engine's status read, and
17
+ * the `/pair` + `/link` slash commands (Task 4, §8.2) close over the
18
+ * SAME engine-wide access state the pipelines use — a webui approval
19
+ * and a chat-button tap drive the same pairing queue, and an issued
20
+ * link code redeems in the matching channel's pipeline.
21
+ */
22
+ import { defineExtension } from "@earendil-works/pi-durable";
23
+ import { createEngine } from "./engine.js";
24
+ import { createStatusRoute } from "./status-route.js";
25
+ import { runPair } from "./commands/pair.js";
26
+ import { createLinkCommand } from "./commands/link.js";
27
+ import { readConfig } from "./config.js";
28
+ import { createGatewayTools } from "./tools.js";
29
+ import { createChannelSection } from "./section.js";
30
+ /**
31
+ * The sigrun extension-package factory: builds the engine over the host.
32
+ * One factory call, one engine (the one-engine rule holds at the loader:
33
+ * a second declarer quarantines).
34
+ */
35
+ export default function createGateway(host) {
36
+ if (host.accounts === undefined) {
37
+ throw new Error("gateway requires sigrun core with host.accounts (multi-user M3a)");
38
+ }
39
+ const engine = createEngine(host);
40
+ // `/pair` (§8.2): the admin surface over the shared pairing queue.
41
+ // The invoke route hands the caller's principal (user + EFFECTIVE
42
+ // admin — the stored flag OR the persisted group grant, C8); an
43
+ // unnamed loopback caller gets the pinned refusal.
44
+ const pair = {
45
+ name: "pair",
46
+ description: "Manage pending channel pairing requests (admin)",
47
+ args: "[approve <code> <user> | deny <code>]",
48
+ run: async (ctx) => ({
49
+ notice: await runPair(engine.pairDeps(host), ctx.args, {
50
+ user: ctx.principal.user,
51
+ admin: ctx.principal.admin,
52
+ }),
53
+ }),
54
+ };
55
+ // `/link <channel>` (§8.2): issue a single-use self-link code bound
56
+ // to the invoking user and channel. `knownChannels` reads the ids the
57
+ // last start recorded — an engine that never started knows none, and
58
+ // every channel id is unknown (the honest refusal).
59
+ const link = createLinkCommand({
60
+ readConfig: () => readConfig(host),
61
+ linkCodes: engine.accessState().linkCodes,
62
+ knownChannels: engine.channelIds,
63
+ now: Date.now,
64
+ });
65
+ return {
66
+ extension: defineExtension({
67
+ name: "gateway",
68
+ tools: createGatewayTools(engine.toolDeps()),
69
+ sections: [createChannelSection(engine.sectionDeps())],
70
+ }),
71
+ gateway: engine,
72
+ // The settled-run fan-out (§8.6): boot registers this on the event
73
+ // bus — one call per settled run, which enqueues the finals and
74
+ // kicks the workers. The narrowed document read is mandatory:
75
+ // unlike the host bridge, it resolves in harness-only embedders
76
+ // without a 30 s wait.
77
+ runSettled: (event, api, context) => engine.settle(event, api, context),
78
+ interactionSettled: async (event, _api, context) => {
79
+ await engine.interactionSettled(event, context);
80
+ },
81
+ // Task 12 owns links as well as homes; Task 13 moves links to core's
82
+ // account table and narrows this cleanup to the home-chat records.
83
+ userDeleted: async ({ user }) => {
84
+ await engine.accessState().identities.forgetHomes(user);
85
+ },
86
+ // Home-chat notifications share the delivery ledger and workers;
87
+ // this wrapper kicks delivery after each bounded enqueue.
88
+ notifiers: [engine.notifier()],
89
+ // The admin status route (§8.12): mounts at /v1/ext/gateway/status,
90
+ // admin-gated by core's route mounting (the handler renders the
91
+ // engine's read; `pairing` rows carry the hashed sender id, never
92
+ // the code).
93
+ routes: [
94
+ createStatusRoute(() => ({
95
+ channels: engine.status().channels,
96
+ pairing: engine.pairingStatus(),
97
+ })),
98
+ ],
99
+ commands: [pair, link],
100
+ };
101
+ }
102
+ export { createEngine } from "./engine.js";
103
+ export { collectAttachments, TOO_LARGE_NOTE, VOICE_NOTE, ATTACHMENT_FAILED_NOTE } from "./inbound/media.js";
104
+ export { createGatewayTools, RECEIPT_WAIT_MS } from "./tools.js";
105
+ export { createChannelSection } from "./section.js";
106
+ export { splitMarkdown, stripMarkdown } from "./outbound/markdown.js";
107
+ export { renderChunks } from "./outbound/render.js";
108
+ export { Ledger, PRUNE_AFTER_MS } from "./state/ledger.js";
109
+ export { DeliveryWorker, RESENT_MARKER } from "./outbound/delivery.js";
110
+ export { onRunSettled, NO_TEXT_REPLY, STOPPED } from "./outbound/settled.js";
111
+ export { parseTarget, resolveTargets } from "./jobs.js";
112
+ export { createNotifier } from "./notify.js";
113
+ export { StreamRegistry } from "./outbound/stream.js";
114
+ export { Supervisor } from "./supervisor.js";
115
+ export { createStatusRoute } from "./status-route.js";
116
+ export { parseGatewayConfig, readConfig, DEFAULT_MEDIA_MAX_BYTES, } from "./config.js";
117
+ export { JsonFile } from "./state/json-file.js";
118
+ export { bindingKey, BindingStore } from "./state/bindings.js";
119
+ export { SeenSet } from "./state/seen.js";
120
+ export { IdentityStore } from "./state/identities.js";
121
+ export { buildEnvelope } from "./inbound/envelope.js";
122
+ export { createPipeline } from "./inbound/pipeline.js";
123
+ export { AlbumCollector, ALBUM_WINDOW_MS } from "./inbound/albums.js";
124
+ export { ButtonTokens } from "./inbound/tokens.js";
125
+ export { CODE_ALPHABET, newCode, normalizeCode } from "./access/codes.js";
126
+ export { PairingStore, PAIRING_TTL_MS } from "./access/pairing.js";
127
+ export { LinkCodes } from "./access/link-codes.js";
128
+ export { LoopGuard } from "./access/loop-guard.js";
129
+ export { runPair } from "./commands/pair.js";
130
+ export { createLinkCommand, redeemLink } from "./commands/link.js";
131
+ export { GATEWAY_COMMANDS, chatCommandMenu, handleChatCommand, } from "./commands/chat.js";
132
+ export { createLog, hashId } from "./log.js";
133
+ export { planInteraction, renderInteraction } from "./interactions/render.js";
134
+ export { PromptStore } from "./interactions/prompts.js";
135
+ export { answerFromChat, createInteractionAnswerStage } from "./interactions/answer.js";
136
+ export { createInteractionNotifier, onInteractionSettled } from "./interactions/chat.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Write-gated answers originating in chat. Identity resolution belongs
3
+ * to the pipeline/tap caller; this module independently re-checks
4
+ * conversation write access immediately before answering.
5
+ */
6
+ import type { JsonValue } from "@sigrun/shared/blocks";
7
+ import type { HandlerContext, InboundEvent, SigrunHost } from "@sigrun/sigrun/extension";
8
+ import { type BindingStore } from "../state/bindings.js";
9
+ import { type IdentityStore } from "../state/identities.js";
10
+ import type { PromptStore } from "./prompts.js";
11
+ export interface AnswerDeps {
12
+ readonly host: SigrunHost;
13
+ readonly bindings: BindingStore;
14
+ readonly identities: IdentityStore;
15
+ readonly prompts: PromptStore;
16
+ readonly context?: HandlerContext;
17
+ /** Direct adapter reply (the pipeline supplies it; unit callers need not). */
18
+ readonly reply?: (event: InboundEvent, text: string) => Promise<void>;
19
+ }
20
+ export interface ChatAnswer {
21
+ readonly conversationId: number;
22
+ readonly callId: string;
23
+ readonly values: Record<string, JsonValue>;
24
+ readonly user: string | undefined;
25
+ }
26
+ /** Answer after proving the named chat user can still write the conversation. */
27
+ export declare function answerFromChat(deps: AnswerDeps, answer: ChatAnswer): Promise<string>;
28
+ /** One shared human-readable outcome for settlement edits and stale taps. */
29
+ export declare function outcomeOf(record: {
30
+ readonly status: string;
31
+ readonly answer?: {
32
+ readonly values: Readonly<Record<string, JsonValue>>;
33
+ };
34
+ readonly answeredByUser?: string;
35
+ }): string;
36
+ export declare function summarizeValues(values: Readonly<Record<string, JsonValue>>): string;
37
+ /** Stage 8: consume a numbered or reply-to answer, else continue admission. */
38
+ export declare function createInteractionAnswerStage(deps: AnswerDeps & {
39
+ readonly configOf?: (channel: string) => Promise<import("../config.js").ChannelConfig | undefined>;
40
+ }): (channelId: string, event: InboundEvent) => Promise<{
41
+ outcome: "command";
42
+ } | undefined>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Write-gated answers originating in chat. Identity resolution belongs
3
+ * to the pipeline/tap caller; this module independently re-checks
4
+ * conversation write access immediately before answering.
5
+ */
6
+ import { bindingKey } from "../state/bindings.js";
7
+ import { chatProviderOf } from "../state/identities.js";
8
+ const TERMINAL = new Set(["answered", "skipped", "expired", "cancelled"]);
9
+ /** Answer after proving the named chat user can still write the conversation. */
10
+ export async function answerFromChat(deps, answer) {
11
+ if (answer.user === undefined)
12
+ return "You do not have write access to this conversation.";
13
+ const meta = await deps.host.conversations.meta(answer.conversationId);
14
+ const access = await deps.host.access.accessOf({ user: answer.user }, { owner: meta.owner, sharing: meta.sharing });
15
+ if (access !== "write")
16
+ return "You do not have write access to this conversation.";
17
+ // Terminal-snapshot invariant: secret-bearing records are webui-plan,
18
+ // so no chat answer reaches this consuming read. Widening the planner
19
+ // requires a non-consuming snapshot before any terminal path reads one.
20
+ const record = await deps.host.snapshotInteraction(answer.conversationId, answer.callId, deps.context ?? { abortSignal: new AbortController().signal });
21
+ if (record !== undefined && TERMINAL.has(record.status)) {
22
+ return `Already ${outcomeOf(record)}.`;
23
+ }
24
+ if (record === undefined)
25
+ return "That interaction is no longer available.";
26
+ const answered = await deps.host.answerInteraction(answer.conversationId, answer.callId, { values: answer.values }, deps.context ?? { abortSignal: new AbortController().signal }, { user: answer.user });
27
+ if (!answered) {
28
+ // A lost race: someone else's answer won. NEVER snapshotInteraction
29
+ // here — that read takes the WINNER's one-shot secret delivery
30
+ // (Review Focus 4: the delivery belongs to the winner's extension
31
+ // read; a losing path consuming it would destroy the secret).
32
+ return "Already answered.";
33
+ }
34
+ return `Answered: ${summarizeValues(answer.values)}.`;
35
+ }
36
+ /** One shared human-readable outcome for settlement edits and stale taps. */
37
+ export function outcomeOf(record) {
38
+ const who = record.answeredByUser === undefined ? "" : ` by ${record.answeredByUser}`;
39
+ if (record.status === "answered") {
40
+ return `answered${who}: ${summarizeValues(record.answer?.values ?? {})}`;
41
+ }
42
+ return `${record.status}${who}`;
43
+ }
44
+ export function summarizeValues(values) {
45
+ const parts = Object.entries(values).map(([field, value]) => {
46
+ const rendered = typeof value === "string" ? value : JSON.stringify(value);
47
+ return `${field}=${rendered ?? "null"}`;
48
+ });
49
+ return parts.length === 0 ? "no values" : parts.join(", ");
50
+ }
51
+ /** Stage 8: consume a numbered or reply-to answer, else continue admission. */
52
+ export function createInteractionAnswerStage(deps) {
53
+ return async (channelId, event) => {
54
+ const key = bindingKey(channelId, event.chat);
55
+ const config = await deps.configOf?.(channelId);
56
+ const resolved = config === undefined
57
+ ? await deps.host.accounts.lookup(chatProviderOf(channelId), event.sender.id)
58
+ : await deps.identities.userFor(config, channelId, event.sender.id);
59
+ const user = resolved?.user;
60
+ const lookupKeys = user === undefined ? [key] : [key, `user:${user}`];
61
+ const lookup = (read) => {
62
+ for (const candidate of lookupKeys) {
63
+ const found = read(candidate);
64
+ if (found !== undefined)
65
+ return found;
66
+ }
67
+ return undefined;
68
+ };
69
+ let prompt;
70
+ const replyTarget = event.replyTo?.messageId;
71
+ if (replyTarget !== undefined) {
72
+ const replied = lookup((candidate) => deps.prompts.byReplyTo(candidate, replyTarget));
73
+ if (replied?.plan.kind === "text") {
74
+ prompt = {
75
+ conversationId: replied.conversationId,
76
+ callId: replied.callId,
77
+ values: { [replied.plan.field]: event.text },
78
+ };
79
+ }
80
+ }
81
+ if (prompt === undefined && /^\d+$/.test(event.text.trim())) {
82
+ const pending = lookup((candidate) => {
83
+ const rows = deps.prompts.pendingInChat(candidate);
84
+ return rows.length === 0 ? undefined : rows;
85
+ });
86
+ if (pending?.length === 1) {
87
+ const selected = pending[0];
88
+ const plan = selected.plan;
89
+ if (plan.kind !== "choice")
90
+ return undefined;
91
+ const index = Number(event.text.trim()) - 1;
92
+ const options = [...plan.options].sort((a, b) => Number(b.recommended === true) - Number(a.recommended === true));
93
+ const option = options[index];
94
+ if (option === undefined) {
95
+ await deps.reply?.(event, `Answer number out of range (1-${options.length}).`);
96
+ return { outcome: "command" };
97
+ }
98
+ prompt = {
99
+ conversationId: selected.conversationId,
100
+ callId: selected.callId,
101
+ values: { [plan.field]: option.id },
102
+ };
103
+ }
104
+ }
105
+ if (prompt === undefined)
106
+ return undefined;
107
+ const reply = await answerFromChat(deps, { ...prompt, user });
108
+ await deps.reply?.(event, reply);
109
+ return { outcome: "command" };
110
+ };
111
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Interaction notification and settlement fan-out. Handlers only enqueue
3
+ * durable ledger entries; delivery receipts populate PromptStore refs so
4
+ * settlement can edit the exact platform messages.
5
+ */
6
+ import type { ChannelContribution, ExtensionLifecyclePayload, HandlerContext, NotifierContribution } from "@sigrun/sigrun/extension";
7
+ import type { Ledger } from "../state/ledger.js";
8
+ import type { ButtonTokens } from "../inbound/tokens.js";
9
+ import type { EngineLog } from "../log.js";
10
+ import { type AnswerDeps } from "./answer.js";
11
+ export interface InteractionChatDeps extends AnswerDeps {
12
+ readonly ledger: Ledger;
13
+ readonly tokens: ButtonTokens;
14
+ readonly channelOf: (channel: string) => ChannelContribution | undefined;
15
+ readonly log: EngineLog;
16
+ }
17
+ /** The gateway notifier: interaction.pending plus the ordinary home notifier. */
18
+ export declare function createInteractionNotifier(deps: InteractionChatDeps): NotifierContribution;
19
+ /** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
20
+ export declare function onInteractionSettled(event: ExtensionLifecyclePayload["interactionSettled"], deps: InteractionChatDeps, context: HandlerContext): Promise<void>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Interaction notification and settlement fan-out. Handlers only enqueue
3
+ * durable ledger entries; delivery receipts populate PromptStore refs so
4
+ * settlement can edit the exact platform messages.
5
+ */
6
+ import { summarizeValues } from "./answer.js";
7
+ import { planInteraction, renderInteraction, truncateInteractionText } from "./render.js";
8
+ /** The gateway notifier: interaction.pending plus the ordinary home notifier. */
9
+ export function createInteractionNotifier(deps) {
10
+ return {
11
+ name: "gateway",
12
+ notify: async (event) => {
13
+ if (event.type !== "interaction.pending")
14
+ return;
15
+ if (event.conversationId === undefined || event.callId === undefined)
16
+ return;
17
+ try {
18
+ await notifyInteraction(event, deps);
19
+ }
20
+ catch (error) {
21
+ deps.log.error(`interaction prompt failed (${event.conversationId}:${event.callId})`, error);
22
+ }
23
+ },
24
+ };
25
+ }
26
+ async function notifyInteraction(event, deps) {
27
+ const keys = deps.bindings.keysOf(event.conversationId);
28
+ const targets = [];
29
+ if (keys.length > 0) {
30
+ for (const key of keys) {
31
+ const binding = deps.bindings.get(key);
32
+ if (binding === undefined || binding.dead === true)
33
+ continue;
34
+ targets.push({ key, channel: key.split(":")[0], chat: binding.chat, bindingKey: key });
35
+ }
36
+ }
37
+ else if (event.user !== undefined) {
38
+ for (const home of deps.identities.homesOf(event.user)) {
39
+ targets.push({ key: `user:${event.user}`, channel: home.channel, chat: home.chat });
40
+ }
41
+ }
42
+ // Resolve and dedupe targets BEFORE reading the record. snapshotInteraction
43
+ // consumes a settled secret's one-shot delivery, even when we then return.
44
+ // Final-review follow-up: core should offer a non-consuming snapshot read.
45
+ const pendingTargets = targets.filter((target) => !deps.prompts.has(event.conversationId, event.callId, target.key));
46
+ if (pendingTargets.length === 0)
47
+ return;
48
+ const context = deps.context ?? { abortSignal: new AbortController().signal };
49
+ const record = await deps.host.snapshotInteraction(event.conversationId, event.callId, context);
50
+ if (record === undefined || record.status !== "pending")
51
+ return;
52
+ for (const target of pendingTargets) {
53
+ const channel = deps.channelOf(target.channel);
54
+ if (channel === undefined) {
55
+ deps.log.warn(`interaction prompt skipped: no channel "${target.channel}"`);
56
+ continue;
57
+ }
58
+ const plan = planInteraction(record, channel.capabilities);
59
+ const rendered = renderInteraction(record, channel.capabilities, {
60
+ conversationId: event.conversationId,
61
+ conversationName: (await deps.host.conversations.meta(event.conversationId)).name ?? `Conversation ${event.conversationId}`,
62
+ ...(event.url === undefined ? {} : { link: event.url }),
63
+ issue: (action) => deps.tokens.issue(action.kind === "interaction"
64
+ ? { ...action, conversationId: event.conversationId }
65
+ : action),
66
+ });
67
+ await deps.prompts.markEnqueued(event.conversationId, event.callId, target.key, plan);
68
+ await deps.ledger.enqueue({
69
+ channel: target.channel,
70
+ ...(target.bindingKey === undefined ? {} : { bindingKey: target.bindingKey }),
71
+ chat: target.chat,
72
+ kind: "interaction",
73
+ message: { chat: target.chat, ...rendered },
74
+ tag: `interaction:${event.conversationId}:${event.callId}:${target.key}`,
75
+ });
76
+ }
77
+ }
78
+ /** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
79
+ export async function onInteractionSettled(event, deps, context) {
80
+ try {
81
+ // NEVER snapshotInteraction here: that read is the one-shot SECRET
82
+ // delivery (it merges the true values back once, then deletes the
83
+ // entry) — an outcome edit that consumed it would silently destroy
84
+ // the extension's only chance to receive the secret. The widened C6
85
+ // event carries everything chat rendering needs (the stored answer
86
+ // is masked by construction — secrets never persist).
87
+ const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
88
+ // SAFETY: the stored answer's values are validated JsonValue (the
89
+ // record's answer shape); the payload types them loosely because
90
+ // the contract cannot import the json module's runtime shape.
91
+ const answerValues = (event.answer ?? {});
92
+ const outcome = event.status === "answered"
93
+ ? `answered${who}: ${summarizeValues(answerValues)}`
94
+ : `${event.status}${who}`;
95
+ const editableChannels = new Set();
96
+ const refs = deps.prompts.refsOf(event.conversationId, event.callId);
97
+ for (const ref of refs) {
98
+ const id = channelForRef(deps, event.conversationId, event.callId, ref);
99
+ const channel = id === undefined ? undefined : deps.channelOf(id);
100
+ if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
101
+ editableChannels.add(channel.id);
102
+ const bindingKey = deps.bindings.keysOf(event.conversationId).find((key) => {
103
+ const binding = deps.bindings.get(key);
104
+ return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
105
+ });
106
+ await deps.ledger.enqueue({
107
+ channel: channel.id,
108
+ ...(bindingKey === undefined ? {} : { bindingKey }),
109
+ chat: ref.chat,
110
+ kind: "edit",
111
+ ref,
112
+ message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
113
+ tag: `interaction-settled:${event.conversationId}:${event.callId}:${channel.id}:${ref.messageId}`,
114
+ });
115
+ }
116
+ }
117
+ if (editableChannels.size === 0) {
118
+ for (const key of deps.bindings.keysOf(event.conversationId)) {
119
+ const binding = deps.bindings.get(key);
120
+ const channelId = key.split(":")[0];
121
+ if (binding === undefined || binding.dead === true)
122
+ continue;
123
+ await deps.ledger.enqueue({
124
+ channel: channelId,
125
+ bindingKey: key,
126
+ chat: binding.chat,
127
+ kind: "notice",
128
+ message: { chat: binding.chat, text: `Interaction ${outcome}.` },
129
+ tag: `interaction-settled-notice:${event.conversationId}:${event.callId}:${key}`,
130
+ });
131
+ }
132
+ }
133
+ await deps.prompts.settle(event.conversationId, event.callId);
134
+ }
135
+ catch (error) {
136
+ deps.log.error(`interaction settlement delivery failed (${String(event.conversationId)}:${event.callId})`, error);
137
+ }
138
+ }
139
+ /** Recover the channel from the binding key/home prompt target. */
140
+ function channelForRef(deps, conversationId, callId, ref) {
141
+ for (const key of deps.bindings.keysOf(conversationId)) {
142
+ const binding = deps.bindings.get(key);
143
+ if (binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId)
144
+ return key.split(":")[0];
145
+ }
146
+ return promptChannel(deps, conversationId, callId, ref);
147
+ }
148
+ function promptChannel(deps, conversationId, callId, ref) {
149
+ // PromptStore persists refs, not a second channel mapping. The ledger's
150
+ // interaction entry is the authoritative channel attribution.
151
+ const entry = deps.ledger.list().find((candidate) => candidate.kind === "interaction" &&
152
+ candidate.tag?.startsWith(`interaction:${conversationId}:${callId}:`) === true &&
153
+ candidate.chat.chatId === ref.chat.chatId);
154
+ return entry?.channel;
155
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Durable chat-prompt bookkeeping. Ledger delivery receipts become
3
+ * MessageRefs here; pending reads stay synchronous for inbound stage 8.
4
+ * Settled prompts stop matching numbered/reply-to answers; their refs
5
+ * remain so settlement can edit the delivered platform message.
6
+ */
7
+ import type { MessageRef } from "@sigrun/sigrun/extension";
8
+ import type { ChatAnswerPlan } from "./render.js";
9
+ export interface PendingPrompt {
10
+ readonly conversationId: number;
11
+ readonly callId: string;
12
+ readonly plan: ChatAnswerPlan;
13
+ }
14
+ export declare class PromptStore {
15
+ private readonly file;
16
+ private rows;
17
+ private settled;
18
+ constructor(path: string);
19
+ /** Record a delivered prompt ref (idempotent for one recipient key). */
20
+ record(conversationId: number, callId: string, key: string, ref: MessageRef, plan: ChatAnswerPlan): Promise<void>;
21
+ /** All delivered message refs for settlement edits (including settled). */
22
+ refsOf(conversationId: number, callId: string): readonly MessageRef[];
23
+ /** Whether this recipient/chat already has a prompt recorded. */
24
+ has(conversationId: number, callId: string, key: string): boolean;
25
+ /** Recipient identities for opaque-key receipt correlation. */
26
+ pendingRecipients(conversationId: number): readonly {
27
+ callId: string;
28
+ key: string;
29
+ }[];
30
+ /** Recipient keys for receipt correlation (opaque binding keys may contain colons). */
31
+ keysOf(conversationId: number, callId: string): readonly string[];
32
+ /** Unsettled choice/text prompts in one chat (bound key or user home key). */
33
+ pendingInChat(bindingKey: string): readonly PendingPrompt[];
34
+ /** Find the prompt behind a reply-to platform message id. */
35
+ byReplyTo(bindingKey: string, messageId: string): PendingPrompt | undefined;
36
+ /** A recipient has been enqueued even before its receipt supplies a ref. */
37
+ markEnqueued(conversationId: number, callId: string, key: string, plan: ChatAnswerPlan): Promise<void>;
38
+ /** Install the real refs after the interaction ledger entry is delivered. */
39
+ recordReceipt(conversationId: number, callId: string, key: string, chat: MessageRef["chat"], messageIds: readonly string[]): Promise<void>;
40
+ /** Retire pending answers while preserving refs for the settlement edit. */
41
+ settle(conversationId: number, callId: string): Promise<void>;
42
+ }