@hailer/mcp 1.1.11 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +18 -5
- package/dist/bot/bot-config.d.ts +12 -1
- package/dist/bot/bot-config.js +98 -14
- package/dist/bot/bot-manager.d.ts +13 -3
- package/dist/bot/bot-manager.js +80 -25
- package/dist/bot/bot.d.ts +46 -0
- package/dist/bot/bot.js +542 -166
- package/dist/bot/services/message-classifier.js +17 -0
- package/dist/bot/services/permission-guard.d.ts +52 -0
- package/dist/bot/services/permission-guard.js +149 -0
- package/dist/bot/services/types.d.ts +5 -0
- package/dist/bot/services/typing-indicator.d.ts +6 -1
- package/dist/bot/services/typing-indicator.js +19 -3
- package/dist/config.d.ts +6 -1
- package/dist/config.js +43 -0
- package/dist/core.js +3 -6
- package/dist/mcp/UserContextCache.d.ts +5 -0
- package/dist/mcp/UserContextCache.js +51 -19
- package/dist/mcp/hailer-clients.d.ts +19 -1
- package/dist/mcp/hailer-clients.js +157 -20
- package/dist/mcp/session-store.d.ts +68 -0
- package/dist/mcp/session-store.js +169 -0
- package/dist/mcp/signal-handler.js +12 -12
- package/dist/mcp/tool-registry.d.ts +17 -4
- package/dist/mcp/tool-registry.js +37 -7
- package/dist/mcp/tools/activity.js +99 -7
- package/dist/mcp/tools/app-scaffold.js +304 -336
- package/dist/mcp/tools/company.d.ts +9 -0
- package/dist/mcp/tools/company.js +88 -0
- package/dist/mcp/tools/discussion.js +68 -0
- package/dist/mcp/tools/workflow-permissions.d.ts +15 -0
- package/dist/mcp/tools/workflow-permissions.js +204 -0
- package/dist/mcp/tools/workflow.js +57 -18
- package/dist/mcp/utils/index.d.ts +2 -0
- package/dist/mcp/utils/index.js +12 -1
- package/dist/mcp/utils/role-utils.d.ts +74 -0
- package/dist/mcp/utils/role-utils.js +151 -0
- package/dist/mcp/utils/types.d.ts +43 -1
- package/dist/mcp/utils/types.js +14 -0
- package/dist/mcp/webhook-handler.d.ts +6 -0
- package/dist/mcp/webhook-handler.js +11 -0
- package/dist/mcp-server.d.ts +23 -2
- package/dist/mcp-server.js +639 -111
- package/dist/plugins/vipunen/client.d.ts +150 -0
- package/dist/plugins/vipunen/client.js +535 -0
- package/dist/plugins/vipunen/config/schema-config.json +19 -0
- package/dist/plugins/vipunen/config/schema-doc.json +22 -0
- package/dist/plugins/vipunen/index.d.ts +41 -0
- package/dist/plugins/vipunen/index.js +88 -0
- package/dist/plugins/vipunen/tools.d.ts +26 -0
- package/dist/plugins/vipunen/tools.js +501 -0
- package/package.json +2 -1
- package/.claude/.context-watchdog.json +0 -1
- package/.claude/.session-checked +0 -1
- package/.claude/CLAUDE.md +0 -370
- 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 -247
- 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/commands/app-squad.md +0 -135
- package/.claude/commands/audit-squad.md +0 -158
- package/.claude/commands/autoplan.md +0 -563
- package/.claude/commands/cleanup-squad.md +0 -98
- package/.claude/commands/config-squad.md +0 -106
- package/.claude/commands/crud-squad.md +0 -87
- package/.claude/commands/data-squad.md +0 -97
- package/.claude/commands/debug-squad.md +0 -303
- package/.claude/commands/doc-squad.md +0 -65
- package/.claude/commands/handoff.md +0 -137
- package/.claude/commands/health.md +0 -49
- package/.claude/commands/help.md +0 -29
- package/.claude/commands/help:agents.md +0 -151
- package/.claude/commands/help:commands.md +0 -78
- package/.claude/commands/help:faq.md +0 -79
- package/.claude/commands/help:plugins.md +0 -50
- package/.claude/commands/help:skills.md +0 -93
- package/.claude/commands/help:tools.md +0 -75
- package/.claude/commands/hotfix-squad.md +0 -112
- package/.claude/commands/integration-squad.md +0 -82
- package/.claude/commands/janitor-squad.md +0 -167
- package/.claude/commands/learn-auto.md +0 -120
- package/.claude/commands/learn.md +0 -120
- package/.claude/commands/mcp-list.md +0 -27
- package/.claude/commands/onboard-squad.md +0 -140
- package/.claude/commands/plan-workspace.md +0 -732
- package/.claude/commands/prd.md +0 -130
- package/.claude/commands/project-status.md +0 -82
- package/.claude/commands/publish.md +0 -138
- package/.claude/commands/recap.md +0 -69
- package/.claude/commands/restore.md +0 -64
- package/.claude/commands/review-squad.md +0 -152
- package/.claude/commands/save.md +0 -24
- package/.claude/commands/stats.md +0 -19
- package/.claude/commands/swarm.md +0 -210
- package/.claude/commands/tool-builder.md +0 -39
- package/.claude/commands/ws-pull.md +0 -44
- package/.claude/hooks/_shared-memory.cjs +0 -305
- package/.claude/hooks/_utils.cjs +0 -108
- package/.claude/hooks/agent-failure-detector.cjs +0 -383
- package/.claude/hooks/agent-usage-logger.cjs +0 -204
- package/.claude/hooks/app-edit-guard.cjs +0 -494
- package/.claude/hooks/auto-learn.cjs +0 -304
- package/.claude/hooks/bash-guard.cjs +0 -272
- package/.claude/hooks/builder-mode-manager.cjs +0 -354
- package/.claude/hooks/bulk-activity-guard.cjs +0 -271
- package/.claude/hooks/context-watchdog.cjs +0 -230
- package/.claude/hooks/delegation-reminder.cjs +0 -465
- package/.claude/hooks/design-system-lint.cjs +0 -271
- package/.claude/hooks/post-scaffold-hook.cjs +0 -181
- package/.claude/hooks/prompt-guard.cjs +0 -354
- package/.claude/hooks/publish-template-guard.cjs +0 -147
- package/.claude/hooks/session-start.cjs +0 -35
- package/.claude/hooks/shared-memory-writer.cjs +0 -147
- package/.claude/hooks/skill-injector.cjs +0 -140
- package/.claude/hooks/skill-usage-logger.cjs +0 -258
- package/.claude/hooks/src-edit-guard.cjs +0 -240
- package/.claude/hooks/sync-marketplace-agents.cjs +0 -346
- package/.claude/settings.json +0 -257
- package/.claude/skills/SDK-activity-patterns/SKILL.md +0 -428
- package/.claude/skills/SDK-document-templates/SKILL.md +0 -1033
- package/.claude/skills/SDK-function-fields/SKILL.md +0 -542
- package/.claude/skills/SDK-generate-skill/SKILL.md +0 -92
- package/.claude/skills/SDK-init-skill/SKILL.md +0 -127
- package/.claude/skills/SDK-insight-queries/SKILL.md +0 -787
- package/.claude/skills/SDK-ws-config-skill/SKILL.md +0 -1139
- package/.claude/skills/agent-structure/SKILL.md +0 -98
- package/.claude/skills/api-documentation-patterns/SKILL.md +0 -474
- package/.claude/skills/chrome-mcp-reference/SKILL.md +0 -370
- 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-api-client/SKILL.md +0 -518
- package/.claude/skills/hailer-app-builder/SKILL.md +0 -1434
- package/.claude/skills/hailer-apps-pictures/SKILL.md +0 -269
- package/.claude/skills/hailer-design-system/SKILL.md +0 -235
- package/.claude/skills/hailer-monolith-automations/SKILL.md +0 -686
- package/.claude/skills/hailer-permissions-system/SKILL.md +0 -121
- package/.claude/skills/hailer-project-protocol/SKILL.md +0 -488
- 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/integration-patterns/SKILL.md +0 -421
- package/.claude/skills/json-only-output/SKILL.md +0 -72
- package/.claude/skills/lsp-setup/SKILL.md +0 -160
- package/.claude/skills/mcp-direct-tools/SKILL.md +0 -153
- package/.claude/skills/optional-parameters/SKILL.md +0 -72
- package/.claude/skills/publish-hailer-app/SKILL.md +0 -244
- package/.claude/skills/testing-patterns/SKILL.md +0 -630
- package/.claude/skills/tool-builder/SKILL.md +0 -250
- package/.claude/skills/tool-parameter-usage/SKILL.md +0 -126
- package/.claude/skills/tool-response-verification/SKILL.md +0 -92
- package/.claude/skills/zapier-hailer-patterns/SKILL.md +0 -581
- package/.hailer-mcp-port +0 -1
- package/.mcp.json +0 -13
- 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/.opencode/opencode.json +0 -21
- package/inbox/failures.log +0 -1
- package/inbox/usage.jsonl +0 -4
- package/scripts/postinstall.cjs +0 -64
- package/scripts/test-hal-tools.ts +0 -154
package/dist/bot/bot.js
CHANGED
|
@@ -15,12 +15,14 @@ const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
|
|
|
15
15
|
const tool_registry_1 = require("../mcp/tool-registry");
|
|
16
16
|
const hailer_clients_1 = require("../mcp/hailer-clients");
|
|
17
17
|
const hailer_api_client_1 = require("../mcp/utils/hailer-api-client");
|
|
18
|
+
const types_1 = require("../mcp/utils/types");
|
|
18
19
|
const workspace_cache_1 = require("../mcp/workspace-cache");
|
|
19
20
|
const tool_executor_1 = require("./tool-executor");
|
|
20
21
|
const workspace_overview_1 = require("./workspace-overview");
|
|
21
22
|
const operation_logger_1 = require("./operation-logger");
|
|
22
23
|
const services_1 = require("./services");
|
|
23
24
|
const logger_1 = require("../lib/logger");
|
|
25
|
+
const permission_guard_1 = require("./services/permission-guard");
|
|
24
26
|
/**
|
|
25
27
|
* Tools available to the bot. Add/remove tool names here to control what the LLM can use.
|
|
26
28
|
* The MCP server (Claude Code terminal) is unaffected - it keeps full access to all tools.
|
|
@@ -59,21 +61,31 @@ const BOT_TOOLS = new Set([
|
|
|
59
61
|
'preview_insight',
|
|
60
62
|
'create_insight',
|
|
61
63
|
'update_insight',
|
|
64
|
+
// Permissions
|
|
65
|
+
'list_workflow_permissions',
|
|
66
|
+
'check_user_permissions',
|
|
67
|
+
'grant_workflow_permission',
|
|
68
|
+
'revoke_workflow_permission',
|
|
62
69
|
]);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
'install_workflow',
|
|
70
|
+
/** Tools that require the requesting user to be a workspace admin or owner */
|
|
71
|
+
const ADMIN_ONLY_TOOLS = new Set([
|
|
72
|
+
'grant_workflow_permission',
|
|
73
|
+
'revoke_workflow_permission',
|
|
68
74
|
]);
|
|
69
|
-
/**
|
|
70
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Tools where the workflowId only appears in the result, not the args.
|
|
77
|
+
* These run first, then the result is checked for workflow access.
|
|
78
|
+
*/
|
|
79
|
+
const POST_CHECK_TOOLS = new Set([
|
|
71
80
|
'show_activity_by_id',
|
|
72
81
|
'fetch_discussion_messages',
|
|
73
82
|
'get_activity_from_discussion',
|
|
74
83
|
]);
|
|
84
|
+
/** Minimal tool set for SIMPLE-routed messages (greetings, quick lookups). */
|
|
75
85
|
const MODEL_HAIKU = 'claude-haiku-4-5-20251001';
|
|
76
86
|
const MODEL_SONNET = 'claude-sonnet-4-5-20250929';
|
|
87
|
+
const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
88
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB — skip vision for larger images
|
|
77
89
|
const MAX_TOOL_ITERATIONS = 10;
|
|
78
90
|
const RATE_LIMIT_WINDOW = 60_000; // 60 seconds
|
|
79
91
|
const RATE_LIMIT_PER_DISCUSSION = 30; // max signals per discussion per window
|
|
@@ -91,6 +103,9 @@ class Bot {
|
|
|
91
103
|
workspaceOverview = '';
|
|
92
104
|
userId = '';
|
|
93
105
|
_workspaceId;
|
|
106
|
+
// Live-updatable config (can be hot-swapped without restart)
|
|
107
|
+
_systemPrompt;
|
|
108
|
+
_responseMode = 'always';
|
|
94
109
|
// Services
|
|
95
110
|
conversationManager = null;
|
|
96
111
|
messageClassifier = null;
|
|
@@ -98,6 +113,9 @@ class Bot {
|
|
|
98
113
|
typingIndicator = null;
|
|
99
114
|
tokenBilling = null;
|
|
100
115
|
sessionLogger = null;
|
|
116
|
+
hailerApi = null;
|
|
117
|
+
/** Cache of insightId → Set<workflowId> for permission checks on ID-based insight tools */
|
|
118
|
+
insightWorkflowCache = new Map();
|
|
101
119
|
opLogger = new operation_logger_1.OperationLogger();
|
|
102
120
|
// State
|
|
103
121
|
discussionStates = new Map();
|
|
@@ -119,25 +137,62 @@ class Bot {
|
|
|
119
137
|
permissionIndex = new Map();
|
|
120
138
|
adminUserIds = new Set();
|
|
121
139
|
ownerUserIds = new Set();
|
|
140
|
+
workspaceMemberIds = new Set();
|
|
141
|
+
membersLoaded = false;
|
|
122
142
|
// Config
|
|
123
143
|
config;
|
|
144
|
+
botManager; // BotManager ref for cross-bot awareness
|
|
124
145
|
constructor(config) {
|
|
125
146
|
this.config = {
|
|
126
|
-
|
|
147
|
+
email: config.email,
|
|
148
|
+
password: config.password,
|
|
149
|
+
apiHost: config.apiHost,
|
|
150
|
+
anthropicApiKey: config.anthropicApiKey,
|
|
127
151
|
model: config.model || 'claude-haiku-4-5-20251001',
|
|
152
|
+
toolRegistry: config.toolRegistry,
|
|
153
|
+
workspaceId: config.workspaceId,
|
|
154
|
+
systemPrompt: config.systemPrompt,
|
|
155
|
+
accessLevel: config.accessLevel || 'all',
|
|
156
|
+
allowedWorkflows: config.allowedWorkflows || [],
|
|
128
157
|
};
|
|
158
|
+
this.botManager = config.botManager || null;
|
|
159
|
+
this._systemPrompt = config.systemPrompt;
|
|
160
|
+
this._responseMode = config.responseMode || 'always';
|
|
129
161
|
this.logger = (0, logger_1.createLogger)({ component: 'Bot' });
|
|
130
162
|
this.clientManager = new hailer_clients_1.HailerClientManager(config.apiHost, config.email, config.password);
|
|
131
163
|
this.toolExecutor = new tool_executor_1.ToolExecutor(config.toolRegistry);
|
|
132
164
|
}
|
|
165
|
+
get email() { return this.config.email; }
|
|
166
|
+
get password() { return this.config.password; }
|
|
167
|
+
get accessLevel() { return this.config.accessLevel; }
|
|
168
|
+
/**
|
|
169
|
+
* Hot-update the system prompt without restarting the bot.
|
|
170
|
+
* Takes effect on the next message processed.
|
|
171
|
+
*/
|
|
172
|
+
updateSystemPrompt(prompt) {
|
|
173
|
+
this._systemPrompt = prompt;
|
|
174
|
+
this.logger.info('System prompt updated live', { hasPrompt: !!prompt });
|
|
175
|
+
}
|
|
176
|
+
updateResponseMode(mode) {
|
|
177
|
+
this._responseMode = mode || 'always';
|
|
178
|
+
this.logger.info('Response mode updated live', { responseMode: this._responseMode });
|
|
179
|
+
}
|
|
133
180
|
get connected() {
|
|
134
181
|
return this._connected;
|
|
135
182
|
}
|
|
136
183
|
get workspaceId() {
|
|
137
184
|
return this._workspaceId;
|
|
138
185
|
}
|
|
186
|
+
/** Exposed for BotManager cross-bot awareness */
|
|
187
|
+
get botUserId() {
|
|
188
|
+
return this.userId;
|
|
189
|
+
}
|
|
139
190
|
// ===== LIFECYCLE =====
|
|
140
191
|
async start() {
|
|
192
|
+
if (this._connected) {
|
|
193
|
+
this.logger.warn('Bot.start() called while already connected, ignoring');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
141
196
|
this.logger.debug('Starting bot', { email: this.config.email });
|
|
142
197
|
// 1. Connect to Hailer
|
|
143
198
|
this.client = await this.clientManager.connect();
|
|
@@ -152,9 +207,9 @@ class Bot {
|
|
|
152
207
|
this.init = await this.client.socket.request('v2.core.init', [
|
|
153
208
|
['processes', 'users', 'network', 'networks', 'teams', 'groups'],
|
|
154
209
|
]);
|
|
210
|
+
(0, types_1.normalizeInitProcesses)(this.init);
|
|
155
211
|
this._workspaceId = this.init.network?._id || this.config.workspaceId;
|
|
156
|
-
|
|
157
|
-
// 4. Build workspace cache and overview
|
|
212
|
+
// 4. Build workspace cache and overview (before permission index so names resolve)
|
|
158
213
|
this.workspaceCache = (0, workspace_cache_1.createWorkspaceCache)(this.init, {
|
|
159
214
|
excludeTranslations: true,
|
|
160
215
|
excludeSystemMessages: true,
|
|
@@ -162,6 +217,8 @@ class Bot {
|
|
|
162
217
|
compactUserData: true,
|
|
163
218
|
includeWorkspaceNamesInTools: false,
|
|
164
219
|
});
|
|
220
|
+
this.backfillWorkspaceCacheMembers();
|
|
221
|
+
this.buildPermissionIndex();
|
|
165
222
|
this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
|
|
166
223
|
// 5. Create Anthropic client
|
|
167
224
|
this.anthropic = new sdk_1.default({ apiKey: this.config.anthropicApiKey });
|
|
@@ -180,6 +237,7 @@ class Bot {
|
|
|
180
237
|
this.typingIndicator = new services_1.TypingIndicatorService(botConnection, this.logger);
|
|
181
238
|
// Token billing
|
|
182
239
|
const hailerApi = new hailer_api_client_1.HailerApiClient(this.client);
|
|
240
|
+
this.hailerApi = hailerApi;
|
|
183
241
|
this.tokenBilling = new services_1.TokenBillingService(this.logger, hailerApi);
|
|
184
242
|
// Message formatter - wrap ToolExecutor as MCP-style callback
|
|
185
243
|
const toolCallback = async (name, args) => {
|
|
@@ -190,6 +248,7 @@ class Bot {
|
|
|
190
248
|
this.sessionLogger = new services_1.SessionLoggerService(null, this.logger, toolCallback, () => this.getDefaultTeamId());
|
|
191
249
|
this.sessionLogger.setAnthropicClient(this.anthropic);
|
|
192
250
|
// 8. Build UserContext for tool execution
|
|
251
|
+
const currentWorkspaceId = this.init.network?._id || '';
|
|
193
252
|
this.userContext = {
|
|
194
253
|
client: this.client,
|
|
195
254
|
hailer: hailerApi,
|
|
@@ -199,6 +258,9 @@ class Bot {
|
|
|
199
258
|
createdAt: Date.now(),
|
|
200
259
|
email: this.config.email,
|
|
201
260
|
password: this.config.password,
|
|
261
|
+
workspaceRoles: { [currentWorkspaceId]: 'admin' },
|
|
262
|
+
currentWorkspaceId,
|
|
263
|
+
allowedGroups: ['read', 'write', 'bot_internal'],
|
|
202
264
|
};
|
|
203
265
|
// 9. Subscribe to messenger.new signals
|
|
204
266
|
this.signalHandler = (eventData) => {
|
|
@@ -218,6 +280,9 @@ class Bot {
|
|
|
218
280
|
this.clientManager.onSignal('process.updated', this.processUpdatedHandler);
|
|
219
281
|
this.clientManager.onSignal('workspace.updated', this.workspaceUpdatedHandler);
|
|
220
282
|
this.clientManager.onSignal('cache.invalidate', this.cacheInvalidateHandler);
|
|
283
|
+
// TODO: Subscribe to notification.reload for reminder handling
|
|
284
|
+
// Blocked: need to confirm reminderWorker is running and how the signal
|
|
285
|
+
// reaches the bot's socket (room join + event channel). See memory/project_reminder_bot_integration.md
|
|
221
286
|
this._connected = true;
|
|
222
287
|
this.logger.debug('Bot started', {
|
|
223
288
|
userId: this.userId,
|
|
@@ -265,16 +330,35 @@ class Bot {
|
|
|
265
330
|
this.client = null;
|
|
266
331
|
this.logger.debug('Bot stopped');
|
|
267
332
|
}
|
|
333
|
+
// ===== RESPONSE MODE =====
|
|
334
|
+
checkTriggerCondition(message) {
|
|
335
|
+
// Private DMs always pass — 1:1 conversations are always intentional
|
|
336
|
+
if (message.isPrivateDiscussion)
|
|
337
|
+
return true;
|
|
338
|
+
switch (this._responseMode) {
|
|
339
|
+
case 'mention_only': return message.isMention;
|
|
340
|
+
case 'reply_only': return message.isReplyToBot;
|
|
341
|
+
case 'mention_or_reply': return message.isMention || message.isReplyToBot;
|
|
342
|
+
case 'always':
|
|
343
|
+
default: return true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
268
346
|
// ===== SIGNAL HANDLING =====
|
|
269
347
|
async handleSignal(signal) {
|
|
270
348
|
if (!this._connected || !this.messageClassifier)
|
|
271
349
|
return;
|
|
272
350
|
try {
|
|
273
351
|
const rawMsgId = signal.data.msg_id;
|
|
274
|
-
|
|
275
|
-
|
|
352
|
+
const discussionId = signal.data.discussion;
|
|
353
|
+
const dedupKey = rawMsgId || (discussionId ? `${discussionId}:${signal.data.uid}:${signal.data.created}` : null);
|
|
354
|
+
if (dedupKey) {
|
|
355
|
+
if (this.processedMessageIds.has(dedupKey))
|
|
276
356
|
return;
|
|
277
|
-
this.processedMessageIds.add(
|
|
357
|
+
this.processedMessageIds.add(dedupKey);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
this.logger.warn('messenger.new signal has no dedup key, skipping');
|
|
361
|
+
return;
|
|
278
362
|
}
|
|
279
363
|
const message = await this.messageClassifier.extractIncomingMessage(signal);
|
|
280
364
|
if (!message) {
|
|
@@ -282,9 +366,14 @@ class Bot {
|
|
|
282
366
|
this.processedMessageIds.delete(rawMsgId);
|
|
283
367
|
return;
|
|
284
368
|
}
|
|
285
|
-
// Self-message guard: prevent bot feedback loops
|
|
369
|
+
// Self-message guard: prevent bot feedback loops (checks ALL bots in this workspace)
|
|
286
370
|
if (message.senderId === this.userId)
|
|
287
371
|
return;
|
|
372
|
+
if (this.botManager && this._workspaceId) {
|
|
373
|
+
const botUserIds = this.botManager.getBotUserIdsForWorkspace(this._workspaceId);
|
|
374
|
+
if (botUserIds.has(message.senderId))
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
288
377
|
// Rate limiting
|
|
289
378
|
const now = Date.now();
|
|
290
379
|
const cutoff = now - RATE_LIMIT_WINDOW;
|
|
@@ -320,37 +409,71 @@ class Bot {
|
|
|
320
409
|
return;
|
|
321
410
|
}
|
|
322
411
|
this.globalSignalTimestamps.push(now);
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
412
|
+
// Layer 1: Workspace membership check (fail-closed)
|
|
413
|
+
// Only process messages from verified workspace members.
|
|
414
|
+
// If member data hasn't loaded yet, reject — don't fail open.
|
|
415
|
+
if (!this.membersLoaded || !this.workspaceMemberIds.has(message.senderId)) {
|
|
416
|
+
this.logger.debug('Ignoring message - sender not a verified workspace member', {
|
|
326
417
|
senderId: message.senderId,
|
|
327
418
|
senderName: message.senderName,
|
|
419
|
+
membersLoaded: this.membersLoaded,
|
|
328
420
|
});
|
|
329
421
|
if (rawMsgId)
|
|
330
422
|
this.processedMessageIds.delete(rawMsgId);
|
|
331
423
|
return;
|
|
332
424
|
}
|
|
425
|
+
// Layer 2: AI Hub access gate — who can talk to this bot
|
|
426
|
+
if (this.config.accessLevel !== 'all') {
|
|
427
|
+
const isAdmin = this.adminUserIds.has(message.senderId);
|
|
428
|
+
const isOwner = this.ownerUserIds.has(message.senderId);
|
|
429
|
+
const allowed = this.config.accessLevel === 'owner' ? isOwner : (isAdmin || isOwner);
|
|
430
|
+
if (!allowed) {
|
|
431
|
+
this.logger.debug('Ignoring message - sender does not meet access level', {
|
|
432
|
+
senderId: message.senderId,
|
|
433
|
+
senderName: message.senderName,
|
|
434
|
+
requiredLevel: this.config.accessLevel,
|
|
435
|
+
isAdmin,
|
|
436
|
+
isOwner,
|
|
437
|
+
});
|
|
438
|
+
if (rawMsgId)
|
|
439
|
+
this.processedMessageIds.delete(rawMsgId);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
333
443
|
// Prune dedup set
|
|
334
444
|
if (this.processedMessageIds.size > 500) {
|
|
335
445
|
const ids = Array.from(this.processedMessageIds);
|
|
336
446
|
this.processedMessageIds = new Set(ids.slice(-250));
|
|
337
447
|
}
|
|
338
448
|
const state = this.getOrCreateDiscussionState(discId);
|
|
339
|
-
// Always buffer the message
|
|
449
|
+
// Always buffer the message (provides context when bot IS triggered later)
|
|
340
450
|
state.contextBuffer.push(message);
|
|
451
|
+
// Check response mode trigger condition BEFORE engagement logic
|
|
452
|
+
// DMs always pass — 1:1 conversations are always intentional
|
|
453
|
+
const triggerMet = this.checkTriggerCondition(message);
|
|
454
|
+
if (!triggerMet) {
|
|
455
|
+
return; // Signal dropped — no LLM call, no token cost
|
|
456
|
+
}
|
|
341
457
|
// Determine if this should trigger processing
|
|
342
|
-
|
|
458
|
+
// Private discussions (DMs) are always explicit triggers
|
|
459
|
+
const isExplicitTrigger = message.isMention || message.isReplyToBot || message.isPrivateDiscussion;
|
|
343
460
|
if (state.state === 'idle') {
|
|
344
|
-
|
|
461
|
+
// In 'always' mode, every message triggers engagement (original behavior)
|
|
462
|
+
if (isExplicitTrigger || this._responseMode === 'always') {
|
|
345
463
|
state.state = 'engaged';
|
|
346
464
|
state.consecutiveNonResponses = 0;
|
|
347
|
-
this.opLogger.engage(discId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : 'dm');
|
|
465
|
+
this.opLogger.engage(discId, message.isMention ? 'mention' : message.isReplyToBot ? 'reply' : message.isDirectMessage ? 'dm' : 'always');
|
|
348
466
|
}
|
|
349
467
|
else {
|
|
350
468
|
// Not addressed while idle — message sits in buffer as context only
|
|
351
469
|
return;
|
|
352
470
|
}
|
|
353
471
|
}
|
|
472
|
+
// In non-'always' modes, each message must independently meet the trigger condition
|
|
473
|
+
// This prevents the bot from staying engaged and responding to everything after one mention
|
|
474
|
+
if (this._responseMode !== 'always' && !isExplicitTrigger) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
354
477
|
// State is 'engaged' — trigger processing if not already running
|
|
355
478
|
if (!this.processingDiscussions.has(discId)) {
|
|
356
479
|
this.processDiscussion(discId).catch(err => {
|
|
@@ -389,7 +512,17 @@ class Bot {
|
|
|
389
512
|
if (messages.length > 1) {
|
|
390
513
|
merged = `<context type="coalesced" count="${messages.length}">These ${messages.length} messages arrived while you were processing. Respond to the most recent/relevant.</context>\n\n${merged}`;
|
|
391
514
|
}
|
|
392
|
-
|
|
515
|
+
// Resolve image attachments for vision
|
|
516
|
+
const imageBlocks = await this.resolveImageAttachments(messages);
|
|
517
|
+
if (imageBlocks.length > 0) {
|
|
518
|
+
conversation.push({
|
|
519
|
+
role: 'user',
|
|
520
|
+
content: [{ type: 'text', text: merged }, ...imageBlocks],
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
conversation.push({ role: 'user', content: merged });
|
|
525
|
+
}
|
|
393
526
|
// Use last message for routing/billing context
|
|
394
527
|
const primaryMessage = messages[messages.length - 1];
|
|
395
528
|
state.lastProgressTime = 0;
|
|
@@ -451,7 +584,6 @@ class Bot {
|
|
|
451
584
|
const defaultRoute = {
|
|
452
585
|
model: MODEL_HAIKU,
|
|
453
586
|
maxTokens: 2000,
|
|
454
|
-
classification: 'SIMPLE',
|
|
455
587
|
};
|
|
456
588
|
try {
|
|
457
589
|
const wsName = this.init?.network?.name || 'Workspace';
|
|
@@ -493,12 +625,11 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
493
625
|
const route = {
|
|
494
626
|
model: MODEL_SONNET,
|
|
495
627
|
maxTokens: 16384,
|
|
496
|
-
classification: 'COMPLEX',
|
|
497
628
|
};
|
|
498
|
-
this.opLogger.route(message.discussionId,
|
|
629
|
+
this.opLogger.route(message.discussionId, 'COMPLEX', route.model, message.content);
|
|
499
630
|
return route;
|
|
500
631
|
}
|
|
501
|
-
this.opLogger.route(message.discussionId,
|
|
632
|
+
this.opLogger.route(message.discussionId, 'SIMPLE', defaultRoute.model, message.content);
|
|
502
633
|
return defaultRoute;
|
|
503
634
|
}
|
|
504
635
|
catch (error) {
|
|
@@ -564,7 +695,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
564
695
|
}
|
|
565
696
|
// ===== MESSAGE PROCESSING =====
|
|
566
697
|
async processMessage(message, signal) {
|
|
567
|
-
this.typingIndicator?.start(message.discussionId);
|
|
698
|
+
this.typingIndicator?.start(message.discussionId, 'Reading');
|
|
568
699
|
// Check token balance
|
|
569
700
|
const billingWorkspaceId = this._workspaceId || message.workspaceId;
|
|
570
701
|
if (this.tokenBilling && billingWorkspaceId) {
|
|
@@ -604,6 +735,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
604
735
|
const cachedConversation = this.conversationManager.prepareForCaching(conversation);
|
|
605
736
|
let response;
|
|
606
737
|
let llmStart = Date.now();
|
|
738
|
+
this.typingIndicator?.updateStatus(message.discussionId, i === 0 ? 'Thinking' : 'Processing');
|
|
607
739
|
try {
|
|
608
740
|
response = await this.anthropic.messages.create({
|
|
609
741
|
model: route.model,
|
|
@@ -626,7 +758,8 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
626
758
|
this.logger.warn('Sonnet API call failed, falling back to Haiku', {
|
|
627
759
|
error: error instanceof Error ? error.message : String(error),
|
|
628
760
|
});
|
|
629
|
-
route = { model: MODEL_HAIKU, maxTokens: 2000
|
|
761
|
+
route = { model: MODEL_HAIKU, maxTokens: 2000 };
|
|
762
|
+
this.typingIndicator?.updateStatus(message.discussionId, 'Switching models');
|
|
630
763
|
llmStart = Date.now();
|
|
631
764
|
response = await this.anthropic.messages.create({
|
|
632
765
|
model: MODEL_HAIKU,
|
|
@@ -682,10 +815,11 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
682
815
|
// Check for tool calls
|
|
683
816
|
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
684
817
|
if (toolUseBlocks.length > 0) {
|
|
818
|
+
this.typingIndicator?.updateStatus(message.discussionId, this.getToolStatus(toolUseBlocks));
|
|
685
819
|
const toolResults = await this.executeTools(toolUseBlocks, message, signal);
|
|
686
820
|
conversation.push({ role: 'user', content: toolResults });
|
|
687
821
|
// Inject any messages that arrived during tool execution
|
|
688
|
-
this.injectPendingContext(message.discussionId, conversation);
|
|
822
|
+
await this.injectPendingContext(message.discussionId, conversation);
|
|
689
823
|
// Auto-escalate: if Haiku is failing tool calls, switch to Sonnet
|
|
690
824
|
if (route.model === MODEL_HAIKU) {
|
|
691
825
|
const failedCount = toolResults
|
|
@@ -696,7 +830,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
696
830
|
failedTools: failedCount,
|
|
697
831
|
iteration: i,
|
|
698
832
|
});
|
|
699
|
-
route = { model: MODEL_SONNET, maxTokens: 16384
|
|
833
|
+
route = { model: MODEL_SONNET, maxTokens: 16384 };
|
|
700
834
|
await this.sendMessage(message.discussionId, `Switching to a more capable model to handle this.`);
|
|
701
835
|
}
|
|
702
836
|
}
|
|
@@ -730,6 +864,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
730
864
|
break;
|
|
731
865
|
}
|
|
732
866
|
// Post the response
|
|
867
|
+
this.typingIndicator?.updateStatus(message.discussionId, 'Writing response');
|
|
733
868
|
this.typingIndicator?.stop(message.discussionId);
|
|
734
869
|
const formatted = await this.formatOutgoingMessage(responseText);
|
|
735
870
|
const links = this.messageFormatter.extractTagLinks(formatted);
|
|
@@ -746,17 +881,36 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
746
881
|
const WRITE_TOOLS = new Set(['create_activity', 'update_activity']);
|
|
747
882
|
for (const block of toolUseBlocks) {
|
|
748
883
|
const toolStart = Date.now();
|
|
749
|
-
//
|
|
884
|
+
// ── Admin-only gate ──
|
|
885
|
+
// Some tools require workspace admin/owner regardless of workflow access.
|
|
886
|
+
if (ADMIN_ONLY_TOOLS.has(block.name)) {
|
|
887
|
+
if (!this.adminUserIds.has(message.senderId) && !this.ownerUserIds.has(message.senderId)) {
|
|
888
|
+
this.opLogger.permDenied(discussionId, block.name, 'n/a', 'requires-admin');
|
|
889
|
+
results.push({
|
|
890
|
+
type: 'tool_result',
|
|
891
|
+
tool_use_id: block.id,
|
|
892
|
+
content: `Permission denied: Only workspace admins can modify workflow permissions.`,
|
|
893
|
+
is_error: true,
|
|
894
|
+
});
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// ── Unified permission gate ──
|
|
899
|
+
// Extract all workflow IDs from tool args (any shape) and check access.
|
|
900
|
+
// POST_CHECK_TOOLS are checked after execution (workflow ID is in the result, not the args).
|
|
750
901
|
const args = block.input;
|
|
751
|
-
const
|
|
752
|
-
if (
|
|
753
|
-
const
|
|
754
|
-
|
|
755
|
-
|
|
902
|
+
const permCtx = this.getPermissionContext();
|
|
903
|
+
if (!POST_CHECK_TOOLS.has(block.name)) {
|
|
904
|
+
const workflowIds = (0, permission_guard_1.extractWorkflowIdsFromArgs)(args, this.insightWorkflowCache);
|
|
905
|
+
const denied = (0, permission_guard_1.checkWorkflowAccess)(workflowIds, message.senderId, permCtx);
|
|
906
|
+
if (denied) {
|
|
907
|
+
this.opLogger.permDenied(discussionId, block.name, denied.workflowId, denied.reason);
|
|
756
908
|
results.push({
|
|
757
909
|
type: 'tool_result',
|
|
758
910
|
tool_use_id: block.id,
|
|
759
|
-
content:
|
|
911
|
+
content: denied.reason === 'bot-scope'
|
|
912
|
+
? `Permission denied: This bot does not have access to workflow ${denied.workflowId}.`
|
|
913
|
+
: `Permission denied: You do not have access to workflow ${denied.workflowId}.`,
|
|
760
914
|
is_error: true,
|
|
761
915
|
});
|
|
762
916
|
continue;
|
|
@@ -784,40 +938,32 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
784
938
|
const text = result?.content?.[0]?.text ?? JSON.stringify(result);
|
|
785
939
|
let contentStr = typeof text === 'string' ? text : JSON.stringify(text);
|
|
786
940
|
const toolDuration = (Date.now() - toolStart) / 1000;
|
|
787
|
-
// Post-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
return true;
|
|
797
|
-
const allowed = this.permissionIndex.get(wfId);
|
|
798
|
-
return !allowed || allowed.has(message.senderId);
|
|
799
|
-
});
|
|
800
|
-
if (parsed.workflows.length < before) {
|
|
801
|
-
this.opLogger.permFiltered(discussionId, block.name, before, parsed.workflows.length);
|
|
802
|
-
}
|
|
803
|
-
contentStr = JSON.stringify(parsed);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
catch {
|
|
807
|
-
// Not JSON or unexpected format, pass through unfiltered
|
|
941
|
+
// ── Post-execution permission checks ──
|
|
942
|
+
// Cache insight → workflow mapping after successful create_insight
|
|
943
|
+
if (block.name === 'create_insight' && !contentStr.includes('❌')) {
|
|
944
|
+
const idMatch = contentStr.match(/\*\*Insight ID:\*\*\s+`([a-f0-9]{24})`/);
|
|
945
|
+
if (idMatch) {
|
|
946
|
+
const sources = args.sources || [];
|
|
947
|
+
const wfIds = new Set(sources.map((s) => s.workflowId).filter(Boolean));
|
|
948
|
+
if (wfIds.size > 0)
|
|
949
|
+
this.insightWorkflowCache.set(idMatch[1], wfIds);
|
|
808
950
|
}
|
|
809
951
|
}
|
|
810
|
-
// Post-
|
|
811
|
-
|
|
952
|
+
// Post-filter list results: remove items the user can't access
|
|
953
|
+
contentStr = await this.postFilterListResults(block.name, contentStr, message.senderId, discussionId);
|
|
954
|
+
// Post-execution permission check for tools where workflowId is in the result
|
|
955
|
+
if (POST_CHECK_TOOLS.has(block.name)) {
|
|
812
956
|
const extractedWorkflowId = this.extractWorkflowIdFromResult(block.name, contentStr);
|
|
813
957
|
if (extractedWorkflowId) {
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
816
|
-
this.opLogger.permDenied(discussionId, block.name,
|
|
958
|
+
const denied = (0, permission_guard_1.checkWorkflowAccess)([extractedWorkflowId], message.senderId, permCtx);
|
|
959
|
+
if (denied) {
|
|
960
|
+
this.opLogger.permDenied(discussionId, block.name, denied.workflowId, denied.reason);
|
|
817
961
|
results.push({
|
|
818
962
|
type: 'tool_result',
|
|
819
963
|
tool_use_id: block.id,
|
|
820
|
-
content:
|
|
964
|
+
content: denied.reason === 'bot-scope'
|
|
965
|
+
? `Permission denied: This bot does not have access to that resource.`
|
|
966
|
+
: `Permission denied: You do not have access to that resource.`,
|
|
821
967
|
is_error: true,
|
|
822
968
|
});
|
|
823
969
|
continue;
|
|
@@ -866,7 +1012,7 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
866
1012
|
* between LLM iterations. Merges into the existing user message to preserve
|
|
867
1013
|
* Anthropic's alternation rule (user/assistant/user/assistant).
|
|
868
1014
|
*/
|
|
869
|
-
injectPendingContext(discussionId, conversation) {
|
|
1015
|
+
async injectPendingContext(discussionId, conversation) {
|
|
870
1016
|
const state = this.discussionStates.get(discussionId);
|
|
871
1017
|
if (!state || state.contextBuffer.length === 0)
|
|
872
1018
|
return;
|
|
@@ -878,6 +1024,10 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
878
1024
|
type: 'text',
|
|
879
1025
|
text: `<context type="messages-during-processing">\n${contextText}\n</context>`,
|
|
880
1026
|
});
|
|
1027
|
+
const imageBlocks = await this.resolveImageAttachments(pending);
|
|
1028
|
+
for (const block of imageBlocks) {
|
|
1029
|
+
lastMsg.content.push(block);
|
|
1030
|
+
}
|
|
881
1031
|
this.opLogger.contextInject(discussionId, pending.length);
|
|
882
1032
|
}
|
|
883
1033
|
}
|
|
@@ -907,11 +1057,59 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
907
1057
|
const nameAttr = discName ? ` discussion_name="${discName}"` : '';
|
|
908
1058
|
let fileInfo = '';
|
|
909
1059
|
if (message.fileAttachments?.length) {
|
|
910
|
-
const
|
|
911
|
-
|
|
1060
|
+
const imageFiles = message.fileAttachments.filter(f => f.mime && IMAGE_MIME_TYPES.has(f.mime) && (!f.size || f.size <= MAX_IMAGE_SIZE));
|
|
1061
|
+
const nonImageFiles = message.fileAttachments.filter(f => !f.mime || !IMAGE_MIME_TYPES.has(f.mime) || (f.size && f.size > MAX_IMAGE_SIZE));
|
|
1062
|
+
const parts = [];
|
|
1063
|
+
for (const img of imageFiles) {
|
|
1064
|
+
parts.push(`[Image attached: ${img.filename} — included below for vision]`);
|
|
1065
|
+
}
|
|
1066
|
+
if (nonImageFiles.length) {
|
|
1067
|
+
const ids = nonImageFiles.map(f => f.fileId).join(', ');
|
|
1068
|
+
parts.push(`[File attached: ${nonImageFiles.length} file(s) - IDs: ${ids}]\nUse download_file tool with fileId to read file contents.`);
|
|
1069
|
+
}
|
|
1070
|
+
if (parts.length)
|
|
1071
|
+
fileInfo = '\n' + parts.join('\n');
|
|
912
1072
|
}
|
|
913
1073
|
return `<incoming discussion="${message.discussionId}"${nameAttr}${activityAttr} from="${message.senderName}" user_id="${message.senderId}" timestamp="${new Date(message.timestamp).toISOString()}">\n${message.content}${fileInfo}\n</incoming>`;
|
|
914
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Download image attachments and return Anthropic image content blocks.
|
|
1077
|
+
* Falls back silently on errors — the text note still tells the LLM about the file.
|
|
1078
|
+
*/
|
|
1079
|
+
async resolveImageAttachments(messages) {
|
|
1080
|
+
const blocks = [];
|
|
1081
|
+
if (!this.hailerApi)
|
|
1082
|
+
return blocks;
|
|
1083
|
+
const imageFiles = [];
|
|
1084
|
+
for (const msg of messages) {
|
|
1085
|
+
if (!msg.fileAttachments?.length)
|
|
1086
|
+
continue;
|
|
1087
|
+
for (const f of msg.fileAttachments) {
|
|
1088
|
+
if (f.mime && IMAGE_MIME_TYPES.has(f.mime) && (!f.size || f.size <= MAX_IMAGE_SIZE)) {
|
|
1089
|
+
imageFiles.push({ fileId: f.fileId, filename: f.filename, mime: f.mime });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
for (const img of imageFiles) {
|
|
1094
|
+
try {
|
|
1095
|
+
const result = await this.hailerApi.downloadFile(img.fileId);
|
|
1096
|
+
if (result.encoding === 'base64') {
|
|
1097
|
+
blocks.push({
|
|
1098
|
+
type: 'image',
|
|
1099
|
+
source: {
|
|
1100
|
+
type: 'base64',
|
|
1101
|
+
media_type: img.mime,
|
|
1102
|
+
data: result.content,
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
catch (err) {
|
|
1108
|
+
this.logger.warn('Failed to download image for vision', { fileId: img.fileId, filename: img.filename, error: err });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return blocks;
|
|
1112
|
+
}
|
|
915
1113
|
async formatOutgoingMessage(text) {
|
|
916
1114
|
let formatted = await this.messageFormatter.resolveUserTags(text);
|
|
917
1115
|
formatted = await this.messageFormatter.resolveActivityTags(formatted);
|
|
@@ -987,46 +1185,123 @@ Reply with exactly one word: SIMPLE or COMPLEX`,
|
|
|
987
1185
|
buildSystemPrompt() {
|
|
988
1186
|
const wsName = this.init?.network?.name || 'Workspace';
|
|
989
1187
|
const botName = this.getBotDisplayName();
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
<bot-identity>
|
|
1188
|
+
const identity = `<bot-identity>
|
|
993
1189
|
Your user ID: ${this.userId}
|
|
994
1190
|
Your display name: ${botName}
|
|
995
|
-
|
|
1191
|
+
Workspace: ${wsName}
|
|
1192
|
+
</bot-identity>`;
|
|
1193
|
+
// Platform rules — always applied regardless of custom prompt.
|
|
1194
|
+
// These are operational requirements that keep the bot functional.
|
|
1195
|
+
const platformRules = `<platform-rules>
|
|
1196
|
+
- Use tools to answer questions — don't guess or make up data.
|
|
1197
|
+
- When a tool call fails, tell the user what failed and why. Never silently try alternatives.
|
|
1198
|
+
- For bulk operations, batch into groups of 25-50 per tool call.
|
|
1199
|
+
- NEVER output raw URLs like https://app.hailer.com/...
|
|
1200
|
+
|
|
1201
|
+
HAILERTAG LINKING (mandatory):
|
|
1202
|
+
- When referencing activities, discussions, or users, ALWAYS use: [hailerTag|Display Name](objectId)
|
|
1203
|
+
- Examples:
|
|
1204
|
+
[hailerTag|AC Milan deal](691ffe654217e9e8434e578a)
|
|
1205
|
+
[hailerTag|John Smith](691ffe654217e9e8434e5123)
|
|
1206
|
+
- Tool responses include both names and IDs — use them directly in this format.
|
|
1207
|
+
- NEVER use hailerTag for workflows, phases, teams, or groups — these don't support linking. Just use their plain text names.
|
|
1208
|
+
- NEVER output bare 24-character hex IDs to the user.
|
|
1209
|
+
</platform-rules>`;
|
|
1210
|
+
const now = new Date();
|
|
1211
|
+
const isoDate = now.toISOString().split('T')[0];
|
|
1212
|
+
const dayOfWeek = now.getUTCDay(); // 0=Sun, 1=Mon, ...
|
|
1213
|
+
const weekNum = Math.ceil(((+now - +new Date(now.getFullYear(), 0, 1)) / 86400000 + new Date(now.getFullYear(), 0, 1).getDay() + 1) / 7);
|
|
1214
|
+
const dateContext = `<current-date>
|
|
1215
|
+
Today: ${now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} (${isoDate}, ISO week ${weekNum}, day ${dayOfWeek})
|
|
1216
|
+
Time: ${now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Helsinki' })} Europe/Helsinki
|
|
1217
|
+
Year: ${now.getFullYear()}
|
|
996
1218
|
|
|
997
|
-
|
|
1219
|
+
DATE HANDLING — CRITICAL:
|
|
1220
|
+
- The current year is ${now.getFullYear()}. When no year is specified, ALWAYS use ${now.getFullYear()} (or ${now.getFullYear() + 1} if the date has already passed this year).
|
|
1221
|
+
- European/Finnish dates use DD.MM.YYYY or DD.M.YYYY format (day first, NOT month first). "18.7.2026" = July 18, 2026. "3.12" = December 3. Never confuse day and month.
|
|
1222
|
+
- Resolve relative dates in any language (Finnish, Swedish, English, etc.) by calculating from today's date.
|
|
1223
|
+
- For activity date fields, always output YYYY-MM-DD format. The system auto-converts to timestamps.
|
|
1224
|
+
- If a date is ambiguous, ask the user to clarify.
|
|
1225
|
+
</current-date>`;
|
|
1226
|
+
const wsContext = `<workspace-context>
|
|
1227
|
+
${this.workspaceOverview}
|
|
1228
|
+
</workspace-context>`;
|
|
1229
|
+
const customPrompt = this._systemPrompt?.trim();
|
|
1230
|
+
if (customPrompt) {
|
|
1231
|
+
// Custom prompt = the bot's mission brief. It defines personality, scope, and behavior.
|
|
1232
|
+
// Platform rules and identity are always appended — they keep the bot functional.
|
|
1233
|
+
const body = customPrompt
|
|
1234
|
+
.replace(/\{wsName\}/g, wsName)
|
|
1235
|
+
.replace(/\{userId\}/g, this.userId)
|
|
1236
|
+
.replace(/\{botName\}/g, botName);
|
|
1237
|
+
return `${body}\n\n${platformRules}\n\n${identity}\n\n${dateContext}\n\n${wsContext}`;
|
|
1238
|
+
}
|
|
1239
|
+
// Default prompt — general-purpose workspace assistant with behavioral defaults.
|
|
1240
|
+
const defaultBehavior = `<behavior>
|
|
998
1241
|
- Be concise. Short answers, no filler.
|
|
999
1242
|
- Never list your capabilities unprompted. If asked what you can do, give a 1-2 sentence summary based on the workspace context below, not a feature dump.
|
|
1000
1243
|
- Never use emojis unless the user does first.
|
|
1001
|
-
-
|
|
1002
|
-
- When a task will require multiple steps (investigating errors, fixing data, bulk lookups), briefly tell the user what you're specifically about to do before starting. Be concrete: "I'll check each insight's SQL and fix any broken column references." NEVER use vague filler like "Let me look into that" — always name the actual action.
|
|
1244
|
+
- When a task will require multiple steps, briefly tell the user what you're about to do. Be concrete — never use vague filler like "Let me look into that".
|
|
1003
1245
|
- When showing data, use clean formatting (tables, bullet points). Don't over-explain.
|
|
1004
1246
|
- If a request is ambiguous, ask a clarifying question instead of guessing.
|
|
1005
|
-
- Always reference actual workspace data (workflow names, field values)
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
- NEVER output raw URLs like https://app.hailer.com/...
|
|
1009
|
-
- When referencing activities, discussions, or users, use hailerTag format: [hailerTag|Display Name](objectId)
|
|
1010
|
-
Examples: [hailerTag|AC Milan](691ffe654217e9e8434e578a) or [hailerTag|John Smith](691ffe654217e9e8434e5123)
|
|
1011
|
-
You always have the name and ID from tool responses - use them directly in this format.
|
|
1012
|
-
</rules>
|
|
1247
|
+
- Always reference actual workspace data (workflow names, field values) — never speak in generic terms.
|
|
1248
|
+
</behavior>`;
|
|
1249
|
+
return `You are a workspace assistant for "${wsName}" on Hailer.
|
|
1013
1250
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1251
|
+
${identity}
|
|
1252
|
+
|
|
1253
|
+
${platformRules}
|
|
1254
|
+
|
|
1255
|
+
${defaultBehavior}
|
|
1256
|
+
|
|
1257
|
+
${dateContext}
|
|
1258
|
+
|
|
1259
|
+
${wsContext}`;
|
|
1017
1260
|
}
|
|
1018
1261
|
// ===== TOOLS =====
|
|
1262
|
+
static TOOL_STATUS_LABELS = {
|
|
1263
|
+
list_activities: 'Searching activities',
|
|
1264
|
+
count_activities: 'Counting activities',
|
|
1265
|
+
show_activity_by_id: 'Reading activity',
|
|
1266
|
+
create_activity: 'Creating activity',
|
|
1267
|
+
update_activity: 'Updating activity',
|
|
1268
|
+
list_workflows: 'Loading workflows',
|
|
1269
|
+
list_workflows_minimal: 'Loading workflows',
|
|
1270
|
+
get_workflow_schema: 'Reading workflow schema',
|
|
1271
|
+
list_workflow_phases: 'Loading phases',
|
|
1272
|
+
preview_insight: 'Running query',
|
|
1273
|
+
get_insight_data: 'Running report',
|
|
1274
|
+
search_workspace_users: 'Searching users',
|
|
1275
|
+
fetch_discussion_messages: 'Reading messages',
|
|
1276
|
+
add_discussion_message: 'Sending message',
|
|
1277
|
+
join_discussion: 'Joining discussion',
|
|
1278
|
+
};
|
|
1279
|
+
getToolStatus(toolUseBlocks) {
|
|
1280
|
+
if (toolUseBlocks.length === 1) {
|
|
1281
|
+
return Bot.TOOL_STATUS_LABELS[toolUseBlocks[0].name]
|
|
1282
|
+
|| toolUseBlocks[0].name.replace(/_/g, ' ');
|
|
1283
|
+
}
|
|
1284
|
+
const firstLabel = Bot.TOOL_STATUS_LABELS[toolUseBlocks[0].name]
|
|
1285
|
+
|| toolUseBlocks[0].name.replace(/_/g, ' ');
|
|
1286
|
+
return `${firstLabel} (+${toolUseBlocks.length - 1} more)`;
|
|
1287
|
+
}
|
|
1019
1288
|
getAnthropicTools() {
|
|
1289
|
+
const allowedTools = BOT_TOOLS;
|
|
1020
1290
|
const defs = this.toolExecutor.getToolDefinitions({
|
|
1021
1291
|
allowedGroups: [tool_registry_1.ToolGroup.READ, tool_registry_1.ToolGroup.WRITE, tool_registry_1.ToolGroup.PLAYGROUND, tool_registry_1.ToolGroup.BOT_INTERNAL],
|
|
1022
1292
|
});
|
|
1023
|
-
|
|
1024
|
-
.filter(d =>
|
|
1293
|
+
const tools = defs
|
|
1294
|
+
.filter(d => allowedTools.has(d.name))
|
|
1025
1295
|
.map(d => ({
|
|
1026
1296
|
name: d.name,
|
|
1027
1297
|
description: d.description,
|
|
1028
1298
|
input_schema: d.inputSchema,
|
|
1029
1299
|
}));
|
|
1300
|
+
// Mark last tool for prompt caching — tools never change per session
|
|
1301
|
+
if (tools.length > 0) {
|
|
1302
|
+
tools[tools.length - 1].cache_control = { type: 'ephemeral' };
|
|
1303
|
+
}
|
|
1304
|
+
return tools;
|
|
1030
1305
|
}
|
|
1031
1306
|
// ===== HAILER MESSAGING =====
|
|
1032
1307
|
async sendMessage(discussionId, text, links) {
|
|
@@ -1091,6 +1366,7 @@ ${this.workspaceOverview}
|
|
|
1091
1366
|
this.logger.debug('Refreshing workspace data', { keys, trigger: isFull ? 'full' : Array.from(scopes).join(',') });
|
|
1092
1367
|
const freshData = await this.client.socket.request('v2.core.init', [keys]);
|
|
1093
1368
|
// Merge fresh data into existing init (partial update)
|
|
1369
|
+
(0, types_1.normalizeInitProcesses)(freshData);
|
|
1094
1370
|
if (!this.init) {
|
|
1095
1371
|
this.init = freshData;
|
|
1096
1372
|
}
|
|
@@ -1117,6 +1393,7 @@ ${this.workspaceOverview}
|
|
|
1117
1393
|
compactUserData: true,
|
|
1118
1394
|
includeWorkspaceNamesInTools: false,
|
|
1119
1395
|
});
|
|
1396
|
+
this.backfillWorkspaceCacheMembers();
|
|
1120
1397
|
// Rebuild workspace overview for system prompt
|
|
1121
1398
|
this.workspaceOverview = (0, workspace_overview_1.generateWorkspaceOverview)(this.init);
|
|
1122
1399
|
// Update UserContext with fresh data
|
|
@@ -1143,95 +1420,112 @@ ${this.workspaceOverview}
|
|
|
1143
1420
|
}
|
|
1144
1421
|
}
|
|
1145
1422
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1423
|
+
/**
|
|
1424
|
+
* Backfill workspaceCache.usersById with entries from network.members
|
|
1425
|
+
* that are missing from init.users (which is only a partial user list).
|
|
1426
|
+
*/
|
|
1427
|
+
backfillWorkspaceCacheMembers() {
|
|
1428
|
+
if (!this.workspaceCache || !this.init)
|
|
1150
1429
|
return;
|
|
1151
|
-
// Extract workspace admins from network members
|
|
1152
|
-
this.adminUserIds.clear();
|
|
1153
1430
|
const network = this.init.network;
|
|
1154
|
-
if (network?.members)
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
const groups = this.init.groups?.[wsId] || {};
|
|
1174
|
-
let networkOpenCount = 0;
|
|
1175
|
-
for (const proc of this.init.processes) {
|
|
1176
|
-
const members = proc.members || [];
|
|
1177
|
-
// If any member is network_ (all workspace members), skip indexing — everyone has access
|
|
1178
|
-
if (members.some((m) => m.id.startsWith('network_'))) {
|
|
1179
|
-
networkOpenCount++;
|
|
1180
|
-
continue;
|
|
1181
|
-
}
|
|
1182
|
-
// If any member references a public team, treat as open — all workspace members have access
|
|
1183
|
-
let hasPublicTeam = false;
|
|
1184
|
-
for (const m of members) {
|
|
1185
|
-
if (!m.id.startsWith('team_'))
|
|
1186
|
-
continue;
|
|
1187
|
-
const team = teams[m.id.slice(5)];
|
|
1188
|
-
if (team) {
|
|
1189
|
-
if (team.public) {
|
|
1190
|
-
hasPublicTeam = true;
|
|
1191
|
-
break;
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
if (hasPublicTeam) {
|
|
1196
|
-
networkOpenCount++;
|
|
1197
|
-
continue;
|
|
1198
|
-
}
|
|
1199
|
-
const userIds = new Set();
|
|
1200
|
-
for (const member of members) {
|
|
1201
|
-
const id = member.id;
|
|
1202
|
-
if (id.startsWith('user_')) {
|
|
1203
|
-
userIds.add(id.slice(5));
|
|
1204
|
-
}
|
|
1205
|
-
else if (id.startsWith('team_')) {
|
|
1206
|
-
const team = teams[id.slice(5)];
|
|
1207
|
-
if (team?.members) {
|
|
1208
|
-
for (const uid of team.members) {
|
|
1209
|
-
userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
else if (id.startsWith('group_')) {
|
|
1214
|
-
const group = groups[id.slice(6)];
|
|
1215
|
-
if (group?.members) {
|
|
1216
|
-
for (const uid of group.members) {
|
|
1217
|
-
userIds.add(typeof uid === 'string' ? uid : uid._id || uid.id || uid.uid);
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1431
|
+
if (!network?.members)
|
|
1432
|
+
return;
|
|
1433
|
+
const members = Array.isArray(network.members) ? network.members : Object.values(network.members);
|
|
1434
|
+
let backfilled = 0;
|
|
1435
|
+
for (const member of members) {
|
|
1436
|
+
if (member.uid && !this.workspaceCache.usersById[member.uid]) {
|
|
1437
|
+
const stub = {
|
|
1438
|
+
id: member.uid,
|
|
1439
|
+
firstname: member.firstname || '',
|
|
1440
|
+
lastname: member.lastname || '',
|
|
1441
|
+
fullName: member.firstname && member.lastname
|
|
1442
|
+
? `${member.firstname} ${member.lastname}`
|
|
1443
|
+
: member.firstname || member.lastname || '',
|
|
1444
|
+
companies: [],
|
|
1445
|
+
lastSeen: 0,
|
|
1446
|
+
};
|
|
1447
|
+
this.workspaceCache.usersById[member.uid] = stub;
|
|
1448
|
+
this.workspaceCache.users.push(stub);
|
|
1449
|
+
backfilled++;
|
|
1221
1450
|
}
|
|
1222
|
-
// Workflow creator always has access
|
|
1223
|
-
if (proc.uid)
|
|
1224
|
-
userIds.add(proc.uid);
|
|
1225
|
-
this.permissionIndex.set(proc._id, userIds);
|
|
1226
1451
|
}
|
|
1452
|
+
this.logger.debug('Workspace cache member backfill', {
|
|
1453
|
+
totalMembers: members.length,
|
|
1454
|
+
inInitUsers: members.length - backfilled,
|
|
1455
|
+
backfilled,
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
buildPermissionIndex() {
|
|
1459
|
+
const wsId = this._workspaceId;
|
|
1460
|
+
if (!wsId)
|
|
1461
|
+
return;
|
|
1462
|
+
const result = (0, permission_guard_1.buildPermissionIndex)(this.init, wsId);
|
|
1463
|
+
this.permissionIndex = result.permissionIndex;
|
|
1464
|
+
this.adminUserIds = result.adminUserIds;
|
|
1465
|
+
this.ownerUserIds = result.ownerUserIds;
|
|
1466
|
+
this.workspaceMemberIds = result.workspaceMemberIds;
|
|
1467
|
+
this.membersLoaded = result.workspaceMemberIds.size > 0;
|
|
1468
|
+
const totalWorkflows = this.init?.processes?.length || 0;
|
|
1227
1469
|
this.logger.debug('Permission index built', {
|
|
1228
1470
|
restricted: this.permissionIndex.size,
|
|
1229
|
-
open:
|
|
1230
|
-
totalWorkflows
|
|
1471
|
+
open: totalWorkflows - this.permissionIndex.size,
|
|
1472
|
+
totalWorkflows,
|
|
1231
1473
|
totalEntries: Array.from(this.permissionIndex.values()).reduce((n, s) => n + s.size, 0),
|
|
1232
1474
|
admins: this.adminUserIds.size,
|
|
1233
1475
|
owners: this.ownerUserIds.size,
|
|
1476
|
+
membersLoaded: this.membersLoaded,
|
|
1477
|
+
workspaceMembers: this.workspaceMemberIds.size,
|
|
1478
|
+
accessLevel: this.config.accessLevel,
|
|
1479
|
+
botWorkflowScope: this.config.allowedWorkflows.length > 0
|
|
1480
|
+
? `${this.config.allowedWorkflows.length} workflows`
|
|
1481
|
+
: 'all',
|
|
1234
1482
|
});
|
|
1483
|
+
// Detailed permission map for verification
|
|
1484
|
+
const processNames = new Map();
|
|
1485
|
+
for (const proc of (this.init?.processes || [])) {
|
|
1486
|
+
processNames.set(proc._id, proc.name || proc._id);
|
|
1487
|
+
}
|
|
1488
|
+
// Resolve user IDs to names where possible
|
|
1489
|
+
const userNames = new Map();
|
|
1490
|
+
if (this.workspaceCache?.usersById) {
|
|
1491
|
+
for (const uid of this.workspaceMemberIds) {
|
|
1492
|
+
const user = this.workspaceCache.usersById[uid];
|
|
1493
|
+
if (user)
|
|
1494
|
+
userNames.set(uid, user.fullName || user.email || uid);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const resolve = (uid) => {
|
|
1498
|
+
const name = userNames.get(uid);
|
|
1499
|
+
return name ? `${name} (${uid})` : uid;
|
|
1500
|
+
};
|
|
1501
|
+
this.logger.debug('Permission map — admins', {
|
|
1502
|
+
admins: [...this.adminUserIds].map(resolve),
|
|
1503
|
+
});
|
|
1504
|
+
this.logger.debug('Permission map — owners', {
|
|
1505
|
+
owners: [...this.ownerUserIds].map(resolve),
|
|
1506
|
+
});
|
|
1507
|
+
this.logger.debug('Permission map — workspace members', {
|
|
1508
|
+
members: [...this.workspaceMemberIds].map(resolve),
|
|
1509
|
+
});
|
|
1510
|
+
// Log each restricted workflow with its allowed users
|
|
1511
|
+
for (const [wfId, userIds] of this.permissionIndex) {
|
|
1512
|
+
this.logger.debug('Permission map — restricted workflow', {
|
|
1513
|
+
workflow: processNames.get(wfId) || wfId,
|
|
1514
|
+
workflowId: wfId,
|
|
1515
|
+
allowedUsers: [...userIds].map(resolve),
|
|
1516
|
+
allowedCount: userIds.size,
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
// Log open workflows
|
|
1520
|
+
const openWorkflows = (this.init?.processes || [])
|
|
1521
|
+
.filter((p) => !this.permissionIndex.has(p._id))
|
|
1522
|
+
.map((p) => p.name || p._id);
|
|
1523
|
+
if (openWorkflows.length > 0) {
|
|
1524
|
+
this.logger.debug('Permission map — open workflows (all members)', {
|
|
1525
|
+
workflows: openWorkflows,
|
|
1526
|
+
count: openWorkflows.length,
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1235
1529
|
}
|
|
1236
1530
|
getOrCreateDiscussionState(discussionId) {
|
|
1237
1531
|
let state = this.discussionStates.get(discussionId);
|
|
@@ -1271,6 +1565,88 @@ ${this.workspaceOverview}
|
|
|
1271
1565
|
return undefined;
|
|
1272
1566
|
return Object.keys(workspaceTeams)[0];
|
|
1273
1567
|
}
|
|
1568
|
+
// ===== UNIFIED PERMISSION SYSTEM =====
|
|
1569
|
+
/** Get the current permission context for checkWorkflowAccess calls */
|
|
1570
|
+
getPermissionContext() {
|
|
1571
|
+
return {
|
|
1572
|
+
allowedWorkflows: this.config.allowedWorkflows,
|
|
1573
|
+
permissionIndex: this.permissionIndex,
|
|
1574
|
+
adminUserIds: this.adminUserIds,
|
|
1575
|
+
ownerUserIds: this.ownerUserIds,
|
|
1576
|
+
insightWorkflowCache: this.insightWorkflowCache,
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Post-filter list-type tool results to remove items the user can't access.
|
|
1581
|
+
* Handles list_workflows, list_workflows_minimal, and list_insights.
|
|
1582
|
+
*/
|
|
1583
|
+
async postFilterListResults(toolName, contentStr, senderId, discussionId) {
|
|
1584
|
+
const ctx = this.getPermissionContext();
|
|
1585
|
+
// Filter workflow lists (JSON response)
|
|
1586
|
+
if (toolName === 'list_workflows' || toolName === 'list_workflows_minimal') {
|
|
1587
|
+
try {
|
|
1588
|
+
const parsed = JSON.parse(contentStr);
|
|
1589
|
+
if (parsed.workflows && Array.isArray(parsed.workflows)) {
|
|
1590
|
+
const before = parsed.workflows.length;
|
|
1591
|
+
parsed.workflows = parsed.workflows.filter((wf) => {
|
|
1592
|
+
const wfId = wf.id || wf._id || wf.workflowId;
|
|
1593
|
+
if (!wfId)
|
|
1594
|
+
return true;
|
|
1595
|
+
return !(0, permission_guard_1.checkWorkflowAccess)([wfId], senderId, ctx);
|
|
1596
|
+
});
|
|
1597
|
+
if (parsed.workflows.length < before) {
|
|
1598
|
+
this.opLogger.permFiltered(discussionId, toolName, before, parsed.workflows.length);
|
|
1599
|
+
}
|
|
1600
|
+
return JSON.stringify(parsed);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
// Not JSON (markdown response) — pass through unmodified
|
|
1605
|
+
return contentStr;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
// Filter insight lists (formatted text response — need raw API data)
|
|
1609
|
+
if (toolName === 'list_insights') {
|
|
1610
|
+
try {
|
|
1611
|
+
const wsId = this._workspaceId || this.init?.network?._id;
|
|
1612
|
+
if (wsId && this.hailerApi) {
|
|
1613
|
+
const rawInsights = await this.hailerApi.request('v3.insight.list', [wsId]);
|
|
1614
|
+
const insights = Array.isArray(rawInsights) ? rawInsights : [];
|
|
1615
|
+
const deniedIds = new Set();
|
|
1616
|
+
for (const insight of insights) {
|
|
1617
|
+
const wfIds = [];
|
|
1618
|
+
for (const src of (insight.sources || [])) {
|
|
1619
|
+
if (src.workflowId)
|
|
1620
|
+
wfIds.push(src.workflowId);
|
|
1621
|
+
}
|
|
1622
|
+
// Always cache the mapping
|
|
1623
|
+
if (wfIds.length > 0) {
|
|
1624
|
+
this.insightWorkflowCache.set(insight._id, new Set(wfIds));
|
|
1625
|
+
}
|
|
1626
|
+
// Check access
|
|
1627
|
+
if ((0, permission_guard_1.checkWorkflowAccess)(wfIds, senderId, ctx)) {
|
|
1628
|
+
deniedIds.add(insight._id);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (deniedIds.size > 0) {
|
|
1632
|
+
let filtered = contentStr;
|
|
1633
|
+
for (const id of deniedIds) {
|
|
1634
|
+
const pattern = new RegExp(`\\d+\\.\\s+\\*\\*[^*]+\\*\\*\\n(?:.*\\n)*?\\s+-\\s+Insight ID:\\s+\`${id}\`(?:.*\\n)*?(?=\\d+\\.\\s+\\*\\*|💡|$)`, 'g');
|
|
1635
|
+
filtered = filtered.replace(pattern, '');
|
|
1636
|
+
}
|
|
1637
|
+
const visibleCount = insights.length - deniedIds.size;
|
|
1638
|
+
filtered = filtered.replace(/\*\*Insights Found\*\*\s+\(\d+ total\)/, `**Insights Found** (${visibleCount} total)`);
|
|
1639
|
+
this.opLogger.permFiltered(discussionId, toolName, insights.length, visibleCount);
|
|
1640
|
+
return filtered;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
catch (err) {
|
|
1645
|
+
this.logger.warn('Failed to filter list_insights by permissions', { error: err });
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return contentStr;
|
|
1649
|
+
}
|
|
1274
1650
|
/**
|
|
1275
1651
|
* Extract workflow ID from tool result for post-execution permission checks.
|
|
1276
1652
|
* Returns the workflow ID if found, or undefined if extraction fails.
|