@agentshouse/kit 0.1.0-alpha.27 → 0.1.0-alpha.29

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.
@@ -0,0 +1,214 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ TELEGRAM_RICH_MESSAGE_BLOCKS_MAX,
4
+ TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX,
5
+ } from "./contract.js";
6
+
7
+ const PARTS = Object.freeze({ draft: "blocks", plan: "steps" });
8
+ const UNCOUNTED = new Set(["type", "language"]);
9
+
10
+ function richCharacters(value, key = null) {
11
+ if (typeof value === "string") return UNCOUNTED.has(key) ? 0 : value.length;
12
+ if (Array.isArray(value)) return value.reduce((sum, item) => sum + richCharacters(item), 0);
13
+ if (typeof value === "object" && value !== null) {
14
+ return Object.entries(value).reduce((sum, [name, item]) => sum + richCharacters(item, name), 0);
15
+ }
16
+ return 0;
17
+ }
18
+
19
+ function richText(value, key = null) {
20
+ if (typeof value === "string") return UNCOUNTED.has(key) ? [] : [value];
21
+ if (Array.isArray(value)) return value.flatMap((item) => richText(item));
22
+ if (typeof value === "object" && value !== null) {
23
+ return Object.entries(value).flatMap(([name, item]) => richText(item, name));
24
+ }
25
+ return [];
26
+ }
27
+
28
+ function slices(text) {
29
+ const pieces = [];
30
+ let start = 0;
31
+ while (start < text.length) {
32
+ let end = Math.min(start + TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX, text.length);
33
+ const last = text.charCodeAt(end - 1);
34
+ if (end < text.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
35
+ pieces.push(text.slice(start, end));
36
+ start = end;
37
+ }
38
+ return pieces;
39
+ }
40
+
41
+ function grouped(block, listed) {
42
+ const groups = [];
43
+ let group = [];
44
+ let characters = 0;
45
+ for (const entry of block[listed]) {
46
+ const length = richCharacters(entry);
47
+ if (group.length > 0 && characters + length > TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) {
48
+ groups.push(group);
49
+ group = [];
50
+ characters = 0;
51
+ }
52
+ group.push(entry);
53
+ characters += length;
54
+ }
55
+ if (group.length > 0) groups.push(group);
56
+ return groups.flatMap((entries) => fitted({ ...block, [listed]: entries }));
57
+ }
58
+
59
+ function fitted(block) {
60
+ if (richCharacters(block) <= TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) return [block];
61
+ if (typeof block.text === "string") return slices(block.text).map((text) => ({ ...block, text }));
62
+ if (typeof block.expression === "string") {
63
+ return slices(block.expression).map((expression) => ({ ...block, expression }));
64
+ }
65
+ for (const listed of ["items", "cells"]) {
66
+ if (Array.isArray(block[listed]) && block[listed].length > 1) return grouped(block, listed);
67
+ }
68
+ return slices(richText(block).join("\n")).map((text) => ({ type: "paragraph", text }));
69
+ }
70
+
71
+ export function richPieces(message) {
72
+ const pieces = [];
73
+ let blocks = [];
74
+ let characters = 0;
75
+ for (const part of message.blocks.flatMap(fitted)) {
76
+ const length = richCharacters(part);
77
+ if (
78
+ blocks.length > 0 &&
79
+ (blocks.length >= TELEGRAM_RICH_MESSAGE_BLOCKS_MAX ||
80
+ characters + length > TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX)
81
+ ) {
82
+ pieces.push({ ...message, blocks });
83
+ blocks = [];
84
+ characters = 0;
85
+ }
86
+ blocks.push(part);
87
+ characters += length;
88
+ }
89
+ pieces.push({ ...message, blocks });
90
+ return pieces;
91
+ }
92
+
93
+ function listItems(items, ordered) {
94
+ return items.map((item, index) => ({
95
+ blocks: [{ type: "paragraph", text: item }],
96
+ ...(ordered ? { value: index + 1, type: "1" } : {}),
97
+ }));
98
+ }
99
+
100
+ function telegramBlock(block) {
101
+ if (typeof block !== "object" || block === null) return null;
102
+ if (block.type === "paragraph") return { type: "paragraph", text: block.text };
103
+ if (block.type === "heading") return { type: "heading", size: block.level, text: block.text };
104
+ if (block.type === "code") {
105
+ return {
106
+ type: "pre",
107
+ ...(typeof block.language === "string" ? { language: block.language } : {}),
108
+ text: block.text,
109
+ };
110
+ }
111
+ if (block.type === "list" && Array.isArray(block.items)) {
112
+ return { type: "list", items: listItems(block.items, block.ordered === true) };
113
+ }
114
+ if (block.type === "table" && Array.isArray(block.headers) && Array.isArray(block.rows)) {
115
+ return {
116
+ type: "table",
117
+ cells: [
118
+ block.headers.map((text) => ({ text, is_header: true })),
119
+ ...block.rows.map((row) => (Array.isArray(row) ? row : []).map((text) => ({ text }))),
120
+ ],
121
+ is_bordered: true,
122
+ };
123
+ }
124
+ if (block.type === "formula") return { type: "mathematical_expression", expression: block.expression };
125
+ return null;
126
+ }
127
+
128
+ function planBlocks(steps) {
129
+ return [
130
+ { type: "heading", text: "Plan", size: 3 },
131
+ {
132
+ type: "list",
133
+ items: steps.map((step, index) => ({
134
+ blocks: [{ type: "paragraph", text: [`${step.label} — `, { type: "bold", text: step.status }] }],
135
+ value: index + 1,
136
+ type: "1",
137
+ })),
138
+ },
139
+ ];
140
+ }
141
+
142
+ function draftId(turn) {
143
+ return (createHash("sha256").update(turn).digest().readUInt32BE(0) % 2_147_483_647) + 1;
144
+ }
145
+
146
+ function viewFrame(frame) {
147
+ if (typeof frame !== "object" || frame === null || !Object.hasOwn(PARTS, frame.type)) return null;
148
+ for (const named of ["conversation_id", "turn_id"]) {
149
+ if (typeof frame[named] !== "string" || frame[named] === "") return null;
150
+ }
151
+ if (!Number.isSafeInteger(frame.sequence) || frame.sequence < 1) return null;
152
+ if (!Number.isSafeInteger(frame.from) || frame.from < 0) return null;
153
+ return Array.isArray(frame[PARTS[frame.type]]) ? frame : null;
154
+ }
155
+
156
+ export function turnViews(options) {
157
+ let threads = new Map();
158
+ const views = new Map();
159
+
160
+ function applied(view, frame) {
161
+ const items = frame[PARTS[frame.type]];
162
+ const held = view[frame.type];
163
+ if (held !== null && frame.sequence <= held.sequence) return false;
164
+ if (frame.from === 0) {
165
+ view[frame.type] = { sequence: frame.sequence, items };
166
+ view.asked = false;
167
+ return true;
168
+ }
169
+ if (held === null || frame.sequence !== held.sequence + 1 || frame.from > held.items.length) {
170
+ if (!view.asked) {
171
+ view.asked = true;
172
+ options.gap({ type: "gap", conversation_id: frame.conversation_id, turn_id: frame.turn_id });
173
+ }
174
+ return false;
175
+ }
176
+ view[frame.type] = { sequence: frame.sequence, items: [...held.items.slice(0, frame.from), ...items] };
177
+ return true;
178
+ }
179
+
180
+ return {
181
+ address(listed) {
182
+ threads = new Map(listed.map((thread) => [thread.conversation_id, thread]));
183
+ for (const conversation of views.keys()) {
184
+ if (!threads.has(conversation)) views.delete(conversation);
185
+ }
186
+ },
187
+ receive(value) {
188
+ const frame = viewFrame(value);
189
+ if (frame === null || !threads.has(frame.conversation_id)) return null;
190
+ let view = views.get(frame.conversation_id);
191
+ if (view === undefined || view.turn !== frame.turn_id) {
192
+ view = { turn: frame.turn_id, draft: null, plan: null, asked: false };
193
+ views.set(frame.conversation_id, view);
194
+ }
195
+ return applied(view, frame) ? frame.conversation_id : null;
196
+ },
197
+ draft(conversation) {
198
+ const view = views.get(conversation);
199
+ const thread = threads.get(conversation);
200
+ if (view === undefined || thread === undefined) return null;
201
+ const blocks = [
202
+ ...(view.plan === null ? [] : planBlocks(view.plan.items)),
203
+ ...(view.draft === null ? [] : view.draft.items.map(telegramBlock).filter((block) => block !== null)),
204
+ ];
205
+ if (blocks.length === 0) return null;
206
+ return {
207
+ chat_id: thread.chat_id,
208
+ topic_id: thread.topic_id,
209
+ draft_id: draftId(view.turn),
210
+ rich_message: richPieces({ blocks })[0],
211
+ };
212
+ },
213
+ };
214
+ }
package/src/telegram.js CHANGED
@@ -24,8 +24,11 @@ import {
24
24
  telegramFileUrl,
25
25
  } from "./contract.js";
26
26
  import { heldControl, houseRequest, maintainControl, openSession } from "./house.js";
27
+ import { watchKitWork } from "./kit-work-stream.js";
27
28
  import { KitRefusal } from "./refusal.js";
28
29
  import { writeText } from "./state.js";
30
+ import { richPieces, turnViews } from "./telegram-view.js";
31
+ import { kitStreamWriter } from "./turn-frames.js";
29
32
 
30
33
  const TOKEN_PROMPT = "Paste the BotFather token for this Environment: ";
31
34
  const AMBIGUOUS_DELIVERY = Object.freeze({ telegram_message_id: null, telegram_topic_id: null });
@@ -282,6 +285,15 @@ export function deliveryCall(delivery) {
282
285
  return { method: "sendMessage", body: sendBody(delivery) };
283
286
  }
284
287
 
288
+ function deliveryCalls(delivery) {
289
+ const call = deliveryCall(delivery);
290
+ if (call.method !== "sendRichMessage") return [call];
291
+ return richPieces(call.body.rich_message).map((rich_message) => ({
292
+ method: call.method,
293
+ body: { ...call.body, rich_message },
294
+ }));
295
+ }
296
+
285
297
  function acceptedDelivery(delivery, result) {
286
298
  if (delivery.kind === "conversation_topic") {
287
299
  const threadId = plain(result) ? result.message_thread_id : undefined;
@@ -353,12 +365,15 @@ function queuedDelivery(value) {
353
365
  return value;
354
366
  }
355
367
 
356
- function queuedDraft(value) {
357
- if (!plain(value) || !plain(value.rich_message)) refuseAnswer();
358
- for (const named of ["conversation_id", "turn_id", "chat_id", "topic_id", "draft_id"]) {
359
- if (typeof value[named] !== "string" || value[named].length === 0) refuseAnswer();
360
- }
361
- return value;
368
+ function threadsOf(answer) {
369
+ if (!Array.isArray(answer?.threads)) refuseAnswer();
370
+ return answer.threads.map((thread) => {
371
+ if (!plain(thread)) refuseAnswer();
372
+ for (const named of ["conversation_id", "chat_id", "topic_id"]) {
373
+ if (typeof thread[named] !== "string" || thread[named].length === 0) refuseAnswer();
374
+ }
375
+ return { conversation_id: thread.conversation_id, chat_id: thread.chat_id, topic_id: thread.topic_id };
376
+ });
362
377
  }
363
378
 
364
379
  function queuedUpload(value) {
@@ -374,7 +389,6 @@ function queueOf(answer) {
374
389
  return answer.queue.map((item) => {
375
390
  if (!plain(item)) refuseAnswer();
376
391
  if (item.type === "message") return { type: "message", delivery: queuedDelivery(item.delivery) };
377
- if (item.type === "draft") return { type: "draft", draft: queuedDraft(item.draft) };
378
392
  if (item.type !== "file") refuseAnswer();
379
393
  return { type: "file", upload: queuedUpload(item.upload) };
380
394
  });
@@ -751,23 +765,30 @@ async function reportUpload(gateway, upload, settled) {
751
765
 
752
766
  async function sendQueuedMessage(gateway, token, delivery) {
753
767
  await houseCall(gateway, `deliveries/${delivery.id}/attempt`);
754
- const call = deliveryCall(delivery);
755
- let answered;
756
- try {
757
- answered = await telegramPost(gateway, telegramApiUrl(token, call.method), call.body);
758
- } catch (error) {
759
- if (cancelled(error)) throw error;
760
- await reportDelivery(gateway, delivery, {
761
- outcome: "delivery_unknown",
762
- report: AMBIGUOUS_DELIVERY,
763
- });
764
- return false;
768
+ let first = null;
769
+ for (const call of deliveryCalls(delivery)) {
770
+ let answered;
771
+ try {
772
+ answered = await telegramPost(gateway, telegramApiUrl(token, call.method), call.body);
773
+ } catch (error) {
774
+ if (cancelled(error)) throw error;
775
+ await reportDelivery(gateway, delivery, {
776
+ outcome: "delivery_unknown",
777
+ report: AMBIGUOUS_DELIVERY,
778
+ });
779
+ return false;
780
+ }
781
+ const settled = deliveryOutcome(delivery, answered);
782
+ const retryAfter = telegramRetryAfter(answered);
783
+ if (retryAfter !== null) gateway.gate.hold(retryAfter);
784
+ if (settled.outcome !== "delivered") {
785
+ await reportDelivery(gateway, delivery, settled);
786
+ return retryAfter !== null;
787
+ }
788
+ first ??= settled;
765
789
  }
766
- await reportDelivery(gateway, delivery, deliveryOutcome(delivery, answered));
767
- const retryAfter = telegramRetryAfter(answered);
768
- if (retryAfter === null) return false;
769
- gateway.gate.hold(retryAfter);
770
- return true;
790
+ await reportDelivery(gateway, delivery, first);
791
+ return false;
771
792
  }
772
793
 
773
794
  async function sendQueuedUpload(gateway, token, upload) {
@@ -812,51 +833,85 @@ async function sendQueuedUpload(gateway, token, upload) {
812
833
  return true;
813
834
  }
814
835
 
815
- export async function streamDrafts(drafts, gate, post) {
816
- for (const draft of drafts) {
817
- if (gate.open()) return;
818
- const call = draftCall(draft);
819
- let answered;
820
- try {
821
- answered = await post(call);
822
- } catch (error) {
823
- if (cancelled(error)) throw error;
824
- return;
825
- }
826
- const retryAfter = telegramRetryAfter(answered);
827
- if (retryAfter !== null) {
828
- gate.hold(retryAfter);
829
- return;
836
+ function liveDrafts(gateway) {
837
+ const stream = kitStreamWriter();
838
+ const views = turnViews({ gap: (frame) => stream.write(frame) });
839
+ const dirty = new Set();
840
+ let pumping = null;
841
+
842
+ async function pump() {
843
+ while (dirty.size > 0) {
844
+ const [conversation] = dirty;
845
+ dirty.delete(conversation);
846
+ if (gateway.gate.open()) {
847
+ dirty.clear();
848
+ return;
849
+ }
850
+ const draft = views.draft(conversation);
851
+ if (draft === null) continue;
852
+ const call = draftCall(draft);
853
+ let answered;
854
+ try {
855
+ const token = await gatewayToken(gateway);
856
+ answered = await telegramPost(gateway, telegramApiUrl(token, call.method), call.body);
857
+ } catch (error) {
858
+ if (!(error instanceof KitRefusal)) throw error;
859
+ if (cancelled(error)) dirty.clear();
860
+ continue;
861
+ }
862
+ const retryAfter = telegramRetryAfter(answered);
863
+ if (retryAfter !== null) {
864
+ gateway.gate.hold(retryAfter);
865
+ dirty.clear();
866
+ }
830
867
  }
831
868
  }
869
+
870
+ function start() {
871
+ if (pumping !== null || dirty.size === 0) return;
872
+ pumping = pump().finally(() => {
873
+ pumping = null;
874
+ start();
875
+ });
876
+ pumping.catch(() => undefined);
877
+ }
878
+
879
+ return {
880
+ heard: {
881
+ available: () => undefined,
882
+ changed: () => undefined,
883
+ opened: stream.opened,
884
+ closed: stream.closed,
885
+ viewed: (frame) => {
886
+ const conversation = views.receive(frame);
887
+ if (conversation === null) return;
888
+ dirty.add(conversation);
889
+ start();
890
+ },
891
+ },
892
+ address: (threads) => views.address(threads),
893
+ settled: () => pumping ?? Promise.resolve(),
894
+ };
832
895
  }
833
896
 
834
- async function streamQueuedDrafts(gateway, token, drafts) {
835
- if (gateway.gate.open()) return;
836
- await streamDrafts(drafts, gateway.gate, (call) =>
837
- telegramPost(gateway, telegramApiUrl(token, call.method), call.body),
838
- );
897
+ function watchGatewayStream(session, signal, heard) {
898
+ if (session.relay !== undefined) return session.relay(signal, heard);
899
+ return (session.dependencies.watchKitWork ?? watchKitWork)(session, signal, heard, session.dependencies);
839
900
  }
840
901
 
841
902
  async function drainOutboundQueue(gateway) {
842
903
  if (gateway.gate.open()) return;
843
904
  const token = await gatewayToken(gateway);
844
- const queue = queueOf(
845
- await houseCall(gateway, "queue/acquire", { max: TELEGRAM_DELIVERY_BATCH_MAX }),
846
- );
847
- const drafts = [];
905
+ const answer = await houseCall(gateway, "queue/acquire", { max: TELEGRAM_DELIVERY_BATCH_MAX });
906
+ const queue = queueOf(answer);
907
+ gateway.live?.address(threadsOf(answer));
848
908
  for (const item of queue) {
849
- if (item.type === "draft") {
850
- drafts.push(item.draft);
851
- continue;
852
- }
853
909
  const gated =
854
910
  item.type === "file"
855
911
  ? await sendQueuedUpload(gateway, token, item.upload)
856
912
  : await sendQueuedMessage(gateway, token, item.delivery);
857
913
  if (gated) return;
858
914
  }
859
- await streamQueuedDrafts(gateway, token, drafts);
860
915
  }
861
916
 
862
917
  async function onePass(gateway, activity, work) {
@@ -1052,7 +1107,23 @@ export async function serveTelegramGateway(options, dependencies = {}) {
1052
1107
  const shared = AbortSignal.any([signal, stopping.signal]);
1053
1108
  const gateway = gatewayContext(session, options, shared, summary);
1054
1109
  summary.abandoned = abandonedOf(await houseCall(gateway, "deliveries/abandoned"));
1055
- await serveActivities(gateway, stopping);
1110
+ gateway.live = liveDrafts(gateway);
1111
+ const watcher = new AbortController();
1112
+ const watching = watchGatewayStream(
1113
+ session,
1114
+ AbortSignal.any([shared, watcher.signal]),
1115
+ gateway.live.heard,
1116
+ ).catch((error) => {
1117
+ if (cancelled(error)) return;
1118
+ summary.failures.push({ activity: "drafts", item: null, code: error?.code ?? "kit_stream_failed" });
1119
+ });
1120
+ try {
1121
+ await serveActivities(gateway, stopping);
1122
+ } finally {
1123
+ watcher.abort();
1124
+ await watching;
1125
+ await gateway.live.settled();
1126
+ }
1056
1127
  },
1057
1128
  options.signal,
1058
1129
  );
@@ -0,0 +1,69 @@
1
+ import { assistantBlocks } from "./blocks.js";
2
+
3
+ export function kitStreamWriter() {
4
+ let send = null;
5
+ let generation = 0;
6
+ return {
7
+ get generation() {
8
+ return generation;
9
+ },
10
+ opened(write) {
11
+ generation += 1;
12
+ send = write;
13
+ },
14
+ closed() {
15
+ send = null;
16
+ },
17
+ write(frame) {
18
+ if (send === null) return false;
19
+ send(frame);
20
+ return true;
21
+ },
22
+ };
23
+ }
24
+
25
+ function firstChange(written, items) {
26
+ const shared = Math.min(written.length, items.length);
27
+ for (let index = 0; index < shared; index += 1) {
28
+ if (JSON.stringify(written[index]) !== JSON.stringify(items[index])) return index;
29
+ }
30
+ return shared;
31
+ }
32
+
33
+ export function turnFrames(stream) {
34
+ let identity = null;
35
+ const parts = {
36
+ draft: { key: "blocks", latest: null, written: null, generation: null },
37
+ plan: { key: "steps", latest: null, written: null, generation: null },
38
+ };
39
+
40
+ function write(type) {
41
+ const part = parts[type];
42
+ if (identity === null || part.latest === null) return;
43
+ const { sequence, items } = part.latest;
44
+ const whole = part.written === null || part.generation !== stream.generation;
45
+ const from = whole ? 0 : firstChange(part.written, items);
46
+ const generation = stream.generation;
47
+ const sent = stream.write({ type, ...identity, sequence, from, [part.key]: items.slice(from) });
48
+ part.written = sent ? items : null;
49
+ part.generation = generation;
50
+ }
51
+
52
+ return {
53
+ identify(named) {
54
+ identity = named;
55
+ write("draft");
56
+ write("plan");
57
+ },
58
+ draft(view) {
59
+ const blocks = assistantBlocks(view.text);
60
+ if (blocks === null) return;
61
+ parts.draft.latest = { sequence: view.sequence, items: blocks };
62
+ write("draft");
63
+ },
64
+ plan(snapshot) {
65
+ parts.plan.latest = { sequence: snapshot.sequence, items: snapshot.steps };
66
+ write("plan");
67
+ },
68
+ };
69
+ }