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