@blunking/codexlink 0.1.16 → 0.1.18

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.
@@ -1,9 +1,9 @@
1
- import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, extname, join } from "node:path";
3
3
  import { listLoadedThreadsOverWs, readThreadOverWs } from "./app-server-client.js";
4
4
  import { loadConfig } from "./env.js";
5
5
  import { injectIntoThread, isAddressOnlyPing } from "./codex.js";
6
- import { getUpdates, sendChatAction, sendMessage } from "./telegram.js";
6
+ import { downloadFileBuffer, getFileInfo, getUpdates, sendChatAction, sendMessage } from "./telegram.js";
7
7
  import { appendJsonl, appendLog, defaultState, loadJson, nowIso, readTail, saveJson } from "./storage.js";
8
8
 
9
9
  function loadState(config) {
@@ -384,6 +384,19 @@ function escapeRegExp(value) {
384
384
  return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
385
385
  }
386
386
 
387
+ function startsWithAgentAddress(normalized, mention) {
388
+ // normalizeTriggerText strips most punctuation, so this covers "Otto?",
389
+ // "Otto:", "/otto", "!otto", "#otto" and "[otto]" as "otto".
390
+ return new RegExp(`^@?${mention}\\b(?:\\s|$)`, "u").test(normalized);
391
+ }
392
+
393
+ function startsWithTeamAddressList(normalized, mention) {
394
+ // Group chats often address multiple agents in one breath: "Angel, Otto ...".
395
+ // Keep this conservative: only treat it as direct if Otto is in the first
396
+ // small address block at the beginning, not if Otto is mentioned later.
397
+ return new RegExp(`^(?:@?[a-z][a-z0-9_-]{1,24}\\b\\s+){1,3}@?${mention}\\b(?:\\s|$)`, "u").test(normalized);
398
+ }
399
+
387
400
  function looksLikeStatusBroadcast(text) {
388
401
  const normalized = foldTriggerText(text);
389
402
  if (/^status(?:\s|$|[~:.-])/u.test(normalized)) {
@@ -408,7 +421,7 @@ function isAgentAddressed(config, text) {
408
421
 
409
422
  for (const name of mentionNames) {
410
423
  const mention = escapeRegExp(name);
411
- const startsAddressed = new RegExp(`^@?${mention}\\b(?:\\s|\\s*[-:,]|$)`, "u").test(normalized);
424
+ const startsAddressed = startsWithAgentAddress(normalized, mention) || startsWithTeamAddressList(normalized, mention);
412
425
  if (startsAddressed) {
413
426
  return true;
414
427
  }
@@ -423,10 +436,15 @@ function isAgentAddressed(config, text) {
423
436
  return true;
424
437
  }
425
438
 
426
- const imperativeAfterMention = new RegExp(`\\b@?${mention}\\b\\s+(?:bitte|please|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|debugge|fix|patch|mach|setz|starte|antwort|melde)\\b`, "u").test(normalized);
439
+ const imperativeAfterMention = new RegExp(`\\b@?${mention}\\b\\s+(?:bitte|please|du|kannst|kann|sollst|soll|bekommst|bekommt|kriegst|erhaeltst|erhaltst|brauchst|brauche|hilf|unterstuetz|unterstuetze|sag|schick|send|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|test|debugge|fix|patch|mach|setz|starte|aktivier|antwort|melde|bescheid)\\b`, "u").test(normalized);
427
440
  if (imperativeAfterMention) {
428
441
  return true;
429
442
  }
443
+
444
+ const workDirective = new RegExp(`\\b@?${mention}\\b[\\s\\S]{0,120}\\b(?:bitte|please|du|kannst|kann|sollst|soll|bekommst|bekommt|kriegst|erhaeltst|erhaltst|brauchst|brauche|hilf|unterstuetz|unterstuetze|sag|schick|send|weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|test|debugge|fix|patch|setz|starte|aktivier|antwort|melde|bescheid|signal|live|stream|chunk|content)\\b|\\b(?:brauchst|brauche|hilf|unterstuetz|unterstuetze|sag|schick|send|weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|test|debugge|fix|patch|setz|starte|aktivier|antwort|melde|bescheid|signal|live|stream|chunk|content)\\b[\\s\\S]{0,120}\\b@?${mention}\\b`, "u").test(normalized);
445
+ if (workDirective) {
446
+ return true;
447
+ }
430
448
  }
431
449
 
432
450
  return false;
@@ -445,7 +463,7 @@ function isOtherAgentAddressed(config, text) {
445
463
 
446
464
  for (const name of mentionNames) {
447
465
  const mention = escapeRegExp(name);
448
- const startsAddressed = new RegExp(`^@?${mention}\\b(?:\\s|\\s*[-:,]|$)`, "u").test(normalized);
466
+ const startsAddressed = startsWithAgentAddress(normalized, mention) || startsWithTeamAddressList(normalized, mention);
449
467
  if (startsAddressed) {
450
468
  return true;
451
469
  }
@@ -460,7 +478,7 @@ function isOtherAgentAddressed(config, text) {
460
478
  return true;
461
479
  }
462
480
 
463
- const workDirective = new RegExp(`\\b@?${mention}\\b[\\s\\S]{0,80}\\b(?:bitte|please|weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|debugge|fix|patch|setz|starte|antwort|melde|uebersetz|ubersetz|translate)\\b|\\b(?:weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|debugge|fix|patch|setz|starte|antwort|melde|uebersetz|ubersetz|translate)\\b[\\s\\S]{0,80}\\b@?${mention}\\b`, "u").test(normalized);
481
+ const workDirective = new RegExp(`\\b@?${mention}\\b[\\s\\S]{0,120}\\b(?:bitte|please|du|kannst|kann|sollst|soll|bekommst|bekommt|kriegst|erhaeltst|erhaltst|brauchst|brauche|hilf|unterstuetz|unterstuetze|sag|schick|send|weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|test|debugge|fix|patch|setz|starte|aktivier|antwort|melde|bescheid|signal|live|stream|chunk|content|uebersetz|ubersetz|translate)\\b|\\b(?:brauchst|brauche|hilf|unterstuetz|unterstuetze|sag|schick|send|weiter|continue|mach|pull|abruf|abrufen|zieh|hol|hole|pruef|pruf|teste|test|debugge|fix|patch|setz|starte|aktivier|antwort|melde|bescheid|signal|live|stream|chunk|content|uebersetz|ubersetz|translate)\\b[\\s\\S]{0,120}\\b@?${mention}\\b`, "u").test(normalized);
464
482
  if (workDirective) {
465
483
  return true;
466
484
  }
@@ -1014,6 +1032,135 @@ function mergeStateSnapshots(currentState, incomingState) {
1014
1032
  return merged;
1015
1033
  }
1016
1034
 
1035
+ const IMAGE_EXTENSIONS = new Set([".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"]);
1036
+
1037
+ function sanitizeAttachmentName(name) {
1038
+ const raw = basename(String(name || "").replace(/\0/g, "")).replace(/\.\./g, "");
1039
+ const cleaned = raw.replace(/[^0-9A-Za-z._-]+/g, "_").replace(/^_+|_+$/g, "");
1040
+ return cleaned || "telegram-file.bin";
1041
+ }
1042
+
1043
+ function safePathPart(value) {
1044
+ return String(value || "")
1045
+ .replace(/[^0-9A-Za-z_-]+/g, "_")
1046
+ .replace(/^_+|_+$/g, "")
1047
+ || "telegram";
1048
+ }
1049
+
1050
+ function isImageMimeOrName(mimeType, name) {
1051
+ const mime = String(mimeType || "").toLowerCase();
1052
+ if (mime.startsWith("image/")) {
1053
+ return true;
1054
+ }
1055
+ return IMAGE_EXTENSIONS.has(extname(String(name || "")).toLowerCase());
1056
+ }
1057
+
1058
+ function pickTelegramAttachment(message) {
1059
+ const messageId = String(message?.message_id || Date.now());
1060
+ const photos = Array.isArray(message?.photo) ? message.photo : [];
1061
+ if (photos.length > 0) {
1062
+ const photo = [...photos].sort((left, right) => Number(left?.file_size || 0) - Number(right?.file_size || 0)).pop();
1063
+ if (photo?.file_id) {
1064
+ return {
1065
+ kind: "photo",
1066
+ fileId: String(photo.file_id),
1067
+ fileUniqueId: String(photo.file_unique_id || ""),
1068
+ originalName: `telegram-photo-${messageId}.jpg`,
1069
+ mimeType: "image/jpeg",
1070
+ sizeBytes: Number(photo.file_size || 0),
1071
+ isImage: true
1072
+ };
1073
+ }
1074
+ }
1075
+
1076
+ const document = message?.document;
1077
+ if (document?.file_id) {
1078
+ const originalName = String(document.file_name || `telegram-document-${messageId}`);
1079
+ const mimeType = String(document.mime_type || "application/octet-stream");
1080
+ return {
1081
+ kind: "document",
1082
+ fileId: String(document.file_id),
1083
+ fileUniqueId: String(document.file_unique_id || ""),
1084
+ originalName,
1085
+ mimeType,
1086
+ sizeBytes: Number(document.file_size || 0),
1087
+ isImage: isImageMimeOrName(mimeType, originalName)
1088
+ };
1089
+ }
1090
+
1091
+ const video = message?.video || message?.animation;
1092
+ if (video?.file_id) {
1093
+ const mimeType = String(video.mime_type || "video/mp4");
1094
+ return {
1095
+ kind: message?.animation ? "animation" : "video",
1096
+ fileId: String(video.file_id),
1097
+ fileUniqueId: String(video.file_unique_id || ""),
1098
+ originalName: String(video.file_name || `telegram-video-${messageId}.mp4`),
1099
+ mimeType,
1100
+ sizeBytes: Number(video.file_size || 0),
1101
+ isImage: false
1102
+ };
1103
+ }
1104
+
1105
+ return null;
1106
+ }
1107
+
1108
+ async function stageTelegramAttachment(config, inbound) {
1109
+ if (!inbound?.attachment?.fileId) {
1110
+ delete inbound.attachment;
1111
+ return;
1112
+ }
1113
+
1114
+ const attachment = inbound.attachment;
1115
+ const maxBytes = Math.max(Number(config.attachmentMaxBytes || 0), 1);
1116
+ try {
1117
+ const file = await getFileInfo(config, attachment.fileId);
1118
+ if (!file?.file_path) {
1119
+ throw new Error("Telegram returned no file path");
1120
+ }
1121
+
1122
+ const fileSize = Number(file.file_size || attachment.sizeBytes || 0);
1123
+ if (fileSize > maxBytes) {
1124
+ throw new Error(`file too large (${Math.round(fileSize / 1024 / 1024)} MB, max ${Math.round(maxBytes / 1024 / 1024)} MB)`);
1125
+ }
1126
+
1127
+ const buffer = await downloadFileBuffer(config, file.file_path);
1128
+ if (buffer.byteLength > maxBytes) {
1129
+ throw new Error(`file too large (${Math.round(buffer.byteLength / 1024 / 1024)} MB, max ${Math.round(maxBytes / 1024 / 1024)} MB)`);
1130
+ }
1131
+
1132
+ const fallbackName = basename(file.file_path) || attachment.originalName || "telegram-file.bin";
1133
+ const originalName = attachment.originalName || fallbackName;
1134
+ const safeName = sanitizeAttachmentName(originalName.includes(".") ? originalName : fallbackName);
1135
+ const dir = join(
1136
+ config.paths.attachmentsDir,
1137
+ `${safePathPart(inbound.chatId)}_${safePathPart(inbound.messageId)}`
1138
+ );
1139
+ mkdirSync(dir, { recursive: true });
1140
+ const localPath = join(dir, safeName);
1141
+ writeFileSync(localPath, buffer);
1142
+
1143
+ inbound.attachments = [{
1144
+ ...attachment,
1145
+ originalName,
1146
+ safeName,
1147
+ sizeBytes: buffer.byteLength,
1148
+ telegramFilePath: file.file_path,
1149
+ localPath,
1150
+ isImage: Boolean(attachment.isImage || isImageMimeOrName(attachment.mimeType, safeName))
1151
+ }];
1152
+ appendLog(config.paths.activityFile, `ATTACHMENT_SAVED chat=${inbound.chatId} message=${inbound.messageId} file=${safeName} bytes=${buffer.byteLength}`);
1153
+ } catch (error) {
1154
+ inbound.attachments = [{
1155
+ ...attachment,
1156
+ error: String(error?.message || error)
1157
+ }];
1158
+ appendLog(config.paths.activityFile, `ATTACHMENT_ERROR chat=${inbound.chatId} message=${inbound.messageId}: ${String(error?.message || error)}`);
1159
+ } finally {
1160
+ delete inbound.attachment;
1161
+ }
1162
+ }
1163
+
1017
1164
  function normalizeInbound(message) {
1018
1165
  const text = message.text ?? message.caption ?? "";
1019
1166
  const chatType = String(message.chat?.type || "unknown");
@@ -1033,6 +1180,7 @@ function normalizeInbound(message) {
1033
1180
  user: message.from?.username || message.from?.first_name || "unknown",
1034
1181
  userId: message.from?.id ? String(message.from.id) : "",
1035
1182
  text,
1183
+ attachment: pickTelegramAttachment(message),
1036
1184
  ts: nowIso(),
1037
1185
  intent: "message",
1038
1186
  relevance: "ambient",
@@ -1321,7 +1469,13 @@ function shouldPublishInboundUiNotice(entry) {
1321
1469
  }
1322
1470
 
1323
1471
  function formatCompactInboundUiNotice(entry) {
1324
- const text = normalizeWhitespace(repairMojibake(entry?.text || "")).slice(0, 180);
1472
+ let text = normalizeWhitespace(repairMojibake(entry?.text || "")).slice(0, 180);
1473
+ if (!text && Array.isArray(entry?.attachments) && entry.attachments.length > 0) {
1474
+ const first = entry.attachments[0];
1475
+ text = first?.isImage ? "sendete einen Screenshot" : `sendete ${first?.originalName || "eine Datei"}`;
1476
+ } else if (!text && entry?.attachment) {
1477
+ text = entry.attachment.isImage ? "sendete einen Screenshot" : `sendete ${entry.attachment.originalName || "eine Datei"}`;
1478
+ }
1325
1479
  if (!text) {
1326
1480
  return "";
1327
1481
  }
@@ -1592,8 +1746,17 @@ function normalizeThreadTimestampMs(value) {
1592
1746
  return numeric > 100000000000 ? numeric : numeric * 1000;
1593
1747
  }
1594
1748
 
1749
+ function parseRuntimeStartedAtMs(runtime) {
1750
+ const parsed = Date.parse(String(runtime?.started_at || ""));
1751
+ return Number.isFinite(parsed) ? parsed : 0;
1752
+ }
1753
+
1595
1754
  function buildHistoryText(message) {
1596
- const text = String(message.text || "").trim();
1755
+ let text = String(message.text || "").trim();
1756
+ if (!text && Array.isArray(message.attachments) && message.attachments.length > 0) {
1757
+ const first = message.attachments[0];
1758
+ text = first?.isImage ? "[Telegram screenshot]" : `[Telegram file: ${first?.originalName || "attachment"}]`;
1759
+ }
1597
1760
  const chatType = String(message.chatType || "");
1598
1761
  if (chatType === "private" || !chatType) {
1599
1762
  return text;
@@ -1702,6 +1865,7 @@ async function resolveActiveThreadId(config, state, preferredThreadId, options =
1702
1865
 
1703
1866
  const runtimeOwner = getRuntimeOwner(config);
1704
1867
  const runtimeThreadId = String(runtimeOwner?.runtime?.thread_id || "").trim();
1868
+ const runtimeStartedAtMs = parseRuntimeStartedAtMs(runtimeOwner?.runtime);
1705
1869
  const pinnedThreadId = String(preferredThreadId || config.currentThreadId || runtimeThreadId || "").trim();
1706
1870
  if (options.forcePreferred && pinnedThreadId && loadedIds.includes(pinnedThreadId)) {
1707
1871
  if (state.currentThreadId !== pinnedThreadId) {
@@ -1732,11 +1896,14 @@ async function resolveActiveThreadId(config, state, preferredThreadId, options =
1732
1896
  const source = String(thread.source || "").toLowerCase();
1733
1897
  const statusType = String(thread.status?.type || "").toLowerCase();
1734
1898
  if (source === "cli" && statusType === "active") {
1735
- score += 1000000000000000;
1899
+ score += 4000000000000000;
1736
1900
  } else if (statusType === "active") {
1737
- score += 900000000000000;
1901
+ score += 3000000000000000;
1738
1902
  } else if (source === "cli") {
1739
- score += 800000000000000;
1903
+ score += 2000000000000000;
1904
+ }
1905
+ if (runtimeStartedAtMs > 0 && createdAtMs >= runtimeStartedAtMs - 120000) {
1906
+ score += 1000000000000000;
1740
1907
  }
1741
1908
  const sessionPath = String(thread.path || "").trim();
1742
1909
  if (sessionPath && existsSync(sessionPath)) {
@@ -1806,7 +1973,7 @@ export function bridgeStatus() {
1806
1973
  lastPollAt: state.lastPollAt,
1807
1974
  lastInjectAt: state.lastInjectAt,
1808
1975
  stateDir: config.paths.root,
1809
- note: "Telegram first lands in queue. Automatic delivery waits for an idle session, skips ambient group noise, and still lets escalations through."
1976
+ note: "Telegram first lands in queue. Direct/private/lane messages are delivered immediately in app-server mode; active turns receive them via turn/steer. Ambient group noise stays parked."
1810
1977
  };
1811
1978
  }
1812
1979
 
@@ -1968,7 +2135,7 @@ export async function pollOnce() {
1968
2135
  appendLog(config.paths.activityFile, `IGNORED_IDLE_BRIEF chat=${inbound.chatId} message=${inbound.messageId}`);
1969
2136
  continue;
1970
2137
  }
1971
- if (!inbound.text.trim()) {
2138
+ if (!inbound.text.trim() && !inbound.attachment) {
1972
2139
  ignored += 1;
1973
2140
  appendLog(config.paths.activityFile, `IGNORED_EMPTY chat=${inbound.chatId} message=${inbound.messageId}`);
1974
2141
  continue;
@@ -1993,6 +2160,7 @@ export async function pollOnce() {
1993
2160
  telegramThreadId: inbound.telegramThreadId
1994
2161
  }).catch(() => {});
1995
2162
  }
2163
+ await stageTelegramAttachment(config, inbound);
1996
2164
  state.queue.push(inbound);
1997
2165
  state.lastInbound = inbound;
1998
2166
  if (shouldPublishInboundUiNotice(inbound)) {
@@ -2091,6 +2259,15 @@ function isFastDispatchEntry(entry) {
2091
2259
  return false;
2092
2260
  }
2093
2261
 
2262
+ function isRealtimeAppServerEntry(entry) {
2263
+ if (!entry) {
2264
+ return false;
2265
+ }
2266
+ const relevance = String(entry.relevance || "").trim().toLowerCase();
2267
+ const chatType = String(entry.chatType || "").trim().toLowerCase();
2268
+ return chatType === "private" || relevance === "direct" || relevance === "lane";
2269
+ }
2270
+
2094
2271
  export async function injectNext(threadId, options = {}) {
2095
2272
  const config = loadConfig();
2096
2273
  const state = loadState(config);
@@ -2188,10 +2365,17 @@ export async function injectNext(threadId, options = {}) {
2188
2365
  saveStateForConfig(config, state);
2189
2366
  }
2190
2367
 
2191
- const bypassDeferredGate = auto && (next.relevance === "escalation" || isFastDispatchEntry(next));
2368
+ const bypassDeferredGate = auto && (
2369
+ next.relevance === "escalation"
2370
+ || isFastDispatchEntry(next)
2371
+ || (useAppServer && isRealtimeAppServerEntry(next))
2372
+ );
2192
2373
  if (bypassDeferredGate && auto && isFastDispatchEntry(next)) {
2193
2374
  appendLog(config.paths.activityFile, `FAST_TRIGGER_BYPASS chat=${next.chatId} message=${next.messageId} intent=${next.intent}`);
2194
2375
  }
2376
+ if (bypassDeferredGate && auto && useAppServer && isRealtimeAppServerEntry(next) && !isFastDispatchEntry(next)) {
2377
+ appendLog(config.paths.activityFile, `REALTIME_BYPASS chat=${next.chatId} message=${next.messageId} relevance=${next.relevance || "-"} chat_type=${next.chatType || "-"}`);
2378
+ }
2195
2379
  if (auto && !bypassDeferredGate && String(config.dispatchMode || "deferred").toLowerCase() !== "legacy") {
2196
2380
  const openPendingReplies = countOpenPendingReplies(state, config);
2197
2381
  if (openPendingReplies > 0) {
@@ -2296,6 +2480,10 @@ export async function injectNext(threadId, options = {}) {
2296
2480
  state.lastAutoDispatchAt = auto ? state.lastInjectAt : state.lastAutoDispatchAt;
2297
2481
  const latestState = loadState(config);
2298
2482
  saveStateForConfig(config, mergeStateSnapshots(latestState, state));
2483
+ const injectPreview = normalizeWhitespace(result.responseText || result.stderr || "").slice(0, 220);
2484
+ if (injectPreview) {
2485
+ appendLog(config.paths.activityFile, `INJECT_RESULT thread=${resolvedThreadId} message=${next.messageId}: ${injectPreview}`);
2486
+ }
2299
2487
  appendLog(config.paths.activityFile, `INJECT_${result.ok ? "OK" : "ERROR"} thread=${resolvedThreadId} message=${next.messageId}`);
2300
2488
  return {
2301
2489
  ok: result.ok,
@@ -1,7 +1,11 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { spawn } from "node:child_process";
3
- import { join } from "node:path";
4
- import { startTextTurnOverWs } from "./app-server-client.js";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { startOrSteerTextTurnOverWs } from "./app-server-client.js";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const runtimeRoot = join(here, "..", "..");
5
9
 
6
10
  function repairMojibake(value) {
7
11
  const input = String(value || "");
@@ -86,6 +90,9 @@ function summarizeBrief(text) {
86
90
  function compactInboundText(message) {
87
91
  const text = String(message.text || "").trim();
88
92
  if (!text) {
93
+ if (Array.isArray(message.attachments) && message.attachments.length > 0) {
94
+ return "hat eine Datei per Telegram gesendet.";
95
+ }
89
96
  return "";
90
97
  }
91
98
  if (/^---\s*BRIEF\b/i.test(text)) {
@@ -94,6 +101,55 @@ function compactInboundText(message) {
94
101
  return text;
95
102
  }
96
103
 
104
+ function formatBytes(bytes) {
105
+ const value = Number(bytes || 0);
106
+ if (!Number.isFinite(value) || value <= 0) {
107
+ return "";
108
+ }
109
+ if (value < 1024) {
110
+ return `${Math.round(value)} B`;
111
+ }
112
+ if (value < 1024 * 1024) {
113
+ return `${(value / 1024).toFixed(1)} KB`;
114
+ }
115
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
116
+ }
117
+
118
+ function formatAttachmentInstructions(message) {
119
+ const attachments = Array.isArray(message.attachments) ? message.attachments : [];
120
+ if (attachments.length === 0) {
121
+ return [];
122
+ }
123
+
124
+ const lines = [
125
+ "",
126
+ "Telegram-Anhang:"
127
+ ];
128
+
129
+ for (const attachment of attachments) {
130
+ if (attachment?.error) {
131
+ lines.push(`- ${attachment.kind || "Datei"} konnte nicht geladen werden: ${attachment.error}`);
132
+ continue;
133
+ }
134
+ const label = attachment.isImage ? "Bild/Screenshot" : (attachment.kind || "Datei");
135
+ const meta = [
136
+ attachment.mimeType,
137
+ formatBytes(attachment.sizeBytes)
138
+ ].filter(Boolean).join(", ");
139
+ const name = attachment.originalName || attachment.safeName || "telegram-file";
140
+ lines.push(`- ${label}: ${name}${meta ? ` (${meta})` : ""}`);
141
+ lines.push(` Lokaler Pfad: ${attachment.localPath}`);
142
+ }
143
+
144
+ if (attachments.some((attachment) => attachment?.isImage && attachment?.localPath)) {
145
+ lines.push("Die Bilddatei wurde als lokaler Bild-Input an diesen Turn angehaengt. Nutze sie direkt fuer Screenshot-/UI-Analyse.");
146
+ } else {
147
+ lines.push("Nutze die lokalen Pfade, wenn du den Inhalt der Datei pruefen oder weiterverarbeiten sollst.");
148
+ }
149
+
150
+ return lines;
151
+ }
152
+
97
153
  function normalizeAddressText(value) {
98
154
  return repairMojibake(String(value || ""))
99
155
  .normalize("NFKC")
@@ -147,6 +203,7 @@ function buildPrompt(config, message) {
147
203
  header.push(label);
148
204
  }
149
205
  header.push(compactText);
206
+ header.push(...formatAttachmentInstructions(message));
150
207
 
151
208
  if (message.intent === "continue_nudge") {
152
209
  header.push(
@@ -165,12 +222,173 @@ function buildPrompt(config, message) {
165
222
  return header.join("\n");
166
223
  }
167
224
 
225
+ function buildVisibleConsoleText(config, message) {
226
+ const compactText = compactInboundText(message);
227
+ const isBriefSummary = compactText.startsWith("Brief von ") || compactText.startsWith("Mnemo Idle");
228
+ const parts = [];
229
+ if (!isBriefSummary) {
230
+ parts.push(compactInboundLabel(message));
231
+ }
232
+ parts.push(compactText);
233
+ parts.push(...formatAttachmentInstructions(message));
234
+ if (message.intent === "continue_nudge") {
235
+ parts.push("Weiter-Signal: Bitte den laufenden Arbeitsfluss fortsetzen und nur antworten, wenn es ein konkretes Ergebnis, einen Blocker oder eine Entscheidung gibt.");
236
+ }
237
+ return parts
238
+ .join("\n")
239
+ .replace(/\s+\n/g, "\n")
240
+ .replace(/\n\s+/g, "\n")
241
+ .trim();
242
+ }
243
+
244
+ function readRuntime(config) {
245
+ try {
246
+ if (!config?.paths?.currentRuntimeFile || !existsSync(config.paths.currentRuntimeFile)) {
247
+ return null;
248
+ }
249
+ return JSON.parse(readFileSync(config.paths.currentRuntimeFile, "utf8"));
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function isPidAlive(pid) {
256
+ const parsed = Number.parseInt(String(pid || "0"), 10);
257
+ if (!parsed || parsed <= 0) {
258
+ return false;
259
+ }
260
+ try {
261
+ process.kill(parsed, 0);
262
+ return true;
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ function getVisibleConsoleSkipReason(config, message) {
269
+ if (process.platform !== "win32") {
270
+ return "not_windows";
271
+ }
272
+ if (!config.appServerWsUrl) {
273
+ return "no_app_server";
274
+ }
275
+ const visibleConsoleMode = String(process.env.BLUN_TELEGRAM_VISIBLE_CONSOLE_INJECT || "0").trim().toLowerCase();
276
+ if (visibleConsoleMode !== "force") {
277
+ return "env_disabled";
278
+ }
279
+ if (Array.isArray(message.attachments) && message.attachments.some((attachment) => attachment?.isImage && attachment?.localPath && !attachment.error)) {
280
+ return "image_attachment";
281
+ }
282
+ return "";
283
+ }
284
+
285
+ function injectVisibleConsole(config, message) {
286
+ const skipReason = getVisibleConsoleSkipReason(config, message);
287
+ if (skipReason) {
288
+ return { ok: false, skipped: true, reason: skipReason };
289
+ }
290
+
291
+ const runtime = readRuntime(config);
292
+ const frontendPid = Number.parseInt(String(runtime?.frontend_host_pid || "0"), 10) || 0;
293
+ if (!isPidAlive(frontendPid)) {
294
+ return { ok: false, skipped: true, reason: "frontend_offline" };
295
+ }
296
+
297
+ const scriptPath = join(runtimeRoot, "telegram-console-input.ps1");
298
+ if (!existsSync(scriptPath)) {
299
+ return { ok: false, skipped: true, reason: "script_missing" };
300
+ }
301
+
302
+ const visibleText = buildVisibleConsoleText(config, message);
303
+ if (!visibleText) {
304
+ return { ok: false, skipped: true, reason: "empty" };
305
+ }
306
+
307
+ const result = spawnSync("powershell.exe", [
308
+ "-NoProfile",
309
+ "-ExecutionPolicy",
310
+ "Bypass",
311
+ "-File",
312
+ scriptPath,
313
+ "-TargetPid",
314
+ String(frontendPid),
315
+ "-Text",
316
+ visibleText,
317
+ "-ClearBefore",
318
+ "-Submit"
319
+ ], {
320
+ cwd: runtimeRoot,
321
+ encoding: "utf8",
322
+ windowsHide: true,
323
+ timeout: 20000
324
+ });
325
+
326
+ if (result.status === 0) {
327
+ return {
328
+ ok: true,
329
+ frontendPid,
330
+ visibleText
331
+ };
332
+ }
333
+
334
+ return {
335
+ ok: false,
336
+ skipped: false,
337
+ reason: "script_failed",
338
+ stderr: String(result.stderr || result.error || "").trim(),
339
+ stdout: String(result.stdout || "").trim()
340
+ };
341
+ }
342
+
343
+ function buildTurnInput(config, message) {
344
+ const prompt = buildPrompt(config, message);
345
+ const input = [
346
+ {
347
+ type: "text",
348
+ text: prompt,
349
+ text_elements: []
350
+ }
351
+ ];
352
+
353
+ const attachments = Array.isArray(message.attachments) ? message.attachments : [];
354
+ for (const attachment of attachments) {
355
+ if (!attachment?.isImage || !attachment.localPath || attachment.error) {
356
+ continue;
357
+ }
358
+ input.push({
359
+ type: "localImage",
360
+ path: attachment.localPath
361
+ });
362
+ }
363
+
364
+ return {
365
+ prompt,
366
+ input
367
+ };
368
+ }
369
+
168
370
  export async function injectIntoThread(config, message, threadId) {
371
+ const turnInput = buildTurnInput(config, message);
169
372
  if (config.appServerWsUrl) {
170
- const result = await startTextTurnOverWs({
373
+ const consoleResult = injectVisibleConsole(config, message);
374
+ if (consoleResult.ok) {
375
+ return {
376
+ ok: true,
377
+ busy: false,
378
+ turnId: "",
379
+ code: 0,
380
+ signal: null,
381
+ responseText: `console_injected thread=${threadId} frontend_pid=${consoleResult.frontendPid}`,
382
+ stdout: "",
383
+ stderr: ""
384
+ };
385
+ }
386
+
387
+ const result = await startOrSteerTextTurnOverWs({
171
388
  wsUrl: config.appServerWsUrl,
172
389
  threadId,
173
- text: buildPrompt(config, message),
390
+ text: turnInput.prompt,
391
+ input: turnInput.input,
174
392
  model: config.model || null,
175
393
  effort: config.reasoningEffort || null,
176
394
  personality: config.personality || null,
@@ -183,7 +401,9 @@ export async function injectIntoThread(config, message, threadId) {
183
401
  turnId: result.turnId || "",
184
402
  code: result.ok ? 0 : null,
185
403
  signal: null,
186
- responseText: result.ok ? `turn_started thread=${threadId}` : "",
404
+ responseText: result.ok
405
+ ? `${result.steered ? "turn_steered" : "turn_started"} thread=${threadId} console_${consoleResult.skipped ? "skip" : "fail"}=${consoleResult.reason || "unknown"}`
406
+ : "",
187
407
  stdout: "",
188
408
  stderr: result.error ? String(result.error.message || result.error) : ""
189
409
  };
@@ -192,7 +412,7 @@ export async function injectIntoThread(config, message, threadId) {
192
412
  const safeKey = `${message.chatId}_${message.messageId}`.replace(/[^0-9A-Za-z_-]/g, "_");
193
413
  const promptFile = `${config.paths.promptsDir}\\${safeKey}.md`;
194
414
  const responseFile = `${config.paths.responsesDir}\\${safeKey}.txt`;
195
- writeFileSync(promptFile, buildPrompt(config, message), "utf8");
415
+ writeFileSync(promptFile, turnInput.prompt, "utf8");
196
416
 
197
417
  return await new Promise((resolve) => {
198
418
  let timedOut = false;
@@ -75,6 +75,7 @@ export function loadConfig() {
75
75
  idleCooldownMs: Number.parseInt(env.BLUN_TELEGRAM_IDLE_COOLDOWN_MS || "3000", 10) || 3000,
76
76
  ambientQueueTtlMs: Number.parseInt(env.BLUN_TELEGRAM_AMBIENT_QUEUE_TTL_MS || "600000", 10) || 600000,
77
77
  pendingReplyTimeoutMs: Number.parseInt(env.BLUN_TELEGRAM_PENDING_REPLY_TIMEOUT_MS || "1800000", 10) || 1800000,
78
+ attachmentMaxBytes: Number.parseInt(env.BLUN_TELEGRAM_ATTACHMENT_MAX_BYTES || "52428800", 10) || 52428800,
78
79
  progressFallbackMs: Number.parseInt(env.BLUN_TELEGRAM_PROGRESS_FALLBACK_MS || "20000", 10) || 20000,
79
80
  progressRelayMode: env.BLUN_TELEGRAM_PROGRESS_RELAY?.trim().toLowerCase() || "status",
80
81
  queueNoticeEnabled: /^(1|true|yes|on)$/i.test(env.BLUN_TELEGRAM_QUEUE_NOTICE || ""),
@@ -25,6 +25,7 @@ export function getPaths() {
25
25
  inboxFile: join(root, "inbox.jsonl"),
26
26
  outboxFile: join(root, "outbox.jsonl"),
27
27
  activityFile: join(root, "activity.log"),
28
+ attachmentsDir: join(root, "attachments"),
28
29
  pollerPidFile: join(root, "poller.pid"),
29
30
  dispatcherPidFile: join(root, "dispatcher.pid"),
30
31
  responderPidFile: join(root, "responder.pid"),
@@ -42,7 +43,7 @@ export function getPaths() {
42
43
 
43
44
  export function ensureStateLayout() {
44
45
  const paths = getPaths();
45
- for (const dir of [paths.root, paths.promptsDir, paths.responsesDir, paths.runtimeDir]) {
46
+ for (const dir of [paths.root, paths.promptsDir, paths.responsesDir, paths.runtimeDir, paths.attachmentsDir]) {
46
47
  if (!existsSync(dir)) {
47
48
  mkdirSync(dir, { recursive: true });
48
49
  }
@@ -27,6 +27,21 @@ export async function getUpdates(config, offset) {
27
27
  });
28
28
  }
29
29
 
30
+ export async function getFileInfo(config, fileId) {
31
+ return telegramRequest(config, "getFile", {
32
+ file_id: fileId
33
+ });
34
+ }
35
+
36
+ export async function downloadFileBuffer(config, filePath) {
37
+ requireToken(config);
38
+ const response = await fetch(`https://api.telegram.org/file/bot${config.botToken}/${filePath}`);
39
+ if (!response.ok) {
40
+ throw new Error(`Telegram file download failed: ${response.status}`);
41
+ }
42
+ return Buffer.from(await response.arrayBuffer());
43
+ }
44
+
30
45
  export async function sendMessage(config, { chatId, text, replyToMessageId, telegramThreadId }) {
31
46
  return telegramRequest(config, "sendMessage", {
32
47
  chat_id: chatId,