@agentprojectcontext/apx 1.32.0 → 1.32.2

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 (219) hide show
  1. package/package.json +6 -1
  2. package/skills/apc-context/SKILL.md +5 -2
  3. package/skills/apx/SKILL.md +3 -3
  4. package/skills/apx-agency-agents/SKILL.md +5 -5
  5. package/skills/apx-agent/SKILL.md +7 -7
  6. package/skills/apx-mcp/SKILL.md +6 -4
  7. package/skills/apx-mcp-builder/SKILL.md +4 -7
  8. package/skills/apx-project/SKILL.md +4 -5
  9. package/skills/apx-routine/SKILL.md +14 -12
  10. package/skills/apx-runtime/SKILL.md +5 -3
  11. package/skills/apx-sessions/SKILL.md +5 -5
  12. package/skills/apx-skill-builder/SKILL.md +10 -6
  13. package/skills/apx-task/SKILL.md +8 -8
  14. package/skills/apx-telegram/SKILL.md +23 -7
  15. package/skills/apx-voice/SKILL.md +8 -6
  16. package/src/core/{agent-system.js → agent/build-agent-system.js} +10 -12
  17. package/src/core/agent/index.js +0 -2
  18. package/src/core/{agent-memory.js → agent/memory.js} +2 -2
  19. package/src/core/agent/model-router.js +21 -43
  20. package/src/core/agent/prompt-builder.js +17 -63
  21. package/src/core/agent/prompts/action-discipline.md +17 -0
  22. package/src/core/agent/prompts/channels/code.md +8 -12
  23. package/src/core/agent/prompts/channels/desktop.md +6 -4
  24. package/src/core/agent/prompts/channels/routine.md +10 -1
  25. package/src/core/agent/prompts/channels/telegram.md +5 -0
  26. package/src/core/agent/prompts/channels/web_code.md +20 -0
  27. package/src/core/agent/prompts/modes/voice.md +2 -2
  28. package/src/core/agent/prompts/super-agent-base.md +2 -2
  29. package/src/core/agent/run-agent.js +37 -35
  30. package/src/core/agent/runtime-bridge.js +42 -0
  31. package/src/core/agent/self-memory.js +19 -9
  32. package/src/core/agent/skills/catalog.js +65 -0
  33. package/src/core/agent/skills/index.js +6 -0
  34. package/src/{host/daemon/skills-loader.js → core/agent/skills/loader.js} +3 -3
  35. package/src/core/agent/skills/rag.js +91 -0
  36. package/src/core/agent/skills/trigger.js +71 -0
  37. package/src/{host/daemon → core/agent}/super-agent.js +5 -5
  38. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/add-project.js +3 -4
  39. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/call-agent.js +2 -2
  40. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/call-mcp.js +1 -2
  41. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/call-runtime.js +10 -11
  42. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/create-task.js +1 -1
  43. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/discover-tools.js +1 -1
  44. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/edit-file.js +1 -2
  45. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/import-agent.js +4 -5
  46. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-agents.js +1 -1
  47. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-skills.js +7 -2
  48. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-tasks.js +1 -1
  49. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-vault-agents.js +1 -1
  50. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/load-skill.js +1 -1
  51. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/read-agent-memory.js +1 -1
  52. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/read-self-memory.js +1 -1
  53. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/remember.js +1 -1
  54. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/run-shell.js +1 -2
  55. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/search-messages.js +1 -1
  56. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/search-sessions.js +1 -1
  57. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/send-telegram.js +0 -2
  58. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/set-identity.js +1 -3
  59. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/set-permission-mode.js +1 -3
  60. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/tail-messages.js +1 -1
  61. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/transcribe-audio.js +1 -1
  62. package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/write-file.js +1 -2
  63. package/src/core/agent/tools/helpers.js +74 -0
  64. package/src/{host/daemon/super-agent-tools → core/agent/tools}/registry-bridge.js +3 -3
  65. package/src/{host/daemon/super-agent-tools/index.js → core/agent/tools/registry.js} +31 -32
  66. package/src/core/apc/agents-vault.js +37 -0
  67. package/src/core/{scaffold.js → apc/scaffold.js} +4 -5
  68. package/src/core/{config.js → config/index.js} +21 -27
  69. package/src/core/config/paths.js +32 -0
  70. package/src/core/constants/actors.js +8 -0
  71. package/src/core/constants/channels.js +19 -0
  72. package/src/core/constants/index.js +5 -0
  73. package/src/core/constants/permissions.js +17 -0
  74. package/src/core/constants/roles.js +9 -0
  75. package/src/core/engines/_streaming.js +63 -0
  76. package/src/core/engines/anthropic.js +11 -22
  77. package/src/core/engines/ollama.js +7 -16
  78. package/src/core/identity/index.js +8 -0
  79. package/src/core/{identity.js → identity/self.js} +5 -5
  80. package/src/core/{telegram-identity.js → identity/telegram.js} +1 -1
  81. package/src/core/logging.js +1 -1
  82. package/src/core/mascot.js +1 -1
  83. package/src/core/memory/active-threads.js +10 -10
  84. package/src/core/memory/broker.js +9 -9
  85. package/src/core/memory/compactor.js +2 -2
  86. package/src/core/memory/index.js +2 -2
  87. package/src/core/memory/indexer.js +1 -1
  88. package/src/core/{code-sessions-store.js → stores/code-sessions.js} +3 -7
  89. package/src/core/{messages-store.js → stores/messages.js} +6 -4
  90. package/src/core/stores/routine-memory.js +71 -0
  91. package/src/core/{routines-store.js → stores/routines.js} +1 -3
  92. package/src/core/stores/runtime-sessions.js +99 -0
  93. package/src/core/{tasks-store.js → stores/tasks.js} +3 -8
  94. package/src/core/update-check.js +1 -1
  95. package/src/core/util/ids.js +14 -0
  96. package/src/core/util/index.js +2 -0
  97. package/src/core/util/text-similarity.js +52 -0
  98. package/src/core/util/time.js +9 -0
  99. package/src/core/voice/tts.js +1 -1
  100. package/src/host/daemon/api/admin-config.js +4 -3
  101. package/src/host/daemon/api/admin.js +1 -1
  102. package/src/host/daemon/api/agents.js +4 -25
  103. package/src/host/daemon/api/artifacts.js +1 -1
  104. package/src/host/daemon/api/code.js +48 -16
  105. package/src/host/daemon/api/confirm.js +1 -1
  106. package/src/host/daemon/api/connections.js +2 -2
  107. package/src/host/daemon/api/conversations.js +2 -2
  108. package/src/host/daemon/api/deck.js +1 -1
  109. package/src/host/daemon/api/desktop.js +1 -1
  110. package/src/host/daemon/api/embeddings.js +4 -4
  111. package/src/host/daemon/api/engines.js +2 -2
  112. package/src/host/daemon/api/exec.js +3 -3
  113. package/src/host/daemon/api/identity.js +1 -1
  114. package/src/host/daemon/api/mcps.js +1 -1
  115. package/src/host/daemon/api/messages.js +1 -1
  116. package/src/host/daemon/api/runtimes.js +9 -8
  117. package/src/host/daemon/api/sessions-search.js +1 -1
  118. package/src/host/daemon/api/sessions.js +2 -2
  119. package/src/host/daemon/api/shared.js +5 -4
  120. package/src/host/daemon/api/skills.js +30 -0
  121. package/src/host/daemon/api/super-agent.js +29 -9
  122. package/src/host/daemon/api/tasks.js +2 -2
  123. package/src/host/daemon/api/telegram.js +1 -1
  124. package/src/host/daemon/api/tools.js +6 -6
  125. package/src/host/daemon/api/tts.js +2 -2
  126. package/src/host/daemon/api/voice.js +14 -12
  127. package/src/host/daemon/api.js +2 -0
  128. package/src/host/daemon/compact.js +1 -1
  129. package/src/host/daemon/db.js +4 -4
  130. package/src/host/daemon/desktop-ws.js +1 -1
  131. package/src/host/daemon/index.js +4 -4
  132. package/src/host/daemon/plugins/{desktop.js → desktop/index.js} +11 -6
  133. package/src/host/daemon/plugins/index.js +2 -2
  134. package/src/host/daemon/plugins/{telegram.js → telegram/index.js} +66 -195
  135. package/src/host/daemon/plugins/telegram/media.js +162 -0
  136. package/src/host/daemon/projects-helpers.js +54 -0
  137. package/src/host/daemon/routines.js +28 -12
  138. package/src/host/daemon/smoke.js +2 -2
  139. package/src/host/daemon/token-store.js +1 -1
  140. package/src/host/daemon/transcription.js +2 -2
  141. package/src/host/daemon/wakeup.js +2 -2
  142. package/src/interfaces/cli/commands/agent.js +3 -3
  143. package/src/interfaces/cli/commands/command.js +1 -1
  144. package/src/interfaces/cli/commands/config.js +3 -2
  145. package/src/interfaces/cli/commands/desktop.js +1 -1
  146. package/src/interfaces/cli/commands/exec.js +2 -1
  147. package/src/interfaces/cli/commands/identity.js +2 -2
  148. package/src/interfaces/cli/commands/init.js +1 -1
  149. package/src/interfaces/cli/commands/mcp.js +1 -1
  150. package/src/interfaces/cli/commands/memory.js +2 -2
  151. package/src/interfaces/cli/commands/model.js +16 -6
  152. package/src/interfaces/cli/commands/project.js +1 -1
  153. package/src/interfaces/cli/commands/routine.js +58 -0
  154. package/src/interfaces/cli/commands/search.js +1 -1
  155. package/src/interfaces/cli/commands/session.js +4 -4
  156. package/src/interfaces/cli/commands/setup.js +4 -3
  157. package/src/interfaces/cli/commands/skills.js +25 -4
  158. package/src/interfaces/cli/commands/status.js +1 -1
  159. package/src/interfaces/cli/commands/sys.js +11 -4
  160. package/src/interfaces/cli/commands/update.js +1 -1
  161. package/src/interfaces/cli/index.js +4 -4
  162. package/src/interfaces/cli/postinstall.js +2 -2
  163. package/src/interfaces/mcp-server/index.js +1 -1
  164. package/src/interfaces/tui/component/prompt/index.tsx +3 -1
  165. package/src/interfaces/tui/context/sdk-apx.tsx +47 -7
  166. package/src/interfaces/tui/context/sync-apx.tsx +20 -2
  167. package/src/interfaces/tui/context/sync.tsx +2 -1
  168. package/src/interfaces/tui/routes/session/index.tsx +151 -136
  169. package/src/interfaces/tui/routes/session/sidebar-apx.tsx +37 -15
  170. package/src/interfaces/tui/run.ts +2 -0
  171. package/src/interfaces/web/dist/assets/index-34U_Mp1M.css +1 -0
  172. package/src/interfaces/web/dist/assets/index-BkybwwRn.js +570 -0
  173. package/src/interfaces/web/dist/assets/index-BkybwwRn.js.map +1 -0
  174. package/src/interfaces/web/dist/index.html +2 -2
  175. package/src/interfaces/web/package-lock.json +3 -3
  176. package/src/interfaces/web/src/App.tsx +51 -32
  177. package/src/interfaces/web/src/components/RobyBubble.tsx +12 -6
  178. package/src/interfaces/web/src/components/UiSelect.tsx +1 -1
  179. package/src/interfaces/web/src/components/chat/SkillPicker.tsx +77 -0
  180. package/src/interfaces/web/src/components/code/CodeProjectPicker.tsx +1 -1
  181. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +33 -18
  182. package/src/interfaces/web/src/components/common/TabLayout.tsx +9 -5
  183. package/src/interfaces/web/src/components/common/TabNav.tsx +3 -3
  184. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +4 -2
  185. package/src/interfaces/web/src/hooks/useChat.ts +47 -2
  186. package/src/interfaces/web/src/hooks/useNavCollapseCtx.tsx +59 -0
  187. package/src/interfaces/web/src/hooks/usePersonaName.ts +11 -0
  188. package/src/interfaces/web/src/i18n/en.ts +7 -7
  189. package/src/interfaces/web/src/i18n/es.ts +7 -7
  190. package/src/interfaces/web/src/lib/api/skills.ts +25 -0
  191. package/src/interfaces/web/src/lib/api.ts +1 -0
  192. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +18 -18
  193. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +5 -18
  194. package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +1 -8
  195. package/src/interfaces/web/src/screens/modules/VoiceScreen.tsx +39 -40
  196. package/src/interfaces/web/src/screens/project/ChatTab.tsx +12 -9
  197. package/src/skills/apc-context/SKILL.md +159 -0
  198. package/src/core/agent/ghost-guard.js +0 -24
  199. package/src/core/agent/prompts/channels/terminal.md +0 -16
  200. package/src/host/daemon/apc-runtime-context.js +0 -124
  201. package/src/host/daemon/super-agent-tools/helpers.js +0 -124
  202. package/src/host/daemon/tool-call-parser.js +0 -2
  203. package/src/interfaces/web/dist/assets/index-63P_ji1a.js +0 -571
  204. package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +0 -1
  205. package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +0 -1
  206. /package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/ask-questions.js +0 -0
  207. /package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-files.js +0 -0
  208. /package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-mcps.js +0 -0
  209. /package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/list-projects.js +0 -0
  210. /package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/read-file.js +0 -0
  211. /package/src/{host/daemon/super-agent-tools/tools → core/agent/tools/handlers}/search-files.js +0 -0
  212. /package/src/core/agent/{pseudo-tools.js → tools/pseudo-tools.js} +0 -0
  213. /package/src/core/agent/{tool-call-parser.js → tools/tool-call-parser.js} +0 -0
  214. /package/src/core/{parser.js → apc/parser.js} +0 -0
  215. /package/src/core/{apc-skill-sync.js → apc/skill-sync.js} +0 -0
  216. /package/src/core/{artifacts-store.js → stores/artifacts.js} +0 -0
  217. /package/src/{host/daemon → core/stores}/engine-sessions.js +0 -0
  218. /package/src/core/{session-store.js → stores/sessions.js} +0 -0
  219. /package/src/host/daemon/plugins/{telegram-ask.js → telegram/ask.js} +0 -0
@@ -14,17 +14,17 @@ import {
14
14
  LOG_PATH,
15
15
  APX_HOME,
16
16
  TOKEN_PATH,
17
- } from "../../core/config.js";
17
+ } from "#core/config/index.js";
18
18
  import { ProjectManager } from "./db.js";
19
- import { McpRegistry } from "../../core/mcp/runner.js";
19
+ import { McpRegistry } from "#core/mcp/runner.js";
20
20
  import { PluginManager } from "./plugins/index.js";
21
21
  import { RoutineScheduler } from "./routines.js";
22
22
  import { buildApi } from "./api.js";
23
23
  import { createTokenStore } from "./token-store.js";
24
24
  import { triggerWakeup } from "./wakeup.js";
25
25
  import { registerDesktopClient } from "./desktop-ws.js";
26
- import { log as logToUnified } from "../../core/logging.js";
27
- import { initMemory, stopMemory } from "../../core/memory/index.js";
26
+ import { log as logToUnified } from "#core/logging.js";
27
+ import { initMemory, stopMemory } from "#core/memory/index.js";
28
28
 
29
29
  const __filename = fileURLToPath(import.meta.url);
30
30
  const __dirname = path.dirname(__filename);
@@ -21,11 +21,13 @@ import {
21
21
  broadcastDesktop,
22
22
  sendToClient,
23
23
  setDesktopMessageHandler,
24
- } from "../desktop-ws.js";
25
- import { runSuperAgent, isSuperAgentEnabled } from "../super-agent.js";
26
- import { appendGlobalMessage } from "../../../core/messages-store.js";
24
+ } from "../../desktop-ws.js";
25
+ import { runSuperAgent, isSuperAgentEnabled } from "#core/agent/super-agent.js";
26
+ import { appendGlobalMessage } from "#core/stores/messages.js";
27
+ import { CHANNELS } from "#core/constants/channels.js";
28
+ import { tryResolveSkillCommand } from "#core/agent/skills/trigger.js";
27
29
 
28
- const CHANNEL = "desktop";
30
+ const CHANNEL = CHANNELS.DESKTOP;
29
31
 
30
32
  export default {
31
33
  id: "desktop",
@@ -128,12 +130,15 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
128
130
 
129
131
  log(`desktop: super-agent turn start — model=${cfg.model || config?.super_agent?.model || "(default)"} text="${text.slice(0, 60)}"`);
130
132
  const t0 = Date.now();
133
+ const slashed = tryResolveSkillCommand(text);
134
+ const slashedPrompt = slashed.handled ? slashed.prompt : text;
131
135
  const result = await runSuperAgent({
132
136
  globalConfig: config,
133
137
  projects,
134
138
  plugins,
135
- prompt: text,
136
- channel: "desktop",
139
+ prompt: slashedPrompt,
140
+ channel: CHANNELS.DESKTOP,
141
+ ...(slashed.handled ? { contextNote: slashed.contextNote } : {}),
137
142
  channelMeta: { voice: true }, // desktop module is voice-first → spoken mode
138
143
  previousMessages: history.slice(0, -1),
139
144
  overrideModel: cfg.model || null,
@@ -12,8 +12,8 @@
12
12
  //
13
13
  // Plugins are discovered by static import here. Adding a new plugin = importing
14
14
  // it and pushing into PLUGINS.
15
- import telegramPlugin from "./telegram.js";
16
- import desktopPlugin from "./desktop.js";
15
+ import telegramPlugin from "./telegram/index.js";
16
+ import desktopPlugin from "./desktop/index.js";
17
17
 
18
18
  export const PLUGINS = [telegramPlugin, desktopPlugin];
19
19
 
@@ -29,23 +29,26 @@
29
29
 
30
30
  import fs from "node:fs";
31
31
  import path from "node:path";
32
- import { TELEGRAM_STATE_PATH, APX_HOME } from "../../../core/config.js";
33
- import { callEngine } from "../../../core/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 { compactChannelIfNeeded } from "../../../core/memory/index.js";
38
- import { readAgents } from "../../../core/parser.js";
39
- import { buildAgentSystem } from "../../../core/agent-system.js";
40
- import { transcribe as transcribeAudioFile } from "../transcription.js";
41
- import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "../../../core/identity.js";
42
- import { registerSender, resolveAllowedTools } from "../../../core/telegram-identity.js";
43
- import { buildRelationshipBlock } from "../../../core/agent/index.js";
44
- import { getConfirmationStore as getConfirmStore } from "../../../core/confirmation/pending-store.js";
45
- import { createTelegramConfirmAdapter } from "../../../core/confirmation/adapters/telegram.js";
46
- import * as askFlow from "./telegram-ask.js";
47
-
48
- const API_BASE = "https://api.telegram.org";
32
+ import { TELEGRAM_STATE_PATH, APX_HOME } from "#core/config/index.js";
33
+ import { callEngine } from "#core/engines/index.js";
34
+ import { runSuperAgent, isSuperAgentEnabled } from "#core/agent/super-agent.js";
35
+ import { stripThinking } from "../../thinking.js";
36
+ import { getRecentTelegramTurnsFromFs, appendGlobalMessage } from "#core/stores/messages.js";
37
+ import { compactChannelIfNeeded } from "#core/memory/index.js";
38
+ import { readAgents } from "#core/apc/parser.js";
39
+ import { buildAgentSystem } from "#core/agent/build-agent-system.js";
40
+ import { transcribe as transcribeAudioFile } from "../../transcription.js";
41
+ import { resolveAgentName, SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
42
+ import { registerSender, resolveAllowedTools } from "#core/identity/telegram.js";
43
+ import { buildRelationshipBlock } from "#core/agent/index.js";
44
+ import { getConfirmationStore as getConfirmStore } from "#core/confirmation/pending-store.js";
45
+ import { CHANNELS } from "#core/constants/channels.js";
46
+ import { tryResolveSkillCommand } from "#core/agent/skills/trigger.js";
47
+ import { isLikelyDuplicate } from "#core/util/text-similarity.js";
48
+ import { createTelegramConfirmAdapter } from "#core/confirmation/adapters/telegram.js";
49
+ import * as askFlow from "./ask.js";
50
+
51
+ // API_BASE re-imported from ./media.js below
49
52
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
50
53
 
51
54
  // Build the channelMeta passed to the super-agent loop. The prompt template at
@@ -85,159 +88,9 @@ function buildTelegramMeta({ channelName, author, chatId, target, routeToAgent }
85
88
  };
86
89
  }
87
90
 
88
- // ---------- media sending helpers -------------------------------------------
89
-
90
- /**
91
- * Send a photo to a Telegram chat.
92
- * @param {string} token Bot token
93
- * @param {string|number} chatId Telegram chat_id
94
- * @param {string|Buffer} photo Absolute file path OR Buffer of image data
95
- * @param {object} [opts]
96
- * @param {string} [opts.caption]
97
- * @param {string} [opts.parse_mode] "HTML" | "Markdown" | "MarkdownV2"
98
- */
99
- export async function sendPhoto(token, chatId, photo, { caption, parse_mode } = {}) {
100
- const url = `${API_BASE}/bot${token}/sendPhoto`;
101
- const form = new FormData();
102
- form.append("chat_id", String(chatId));
103
- if (caption) form.append("caption", caption);
104
- if (parse_mode) form.append("parse_mode", parse_mode);
105
-
106
- if (typeof photo === "string" && photo.startsWith("http")) {
107
- // Public URL — send as string
108
- form.append("photo", photo);
109
- } else {
110
- // Local file path or Buffer
111
- const buf = Buffer.isBuffer(photo) ? photo : fs.readFileSync(photo);
112
- const name = typeof photo === "string" ? path.basename(photo) : "photo.jpg";
113
- const blob = new Blob([buf], { type: name.endsWith(".png") ? "image/png" : "image/jpeg" });
114
- form.append("photo", blob, name);
115
- }
116
-
117
- const res = await fetch(url, { method: "POST", body: form });
118
- const json = await res.json();
119
- if (!json.ok) throw new Error(`sendPhoto failed: ${json.description || res.status}`);
120
- return json.result;
121
- }
122
-
123
- /**
124
- * Send a voice message (OGG/Opus preferred by Telegram).
125
- * @param {string} token
126
- * @param {string|number} chatId
127
- * @param {string|Buffer} audio Path or Buffer
128
- * @param {object} [opts]
129
- * @param {string} [opts.caption]
130
- * @param {number} [opts.duration]
131
- */
132
- export async function sendVoice(token, chatId, audio, { caption, duration } = {}) {
133
- const url = `${API_BASE}/bot${token}/sendVoice`;
134
- const form = new FormData();
135
- form.append("chat_id", String(chatId));
136
- if (caption) form.append("caption", caption);
137
- if (duration) form.append("duration", String(duration));
138
-
139
- const buf = Buffer.isBuffer(audio) ? audio : fs.readFileSync(audio);
140
- const name = typeof audio === "string" ? path.basename(audio) : "voice.ogg";
141
- const blob = new Blob([buf], { type: "audio/ogg" });
142
- form.append("voice", blob, name);
143
-
144
- const res = await fetch(url, { method: "POST", body: form });
145
- const json = await res.json();
146
- if (!json.ok) throw new Error(`sendVoice failed: ${json.description || res.status}`);
147
- return json.result;
148
- }
149
-
150
- /**
151
- * Send an audio file (MP3, M4A, etc — shown in Telegram music player).
152
- * @param {string} token
153
- * @param {string|number} chatId
154
- * @param {string|Buffer} audio Path or Buffer
155
- * @param {object} [opts]
156
- * @param {string} [opts.caption]
157
- * @param {string} [opts.title]
158
- * @param {string} [opts.performer]
159
- */
160
- /**
161
- * Send any file as a Telegram document (PDF, zip, txt, etc).
162
- * @param {string} token
163
- * @param {string|number} chatId
164
- * @param {string|Buffer} document Path or Buffer of document data
165
- * @param {object} [opts]
166
- * @param {string} [opts.caption]
167
- * @param {string} [opts.filename] override filename for Buffer input
168
- * @param {string} [opts.mime_type]
169
- */
170
- export async function sendDocument(token, chatId, document, { caption, filename, mime_type } = {}) {
171
- const url = `${API_BASE}/bot${token}/sendDocument`;
172
- const form = new FormData();
173
- form.append("chat_id", String(chatId));
174
- if (caption) form.append("caption", caption);
175
-
176
- // URL string → let Telegram fetch it
177
- if (typeof document === "string" && /^https?:\/\//.test(document)) {
178
- form.append("document", document);
179
- } else {
180
- const buf = Buffer.isBuffer(document) ? document : fs.readFileSync(document);
181
- const name =
182
- filename ||
183
- (typeof document === "string" ? path.basename(document) : "document.bin");
184
- const blob = new Blob([buf], { type: mime_type || "application/octet-stream" });
185
- form.append("document", blob, name);
186
- }
187
-
188
- const res = await fetch(url, { method: "POST", body: form });
189
- const json = await res.json();
190
- if (!json.ok) throw new Error(`sendDocument failed: ${json.description || res.status}`);
191
- return json.result;
192
- }
193
-
194
- export async function sendAudio(token, chatId, audio, { caption, title, performer } = {}) {
195
- const url = `${API_BASE}/bot${token}/sendAudio`;
196
- const form = new FormData();
197
- form.append("chat_id", String(chatId));
198
- if (caption) form.append("caption", caption);
199
- if (title) form.append("title", title);
200
- if (performer) form.append("performer", performer);
201
-
202
- const buf = Buffer.isBuffer(audio) ? audio : fs.readFileSync(audio);
203
- const name = typeof audio === "string" ? path.basename(audio) : "audio.mp3";
204
- const blob = new Blob([buf], { type: "audio/mpeg" });
205
- form.append("audio", blob, name);
206
-
207
- const res = await fetch(url, { method: "POST", body: form });
208
- const json = await res.json();
209
- if (!json.ok) throw new Error(`sendAudio failed: ${json.description || res.status}`);
210
- return json.result;
211
- }
212
-
213
- // Audio transcription is delegated to the central dispatcher
214
- // (../transcription.js) which handles local (faster-whisper via Python) +
215
- // OpenAI cloud fallback. See that module for config keys.
216
-
217
- /**
218
- * Download a file from Telegram servers.
219
- * Returns the local file path where it was saved.
220
- */
221
- async function downloadTelegramFile(token, fileId, destDir) {
222
- // Step 1: get file path from Telegram
223
- const infoRes = await fetch(`${API_BASE}/bot${token}/getFile?file_id=${fileId}`);
224
- const infoJson = await infoRes.json();
225
- if (!infoJson.ok) throw new Error(`getFile failed: ${infoJson.description}`);
226
- const filePath = infoJson.result.file_path; // e.g. "photos/file_123.jpg"
227
- const ext = path.extname(filePath) || ".jpg";
228
- const fileName = `tg_${fileId.slice(-8)}_${Date.now()}${ext}`;
229
- const localPath = path.join(destDir, fileName);
230
-
231
- // Step 2: download
232
- const dlRes = await fetch(`${API_BASE}/file/bot${token}/${filePath}`);
233
- if (!dlRes.ok) throw new Error(`download failed: ${dlRes.status}`);
234
- const buf = Buffer.from(await dlRes.arrayBuffer());
235
- fs.writeFileSync(localPath, buf);
236
- return localPath;
237
- }
238
-
239
- // ---------- shared state ----------------------------------------------------
240
-
91
+ // Media sending helpers moved to ./media.js.
92
+ import { sendPhoto, sendVoice, sendDocument, sendAudio, downloadTelegramFile, API_BASE } from "./media.js";
93
+ export { sendPhoto, sendVoice, sendDocument, sendAudio };
241
94
  function loadState() {
242
95
  if (!fs.existsSync(TELEGRAM_STATE_PATH)) return { channels: {} };
243
96
  try {
@@ -485,7 +338,7 @@ class ChannelPoller {
485
338
  const localPath = await downloadTelegramFile(token, bestPhoto.file_id, mediaDir);
486
339
  this.log(`telegram[${this.channel.name}] photo saved: ${localPath}`);
487
340
  appendGlobalMessage({
488
- channel: "telegram",
341
+ channel: CHANNELS.TELEGRAM,
489
342
  direction: "in",
490
343
  type: "photo",
491
344
  actor_id: msg.from?.id ? String(msg.from.id) : author,
@@ -551,7 +404,7 @@ class ChannelPoller {
551
404
  : `[audio] (transcription unavailable${transcribeError ? ": " + transcribeError : ""})`;
552
405
 
553
406
  appendGlobalMessage({
554
- channel: "telegram",
407
+ channel: CHANNELS.TELEGRAM,
555
408
  direction: "in",
556
409
  type: "audio",
557
410
  actor_id: msg.from?.id ? String(msg.from.id) : author,
@@ -584,7 +437,7 @@ class ChannelPoller {
584
437
  if (chat_id && text && await this._maybeConsumeAskTextAnswer({ chat_id, text })) {
585
438
  // Still log the inbound so the chat history records what the user said.
586
439
  appendGlobalMessage({
587
- channel: "telegram",
440
+ channel: CHANNELS.TELEGRAM,
588
441
  direction: "in",
589
442
  type: "user",
590
443
  actor_id: msg.from?.id ? String(msg.from.id) : author,
@@ -623,7 +476,7 @@ class ChannelPoller {
623
476
  // the next turn reads a [RESUMEN COMPACTADO] instead of raw history. Never
624
477
  // awaited — adds zero latency to this reply, degrades gracefully.
625
478
  compactChannelIfNeeded({
626
- channel: "telegram",
479
+ channel: CHANNELS.TELEGRAM,
627
480
  chat_id,
628
481
  config: this.globalConfig,
629
482
  log: this.log,
@@ -647,7 +500,7 @@ class ChannelPoller {
647
500
 
648
501
  // Always log inbound to global store (~/.apx/messages/telegram/)
649
502
  appendGlobalMessage({
650
- channel: "telegram",
503
+ channel: CHANNELS.TELEGRAM,
651
504
  direction: "in",
652
505
  type: "user",
653
506
  actor_id: msg.from?.id ? String(msg.from.id) : author,
@@ -679,7 +532,7 @@ class ChannelPoller {
679
532
  const ack = "Done, context cleared. Starting fresh. What do you need?";
680
533
  await this._send({ chat_id, text: ack });
681
534
  appendGlobalMessage({
682
- channel: "telegram",
535
+ channel: CHANNELS.TELEGRAM,
683
536
  direction: "out",
684
537
  type: "agent",
685
538
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -703,7 +556,7 @@ class ChannelPoller {
703
556
  let replyActorId; // stable id: super_agent | agent slug
704
557
  let replyKind; // actor_kind: superagent | agent
705
558
  const projectCfg = target.config || this.globalConfig;
706
- // Display name for the super-agent persona on this channel (Roby / APX).
559
+ // Display name for the super-agent persona on this channel (from identity.json).
707
560
  const agentDisplay = resolveAgentName(this.globalConfig);
708
561
 
709
562
  // Try the project's chosen agent first (skipped if the legacy
@@ -777,7 +630,7 @@ class ChannelPoller {
777
630
  const heads = headsUpPhrase();
778
631
  await this._send({ chat_id, text: heads });
779
632
  appendGlobalMessage({
780
- channel: "telegram",
633
+ channel: CHANNELS.TELEGRAM,
781
634
  direction: "out",
782
635
  type: "agent",
783
636
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -792,11 +645,17 @@ class ChannelPoller {
792
645
  if (ev.type === "assistant_text" && ev.text) {
793
646
  const piece = stripThinking(ev.text).trim();
794
647
  if (!piece) return;
648
+ // Skip post-tool segments that just restate the pre-tool intro —
649
+ // weaker models (gemini-flash et al.) frequently paraphrase the
650
+ // same content twice within a single turn.
651
+ if (lastStreamedText && isLikelyDuplicate(piece, lastStreamedText)) {
652
+ return;
653
+ }
795
654
  await this._send({ chat_id, text: piece });
796
655
  lastStreamedText = piece;
797
656
  streamedCount += 1;
798
657
  appendGlobalMessage({
799
- channel: "telegram",
658
+ channel: CHANNELS.TELEGRAM,
800
659
  direction: "out",
801
660
  type: "agent",
802
661
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -816,7 +675,7 @@ class ChannelPoller {
816
675
  // Logged for the audit trail / other channels — NOT sent to Telegram.
817
676
  const t = ev.trace;
818
677
  appendGlobalMessage({
819
- channel: "telegram",
678
+ channel: CHANNELS.TELEGRAM,
820
679
  direction: "out",
821
680
  type: "tool",
822
681
  actor_id: t.tool,
@@ -846,17 +705,24 @@ class ChannelPoller {
846
705
  pendingStore: getConfirmStore(),
847
706
  });
848
707
 
708
+ // `/slug ...` shortcut: load the matching skill body into contextNote
709
+ // and strip the prefix from the user prompt before sending to the loop.
710
+ const slashed = tryResolveSkillCommand(text, { projectPath: target?.path });
711
+ const slashedPrompt = slashed.handled ? slashed.prompt : text;
712
+ const slashedContextNote = slashed.handled ? slashed.contextNote : "";
713
+
849
714
  try {
850
715
  const sa = await runSuperAgent({
851
716
  globalConfig: this.globalConfig,
852
717
  projects: this.projects,
853
718
  plugins: this.plugins,
854
719
  registries: this.registries,
855
- prompt: text,
720
+ prompt: slashedPrompt,
856
721
  previousMessages,
857
- channel: "telegram",
722
+ channel: CHANNELS.TELEGRAM,
858
723
  relationshipBlock,
859
724
  allowedTools,
725
+ contextNote: slashedContextNote || undefined,
860
726
  channelMeta: buildTelegramMeta({
861
727
  channelName: this.channel.name,
862
728
  author,
@@ -932,8 +798,13 @@ class ChannelPoller {
932
798
  // turn isn't silently empty.
933
799
  const finalClean = replyText ? stripThinking(replyText).trim() : "";
934
800
  let toSend = "";
935
- if (finalClean && finalClean !== lastStreamedText) toSend = finalClean;
936
- else if (!finalClean && streamedCount === 0) toSend = "Listo.";
801
+ // Fuzzy dedupe against the last streamed segment: catches paraphrases
802
+ // (Jaccard 0.4 or short-mostly-inside-long), not just exact matches.
803
+ if (finalClean && !isLikelyDuplicate(finalClean, lastStreamedText) && finalClean !== lastStreamedText) {
804
+ toSend = finalClean;
805
+ } else if (!finalClean && streamedCount === 0) {
806
+ toSend = "Listo.";
807
+ }
937
808
 
938
809
  stopTyping();
939
810
  if (!toSend) return; // everything was already streamed — nothing left to send
@@ -949,7 +820,7 @@ class ChannelPoller {
949
820
  if (replyText && stripThinking(replyText) !== replyText) meta.thinking_stripped = true;
950
821
  if (saUsage) meta.usage = saUsage;
951
822
  appendGlobalMessage({
952
- channel: "telegram",
823
+ channel: CHANNELS.TELEGRAM,
953
824
  direction: "out",
954
825
  type: "agent",
955
826
  actor_id: replyActorId || SUPERAGENT_ACTOR_ID,
@@ -962,7 +833,7 @@ class ChannelPoller {
962
833
  } catch (e) {
963
834
  this.log(`telegram[${this.channel.name}] send-back error: ${e.message}`);
964
835
  appendGlobalMessage({
965
- channel: "telegram",
836
+ channel: CHANNELS.TELEGRAM,
966
837
  direction: "out",
967
838
  type: "agent",
968
839
  actor_id: replyActorId || SUPERAGENT_ACTOR_ID,
@@ -1138,7 +1009,7 @@ class ChannelPoller {
1138
1009
  // it up on the NEXT inbound. Mirrors how a normal text reply would be
1139
1010
  // recorded.
1140
1011
  appendGlobalMessage({
1141
- channel: "telegram",
1012
+ channel: CHANNELS.TELEGRAM,
1142
1013
  direction: "in",
1143
1014
  type: "user",
1144
1015
  actor_id: authorId ? String(authorId) : (author || "ask_flow"),
@@ -1168,10 +1039,10 @@ class ChannelPoller {
1168
1039
  registries: this.registries,
1169
1040
  prompt: compiled,
1170
1041
  previousMessages,
1171
- channel: "telegram",
1042
+ channel: CHANNELS.TELEGRAM,
1172
1043
  relationshipBlock,
1173
1044
  allowedTools,
1174
- channelMeta: { channel: "telegram", chat_id, author, route_to_agent: this.channel.route_to_agent },
1045
+ channelMeta: { channel: CHANNELS.TELEGRAM, chat_id, author, route_to_agent: this.channel.route_to_agent },
1175
1046
  });
1176
1047
  stopTyping();
1177
1048
 
@@ -1198,7 +1069,7 @@ class ChannelPoller {
1198
1069
  if (replyText) {
1199
1070
  await this._send({ chat_id, text: replyText });
1200
1071
  appendGlobalMessage({
1201
- channel: "telegram",
1072
+ channel: CHANNELS.TELEGRAM,
1202
1073
  direction: "out",
1203
1074
  type: "agent",
1204
1075
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -1401,7 +1272,7 @@ export default {
1401
1272
  if (!p) throw new Error("no telegram channel available");
1402
1273
  const result = await p._send({ chat_id, text });
1403
1274
  appendGlobalMessage({
1404
- channel: "telegram",
1275
+ channel: CHANNELS.TELEGRAM,
1405
1276
  direction: "out",
1406
1277
  type: "agent",
1407
1278
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -1431,7 +1302,7 @@ export default {
1431
1302
  if (!p) throw new Error("no telegram channel available");
1432
1303
  const result = await p._sendPhoto({ chat_id, photo, caption, parse_mode });
1433
1304
  appendGlobalMessage({
1434
- channel: "telegram",
1305
+ channel: CHANNELS.TELEGRAM,
1435
1306
  direction: "out",
1436
1307
  type: "photo",
1437
1308
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -1455,7 +1326,7 @@ export default {
1455
1326
  if (!p) throw new Error("no telegram channel available");
1456
1327
  const result = await p._sendVoice({ chat_id, audio, caption, duration });
1457
1328
  appendGlobalMessage({
1458
- channel: "telegram",
1329
+ channel: CHANNELS.TELEGRAM,
1459
1330
  direction: "out",
1460
1331
  type: "voice",
1461
1332
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -1479,7 +1350,7 @@ export default {
1479
1350
  if (!p) throw new Error("no telegram channel available");
1480
1351
  const result = await p._sendDocument({ chat_id, document, caption, filename, mime_type });
1481
1352
  appendGlobalMessage({
1482
- channel: "telegram",
1353
+ channel: CHANNELS.TELEGRAM,
1483
1354
  direction: "out",
1484
1355
  type: "document",
1485
1356
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -1503,7 +1374,7 @@ export default {
1503
1374
  if (!p) throw new Error("no telegram channel available");
1504
1375
  const result = await p._sendAudio({ chat_id, audio, caption, title, performer });
1505
1376
  appendGlobalMessage({
1506
- channel: "telegram",
1377
+ channel: CHANNELS.TELEGRAM,
1507
1378
  direction: "out",
1508
1379
  type: "audio",
1509
1380
  actor_id: SUPERAGENT_ACTOR_ID,
@@ -0,0 +1,162 @@
1
+ // Telegram media helpers: send photo/voice/document/audio + download a remote
2
+ // file. Auto-extracted from plugins/telegram/index.js — these used to live
3
+ // inline next to the poll loop and the super-agent dispatch.
4
+ //
5
+ // Each helper takes the bot token and chat id explicitly so they can be used
6
+ // from any code path (tests, other plugins, future agents). Buffer or
7
+ // absolute path input is accepted for media; for URLs the helpers pass them
8
+ // through to Telegram and let the API fetch them.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const API_BASE = "https://api.telegram.org";
13
+
14
+ /**
15
+ * Send a photo to a Telegram chat.
16
+ * @param {string} token Bot token
17
+ * @param {string|number} chatId Telegram chat_id
18
+ * @param {string|Buffer} photo Absolute file path OR Buffer of image data
19
+ * @param {object} [opts]
20
+ * @param {string} [opts.caption]
21
+ * @param {string} [opts.parse_mode] "HTML" | "Markdown" | "MarkdownV2"
22
+ */
23
+ export async function sendPhoto(token, chatId, photo, { caption, parse_mode } = {}) {
24
+ const url = `${API_BASE}/bot${token}/sendPhoto`;
25
+ const form = new FormData();
26
+ form.append("chat_id", String(chatId));
27
+ if (caption) form.append("caption", caption);
28
+ if (parse_mode) form.append("parse_mode", parse_mode);
29
+
30
+ if (typeof photo === "string" && photo.startsWith("http")) {
31
+ // Public URL — send as string
32
+ form.append("photo", photo);
33
+ } else {
34
+ // Local file path or Buffer
35
+ const buf = Buffer.isBuffer(photo) ? photo : fs.readFileSync(photo);
36
+ const name = typeof photo === "string" ? path.basename(photo) : "photo.jpg";
37
+ const blob = new Blob([buf], { type: name.endsWith(".png") ? "image/png" : "image/jpeg" });
38
+ form.append("photo", blob, name);
39
+ }
40
+
41
+ const res = await fetch(url, { method: "POST", body: form });
42
+ const json = await res.json();
43
+ if (!json.ok) throw new Error(`sendPhoto failed: ${json.description || res.status}`);
44
+ return json.result;
45
+ }
46
+
47
+ /**
48
+ * Send a voice message (OGG/Opus preferred by Telegram).
49
+ * @param {string} token
50
+ * @param {string|number} chatId
51
+ * @param {string|Buffer} audio Path or Buffer
52
+ * @param {object} [opts]
53
+ * @param {string} [opts.caption]
54
+ * @param {number} [opts.duration]
55
+ */
56
+ export async function sendVoice(token, chatId, audio, { caption, duration } = {}) {
57
+ const url = `${API_BASE}/bot${token}/sendVoice`;
58
+ const form = new FormData();
59
+ form.append("chat_id", String(chatId));
60
+ if (caption) form.append("caption", caption);
61
+ if (duration) form.append("duration", String(duration));
62
+
63
+ const buf = Buffer.isBuffer(audio) ? audio : fs.readFileSync(audio);
64
+ const name = typeof audio === "string" ? path.basename(audio) : "voice.ogg";
65
+ const blob = new Blob([buf], { type: "audio/ogg" });
66
+ form.append("voice", blob, name);
67
+
68
+ const res = await fetch(url, { method: "POST", body: form });
69
+ const json = await res.json();
70
+ if (!json.ok) throw new Error(`sendVoice failed: ${json.description || res.status}`);
71
+ return json.result;
72
+ }
73
+
74
+ /**
75
+ * Send an audio file (MP3, M4A, etc — shown in Telegram music player).
76
+ * @param {string} token
77
+ * @param {string|number} chatId
78
+ * @param {string|Buffer} audio Path or Buffer
79
+ * @param {object} [opts]
80
+ * @param {string} [opts.caption]
81
+ * @param {string} [opts.title]
82
+ * @param {string} [opts.performer]
83
+ */
84
+ /**
85
+ * Send any file as a Telegram document (PDF, zip, txt, etc).
86
+ * @param {string} token
87
+ * @param {string|number} chatId
88
+ * @param {string|Buffer} document Path or Buffer of document data
89
+ * @param {object} [opts]
90
+ * @param {string} [opts.caption]
91
+ * @param {string} [opts.filename] override filename for Buffer input
92
+ * @param {string} [opts.mime_type]
93
+ */
94
+ export async function sendDocument(token, chatId, document, { caption, filename, mime_type } = {}) {
95
+ const url = `${API_BASE}/bot${token}/sendDocument`;
96
+ const form = new FormData();
97
+ form.append("chat_id", String(chatId));
98
+ if (caption) form.append("caption", caption);
99
+
100
+ // URL string → let Telegram fetch it
101
+ if (typeof document === "string" && /^https?:\/\//.test(document)) {
102
+ form.append("document", document);
103
+ } else {
104
+ const buf = Buffer.isBuffer(document) ? document : fs.readFileSync(document);
105
+ const name =
106
+ filename ||
107
+ (typeof document === "string" ? path.basename(document) : "document.bin");
108
+ const blob = new Blob([buf], { type: mime_type || "application/octet-stream" });
109
+ form.append("document", blob, name);
110
+ }
111
+
112
+ const res = await fetch(url, { method: "POST", body: form });
113
+ const json = await res.json();
114
+ if (!json.ok) throw new Error(`sendDocument failed: ${json.description || res.status}`);
115
+ return json.result;
116
+ }
117
+
118
+ export async function sendAudio(token, chatId, audio, { caption, title, performer } = {}) {
119
+ const url = `${API_BASE}/bot${token}/sendAudio`;
120
+ const form = new FormData();
121
+ form.append("chat_id", String(chatId));
122
+ if (caption) form.append("caption", caption);
123
+ if (title) form.append("title", title);
124
+ if (performer) form.append("performer", performer);
125
+
126
+ const buf = Buffer.isBuffer(audio) ? audio : fs.readFileSync(audio);
127
+ const name = typeof audio === "string" ? path.basename(audio) : "audio.mp3";
128
+ const blob = new Blob([buf], { type: "audio/mpeg" });
129
+ form.append("audio", blob, name);
130
+
131
+ const res = await fetch(url, { method: "POST", body: form });
132
+ const json = await res.json();
133
+ if (!json.ok) throw new Error(`sendAudio failed: ${json.description || res.status}`);
134
+ return json.result;
135
+ }
136
+
137
+ // Audio transcription is delegated to the central dispatcher
138
+ // (../transcription.js) which handles local (faster-whisper via Python) +
139
+ // OpenAI cloud fallback. See that module for config keys.
140
+
141
+ /**
142
+ * Download a file from Telegram servers.
143
+ * Returns the local file path where it was saved.
144
+ */
145
+ export async function downloadTelegramFile(token, fileId, destDir) {
146
+ // Step 1: get file path from Telegram
147
+ const infoRes = await fetch(`${API_BASE}/bot${token}/getFile?file_id=${fileId}`);
148
+ const infoJson = await infoRes.json();
149
+ if (!infoJson.ok) throw new Error(`getFile failed: ${infoJson.description}`);
150
+ const filePath = infoJson.result.file_path; // e.g. "photos/file_123.jpg"
151
+ const ext = path.extname(filePath) || ".jpg";
152
+ const fileName = `tg_${fileId.slice(-8)}_${Date.now()}${ext}`;
153
+ const localPath = path.join(destDir, fileName);
154
+
155
+ // Step 2: download
156
+ const dlRes = await fetch(`${API_BASE}/file/bot${token}/${filePath}`);
157
+ if (!dlRes.ok) throw new Error(`download failed: ${dlRes.status}`);
158
+ const buf = Buffer.from(await dlRes.arrayBuffer());
159
+ fs.writeFileSync(localPath, buf);
160
+ return localPath;
161
+ }
162
+