@hailer/mcp 1.0.28 → 1.1.2
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/.session-checked +1 -0
- package/.claude/agents/agent-ada-skill-builder.md +10 -2
- package/.claude/agents/agent-alejandro-function-fields.md +104 -37
- package/.claude/agents/agent-bjorn-config-audit.md +41 -21
- package/.claude/agents/agent-builder-agent-creator.md +13 -3
- package/.claude/agents/agent-code-simplifier.md +53 -0
- package/.claude/agents/agent-dmitri-activity-crud.md +126 -11
- package/.claude/agents/agent-giuseppe-app-builder.md +212 -22
- package/.claude/agents/agent-gunther-mcp-tools.md +7 -36
- package/.claude/agents/agent-helga-workflow-config.md +75 -10
- package/.claude/agents/agent-igor-activity-mover-automation.md +125 -0
- package/.claude/agents/agent-ingrid-doc-templates.md +164 -36
- package/.claude/agents/agent-ivan-monolith.md +154 -0
- package/.claude/agents/agent-kenji-data-reader.md +15 -8
- package/.claude/agents/agent-lars-code-inspector.md +56 -8
- package/.claude/agents/agent-marco-mockup-builder.md +110 -0
- package/.claude/agents/agent-marcus-api-documenter.md +323 -0
- package/.claude/agents/agent-marketplace-publisher.md +232 -72
- package/.claude/agents/agent-marketplace-reviewer.md +255 -79
- package/.claude/agents/agent-permissions-handler.md +208 -0
- package/.claude/agents/agent-simple-writer.md +48 -0
- package/.claude/agents/agent-svetlana-code-review.md +127 -14
- package/.claude/agents/agent-tanya-test-runner.md +333 -0
- package/.claude/agents/agent-ui-designer.md +100 -0
- package/.claude/agents/agent-viktor-sql-insights.md +19 -6
- package/.claude/agents/agent-web-search.md +55 -0
- package/.claude/agents/agent-yevgeni-discussions.md +7 -1
- package/.claude/agents/agent-zara-zapier.md +159 -0
- package/.claude/commands/app-squad.md +135 -0
- package/.claude/commands/audit-squad.md +158 -0
- package/.claude/commands/autoplan.md +563 -0
- package/.claude/commands/cleanup-squad.md +98 -0
- package/.claude/commands/config-squad.md +106 -0
- package/.claude/commands/crud-squad.md +87 -0
- package/.claude/commands/data-squad.md +97 -0
- package/.claude/commands/debug-squad.md +303 -0
- package/.claude/commands/doc-squad.md +65 -0
- package/.claude/commands/handoff.md +137 -0
- package/.claude/commands/health.md +49 -0
- package/.claude/commands/help.md +2 -1
- package/.claude/commands/help:agents.md +96 -16
- package/.claude/commands/help:commands.md +55 -11
- package/.claude/commands/help:faq.md +16 -1
- package/.claude/commands/help:skills.md +93 -0
- package/.claude/commands/hotfix-squad.md +112 -0
- package/.claude/commands/integration-squad.md +82 -0
- package/.claude/commands/janitor-squad.md +167 -0
- package/.claude/commands/learn-auto.md +120 -0
- package/.claude/commands/learn.md +120 -0
- package/.claude/commands/mcp-list.md +27 -0
- package/.claude/commands/onboard-squad.md +140 -0
- package/.claude/commands/plan-workspace.md +732 -0
- package/.claude/commands/prd.md +131 -0
- package/.claude/commands/project-status.md +82 -0
- package/.claude/commands/publish.md +138 -0
- package/.claude/commands/recap.md +69 -0
- package/.claude/commands/restore.md +64 -0
- package/.claude/commands/review-squad.md +152 -0
- package/.claude/commands/save.md +24 -0
- package/.claude/commands/stats.md +19 -0
- package/.claude/commands/swarm.md +210 -0
- package/.claude/commands/tool-builder.md +3 -1
- package/.claude/commands/ws-pull.md +1 -1
- package/.claude/commands/yolo-off.md +17 -0
- package/.claude/commands/yolo.md +82 -0
- package/.claude/hooks/_shared-memory.cjs +305 -0
- package/.claude/hooks/_utils.cjs +134 -0
- package/.claude/hooks/agent-failure-detector.cjs +164 -79
- package/.claude/hooks/agent-usage-logger.cjs +204 -0
- package/.claude/hooks/app-edit-guard.cjs +20 -4
- package/.claude/hooks/auto-learn.cjs +316 -0
- package/.claude/hooks/bash-guard.cjs +282 -0
- package/.claude/hooks/builder-mode-manager.cjs +183 -54
- package/.claude/hooks/bulk-activity-guard.cjs +283 -0
- package/.claude/hooks/context-watchdog.cjs +292 -0
- package/.claude/hooks/delegation-reminder.cjs +478 -0
- package/.claude/hooks/design-system-lint.cjs +283 -0
- package/.claude/hooks/post-scaffold-hook.cjs +16 -3
- package/.claude/hooks/prompt-guard.cjs +366 -0
- package/.claude/hooks/publish-template-guard.cjs +16 -0
- package/.claude/hooks/session-start.cjs +35 -0
- package/.claude/hooks/shared-memory-writer.cjs +147 -0
- package/.claude/hooks/skill-injector.cjs +140 -0
- package/.claude/hooks/skill-usage-logger.cjs +258 -0
- package/.claude/hooks/src-edit-guard.cjs +16 -1
- package/.claude/hooks/sync-marketplace-agents.cjs +53 -8
- package/.claude/scripts/yolo-toggle.cjs +142 -0
- package/.claude/settings.json +141 -14
- package/.claude/skills/SDK-activity-patterns/SKILL.md +428 -0
- package/.claude/skills/SDK-document-templates/SKILL.md +1033 -0
- package/.claude/skills/SDK-function-fields/SKILL.md +542 -0
- package/.claude/skills/SDK-generate-skill/SKILL.md +92 -0
- package/.claude/skills/SDK-init-skill/SKILL.md +127 -0
- package/.claude/skills/SDK-insight-queries/SKILL.md +787 -0
- package/.claude/skills/SDK-ws-config-skill/SKILL.md +1139 -0
- package/.claude/skills/agent-structure/SKILL.md +98 -0
- package/.claude/skills/api-documentation-patterns/SKILL.md +474 -0
- package/.claude/skills/chrome-mcp-reference/SKILL.md +370 -0
- package/.claude/skills/delegation-routing/SKILL.md +202 -0
- package/.claude/skills/frontend-design/SKILL.md +254 -0
- package/.claude/skills/hailer-activity-mover/SKILL.md +213 -0
- package/.claude/skills/hailer-api-client/SKILL.md +518 -0
- package/.claude/skills/hailer-app-builder/SKILL.md +939 -11
- package/.claude/skills/hailer-apps-pictures/SKILL.md +269 -0
- package/.claude/skills/hailer-design-system/SKILL.md +235 -0
- package/.claude/skills/hailer-monolith-automations/SKILL.md +686 -0
- package/.claude/skills/hailer-permissions-system/SKILL.md +121 -0
- package/.claude/skills/hailer-project-protocol/SKILL.md +488 -0
- package/.claude/skills/hailer-rest-api/SKILL.md +61 -0
- package/.claude/skills/hailer-rest-api/hailer-activities.md +184 -0
- package/.claude/skills/hailer-rest-api/hailer-admin.md +473 -0
- package/.claude/skills/hailer-rest-api/hailer-calendar.md +256 -0
- package/.claude/skills/hailer-rest-api/hailer-feed.md +249 -0
- package/.claude/skills/hailer-rest-api/hailer-insights.md +195 -0
- package/.claude/skills/hailer-rest-api/hailer-messaging.md +276 -0
- package/.claude/skills/hailer-rest-api/hailer-workflows.md +283 -0
- package/.claude/skills/insight-join-patterns/SKILL.md +3 -0
- package/.claude/skills/integration-patterns/SKILL.md +421 -0
- package/.claude/skills/json-only-output/SKILL.md +52 -12
- package/.claude/skills/lsp-setup/SKILL.md +160 -0
- package/.claude/skills/mcp-direct-tools/SKILL.md +153 -0
- package/.claude/skills/optional-parameters/SKILL.md +32 -23
- package/.claude/skills/publish-hailer-app/SKILL.md +76 -12
- package/.claude/skills/testing-patterns/SKILL.md +630 -0
- package/.claude/skills/tool-builder/SKILL.md +250 -0
- package/.claude/skills/tool-parameter-usage/SKILL.md +59 -45
- package/.claude/skills/tool-response-verification/SKILL.md +82 -48
- package/.claude/skills/zapier-hailer-patterns/SKILL.md +581 -0
- package/.env.example +26 -7
- package/CLAUDE.md +290 -224
- package/dist/CLAUDE.md +370 -0
- package/dist/app.d.ts +1 -1
- package/dist/app.js +101 -101
- package/dist/bot/bot-config.d.ts +26 -0
- package/dist/bot/bot-config.js +135 -0
- package/dist/bot/bot-manager.d.ts +40 -0
- package/dist/bot/bot-manager.js +137 -0
- package/dist/bot/bot.d.ts +127 -0
- package/dist/bot/bot.js +1328 -0
- package/dist/bot/operation-logger.d.ts +28 -0
- package/dist/bot/operation-logger.js +132 -0
- package/dist/bot/services/conversation-manager.d.ts +60 -0
- package/dist/bot/services/conversation-manager.js +246 -0
- package/dist/bot/services/index.d.ts +9 -0
- package/dist/bot/services/index.js +18 -0
- package/dist/bot/services/message-classifier.d.ts +42 -0
- package/dist/bot/services/message-classifier.js +228 -0
- package/dist/bot/services/message-formatter.d.ts +88 -0
- package/dist/bot/services/message-formatter.js +411 -0
- package/dist/bot/services/session-logger.d.ts +162 -0
- package/dist/bot/services/session-logger.js +724 -0
- package/dist/bot/services/token-billing.d.ts +78 -0
- package/dist/bot/services/token-billing.js +233 -0
- package/dist/bot/services/types.d.ts +169 -0
- package/dist/bot/services/types.js +12 -0
- package/dist/bot/services/typing-indicator.d.ts +23 -0
- package/dist/bot/services/typing-indicator.js +60 -0
- package/dist/bot/services/workspace-schema-cache.d.ts +122 -0
- package/dist/bot/services/workspace-schema-cache.js +506 -0
- package/dist/bot/tool-executor.d.ts +28 -0
- package/dist/bot/tool-executor.js +48 -0
- package/dist/bot/workspace-overview.d.ts +12 -0
- package/dist/bot/workspace-overview.js +94 -0
- package/dist/cli.d.ts +1 -8
- package/dist/cli.js +1 -249
- package/dist/config.d.ts +96 -3
- package/dist/config.js +148 -37
- package/dist/core.d.ts +5 -0
- package/dist/core.js +61 -8
- package/dist/lib/discussion-lock.d.ts +42 -0
- package/dist/lib/discussion-lock.js +110 -0
- package/dist/lib/logger.d.ts +0 -1
- package/dist/lib/logger.js +39 -23
- package/dist/lib/request-logger.d.ts +77 -0
- package/dist/lib/request-logger.js +147 -0
- package/dist/mcp/UserContextCache.js +16 -13
- package/dist/mcp/hailer-clients.js +18 -17
- package/dist/mcp/signal-handler.js +29 -13
- package/dist/mcp/tool-registry.d.ts +4 -15
- package/dist/mcp/tool-registry.js +94 -32
- package/dist/mcp/tools/activity.js +28 -69
- package/dist/mcp/tools/app-core.js +9 -4
- package/dist/mcp/tools/app-marketplace.js +22 -12
- package/dist/mcp/tools/app-member.js +5 -2
- package/dist/mcp/tools/app-scaffold.js +32 -18
- package/dist/mcp/tools/bot-config/constants.d.ts +23 -0
- package/dist/mcp/tools/bot-config/constants.js +94 -0
- package/dist/mcp/tools/bot-config/core.d.ts +253 -0
- package/dist/mcp/tools/bot-config/core.js +2456 -0
- package/dist/mcp/tools/bot-config/index.d.ts +10 -0
- package/dist/mcp/tools/bot-config/index.js +59 -0
- package/dist/mcp/tools/bot-config/tools.d.ts +7 -0
- package/dist/mcp/tools/bot-config/tools.js +15 -0
- package/dist/mcp/tools/bot-config/types.d.ts +50 -0
- package/dist/mcp/tools/bot-config/types.js +6 -0
- package/dist/mcp/tools/discussion.js +107 -77
- package/dist/mcp/tools/document.d.ts +11 -0
- package/dist/mcp/tools/document.js +741 -0
- package/dist/mcp/tools/file.js +5 -2
- package/dist/mcp/tools/insight.js +36 -12
- package/dist/mcp/tools/investigate.d.ts +9 -0
- package/dist/mcp/tools/investigate.js +254 -0
- package/dist/mcp/tools/user.d.ts +2 -4
- package/dist/mcp/tools/user.js +9 -50
- package/dist/mcp/tools/workflow.d.ts +1 -0
- package/dist/mcp/tools/workflow.js +164 -52
- package/dist/mcp/utils/hailer-api-client.js +26 -17
- package/dist/mcp/webhook-handler.d.ts +64 -3
- package/dist/mcp/webhook-handler.js +219 -9
- package/dist/mcp-server.d.ts +4 -0
- package/dist/mcp-server.js +237 -25
- package/dist/plugins/bug-fixer/index.d.ts +2 -0
- package/dist/plugins/bug-fixer/index.js +18 -0
- package/dist/plugins/bug-fixer/tools.d.ts +45 -0
- package/dist/plugins/bug-fixer/tools.js +1096 -0
- package/package.json +10 -10
- package/scripts/test-hal-tools.ts +154 -0
- package/.claude/agents/agent-nora-name-functions.md +0 -123
- package/.claude/assistant-knowledge.md +0 -23
- package/.claude/commands/install-plugin.md +0 -261
- package/.claude/commands/list-plugins.md +0 -42
- package/.claude/commands/marketplace-setup.md +0 -33
- package/.claude/commands/publish-plugin.md +0 -55
- package/.claude/commands/uninstall-plugin.md +0 -87
- package/.claude/hooks/interactive-mode.cjs +0 -87
- package/.claude/hooks/mcp-server-guard.cjs +0 -108
- package/.claude/skills/marketplace-publishing.md +0 -155
- package/dist/bot/chat-bot.d.ts +0 -31
- package/dist/bot/chat-bot.js +0 -357
- package/dist/mcp/tools/metrics.d.ts +0 -13
- package/dist/mcp/tools/metrics.js +0 -546
- package/dist/stdio-server.d.ts +0 -14
- package/dist/stdio-server.js +0 -114
package/dist/bot/bot.js
ADDED
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bot - Single class replacing the agents/ hierarchy.
|
|
4
|
+
*
|
|
5
|
+
* Connects to Hailer, subscribes to messenger signals,
|
|
6
|
+
* processes messages through the Anthropic API with tool use,
|
|
7
|
+
* and posts responses back to discussions.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.Bot = void 0;
|
|
14
|
+
const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
|
|
15
|
+
const tool_registry_1 = require("../mcp/tool-registry");
|
|
16
|
+
const hailer_clients_1 = require("../mcp/hailer-clients");
|
|
17
|
+
const hailer_api_client_1 = require("../mcp/utils/hailer-api-client");
|
|
18
|
+
const workspace_cache_1 = require("../mcp/workspace-cache");
|
|
19
|
+
const tool_executor_1 = require("./tool-executor");
|
|
20
|
+
const workspace_overview_1 = require("./workspace-overview");
|
|
21
|
+
const operation_logger_1 = require("./operation-logger");
|
|
22
|
+
const services_1 = require("./services");
|
|
23
|
+
const logger_1 = require("../lib/logger");
|
|
24
|
+
/**
|
|
25
|
+
* Tools available to the bot. Add/remove tool names here to control what the LLM can use.
|
|
26
|
+
* The MCP server (Claude Code terminal) is unaffected - it keeps full access to all tools.
|
|
27
|
+
*/
|
|
28
|
+
const BOT_TOOLS = new Set([
|
|
29
|
+
// Data reading
|
|
30
|
+
'list_workflows',
|
|
31
|
+
'list_workflows_minimal',
|
|
32
|
+
'list_workflow_phases',
|
|
33
|
+
'get_workflow_schema',
|
|
34
|
+
'list_activities',
|
|
35
|
+
'show_activity_by_id',
|
|
36
|
+
'count_activities',
|
|
37
|
+
'search_workspace_users',
|
|
38
|
+
'get_workspace_balance',
|
|
39
|
+
// Data writing
|
|
40
|
+
'create_activity',
|
|
41
|
+
'update_activity',
|
|
42
|
+
// Workflow management
|
|
43
|
+
'install_workflow',
|
|
44
|
+
// Discussions
|
|
45
|
+
'list_my_discussions',
|
|
46
|
+
'fetch_discussion_messages',
|
|
47
|
+
'fetch_previous_discussion_messages',
|
|
48
|
+
'add_discussion_message',
|
|
49
|
+
'join_discussion',
|
|
50
|
+
'leave_discussion',
|
|
51
|
+
'invite_discussion_members',
|
|
52
|
+
'get_activity_from_discussion',
|
|
53
|
+
// Files
|
|
54
|
+
'upload_files',
|
|
55
|
+
'download_file',
|
|
56
|
+
// Insights
|
|
57
|
+
'list_insights',
|
|
58
|
+
'get_insight_data',
|
|
59
|
+
'preview_insight',
|
|
60
|
+
'create_insight',
|
|
61
|
+
'update_insight',
|
|
62
|
+
]);
|
|
63
|
+
const WORKFLOW_SCOPED_TOOLS = new Set([
|
|
64
|
+
'list_activities', 'count_activities',
|
|
65
|
+
'get_workflow_schema', 'list_workflow_phases',
|
|
66
|
+
'create_activity', 'update_activity',
|
|
67
|
+
'install_workflow',
|
|
68
|
+
]);
|
|
69
|
+
/** Tools that bypass the workflowId arg gate and need post-execution permission checks */
|
|
70
|
+
const UNGATED_TOOLS = new Set([
|
|
71
|
+
'show_activity_by_id',
|
|
72
|
+
'fetch_discussion_messages',
|
|
73
|
+
'get_activity_from_discussion',
|
|
74
|
+
]);
|
|
75
|
+
const MODEL_HAIKU = 'claude-haiku-4-5-20251001';
|
|
76
|
+
const MODEL_SONNET = 'claude-sonnet-4-5-20250929';
|
|
77
|
+
const MAX_TOOL_ITERATIONS = 10;
|
|
78
|
+
const RATE_LIMIT_WINDOW = 60_000; // 60 seconds
|
|
79
|
+
const RATE_LIMIT_PER_DISCUSSION = 30; // max signals per discussion per window
|
|
80
|
+
const RATE_LIMIT_GLOBAL = 100; // max signals total per window
|
|
81
|
+
class Bot {
|
|
82
|
+
logger;
|
|
83
|
+
clientManager;
|
|
84
|
+
client = null;
|
|
85
|
+
anthropic = null;
|
|
86
|
+
toolExecutor;
|
|
87
|
+
userContext = null;
|
|
88
|
+
// Hailer data
|
|
89
|
+
init = null;
|
|
90
|
+
workspaceCache = null;
|
|
91
|
+
workspaceOverview = '';
|
|
92
|
+
userId = '';
|
|
93
|
+
_workspaceId;
|
|
94
|
+
// Services
|
|
95
|
+
conversationManager = null;
|
|
96
|
+
messageClassifier = null;
|
|
97
|
+
messageFormatter = null;
|
|
98
|
+
typingIndicator = null;
|
|
99
|
+
tokenBilling = null;
|
|
100
|
+
sessionLogger = null;
|
|
101
|
+
opLogger = new operation_logger_1.OperationLogger();
|
|
102
|
+
// State
|
|
103
|
+
discussionStates = new Map();
|
|
104
|
+
processingDiscussions = new Set(); // concurrency guard only
|
|
105
|
+
static DISENGAGE_THRESHOLD = 5;
|
|
106
|
+
processedMessageIds = new Set();
|
|
107
|
+
signalTimestampsPerDiscussion = new Map();
|
|
108
|
+
globalSignalTimestamps = [];
|
|
109
|
+
signalHandler = null;
|
|
110
|
+
processUpdatedHandler = null;
|
|
111
|
+
workspaceUpdatedHandler = null;
|
|
112
|
+
cacheInvalidateHandler = null;
|
|
113
|
+
_connected = false;
|
|
114
|
+
refreshTimer = null;
|
|
115
|
+
refreshDirty = false;
|
|
116
|
+
refreshScope = new Set();
|
|
117
|
+
isRefreshing = false;
|
|
118
|
+
refreshQueued = false;
|
|
119
|
+
permissionIndex = new Map();
|
|
120
|
+
adminUserIds = new Set();
|
|
121
|
+
ownerUserIds = new Set();
|
|
122
|
+
// Config
|
|
123
|
+
config;
|
|
124
|
+
constructor(config) {
|
|
125
|
+
this.config = {
|
|
126
|
+
...config,
|
|
127
|
+
model: config.model || 'claude-haiku-4-5-20251001',
|
|
128
|
+
};
|
|
129
|
+
this.logger = (0, logger_1.createLogger)({ component: 'Bot' });
|
|
130
|
+
this.clientManager = new hailer_clients_1.HailerClientManager(config.apiHost, config.email, config.password);
|
|
131
|
+
this.toolExecutor = new tool_executor_1.ToolExecutor(config.toolRegistry);
|
|
132
|
+
}
|
|
133
|
+
get connected() {
|
|
134
|
+
return this._connected;
|
|
135
|
+
}
|
|
136
|
+
get workspaceId() {
|
|
137
|
+
return this._workspaceId;
|
|
138
|
+
}
|
|
139
|
+
// ===== LIFECYCLE =====
|
|
140
|
+
async start() {
|
|
141
|
+
this.logger.debug('Starting bot', { email: this.config.email });
|
|
142
|
+
// 1. Connect to Hailer
|
|
143
|
+
this.client = await this.clientManager.connect();
|
|
144
|
+
// Clear password from memory now that connection is established
|
|
145
|
+
this.config.password = '';
|
|
146
|
+
// 2. Get user ID
|
|
147
|
+
const userInit = await this.client.socket.request('v2.core.init', [['user']]);
|
|
148
|
+
this.userId = userInit?.user?._id;
|
|
149
|
+
if (!this.userId)
|
|
150
|
+
throw new Error('Could not determine bot user ID');
|
|
151
|
+
// 3. Fetch workspace data
|
|
152
|
+
this.init = await this.client.socket.request('v2.core.init', [
|
|
153
|
+
['processes', 'users', 'network', 'networks', 'teams', 'groups'],
|
|
154
|
+
]);
|
|
155
|
+
this._workspaceId = this.init.network?._id || this.config.workspaceId;
|
|
156
|
+
this.buildPermissionIndex();
|
|
157
|
+
// 4. Build workspace cache and overview
|
|
158
|
+
this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
|
|
159
|
+
excludeTranslations: true,
|
|
160
|
+
excludeSystemMessages: true,
|
|
161
|
+
excludeEmptyFields: true,
|
|
162
|
+
compactUserData: true,
|
|
163
|
+
includeWorkspaceNamesInTools: false,
|
|
164
|
+
});
|
|
165
|
+
this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
|
|
166
|
+
// 5. Create Anthropic client
|
|
167
|
+
this.anthropic = new sdk_1.default({ apiKey: this.config.anthropicApiKey });
|
|
168
|
+
// 6. Build BotConnection adapter for services
|
|
169
|
+
const botConnection = {
|
|
170
|
+
client: this.client,
|
|
171
|
+
workspaceCache: this.workspaceCache ? {
|
|
172
|
+
usersById: this.workspaceCache.usersById,
|
|
173
|
+
users: this.workspaceCache.users,
|
|
174
|
+
rawInit: this.workspaceCache.rawInit,
|
|
175
|
+
} : undefined,
|
|
176
|
+
};
|
|
177
|
+
// 7. Create services
|
|
178
|
+
this.conversationManager = new services_1.ConversationManager(100, 50, this.logger);
|
|
179
|
+
this.messageClassifier = new services_1.MessageClassifier(this.userId, botConnection, this.logger);
|
|
180
|
+
this.typingIndicator = new services_1.TypingIndicatorService(botConnection, this.logger);
|
|
181
|
+
// Token billing
|
|
182
|
+
const hailerApi = new hailer_api_client_1.HailerApiClient(this.client);
|
|
183
|
+
this.tokenBilling = new services_1.TokenBillingService(this.logger, hailerApi);
|
|
184
|
+
// Message formatter - wrap ToolExecutor as MCP-style callback
|
|
185
|
+
const toolCallback = async (name, args) => {
|
|
186
|
+
return this.toolExecutor.execute(name, args, this.getUserContext());
|
|
187
|
+
};
|
|
188
|
+
this.messageFormatter = new services_1.MessageFormatterService(botConnection, this.logger, toolCallback);
|
|
189
|
+
// Session logger
|
|
190
|
+
this.sessionLogger = new services_1.SessionLoggerService(null, this.logger, toolCallback, () => this.getDefaultTeamId());
|
|
191
|
+
this.sessionLogger.setAnthropicClient(this.anthropic);
|
|
192
|
+
// 8. Build UserContext for tool execution
|
|
193
|
+
this.userContext = {
|
|
194
|
+
client: this.client,
|
|
195
|
+
hailer: hailerApi,
|
|
196
|
+
init: this.init,
|
|
197
|
+
workspaceCache: this.workspaceCache,
|
|
198
|
+
apiKey: '',
|
|
199
|
+
createdAt: Date.now(),
|
|
200
|
+
email: this.config.email,
|
|
201
|
+
password: this.config.password,
|
|
202
|
+
};
|
|
203
|
+
// 9. Subscribe to messenger.new signals
|
|
204
|
+
this.signalHandler = (eventData) => {
|
|
205
|
+
const signal = {
|
|
206
|
+
type: 'messenger.new',
|
|
207
|
+
data: eventData,
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
workspaceId: eventData.sid,
|
|
210
|
+
};
|
|
211
|
+
this.handleSignal(signal);
|
|
212
|
+
};
|
|
213
|
+
this.clientManager.onSignal('messenger.new', this.signalHandler);
|
|
214
|
+
// 10. Subscribe to workspace change signals for live updates
|
|
215
|
+
this.processUpdatedHandler = () => this.scheduleRefresh('processes');
|
|
216
|
+
this.workspaceUpdatedHandler = () => this.scheduleRefresh('network');
|
|
217
|
+
this.cacheInvalidateHandler = () => this.scheduleRefresh('full');
|
|
218
|
+
this.clientManager.onSignal('process.updated', this.processUpdatedHandler);
|
|
219
|
+
this.clientManager.onSignal('workspace.updated', this.workspaceUpdatedHandler);
|
|
220
|
+
this.clientManager.onSignal('cache.invalidate', this.cacheInvalidateHandler);
|
|
221
|
+
this._connected = true;
|
|
222
|
+
this.logger.debug('Bot started', {
|
|
223
|
+
userId: this.userId,
|
|
224
|
+
workspaceId: this._workspaceId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async stop() {
|
|
228
|
+
this.logger.debug('Stopping bot');
|
|
229
|
+
// Clear refresh timer
|
|
230
|
+
if (this.refreshTimer) {
|
|
231
|
+
clearTimeout(this.refreshTimer);
|
|
232
|
+
this.refreshTimer = null;
|
|
233
|
+
}
|
|
234
|
+
// Flush sessions
|
|
235
|
+
this.sessionLogger?.stopIdleCheckTimer();
|
|
236
|
+
await this.sessionLogger?.flushAllSessions();
|
|
237
|
+
// Abort all in-flight discussions before clearing state
|
|
238
|
+
for (const [, state] of this.discussionStates) {
|
|
239
|
+
state.abortController?.abort();
|
|
240
|
+
}
|
|
241
|
+
this.discussionStates.clear();
|
|
242
|
+
// Clear rate limit tracking
|
|
243
|
+
this.signalTimestampsPerDiscussion.clear();
|
|
244
|
+
this.globalSignalTimestamps = [];
|
|
245
|
+
// Unsubscribe from signals
|
|
246
|
+
if (this.signalHandler) {
|
|
247
|
+
this.clientManager.offSignal('messenger.new', this.signalHandler);
|
|
248
|
+
this.signalHandler = null;
|
|
249
|
+
}
|
|
250
|
+
if (this.processUpdatedHandler) {
|
|
251
|
+
this.clientManager.offSignal('process.updated', this.processUpdatedHandler);
|
|
252
|
+
this.processUpdatedHandler = null;
|
|
253
|
+
}
|
|
254
|
+
if (this.workspaceUpdatedHandler) {
|
|
255
|
+
this.clientManager.offSignal('workspace.updated', this.workspaceUpdatedHandler);
|
|
256
|
+
this.workspaceUpdatedHandler = null;
|
|
257
|
+
}
|
|
258
|
+
if (this.cacheInvalidateHandler) {
|
|
259
|
+
this.clientManager.offSignal('cache.invalidate', this.cacheInvalidateHandler);
|
|
260
|
+
this.cacheInvalidateHandler = null;
|
|
261
|
+
}
|
|
262
|
+
// Disconnect
|
|
263
|
+
this.clientManager.disconnect();
|
|
264
|
+
this._connected = false;
|
|
265
|
+
this.client = null;
|
|
266
|
+
this.logger.debug('Bot stopped');
|
|
267
|
+
}
|
|
268
|
+
// ===== SIGNAL HANDLING =====
|
|
269
|
+
async handleSignal(signal) {
|
|
270
|
+
if (!this._connected || !this.messageClassifier)
|
|
271
|
+
return;
|
|
272
|
+
try {
|
|
273
|
+
const rawMsgId = signal.data.msg_id;
|
|
274
|
+
if (rawMsgId) {
|
|
275
|
+
if (this.processedMessageIds.has(rawMsgId))
|
|
276
|
+
return;
|
|
277
|
+
this.processedMessageIds.add(rawMsgId);
|
|
278
|
+
}
|
|
279
|
+
const message = await this.messageClassifier.extractIncomingMessage(signal);
|
|
280
|
+
if (!message) {
|
|
281
|
+
if (rawMsgId)
|
|
282
|
+
this.processedMessageIds.delete(rawMsgId);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Self-message guard: prevent bot feedback loops
|
|
286
|
+
if (message.senderId === this.userId)
|
|
287
|
+
return;
|
|
288
|
+
// Rate limiting
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
const cutoff = now - RATE_LIMIT_WINDOW;
|
|
291
|
+
// Per-discussion rate limit
|
|
292
|
+
const discId = message.discussionId;
|
|
293
|
+
let discTimestamps = this.signalTimestampsPerDiscussion.get(discId);
|
|
294
|
+
if (!discTimestamps) {
|
|
295
|
+
discTimestamps = [];
|
|
296
|
+
this.signalTimestampsPerDiscussion.set(discId, discTimestamps);
|
|
297
|
+
}
|
|
298
|
+
// Prune old entries
|
|
299
|
+
while (discTimestamps.length > 0 && discTimestamps[0] < cutoff) {
|
|
300
|
+
discTimestamps.shift();
|
|
301
|
+
}
|
|
302
|
+
if (discTimestamps.length >= RATE_LIMIT_PER_DISCUSSION) {
|
|
303
|
+
this.logger.warn('Per-discussion rate limit exceeded', {
|
|
304
|
+
discussionId: discId,
|
|
305
|
+
count: discTimestamps.length,
|
|
306
|
+
window: RATE_LIMIT_WINDOW,
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
discTimestamps.push(now);
|
|
311
|
+
// Global rate limit
|
|
312
|
+
while (this.globalSignalTimestamps.length > 0 && this.globalSignalTimestamps[0] < cutoff) {
|
|
313
|
+
this.globalSignalTimestamps.shift();
|
|
314
|
+
}
|
|
315
|
+
if (this.globalSignalTimestamps.length >= RATE_LIMIT_GLOBAL) {
|
|
316
|
+
this.logger.warn('Global rate limit exceeded', {
|
|
317
|
+
count: this.globalSignalTimestamps.length,
|
|
318
|
+
window: RATE_LIMIT_WINDOW,
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.globalSignalTimestamps.push(now);
|
|
323
|
+
// Membership check - only respond to users who are members of the bot's workspace
|
|
324
|
+
if (this.init?.users && !this.init.users[message.senderId]) {
|
|
325
|
+
this.logger.debug('Ignoring message from non-workspace member', {
|
|
326
|
+
senderId: message.senderId,
|
|
327
|
+
senderName: message.senderName,
|
|
328
|
+
});
|
|
329
|
+
if (rawMsgId)
|
|
330
|
+
this.processedMessageIds.delete(rawMsgId);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Prune dedup set
|
|
334
|
+
if (this.processedMessageIds.size > 500) {
|
|
335
|
+
const ids = Array.from(this.processedMessageIds);
|
|
336
|
+
this.processedMessageIds = new Set(ids.slice(-250));
|
|
337
|
+
}
|
|
338
|
+
const state = this.getOrCreateDiscussionState(discId);
|
|
339
|
+
// Always buffer the message
|
|
340
|
+
state.contextBuffer.push(message);
|
|
341
|
+
// Determine if this should trigger processing
|
|
342
|
+
const isExplicitTrigger = message.isMention || message.isReplyToBot || message.isDirectMessage;
|
|
343
|
+
if (state.state === 'idle') {
|
|
344
|
+
if (isExplicitTrigger) {
|
|
345
|
+
state.state = 'engaged';
|
|
346
|
+
state.consecutiveNonResponses = 0;
|
|
347
|
+
this.opLogger.engage(discId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : 'dm');
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// Not addressed while idle — message sits in buffer as context only
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// State is 'engaged' — trigger processing if not already running
|
|
355
|
+
if (!this.processingDiscussions.has(discId)) {
|
|
356
|
+
this.processDiscussion(discId).catch(err => {
|
|
357
|
+
this.logger.error('processDiscussion failed', { discussionId: discId, error: err });
|
|
358
|
+
this.processingDiscussions.delete(discId);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
// Already processing — message stays in contextBuffer.
|
|
363
|
+
// injectPendingContext delivers it between tool calls as <context>,
|
|
364
|
+
// and the LLM naturally decides whether to continue or stop.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
this.logger.error('Failed to handle signal', error);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async processDiscussion(discussionId) {
|
|
372
|
+
if (this.processingDiscussions.has(discussionId))
|
|
373
|
+
return;
|
|
374
|
+
this.processingDiscussions.add(discussionId);
|
|
375
|
+
const state = this.getOrCreateDiscussionState(discussionId);
|
|
376
|
+
try {
|
|
377
|
+
while (true) {
|
|
378
|
+
// Drain buffer
|
|
379
|
+
const messages = state.contextBuffer.splice(0);
|
|
380
|
+
if (messages.length === 0)
|
|
381
|
+
break;
|
|
382
|
+
if (messages.length > 1) {
|
|
383
|
+
this.opLogger.coalesce(discussionId, messages.length);
|
|
384
|
+
}
|
|
385
|
+
// Merge all buffered messages into a single user message (conversation alternation)
|
|
386
|
+
const conversation = this.conversationManager.getConversation(discussionId);
|
|
387
|
+
const parts = messages.map(msg => this.formatIncomingMessage(msg));
|
|
388
|
+
let merged = parts.join('\n\n');
|
|
389
|
+
if (messages.length > 1) {
|
|
390
|
+
merged = `<context type="coalesced" count="${messages.length}">These ${messages.length} messages arrived while you were processing. Respond to the most recent/relevant.</context>\n\n${merged}`;
|
|
391
|
+
}
|
|
392
|
+
conversation.push({ role: 'user', content: merged });
|
|
393
|
+
// Use last message for routing/billing context
|
|
394
|
+
const primaryMessage = messages[messages.length - 1];
|
|
395
|
+
state.lastProgressTime = 0;
|
|
396
|
+
state.abortController = new AbortController();
|
|
397
|
+
// Log each incoming message
|
|
398
|
+
for (const msg of messages) {
|
|
399
|
+
this.opLogger.messageIn(msg.discussionId, msg.senderName, msg.content);
|
|
400
|
+
}
|
|
401
|
+
// Process — the LLM decides whether to respond
|
|
402
|
+
let responded;
|
|
403
|
+
try {
|
|
404
|
+
responded = await this.processMessage(primaryMessage, state.abortController.signal);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (error instanceof sdk_1.default.APIUserAbortError) {
|
|
408
|
+
// Aborted — new message arrived, loop will restart with updated buffer
|
|
409
|
+
this.typingIndicator?.stop(discussionId);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
if (responded) {
|
|
415
|
+
state.consecutiveNonResponses = 0;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
state.consecutiveNonResponses++;
|
|
419
|
+
if (state.consecutiveNonResponses >= Bot.DISENGAGE_THRESHOLD) {
|
|
420
|
+
state.state = 'idle';
|
|
421
|
+
this.opLogger.disengage(discussionId, state.consecutiveNonResponses);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Check if more messages arrived during processing
|
|
426
|
+
if (state.contextBuffer.length === 0)
|
|
427
|
+
break;
|
|
428
|
+
// If disengaged, only continue if there's an explicit trigger
|
|
429
|
+
if (state.state === 'idle') {
|
|
430
|
+
const hasExplicit = state.contextBuffer.some(m => m.isMention || m.isReplyToBot || m.isDirectMessage);
|
|
431
|
+
if (!hasExplicit)
|
|
432
|
+
break;
|
|
433
|
+
state.state = 'engaged';
|
|
434
|
+
state.consecutiveNonResponses = 0;
|
|
435
|
+
this.opLogger.engage(discussionId, 'reengaged');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
this.processingDiscussions.delete(discussionId);
|
|
441
|
+
state.abortController = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// ===== MODEL ROUTING =====
|
|
445
|
+
/**
|
|
446
|
+
* Classify message complexity using a lightweight Haiku call.
|
|
447
|
+
* Returns model + max_tokens to use for the main LLM loop.
|
|
448
|
+
* Falls back to Haiku on any error.
|
|
449
|
+
*/
|
|
450
|
+
async routeMessage(message, conversation, signal) {
|
|
451
|
+
const defaultRoute = {
|
|
452
|
+
model: MODEL_HAIKU,
|
|
453
|
+
maxTokens: 2000,
|
|
454
|
+
classification: 'SIMPLE',
|
|
455
|
+
};
|
|
456
|
+
try {
|
|
457
|
+
const wsName = this.init?.network?.name || 'Workspace';
|
|
458
|
+
const recentContext = this.getRecentContext(conversation);
|
|
459
|
+
const contextBlock = recentContext
|
|
460
|
+
? `\nRecent context:\n${recentContext}\n`
|
|
461
|
+
: '';
|
|
462
|
+
const response = await this.anthropic.messages.create({
|
|
463
|
+
model: MODEL_HAIKU,
|
|
464
|
+
max_tokens: 10,
|
|
465
|
+
temperature: 0,
|
|
466
|
+
messages: [
|
|
467
|
+
{
|
|
468
|
+
role: 'user',
|
|
469
|
+
content: `Classify this chat message as SIMPLE or COMPLEX.
|
|
470
|
+
|
|
471
|
+
SIMPLE = can be answered in 1-2 tool calls with straightforward logic.
|
|
472
|
+
COMPLEX = needs 3+ tool calls, reasoning across multiple data sources, fixing/creating structured data, or investigation.
|
|
473
|
+
|
|
474
|
+
When in doubt, classify as COMPLEX.
|
|
475
|
+
|
|
476
|
+
Workspace: ${wsName}
|
|
477
|
+
${contextBlock}Current message: ${message.content}
|
|
478
|
+
|
|
479
|
+
Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
}, { signal });
|
|
483
|
+
// Bill the router call
|
|
484
|
+
this.trackRouterTokenUsage(response, message);
|
|
485
|
+
// Parse classification
|
|
486
|
+
const text = response.content
|
|
487
|
+
.filter((b) => b.type === 'text')
|
|
488
|
+
.map(b => b.text)
|
|
489
|
+
.join('')
|
|
490
|
+
.trim()
|
|
491
|
+
.toUpperCase();
|
|
492
|
+
if (text.includes('COMPLEX')) {
|
|
493
|
+
const route = {
|
|
494
|
+
model: MODEL_SONNET,
|
|
495
|
+
maxTokens: 16384,
|
|
496
|
+
classification: 'COMPLEX',
|
|
497
|
+
};
|
|
498
|
+
this.opLogger.route(message.discussionId, route.classification, route.model, message.content);
|
|
499
|
+
return route;
|
|
500
|
+
}
|
|
501
|
+
this.opLogger.route(message.discussionId, defaultRoute.classification, defaultRoute.model, message.content);
|
|
502
|
+
return defaultRoute;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
this.logger.warn('Router failed, falling back to Haiku', {
|
|
506
|
+
error: error instanceof Error ? error.message : String(error),
|
|
507
|
+
});
|
|
508
|
+
return defaultRoute;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Extract recent conversation text for the router prompt.
|
|
513
|
+
* Walks backwards, extracts only text blocks (no tool calls/results),
|
|
514
|
+
* truncates each to 200 chars. Returns up to 3 recent exchanges.
|
|
515
|
+
*/
|
|
516
|
+
getRecentContext(conversation) {
|
|
517
|
+
const lines = [];
|
|
518
|
+
let count = 0;
|
|
519
|
+
for (let i = conversation.length - 1; i >= 0 && count < 3; i--) {
|
|
520
|
+
const msg = conversation[i];
|
|
521
|
+
const content = msg.content;
|
|
522
|
+
let text = '';
|
|
523
|
+
if (typeof content === 'string') {
|
|
524
|
+
text = content;
|
|
525
|
+
}
|
|
526
|
+
else if (Array.isArray(content)) {
|
|
527
|
+
text = content
|
|
528
|
+
.filter((b) => b.type === 'text')
|
|
529
|
+
.map((b) => b.text)
|
|
530
|
+
.join(' ');
|
|
531
|
+
}
|
|
532
|
+
if (!text)
|
|
533
|
+
continue;
|
|
534
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + '...' : text;
|
|
535
|
+
const role = msg.role === 'user' ? 'User' : 'Assistant';
|
|
536
|
+
lines.unshift(`${role}: ${truncated}`);
|
|
537
|
+
count++;
|
|
538
|
+
}
|
|
539
|
+
return lines.join('\n');
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Bill the router classification call the same way as main LLM calls.
|
|
543
|
+
*/
|
|
544
|
+
trackRouterTokenUsage(response, message) {
|
|
545
|
+
if (!response.usage)
|
|
546
|
+
return;
|
|
547
|
+
const { input_tokens, output_tokens } = response.usage;
|
|
548
|
+
const cacheCreation = response.usage.cache_creation_input_tokens || 0;
|
|
549
|
+
const cacheRead = response.usage.cache_read_input_tokens || 0;
|
|
550
|
+
const burnWorkspaceId = this._workspaceId || message.workspaceId;
|
|
551
|
+
if (this.tokenBilling && burnWorkspaceId) {
|
|
552
|
+
const cost = this.tokenBilling.calculateCost(input_tokens, output_tokens, cacheCreation, cacheRead, MODEL_HAIKU);
|
|
553
|
+
this.tokenBilling.burnTokens({
|
|
554
|
+
workspaceId: burnWorkspaceId,
|
|
555
|
+
inputTokens: input_tokens,
|
|
556
|
+
outputTokens: output_tokens,
|
|
557
|
+
cacheCreationTokens: cacheCreation,
|
|
558
|
+
cacheReadTokens: cacheRead,
|
|
559
|
+
costUsd: cost,
|
|
560
|
+
sessionId: message.discussionId,
|
|
561
|
+
model: MODEL_HAIKU,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// ===== MESSAGE PROCESSING =====
|
|
566
|
+
async processMessage(message, signal) {
|
|
567
|
+
this.typingIndicator?.start(message.discussionId);
|
|
568
|
+
// Check token balance
|
|
569
|
+
const billingWorkspaceId = this._workspaceId || message.workspaceId;
|
|
570
|
+
if (this.tokenBilling && billingWorkspaceId) {
|
|
571
|
+
const balance = await this.tokenBilling.checkBalance(billingWorkspaceId);
|
|
572
|
+
const balanceStatus = balance.hasBalance ? (balance.balance < 5 ? 'LOW' : 'OK') : 'EMPTY';
|
|
573
|
+
this.opLogger.balanceCheck(message.discussionId, billingWorkspaceId, balance.balance, balanceStatus);
|
|
574
|
+
if (!balance.hasBalance) {
|
|
575
|
+
this.typingIndicator?.stop(message.discussionId);
|
|
576
|
+
await this.sendMessage(message.discussionId, 'Insufficient balance. Please top up your workspace AI credits to continue.');
|
|
577
|
+
return true; // We did respond (with balance error)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Manage context size
|
|
581
|
+
this.conversationManager.manageContextSize(message.discussionId);
|
|
582
|
+
const conversation = this.conversationManager.getConversation(message.discussionId);
|
|
583
|
+
const snapshotLength = conversation.length;
|
|
584
|
+
try {
|
|
585
|
+
const route = await this.routeMessage(message, conversation, signal);
|
|
586
|
+
return await this.runLlmLoop(message, route, signal);
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
if (error instanceof sdk_1.default.APIUserAbortError) {
|
|
590
|
+
throw error; // Let processDiscussion handle abort
|
|
591
|
+
}
|
|
592
|
+
this.logger.error('Message processing failed', error);
|
|
593
|
+
this.typingIndicator?.stop(message.discussionId);
|
|
594
|
+
conversation.length = snapshotLength;
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async runLlmLoop(message, route, signal) {
|
|
599
|
+
const conversation = this.conversationManager.getConversation(message.discussionId);
|
|
600
|
+
const systemPrompt = this.buildSystemPrompt();
|
|
601
|
+
const tools = this.getAnthropicTools();
|
|
602
|
+
const processingStartTime = Date.now();
|
|
603
|
+
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
|
604
|
+
const cachedConversation = this.conversationManager.prepareForCaching(conversation);
|
|
605
|
+
let response;
|
|
606
|
+
let llmStart = Date.now();
|
|
607
|
+
try {
|
|
608
|
+
response = await this.anthropic.messages.create({
|
|
609
|
+
model: route.model,
|
|
610
|
+
max_tokens: route.maxTokens,
|
|
611
|
+
temperature: 0,
|
|
612
|
+
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
|
|
613
|
+
messages: cachedConversation,
|
|
614
|
+
tools,
|
|
615
|
+
}, { signal });
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
// Handle abort — new message arrived, restart processing loop
|
|
619
|
+
if (error instanceof sdk_1.default.APIUserAbortError) {
|
|
620
|
+
this.cleanupIncompleteExchange(conversation);
|
|
621
|
+
this.opLogger.interrupt(message.discussionId, 'new message received');
|
|
622
|
+
return false; // processDiscussion loop restarts with full context
|
|
623
|
+
}
|
|
624
|
+
// If Sonnet fails, fall back to Haiku and retry once
|
|
625
|
+
if (route.model !== MODEL_HAIKU) {
|
|
626
|
+
this.logger.warn('Sonnet API call failed, falling back to Haiku', {
|
|
627
|
+
error: error instanceof Error ? error.message : String(error),
|
|
628
|
+
});
|
|
629
|
+
route = { model: MODEL_HAIKU, maxTokens: 2000, classification: 'SIMPLE' };
|
|
630
|
+
llmStart = Date.now();
|
|
631
|
+
response = await this.anthropic.messages.create({
|
|
632
|
+
model: MODEL_HAIKU,
|
|
633
|
+
max_tokens: 2000,
|
|
634
|
+
temperature: 0,
|
|
635
|
+
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
|
|
636
|
+
messages: cachedConversation,
|
|
637
|
+
tools,
|
|
638
|
+
}, { signal });
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Track token usage
|
|
645
|
+
this.trackTokenUsage(response, message, route.model);
|
|
646
|
+
// Log LLM call with timing and cache stats
|
|
647
|
+
const llmDuration = (Date.now() - llmStart) / 1000;
|
|
648
|
+
const usage = response.usage;
|
|
649
|
+
const cacheRead = usage.cache_read_input_tokens || 0;
|
|
650
|
+
const totalInput = usage.input_tokens + cacheRead + (usage.cache_creation_input_tokens || 0);
|
|
651
|
+
const cacheHitPct = totalInput > 0 ? Math.round((cacheRead / totalInput) * 100) : 0;
|
|
652
|
+
this.opLogger.llmCall(message.discussionId, route.model, usage.input_tokens, usage.output_tokens, cacheHitPct, llmDuration);
|
|
653
|
+
// Validate assistant response has content before adding to conversation
|
|
654
|
+
if (!response.content || (Array.isArray(response.content) && response.content.length === 0)) {
|
|
655
|
+
this.logger.warn('LLM returned empty content, skipping', {
|
|
656
|
+
discussionId: message.discussionId,
|
|
657
|
+
model: route.model,
|
|
658
|
+
stopReason: response.stop_reason,
|
|
659
|
+
});
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
// Add assistant response to conversation
|
|
663
|
+
conversation.push({
|
|
664
|
+
role: 'assistant',
|
|
665
|
+
content: response.content,
|
|
666
|
+
});
|
|
667
|
+
// Check for output truncation — if max_tokens was hit, tool call JSON is likely broken
|
|
668
|
+
if (response.stop_reason === 'max_tokens') {
|
|
669
|
+
this.logger.warn('LLM output truncated at max_tokens', {
|
|
670
|
+
discussionId: message.discussionId,
|
|
671
|
+
model: route.model,
|
|
672
|
+
maxTokens: route.maxTokens,
|
|
673
|
+
outputTokens: usage.output_tokens,
|
|
674
|
+
});
|
|
675
|
+
// Don't execute broken tool calls — tell the LLM to use smaller batches
|
|
676
|
+
conversation.push({
|
|
677
|
+
role: 'user',
|
|
678
|
+
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.',
|
|
679
|
+
});
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
// Check for tool calls
|
|
683
|
+
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
684
|
+
if (toolUseBlocks.length > 0) {
|
|
685
|
+
const toolResults = await this.executeTools(toolUseBlocks, message, signal);
|
|
686
|
+
conversation.push({ role: 'user', content: toolResults });
|
|
687
|
+
// Inject any messages that arrived during tool execution
|
|
688
|
+
this.injectPendingContext(message.discussionId, conversation);
|
|
689
|
+
// Auto-escalate: if Haiku is failing tool calls, switch to Sonnet
|
|
690
|
+
if (route.model === MODEL_HAIKU) {
|
|
691
|
+
const failedCount = toolResults
|
|
692
|
+
.filter(r => r.is_error).length;
|
|
693
|
+
if (failedCount >= 2) {
|
|
694
|
+
this.logger.info('Escalating to Sonnet — Haiku failing tool calls', {
|
|
695
|
+
discussionId: message.discussionId,
|
|
696
|
+
failedTools: failedCount,
|
|
697
|
+
iteration: i,
|
|
698
|
+
});
|
|
699
|
+
route = { model: MODEL_SONNET, maxTokens: 16384, classification: 'COMPLEX' };
|
|
700
|
+
await this.sendMessage(message.discussionId, `Switching to a more capable model to handle this.`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// Progress feedback: send contextual update every 20s during long operations
|
|
704
|
+
const state = this.discussionStates.get(message.discussionId);
|
|
705
|
+
const now = Date.now();
|
|
706
|
+
const elapsed = now - processingStartTime;
|
|
707
|
+
const lastProgress = state?.lastProgressTime || processingStartTime;
|
|
708
|
+
if (state && elapsed > 8000 && (now - lastProgress) >= 20000) {
|
|
709
|
+
state.lastProgressTime = now;
|
|
710
|
+
const toolCounts = new Map();
|
|
711
|
+
for (const b of toolUseBlocks) {
|
|
712
|
+
const name = b.name.replace(/_/g, ' ');
|
|
713
|
+
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
|
|
714
|
+
}
|
|
715
|
+
const toolSummary = Array.from(toolCounts.entries())
|
|
716
|
+
.map(([name, count]) => count > 1 ? `${name} x${count}` : name)
|
|
717
|
+
.join(', ');
|
|
718
|
+
const progressMsg = `Still working (step ${i + 1}) — ${toolSummary}`;
|
|
719
|
+
await this.sendMessage(message.discussionId, progressMsg);
|
|
720
|
+
this.opLogger.progress(message.discussionId);
|
|
721
|
+
}
|
|
722
|
+
continue; // Loop back for more tool calls or final response
|
|
723
|
+
}
|
|
724
|
+
// Extract text response
|
|
725
|
+
const textBlocks = response.content.filter((b) => b.type === 'text');
|
|
726
|
+
const responseText = textBlocks.map(b => b.text).join('\n').trim();
|
|
727
|
+
if (!responseText) {
|
|
728
|
+
// Remove the empty assistant message we just pushed
|
|
729
|
+
conversation.pop();
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
// Post the response
|
|
733
|
+
this.typingIndicator?.stop(message.discussionId);
|
|
734
|
+
const formatted = await this.formatOutgoingMessage(responseText);
|
|
735
|
+
const links = this.messageFormatter.extractTagLinks(formatted);
|
|
736
|
+
await this.sendMessage(message.discussionId, formatted, links);
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
// Max iterations reached or empty response
|
|
740
|
+
this.typingIndicator?.stop(message.discussionId);
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
async executeTools(toolUseBlocks, message, signal) {
|
|
744
|
+
const discussionId = message.discussionId;
|
|
745
|
+
const results = [];
|
|
746
|
+
const WRITE_TOOLS = new Set(['create_activity', 'update_activity']);
|
|
747
|
+
for (const block of toolUseBlocks) {
|
|
748
|
+
const toolStart = Date.now();
|
|
749
|
+
// Permission gate: check if user has access to the workflow
|
|
750
|
+
const args = block.input;
|
|
751
|
+
const workflowId = args.workflowId;
|
|
752
|
+
if (workflowId && WORKFLOW_SCOPED_TOOLS.has(block.name)) {
|
|
753
|
+
const allowed = this.permissionIndex.get(workflowId);
|
|
754
|
+
if (allowed && !allowed.has(message.senderId) && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
|
|
755
|
+
this.opLogger.permDenied(discussionId, block.name, workflowId, message.senderId);
|
|
756
|
+
results.push({
|
|
757
|
+
type: 'tool_result',
|
|
758
|
+
tool_use_id: block.id,
|
|
759
|
+
content: `Permission denied: This user does not have access to workflow ${workflowId}. This is a workspace permission restriction on the user, not on you (the bot).`,
|
|
760
|
+
is_error: true,
|
|
761
|
+
});
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
let toolArgs = args;
|
|
767
|
+
// Auto-inject context for join_discussion calls
|
|
768
|
+
if (block.name === 'join_discussion') {
|
|
769
|
+
toolArgs = { ...toolArgs };
|
|
770
|
+
// Auto-inject inviteUserId from the sender if not already set
|
|
771
|
+
if (!toolArgs.inviteUserId && message.senderId) {
|
|
772
|
+
toolArgs.inviteUserId = message.senderId;
|
|
773
|
+
}
|
|
774
|
+
// Auto-inject sourceActivityId for wormhole (only if from an activity discussion, not DMs)
|
|
775
|
+
if (!toolArgs.sourceActivityId && message.linkedActivityId) {
|
|
776
|
+
toolArgs.sourceActivityId = message.linkedActivityId;
|
|
777
|
+
}
|
|
778
|
+
// Auto-inject welcomeReason with the actual message content for context
|
|
779
|
+
if (!toolArgs.welcomeReason && message.content) {
|
|
780
|
+
toolArgs.welcomeReason = message.content;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const result = await this.toolExecutor.execute(block.name, toolArgs, this.getUserContext());
|
|
784
|
+
const text = result?.content?.[0]?.text ?? JSON.stringify(result);
|
|
785
|
+
let contentStr = typeof text === 'string' ? text : JSON.stringify(text);
|
|
786
|
+
const toolDuration = (Date.now() - toolStart) / 1000;
|
|
787
|
+
// Post-filter list_workflows results to only show accessible workflows
|
|
788
|
+
if ((block.name === 'list_workflows' || block.name === 'list_workflows_minimal') && this.permissionIndex.size > 0 && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
|
|
789
|
+
try {
|
|
790
|
+
const parsed = JSON.parse(contentStr);
|
|
791
|
+
if (parsed.workflows && Array.isArray(parsed.workflows)) {
|
|
792
|
+
const before = parsed.workflows.length;
|
|
793
|
+
parsed.workflows = parsed.workflows.filter((wf) => {
|
|
794
|
+
const wfId = wf.id || wf._id || wf.workflowId;
|
|
795
|
+
if (!wfId)
|
|
796
|
+
return true;
|
|
797
|
+
const allowed = this.permissionIndex.get(wfId);
|
|
798
|
+
return !allowed || allowed.has(message.senderId);
|
|
799
|
+
});
|
|
800
|
+
if (parsed.workflows.length < before) {
|
|
801
|
+
this.opLogger.permFiltered(discussionId, block.name, before, parsed.workflows.length);
|
|
802
|
+
}
|
|
803
|
+
contentStr = JSON.stringify(parsed);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// Not JSON or unexpected format, pass through unfiltered
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// Post-execution permission check for tools not gated by workflowId arg
|
|
811
|
+
if (this.permissionIndex.size > 0 && UNGATED_TOOLS.has(block.name)) {
|
|
812
|
+
const extractedWorkflowId = this.extractWorkflowIdFromResult(block.name, contentStr);
|
|
813
|
+
if (extractedWorkflowId) {
|
|
814
|
+
const allowed = this.permissionIndex.get(extractedWorkflowId);
|
|
815
|
+
if (allowed && !allowed.has(message.senderId) && !this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
|
|
816
|
+
this.opLogger.permDenied(discussionId, block.name, extractedWorkflowId, message.senderId);
|
|
817
|
+
results.push({
|
|
818
|
+
type: 'tool_result',
|
|
819
|
+
tool_use_id: block.id,
|
|
820
|
+
content: `Permission denied: This user does not have access to the resource in workflow ${extractedWorkflowId}. This is a workspace permission restriction on the user, not on you (the bot).`,
|
|
821
|
+
is_error: true,
|
|
822
|
+
});
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// Check if tool returned an error in its response text
|
|
828
|
+
const lower = contentStr.toLowerCase();
|
|
829
|
+
const stripped = contentStr.replace(/[^\w]/g, ' ').trimStart().toLowerCase();
|
|
830
|
+
const isToolError = lower.includes('"error"') || lower.includes('❌') || stripped.startsWith('error');
|
|
831
|
+
const summary = (0, operation_logger_1.summarizeToolResult)(block.name, contentStr);
|
|
832
|
+
if (isToolError) {
|
|
833
|
+
this.opLogger.toolCall(discussionId, block.name, 'error', toolDuration, 'FAIL', summary);
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
this.opLogger.toolCall(discussionId, block.name, summary, toolDuration, 'OK');
|
|
837
|
+
}
|
|
838
|
+
// Warn if a write tool completed before abort — not idempotent
|
|
839
|
+
if (signal?.aborted && WRITE_TOOLS.has(block.name)) {
|
|
840
|
+
this.logger.warn('Write tool completed before abort', {
|
|
841
|
+
discussionId, tool: block.name,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
results.push({
|
|
845
|
+
type: 'tool_result',
|
|
846
|
+
tool_use_id: block.id,
|
|
847
|
+
content: contentStr,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
const toolDuration = (Date.now() - toolStart) / 1000;
|
|
852
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
853
|
+
this.opLogger.toolCall(discussionId, block.name, 'error', toolDuration, 'FAIL', errMsg);
|
|
854
|
+
results.push({
|
|
855
|
+
type: 'tool_result',
|
|
856
|
+
tool_use_id: block.id,
|
|
857
|
+
content: `Error: ${errMsg}`,
|
|
858
|
+
is_error: true,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return results;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Inject pending messages from the context buffer into the conversation
|
|
866
|
+
* between LLM iterations. Merges into the existing user message to preserve
|
|
867
|
+
* Anthropic's alternation rule (user/assistant/user/assistant).
|
|
868
|
+
*/
|
|
869
|
+
injectPendingContext(discussionId, conversation) {
|
|
870
|
+
const state = this.discussionStates.get(discussionId);
|
|
871
|
+
if (!state || state.contextBuffer.length === 0)
|
|
872
|
+
return;
|
|
873
|
+
const pending = state.contextBuffer.splice(0);
|
|
874
|
+
const lastMsg = conversation[conversation.length - 1];
|
|
875
|
+
if (lastMsg?.role === 'user' && Array.isArray(lastMsg.content)) {
|
|
876
|
+
const contextText = pending.map(m => this.formatIncomingMessage(m)).join('\n\n');
|
|
877
|
+
lastMsg.content.push({
|
|
878
|
+
type: 'text',
|
|
879
|
+
text: `<context type="messages-during-processing">\n${contextText}\n</context>`,
|
|
880
|
+
});
|
|
881
|
+
this.opLogger.contextInject(discussionId, pending.length);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Remove orphaned tool_use/tool_result pairs from conversation after abort.
|
|
886
|
+
* Walks backwards and removes incomplete exchanges to keep conversation valid.
|
|
887
|
+
*/
|
|
888
|
+
cleanupIncompleteExchange(conversation) {
|
|
889
|
+
while (conversation.length > 0) {
|
|
890
|
+
const last = conversation[conversation.length - 1];
|
|
891
|
+
const content = Array.isArray(last.content) ? last.content : [];
|
|
892
|
+
if (last.role === 'assistant' && content.some((b) => b.type === 'tool_use')) {
|
|
893
|
+
conversation.pop();
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (last.role === 'user' && content.some((b) => b.type === 'tool_result')) {
|
|
897
|
+
conversation.pop();
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
// ===== FORMATTING =====
|
|
904
|
+
formatIncomingMessage(message) {
|
|
905
|
+
const activityAttr = message.linkedActivityId ? ` activity_id="${message.linkedActivityId}"` : '';
|
|
906
|
+
const discName = message.discussionName || message.linkedActivityName;
|
|
907
|
+
const nameAttr = discName ? ` discussion_name="${discName}"` : '';
|
|
908
|
+
let fileInfo = '';
|
|
909
|
+
if (message.fileAttachments?.length) {
|
|
910
|
+
const ids = message.fileAttachments.map(f => f.fileId).join(', ');
|
|
911
|
+
fileInfo = `\n[File attached: ${message.fileAttachments.length} file(s) - IDs: ${ids}]\nUse download_file tool with fileId to read file contents.`;
|
|
912
|
+
}
|
|
913
|
+
return `<incoming discussion="${message.discussionId}"${nameAttr}${activityAttr} from="${message.senderName}" user_id="${message.senderId}" timestamp="${new Date(message.timestamp).toISOString()}">\n${message.content}${fileInfo}\n</incoming>`;
|
|
914
|
+
}
|
|
915
|
+
async formatOutgoingMessage(text) {
|
|
916
|
+
let formatted = await this.messageFormatter.resolveUserTags(text);
|
|
917
|
+
formatted = await this.messageFormatter.resolveActivityTags(formatted);
|
|
918
|
+
formatted = await this.messageFormatter.resolveHailerUrls(formatted);
|
|
919
|
+
formatted = this.messageFormatter.convertMentionsToTags(formatted);
|
|
920
|
+
// Remove redundant name after tags
|
|
921
|
+
formatted = formatted.replace(/(\[hailerTag\|[^\]]+\]\([a-f0-9]{24}\)\uFEFF?)\s*\([^)]+\)/gi, '$1');
|
|
922
|
+
// Auto-resolve bare 24-char hex IDs not already in hailerTag format
|
|
923
|
+
formatted = await this.resolveBareIds(formatted);
|
|
924
|
+
return formatted;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Find bare 24-char hex IDs in text that aren't already inside hailerTags
|
|
928
|
+
* and try to resolve them to clickable hailerTags.
|
|
929
|
+
*/
|
|
930
|
+
async resolveBareIds(text) {
|
|
931
|
+
// Match 24-char hex IDs that are NOT preceded by ( which would mean they're already in a hailerTag
|
|
932
|
+
const bareIdPattern = /(?<!\()([a-f0-9]{24})(?!\)[^\[]*\[hailerTag)/gi;
|
|
933
|
+
const matches = [...text.matchAll(bareIdPattern)];
|
|
934
|
+
// Collect IDs that aren't already in hailerTags, limit to 5
|
|
935
|
+
const idsToResolve = [];
|
|
936
|
+
for (const match of matches) {
|
|
937
|
+
const id = match[1];
|
|
938
|
+
const tagCheck = new RegExp(`\\[hailerTag\\|[^\\]]+\\]\\(${id}\\)`, 'i');
|
|
939
|
+
if (tagCheck.test(text))
|
|
940
|
+
continue;
|
|
941
|
+
idsToResolve.push(id);
|
|
942
|
+
if (idsToResolve.length >= 5)
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
if (idsToResolve.length === 0)
|
|
946
|
+
return text;
|
|
947
|
+
// Resolve all IDs in parallel
|
|
948
|
+
const resolutions = await Promise.all(idsToResolve.map(async (id) => {
|
|
949
|
+
try {
|
|
950
|
+
const result = await this.toolExecutor.execute('show_activity_by_id', { activityId: id }, this.getUserContext());
|
|
951
|
+
const resultText = result?.content?.[0]?.text;
|
|
952
|
+
if (resultText) {
|
|
953
|
+
const jsonMatch = resultText.match(/\{[\s\S]*\}/);
|
|
954
|
+
if (jsonMatch) {
|
|
955
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
956
|
+
if (parsed.name)
|
|
957
|
+
return { id, name: parsed.name };
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
// Not an activity, skip
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
}));
|
|
966
|
+
// Apply replacements
|
|
967
|
+
for (const resolved of resolutions) {
|
|
968
|
+
if (!resolved)
|
|
969
|
+
continue;
|
|
970
|
+
const ZWNBSP = '\uFEFF';
|
|
971
|
+
const tag = `${ZWNBSP}[hailerTag|${resolved.name}](${resolved.id})${ZWNBSP}`;
|
|
972
|
+
text = text.replace(resolved.id, tag);
|
|
973
|
+
}
|
|
974
|
+
return text;
|
|
975
|
+
}
|
|
976
|
+
// ===== SYSTEM PROMPT =====
|
|
977
|
+
getBotDisplayName() {
|
|
978
|
+
if (!this.init?.users || !this.userId)
|
|
979
|
+
return 'Assistant';
|
|
980
|
+
const user = this.init.users[this.userId];
|
|
981
|
+
if (!user)
|
|
982
|
+
return 'Assistant';
|
|
983
|
+
const name = user.display_name || user.fullName ||
|
|
984
|
+
`${user.firstname || ''} ${user.lastname || ''}`.trim();
|
|
985
|
+
return name || 'Assistant';
|
|
986
|
+
}
|
|
987
|
+
buildSystemPrompt() {
|
|
988
|
+
const wsName = this.init?.network?.name || 'Workspace';
|
|
989
|
+
const botName = this.getBotDisplayName();
|
|
990
|
+
return `You are a workspace assistant for "${wsName}" on Hailer.
|
|
991
|
+
|
|
992
|
+
<bot-identity>
|
|
993
|
+
Your user ID: ${this.userId}
|
|
994
|
+
Your display name: ${botName}
|
|
995
|
+
</bot-identity>
|
|
996
|
+
|
|
997
|
+
<rules>
|
|
998
|
+
- Be concise. Short answers, no filler.
|
|
999
|
+
- Never list your capabilities unprompted. If asked what you can do, give a 1-2 sentence summary based on the workspace context below, not a feature dump.
|
|
1000
|
+
- Never use emojis unless the user does first.
|
|
1001
|
+
- Use tools to answer questions - don't guess or make up data.
|
|
1002
|
+
- When a task will require multiple steps (investigating errors, fixing data, bulk lookups), briefly tell the user what you're specifically about to do before starting. Be concrete: "I'll check each insight's SQL and fix any broken column references." NEVER use vague filler like "Let me look into that" — always name the actual action.
|
|
1003
|
+
- When showing data, use clean formatting (tables, bullet points). Don't over-explain.
|
|
1004
|
+
- If a request is ambiguous, ask a clarifying question instead of guessing.
|
|
1005
|
+
- Always reference actual workspace data (workflow names, field values) - never speak in generic terms.
|
|
1006
|
+
- For bulk operations (creating/updating many items), batch into groups of 25-50 per tool call. Never try to create more than 50 items in a single tool call.
|
|
1007
|
+
- When a tool call fails, ALWAYS tell the user what failed and why. Never silently try alternatives. If an insight has a SQL error, tell the user the insight is broken, explain what's wrong (e.g. missing column), and offer to fix it using update_insight. Fix the root cause, don't work around it.
|
|
1008
|
+
- NEVER output raw URLs like https://app.hailer.com/...
|
|
1009
|
+
- When referencing activities, discussions, or users, use hailerTag format: [hailerTag|Display Name](objectId)
|
|
1010
|
+
Examples: [hailerTag|AC Milan](691ffe654217e9e8434e578a) or [hailerTag|John Smith](691ffe654217e9e8434e5123)
|
|
1011
|
+
You always have the name and ID from tool responses - use them directly in this format.
|
|
1012
|
+
</rules>
|
|
1013
|
+
|
|
1014
|
+
<workspace-context>
|
|
1015
|
+
${this.workspaceOverview}
|
|
1016
|
+
</workspace-context>`;
|
|
1017
|
+
}
|
|
1018
|
+
// ===== TOOLS =====
|
|
1019
|
+
getAnthropicTools() {
|
|
1020
|
+
const defs = this.toolExecutor.getToolDefinitions({
|
|
1021
|
+
allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND, tool_registry_1.ToolGroup.BOT_INTERNAL],
|
|
1022
|
+
});
|
|
1023
|
+
return defs
|
|
1024
|
+
.filter(d => BOT_TOOLS.has(d.name))
|
|
1025
|
+
.map(d => ({
|
|
1026
|
+
name: d.name,
|
|
1027
|
+
description: d.description,
|
|
1028
|
+
input_schema: d.inputSchema,
|
|
1029
|
+
}));
|
|
1030
|
+
}
|
|
1031
|
+
// ===== HAILER MESSAGING =====
|
|
1032
|
+
async sendMessage(discussionId, text, links) {
|
|
1033
|
+
try {
|
|
1034
|
+
const msgData = { msg: text };
|
|
1035
|
+
if (links?.length)
|
|
1036
|
+
msgData.links = links;
|
|
1037
|
+
await this.client.socket.request('messenger.send', [msgData, discussionId]);
|
|
1038
|
+
const tagCount = (text.match(/\[hailerTag\|/g) || []).length;
|
|
1039
|
+
this.opLogger.messageOut(discussionId, text.length, tagCount);
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
this.logger.error('Failed to send message', error);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// ===== HELPERS =====
|
|
1046
|
+
/**
|
|
1047
|
+
* Schedule a workspace data refresh with debounce.
|
|
1048
|
+
* Signals can fire in bursts (e.g., bulk field edits), so we debounce
|
|
1049
|
+
* to avoid hammering the API. 2-second delay is effectively instant
|
|
1050
|
+
* from a user perspective but handles bursts.
|
|
1051
|
+
*/
|
|
1052
|
+
scheduleRefresh(scope) {
|
|
1053
|
+
this.refreshDirty = true;
|
|
1054
|
+
if (scope === 'full') {
|
|
1055
|
+
this.refreshScope.clear();
|
|
1056
|
+
this.refreshScope.add('full');
|
|
1057
|
+
}
|
|
1058
|
+
else if (!this.refreshScope.has('full')) {
|
|
1059
|
+
this.refreshScope.add(scope);
|
|
1060
|
+
}
|
|
1061
|
+
if (this.refreshTimer)
|
|
1062
|
+
clearTimeout(this.refreshTimer);
|
|
1063
|
+
this.refreshTimer = setTimeout(() => this.refreshWorkspaceData(), 2000);
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Re-fetch workspace data and rebuild the overview.
|
|
1067
|
+
* Called when process.updated, workspace.updated, or cache.invalidate signals fire.
|
|
1068
|
+
*/
|
|
1069
|
+
async refreshWorkspaceData() {
|
|
1070
|
+
if (!this.refreshDirty || !this.client)
|
|
1071
|
+
return;
|
|
1072
|
+
if (this.isRefreshing) {
|
|
1073
|
+
this.refreshQueued = true;
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
this.isRefreshing = true;
|
|
1077
|
+
this.refreshDirty = false;
|
|
1078
|
+
const scopes = new Set(this.refreshScope);
|
|
1079
|
+
this.refreshScope.clear();
|
|
1080
|
+
this.refreshTimer = null;
|
|
1081
|
+
try {
|
|
1082
|
+
// Determine what to re-fetch based on signal types
|
|
1083
|
+
const isFull = scopes.has('full');
|
|
1084
|
+
const keys = isFull
|
|
1085
|
+
? ['processes', 'users', 'network', 'networks', 'teams', 'groups']
|
|
1086
|
+
: Array.from(scopes);
|
|
1087
|
+
// Always include users with processes (membership check depends on it)
|
|
1088
|
+
if (keys.includes('processes') && !keys.includes('users')) {
|
|
1089
|
+
keys.push('users');
|
|
1090
|
+
}
|
|
1091
|
+
this.logger.debug('Refreshing workspace data', { keys, trigger: isFull ? 'full' : Array.from(scopes).join(',') });
|
|
1092
|
+
const freshData = await this.client.socket.request('v2.core.init', [keys]);
|
|
1093
|
+
// Merge fresh data into existing init (partial update)
|
|
1094
|
+
if (!this.init) {
|
|
1095
|
+
this.init = freshData;
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
if (freshData.processes)
|
|
1099
|
+
this.init.processes = freshData.processes;
|
|
1100
|
+
if (freshData.users)
|
|
1101
|
+
this.init.users = freshData.users;
|
|
1102
|
+
if (freshData.network)
|
|
1103
|
+
this.init.network = freshData.network;
|
|
1104
|
+
if (freshData.networks)
|
|
1105
|
+
this.init.networks = freshData.networks;
|
|
1106
|
+
if (freshData.teams)
|
|
1107
|
+
this.init.teams = freshData.teams;
|
|
1108
|
+
if (freshData.groups)
|
|
1109
|
+
this.init.groups = freshData.groups;
|
|
1110
|
+
}
|
|
1111
|
+
this._workspaceId = this.init.network?._id || this.config.workspaceId;
|
|
1112
|
+
// Rebuild workspace cache
|
|
1113
|
+
this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
|
|
1114
|
+
excludeTranslations: true,
|
|
1115
|
+
excludeSystemMessages: true,
|
|
1116
|
+
excludeEmptyFields: true,
|
|
1117
|
+
compactUserData: true,
|
|
1118
|
+
includeWorkspaceNamesInTools: false,
|
|
1119
|
+
});
|
|
1120
|
+
// Rebuild workspace overview for system prompt
|
|
1121
|
+
this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
|
|
1122
|
+
// Update UserContext with fresh data
|
|
1123
|
+
if (this.userContext) {
|
|
1124
|
+
this.userContext.init = this.init;
|
|
1125
|
+
this.userContext.workspaceCache = this.workspaceCache;
|
|
1126
|
+
}
|
|
1127
|
+
// Rebuild permission index
|
|
1128
|
+
this.buildPermissionIndex();
|
|
1129
|
+
this.logger.debug('Workspace data refreshed', {
|
|
1130
|
+
keys,
|
|
1131
|
+
workflowCount: this.init.processes?.length || 0,
|
|
1132
|
+
userCount: Object.keys(this.init.users || {}).length,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
catch (error) {
|
|
1136
|
+
this.logger.error('Failed to refresh workspace data', error);
|
|
1137
|
+
}
|
|
1138
|
+
finally {
|
|
1139
|
+
this.isRefreshing = false;
|
|
1140
|
+
if (this.refreshQueued) {
|
|
1141
|
+
this.refreshQueued = false;
|
|
1142
|
+
this.refreshWorkspaceData();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
buildPermissionIndex() {
|
|
1147
|
+
this.permissionIndex.clear();
|
|
1148
|
+
const wsId = this._workspaceId;
|
|
1149
|
+
if (!this.init?.processes || !wsId)
|
|
1150
|
+
return;
|
|
1151
|
+
// Extract workspace admins from network members
|
|
1152
|
+
this.adminUserIds.clear();
|
|
1153
|
+
const network = this.init.network;
|
|
1154
|
+
if (network?.members) {
|
|
1155
|
+
const members = Array.isArray(network.members) ? network.members : Object.values(network.members);
|
|
1156
|
+
for (const member of members) {
|
|
1157
|
+
if (member.admin === true) {
|
|
1158
|
+
this.adminUserIds.add(member.uid);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
// Extract workspace owners from network members
|
|
1163
|
+
this.ownerUserIds.clear();
|
|
1164
|
+
if (network?.members) {
|
|
1165
|
+
const ownerMembers = Array.isArray(network.members) ? network.members : Object.values(network.members);
|
|
1166
|
+
for (const member of ownerMembers) {
|
|
1167
|
+
if (member.owner === true) {
|
|
1168
|
+
this.ownerUserIds.add(member.uid);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const teams = this.init.teams?.[wsId] || {};
|
|
1173
|
+
const groups = this.init.groups?.[wsId] || {};
|
|
1174
|
+
let networkOpenCount = 0;
|
|
1175
|
+
for (const proc of this.init.processes) {
|
|
1176
|
+
const members = proc.members || [];
|
|
1177
|
+
// If any member is network_ (all workspace members), skip indexing — everyone has access
|
|
1178
|
+
if (members.some((m) => m.id.startsWith('network_'))) {
|
|
1179
|
+
networkOpenCount++;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
// If any member references a public team, treat as open — all workspace members have access
|
|
1183
|
+
let hasPublicTeam = false;
|
|
1184
|
+
for (const m of members) {
|
|
1185
|
+
if (!m.id.startsWith('team_'))
|
|
1186
|
+
continue;
|
|
1187
|
+
const team = teams[m.id.slice(5)];
|
|
1188
|
+
if (team) {
|
|
1189
|
+
if (team.public) {
|
|
1190
|
+
hasPublicTeam = true;
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (hasPublicTeam) {
|
|
1196
|
+
networkOpenCount++;
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
const userIds = new Set();
|
|
1200
|
+
for (const member of members) {
|
|
1201
|
+
const id = member.id;
|
|
1202
|
+
if (id.startsWith('user_')) {
|
|
1203
|
+
userIds.add(id.slice(5));
|
|
1204
|
+
}
|
|
1205
|
+
else if (id.startsWith('team_')) {
|
|
1206
|
+
const team = teams[id.slice(5)];
|
|
1207
|
+
if (team?.members) {
|
|
1208
|
+
for (const uid of team.members) {
|
|
1209
|
+
userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
else if (id.startsWith('group_')) {
|
|
1214
|
+
const group = groups[id.slice(6)];
|
|
1215
|
+
if (group?.members) {
|
|
1216
|
+
for (const uid of group.members) {
|
|
1217
|
+
userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
// Workflow creator always has access
|
|
1223
|
+
if (proc.uid)
|
|
1224
|
+
userIds.add(proc.uid);
|
|
1225
|
+
this.permissionIndex.set(proc._id, userIds);
|
|
1226
|
+
}
|
|
1227
|
+
this.logger.debug('Permission index built', {
|
|
1228
|
+
restricted: this.permissionIndex.size,
|
|
1229
|
+
open: networkOpenCount,
|
|
1230
|
+
totalWorkflows: this.init.processes.length,
|
|
1231
|
+
totalEntries: Array.from(this.permissionIndex.values()).reduce((n, s) => n + s.size, 0),
|
|
1232
|
+
admins: this.adminUserIds.size,
|
|
1233
|
+
owners: this.ownerUserIds.size,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
getOrCreateDiscussionState(discussionId) {
|
|
1237
|
+
let state = this.discussionStates.get(discussionId);
|
|
1238
|
+
if (state) {
|
|
1239
|
+
// LRU: move to end
|
|
1240
|
+
this.discussionStates.delete(discussionId);
|
|
1241
|
+
this.discussionStates.set(discussionId, state);
|
|
1242
|
+
return state;
|
|
1243
|
+
}
|
|
1244
|
+
// Evict oldest if at capacity
|
|
1245
|
+
if (this.discussionStates.size >= 200) {
|
|
1246
|
+
const oldest = this.discussionStates.keys().next().value;
|
|
1247
|
+
if (oldest)
|
|
1248
|
+
this.discussionStates.delete(oldest);
|
|
1249
|
+
}
|
|
1250
|
+
state = {
|
|
1251
|
+
contextBuffer: [],
|
|
1252
|
+
abortController: null,
|
|
1253
|
+
state: 'idle',
|
|
1254
|
+
consecutiveNonResponses: 0,
|
|
1255
|
+
lastProgressTime: 0,
|
|
1256
|
+
};
|
|
1257
|
+
this.discussionStates.set(discussionId, state);
|
|
1258
|
+
return state;
|
|
1259
|
+
}
|
|
1260
|
+
getUserContext() {
|
|
1261
|
+
if (!this.userContext)
|
|
1262
|
+
throw new Error('Bot not started');
|
|
1263
|
+
return this.userContext;
|
|
1264
|
+
}
|
|
1265
|
+
getDefaultTeamId() {
|
|
1266
|
+
const teams = this.init?.teams;
|
|
1267
|
+
if (!teams)
|
|
1268
|
+
return undefined;
|
|
1269
|
+
const workspaceTeams = Object.values(teams)[0];
|
|
1270
|
+
if (!workspaceTeams || typeof workspaceTeams !== 'object')
|
|
1271
|
+
return undefined;
|
|
1272
|
+
return Object.keys(workspaceTeams)[0];
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Extract workflow ID from tool result for post-execution permission checks.
|
|
1276
|
+
* Returns the workflow ID if found, or undefined if extraction fails.
|
|
1277
|
+
*/
|
|
1278
|
+
extractWorkflowIdFromResult(toolName, resultStr) {
|
|
1279
|
+
try {
|
|
1280
|
+
const jsonMatch = resultStr.match(/\{[\s\S]*\}/);
|
|
1281
|
+
if (!jsonMatch)
|
|
1282
|
+
return undefined;
|
|
1283
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1284
|
+
// show_activity_by_id: workflowId is in the activity result
|
|
1285
|
+
if (toolName === 'show_activity_by_id') {
|
|
1286
|
+
return parsed.workflowId || parsed.workflow_id || parsed.processId;
|
|
1287
|
+
}
|
|
1288
|
+
// get_activity_from_discussion: returns activity with workflow info
|
|
1289
|
+
if (toolName === 'get_activity_from_discussion') {
|
|
1290
|
+
return parsed.workflowId || parsed.workflow_id || parsed.processId
|
|
1291
|
+
|| parsed.activity?.workflowId || parsed.activity?.processId;
|
|
1292
|
+
}
|
|
1293
|
+
// fetch_discussion_messages: result may contain a workflowId from linked activity
|
|
1294
|
+
if (toolName === 'fetch_discussion_messages') {
|
|
1295
|
+
return parsed.workflowId || parsed.workflow_id || parsed.processId
|
|
1296
|
+
|| parsed.activity?.workflowId || parsed.activity?.processId;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
catch {
|
|
1300
|
+
// JSON parse failed, cannot extract
|
|
1301
|
+
}
|
|
1302
|
+
return undefined;
|
|
1303
|
+
}
|
|
1304
|
+
trackTokenUsage(response, message, model) {
|
|
1305
|
+
if (!response.usage)
|
|
1306
|
+
return;
|
|
1307
|
+
const { input_tokens, output_tokens } = response.usage;
|
|
1308
|
+
const cacheCreation = response.usage.cache_creation_input_tokens || 0;
|
|
1309
|
+
const cacheRead = response.usage.cache_read_input_tokens || 0;
|
|
1310
|
+
// Burn tokens - always use bot's workspace, not the discussion's workspace
|
|
1311
|
+
const burnWorkspaceId = this._workspaceId || message.workspaceId;
|
|
1312
|
+
if (this.tokenBilling && burnWorkspaceId) {
|
|
1313
|
+
const cost = this.tokenBilling.calculateCost(input_tokens, output_tokens, cacheCreation, cacheRead, model);
|
|
1314
|
+
this.tokenBilling.burnTokens({
|
|
1315
|
+
workspaceId: burnWorkspaceId,
|
|
1316
|
+
inputTokens: input_tokens,
|
|
1317
|
+
outputTokens: output_tokens,
|
|
1318
|
+
cacheCreationTokens: cacheCreation,
|
|
1319
|
+
cacheReadTokens: cacheRead,
|
|
1320
|
+
costUsd: cost,
|
|
1321
|
+
sessionId: message.discussionId,
|
|
1322
|
+
model,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
exports.Bot = Bot;
|
|
1328
|
+
//# sourceMappingURL=bot.js.map
|