@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/package.json +52 -0
  4. package/skills/apx/SKILL.md +77 -0
  5. package/src/cli/commands/a2a.js +66 -0
  6. package/src/cli/commands/agent.js +181 -0
  7. package/src/cli/commands/chat.js +84 -0
  8. package/src/cli/commands/command.js +42 -0
  9. package/src/cli/commands/config.js +56 -0
  10. package/src/cli/commands/daemon.js +148 -0
  11. package/src/cli/commands/exec.js +56 -0
  12. package/src/cli/commands/identity.js +146 -0
  13. package/src/cli/commands/init.js +23 -0
  14. package/src/cli/commands/mcp.js +147 -0
  15. package/src/cli/commands/memory.js +69 -0
  16. package/src/cli/commands/messages.js +61 -0
  17. package/src/cli/commands/plugins.js +23 -0
  18. package/src/cli/commands/project.js +124 -0
  19. package/src/cli/commands/routine.js +99 -0
  20. package/src/cli/commands/runtime.js +64 -0
  21. package/src/cli/commands/session.js +387 -0
  22. package/src/cli/commands/skills.js +153 -0
  23. package/src/cli/commands/telegram.js +48 -0
  24. package/src/cli/http.js +102 -0
  25. package/src/cli/index.js +481 -0
  26. package/src/cli/postinstall.js +25 -0
  27. package/src/core/apc-context-skill.md +150 -0
  28. package/src/core/apx-skill.md +78 -0
  29. package/src/core/config.js +129 -0
  30. package/src/core/identity.js +23 -0
  31. package/src/core/messages-store.js +421 -0
  32. package/src/core/parser.js +217 -0
  33. package/src/core/routines-store.js +144 -0
  34. package/src/core/scaffold.js +417 -0
  35. package/src/core/session-store.js +36 -0
  36. package/src/daemon/apc-runtime-context.js +123 -0
  37. package/src/daemon/api.js +946 -0
  38. package/src/daemon/compact.js +140 -0
  39. package/src/daemon/conversations.js +108 -0
  40. package/src/daemon/db.js +81 -0
  41. package/src/daemon/engines/anthropic.js +58 -0
  42. package/src/daemon/engines/gemini.js +55 -0
  43. package/src/daemon/engines/index.js +65 -0
  44. package/src/daemon/engines/mock.js +18 -0
  45. package/src/daemon/engines/ollama.js +66 -0
  46. package/src/daemon/engines/openai.js +58 -0
  47. package/src/daemon/env-detect.js +69 -0
  48. package/src/daemon/index.js +156 -0
  49. package/src/daemon/mcp-runner.js +218 -0
  50. package/src/daemon/mcp-sources.js +114 -0
  51. package/src/daemon/plugins/index.js +91 -0
  52. package/src/daemon/plugins/telegram.js +549 -0
  53. package/src/daemon/project-config.js +98 -0
  54. package/src/daemon/routines.js +211 -0
  55. package/src/daemon/runtimes/_spawn.js +44 -0
  56. package/src/daemon/runtimes/aider.js +32 -0
  57. package/src/daemon/runtimes/claude-code.js +60 -0
  58. package/src/daemon/runtimes/codex.js +30 -0
  59. package/src/daemon/runtimes/index.js +39 -0
  60. package/src/daemon/runtimes/opencode.js +28 -0
  61. package/src/daemon/smoke.js +54 -0
  62. package/src/daemon/super-agent-tools.js +539 -0
  63. package/src/daemon/super-agent.js +188 -0
  64. package/src/daemon/thinking.js +45 -0
  65. package/src/daemon/tool-call-parser.js +116 -0
  66. package/src/daemon/wakeup.js +92 -0
  67. 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
+ }