@agentshouse/kit 0.1.0-alpha.33 → 0.1.0-alpha.35

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/README.md CHANGED
@@ -12,7 +12,7 @@ The Skill set Kit installs is Matt Pocock's
12
12
  On Linux `amd64` or `arm64`, run the published, versioned bootstrap once:
13
13
 
14
14
  ```sh
15
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.33/connect-linux.sh)
15
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.35/connect-linux.sh)
16
16
  ```
17
17
 
18
18
  The bootstrap first establishes Docker Engine from what it observes on the
@@ -445,7 +445,8 @@ service to a signed-in account asks only for that service's consent, and each
445
445
  service keeps its own grant. An address that answers another consent, or a code
446
446
  Google refuses to exchange, stores nothing; a declined consent or a ceremony
447
447
  that outlives its ten-minute bound cancels the sign-in, and the Kit tries again
448
- at the next reconciliation.
448
+ at the next reconciliation. A capture that finds the grant revoked removes it,
449
+ reports the entry `signed_out` and opens a new sign-in at once.
449
450
 
450
451
  The resident captures each signed-in entry every 15 minutes, with no command.
451
452
  An occurrence never overlaps its entry's previous one, and one missed while the
@@ -888,9 +889,10 @@ the same for every adapter kind: the line
888
889
  `The agents.house house CLI is available: run house --help.`, the base
889
890
  instructions of the Execution attempt plan House acquired, and the current text
890
891
  of the owner's How-we-work document `/private/library/how-we-work.md`, which the
891
- launcher reads with the `shell` tool over the Session's own bridge, following
892
- House's cursor to the whole text. An absent document, one the Session's ceiling
893
- cannot read, and empty base instructions add nothing. Any other failed read
892
+ launcher reads with the `shell` tool over the Session's own bridge, running
893
+ the continuation each cut reply's `output_cut` line prints until a reply comes
894
+ back uncut. A document that is absent, or that the Session's ceiling cannot
895
+ read, at the first read adds nothing, and so do empty base instructions. Any other failed read
894
896
  refuses the attempt as `kit_how_we_work_unreadable` with the bridge's cause
895
897
  before a prompt is sent. A session reloaded with `session/load` already holds
896
898
  the block and gets none. It forwards only adapter-classified User-visible assistant
@@ -1576,8 +1578,9 @@ kit sync rebootstrap ./replica
1576
1578
  ```
1577
1579
 
1578
1580
  `bootstrap` writes `ROOM.md`, Library text, House-derived projections, and
1579
- Capture text with its provenance sidecar, and records one position per selected
1580
- authority from the bundle, with the log epoch House issued it in, together with
1581
+ Capture text, with the provenance House serves on a file's manifest entry as
1582
+ its `_provenance` sidecar, and records one position per selected authority from
1583
+ the bundle, with the log epoch House issued it in, together with
1581
1584
  the positive operation scopes House declared for it: `read`, `sync`, `write`,
1582
1585
  and `delete`, each a list of paths relative to the authority under which that
1583
1586
  operation is admitted. Managed replication needs `read` plus `sync` on a Room;
@@ -1586,11 +1589,11 @@ with `operation_denied`. Generated `_provenance` files delivered beside authored
1586
1589
  Library paths are House metadata, not authored Library documents. `pull` presents
1587
1590
  each recorded position with its log epoch, applies each enumerated path's whole
1588
1591
  content or removal, and advances those positions. A removal, pulled or accepted
1589
- from `submit`, also removes each directory it leaves empty up to the authority,
1590
- so a later file can take that path; a directory still holding a file, such as a
1591
- local draft, stays. A position older than House's
1592
- declared retention, or one of a log epoch before House's database was restored,
1593
- is reported as `position_expired`; only `rebootstrap` rebuilds the directory.
1592
+ from `submit`, also removes the file's `_provenance` sidecar and each directory
1593
+ it leaves empty up to the authority, so a later file can take that path; a
1594
+ directory still holding a file, such as a local draft, stays. A position older
1595
+ than House's declared retention, or one of a log epoch before House's database
1596
+ was restored, is reported as `position_expired`; only `rebootstrap` rebuilds the directory.
1594
1597
 
1595
1598
  `submit` sends field, section, and preamble operations with that value's prior
1596
1599
  content as base, and whole-file, rename, and removal operations with the file
@@ -1803,7 +1806,12 @@ another, beside the live drafts described below:
1803
1806
  the answer is absent or unreadable. File items are pulled back from House,
1804
1807
  refused as `file_too_large` above the 10 MiB image ceiling, uploaded as
1805
1808
  `sendPhoto` with the caption House composed around the link to the retained
1806
- original, and reported the same way; a pull that fails reports
1809
+ original, and reported the same way. A caption past Telegram's
1810
+ 1,024-character caption limit keeps its head under the image, cut at its last
1811
+ line break that fits or else at the limit, and the rest follows in the topic
1812
+ as ordinary messages; the upload is reported with the image's answer when
1813
+ every part was delivered and otherwise with the answer of the first part that
1814
+ was not. A pull that fails reports
1807
1815
  `transfer_failed` and an ambiguous upload answer reports `delivery_unknown`.
1808
1816
  Only an image ever becomes a file item, because House offers every other file
1809
1817
  as a `conversation_file_link` message this queue delivers like any other. The
@@ -1814,6 +1822,10 @@ another, beside the live drafts described below:
1814
1822
  between blocks and inside a block only when that block alone exceeds them,
1815
1823
  and reported once, `delivered` with the first message's id when every part
1816
1824
  was delivered and otherwise with the answer of the first part that was not.
1825
+ A new plain-text message past Telegram's 4,096-character message limit, such
1826
+ as a `conversation_file_link` text, is sent and reported the same way, cut
1827
+ at the last line break that fits or else at the limit, so the first message
1828
+ keeps its reply and keyboard and a link on its own line stays whole.
1817
1829
  Each answer also names the `threads` the bot's topics carry, a
1818
1830
  `conversation_id` with its `chat_id` and `topic_id`, which is where live
1819
1831
  drafts go.
@@ -2177,6 +2189,17 @@ and that a pending submission House answers `operation_expired` is pulled first,
2177
2189
  then sent again under a new identifier only if it never landed. Evidence is
2178
2190
  written to `tmp/delivery-evidence/sync-pair.json`.
2179
2191
 
2192
+ The How-we-work reader has its own paired proof:
2193
+
2194
+ ```sh
2195
+ HOUSE_CORE_ROOT=<core checkout> HOUSE_AUTH_ROOT=<auth checkout> \
2196
+ KIT_PAIR_SPECS=/kit/test/pair/how-we-work.vitest.ts bash test/pair/run.sh
2197
+ ```
2198
+
2199
+ It writes a document longer than two of House's cut shell replies and proves
2200
+ the installed reader returns its whole text by running each printed
2201
+ continuation. Evidence is written to `tmp/delivery-evidence/how-we-work-pair.json`.
2202
+
2180
2203
  The retained-original consumers have their own paired proofs:
2181
2204
 
2182
2205
  ```sh
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.33",
3
+ "version": "0.1.0-alpha.35",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.33/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.35/connect-linux.sh)",
7
7
  "asset": {
8
8
  "name": "connect-linux.sh",
9
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.33/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.35/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.33",
3
+ "version": "0.1.0-alpha.35",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,54 +38,70 @@ export async function commandHouseInstructions(bridge) {
38
38
  return frame.result.instructions;
39
39
  }
40
40
 
41
+ const CUT_NOTICE = "shell: output_cut ";
42
+ const CONTINUATION = "; continue with: ";
43
+ const BYTE_CONTINUATION = /\| tail -c \+[1-9][0-9]*$/u;
44
+
41
45
  function shellAnswer(text) {
42
46
  const lines = text.split("\n");
43
47
  if (!lines[0].startsWith(`protocol: render=${SHELL_RENDER} `)) return null;
44
48
  const exit = Number(lines[2].slice("exit: ".length));
49
+ const truncation = lines[3].slice("truncation: ".length);
45
50
  let at = lines[5]?.startsWith("authority: ") ? 6 : 5;
46
- if (exit === 0) return { exit, stderr: [], stdout: lines.slice(at).join("\n"), cursor: null };
51
+ if (exit === 0 && truncation === "none") {
52
+ return { exit, truncation, stderr: [], stdout: lines.slice(at).join("\n"), continuation: null };
53
+ }
47
54
  const stderr = [];
48
- while (lines[at]?.startsWith("stderr: ")) stderr.push(lines[at++].slice("stderr: ".length));
49
- const stdout = lines.slice(at);
50
- const cursor = stdout.at(-1)?.startsWith("cursor: ") ? stdout.pop().slice("cursor: ".length) : null;
51
- return { exit, stderr, stdout: stdout.join("\n"), cursor };
55
+ let continuation = null;
56
+ while (continuation === null && lines[at]?.startsWith("stderr: ")) {
57
+ const line = lines[at++].slice("stderr: ".length);
58
+ stderr.push(line);
59
+ const separator = line.indexOf(CONTINUATION);
60
+ if (line.startsWith(CUT_NOTICE) && separator !== -1) {
61
+ continuation = line.slice(separator + CONTINUATION.length);
62
+ }
63
+ }
64
+ return { exit, truncation, stderr, stdout: lines.slice(at).join("\n"), continuation };
52
65
  }
53
66
 
54
- async function howWeWorkPage(bridge, cursor) {
67
+ async function howWeWorkReply(bridge, command, first) {
55
68
  const frame = await bridgeCall(bridge, {
56
69
  method: "tools/call",
57
- params: {
58
- name: "shell",
59
- arguments: { command: `cat ${HOW_WE_WORK_PATH}`, ...(cursor === null ? {} : { cursor }) },
60
- },
70
+ params: { name: "shell", arguments: { command } },
61
71
  });
62
72
  if (frame.error !== undefined) {
63
73
  throw new KitRefusal("kit_how_we_work_unreadable", frame.error.message ?? null);
64
74
  }
65
75
  const text = answerText(frame.result);
66
76
  if (frame.result?.isError === true) {
67
- if (text.split(":")[0] === "room_not_found") return null;
77
+ if (first && text.split(":")[0] === "room_not_found") return null;
68
78
  throw new KitRefusal("kit_how_we_work_unreadable", text);
69
79
  }
70
- const page = shellAnswer(text);
71
- if (page === null) throw new KitRefusal("kit_how_we_work_unreadable", text);
72
- if (page.cursor === null && page.exit !== 0) {
73
- if (page.stderr.length === 1 && page.stderr[0].split(" ")[1] === "path_not_found") return null;
74
- throw new KitRefusal("kit_how_we_work_unreadable", page.stderr.join("\n"));
80
+ const reply = shellAnswer(text);
81
+ if (reply === null) throw new KitRefusal("kit_how_we_work_unreadable", text);
82
+ if (reply.exit !== 0) {
83
+ if (first && reply.stderr.length === 1 && reply.stderr[0].split(" ")[1] === "path_not_found") return null;
84
+ throw new KitRefusal("kit_how_we_work_unreadable", reply.stderr.join("\n"));
85
+ }
86
+ if (reply.truncation !== "none" && reply.continuation === null) {
87
+ throw new KitRefusal("kit_how_we_work_unreadable", reply.stderr.join("\n"));
75
88
  }
76
- return page;
89
+ return reply;
77
90
  }
78
91
 
79
92
  export async function commandHouseHowWeWork(bridge) {
80
- const pages = [];
81
- let cursor = null;
82
- do {
83
- const page = await howWeWorkPage(bridge, cursor);
84
- if (page === null) return "";
85
- pages.push(page.stdout);
86
- cursor = page.cursor;
87
- } while (cursor !== null);
88
- return pages.join("\n");
93
+ let document = "";
94
+ let command = `cat ${HOW_WE_WORK_PATH}`;
95
+ let first = true;
96
+ while (command !== null) {
97
+ const reply = await howWeWorkReply(bridge, command, first);
98
+ if (reply === null) return "";
99
+ document += reply.stdout;
100
+ command = reply.continuation;
101
+ if (command !== null && !BYTE_CONTINUATION.test(command)) document += "\n";
102
+ first = false;
103
+ }
104
+ return document;
89
105
  }
90
106
 
91
107
  export async function commandHouseCreateHowWeWork(bridge) {
package/src/contract.js CHANGED
@@ -61,6 +61,8 @@ export const TELEGRAM_DOWNLOAD_CEILING_BYTES = 20 * 1024 * 1024;
61
61
  export const TELEGRAM_BOT_API_TIMEOUT_MS = 30_000;
62
62
  export const TELEGRAM_GATEWAY_POLL_MS = 2_000;
63
63
  export const TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX = 32_768;
64
+ export const TELEGRAM_MESSAGE_CHARACTERS_MAX = 4_096;
65
+ export const TELEGRAM_CAPTION_CHARACTERS_MAX = 1_024;
64
66
  export const TELEGRAM_RICH_MESSAGE_BLOCKS_MAX = 500;
65
67
  export const TELEGRAM_CONTROL_KEYBOARD_COMMANDS = ["/stop", "/kill"];
66
68
  export const TELEGRAM_MEDIA_KINDS = [
@@ -213,7 +213,10 @@ export function sourceEntries(context) {
213
213
  const task = signIn(occasion(record), entry);
214
214
  signingIn = { source: entry.source, task };
215
215
  try {
216
- if (await task) record.last = null;
216
+ if (await task) {
217
+ record.last = null;
218
+ attempted.delete(entry.source);
219
+ }
217
220
  } catch (error) {
218
221
  if (!record.stop.signal.aborted) throw error;
219
222
  } finally {
package/src/sync.js CHANGED
@@ -131,23 +131,31 @@ function scopeContents(replica, scope) {
131
131
  return scope.contents;
132
132
  }
133
133
 
134
+ function servedFile(scope, path) {
135
+ return scope.manifest.files.find((file) => file?.path === path);
136
+ }
137
+
134
138
  async function writeScope(replica, scope, signal) {
135
- const files = [];
139
+ const recorded = new Map();
136
140
  for (const entry of scopeContents(replica, scope)) {
137
- const meta = scope.manifest.files.find((file) => file.path === entry.path);
141
+ const meta = servedFile(scope, entry.path) ?? {};
138
142
  await writeReplicaFile(locate(replica, scope.manifest.path, entry.path), entry.content, signal);
139
- files.push({
140
- path: entry.path,
141
- revision: typeof meta?.revision === "string" ? meta.revision : "",
142
- sha256: typeof meta?.sha256 === "string" ? meta.sha256 : "",
143
- content: entry.content,
144
- });
143
+ recorded.set(entry.path, enumeratedRecord(entry.path, entry.content, meta));
144
+ if (typeof meta.provenance === "string") {
145
+ await writeSidecar({ replica }, scope.manifest, recorded, entry.path, meta, signal);
146
+ }
145
147
  }
146
- return files;
148
+ return [...recorded.values()];
147
149
  }
148
150
 
149
151
  async function replaceScope(replica, previous, scope, signal) {
150
- const kept = new Set(scopeContents(replica, scope).map((entry) => entry.path));
152
+ const kept = new Set(
153
+ scopeContents(replica, scope).flatMap((entry) =>
154
+ typeof servedFile(scope, entry.path)?.provenance === "string"
155
+ ? [entry.path, sidecarPath(entry.path)]
156
+ : [entry.path],
157
+ ),
158
+ );
151
159
  const root = locate(replica, scope.manifest.path);
152
160
  for (const path of await listFiles(root)) {
153
161
  if (!kept.has(path)) await removeReplicaFile(replica, scope.manifest.path, path);
@@ -255,6 +263,12 @@ async function writeSidecar(options, authority, recorded, path, change, signal)
255
263
  recorded.set(sidecar, enumeratedRecord(sidecar, change.provenance, change));
256
264
  }
257
265
 
266
+ async function removeSidecar(options, authority, recorded, path) {
267
+ const sidecar = sidecarPath(path);
268
+ await removeReplicaFile(options.replica, authority.path, sidecar);
269
+ recorded.delete(sidecar);
270
+ }
271
+
258
272
  async function applyEnumeration(options, state, enumeration, signal) {
259
273
  if (!Array.isArray(enumeration?.authorities)) throw new KitRefusal("kit_sync_rejected", "enumerate");
260
274
  for (const view of enumeration.authorities) {
@@ -267,6 +281,7 @@ async function applyEnumeration(options, state, enumeration, signal) {
267
281
  if (change.removed === true) {
268
282
  await removeReplicaFile(options.replica, authority.path, change.path);
269
283
  recorded.delete(change.path);
284
+ await removeSidecar(options, authority, recorded, change.path);
270
285
  continue;
271
286
  }
272
287
  if (typeof change.content === "string") {
@@ -360,6 +375,7 @@ async function acceptBatch(session, options, state, outcome, signal, pending = n
360
375
  if (accepted === null) {
361
376
  if (!preserve) await removeReplicaFile(options.replica, authority.path, relative);
362
377
  recorded.delete(relative);
378
+ await removeSidecar(options, authority, recorded, relative);
363
379
  continue;
364
380
  }
365
381
  if (!preserve) await writeReplicaFile(destination, accepted, signal);
@@ -25,13 +25,16 @@ function richText(value, key = null) {
25
25
  return [];
26
26
  }
27
27
 
28
- function slices(text) {
28
+ export function slices(text, limit = TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) {
29
29
  const pieces = [];
30
30
  let start = 0;
31
31
  while (start < text.length) {
32
- let end = Math.min(start + TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX, text.length);
32
+ let end = Math.min(start + limit, text.length);
33
+ const window = text.slice(start, end);
34
+ const line = window.lastIndexOf("\n");
33
35
  const last = text.charCodeAt(end - 1);
34
- if (end < text.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
36
+ if (end < text.length && line > 0 && /\S/.test(window.slice(0, line))) end = start + line + 1;
37
+ else if (end < text.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
35
38
  pieces.push(text.slice(start, end));
36
39
  start = end;
37
40
  }
package/src/telegram.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  PERSONAL_BOT_OFFSET_RELATIVE_PATH,
11
11
  PERSONAL_BOT_TOKEN_RELATIVE_PATH,
12
12
  TELEGRAM_BOT_API_TIMEOUT_MS,
13
+ TELEGRAM_CAPTION_CHARACTERS_MAX,
13
14
  TELEGRAM_CONTROL_KEYBOARD_COMMANDS,
14
15
  TELEGRAM_DELIVERY_BATCH_MAX,
15
16
  TELEGRAM_DELIVERY_KINDS,
@@ -18,6 +19,7 @@ import {
18
19
  TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES,
19
20
  TELEGRAM_INGRESS_BATCH_MAX,
20
21
  TELEGRAM_MEDIA_KINDS,
22
+ TELEGRAM_MESSAGE_CHARACTERS_MAX,
21
23
  TELEGRAM_UNSUPPORTED_MEDIA_FIELDS,
22
24
  TELEGRAM_UPDATE_OUTCOMES,
23
25
  telegramApiUrl,
@@ -27,7 +29,7 @@ import { heldControl, houseRequest, maintainControl, openSession } from "./house
27
29
  import { watchKitWork } from "./kit-work-stream.js";
28
30
  import { KitRefusal } from "./refusal.js";
29
31
  import { writeText } from "./state.js";
30
- import { richPieces, turnViews } from "./telegram-view.js";
32
+ import { richPieces, slices, turnViews } from "./telegram-view.js";
31
33
  import { kitStreamWriter } from "./turn-frames.js";
32
34
 
33
35
  const TOKEN_PROMPT = "Paste the BotFather token for this Environment: ";
@@ -285,8 +287,21 @@ export function deliveryCall(delivery) {
285
287
  return { method: "sendMessage", body: sendBody(delivery) };
286
288
  }
287
289
 
290
+ function topicMessage(chat, topic, text) {
291
+ const body = { chat_id: Number(chat), text };
292
+ if (topic !== null) body.message_thread_id = Number(topic);
293
+ return { method: "sendMessage", body };
294
+ }
295
+
288
296
  function deliveryCalls(delivery) {
289
297
  const call = deliveryCall(delivery);
298
+ if (call.method === "sendMessage" && call.body.text.length > TELEGRAM_MESSAGE_CHARACTERS_MAX) {
299
+ const [first, ...following] = slices(call.body.text, TELEGRAM_MESSAGE_CHARACTERS_MAX);
300
+ return [
301
+ { method: call.method, body: { ...call.body, text: first } },
302
+ ...following.map((text) => topicMessage(delivery.chat_id, delivery.topic_id, text)),
303
+ ];
304
+ }
290
305
  if (call.method !== "sendRichMessage") return [call];
291
306
  return richPieces(call.body.rich_message).map((rich_message) => ({
292
307
  method: call.method,
@@ -791,6 +806,27 @@ async function sendQueuedMessage(gateway, token, delivery) {
791
806
  return false;
792
807
  }
793
808
 
809
+ function captionPieces(caption) {
810
+ if (caption === null || caption.length <= TELEGRAM_CAPTION_CHARACTERS_MAX) return [caption];
811
+ const [head] = slices(caption, TELEGRAM_CAPTION_CHARACTERS_MAX);
812
+ return [head, ...slices(caption.slice(head.length), TELEGRAM_MESSAGE_CHARACTERS_MAX)];
813
+ }
814
+
815
+ async function sentOverflow(gateway, token, upload, overflow) {
816
+ for (const text of overflow) {
817
+ const call = topicMessage(upload.chat_id, upload.topic_id, text);
818
+ let answered;
819
+ try {
820
+ answered = await telegramPost(gateway, telegramApiUrl(token, call.method), call.body);
821
+ } catch (error) {
822
+ if (cancelled(error)) throw error;
823
+ return { settled: uploadOutcome(null), answered: null };
824
+ }
825
+ if (!botApiAccepted(answered)) return { settled: uploadOutcome(answered), answered };
826
+ }
827
+ return null;
828
+ }
829
+
794
830
  async function sendQueuedUpload(gateway, token, upload) {
795
831
  let bytes;
796
832
  try {
@@ -810,12 +846,13 @@ async function sendQueuedUpload(gateway, token, upload) {
810
846
  });
811
847
  return false;
812
848
  }
849
+ const [caption, ...overflow] = captionPieces(upload.caption);
813
850
  let answered;
814
851
  try {
815
852
  answered = await telegramUpload(
816
853
  gateway,
817
854
  telegramApiUrl(token, "sendPhoto"),
818
- uploadFields(upload),
855
+ uploadFields({ ...upload, caption }),
819
856
  { field: "photo", name: upload.file_name, bytes },
820
857
  );
821
858
  } catch (error) {
@@ -826,8 +863,11 @@ async function sendQueuedUpload(gateway, token, upload) {
826
863
  });
827
864
  return false;
828
865
  }
829
- await reportUpload(gateway, upload, uploadOutcome(answered));
830
- const retryAfter = telegramRetryAfter(answered);
866
+ const photographed = uploadOutcome(answered);
867
+ const unsent =
868
+ photographed.outcome === "delivered" ? await sentOverflow(gateway, token, upload, overflow) : null;
869
+ await reportUpload(gateway, upload, unsent?.settled ?? photographed);
870
+ const retryAfter = telegramRetryAfter(unsent === null ? answered : unsent.answered);
831
871
  if (retryAfter === null) return false;
832
872
  gateway.gate.hold(retryAfter);
833
873
  return true;