@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,271 @@
1
+ /**
2
+ * onRunSettled (spec §8.6's "which runs deliver" + the M7 access
3
+ * re-check): the engine's `runSettled` factory handler. ONLY ENQUEUES —
4
+ * the per-channel delivery workers own the platform calls.
5
+ *
6
+ * - Every key in `bindings.keysOf(conversationId)` that is not dead
7
+ * gets a final (whoever started the run — ruling 4: a webui submit
8
+ * into a bound conversation delivers to the chat).
9
+ * - Access is RE-CHECKED per delivery (M7): `meta` + `accessOf` for
10
+ * the binding's user — the LINKED user for DMs, the group owner for
11
+ * groups. A failed check UNBINDS the chat, logs it, and enqueues
12
+ * nothing.
13
+ * - An answered run renders the answer's text blocks (thinking and
14
+ * tool calls are never shown); an answer with NO text blocks gets
15
+ * the pinned "(no text reply)" notice (Review Focus 4 — an empty
16
+ * send is a platform rejection, never an honest reply).
17
+ * - The origin-chat streaming hand-off runs through `deps.streams`:
18
+ * rendered chunks cross to the registry at this layer while the
19
+ * boolean answer decides whether the origin's finals enqueue.
20
+ * - An unanswered run notifies the ORIGIN chat only (from
21
+ * `deps.origins`): the pinned "Stopped." for an abort reason, a
22
+ * short reason notice otherwise.
23
+ */
24
+ import { hashId } from "../log.js";
25
+ import { renderChunks } from "./render.js";
26
+ import { resolveTargets } from "../jobs.js";
27
+ /** The pinned notice for a textless answer (Review Focus 4). */
28
+ export const NO_TEXT_REPLY = "(no text reply)";
29
+ /** The pinned abort notice (spec §8.6: "An abort from /stop produces Stopped."). */
30
+ export const STOPPED = "Stopped.";
31
+ /**
32
+ * The settled-run handler: enqueue finals (or the empty-answer notice)
33
+ * for every live bound chat, and the unanswered notice for the origin
34
+ * chat. Never throws (the event bus's bounded handler owns isolation,
35
+ * but a ledger failure must not lose the rest of the fan-out).
36
+ */
37
+ export async function onRunSettled(event, deps) {
38
+ try {
39
+ await run(event, deps);
40
+ }
41
+ catch (error) {
42
+ // A fan-out failure is logged loud: the entries that landed stay
43
+ // queued (the ledger is the source of truth), the lost ones log
44
+ // their conversation (§8.6: nothing is dropped silently).
45
+ deps.log.error(`settled fan-out failed (conversation ${String(event.conversationId)})`, error);
46
+ }
47
+ }
48
+ /** The body (isolated from the catch so a partial fan-out still logs). */
49
+ async function run(event, deps) {
50
+ const conversationId = event.conversationId;
51
+ if (event.outcome.status === "answered") {
52
+ await fanOutAnswer(event, deps, conversationId);
53
+ await deliverJobAnswer(event, deps, conversationId);
54
+ return;
55
+ }
56
+ if (originKeyOf(deps, event) !== undefined) {
57
+ deps.streams.abandon(conversationId);
58
+ }
59
+ await notifyOrigin(event, deps);
60
+ }
61
+ /** The answered fan-out: every live bound key, access-checked. */
62
+ async function fanOutAnswer(event, deps, conversationId) {
63
+ const keys = deps.bindings.keysOf(conversationId);
64
+ // The answer's visible text: text blocks joined (thinking/tool calls
65
+ // are never shown — §8.6).
66
+ const text = answerText(event);
67
+ // The origin-chat streaming hand-off runs ONCE per conversation,
68
+ // before the fan-out and even when the binding disappeared (the watch
69
+ // still needs releasing). Only the origin gets its chunks here.
70
+ const origin = originKeyOf(deps, event);
71
+ const originChannel = origin === undefined ? undefined : deps.channelOf(origin.channel);
72
+ const originBinding = origin === undefined ? undefined : deps.bindings.get(origin.bindingKey);
73
+ const originChunks = originChannel === undefined || originBinding === undefined || originBinding.dead === true || text === ""
74
+ ? []
75
+ : renderChunks(text, originChannel.capabilities, originChannel.format);
76
+ // Foreign settles (webui/jobs) still fan out finals, but must not tear
77
+ // down the gateway-originated run's live preview.
78
+ const streamed = origin === undefined
79
+ ? false
80
+ : await deps.streams.finalize(conversationId, originChunks);
81
+ if (keys.length === 0)
82
+ return;
83
+ for (const key of keys) {
84
+ const binding = deps.bindings.get(key);
85
+ if (binding === undefined || binding.dead === true)
86
+ continue;
87
+ // A channel config that is ABSENT or {failed} is a CONFIG problem,
88
+ // not a revoked binding: the binding is KEPT (a config typo must
89
+ // never silently delete chats) and the fan-out skips with a log.
90
+ const channel = channelOfFor(deps, key);
91
+ if (channel === undefined) {
92
+ deps.log.warn(`settled: no channel adapter for binding "${hashId(key)}" — skipped, binding kept`);
93
+ deps.log.debug(`settled: no channel adapter for binding "${key}" — skipped, binding kept`);
94
+ continue;
95
+ }
96
+ const config = await deps.configOf(key.split(":")[0] ?? "");
97
+ if (config === undefined) {
98
+ deps.log.warn(`settled: channel config unavailable for binding "${hashId(key)}" — skipped, binding kept`);
99
+ deps.log.debug(`settled: channel config unavailable for binding "${key}" — skipped, binding kept`);
100
+ continue;
101
+ }
102
+ // M7: the access re-check. DM bindings resolve their user through
103
+ // the SAME allowlist-then-link resolution admission ran; group
104
+ // bindings use the config's owner. A FAILED CHECK (a present config
105
+ // whose user is unresolvable, or access "none") unbinds, logs, skips.
106
+ const meta = await deps.host.conversations.meta(conversationId);
107
+ // A removed or malformed group row pauses that group; neither revokes
108
+ // the binding. This matches inbound admission and preserves continuity
109
+ // if the operator restores or repairs the row. Only a valid row whose
110
+ // owner is genuinely unresolvable reaches the unbind path below.
111
+ const group = binding.chat.chatType === "dm"
112
+ ? undefined
113
+ : config.groups.find((entry) => entry.chat === binding.chat.chatId);
114
+ if (binding.chat.chatType !== "dm" &&
115
+ (group === undefined || "failed" in group)) {
116
+ deps.log.warn(`settled: group config absent or invalid for binding "${hashId(key)}" — skipped, binding kept`);
117
+ deps.log.debug(`settled: group config absent or invalid for binding "${key}" — skipped, binding kept`);
118
+ continue;
119
+ }
120
+ const user = await bindingUser(deps, config, key, binding.chat);
121
+ if (user === undefined || meta === undefined) {
122
+ await unbindAndLog(deps, conversationId, key, "user unresolvable");
123
+ continue;
124
+ }
125
+ const access = await deps.host.access.accessOf({ user }, { owner: meta.owner, sharing: meta.sharing });
126
+ if (access === "none") {
127
+ await unbindAndLog(deps, conversationId, key, `access revoked for user "${user}"`);
128
+ continue;
129
+ }
130
+ const chunks = text === "" ? [NO_TEXT_REPLY] : renderChunks(text, channel.capabilities, channel.format);
131
+ // The stream owns the ORIGIN chat (the chat the run streamed to):
132
+ // that key is skipped; every other key still enqueues.
133
+ if (streamed && key === origin?.bindingKey) {
134
+ continue;
135
+ }
136
+ for (let index = 0; index < chunks.length; index++) {
137
+ await deps.ledger.enqueue({
138
+ channel: channel.id,
139
+ bindingKey: key,
140
+ chat: binding.chat,
141
+ kind: text === "" ? "notice" : "final",
142
+ message: { chat: binding.chat, text: chunks[index] },
143
+ // The tag is chunk-indexed: a multi-chunk final's entries are
144
+ // distinct rows (a replayed settle re-dedupes the whole set).
145
+ tag: `final:${conversationId}:${String(event.outcome.answerEntryId)}:${key}:${index}`,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ /**
151
+ * A new-conversation job's FIRST answered settle: resolve the meta's
152
+ * targets and enqueue one headed answer per target. The job-tag prefix
153
+ * makes this one-shot even when later runs carry different answers.
154
+ */
155
+ async function deliverJobAnswer(event, deps, conversationId) {
156
+ if (deps.ledger.list().some((entry) => entry.tag?.startsWith(`job:${conversationId}:`) === true))
157
+ return;
158
+ const meta = deps.readMeta === undefined
159
+ ? await deps.host.conversations.meta(conversationId)
160
+ : await deps.readMeta(conversationId);
161
+ if (meta?.jobId === undefined || meta.deliver === undefined)
162
+ return;
163
+ const owner = meta.owner;
164
+ if (owner === undefined) {
165
+ deps.log.warn(`job delivery rejected (conversation ${conversationId}): no owner`);
166
+ return;
167
+ }
168
+ const targets = await resolveTargets(deps, owner, meta.deliver);
169
+ for (const rejected of targets.rejected) {
170
+ deps.log.warn(`job delivery rejected (conversation ${conversationId}, target "${rejected.target}"): ${rejected.reason}`);
171
+ }
172
+ const text = answerText(event);
173
+ const body = text === "" ? NO_TEXT_REPLY : text;
174
+ const name = meta.name ?? meta.autoName ?? `Conversation ${conversationId}`;
175
+ for (const target of targets.resolved) {
176
+ const channel = deps.channelOf(target.channel);
177
+ if (channel === undefined) {
178
+ deps.log.warn(`job delivery skipped: no channel "${target.channel}"`);
179
+ continue;
180
+ }
181
+ const chunks = renderChunks(`Job ${name}\n${body}`, channel.capabilities, channel.format);
182
+ for (let index = 0; index < chunks.length; index++) {
183
+ await deps.ledger.enqueue({
184
+ channel: target.channel,
185
+ chat: target.chat,
186
+ kind: "final",
187
+ message: { chat: target.chat, text: chunks[index] },
188
+ // The target suffix distinguishes simultaneous targets; the
189
+ // chunk index distinguishes rows of a split answer.
190
+ tag: `job:${conversationId}:${target.channel}:${target.chat.chatId}:${index}`,
191
+ });
192
+ }
193
+ }
194
+ }
195
+ /** The unanswered notice: the origin chat only. */
196
+ async function notifyOrigin(event, deps) {
197
+ const origin = originKeyOf(deps, event);
198
+ if (origin === undefined) {
199
+ // A run the engine never admitted (webui-started, a job): no chat
200
+ // to notify — the webui shows the failure itself.
201
+ return;
202
+ }
203
+ const binding = deps.bindings.get(origin.bindingKey);
204
+ if (binding === undefined || binding.dead === true)
205
+ return;
206
+ const channel = deps.channelOf(origin.channel);
207
+ if (channel === undefined) {
208
+ deps.log.warn(`settled: no channel "${origin.channel}" for the unanswered notice`);
209
+ return;
210
+ }
211
+ const text = event.outcome.reason === "aborted"
212
+ ? STOPPED
213
+ : `Could not answer: ${event.outcome.reason}`;
214
+ await deps.ledger.enqueue({
215
+ channel: origin.channel,
216
+ bindingKey: origin.bindingKey,
217
+ chat: binding.chat,
218
+ kind: "notice",
219
+ message: { chat: binding.chat, text },
220
+ // Tagged: a replayed settle re-dedupes (the same rule the finals
221
+ // carry).
222
+ tag: `notice:${String(event.conversationId)}:${origin.bindingKey}`,
223
+ });
224
+ }
225
+ /**
226
+ * The origin record of the event's run: the LAST input's record (the
227
+ * run's final submission — an album's batch shares one record per
228
+ * submit). Undefined when the engine never admitted the run.
229
+ */
230
+ function originKeyOf(deps, event) {
231
+ for (const id of [...event.inputs].reverse()) {
232
+ const origin = deps.origins.get(id);
233
+ if (origin !== undefined)
234
+ return origin;
235
+ }
236
+ return undefined;
237
+ }
238
+ /** The binding's user: the DM's resolved user (allowlist then link —
239
+ * the SAME resolution the admission ran), the group owner for a group (M7).
240
+ * The config is the CALLER's (the fan-out already verified it present). */
241
+ async function bindingUser(deps, config, key, chat) {
242
+ const channel = key.split(":")[0] ?? "";
243
+ if (chat.chatType === "dm") {
244
+ // A DM binding's chat id IS the platform sender id: the admission's
245
+ // own resolution (allowlist first, then the stored link) — the
246
+ // allowlist mapping outlives the link.
247
+ return (await deps.identities.userFor(config, channel, chat.chatId))?.user;
248
+ }
249
+ // A group binding's user is the configured OWNER (spec §8.2: the
250
+ // group's owner is who the run acts for).
251
+ const group = config.groups.find((entry) => entry.chat === chat.chatId && !("failed" in entry));
252
+ return group === undefined || "failed" in group ? undefined : group.owner;
253
+ }
254
+ /** The channel adapter for a binding key's channel segment. */
255
+ function channelOfFor(deps, key) {
256
+ const channel = key.split(":")[0];
257
+ return deps.channelOf(channel);
258
+ }
259
+ /** Unbind + log (the M7 revoked-access path). */
260
+ async function unbindAndLog(deps, conversationId, key, reason) {
261
+ await deps.bindings.unbind(key);
262
+ deps.log.info(`settled: unbound "${hashId(key)}" from conversation ${String(conversationId)} (${reason})`);
263
+ deps.log.debug(`settled: unbound "${key}" from conversation ${String(conversationId)} (${reason})`);
264
+ }
265
+ /** The answer's visible text (text blocks joined). */
266
+ function answerText(event) {
267
+ return event.outcome.answer.content
268
+ .filter((part) => part.type === "text")
269
+ .map((part) => part.text)
270
+ .join("\n");
271
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Origin-chat streaming (spec §8.6): live frames ride the conversation
3
+ * watch, never the durable ledger. The first send waits for 20 visible
4
+ * characters; later edits are prefix-stable and rate-limited. Final
5
+ * chunks come from runSettled, not from the last preview frame.
6
+ */
7
+ import type { ChannelContribution, ChatRef, SigrunHost } from "@sigrun/sigrun/extension";
8
+ import type { ChannelConfig } from "../config.js";
9
+ import type { EngineLog } from "../log.js";
10
+ import type { Streams } from "./settled.js";
11
+ export interface StreamOrigin {
12
+ readonly channel: string;
13
+ readonly bindingKey: string;
14
+ readonly chat: ChatRef;
15
+ }
16
+ export interface StreamRegistryDeps {
17
+ readonly host: SigrunHost;
18
+ readonly channelOf: (id: string) => ChannelContribution | undefined;
19
+ readonly configOf: (id: string) => Promise<ChannelConfig | undefined>;
20
+ readonly log: EngineLog;
21
+ readonly now?: () => number;
22
+ readonly setTimer?: (fn: () => void, ms: number) => () => void;
23
+ }
24
+ export declare class StreamRegistry implements Streams {
25
+ private readonly deps;
26
+ private readonly now;
27
+ private readonly setTimer;
28
+ private readonly active;
29
+ private readonly openings;
30
+ constructor(deps: StreamRegistryDeps);
31
+ open(conversationId: number, origin: StreamOrigin): Promise<boolean>;
32
+ finalize(conversationId: number, chunks: string[]): Promise<boolean>;
33
+ abandonAll(): void;
34
+ abandon(conversationId: number): void;
35
+ private startTyping;
36
+ private preview;
37
+ private edit;
38
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Origin-chat streaming (spec §8.6): live frames ride the conversation
3
+ * watch, never the durable ledger. The first send waits for 20 visible
4
+ * characters; later edits are prefix-stable and rate-limited. Final
5
+ * chunks come from runSettled, not from the last preview frame.
6
+ */
7
+ import { liveGenerationText } from "@sigrun/shared/live";
8
+ import { stripMarkdown } from "./markdown.js";
9
+ const FIRST_SEND_VISIBLE_CHARS = 20;
10
+ export class StreamRegistry {
11
+ deps;
12
+ now;
13
+ setTimer;
14
+ active = new Map();
15
+ openings = new Map();
16
+ constructor(deps) {
17
+ this.deps = deps;
18
+ this.now = deps.now ?? Date.now;
19
+ this.setTimer = deps.setTimer ?? ((fn, ms) => {
20
+ const timer = setTimeout(fn, ms);
21
+ return () => clearTimeout(timer);
22
+ });
23
+ }
24
+ async open(conversationId, origin) {
25
+ // One preview per conversation: a second admission is final-only;
26
+ // abandoning here would freeze the owning run's streamed partial.
27
+ if (this.active.has(conversationId) || this.openings.has(conversationId))
28
+ return false;
29
+ const opening = {};
30
+ this.openings.set(conversationId, opening);
31
+ try {
32
+ const channel = this.deps.channelOf(origin.channel);
33
+ const config = await this.deps.configOf(origin.channel);
34
+ if (channel === undefined || config === undefined || !config.stream) {
35
+ this.openings.delete(conversationId);
36
+ return false;
37
+ }
38
+ if (channel.capabilities.edit !== undefined && channel.edit === undefined) {
39
+ this.openings.delete(conversationId);
40
+ this.deps.log.error(`stream: channel "${channel.id}" declares edit capabilities without edit()`);
41
+ return false;
42
+ }
43
+ // Abandon/reopen while the config read was awaiting invalidates this
44
+ // open; without the generation check it could orphan a fresh watch.
45
+ if (this.openings.get(conversationId) !== opening)
46
+ return false;
47
+ let listener;
48
+ const releaseWatch = await this.deps.host.conversations.watch(conversationId, (view) => listener?.(view));
49
+ if (this.openings.get(conversationId) !== opening) {
50
+ await releaseWatch();
51
+ return false;
52
+ }
53
+ this.openings.delete(conversationId);
54
+ const state = {
55
+ conversationId,
56
+ origin,
57
+ channel,
58
+ releaseWatch,
59
+ closed: false,
60
+ failed: false,
61
+ lastDeliveryAt: Number.NEGATIVE_INFINITY,
62
+ operations: Promise.resolve(),
63
+ };
64
+ listener = (view) => {
65
+ if (state.closed || state.failed)
66
+ return;
67
+ const text = liveGenerationText(view);
68
+ state.operations = state.operations
69
+ .then(async () => this.preview(state, text))
70
+ .catch((error) => {
71
+ state.failed = true;
72
+ this.deps.log.error("stream preview failed", error);
73
+ });
74
+ };
75
+ this.active.set(conversationId, state);
76
+ if (channel.capabilities.edit === undefined)
77
+ this.startTyping(state);
78
+ return true;
79
+ }
80
+ catch (error) {
81
+ // A rejected config/watch attempt owns no active stream; release
82
+ // its slot so a later admission can retry instead of staying final-only.
83
+ if (this.openings.get(conversationId) === opening) {
84
+ this.openings.delete(conversationId);
85
+ }
86
+ throw error;
87
+ }
88
+ }
89
+ async finalize(conversationId, chunks) {
90
+ const state = this.active.get(conversationId);
91
+ if (state === undefined)
92
+ return false;
93
+ this.active.delete(conversationId);
94
+ state.closed = true;
95
+ state.cancelEdit?.();
96
+ state.cancelTyping?.();
97
+ await state.releaseWatch();
98
+ await state.operations;
99
+ if (state.failed || state.message === undefined || chunks.length === 0)
100
+ return false;
101
+ try {
102
+ // At-least-once: a failed tail send re-delivers all chunks fresh,
103
+ // the cost of the ledger fallback (chunk 1 may duplicate).
104
+ await this.edit(state, chunks[0]);
105
+ for (const chunk of chunks.slice(1)) {
106
+ await state.channel.send({ chat: state.origin.chat, text: chunk });
107
+ }
108
+ return true;
109
+ }
110
+ catch (error) {
111
+ this.deps.log.error("stream final delivery failed", error);
112
+ return false;
113
+ }
114
+ }
115
+ abandonAll() {
116
+ for (const conversationId of [
117
+ ...this.active.keys(),
118
+ ...this.openings.keys(),
119
+ ])
120
+ this.abandon(conversationId);
121
+ }
122
+ abandon(conversationId) {
123
+ const state = this.active.get(conversationId);
124
+ if (state === undefined) {
125
+ // Before activation, the opening itself is the only ownership
126
+ // handle; deleting it invalidates that pending preview.
127
+ this.openings.delete(conversationId);
128
+ return;
129
+ }
130
+ this.active.delete(conversationId);
131
+ state.closed = true;
132
+ state.cancelEdit?.();
133
+ state.cancelTyping?.();
134
+ void state.releaseWatch().catch((error) => {
135
+ this.deps.log.error("stream watch release failed", error);
136
+ });
137
+ }
138
+ startTyping(state) {
139
+ const typing = state.channel.typing;
140
+ const interval = state.channel.capabilities.typing?.intervalMs;
141
+ if (typing === undefined || interval === undefined)
142
+ return;
143
+ const pulse = () => {
144
+ if (state.closed)
145
+ return;
146
+ void typing.call(state.channel, state.origin.chat).catch((error) => {
147
+ this.deps.log.error("stream typing failed", error);
148
+ });
149
+ if (!state.closed)
150
+ state.cancelTyping = this.setTimer(pulse, interval);
151
+ };
152
+ state.cancelTyping = this.setTimer(pulse, interval);
153
+ }
154
+ async preview(state, text) {
155
+ // Raw cumulative text preserves prefix-stability; re-formatting is
156
+ // reserved for the authoritative final through renderChunks.
157
+ if (state.closed || state.failed || state.channel.capabilities.edit === undefined)
158
+ return;
159
+ if (stripMarkdown(text).trim().length < FIRST_SEND_VISIBLE_CHARS)
160
+ return;
161
+ if (state.lastSent !== undefined && !text.startsWith(state.lastSent))
162
+ return;
163
+ if (state.message === undefined) {
164
+ const receipt = await state.channel.send({ chat: state.origin.chat, text });
165
+ const messageId = receipt.messageIds[0];
166
+ if (messageId === undefined)
167
+ throw new Error("stream send returned no message id");
168
+ state.message = { chat: state.origin.chat, messageId };
169
+ state.lastSent = text;
170
+ state.lastDeliveryAt = this.now();
171
+ return;
172
+ }
173
+ if (text === state.lastSent)
174
+ return;
175
+ const wait = Math.max(0, state.channel.capabilities.edit.minIntervalMs - (this.now() - state.lastDeliveryAt));
176
+ if (wait > 0) {
177
+ state.pendingText = text;
178
+ if (state.cancelEdit === undefined) {
179
+ state.cancelEdit = this.setTimer(() => {
180
+ state.cancelEdit = undefined;
181
+ const pending = state.pendingText;
182
+ state.pendingText = undefined;
183
+ if (pending !== undefined && !state.closed && !state.failed) {
184
+ state.operations = state.operations
185
+ .then(async () => this.preview(state, pending))
186
+ .catch((error) => {
187
+ state.failed = true;
188
+ this.deps.log.error("stream preview failed", error);
189
+ });
190
+ }
191
+ }, wait);
192
+ }
193
+ return;
194
+ }
195
+ await this.edit(state, text);
196
+ state.lastSent = text;
197
+ state.lastDeliveryAt = this.now();
198
+ }
199
+ async edit(state, text) {
200
+ if (state.message === undefined || state.channel.edit === undefined) {
201
+ throw new Error("stream edit requires a streamed message and adapter edit()");
202
+ }
203
+ await state.channel.edit(state.message, { chat: state.origin.chat, text });
204
+ }
205
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The bound-chat prompt section (spec §8.10). It describes only the
3
+ * first live binding, keeping its text static per binding for prompt-cache
4
+ * stability; additional bindings collapse to the pinned count suffix.
5
+ */
6
+ import { type PromptSection } from "@earendil-works/pi-durable";
7
+ import type { ChannelContribution, SigrunHost } from "@sigrun/sigrun/extension";
8
+ import type { BindingStore } from "./state/bindings.js";
9
+ import type { GatewayConfig } from "./config.js";
10
+ export interface ChannelSectionDeps {
11
+ readonly host: SigrunHost;
12
+ readonly bindings: BindingStore;
13
+ channelOf(channel: string): ChannelContribution | undefined;
14
+ readConfig(): Promise<GatewayConfig>;
15
+ }
16
+ export declare function createChannelSection(deps: ChannelSectionDeps): PromptSection;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The bound-chat prompt section (spec §8.10). It describes only the
3
+ * first live binding, keeping its text static per binding for prompt-cache
4
+ * stability; additional bindings collapse to the pinned count suffix.
5
+ */
6
+ import { section } from "@earendil-works/pi-durable";
7
+ export function createChannelSection(deps) {
8
+ return section("gateway-channel", async (input) => {
9
+ const keys = deps.bindings.keysOf(input.conversationId).filter((key) => deps.bindings.get(key)?.dead !== true);
10
+ const first = keys[0];
11
+ if (first === undefined)
12
+ return undefined;
13
+ const binding = deps.bindings.get(first);
14
+ if (binding === undefined)
15
+ return undefined;
16
+ const channelId = first.split(":", 1)[0];
17
+ if (channelId === undefined)
18
+ return undefined;
19
+ const channel = deps.channelOf(channelId);
20
+ if (channel === undefined)
21
+ return undefined;
22
+ const config = await deps.readConfig();
23
+ const block = config.channels[channelId];
24
+ const group = block === undefined || "failed" in block
25
+ ? undefined
26
+ : findGroup(block.groups, binding.chat);
27
+ const title = binding.chat.title;
28
+ const chatLabel = binding.chat.chatType === "dm"
29
+ ? "direct message"
30
+ : `group "${title ?? binding.chat.chatId}"`;
31
+ const hint = channel.capabilities.promptHint;
32
+ const lines = [
33
+ `This conversation is connected to ${channel.label} (${chatLabel}).${hint === undefined ? "" : ` ${hint}`}`,
34
+ ];
35
+ if (binding.chat.chatType !== "dm") {
36
+ const owner = group === undefined
37
+ ? (await deps.host.conversations.meta(input.conversationId)).owner
38
+ : group.owner;
39
+ if (owner !== undefined) {
40
+ const ownerName = (await deps.host.users.get(owner))?.name ?? owner;
41
+ lines.push(`Messages are prefixed with the sender's name. You act for ${ownerName}; senders other than ${ownerName} are not the account owner.`);
42
+ }
43
+ if (group?.members === "anyone") {
44
+ lines.push("Anyone in the group can message you.");
45
+ }
46
+ }
47
+ let rendered = lines.join("\n");
48
+ if (keys.length > 1)
49
+ rendered += ` (also connected to ${keys.length - 1} more)`;
50
+ return rendered;
51
+ });
52
+ }
53
+ function findGroup(groups, chat) {
54
+ return groups.find((group) => !("failed" in group) && group.chat === chat.chatId);
55
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The core session-meta document token, re-declared structurally from
3
+ * the shared wire type. Standalone doc tokens compare by kind/scope;
4
+ * declaring it here avoids a runtime import of core's server modules.
5
+ * Core-contract follow-up: version drift here silently reads job meta as absent.
6
+ */
7
+ import type { SessionMeta } from "@sigrun/shared/views";
8
+ export declare const SessionMetaDoc: import("@earendil-works/pi-durable").ConversationDocToken<SessionMeta>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The core session-meta document token, re-declared structurally from
3
+ * the shared wire type. Standalone doc tokens compare by kind/scope;
4
+ * declaring it here avoids a runtime import of core's server modules.
5
+ * Core-contract follow-up: version drift here silently reads job meta as absent.
6
+ */
7
+ import { defineDoc } from "@earendil-works/pi-durable";
8
+ export const SessionMetaDoc = defineDoc({
9
+ kind: "sigrun.session-meta",
10
+ version: 1,
11
+ scope: "conversation",
12
+ history: "latest",
13
+ fork: "current",
14
+ initial: () => ({
15
+ pinned: false,
16
+ tagged: [],
17
+ archived: false,
18
+ draft: { text: "", attachments: [] },
19
+ source: "user",
20
+ }),
21
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The binding store (spec §8.3): one row per chat, key → the
3
+ * conversation the chat is bound to. Persisted at
4
+ * `<stateDir>/gateway/bindings.json` through {@link JsonFile} (0600,
5
+ * atomic, serialized updates).
6
+ *
7
+ * `account` is always `default` in v1 (the multi-account slot the key
8
+ * reserves so keys never need migrating); a thread rides its own key
9
+ * segment. `previous` records every conversation the chat was bound to
10
+ * before the current one (`/new` and rebinds append) — the outbound
11
+ * side never reads it today, but `/status` (Task 7) shows it and the
12
+ * array is the honest history a rebind loses otherwise.
13
+ */
14
+ import type { ChatRef } from "@sigrun/sigrun/extension";
15
+ /** One live binding: the chat → conversation edge. */
16
+ export interface Binding {
17
+ readonly conversationId: number;
18
+ readonly chat: ChatRef;
19
+ /** Clock ms when the CURRENT conversation was bound. */
20
+ readonly boundAt: number;
21
+ /** Earlier conversation ids this chat was bound to, oldest first. */
22
+ readonly previous: readonly number[];
23
+ /** Set when delivery classified the chat gone (`chat_gone`). */
24
+ readonly dead?: boolean;
25
+ }
26
+ /** The binding key (§8.3): `<channel>:default:<chatType>:<chatId>[:threadId]`. */
27
+ export declare function bindingKey(channel: string, chat: ChatRef): string;
28
+ /**
29
+ * The chat → conversation bindings, one instance per engine. `bind` is
30
+ * the only mutator that grows `previous`; every method persists through
31
+ * the serialized JsonFile queue, so concurrent receives on the same
32
+ * chat never lose a rebind.
33
+ */
34
+ export declare class BindingStore {
35
+ private readonly file;
36
+ /** The write-side clock (tests inject a fake; default real). */
37
+ private readonly now;
38
+ constructor(path: string, opts?: {
39
+ readonly now?: () => number;
40
+ });
41
+ /** The live binding for `key`, or undefined when unbound. */
42
+ get(key: string): Binding | undefined;
43
+ /** One file read's binding rows, keyed for target authorization. */
44
+ entries(): Readonly<Record<string, Binding>>;
45
+ /** Every live key bound to `conversationId` (the delivery fan-out read). */
46
+ keysOf(conversationId: number): readonly string[];
47
+ /** Bind `key` to `conversationId`, recording any prior binding in `previous`. */
48
+ bind(key: string, conversationId: number, chat: ChatRef): Promise<void>;
49
+ /** Remove the binding entirely (the access-revoked path). */
50
+ unbind(key: string): Promise<void>;
51
+ /** Mark the binding dead (`chat_gone` delivery): kept, never delivered to. */
52
+ markDead(key: string): Promise<void>;
53
+ /** Clear the dead flag (a new inbound event proves the chat alive). */
54
+ clearDead(key: string): Promise<void>;
55
+ }