@hailer/mcp 1.1.12 → 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 (271) hide show
  1. package/CHANGELOG.md +0 -7
  2. package/{.claude → dist}/CLAUDE.md +2 -2
  3. package/dist/app.js +18 -5
  4. package/dist/bot/bot-config.d.ts +10 -1
  5. package/dist/bot/bot-config.js +64 -3
  6. package/dist/bot/bot-manager.d.ts +2 -0
  7. package/dist/bot/bot-manager.js +9 -2
  8. package/dist/bot/bot.d.ts +33 -0
  9. package/dist/bot/bot.js +461 -160
  10. package/dist/bot/services/message-classifier.js +17 -0
  11. package/dist/bot/services/permission-guard.d.ts +52 -0
  12. package/dist/bot/services/permission-guard.js +149 -0
  13. package/dist/bot/services/types.d.ts +5 -0
  14. package/dist/bot/services/typing-indicator.d.ts +6 -1
  15. package/dist/bot/services/typing-indicator.js +19 -3
  16. package/dist/cli.js +0 -0
  17. package/dist/config.d.ts +6 -1
  18. package/dist/config.js +43 -0
  19. package/dist/core.js +3 -6
  20. package/dist/lib/discussion-lock.d.ts +42 -0
  21. package/dist/lib/discussion-lock.js +110 -0
  22. package/dist/mcp/UserContextCache.d.ts +5 -0
  23. package/dist/mcp/UserContextCache.js +51 -19
  24. package/dist/mcp/hailer-clients.d.ts +19 -1
  25. package/dist/mcp/hailer-clients.js +158 -24
  26. package/dist/mcp/session-store.d.ts +68 -0
  27. package/dist/mcp/session-store.js +169 -0
  28. package/dist/mcp/signal-handler.js +2 -0
  29. package/dist/mcp/tool-registry.d.ts +17 -4
  30. package/dist/mcp/tool-registry.js +37 -7
  31. package/dist/mcp/tools/activity.js +99 -7
  32. package/dist/mcp/tools/app-scaffold.js +304 -336
  33. package/dist/mcp/tools/bot-config/constants.d.ts +23 -0
  34. package/dist/mcp/tools/bot-config/constants.js +94 -0
  35. package/dist/mcp/tools/bot-config/core.d.ts +253 -0
  36. package/dist/mcp/tools/bot-config/core.js +2456 -0
  37. package/dist/mcp/tools/bot-config/index.d.ts +10 -0
  38. package/dist/mcp/tools/bot-config/index.js +59 -0
  39. package/dist/mcp/tools/bot-config/tools.d.ts +7 -0
  40. package/dist/mcp/tools/bot-config/tools.js +15 -0
  41. package/dist/mcp/tools/bot-config/types.d.ts +50 -0
  42. package/dist/mcp/tools/bot-config/types.js +6 -0
  43. package/dist/mcp/tools/bug-fixer-tools.d.ts +45 -0
  44. package/dist/mcp/tools/bug-fixer-tools.js +1096 -0
  45. package/dist/mcp/tools/company.d.ts +9 -0
  46. package/dist/mcp/tools/company.js +88 -0
  47. package/dist/mcp/tools/discussion.js +68 -0
  48. package/dist/mcp/tools/document.d.ts +11 -0
  49. package/dist/mcp/tools/document.js +741 -0
  50. package/dist/mcp/tools/investigate.d.ts +9 -0
  51. package/dist/mcp/tools/investigate.js +254 -0
  52. package/dist/mcp/tools/workflow-permissions.d.ts +15 -0
  53. package/dist/mcp/tools/workflow-permissions.js +204 -0
  54. package/dist/mcp/tools/workflow.js +57 -18
  55. package/dist/mcp/utils/index.d.ts +2 -0
  56. package/dist/mcp/utils/index.js +12 -1
  57. package/dist/mcp/utils/role-utils.d.ts +74 -0
  58. package/dist/mcp/utils/role-utils.js +151 -0
  59. package/dist/mcp/utils/types.d.ts +43 -1
  60. package/dist/mcp/utils/types.js +14 -0
  61. package/dist/mcp/webhook-handler.d.ts +4 -0
  62. package/dist/mcp/webhook-handler.js +8 -0
  63. package/dist/mcp-server.d.ts +23 -2
  64. package/dist/mcp-server.js +639 -127
  65. package/dist/plugins/vipunen/client.d.ts +150 -0
  66. package/dist/plugins/vipunen/client.js +535 -0
  67. package/dist/plugins/vipunen/config/schema-config.json +19 -0
  68. package/dist/plugins/vipunen/config/schema-doc.json +22 -0
  69. package/dist/plugins/vipunen/index.d.ts +41 -0
  70. package/dist/plugins/vipunen/index.js +88 -0
  71. package/dist/plugins/vipunen/tools.d.ts +26 -0
  72. package/dist/plugins/vipunen/tools.js +501 -0
  73. package/dist/stdio-server.d.ts +14 -0
  74. package/dist/stdio-server.js +101 -0
  75. package/package.json +2 -1
  76. package/.claude/agents/agent-ada-skill-builder.md +0 -94
  77. package/.claude/agents/agent-alejandro-function-fields.md +0 -342
  78. package/.claude/agents/agent-bjorn-config-audit.md +0 -103
  79. package/.claude/agents/agent-builder-agent-creator.md +0 -130
  80. package/.claude/agents/agent-code-simplifier.md +0 -53
  81. package/.claude/agents/agent-dmitri-activity-crud.md +0 -159
  82. package/.claude/agents/agent-giuseppe-app-builder.md +0 -247
  83. package/.claude/agents/agent-gunther-mcp-tools.md +0 -39
  84. package/.claude/agents/agent-helga-workflow-config.md +0 -204
  85. package/.claude/agents/agent-igor-activity-mover-automation.md +0 -125
  86. package/.claude/agents/agent-ingrid-doc-templates.md +0 -261
  87. package/.claude/agents/agent-ivan-monolith.md +0 -154
  88. package/.claude/agents/agent-kenji-data-reader.md +0 -86
  89. package/.claude/agents/agent-lars-code-inspector.md +0 -102
  90. package/.claude/agents/agent-marco-mockup-builder.md +0 -110
  91. package/.claude/agents/agent-marcus-api-documenter.md +0 -323
  92. package/.claude/agents/agent-marketplace-publisher.md +0 -280
  93. package/.claude/agents/agent-marketplace-reviewer.md +0 -309
  94. package/.claude/agents/agent-permissions-handler.md +0 -208
  95. package/.claude/agents/agent-simple-writer.md +0 -48
  96. package/.claude/agents/agent-svetlana-code-review.md +0 -171
  97. package/.claude/agents/agent-tanya-test-runner.md +0 -333
  98. package/.claude/agents/agent-ui-designer.md +0 -100
  99. package/.claude/agents/agent-viktor-sql-insights.md +0 -212
  100. package/.claude/agents/agent-web-search.md +0 -55
  101. package/.claude/agents/agent-yevgeni-discussions.md +0 -45
  102. package/.claude/agents/agent-zara-zapier.md +0 -159
  103. package/.claude/commands/app-squad.md +0 -135
  104. package/.claude/commands/audit-squad.md +0 -158
  105. package/.claude/commands/autoplan.md +0 -563
  106. package/.claude/commands/cleanup-squad.md +0 -98
  107. package/.claude/commands/config-squad.md +0 -106
  108. package/.claude/commands/crud-squad.md +0 -87
  109. package/.claude/commands/data-squad.md +0 -97
  110. package/.claude/commands/debug-squad.md +0 -303
  111. package/.claude/commands/doc-squad.md +0 -65
  112. package/.claude/commands/handoff.md +0 -137
  113. package/.claude/commands/health.md +0 -49
  114. package/.claude/commands/help.md +0 -29
  115. package/.claude/commands/help:agents.md +0 -151
  116. package/.claude/commands/help:commands.md +0 -78
  117. package/.claude/commands/help:faq.md +0 -79
  118. package/.claude/commands/help:plugins.md +0 -50
  119. package/.claude/commands/help:skills.md +0 -93
  120. package/.claude/commands/help:tools.md +0 -75
  121. package/.claude/commands/hotfix-squad.md +0 -112
  122. package/.claude/commands/integration-squad.md +0 -82
  123. package/.claude/commands/janitor-squad.md +0 -167
  124. package/.claude/commands/learn-auto.md +0 -120
  125. package/.claude/commands/learn.md +0 -120
  126. package/.claude/commands/mcp-list.md +0 -27
  127. package/.claude/commands/onboard-squad.md +0 -140
  128. package/.claude/commands/plan-workspace.md +0 -732
  129. package/.claude/commands/prd.md +0 -130
  130. package/.claude/commands/project-status.md +0 -82
  131. package/.claude/commands/publish.md +0 -138
  132. package/.claude/commands/recap.md +0 -69
  133. package/.claude/commands/restore.md +0 -64
  134. package/.claude/commands/review-squad.md +0 -152
  135. package/.claude/commands/save.md +0 -24
  136. package/.claude/commands/stats.md +0 -19
  137. package/.claude/commands/swarm.md +0 -210
  138. package/.claude/commands/tool-builder.md +0 -39
  139. package/.claude/commands/ws-pull.md +0 -44
  140. package/.claude/hooks/_shared-memory.cjs +0 -305
  141. package/.claude/hooks/_utils.cjs +0 -108
  142. package/.claude/hooks/agent-failure-detector.cjs +0 -383
  143. package/.claude/hooks/agent-usage-logger.cjs +0 -204
  144. package/.claude/hooks/app-edit-guard.cjs +0 -494
  145. package/.claude/hooks/auto-learn.cjs +0 -304
  146. package/.claude/hooks/bash-guard.cjs +0 -272
  147. package/.claude/hooks/builder-mode-manager.cjs +0 -354
  148. package/.claude/hooks/bulk-activity-guard.cjs +0 -271
  149. package/.claude/hooks/context-watchdog.cjs +0 -230
  150. package/.claude/hooks/delegation-reminder.cjs +0 -465
  151. package/.claude/hooks/design-system-lint.cjs +0 -271
  152. package/.claude/hooks/post-scaffold-hook.cjs +0 -181
  153. package/.claude/hooks/prompt-guard.cjs +0 -354
  154. package/.claude/hooks/publish-template-guard.cjs +0 -147
  155. package/.claude/hooks/session-start.cjs +0 -35
  156. package/.claude/hooks/shared-memory-writer.cjs +0 -147
  157. package/.claude/hooks/skill-injector.cjs +0 -140
  158. package/.claude/hooks/skill-usage-logger.cjs +0 -258
  159. package/.claude/hooks/src-edit-guard.cjs +0 -240
  160. package/.claude/hooks/sync-marketplace-agents.cjs +0 -346
  161. package/.claude/settings.json +0 -257
  162. package/.claude/skills/SDK-activity-patterns/SKILL.md +0 -428
  163. package/.claude/skills/SDK-document-templates/SKILL.md +0 -1033
  164. package/.claude/skills/SDK-function-fields/SKILL.md +0 -542
  165. package/.claude/skills/SDK-generate-skill/SKILL.md +0 -92
  166. package/.claude/skills/SDK-init-skill/SKILL.md +0 -127
  167. package/.claude/skills/SDK-insight-queries/SKILL.md +0 -787
  168. package/.claude/skills/SDK-ws-config-skill/SKILL.md +0 -1139
  169. package/.claude/skills/agent-structure/SKILL.md +0 -98
  170. package/.claude/skills/api-documentation-patterns/SKILL.md +0 -474
  171. package/.claude/skills/chrome-mcp-reference/SKILL.md +0 -370
  172. package/.claude/skills/delegation-routing/SKILL.md +0 -202
  173. package/.claude/skills/frontend-design/SKILL.md +0 -254
  174. package/.claude/skills/hailer-activity-mover/SKILL.md +0 -213
  175. package/.claude/skills/hailer-api-client/SKILL.md +0 -518
  176. package/.claude/skills/hailer-app-builder/SKILL.md +0 -1434
  177. package/.claude/skills/hailer-apps-pictures/SKILL.md +0 -269
  178. package/.claude/skills/hailer-design-system/SKILL.md +0 -235
  179. package/.claude/skills/hailer-monolith-automations/SKILL.md +0 -686
  180. package/.claude/skills/hailer-permissions-system/SKILL.md +0 -121
  181. package/.claude/skills/hailer-project-protocol/SKILL.md +0 -488
  182. package/.claude/skills/hailer-rest-api/SKILL.md +0 -61
  183. package/.claude/skills/hailer-rest-api/hailer-activities.md +0 -184
  184. package/.claude/skills/hailer-rest-api/hailer-admin.md +0 -473
  185. package/.claude/skills/hailer-rest-api/hailer-calendar.md +0 -256
  186. package/.claude/skills/hailer-rest-api/hailer-feed.md +0 -249
  187. package/.claude/skills/hailer-rest-api/hailer-insights.md +0 -195
  188. package/.claude/skills/hailer-rest-api/hailer-messaging.md +0 -276
  189. package/.claude/skills/hailer-rest-api/hailer-workflows.md +0 -283
  190. package/.claude/skills/insight-join-patterns/SKILL.md +0 -174
  191. package/.claude/skills/integration-patterns/SKILL.md +0 -421
  192. package/.claude/skills/json-only-output/SKILL.md +0 -72
  193. package/.claude/skills/lsp-setup/SKILL.md +0 -160
  194. package/.claude/skills/mcp-direct-tools/SKILL.md +0 -153
  195. package/.claude/skills/optional-parameters/SKILL.md +0 -72
  196. package/.claude/skills/publish-hailer-app/SKILL.md +0 -244
  197. package/.claude/skills/testing-patterns/SKILL.md +0 -630
  198. package/.claude/skills/tool-builder/SKILL.md +0 -250
  199. package/.claude/skills/tool-parameter-usage/SKILL.md +0 -126
  200. package/.claude/skills/tool-response-verification/SKILL.md +0 -92
  201. package/.claude/skills/zapier-hailer-patterns/SKILL.md +0 -581
  202. package/.mcp.json +0 -13
  203. package/.opencode/agent/agent-ada-skill-builder.md +0 -35
  204. package/.opencode/agent/agent-alejandro-function-fields.md +0 -39
  205. package/.opencode/agent/agent-bjorn-config-audit.md +0 -36
  206. package/.opencode/agent/agent-builder-agent-creator.md +0 -39
  207. package/.opencode/agent/agent-code-simplifier.md +0 -31
  208. package/.opencode/agent/agent-dmitri-activity-crud.md +0 -40
  209. package/.opencode/agent/agent-giuseppe-app-builder.md +0 -37
  210. package/.opencode/agent/agent-gunther-mcp-tools.md +0 -39
  211. package/.opencode/agent/agent-helga-workflow-config.md +0 -203
  212. package/.opencode/agent/agent-igor-activity-mover-automation.md +0 -46
  213. package/.opencode/agent/agent-ingrid-doc-templates.md +0 -39
  214. package/.opencode/agent/agent-ivan-monolith.md +0 -46
  215. package/.opencode/agent/agent-kenji-data-reader.md +0 -53
  216. package/.opencode/agent/agent-lars-code-inspector.md +0 -28
  217. package/.opencode/agent/agent-marco-mockup-builder.md +0 -42
  218. package/.opencode/agent/agent-marcus-api-documenter.md +0 -53
  219. package/.opencode/agent/agent-marketplace-publisher.md +0 -44
  220. package/.opencode/agent/agent-marketplace-reviewer.md +0 -42
  221. package/.opencode/agent/agent-permissions-handler.md +0 -50
  222. package/.opencode/agent/agent-simple-writer.md +0 -45
  223. package/.opencode/agent/agent-svetlana-code-review.md +0 -39
  224. package/.opencode/agent/agent-tanya-test-runner.md +0 -57
  225. package/.opencode/agent/agent-ui-designer.md +0 -56
  226. package/.opencode/agent/agent-viktor-sql-insights.md +0 -34
  227. package/.opencode/agent/agent-web-search.md +0 -42
  228. package/.opencode/agent/agent-yevgeni-discussions.md +0 -37
  229. package/.opencode/agent/agent-zara-zapier.md +0 -53
  230. package/.opencode/commands/app-squad.md +0 -135
  231. package/.opencode/commands/audit-squad.md +0 -158
  232. package/.opencode/commands/autoplan.md +0 -563
  233. package/.opencode/commands/cleanup-squad.md +0 -98
  234. package/.opencode/commands/config-squad.md +0 -106
  235. package/.opencode/commands/crud-squad.md +0 -87
  236. package/.opencode/commands/data-squad.md +0 -97
  237. package/.opencode/commands/debug-squad.md +0 -303
  238. package/.opencode/commands/doc-squad.md +0 -65
  239. package/.opencode/commands/handoff.md +0 -137
  240. package/.opencode/commands/health.md +0 -49
  241. package/.opencode/commands/help-agents.md +0 -151
  242. package/.opencode/commands/help-commands.md +0 -32
  243. package/.opencode/commands/help-faq.md +0 -29
  244. package/.opencode/commands/help-plugins.md +0 -28
  245. package/.opencode/commands/help-skills.md +0 -7
  246. package/.opencode/commands/help-tools.md +0 -40
  247. package/.opencode/commands/help.md +0 -28
  248. package/.opencode/commands/hotfix-squad.md +0 -112
  249. package/.opencode/commands/integration-squad.md +0 -82
  250. package/.opencode/commands/janitor-squad.md +0 -167
  251. package/.opencode/commands/learn-auto.md +0 -120
  252. package/.opencode/commands/learn.md +0 -120
  253. package/.opencode/commands/mcp-list.md +0 -27
  254. package/.opencode/commands/onboard-squad.md +0 -140
  255. package/.opencode/commands/plan-workspace.md +0 -732
  256. package/.opencode/commands/prd.md +0 -131
  257. package/.opencode/commands/project-status.md +0 -82
  258. package/.opencode/commands/publish.md +0 -138
  259. package/.opencode/commands/recap.md +0 -69
  260. package/.opencode/commands/restore.md +0 -64
  261. package/.opencode/commands/review-squad.md +0 -152
  262. package/.opencode/commands/save.md +0 -24
  263. package/.opencode/commands/stats.md +0 -19
  264. package/.opencode/commands/swarm.md +0 -210
  265. package/.opencode/commands/tool-builder.md +0 -39
  266. package/.opencode/commands/ws-pull.md +0 -44
  267. package/.opencode/opencode.json +0 -28
  268. package/SESSION-HANDOFF.md +0 -68
  269. package/inbox/2026-03-04-bot-config-patterns.md +0 -24
  270. package/scripts/postinstall.cjs +0 -64
  271. 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,34 +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
  ]);
75
84
  /** Minimal tool set for SIMPLE-routed messages (greetings, quick lookups). */
76
- const SIMPLE_TOOLS = new Set([
77
- 'list_workflows_minimal',
78
- 'list_activities',
79
- 'show_activity_by_id',
80
- 'count_activities',
81
- 'search_workspace_users',
82
- 'get_workspace_balance',
83
- 'list_my_discussions',
84
- 'fetch_discussion_messages',
85
- 'add_discussion_message',
86
- 'get_insight_data',
87
- ]);
88
85
  const MODEL_HAIKU = 'claude-haiku-4-5-20251001';
89
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
90
89
  const MAX_TOOL_ITERATIONS = 10;
91
90
  const RATE_LIMIT_WINDOW = 60_000; // 60 seconds
92
91
  const RATE_LIMIT_PER_DISCUSSION = 30; // max signals per discussion per window
@@ -104,8 +103,9 @@ class Bot {
104
103
  workspaceOverview = '';
105
104
  userId = '';
106
105
  _workspaceId;
107
- // Live-updatable system prompt (separate from config so it can be hot-swapped)
106
+ // Live-updatable config (can be hot-swapped without restart)
108
107
  _systemPrompt;
108
+ _responseMode = 'always';
109
109
  // Services
110
110
  conversationManager = null;
111
111
  messageClassifier = null;
@@ -113,6 +113,9 @@ class Bot {
113
113
  typingIndicator = null;
114
114
  tokenBilling = null;
115
115
  sessionLogger = null;
116
+ hailerApi = null;
117
+ /** Cache of insightId → Set<workflowId> for permission checks on ID-based insight tools */
118
+ insightWorkflowCache = new Map();
116
119
  opLogger = new operation_logger_1.OperationLogger();
117
120
  // State
118
121
  discussionStates = new Map();
@@ -134,22 +137,34 @@ class Bot {
134
137
  permissionIndex = new Map();
135
138
  adminUserIds = new Set();
136
139
  ownerUserIds = new Set();
140
+ workspaceMemberIds = new Set();
141
+ membersLoaded = false;
137
142
  // Config
138
143
  config;
139
144
  botManager; // BotManager ref for cross-bot awareness
140
145
  constructor(config) {
141
146
  this.config = {
142
- ...config,
147
+ email: config.email,
148
+ password: config.password,
149
+ apiHost: config.apiHost,
150
+ anthropicApiKey: config.anthropicApiKey,
143
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 || [],
144
157
  };
145
158
  this.botManager = config.botManager || null;
146
159
  this._systemPrompt = config.systemPrompt;
160
+ this._responseMode = config.responseMode || 'always';
147
161
  this.logger = (0, logger_1.createLogger)({ component: 'Bot' });
148
162
  this.clientManager = new hailer_clients_1.HailerClientManager(config.apiHost, config.email, config.password);
149
163
  this.toolExecutor = new tool_executor_1.ToolExecutor(config.toolRegistry);
150
164
  }
151
165
  get email() { return this.config.email; }
152
166
  get password() { return this.config.password; }
167
+ get accessLevel() { return this.config.accessLevel; }
153
168
  /**
154
169
  * Hot-update the system prompt without restarting the bot.
155
170
  * Takes effect on the next message processed.
@@ -158,6 +173,10 @@ class Bot {
158
173
  this._systemPrompt = prompt;
159
174
  this.logger.info('System prompt updated live', { hasPrompt: !!prompt });
160
175
  }
176
+ updateResponseMode(mode) {
177
+ this._responseMode = mode || 'always';
178
+ this.logger.info('Response mode updated live', { responseMode: this._responseMode });
179
+ }
161
180
  get connected() {
162
181
  return this._connected;
163
182
  }
@@ -188,9 +207,9 @@ class Bot {
188
207
  this.init = await this.client.socket.request('v2.core.init', [
189
208
  ['processes', 'users', 'network', 'networks', 'teams', 'groups'],
190
209
  ]);
210
+ (0, types_1.normalizeInitProcesses)(this.init);
191
211
  this._workspaceId = this.init.network?._id || this.config.workspaceId;
192
- this.buildPermissionIndex();
193
- // 4. Build workspace cache and overview
212
+ // 4. Build workspace cache and overview (before permission index so names resolve)
194
213
  this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
195
214
  excludeTranslations: true,
196
215
  excludeSystemMessages: true,
@@ -198,6 +217,8 @@ class Bot {
198
217
  compactUserData: true,
199
218
  includeWorkspaceNamesInTools: false,
200
219
  });
220
+ this.backfillWorkspaceCacheMembers();
221
+ this.buildPermissionIndex();
201
222
  this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
202
223
  // 5. Create Anthropic client
203
224
  this.anthropic = new sdk_1.default({ apiKey: this.config.anthropicApiKey });
@@ -216,6 +237,7 @@ class Bot {
216
237
  this.typingIndicator = new services_1.TypingIndicatorService(botConnection, this.logger);
217
238
  // Token billing
218
239
  const hailerApi = new hailer_api_client_1.HailerApiClient(this.client);
240
+ this.hailerApi = hailerApi;
219
241
  this.tokenBilling = new services_1.TokenBillingService(this.logger, hailerApi);
220
242
  // Message formatter - wrap ToolExecutor as MCP-style callback
221
243
  const toolCallback = async (name, args) => {
@@ -226,6 +248,7 @@ class Bot {
226
248
  this.sessionLogger = new services_1.SessionLoggerService(null, this.logger, toolCallback, () => this.getDefaultTeamId());
227
249
  this.sessionLogger.setAnthropicClient(this.anthropic);
228
250
  // 8. Build UserContext for tool execution
251
+ const currentWorkspaceId = this.init.network?._id || '';
229
252
  this.userContext = {
230
253
  client: this.client,
231
254
  hailer: hailerApi,
@@ -235,6 +258,9 @@ class Bot {
235
258
  createdAt: Date.now(),
236
259
  email: this.config.email,
237
260
  password: this.config.password,
261
+ workspaceRoles: { [currentWorkspaceId]: 'admin' },
262
+ currentWorkspaceId,
263
+ allowedGroups: ['read', 'write', 'bot_internal'],
238
264
  };
239
265
  // 9. Subscribe to messenger.new signals
240
266
  this.signalHandler = (eventData) => {
@@ -254,6 +280,9 @@ class Bot {
254
280
  this.clientManager.onSignal('process.updated', this.processUpdatedHandler);
255
281
  this.clientManager.onSignal('workspace.updated', this.workspaceUpdatedHandler);
256
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
257
286
  this._connected = true;
258
287
  this.logger.debug('Bot started', {
259
288
  userId: this.userId,
@@ -301,6 +330,19 @@ class Bot {
301
330
  this.client = null;
302
331
  this.logger.debug('Bot stopped');
303
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
+ }
304
346
  // ===== SIGNAL HANDLING =====
305
347
  async handleSignal(signal) {
306
348
  if (!this._connected || !this.messageClassifier)
@@ -367,37 +409,71 @@ class Bot {
367
409
  return;
368
410
  }
369
411
  this.globalSignalTimestamps.push(now);
370
- // Membership check - only respond to users who are members of the bot's workspace
371
- if (this.init?.users && !this.init.users[message.senderId]) {
372
- 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', {
373
417
  senderId: message.senderId,
374
418
  senderName: message.senderName,
419
+ membersLoaded: this.membersLoaded,
375
420
  });
376
421
  if (rawMsgId)
377
422
  this.processedMessageIds.delete(rawMsgId);
378
423
  return;
379
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
+ }
380
443
  // Prune dedup set
381
444
  if (this.processedMessageIds.size > 500) {
382
445
  const ids = Array.from(this.processedMessageIds);
383
446
  this.processedMessageIds = new Set(ids.slice(-250));
384
447
  }
385
448
  const state = this.getOrCreateDiscussionState(discId);
386
- // Always buffer the message
449
+ // Always buffer the message (provides context when bot IS triggered later)
387
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
+ }
388
457
  // Determine if this should trigger processing
389
- 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;
390
460
  if (state.state === 'idle') {
391
- if (isExplicitTrigger) {
461
+ // In 'always' mode, every message triggers engagement (original behavior)
462
+ if (isExplicitTrigger || this._responseMode === 'always') {
392
463
  state.state = 'engaged';
393
464
  state.consecutiveNonResponses = 0;
394
- 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');
395
466
  }
396
467
  else {
397
468
  // Not addressed while idle — message sits in buffer as context only
398
469
  return;
399
470
  }
400
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
+ }
401
477
  // State is 'engaged' — trigger processing if not already running
402
478
  if (!this.processingDiscussions.has(discId)) {
403
479
  this.processDiscussion(discId).catch(err => {
@@ -436,7 +512,17 @@ class Bot {
436
512
  if (messages.length > 1) {
437
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}`;
438
514
  }
439
- 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
+ }
440
526
  // Use last message for routing/billing context
441
527
  const primaryMessage = messages[messages.length - 1];
442
528
  state.lastProgressTime = 0;
@@ -498,7 +584,6 @@ class Bot {
498
584
  const defaultRoute = {
499
585
  model: MODEL_HAIKU,
500
586
  maxTokens: 2000,
501
- classification: 'SIMPLE',
502
587
  };
503
588
  try {
504
589
  const wsName = this.init?.network?.name || 'Workspace';
@@ -540,12 +625,11 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
540
625
  const route = {
541
626
  model: MODEL_SONNET,
542
627
  maxTokens: 16384,
543
- classification: 'COMPLEX',
544
628
  };
545
- this.opLogger.route(message.discussionId, route.classification, route.model, message.content);
629
+ this.opLogger.route(message.discussionId, 'COMPLEX', route.model, message.content);
546
630
  return route;
547
631
  }
548
- this.opLogger.route(message.discussionId, defaultRoute.classification, defaultRoute.model, message.content);
632
+ this.opLogger.route(message.discussionId, 'SIMPLE', defaultRoute.model, message.content);
549
633
  return defaultRoute;
550
634
  }
551
635
  catch (error) {
@@ -611,7 +695,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
611
695
  }
612
696
  // ===== MESSAGE PROCESSING =====
613
697
  async processMessage(message, signal) {
614
- this.typingIndicator?.start(message.discussionId);
698
+ this.typingIndicator?.start(message.discussionId, 'Reading');
615
699
  // Check token balance
616
700
  const billingWorkspaceId = this._workspaceId || message.workspaceId;
617
701
  if (this.tokenBilling && billingWorkspaceId) {
@@ -645,12 +729,13 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
645
729
  async runLlmLoop(message, route, signal) {
646
730
  const conversation = this.conversationManager.getConversation(message.discussionId);
647
731
  const systemPrompt = this.buildSystemPrompt();
648
- const tools = this.getAnthropicTools(route.classification);
732
+ const tools = this.getAnthropicTools();
649
733
  const processingStartTime = Date.now();
650
734
  for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
651
735
  const cachedConversation = this.conversationManager.prepareForCaching(conversation);
652
736
  let response;
653
737
  let llmStart = Date.now();
738
+ this.typingIndicator?.updateStatus(message.discussionId, i === 0 ? 'Thinking' : 'Processing');
654
739
  try {
655
740
  response = await this.anthropic.messages.create({
656
741
  model: route.model,
@@ -673,7 +758,8 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
673
758
  this.logger.warn('Sonnet API call failed, falling back to Haiku', {
674
759
  error: error instanceof Error ? error.message : String(error),
675
760
  });
676
- route = { model: MODEL_HAIKU, maxTokens: 2000, classification: 'SIMPLE' };
761
+ route = { model: MODEL_HAIKU, maxTokens: 2000 };
762
+ this.typingIndicator?.updateStatus(message.discussionId, 'Switching models');
677
763
  llmStart = Date.now();
678
764
  response = await this.anthropic.messages.create({
679
765
  model: MODEL_HAIKU,
@@ -729,10 +815,11 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
729
815
  // Check for tool calls
730
816
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
731
817
  if (toolUseBlocks.length > 0) {
818
+ this.typingIndicator?.updateStatus(message.discussionId, this.getToolStatus(toolUseBlocks));
732
819
  const toolResults = await this.executeTools(toolUseBlocks, message, signal);
733
820
  conversation.push({ role: 'user', content: toolResults });
734
821
  // Inject any messages that arrived during tool execution
735
- this.injectPendingContext(message.discussionId, conversation);
822
+ await this.injectPendingContext(message.discussionId, conversation);
736
823
  // Auto-escalate: if Haiku is failing tool calls, switch to Sonnet
737
824
  if (route.model === MODEL_HAIKU) {
738
825
  const failedCount = toolResults
@@ -743,7 +830,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
743
830
  failedTools: failedCount,
744
831
  iteration: i,
745
832
  });
746
- route = { model: MODEL_SONNET, maxTokens: 16384, classification: 'COMPLEX' };
833
+ route = { model: MODEL_SONNET, maxTokens: 16384 };
747
834
  await this.sendMessage(message.discussionId, `Switching to a more capable model to handle this.`);
748
835
  }
749
836
  }
@@ -777,6 +864,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
777
864
  break;
778
865
  }
779
866
  // Post the response
867
+ this.typingIndicator?.updateStatus(message.discussionId, 'Writing response');
780
868
  this.typingIndicator?.stop(message.discussionId);
781
869
  const formatted = await this.formatOutgoingMessage(responseText);
782
870
  const links = this.messageFormatter.extractTagLinks(formatted);
@@ -793,17 +881,36 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
793
881
  const WRITE_TOOLS = new Set(['create_activity', 'update_activity']);
794
882
  for (const block of toolUseBlocks) {
795
883
  const toolStart = Date.now();
796
- // 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).
797
901
  const args = block.input;
798
- const workflowId = args.workflowId;
799
- if (workflowId && WORKFLOW_SCOPED_TOOLS.has(block.name)) {
800
- const allowed = this.permissionIndex.get(workflowId);
801
- if (allowed && !allowed.has(message.senderId) && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
802
- 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);
803
908
  results.push({
804
909
  type: 'tool_result',
805
910
  tool_use_id: block.id,
806
- 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}.`,
807
914
  is_error: true,
808
915
  });
809
916
  continue;
@@ -831,40 +938,32 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
831
938
  const text = result?.content?.[0]?.text ?? JSON.stringify(result);
832
939
  let contentStr = typeof text === 'string' ? text : JSON.stringify(text);
833
940
  const toolDuration = (Date.now() - toolStart) / 1000;
834
- // Post-filter list_workflows results to only show accessible workflows
835
- if ((block.name === 'list_workflows' || block.name === 'list_workflows_minimal') && this.permissionIndex.size > 0 && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
836
- try {
837
- const parsed = JSON.parse(contentStr);
838
- if (parsed.workflows && Array.isArray(parsed.workflows)) {
839
- const before = parsed.workflows.length;
840
- parsed.workflows = parsed.workflows.filter((wf) => {
841
- const wfId = wf.id || wf._id || wf.workflowId;
842
- if (!wfId)
843
- return true;
844
- const allowed = this.permissionIndex.get(wfId);
845
- return !allowed || allowed.has(message.senderId);
846
- });
847
- if (parsed.workflows.length < before) {
848
- this.opLogger.permFiltered(discussionId, block.name, before, parsed.workflows.length);
849
- }
850
- contentStr = JSON.stringify(parsed);
851
- }
852
- }
853
- catch {
854
- // 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);
855
950
  }
856
951
  }
857
- // Post-execution permission check for tools not gated by workflowId arg
858
- 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)) {
859
956
  const extractedWorkflowId = this.extractWorkflowIdFromResult(block.name, contentStr);
860
957
  if (extractedWorkflowId) {
861
- const allowed = this.permissionIndex.get(extractedWorkflowId);
862
- if (allowed && !allowed.has(message.senderId) && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
863
- 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);
864
961
  results.push({
865
962
  type: 'tool_result',
866
963
  tool_use_id: block.id,
867
- 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.`,
868
967
  is_error: true,
869
968
  });
870
969
  continue;
@@ -913,7 +1012,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
913
1012
  * between LLM iterations. Merges into the existing user message to preserve
914
1013
  * Anthropic's alternation rule (user/assistant/user/assistant).
915
1014
  */
916
- injectPendingContext(discussionId, conversation) {
1015
+ async injectPendingContext(discussionId, conversation) {
917
1016
  const state = this.discussionStates.get(discussionId);
918
1017
  if (!state || state.contextBuffer.length === 0)
919
1018
  return;
@@ -925,6 +1024,10 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
925
1024
  type: 'text',
926
1025
  text: `<context type="messages-during-processing">\n${contextText}\n</context>`,
927
1026
  });
1027
+ const imageBlocks = await this.resolveImageAttachments(pending);
1028
+ for (const block of imageBlocks) {
1029
+ lastMsg.content.push(block);
1030
+ }
928
1031
  this.opLogger.contextInject(discussionId, pending.length);
929
1032
  }
930
1033
  }
@@ -954,11 +1057,59 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
954
1057
  const nameAttr = discName ? ` discussion_name="${discName}"` : '';
955
1058
  let fileInfo = '';
956
1059
  if (message.fileAttachments?.length) {
957
- const ids = message.fileAttachments.map(f => f.fileId).join(', ');
958
- 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');
959
1072
  }
960
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>`;
961
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
+ }
962
1113
  async formatOutgoingMessage(text) {
963
1114
  let formatted = await this.messageFormatter.resolveUserTags(text);
964
1115
  formatted = await this.messageFormatter.resolveActivityTags(formatted);
@@ -1056,6 +1207,22 @@ HAILERTAG LINKING (mandatory):
1056
1207
  - NEVER use hailerTag for workflows, phases, teams, or groups — these don't support linking. Just use their plain text names.
1057
1208
  - NEVER output bare 24-character hex IDs to the user.
1058
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>`;
1059
1226
  const wsContext = `<workspace-context>
1060
1227
  ${this.workspaceOverview}
1061
1228
  </workspace-context>`;
@@ -1067,7 +1234,7 @@ ${this.workspaceOverview}
1067
1234
  .replace(/\{wsName\}/g, wsName)
1068
1235
  .replace(/\{userId\}/g, this.userId)
1069
1236
  .replace(/\{botName\}/g, botName);
1070
- return `${body}\n\n${platformRules}\n\n${identity}\n\n${wsContext}`;
1237
+ return `${body}\n\n${platformRules}\n\n${identity}\n\n${dateContext}\n\n${wsContext}`;
1071
1238
  }
1072
1239
  // Default prompt — general-purpose workspace assistant with behavioral defaults.
1073
1240
  const defaultBehavior = `<behavior>
@@ -1087,21 +1254,54 @@ ${platformRules}
1087
1254
 
1088
1255
  ${defaultBehavior}
1089
1256
 
1257
+ ${dateContext}
1258
+
1090
1259
  ${wsContext}`;
1091
1260
  }
1092
1261
  // ===== TOOLS =====
1093
- getAnthropicTools(classification) {
1094
- const allowedTools = classification === 'SIMPLE' ? SIMPLE_TOOLS : BOT_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
+ }
1288
+ getAnthropicTools() {
1289
+ const allowedTools = BOT_TOOLS;
1095
1290
  const defs = this.toolExecutor.getToolDefinitions({
1096
1291
  allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND, tool_registry_1.ToolGroup.BOT_INTERNAL],
1097
1292
  });
1098
- return defs
1293
+ const tools = defs
1099
1294
  .filter(d => allowedTools.has(d.name))
1100
1295
  .map(d => ({
1101
1296
  name: d.name,
1102
1297
  description: d.description,
1103
1298
  input_schema: d.inputSchema,
1104
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;
1105
1305
  }
1106
1306
  // ===== HAILER MESSAGING =====
1107
1307
  async sendMessage(discussionId, text, links) {
@@ -1166,6 +1366,7 @@ ${wsContext}`;
1166
1366
  this.logger.debug('Refreshing workspace data', { keys, trigger: isFull ? 'full' : Array.from(scopes).join(',') });
1167
1367
  const freshData = await this.client.socket.request('v2.core.init', [keys]);
1168
1368
  // Merge fresh data into existing init (partial update)
1369
+ (0, types_1.normalizeInitProcesses)(freshData);
1169
1370
  if (!this.init) {
1170
1371
  this.init = freshData;
1171
1372
  }
@@ -1192,6 +1393,7 @@ ${wsContext}`;
1192
1393
  compactUserData: true,
1193
1394
  includeWorkspaceNamesInTools: false,
1194
1395
  });
1396
+ this.backfillWorkspaceCacheMembers();
1195
1397
  // Rebuild workspace overview for system prompt
1196
1398
  this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
1197
1399
  // Update UserContext with fresh data
@@ -1218,95 +1420,112 @@ ${wsContext}`;
1218
1420
  }
1219
1421
  }
1220
1422
  }
1221
- buildPermissionIndex() {
1222
- this.permissionIndex.clear();
1223
- const wsId = this._workspaceId;
1224
- 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)
1225
1429
  return;
1226
- // Extract workspace admins from network members
1227
- this.adminUserIds.clear();
1228
1430
  const network = this.init.network;
1229
- if (network?.members) {
1230
- const members = Array.isArray(network.members) ? network.members : Object.values(network.members);
1231
- for (const member of members) {
1232
- if (member.admin === true) {
1233
- this.adminUserIds.add(member.uid);
1234
- }
1235
- }
1236
- }
1237
- // Extract workspace owners from network members
1238
- this.ownerUserIds.clear();
1239
- if (network?.members) {
1240
- const ownerMembers = Array.isArray(network.members) ? network.members : Object.values(network.members);
1241
- for (const member of ownerMembers) {
1242
- if (member.owner === true) {
1243
- this.ownerUserIds.add(member.uid);
1244
- }
1245
- }
1246
- }
1247
- const teams = this.init.teams?.[wsId] || {};
1248
- const groups = this.init.groups?.[wsId] || {};
1249
- let networkOpenCount = 0;
1250
- for (const proc of this.init.processes) {
1251
- const members = proc.members || [];
1252
- // If any member is network_ (all workspace members), skip indexing — everyone has access
1253
- if (members.some((m) => m.id.startsWith('network_'))) {
1254
- networkOpenCount++;
1255
- continue;
1256
- }
1257
- // If any member references a public team, treat as open — all workspace members have access
1258
- let hasPublicTeam = false;
1259
- for (const m of members) {
1260
- if (!m.id.startsWith('team_'))
1261
- continue;
1262
- const team = teams[m.id.slice(5)];
1263
- if (team) {
1264
- if (team.public) {
1265
- hasPublicTeam = true;
1266
- break;
1267
- }
1268
- }
1269
- }
1270
- if (hasPublicTeam) {
1271
- networkOpenCount++;
1272
- continue;
1273
- }
1274
- const userIds = new Set();
1275
- for (const member of members) {
1276
- const id = member.id;
1277
- if (id.startsWith('user_')) {
1278
- userIds.add(id.slice(5));
1279
- }
1280
- else if (id.startsWith('team_')) {
1281
- const team = teams[id.slice(5)];
1282
- if (team?.members) {
1283
- for (const uid of team.members) {
1284
- userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
1285
- }
1286
- }
1287
- }
1288
- else if (id.startsWith('group_')) {
1289
- const group = groups[id.slice(6)];
1290
- if (group?.members) {
1291
- for (const uid of group.members) {
1292
- userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
1293
- }
1294
- }
1295
- }
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++;
1296
1450
  }
1297
- // Workflow creator always has access
1298
- if (proc.uid)
1299
- userIds.add(proc.uid);
1300
- this.permissionIndex.set(proc._id, userIds);
1301
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;
1302
1469
  this.logger.debug('Permission index built', {
1303
1470
  restricted: this.permissionIndex.size,
1304
- open: networkOpenCount,
1305
- totalWorkflows: this.init.processes.length,
1471
+ open: totalWorkflows - this.permissionIndex.size,
1472
+ totalWorkflows,
1306
1473
  totalEntries: Array.from(this.permissionIndex.values()).reduce((n, s) => n + s.size, 0),
1307
1474
  admins: this.adminUserIds.size,
1308
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),
1309
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
+ }
1310
1529
  }
1311
1530
  getOrCreateDiscussionState(discussionId) {
1312
1531
  let state = this.discussionStates.get(discussionId);
@@ -1346,6 +1565,88 @@ ${wsContext}`;
1346
1565
  return undefined;
1347
1566
  return Object.keys(workspaceTeams)[0];
1348
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
+ }
1349
1650
  /**
1350
1651
  * Extract workflow ID from tool result for post-execution permission checks.
1351
1652
  * Returns the workflow ID if found, or undefined if extraction fails.