@sigrun/ext-gateway 0.0.0-stage → 0.1.2

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 +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -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 +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -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 +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -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 +81 -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 +18 -0
  65. package/dist/session-meta.js +39 -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 +65 -0
  69. package/dist/state/identities.js +135 -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 +176 -0
  73. package/dist/state/ledger.js +256 -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,62 @@
1
+ /**
2
+ * Album merging (spec §8.4 step 6): events sharing a `groupId` that
3
+ * arrive within 1500 ms merge into ONE submission (a platform media
4
+ * group delivered as several events). There is no time-based burst
5
+ * merging otherwise — every non-album message submits on arrival.
6
+ *
7
+ * The window runs from the FIRST event of its group: the group is
8
+ * closed once a later sibling arrives past `firstAt + 1500`, and a
9
+ * `groupId` reused later opens a NEW group. Group state is per
10
+ * binding key (the chat) — two chats never share an album. Attachment
11
+ * arrays are preserved alongside envelope texts so every sibling's
12
+ * media reaches the one submission.
13
+ */
14
+ /** The album merge window (ms) — the plan's pinned 1.5 s. */
15
+ export const ALBUM_WINDOW_MS = 1_500;
16
+ /**
17
+ * The in-flight album collector, one instance per engine. `add` returns
18
+ * `undefined` while the album stays OPEN (the caller holds the event
19
+ * back — no submission yet) and the collected batch when the group is
20
+ * COMPLETE. A follow-up event with no `groupId` returns its own batch.
21
+ */
22
+ export class AlbumCollector {
23
+ now;
24
+ windowMs;
25
+ groups = new Map();
26
+ constructor(now, windowMs = ALBUM_WINDOW_MS) {
27
+ this.now = now;
28
+ this.windowMs = windowMs;
29
+ }
30
+ /** Offer one event's envelope text; undefined means still collecting. */
31
+ add(key, event, text) {
32
+ if (event.groupId === undefined) {
33
+ return { texts: [text], attachments: event.attachments ?? [] };
34
+ }
35
+ const at = this.now();
36
+ const group = this.groups.get(`${key}:${event.groupId}`);
37
+ if (group === undefined || at - group.firstAt > this.windowMs) {
38
+ this.groups.set(`${key}:${event.groupId}`, {
39
+ texts: [text],
40
+ attachments: [...(event.attachments ?? [])],
41
+ firstAt: at,
42
+ });
43
+ return undefined;
44
+ }
45
+ group.texts.push(text);
46
+ group.attachments.push(...(event.attachments ?? []));
47
+ // Every sibling stays held until the timer or shutdown flush.
48
+ return undefined;
49
+ }
50
+ /** Close and remove one open group, returning its complete batch. */
51
+ close(key, groupId) {
52
+ const group = this.groups.get(`${key}:${groupId}`);
53
+ if (group === undefined)
54
+ return { texts: [], attachments: [] };
55
+ this.groups.delete(`${key}:${groupId}`);
56
+ return { texts: group.texts, attachments: group.attachments };
57
+ }
58
+ /** Is a group OPEN (collecting) for `key`/`groupId`? */
59
+ isOpen(key, groupId) {
60
+ return this.groups.has(`${key}:${groupId}`);
61
+ }
62
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The inbound envelope (spec §8.10): the text the model sees — the
3
+ * group `[<label>]` prefix, the reply-quote block, and the DM
4
+ * pass-through. The quote truncates at 500 chars (the plan's pinned
5
+ * value) with a single ellipsis marker; the author line renders when
6
+ * the platform names the replied-to author (a reply to the bot itself
7
+ * carries no author name — the quote block alone keeps the thread
8
+ * context).
9
+ */
10
+ import type { InboundEvent } from "@sigrun/sigrun/extension";
11
+ /**
12
+ * Build the envelope text for one inbound event. `group` selects the
13
+ * `[<senderLabel>]` prefix; the reply-quote block prefixes either form
14
+ * when the event carries a `replyTo`.
15
+ */
16
+ export declare function buildEnvelope(event: InboundEvent, opts: {
17
+ group: boolean;
18
+ senderLabel: string;
19
+ }): string;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The inbound envelope (spec §8.10): the text the model sees — the
3
+ * group `[<label>]` prefix, the reply-quote block, and the DM
4
+ * pass-through. The quote truncates at 500 chars (the plan's pinned
5
+ * value) with a single ellipsis marker; the author line renders when
6
+ * the platform names the replied-to author (a reply to the bot itself
7
+ * carries no author name — the quote block alone keeps the thread
8
+ * context).
9
+ */
10
+ /** The reply-quote truncation cap (the plan's pinned 500 chars). */
11
+ const QUOTE_MAX = 500;
12
+ /**
13
+ * Build the envelope text for one inbound event. `group` selects the
14
+ * `[<senderLabel>]` prefix; the reply-quote block prefixes either form
15
+ * when the event carries a `replyTo`.
16
+ */
17
+ export function buildEnvelope(event, opts) {
18
+ const body = opts.group
19
+ ? `[${opts.senderLabel}] ${event.text}`
20
+ : event.text;
21
+ if (event.replyTo === undefined)
22
+ return body;
23
+ const parts = [];
24
+ if (event.replyTo.text !== undefined) {
25
+ parts.push(`> ${truncate(event.replyTo.text)}`);
26
+ }
27
+ if (event.replyTo.authorName !== undefined) {
28
+ parts.push(`(replying to ${event.replyTo.authorName})`);
29
+ }
30
+ if (parts.length === 0)
31
+ return body;
32
+ return `${parts.join("\n")}\n\n${body}`;
33
+ }
34
+ /** The 500-char truncation with a bare ellipsis marker. */
35
+ function truncate(text) {
36
+ return text.length <= QUOTE_MAX
37
+ ? text
38
+ : `${text.slice(0, QUOTE_MAX)}…`;
39
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Group admission and sharing (spec §8.2): group config is consulted for
3
+ * every event, linked identities retain their own sender attribution,
4
+ * and unlinked `anyone` senders act only through the group owner's
5
+ * restricted conversation. Identity resolution reads core's link table.
6
+ */
7
+ import type { InboundEvent, SigrunHost } from "@sigrun/sigrun/extension";
8
+ import type { ConversationId } from "@earendil-works/pi-durable";
9
+ import type { ChannelConfig, GroupConfig } from "../config.js";
10
+ import type { IdentityStore } from "../state/identities.js";
11
+ /** The terminal result of group admission. */
12
+ export type GroupAdmission = {
13
+ readonly admit: false;
14
+ readonly reason: "unlisted" | "failed" | "mention" | "unlinked";
15
+ } | {
16
+ readonly admit: true;
17
+ readonly group: GroupConfig;
18
+ /** The linked user, or the group owner for an unlinked sender. */
19
+ readonly user: string;
20
+ readonly linked: boolean;
21
+ readonly senderLabel: string;
22
+ };
23
+ /** Resolve one event against the channel's current group configuration. */
24
+ export declare function admitGroupEvent(config: ChannelConfig, channel: string, event: InboundEvent, identities: IdentityStore): Promise<GroupAdmission>;
25
+ /**
26
+ * Give a linked speaker write access without discarding existing people.
27
+ * The owner already has write access and must not also appear as a guest.
28
+ */
29
+ export declare function ensureGroupShare(host: SigrunHost, conversationId: ConversationId, user: string): Promise<void>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Group admission and sharing (spec §8.2): group config is consulted for
3
+ * every event, linked identities retain their own sender attribution,
4
+ * and unlinked `anyone` senders act only through the group owner's
5
+ * restricted conversation. Identity resolution reads core's link table.
6
+ */
7
+ /** Resolve one event against the channel's current group configuration. */
8
+ export async function admitGroupEvent(config, channel, event, identities) {
9
+ const row = config.groups.find((group) => group.chat === event.chat.chatId);
10
+ if (row === undefined)
11
+ return { admit: false, reason: "unlisted" };
12
+ if ("failed" in row)
13
+ return { admit: false, reason: "failed" };
14
+ if (row.mention === "required" && event.mentioned !== true) {
15
+ return { admit: false, reason: "mention" };
16
+ }
17
+ const resolved = await identities.userFor(config, channel, event.sender.id);
18
+ if (resolved === undefined && row.members === "linked") {
19
+ return { admit: false, reason: "unlinked" };
20
+ }
21
+ if (resolved === undefined) {
22
+ return {
23
+ admit: true,
24
+ group: row,
25
+ user: row.owner,
26
+ linked: false,
27
+ senderLabel: `${event.sender.name ?? event.sender.id} (not linked)`,
28
+ };
29
+ }
30
+ return {
31
+ admit: true,
32
+ group: row,
33
+ user: resolved.user,
34
+ linked: true,
35
+ // The pipeline replaces this platform fallback with the directory
36
+ // display name when the directory supplies one.
37
+ senderLabel: event.sender.name ?? resolved.user,
38
+ };
39
+ }
40
+ /**
41
+ * Give a linked speaker write access without discarding existing people.
42
+ * The owner already has write access and must not also appear as a guest.
43
+ */
44
+ export async function ensureGroupShare(host, conversationId, user) {
45
+ const meta = await host.conversations.meta(conversationId);
46
+ if (meta.owner === user)
47
+ return;
48
+ const sharing = meta.sharing;
49
+ if (sharing?.scope === "people" && sharing.users.includes(user))
50
+ return;
51
+ const users = sharing?.scope === "people" ? [...sharing.users, user] : [user];
52
+ await host.conversations.patchMeta(conversationId, {
53
+ sharing: { scope: "people", users, level: "write" },
54
+ });
55
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Inbound attachments (spec §8.11): lazy, capped fetches become
3
+ * upload-backed submission parts. A failed attachment never rejects the
4
+ * whole chat message; its pinned note is the model-visible outcome.
5
+ */
6
+ import type { InboundAttachment, SigrunHost, SubmissionInputPart } from "@sigrun/sigrun/extension";
7
+ import type { EngineLog } from "../log.js";
8
+ export declare const TOO_LARGE_NOTE = "[attachment skipped: too large]";
9
+ export declare const VOICE_NOTE = "[voice note attached; transcription unavailable]";
10
+ export declare const ATTACHMENT_FAILED_NOTE = "[attachment failed]";
11
+ export interface MediaDeps {
12
+ readonly host: SigrunHost;
13
+ readonly log: EngineLog;
14
+ }
15
+ export interface CollectedAttachments {
16
+ readonly parts: SubmissionInputPart[];
17
+ readonly notes: string[];
18
+ }
19
+ /** Collect every attachment independently, preserving event order. */
20
+ export declare function collectAttachments(deps: MediaDeps, conversationId: number, attachments: readonly InboundAttachment[], maxBytes: number, signal: AbortSignal): Promise<CollectedAttachments>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Inbound attachments (spec §8.11): lazy, capped fetches become
3
+ * upload-backed submission parts. A failed attachment never rejects the
4
+ * whole chat message; its pinned note is the model-visible outcome.
5
+ */
6
+ export const TOO_LARGE_NOTE = "[attachment skipped: too large]";
7
+ export const VOICE_NOTE = "[voice note attached; transcription unavailable]";
8
+ export const ATTACHMENT_FAILED_NOTE = "[attachment failed]";
9
+ /** Collect every attachment independently, preserving event order. */
10
+ export async function collectAttachments(deps, conversationId, attachments, maxBytes, signal) {
11
+ const parts = [];
12
+ const notes = [];
13
+ const usedNames = new Set();
14
+ for (const attachment of attachments) {
15
+ // The declared size is the cheap gate: an already-oversize object
16
+ // must not even open its platform download stream.
17
+ if (attachment.size !== undefined && attachment.size > maxBytes) {
18
+ notes.push(TOO_LARGE_NOTE);
19
+ continue;
20
+ }
21
+ let bytes;
22
+ try {
23
+ bytes = await attachment.fetch({ maxBytes, signal });
24
+ }
25
+ catch (error) {
26
+ if (thrownName(error) === "AttachmentTooLarge") {
27
+ notes.push(TOO_LARGE_NOTE);
28
+ }
29
+ else {
30
+ deps.log.warn(`attachment fetch failed: ${errorMessage(error)}`);
31
+ notes.push(ATTACHMENT_FAILED_NOTE);
32
+ }
33
+ continue;
34
+ }
35
+ try {
36
+ const name = uniqueUploadName(uploadName(attachment), usedNames);
37
+ const uploaded = await deps.host.conversations.upload(conversationId, {
38
+ name,
39
+ ...(attachment.mime === undefined ? {} : { mime: attachment.mime }),
40
+ bytes,
41
+ });
42
+ parts.push({
43
+ type: attachment.kind === "image" ? "image" : "file",
44
+ path: uploaded.path,
45
+ ...(attachment.mime === undefined ? {} : { mime: attachment.mime }),
46
+ });
47
+ if (attachment.kind === "voice")
48
+ notes.push(VOICE_NOTE);
49
+ }
50
+ catch (error) {
51
+ deps.log.error("attachment upload failed", error);
52
+ notes.push(ATTACHMENT_FAILED_NOTE);
53
+ }
54
+ }
55
+ return { parts, notes };
56
+ }
57
+ /** Preserve every same-named sibling by appending its batch position. */
58
+ function uniqueUploadName(base, used) {
59
+ let candidate = base;
60
+ let suffix = 2;
61
+ while (used.has(candidate)) {
62
+ const dot = base.lastIndexOf(".");
63
+ candidate = dot > 0
64
+ ? `${base.slice(0, dot)}-${suffix}${base.slice(dot)}`
65
+ : `${base}-${suffix}`;
66
+ suffix++;
67
+ }
68
+ used.add(candidate);
69
+ return candidate;
70
+ }
71
+ /** Match adapter errors by name across realms and non-Error throw shapes. */
72
+ function thrownName(error) {
73
+ return typeof error === "object" && error !== null && "name" in error &&
74
+ typeof error.name === "string"
75
+ ? error.name
76
+ : undefined;
77
+ }
78
+ function errorMessage(error) {
79
+ return error instanceof Error ? error.message : String(error);
80
+ }
81
+ /** Keep upload names to the host's single-path-segment contract. */
82
+ function uploadName(attachment) {
83
+ const supplied = attachment.filename?.split(/[\\/]/).pop();
84
+ if (supplied !== undefined && supplied !== "" && supplied !== "." && supplied !== "..") {
85
+ return supplied;
86
+ }
87
+ return attachment.kind === "image" ? "attachment.image" : "attachment.file";
88
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The inbound pipeline (spec §8.4): one `receive` runs the ordered
3
+ * steps. Task 3 landed steps 2 (dedupe), 3 (self/bot drops), 6 (album
4
+ * merge) and 10 (submit) plus the M12 submit-error outcomes; Task 4
5
+ * fills step 1 (the claimed gate), step 4 (self-link) and step 5 (the
6
+ * DM access policies) INLINE over the shared {@link AccessState} — the
7
+ * access stores are engine-wide (one pairing queue, one link-code
8
+ * table, one token table, one loop guard), so the engine builds the
9
+ * state and hands it to every channel's pipeline. Steps 7 (commands),
10
+ * 8 (interaction answers) and 9 (attachments) remain INJECTABLE STAGES
11
+ * (`PipelineDeps.stages`) filled by their own tasks — the design keeps
12
+ * the step ORDER in one place while each task lands its logic.
13
+ *
14
+ * The channel's config is read LIVE per event by the CALLER (the
15
+ * engine's `receive` wrapper reads `readConfig(host)` and passes the
16
+ * channel's block in) — every field except `enabled` is per-event.
17
+ *
18
+ * On admission the pipeline records `submissionId → { bindingKey,
19
+ * channel }` in `deps.origins` (the in-memory map the outbound tasks
20
+ * consume — which chat a run came from, so settled answers route).
21
+ *
22
+ * The notice/error replies go through `deps.channel.send` — the
23
+ * adapter-bound transport, host-independent (the loopback fixture's
24
+ * `sent` array records them, which is how the sigrun e2e asserts reply
25
+ * text). A failed notice send is logged, never thrown: the submit
26
+ * error already resolved the event's fate.
27
+ */
28
+ import type { ActionEvent, ChannelContribution, InboundEvent, SigrunHost } from "@sigrun/sigrun/extension";
29
+ import type { SubmissionId } from "@earendil-works/pi-durable";
30
+ import type { ChannelConfig } from "../config.js";
31
+ import type { JsonValue } from "@sigrun/shared/blocks";
32
+ import type { EngineLog } from "../log.js";
33
+ import type { StreamOrigin } from "../outbound/stream.js";
34
+ import { BindingStore } from "../state/bindings.js";
35
+ import { IdentityStore } from "../state/identities.js";
36
+ import { PairingStore } from "../access/pairing.js";
37
+ import { LinkCodes } from "../access/link-codes.js";
38
+ import { LoopGuard } from "../access/loop-guard.js";
39
+ import { ButtonTokens } from "./tokens.js";
40
+ /** A receive's terminal outcome (the adapter acks on any resolution). */
41
+ export type PipelineOutcome = {
42
+ readonly outcome: "admitted";
43
+ } | {
44
+ readonly outcome: "dropped";
45
+ } | {
46
+ readonly outcome: "command";
47
+ };
48
+ /**
49
+ * The origin record: which chat a submission came from. The outbound
50
+ * tasks (streaming hand-off, unanswered notices) read it.
51
+ */
52
+ export interface SubmissionOrigin extends StreamOrigin {
53
+ }
54
+ /**
55
+ * The engine-wide access state (Task 4): every store the access steps
56
+ * and the pair/link commands share. The ENGINE builds one and threads
57
+ * it into every channel pipeline AND the command closures; a pipeline
58
+ * constructed WITHOUT one builds a private state over its own state
59
+ * dir (the stand-alone Task 3 shape).
60
+ */
61
+ export interface AccessState {
62
+ readonly identities: IdentityStore;
63
+ readonly pairing: PairingStore;
64
+ readonly linkCodes: LinkCodes;
65
+ readonly tokens: ButtonTokens;
66
+ readonly loopGuard: LoopGuard;
67
+ }
68
+ /** The stages later tasks inject (§8.4 steps 8, 9). */
69
+ export interface PipelineStages {
70
+ /** Step 8 (interaction text answers): consumed-or-continue, as above. */
71
+ readonly interactionAnswer?: (channelId: string, event: InboundEvent) => Promise<PipelineOutcome | undefined>;
72
+ /** Step 9 (attachments): render the event's media into submit parts. */
73
+ readonly attachments?: (event: InboundEvent) => Promise<import("./media.js").CollectedAttachments>;
74
+ }
75
+ /** The pipeline's injectable dependencies. */
76
+ export interface PipelineDeps {
77
+ readonly host: SigrunHost;
78
+ /** The channel adapter (send for notice replies; the label for names). */
79
+ readonly channel: ChannelContribution;
80
+ /** The engine's state dir (`<stateDir>/gateway`). */
81
+ readonly stateDir: string;
82
+ readonly log: EngineLog;
83
+ /** submissionId → the origin record (the outbound seam). */
84
+ readonly origins: Map<SubmissionId, SubmissionOrigin>;
85
+ /** The engine-wide binding store (the settled fan-out and the
86
+ * delivery workers share this ONE instance — cross-instance writes
87
+ * would interleave JsonFile queues and lose updates). Absent → a
88
+ * private instance (the stand-alone Task 3 shape). */
89
+ readonly bindings?: BindingStore;
90
+ /** The injectable stages (§8.4 steps 8, 9). */
91
+ readonly stages?: PipelineStages;
92
+ /** Task 9's write-gated interaction answer seam (button taps). */
93
+ readonly interactionAnswer?: (answer: {
94
+ readonly conversationId: number;
95
+ readonly callId: string;
96
+ readonly values: Record<string, JsonValue>;
97
+ readonly user: string | undefined;
98
+ }) => Promise<string>;
99
+ /** Cross-channel lookup for `/pair` approval notices. Stand-alone
100
+ * pipelines fall back to their own adapter. */
101
+ readonly channelOf?: (channel: string) => ChannelContribution | undefined;
102
+ /** The engine-wide access state (Task 4). Absent → a private one. */
103
+ readonly access?: AccessState;
104
+ /** The origin-chat stream registry (Task 6). Absent in stand-alone
105
+ * pipeline tests disables streaming; the engine always wires it. */
106
+ readonly streams?: PipelineStreams;
107
+ /** The clock (tests inject a fake). Default: real. */
108
+ readonly now?: () => number;
109
+ }
110
+ /** The pipeline's narrow stream seam; StreamRegistry and test fakes fit it. */
111
+ export interface PipelineStreams {
112
+ open(conversationId: number, origin: StreamOrigin): Promise<boolean>;
113
+ abandon(conversationId: number): void;
114
+ }
115
+ /**
116
+ * The engine's per-receive handler: `(channelId, event, config) →
117
+ * outcome`. The config is the LIVE channel block (the caller read it
118
+ * per event through `readConfig`).
119
+ */
120
+ export type Receive = (channelId: string, event: InboundEvent, config: ChannelConfig) => Promise<PipelineOutcome>;
121
+ /**
122
+ * A pipeline's receive, carrying the engine-owned flush handle and the
123
+ * tap handler: ONE pipeline per supervised channel (the binding key
124
+ * carries the channel) — the engine
125
+ * wires every supervised channel's `ChannelContext.receive` through
126
+ * the callable, calls `flushAlbums` from its `stopIntake`, and routes
127
+ * `ChannelContext.action` through `action`.
128
+ */
129
+ export interface PipelineHandle extends Receive {
130
+ /**
131
+ * Close every open album window and submit its merged batch; the
132
+ * engine's `stopIntake` runs this before intake closes (the §8.0
133
+ * ordering: flush, then stop admitting).
134
+ */
135
+ flushAlbums(): Promise<void>;
136
+ /**
137
+ * A button tap (§8.7): resolve the token, guard the tapper (a pair
138
+ * decision needs an EFFECTIVE admin — C8), apply the decision.
139
+ * Foreign or replayed tokens are ignored; a tap never throws across
140
+ * the adapter boundary.
141
+ */
142
+ action(channelId: string, event: ActionEvent, config: ChannelConfig): Promise<void>;
143
+ }
144
+ export declare function createPipeline(deps: PipelineDeps): PipelineHandle;