@agentprojectcontext/apx 1.0.3
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/LICENSE +21 -0
- package/README.md +142 -0
- package/package.json +52 -0
- package/skills/apx/SKILL.md +77 -0
- package/src/cli/commands/a2a.js +66 -0
- package/src/cli/commands/agent.js +181 -0
- package/src/cli/commands/chat.js +84 -0
- package/src/cli/commands/command.js +42 -0
- package/src/cli/commands/config.js +56 -0
- package/src/cli/commands/daemon.js +148 -0
- package/src/cli/commands/exec.js +56 -0
- package/src/cli/commands/identity.js +146 -0
- package/src/cli/commands/init.js +23 -0
- package/src/cli/commands/mcp.js +147 -0
- package/src/cli/commands/memory.js +69 -0
- package/src/cli/commands/messages.js +61 -0
- package/src/cli/commands/plugins.js +23 -0
- package/src/cli/commands/project.js +124 -0
- package/src/cli/commands/routine.js +99 -0
- package/src/cli/commands/runtime.js +64 -0
- package/src/cli/commands/session.js +387 -0
- package/src/cli/commands/skills.js +153 -0
- package/src/cli/commands/telegram.js +48 -0
- package/src/cli/http.js +102 -0
- package/src/cli/index.js +481 -0
- package/src/cli/postinstall.js +25 -0
- package/src/core/apc-context-skill.md +150 -0
- package/src/core/apx-skill.md +78 -0
- package/src/core/config.js +129 -0
- package/src/core/identity.js +23 -0
- package/src/core/messages-store.js +421 -0
- package/src/core/parser.js +217 -0
- package/src/core/routines-store.js +144 -0
- package/src/core/scaffold.js +417 -0
- package/src/core/session-store.js +36 -0
- package/src/daemon/apc-runtime-context.js +123 -0
- package/src/daemon/api.js +946 -0
- package/src/daemon/compact.js +140 -0
- package/src/daemon/conversations.js +108 -0
- package/src/daemon/db.js +81 -0
- package/src/daemon/engines/anthropic.js +58 -0
- package/src/daemon/engines/gemini.js +55 -0
- package/src/daemon/engines/index.js +65 -0
- package/src/daemon/engines/mock.js +18 -0
- package/src/daemon/engines/ollama.js +66 -0
- package/src/daemon/engines/openai.js +58 -0
- package/src/daemon/env-detect.js +69 -0
- package/src/daemon/index.js +156 -0
- package/src/daemon/mcp-runner.js +218 -0
- package/src/daemon/mcp-sources.js +114 -0
- package/src/daemon/plugins/index.js +91 -0
- package/src/daemon/plugins/telegram.js +549 -0
- package/src/daemon/project-config.js +98 -0
- package/src/daemon/routines.js +211 -0
- package/src/daemon/runtimes/_spawn.js +44 -0
- package/src/daemon/runtimes/aider.js +32 -0
- package/src/daemon/runtimes/claude-code.js +60 -0
- package/src/daemon/runtimes/codex.js +30 -0
- package/src/daemon/runtimes/index.js +39 -0
- package/src/daemon/runtimes/opencode.js +28 -0
- package/src/daemon/smoke.js +54 -0
- package/src/daemon/super-agent-tools.js +539 -0
- package/src/daemon/super-agent.js +188 -0
- package/src/daemon/thinking.js +45 -0
- package/src/daemon/tool-call-parser.js +116 -0
- package/src/daemon/wakeup.js +92 -0
- package/src/mcp/index.js +220 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// Telegram plugin (multi-channel).
|
|
2
|
+
//
|
|
3
|
+
// Each "channel" is a Telegram bot that polls independently and routes inbound
|
|
4
|
+
// to a specific project + agent. Multiple channels in one daemon = multiple
|
|
5
|
+
// bots, each with its own persona, all sharing the same APC runtime.
|
|
6
|
+
//
|
|
7
|
+
// Config shape (in ~/.apx/config.json or .apc/config.json):
|
|
8
|
+
//
|
|
9
|
+
// "telegram": {
|
|
10
|
+
// "enabled": true,
|
|
11
|
+
// "respond_with_engine": true, // default for all channels
|
|
12
|
+
// "channels": [
|
|
13
|
+
// {
|
|
14
|
+
// "name": "support",
|
|
15
|
+
// "bot_token": "...",
|
|
16
|
+
// "chat_id": "1234", // default outbound chat
|
|
17
|
+
// "route_to_agent": "sofia", // who replies; "" → super-agent fallback
|
|
18
|
+
// "project": "/path/to/proj", // optional; defaults to first registered
|
|
19
|
+
// "respond_with_engine": true // override for this channel
|
|
20
|
+
// },
|
|
21
|
+
// ...
|
|
22
|
+
// ],
|
|
23
|
+
// // legacy single-channel keys (used only when channels[] is absent/empty):
|
|
24
|
+
// "bot_token": "",
|
|
25
|
+
// "chat_id": "",
|
|
26
|
+
// "route_to_agent": "",
|
|
27
|
+
// "poll_interval_ms": 1500
|
|
28
|
+
// }
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { TELEGRAM_STATE_PATH } from "../../core/config.js";
|
|
33
|
+
import { callEngine } from "../engines/index.js";
|
|
34
|
+
import { runSuperAgent, isSuperAgentEnabled } from "../super-agent.js";
|
|
35
|
+
import { stripThinking } from "../thinking.js";
|
|
36
|
+
import { getRecentTelegramTurnsFromFs, appendGlobalMessage } from "../../core/messages-store.js";
|
|
37
|
+
import { readAgents } from "../../core/parser.js";
|
|
38
|
+
|
|
39
|
+
const API_BASE = "https://api.telegram.org";
|
|
40
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
41
|
+
|
|
42
|
+
// ---------- shared state ----------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function loadState() {
|
|
45
|
+
if (!fs.existsSync(TELEGRAM_STATE_PATH)) return { channels: {} };
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(fs.readFileSync(TELEGRAM_STATE_PATH, "utf8"));
|
|
48
|
+
return { channels: raw.channels || {}, _legacy_offset: raw.offset || 0 };
|
|
49
|
+
} catch {
|
|
50
|
+
return { channels: {} };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function saveState(state) {
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
TELEGRAM_STATE_PATH,
|
|
57
|
+
JSON.stringify({ ...state, updated_at: nowIso() }, null, 2) + "\n"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- env-fallback helpers --------------------------------------------
|
|
62
|
+
|
|
63
|
+
function resolveBotToken(channel) {
|
|
64
|
+
return (
|
|
65
|
+
channel.bot_token ||
|
|
66
|
+
process.env.BOT_TELEGRAM_TOKEN ||
|
|
67
|
+
process.env.TELEGRAM_BOT_TOKEN ||
|
|
68
|
+
""
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveChatId(channel) {
|
|
73
|
+
return (
|
|
74
|
+
channel.chat_id ||
|
|
75
|
+
process.env.TELEGRAM_CHAT_ID ||
|
|
76
|
+
process.env.BOT_TELEGRAM_CHAT_ID ||
|
|
77
|
+
""
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tokenSource(channel) {
|
|
82
|
+
if (channel.bot_token) return "config";
|
|
83
|
+
if (process.env.BOT_TELEGRAM_TOKEN) return "env:BOT_TELEGRAM_TOKEN";
|
|
84
|
+
if (process.env.TELEGRAM_BOT_TOKEN) return "env:TELEGRAM_BOT_TOKEN";
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------- channel-list resolution -----------------------------------------
|
|
89
|
+
|
|
90
|
+
function resolveChannels(globalConfig) {
|
|
91
|
+
const tg = globalConfig.telegram || {};
|
|
92
|
+
if (Array.isArray(tg.channels) && tg.channels.length > 0) {
|
|
93
|
+
return tg.channels.map((c, i) => ({
|
|
94
|
+
name: c.name || `channel-${i + 1}`,
|
|
95
|
+
bot_token: c.bot_token || "",
|
|
96
|
+
chat_id: c.chat_id || "",
|
|
97
|
+
route_to_agent: c.route_to_agent || "",
|
|
98
|
+
project: c.project || null,
|
|
99
|
+
respond_with_engine:
|
|
100
|
+
c.respond_with_engine !== undefined
|
|
101
|
+
? c.respond_with_engine
|
|
102
|
+
: tg.respond_with_engine !== false,
|
|
103
|
+
poll_interval_ms: c.poll_interval_ms || tg.poll_interval_ms || 1500,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
// Legacy single-channel mode
|
|
107
|
+
if (!tg.bot_token && !process.env.BOT_TELEGRAM_TOKEN && !process.env.TELEGRAM_BOT_TOKEN) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
name: "default",
|
|
113
|
+
bot_token: tg.bot_token || "",
|
|
114
|
+
chat_id: tg.chat_id || "",
|
|
115
|
+
route_to_agent: tg.route_to_agent || "",
|
|
116
|
+
project: null,
|
|
117
|
+
respond_with_engine: tg.respond_with_engine !== false,
|
|
118
|
+
poll_interval_ms: tg.poll_interval_ms || 1500,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------- per-channel poller ----------------------------------------------
|
|
124
|
+
|
|
125
|
+
class ChannelPoller {
|
|
126
|
+
constructor({ channel, projects, globalConfig, log, plugins, registries }) {
|
|
127
|
+
this.channel = channel;
|
|
128
|
+
this.projects = projects;
|
|
129
|
+
this.globalConfig = globalConfig;
|
|
130
|
+
this.log = log;
|
|
131
|
+
this.plugins = plugins;
|
|
132
|
+
this.registries = registries;
|
|
133
|
+
this.state = loadState();
|
|
134
|
+
this.offset =
|
|
135
|
+
this.state.channels?.[channel.name]?.offset ??
|
|
136
|
+
(channel.name === "default" ? this.state._legacy_offset || 0 : 0);
|
|
137
|
+
this.polling = false;
|
|
138
|
+
this.lastError = null;
|
|
139
|
+
this.lastUpdateAt = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
resolveProject() {
|
|
143
|
+
if (this.channel.project) {
|
|
144
|
+
const e = this.projects.getByPath(this.channel.project);
|
|
145
|
+
if (e) return e;
|
|
146
|
+
this.log(`telegram[${this.channel.name}]: project ${this.channel.project} not registered`);
|
|
147
|
+
}
|
|
148
|
+
const all = this.projects.list();
|
|
149
|
+
if (all.length === 0) return null;
|
|
150
|
+
return this.projects.get(all[0].id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
status() {
|
|
154
|
+
return {
|
|
155
|
+
name: this.channel.name,
|
|
156
|
+
polling: this.polling,
|
|
157
|
+
offset: this.offset,
|
|
158
|
+
route_to_agent: this.channel.route_to_agent || null,
|
|
159
|
+
respond_with_engine: this.channel.respond_with_engine,
|
|
160
|
+
project: this.channel.project || null,
|
|
161
|
+
bot_token_present: !!resolveBotToken(this.channel),
|
|
162
|
+
bot_token_source: tokenSource(this.channel),
|
|
163
|
+
chat_id: resolveChatId(this.channel) || null,
|
|
164
|
+
last_error: this.lastError,
|
|
165
|
+
last_update_at: this.lastUpdateAt,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
start() {
|
|
170
|
+
if (this.polling) return;
|
|
171
|
+
if (!resolveBotToken(this.channel)) {
|
|
172
|
+
this.log(`telegram[${this.channel.name}]: no bot_token (config or env) — not starting`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.polling = true;
|
|
176
|
+
this._loop().catch((e) => {
|
|
177
|
+
this.lastError = e.message;
|
|
178
|
+
this.polling = false;
|
|
179
|
+
this.log(`telegram[${this.channel.name}] loop crashed: ${e.message}`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
stop() {
|
|
184
|
+
this.polling = false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _loop() {
|
|
188
|
+
const interval = this.channel.poll_interval_ms;
|
|
189
|
+
let backoff = 1000;
|
|
190
|
+
while (this.polling) {
|
|
191
|
+
try {
|
|
192
|
+
const updates = await this._getUpdates();
|
|
193
|
+
for (const u of updates) {
|
|
194
|
+
await this._handleUpdate(u);
|
|
195
|
+
this.offset = u.update_id + 1;
|
|
196
|
+
this._saveOffset();
|
|
197
|
+
}
|
|
198
|
+
backoff = 1000;
|
|
199
|
+
if (updates.length === 0) await sleep(interval);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
this.lastError = e.message;
|
|
202
|
+
this.log(`telegram[${this.channel.name}] error: ${e.message}; backing off ${backoff}ms`);
|
|
203
|
+
await sleep(backoff);
|
|
204
|
+
backoff = Math.min(backoff * 2, 60_000);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
_saveOffset() {
|
|
210
|
+
const s = loadState();
|
|
211
|
+
s.channels = s.channels || {};
|
|
212
|
+
s.channels[this.channel.name] = { offset: this.offset, updated_at: nowIso() };
|
|
213
|
+
saveState(s);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async _getUpdates() {
|
|
217
|
+
const token = resolveBotToken(this.channel);
|
|
218
|
+
const url = `${API_BASE}/bot${token}/getUpdates?timeout=25&offset=${this.offset}`;
|
|
219
|
+
const res = await fetch(url);
|
|
220
|
+
if (!res.ok) throw new Error(`getUpdates ${res.status}`);
|
|
221
|
+
const json = await res.json();
|
|
222
|
+
if (!json.ok) throw new Error(json.description || "telegram error");
|
|
223
|
+
return json.result || [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async _handleUpdate(u) {
|
|
227
|
+
this.lastUpdateAt = nowIso();
|
|
228
|
+
const msg = u.message || u.edited_message;
|
|
229
|
+
if (!msg) return;
|
|
230
|
+
const target = this.resolveProject();
|
|
231
|
+
if (!target) {
|
|
232
|
+
this.log(`telegram[${this.channel.name}] update ${u.update_id} ignored — no target project`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const author =
|
|
236
|
+
msg.from?.username
|
|
237
|
+
? "@" + msg.from.username
|
|
238
|
+
: `${msg.from?.first_name || ""} ${msg.from?.last_name || ""}`.trim() || "unknown";
|
|
239
|
+
const chat_id = msg.chat?.id;
|
|
240
|
+
const text = msg.text || "";
|
|
241
|
+
|
|
242
|
+
// /reset or /new wipes the rolling context for this chat. We just
|
|
243
|
+
// remember a marker timestamp; subsequent inbounds will only consider
|
|
244
|
+
// history newer than this. Implemented by writing a synthetic message
|
|
245
|
+
// with a known marker so getRecentTelegramTurns naturally cuts off.
|
|
246
|
+
const isReset = /^\/(reset|new)\b/i.test(text.trim());
|
|
247
|
+
|
|
248
|
+
// Pull the prior conversation BEFORE we log this inbound — so the
|
|
249
|
+
// current message isn't part of its own history. We then prune anything
|
|
250
|
+
// older than the most recent /reset for this chat_id.
|
|
251
|
+
let previousMessages = [];
|
|
252
|
+
if (chat_id && !isReset) {
|
|
253
|
+
previousMessages = getRecentTelegramTurnsFromFs({
|
|
254
|
+
chat_id,
|
|
255
|
+
limit: 12,
|
|
256
|
+
max_age_hours: 24,
|
|
257
|
+
});
|
|
258
|
+
// Honour a /reset marker: drop everything up to and including it.
|
|
259
|
+
const lastResetIdx = (() => {
|
|
260
|
+
for (let i = previousMessages.length - 1; i >= 0; i--) {
|
|
261
|
+
if (
|
|
262
|
+
previousMessages[i].role === "user" &&
|
|
263
|
+
/^\/(reset|new)\b/i.test(previousMessages[i].content.trim())
|
|
264
|
+
) {
|
|
265
|
+
return i;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return -1;
|
|
269
|
+
})();
|
|
270
|
+
if (lastResetIdx >= 0) {
|
|
271
|
+
previousMessages = previousMessages.slice(lastResetIdx + 1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Always log inbound to global store (~/.apx/messages/telegram/)
|
|
276
|
+
appendGlobalMessage({
|
|
277
|
+
channel: "telegram",
|
|
278
|
+
direction: "in",
|
|
279
|
+
external_id: String(u.update_id),
|
|
280
|
+
author,
|
|
281
|
+
body: text,
|
|
282
|
+
meta: {
|
|
283
|
+
chat_id,
|
|
284
|
+
message_id: msg.message_id,
|
|
285
|
+
tg_channel: this.channel.name,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!this.channel.respond_with_engine) return;
|
|
290
|
+
if (!text) return;
|
|
291
|
+
|
|
292
|
+
// Short-circuit /reset / /new: send an ack and don't engage the engine.
|
|
293
|
+
// The marker we just logged is enough — getRecentTelegramTurns will
|
|
294
|
+
// honor it for future messages.
|
|
295
|
+
if (isReset) {
|
|
296
|
+
try {
|
|
297
|
+
const ack = "Listo, contexto limpiado. Empezamos de cero — ¿qué necesitás?";
|
|
298
|
+
await this._send({ chat_id, text: ack });
|
|
299
|
+
appendGlobalMessage({
|
|
300
|
+
channel: "telegram",
|
|
301
|
+
direction: "out",
|
|
302
|
+
author: "apx",
|
|
303
|
+
body: ack,
|
|
304
|
+
meta: { chat_id, tg_channel: this.channel.name, in_reply_to: u.update_id, reset: true },
|
|
305
|
+
});
|
|
306
|
+
} catch (e) {
|
|
307
|
+
this.log(`telegram[${this.channel.name}] reset ack failed: ${e.message}`);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Start "typing..." indicator. Stops when we send the reply (or fail).
|
|
313
|
+
const stopTyping = this._startTyping(chat_id);
|
|
314
|
+
|
|
315
|
+
let replyText;
|
|
316
|
+
let replyAuthor;
|
|
317
|
+
const projectCfg = target.config || this.globalConfig;
|
|
318
|
+
|
|
319
|
+
// Try the project's chosen agent first
|
|
320
|
+
const routeSlug = this.channel.route_to_agent;
|
|
321
|
+
if (routeSlug) {
|
|
322
|
+
const agent = readAgents(target.path).find((a) => a.slug === routeSlug);
|
|
323
|
+
if (agent && agent.fields.Model) {
|
|
324
|
+
try {
|
|
325
|
+
const system = buildAgentSystem(target, agent, author);
|
|
326
|
+
const result = await callEngine({
|
|
327
|
+
modelId: agent.fields.Model,
|
|
328
|
+
system,
|
|
329
|
+
messages: [{ role: "user", content: text }],
|
|
330
|
+
config: projectCfg,
|
|
331
|
+
});
|
|
332
|
+
replyText = result.text;
|
|
333
|
+
replyAuthor = agent.slug;
|
|
334
|
+
} catch (e) {
|
|
335
|
+
this.log(`telegram[${this.channel.name}] agent reply failed: ${e.message}`);
|
|
336
|
+
replyText = `[apx error] ${e.message.slice(0, 200)}`;
|
|
337
|
+
replyAuthor = "apx";
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
this.log(
|
|
341
|
+
`telegram[${this.channel.name}] route_to_agent="${routeSlug}" not usable (missing or no model) → trying super-agent`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Fallback: super-agent
|
|
347
|
+
let saTrace = null;
|
|
348
|
+
let saUsage = null;
|
|
349
|
+
if (!replyText && isSuperAgentEnabled(this.globalConfig)) {
|
|
350
|
+
try {
|
|
351
|
+
const sa = await runSuperAgent({
|
|
352
|
+
globalConfig: this.globalConfig,
|
|
353
|
+
projects: this.projects,
|
|
354
|
+
plugins: this.plugins,
|
|
355
|
+
registries: this.registries,
|
|
356
|
+
prompt: text,
|
|
357
|
+
previousMessages,
|
|
358
|
+
contextNote: `Inbound came on Telegram channel "${this.channel.name}" from ${author}. Previous turns of this chat are included for context.`,
|
|
359
|
+
});
|
|
360
|
+
replyText = sa.text;
|
|
361
|
+
replyAuthor = sa.name;
|
|
362
|
+
saTrace = sa.trace;
|
|
363
|
+
saUsage = sa.usage;
|
|
364
|
+
} catch (e) {
|
|
365
|
+
this.log(`telegram[${this.channel.name}] super-agent failed: ${e.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!replyText) {
|
|
370
|
+
stopTyping();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Strip <thinking>...</thinking> blocks before sending to Telegram —
|
|
375
|
+
// reasoning is noise to the chat reader. The full text (with thinking)
|
|
376
|
+
// stays in the daemon log and in messages with channel='engine' if the
|
|
377
|
+
// model produced any.
|
|
378
|
+
const clean = stripThinking(replyText);
|
|
379
|
+
|
|
380
|
+
// Send reply via this channel's bot
|
|
381
|
+
stopTyping();
|
|
382
|
+
try {
|
|
383
|
+
await this._send({ chat_id, text: clean || replyText });
|
|
384
|
+
// Log outbound — store the cleaned text (what we actually sent). The
|
|
385
|
+
// full reasoning (if any) goes in meta_json so it's recoverable.
|
|
386
|
+
const meta = {
|
|
387
|
+
chat_id,
|
|
388
|
+
tg_channel: this.channel.name,
|
|
389
|
+
in_reply_to: u.update_id,
|
|
390
|
+
};
|
|
391
|
+
if (clean !== replyText) meta.thinking_stripped = true;
|
|
392
|
+
if (saTrace && saTrace.length > 0) {
|
|
393
|
+
// Compact representation: [{tool, args}] without the full result
|
|
394
|
+
// (results can be huge — keep them out of the long-lived FS log).
|
|
395
|
+
meta.tools_called = saTrace.map((t) => ({
|
|
396
|
+
tool: t.tool,
|
|
397
|
+
args: t.args,
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
if (saUsage) meta.usage = saUsage;
|
|
401
|
+
appendGlobalMessage({
|
|
402
|
+
channel: "telegram",
|
|
403
|
+
direction: "out",
|
|
404
|
+
author: replyAuthor || "apx",
|
|
405
|
+
body: clean || replyText,
|
|
406
|
+
meta,
|
|
407
|
+
});
|
|
408
|
+
} catch (e) {
|
|
409
|
+
this.log(`telegram[${this.channel.name}] send-back error: ${e.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Show "typing..." indicator in the chat. Telegram clears it automatically
|
|
414
|
+
// after 5 seconds, so call this every ~4s while a long operation is going.
|
|
415
|
+
async _typing(chat_id) {
|
|
416
|
+
try {
|
|
417
|
+
const token = resolveBotToken(this.channel);
|
|
418
|
+
if (!token || !chat_id) return;
|
|
419
|
+
const url = `${API_BASE}/bot${token}/sendChatAction`;
|
|
420
|
+
await fetch(url, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: { "content-type": "application/json" },
|
|
423
|
+
body: JSON.stringify({ chat_id, action: "typing" }),
|
|
424
|
+
});
|
|
425
|
+
} catch {
|
|
426
|
+
// best-effort; failures here aren't worth surfacing
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Returns a function that pings sendChatAction every 4s until called as
|
|
431
|
+
// stop(). Used to wrap the engine round-trip in a "typing" loop so the
|
|
432
|
+
// user sees feedback while qwen thinks.
|
|
433
|
+
_startTyping(chat_id) {
|
|
434
|
+
if (!chat_id) return () => {};
|
|
435
|
+
let stopped = false;
|
|
436
|
+
const tick = async () => {
|
|
437
|
+
if (stopped) return;
|
|
438
|
+
await this._typing(chat_id);
|
|
439
|
+
if (!stopped) setTimeout(tick, 4000);
|
|
440
|
+
};
|
|
441
|
+
tick();
|
|
442
|
+
return () => { stopped = true; };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async _send({ chat_id, text }) {
|
|
446
|
+
const token = resolveBotToken(this.channel);
|
|
447
|
+
if (!token) throw new Error(`channel ${this.channel.name}: no bot_token`);
|
|
448
|
+
const target = chat_id || resolveChatId(this.channel);
|
|
449
|
+
if (!target) throw new Error(`channel ${this.channel.name}: no chat_id`);
|
|
450
|
+
const url = `${API_BASE}/bot${token}/sendMessage`;
|
|
451
|
+
const res = await fetch(url, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: { "content-type": "application/json" },
|
|
454
|
+
body: JSON.stringify({ chat_id: target, text }),
|
|
455
|
+
});
|
|
456
|
+
const json = await res.json();
|
|
457
|
+
if (!json.ok) throw new Error(json.description || `send failed (${res.status})`);
|
|
458
|
+
return json.result;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------- system-prompt builder (same as /exec) ---------------------------
|
|
463
|
+
|
|
464
|
+
function buildAgentSystem(target, agent, author) {
|
|
465
|
+
const parts = [];
|
|
466
|
+
if (agent.fields.Description) parts.push(agent.fields.Description);
|
|
467
|
+
if (agent.fields.Role) parts.push(`Role: ${agent.fields.Role}`);
|
|
468
|
+
if (agent.fields.Language) parts.push(`Default language: ${agent.fields.Language}`);
|
|
469
|
+
parts.push(
|
|
470
|
+
`You are speaking via Telegram with ${author}. Keep responses brief — ideally under 4 sentences. Mirror their language.`
|
|
471
|
+
);
|
|
472
|
+
const memPath = path.join(target.path, ".apc", "agents", agent.slug, "memory.md");
|
|
473
|
+
if (fs.existsSync(memPath)) parts.push("## Memory\n" + fs.readFileSync(memPath, "utf8"));
|
|
474
|
+
const skills = (agent.fields.Skills || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
475
|
+
for (const skill of skills) {
|
|
476
|
+
const sp = path.join(target.path, ".apc", "skills", `${skill}.md`);
|
|
477
|
+
if (fs.existsSync(sp)) parts.push(`## Skill: ${skill}\n` + fs.readFileSync(sp, "utf8"));
|
|
478
|
+
}
|
|
479
|
+
return parts.join("\n\n");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function sleep(ms) {
|
|
483
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---------- plugin export ---------------------------------------------------
|
|
487
|
+
|
|
488
|
+
export default {
|
|
489
|
+
id: "telegram",
|
|
490
|
+
|
|
491
|
+
init({ projects, config, log, plugins, registries }) {
|
|
492
|
+
const channels = resolveChannels(config);
|
|
493
|
+
const pollers = channels.map(
|
|
494
|
+
(channel) =>
|
|
495
|
+
new ChannelPoller({
|
|
496
|
+
channel,
|
|
497
|
+
projects,
|
|
498
|
+
globalConfig: config,
|
|
499
|
+
log,
|
|
500
|
+
plugins,
|
|
501
|
+
registries,
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
start() {
|
|
507
|
+
if (!config.telegram?.enabled) {
|
|
508
|
+
log("telegram disabled in config — not starting any channel");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
for (const p of pollers) p.start();
|
|
512
|
+
},
|
|
513
|
+
stop() {
|
|
514
|
+
for (const p of pollers) p.stop();
|
|
515
|
+
},
|
|
516
|
+
status() {
|
|
517
|
+
return {
|
|
518
|
+
enabled: !!config.telegram?.enabled,
|
|
519
|
+
channels: pollers.map((p) => p.status()),
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
// Direct send used by the API and by routines. If `channel` given, use
|
|
523
|
+
// that bot; otherwise first available bot-tokened channel. Always logs
|
|
524
|
+
// the outbound on `messages` of the channel's target project so audit
|
|
525
|
+
// trails are complete.
|
|
526
|
+
async send({ channel: channelName, chat_id, text, author = "apx", project }) {
|
|
527
|
+
const p =
|
|
528
|
+
(channelName && pollers.find((pp) => pp.channel.name === channelName)) ||
|
|
529
|
+
pollers.find((pp) => resolveBotToken(pp.channel)) ||
|
|
530
|
+
null;
|
|
531
|
+
if (!p) throw new Error("no telegram channel available");
|
|
532
|
+
const result = await p._send({ chat_id, text });
|
|
533
|
+
appendGlobalMessage({
|
|
534
|
+
channel: "telegram",
|
|
535
|
+
direction: "out",
|
|
536
|
+
author,
|
|
537
|
+
body: text,
|
|
538
|
+
meta: {
|
|
539
|
+
chat_id: chat_id || resolveChatId(p.channel),
|
|
540
|
+
tg_channel: p.channel.name,
|
|
541
|
+
via: channelName ? "explicit" : "auto",
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
return result;
|
|
545
|
+
},
|
|
546
|
+
pollers,
|
|
547
|
+
};
|
|
548
|
+
},
|
|
549
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Per-project config (.apc/config.json) — overrides specific sections of the
|
|
2
|
+
// global ~/.apx/config.json *only when serving that project*.
|
|
3
|
+
//
|
|
4
|
+
// Shape (every section optional):
|
|
5
|
+
// {
|
|
6
|
+
// "telegram": {
|
|
7
|
+
// "bot_token": "...", // override global bot
|
|
8
|
+
// "chat_id": "...", // override global chat
|
|
9
|
+
// "route_to_agent": "sofia", // who replies to inbound for THIS project
|
|
10
|
+
// "respond_with_engine": true
|
|
11
|
+
// },
|
|
12
|
+
// "engines": {
|
|
13
|
+
// "ollama": { "base_url": "http://localhost:11434" },
|
|
14
|
+
// "anthropic": { "api_key": "..." }
|
|
15
|
+
// },
|
|
16
|
+
// "routines": [
|
|
17
|
+
// { "name": "morning-report", "schedule": "0 9 * * *", "agent": "sofia",
|
|
18
|
+
// "prompt": "Resumen del día anterior", "channel": "telegram" }
|
|
19
|
+
// ]
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Resolution rule (deep merge): project wins on conflict, but only at leaf
|
|
23
|
+
// keys — arrays are replaced wholesale, primitives override, objects recurse.
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
export const PROJECT_CONFIG_REL = ".apc/config.json";
|
|
29
|
+
|
|
30
|
+
export function projectConfigPath(projectRoot) {
|
|
31
|
+
return path.join(projectRoot, PROJECT_CONFIG_REL);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readProjectConfig(projectRoot) {
|
|
35
|
+
const p = projectConfigPath(projectRoot);
|
|
36
|
+
if (!fs.existsSync(p)) return {};
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeProjectConfig(projectRoot, cfg) {
|
|
45
|
+
const p = projectConfigPath(projectRoot);
|
|
46
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
47
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Deep-merge `a` (lower priority) and `b` (higher priority). Arrays in `b`
|
|
51
|
+
// replace arrays in `a`. Plain objects recurse. Anything else: `b` wins.
|
|
52
|
+
export function deepMerge(a, b) {
|
|
53
|
+
if (Array.isArray(b)) return b.slice();
|
|
54
|
+
if (b === null || b === undefined) return a;
|
|
55
|
+
if (typeof b !== "object") return b;
|
|
56
|
+
if (typeof a !== "object" || Array.isArray(a) || a === null) return { ...b };
|
|
57
|
+
const out = { ...a };
|
|
58
|
+
for (const k of Object.keys(b)) {
|
|
59
|
+
out[k] = deepMerge(a[k], b[k]);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Compute the effective config for a project: global, then project overrides.
|
|
65
|
+
export function effectiveConfig(globalConfig, projectRoot) {
|
|
66
|
+
const project = readProjectConfig(projectRoot);
|
|
67
|
+
return deepMerge(globalConfig, project);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Set a dotted key path in the project config. Creates intermediate objects.
|
|
71
|
+
// setKey(cfg, "telegram.route_to_agent", "sofia")
|
|
72
|
+
export function setDottedKey(obj, dottedKey, value) {
|
|
73
|
+
const parts = dottedKey.split(".");
|
|
74
|
+
let cur = obj;
|
|
75
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
76
|
+
const k = parts[i];
|
|
77
|
+
if (typeof cur[k] !== "object" || cur[k] === null || Array.isArray(cur[k])) {
|
|
78
|
+
cur[k] = {};
|
|
79
|
+
}
|
|
80
|
+
cur = cur[k];
|
|
81
|
+
}
|
|
82
|
+
cur[parts[parts.length - 1]] = value;
|
|
83
|
+
return obj;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function unsetDottedKey(obj, dottedKey) {
|
|
87
|
+
const parts = dottedKey.split(".");
|
|
88
|
+
let cur = obj;
|
|
89
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
90
|
+
if (typeof cur[parts[i]] !== "object") return false;
|
|
91
|
+
cur = cur[parts[i]];
|
|
92
|
+
}
|
|
93
|
+
if (parts[parts.length - 1] in cur) {
|
|
94
|
+
delete cur[parts[parts.length - 1]];
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|