@0xmaxma/claude-gateway 2.0.3 → 2.0.5

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 (254) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/builtin-commands.d.ts +1 -1
  3. package/dist/agent/builtin-commands.d.ts.map +1 -1
  4. package/dist/agent/builtin-commands.js +3 -2
  5. package/dist/agent/builtin-commands.js.map +1 -1
  6. package/dist/agent/runner.d.ts +109 -9
  7. package/dist/agent/runner.d.ts.map +1 -1
  8. package/dist/agent/runner.js +211 -135
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/skill-learning/index.d.ts.map +1 -1
  11. package/dist/agent/skill-learning/index.js +27 -8
  12. package/dist/agent/skill-learning/index.js.map +1 -1
  13. package/dist/agent/skill-learning/reviewer.d.ts +3 -1
  14. package/dist/agent/skill-learning/reviewer.d.ts.map +1 -1
  15. package/dist/agent/skill-learning/reviewer.js +6 -2
  16. package/dist/agent/skill-learning/reviewer.js.map +1 -1
  17. package/dist/agent/turn-stream.d.ts +3 -1
  18. package/dist/agent/turn-stream.d.ts.map +1 -1
  19. package/dist/agent/turn-stream.js +8 -4
  20. package/dist/agent/turn-stream.js.map +1 -1
  21. package/dist/api/gateway-router.d.ts +2 -0
  22. package/dist/api/gateway-router.d.ts.map +1 -1
  23. package/dist/api/gateway-router.js +229 -12
  24. package/dist/api/gateway-router.js.map +1 -1
  25. package/dist/api/router.js +11 -11
  26. package/dist/api/router.js.map +1 -1
  27. package/dist/api/slack-webhook-router.js +1 -1
  28. package/dist/api/slack-webhook-router.js.map +1 -1
  29. package/dist/api/voice-router.d.ts +1 -0
  30. package/dist/api/voice-router.d.ts.map +1 -1
  31. package/dist/api/voice-router.js +129 -7
  32. package/dist/api/voice-router.js.map +1 -1
  33. package/dist/config/claude-settings.d.ts +14 -0
  34. package/dist/config/claude-settings.d.ts.map +1 -1
  35. package/dist/config/claude-settings.js +20 -0
  36. package/dist/config/claude-settings.js.map +1 -1
  37. package/dist/orchestration/agent-cli-session.d.ts +46 -0
  38. package/dist/orchestration/agent-cli-session.d.ts.map +1 -0
  39. package/dist/orchestration/agent-cli-session.js +121 -0
  40. package/dist/orchestration/agent-cli-session.js.map +1 -0
  41. package/dist/orchestration/agent-tool-schemas.d.ts +6 -0
  42. package/dist/orchestration/agent-tool-schemas.d.ts.map +1 -0
  43. package/dist/orchestration/agent-tool-schemas.js +23 -0
  44. package/dist/orchestration/agent-tool-schemas.js.map +1 -0
  45. package/dist/orchestration/bridge.d.ts +7 -3
  46. package/dist/orchestration/bridge.d.ts.map +1 -1
  47. package/dist/orchestration/bridge.js +93 -66
  48. package/dist/orchestration/bridge.js.map +1 -1
  49. package/dist/orchestration/browser-voice.d.ts +14 -0
  50. package/dist/orchestration/browser-voice.d.ts.map +1 -0
  51. package/dist/orchestration/browser-voice.js +30 -0
  52. package/dist/orchestration/browser-voice.js.map +1 -0
  53. package/dist/orchestration/channel-input-media.d.ts +6 -0
  54. package/dist/orchestration/channel-input-media.d.ts.map +1 -1
  55. package/dist/orchestration/channel-input-media.js +35 -11
  56. package/dist/orchestration/channel-input-media.js.map +1 -1
  57. package/dist/orchestration/channel-media-error.d.ts +11 -0
  58. package/dist/orchestration/channel-media-error.d.ts.map +1 -0
  59. package/dist/orchestration/channel-media-error.js +38 -0
  60. package/dist/orchestration/channel-media-error.js.map +1 -0
  61. package/dist/orchestration/channel-media.d.ts +1 -1
  62. package/dist/orchestration/channel-media.d.ts.map +1 -1
  63. package/dist/orchestration/channel-media.js +37 -7
  64. package/dist/orchestration/channel-media.js.map +1 -1
  65. package/dist/orchestration/context-footprint.d.ts +14 -0
  66. package/dist/orchestration/context-footprint.d.ts.map +1 -0
  67. package/dist/orchestration/context-footprint.js +57 -0
  68. package/dist/orchestration/context-footprint.js.map +1 -0
  69. package/dist/orchestration/conversation-intake.d.ts +1 -1
  70. package/dist/orchestration/conversation-intake.d.ts.map +1 -1
  71. package/dist/orchestration/conversation-intake.js +1 -1
  72. package/dist/orchestration/dashboard-reader-worker.d.ts +2 -0
  73. package/dist/orchestration/dashboard-reader-worker.d.ts.map +1 -0
  74. package/dist/orchestration/dashboard-reader-worker.js +155 -0
  75. package/dist/orchestration/dashboard-reader-worker.js.map +1 -0
  76. package/dist/orchestration/dashboard-reader.d.ts +15 -0
  77. package/dist/orchestration/dashboard-reader.d.ts.map +1 -0
  78. package/dist/orchestration/dashboard-reader.js +87 -0
  79. package/dist/orchestration/dashboard-reader.js.map +1 -0
  80. package/dist/orchestration/decision-context.d.ts +15 -0
  81. package/dist/orchestration/decision-context.d.ts.map +1 -0
  82. package/dist/orchestration/decision-context.js +57 -0
  83. package/dist/orchestration/decision-context.js.map +1 -0
  84. package/dist/orchestration/delivery.d.ts +3 -0
  85. package/dist/orchestration/delivery.d.ts.map +1 -1
  86. package/dist/orchestration/delivery.js +14 -5
  87. package/dist/orchestration/delivery.js.map +1 -1
  88. package/dist/orchestration/display-stream.d.ts +21 -2
  89. package/dist/orchestration/display-stream.d.ts.map +1 -1
  90. package/dist/orchestration/display-stream.js +61 -8
  91. package/dist/orchestration/display-stream.js.map +1 -1
  92. package/dist/orchestration/inference-errors.d.ts.map +1 -1
  93. package/dist/orchestration/inference-errors.js +56 -3
  94. package/dist/orchestration/inference-errors.js.map +1 -1
  95. package/dist/orchestration/mutation-recovery.d.ts +12 -0
  96. package/dist/orchestration/mutation-recovery.d.ts.map +1 -0
  97. package/dist/orchestration/mutation-recovery.js +41 -0
  98. package/dist/orchestration/mutation-recovery.js.map +1 -0
  99. package/dist/orchestration/native-compact.d.ts +5 -0
  100. package/dist/orchestration/native-compact.d.ts.map +1 -0
  101. package/dist/orchestration/native-compact.js +29 -0
  102. package/dist/orchestration/native-compact.js.map +1 -0
  103. package/dist/orchestration/notification-mailbox.d.ts +3 -1
  104. package/dist/orchestration/notification-mailbox.d.ts.map +1 -1
  105. package/dist/orchestration/notification-mailbox.js +4 -2
  106. package/dist/orchestration/notification-mailbox.js.map +1 -1
  107. package/dist/orchestration/process-turn.d.ts +15 -2
  108. package/dist/orchestration/process-turn.d.ts.map +1 -1
  109. package/dist/orchestration/process-turn.js +103 -16
  110. package/dist/orchestration/process-turn.js.map +1 -1
  111. package/dist/orchestration/progress-review.d.ts +8 -18
  112. package/dist/orchestration/progress-review.d.ts.map +1 -1
  113. package/dist/orchestration/progress-review.js +12 -14
  114. package/dist/orchestration/progress-review.js.map +1 -1
  115. package/dist/orchestration/provider-message.d.ts +7 -0
  116. package/dist/orchestration/provider-message.d.ts.map +1 -0
  117. package/dist/orchestration/provider-message.js +63 -0
  118. package/dist/orchestration/provider-message.js.map +1 -0
  119. package/dist/orchestration/response-errors.d.ts +6 -0
  120. package/dist/orchestration/response-errors.d.ts.map +1 -0
  121. package/dist/orchestration/response-errors.js +88 -0
  122. package/dist/orchestration/response-errors.js.map +1 -0
  123. package/dist/orchestration/response-schema.d.ts +33 -0
  124. package/dist/orchestration/response-schema.d.ts.map +1 -0
  125. package/dist/orchestration/response-schema.js +61 -0
  126. package/dist/orchestration/response-schema.js.map +1 -0
  127. package/dist/orchestration/runtime.d.ts +69 -0
  128. package/dist/orchestration/runtime.d.ts.map +1 -1
  129. package/dist/orchestration/runtime.js +347 -98
  130. package/dist/orchestration/runtime.js.map +1 -1
  131. package/dist/orchestration/skills.d.ts.map +1 -1
  132. package/dist/orchestration/skills.js +3 -2
  133. package/dist/orchestration/skills.js.map +1 -1
  134. package/dist/orchestration/source-policy.d.ts +1 -1
  135. package/dist/orchestration/source-policy.d.ts.map +1 -1
  136. package/dist/orchestration/source-policy.js +1 -1
  137. package/dist/orchestration/speech.d.ts +15 -17
  138. package/dist/orchestration/speech.d.ts.map +1 -1
  139. package/dist/orchestration/speech.js +90 -30
  140. package/dist/orchestration/speech.js.map +1 -1
  141. package/dist/orchestration/store.d.ts +19 -0
  142. package/dist/orchestration/store.d.ts.map +1 -1
  143. package/dist/orchestration/store.js +44 -1
  144. package/dist/orchestration/store.js.map +1 -1
  145. package/dist/orchestration/tasks/driver.d.ts.map +1 -1
  146. package/dist/orchestration/tasks/driver.js +8 -3
  147. package/dist/orchestration/tasks/driver.js.map +1 -1
  148. package/dist/orchestration/tasks/pool.d.ts.map +1 -1
  149. package/dist/orchestration/tasks/pool.js +8 -4
  150. package/dist/orchestration/tasks/pool.js.map +1 -1
  151. package/dist/orchestration/tasks/service.d.ts +48 -1
  152. package/dist/orchestration/tasks/service.d.ts.map +1 -1
  153. package/dist/orchestration/tasks/service.js +41 -17
  154. package/dist/orchestration/tasks/service.js.map +1 -1
  155. package/dist/orchestration/token-ledger.d.ts +125 -0
  156. package/dist/orchestration/token-ledger.d.ts.map +1 -0
  157. package/dist/orchestration/token-ledger.js +232 -0
  158. package/dist/orchestration/token-ledger.js.map +1 -0
  159. package/dist/orchestration/token-usage.d.ts +46 -0
  160. package/dist/orchestration/token-usage.d.ts.map +1 -0
  161. package/dist/orchestration/token-usage.js +102 -0
  162. package/dist/orchestration/token-usage.js.map +1 -0
  163. package/dist/orchestration/tool-activity.d.ts.map +1 -1
  164. package/dist/orchestration/tool-activity.js +5 -3
  165. package/dist/orchestration/tool-activity.js.map +1 -1
  166. package/dist/orchestration/tool-name.d.ts +10 -0
  167. package/dist/orchestration/tool-name.d.ts.map +1 -0
  168. package/dist/orchestration/tool-name.js +14 -0
  169. package/dist/orchestration/tool-name.js.map +1 -0
  170. package/dist/orchestration/transcript-checkpoint.d.ts +14 -0
  171. package/dist/orchestration/transcript-checkpoint.d.ts.map +1 -0
  172. package/dist/orchestration/transcript-checkpoint.js +83 -0
  173. package/dist/orchestration/transcript-checkpoint.js.map +1 -0
  174. package/dist/orchestration/unanswered-inputs.d.ts +6 -0
  175. package/dist/orchestration/unanswered-inputs.d.ts.map +1 -0
  176. package/dist/orchestration/unanswered-inputs.js +18 -0
  177. package/dist/orchestration/unanswered-inputs.js.map +1 -0
  178. package/dist/session/process.d.ts +10 -0
  179. package/dist/session/process.d.ts.map +1 -1
  180. package/dist/session/process.js +91 -9
  181. package/dist/session/process.js.map +1 -1
  182. package/dist/session/request-tool-capture.d.ts +31 -0
  183. package/dist/session/request-tool-capture.d.ts.map +1 -0
  184. package/dist/session/request-tool-capture.js +194 -0
  185. package/dist/session/request-tool-capture.js.map +1 -0
  186. package/dist/session/runtime-profile.d.ts +7 -3
  187. package/dist/session/runtime-profile.d.ts.map +1 -1
  188. package/dist/session/runtime-profile.js +3 -1
  189. package/dist/session/runtime-profile.js.map +1 -1
  190. package/dist/session/store.d.ts +7 -0
  191. package/dist/session/store.d.ts.map +1 -1
  192. package/dist/session/store.js +27 -0
  193. package/dist/session/store.js.map +1 -1
  194. package/dist/shell/tailer.d.ts +7 -5
  195. package/dist/shell/tailer.d.ts.map +1 -1
  196. package/dist/shell/tailer.js +6 -17
  197. package/dist/shell/tailer.js.map +1 -1
  198. package/dist/ui/cli-viewer-ui.js +1 -1
  199. package/dist/ui/dashboard-client.d.ts +3 -0
  200. package/dist/ui/dashboard-client.d.ts.map +1 -0
  201. package/dist/ui/dashboard-client.js +188 -0
  202. package/dist/ui/dashboard-client.js.map +1 -0
  203. package/dist/ui/dashboard-presentation.d.ts +9 -0
  204. package/dist/ui/dashboard-presentation.d.ts.map +1 -0
  205. package/dist/ui/dashboard-presentation.js +56 -0
  206. package/dist/ui/dashboard-presentation.js.map +1 -0
  207. package/dist/ui/dashboard-range.d.ts +5 -0
  208. package/dist/ui/dashboard-range.d.ts.map +1 -0
  209. package/dist/ui/dashboard-range.js +20 -0
  210. package/dist/ui/dashboard-range.js.map +1 -0
  211. package/dist/ui/dashboard-theme.d.ts +4 -0
  212. package/dist/ui/dashboard-theme.d.ts.map +1 -0
  213. package/dist/ui/dashboard-theme.js +485 -0
  214. package/dist/ui/dashboard-theme.js.map +1 -0
  215. package/dist/ui/token-report-client.d.ts +3 -0
  216. package/dist/ui/token-report-client.d.ts.map +1 -0
  217. package/dist/ui/token-report-client.js +34 -0
  218. package/dist/ui/token-report-client.js.map +1 -0
  219. package/dist/ui/token-report.d.ts +89 -0
  220. package/dist/ui/token-report.d.ts.map +1 -0
  221. package/dist/ui/token-report.js +200 -0
  222. package/dist/ui/token-report.js.map +1 -0
  223. package/dist/ui/web-ui.d.ts.map +1 -1
  224. package/dist/ui/web-ui.js +124 -181
  225. package/dist/ui/web-ui.js.map +1 -1
  226. package/dist/voice/replay.d.ts +8 -0
  227. package/dist/voice/replay.d.ts.map +1 -0
  228. package/dist/voice/replay.js +48 -0
  229. package/dist/voice/replay.js.map +1 -0
  230. package/dist/voice/session.d.ts +9 -0
  231. package/dist/voice/session.d.ts.map +1 -1
  232. package/dist/voice/session.js +72 -14
  233. package/dist/voice/session.js.map +1 -1
  234. package/mcp/lazy-connector.test.ts +58 -0
  235. package/mcp/lazy-connector.ts +67 -0
  236. package/mcp/lazy-tools.test.ts +152 -0
  237. package/mcp/lazy-tools.ts +41 -0
  238. package/mcp/server.ts +53 -34
  239. package/mcp/tools/discord/commands.ts +2 -0
  240. package/mcp/tools/discord/inbound.ts +1 -1
  241. package/mcp/tools/discord/receiver-server.ts +1 -1
  242. package/mcp/tools/discord/types.ts +1 -1
  243. package/mcp/tools/receiver-spool.test.ts +8 -2
  244. package/mcp/tools/receiver-spool.ts +198 -25
  245. package/mcp/tools/tasks/module.ts +1 -12
  246. package/mcp/tools/telegram/media-group.ts +3 -3
  247. package/mcp/tools/telegram/receiver-server.ts +15 -15
  248. package/mcp/types.ts +1 -1
  249. package/package.json +2 -1
  250. package/resource/dashboard/OFL.txt +93 -0
  251. package/resource/dashboard/poppins-300.woff2 +0 -0
  252. package/resource/dashboard/poppins-400.woff2 +0 -0
  253. package/resource/dashboard/poppins-500.woff2 +0 -0
  254. package/resource/dashboard/poppins-600.woff2 +0 -0
@@ -0,0 +1,41 @@
1
+ import type { McpToolDefinition, McpToolResult } from './types';
2
+
3
+ export const LAZY_TOOL_DEFINITIONS: McpToolDefinition[] = [
4
+ { name: 'tool_search', description: 'Discover available gateway capabilities. Empty query lists tool names and descriptions; search by query or exact name to retrieve original argument schemas before calling tool_call. Results are paginated. This catalog contains only tools authorized for this worker.',
5
+ inputSchema: { type: 'object', properties: { query: { type: 'string' }, name: { type: 'string' }, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 20 }, include_schema: { type: 'boolean' } }, additionalProperties: false } },
6
+ { name: 'tool_call', description: 'Execute a gateway capability discovered with tool_search. Use its exact name and original arguments. All original permissions, validation and cancellation apply. Task reporting and memory retrieval remain directly available.',
7
+ inputSchema: { type: 'object', properties: { name: { type: 'string' }, arguments: { type: 'object' } }, required: ['name', 'arguments'], additionalProperties: false } },
8
+ ];
9
+
10
+ const fail = (text: string): McpToolResult => ({ content: [{ type: 'text', text }], isError: true });
11
+ const result = (value: unknown): McpToolResult => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
12
+
13
+ /** Discovery is data-only. Invocation delegates to the SAME dispatcher as direct calls. */
14
+ export function createLazyToolCatalog(tools: readonly McpToolDefinition[], excludeReserved = true) {
15
+ const reserved = new Set(LAZY_TOOL_DEFINITIONS.map(tool => tool.name));
16
+ const catalog = [...tools].filter(tool => !excludeReserved || !reserved.has(tool.name))
17
+ .sort((a, b) => a.name.localeCompare(b.name));
18
+ const names = new Set(catalog.map(tool => tool.name));
19
+ return {
20
+ search(args: Record<string, unknown>): McpToolResult {
21
+ if ((args.query !== undefined && typeof args.query !== 'string') ||
22
+ (args.name !== undefined && typeof args.name !== 'string') ||
23
+ (args.include_schema !== undefined && typeof args.include_schema !== 'boolean')) return fail('Invalid search arguments');
24
+ const offset = args.offset ?? 0; const limit = args.limit ?? 5;
25
+ if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 ||
26
+ typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 20) return fail('Invalid search pagination');
27
+ const query = String(args.query ?? '').trim().toLowerCase();
28
+ const name = String(args.name ?? '').trim().toLowerCase();
29
+ const matches = catalog.filter(tool => name ? tool.name.toLowerCase() === name :
30
+ query.split(/\s+/).every(word => `${tool.name} ${tool.description}`.toLowerCase().includes(word)));
31
+ const schemas = args.include_schema ?? Boolean(query || name);
32
+ return result({ total: matches.length, offset, next_offset: offset + limit < matches.length ? offset + limit : null,
33
+ tools: matches.slice(offset, offset + limit).map(tool => schemas ? tool : { name: tool.name, description: tool.description }) });
34
+ },
35
+ async call(args: Record<string, unknown>, dispatch: (name: string, input: Record<string, unknown>) => Promise<McpToolResult>): Promise<McpToolResult> {
36
+ if (typeof args.name !== 'string' || !names.has(args.name)) return fail('Unknown or unauthorized gateway tool');
37
+ if (!args.arguments || typeof args.arguments !== 'object' || Array.isArray(args.arguments)) return fail('Tool arguments must be an object');
38
+ return dispatch(args.name, args.arguments as Record<string, unknown>);
39
+ },
40
+ };
41
+ }
package/mcp/server.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from '@modelcontextprotocol/sdk/types.js';
15
15
  import { gatewayModules } from './modules';
16
16
  import { AGENT_TASK_TOOLS, WORKER_REPORT_TOOLS, callTaskBridge } from './tools/tasks/module';
17
+ import { createLazyToolCatalog, LAZY_TOOL_DEFINITIONS } from './lazy-tools';
17
18
  import { buildChannelInstructions } from './instructions';
18
19
  import type { ChannelModule, ToolModule, McpToolDefinition } from './types';
19
20
 
@@ -52,9 +53,23 @@ for (const mod of modules) {
52
53
  }
53
54
  }
54
55
 
55
- const taskTools = ORCHESTRATION_ROLE === 'agent' ? AGENT_TASK_TOOLS.filter(tool => (tool.name !== 'conversation_intake' || process.env.GATEWAY_SEMANTIC_INTAKE === 'true') && (tool.name !== 'capabilities_list' || process.env.GATEWAY_CAPABILITY_CATALOG === 'true')) : ORCHESTRATION_ROLE === 'worker' ? WORKER_REPORT_TOOLS.filter(tool => tool.name !== 'task_memory_append' || process.env.GATEWAY_ORCHESTRATION_WRITE_MEMORY === 'true') : [];
56
+ // conversation_intake is declared on every agent turn, whatever the semantic-intake feature
57
+ // state. The advertised tool list is position 0 of Anthropic's cached [tools, system, messages]
58
+ // prefix, and the feature is decided per turn (runtime.ts computes it from the flag AND from
59
+ // whether this turn is a notification), so filtering the declaration here re-wrote the prefix
60
+ // mid-session and invalidated the whole cache — the same defect ee800a7 fixed for the response
61
+ // schema. The turn's INTAKE_OVERLAY prompt text, below the cache breakpoint, is what asks for
62
+ // the tool; the bridge refuses the call outright when the feature is not active.
63
+ const taskTools = ORCHESTRATION_ROLE === 'agent' ? AGENT_TASK_TOOLS.filter(tool => tool.name !== 'capabilities_list' || process.env.GATEWAY_CAPABILITY_CATALOG === 'true') : ORCHESTRATION_ROLE === 'worker' ? WORKER_REPORT_TOOLS.filter(tool => tool.name !== 'task_memory_append' || process.env.GATEWAY_ORCHESTRATION_WRITE_MEMORY === 'true') : [];
56
64
  if (ORCHESTRATION_ROLE && process.env.GATEWAY_ORCHESTRATION_TICKET_FILE) visibleTools.push(...taskTools);
57
65
 
66
+ // Preserve the full policy-filtered inventory internally; only schema exposure is lazy.
67
+ const lazyEnabled = ORCHESTRATION_ROLE === 'worker' && process.env.GATEWAY_LAZY_TOOLS === 'true';
68
+ const deferredTools = visibleTools.filter(tool => toolMap.has(tool.name) && !['memory_search','memory_get'].includes(tool.name));
69
+ const deferredNames = new Set(deferredTools.map(tool => tool.name));
70
+ const lazyCatalog = createLazyToolCatalog(deferredTools);
71
+ const advertisedTools = lazyEnabled ? [...visibleTools.filter(tool => !deferredNames.has(tool.name)), ...LAZY_TOOL_DEFINITIONS] : visibleTools;
72
+
58
73
  // Initialize channel modules so their bot API clients are ready for tool calls.
59
74
  // initBot() returns immediately after creating the client — no blocking.
60
75
  for (const mod of modules) {
@@ -79,47 +94,51 @@ const mcp = new Server(
79
94
  'claude/channel/permission': {},
80
95
  },
81
96
  },
82
- instructions: ORCHESTRATION_ROLE ? 'Use scoped capability discovery, task tools and memory retrieval. The agent writes user-facing responses; workers return task results and orchestration handles delivery.' : buildChannelInstructions(imageEnabled, videoEnabled),
97
+ instructions: ORCHESTRATION_ROLE ? 'Use scoped capability discovery, task tools and memory retrieval. The agent writes user-facing responses; workers return task results and orchestration handles delivery.' + (lazyEnabled ? ' Gateway execution capabilities are available through tool_search and tool_call: search for the capability to read its original arguments, then invoke it by exact name. An unlisted execution tool is not unavailable; inspect the catalog. Task reporting and memory retrieval remain direct tools.' : '') : buildChannelInstructions(imageEnabled, videoEnabled),
83
98
  },
84
99
  );
85
100
 
86
101
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
87
- tools: visibleTools,
102
+ tools: advertisedTools,
88
103
  }));
89
104
 
90
105
  mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
91
- const toolName = req.params.name;
106
+ const dispatch = async (toolName: string, args: Record<string, unknown>) => {
107
+ if (ORCHESTRATION_ROLE && process.env.GATEWAY_ORCHESTRATION_TICKET_FILE && taskTools.some(tool => tool.name === toolName)) {
108
+ return callTaskBridge(toolName, args, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
109
+ }
110
+
111
+ const mod = toolMap.get(toolName);
112
+ if (!mod) {
113
+ return {
114
+ content: [{ type: 'text', text: `unknown tool: ${toolName}` }],
115
+ isError: true,
116
+ };
117
+ }
118
+ if (ORCHESTRATION_ROLE === 'worker' && process.env.GATEWAY_ORCHESTRATION_MEDIA === 'true') {
119
+ const validation = await callTaskBridge('task_validate', {}, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
120
+ if (validation.isError) return validation;
121
+ }
122
+
123
+ // extra.signal fires on notifications/cancelled for THIS call (the SDK matches
124
+ // it by requestId) — the CLI sends that when a user Stop/Ctrl-C interrupts an
125
+ // in-flight tool call. Threaded through so a long-running module (image
126
+ // generation's poll loop) can react instead of running to its full timeout.
127
+ // Also combine with shutdownController.signal: the CLI sending that
128
+ // notification is only a "SHOULD" in the MCP spec, not a "MUST", and in
129
+ // practice a Stop that lets the CLI process exit cleanly (rather than
130
+ // staying alive to keep chatting) closes this server's stdin without ever
131
+ // sending notifications/cancelled — extra.signal would then never fire, and
132
+ // an in-flight image generation would poll to its full timeout instead of
133
+ // cancelling. stdin closing is a reliable, protocol-independent signal that
134
+ // the turn is over either way, so it backstops the notification.
135
+ const combinedSignal = AbortSignal.any([extra.signal, shutdownController.signal]);
136
+ return mod.handleTool(toolName, args, combinedSignal);
137
+ };
92
138
  const args = (req.params.arguments ?? {}) as Record<string, unknown>;
93
- if (ORCHESTRATION_ROLE && process.env.GATEWAY_ORCHESTRATION_TICKET_FILE && taskTools.some(tool => tool.name === toolName)) {
94
- return callTaskBridge(toolName, args, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
95
- }
96
-
97
- const mod = toolMap.get(toolName);
98
- if (!mod) {
99
- return {
100
- content: [{ type: 'text', text: `unknown tool: ${toolName}` }],
101
- isError: true,
102
- };
103
- }
104
- if (ORCHESTRATION_ROLE === 'worker' && process.env.GATEWAY_ORCHESTRATION_MEDIA === 'true') {
105
- const validation = await callTaskBridge('task_validate', {}, String(extra.requestId), AbortSignal.any([extra.signal, shutdownController.signal]));
106
- if (validation.isError) return validation;
107
- }
108
-
109
- // extra.signal fires on notifications/cancelled for THIS call (the SDK matches
110
- // it by requestId) — the CLI sends that when a user Stop/Ctrl-C interrupts an
111
- // in-flight tool call. Threaded through so a long-running module (image
112
- // generation's poll loop) can react instead of running to its full timeout.
113
- // Also combine with shutdownController.signal: the CLI sending that
114
- // notification is only a "SHOULD" in the MCP spec, not a "MUST", and in
115
- // practice a Stop that lets the CLI process exit cleanly (rather than
116
- // staying alive to keep chatting) closes this server's stdin without ever
117
- // sending notifications/cancelled — extra.signal would then never fire, and
118
- // an in-flight image generation would poll to its full timeout instead of
119
- // cancelling. stdin closing is a reliable, protocol-independent signal that
120
- // the turn is over either way, so it backstops the notification.
121
- const combinedSignal = AbortSignal.any([extra.signal, shutdownController.signal]);
122
- return mod.handleTool(toolName, args, combinedSignal);
139
+ if (lazyEnabled && req.params.name === 'tool_search') return lazyCatalog.search(args);
140
+ if (lazyEnabled && req.params.name === 'tool_call') return lazyCatalog.call(args, dispatch);
141
+ return dispatch(req.params.name, args);
123
142
  });
124
143
 
125
144
  // Connect MCP transport
@@ -11,6 +11,8 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
11
11
  description: 'Ask the agent a question',
12
12
  options: [{ name: 'question', description: 'Your question', required: true, type: 'STRING' }],
13
13
  },
14
+ { name: 'clear', description: 'Reset model context without deleting chat history' },
15
+ { name: 'compact', description: 'Compact Claude Code context without changing chat history' },
14
16
  { name: 'session', description: 'Show current session info' },
15
17
  {
16
18
  name: 'new',
@@ -49,7 +49,7 @@ export function createMessageHandler(
49
49
  ts: message.createdTimestamp,
50
50
  };
51
51
 
52
- const attachments = (msg: DiscordMessage, quoted=false) => (msg.attachments.values ? [...msg.attachments.values()] : [msg.attachments.first()].filter(Boolean)).slice(0,10).map(a=>({url:a!.url,name:a!.name,kind:a!.contentType?.startsWith('image/')?'image':a!.contentType?.startsWith('audio/')?'audio':'file',quoted}));
52
+ const attachments = (msg: DiscordMessage, quoted=false) => (msg.attachments.values ? [...msg.attachments.values()] : [msg.attachments.first()].filter(Boolean)).slice(0,10).map(a=>({url:a!.url,name:a!.name,size:a!.size,kind:a!.contentType?.startsWith('image/')?'image':a!.contentType?.startsWith('audio/')?'audio':'file',quoted}));
53
53
  inbound.attachments = attachments(message);
54
54
  if (message.reference?.messageId && (!message.reference.channelId || message.reference.channelId === message.channelId)) {
55
55
  inbound.replyToMessageId = message.reference.messageId;
@@ -32,7 +32,7 @@ async function postCallback(inbound: InboundMessage): Promise<void> {
32
32
  if (inbound.attachmentFileId) {
33
33
  meta['attachment_file_id'] = inbound.attachmentFileId;
34
34
  }
35
- if(inbound.attachments?.length)meta.attachments_json=JSON.stringify(inbound.attachments.map(a=>({ref:a.url,name:a.name,kind:a.kind,quoted:a.quoted})));
35
+ if(inbound.attachments?.length)meta.attachments_json=JSON.stringify(inbound.attachments.map(a=>({ref:a.url,name:a.name,kind:a.kind,quoted:a.quoted,size:a.size})));
36
36
  if(inbound.replyToMessageId)meta.replied_message_id=inbound.replyToMessageId;
37
37
  if(inbound.repliedText)meta.replied_text=inbound.repliedText;
38
38
  if(inbound.repliedSender)meta.replied_user=inbound.repliedSender;
@@ -111,7 +111,7 @@ export interface DiscordMessage {
111
111
  reference?: {messageId?:string;channelId?:string};
112
112
  fetchReference?(): Promise<DiscordMessage>;
113
113
  flags?: number | {has(bit:number):boolean};
114
- attachments: { first(): { url: string; contentType?: string | null; name?: string } | undefined; values?(): IterableIterator<{url:string;contentType?:string|null;name?:string}> };
114
+ attachments: { first(): { url: string; contentType?: string | null; name?: string; size?: number } | undefined; values?(): IterableIterator<{url:string;contentType?:string|null;name?:string;size?:number}> };
115
115
  client: { user: { id: string } | null };
116
116
  startThread(options: { name: string; autoArchiveDuration: number }): Promise<{ id: string }>;
117
117
  }
@@ -1,4 +1,4 @@
1
- import { test, expect } from 'bun:test';
1
+ import { test, expect, spyOn } from 'bun:test';
2
2
  import { mkdtempSync, readdirSync, rmSync, readFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
@@ -6,6 +6,8 @@ import { ReceiverSpool } from './receiver-spool';
6
6
 
7
7
  test('receiver restart retains unacknowledged input and removes it only after gateway acceptance', async () => {
8
8
  const root = mkdtempSync(join(tmpdir(), 'receiver-spool-'));
9
+ let now = Date.now();
10
+ const clock = spyOn(Date, 'now').mockImplementation(() => now);
9
11
  let fail = true;
10
12
  const delivered: unknown[] = [];
11
13
  const request = (async (_url: string | URL | Request, options?: RequestInit) => {
@@ -22,7 +24,11 @@ test('receiver restart retains unacknowledged input and removes it only after ga
22
24
  spool.close(); fail = false;
23
25
  spool = new ReceiverSpool(root, 'http://fixture.invalid/channel', request);
24
26
  await new Promise(resolve => setTimeout(resolve, 10));
27
+ expect(delivered).toHaveLength(1);
28
+ expect(readdirSync(root).filter(file => file.endsWith('.json'))).toHaveLength(1);
29
+ now += 1000;
30
+ await spool.flush();
25
31
  expect(readdirSync(root)).toEqual([]);
26
32
  expect(delivered.at(-1)).toEqual(input);
27
- } finally { spool.close(); rmSync(root, { recursive: true, force: true }); }
33
+ } finally { clock.mockRestore(); spool.close(); rmSync(root, { recursive: true, force: true }); }
28
34
  });
@@ -1,55 +1,228 @@
1
1
  import { mediaGroupKey, mergeMediaGroup, type ChannelInput } from './telegram/media-group';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { mkdirSync, readdirSync, openSync, closeSync, writeFileSync, fsyncSync, renameSync, readFileSync, unlinkSync, statSync } from 'node:fs';
3
+ import { mkdirSync, readdirSync, openSync, closeSync, writeFileSync, fsyncSync, renameSync, readFileSync, unlinkSync, statSync, existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
 
6
- /** Receiver-local handoff journal. Persist before returning to the platform
7
- * client; only remove after the gateway confirms its SQLite admission. */
6
+ type RetryState = { queuedAt: number; attempts: number; nextAttemptAt: number; recoveryBatch?: string; sealedMessageIds?: string[] };
7
+ type Entry = { file: string; payload: string; input: ChannelInput; modified: number; state: RetryState; albumMessageIds?: string[] };
8
+ const MAX_BACKOFF_MS = 5 * 60_000;
9
+ const STALE_STARTUP_AGE_MS = 5 * 60_000;
10
+
11
+ /** Persist raw provider payloads until SQLite admission. Retry state is separate
12
+ * so old journals remain readable and provider deduplication identities stay intact. */
8
13
  export class ReceiverSpool {
9
14
  private active = false;
15
+ private cursor = 0;
16
+ private lastQueuedAt = 0;
17
+ private reportedInvalidEntry = false;
10
18
  private readonly timer: ReturnType<typeof setInterval>;
11
19
  constructor(private readonly directory: string, private readonly callback: string, private readonly request: typeof fetch = fetch, private readonly mediaGroupWaitMs = 2000) {
12
20
  mkdirSync(directory, { recursive: true, mode: 0o700 });
21
+ this.pruneAlbumReceipts();
22
+ const snapshot = this.entries();
23
+ this.lastQueuedAt = Math.max(0, ...snapshot.map(entry => entry.state.queuedAt));
24
+ const batches = new Map<string, string>();
25
+ for (const entry of snapshot) {
26
+ if (entry.state.recoveryBatch) batches.set(this.conversation(entry.input), entry.state.recoveryBatch);
27
+ }
28
+ for (const entry of snapshot) {
29
+ const conversation = this.conversation(entry.input);
30
+ if (Date.now() - entry.state.queuedAt > STALE_STARTUP_AGE_MS && !batches.has(conversation)) batches.set(conversation, randomUUID());
31
+ }
32
+ // Quarantine the entire startup conversation, including its newer tail.
33
+ // Persist the batch before sending anything so retries/restarts share one notice.
34
+ for (const entry of snapshot) {
35
+ const batch = batches.get(this.conversation(entry.input));
36
+ if (batch) this.saveState(entry.file, { ...entry.state, recoveryBatch: batch });
37
+ }
13
38
  this.timer = setInterval(() => { void this.flush(); }, 1000); this.timer.unref();
14
39
  void this.flush();
15
40
  }
41
+ private conversation(input: ChannelInput): string {
42
+ const m = input?.meta ?? {};
43
+ return JSON.stringify([m.source ?? m.channel ?? '', m.account_id ?? '', m.chat_id ?? '', m.message_thread_id ?? m.thread_id ?? m.thread_ts ?? '']);
44
+ }
45
+ private atomicWrite(file: string, payload: string): void {
46
+ const temporary = join(this.directory, `${randomUUID()}.tmp`);
47
+ const descriptor = openSync(temporary, 'wx', 0o600);
48
+ try { writeFileSync(descriptor, payload); fsyncSync(descriptor); } finally { closeSync(descriptor); }
49
+ renameSync(temporary, join(this.directory, file));
50
+ this.syncDirectory();
51
+ }
52
+ private state(file: string, queuedAt: number): RetryState {
53
+ try {
54
+ const state = JSON.parse(readFileSync(join(this.directory, `${file}.retry`), 'utf8')) as RetryState;
55
+ const timestamp = (value: number) => Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER;
56
+ if (!state || !timestamp(state.queuedAt) || !timestamp(state.nextAttemptAt)
57
+ || !Number.isSafeInteger(state.attempts) || state.attempts < 0
58
+ || (state.sealedMessageIds !== undefined && (!Array.isArray(state.sealedMessageIds) || state.sealedMessageIds.length > 10 || state.sealedMessageIds.some(id => typeof id !== 'string' || !id)))
59
+ || (state.recoveryBatch !== undefined && (typeof state.recoveryBatch !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(state.recoveryBatch)))) {
60
+ throw new Error('Invalid ingress retry state');
61
+ }
62
+ return state;
63
+ }
64
+ catch (error) {
65
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
66
+ return { queuedAt, attempts: 0, nextAttemptAt: 0 };
67
+ }
68
+ }
69
+ private saveState(file: string, state: RetryState): void { this.atomicWrite(`${file}.retry`, JSON.stringify(state)); }
70
+ private entries(): Entry[] {
71
+ const entries: Entry[] = [];
72
+ const blocked = new Set<string>();
73
+ for (const file of readdirSync(this.directory).filter(file => /^[a-f0-9]{64}\.json$/.test(file))) {
74
+ let input: ChannelInput | undefined;
75
+ try {
76
+ const location = join(this.directory, file);
77
+ const modified = statSync(location).mtimeMs;
78
+ const payload = readFileSync(location, 'utf8');
79
+ input = JSON.parse(payload) as ChannelInput;
80
+ if (!input || typeof input !== 'object' || typeof input.content !== 'string'
81
+ || !input.meta || typeof input.meta !== 'object' || Array.isArray(input.meta)
82
+ || Object.values(input.meta).some(value => typeof value !== 'string')) {
83
+ input = undefined;
84
+ throw new Error('Invalid ingress payload');
85
+ }
86
+ // Validate nested album metadata inside the per-record boundary, so a
87
+ // damaged album cannot abort delivery for unrelated conversations.
88
+ let albumMessageIds: string[] | undefined;
89
+ if (mediaGroupKey(input)) {
90
+ const ids: unknown = JSON.parse(input.meta.message_ids_json);
91
+ if (!Array.isArray(ids) || !ids.length || ids.length > 10 || ids.some(id=>typeof id !== 'string' || !id)) {
92
+ throw new Error('Invalid ingress album members');
93
+ }
94
+ albumMessageIds = ids;
95
+ }
96
+ entries.push({ file, modified, payload, input, state: this.state(file, modified), albumMessageIds });
97
+ } catch {
98
+ // An identifiable conversation must not skip its damaged head. Other
99
+ // conversations remain usable; preserve all bytes for operator recovery.
100
+ if (input) blocked.add(this.conversation(input));
101
+ if (!this.reportedInvalidEntry) {
102
+ this.reportedInvalidEntry = true;
103
+ process.stderr.write('Receiver spool retained an unreadable or invalid entry; inspect the local journal.\n');
104
+ }
105
+ }
106
+ }
107
+ return entries.filter(entry => !blocked.has(this.conversation(entry.input)))
108
+ .sort((a, b) => a.state.queuedAt - b.state.queuedAt || a.file.localeCompare(b.file));
109
+ }
110
+
16
111
  enqueue(input: unknown): void {
17
- const group=mediaGroupKey(input as ChannelInput);
18
- if(group){
19
- let prior:ChannelInput|undefined;
20
- try{prior=JSON.parse(readFileSync(join(this.directory,`${group}.json`),'utf8'));}catch{}
21
- input=mergeMediaGroup(prior,input as ChannelInput);
112
+ const group = mediaGroupKey(input as ChannelInput);
113
+ let albumKey = group;
114
+ if (group) {
115
+ const messageId = (input as ChannelInput).meta.message_id;
116
+ const initial = this.state(`${group}.json`, Date.now());
117
+ if (initial.sealedMessageIds) {
118
+ if (initial.sealedMessageIds.includes(messageId)) return;
119
+ // An attempted body is immutable: late members use their own stable
120
+ // first-message identity, never an expanded body under an existing ID.
121
+ albumKey = createHash('sha256').update(JSON.stringify([group, messageId])).digest('hex');
122
+ }
123
+ const target = `${albumKey}.json`;
124
+ const state = this.state(target, Date.now());
125
+ if (state.sealedMessageIds?.includes(messageId)) return;
126
+ let prior: ChannelInput | undefined;
127
+ try { prior = JSON.parse(readFileSync(join(this.directory, target), 'utf8')); }
128
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
129
+ input = mergeMediaGroup(prior, input as ChannelInput);
22
130
  }
23
131
  const payload = JSON.stringify(input);
24
132
  if (Buffer.byteLength(payload) > 131072) throw new Error('Ingress payload too large');
25
- if (readdirSync(this.directory).filter(file => file.endsWith('.json')).length >= 1000) throw new Error('Ingress spool full');
26
- const key = group ?? createHash('sha256').update(payload).digest('hex');
27
- const temporary = join(this.directory, `${key}.${randomUUID()}.tmp`);
28
- const descriptor = openSync(temporary, 'wx', 0o600);
29
- try { writeFileSync(descriptor, payload); fsyncSync(descriptor); } finally { closeSync(descriptor); }
30
- renameSync(temporary, join(this.directory, `${key}.json`));
31
- this.syncDirectory();
133
+ const key = albumKey ?? createHash('sha256').update(payload).digest('hex');
134
+ const file = `${key}.json`;
135
+ const location = join(this.directory, file);
136
+ const present = existsSync(location);
137
+ if (!present && readdirSync(this.directory).filter(file => file.endsWith('.json')).length >= 1000) throw new Error('Ingress spool full');
138
+ // Preserve admission order even when an album's quiet window is reset.
139
+ const queuedAt = present ? statSync(location).mtimeMs : Math.max(Date.now(), this.lastQueuedAt + 0.01);
140
+ const state = this.state(file, queuedAt);
141
+ this.lastQueuedAt = Math.max(this.lastQueuedAt, state.queuedAt);
142
+ this.saveState(file, state);
143
+ this.atomicWrite(file, payload);
32
144
  void this.flush();
33
145
  }
146
+ private pruneAlbumReceipts(): void {
147
+ const receipts = readdirSync(this.directory).filter(file => /^[a-f0-9]{64}\.json\.retry$/.test(file)
148
+ && !existsSync(join(this.directory, file.slice(0, -6))))
149
+ .map(file => ({ file, modified: statSync(join(this.directory, file)).mtimeMs }))
150
+ .sort((a, b) => b.modified - a.modified);
151
+ let changed = false;
152
+ for (const [index, receipt] of receipts.entries()) {
153
+ if (index < 1000 && Date.now() - receipt.modified < 24 * 60 * 60_000) continue;
154
+ // Do not remove an unknown/corrupt sidecar as part of receipt cleanup.
155
+ try {
156
+ if (!this.state(receipt.file.slice(0, -6), receipt.modified).sealedMessageIds) continue;
157
+ unlinkSync(join(this.directory, receipt.file)); changed = true;
158
+ } catch { /* Preserve unrecognized bytes for operator recovery. */ }
159
+ }
160
+ if (changed) this.syncDirectory();
161
+ }
34
162
  private syncDirectory(): void {
35
163
  const descriptor = openSync(this.directory, 'r');
36
164
  try { fsyncSync(descriptor); } finally { closeSync(descriptor); }
37
165
  }
166
+ private defer(entry: Entry, response?: Response): void {
167
+ const attempts = Math.min(Number.MAX_SAFE_INTEGER, entry.state.attempts + 1);
168
+ const exponential = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.min(attempts - 1, 9));
169
+ const header = response?.headers.get('retry-after');
170
+ const retryAfter = header ? (/^\d+(\.\d+)?$/.test(header) ? Number(header) * 1000 : Date.parse(header) - Date.now()) : 0;
171
+ // Bound our exponential policy; honor a longer server-requested pause.
172
+ const delay = Math.max(exponential, Number.isFinite(retryAfter) ? Math.min(24 * 60 * 60_000, retryAfter) : 0);
173
+ this.saveState(entry.file, { ...entry.state, attempts, nextAttemptAt: Date.now() + delay });
174
+ }
38
175
  async flush(): Promise<void> {
39
176
  if (this.active) return;
40
177
  this.active = true;
41
178
  try {
42
- const files = readdirSync(this.directory).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).map(file => ({ file, created: statSync(join(this.directory, file)).mtimeMs })).sort((a, b) => a.created - b.created);
43
- for (const { file } of files.slice(0, 100)) {
44
- const location = join(this.directory, file);
45
- const payload=readFileSync(location,'utf8');
46
- const input=JSON.parse(payload) as ChannelInput;
47
- if(mediaGroupKey(input)&&Date.now()-statSync(location).mtimeMs<this.mediaGroupWaitMs)continue;
48
- const response = await this.request(this.callback, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, signal: AbortSignal.timeout(10000) });
49
- if (!response.ok) break;
50
- if(readFileSync(location,'utf8')===payload){unlinkSync(location); this.syncDirectory();}
179
+ const conversations = new Map<string, Entry[]>();
180
+ for (const entry of this.entries()) {
181
+ const conversation = this.conversation(entry.input);
182
+ const entries = conversations.get(conversation) ?? [];
183
+ entries.push(entry);
184
+ conversations.set(conversation, entries);
185
+ }
186
+ const groups = [...conversations.values()];
187
+ // Rotate conversation heads, then drain successful conversations round-robin.
188
+ // Failed/waiting heads block only their own ordered tail.
189
+ const start = this.cursor % Math.max(groups.length, 1);
190
+ this.cursor = start + Math.min(100, groups.length);
191
+ const pending = [...groups.slice(start), ...groups.slice(0, start)];
192
+ let attempts = 0;
193
+ while (pending.length && attempts < 100) {
194
+ const group = pending.shift()!;
195
+ const entry = group.shift()!;
196
+ if (entry.state.nextAttemptAt > Date.now()) continue;
197
+ // Another callback may have yielded while this unsealed album grew.
198
+ // Refresh its full body and quiet window next pass before sealing it.
199
+ if (readFileSync(join(this.directory, entry.file), 'utf8') !== entry.payload) continue;
200
+ if (mediaGroupKey(entry.input) && Date.now() - entry.modified < this.mediaGroupWaitMs) continue;
201
+ const body = entry.state.recoveryBatch ? JSON.stringify({ ...entry.input, meta: { ...entry.input.meta, ingress_recovery_batch: entry.state.recoveryBatch } }) : entry.payload;
202
+ if (entry.albumMessageIds && !entry.state.sealedMessageIds) {
203
+ entry.state = { ...entry.state, sealedMessageIds: entry.albumMessageIds };
204
+ this.saveState(entry.file, entry.state);
205
+ }
206
+ attempts++;
207
+ let response: Response;
208
+ try {
209
+ response = await this.request(this.callback, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(10000) });
210
+ } catch { this.defer(entry); continue; }
211
+ if (!response.ok) { this.defer(entry, response); continue; }
212
+ const location = join(this.directory, entry.file);
213
+ if (readFileSync(location, 'utf8') === entry.payload) {
214
+ unlinkSync(location);
215
+ const retry = join(this.directory, `${entry.file}.retry`);
216
+ // Pending retries never expire. Acknowledged album receipts retain
217
+ // member IDs for bounded platform-redelivery deduplication.
218
+ if (entry.state.sealedMessageIds) this.saveState(entry.file, { ...entry.state, attempts: 0, nextAttemptAt: 0 });
219
+ else if (existsSync(retry)) unlinkSync(retry);
220
+ this.syncDirectory();
221
+ if (entry.state.sealedMessageIds) this.pruneAlbumReceipts();
222
+ if (group.length) pending.push(group);
223
+ }
51
224
  }
52
- } catch { /* persisted message is retried on next tick or process restart */ }
225
+ } catch { /* Keep persisted input on filesystem failure. */ }
53
226
  finally { this.active = false; }
54
227
  }
55
228
  close(): void { clearInterval(this.timer); }
@@ -5,18 +5,7 @@ import type { McpToolDefinition, McpToolResult } from '../../types';
5
5
 
6
6
  const schema = (properties: Record<string, unknown>, required: string[]) => ({ type: 'object', properties, required, additionalProperties: false });
7
7
  const text = { type: 'string' };
8
- export const AGENT_TASK_TOOLS: McpToolDefinition[] = [
9
- { name: 'capabilities_list', description: 'Discover all capabilities this agent can offer: installed/shared/native skills, gateway MCP tools and enabled connector/plugin MCP tools, including tools executed only by workers. Read metadata only; never executes a tool or grants permissions. Use query to find relevant tools before assigning work. Follow next_offset until null when the user asks for the complete list, passing catalog_version from the first page. On CAPABILITY_CATALOG_CHANGED restart from offset 0. Report unavailable discovery honestly; do not confuse your own restricted callable tools with the capabilities of the agent as a whole.', inputSchema: schema({query:text,catalog_version:text,offset:{type:'integer',minimum:0}},[]) },
10
- { name: 'conversation_intake', description: 'Prepare executable work, incomplete materials, or an amendment to an existing task. Do not call this for greetings, introductions, casual conversation or questions you can answer directly; answer those normally once. When semantic intake is enabled, acknowledge before work mutations. Incomplete material is inspected now; the gateway asks the supplied clarification after the configured silence interval, not on every item.',
11
- inputSchema: schema({mode:{type:'string',enum:['ready','wait','update']},acknowledgement:{type:'string',description:'A brief contextual message addressed directly to the user about the work you will do. Immediately displayed and spoken aloud. Never include private classification, reasoning, or instructions to yourself.'},preparation:text,clarification:text,task_id:text},['mode']) },
12
- { name: 'task_spawn', description: 'Durably queue a task for an independent worker. Returns after commit without waiting for worker startup or completion.',
13
- inputSchema: schema({ title: text, instructions: text, target_profile: text, spoken_acknowledgement: { type: 'string', description: 'Required for live voice: a short natural response in the user language explaining the specific work you are about to do. Describe the specific user request as your own task, in the voice of your existing persona; do not announce handing it to a worker or use a generic acknowledgement. Speak as the person doing the work; avoid Thai delegation phrases such as สั่งเช็คแล้ว, สั่งไปแล้ว or ส่งให้ตรวจแล้ว. Describe queued work as accepted/to do next, and only say it is running when that state is confirmed. Do not claim completion. The gateway manages playback and channel text-before-voice ordering.' }, skill_name: { type: 'string', description: 'Exact installed gateway or discovered Claude Code runtime skill name for skill-worker. Native skills are invoked directly by the CLI; shared/workspace skills are supplied with their resources. Never supply a file path.' }, skill_args: { type: 'string', description: 'Arguments for the installed skill; required with skill_name.' }, continue_task_id: { type: 'string', description: 'Prior task ID in this conversation for related follow-up work. Reuses its worker session when compatible; waits for success by default. Queue every authorized dependent step now, using each returned task ID for the next step. Omit for independent work.' }, continuation_policy: { type: 'string', enum: ['after_success', 'after_terminal'], description: 'With continue_task_id: after_success (default) starts only after the predecessor succeeds; otherwise fails without executing. Use after_terminal only for explicitly authorized recovery/inspection that must run after failure or cancellation.' }, context_refs: { type: 'array', items: text, description: 'Only orchestration input IDs, authorized attachment refs or task artifact IDs. Put project filenames in instructions instead.' } }, ['title', 'instructions', 'target_profile']) },
14
- { name: 'task_status', description: 'Read the task index in this conversation without waiting. Pass task_id to retrieve the complete stored result and evidence before discussing an earlier task. An index entry is not a partial worker report.', inputSchema: schema({ task_id: text }, []) },
15
- { name: 'task_cancel', description: 'Request cancellation, including needs_reconciliation cleanup. cancel_requested is not proof of termination. Existing files are retained. Set replaced_by_task_id only for a known replacement task, never infer from similar titles.', inputSchema: schema({ task_id: text, replaced_by_task_id: text }, ['task_id']) },
16
- { name: 'task_update', description: 'Accept a revised task instruction. applied_revision changes only when the worker accepts it.', inputSchema: schema({ task_id: text, expected_revision: { type: 'integer', minimum: 1 }, instruction: text, mode: { type: 'string', enum: ['when_ready', 'interrupt_and_resume'] } }, ['task_id', 'expected_revision', 'instruction', 'mode']) },
17
- { name: 'task_question', description: 'Manage pending questions without answering or resuming work. Ask stages one natural separate message after your reply; discuss marks ongoing consultation; defer/mute/resume persist the user reminder preference.', inputSchema: schema({ action: { type: 'string', enum: ['ask','discuss','defer','mute','resume'] }, question_ids: { type: 'array', items: text, minItems: 1, maxItems: 20 }, text, delay_ms: { type: 'integer', minimum: 60000, maximum: 2592000000 } }, ['action', 'question_ids']) },
18
- { name: 'task_answer', description: 'Answer the matching pending question on a task.', inputSchema: schema({ task_id: text, question_id: text, answer: text }, ['task_id', 'question_id', 'answer']) },
19
- ];
8
+ export { AGENT_TASK_TOOLS } from '../../../dist/orchestration/agent-tool-schemas.js';
20
9
  export const WORKER_REPORT_TOOLS: McpToolDefinition[] = [
21
10
  { name: 'task_memory_append', description: 'Append an explicitly requested memory note for a channel task. API tasks cannot write memory. Files are restricted to MEMORY.md, USER.md, or memory/<name>.md. Retains existing content and deduplicates retries.', inputSchema: schema({ note: text, path: text }, ['note']) },
22
11
  { name: 'task_stage_file', description: 'Stage a finished image or document for the user. The orchestration attaches it to the next completed agent response after this task succeeds, using the original destination. This tool does not send to a channel. Call once per output file. For an image returned by an MCP tool (such as a remote browser screenshot), omit path to use the latest captured image in this attempt, or pass source_tool_call_id to select its tool call. The gateway saves the actual image bytes; never invent a screenshot filename. For an existing local file supply path.', inputSchema: schema({ path: text, source_tool_call_id: text, caption: text }, []) },
@@ -9,12 +9,12 @@ export function mergeMediaGroup(prior:ChannelInput|undefined,input:ChannelInput)
9
9
  const ids:string[]=prior?JSON.parse(prior.meta.message_ids_json):[];
10
10
  if(ids.includes(input.meta.message_id))return prior!;
11
11
  if(ids.length>=10)throw Error('Telegram media group exceeds 10 items');
12
- const parts:Array<{id:string;caption:string;ref?:string;path?:string;name?:string}> = prior?JSON.parse(prior.meta.media_group_parts_json):[];
13
- parts.push({id:input.meta.message_id,caption:input.meta.media_caption??'',ref:input.meta.attachment_file_id,path:input.meta.image_path,name:input.meta.attachment_name});
12
+ const parts:Array<{id:string;caption:string;ref?:string;path?:string;name?:string;size?:number}> = prior?JSON.parse(prior.meta.media_group_parts_json):[];
13
+ parts.push({id:input.meta.message_id,caption:input.meta.media_caption??'',ref:input.meta.attachment_file_id,path:input.meta.image_path,name:input.meta.attachment_name,size:input.meta.attachment_size===undefined?undefined:Number(input.meta.attachment_size)});
14
14
  parts.sort((a,b)=>Number(a.id)-Number(b.id));
15
15
  const meta:Record<string,string>={...(prior?.meta??input.meta),media_group_id:input.meta.media_group_id,message_id:parts[0]!.id,
16
16
  message_ids_json:JSON.stringify(parts.map(p=>p.id)),media_group_parts_json:JSON.stringify(parts),
17
- attachments_json:JSON.stringify(parts.map(({ref,path,name})=>({ref,path,name})))};
17
+ attachments_json:JSON.stringify(parts.map(({ref,path,name,size})=>({ref,path,name,size})))};
18
18
  // Album files must not turn a mixed album into a single-file voice-note turn.
19
19
  delete meta.attachment_file_id;delete meta.image_path;delete meta.attachment_kind;
20
20
  return {content:parts.map(p=>p.caption).filter(Boolean).join('\n\n')||'[Media album attached]',meta};