@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +111 -0
- package/dist/interactions/chat.d.ts +20 -0
- package/dist/interactions/chat.js +155 -0
- package/dist/interactions/prompts.d.ts +42 -0
- package/dist/interactions/prompts.js +96 -0
- package/dist/interactions/receipts.d.ts +6 -0
- package/dist/interactions/receipts.js +36 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +57 -0
- package/dist/outbound/delivery.d.ts +124 -0
- package/dist/outbound/delivery.js +387 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +80 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +8 -0
- package/dist/session-meta.js +21 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +61 -0
- package/dist/state/identities.js +131 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +152 -0
- package/dist/state/ledger.js +207 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- 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;
|
package/dist/section.js
ADDED
|
@@ -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
|
+
}
|