@hailer/mcp 1.1.16 → 1.1.17-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/.claude/CLAUDE.md +117 -320
  2. package/.claude/commands/app-squad.md +86 -90
  3. package/.claude/commands/audit-squad.md +19 -19
  4. package/.claude/commands/autoplan.md +3 -3
  5. package/.claude/commands/cleanup-squad.md +16 -16
  6. package/.claude/commands/config-squad.md +30 -30
  7. package/.claude/commands/crud-squad.md +23 -23
  8. package/.claude/commands/data-squad.md +21 -21
  9. package/.claude/commands/debug-squad.md +44 -44
  10. package/.claude/commands/doc-squad.md +16 -16
  11. package/.claude/commands/help:agents.md +130 -99
  12. package/.claude/commands/help:commands.md +15 -15
  13. package/.claude/commands/help:faq.md +17 -17
  14. package/.claude/commands/help:plugins.md +1 -1
  15. package/.claude/commands/help:skills.md +18 -24
  16. package/.claude/commands/hotfix-squad.md +22 -22
  17. package/.claude/commands/integration-squad.md +22 -22
  18. package/.claude/commands/janitor-squad.md +31 -31
  19. package/.claude/commands/learn-auto.md +5 -5
  20. package/.claude/commands/learn.md +12 -20
  21. package/.claude/commands/onboard-squad.md +39 -49
  22. package/.claude/commands/plan-workspace.md +2 -2
  23. package/.claude/commands/publish.md +32 -37
  24. package/.claude/commands/review-squad.md +27 -27
  25. package/.claude/commands/stats.md +26 -12
  26. package/.claude/commands/swarm.md +25 -25
  27. package/.claude/skills/chrome-mcp-reference/SKILL.md +5 -0
  28. package/.claude/skills/hailer-api-client/SKILL.md +55 -16
  29. package/.claude/skills/hailer-app-builder/SKILL.md +4 -270
  30. package/.claude/skills/hailer-apps-pictures/SKILL.md +3 -3
  31. package/.claude/skills/hailer-design-system/SKILL.md +96 -4
  32. package/.claude/skills/hailer-monolith-automations/SKILL.md +138 -116
  33. package/.claude/skills/hailer-permissions-system/SKILL.md +6 -9
  34. package/.claude/skills/hailer-project-protocol/SKILL.md +20 -110
  35. package/.claude/skills/integration-patterns/SKILL.md +6 -6
  36. package/.claude/skills/lsp-setup/SKILL.md +8 -9
  37. package/.claude/skills/sdk-activity-patterns/SKILL.md +238 -0
  38. package/.claude/skills/{SDK-document-templates → sdk-document-templates}/SKILL.md +13 -340
  39. package/.claude/skills/{SDK-function-fields → sdk-function-fields}/SKILL.md +8 -40
  40. package/.claude/skills/{SDK-insight-queries → sdk-insight-queries}/SKILL.md +114 -392
  41. package/.claude/skills/{SDK-ws-config-skill → sdk-ws-config-skill}/SKILL.md +79 -310
  42. package/.claude/skills/zapier-hailer-patterns/SKILL.md +84 -361
  43. package/.opencode/package-lock.json +117 -0
  44. package/CLAUDE.md +5 -358
  45. package/dist/app.d.ts.map +1 -1
  46. package/dist/app.js +10 -127
  47. package/dist/app.js.map +1 -1
  48. package/dist/bot/bot-manager.d.ts +3 -14
  49. package/dist/bot/bot-manager.d.ts.map +1 -1
  50. package/dist/bot/bot-manager.js +13 -4
  51. package/dist/bot/bot-manager.js.map +1 -1
  52. package/dist/bot/bot.d.ts +23 -102
  53. package/dist/bot/bot.d.ts.map +1 -1
  54. package/dist/bot/bot.js +356 -1212
  55. package/dist/bot/bot.js.map +1 -1
  56. package/dist/bot/services/bot-permissions.d.ts +50 -0
  57. package/dist/bot/services/bot-permissions.d.ts.map +1 -0
  58. package/dist/bot/services/bot-permissions.js +198 -0
  59. package/dist/bot/services/bot-permissions.js.map +1 -0
  60. package/dist/bot/services/index.d.ts +4 -2
  61. package/dist/bot/services/index.d.ts.map +1 -1
  62. package/dist/bot/services/index.js +10 -5
  63. package/dist/bot/services/index.js.map +1 -1
  64. package/dist/bot/services/message-classifier.d.ts +1 -1
  65. package/dist/bot/services/message-classifier.d.ts.map +1 -1
  66. package/dist/bot/services/message-classifier.js.map +1 -1
  67. package/dist/bot/services/signal-router.d.ts +32 -0
  68. package/dist/bot/services/signal-router.d.ts.map +1 -0
  69. package/dist/bot/services/signal-router.js +132 -0
  70. package/dist/bot/services/signal-router.js.map +1 -0
  71. package/dist/bot/services/system-prompt.d.ts +12 -0
  72. package/dist/bot/services/system-prompt.d.ts.map +1 -0
  73. package/dist/bot/services/system-prompt.js +93 -0
  74. package/dist/bot/services/system-prompt.js.map +1 -0
  75. package/dist/bot/services/types.d.ts +7 -34
  76. package/dist/bot/services/types.d.ts.map +1 -1
  77. package/dist/bot/services/types.js +0 -3
  78. package/dist/bot/services/types.js.map +1 -1
  79. package/dist/bot/services/workspace-refresh.d.ts +47 -0
  80. package/dist/bot/services/workspace-refresh.d.ts.map +1 -0
  81. package/dist/bot/services/workspace-refresh.js +154 -0
  82. package/dist/bot/services/workspace-refresh.js.map +1 -0
  83. package/dist/bot-config/constants.d.ts +0 -36
  84. package/dist/bot-config/constants.d.ts.map +1 -1
  85. package/dist/bot-config/constants.js +1 -76
  86. package/dist/bot-config/constants.js.map +1 -1
  87. package/dist/bot-config/context.d.ts +2 -42
  88. package/dist/bot-config/context.d.ts.map +1 -1
  89. package/dist/bot-config/context.js +13 -134
  90. package/dist/bot-config/context.js.map +1 -1
  91. package/dist/bot-config/index.d.ts +6 -15
  92. package/dist/bot-config/index.d.ts.map +1 -1
  93. package/dist/bot-config/index.js +5 -80
  94. package/dist/bot-config/index.js.map +1 -1
  95. package/dist/bot-config/loader.d.ts +16 -4
  96. package/dist/bot-config/loader.d.ts.map +1 -1
  97. package/dist/bot-config/loader.js +187 -96
  98. package/dist/bot-config/loader.js.map +1 -1
  99. package/dist/bot-config/persistence.d.ts +1 -52
  100. package/dist/bot-config/persistence.d.ts.map +1 -1
  101. package/dist/bot-config/persistence.js +3 -213
  102. package/dist/bot-config/persistence.js.map +1 -1
  103. package/dist/bot-config/state.d.ts +0 -41
  104. package/dist/bot-config/state.d.ts.map +1 -1
  105. package/dist/bot-config/state.js +0 -151
  106. package/dist/bot-config/state.js.map +1 -1
  107. package/dist/bot-config/tools.d.ts +1 -1
  108. package/dist/bot-config/tools.js +27 -27
  109. package/dist/bot-config/tools.js.map +1 -1
  110. package/dist/bot-config/types.d.ts +39 -32
  111. package/dist/bot-config/types.d.ts.map +1 -1
  112. package/dist/bot-config/types.js +0 -3
  113. package/dist/bot-config/types.js.map +1 -1
  114. package/dist/bot-config/webhooks.d.ts +0 -4
  115. package/dist/bot-config/webhooks.d.ts.map +1 -1
  116. package/dist/bot-config/webhooks.js +0 -13
  117. package/dist/bot-config/webhooks.js.map +1 -1
  118. package/dist/commands/seed-config.js +16 -31
  119. package/dist/commands/seed-config.js.map +1 -1
  120. package/dist/config.d.ts +0 -9
  121. package/dist/config.d.ts.map +1 -1
  122. package/dist/config.js +0 -15
  123. package/dist/config.js.map +1 -1
  124. package/dist/mcp/hailer-clients.js +2 -2
  125. package/dist/mcp/hailer-clients.js.map +1 -1
  126. package/dist/mcp/tool-registry.d.ts +10 -115
  127. package/dist/mcp/tool-registry.d.ts.map +1 -1
  128. package/dist/mcp/tool-registry.js +39 -363
  129. package/dist/mcp/tool-registry.js.map +1 -1
  130. package/dist/mcp/tools/activity.d.ts +3 -0
  131. package/dist/mcp/tools/activity.d.ts.map +1 -1
  132. package/dist/mcp/tools/activity.js +8 -1
  133. package/dist/mcp/tools/activity.js.map +1 -1
  134. package/dist/mcp/tools/app-core.d.ts +3 -0
  135. package/dist/mcp/tools/app-core.d.ts.map +1 -1
  136. package/dist/mcp/tools/app-core.js +9 -2
  137. package/dist/mcp/tools/app-core.js.map +1 -1
  138. package/dist/mcp/tools/app-marketplace.d.ts +3 -0
  139. package/dist/mcp/tools/app-marketplace.d.ts.map +1 -1
  140. package/dist/mcp/tools/app-marketplace.js +13 -1
  141. package/dist/mcp/tools/app-marketplace.js.map +1 -1
  142. package/dist/mcp/tools/app-member.d.ts +3 -0
  143. package/dist/mcp/tools/app-member.d.ts.map +1 -1
  144. package/dist/mcp/tools/app-member.js +6 -1
  145. package/dist/mcp/tools/app-member.js.map +1 -1
  146. package/dist/mcp/tools/app-scaffold.d.ts +3 -0
  147. package/dist/mcp/tools/app-scaffold.d.ts.map +1 -1
  148. package/dist/mcp/tools/app-scaffold.js +15 -11
  149. package/dist/mcp/tools/app-scaffold.js.map +1 -1
  150. package/dist/mcp/tools/company.d.ts +3 -0
  151. package/dist/mcp/tools/company.d.ts.map +1 -1
  152. package/dist/mcp/tools/company.js +5 -1
  153. package/dist/mcp/tools/company.js.map +1 -1
  154. package/dist/mcp/tools/discussion.d.ts +3 -0
  155. package/dist/mcp/tools/discussion.d.ts.map +1 -1
  156. package/dist/mcp/tools/discussion.js +13 -2
  157. package/dist/mcp/tools/discussion.js.map +1 -1
  158. package/dist/mcp/tools/file.d.ts +3 -0
  159. package/dist/mcp/tools/file.d.ts.map +1 -1
  160. package/dist/mcp/tools/file.js +6 -1
  161. package/dist/mcp/tools/file.js.map +1 -1
  162. package/dist/mcp/tools/index.d.ts +7 -0
  163. package/dist/mcp/tools/index.d.ts.map +1 -0
  164. package/dist/mcp/tools/index.js +34 -0
  165. package/dist/mcp/tools/index.js.map +1 -0
  166. package/dist/mcp/tools/insight.d.ts +3 -0
  167. package/dist/mcp/tools/insight.d.ts.map +1 -1
  168. package/dist/mcp/tools/insight.js +18 -8
  169. package/dist/mcp/tools/insight.js.map +1 -1
  170. package/dist/mcp/tools/user.d.ts +3 -0
  171. package/dist/mcp/tools/user.d.ts.map +1 -1
  172. package/dist/mcp/tools/user.js +6 -1
  173. package/dist/mcp/tools/user.js.map +1 -1
  174. package/dist/mcp/tools/workflow-permissions.d.ts +3 -0
  175. package/dist/mcp/tools/workflow-permissions.d.ts.map +1 -1
  176. package/dist/mcp/tools/workflow-permissions.js +8 -1
  177. package/dist/mcp/tools/workflow-permissions.js.map +1 -1
  178. package/dist/mcp/tools/workflow.d.ts +3 -0
  179. package/dist/mcp/tools/workflow.d.ts.map +1 -1
  180. package/dist/mcp/tools/workflow.js +29 -28
  181. package/dist/mcp/tools/workflow.js.map +1 -1
  182. package/dist/mcp/utils/index.d.ts +4 -11
  183. package/dist/mcp/utils/index.d.ts.map +1 -1
  184. package/dist/mcp/utils/index.js +5 -36
  185. package/dist/mcp/utils/index.js.map +1 -1
  186. package/dist/mcp/utils/role-utils.d.ts +0 -32
  187. package/dist/mcp/utils/role-utils.d.ts.map +1 -1
  188. package/dist/mcp/utils/role-utils.js +0 -73
  189. package/dist/mcp/utils/role-utils.js.map +1 -1
  190. package/dist/mcp/utils/tool-helpers.d.ts +0 -25
  191. package/dist/mcp/utils/tool-helpers.d.ts.map +1 -1
  192. package/dist/mcp/utils/tool-helpers.js +0 -34
  193. package/dist/mcp/utils/tool-helpers.js.map +1 -1
  194. package/dist/mcp/webhook-handler.d.ts +4 -34
  195. package/dist/mcp/webhook-handler.d.ts.map +1 -1
  196. package/dist/mcp/webhook-handler.js +57 -74
  197. package/dist/mcp/webhook-handler.js.map +1 -1
  198. package/dist/mcp-server.d.ts.map +1 -1
  199. package/dist/mcp-server.js +3 -78
  200. package/dist/mcp-server.js.map +1 -1
  201. package/package.json +1 -2
  202. package/.claude/agents/agent-ada-skill-builder.md +0 -94
  203. package/.claude/agents/agent-alejandro-function-fields.md +0 -342
  204. package/.claude/agents/agent-bjorn-config-audit.md +0 -103
  205. package/.claude/agents/agent-builder-agent-creator.md +0 -130
  206. package/.claude/agents/agent-code-simplifier.md +0 -53
  207. package/.claude/agents/agent-dmitri-activity-crud.md +0 -159
  208. package/.claude/agents/agent-giuseppe-app-builder.md +0 -208
  209. package/.claude/agents/agent-gunther-mcp-tools.md +0 -39
  210. package/.claude/agents/agent-helga-workflow-config.md +0 -204
  211. package/.claude/agents/agent-igor-activity-mover-automation.md +0 -125
  212. package/.claude/agents/agent-ingrid-doc-templates.md +0 -261
  213. package/.claude/agents/agent-ivan-monolith.md +0 -154
  214. package/.claude/agents/agent-kenji-data-reader.md +0 -86
  215. package/.claude/agents/agent-lars-code-inspector.md +0 -102
  216. package/.claude/agents/agent-marco-mockup-builder.md +0 -110
  217. package/.claude/agents/agent-marcus-api-documenter.md +0 -323
  218. package/.claude/agents/agent-marketplace-publisher.md +0 -280
  219. package/.claude/agents/agent-marketplace-reviewer.md +0 -309
  220. package/.claude/agents/agent-permissions-handler.md +0 -208
  221. package/.claude/agents/agent-simple-writer.md +0 -48
  222. package/.claude/agents/agent-svetlana-code-review.md +0 -171
  223. package/.claude/agents/agent-tanya-test-runner.md +0 -333
  224. package/.claude/agents/agent-ui-designer.md +0 -100
  225. package/.claude/agents/agent-viktor-sql-insights.md +0 -212
  226. package/.claude/agents/agent-web-search.md +0 -55
  227. package/.claude/agents/agent-yevgeni-discussions.md +0 -45
  228. package/.claude/agents/agent-zara-zapier.md +0 -159
  229. package/.claude/skills/SDK-activity-patterns/SKILL.md +0 -428
  230. package/.claude/skills/SDK-generate-skill/SKILL.md +0 -92
  231. package/.claude/skills/SDK-init-skill/SKILL.md +0 -127
  232. package/.claude/skills/agent-structure/SKILL.md +0 -98
  233. package/.claude/skills/delegation-routing/SKILL.md +0 -202
  234. package/.claude/skills/frontend-design/SKILL.md +0 -254
  235. package/.claude/skills/hailer-activity-mover/SKILL.md +0 -213
  236. package/.claude/skills/hailer-rest-api/SKILL.md +0 -61
  237. package/.claude/skills/hailer-rest-api/hailer-activities.md +0 -184
  238. package/.claude/skills/hailer-rest-api/hailer-admin.md +0 -473
  239. package/.claude/skills/hailer-rest-api/hailer-calendar.md +0 -256
  240. package/.claude/skills/hailer-rest-api/hailer-feed.md +0 -249
  241. package/.claude/skills/hailer-rest-api/hailer-insights.md +0 -195
  242. package/.claude/skills/hailer-rest-api/hailer-messaging.md +0 -276
  243. package/.claude/skills/hailer-rest-api/hailer-workflows.md +0 -283
  244. package/.claude/skills/insight-join-patterns/SKILL.md +0 -174
  245. package/.claude/skills/json-only-output/SKILL.md +0 -72
  246. package/.claude/skills/mcp-direct-tools/SKILL.md +0 -153
  247. package/.claude/skills/optional-parameters/SKILL.md +0 -72
  248. package/.claude/skills/tool-parameter-usage/SKILL.md +0 -126
  249. package/.claude/skills/tool-response-verification/SKILL.md +0 -92
  250. package/.opencode/agent/agent-ada-skill-builder.md +0 -35
  251. package/.opencode/agent/agent-alejandro-function-fields.md +0 -39
  252. package/.opencode/agent/agent-bjorn-config-audit.md +0 -36
  253. package/.opencode/agent/agent-builder-agent-creator.md +0 -39
  254. package/.opencode/agent/agent-code-simplifier.md +0 -31
  255. package/.opencode/agent/agent-dmitri-activity-crud.md +0 -40
  256. package/.opencode/agent/agent-giuseppe-app-builder.md +0 -37
  257. package/.opencode/agent/agent-gunther-mcp-tools.md +0 -39
  258. package/.opencode/agent/agent-helga-workflow-config.md +0 -204
  259. package/.opencode/agent/agent-igor-activity-mover-automation.md +0 -46
  260. package/.opencode/agent/agent-ingrid-doc-templates.md +0 -39
  261. package/.opencode/agent/agent-ivan-monolith.md +0 -46
  262. package/.opencode/agent/agent-kenji-data-reader.md +0 -53
  263. package/.opencode/agent/agent-lars-code-inspector.md +0 -28
  264. package/.opencode/agent/agent-marco-mockup-builder.md +0 -42
  265. package/.opencode/agent/agent-marcus-api-documenter.md +0 -53
  266. package/.opencode/agent/agent-marketplace-publisher.md +0 -44
  267. package/.opencode/agent/agent-marketplace-reviewer.md +0 -42
  268. package/.opencode/agent/agent-permissions-handler.md +0 -50
  269. package/.opencode/agent/agent-simple-writer.md +0 -45
  270. package/.opencode/agent/agent-svetlana-code-review.md +0 -39
  271. package/.opencode/agent/agent-tanya-test-runner.md +0 -57
  272. package/.opencode/agent/agent-ui-designer.md +0 -56
  273. package/.opencode/agent/agent-viktor-sql-insights.md +0 -34
  274. package/.opencode/agent/agent-web-search.md +0 -42
  275. package/.opencode/agent/agent-yevgeni-discussions.md +0 -37
  276. package/.opencode/agent/agent-zara-zapier.md +0 -53
  277. package/.opencode/commands/app-squad.md +0 -135
  278. package/.opencode/commands/audit-squad.md +0 -158
  279. package/.opencode/commands/autoplan.md +0 -563
  280. package/.opencode/commands/cleanup-squad.md +0 -98
  281. package/.opencode/commands/config-squad.md +0 -106
  282. package/.opencode/commands/crud-squad.md +0 -87
  283. package/.opencode/commands/data-squad.md +0 -97
  284. package/.opencode/commands/debug-squad.md +0 -303
  285. package/.opencode/commands/doc-squad.md +0 -65
  286. package/.opencode/commands/handoff.md +0 -137
  287. package/.opencode/commands/health.md +0 -49
  288. package/.opencode/commands/help-agents.md +0 -151
  289. package/.opencode/commands/help-commands.md +0 -32
  290. package/.opencode/commands/help-faq.md +0 -29
  291. package/.opencode/commands/help-plugins.md +0 -28
  292. package/.opencode/commands/help-skills.md +0 -7
  293. package/.opencode/commands/help-tools.md +0 -40
  294. package/.opencode/commands/help.md +0 -28
  295. package/.opencode/commands/hotfix-squad.md +0 -112
  296. package/.opencode/commands/integration-squad.md +0 -82
  297. package/.opencode/commands/janitor-squad.md +0 -167
  298. package/.opencode/commands/learn-auto.md +0 -120
  299. package/.opencode/commands/learn.md +0 -120
  300. package/.opencode/commands/mcp-list.md +0 -27
  301. package/.opencode/commands/onboard-squad.md +0 -140
  302. package/.opencode/commands/plan-workspace.md +0 -732
  303. package/.opencode/commands/prd.md +0 -131
  304. package/.opencode/commands/project-status.md +0 -82
  305. package/.opencode/commands/publish.md +0 -138
  306. package/.opencode/commands/recap.md +0 -69
  307. package/.opencode/commands/restore.md +0 -64
  308. package/.opencode/commands/review-squad.md +0 -152
  309. package/.opencode/commands/save.md +0 -24
  310. package/.opencode/commands/stats.md +0 -19
  311. package/.opencode/commands/swarm.md +0 -210
  312. package/.opencode/commands/tool-builder.md +0 -39
  313. package/.opencode/commands/ws-pull.md +0 -44
package/dist/bot/bot.js CHANGED
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  /**
3
- * Bot - Single class replacing the agents/ hierarchy.
3
+ * Bot connects to Hailer, listens for messages, responds via Anthropic API.
4
4
  *
5
- * Connects to Hailer, subscribes to messenger signals,
6
- * processes messages through the Anthropic API with tool use,
7
- * and posts responses back to discussions.
5
+ * This is the orchestrator. Domain logic lives in services:
6
+ * - SignalRouter: rate limiting, dedup, discussion state
7
+ * - BotPermissions: workspace permission checks, post-filtering
8
+ * - WorkspaceRefresh: debounced cache refresh from Hailer signals
9
+ * - buildSystemPrompt: LLM system prompt construction
10
+ * - ConversationManager, MessageClassifier, MessageFormatterService, etc.
8
11
  */
9
12
  var __importDefault = (this && this.__importDefault) || function (mod) {
10
13
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -22,74 +25,42 @@ const workspace_overview_1 = require("./workspace-overview");
22
25
  const operation_logger_1 = require("./operation-logger");
23
26
  const services_1 = require("./services");
24
27
  const logger_1 = require("../lib/logger");
25
- const permission_guard_1 = require("./services/permission-guard");
26
- /**
27
- * Tools available to the bot. Add/remove tool names here to control what the LLM can use.
28
- * The MCP server (Claude Code terminal) is unaffected - it keeps full access to all tools.
29
- */
28
+ // ===== CONSTANTS =====
29
+ const WRITE_TOOLS = new Set(['create_activity', 'update_activity']);
30
+ const DISCUSSION_TOOLS = new Set([
31
+ 'fetch_discussion_messages', 'fetch_previous_discussion_messages',
32
+ 'add_discussion_message', 'invite_discussion_members', 'get_activity_from_discussion',
33
+ ]);
30
34
  const BOT_TOOLS = new Set([
31
- // Data reading
32
- 'list_workflows',
33
- 'list_workflows_minimal',
34
- 'list_workflow_phases',
35
- 'get_workflow_schema',
36
- 'list_activities',
37
- 'show_activity_by_id',
38
- 'count_activities',
39
- 'search_workspace_users',
40
- 'get_workspace_balance',
41
- // Data writing
42
- 'create_activity',
43
- 'update_activity',
44
- // Workflow management
35
+ 'list_workflows', 'list_workflows_minimal', 'list_workflow_phases', 'get_workflow_schema',
36
+ 'list_activities', 'show_activity_by_id', 'count_activities',
37
+ 'search_workspace_users', 'get_workspace_balance',
38
+ 'create_activity', 'update_activity',
45
39
  'install_workflow',
46
- // Discussions
47
- 'list_my_discussions',
48
- 'fetch_discussion_messages',
49
- 'fetch_previous_discussion_messages',
50
- 'add_discussion_message',
51
- 'join_discussion',
52
- 'leave_discussion',
53
- 'invite_discussion_members',
54
- 'get_activity_from_discussion',
55
- // Files
56
- 'upload_files',
57
- 'download_file',
58
- // Insights
59
- 'list_insights',
60
- 'get_insight_data',
61
- 'preview_insight',
62
- 'create_insight',
63
- 'update_insight',
64
- // Permissions
65
- 'list_workflow_permissions',
66
- 'check_user_permissions',
67
- 'grant_workflow_permission',
68
- 'revoke_workflow_permission',
40
+ 'list_my_discussions', 'fetch_discussion_messages', 'fetch_previous_discussion_messages',
41
+ 'add_discussion_message', 'join_discussion', 'leave_discussion',
42
+ 'invite_discussion_members', 'get_activity_from_discussion',
43
+ 'upload_files', 'download_file',
44
+ 'list_insights', 'get_insight_data', 'preview_insight', 'create_insight', 'update_insight',
45
+ 'list_workflow_permissions', 'check_user_permissions',
46
+ 'grant_workflow_permission', 'revoke_workflow_permission',
69
47
  ]);
70
- /** Tools that require the requesting user to be a workspace admin or owner */
71
- const ADMIN_ONLY_TOOLS = new Set([
72
- 'grant_workflow_permission',
73
- 'revoke_workflow_permission',
74
- ]);
75
- /**
76
- * Tools where the workflowId only appears in the result, not the args.
77
- * These run first, then the result is checked for workflow access.
78
- */
79
- const POST_CHECK_TOOLS = new Set([
80
- 'show_activity_by_id',
81
- 'fetch_discussion_messages',
82
- 'get_activity_from_discussion',
83
- ]);
84
- /** Minimal tool set for SIMPLE-routed messages (greetings, quick lookups). */
85
48
  const MODEL_HAIKU = 'claude-haiku-4-5-20251001';
86
49
  const MODEL_SONNET = 'claude-sonnet-4-5-20250929';
87
50
  const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
88
- const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB — skip vision for larger images
51
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
89
52
  const MAX_TOOL_ITERATIONS = 10;
90
- const RATE_LIMIT_WINDOW = 60_000; // 60 seconds
91
- const RATE_LIMIT_PER_DISCUSSION = 30; // max signals per discussion per window
92
- const RATE_LIMIT_GLOBAL = 100; // max signals total per window
53
+ const TOOL_STATUS_LABELS = {
54
+ list_activities: 'Searching activities', count_activities: 'Counting activities',
55
+ show_activity_by_id: 'Reading activity', create_activity: 'Creating activity',
56
+ update_activity: 'Updating activity', list_workflows: 'Loading workflows',
57
+ list_workflows_minimal: 'Loading workflows', get_workflow_schema: 'Reading workflow schema',
58
+ list_workflow_phases: 'Loading phases', preview_insight: 'Running query',
59
+ get_insight_data: 'Running report', search_workspace_users: 'Searching users',
60
+ fetch_discussion_messages: 'Reading messages', add_discussion_message: 'Sending message',
61
+ join_discussion: 'Joining discussion',
62
+ };
63
+ // ===== BOT CLASS =====
93
64
  class Bot {
94
65
  logger;
95
66
  clientManager;
@@ -103,55 +74,37 @@ class Bot {
103
74
  workspaceOverview = '';
104
75
  userId = '';
105
76
  _workspaceId;
106
- // Live-updatable config (can be hot-swapped without restart)
77
+ // Live-updatable config
107
78
  _systemPrompt;
108
79
  _responseMode = 'always';
109
80
  // Services
81
+ signalRouter;
82
+ permissions;
83
+ workspaceRefresh;
110
84
  conversationManager = null;
111
85
  messageClassifier = null;
112
86
  messageFormatter = null;
113
87
  typingIndicator = null;
114
88
  tokenBilling = null;
115
- sessionLogger = null;
116
89
  hailerApi = null;
117
- /** Cache of insightId → Set<workflowId> for permission checks on ID-based insight tools */
118
- insightWorkflowCache = new Map();
119
90
  opLogger = new operation_logger_1.OperationLogger();
120
- // State
121
- discussionStates = new Map();
122
- processingDiscussions = new Set(); // concurrency guard only
123
- static DISENGAGE_THRESHOLD = 5;
124
- processedMessageIds = new Set();
125
- signalTimestampsPerDiscussion = new Map();
126
- globalSignalTimestamps = [];
91
+ cachedTools = null;
92
+ // Signal handlers (for cleanup)
127
93
  signalHandler = null;
128
94
  processUpdatedHandler = null;
129
95
  workspaceUpdatedHandler = null;
130
96
  cacheInvalidateHandler = null;
131
97
  _connected = false;
132
- refreshTimer = null;
133
- refreshDirty = false;
134
- refreshScope = new Set();
135
- isRefreshing = false;
136
- refreshQueued = false;
137
- permissionIndex = new Map();
138
- adminUserIds = new Set();
139
- ownerUserIds = new Set();
140
- workspaceMemberIds = new Set();
141
- membersLoaded = false;
142
98
  // Config
143
99
  config;
144
- botManager; // BotManager ref for cross-bot awareness
100
+ botManager;
145
101
  constructor(config) {
146
102
  this.config = {
147
- email: config.email,
148
- password: config.password,
149
- apiHost: config.apiHost,
103
+ email: config.email, password: config.password, apiHost: config.apiHost,
150
104
  anthropicApiKey: config.anthropicApiKey,
151
- model: config.model || 'claude-haiku-4-5-20251001',
105
+ model: config.model || MODEL_HAIKU,
152
106
  toolRegistry: config.toolRegistry,
153
- workspaceId: config.workspaceId,
154
- systemPrompt: config.systemPrompt,
107
+ workspaceId: config.workspaceId, systemPrompt: config.systemPrompt,
155
108
  accessLevel: config.accessLevel || 'all',
156
109
  allowedWorkflows: config.allowedWorkflows || [],
157
110
  };
@@ -161,14 +114,17 @@ class Bot {
161
114
  this.logger = (0, logger_1.createLogger)({ component: 'Bot' });
162
115
  this.clientManager = new hailer_clients_1.HailerClientManager(config.apiHost, config.email, config.password);
163
116
  this.toolExecutor = new tool_executor_1.ToolExecutor(config.toolRegistry);
117
+ this.signalRouter = new services_1.SignalRouter(this.logger);
118
+ this.permissions = new services_1.BotPermissions(this.config.allowedWorkflows, this.logger);
119
+ this.workspaceRefresh = new services_1.WorkspaceRefresh(this.logger);
164
120
  }
121
+ // Public accessors (used by BotManager for hot-reload)
165
122
  get email() { return this.config.email; }
166
123
  get password() { return this.config.password; }
167
124
  get accessLevel() { return this.config.accessLevel; }
168
- /**
169
- * Hot-update the system prompt without restarting the bot.
170
- * Takes effect on the next message processed.
171
- */
125
+ get connected() { return this._connected; }
126
+ get workspaceId() { return this._workspaceId; }
127
+ get botUserId() { return this.userId; }
172
128
  updateSystemPrompt(prompt) {
173
129
  this._systemPrompt = prompt;
174
130
  this.logger.info('System prompt updated live', { hasPrompt: !!prompt });
@@ -177,52 +133,34 @@ class Bot {
177
133
  this._responseMode = mode || 'always';
178
134
  this.logger.info('Response mode updated live', { responseMode: this._responseMode });
179
135
  }
180
- get connected() {
181
- return this._connected;
182
- }
183
- get workspaceId() {
184
- return this._workspaceId;
185
- }
186
- /** Exposed for BotManager cross-bot awareness */
187
- get botUserId() {
188
- return this.userId;
189
- }
190
136
  // ===== LIFECYCLE =====
191
137
  async start() {
192
- if (this._connected) {
193
- this.logger.warn('Bot.start() called while already connected, ignoring');
138
+ if (this._connected)
194
139
  return;
195
- }
196
140
  this.logger.debug('Starting bot', { email: this.config.email });
197
- // 1. Connect to Hailer
141
+ // 1. Connect
198
142
  this.client = await this.clientManager.connect();
199
- // Clear password from memory now that connection is established
200
- this.config.password = '';
201
- // 2. Get user ID
202
- const userInit = await this.client.socket.request('v2.core.init', [['user']]);
203
- this.userId = userInit?.user?._id;
204
- if (!this.userId)
205
- throw new Error('Could not determine bot user ID');
206
- // 3. Fetch workspace data
143
+ this.config.password = ''; // Clear from memory connection is established
144
+ // 2. Fetch workspace data + user in one call
207
145
  this.init = await this.client.socket.request('v2.core.init', [
208
- ['processes', 'users', 'network', 'networks', 'teams', 'groups'],
146
+ ['user', 'processes', 'users', 'network', 'networks', 'teams', 'groups'],
209
147
  ]);
148
+ this.userId = this.init.user?._id;
149
+ if (!this.userId)
150
+ throw new Error('Could not determine bot user ID');
210
151
  (0, types_1.normalizeInitProcesses)(this.init);
211
152
  this._workspaceId = this.init.network?._id || this.config.workspaceId;
212
- // 4. Build workspace cache and overview (before permission index so names resolve)
153
+ // 3. Build caches
213
154
  this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
214
- excludeTranslations: true,
215
- excludeSystemMessages: true,
216
- excludeEmptyFields: true,
217
- compactUserData: true,
218
- includeWorkspaceNamesInTools: false,
155
+ excludeTranslations: true, excludeSystemMessages: true,
156
+ excludeEmptyFields: true, compactUserData: true, includeWorkspaceNamesInTools: false,
219
157
  });
220
- this.backfillWorkspaceCacheMembers();
221
- this.buildPermissionIndex();
158
+ (0, services_1.backfillWorkspaceCacheMembers)(this.workspaceCache, this.init, this.logger);
159
+ this.permissions.build(this.init, this._workspaceId);
222
160
  this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
223
- // 5. Create Anthropic client
161
+ // 4. Anthropic client
224
162
  this.anthropic = new sdk_1.default({ apiKey: this.config.anthropicApiKey });
225
- // 6. Build BotConnection adapter for services
163
+ // 5. Services
226
164
  const botConnection = {
227
165
  client: this.client,
228
166
  workspaceCache: this.workspaceCache ? {
@@ -231,83 +169,52 @@ class Bot {
231
169
  rawInit: this.workspaceCache.rawInit,
232
170
  } : undefined,
233
171
  };
234
- // 7. Create services
235
172
  this.conversationManager = new services_1.ConversationManager(100, 50, this.logger);
236
173
  this.messageClassifier = new services_1.MessageClassifier(this.userId, botConnection, this.logger);
237
174
  this.typingIndicator = new services_1.TypingIndicatorService(botConnection, this.logger);
238
- // Token billing
239
175
  const hailerApi = new hailer_api_client_1.HailerApiClient(this.client);
240
176
  this.hailerApi = hailerApi;
241
177
  this.tokenBilling = new services_1.TokenBillingService(this.logger, hailerApi);
242
- // Message formatter - wrap ToolExecutor as MCP-style callback
243
- const toolCallback = async (name, args) => {
244
- return this.toolExecutor.execute(name, args, this.getUserContext());
245
- };
178
+ const toolCallback = async (name, args) => this.toolExecutor.execute(name, args, this.getUserContext());
246
179
  this.messageFormatter = new services_1.MessageFormatterService(botConnection, this.logger, toolCallback);
247
- // Session logger
248
- this.sessionLogger = new services_1.SessionLoggerService(null, this.logger, toolCallback, () => this.getDefaultTeamId());
249
- this.sessionLogger.setAnthropicClient(this.anthropic);
250
- // 8. Build UserContext for tool execution
180
+ // 6. UserContext for tool execution
251
181
  const currentWorkspaceId = this.init.network?._id || '';
252
182
  this.userContext = {
253
- client: this.client,
254
- hailer: hailerApi,
255
- init: this.init,
256
- workspaceCache: this.workspaceCache,
257
- apiKey: '',
258
- createdAt: Date.now(),
259
- email: this.config.email,
260
- password: this.config.password,
183
+ client: this.client, hailer: hailerApi, init: this.init,
184
+ workspaceCache: this.workspaceCache, apiKey: '', createdAt: Date.now(),
185
+ email: this.config.email, password: '', // Intentionally empty — cleared after connect
261
186
  workspaceRoles: { [currentWorkspaceId]: 'admin' },
262
- currentWorkspaceId,
263
- allowedGroups: ['read', 'write', 'bot_internal'],
264
- };
265
- // 9. Subscribe to messenger.new signals
266
- this.signalHandler = (eventData) => {
267
- const signal = {
268
- type: 'messenger.new',
269
- data: eventData,
270
- timestamp: Date.now(),
271
- workspaceId: eventData.sid,
272
- };
273
- this.handleSignal(signal);
187
+ currentWorkspaceId, allowedGroups: ['read', 'write', 'bot_internal'],
274
188
  };
189
+ // 7. Wire workspace refresh
190
+ this.workspaceRefresh.setHandler(async (scopes) => {
191
+ if (!this.client || !this.init)
192
+ return;
193
+ const result = await services_1.WorkspaceRefresh.refresh(this.client, scopes, this.init, this.config.workspaceId, this.permissions, this.userContext, this.logger);
194
+ this.init = result.init;
195
+ this.workspaceCache = result.workspaceCache;
196
+ this.workspaceOverview = result.workspaceOverview;
197
+ this._workspaceId = result.workspaceId;
198
+ });
199
+ // 8. Subscribe to signals
200
+ this.signalHandler = (data) => this.handleSignal({
201
+ type: 'messenger.new', data, timestamp: Date.now(),
202
+ workspaceId: data.sid,
203
+ });
275
204
  this.clientManager.onSignal('messenger.new', this.signalHandler);
276
- // 10. Subscribe to workspace change signals for live updates
277
- this.processUpdatedHandler = () => this.scheduleRefresh('processes');
278
- this.workspaceUpdatedHandler = () => this.scheduleRefresh('network');
279
- this.cacheInvalidateHandler = () => this.scheduleRefresh('full');
205
+ this.processUpdatedHandler = () => this.workspaceRefresh.schedule('processes');
206
+ this.workspaceUpdatedHandler = () => this.workspaceRefresh.schedule('network');
207
+ this.cacheInvalidateHandler = () => this.workspaceRefresh.schedule('full');
280
208
  this.clientManager.onSignal('process.updated', this.processUpdatedHandler);
281
209
  this.clientManager.onSignal('workspace.updated', this.workspaceUpdatedHandler);
282
210
  this.clientManager.onSignal('cache.invalidate', this.cacheInvalidateHandler);
283
- // TODO: Subscribe to notification.reload for reminder handling
284
- // Blocked: need to confirm reminderWorker is running and how the signal
285
- // reaches the bot's socket (room join + event channel). See memory/project_reminder_bot_integration.md
286
211
  this._connected = true;
287
- this.logger.debug('Bot started', {
288
- userId: this.userId,
289
- workspaceId: this._workspaceId,
290
- });
212
+ this.logger.debug('Bot started', { userId: this.userId, workspaceId: this._workspaceId });
291
213
  }
292
214
  async stop() {
293
215
  this.logger.debug('Stopping bot');
294
- // Clear refresh timer
295
- if (this.refreshTimer) {
296
- clearTimeout(this.refreshTimer);
297
- this.refreshTimer = null;
298
- }
299
- // Flush sessions
300
- this.sessionLogger?.stopIdleCheckTimer();
301
- await this.sessionLogger?.flushAllSessions();
302
- // Abort all in-flight discussions before clearing state
303
- for (const [, state] of this.discussionStates) {
304
- state.abortController?.abort();
305
- }
306
- this.discussionStates.clear();
307
- // Clear rate limit tracking
308
- this.signalTimestampsPerDiscussion.clear();
309
- this.globalSignalTimestamps = [];
310
- // Unsubscribe from signals
216
+ this.workspaceRefresh.cancel();
217
+ this.signalRouter.clear();
311
218
  if (this.signalHandler) {
312
219
  this.clientManager.offSignal('messenger.new', this.signalHandler);
313
220
  this.signalHandler = null;
@@ -324,25 +231,11 @@ class Bot {
324
231
  this.clientManager.offSignal('cache.invalidate', this.cacheInvalidateHandler);
325
232
  this.cacheInvalidateHandler = null;
326
233
  }
327
- // Disconnect
328
234
  this.clientManager.disconnect();
329
235
  this._connected = false;
330
236
  this.client = null;
331
237
  this.logger.debug('Bot stopped');
332
238
  }
333
- // ===== RESPONSE MODE =====
334
- checkTriggerCondition(message) {
335
- // Private DMs always pass — 1:1 conversations are always intentional
336
- if (message.isPrivateDiscussion)
337
- return true;
338
- switch (this._responseMode) {
339
- case 'mention_only': return message.isMention;
340
- case 'reply_only': return message.isReplyToBot;
341
- case 'mention_or_reply': return message.isMention || message.isReplyToBot;
342
- case 'always':
343
- default: return true;
344
- }
345
- }
346
239
  // ===== SIGNAL HANDLING =====
347
240
  async handleSignal(signal) {
348
241
  if (!this._connected || !this.messageClassifier)
@@ -351,194 +244,110 @@ class Bot {
351
244
  const rawMsgId = signal.data.msg_id;
352
245
  const discussionId = signal.data.discussion;
353
246
  const dedupKey = rawMsgId || (discussionId ? `${discussionId}:${signal.data.uid}:${signal.data.created}` : null);
354
- if (dedupKey) {
355
- if (this.processedMessageIds.has(dedupKey))
356
- return;
357
- this.processedMessageIds.add(dedupKey);
358
- }
359
- else {
360
- this.logger.warn('messenger.new signal has no dedup key, skipping');
247
+ if (!dedupKey) {
248
+ this.logger.warn('messenger.new signal has no dedup key');
361
249
  return;
362
250
  }
251
+ if (this.signalRouter.isDuplicate(dedupKey))
252
+ return;
363
253
  const message = await this.messageClassifier.extractIncomingMessage(signal);
364
254
  if (!message) {
365
255
  if (rawMsgId)
366
- this.processedMessageIds.delete(rawMsgId);
256
+ this.signalRouter.removeDedupKey(rawMsgId);
367
257
  return;
368
258
  }
369
- // Self-message guard: prevent bot feedback loops (checks ALL bots in this workspace)
259
+ // Self-message guard
370
260
  if (message.senderId === this.userId)
371
261
  return;
372
262
  if (this.botManager && this._workspaceId) {
373
- const botUserIds = this.botManager.getBotUserIdsForWorkspace(this._workspaceId);
374
- if (botUserIds.has(message.senderId))
263
+ if (this.botManager.getBotUserIdsForWorkspace(this._workspaceId).has(message.senderId))
375
264
  return;
376
265
  }
377
266
  // Rate limiting
378
- const now = Date.now();
379
- const cutoff = now - RATE_LIMIT_WINDOW;
380
- // Per-discussion rate limit
381
- const discId = message.discussionId;
382
- let discTimestamps = this.signalTimestampsPerDiscussion.get(discId);
383
- if (!discTimestamps) {
384
- discTimestamps = [];
385
- this.signalTimestampsPerDiscussion.set(discId, discTimestamps);
386
- }
387
- // Prune old entries
388
- while (discTimestamps.length > 0 && discTimestamps[0] < cutoff) {
389
- discTimestamps.shift();
390
- }
391
- if (discTimestamps.length >= RATE_LIMIT_PER_DISCUSSION) {
392
- this.logger.warn('Per-discussion rate limit exceeded', {
393
- discussionId: discId,
394
- count: discTimestamps.length,
395
- window: RATE_LIMIT_WINDOW,
396
- });
267
+ if (this.signalRouter.isRateLimited(message.discussionId))
397
268
  return;
398
- }
399
- discTimestamps.push(now);
400
- // Global rate limit
401
- while (this.globalSignalTimestamps.length > 0 && this.globalSignalTimestamps[0] < cutoff) {
402
- this.globalSignalTimestamps.shift();
403
- }
404
- if (this.globalSignalTimestamps.length >= RATE_LIMIT_GLOBAL) {
405
- this.logger.warn('Global rate limit exceeded', {
406
- count: this.globalSignalTimestamps.length,
407
- window: RATE_LIMIT_WINDOW,
408
- });
269
+ // Layer 1: Workspace membership
270
+ if (!this.permissions.isMember(message.senderId)) {
271
+ this.logger.debug('Ignoring message - not a workspace member', { senderId: message.senderId });
272
+ if (rawMsgId)
273
+ this.signalRouter.removeDedupKey(rawMsgId);
409
274
  return;
410
275
  }
411
- this.globalSignalTimestamps.push(now);
412
- // Layer 1: Workspace membership check (fail-closed)
413
- // Only process messages from verified workspace members.
414
- // If member data hasn't loaded yet, reject — don't fail open.
415
- if (!this.membersLoaded || !this.workspaceMemberIds.has(message.senderId)) {
416
- this.logger.debug('Ignoring message - sender not a verified workspace member', {
417
- senderId: message.senderId,
418
- senderName: message.senderName,
419
- membersLoaded: this.membersLoaded,
420
- });
276
+ // Layer 2: Access level
277
+ if (!this.permissions.meetsAccessLevel(message.senderId, this.config.accessLevel)) {
278
+ this.logger.debug('Ignoring message - access level not met', { senderId: message.senderId });
421
279
  if (rawMsgId)
422
- this.processedMessageIds.delete(rawMsgId);
280
+ this.signalRouter.removeDedupKey(rawMsgId);
423
281
  return;
424
282
  }
425
- // Layer 2: AI Hub access gate — who can talk to this bot
426
- if (this.config.accessLevel !== 'all') {
427
- const isAdmin = this.adminUserIds.has(message.senderId);
428
- const isOwner = this.ownerUserIds.has(message.senderId);
429
- const allowed = this.config.accessLevel === 'owner' ? isOwner : (isAdmin || isOwner);
430
- if (!allowed) {
431
- this.logger.debug('Ignoring message - sender does not meet access level', {
432
- senderId: message.senderId,
433
- senderName: message.senderName,
434
- requiredLevel: this.config.accessLevel,
435
- isAdmin,
436
- isOwner,
437
- });
438
- if (rawMsgId)
439
- this.processedMessageIds.delete(rawMsgId);
440
- return;
441
- }
442
- }
443
- // Prune dedup set
444
- if (this.processedMessageIds.size > 500) {
445
- const ids = Array.from(this.processedMessageIds);
446
- this.processedMessageIds = new Set(ids.slice(-250));
447
- }
448
- const state = this.getOrCreateDiscussionState(discId);
449
- // Always buffer the message (provides context when bot IS triggered later)
283
+ // Engagement logic
284
+ const state = this.signalRouter.getOrCreateState(message.discussionId);
450
285
  state.contextBuffer.push(message);
451
- // Check response mode trigger condition BEFORE engagement logic
452
- // DMs always pass — 1:1 conversations are always intentional
453
- const triggerMet = this.checkTriggerCondition(message);
454
- if (!triggerMet) {
455
- return; // Signal dropped — no LLM call, no token cost
456
- }
457
- // Determine if this should trigger processing
458
- // Private discussions (DMs) are always explicit triggers
459
- const isExplicitTrigger = message.isMention || message.isReplyToBot || message.isPrivateDiscussion;
286
+ if (!this.signalRouter.checkTrigger(message, this._responseMode))
287
+ return;
288
+ const isExplicit = this.signalRouter.isExplicitTrigger(message);
460
289
  if (state.state === 'idle') {
461
- // In 'always' mode, every message triggers engagement (original behavior)
462
- if (isExplicitTrigger || this._responseMode === 'always') {
290
+ if (isExplicit || this._responseMode === 'always') {
463
291
  state.state = 'engaged';
464
292
  state.consecutiveNonResponses = 0;
465
- this.opLogger.engage(discId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : message.isDirectMessage ? 'dm' : 'always');
293
+ this.opLogger.engage(message.discussionId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : message.isDirectMessage ? 'dm' : 'always');
466
294
  }
467
295
  else {
468
- // Not addressed while idle — message sits in buffer as context only
469
296
  return;
470
297
  }
471
298
  }
472
- // In non-'always' modes, each message must independently meet the trigger condition
473
- // This prevents the bot from staying engaged and responding to everything after one mention
474
- if (this._responseMode !== 'always' && !isExplicitTrigger) {
299
+ if (this._responseMode !== 'always' && !isExplicit)
475
300
  return;
476
- }
477
- // State is 'engaged' — trigger processing if not already running
478
- if (!this.processingDiscussions.has(discId)) {
479
- this.processDiscussion(discId).catch(err => {
480
- this.logger.error('processDiscussion failed', { discussionId: discId, error: err });
481
- this.processingDiscussions.delete(discId);
301
+ // Process if not already running
302
+ if (!this.signalRouter.processingDiscussions.has(message.discussionId)) {
303
+ this.processDiscussion(message.discussionId).catch(err => {
304
+ this.logger.error('processDiscussion failed', { discussionId: message.discussionId, error: err });
305
+ this.signalRouter.processingDiscussions.delete(message.discussionId);
482
306
  });
483
307
  }
484
- else {
485
- // Already processing — message stays in contextBuffer.
486
- // injectPendingContext delivers it between tool calls as <context>,
487
- // and the LLM naturally decides whether to continue or stop.
488
- }
489
308
  }
490
309
  catch (error) {
491
310
  this.logger.error('Failed to handle signal', error);
492
311
  }
493
312
  }
313
+ // ===== DISCUSSION PROCESSING =====
494
314
  async processDiscussion(discussionId) {
495
- if (this.processingDiscussions.has(discussionId))
315
+ if (this.signalRouter.processingDiscussions.has(discussionId))
496
316
  return;
497
- this.processingDiscussions.add(discussionId);
498
- const state = this.getOrCreateDiscussionState(discussionId);
317
+ this.signalRouter.processingDiscussions.add(discussionId);
318
+ const state = this.signalRouter.getOrCreateState(discussionId);
499
319
  try {
500
320
  while (true) {
501
- // Drain buffer
502
321
  const messages = state.contextBuffer.splice(0);
503
322
  if (messages.length === 0)
504
323
  break;
505
- if (messages.length > 1) {
324
+ if (messages.length > 1)
506
325
  this.opLogger.coalesce(discussionId, messages.length);
507
- }
508
- // Merge all buffered messages into a single user message (conversation alternation)
326
+ // Build conversation entry
509
327
  const conversation = this.conversationManager.getConversation(discussionId);
510
328
  const parts = messages.map(msg => this.formatIncomingMessage(msg));
511
329
  let merged = parts.join('\n\n');
512
330
  if (messages.length > 1) {
513
331
  merged = `<context type="coalesced" count="${messages.length}">These ${messages.length} messages arrived while you were processing. Respond to the most recent/relevant.</context>\n\n${merged}`;
514
332
  }
515
- // Resolve image attachments for vision
516
333
  const imageBlocks = await this.resolveImageAttachments(messages);
517
334
  if (imageBlocks.length > 0) {
518
- conversation.push({
519
- role: 'user',
520
- content: [{ type: 'text', text: merged }, ...imageBlocks],
521
- });
335
+ conversation.push({ role: 'user', content: [{ type: 'text', text: merged }, ...imageBlocks] });
522
336
  }
523
337
  else {
524
338
  conversation.push({ role: 'user', content: merged });
525
339
  }
526
- // Use last message for routing/billing context
527
340
  const primaryMessage = messages[messages.length - 1];
528
341
  state.lastProgressTime = 0;
529
342
  state.abortController = new AbortController();
530
- // Log each incoming message
531
- for (const msg of messages) {
343
+ for (const msg of messages)
532
344
  this.opLogger.messageIn(msg.discussionId, msg.senderName, msg.content);
533
- }
534
- // Process — the LLM decides whether to respond
535
345
  let responded;
536
346
  try {
537
347
  responded = await this.processMessage(primaryMessage, state.abortController.signal);
538
348
  }
539
349
  catch (error) {
540
350
  if (error instanceof sdk_1.default.APIUserAbortError) {
541
- // Aborted — new message arrived, loop will restart with updated buffer
542
351
  this.typingIndicator?.stop(discussionId);
543
352
  continue;
544
353
  }
@@ -549,18 +358,16 @@ class Bot {
549
358
  }
550
359
  else {
551
360
  state.consecutiveNonResponses++;
552
- if (state.consecutiveNonResponses >= Bot.DISENGAGE_THRESHOLD) {
361
+ if (state.consecutiveNonResponses >= this.signalRouter.disengageThreshold) {
553
362
  state.state = 'idle';
554
363
  this.opLogger.disengage(discussionId, state.consecutiveNonResponses);
555
364
  break;
556
365
  }
557
366
  }
558
- // Check if more messages arrived during processing
559
367
  if (state.contextBuffer.length === 0)
560
368
  break;
561
- // If disengaged, only continue if there's an explicit trigger
562
369
  if (state.state === 'idle') {
563
- const hasExplicit = state.contextBuffer.some(m => m.isMention || m.isReplyToBot || m.isDirectMessage);
370
+ const hasExplicit = state.contextBuffer.some(m => this.signalRouter.isExplicitTrigger(m));
564
371
  if (!hasExplicit)
565
372
  break;
566
373
  state.state = 'engaged';
@@ -570,571 +377,361 @@ class Bot {
570
377
  }
571
378
  }
572
379
  finally {
573
- this.processingDiscussions.delete(discussionId);
380
+ this.signalRouter.processingDiscussions.delete(discussionId);
574
381
  state.abortController = null;
575
382
  }
576
383
  }
384
+ // ===== MESSAGE PROCESSING =====
385
+ async processMessage(message, signal) {
386
+ this.typingIndicator?.start(message.discussionId, 'Reading');
387
+ // Check balance
388
+ const billingWsId = this._workspaceId || message.workspaceId;
389
+ if (this.tokenBilling && billingWsId) {
390
+ const balance = await this.tokenBilling.checkBalance(billingWsId);
391
+ this.opLogger.balanceCheck(message.discussionId, billingWsId, balance.balance, balance.hasBalance ? (balance.balance < 5 ? 'LOW' : 'OK') : 'EMPTY');
392
+ if (!balance.hasBalance) {
393
+ this.typingIndicator?.stop(message.discussionId);
394
+ await this.sendMessage(message.discussionId, 'Insufficient balance. Please top up your workspace AI credits to continue.');
395
+ return true;
396
+ }
397
+ }
398
+ this.conversationManager.manageContextSize(message.discussionId);
399
+ const conversation = this.conversationManager.getConversation(message.discussionId);
400
+ const snapshotLength = conversation.length;
401
+ try {
402
+ const route = await this.routeMessage(message, conversation, signal);
403
+ return await this.runLlmLoop(message, route, signal);
404
+ }
405
+ catch (error) {
406
+ if (error instanceof sdk_1.default.APIUserAbortError)
407
+ throw error;
408
+ this.logger.error('Message processing failed', error);
409
+ this.typingIndicator?.stop(message.discussionId);
410
+ conversation.length = snapshotLength;
411
+ return false;
412
+ }
413
+ }
577
414
  // ===== MODEL ROUTING =====
578
- /**
579
- * Classify message complexity using a lightweight Haiku call.
580
- * Returns model + max_tokens to use for the main LLM loop.
581
- * Falls back to Haiku on any error.
582
- */
583
415
  async routeMessage(message, conversation, signal) {
584
- const defaultRoute = {
585
- model: MODEL_HAIKU,
586
- maxTokens: 2000,
587
- };
416
+ const defaultRoute = { model: MODEL_HAIKU, maxTokens: 2000 };
588
417
  try {
589
418
  const wsName = this.init?.network?.name || 'Workspace';
590
419
  const recentContext = this.getRecentContext(conversation);
591
- const contextBlock = recentContext
592
- ? `\nRecent context:\n${recentContext}\n`
593
- : '';
420
+ const contextBlock = recentContext ? `\nRecent context:\n${recentContext}\n` : '';
594
421
  const response = await this.anthropic.messages.create({
595
- model: MODEL_HAIKU,
596
- max_tokens: 10,
597
- temperature: 0,
598
- messages: [
599
- {
600
- role: 'user',
601
- content: `Classify this chat message as SIMPLE or COMPLEX.
602
-
603
- SIMPLE = can be answered in 1-2 tool calls with straightforward logic.
604
- COMPLEX = needs 3+ tool calls, reasoning across multiple data sources, fixing/creating structured data, or investigation.
605
-
606
- When in doubt, classify as COMPLEX.
607
-
608
- Workspace: ${wsName}
609
- ${contextBlock}Current message: ${message.content}
610
-
611
- Reply with exactly one word: SIMPLE or COMPLEX`,
612
- },
613
- ],
422
+ model: MODEL_HAIKU, max_tokens: 10, temperature: 0,
423
+ messages: [{ role: 'user', content: `Classify this chat message as SIMPLE or COMPLEX.\n\nSIMPLE = can be answered in 1-2 tool calls with straightforward logic.\nCOMPLEX = needs 3+ tool calls, reasoning across multiple data sources, fixing/creating structured data, or investigation.\n\nWhen in doubt, classify as COMPLEX.\n\nWorkspace: ${wsName}\n${contextBlock}Current message: ${message.content}\n\nReply with exactly one word: SIMPLE or COMPLEX` }],
614
424
  }, { signal });
615
- // Bill the router call
616
- this.trackRouterTokenUsage(response, message);
617
- // Parse classification
618
- const text = response.content
619
- .filter((b) => b.type === 'text')
620
- .map(b => b.text)
621
- .join('')
622
- .trim()
623
- .toUpperCase();
425
+ this.trackTokenUsage(response, message, MODEL_HAIKU);
426
+ const text = response.content.filter((b) => b.type === 'text').map(b => b.text).join('').trim().toUpperCase();
624
427
  if (text.includes('COMPLEX')) {
625
- const route = {
626
- model: MODEL_SONNET,
627
- maxTokens: 16384,
628
- };
428
+ const route = { model: MODEL_SONNET, maxTokens: 16384 };
629
429
  this.opLogger.route(message.discussionId, 'COMPLEX', route.model, message.content);
630
430
  return route;
631
431
  }
632
432
  this.opLogger.route(message.discussionId, 'SIMPLE', defaultRoute.model, message.content);
633
433
  return defaultRoute;
634
434
  }
635
- catch (error) {
636
- this.logger.warn('Router failed, falling back to Haiku', {
637
- error: error instanceof Error ? error.message : String(error),
638
- });
435
+ catch {
639
436
  return defaultRoute;
640
437
  }
641
438
  }
642
- /**
643
- * Extract recent conversation text for the router prompt.
644
- * Walks backwards, extracts only text blocks (no tool calls/results),
645
- * truncates each to 200 chars. Returns up to 3 recent exchanges.
646
- */
647
439
  getRecentContext(conversation) {
648
440
  const lines = [];
649
441
  let count = 0;
650
442
  for (let i = conversation.length - 1; i >= 0 && count < 3; i--) {
651
443
  const msg = conversation[i];
652
- const content = msg.content;
653
444
  let text = '';
654
- if (typeof content === 'string') {
655
- text = content;
445
+ if (typeof msg.content === 'string') {
446
+ text = msg.content;
656
447
  }
657
- else if (Array.isArray(content)) {
658
- text = content
659
- .filter((b) => b.type === 'text')
660
- .map((b) => b.text)
661
- .join(' ');
448
+ else if (Array.isArray(msg.content)) {
449
+ text = msg.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ');
662
450
  }
663
451
  if (!text)
664
452
  continue;
665
- const truncated = text.length > 200 ? text.slice(0, 200) + '...' : text;
666
- const role = msg.role === 'user' ? 'User' : 'Assistant';
667
- lines.unshift(`${role}: ${truncated}`);
453
+ lines.unshift(`${msg.role === 'user' ? 'User' : 'Assistant'}: ${text.length > 200 ? text.slice(0, 200) + '...' : text}`);
668
454
  count++;
669
455
  }
670
456
  return lines.join('\n');
671
457
  }
672
- /**
673
- * Bill the router classification call the same way as main LLM calls.
674
- */
675
- trackRouterTokenUsage(response, message) {
676
- if (!response.usage)
677
- return;
678
- const { input_tokens, output_tokens } = response.usage;
679
- const cacheCreation = response.usage.cache_creation_input_tokens || 0;
680
- const cacheRead = response.usage.cache_read_input_tokens || 0;
681
- const burnWorkspaceId = this._workspaceId || message.workspaceId;
682
- if (this.tokenBilling && burnWorkspaceId) {
683
- const cost = this.tokenBilling.calculateCost(input_tokens, output_tokens, cacheCreation, cacheRead, MODEL_HAIKU);
684
- this.tokenBilling.burnTokens({
685
- workspaceId: burnWorkspaceId,
686
- inputTokens: input_tokens,
687
- outputTokens: output_tokens,
688
- cacheCreationTokens: cacheCreation,
689
- cacheReadTokens: cacheRead,
690
- costUsd: cost,
691
- sessionId: message.discussionId,
692
- model: MODEL_HAIKU,
693
- });
694
- }
695
- }
696
- // ===== MESSAGE PROCESSING =====
697
- async processMessage(message, signal) {
698
- this.typingIndicator?.start(message.discussionId, 'Reading');
699
- // Check token balance
700
- const billingWorkspaceId = this._workspaceId || message.workspaceId;
701
- if (this.tokenBilling && billingWorkspaceId) {
702
- const balance = await this.tokenBilling.checkBalance(billingWorkspaceId);
703
- const balanceStatus = balance.hasBalance ? (balance.balance < 5 ? 'LOW' : 'OK') : 'EMPTY';
704
- this.opLogger.balanceCheck(message.discussionId, billingWorkspaceId, balance.balance, balanceStatus);
705
- if (!balance.hasBalance) {
706
- this.typingIndicator?.stop(message.discussionId);
707
- await this.sendMessage(message.discussionId, 'Insufficient balance. Please top up your workspace AI credits to continue.');
708
- return true; // We did respond (with balance error)
709
- }
710
- }
711
- // Manage context size
712
- this.conversationManager.manageContextSize(message.discussionId);
713
- const conversation = this.conversationManager.getConversation(message.discussionId);
714
- const snapshotLength = conversation.length;
715
- try {
716
- const route = await this.routeMessage(message, conversation, signal);
717
- return await this.runLlmLoop(message, route, signal);
718
- }
719
- catch (error) {
720
- if (error instanceof sdk_1.default.APIUserAbortError) {
721
- throw error; // Let processDiscussion handle abort
722
- }
723
- this.logger.error('Message processing failed', error);
724
- this.typingIndicator?.stop(message.discussionId);
725
- conversation.length = snapshotLength;
726
- return false;
727
- }
728
- }
458
+ // ===== LLM LOOP =====
729
459
  async runLlmLoop(message, route, signal) {
730
460
  const conversation = this.conversationManager.getConversation(message.discussionId);
731
- const systemPrompt = this.buildSystemPrompt();
732
- const tools = this.getAnthropicTools();
461
+ const systemPrompt = (0, services_1.buildSystemPrompt)({
462
+ init: this.init, userId: this.userId,
463
+ workspaceOverview: this.workspaceOverview, customPrompt: this._systemPrompt,
464
+ });
465
+ const tools = this.cachedTools || this.getAnthropicTools();
733
466
  const processingStartTime = Date.now();
734
467
  for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
735
468
  const cachedConversation = this.conversationManager.prepareForCaching(conversation);
469
+ this.typingIndicator?.updateStatus(message.discussionId, i === 0 ? 'Thinking' : 'Processing');
736
470
  let response;
737
471
  let llmStart = Date.now();
738
- this.typingIndicator?.updateStatus(message.discussionId, i === 0 ? 'Thinking' : 'Processing');
739
472
  try {
740
473
  response = await this.anthropic.messages.create({
741
- model: route.model,
742
- max_tokens: route.maxTokens,
743
- temperature: 0,
474
+ model: route.model, max_tokens: route.maxTokens, temperature: 0,
744
475
  system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
745
- messages: cachedConversation,
746
- tools,
476
+ messages: cachedConversation, tools,
747
477
  }, { signal });
748
478
  }
749
479
  catch (error) {
750
- // Handle abort — new message arrived, restart processing loop
751
480
  if (error instanceof sdk_1.default.APIUserAbortError) {
752
481
  this.cleanupIncompleteExchange(conversation);
753
482
  this.opLogger.interrupt(message.discussionId, 'new message received');
754
- return false; // processDiscussion loop restarts with full context
483
+ return false;
755
484
  }
756
- // If Sonnet fails, fall back to Haiku and retry once
757
485
  if (route.model !== MODEL_HAIKU) {
758
- this.logger.warn('Sonnet API call failed, falling back to Haiku', {
759
- error: error instanceof Error ? error.message : String(error),
760
- });
486
+ this.logger.warn('Sonnet failed, falling back to Haiku');
761
487
  route = { model: MODEL_HAIKU, maxTokens: 2000 };
762
- this.typingIndicator?.updateStatus(message.discussionId, 'Switching models');
763
488
  llmStart = Date.now();
764
489
  response = await this.anthropic.messages.create({
765
- model: MODEL_HAIKU,
766
- max_tokens: 2000,
767
- temperature: 0,
490
+ model: MODEL_HAIKU, max_tokens: 2000, temperature: 0,
768
491
  system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
769
- messages: cachedConversation,
770
- tools,
492
+ messages: cachedConversation, tools,
771
493
  }, { signal });
772
494
  }
773
495
  else {
774
496
  throw error;
775
497
  }
776
498
  }
777
- // Track token usage
778
499
  this.trackTokenUsage(response, message, route.model);
779
- // Log LLM call with timing and cache stats
780
500
  const llmDuration = (Date.now() - llmStart) / 1000;
781
501
  const usage = response.usage;
782
502
  const cacheRead = usage.cache_read_input_tokens || 0;
783
503
  const totalInput = usage.input_tokens + cacheRead + (usage.cache_creation_input_tokens || 0);
784
- const cacheHitPct = totalInput > 0 ? Math.round((cacheRead / totalInput) * 100) : 0;
785
- this.opLogger.llmCall(message.discussionId, route.model, usage.input_tokens, usage.output_tokens, cacheHitPct, llmDuration);
786
- // Validate assistant response has content before adding to conversation
787
- if (!response.content || (Array.isArray(response.content) && response.content.length === 0)) {
788
- this.logger.warn('LLM returned empty content, skipping', {
789
- discussionId: message.discussionId,
790
- model: route.model,
791
- stopReason: response.stop_reason,
792
- });
504
+ this.opLogger.llmCall(message.discussionId, route.model, usage.input_tokens, usage.output_tokens, totalInput > 0 ? Math.round((cacheRead / totalInput) * 100) : 0, llmDuration);
505
+ if (!response.content || response.content.length === 0)
793
506
  break;
794
- }
795
- // Add assistant response to conversation
796
- conversation.push({
797
- role: 'assistant',
798
- content: response.content,
799
- });
800
- // Check for output truncation — if max_tokens was hit, tool call JSON is likely broken
507
+ conversation.push({ role: 'assistant', content: response.content });
508
+ // Truncation guard
801
509
  if (response.stop_reason === 'max_tokens') {
802
- this.logger.warn('LLM output truncated at max_tokens', {
803
- discussionId: message.discussionId,
804
- model: route.model,
805
- maxTokens: route.maxTokens,
806
- outputTokens: usage.output_tokens,
807
- });
808
- // Don't execute broken tool calls — tell the LLM to use smaller batches
809
- conversation.push({
810
- role: 'user',
811
- content: 'Your previous response was truncated because it exceeded the output token limit. Do NOT try to generate all items at once. Break it into smaller batches of 25-50 items per tool call. You have multiple iterations available.',
812
- });
510
+ conversation.push({ role: 'user', content: 'Your previous response was truncated because it exceeded the output token limit. Break it into smaller batches of 25-50 items per tool call.' });
813
511
  continue;
814
512
  }
815
- // Check for tool calls
513
+ // Tool calls
816
514
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
817
515
  if (toolUseBlocks.length > 0) {
818
516
  this.typingIndicator?.updateStatus(message.discussionId, this.getToolStatus(toolUseBlocks));
819
517
  const toolResults = await this.executeTools(toolUseBlocks, message, signal);
820
518
  conversation.push({ role: 'user', content: toolResults });
821
- // Inject any messages that arrived during tool execution
822
519
  await this.injectPendingContext(message.discussionId, conversation);
823
- // Auto-escalate: if Haiku is failing tool calls, switch to Sonnet
520
+ // Auto-escalate
824
521
  if (route.model === MODEL_HAIKU) {
825
- const failedCount = toolResults
826
- .filter(r => r.is_error).length;
522
+ const failedCount = toolResults.filter(r => r.is_error).length;
827
523
  if (failedCount >= 2) {
828
- this.logger.info('Escalating to Sonnet — Haiku failing tool calls', {
829
- discussionId: message.discussionId,
830
- failedTools: failedCount,
831
- iteration: i,
832
- });
833
524
  route = { model: MODEL_SONNET, maxTokens: 16384 };
834
- await this.sendMessage(message.discussionId, `Switching to a more capable model to handle this.`);
525
+ await this.sendMessage(message.discussionId, 'Switching to a more capable model to handle this.');
835
526
  }
836
527
  }
837
- // Progress feedback: send contextual update every 20s during long operations
838
- const state = this.discussionStates.get(message.discussionId);
528
+ // Progress feedback
529
+ const state = this.signalRouter.getState(message.discussionId);
839
530
  const now = Date.now();
840
- const elapsed = now - processingStartTime;
841
- const lastProgress = state?.lastProgressTime || processingStartTime;
842
- if (state && elapsed > 8000 && (now - lastProgress) >= 20000) {
531
+ if (state && (now - processingStartTime) > 8000 && (now - (state.lastProgressTime || processingStartTime)) >= 20000) {
843
532
  state.lastProgressTime = now;
844
- const toolCounts = new Map();
845
- for (const b of toolUseBlocks) {
846
- const name = b.name.replace(/_/g, ' ');
847
- toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
848
- }
849
- const toolSummary = Array.from(toolCounts.entries())
850
- .map(([name, count]) => count > 1 ? `${name} x${count}` : name)
851
- .join(', ');
852
- const progressMsg = `Still working (step ${i + 1}) — ${toolSummary}`;
853
- await this.sendMessage(message.discussionId, progressMsg);
533
+ const toolSummary = toolUseBlocks.map(b => b.name.replace(/_/g, ' ')).join(', ');
534
+ await this.sendMessage(message.discussionId, `Still working (step ${i + 1}) — ${toolSummary}`);
854
535
  this.opLogger.progress(message.discussionId);
855
536
  }
856
- continue; // Loop back for more tool calls or final response
537
+ continue;
857
538
  }
858
- // Extract text response
859
- const textBlocks = response.content.filter((b) => b.type === 'text');
860
- const responseText = textBlocks.map(b => b.text).join('\n').trim();
539
+ // Text response
540
+ const responseText = response.content.filter((b) => b.type === 'text').map(b => b.text).join('\n').trim();
861
541
  if (!responseText) {
862
- // Remove the empty assistant message we just pushed
863
542
  conversation.pop();
864
543
  break;
865
544
  }
866
- // Post the response
867
- this.typingIndicator?.updateStatus(message.discussionId, 'Writing response');
868
545
  this.typingIndicator?.stop(message.discussionId);
869
546
  const formatted = await this.formatOutgoingMessage(responseText);
870
547
  const links = this.messageFormatter.extractTagLinks(formatted);
871
548
  await this.sendMessage(message.discussionId, formatted, links);
872
549
  return true;
873
550
  }
874
- // Max iterations reached or empty response
875
551
  this.typingIndicator?.stop(message.discussionId);
876
552
  return false;
877
553
  }
554
+ // ===== TOOL EXECUTION =====
878
555
  async executeTools(toolUseBlocks, message, signal) {
879
- const discussionId = message.discussionId;
880
556
  const results = [];
881
- const WRITE_TOOLS = new Set(['create_activity', 'update_activity']);
882
557
  for (const block of toolUseBlocks) {
883
558
  const toolStart = Date.now();
884
- // ── Admin-only gate ──
885
- // Some tools require workspace admin/owner regardless of workflow access.
886
- if (ADMIN_ONLY_TOOLS.has(block.name)) {
887
- if (!this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
888
- this.opLogger.permDenied(discussionId, block.name, 'n/a', 'requires-admin');
889
- results.push({
890
- type: 'tool_result',
891
- tool_use_id: block.id,
892
- content: `Permission denied: Only workspace admins can modify workflow permissions.`,
893
- is_error: true,
894
- });
559
+ const args = block.input;
560
+ // Admin-only gate
561
+ if (this.permissions.isAdminOnlyTool(block.name) && !this.permissions.isAdminOrOwner(message.senderId)) {
562
+ this.opLogger.permDenied(message.discussionId, block.name, 'n/a', 'requires-admin');
563
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: 'Permission denied: Only workspace admins can modify workflow permissions.', is_error: true });
564
+ continue;
565
+ }
566
+ // Pre-execution permission check
567
+ if (!this.permissions.isPostCheckTool(block.name)) {
568
+ const denied = this.permissions.checkArgs(args, message.senderId);
569
+ if (denied) {
570
+ this.opLogger.permDenied(message.discussionId, block.name, denied.workflowId, denied.reason);
571
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: denied.reason === 'bot-scope' ? `Permission denied: This bot does not have access to workflow ${denied.workflowId}.` : `Permission denied: You do not have access to workflow ${denied.workflowId}.`, is_error: true });
895
572
  continue;
896
573
  }
897
574
  }
898
- // ── Unified permission gate ──
899
- // Extract all workflow IDs from tool args (any shape) and check access.
900
- // POST_CHECK_TOOLS are checked after execution (workflow ID is in the result, not the args).
901
- const args = block.input;
902
- const permCtx = this.getPermissionContext();
903
- if (!POST_CHECK_TOOLS.has(block.name)) {
904
- const workflowIds = (0, permission_guard_1.extractWorkflowIdsFromArgs)(args, this.insightWorkflowCache);
905
- const denied = (0, permission_guard_1.checkWorkflowAccess)(workflowIds, message.senderId, permCtx);
906
- if (denied) {
907
- this.opLogger.permDenied(discussionId, block.name, denied.workflowId, denied.reason);
908
- results.push({
909
- type: 'tool_result',
910
- tool_use_id: block.id,
911
- content: denied.reason === 'bot-scope'
912
- ? `Permission denied: This bot does not have access to workflow ${denied.workflowId}.`
913
- : `Permission denied: You do not have access to workflow ${denied.workflowId}.`,
914
- is_error: true,
915
- });
575
+ // Discussion isolation: prevent leaking DMs and discussions the user shouldn't access
576
+ if (DISCUSSION_TOOLS.has(block.name) && args.discussionId && args.discussionId !== message.discussionId) {
577
+ try {
578
+ const discResult = await this.client.socket.request('v3.discussion.message.latest', [args.discussionId]);
579
+ const disc = discResult?.discussion || {};
580
+ const participants = disc.participants || [];
581
+ const isPrivate = disc.private === true;
582
+ const isActivityDisc = !isPrivate && !!(disc.linkedActivity || disc.activity);
583
+ if (isActivityDisc) {
584
+ // Activity discussion (not private) workflow permissions handle access
585
+ if (!participants.includes(this.userId)) {
586
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: 'Permission denied: The bot is not a participant in that discussion.', is_error: true });
587
+ continue;
588
+ }
589
+ }
590
+ else {
591
+ // Private/DM discussion → both bot AND user must be participants
592
+ if (!participants.includes(this.userId) || !participants.includes(message.senderId)) {
593
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: 'Permission denied: You are not a participant in that discussion.', is_error: true });
594
+ continue;
595
+ }
596
+ }
597
+ }
598
+ catch {
599
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: 'Permission denied: Could not verify discussion access.', is_error: true });
916
600
  continue;
917
601
  }
918
602
  }
603
+ // Block add_discussion_message to current discussion — bot's text response IS the reply
604
+ if (block.name === 'add_discussion_message' && args.discussionId === message.discussionId) {
605
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: 'You are already responding in this discussion. Your text response will be posted automatically — do not use add_discussion_message for the current discussion. Use it only for OTHER discussions.', is_error: true });
606
+ continue;
607
+ }
919
608
  try {
920
609
  let toolArgs = args;
921
- // Auto-inject context for join_discussion calls
610
+ // Auto-inject for join_discussion
922
611
  if (block.name === 'join_discussion') {
923
612
  toolArgs = { ...toolArgs };
924
- // Auto-inject inviteUserId from the sender if not already set
925
- if (!toolArgs.inviteUserId && message.senderId) {
613
+ if (!toolArgs.inviteUserId && message.senderId)
926
614
  toolArgs.inviteUserId = message.senderId;
927
- }
928
- // Auto-inject sourceActivityId for wormhole (only if from an activity discussion, not DMs)
929
- if (!toolArgs.sourceActivityId && message.linkedActivityId) {
930
- toolArgs.sourceActivityId = message.linkedActivityId;
931
- }
932
- // Auto-inject welcomeReason with the actual message content for context
933
- if (!toolArgs.welcomeReason && message.content) {
615
+ if (!toolArgs.welcomeReason && message.content)
934
616
  toolArgs.welcomeReason = message.content;
935
- }
617
+ // Only auto-inject sourceActivityId if explicitly provided — don't link DMs to random activities
936
618
  }
937
619
  const result = await this.toolExecutor.execute(block.name, toolArgs, this.getUserContext());
938
620
  const text = result?.content?.[0]?.text ?? JSON.stringify(result);
939
621
  let contentStr = typeof text === 'string' ? text : JSON.stringify(text);
940
622
  const toolDuration = (Date.now() - toolStart) / 1000;
941
- // ── Post-execution permission checks ──
942
- // Cache insight → workflow mapping after successful create_insight
623
+ // Cache insight mappings
943
624
  if (block.name === 'create_insight' && !contentStr.includes('❌')) {
944
- const idMatch = contentStr.match(/\*\*Insight ID:\*\*\s+`([a-f0-9]{24})`/);
945
- if (idMatch) {
946
- const sources = args.sources || [];
947
- const wfIds = new Set(sources.map((s) => s.workflowId).filter(Boolean));
948
- if (wfIds.size > 0)
949
- this.insightWorkflowCache.set(idMatch[1], wfIds);
950
- }
625
+ this.permissions.cacheInsightWorkflows(contentStr, args);
951
626
  }
952
- // Post-filter list results: remove items the user can't access
953
- contentStr = await this.postFilterListResults(block.name, contentStr, message.senderId, discussionId);
954
- // Post-execution permission check for tools where workflowId is in the result
955
- if (POST_CHECK_TOOLS.has(block.name)) {
956
- const extractedWorkflowId = this.extractWorkflowIdFromResult(block.name, contentStr);
957
- if (extractedWorkflowId) {
958
- const denied = (0, permission_guard_1.checkWorkflowAccess)([extractedWorkflowId], message.senderId, permCtx);
627
+ // Post-filter list results
628
+ contentStr = await this.permissions.postFilterListResults(block.name, contentStr, message.senderId, message.discussionId, this.opLogger);
629
+ // Post-execution permission check
630
+ if (this.permissions.isPostCheckTool(block.name)) {
631
+ const wfId = this.permissions.extractWorkflowIdFromResult(block.name, contentStr);
632
+ if (wfId) {
633
+ const denied = this.permissions.checkWorkflow([wfId], message.senderId);
959
634
  if (denied) {
960
- this.opLogger.permDenied(discussionId, block.name, denied.workflowId, denied.reason);
961
- results.push({
962
- type: 'tool_result',
963
- tool_use_id: block.id,
964
- content: denied.reason === 'bot-scope'
965
- ? `Permission denied: This bot does not have access to that resource.`
966
- : `Permission denied: You do not have access to that resource.`,
967
- is_error: true,
968
- });
635
+ this.opLogger.permDenied(message.discussionId, block.name, denied.workflowId, denied.reason);
636
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: denied.reason === 'bot-scope' ? 'Permission denied: This bot does not have access to that resource.' : 'Permission denied: You do not have access to that resource.', is_error: true });
969
637
  continue;
970
638
  }
971
639
  }
972
640
  }
973
- // Check if tool returned an error in its response text
641
+ // Log result
642
+ const summary = (0, operation_logger_1.summarizeToolResult)(block.name, contentStr);
974
643
  const lower = contentStr.toLowerCase();
975
644
  const stripped = contentStr.replace(/[^\w]/g, ' ').trimStart().toLowerCase();
976
645
  const isToolError = lower.includes('"error"') || lower.includes('❌') || stripped.startsWith('error');
977
- const summary = (0, operation_logger_1.summarizeToolResult)(block.name, contentStr);
978
- if (isToolError) {
979
- this.opLogger.toolCall(discussionId, block.name, 'error', toolDuration, 'FAIL', summary);
980
- }
981
- else {
982
- this.opLogger.toolCall(discussionId, block.name, summary, toolDuration, 'OK');
983
- }
984
- // Warn if a write tool completed before abort — not idempotent
646
+ this.opLogger.toolCall(message.discussionId, block.name, isToolError ? 'error' : summary, toolDuration, isToolError ? 'FAIL' : 'OK', isToolError ? summary : undefined);
985
647
  if (signal?.aborted && WRITE_TOOLS.has(block.name)) {
986
- this.logger.warn('Write tool completed before abort', {
987
- discussionId, tool: block.name,
988
- });
648
+ this.logger.warn('Write tool completed before abort', { tool: block.name });
989
649
  }
990
- results.push({
991
- type: 'tool_result',
992
- tool_use_id: block.id,
993
- content: contentStr,
994
- });
650
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: contentStr });
995
651
  }
996
652
  catch (error) {
997
- const toolDuration = (Date.now() - toolStart) / 1000;
998
653
  const errMsg = error instanceof Error ? error.message : String(error);
999
- this.opLogger.toolCall(discussionId, block.name, 'error', toolDuration, 'FAIL', errMsg);
1000
- results.push({
1001
- type: 'tool_result',
1002
- tool_use_id: block.id,
1003
- content: `Error: ${errMsg}`,
1004
- is_error: true,
1005
- });
654
+ this.opLogger.toolCall(message.discussionId, block.name, 'error', (Date.now() - toolStart) / 1000, 'FAIL', errMsg);
655
+ results.push({ type: 'tool_result', tool_use_id: block.id, content: `Error: ${errMsg}`, is_error: true });
1006
656
  }
1007
657
  }
1008
658
  return results;
1009
659
  }
1010
- /**
1011
- * Inject pending messages from the context buffer into the conversation
1012
- * between LLM iterations. Merges into the existing user message to preserve
1013
- * Anthropic's alternation rule (user/assistant/user/assistant).
1014
- */
1015
- async injectPendingContext(discussionId, conversation) {
1016
- const state = this.discussionStates.get(discussionId);
1017
- if (!state || state.contextBuffer.length === 0)
1018
- return;
1019
- const pending = state.contextBuffer.splice(0);
1020
- const lastMsg = conversation[conversation.length - 1];
1021
- if (lastMsg?.role === 'user' && Array.isArray(lastMsg.content)) {
1022
- const contextText = pending.map(m => this.formatIncomingMessage(m)).join('\n\n');
1023
- lastMsg.content.push({
1024
- type: 'text',
1025
- text: `<context type="messages-during-processing">\n${contextText}\n</context>`,
1026
- });
1027
- const imageBlocks = await this.resolveImageAttachments(pending);
1028
- for (const block of imageBlocks) {
1029
- lastMsg.content.push(block);
1030
- }
1031
- this.opLogger.contextInject(discussionId, pending.length);
1032
- }
1033
- }
1034
- /**
1035
- * Remove orphaned tool_use/tool_result pairs from conversation after abort.
1036
- * Walks backwards and removes incomplete exchanges to keep conversation valid.
1037
- */
1038
- cleanupIncompleteExchange(conversation) {
1039
- while (conversation.length > 0) {
1040
- const last = conversation[conversation.length - 1];
1041
- const content = Array.isArray(last.content) ? last.content : [];
1042
- if (last.role === 'assistant' && content.some((b) => b.type === 'tool_use')) {
1043
- conversation.pop();
1044
- continue;
1045
- }
1046
- if (last.role === 'user' && content.some((b) => b.type === 'tool_result')) {
1047
- conversation.pop();
1048
- continue;
1049
- }
1050
- break;
1051
- }
1052
- }
1053
660
  // ===== FORMATTING =====
1054
661
  formatIncomingMessage(message) {
1055
- const activityAttr = message.linkedActivityId ? ` activity_id="${message.linkedActivityId}"` : '';
1056
- const discName = message.discussionName || message.linkedActivityName;
662
+ const isDm = message.isDirectMessage || message.isPrivateDiscussion;
663
+ // DMs: don't include stale linked activity metadata
664
+ const activityAttr = (!isDm && message.linkedActivityId) ? ` activity_id="${message.linkedActivityId}"` : '';
665
+ const discName = isDm ? undefined : (message.discussionName || message.linkedActivityName);
1057
666
  const nameAttr = discName ? ` discussion_name="${discName}"` : '';
667
+ const typeAttr = isDm ? ' type="dm"' : (message.linkedActivityId ? ' type="activity"' : ' type="group"');
1058
668
  let fileInfo = '';
1059
669
  if (message.fileAttachments?.length) {
1060
670
  const imageFiles = message.fileAttachments.filter(f => f.mime && IMAGE_MIME_TYPES.has(f.mime) && (!f.size || f.size <= MAX_IMAGE_SIZE));
1061
671
  const nonImageFiles = message.fileAttachments.filter(f => !f.mime || !IMAGE_MIME_TYPES.has(f.mime) || (f.size && f.size > MAX_IMAGE_SIZE));
1062
672
  const parts = [];
1063
- for (const img of imageFiles) {
673
+ for (const img of imageFiles)
1064
674
  parts.push(`[Image attached: ${img.filename} — included below for vision]`);
1065
- }
1066
- if (nonImageFiles.length) {
1067
- const ids = nonImageFiles.map(f => f.fileId).join(', ');
1068
- parts.push(`[File attached: ${nonImageFiles.length} file(s) - IDs: ${ids}]\nUse download_file tool with fileId to read file contents.`);
1069
- }
675
+ if (nonImageFiles.length)
676
+ parts.push(`[File attached: ${nonImageFiles.length} file(s) - IDs: ${nonImageFiles.map(f => f.fileId).join(', ')}]\nUse download_file tool with fileId to read file contents.`);
1070
677
  if (parts.length)
1071
678
  fileInfo = '\n' + parts.join('\n');
1072
679
  }
1073
- return `<incoming discussion="${message.discussionId}"${nameAttr}${activityAttr} from="${message.senderName}" user_id="${message.senderId}" timestamp="${new Date(message.timestamp).toISOString()}">\n${message.content}${fileInfo}\n</incoming>`;
680
+ const dmNote = isDm ? '\n[This is a private DM conversation, not an activity discussion.]' : '';
681
+ return `<incoming discussion="${message.discussionId}"${typeAttr}${nameAttr}${activityAttr} from="${message.senderName}" user_id="${message.senderId}" timestamp="${new Date(message.timestamp).toISOString()}">\n${message.content}${fileInfo}${dmNote}\n</incoming>`;
1074
682
  }
1075
- /**
1076
- * Download image attachments and return Anthropic image content blocks.
1077
- * Falls back silently on errors — the text note still tells the LLM about the file.
1078
- */
1079
683
  async resolveImageAttachments(messages) {
1080
- const blocks = [];
1081
684
  if (!this.hailerApi)
1082
- return blocks;
1083
- const imageFiles = [];
685
+ return [];
686
+ const eligible = [];
1084
687
  for (const msg of messages) {
1085
- if (!msg.fileAttachments?.length)
1086
- continue;
1087
- for (const f of msg.fileAttachments) {
688
+ for (const f of msg.fileAttachments || []) {
1088
689
  if (f.mime && IMAGE_MIME_TYPES.has(f.mime) && (!f.size || f.size <= MAX_IMAGE_SIZE)) {
1089
- imageFiles.push({ fileId: f.fileId, filename: f.filename, mime: f.mime });
690
+ eligible.push({ fileId: f.fileId, mime: f.mime });
1090
691
  }
1091
692
  }
1092
693
  }
1093
- for (const img of imageFiles) {
694
+ if (!eligible.length)
695
+ return [];
696
+ const results = await Promise.all(eligible.map(async (f) => {
1094
697
  try {
1095
- const result = await this.hailerApi.downloadFile(img.fileId);
698
+ const result = await this.hailerApi.downloadFile(f.fileId);
1096
699
  if (result.encoding === 'base64') {
1097
- blocks.push({
1098
- type: 'image',
1099
- source: {
1100
- type: 'base64',
1101
- media_type: img.mime,
1102
- data: result.content,
1103
- },
1104
- });
700
+ return { type: 'image', source: { type: 'base64', media_type: f.mime, data: result.content } };
1105
701
  }
1106
702
  }
1107
703
  catch (err) {
1108
- this.logger.warn('Failed to download image for vision', { fileId: img.fileId, filename: img.filename, error: err });
704
+ this.logger.warn('Failed to download image', { fileId: f.fileId });
1109
705
  }
1110
- }
1111
- return blocks;
706
+ return null;
707
+ }));
708
+ return results.filter((b) => b !== null);
1112
709
  }
1113
710
  async formatOutgoingMessage(text) {
1114
711
  let formatted = await this.messageFormatter.resolveUserTags(text);
1115
712
  formatted = await this.messageFormatter.resolveActivityTags(formatted);
1116
713
  formatted = await this.messageFormatter.resolveHailerUrls(formatted);
1117
714
  formatted = this.messageFormatter.convertMentionsToTags(formatted);
1118
- // Remove redundant name after tags
715
+ // Convert markdown links with hex IDs to hailerTags: [Name](hexid) → [hailerTag|Name](hexid)
716
+ formatted = formatted.replace(/\[(?!hailerTag\|)([^\]]+)\]\(([a-f0-9]{24})\)/gi, (_match, name, id) => `\uFEFF[hailerTag|${name}](${id})\uFEFF`);
717
+ // Remove redundant name after tags: [hailerTag|Name](id) (Name) → [hailerTag|Name](id)
1119
718
  formatted = formatted.replace(/(\[hailerTag\|[^\]]+\]\([a-f0-9]{24}\)\uFEFF?)\s*\([^)]+\)/gi, '$1');
1120
- // Auto-resolve bare 24-char hex IDs not already in hailerTag format
719
+ // Strip hailerTags inside markdown table rows (pipe in hailerTag|Name breaks table columns)
720
+ formatted = formatted.split('\n').map(line => {
721
+ if (!line.trimStart().startsWith('|'))
722
+ return line;
723
+ return line.replace(/\uFEFF?\[hailerTag\|([^\]]+)\]\([a-f0-9]{24}\)\uFEFF?/gi, '$1');
724
+ }).join('\n');
1121
725
  formatted = await this.resolveBareIds(formatted);
1122
726
  return formatted;
1123
727
  }
1124
- /**
1125
- * Find bare 24-char hex IDs in text that aren't already inside hailerTags
1126
- * and try to resolve them to clickable hailerTags.
1127
- */
1128
728
  async resolveBareIds(text) {
1129
- // Match 24-char hex IDs that are NOT preceded by ( which would mean they're already in a hailerTag
1130
729
  const bareIdPattern = /(?<!\()([a-f0-9]{24})(?!\)[^\[]*\[hailerTag)/gi;
1131
730
  const matches = [...text.matchAll(bareIdPattern)];
1132
- // Collect IDs that aren't already in hailerTags, limit to 5
1133
731
  const idsToResolve = [];
1134
732
  for (const match of matches) {
1135
733
  const id = match[1];
1136
- const tagCheck = new RegExp(`\\[hailerTag\\|[^\\]]+\\]\\(${id}\\)`, 'i');
1137
- if (tagCheck.test(text))
734
+ if (new RegExp(`\\[hailerTag\\|[^\\]]+\\]\\(${id}\\)`, 'i').test(text))
1138
735
  continue;
1139
736
  idsToResolve.push(id);
1140
737
  if (idsToResolve.length >= 5)
@@ -1142,560 +739,107 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
1142
739
  }
1143
740
  if (idsToResolve.length === 0)
1144
741
  return text;
1145
- // Resolve all IDs in parallel
1146
742
  const resolutions = await Promise.all(idsToResolve.map(async (id) => {
1147
743
  try {
1148
744
  const result = await this.toolExecutor.execute('show_activity_by_id', { activityId: id }, this.getUserContext());
1149
745
  const resultText = result?.content?.[0]?.text;
1150
746
  if (resultText) {
1151
- const jsonMatch = resultText.match(/\{[\s\S]*\}/);
1152
- if (jsonMatch) {
1153
- const parsed = JSON.parse(jsonMatch[0]);
1154
- if (parsed.name)
1155
- return { id, name: parsed.name };
747
+ const j = resultText.match(/\{[\s\S]*\}/);
748
+ if (j) {
749
+ const p = JSON.parse(j[0]);
750
+ if (p.name)
751
+ return { id, name: p.name };
1156
752
  }
1157
753
  }
1158
754
  }
1159
- catch {
1160
- // Not an activity, skip
1161
- }
755
+ catch { /* not an activity */ }
1162
756
  return null;
1163
757
  }));
1164
- // Apply replacements
1165
- for (const resolved of resolutions) {
1166
- if (!resolved)
758
+ for (const r of resolutions) {
759
+ if (!r)
1167
760
  continue;
1168
- const ZWNBSP = '\uFEFF';
1169
- const tag = `${ZWNBSP}[hailerTag|${resolved.name}](${resolved.id})${ZWNBSP}`;
1170
- text = text.replace(resolved.id, tag);
761
+ text = text.replace(r.id, `\uFEFF[hailerTag|${r.name}](${r.id})\uFEFF`);
1171
762
  }
1172
763
  return text;
1173
764
  }
1174
- // ===== SYSTEM PROMPT =====
1175
- getBotDisplayName() {
1176
- if (!this.init?.users || !this.userId)
1177
- return 'Assistant';
1178
- const user = this.init.users[this.userId];
1179
- if (!user)
1180
- return 'Assistant';
1181
- const name = user.display_name || user.fullName ||
1182
- `${user.firstname || ''} ${user.lastname || ''}`.trim();
1183
- return name || 'Assistant';
765
+ // ===== HELPERS =====
766
+ async injectPendingContext(discussionId, conversation) {
767
+ const state = this.signalRouter.getState(discussionId);
768
+ if (!state || state.contextBuffer.length === 0)
769
+ return;
770
+ const pending = state.contextBuffer.splice(0);
771
+ const lastMsg = conversation[conversation.length - 1];
772
+ if (lastMsg?.role === 'user' && Array.isArray(lastMsg.content)) {
773
+ lastMsg.content.push({ type: 'text', text: `<context type="messages-during-processing">\n${pending.map(m => this.formatIncomingMessage(m)).join('\n\n')}\n</context>` });
774
+ for (const block of await this.resolveImageAttachments(pending))
775
+ lastMsg.content.push(block);
776
+ this.opLogger.contextInject(discussionId, pending.length);
777
+ }
1184
778
  }
1185
- buildSystemPrompt() {
1186
- const wsName = this.init?.network?.name || 'Workspace';
1187
- const botName = this.getBotDisplayName();
1188
- const identity = `<bot-identity>
1189
- Your user ID: ${this.userId}
1190
- Your display name: ${botName}
1191
- Workspace: ${wsName}
1192
- </bot-identity>`;
1193
- // Platform rules always applied regardless of custom prompt.
1194
- // These are operational requirements that keep the bot functional.
1195
- const platformRules = `<platform-rules>
1196
- - Use tools to answer questions — don't guess or make up data.
1197
- - When a tool call fails, tell the user what failed and why. Never silently try alternatives.
1198
- - For bulk operations, batch into groups of 25-50 per tool call.
1199
- - NEVER output raw URLs like https://app.hailer.com/...
1200
-
1201
- HAILERTAG LINKING (mandatory):
1202
- - When referencing activities, discussions, or users, ALWAYS use: [hailerTag|Display Name](objectId)
1203
- - Examples:
1204
- [hailerTag|AC Milan deal](691ffe654217e9e8434e578a)
1205
- [hailerTag|John Smith](691ffe654217e9e8434e5123)
1206
- - Tool responses include both names and IDs — use them directly in this format.
1207
- - NEVER use hailerTag for workflows, phases, teams, or groups — these don't support linking. Just use their plain text names.
1208
- - NEVER output bare 24-character hex IDs to the user.
1209
- </platform-rules>`;
1210
- const now = new Date();
1211
- const isoDate = now.toISOString().split('T')[0];
1212
- const dayOfWeek = now.getUTCDay(); // 0=Sun, 1=Mon, ...
1213
- const weekNum = Math.ceil(((+now - +new Date(now.getFullYear(), 0, 1)) / 86400000 + new Date(now.getFullYear(), 0, 1).getDay() + 1) / 7);
1214
- const dateContext = `<current-date>
1215
- Today: ${now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} (${isoDate}, ISO week ${weekNum}, day ${dayOfWeek})
1216
- Time: ${now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Helsinki' })} Europe/Helsinki
1217
- Year: ${now.getFullYear()}
1218
-
1219
- DATE HANDLING — CRITICAL:
1220
- - The current year is ${now.getFullYear()}. When no year is specified, ALWAYS use ${now.getFullYear()} (or ${now.getFullYear() + 1} if the date has already passed this year).
1221
- - European/Finnish dates use DD.MM.YYYY or DD.M.YYYY format (day first, NOT month first). "18.7.2026" = July 18, 2026. "3.12" = December 3. Never confuse day and month.
1222
- - Resolve relative dates in any language (Finnish, Swedish, English, etc.) by calculating from today's date.
1223
- - For activity date fields, always output YYYY-MM-DD format. The system auto-converts to timestamps.
1224
- - If a date is ambiguous, ask the user to clarify.
1225
- </current-date>`;
1226
- const wsContext = `<workspace-context>
1227
- ${this.workspaceOverview}
1228
- </workspace-context>`;
1229
- const customPrompt = this._systemPrompt?.trim();
1230
- if (customPrompt) {
1231
- // Custom prompt = the bot's mission brief. It defines personality, scope, and behavior.
1232
- // Platform rules and identity are always appended — they keep the bot functional.
1233
- const body = customPrompt
1234
- .replace(/\{wsName\}/g, wsName)
1235
- .replace(/\{userId\}/g, this.userId)
1236
- .replace(/\{botName\}/g, botName);
1237
- return `${body}\n\n${platformRules}\n\n${identity}\n\n${dateContext}\n\n${wsContext}`;
779
+ cleanupIncompleteExchange(conversation) {
780
+ while (conversation.length > 0) {
781
+ const last = conversation[conversation.length - 1];
782
+ const content = Array.isArray(last.content) ? last.content : [];
783
+ if (last.role === 'assistant' && content.some((b) => b.type === 'tool_use')) {
784
+ conversation.pop();
785
+ continue;
786
+ }
787
+ if (last.role === 'user' && content.some((b) => b.type === 'tool_result')) {
788
+ conversation.pop();
789
+ continue;
790
+ }
791
+ break;
1238
792
  }
1239
- // Default prompt — general-purpose workspace assistant with behavioral defaults.
1240
- const defaultBehavior = `<behavior>
1241
- - Be concise. Short answers, no filler.
1242
- - Never list your capabilities unprompted. If asked what you can do, give a 1-2 sentence summary based on the workspace context below, not a feature dump.
1243
- - Never use emojis unless the user does first.
1244
- - When a task will require multiple steps, briefly tell the user what you're about to do. Be concrete — never use vague filler like "Let me look into that".
1245
- - When showing data, use clean formatting (tables, bullet points). Don't over-explain.
1246
- - If a request is ambiguous, ask a clarifying question instead of guessing.
1247
- - Always reference actual workspace data (workflow names, field values) — never speak in generic terms.
1248
- </behavior>`;
1249
- return `You are a workspace assistant for "${wsName}" on Hailer.
1250
-
1251
- ${identity}
1252
-
1253
- ${platformRules}
1254
-
1255
- ${defaultBehavior}
1256
-
1257
- ${dateContext}
1258
-
1259
- ${wsContext}`;
1260
793
  }
1261
- // ===== TOOLS =====
1262
- static TOOL_STATUS_LABELS = {
1263
- list_activities: 'Searching activities',
1264
- count_activities: 'Counting activities',
1265
- show_activity_by_id: 'Reading activity',
1266
- create_activity: 'Creating activity',
1267
- update_activity: 'Updating activity',
1268
- list_workflows: 'Loading workflows',
1269
- list_workflows_minimal: 'Loading workflows',
1270
- get_workflow_schema: 'Reading workflow schema',
1271
- list_workflow_phases: 'Loading phases',
1272
- preview_insight: 'Running query',
1273
- get_insight_data: 'Running report',
1274
- search_workspace_users: 'Searching users',
1275
- fetch_discussion_messages: 'Reading messages',
1276
- add_discussion_message: 'Sending message',
1277
- join_discussion: 'Joining discussion',
1278
- };
1279
794
  getToolStatus(toolUseBlocks) {
1280
- if (toolUseBlocks.length === 1) {
1281
- return Bot.TOOL_STATUS_LABELS[toolUseBlocks[0].name]
1282
- || toolUseBlocks[0].name.replace(/_/g, ' ');
1283
- }
1284
- const firstLabel = Bot.TOOL_STATUS_LABELS[toolUseBlocks[0].name]
1285
- || toolUseBlocks[0].name.replace(/_/g, ' ');
1286
- return `${firstLabel} (+${toolUseBlocks.length - 1} more)`;
795
+ if (toolUseBlocks.length === 1)
796
+ return TOOL_STATUS_LABELS[toolUseBlocks[0].name] || toolUseBlocks[0].name.replace(/_/g, ' ');
797
+ return `${TOOL_STATUS_LABELS[toolUseBlocks[0].name] || toolUseBlocks[0].name.replace(/_/g, ' ')} (+${toolUseBlocks.length - 1} more)`;
1287
798
  }
1288
799
  getAnthropicTools() {
1289
- const allowedTools = BOT_TOOLS;
800
+ if (this.cachedTools)
801
+ return this.cachedTools;
1290
802
  const defs = this.toolExecutor.getToolDefinitions({
1291
803
  allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND, tool_registry_1.ToolGroup.BOT_INTERNAL],
1292
804
  });
1293
- const tools = defs
1294
- .filter(d => allowedTools.has(d.name))
1295
- .map(d => ({
1296
- name: d.name,
1297
- description: d.description,
1298
- input_schema: d.inputSchema,
805
+ const tools = defs.filter(d => BOT_TOOLS.has(d.name)).map(d => ({
806
+ name: d.name, description: d.description, input_schema: d.inputSchema,
1299
807
  }));
1300
- // Mark last tool for prompt caching — tools never change per session
1301
- if (tools.length > 0) {
808
+ if (tools.length > 0)
1302
809
  tools[tools.length - 1].cache_control = { type: 'ephemeral' };
1303
- }
810
+ this.cachedTools = tools;
1304
811
  return tools;
1305
812
  }
1306
- // ===== HAILER MESSAGING =====
1307
813
  async sendMessage(discussionId, text, links) {
1308
814
  try {
1309
815
  const msgData = { msg: text };
1310
816
  if (links?.length)
1311
817
  msgData.links = links;
1312
818
  await this.client.socket.request('messenger.send', [msgData, discussionId]);
1313
- const tagCount = (text.match(/\[hailerTag\|/g) || []).length;
1314
- this.opLogger.messageOut(discussionId, text.length, tagCount);
819
+ this.opLogger.messageOut(discussionId, text.length, (text.match(/\[hailerTag\|/g) || []).length);
1315
820
  }
1316
821
  catch (error) {
1317
822
  this.logger.error('Failed to send message', error);
1318
823
  }
1319
824
  }
1320
- // ===== HELPERS =====
1321
- /**
1322
- * Schedule a workspace data refresh with debounce.
1323
- * Signals can fire in bursts (e.g., bulk field edits), so we debounce
1324
- * to avoid hammering the API. 2-second delay is effectively instant
1325
- * from a user perspective but handles bursts.
1326
- */
1327
- scheduleRefresh(scope) {
1328
- this.refreshDirty = true;
1329
- if (scope === 'full') {
1330
- this.refreshScope.clear();
1331
- this.refreshScope.add('full');
1332
- }
1333
- else if (!this.refreshScope.has('full')) {
1334
- this.refreshScope.add(scope);
1335
- }
1336
- if (this.refreshTimer)
1337
- clearTimeout(this.refreshTimer);
1338
- this.refreshTimer = setTimeout(() => this.refreshWorkspaceData(), 2000);
1339
- }
1340
- /**
1341
- * Re-fetch workspace data and rebuild the overview.
1342
- * Called when process.updated, workspace.updated, or cache.invalidate signals fire.
1343
- */
1344
- async refreshWorkspaceData() {
1345
- if (!this.refreshDirty || !this.client)
1346
- return;
1347
- if (this.isRefreshing) {
1348
- this.refreshQueued = true;
1349
- return;
1350
- }
1351
- this.isRefreshing = true;
1352
- this.refreshDirty = false;
1353
- const scopes = new Set(this.refreshScope);
1354
- this.refreshScope.clear();
1355
- this.refreshTimer = null;
1356
- try {
1357
- // Determine what to re-fetch based on signal types
1358
- const isFull = scopes.has('full');
1359
- const keys = isFull
1360
- ? ['processes', 'users', 'network', 'networks', 'teams', 'groups']
1361
- : Array.from(scopes);
1362
- // Always include users with processes (membership check depends on it)
1363
- if (keys.includes('processes') && !keys.includes('users')) {
1364
- keys.push('users');
1365
- }
1366
- this.logger.debug('Refreshing workspace data', { keys, trigger: isFull ? 'full' : Array.from(scopes).join(',') });
1367
- const freshData = await this.client.socket.request('v2.core.init', [keys]);
1368
- // Merge fresh data into existing init (partial update)
1369
- (0, types_1.normalizeInitProcesses)(freshData);
1370
- if (!this.init) {
1371
- this.init = freshData;
1372
- }
1373
- else {
1374
- if (freshData.processes)
1375
- this.init.processes = freshData.processes;
1376
- if (freshData.users)
1377
- this.init.users = freshData.users;
1378
- if (freshData.network)
1379
- this.init.network = freshData.network;
1380
- if (freshData.networks)
1381
- this.init.networks = freshData.networks;
1382
- if (freshData.teams)
1383
- this.init.teams = freshData.teams;
1384
- if (freshData.groups)
1385
- this.init.groups = freshData.groups;
1386
- }
1387
- this._workspaceId = this.init.network?._id || this.config.workspaceId;
1388
- // Rebuild workspace cache
1389
- this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
1390
- excludeTranslations: true,
1391
- excludeSystemMessages: true,
1392
- excludeEmptyFields: true,
1393
- compactUserData: true,
1394
- includeWorkspaceNamesInTools: false,
1395
- });
1396
- this.backfillWorkspaceCacheMembers();
1397
- // Rebuild workspace overview for system prompt
1398
- this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
1399
- // Update UserContext with fresh data
1400
- if (this.userContext) {
1401
- this.userContext.init = this.init;
1402
- this.userContext.workspaceCache = this.workspaceCache;
1403
- }
1404
- // Rebuild permission index
1405
- this.buildPermissionIndex();
1406
- this.logger.debug('Workspace data refreshed', {
1407
- keys,
1408
- workflowCount: this.init.processes?.length || 0,
1409
- userCount: Object.keys(this.init.users || {}).length,
1410
- });
1411
- }
1412
- catch (error) {
1413
- this.logger.error('Failed to refresh workspace data', error);
1414
- }
1415
- finally {
1416
- this.isRefreshing = false;
1417
- if (this.refreshQueued) {
1418
- this.refreshQueued = false;
1419
- this.refreshWorkspaceData();
1420
- }
1421
- }
1422
- }
1423
- /**
1424
- * Backfill workspaceCache.usersById with entries from network.members
1425
- * that are missing from init.users (which is only a partial user list).
1426
- */
1427
- backfillWorkspaceCacheMembers() {
1428
- if (!this.workspaceCache || !this.init)
1429
- return;
1430
- const network = this.init.network;
1431
- if (!network?.members)
1432
- return;
1433
- const members = Array.isArray(network.members) ? network.members : Object.values(network.members);
1434
- let backfilled = 0;
1435
- for (const member of members) {
1436
- if (member.uid && !this.workspaceCache.usersById[member.uid]) {
1437
- const stub = {
1438
- id: member.uid,
1439
- firstname: member.firstname || '',
1440
- lastname: member.lastname || '',
1441
- fullName: member.firstname && member.lastname
1442
- ? `${member.firstname} ${member.lastname}`
1443
- : member.firstname || member.lastname || '',
1444
- companies: [],
1445
- lastSeen: 0,
1446
- };
1447
- this.workspaceCache.usersById[member.uid] = stub;
1448
- this.workspaceCache.users.push(stub);
1449
- backfilled++;
1450
- }
1451
- }
1452
- this.logger.debug('Workspace cache member backfill', {
1453
- totalMembers: members.length,
1454
- inInitUsers: members.length - backfilled,
1455
- backfilled,
1456
- });
1457
- }
1458
- buildPermissionIndex() {
1459
- const wsId = this._workspaceId;
1460
- if (!wsId)
1461
- return;
1462
- const result = (0, permission_guard_1.buildPermissionIndex)(this.init, wsId);
1463
- this.permissionIndex = result.permissionIndex;
1464
- this.adminUserIds = result.adminUserIds;
1465
- this.ownerUserIds = result.ownerUserIds;
1466
- this.workspaceMemberIds = result.workspaceMemberIds;
1467
- this.membersLoaded = result.workspaceMemberIds.size > 0;
1468
- const totalWorkflows = this.init?.processes?.length || 0;
1469
- this.logger.debug('Permission index built', {
1470
- restricted: this.permissionIndex.size,
1471
- open: totalWorkflows - this.permissionIndex.size,
1472
- totalWorkflows,
1473
- totalEntries: Array.from(this.permissionIndex.values()).reduce((n, s) => n + s.size, 0),
1474
- admins: this.adminUserIds.size,
1475
- owners: this.ownerUserIds.size,
1476
- membersLoaded: this.membersLoaded,
1477
- workspaceMembers: this.workspaceMemberIds.size,
1478
- accessLevel: this.config.accessLevel,
1479
- botWorkflowScope: this.config.allowedWorkflows.length > 0
1480
- ? `${this.config.allowedWorkflows.length} workflows`
1481
- : 'all',
1482
- });
1483
- // Detailed permission map for verification
1484
- const processNames = new Map();
1485
- for (const proc of (this.init?.processes || [])) {
1486
- processNames.set(proc._id, proc.name || proc._id);
1487
- }
1488
- // Resolve user IDs to names where possible
1489
- const userNames = new Map();
1490
- if (this.workspaceCache?.usersById) {
1491
- for (const uid of this.workspaceMemberIds) {
1492
- const user = this.workspaceCache.usersById[uid];
1493
- if (user)
1494
- userNames.set(uid, user.fullName || user.email || uid);
1495
- }
1496
- }
1497
- const resolve = (uid) => {
1498
- const name = userNames.get(uid);
1499
- return name ? `${name} (${uid})` : uid;
1500
- };
1501
- this.logger.debug('Permission map — admins', {
1502
- admins: [...this.adminUserIds].map(resolve),
1503
- });
1504
- this.logger.debug('Permission map — owners', {
1505
- owners: [...this.ownerUserIds].map(resolve),
1506
- });
1507
- this.logger.debug('Permission map — workspace members', {
1508
- members: [...this.workspaceMemberIds].map(resolve),
1509
- });
1510
- // Log each restricted workflow with its allowed users
1511
- for (const [wfId, userIds] of this.permissionIndex) {
1512
- this.logger.debug('Permission map — restricted workflow', {
1513
- workflow: processNames.get(wfId) || wfId,
1514
- workflowId: wfId,
1515
- allowedUsers: [...userIds].map(resolve),
1516
- allowedCount: userIds.size,
1517
- });
1518
- }
1519
- // Log open workflows
1520
- const openWorkflows = (this.init?.processes || [])
1521
- .filter((p) => !this.permissionIndex.has(p._id))
1522
- .map((p) => p.name || p._id);
1523
- if (openWorkflows.length > 0) {
1524
- this.logger.debug('Permission map — open workflows (all members)', {
1525
- workflows: openWorkflows,
1526
- count: openWorkflows.length,
1527
- });
1528
- }
1529
- }
1530
- getOrCreateDiscussionState(discussionId) {
1531
- let state = this.discussionStates.get(discussionId);
1532
- if (state) {
1533
- // LRU: move to end
1534
- this.discussionStates.delete(discussionId);
1535
- this.discussionStates.set(discussionId, state);
1536
- return state;
1537
- }
1538
- // Evict oldest if at capacity
1539
- if (this.discussionStates.size >= 200) {
1540
- const oldest = this.discussionStates.keys().next().value;
1541
- if (oldest)
1542
- this.discussionStates.delete(oldest);
1543
- }
1544
- state = {
1545
- contextBuffer: [],
1546
- abortController: null,
1547
- state: 'idle',
1548
- consecutiveNonResponses: 0,
1549
- lastProgressTime: 0,
1550
- };
1551
- this.discussionStates.set(discussionId, state);
1552
- return state;
1553
- }
1554
825
  getUserContext() {
1555
826
  if (!this.userContext)
1556
827
  throw new Error('Bot not started');
1557
828
  return this.userContext;
1558
829
  }
1559
- getDefaultTeamId() {
1560
- const teams = this.init?.teams;
1561
- if (!teams)
1562
- return undefined;
1563
- const workspaceTeams = Object.values(teams)[0];
1564
- if (!workspaceTeams || typeof workspaceTeams !== 'object')
1565
- return undefined;
1566
- return Object.keys(workspaceTeams)[0];
1567
- }
1568
- // ===== UNIFIED PERMISSION SYSTEM =====
1569
- /** Get the current permission context for checkWorkflowAccess calls */
1570
- getPermissionContext() {
1571
- return {
1572
- allowedWorkflows: this.config.allowedWorkflows,
1573
- permissionIndex: this.permissionIndex,
1574
- adminUserIds: this.adminUserIds,
1575
- ownerUserIds: this.ownerUserIds,
1576
- insightWorkflowCache: this.insightWorkflowCache,
1577
- };
1578
- }
1579
- /**
1580
- * Post-filter list-type tool results to remove items the user can't access.
1581
- * Handles list_workflows, list_workflows_minimal, and list_insights.
1582
- */
1583
- async postFilterListResults(toolName, contentStr, senderId, discussionId) {
1584
- const ctx = this.getPermissionContext();
1585
- // Filter workflow lists (JSON response)
1586
- if (toolName === 'list_workflows' || toolName === 'list_workflows_minimal') {
1587
- try {
1588
- const parsed = JSON.parse(contentStr);
1589
- if (parsed.workflows && Array.isArray(parsed.workflows)) {
1590
- const before = parsed.workflows.length;
1591
- parsed.workflows = parsed.workflows.filter((wf) => {
1592
- const wfId = wf.id || wf._id || wf.workflowId;
1593
- if (!wfId)
1594
- return true;
1595
- return !(0, permission_guard_1.checkWorkflowAccess)([wfId], senderId, ctx);
1596
- });
1597
- if (parsed.workflows.length < before) {
1598
- this.opLogger.permFiltered(discussionId, toolName, before, parsed.workflows.length);
1599
- }
1600
- return JSON.stringify(parsed);
1601
- }
1602
- }
1603
- catch {
1604
- // Not JSON (markdown response) — pass through unmodified
1605
- return contentStr;
1606
- }
1607
- }
1608
- // Filter insight lists (formatted text response — need raw API data)
1609
- if (toolName === 'list_insights') {
1610
- try {
1611
- const wsId = this._workspaceId || this.init?.network?._id;
1612
- if (wsId && this.hailerApi) {
1613
- const rawInsights = await this.hailerApi.request('v3.insight.list', [wsId]);
1614
- const insights = Array.isArray(rawInsights) ? rawInsights : [];
1615
- const deniedIds = new Set();
1616
- for (const insight of insights) {
1617
- const wfIds = [];
1618
- for (const src of (insight.sources || [])) {
1619
- if (src.workflowId)
1620
- wfIds.push(src.workflowId);
1621
- }
1622
- // Always cache the mapping
1623
- if (wfIds.length > 0) {
1624
- this.insightWorkflowCache.set(insight._id, new Set(wfIds));
1625
- }
1626
- // Check access
1627
- if ((0, permission_guard_1.checkWorkflowAccess)(wfIds, senderId, ctx)) {
1628
- deniedIds.add(insight._id);
1629
- }
1630
- }
1631
- if (deniedIds.size > 0) {
1632
- let filtered = contentStr;
1633
- for (const id of deniedIds) {
1634
- const pattern = new RegExp(`\\d+\\.\\s+\\*\\*[^*]+\\*\\*\\n(?:.*\\n)*?\\s+-\\s+Insight ID:\\s+\`${id}\`(?:.*\\n)*?(?=\\d+\\.\\s+\\*\\*|💡|$)`, 'g');
1635
- filtered = filtered.replace(pattern, '');
1636
- }
1637
- const visibleCount = insights.length - deniedIds.size;
1638
- filtered = filtered.replace(/\*\*Insights Found\*\*\s+\(\d+ total\)/, `**Insights Found** (${visibleCount} total)`);
1639
- this.opLogger.permFiltered(discussionId, toolName, insights.length, visibleCount);
1640
- return filtered;
1641
- }
1642
- }
1643
- }
1644
- catch (err) {
1645
- this.logger.warn('Failed to filter list_insights by permissions', { error: err });
1646
- }
1647
- }
1648
- return contentStr;
1649
- }
1650
- /**
1651
- * Extract workflow ID from tool result for post-execution permission checks.
1652
- * Returns the workflow ID if found, or undefined if extraction fails.
1653
- */
1654
- extractWorkflowIdFromResult(toolName, resultStr) {
1655
- try {
1656
- const jsonMatch = resultStr.match(/\{[\s\S]*\}/);
1657
- if (!jsonMatch)
1658
- return undefined;
1659
- const parsed = JSON.parse(jsonMatch[0]);
1660
- // show_activity_by_id: workflowId is in the activity result
1661
- if (toolName === 'show_activity_by_id') {
1662
- return parsed.workflowId || parsed.workflow_id || parsed.processId;
1663
- }
1664
- // get_activity_from_discussion: returns activity with workflow info
1665
- if (toolName === 'get_activity_from_discussion') {
1666
- return parsed.workflowId || parsed.workflow_id || parsed.processId
1667
- || parsed.activity?.workflowId || parsed.activity?.processId;
1668
- }
1669
- // fetch_discussion_messages: result may contain a workflowId from linked activity
1670
- if (toolName === 'fetch_discussion_messages') {
1671
- return parsed.workflowId || parsed.workflow_id || parsed.processId
1672
- || parsed.activity?.workflowId || parsed.activity?.processId;
1673
- }
1674
- }
1675
- catch {
1676
- // JSON parse failed, cannot extract
1677
- }
1678
- return undefined;
1679
- }
1680
830
  trackTokenUsage(response, message, model) {
1681
831
  if (!response.usage)
1682
832
  return;
1683
833
  const { input_tokens, output_tokens } = response.usage;
1684
834
  const cacheCreation = response.usage.cache_creation_input_tokens || 0;
1685
835
  const cacheRead = response.usage.cache_read_input_tokens || 0;
1686
- // Burn tokens - always use bot's workspace, not the discussion's workspace
1687
836
  const burnWorkspaceId = this._workspaceId || message.workspaceId;
1688
837
  if (this.tokenBilling && burnWorkspaceId) {
1689
838
  const cost = this.tokenBilling.calculateCost(input_tokens, output_tokens, cacheCreation, cacheRead, model);
1690
839
  this.tokenBilling.burnTokens({
1691
- workspaceId: burnWorkspaceId,
1692
- inputTokens: input_tokens,
1693
- outputTokens: output_tokens,
1694
- cacheCreationTokens: cacheCreation,
1695
- cacheReadTokens: cacheRead,
1696
- costUsd: cost,
1697
- sessionId: message.discussionId,
1698
- model,
840
+ workspaceId: burnWorkspaceId, inputTokens: input_tokens, outputTokens: output_tokens,
841
+ cacheCreationTokens: cacheCreation, cacheReadTokens: cacheRead, costUsd: cost,
842
+ sessionId: message.discussionId, model,
1699
843
  });
1700
844
  }
1701
845
  }