@gajae-code/coding-agent 0.7.0 → 0.7.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/cli/notify-cli.d.ts +2 -0
  3. package/dist/types/config/settings-schema.d.ts +39 -2
  4. package/dist/types/extensibility/shared-events.d.ts +1 -0
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  6. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
  7. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  11. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  12. package/dist/types/notifications/config.d.ts +9 -1
  13. package/dist/types/notifications/engine.d.ts +59 -0
  14. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  15. package/dist/types/notifications/telegram-daemon.d.ts +19 -0
  16. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  17. package/dist/types/notifications/threaded-render.d.ts +6 -1
  18. package/dist/types/session/agent-session.d.ts +2 -0
  19. package/dist/types/tools/fetch.d.ts +23 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/telegram-send.d.ts +32 -0
  22. package/dist/types/web/insane/bridge.d.ts +103 -0
  23. package/dist/types/web/insane/url-guard.d.ts +22 -0
  24. package/dist/types/web/search/provider.d.ts +18 -1
  25. package/dist/types/web/search/providers/insane.d.ts +53 -0
  26. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  27. package/dist/types/web/search/types.d.ts +12 -4
  28. package/package.json +10 -8
  29. package/scripts/verify-insane-vendor.ts +132 -0
  30. package/src/cli/args.ts +1 -1
  31. package/src/cli/fast-help.ts +1 -1
  32. package/src/cli/notify-cli.ts +152 -5
  33. package/src/cli.ts +1 -3
  34. package/src/commands/team.ts +1 -1
  35. package/src/config/settings-schema.ts +30 -1
  36. package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
  37. package/src/edit/modes/replace.ts +1 -1
  38. package/src/extensibility/shared-events.ts +1 -0
  39. package/src/gjc-runtime/launch-tmux.ts +27 -5
  40. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  41. package/src/gjc-runtime/ralplan-runtime.ts +2 -2
  42. package/src/gjc-runtime/tmux-common.ts +8 -0
  43. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  44. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  45. package/src/gjc-runtime/workflow-manifest.ts +7 -2
  46. package/src/hashline/hash.ts +1 -1
  47. package/src/internal-urls/docs-index.generated.ts +9 -8
  48. package/src/lsp/config.ts +16 -3
  49. package/src/lsp/defaults.json +7 -0
  50. package/src/lsp/types.ts +2 -0
  51. package/src/modes/controllers/event-controller.ts +15 -0
  52. package/src/modes/interactive-mode.ts +46 -2
  53. package/src/modes/utils/context-usage.ts +2 -2
  54. package/src/notifications/attachment-registry.ts +23 -0
  55. package/src/notifications/chat-adapters.ts +147 -0
  56. package/src/notifications/config.ts +23 -2
  57. package/src/notifications/engine.ts +100 -0
  58. package/src/notifications/index.ts +224 -45
  59. package/src/notifications/managed-daemon.ts +163 -0
  60. package/src/notifications/telegram-daemon.ts +235 -14
  61. package/src/notifications/threaded-inbound.ts +60 -4
  62. package/src/notifications/threaded-render.ts +20 -2
  63. package/src/session/agent-session.ts +82 -51
  64. package/src/tools/ask.ts +3 -2
  65. package/src/tools/fetch.ts +78 -1
  66. package/src/tools/index.ts +3 -0
  67. package/src/tools/telegram-send.ts +137 -0
  68. package/src/web/insane/bridge.ts +350 -0
  69. package/src/web/insane/url-guard.ts +155 -0
  70. package/src/web/search/provider.ts +77 -18
  71. package/src/web/search/providers/anthropic.ts +70 -3
  72. package/src/web/search/providers/codex.ts +1 -119
  73. package/src/web/search/providers/gemini.ts +99 -0
  74. package/src/web/search/providers/insane.ts +551 -0
  75. package/src/web/search/providers/openai-compatible.ts +66 -32
  76. package/src/web/search/providers/text-citations.ts +111 -0
  77. package/src/web/search/types.ts +13 -2
  78. package/vendor/insane-search/LICENSE +21 -0
  79. package/vendor/insane-search/MANIFEST.json +24 -0
  80. package/vendor/insane-search/engine/__init__.py +23 -0
  81. package/vendor/insane-search/engine/__main__.py +128 -0
  82. package/vendor/insane-search/engine/bias_check.py +183 -0
  83. package/vendor/insane-search/engine/executor.py +254 -0
  84. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  85. package/vendor/insane-search/engine/learning.py +175 -0
  86. package/vendor/insane-search/engine/phase0.py +214 -0
  87. package/vendor/insane-search/engine/safety.py +91 -0
  88. package/vendor/insane-search/engine/templates/package.json +11 -0
  89. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  90. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  91. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  92. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  93. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  94. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  95. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  96. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  97. package/vendor/insane-search/engine/transport.py +211 -0
  98. package/vendor/insane-search/engine/url_transforms.py +98 -0
  99. package/vendor/insane-search/engine/validators.py +331 -0
  100. package/vendor/insane-search/engine/waf_detector.py +214 -0
  101. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -1,5 +1,7 @@
1
1
  import { spawn as childProcessSpawn } from "node:child_process";
2
+ import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
3
5
  import * as path from "node:path";
4
6
  import { logger } from "@gajae-code/utils";
5
7
  import { withFileLock } from "../config/file-lock";
@@ -19,7 +21,7 @@ import {
19
21
  readEndpoint,
20
22
  routeInboundUpdate,
21
23
  } from "./telegram-reference";
22
- import { decideThreadedInbound } from "./threaded-inbound";
24
+ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
23
25
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
24
26
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
25
27
 
@@ -582,8 +584,14 @@ export class TelegramNotificationDaemon {
582
584
  private readonly fsImpl: TelegramDaemonFs;
583
585
  private readonly botApi: BotApi;
584
586
  private readonly topics = new TopicRegistry();
585
- private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId: string }>;
587
+ private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
586
588
  private readonly seenUpdateIds = new Set<number>();
589
+ /** True once the daemon has nudged the user to enable Threaded Mode. */
590
+ private threadedFallbackNoticeSent = false;
591
+ /** Sessions whose identity header was already sent flat (Threaded Mode off). */
592
+ private readonly flatIdentitySent = new Set<string>();
593
+ /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
594
+ private pairedChatPrivate: boolean | undefined;
587
595
  private flushTimer: ReturnType<typeof setInterval> | undefined;
588
596
  private scanTimer: ReturnType<typeof setInterval> | undefined;
589
597
  private scanning = false;
@@ -646,6 +654,35 @@ export class TelegramNotificationDaemon {
646
654
  );
647
655
  return res.json();
648
656
  }
657
+ const docBody = body as { document?: unknown } | null;
658
+ if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
659
+ const b = body as {
660
+ chat_id: unknown;
661
+ message_thread_id?: unknown;
662
+ document: string;
663
+ mime?: string;
664
+ fileName?: string;
665
+ caption?: string;
666
+ parse_mode?: string;
667
+ };
668
+ const form = new FormData();
669
+ form.set("chat_id", String(b.chat_id));
670
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
671
+ if (b.caption) form.set("caption", b.caption);
672
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
673
+ form.set(
674
+ "document",
675
+ new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
676
+ b.fileName ?? "file",
677
+ );
678
+ const res = await fetchWithRetry(
679
+ fetchImpl,
680
+ url,
681
+ { method: "POST", body: form, signal: callOpts?.signal },
682
+ sleep,
683
+ );
684
+ return res.json();
685
+ }
649
686
  const res = await fetchWithRetry(
650
687
  fetchImpl,
651
688
  url,
@@ -660,7 +697,7 @@ export class TelegramNotificationDaemon {
660
697
  return res.json();
661
698
  },
662
699
  };
663
- this.pool = new RateLimitPool<{ send: ThreadedSend; topicId: string }>({ now: opts.now });
700
+ this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
664
701
  }
665
702
 
666
703
  async loadAliases(): Promise<void> {
@@ -797,6 +834,7 @@ export class TelegramNotificationDaemon {
797
834
  "context_update",
798
835
  "turn_stream",
799
836
  "image_attachment",
837
+ "file_attachment",
800
838
  "config_update",
801
839
  ]);
802
840
 
@@ -815,8 +853,9 @@ export class TelegramNotificationDaemon {
815
853
 
816
854
  /**
817
855
  * Resolve (creating once via `createForumTopic`) the forum topic for a
818
- * session. Threaded mode is required: on capability failure this returns
819
- * `undefined` and the caller drops the send (no flat fallback).
856
+ * session. On capability failure (e.g. Threaded Mode off) this returns
857
+ * `undefined`; callers then flat-deliver to a private paired chat (with a
858
+ * one-time nudge) or drop fail-closed for a non-private chat.
820
859
  */
821
860
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
822
861
  const existing = this.topics.get(sessionId);
@@ -857,26 +896,140 @@ export class TelegramNotificationDaemon {
857
896
  if (raw && typeof raw === "object") this.topics.load(raw);
858
897
  }
859
898
 
899
+ /** Download a Telegram file by its file_path (from getFile) into memory. */
900
+ private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
901
+ const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
902
+ const fetchImpl = this.opts.fetchImpl ?? fetch;
903
+ // `filePath` is remote metadata from getFile; reject suspicious segments
904
+ // (traversal/absolute/backslash) and percent-encode each component before
905
+ // composing the download URL.
906
+ if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
907
+ logger.warn("notifications: rejecting suspicious Telegram file_path");
908
+ return undefined;
909
+ }
910
+ const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
911
+ const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
912
+ try {
913
+ const res = await fetchImpl(url);
914
+ if (!res.ok) return undefined;
915
+ return Buffer.from(await res.arrayBuffer());
916
+ } catch (e) {
917
+ logger.warn(`notifications: file download failed: ${String(e)}`);
918
+ return undefined;
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Per-session private temp directories (mode 0700) holding inbound non-image
924
+ * attachments. Keyed by session id and reused across transient reconnects;
925
+ * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
926
+ */
927
+ private readonly attachmentDirs = new Map<string, string>();
928
+
929
+ /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
930
+ private async ensureAttachmentDir(sessionId: string): Promise<string> {
931
+ const existing = this.attachmentDirs.get(sessionId);
932
+ if (existing) return existing;
933
+ // mkdtemp creates a directory with an unguessable suffix and 0700 perms;
934
+ // chmod defensively in case of an unusual platform/umask.
935
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-telegram-"));
936
+ await fs.promises.chmod(dir, 0o700).catch(() => undefined);
937
+ this.attachmentDirs.set(sessionId, dir);
938
+ return dir;
939
+ }
940
+
941
+ /** Remove all per-session attachment directories. Called on daemon shutdown. */
942
+ private async cleanupAllAttachmentDirs(): Promise<void> {
943
+ const dirs = [...this.attachmentDirs.values()];
944
+ this.attachmentDirs.clear();
945
+ await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
946
+ }
947
+
948
+ /**
949
+ * Resolve an inbound attachment to inline image bytes (forwarded as images) or
950
+ * a securely-saved file path note (non-images). Non-image bytes are written
951
+ * into a private per-session temp dir (0700) under an unguessable name via an
952
+ * exclusive 0600 create (`wx`), so the files are not world-readable and the
953
+ * write never follows a pre-existing symlink. The directory is removed when the
954
+ * daemon stops. Returns base64 images to inline plus human-readable file notes
955
+ * to append to the injected text.
956
+ */
957
+ private async resolveInboundAttachment(
958
+ att: InboundAttachment,
959
+ sessionId: string,
960
+ ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
961
+ const images: { data: string; mime?: string }[] = [];
962
+ const fileNotes: string[] = [];
963
+ const label = att.fileName ?? att.kind;
964
+ try {
965
+ const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
966
+ result?: { file_path?: unknown };
967
+ };
968
+ const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
969
+ if (!filePath) {
970
+ fileNotes.push(`[attachment unavailable: ${label}]`);
971
+ return { images, fileNotes };
972
+ }
973
+ const bytes = await this.downloadTelegramFile(filePath);
974
+ if (!bytes) {
975
+ fileNotes.push(`[attachment download failed: ${label}]`);
976
+ return { images, fileNotes };
977
+ }
978
+ const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
979
+ if (isImage) {
980
+ images.push({ data: bytes.toString("base64"), mime: att.mime ?? "image/jpeg" });
981
+ } else {
982
+ const safeBase =
983
+ (att.fileName?.trim() || path.basename(filePath) || `${att.kind}-${att.fileId}`)
984
+ .replace(/[^\w.-]+/g, "_") // drop path separators and unusual chars
985
+ .replace(/\.\.+/g, "_") // neutralize any ".." traversal-looking runs
986
+ .replace(/^[.-]+/, "_") // no leading dot/hyphen
987
+ .slice(-128) || "file";
988
+ const dir = await this.ensureAttachmentDir(sessionId);
989
+ // Unguessable, non-colliding name inside the private 0700 dir; the
990
+ // exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
991
+ const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
992
+ await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
993
+ fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
994
+ }
995
+ } catch (e) {
996
+ logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
997
+ fileNotes.push(`[attachment error: ${label}]`);
998
+ }
999
+ return { images, fileNotes };
1000
+ }
1001
+
860
1002
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
861
1003
  private async flushPool(): Promise<void> {
862
1004
  for (const item of this.pool.drain()) {
863
1005
  const { send, topicId } = item.payload;
864
- const thread = Number(topicId);
1006
+ // Threaded topic when available; otherwise deliver flat to the paired chat.
1007
+ const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
865
1008
  try {
866
1009
  if (send.method === "sendPhoto" && send.photoBase64) {
867
1010
  // Real photo upload (the default botApi multiparts base64 -> file).
868
1011
  await this.botApi.call("sendPhoto", {
869
1012
  chat_id: this.opts.chatId,
870
- message_thread_id: thread,
1013
+ ...threadField,
871
1014
  photo: send.photoBase64,
872
1015
  mime: send.mime,
873
1016
  caption: send.text,
874
1017
  parse_mode: TELEGRAM_PARSE_MODE,
875
1018
  });
1019
+ } else if (send.method === "sendDocument" && send.documentBase64) {
1020
+ await this.botApi.call("sendDocument", {
1021
+ chat_id: this.opts.chatId,
1022
+ ...threadField,
1023
+ document: send.documentBase64,
1024
+ mime: send.mime,
1025
+ fileName: send.fileName,
1026
+ caption: send.text,
1027
+ parse_mode: TELEGRAM_PARSE_MODE,
1028
+ });
876
1029
  } else if (send.text) {
877
1030
  await this.botApi.call("sendMessage", {
878
1031
  chat_id: this.opts.chatId,
879
- message_thread_id: thread,
1032
+ ...threadField,
880
1033
  text: send.text,
881
1034
  parse_mode: TELEGRAM_PARSE_MODE,
882
1035
  });
@@ -887,6 +1040,57 @@ export class TelegramNotificationDaemon {
887
1040
  }
888
1041
  }
889
1042
 
1043
+ /**
1044
+ * Threaded Mode is unavailable (the bot owner has not enabled forum topics in
1045
+ * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
1046
+ * the paired chat instead of dropping it, and nudge the user once. Flat delivery
1047
+ * is gated on the paired chat being a private chat: for a group/supergroup/channel
1048
+ * (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session
1049
+ * content never lands in a shared chat. Identity headers are sent at most once per
1050
+ * session in flat mode.
1051
+ */
1052
+ private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
1053
+ if (!(await this.pairedChatIsPrivate())) return;
1054
+ await this.notifyThreadedFallback();
1055
+ if (send.identity && this.flatIdentitySent.has(sessionId)) return;
1056
+ this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
1057
+ await this.flushPool();
1058
+ if (send.identity) this.flatIdentitySent.add(sessionId);
1059
+ }
1060
+
1061
+ /**
1062
+ * Resolve once (cached) whether the paired `chatId` is a private chat. Flat
1063
+ * fallback is only safe in a private DM; any non-private chat or an unresolvable
1064
+ * `getChat` is treated as not-private so delivery fails closed.
1065
+ */
1066
+ private async pairedChatIsPrivate(): Promise<boolean> {
1067
+ if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
1068
+ try {
1069
+ const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
1070
+ result?: { type?: string };
1071
+ };
1072
+ this.pairedChatPrivate = res.result?.type === "private";
1073
+ } catch {
1074
+ this.pairedChatPrivate = false;
1075
+ }
1076
+ return this.pairedChatPrivate;
1077
+ }
1078
+
1079
+ /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1080
+ private async notifyThreadedFallback(): Promise<void> {
1081
+ if (this.threadedFallbackNoticeSent) return;
1082
+ this.threadedFallbackNoticeSent = true;
1083
+ try {
1084
+ await this.botApi.call("sendMessage", {
1085
+ chat_id: this.opts.chatId,
1086
+ text: "turn on threaded mode from botfather miniapp to receive gjc notification!",
1087
+ parse_mode: TELEGRAM_PARSE_MODE,
1088
+ });
1089
+ } catch {
1090
+ // Best-effort nudge; never block delivery.
1091
+ }
1092
+ }
1093
+
890
1094
  private startFlushTimer(): void {
891
1095
  if (this.flushTimer) return;
892
1096
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
@@ -1015,7 +1219,10 @@ export class TelegramNotificationDaemon {
1015
1219
  const send = renderThreadedFrame(msg);
1016
1220
  if (!send) return;
1017
1221
  const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1018
- if (!topicId) return;
1222
+ if (!topicId) {
1223
+ await this.deliverFlatFallback(session.sessionId, send);
1224
+ return;
1225
+ }
1019
1226
  if (send.identity) {
1020
1227
  // Rename the topic if the title changed (e.g. the session title was
1021
1228
  // auto-generated after the topic was first created). This runs on
@@ -1058,7 +1265,12 @@ export class TelegramNotificationDaemon {
1058
1265
  if (msg.type === "action_needed" && msg.id) {
1059
1266
  if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
1060
1267
  const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1061
- if (!topicId) return;
1268
+ if (!topicId) {
1269
+ // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
1270
+ if (!(await this.pairedChatIsPrivate())) return;
1271
+ await this.notifyThreadedFallback();
1272
+ }
1273
+ const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1062
1274
  const rendered = buildActionMessage({
1063
1275
  kind: msg.kind ?? "ask",
1064
1276
  id: msg.id,
@@ -1074,7 +1286,7 @@ export class TelegramNotificationDaemon {
1074
1286
  );
1075
1287
  const result = (await this.botApi.call("sendMessage", {
1076
1288
  chat_id: this.opts.chatId,
1077
- message_thread_id: Number(topicId),
1289
+ ...threadField,
1078
1290
  text: rendered.text,
1079
1291
  parse_mode: TELEGRAM_PARSE_MODE,
1080
1292
  ...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
@@ -1138,12 +1350,19 @@ export class TelegramNotificationDaemon {
1138
1350
  this.seenUpdateIds.add(inbound.updateId);
1139
1351
  const session = this.sessions.get(inbound.sessionId);
1140
1352
  if (session?.ws.readyState === WebSocket.OPEN) {
1141
- const cfg = parseInThreadConfigCommand(inbound.text);
1353
+ const attachmentResult = inbound.attachment
1354
+ ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
1355
+ : undefined;
1356
+ const images = attachmentResult?.images ?? [];
1357
+ const fileNotes = attachmentResult?.fileNotes ?? [];
1358
+ const hasMedia = images.length > 0 || fileNotes.length > 0;
1359
+ const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
1360
+ const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
1142
1361
  // A plain (non-config) message while an ask is pending for this session
1143
1362
  // answers that ask as free-input — instead of starting a new user turn.
1144
1363
  // Telegram asks always accept custom text (the SDK maps a string answer
1145
1364
  // to the ask's custom-input slot), so route the latest pending ask here.
1146
- const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
1365
+ const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
1147
1366
  if (pendingAsk) {
1148
1367
  session.ws.send(
1149
1368
  JSON.stringify({
@@ -1163,10 +1382,11 @@ export class TelegramNotificationDaemon {
1163
1382
  : {
1164
1383
  type: "user_message",
1165
1384
  sessionId: inbound.sessionId,
1166
- text: inbound.text,
1385
+ text: injectedText,
1167
1386
  token: session.token,
1168
1387
  updateId: inbound.updateId,
1169
1388
  threadId: inbound.threadId,
1389
+ images,
1170
1390
  },
1171
1391
  ),
1172
1392
  );
@@ -1343,6 +1563,7 @@ export class TelegramNotificationDaemon {
1343
1563
  this.stopFlushTimer();
1344
1564
  this.stopScanTimer();
1345
1565
  this.stopTypingTimer();
1566
+ await this.cleanupAllAttachmentDirs();
1346
1567
  // Persist durable state before releasing ownership so a fresh daemon
1347
1568
  // (e.g. after reload) reloads aliases/topics seamlessly.
1348
1569
  await this.persistAliases().catch(() => undefined);
@@ -21,11 +21,30 @@ export interface InboundUpdate {
21
21
  message?: {
22
22
  message_id?: unknown;
23
23
  text?: unknown;
24
+ caption?: unknown;
25
+ photo?: unknown;
26
+ document?: unknown;
27
+ video?: unknown;
28
+ audio?: unknown;
29
+ voice?: unknown;
30
+ animation?: unknown;
24
31
  chat?: { id?: unknown };
25
32
  message_thread_id?: unknown;
26
33
  };
27
34
  }
28
35
 
36
+ /** A downloadable media attachment referenced by an inbound message. */
37
+ export interface InboundAttachment {
38
+ /** Telegram file_id to resolve via getFile. */
39
+ fileId: string;
40
+ /** Source media kind; "photo" is always an image. */
41
+ kind: "photo" | "document" | "video" | "audio" | "voice" | "animation";
42
+ /** MIME type when Telegram provides one. */
43
+ mime?: string;
44
+ /** Original file name when provided. */
45
+ fileName?: string;
46
+ }
47
+
29
48
  /** Context for {@link decideThreadedInbound}. All lookups are injected. */
30
49
  export interface ThreadedInboundCtx {
31
50
  /** The single paired chat id (string-compared). */
@@ -38,7 +57,15 @@ export interface ThreadedInboundCtx {
38
57
 
39
58
  /** Outcome of routing an inbound update. */
40
59
  export type ThreadedInboundDecision =
41
- | { kind: "inject"; sessionId: string; text: string; updateId: number; threadId: string; messageId?: number }
60
+ | {
61
+ kind: "inject";
62
+ sessionId: string;
63
+ text: string;
64
+ updateId: number;
65
+ threadId: string;
66
+ messageId?: number;
67
+ attachment?: InboundAttachment;
68
+ }
42
69
  | { kind: "duplicate"; updateId: number }
43
70
  | { kind: "ignore"; reason: string };
44
71
 
@@ -48,6 +75,29 @@ function asString(value: unknown): string | undefined {
48
75
  return undefined;
49
76
  }
50
77
 
78
+ function extractAttachment(message: NonNullable<InboundUpdate["message"]>): InboundAttachment | undefined {
79
+ if (Array.isArray(message.photo) && message.photo.length > 0) {
80
+ const photo = message.photo[message.photo.length - 1];
81
+ if (photo && typeof photo === "object" && "file_id" in photo && typeof photo.file_id === "string") {
82
+ return { fileId: photo.file_id, kind: "photo", mime: "image/jpeg" };
83
+ }
84
+ }
85
+
86
+ for (const kind of ["document", "video", "audio", "voice", "animation"] as const) {
87
+ const file = message[kind];
88
+ if (file && typeof file === "object" && "file_id" in file && typeof file.file_id === "string") {
89
+ return {
90
+ fileId: file.file_id,
91
+ kind,
92
+ mime: "mime_type" in file && typeof file.mime_type === "string" ? file.mime_type : undefined,
93
+ fileName: "file_name" in file && typeof file.file_name === "string" ? file.file_name : undefined,
94
+ };
95
+ }
96
+ }
97
+
98
+ return undefined;
99
+ }
100
+
51
101
  /**
52
102
  * Decide whether an inbound update should inject a user turn. Fail-closed:
53
103
  * returns `ignore` (with a reason) or `duplicate` for anything that is not an
@@ -72,9 +122,15 @@ export function decideThreadedInbound(update: InboundUpdate, ctx: ThreadedInboun
72
122
  const updateId = update.update_id;
73
123
  if (ctx.isDuplicate(updateId)) return { kind: "duplicate", updateId };
74
124
 
75
- const text = typeof message.text === "string" ? message.text.trim() : "";
76
- if (!text) return { kind: "ignore", reason: "empty_text" };
125
+ const text =
126
+ typeof message.text === "string"
127
+ ? message.text.trim()
128
+ : typeof message.caption === "string"
129
+ ? message.caption.trim()
130
+ : "";
131
+ const attachment = extractAttachment(message);
132
+ if (!text && attachment === undefined) return { kind: "ignore", reason: "empty_text" };
77
133
 
78
134
  const messageId = typeof message.message_id === "number" ? message.message_id : undefined;
79
- return { kind: "inject", sessionId, text, updateId, threadId, messageId };
135
+ return { kind: "inject", sessionId, text, updateId, threadId, messageId, attachment };
80
136
  }
@@ -15,15 +15,19 @@ import type { RateLimitLane } from "./rate-limit-pool";
15
15
 
16
16
  /** A Telegram send derived from a threaded frame (topic id is applied by the daemon). */
17
17
  export interface ThreadedSend {
18
- method: "sendMessage" | "sendPhoto";
18
+ method: "sendMessage" | "sendPhoto" | "sendDocument";
19
19
  /** Rate-limit lane for prioritisation/fairness. */
20
20
  lane: RateLimitLane;
21
21
  /** Message text (sendMessage) or photo caption (sendPhoto). */
22
22
  text?: string;
23
23
  /** Base64 image bytes for sendPhoto. */
24
24
  photoBase64?: string;
25
+ /** Base64 file bytes for sendDocument. */
26
+ documentBase64?: string;
25
27
  /** Image MIME type for sendPhoto. */
26
28
  mime?: string;
29
+ /** Suggested document filename. */
30
+ fileName?: string;
27
31
  /** Coalesce key for live edits (same key collapses to the latest). */
28
32
  coalesceKey?: string;
29
33
  /** True for the one-time identity header (the daemon pins it once). */
@@ -49,11 +53,12 @@ interface ThreadedFrame {
49
53
  phase?: unknown;
50
54
  text?: unknown;
51
55
  messageRef?: unknown;
52
- // image_attachment
56
+ // image_attachment / file_attachment
53
57
  source?: unknown;
54
58
  data?: unknown;
55
59
  mime?: unknown;
56
60
  caption?: unknown;
61
+ name?: unknown;
57
62
  // config_update
58
63
  verbosity?: unknown;
59
64
  redact?: unknown;
@@ -141,6 +146,19 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
141
146
  text: finalizeTelegramHtml(caption === undefined ? undefined : escapeHtml(caption)),
142
147
  };
143
148
  }
149
+ case "file_attachment": {
150
+ const data = str(frame.data);
151
+ if (!data) return undefined;
152
+ const caption = str(frame.caption);
153
+ return {
154
+ method: "sendDocument",
155
+ lane: "finalized",
156
+ documentBase64: data,
157
+ mime: str(frame.mime),
158
+ fileName: str(frame.name),
159
+ text: finalizeTelegramHtml(caption === undefined ? undefined : escapeHtml(caption)),
160
+ };
161
+ }
144
162
  case "config_update": {
145
163
  const verbosity = str(frame.verbosity);
146
164
  const redact = typeof frame.redact === "boolean" ? `redact ${frame.redact ? "on" : "off"}` : undefined;