@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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +241 -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 +112 -0
- package/dist/interactions/chat.d.ts +28 -0
- package/dist/interactions/chat.js +199 -0
- package/dist/interactions/prompts.d.ts +68 -0
- package/dist/interactions/prompts.js +246 -0
- package/dist/interactions/receipts.d.ts +7 -0
- package/dist/interactions/receipts.js +69 -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 +64 -0
- package/dist/outbound/delivery.d.ts +131 -0
- package/dist/outbound/delivery.js +404 -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 +81 -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 +18 -0
- package/dist/session-meta.js +39 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +65 -0
- package/dist/state/identities.js +135 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +176 -0
- package/dist/state/ledger.js +256 -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,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown splitting and stripping (spec §8.6): the outbound chunker's
|
|
3
|
+
* step 1 (split at paragraph, then line, then hard boundaries, with
|
|
4
|
+
* fence close-and-reopen) and the plain-format renderer's stripper
|
|
5
|
+
* (headings, emphasis, links → `text (url)`, fences → plain blocks).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions over strings — no imports, no clock, no I/O.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Split `text` into chunks of at most `limit` characters, preferring
|
|
11
|
+
* paragraph boundaries, then line boundaries, then hard cuts.
|
|
12
|
+
*
|
|
13
|
+
* Fence rule: a code fence cut by a split is CLOSED in the earlier
|
|
14
|
+
* chunk and REOPENED with its original info string at the head of the
|
|
15
|
+
* next. An unterminated fence in the input is treated as running to
|
|
16
|
+
* the end (the last chunk's close terminates it — every emitted chunk
|
|
17
|
+
* is well-formed on its own).
|
|
18
|
+
*
|
|
19
|
+
* Every EMITTED chunk respects the limit: the close suffix a
|
|
20
|
+
* fence-interior chunk carries ("\n```") is reserved in each fit
|
|
21
|
+
* check, and hard-split pieces shrink by the ACTUAL reopen width
|
|
22
|
+
* (info string included — "typescript" reopens 13 chars wide, so a
|
|
23
|
+
* fixed reserve under-sizes pieces).
|
|
24
|
+
*
|
|
25
|
+
* The join of the chunks is a lossless representation of the input
|
|
26
|
+
* apart from the inserted fence markers and the whitespace the
|
|
27
|
+
* paragraph/line splits consumed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function splitMarkdown(text: string, limit: number): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Strip markdown to plain text (the `plain` capability's formatter):
|
|
32
|
+
* headings lose their `#` markers, emphasis markers are removed, links
|
|
33
|
+
* render as `text (url)`, and fenced code blocks become their inner
|
|
34
|
+
* lines. Structure (paragraphs, line breaks) is preserved.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripMarkdown(text: string): string;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown splitting and stripping (spec §8.6): the outbound chunker's
|
|
3
|
+
* step 1 (split at paragraph, then line, then hard boundaries, with
|
|
4
|
+
* fence close-and-reopen) and the plain-format renderer's stripper
|
|
5
|
+
* (headings, emphasis, links → `text (url)`, fences → plain blocks).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions over strings — no imports, no clock, no I/O.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Split `text` into chunks of at most `limit` characters, preferring
|
|
11
|
+
* paragraph boundaries, then line boundaries, then hard cuts.
|
|
12
|
+
*
|
|
13
|
+
* Fence rule: a code fence cut by a split is CLOSED in the earlier
|
|
14
|
+
* chunk and REOPENED with its original info string at the head of the
|
|
15
|
+
* next. An unterminated fence in the input is treated as running to
|
|
16
|
+
* the end (the last chunk's close terminates it — every emitted chunk
|
|
17
|
+
* is well-formed on its own).
|
|
18
|
+
*
|
|
19
|
+
* Every EMITTED chunk respects the limit: the close suffix a
|
|
20
|
+
* fence-interior chunk carries ("\n```") is reserved in each fit
|
|
21
|
+
* check, and hard-split pieces shrink by the ACTUAL reopen width
|
|
22
|
+
* (info string included — "typescript" reopens 13 chars wide, so a
|
|
23
|
+
* fixed reserve under-sizes pieces).
|
|
24
|
+
*
|
|
25
|
+
* The join of the chunks is a lossless representation of the input
|
|
26
|
+
* apart from the inserted fence markers and the whitespace the
|
|
27
|
+
* paragraph/line splits consumed.
|
|
28
|
+
*/
|
|
29
|
+
export function splitMarkdown(text, limit) {
|
|
30
|
+
if (limit <= 0) {
|
|
31
|
+
// A zero/negative limit has no legal chunk; the caller's config
|
|
32
|
+
// validation owns that error. Hard-splitting into empty strings
|
|
33
|
+
// would loop forever, so the honest fallback is the whole text.
|
|
34
|
+
return text === "" ? [] : [text];
|
|
35
|
+
}
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
const chunks = [];
|
|
38
|
+
/** The chunk under construction, joined with "\n". */
|
|
39
|
+
let current = [];
|
|
40
|
+
let currentLength = 0;
|
|
41
|
+
/** Whether a code fence is currently open (independent of its info). */
|
|
42
|
+
let fenceOpen = false;
|
|
43
|
+
/** The open fence's info string (e.g. "ts"), undefined when bare. */
|
|
44
|
+
let openFence;
|
|
45
|
+
/** Reconstructed open-fence line for reopening (with info string). */
|
|
46
|
+
const fenceReopen = () => "```" + (openFence === undefined ? "" : openFence);
|
|
47
|
+
/** The close suffix a chunk ending inside an open fence carries
|
|
48
|
+
* ("\n```") — reserved in EVERY fit check so an emitted chunk can
|
|
49
|
+
* never exceed the limit (the reservation is pessimistic: a chunk
|
|
50
|
+
* the input's own close line terminates simply comes out smaller). */
|
|
51
|
+
const CLOSE_COST = 4;
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (current.length === 0)
|
|
54
|
+
return;
|
|
55
|
+
let chunk = current.join("\n");
|
|
56
|
+
// A chunk that IS just the fence (re)open marker — bare or with an
|
|
57
|
+
// info string — is INTERIOR: it was armed as the head of a split
|
|
58
|
+
// chunk whose body went to the next chunk. endsWith("```") would
|
|
59
|
+
// mistake it for the input's own close line and emit an UNCLOSED
|
|
60
|
+
// marker (the lone-``` regression); the close suffix belongs here.
|
|
61
|
+
const loneMarker = fenceOpen &&
|
|
62
|
+
/^```[^`]*$/.test(chunk) &&
|
|
63
|
+
!chunk.includes("\n");
|
|
64
|
+
if (fenceOpen && (loneMarker || !chunk.endsWith("```"))) {
|
|
65
|
+
// The chunk ends INSIDE a fence: close it so the chunk stands
|
|
66
|
+
// alone (the next chunk reopens).
|
|
67
|
+
chunk += "\n```";
|
|
68
|
+
}
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
current = [];
|
|
71
|
+
currentLength = 0;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Close the current chunk and reopen the fence when one is open —
|
|
75
|
+
* the shared split point for every path that starts a fresh chunk
|
|
76
|
+
* (line overflow, paragraph boundary, hard-split pieces).
|
|
77
|
+
*/
|
|
78
|
+
const closeAndReopen = () => {
|
|
79
|
+
flush();
|
|
80
|
+
if (fenceOpen) {
|
|
81
|
+
const reopen = fenceReopen();
|
|
82
|
+
current.push(reopen);
|
|
83
|
+
currentLength = reopen.length;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const pushLine = (line, lineLength) => {
|
|
87
|
+
// A chunk that STARTS inside an open fence carries the reopen as
|
|
88
|
+
// its head line — the well-formed invariant (every chunk is a
|
|
89
|
+
// standalone fence) holds no matter which path emptied the chunk.
|
|
90
|
+
if (current.length === 0 && fenceOpen) {
|
|
91
|
+
const reopen = fenceReopen();
|
|
92
|
+
current.push(reopen);
|
|
93
|
+
currentLength = reopen.length;
|
|
94
|
+
}
|
|
95
|
+
const joinCost = current.length === 0 ? 0 : 1;
|
|
96
|
+
const closeCost = fenceOpen ? CLOSE_COST : 0;
|
|
97
|
+
if (currentLength + joinCost + lineLength + closeCost <= limit) {
|
|
98
|
+
current.push(line);
|
|
99
|
+
currentLength += joinCost + lineLength;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// The line does not fit: split the chunk (close + reopen when a
|
|
103
|
+
// fence is open), then place the line — hard-splitting it when
|
|
104
|
+
// even a fresh chunk cannot hold reopen + line + close.
|
|
105
|
+
closeAndReopen();
|
|
106
|
+
const prefixCost = fenceOpen ? currentLength + 1 : 0;
|
|
107
|
+
if (prefixCost + lineLength + closeCost <= limit) {
|
|
108
|
+
current.push(line);
|
|
109
|
+
currentLength += (prefixCost === 0 ? 0 : 1) + lineLength;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// The line alone exceeds even a fresh chunk's budget: hard-split
|
|
113
|
+
// it (the reopen prefix is dropped first — hardSplit re-arms its
|
|
114
|
+
// own reopen per piece, so every piece's overhead is accounted).
|
|
115
|
+
current = [];
|
|
116
|
+
currentLength = 0;
|
|
117
|
+
hardSplit(line);
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Hard-split one overlong line into pieces sized for the fence
|
|
121
|
+
* overhead (the ACTUAL info string, not a fixed reserve). Each piece
|
|
122
|
+
* chunk carries reopen + join + piece + close when a fence is open,
|
|
123
|
+
* so the piece limit is the limit minus that overhead — and every
|
|
124
|
+
* piece chunk STARTS with the reopen (the prefix is armed per piece,
|
|
125
|
+
* never inherited from a dropped head).
|
|
126
|
+
*/
|
|
127
|
+
const hardSplit = (line) => {
|
|
128
|
+
const reopen = fenceOpen ? fenceReopen() : undefined;
|
|
129
|
+
const overhead = reopen === undefined ? 0 : reopen.length + 1 + CLOSE_COST;
|
|
130
|
+
const pieceLimit = Math.max(1, limit - overhead);
|
|
131
|
+
const emit = (piece) => {
|
|
132
|
+
if (reopen === undefined) {
|
|
133
|
+
current.push(piece);
|
|
134
|
+
currentLength = piece.length;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
current.push(reopen, piece);
|
|
138
|
+
currentLength = reopen.length + 1 + piece.length;
|
|
139
|
+
}
|
|
140
|
+
// flush appends the close when a fence is open; the next piece
|
|
141
|
+
// re-arms its own reopen.
|
|
142
|
+
flush();
|
|
143
|
+
};
|
|
144
|
+
let start = 0;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const remaining = line.length - start;
|
|
147
|
+
if (remaining <= pieceLimit) {
|
|
148
|
+
emit(line.slice(start));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
emit(line.slice(start, start + pieceLimit));
|
|
152
|
+
start += pieceLimit;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let paragraphBreak = false;
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (trimmed === "") {
|
|
159
|
+
// A blank line: a paragraph boundary candidate — INSIDE a fence
|
|
160
|
+
// too (the split still closes and reopens, keeping every chunk
|
|
161
|
+
// well-formed; a mid-fence blank line is content, but a split
|
|
162
|
+
// point there is honest when the chunk is full).
|
|
163
|
+
if (current.length > 0)
|
|
164
|
+
paragraphBreak = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (paragraphBreak) {
|
|
168
|
+
// Split at the paragraph boundary: close (+ reopen inside a
|
|
169
|
+
// fence) so the next chunk starts well-formed. Fence marker
|
|
170
|
+
// lines arrive here too — a blank line before a close marker
|
|
171
|
+
// still splits with the pair.
|
|
172
|
+
if (current.length > 0)
|
|
173
|
+
closeAndReopen();
|
|
174
|
+
paragraphBreak = false;
|
|
175
|
+
}
|
|
176
|
+
const fenceMatch = /^```(.*)$/.exec(trimmed);
|
|
177
|
+
if (fenceMatch !== null) {
|
|
178
|
+
if (!fenceOpen) {
|
|
179
|
+
// Opening a fence. The fit decision is made against the
|
|
180
|
+
// PRE-fence state (fenceOpen still false): flipping it first
|
|
181
|
+
// would make the overflow flush append a CLOSE suffix to a
|
|
182
|
+
// plain-text chunk that never opened a fence (the stray-marker
|
|
183
|
+
// regression). Only when the marker is PLACED does the fence
|
|
184
|
+
// open.
|
|
185
|
+
const info = fenceMatch[1] ?? "";
|
|
186
|
+
const bareInfo = info === "" ? undefined : info;
|
|
187
|
+
const marker = "```" + (bareInfo === undefined ? "" : bareInfo);
|
|
188
|
+
const joinCost = current.length === 0 ? 0 : 1;
|
|
189
|
+
const closeCost = CLOSE_COST;
|
|
190
|
+
if (currentLength + joinCost + marker.length + closeCost <= limit) {
|
|
191
|
+
// The marker fits the current chunk: the fence opens in place.
|
|
192
|
+
current.push(marker);
|
|
193
|
+
currentLength += joinCost + marker.length;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// The marker does not fit: flush the plain-text chunk AS
|
|
197
|
+
// PLAIN TEXT (no fence is open — no close suffix), then open
|
|
198
|
+
// the fence at the head of the fresh chunk.
|
|
199
|
+
flush();
|
|
200
|
+
current.push(marker);
|
|
201
|
+
currentLength = marker.length;
|
|
202
|
+
}
|
|
203
|
+
fenceOpen = true;
|
|
204
|
+
openFence = bareInfo;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// Closing a fence: when the current chunk is empty (or holds only
|
|
208
|
+
// the armed reopen), the PREVIOUS chunk's flush suffix already
|
|
209
|
+
// closed the fence — emitting the marker would add an empty-fence
|
|
210
|
+
// chunk, so it is dropped. Otherwise the marker lands normally,
|
|
211
|
+
// WITHOUT arming a fresh reopen when it overflows (the close is
|
|
212
|
+
// terminal: a reopened fence just to hold its own terminator is
|
|
213
|
+
// noise).
|
|
214
|
+
const onlyReopen = current.length === 1 && current[0] === fenceReopen();
|
|
215
|
+
if (current.length === 0 || onlyReopen) {
|
|
216
|
+
current = [];
|
|
217
|
+
currentLength = 0;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const joinCost = current.length === 0 ? 0 : 1;
|
|
221
|
+
if (currentLength + joinCost + 3 <= limit) {
|
|
222
|
+
current.push("```");
|
|
223
|
+
currentLength += joinCost + 3;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// The marker does not fit: the chunk closes WITHOUT arming a
|
|
227
|
+
// reopen (fenceOpen flips to false first, so flush's suffix
|
|
228
|
+
// closes what stands).
|
|
229
|
+
fenceOpen = false;
|
|
230
|
+
flush();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
fenceOpen = false;
|
|
234
|
+
openFence = undefined;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
pushLine(line, line.length);
|
|
238
|
+
}
|
|
239
|
+
flush();
|
|
240
|
+
// An unterminated fence never emits a trailing empty reopen chunk:
|
|
241
|
+
// flush() already closed whatever chunk ended inside the fence.
|
|
242
|
+
return chunks;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Strip markdown to plain text (the `plain` capability's formatter):
|
|
246
|
+
* headings lose their `#` markers, emphasis markers are removed, links
|
|
247
|
+
* render as `text (url)`, and fenced code blocks become their inner
|
|
248
|
+
* lines. Structure (paragraphs, line breaks) is preserved.
|
|
249
|
+
*/
|
|
250
|
+
export function stripMarkdown(text) {
|
|
251
|
+
const lines = text.split("\n");
|
|
252
|
+
const out = [];
|
|
253
|
+
let inFence = false;
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
const trimmed = line.trim();
|
|
256
|
+
if (/^```/.test(trimmed)) {
|
|
257
|
+
inFence = !inFence;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (inFence) {
|
|
261
|
+
out.push(line);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
out.push(stripInline(stripHeading(line)));
|
|
265
|
+
}
|
|
266
|
+
// Collapse the blank lines the fence markers consumed (a fence's
|
|
267
|
+
// open/close lines leave consecutive blanks around the block).
|
|
268
|
+
return out.join("\n");
|
|
269
|
+
}
|
|
270
|
+
/** Drop a heading's leading `#` run (ATX headings only). */
|
|
271
|
+
function stripHeading(line) {
|
|
272
|
+
const match = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
273
|
+
return match === null ? line : match[2] ?? "";
|
|
274
|
+
}
|
|
275
|
+
/** Strip emphasis and rewrite links on ONE line. */
|
|
276
|
+
function stripInline(line) {
|
|
277
|
+
// Links first: their text may carry emphasis.
|
|
278
|
+
const text = line.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_all, label, url) => `${label} (${url})`);
|
|
279
|
+
// Bold + italics markers (`**`, `__`, `*`, `_` pairs).
|
|
280
|
+
return text
|
|
281
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
282
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
283
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
284
|
+
// Inline code markers.
|
|
285
|
+
.replace(/`([^`]+)`/g, "$1");
|
|
286
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderChunks (spec §8.6's three-step order, verbatim):
|
|
3
|
+
*
|
|
4
|
+
* 1. Split the markdown final at `maxMessageLength` (paragraph, line,
|
|
5
|
+
* hard — {@link splitMarkdown}, with the fence close/reopen rule).
|
|
6
|
+
* 2. Format each chunk per `capabilities.format`: `markdown` as-is,
|
|
7
|
+
* `native` through the adapter's `format`, `plain` through the
|
|
8
|
+
* engine's markdown stripper.
|
|
9
|
+
* 3. A formatted chunk that STILL exceeds `maxMessageLength` (native
|
|
10
|
+
* markup grew it) is hard-split.
|
|
11
|
+
*
|
|
12
|
+
* Empty input renders to zero chunks (the caller owns the empty-answer
|
|
13
|
+
* notice — Review Focus 4).
|
|
14
|
+
*/
|
|
15
|
+
import type { ChannelCapabilities } from "@sigrun/sigrun/extension";
|
|
16
|
+
/**
|
|
17
|
+
* Render the markdown final into platform-ready chunks. `format` is the
|
|
18
|
+
* adapter's formatter (`ChannelContribution.format`), required only for
|
|
19
|
+
* `native` capabilities — a missing formatter on a native channel is
|
|
20
|
+
* the loader's contract violation, handled by the caller; here the
|
|
21
|
+
* chunk passes through unformatted (the honest degraded behavior for
|
|
22
|
+
* an adapter that declared native markup and shipped no renderer).
|
|
23
|
+
*/
|
|
24
|
+
export declare function renderChunks(markdown: string, caps: ChannelCapabilities, format?: (md: string) => string): string[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderChunks (spec §8.6's three-step order, verbatim):
|
|
3
|
+
*
|
|
4
|
+
* 1. Split the markdown final at `maxMessageLength` (paragraph, line,
|
|
5
|
+
* hard — {@link splitMarkdown}, with the fence close/reopen rule).
|
|
6
|
+
* 2. Format each chunk per `capabilities.format`: `markdown` as-is,
|
|
7
|
+
* `native` through the adapter's `format`, `plain` through the
|
|
8
|
+
* engine's markdown stripper.
|
|
9
|
+
* 3. A formatted chunk that STILL exceeds `maxMessageLength` (native
|
|
10
|
+
* markup grew it) is hard-split.
|
|
11
|
+
*
|
|
12
|
+
* Empty input renders to zero chunks (the caller owns the empty-answer
|
|
13
|
+
* notice — Review Focus 4).
|
|
14
|
+
*/
|
|
15
|
+
import { splitMarkdown, stripMarkdown } from "./markdown.js";
|
|
16
|
+
/**
|
|
17
|
+
* Render the markdown final into platform-ready chunks. `format` is the
|
|
18
|
+
* adapter's formatter (`ChannelContribution.format`), required only for
|
|
19
|
+
* `native` capabilities — a missing formatter on a native channel is
|
|
20
|
+
* the loader's contract violation, handled by the caller; here the
|
|
21
|
+
* chunk passes through unformatted (the honest degraded behavior for
|
|
22
|
+
* an adapter that declared native markup and shipped no renderer).
|
|
23
|
+
*/
|
|
24
|
+
export function renderChunks(markdown, caps, format) {
|
|
25
|
+
if (markdown === "")
|
|
26
|
+
return [];
|
|
27
|
+
// Step 1: split at the platform's message ceiling.
|
|
28
|
+
const split = splitMarkdown(markdown, caps.maxMessageLength);
|
|
29
|
+
// Step 2 + 3: format, then hard-split whatever formatting grew.
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const chunk of split) {
|
|
32
|
+
const formatted = formatChunk(chunk, caps, format);
|
|
33
|
+
if (formatted.length <= caps.maxMessageLength) {
|
|
34
|
+
out.push(formatted);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Native markup grew the chunk past the ceiling: hard-split the
|
|
38
|
+
// FORMATTED text (no fence knowledge — the adapter's markup is
|
|
39
|
+
// opaque to the engine; closing it would be guesswork).
|
|
40
|
+
out.push(...hardSplit(formatted, caps.maxMessageLength));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** Step 2 for one chunk. */
|
|
45
|
+
function formatChunk(chunk, caps, format) {
|
|
46
|
+
switch (caps.format) {
|
|
47
|
+
case "markdown":
|
|
48
|
+
return chunk;
|
|
49
|
+
case "plain":
|
|
50
|
+
return stripMarkdown(chunk);
|
|
51
|
+
case "native":
|
|
52
|
+
return format === undefined ? chunk : format(chunk);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Hard-split one formatted string at exact `limit` boundaries. A
|
|
56
|
+
* non-positive limit (a misconfigured adapter) degrades to length-1
|
|
57
|
+
* pieces — never an infinite loop of empty slices. */
|
|
58
|
+
function hardSplit(text, limit) {
|
|
59
|
+
const step = Math.max(1, limit);
|
|
60
|
+
const pieces = [];
|
|
61
|
+
for (let start = 0; start < text.length; start += step) {
|
|
62
|
+
pieces.push(text.slice(start, start + step));
|
|
63
|
+
}
|
|
64
|
+
return pieces;
|
|
65
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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 type { ChannelContribution, RunSettledEvent, SigrunHost } from "@sigrun/sigrun/extension";
|
|
25
|
+
import type { SubmissionId } from "@earendil-works/pi-durable";
|
|
26
|
+
import type { SessionMeta } from "@sigrun/shared/session-meta";
|
|
27
|
+
import type { ChannelConfig } from "../config.js";
|
|
28
|
+
import type { BindingStore } from "../state/bindings.js";
|
|
29
|
+
import type { Ledger } from "../state/ledger.js";
|
|
30
|
+
import { type EngineLog } from "../log.js";
|
|
31
|
+
import type { IdentityStore } from "../state/identities.js";
|
|
32
|
+
import type { SubmissionOrigin } from "../inbound/pipeline.js";
|
|
33
|
+
/** The pinned notice for a textless answer (Review Focus 4). */
|
|
34
|
+
export declare const NO_TEXT_REPLY = "(no text reply)";
|
|
35
|
+
/** The pinned abort notice (spec §8.6: "An abort from /stop produces Stopped."). */
|
|
36
|
+
export declare const STOPPED = "Stopped.";
|
|
37
|
+
/**
|
|
38
|
+
* The streaming hand-off seam. `finalize` answers ONE question: did
|
|
39
|
+
* the stream own this conversation's origin-chat delivery? Chunks are
|
|
40
|
+
* rendered by the settled path (which knows the answer and caps) and
|
|
41
|
+
* consumed by the registry; the boolean-only fan-out decision remains
|
|
42
|
+
* independent of that delivery work. `abandon` releases unanswered runs.
|
|
43
|
+
*/
|
|
44
|
+
export interface Streams {
|
|
45
|
+
finalize(conversationId: number, chunks: string[]): Promise<boolean>;
|
|
46
|
+
abandon(conversationId: number): void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The job-delivery metadata the settled fan-out reads. Derived from the
|
|
50
|
+
* canonical shared session-meta wire type (type-only: no runtime core
|
|
51
|
+
* dependency) so field drift fails the contract fixture's typecheck.
|
|
52
|
+
*/
|
|
53
|
+
export type GatewayJobMeta = Pick<SessionMeta, "owner" | "name" | "autoName" | "jobId" | "deliver">;
|
|
54
|
+
/** onRunSettled's injectable dependencies. */
|
|
55
|
+
export interface SettledDeps {
|
|
56
|
+
readonly host: SigrunHost;
|
|
57
|
+
/** The event bus's bridge-free document read (factory-supplied). */
|
|
58
|
+
readonly readMeta?: (conversationId: number) => Promise<GatewayJobMeta | undefined>;
|
|
59
|
+
/** The engine-wide bindings (the delivery fan-out read). */
|
|
60
|
+
readonly bindings: BindingStore;
|
|
61
|
+
/** The engine-wide identity links (the binding's user resolution). */
|
|
62
|
+
readonly identities: IdentityStore;
|
|
63
|
+
/** The ledger the finals enqueue into. */
|
|
64
|
+
readonly ledger: Ledger;
|
|
65
|
+
/** The channel id → adapter lookup (the render step's caps/format). */
|
|
66
|
+
readonly channelOf: (id: string) => ChannelContribution | undefined;
|
|
67
|
+
/** The live channel configs (the group owner read). */
|
|
68
|
+
readonly configOf: (id: string) => Promise<ChannelConfig | undefined>;
|
|
69
|
+
/** submissionId → the origin record (the unanswered notice's target). */
|
|
70
|
+
readonly origins: Map<SubmissionId, SubmissionOrigin>;
|
|
71
|
+
/** The origin-chat stream registry. */
|
|
72
|
+
readonly streams: Streams;
|
|
73
|
+
readonly log: EngineLog;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The settled-run handler: enqueue finals (or the empty-answer notice)
|
|
77
|
+
* for every live bound chat, and the unanswered notice for the origin
|
|
78
|
+
* chat. Never throws (the event bus's bounded handler owns isolation,
|
|
79
|
+
* but a ledger failure must not lose the rest of the fan-out).
|
|
80
|
+
*/
|
|
81
|
+
export declare function onRunSettled(event: RunSettledEvent, deps: SettledDeps): Promise<void>;
|