@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.
- package/package.json +1 -1
- package/src/core/agent/run-agent.js +46 -6
- package/src/core/agent/super-agent.js +3 -0
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +123 -6
- package/src/core/channels/telegram/ask-callbacks.js +121 -6
- package/src/core/channels/telegram/dispatch.js +21 -4
- package/src/core/channels/telegram/inbound/file.js +108 -0
- package/src/core/channels/telegram/inbound/photo.js +42 -11
- package/src/core/channels/telegram/media.js +31 -3
- package/src/core/channels/telegram/reply.js +32 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +343 -63
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +10 -1
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- 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
|
|
90
|
-
//
|
|
91
|
-
const insideLlamaWrap =
|
|
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
|
-
|
|
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;
|
|
19
|
-
*
|
|
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 (
|
|
34
|
-
|
|
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
|
-
|
|
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.
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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
|
-
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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
|