@hailer/mcp 1.1.11 → 1.1.13

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 (252) hide show
  1. package/dist/app.js +18 -5
  2. package/dist/bot/bot-config.d.ts +12 -1
  3. package/dist/bot/bot-config.js +98 -14
  4. package/dist/bot/bot-manager.d.ts +13 -3
  5. package/dist/bot/bot-manager.js +80 -25
  6. package/dist/bot/bot.d.ts +46 -0
  7. package/dist/bot/bot.js +542 -166
  8. package/dist/bot/services/message-classifier.js +17 -0
  9. package/dist/bot/services/permission-guard.d.ts +52 -0
  10. package/dist/bot/services/permission-guard.js +149 -0
  11. package/dist/bot/services/types.d.ts +5 -0
  12. package/dist/bot/services/typing-indicator.d.ts +6 -1
  13. package/dist/bot/services/typing-indicator.js +19 -3
  14. package/dist/config.d.ts +6 -1
  15. package/dist/config.js +43 -0
  16. package/dist/core.js +3 -6
  17. package/dist/mcp/UserContextCache.d.ts +5 -0
  18. package/dist/mcp/UserContextCache.js +51 -19
  19. package/dist/mcp/hailer-clients.d.ts +19 -1
  20. package/dist/mcp/hailer-clients.js +157 -20
  21. package/dist/mcp/session-store.d.ts +68 -0
  22. package/dist/mcp/session-store.js +169 -0
  23. package/dist/mcp/signal-handler.js +12 -12
  24. package/dist/mcp/tool-registry.d.ts +17 -4
  25. package/dist/mcp/tool-registry.js +37 -7
  26. package/dist/mcp/tools/activity.js +99 -7
  27. package/dist/mcp/tools/app-scaffold.js +304 -336
  28. package/dist/mcp/tools/company.d.ts +9 -0
  29. package/dist/mcp/tools/company.js +88 -0
  30. package/dist/mcp/tools/discussion.js +68 -0
  31. package/dist/mcp/tools/workflow-permissions.d.ts +15 -0
  32. package/dist/mcp/tools/workflow-permissions.js +204 -0
  33. package/dist/mcp/tools/workflow.js +57 -18
  34. package/dist/mcp/utils/index.d.ts +2 -0
  35. package/dist/mcp/utils/index.js +12 -1
  36. package/dist/mcp/utils/role-utils.d.ts +74 -0
  37. package/dist/mcp/utils/role-utils.js +151 -0
  38. package/dist/mcp/utils/types.d.ts +43 -1
  39. package/dist/mcp/utils/types.js +14 -0
  40. package/dist/mcp/webhook-handler.d.ts +6 -0
  41. package/dist/mcp/webhook-handler.js +11 -0
  42. package/dist/mcp-server.d.ts +23 -2
  43. package/dist/mcp-server.js +639 -111
  44. package/dist/plugins/vipunen/client.d.ts +150 -0
  45. package/dist/plugins/vipunen/client.js +535 -0
  46. package/dist/plugins/vipunen/config/schema-config.json +19 -0
  47. package/dist/plugins/vipunen/config/schema-doc.json +22 -0
  48. package/dist/plugins/vipunen/index.d.ts +41 -0
  49. package/dist/plugins/vipunen/index.js +88 -0
  50. package/dist/plugins/vipunen/tools.d.ts +26 -0
  51. package/dist/plugins/vipunen/tools.js +501 -0
  52. package/package.json +2 -1
  53. package/.claude/.context-watchdog.json +0 -1
  54. package/.claude/.session-checked +0 -1
  55. package/.claude/CLAUDE.md +0 -370
  56. package/.claude/agents/agent-ada-skill-builder.md +0 -94
  57. package/.claude/agents/agent-alejandro-function-fields.md +0 -342
  58. package/.claude/agents/agent-bjorn-config-audit.md +0 -103
  59. package/.claude/agents/agent-builder-agent-creator.md +0 -130
  60. package/.claude/agents/agent-code-simplifier.md +0 -53
  61. package/.claude/agents/agent-dmitri-activity-crud.md +0 -159
  62. package/.claude/agents/agent-giuseppe-app-builder.md +0 -247
  63. package/.claude/agents/agent-gunther-mcp-tools.md +0 -39
  64. package/.claude/agents/agent-helga-workflow-config.md +0 -204
  65. package/.claude/agents/agent-igor-activity-mover-automation.md +0 -125
  66. package/.claude/agents/agent-ingrid-doc-templates.md +0 -261
  67. package/.claude/agents/agent-ivan-monolith.md +0 -154
  68. package/.claude/agents/agent-kenji-data-reader.md +0 -86
  69. package/.claude/agents/agent-lars-code-inspector.md +0 -102
  70. package/.claude/agents/agent-marco-mockup-builder.md +0 -110
  71. package/.claude/agents/agent-marcus-api-documenter.md +0 -323
  72. package/.claude/agents/agent-marketplace-publisher.md +0 -280
  73. package/.claude/agents/agent-marketplace-reviewer.md +0 -309
  74. package/.claude/agents/agent-permissions-handler.md +0 -208
  75. package/.claude/agents/agent-simple-writer.md +0 -48
  76. package/.claude/agents/agent-svetlana-code-review.md +0 -171
  77. package/.claude/agents/agent-tanya-test-runner.md +0 -333
  78. package/.claude/agents/agent-ui-designer.md +0 -100
  79. package/.claude/agents/agent-viktor-sql-insights.md +0 -212
  80. package/.claude/agents/agent-web-search.md +0 -55
  81. package/.claude/agents/agent-yevgeni-discussions.md +0 -45
  82. package/.claude/agents/agent-zara-zapier.md +0 -159
  83. package/.claude/commands/app-squad.md +0 -135
  84. package/.claude/commands/audit-squad.md +0 -158
  85. package/.claude/commands/autoplan.md +0 -563
  86. package/.claude/commands/cleanup-squad.md +0 -98
  87. package/.claude/commands/config-squad.md +0 -106
  88. package/.claude/commands/crud-squad.md +0 -87
  89. package/.claude/commands/data-squad.md +0 -97
  90. package/.claude/commands/debug-squad.md +0 -303
  91. package/.claude/commands/doc-squad.md +0 -65
  92. package/.claude/commands/handoff.md +0 -137
  93. package/.claude/commands/health.md +0 -49
  94. package/.claude/commands/help.md +0 -29
  95. package/.claude/commands/help:agents.md +0 -151
  96. package/.claude/commands/help:commands.md +0 -78
  97. package/.claude/commands/help:faq.md +0 -79
  98. package/.claude/commands/help:plugins.md +0 -50
  99. package/.claude/commands/help:skills.md +0 -93
  100. package/.claude/commands/help:tools.md +0 -75
  101. package/.claude/commands/hotfix-squad.md +0 -112
  102. package/.claude/commands/integration-squad.md +0 -82
  103. package/.claude/commands/janitor-squad.md +0 -167
  104. package/.claude/commands/learn-auto.md +0 -120
  105. package/.claude/commands/learn.md +0 -120
  106. package/.claude/commands/mcp-list.md +0 -27
  107. package/.claude/commands/onboard-squad.md +0 -140
  108. package/.claude/commands/plan-workspace.md +0 -732
  109. package/.claude/commands/prd.md +0 -130
  110. package/.claude/commands/project-status.md +0 -82
  111. package/.claude/commands/publish.md +0 -138
  112. package/.claude/commands/recap.md +0 -69
  113. package/.claude/commands/restore.md +0 -64
  114. package/.claude/commands/review-squad.md +0 -152
  115. package/.claude/commands/save.md +0 -24
  116. package/.claude/commands/stats.md +0 -19
  117. package/.claude/commands/swarm.md +0 -210
  118. package/.claude/commands/tool-builder.md +0 -39
  119. package/.claude/commands/ws-pull.md +0 -44
  120. package/.claude/hooks/_shared-memory.cjs +0 -305
  121. package/.claude/hooks/_utils.cjs +0 -108
  122. package/.claude/hooks/agent-failure-detector.cjs +0 -383
  123. package/.claude/hooks/agent-usage-logger.cjs +0 -204
  124. package/.claude/hooks/app-edit-guard.cjs +0 -494
  125. package/.claude/hooks/auto-learn.cjs +0 -304
  126. package/.claude/hooks/bash-guard.cjs +0 -272
  127. package/.claude/hooks/builder-mode-manager.cjs +0 -354
  128. package/.claude/hooks/bulk-activity-guard.cjs +0 -271
  129. package/.claude/hooks/context-watchdog.cjs +0 -230
  130. package/.claude/hooks/delegation-reminder.cjs +0 -465
  131. package/.claude/hooks/design-system-lint.cjs +0 -271
  132. package/.claude/hooks/post-scaffold-hook.cjs +0 -181
  133. package/.claude/hooks/prompt-guard.cjs +0 -354
  134. package/.claude/hooks/publish-template-guard.cjs +0 -147
  135. package/.claude/hooks/session-start.cjs +0 -35
  136. package/.claude/hooks/shared-memory-writer.cjs +0 -147
  137. package/.claude/hooks/skill-injector.cjs +0 -140
  138. package/.claude/hooks/skill-usage-logger.cjs +0 -258
  139. package/.claude/hooks/src-edit-guard.cjs +0 -240
  140. package/.claude/hooks/sync-marketplace-agents.cjs +0 -346
  141. package/.claude/settings.json +0 -257
  142. package/.claude/skills/SDK-activity-patterns/SKILL.md +0 -428
  143. package/.claude/skills/SDK-document-templates/SKILL.md +0 -1033
  144. package/.claude/skills/SDK-function-fields/SKILL.md +0 -542
  145. package/.claude/skills/SDK-generate-skill/SKILL.md +0 -92
  146. package/.claude/skills/SDK-init-skill/SKILL.md +0 -127
  147. package/.claude/skills/SDK-insight-queries/SKILL.md +0 -787
  148. package/.claude/skills/SDK-ws-config-skill/SKILL.md +0 -1139
  149. package/.claude/skills/agent-structure/SKILL.md +0 -98
  150. package/.claude/skills/api-documentation-patterns/SKILL.md +0 -474
  151. package/.claude/skills/chrome-mcp-reference/SKILL.md +0 -370
  152. package/.claude/skills/delegation-routing/SKILL.md +0 -202
  153. package/.claude/skills/frontend-design/SKILL.md +0 -254
  154. package/.claude/skills/hailer-activity-mover/SKILL.md +0 -213
  155. package/.claude/skills/hailer-api-client/SKILL.md +0 -518
  156. package/.claude/skills/hailer-app-builder/SKILL.md +0 -1434
  157. package/.claude/skills/hailer-apps-pictures/SKILL.md +0 -269
  158. package/.claude/skills/hailer-design-system/SKILL.md +0 -235
  159. package/.claude/skills/hailer-monolith-automations/SKILL.md +0 -686
  160. package/.claude/skills/hailer-permissions-system/SKILL.md +0 -121
  161. package/.claude/skills/hailer-project-protocol/SKILL.md +0 -488
  162. package/.claude/skills/hailer-rest-api/SKILL.md +0 -61
  163. package/.claude/skills/hailer-rest-api/hailer-activities.md +0 -184
  164. package/.claude/skills/hailer-rest-api/hailer-admin.md +0 -473
  165. package/.claude/skills/hailer-rest-api/hailer-calendar.md +0 -256
  166. package/.claude/skills/hailer-rest-api/hailer-feed.md +0 -249
  167. package/.claude/skills/hailer-rest-api/hailer-insights.md +0 -195
  168. package/.claude/skills/hailer-rest-api/hailer-messaging.md +0 -276
  169. package/.claude/skills/hailer-rest-api/hailer-workflows.md +0 -283
  170. package/.claude/skills/insight-join-patterns/SKILL.md +0 -174
  171. package/.claude/skills/integration-patterns/SKILL.md +0 -421
  172. package/.claude/skills/json-only-output/SKILL.md +0 -72
  173. package/.claude/skills/lsp-setup/SKILL.md +0 -160
  174. package/.claude/skills/mcp-direct-tools/SKILL.md +0 -153
  175. package/.claude/skills/optional-parameters/SKILL.md +0 -72
  176. package/.claude/skills/publish-hailer-app/SKILL.md +0 -244
  177. package/.claude/skills/testing-patterns/SKILL.md +0 -630
  178. package/.claude/skills/tool-builder/SKILL.md +0 -250
  179. package/.claude/skills/tool-parameter-usage/SKILL.md +0 -126
  180. package/.claude/skills/tool-response-verification/SKILL.md +0 -92
  181. package/.claude/skills/zapier-hailer-patterns/SKILL.md +0 -581
  182. package/.hailer-mcp-port +0 -1
  183. package/.mcp.json +0 -13
  184. package/.opencode/agent/agent-ada-skill-builder.md +0 -35
  185. package/.opencode/agent/agent-alejandro-function-fields.md +0 -39
  186. package/.opencode/agent/agent-bjorn-config-audit.md +0 -36
  187. package/.opencode/agent/agent-builder-agent-creator.md +0 -39
  188. package/.opencode/agent/agent-code-simplifier.md +0 -31
  189. package/.opencode/agent/agent-dmitri-activity-crud.md +0 -40
  190. package/.opencode/agent/agent-giuseppe-app-builder.md +0 -37
  191. package/.opencode/agent/agent-gunther-mcp-tools.md +0 -39
  192. package/.opencode/agent/agent-helga-workflow-config.md +0 -204
  193. package/.opencode/agent/agent-igor-activity-mover-automation.md +0 -46
  194. package/.opencode/agent/agent-ingrid-doc-templates.md +0 -39
  195. package/.opencode/agent/agent-ivan-monolith.md +0 -46
  196. package/.opencode/agent/agent-kenji-data-reader.md +0 -53
  197. package/.opencode/agent/agent-lars-code-inspector.md +0 -28
  198. package/.opencode/agent/agent-marco-mockup-builder.md +0 -42
  199. package/.opencode/agent/agent-marcus-api-documenter.md +0 -53
  200. package/.opencode/agent/agent-marketplace-publisher.md +0 -44
  201. package/.opencode/agent/agent-marketplace-reviewer.md +0 -42
  202. package/.opencode/agent/agent-permissions-handler.md +0 -50
  203. package/.opencode/agent/agent-simple-writer.md +0 -45
  204. package/.opencode/agent/agent-svetlana-code-review.md +0 -39
  205. package/.opencode/agent/agent-tanya-test-runner.md +0 -57
  206. package/.opencode/agent/agent-ui-designer.md +0 -56
  207. package/.opencode/agent/agent-viktor-sql-insights.md +0 -34
  208. package/.opencode/agent/agent-web-search.md +0 -42
  209. package/.opencode/agent/agent-yevgeni-discussions.md +0 -37
  210. package/.opencode/agent/agent-zara-zapier.md +0 -53
  211. package/.opencode/commands/app-squad.md +0 -135
  212. package/.opencode/commands/audit-squad.md +0 -158
  213. package/.opencode/commands/autoplan.md +0 -563
  214. package/.opencode/commands/cleanup-squad.md +0 -98
  215. package/.opencode/commands/config-squad.md +0 -106
  216. package/.opencode/commands/crud-squad.md +0 -87
  217. package/.opencode/commands/data-squad.md +0 -97
  218. package/.opencode/commands/debug-squad.md +0 -303
  219. package/.opencode/commands/doc-squad.md +0 -65
  220. package/.opencode/commands/handoff.md +0 -137
  221. package/.opencode/commands/health.md +0 -49
  222. package/.opencode/commands/help-agents.md +0 -151
  223. package/.opencode/commands/help-commands.md +0 -32
  224. package/.opencode/commands/help-faq.md +0 -29
  225. package/.opencode/commands/help-plugins.md +0 -28
  226. package/.opencode/commands/help-skills.md +0 -7
  227. package/.opencode/commands/help-tools.md +0 -40
  228. package/.opencode/commands/help.md +0 -28
  229. package/.opencode/commands/hotfix-squad.md +0 -112
  230. package/.opencode/commands/integration-squad.md +0 -82
  231. package/.opencode/commands/janitor-squad.md +0 -167
  232. package/.opencode/commands/learn-auto.md +0 -120
  233. package/.opencode/commands/learn.md +0 -120
  234. package/.opencode/commands/mcp-list.md +0 -27
  235. package/.opencode/commands/onboard-squad.md +0 -140
  236. package/.opencode/commands/plan-workspace.md +0 -732
  237. package/.opencode/commands/prd.md +0 -131
  238. package/.opencode/commands/project-status.md +0 -82
  239. package/.opencode/commands/publish.md +0 -138
  240. package/.opencode/commands/recap.md +0 -69
  241. package/.opencode/commands/restore.md +0 -64
  242. package/.opencode/commands/review-squad.md +0 -152
  243. package/.opencode/commands/save.md +0 -24
  244. package/.opencode/commands/stats.md +0 -19
  245. package/.opencode/commands/swarm.md +0 -210
  246. package/.opencode/commands/tool-builder.md +0 -39
  247. package/.opencode/commands/ws-pull.md +0 -44
  248. package/.opencode/opencode.json +0 -21
  249. package/inbox/failures.log +0 -1
  250. package/inbox/usage.jsonl +0 -4
  251. package/scripts/postinstall.cjs +0 -64
  252. package/scripts/test-hal-tools.ts +0 -154
package/dist/bot/bot.js CHANGED
@@ -15,12 +15,14 @@ const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
15
15
  const tool_registry_1 = require("../mcp/tool-registry");
16
16
  const hailer_clients_1 = require("../mcp/hailer-clients");
17
17
  const hailer_api_client_1 = require("../mcp/utils/hailer-api-client");
18
+ const types_1 = require("../mcp/utils/types");
18
19
  const workspace_cache_1 = require("../mcp/workspace-cache");
19
20
  const tool_executor_1 = require("./tool-executor");
20
21
  const workspace_overview_1 = require("./workspace-overview");
21
22
  const operation_logger_1 = require("./operation-logger");
22
23
  const services_1 = require("./services");
23
24
  const logger_1 = require("../lib/logger");
25
+ const permission_guard_1 = require("./services/permission-guard");
24
26
  /**
25
27
  * Tools available to the bot. Add/remove tool names here to control what the LLM can use.
26
28
  * The MCP server (Claude Code terminal) is unaffected - it keeps full access to all tools.
@@ -59,21 +61,31 @@ const BOT_TOOLS = new Set([
59
61
  'preview_insight',
60
62
  'create_insight',
61
63
  'update_insight',
64
+ // Permissions
65
+ 'list_workflow_permissions',
66
+ 'check_user_permissions',
67
+ 'grant_workflow_permission',
68
+ 'revoke_workflow_permission',
62
69
  ]);
63
- const WORKFLOW_SCOPED_TOOLS = new Set([
64
- 'list_activities', 'count_activities',
65
- 'get_workflow_schema', 'list_workflow_phases',
66
- 'create_activity', 'update_activity',
67
- 'install_workflow',
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',
68
74
  ]);
69
- /** Tools that bypass the workflowId arg gate and need post-execution permission checks */
70
- const UNGATED_TOOLS = new Set([
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([
71
80
  'show_activity_by_id',
72
81
  'fetch_discussion_messages',
73
82
  'get_activity_from_discussion',
74
83
  ]);
84
+ /** Minimal tool set for SIMPLE-routed messages (greetings, quick lookups). */
75
85
  const MODEL_HAIKU = 'claude-haiku-4-5-20251001';
76
86
  const MODEL_SONNET = 'claude-sonnet-4-5-20250929';
87
+ 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
77
89
  const MAX_TOOL_ITERATIONS = 10;
78
90
  const RATE_LIMIT_WINDOW = 60_000; // 60 seconds
79
91
  const RATE_LIMIT_PER_DISCUSSION = 30; // max signals per discussion per window
@@ -91,6 +103,9 @@ class Bot {
91
103
  workspaceOverview = '';
92
104
  userId = '';
93
105
  _workspaceId;
106
+ // Live-updatable config (can be hot-swapped without restart)
107
+ _systemPrompt;
108
+ _responseMode = 'always';
94
109
  // Services
95
110
  conversationManager = null;
96
111
  messageClassifier = null;
@@ -98,6 +113,9 @@ class Bot {
98
113
  typingIndicator = null;
99
114
  tokenBilling = null;
100
115
  sessionLogger = null;
116
+ hailerApi = null;
117
+ /** Cache of insightId → Set<workflowId> for permission checks on ID-based insight tools */
118
+ insightWorkflowCache = new Map();
101
119
  opLogger = new operation_logger_1.OperationLogger();
102
120
  // State
103
121
  discussionStates = new Map();
@@ -119,25 +137,62 @@ class Bot {
119
137
  permissionIndex = new Map();
120
138
  adminUserIds = new Set();
121
139
  ownerUserIds = new Set();
140
+ workspaceMemberIds = new Set();
141
+ membersLoaded = false;
122
142
  // Config
123
143
  config;
144
+ botManager; // BotManager ref for cross-bot awareness
124
145
  constructor(config) {
125
146
  this.config = {
126
- ...config,
147
+ email: config.email,
148
+ password: config.password,
149
+ apiHost: config.apiHost,
150
+ anthropicApiKey: config.anthropicApiKey,
127
151
  model: config.model || 'claude-haiku-4-5-20251001',
152
+ toolRegistry: config.toolRegistry,
153
+ workspaceId: config.workspaceId,
154
+ systemPrompt: config.systemPrompt,
155
+ accessLevel: config.accessLevel || 'all',
156
+ allowedWorkflows: config.allowedWorkflows || [],
128
157
  };
158
+ this.botManager = config.botManager || null;
159
+ this._systemPrompt = config.systemPrompt;
160
+ this._responseMode = config.responseMode || 'always';
129
161
  this.logger = (0, logger_1.createLogger)({ component: 'Bot' });
130
162
  this.clientManager = new hailer_clients_1.HailerClientManager(config.apiHost, config.email, config.password);
131
163
  this.toolExecutor = new tool_executor_1.ToolExecutor(config.toolRegistry);
132
164
  }
165
+ get email() { return this.config.email; }
166
+ get password() { return this.config.password; }
167
+ 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
+ */
172
+ updateSystemPrompt(prompt) {
173
+ this._systemPrompt = prompt;
174
+ this.logger.info('System prompt updated live', { hasPrompt: !!prompt });
175
+ }
176
+ updateResponseMode(mode) {
177
+ this._responseMode = mode || 'always';
178
+ this.logger.info('Response mode updated live', { responseMode: this._responseMode });
179
+ }
133
180
  get connected() {
134
181
  return this._connected;
135
182
  }
136
183
  get workspaceId() {
137
184
  return this._workspaceId;
138
185
  }
186
+ /** Exposed for BotManager cross-bot awareness */
187
+ get botUserId() {
188
+ return this.userId;
189
+ }
139
190
  // ===== LIFECYCLE =====
140
191
  async start() {
192
+ if (this._connected) {
193
+ this.logger.warn('Bot.start() called while already connected, ignoring');
194
+ return;
195
+ }
141
196
  this.logger.debug('Starting bot', { email: this.config.email });
142
197
  // 1. Connect to Hailer
143
198
  this.client = await this.clientManager.connect();
@@ -152,9 +207,9 @@ class Bot {
152
207
  this.init = await this.client.socket.request('v2.core.init', [
153
208
  ['processes', 'users', 'network', 'networks', 'teams', 'groups'],
154
209
  ]);
210
+ (0, types_1.normalizeInitProcesses)(this.init);
155
211
  this._workspaceId = this.init.network?._id || this.config.workspaceId;
156
- this.buildPermissionIndex();
157
- // 4. Build workspace cache and overview
212
+ // 4. Build workspace cache and overview (before permission index so names resolve)
158
213
  this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
159
214
  excludeTranslations: true,
160
215
  excludeSystemMessages: true,
@@ -162,6 +217,8 @@ class Bot {
162
217
  compactUserData: true,
163
218
  includeWorkspaceNamesInTools: false,
164
219
  });
220
+ this.backfillWorkspaceCacheMembers();
221
+ this.buildPermissionIndex();
165
222
  this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
166
223
  // 5. Create Anthropic client
167
224
  this.anthropic = new sdk_1.default({ apiKey: this.config.anthropicApiKey });
@@ -180,6 +237,7 @@ class Bot {
180
237
  this.typingIndicator = new services_1.TypingIndicatorService(botConnection, this.logger);
181
238
  // Token billing
182
239
  const hailerApi = new hailer_api_client_1.HailerApiClient(this.client);
240
+ this.hailerApi = hailerApi;
183
241
  this.tokenBilling = new services_1.TokenBillingService(this.logger, hailerApi);
184
242
  // Message formatter - wrap ToolExecutor as MCP-style callback
185
243
  const toolCallback = async (name, args) => {
@@ -190,6 +248,7 @@ class Bot {
190
248
  this.sessionLogger = new services_1.SessionLoggerService(null, this.logger, toolCallback, () => this.getDefaultTeamId());
191
249
  this.sessionLogger.setAnthropicClient(this.anthropic);
192
250
  // 8. Build UserContext for tool execution
251
+ const currentWorkspaceId = this.init.network?._id || '';
193
252
  this.userContext = {
194
253
  client: this.client,
195
254
  hailer: hailerApi,
@@ -199,6 +258,9 @@ class Bot {
199
258
  createdAt: Date.now(),
200
259
  email: this.config.email,
201
260
  password: this.config.password,
261
+ workspaceRoles: { [currentWorkspaceId]: 'admin' },
262
+ currentWorkspaceId,
263
+ allowedGroups: ['read', 'write', 'bot_internal'],
202
264
  };
203
265
  // 9. Subscribe to messenger.new signals
204
266
  this.signalHandler = (eventData) => {
@@ -218,6 +280,9 @@ class Bot {
218
280
  this.clientManager.onSignal('process.updated', this.processUpdatedHandler);
219
281
  this.clientManager.onSignal('workspace.updated', this.workspaceUpdatedHandler);
220
282
  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
221
286
  this._connected = true;
222
287
  this.logger.debug('Bot started', {
223
288
  userId: this.userId,
@@ -265,16 +330,35 @@ class Bot {
265
330
  this.client = null;
266
331
  this.logger.debug('Bot stopped');
267
332
  }
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
+ }
268
346
  // ===== SIGNAL HANDLING =====
269
347
  async handleSignal(signal) {
270
348
  if (!this._connected || !this.messageClassifier)
271
349
  return;
272
350
  try {
273
351
  const rawMsgId = signal.data.msg_id;
274
- if (rawMsgId) {
275
- if (this.processedMessageIds.has(rawMsgId))
352
+ const discussionId = signal.data.discussion;
353
+ const dedupKey = rawMsgId || (discussionId ? `${discussionId}:${signal.data.uid}:${signal.data.created}` : null);
354
+ if (dedupKey) {
355
+ if (this.processedMessageIds.has(dedupKey))
276
356
  return;
277
- this.processedMessageIds.add(rawMsgId);
357
+ this.processedMessageIds.add(dedupKey);
358
+ }
359
+ else {
360
+ this.logger.warn('messenger.new signal has no dedup key, skipping');
361
+ return;
278
362
  }
279
363
  const message = await this.messageClassifier.extractIncomingMessage(signal);
280
364
  if (!message) {
@@ -282,9 +366,14 @@ class Bot {
282
366
  this.processedMessageIds.delete(rawMsgId);
283
367
  return;
284
368
  }
285
- // Self-message guard: prevent bot feedback loops
369
+ // Self-message guard: prevent bot feedback loops (checks ALL bots in this workspace)
286
370
  if (message.senderId === this.userId)
287
371
  return;
372
+ if (this.botManager && this._workspaceId) {
373
+ const botUserIds = this.botManager.getBotUserIdsForWorkspace(this._workspaceId);
374
+ if (botUserIds.has(message.senderId))
375
+ return;
376
+ }
288
377
  // Rate limiting
289
378
  const now = Date.now();
290
379
  const cutoff = now - RATE_LIMIT_WINDOW;
@@ -320,37 +409,71 @@ class Bot {
320
409
  return;
321
410
  }
322
411
  this.globalSignalTimestamps.push(now);
323
- // Membership check - only respond to users who are members of the bot's workspace
324
- if (this.init?.users && !this.init.users[message.senderId]) {
325
- this.logger.debug('Ignoring message from non-workspace member', {
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', {
326
417
  senderId: message.senderId,
327
418
  senderName: message.senderName,
419
+ membersLoaded: this.membersLoaded,
328
420
  });
329
421
  if (rawMsgId)
330
422
  this.processedMessageIds.delete(rawMsgId);
331
423
  return;
332
424
  }
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
+ }
333
443
  // Prune dedup set
334
444
  if (this.processedMessageIds.size > 500) {
335
445
  const ids = Array.from(this.processedMessageIds);
336
446
  this.processedMessageIds = new Set(ids.slice(-250));
337
447
  }
338
448
  const state = this.getOrCreateDiscussionState(discId);
339
- // Always buffer the message
449
+ // Always buffer the message (provides context when bot IS triggered later)
340
450
  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
+ }
341
457
  // Determine if this should trigger processing
342
- const isExplicitTrigger = message.isMention || message.isReplyToBot || message.isDirectMessage;
458
+ // Private discussions (DMs) are always explicit triggers
459
+ const isExplicitTrigger = message.isMention || message.isReplyToBot || message.isPrivateDiscussion;
343
460
  if (state.state === 'idle') {
344
- if (isExplicitTrigger) {
461
+ // In 'always' mode, every message triggers engagement (original behavior)
462
+ if (isExplicitTrigger || this._responseMode === 'always') {
345
463
  state.state = 'engaged';
346
464
  state.consecutiveNonResponses = 0;
347
- this.opLogger.engage(discId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : 'dm');
465
+ this.opLogger.engage(discId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : message.isDirectMessage ? 'dm' : 'always');
348
466
  }
349
467
  else {
350
468
  // Not addressed while idle — message sits in buffer as context only
351
469
  return;
352
470
  }
353
471
  }
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) {
475
+ return;
476
+ }
354
477
  // State is 'engaged' — trigger processing if not already running
355
478
  if (!this.processingDiscussions.has(discId)) {
356
479
  this.processDiscussion(discId).catch(err => {
@@ -389,7 +512,17 @@ class Bot {
389
512
  if (messages.length > 1) {
390
513
  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}`;
391
514
  }
392
- conversation.push({ role: 'user', content: merged });
515
+ // Resolve image attachments for vision
516
+ const imageBlocks = await this.resolveImageAttachments(messages);
517
+ if (imageBlocks.length > 0) {
518
+ conversation.push({
519
+ role: 'user',
520
+ content: [{ type: 'text', text: merged }, ...imageBlocks],
521
+ });
522
+ }
523
+ else {
524
+ conversation.push({ role: 'user', content: merged });
525
+ }
393
526
  // Use last message for routing/billing context
394
527
  const primaryMessage = messages[messages.length - 1];
395
528
  state.lastProgressTime = 0;
@@ -451,7 +584,6 @@ class Bot {
451
584
  const defaultRoute = {
452
585
  model: MODEL_HAIKU,
453
586
  maxTokens: 2000,
454
- classification: 'SIMPLE',
455
587
  };
456
588
  try {
457
589
  const wsName = this.init?.network?.name || 'Workspace';
@@ -493,12 +625,11 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
493
625
  const route = {
494
626
  model: MODEL_SONNET,
495
627
  maxTokens: 16384,
496
- classification: 'COMPLEX',
497
628
  };
498
- this.opLogger.route(message.discussionId, route.classification, route.model, message.content);
629
+ this.opLogger.route(message.discussionId, 'COMPLEX', route.model, message.content);
499
630
  return route;
500
631
  }
501
- this.opLogger.route(message.discussionId, defaultRoute.classification, defaultRoute.model, message.content);
632
+ this.opLogger.route(message.discussionId, 'SIMPLE', defaultRoute.model, message.content);
502
633
  return defaultRoute;
503
634
  }
504
635
  catch (error) {
@@ -564,7 +695,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
564
695
  }
565
696
  // ===== MESSAGE PROCESSING =====
566
697
  async processMessage(message, signal) {
567
- this.typingIndicator?.start(message.discussionId);
698
+ this.typingIndicator?.start(message.discussionId, 'Reading');
568
699
  // Check token balance
569
700
  const billingWorkspaceId = this._workspaceId || message.workspaceId;
570
701
  if (this.tokenBilling && billingWorkspaceId) {
@@ -604,6 +735,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
604
735
  const cachedConversation = this.conversationManager.prepareForCaching(conversation);
605
736
  let response;
606
737
  let llmStart = Date.now();
738
+ this.typingIndicator?.updateStatus(message.discussionId, i === 0 ? 'Thinking' : 'Processing');
607
739
  try {
608
740
  response = await this.anthropic.messages.create({
609
741
  model: route.model,
@@ -626,7 +758,8 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
626
758
  this.logger.warn('Sonnet API call failed, falling back to Haiku', {
627
759
  error: error instanceof Error ? error.message : String(error),
628
760
  });
629
- route = { model: MODEL_HAIKU, maxTokens: 2000, classification: 'SIMPLE' };
761
+ route = { model: MODEL_HAIKU, maxTokens: 2000 };
762
+ this.typingIndicator?.updateStatus(message.discussionId, 'Switching models');
630
763
  llmStart = Date.now();
631
764
  response = await this.anthropic.messages.create({
632
765
  model: MODEL_HAIKU,
@@ -682,10 +815,11 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
682
815
  // Check for tool calls
683
816
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
684
817
  if (toolUseBlocks.length > 0) {
818
+ this.typingIndicator?.updateStatus(message.discussionId, this.getToolStatus(toolUseBlocks));
685
819
  const toolResults = await this.executeTools(toolUseBlocks, message, signal);
686
820
  conversation.push({ role: 'user', content: toolResults });
687
821
  // Inject any messages that arrived during tool execution
688
- this.injectPendingContext(message.discussionId, conversation);
822
+ await this.injectPendingContext(message.discussionId, conversation);
689
823
  // Auto-escalate: if Haiku is failing tool calls, switch to Sonnet
690
824
  if (route.model === MODEL_HAIKU) {
691
825
  const failedCount = toolResults
@@ -696,7 +830,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
696
830
  failedTools: failedCount,
697
831
  iteration: i,
698
832
  });
699
- route = { model: MODEL_SONNET, maxTokens: 16384, classification: 'COMPLEX' };
833
+ route = { model: MODEL_SONNET, maxTokens: 16384 };
700
834
  await this.sendMessage(message.discussionId, `Switching to a more capable model to handle this.`);
701
835
  }
702
836
  }
@@ -730,6 +864,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
730
864
  break;
731
865
  }
732
866
  // Post the response
867
+ this.typingIndicator?.updateStatus(message.discussionId, 'Writing response');
733
868
  this.typingIndicator?.stop(message.discussionId);
734
869
  const formatted = await this.formatOutgoingMessage(responseText);
735
870
  const links = this.messageFormatter.extractTagLinks(formatted);
@@ -746,17 +881,36 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
746
881
  const WRITE_TOOLS = new Set(['create_activity', 'update_activity']);
747
882
  for (const block of toolUseBlocks) {
748
883
  const toolStart = Date.now();
749
- // Permission gate: check if user has access to the workflow
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
+ });
895
+ continue;
896
+ }
897
+ }
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).
750
901
  const args = block.input;
751
- const workflowId = args.workflowId;
752
- if (workflowId && WORKFLOW_SCOPED_TOOLS.has(block.name)) {
753
- const allowed = this.permissionIndex.get(workflowId);
754
- if (allowed && !allowed.has(message.senderId) && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
755
- this.opLogger.permDenied(discussionId, block.name, workflowId, message.senderId);
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);
756
908
  results.push({
757
909
  type: 'tool_result',
758
910
  tool_use_id: block.id,
759
- content: `Permission denied: This user does not have access to workflow ${workflowId}. This is a workspace permission restriction on the user, not on you (the bot).`,
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}.`,
760
914
  is_error: true,
761
915
  });
762
916
  continue;
@@ -784,40 +938,32 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
784
938
  const text = result?.content?.[0]?.text ?? JSON.stringify(result);
785
939
  let contentStr = typeof text === 'string' ? text : JSON.stringify(text);
786
940
  const toolDuration = (Date.now() - toolStart) / 1000;
787
- // Post-filter list_workflows results to only show accessible workflows
788
- if ((block.name === 'list_workflows' || block.name === 'list_workflows_minimal') && this.permissionIndex.size > 0 && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
789
- try {
790
- const parsed = JSON.parse(contentStr);
791
- if (parsed.workflows && Array.isArray(parsed.workflows)) {
792
- const before = parsed.workflows.length;
793
- parsed.workflows = parsed.workflows.filter((wf) => {
794
- const wfId = wf.id || wf._id || wf.workflowId;
795
- if (!wfId)
796
- return true;
797
- const allowed = this.permissionIndex.get(wfId);
798
- return !allowed || allowed.has(message.senderId);
799
- });
800
- if (parsed.workflows.length < before) {
801
- this.opLogger.permFiltered(discussionId, block.name, before, parsed.workflows.length);
802
- }
803
- contentStr = JSON.stringify(parsed);
804
- }
805
- }
806
- catch {
807
- // Not JSON or unexpected format, pass through unfiltered
941
+ // ── Post-execution permission checks ──
942
+ // Cache insight workflow mapping after successful create_insight
943
+ 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);
808
950
  }
809
951
  }
810
- // Post-execution permission check for tools not gated by workflowId arg
811
- if (this.permissionIndex.size > 0 && UNGATED_TOOLS.has(block.name)) {
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)) {
812
956
  const extractedWorkflowId = this.extractWorkflowIdFromResult(block.name, contentStr);
813
957
  if (extractedWorkflowId) {
814
- const allowed = this.permissionIndex.get(extractedWorkflowId);
815
- if (allowed && !allowed.has(message.senderId) && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
816
- this.opLogger.permDenied(discussionId, block.name, extractedWorkflowId, message.senderId);
958
+ const denied = (0, permission_guard_1.checkWorkflowAccess)([extractedWorkflowId], message.senderId, permCtx);
959
+ if (denied) {
960
+ this.opLogger.permDenied(discussionId, block.name, denied.workflowId, denied.reason);
817
961
  results.push({
818
962
  type: 'tool_result',
819
963
  tool_use_id: block.id,
820
- content: `Permission denied: This user does not have access to the resource in workflow ${extractedWorkflowId}. This is a workspace permission restriction on the user, not on you (the bot).`,
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.`,
821
967
  is_error: true,
822
968
  });
823
969
  continue;
@@ -866,7 +1012,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
866
1012
  * between LLM iterations. Merges into the existing user message to preserve
867
1013
  * Anthropic's alternation rule (user/assistant/user/assistant).
868
1014
  */
869
- injectPendingContext(discussionId, conversation) {
1015
+ async injectPendingContext(discussionId, conversation) {
870
1016
  const state = this.discussionStates.get(discussionId);
871
1017
  if (!state || state.contextBuffer.length === 0)
872
1018
  return;
@@ -878,6 +1024,10 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
878
1024
  type: 'text',
879
1025
  text: `<context type="messages-during-processing">\n${contextText}\n</context>`,
880
1026
  });
1027
+ const imageBlocks = await this.resolveImageAttachments(pending);
1028
+ for (const block of imageBlocks) {
1029
+ lastMsg.content.push(block);
1030
+ }
881
1031
  this.opLogger.contextInject(discussionId, pending.length);
882
1032
  }
883
1033
  }
@@ -907,11 +1057,59 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
907
1057
  const nameAttr = discName ? ` discussion_name="${discName}"` : '';
908
1058
  let fileInfo = '';
909
1059
  if (message.fileAttachments?.length) {
910
- const ids = message.fileAttachments.map(f => f.fileId).join(', ');
911
- fileInfo = `\n[File attached: ${message.fileAttachments.length} file(s) - IDs: ${ids}]\nUse download_file tool with fileId to read file contents.`;
1060
+ const imageFiles = message.fileAttachments.filter(f => f.mime && IMAGE_MIME_TYPES.has(f.mime) && (!f.size || f.size <= MAX_IMAGE_SIZE));
1061
+ const nonImageFiles = message.fileAttachments.filter(f => !f.mime || !IMAGE_MIME_TYPES.has(f.mime) || (f.size && f.size > MAX_IMAGE_SIZE));
1062
+ const parts = [];
1063
+ for (const img of imageFiles) {
1064
+ 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
+ }
1070
+ if (parts.length)
1071
+ fileInfo = '\n' + parts.join('\n');
912
1072
  }
913
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>`;
914
1074
  }
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
+ async resolveImageAttachments(messages) {
1080
+ const blocks = [];
1081
+ if (!this.hailerApi)
1082
+ return blocks;
1083
+ const imageFiles = [];
1084
+ for (const msg of messages) {
1085
+ if (!msg.fileAttachments?.length)
1086
+ continue;
1087
+ for (const f of msg.fileAttachments) {
1088
+ 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 });
1090
+ }
1091
+ }
1092
+ }
1093
+ for (const img of imageFiles) {
1094
+ try {
1095
+ const result = await this.hailerApi.downloadFile(img.fileId);
1096
+ 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
+ });
1105
+ }
1106
+ }
1107
+ catch (err) {
1108
+ this.logger.warn('Failed to download image for vision', { fileId: img.fileId, filename: img.filename, error: err });
1109
+ }
1110
+ }
1111
+ return blocks;
1112
+ }
915
1113
  async formatOutgoingMessage(text) {
916
1114
  let formatted = await this.messageFormatter.resolveUserTags(text);
917
1115
  formatted = await this.messageFormatter.resolveActivityTags(formatted);
@@ -987,46 +1185,123 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
987
1185
  buildSystemPrompt() {
988
1186
  const wsName = this.init?.network?.name || 'Workspace';
989
1187
  const botName = this.getBotDisplayName();
990
- return `You are a workspace assistant for "${wsName}" on Hailer.
991
-
992
- <bot-identity>
1188
+ const identity = `<bot-identity>
993
1189
  Your user ID: ${this.userId}
994
1190
  Your display name: ${botName}
995
- </bot-identity>
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()}
996
1218
 
997
- <rules>
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}`;
1238
+ }
1239
+ // Default prompt — general-purpose workspace assistant with behavioral defaults.
1240
+ const defaultBehavior = `<behavior>
998
1241
  - Be concise. Short answers, no filler.
999
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.
1000
1243
  - Never use emojis unless the user does first.
1001
- - Use tools to answer questions - don't guess or make up data.
1002
- - When a task will require multiple steps (investigating errors, fixing data, bulk lookups), briefly tell the user what you're specifically about to do before starting. Be concrete: "I'll check each insight's SQL and fix any broken column references." NEVER use vague filler like "Let me look into that" — always name the actual action.
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".
1003
1245
  - When showing data, use clean formatting (tables, bullet points). Don't over-explain.
1004
1246
  - If a request is ambiguous, ask a clarifying question instead of guessing.
1005
- - Always reference actual workspace data (workflow names, field values) - never speak in generic terms.
1006
- - For bulk operations (creating/updating many items), batch into groups of 25-50 per tool call. Never try to create more than 50 items in a single tool call.
1007
- - When a tool call fails, ALWAYS tell the user what failed and why. Never silently try alternatives. If an insight has a SQL error, tell the user the insight is broken, explain what's wrong (e.g. missing column), and offer to fix it using update_insight. Fix the root cause, don't work around it.
1008
- - NEVER output raw URLs like https://app.hailer.com/...
1009
- - When referencing activities, discussions, or users, use hailerTag format: [hailerTag|Display Name](objectId)
1010
- Examples: [hailerTag|AC Milan](691ffe654217e9e8434e578a) or [hailerTag|John Smith](691ffe654217e9e8434e5123)
1011
- You always have the name and ID from tool responses - use them directly in this format.
1012
- </rules>
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.
1013
1250
 
1014
- <workspace-context>
1015
- ${this.workspaceOverview}
1016
- </workspace-context>`;
1251
+ ${identity}
1252
+
1253
+ ${platformRules}
1254
+
1255
+ ${defaultBehavior}
1256
+
1257
+ ${dateContext}
1258
+
1259
+ ${wsContext}`;
1017
1260
  }
1018
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
+ 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)`;
1287
+ }
1019
1288
  getAnthropicTools() {
1289
+ const allowedTools = BOT_TOOLS;
1020
1290
  const defs = this.toolExecutor.getToolDefinitions({
1021
1291
  allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND, tool_registry_1.ToolGroup.BOT_INTERNAL],
1022
1292
  });
1023
- return defs
1024
- .filter(d => BOT_TOOLS.has(d.name))
1293
+ const tools = defs
1294
+ .filter(d => allowedTools.has(d.name))
1025
1295
  .map(d => ({
1026
1296
  name: d.name,
1027
1297
  description: d.description,
1028
1298
  input_schema: d.inputSchema,
1029
1299
  }));
1300
+ // Mark last tool for prompt caching — tools never change per session
1301
+ if (tools.length > 0) {
1302
+ tools[tools.length - 1].cache_control = { type: 'ephemeral' };
1303
+ }
1304
+ return tools;
1030
1305
  }
1031
1306
  // ===== HAILER MESSAGING =====
1032
1307
  async sendMessage(discussionId, text, links) {
@@ -1091,6 +1366,7 @@ ${this.workspaceOverview}
1091
1366
  this.logger.debug('Refreshing workspace data', { keys, trigger: isFull ? 'full' : Array.from(scopes).join(',') });
1092
1367
  const freshData = await this.client.socket.request('v2.core.init', [keys]);
1093
1368
  // Merge fresh data into existing init (partial update)
1369
+ (0, types_1.normalizeInitProcesses)(freshData);
1094
1370
  if (!this.init) {
1095
1371
  this.init = freshData;
1096
1372
  }
@@ -1117,6 +1393,7 @@ ${this.workspaceOverview}
1117
1393
  compactUserData: true,
1118
1394
  includeWorkspaceNamesInTools: false,
1119
1395
  });
1396
+ this.backfillWorkspaceCacheMembers();
1120
1397
  // Rebuild workspace overview for system prompt
1121
1398
  this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
1122
1399
  // Update UserContext with fresh data
@@ -1143,95 +1420,112 @@ ${this.workspaceOverview}
1143
1420
  }
1144
1421
  }
1145
1422
  }
1146
- buildPermissionIndex() {
1147
- this.permissionIndex.clear();
1148
- const wsId = this._workspaceId;
1149
- if (!this.init?.processes || !wsId)
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)
1150
1429
  return;
1151
- // Extract workspace admins from network members
1152
- this.adminUserIds.clear();
1153
1430
  const network = this.init.network;
1154
- if (network?.members) {
1155
- const members = Array.isArray(network.members) ? network.members : Object.values(network.members);
1156
- for (const member of members) {
1157
- if (member.admin === true) {
1158
- this.adminUserIds.add(member.uid);
1159
- }
1160
- }
1161
- }
1162
- // Extract workspace owners from network members
1163
- this.ownerUserIds.clear();
1164
- if (network?.members) {
1165
- const ownerMembers = Array.isArray(network.members) ? network.members : Object.values(network.members);
1166
- for (const member of ownerMembers) {
1167
- if (member.owner === true) {
1168
- this.ownerUserIds.add(member.uid);
1169
- }
1170
- }
1171
- }
1172
- const teams = this.init.teams?.[wsId] || {};
1173
- const groups = this.init.groups?.[wsId] || {};
1174
- let networkOpenCount = 0;
1175
- for (const proc of this.init.processes) {
1176
- const members = proc.members || [];
1177
- // If any member is network_ (all workspace members), skip indexing — everyone has access
1178
- if (members.some((m) => m.id.startsWith('network_'))) {
1179
- networkOpenCount++;
1180
- continue;
1181
- }
1182
- // If any member references a public team, treat as open — all workspace members have access
1183
- let hasPublicTeam = false;
1184
- for (const m of members) {
1185
- if (!m.id.startsWith('team_'))
1186
- continue;
1187
- const team = teams[m.id.slice(5)];
1188
- if (team) {
1189
- if (team.public) {
1190
- hasPublicTeam = true;
1191
- break;
1192
- }
1193
- }
1194
- }
1195
- if (hasPublicTeam) {
1196
- networkOpenCount++;
1197
- continue;
1198
- }
1199
- const userIds = new Set();
1200
- for (const member of members) {
1201
- const id = member.id;
1202
- if (id.startsWith('user_')) {
1203
- userIds.add(id.slice(5));
1204
- }
1205
- else if (id.startsWith('team_')) {
1206
- const team = teams[id.slice(5)];
1207
- if (team?.members) {
1208
- for (const uid of team.members) {
1209
- userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
1210
- }
1211
- }
1212
- }
1213
- else if (id.startsWith('group_')) {
1214
- const group = groups[id.slice(6)];
1215
- if (group?.members) {
1216
- for (const uid of group.members) {
1217
- userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
1218
- }
1219
- }
1220
- }
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++;
1221
1450
  }
1222
- // Workflow creator always has access
1223
- if (proc.uid)
1224
- userIds.add(proc.uid);
1225
- this.permissionIndex.set(proc._id, userIds);
1226
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;
1227
1469
  this.logger.debug('Permission index built', {
1228
1470
  restricted: this.permissionIndex.size,
1229
- open: networkOpenCount,
1230
- totalWorkflows: this.init.processes.length,
1471
+ open: totalWorkflows - this.permissionIndex.size,
1472
+ totalWorkflows,
1231
1473
  totalEntries: Array.from(this.permissionIndex.values()).reduce((n, s) => n + s.size, 0),
1232
1474
  admins: this.adminUserIds.size,
1233
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',
1234
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
+ }
1235
1529
  }
1236
1530
  getOrCreateDiscussionState(discussionId) {
1237
1531
  let state = this.discussionStates.get(discussionId);
@@ -1271,6 +1565,88 @@ ${this.workspaceOverview}
1271
1565
  return undefined;
1272
1566
  return Object.keys(workspaceTeams)[0];
1273
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
+ }
1274
1650
  /**
1275
1651
  * Extract workflow ID from tool result for post-execution permission checks.
1276
1652
  * Returns the workflow ID if found, or undefined if extraction fails.