@agentprojectcontext/apx 1.78.0 → 1.80.0

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 (97) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +46 -6
  3. package/src/core/agent/super-agent.js +3 -0
  4. package/src/core/agent/tool-summary.js +65 -0
  5. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  6. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  7. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  8. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  9. package/src/core/agent/tools/names.js +6 -0
  10. package/src/core/agent/tools/registry.js +9 -0
  11. package/src/core/agent/tools/tool-call-parser.js +123 -6
  12. package/src/core/channels/telegram/ask-callbacks.js +121 -6
  13. package/src/core/channels/telegram/dispatch.js +21 -4
  14. package/src/core/channels/telegram/inbound/file.js +108 -0
  15. package/src/core/channels/telegram/inbound/photo.js +42 -11
  16. package/src/core/channels/telegram/media.js +31 -3
  17. package/src/core/channels/telegram/reply.js +32 -5
  18. package/src/core/config/paths.js +3 -0
  19. package/src/core/config/redact.js +22 -0
  20. package/src/core/daemon/service.js +238 -0
  21. package/src/core/engines/gemini.js +343 -63
  22. package/src/core/engines/openai-compatible.js +21 -2
  23. package/src/core/memory/consolidate.js +225 -0
  24. package/src/core/nudge/index.js +192 -0
  25. package/src/core/nudge/policy.js +143 -0
  26. package/src/core/nudge/store.js +141 -0
  27. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  28. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  29. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  30. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  31. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  32. package/src/core/routines/runner.js +102 -3
  33. package/src/core/routines/signals.js +270 -0
  34. package/src/core/stores/commitments.js +331 -0
  35. package/src/core/stores/messages.js +10 -1
  36. package/src/core/stores/routines.js +17 -3
  37. package/src/core/util/thinking.js +51 -0
  38. package/src/host/daemon/api/commitments.js +135 -0
  39. package/src/host/daemon/api/nudges.js +112 -0
  40. package/src/host/daemon/api/routines.js +24 -0
  41. package/src/host/daemon/api/self-memory.js +50 -0
  42. package/src/host/daemon/api/telegram.js +42 -4
  43. package/src/host/daemon/api/voice.js +3 -1
  44. package/src/host/daemon/api.js +6 -0
  45. package/src/host/daemon/callback-reconciler.js +16 -0
  46. package/src/host/daemon/plugins/desktop/index.js +7 -1
  47. package/src/host/daemon/plugins/telegram/index.js +7 -2
  48. package/src/host/daemon/wakeup.js +17 -3
  49. package/src/interfaces/cli/commands/commitment.js +154 -0
  50. package/src/interfaces/cli/commands/daemon.js +57 -0
  51. package/src/interfaces/cli/commands/memory.js +73 -0
  52. package/src/interfaces/cli/commands/nudge.js +130 -0
  53. package/src/interfaces/cli/help/index.js +2 -2
  54. package/src/interfaces/cli/routes/commitment.js +19 -0
  55. package/src/interfaces/cli/routes/daemon.js +7 -1
  56. package/src/interfaces/cli/routes/index.js +4 -0
  57. package/src/interfaces/cli/routes/memory.js +10 -2
  58. package/src/interfaces/cli/routes/nudge.js +17 -0
  59. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  60. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  61. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  62. package/src/interfaces/web/dist/index.html +2 -2
  63. package/src/interfaces/web/package-lock.json +11 -10
  64. package/src/interfaces/web/src/components/Section.tsx +18 -3
  65. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  66. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  67. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  68. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  69. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  70. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  71. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  72. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  73. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  74. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  75. package/src/interfaces/web/src/components/ui.tsx +1 -0
  76. package/src/interfaces/web/src/constants/index.ts +1 -0
  77. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  78. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  79. package/src/interfaces/web/src/i18n/en.ts +127 -0
  80. package/src/interfaces/web/src/i18n/es.ts +127 -0
  81. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  82. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  83. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  84. package/src/interfaces/web/src/lib/cron.ts +196 -0
  85. package/src/interfaces/web/src/lib/when.ts +32 -0
  86. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  87. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  88. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  89. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  90. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  91. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  92. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  93. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  94. package/src/interfaces/web/src/types/daemon.ts +10 -1
  95. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  96. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  97. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
@@ -64,6 +64,15 @@ export function extractPseudoToolCalls(text) {
64
64
  // run-agent loop then treats them identically.
65
65
  const llamaCalls = extractLlamaDottedFunctionCalls(text);
66
66
 
67
+ // `[tool call: NAME] {…json…}` — APX's own internal transcription of a tool
68
+ // call, which used to be written into Gemini history when a turn had no
69
+ // thought signature to replay. Models copied the format out of their own
70
+ // history and started writing calls instead of making them, and the line
71
+ // was delivered to the user verbatim. The history no longer contains it,
72
+ // but a model that already learned the shape (or any model that invents it)
73
+ // must have the call EXECUTED rather than printed.
74
+ const bracketCalls = extractBracketToolCalls(text);
75
+
67
76
  // Second pass: balanced `{name, arguments}` JSON anywhere in the text.
68
77
  const jsonCalls = [];
69
78
  for (let i = 0; i < text.length; i++) {
@@ -86,11 +95,11 @@ export function extractPseudoToolCalls(text) {
86
95
  parsed.arguments !== null &&
87
96
  !Array.isArray(parsed.arguments)
88
97
  ) {
89
- // Skip JSON that is actually the args object inside a dotted-function
90
- // wrapper we already captured — otherwise we'd double-fire the tool.
91
- const insideLlamaWrap = llamaCalls.some(
92
- (lc) => lc._rawStart <= i && balanced.end <= lc._rawEnd
93
- );
98
+ // Skip JSON that is actually the args object inside a wrapper we already
99
+ // captured — otherwise we'd double-fire the tool.
100
+ const insideLlamaWrap =
101
+ llamaCalls.some((lc) => lc._rawStart <= i && balanced.end <= lc._rawEnd) ||
102
+ bracketCalls.some((bc) => bc._rawStart <= i && balanced.end <= bc._rawEnd);
94
103
  if (insideLlamaWrap) {
95
104
  i = balanced.end - 1;
96
105
  continue;
@@ -108,10 +117,45 @@ export function extractPseudoToolCalls(text) {
108
117
  // Strip internal markers used to dedupe against JSON pass.
109
118
  return [
110
119
  ...llamaCalls.map(({ _rawStart, _rawEnd, ...rest }) => rest),
120
+ ...bracketCalls.map(({ _rawStart, _rawEnd, ...rest }) => rest),
111
121
  ...jsonCalls,
112
122
  ];
113
123
  }
114
124
 
125
+ // Parse `[tool call: NAME] {…}` — see the note in extractPseudoToolCalls.
126
+ // `_raw` spans the bracket AND its argument object so the whole line is
127
+ // removed from the visible text, not just the JSON half.
128
+ function extractBracketToolCalls(text) {
129
+ const out = [];
130
+ const re = /\[tool[ _]call:\s*([a-zA-Z_][a-zA-Z0-9_]*)\]\s*/g;
131
+ let m;
132
+ while ((m = re.exec(text)) !== null) {
133
+ const argsStart = m.index + m[0].length;
134
+ let args = {};
135
+ let end = argsStart;
136
+ if (text[argsStart] === "{") {
137
+ const balanced = readBalancedJson(text, argsStart);
138
+ if (balanced.ok) {
139
+ try {
140
+ const parsed = JSON.parse(text.slice(argsStart, balanced.end));
141
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed;
142
+ } catch { /* keep {} — a malformed arg blob still beats printing it */ }
143
+ end = balanced.end;
144
+ }
145
+ }
146
+ out.push({
147
+ id: nextId(),
148
+ function: { name: m[1], arguments: args },
149
+ _pseudo: true,
150
+ _raw: text.slice(m.index, end),
151
+ _rawStart: m.index,
152
+ _rawEnd: end,
153
+ });
154
+ re.lastIndex = end;
155
+ }
156
+ return out;
157
+ }
158
+
115
159
  // Parse the dotted-function format emitted by some Llama instructions:
116
160
  //
117
161
  // <function.send_telegram({"text": "hi"})</function>
@@ -162,7 +206,70 @@ function extractLlamaDottedFunctionCalls(text) {
162
206
  // trivial wrappers (<tool_call>, ```tool_use, _icall(), etc.) that often sit
163
207
  // around them. Used to clean up final answers that the model emitted with
164
208
  // leftover textual tool-call gunk.
165
- export function cleanTextOfPseudoToolCalls(text) {
209
+ /**
210
+ * The bare `tool_name({...json...})` form.
211
+ *
212
+ * WHERE THIS COMES FROM, and why it is not hypothetical: APX renders past tool
213
+ * results into model context as `[tool result: <name>] <body>`
214
+ * (stores/messages.js). A weaker model reads that pattern in its own history
215
+ * and imitates it in PROSE — gemini-3.5-flash produced
216
+ *
217
+ * [tool result: create_task] create_task({"project":"apx","title":"…"})
218
+ *
219
+ * and then told the user the task was filed. It was not. That is the worst
220
+ * failure mode available: a confident false confirmation, with nothing on disk.
221
+ *
222
+ * GATED ON KNOWN TOOL NAMES, deliberately. The other two passes key off
223
+ * unambiguous markers (`<function.` or a `{name, arguments}` pair); a bare
224
+ * `foo({...})` is ordinary prose about code. Without the allow-list, a model
225
+ * EXPLAINING `create_task({...})` in an answer would silently create a task.
226
+ * So: no known names, no matches.
227
+ *
228
+ * @param {string} text
229
+ * @param {Iterable<string>} knownNames tool names callable on this turn
230
+ */
231
+ export function extractBareFunctionCalls(text, knownNames) {
232
+ const allowed = new Set(knownNames || []);
233
+ if (!text || typeof text !== "string" || allowed.size === 0) return [];
234
+
235
+ const out = [];
236
+ // `name(` where name is a plausible identifier. The `[tool result: x] `
237
+ // prefix the model copies is left for the cleaner to strip.
238
+ const re = /(^|[^A-Za-z0-9_.])([a-z][a-z0-9_]{2,63})\s*\(\s*(?=\{)/g;
239
+ let m;
240
+ while ((m = re.exec(text)) !== null) {
241
+ const name = m[2];
242
+ if (!allowed.has(name)) continue;
243
+ // The '{' the regex already looked ahead to.
244
+ const i = text.indexOf("{", m.index + m[0].length - 1);
245
+ if (i < 0) continue;
246
+ const balanced = readBalancedJson(text, i);
247
+ if (!balanced.ok) continue;
248
+ let args;
249
+ try {
250
+ args = JSON.parse(text.slice(i, balanced.end));
251
+ } catch {
252
+ continue;
253
+ }
254
+ if (!args || typeof args !== "object" || Array.isArray(args)) continue;
255
+
256
+ // Include the closing paren in the raw span when it is there, so the
257
+ // cleaner removes the whole call rather than leaving a dangling `)`.
258
+ let end = balanced.end;
259
+ const after = text.slice(end).match(/^\s*\)/);
260
+ if (after) end += after[0].length;
261
+
262
+ const rawStart = m.index + (m[1] ? m[1].length : 0);
263
+ out.push({
264
+ id: nextId(),
265
+ type: "function",
266
+ function: { name, arguments: JSON.stringify(args) },
267
+ _raw: text.slice(rawStart, end),
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+ export function cleanTextOfPseudoToolCalls(text, knownNames) {
166
273
  if (!text || typeof text !== "string") return text;
167
274
 
168
275
  // Strip explicit XML-like fences first
@@ -179,6 +286,16 @@ export function cleanTextOfPseudoToolCalls(text) {
179
286
  for (const call of extractPseudoToolCalls(out)) {
180
287
  if (call._raw) out = out.replace(call._raw, "");
181
288
  }
289
+ // The bare `name({...})` form, and the `[tool result: name]` prefix the
290
+ // model copies out of its own transcript.
291
+ for (const call of extractBareFunctionCalls(out, knownNames)) {
292
+ if (call._raw) out = out.replace(call._raw, "");
293
+ }
294
+ out = out.replace(/\[tool result:\s*[^\]]+\]\s*/gi, "");
295
+ // …and the history annotation the message store substitutes for a stale
296
+ // answer (`sanitizeAssistantForContext`). It describes a turn; it is never
297
+ // something to say to the user.
298
+ out = out.replace(/\[omitted:[^\]]*\]\s*/gi, "");
182
299
  // Some models emit a stray `</function>` after the args without the
183
300
  // opening tag — sweep those too.
184
301
  out = out.replace(/<\/?function(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?>/gi, "");
@@ -12,11 +12,35 @@ import { getConfirmationStore as getConfirmStore } from "#core/confirmation/pend
12
12
  import { getRecentTelegramTurnsFromFs, appendGlobalMessage } from "#core/stores/messages.js";
13
13
  import { CHANNELS } from "#core/constants/channels.js";
14
14
  import { SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
15
+ import { applyNudgeCallback } from "#core/nudge/index.js";
16
+
17
+ /**
18
+ * The label the user actually tapped, recovered from the keyboard attached to
19
+ * the message. `callback_data` is a routing slug ("mover_workspace_hoy"); the
20
+ * label is what the human read ("Mover al workspace de hoy"), and that is the
21
+ * better thing to hand an agent.
22
+ */
23
+ export function buttonLabelFor(callbackQuery) {
24
+ const rows = callbackQuery?.message?.reply_markup?.inline_keyboard || [];
25
+ const data = callbackQuery?.data;
26
+ for (const row of rows) {
27
+ for (const btn of row || []) {
28
+ if (btn?.callback_data === data && btn?.text) return String(btn.text);
29
+ }
30
+ }
31
+ return "";
32
+ }
15
33
 
16
34
  /**
17
35
  * Route an inbound callback_query. ask_questions button presses are handled
18
- * here; everything else falls through to the confirmation adapter. Both use
19
- * `apx:<verb>:...` namespacing but the ask flow owns its own state.
36
+ * here; `apx:`-namespaced presses belong to an APX flow (ask / nudge /
37
+ * confirmation) and never reach the agent. Anything else is a button someone
38
+ * else put in the chat, and is treated as a user turn.
39
+ *
40
+ * TELEGRAM CONTRACT: every callback_query must be answered, handled or not.
41
+ * Until it is, the client keeps the button spinning and the tap looks dead —
42
+ * which is exactly how "the inline buttons do nothing" is reported. Nothing
43
+ * below may return without an `_answerCallback`.
20
44
  */
21
45
  export async function handleCallbackQuery(self, callbackQuery) {
22
46
  const data = callbackQuery.data || "";
@@ -24,15 +48,90 @@ export async function handleCallbackQuery(self, callbackQuery) {
24
48
  await handleAskCallback(self, callbackQuery);
25
49
  return;
26
50
  }
51
+ if (data.startsWith("apx:nudge:")) {
52
+ await handleNudgeCallback(self, callbackQuery);
53
+ return;
54
+ }
27
55
  const adapter = createTelegramConfirmAdapter({
28
56
  token: resolveBotToken(self.channel),
29
57
  chatId: callbackQuery.message?.chat?.id,
30
58
  pendingStore: getConfirmStore(),
31
59
  });
32
60
  const handled = await adapter.handleCallbackQuery(callbackQuery);
33
- if (!handled) {
34
- self.log(`telegram[${self.channel.name}] unhandled callback_query: ${callbackQuery.data}`);
61
+ if (handled) return;
62
+
63
+ // `apx:noop` is a deliberately dead button (a disabled confirmation, an
64
+ // expired panel). Ack it so the spinner clears and stop there — replaying it
65
+ // to the agent would answer a question that is already closed.
66
+ if (data === "apx:noop" || data.startsWith("apx:")) {
67
+ await self._answerCallback({ callback_query_id: callbackQuery.id });
68
+ self.log(`telegram[${self.channel.name}] stale apx callback: ${data}`);
69
+ await clearKeyboard(self, callbackQuery);
70
+ return;
71
+ }
72
+
73
+ // A button APX did not send — an agent-authored keyboard, another tool
74
+ // posting into this chat. Ack first (never leave it spinning), then let the
75
+ // press be a turn: re-enter the normal inbound path with the button's label
76
+ // as the text, so identity, routing and the agent loop all apply unchanged.
77
+ await self._answerCallback({ callback_query_id: callbackQuery.id });
78
+ const text = buttonLabelFor(callbackQuery) || data;
79
+ const chat = callbackQuery.message?.chat;
80
+ if (!chat?.id || !text) {
81
+ self.log(`telegram[${self.channel.name}] unhandled callback_query: ${data}`);
82
+ return;
35
83
  }
84
+ self.log(`telegram[${self.channel.name}] button press → turn: ${data} (${text})`);
85
+ await self._handleUpdate({
86
+ update_id: callbackQuery.id,
87
+ message: {
88
+ message_id: callbackQuery.message?.message_id,
89
+ from: callbackQuery.from,
90
+ chat,
91
+ date: Math.floor(Date.now() / 1000),
92
+ text,
93
+ },
94
+ });
95
+ }
96
+
97
+ /** Best-effort: take the keyboard off a message whose buttons are now dead. */
98
+ async function clearKeyboard(self, callbackQuery) {
99
+ const chatId = callbackQuery.message?.chat?.id;
100
+ if (!chatId) return;
101
+ try {
102
+ await self._editKeyboard({
103
+ chat_id: chatId,
104
+ message_id: callbackQuery.message?.message_id,
105
+ reply_markup: { inline_keyboard: [] },
106
+ });
107
+ } catch { /* best-effort */ }
108
+ }
109
+
110
+ /**
111
+ * "Was that worth interrupting you for?" — the feedback loop on proactive
112
+ * pushes (core/nudge). One tap, no reply, and the keyboard disappears so the
113
+ * chat does not accumulate stale buttons. Never re-enters the super-agent: an
114
+ * opinion about a message is not a new turn to answer.
115
+ */
116
+ export async function handleNudgeCallback(self, callbackQuery) {
117
+ const chatId = callbackQuery.message?.chat?.id;
118
+ const result = applyNudgeCallback(callbackQuery.data || "");
119
+ await self._answerCallback({
120
+ callback_query_id: callbackQuery.id,
121
+ text: result?.ack || "",
122
+ });
123
+ if (!result || !chatId) return;
124
+ try {
125
+ await self._editKeyboard({
126
+ chat_id: chatId,
127
+ message_id: callbackQuery.message?.message_id,
128
+ reply_markup: { inline_keyboard: [] },
129
+ });
130
+ } catch { /* best-effort */ }
131
+ self.log(
132
+ `telegram[${self.channel.name}] nudge feedback: ${result.entry?.kind || "?"} → ` +
133
+ `${result.entry?.feedback?.useful ? "useful" : "noise"}`
134
+ );
36
135
  }
37
136
 
38
137
  /**
@@ -79,9 +178,22 @@ export async function handleAskCallback(self, callbackQuery) {
79
178
  const chatId = callbackQuery.message?.chat?.id;
80
179
  if (!chatId) return;
81
180
  const result = askFlow.applyCallback(chatId, callbackQuery.data || "");
82
- // Ack the press regardless — keeps the spinner from hanging client-side.
181
+ if (!result) {
182
+ // The flow is gone: ask state is process-local (see ask.js), so a daemon
183
+ // restart or the 30-min TTL kills it while its keyboard stays in the chat
184
+ // looking live. Acking silently is what makes the button read as broken —
185
+ // the tap "does nothing" and the user keeps tapping. Say so, and take the
186
+ // dead keyboard away so the message stops offering a choice it can't take.
187
+ await self._answerCallback({
188
+ callback_query_id: callbackQuery.id,
189
+ text: "Esa consulta ya expiró — escribime de nuevo y la retomamos.",
190
+ });
191
+ self.log(`telegram[${self.channel.name}] stale ask callback: ${callbackQuery.data}`);
192
+ await clearKeyboard(self, callbackQuery);
193
+ return;
194
+ }
195
+ // Ack the press — keeps the spinner from hanging client-side.
83
196
  await self._answerCallback({ callback_query_id: callbackQuery.id });
84
- if (!result) return; // stale or unknown — adapter already ack'd.
85
197
 
86
198
  if (result.action === "redraw") {
87
199
  // Multi-select toggle: refresh the keyboard on the SAME message.
@@ -182,6 +294,7 @@ export async function runResumedTurn(self, ctx) {
182
294
  let replyAuthor;
183
295
  let saUsage = null;
184
296
  let saModel = null;
297
+ let saTrace = null;
185
298
  try {
186
299
  const sa = await runTelegramSuperAgent(self, {
187
300
  chat_id,
@@ -218,6 +331,7 @@ export async function runResumedTurn(self, ctx) {
218
331
  replyAuthor = sa.name || agentDisplay;
219
332
  saUsage = sa.usage;
220
333
  saModel = sa.model || state.model || null;
334
+ saTrace = sa.trace || null;
221
335
  } catch (e) {
222
336
  self.log(`telegram[${self.channel.name}] ask resume failed: ${e.message}`);
223
337
  replyText = telegramErrorText(self, e);
@@ -235,6 +349,7 @@ export async function runResumedTurn(self, ctx) {
235
349
  replyKind: "superagent",
236
350
  saUsage,
237
351
  saModel,
352
+ saTrace,
238
353
  streamedCount: state.streamedCount,
239
354
  lastStreamedText: state.lastStreamedText,
240
355
  agentDisplay,
@@ -26,6 +26,7 @@ import * as askFlow from "./ask.js";
26
26
  import { telegramAuthorLabel } from "./helpers.js";
27
27
  import { handleIncomingPhoto } from "./inbound/photo.js";
28
28
  import { handleIncomingAudio } from "./inbound/audio.js";
29
+ import { handleIncomingFile, detectIncomingFile } from "./inbound/file.js";
29
30
  import { buildStreamHandler, runTelegramSuperAgent, telegramErrorText, sendFinalReply, runFollowupTurn } from "./reply.js";
30
31
  import { t, resolveLang } from "#core/i18n/index.js";
31
32
 
@@ -87,16 +88,28 @@ export async function handleUpdate(self, u) {
87
88
  // ── Incoming media ────────────────────────────────────────────────────
88
89
  // Photo and voice/audio each download + archive the file and rewrite `text`
89
90
  // so the rest of the pipeline treats them like a typed message. The handlers
90
- // live in ./inbound/ to keep this dispatcher focused on routing. Photos have
91
- // no vision yet, so the handler injects an `[image]` marker (never silent);
92
- // audio injects its `[audio]` transcript.
91
+ // live in ./inbound/ to keep this dispatcher focused on routing. Each one
92
+ // injects a marker so a caption-less attachment is never an empty turn:
93
+ // photos an `[image]` marker (plus the pixels, as an attachment, for a
94
+ // multimodal engine), audio its `[audio]` transcript, files a description
95
+ // with the local path.
96
+ const attachments = [];
93
97
  if (msg.photo && msg.photo.length > 0) {
94
- ({ text } = await handleIncomingPhoto(self, { msg, u, author, chat_id, text }));
98
+ let attachment;
99
+ ({ text, attachment } = await handleIncomingPhoto(self, { msg, u, author, chat_id, text }));
100
+ if (attachment) attachments.push(attachment);
95
101
  }
96
102
  const incomingAudio = msg.voice || msg.audio;
97
103
  if (incomingAudio && incomingAudio.file_id) {
98
104
  ({ text } = await handleIncomingAudio(self, { msg, u, author, chat_id, text, incomingAudio }));
99
105
  }
106
+ // Documents, video, video notes and GIFs. Without this a file sent with no
107
+ // caption left `text` empty, the turn was dropped, and the bot answered
108
+ // nothing at all.
109
+ const incomingFile = detectIncomingFile(msg);
110
+ if (incomingFile) {
111
+ ({ text } = await handleIncomingFile(self, { msg, u, author, chat_id, text, incoming: incomingFile }));
112
+ }
100
113
 
101
114
  // If there's a pending ask_questions flow for this chat AND the current
102
115
  // question is free-text, treat this message as the answer rather than a
@@ -228,6 +241,7 @@ export async function handleUpdate(self, u) {
228
241
  let replyKind = "superagent"; // actor_kind: superagent | agent
229
242
  let replyModel = null; // model that actually produced the reply
230
243
  let replyUsage = null; // token accounting for this turn
244
+ let replyTrace = null; // what the turn actually did (summarised on the message)
231
245
  const projectCfg = target.config || self.globalConfig;
232
246
  // Display name for the super-agent persona on this channel (from identity.json).
233
247
  const agentDisplay = resolveAgentName(self.globalConfig);
@@ -318,6 +332,7 @@ export async function handleUpdate(self, u) {
318
332
  try {
319
333
  const sa = await runTelegramSuperAgent(self, {
320
334
  chat_id,
335
+ attachments,
321
336
  prompt: slashed.handled ? slashed.prompt : text,
322
337
  previousMessages,
323
338
  target,
@@ -335,6 +350,7 @@ export async function handleUpdate(self, u) {
335
350
  replyActorId = SUPERAGENT_ACTOR_ID;
336
351
  replyKind = "superagent";
337
352
  replyUsage = sa.usage;
353
+ replyTrace = sa.trace || null;
338
354
  replyModel = sa.model || state.model || null;
339
355
 
340
356
  // ── ask_questions integration ────────────────────────────────────
@@ -397,6 +413,7 @@ export async function handleUpdate(self, u) {
397
413
  replyKind,
398
414
  saUsage: replyUsage,
399
415
  saModel: replyModel,
416
+ saTrace: replyTrace,
400
417
  streamedCount,
401
418
  lastStreamedText,
402
419
  agentDisplay,
@@ -0,0 +1,108 @@
1
+ // Inbound Telegram FILES: document, video, video_note, animation.
2
+ //
3
+ // Same shape as ./photo.js and ./audio.js — take the poller (`self`) plus the
4
+ // parsed update, download and archive the file, and return the (rewritten)
5
+ // `text` the rest of the pipeline runs with.
6
+ //
7
+ // Why this exists: dispatch only recognised photo and voice/audio. Every other
8
+ // attachment fell through to `text = msg.caption || ""`, and a file sent with
9
+ // no caption produced an EMPTY text — so the turn was dropped and the bot said
10
+ // nothing at all. A user who sends a file and gets silence cannot tell the
11
+ // difference between "not supported" and "broken".
12
+ //
13
+ // The reply is model-authored, as everywhere else: the marker states what
14
+ // arrived and where it landed, and the agent puts that in its own words. No
15
+ // canned "file received" string.
16
+ import { appendGlobalMessage } from "#core/stores/messages.js";
17
+ import { CHANNELS } from "#core/constants/channels.js";
18
+ import { resolveBotToken, telegramMediaDir } from "../helpers.js";
19
+ import { downloadTelegramFile } from "../media.js";
20
+
21
+ /**
22
+ * The attachment kinds handled here, in the order Telegram nests them. A
23
+ * `video_note` (the round selfie clip) has no file_name; a `document` usually
24
+ * does and it is the one worth preserving.
25
+ */
26
+ const FILE_KINDS = [
27
+ { key: "document", label: "document", type: "document" },
28
+ { key: "video", label: "video", type: "video" },
29
+ { key: "video_note", label: "video note", type: "video" },
30
+ { key: "animation", label: "animation (GIF)", type: "animation" },
31
+ ];
32
+
33
+ /** The first file-like attachment on a message, or null. */
34
+ export function detectIncomingFile(msg) {
35
+ for (const kind of FILE_KINDS) {
36
+ const file = msg?.[kind.key];
37
+ if (file?.file_id) return { ...kind, file };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function humanSize(bytes) {
43
+ const n = Number(bytes);
44
+ if (!Number.isFinite(n) || n <= 0) return "";
45
+ if (n < 1024) return `${n} B`;
46
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
47
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
48
+ }
49
+
50
+ /**
51
+ * @param {object} self poller instance (uses self.log, self.channel)
52
+ * @param {object} ctx { msg, u, author, chat_id, text, incoming }
53
+ * @returns {Promise<{ text: string }>}
54
+ */
55
+ export async function handleIncomingFile(self, { msg, u, author, chat_id, text, incoming }) {
56
+ const { file, label, type } = incoming;
57
+ const token = resolveBotToken(self.channel);
58
+ const mediaDir = telegramMediaDir();
59
+ const declaredName = file.file_name || "";
60
+
61
+ let localPath = null;
62
+ let failure = "";
63
+ try {
64
+ localPath = await downloadTelegramFile(token, file.file_id, mediaDir, {
65
+ preferredName: declaredName,
66
+ });
67
+ self.log(`telegram[${self.channel.name}] ${label} saved: ${localPath}`);
68
+ } catch (e) {
69
+ failure = e.message;
70
+ // Telegram refuses getFile over 20 MB for bots; say which failure it was
71
+ // rather than leaving the agent to guess.
72
+ self.log(`telegram[${self.channel.name}] ${label} download failed: ${e.message}`);
73
+ }
74
+
75
+ // Archive regardless of download outcome, so the history records the file
76
+ // even when the fetch failed.
77
+ appendGlobalMessage({
78
+ channel: CHANNELS.TELEGRAM,
79
+ direction: "in",
80
+ type,
81
+ actor_id: msg.from?.id ? String(msg.from.id) : author,
82
+ external_id: String(u.update_id),
83
+ author,
84
+ body: text || `[${label}]`,
85
+ meta: {
86
+ chat_id,
87
+ user_id: msg.from?.id || null,
88
+ message_id: msg.message_id,
89
+ tg_channel: self.channel.name,
90
+ local_path: localPath,
91
+ file_id: file.file_id,
92
+ file_name: declaredName || null,
93
+ mime_type: file.mime_type || null,
94
+ file_size: file.file_size || null,
95
+ duration: file.duration || null,
96
+ },
97
+ });
98
+
99
+ const bits = [declaredName || label];
100
+ const size = humanSize(file.file_size);
101
+ if (size) bits.push(size);
102
+ if (file.mime_type) bits.push(file.mime_type);
103
+ const marker = localPath
104
+ ? `[${label} received: ${bits.join(", ")} — saved to ${localPath}. You can open it with your file tools.]`
105
+ : `[${label} received: ${bits.join(", ")} — the download FAILED (${failure || "unknown error"}), so there is no local copy. Say so; files over 20 MB cannot be fetched by a bot.]`;
106
+
107
+ return { text: text ? `${marker} ${text}` : marker };
108
+ }
@@ -3,12 +3,15 @@
3
3
  // instance (`self`, for logging + channel) plus the parsed update context, and
4
4
  // returns the (possibly rewritten) `text` the rest of the pipeline should run.
5
5
  //
6
- // Vision note: we do NOT have image understanding yet the engine layer can't
7
- // pass image content to the model. So we download + archive the photo and then
8
- // inject an internal `[image]` marker into `text` so the agent ALWAYS produces a
9
- // reply in its own words (never goes silent on a no-caption photo). The reply is
10
- // model-authored; the marker only tells the model an image arrived and that it
11
- // can't see the pixels yet. Mirrors the `[audio]` marker convention.
6
+ // Vision: the photo is downloaded, archived, and returned as an `attachment`
7
+ // that dispatch threads onto the turn. A multimodal engine (Gemini) receives it
8
+ // as real image content; engines without vision ignore it and still get the
9
+ // `[image]` marker, which names the local path so the agent can reach the file
10
+ // with its tools. The marker also guarantees a no-caption photo never produces
11
+ // an empty turn — the reply is always model-authored, never canned. Mirrors the
12
+ // `[audio]` marker convention.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
12
15
  import { appendGlobalMessage } from "#core/stores/messages.js";
13
16
  import { CHANNELS } from "#core/constants/channels.js";
14
17
  import { resolveBotToken, telegramMediaDir } from "../helpers.js";
@@ -55,9 +58,37 @@ export async function handleIncomingPhoto(self, { msg, u, author, chat_id, text
55
58
  },
56
59
  });
57
60
 
58
- // Guard: never go silent. Hand the agent an internal marker so it replies in
59
- // its own words. No vision yet say so, in-band, so the model doesn't
60
- // hallucinate "seeing" the image.
61
- const marker = "[image attached — you cannot see its contents yet]";
62
- return { text: text ? `${marker} ${text}` : marker };
61
+ // Hand the pixels to the turn. A multimodal engine (Gemini) renders them as
62
+ // an inlineData part; the others ignore the field and still have the marker
63
+ // and the path, so nothing regresses for them.
64
+ let attachment = null;
65
+ if (localPath) {
66
+ try {
67
+ attachment = {
68
+ kind: "image",
69
+ mime: mimeFromPath(localPath),
70
+ data: fs.readFileSync(localPath).toString("base64"),
71
+ path: localPath,
72
+ };
73
+ } catch (e) {
74
+ self.log(`telegram[${self.channel.name}] photo read-back failed: ${e.message}`);
75
+ }
76
+ }
77
+
78
+ // Guard: never go silent. The marker states what arrived and where it is,
79
+ // and stays neutral about visibility — a vision model can describe the image
80
+ // it was given, and one without it still has the path and its file tools.
81
+ const marker = localPath
82
+ ? `[image attached — saved to ${localPath}]`
83
+ : "[image attached — the download failed, there is no local copy]";
84
+ return { text: text ? `${marker} ${text}` : marker, attachment };
85
+ }
86
+
87
+ function mimeFromPath(p) {
88
+ const ext = path.extname(p).toLowerCase();
89
+ if (ext === ".png") return "image/png";
90
+ if (ext === ".webp") return "image/webp";
91
+ if (ext === ".gif") return "image/gif";
92
+ if (ext === ".heic") return "image/heic";
93
+ return "image/jpeg";
63
94
  }
@@ -142,14 +142,42 @@ export async function sendAudio(token, chatId, audio, { caption, title, performe
142
142
  * Download a file from Telegram servers.
143
143
  * Returns the local file path where it was saved.
144
144
  */
145
- export async function downloadTelegramFile(token, fileId, destDir) {
145
+ /**
146
+ * Keep a user-supplied filename usable as a leaf name: no directory escape, no
147
+ * separators, no leading dot, bounded length. Returns "" when nothing usable
148
+ * survives, so callers fall back to the generated name.
149
+ */
150
+ export function safeFileBase(name) {
151
+ const base = path.basename(String(name || "")).replace(/\.[^.]*$/, "");
152
+ const cleaned = base
153
+ .replace(/[/\\]/g, "")
154
+ .replace(/[^\p{L}\p{N}._ -]/gu, "")
155
+ .replace(/\s+/g, " ")
156
+ .replace(/^[.\s]+/, "")
157
+ .trim()
158
+ .slice(0, 60);
159
+ return cleaned;
160
+ }
161
+
162
+ /**
163
+ * Download a file by file_id into destDir. Returns the absolute local path.
164
+ *
165
+ * `preferredName` (a document's own `file_name`) is honoured so the archive is
166
+ * browsable and the agent can tell the user "I saved informe.pdf" rather than
167
+ * a generated id. It is sanitised and suffixed with part of the file_id, so a
168
+ * second "informe.pdf" never overwrites the first.
169
+ */
170
+ export async function downloadTelegramFile(token, fileId, destDir, { preferredName } = {}) {
146
171
  // Step 1: get file path from Telegram
147
172
  const infoRes = await fetch(`${API_BASE}/bot${token}/getFile?file_id=${fileId}`);
148
173
  const infoJson = await infoRes.json();
149
174
  if (!infoJson.ok) throw new Error(`getFile failed: ${infoJson.description}`);
150
175
  const filePath = infoJson.result.file_path; // e.g. "photos/file_123.jpg"
151
- const ext = path.extname(filePath) || ".jpg";
152
- const fileName = `tg_${fileId.slice(-8)}_${Date.now()}${ext}`;
176
+ const ext = path.extname(preferredName || "") || path.extname(filePath) || ".jpg";
177
+ const base = safeFileBase(preferredName);
178
+ const fileName = base
179
+ ? `${base}-${fileId.slice(-6)}${ext}`
180
+ : `tg_${fileId.slice(-8)}_${Date.now()}${ext}`;
153
181
  const localPath = path.join(destDir, fileName);
154
182
 
155
183
  // Step 2: download