@teamvibe/poller 0.1.6 → 0.1.8

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.
@@ -0,0 +1,29 @@
1
+ interface BrainConfig {
2
+ brainId: string;
3
+ gitRepoUrl: string;
4
+ branch: string;
5
+ claudePath: string;
6
+ }
7
+ /**
8
+ * Get or clone brain repo. Returns the working directory path.
9
+ */
10
+ export declare function getBrainPath(brain?: BrainConfig): Promise<string>;
11
+ /**
12
+ * Ensure base brain repo is cloned and up to date.
13
+ * Uses same cooldown pattern as channel brains.
14
+ */
15
+ export declare function ensureBaseBrain(): Promise<void>;
16
+ /**
17
+ * Get the path to the base brain directory.
18
+ */
19
+ export declare function getBaseBrainPath(): string;
20
+ /**
21
+ * Commit and push any changes in a brain repo.
22
+ * Silently skips if there are no changes or no remote.
23
+ */
24
+ export declare function pushBrainChanges(brainDir: string, brainId: string): Promise<void>;
25
+ /**
26
+ * Ensure base paths exist
27
+ */
28
+ export declare function ensureDirectories(): Promise<void>;
29
+ export {};
@@ -0,0 +1,130 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { config } from './config.js';
6
+ import { logger } from './logger.js';
7
+ const execAsync = promisify(exec);
8
+ // Cooldown tracker per brain
9
+ const lastUpdateTimes = new Map();
10
+ /**
11
+ * Get or clone brain repo. Returns the working directory path.
12
+ */
13
+ export async function getBrainPath(brain) {
14
+ if (!brain?.gitRepoUrl)
15
+ return config.DEFAULT_BRAIN_PATH;
16
+ const brainDir = join(config.BRAINS_PATH, brain.brainId);
17
+ if (!existsSync(brainDir)) {
18
+ logger.info(`Cloning brain ${brain.brainId} from ${brain.gitRepoUrl} (branch: ${brain.branch})...`);
19
+ await execAsync(`git clone --recurse-submodules ${brain.gitRepoUrl} ${brainDir}`);
20
+ // Ensure the desired branch exists (handles empty repos and missing branches)
21
+ const { stdout: currentBranch } = await execAsync('git branch --show-current', { cwd: brainDir });
22
+ if (currentBranch.trim() !== brain.branch) {
23
+ // Check if the branch exists on remote
24
+ const { stdout: remoteBranches } = await execAsync('git branch -r', { cwd: brainDir });
25
+ if (remoteBranches.includes(`origin/${brain.branch}`)) {
26
+ await execAsync(`git checkout ${brain.branch}`, { cwd: brainDir });
27
+ }
28
+ else {
29
+ await execAsync(`git checkout -b ${brain.branch}`, { cwd: brainDir });
30
+ }
31
+ }
32
+ logger.info(`Brain ${brain.brainId} cloned successfully`);
33
+ }
34
+ else if (config.BRAIN_AUTO_UPDATE) {
35
+ await updateBrain(brainDir, brain.brainId, brain.branch);
36
+ }
37
+ return brainDir;
38
+ }
39
+ /**
40
+ * Update a brain repo (respects cooldown)
41
+ */
42
+ async function updateBrain(brainDir, brainId, branch) {
43
+ const now = Date.now();
44
+ const lastUpdate = lastUpdateTimes.get(brainId) || 0;
45
+ if (now - lastUpdate < config.BRAIN_UPDATE_COOLDOWN_MS) {
46
+ logger.debug(`Brain ${brainId} update skipped - cooldown active (${Math.round((config.BRAIN_UPDATE_COOLDOWN_MS - (now - lastUpdate)) / 1000)}s remaining)`);
47
+ return;
48
+ }
49
+ try {
50
+ logger.info(`Updating brain ${brainId} at ${brainDir}...`);
51
+ await execAsync(`git fetch origin ${branch} && git reset --hard origin/${branch} && git submodule update --init --recursive`, {
52
+ cwd: brainDir,
53
+ });
54
+ lastUpdateTimes.set(brainId, Date.now());
55
+ logger.info(`Brain ${brainId} updated successfully`);
56
+ }
57
+ catch (error) {
58
+ const errorMessage = error instanceof Error ? error.message : String(error);
59
+ logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
60
+ }
61
+ }
62
+ /**
63
+ * Ensure base brain repo is cloned and up to date.
64
+ * Uses same cooldown pattern as channel brains.
65
+ */
66
+ export async function ensureBaseBrain() {
67
+ const brainDir = config.BASE_BRAIN_PATH;
68
+ if (!existsSync(brainDir)) {
69
+ logger.info(`Cloning base brain from ${config.BASE_BRAIN_REPO} (branch: ${config.BASE_BRAIN_BRANCH})...`);
70
+ await execAsync(`git clone --recurse-submodules --branch ${config.BASE_BRAIN_BRANCH} ${config.BASE_BRAIN_REPO} ${brainDir}`);
71
+ lastUpdateTimes.set('__base_brain__', Date.now());
72
+ logger.info('Base brain cloned successfully');
73
+ }
74
+ else if (config.BRAIN_AUTO_UPDATE) {
75
+ await updateBrain(brainDir, '__base_brain__', config.BASE_BRAIN_BRANCH);
76
+ }
77
+ }
78
+ /**
79
+ * Get the path to the base brain directory.
80
+ */
81
+ export function getBaseBrainPath() {
82
+ return config.BASE_BRAIN_PATH;
83
+ }
84
+ /**
85
+ * Commit and push any changes in a brain repo.
86
+ * Silently skips if there are no changes or no remote.
87
+ */
88
+ export async function pushBrainChanges(brainDir, brainId) {
89
+ try {
90
+ // Check if this is a git repo with a remote
91
+ try {
92
+ await execAsync('git remote get-url origin', { cwd: brainDir });
93
+ }
94
+ catch {
95
+ logger.debug(`Brain ${brainId} has no git remote, skipping push`);
96
+ return;
97
+ }
98
+ // Stage all changes
99
+ await execAsync('git add -A', { cwd: brainDir });
100
+ // Check if there are staged changes
101
+ try {
102
+ await execAsync('git diff --cached --quiet', { cwd: brainDir });
103
+ // If the command succeeds (exit 0), there are no changes
104
+ logger.debug(`Brain ${brainId} has no changes to push`);
105
+ return;
106
+ }
107
+ catch {
108
+ // Exit code 1 means there are changes — continue
109
+ }
110
+ await execAsync('git commit -m "auto: session update"', { cwd: brainDir });
111
+ await execAsync('git push', { cwd: brainDir });
112
+ logger.info(`Brain ${brainId} changes pushed successfully`);
113
+ }
114
+ catch (error) {
115
+ const errorMessage = error instanceof Error ? error.message : String(error);
116
+ logger.warn(`Failed to push brain ${brainId} changes: ${errorMessage}`);
117
+ }
118
+ }
119
+ /**
120
+ * Ensure base paths exist
121
+ */
122
+ export async function ensureDirectories() {
123
+ const { mkdirSync } = await import('fs');
124
+ if (!existsSync(config.BRAINS_PATH)) {
125
+ mkdirSync(config.BRAINS_PATH, { recursive: true });
126
+ }
127
+ if (!existsSync(config.DEFAULT_BRAIN_PATH)) {
128
+ mkdirSync(config.DEFAULT_BRAIN_PATH, { recursive: true });
129
+ }
130
+ }
@@ -6,6 +6,6 @@ export interface SpawnResult {
6
6
  exitCode: number | null;
7
7
  error?: string;
8
8
  }
9
- export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string): Promise<SpawnResult>;
9
+ export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void): Promise<SpawnResult>;
10
10
  export declare function getActiveProcessCount(): number;
11
11
  export declare function isAtCapacity(): boolean;
@@ -1,11 +1,9 @@
1
1
  import { spawn } from 'child_process';
2
- import { join, dirname } from 'path';
3
- import { fileURLToPath } from 'url';
2
+ import { join } from 'path';
4
3
  import { WebClient } from '@slack/web-api';
5
4
  import { config } from './config.js';
6
5
  import { logger } from './logger.js';
7
- const __dirname = dirname(fileURLToPath(import.meta.url));
8
- const SLACK_TOOL_PATH = join(__dirname, 'scripts/slack-tool.js');
6
+ import { getBaseBrainPath } from './brain-manager.js';
9
7
  // Per-token Slack client cache for thread context
10
8
  const slackClients = new Map();
11
9
  function getSlackClient(botToken) {
@@ -42,62 +40,8 @@ async function countNewThreadMessages(botToken, channel, threadTs, sinceTs) {
42
40
  return 0;
43
41
  }
44
42
  }
45
- function buildSystemPrompt(persona) {
46
- let prompt = persona?.systemPrompt || getDefaultSystemPrompt();
47
- // Append standard communication instructions
48
- prompt += `\n\n## Communication
49
- You have Slack tools available via Bash. Use these commands to communicate:
50
-
51
- **Send a message** (REQUIRED - always use this to respond):
52
- \`\`\`bash
53
- node $SLACK_TOOL_PATH send_message "Your message here"
54
- \`\`\`
55
-
56
- **Add emoji reaction:**
57
- \`\`\`bash
58
- node $SLACK_TOOL_PATH add_reaction emoji_name
59
- \`\`\`
60
-
61
- **Remove emoji reaction:**
62
- \`\`\`bash
63
- node $SLACK_TOOL_PATH remove_reaction emoji_name
64
- \`\`\`
65
-
66
- **Read thread history:**
67
- \`\`\`bash
68
- node $SLACK_TOOL_PATH read_thread [limit]
69
- \`\`\`
70
-
71
- **Upload code/text snippet:**
72
- \`\`\`bash
73
- node $SLACK_TOOL_PATH upload_snippet "title" "content" [filetype]
74
- \`\`\`
75
-
76
- **Important:** To respond to the user, you MUST use the send_message command via Bash - plain text output will NOT be seen by the user. Always send at least one message back.
77
- All commands return JSON: {"ok": true, ...} on success or {"ok": false, "error": "..."} on failure.
78
-
79
- ## Response Guidelines
80
- 1. **Always respond** - Use send_message to reply
81
- 2. **Acknowledge long tasks** - Send a quick message first for time-consuming work
82
- 3. **Be concise** - Use bullet points, keep it focused
83
- 4. **Use Slack formatting** - \`code\`, *bold*, _italic_, \\\`\\\`\\\`code blocks\\\`\\\`\\\`, > quotes
84
- 5. **For long code** - Use upload_snippet instead of huge code blocks`;
85
- return prompt;
86
- }
87
- function getDefaultSystemPrompt() {
88
- return `You are a helpful assistant operating in a team's Slack workspace. Team members contact you via Slack messages.
89
-
90
- ## Your Setup
91
- You have access to the company's **knowledge base** (the current working directory). You should:
92
- - Read and search files to understand available tools and information
93
- - Execute available commands, skills, and automation scripts
94
- - Run shell commands for tasks (API calls, data processing, etc.)
95
- - Create temporary local files when needed for your work
96
- - Do NOT create new permanent files or edit existing files unless explicitly asked`;
97
- }
98
- function buildPrompt(msg, systemPrompt, resumeHint) {
99
- let prompt = systemPrompt;
100
- prompt += '\n\n---\n\n## Incoming Slack Message\n\n';
43
+ function buildPrompt(msg, resumeHint) {
44
+ let prompt = '## Incoming Slack Message\n\n';
101
45
  prompt += `**From:** ${msg.sender.name}`;
102
46
  if (msg.sender.id && msg.sender.id !== msg.sender.name) {
103
47
  prompt += ` (${msg.sender.id})`;
@@ -123,7 +67,7 @@ function buildPrompt(msg, systemPrompt, resumeHint) {
123
67
  if (resumeHint) {
124
68
  prompt += `\n\n${resumeHint}`;
125
69
  }
126
- prompt += '\n\n---\n\nNow respond to this user using the send_message command via Bash.';
70
+ prompt += `\n\n---\n\nRespond to this user NOW using the send_message tool.`;
127
71
  return prompt;
128
72
  }
129
73
  function truncateOutput(output, maxLen = 200) {
@@ -182,7 +126,7 @@ function formatStreamEvent(event) {
182
126
  return null;
183
127
  }
184
128
  }
185
- async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs) {
129
+ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent) {
186
130
  const slackContext = msg.response_context.slack;
187
131
  const isCronMessage = msg.source === 'cron';
188
132
  if (!slackContext && !isCronMessage) {
@@ -200,8 +144,14 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
200
144
  }
201
145
  }
202
146
  return new Promise((resolve) => {
203
- const systemPrompt = buildSystemPrompt(msg.teamvibe.persona);
204
- const prompt = buildPrompt(msg, systemPrompt, resumeHint);
147
+ const prompt = buildPrompt(msg, resumeHint);
148
+ // MCP server config passed via --mcp-config flag (no temp files needed)
149
+ const mcpServerPath = join(getBaseBrainPath(), 'mcp', 'slack.mjs');
150
+ const mcpConfig = JSON.stringify({
151
+ mcpServers: {
152
+ slack: { command: 'node', args: [mcpServerPath] },
153
+ },
154
+ });
205
155
  const args = [
206
156
  '--print',
207
157
  '--verbose',
@@ -209,8 +159,10 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
209
159
  'stream-json',
210
160
  '--max-turns',
211
161
  '50',
162
+ '--mcp-config',
163
+ mcpConfig,
212
164
  '--allowedTools',
213
- 'Bash,Read,WebFetch',
165
+ 'Bash,Read,Write,Edit,WebFetch,mcp__slack__send_message,mcp__slack__add_reaction,mcp__slack__remove_reaction,mcp__slack__read_thread,mcp__slack__read_channel,mcp__slack__upload_snippet,mcp__slack__download_file,mcp__slack__upload_file,mcp__slack__set_status',
214
166
  ];
215
167
  // Handle session continuity
216
168
  if (sessionId) {
@@ -225,14 +177,22 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
225
177
  sessionLog.info(`Working directory: ${cwd}`);
226
178
  sessionLog.info(`Prompt length: ${prompt.length} chars`);
227
179
  sessionLog.logPrompt(prompt);
180
+ // Prepend persistent bin dir to PATH if configured
181
+ const envPath = config.PERSISTENT_STORAGE_PATH
182
+ ? `${config.PERSISTENT_STORAGE_PATH}/bin:${process.env['PATH'] || ''}`
183
+ : process.env['PATH'];
228
184
  const proc = spawn(config.CLAUDE_CLI_PATH, args, {
229
185
  cwd,
230
186
  env: {
231
187
  ...process.env,
188
+ CLAUDECODE: undefined,
189
+ PATH: envPath,
232
190
  CI: 'true',
191
+ CLAUDE_CONFIG_DIR: getBaseBrainPath(),
233
192
  SLACK_BOT_TOKEN: msg.teamvibe.botToken,
234
- SLACK_TOOL_PATH,
235
- SLACK_FILES_DIR: join(cwd, '.local/slack-files'),
193
+ ...(config.PERSISTENT_STORAGE_PATH && {
194
+ PERSISTENT_STORAGE_PATH: config.PERSISTENT_STORAGE_PATH,
195
+ }),
236
196
  ...(slackContext && {
237
197
  SLACK_CHANNEL: slackContext.channel,
238
198
  SLACK_THREAD_TS: slackContext.thread_ts,
@@ -265,6 +225,12 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
265
225
  if (formatted) {
266
226
  sessionLog.claude('stdout', formatted);
267
227
  }
228
+ // Detect when Claude sends a Slack message and clear typing indicator
229
+ if (onMessageSent &&
230
+ event.type === 'tool_use' &&
231
+ event.name === 'mcp__slack__send_message') {
232
+ onMessageSent();
233
+ }
268
234
  }
269
235
  catch {
270
236
  const truncated = line.length > 500 ? line.slice(0, 500) + '...' : line;
@@ -307,12 +273,12 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
307
273
  });
308
274
  });
309
275
  }
310
- export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs) {
311
- const result = await runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage, lastMessageTs);
276
+ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent) {
277
+ const result = await runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage, lastMessageTs, onMessageSent);
312
278
  // If --resume failed, retry as a fresh session (session files may have been lost on container restart)
313
279
  if (!result.success && sessionId && !isFirstMessage) {
314
280
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
315
- return runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs);
281
+ return runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent);
316
282
  }
317
283
  return result;
318
284
  }
package/dist/config.d.ts CHANGED
@@ -12,11 +12,15 @@ declare const configSchema: z.ZodObject<{
12
12
  CLAUDE_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
13
13
  STALE_LOCK_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
14
14
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
15
- KB_BASE_PATH: z.ZodDefault<z.ZodString>;
16
- DEFAULT_KB_PATH: z.ZodDefault<z.ZodString>;
15
+ BRAINS_PATH: z.ZodDefault<z.ZodString>;
16
+ DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
17
17
  CLAUDE_CLI_PATH: z.ZodDefault<z.ZodString>;
18
- KB_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
19
- KB_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
18
+ PERSISTENT_STORAGE_PATH: z.ZodDefault<z.ZodString>;
19
+ BASE_BRAIN_REPO: z.ZodDefault<z.ZodString>;
20
+ BASE_BRAIN_BRANCH: z.ZodDefault<z.ZodString>;
21
+ BASE_BRAIN_PATH: z.ZodDefault<z.ZodString>;
22
+ BRAIN_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
23
+ BRAIN_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
20
24
  }, "strip", z.ZodTypeAny, {
21
25
  AWS_REGION: string;
22
26
  TEAMVIBE_API_URL: string;
@@ -27,11 +31,15 @@ declare const configSchema: z.ZodObject<{
27
31
  CLAUDE_TIMEOUT_MS: number;
28
32
  STALE_LOCK_TIMEOUT_MS: number;
29
33
  TEAMVIBE_DATA_DIR: string;
30
- KB_BASE_PATH: string;
31
- DEFAULT_KB_PATH: string;
34
+ BRAINS_PATH: string;
35
+ DEFAULT_BRAIN_PATH: string;
32
36
  CLAUDE_CLI_PATH: string;
33
- KB_AUTO_UPDATE: boolean;
34
- KB_UPDATE_COOLDOWN_MS: number;
37
+ PERSISTENT_STORAGE_PATH: string;
38
+ BASE_BRAIN_REPO: string;
39
+ BASE_BRAIN_BRANCH: string;
40
+ BASE_BRAIN_PATH: string;
41
+ BRAIN_AUTO_UPDATE: boolean;
42
+ BRAIN_UPDATE_COOLDOWN_MS: number;
35
43
  SQS_QUEUE_URL?: string | undefined;
36
44
  SESSIONS_TABLE?: string | undefined;
37
45
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
@@ -48,11 +56,15 @@ declare const configSchema: z.ZodObject<{
48
56
  CLAUDE_TIMEOUT_MS?: number | undefined;
49
57
  STALE_LOCK_TIMEOUT_MS?: number | undefined;
50
58
  TEAMVIBE_DATA_DIR?: string | undefined;
51
- KB_BASE_PATH?: string | undefined;
52
- DEFAULT_KB_PATH?: string | undefined;
59
+ BRAINS_PATH?: string | undefined;
60
+ DEFAULT_BRAIN_PATH?: string | undefined;
53
61
  CLAUDE_CLI_PATH?: string | undefined;
54
- KB_AUTO_UPDATE?: string | undefined;
55
- KB_UPDATE_COOLDOWN_MS?: number | undefined;
62
+ PERSISTENT_STORAGE_PATH?: string | undefined;
63
+ BASE_BRAIN_REPO?: string | undefined;
64
+ BASE_BRAIN_BRANCH?: string | undefined;
65
+ BASE_BRAIN_PATH?: string | undefined;
66
+ BRAIN_AUTO_UPDATE?: string | undefined;
67
+ BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
56
68
  }>;
57
69
  export type Config = z.infer<typeof configSchema>;
58
70
  export declare function loadConfig(): Config;
@@ -66,11 +78,15 @@ export declare const config: {
66
78
  CLAUDE_TIMEOUT_MS: number;
67
79
  STALE_LOCK_TIMEOUT_MS: number;
68
80
  TEAMVIBE_DATA_DIR: string;
69
- KB_BASE_PATH: string;
70
- DEFAULT_KB_PATH: string;
81
+ BRAINS_PATH: string;
82
+ DEFAULT_BRAIN_PATH: string;
71
83
  CLAUDE_CLI_PATH: string;
72
- KB_AUTO_UPDATE: boolean;
73
- KB_UPDATE_COOLDOWN_MS: number;
84
+ PERSISTENT_STORAGE_PATH: string;
85
+ BASE_BRAIN_REPO: string;
86
+ BASE_BRAIN_BRANCH: string;
87
+ BASE_BRAIN_PATH: string;
88
+ BRAIN_AUTO_UPDATE: boolean;
89
+ BRAIN_UPDATE_COOLDOWN_MS: number;
74
90
  SQS_QUEUE_URL?: string | undefined;
75
91
  SESSIONS_TABLE?: string | undefined;
76
92
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
package/dist/config.js CHANGED
@@ -19,15 +19,20 @@ const configSchema = z.object({
19
19
  STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(2100000), // 35 minutes
20
20
  // Paths
21
21
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
22
- KB_BASE_PATH: z.string().default(''),
23
- DEFAULT_KB_PATH: z.string().default(''),
22
+ BRAINS_PATH: z.string().default(''),
23
+ DEFAULT_BRAIN_PATH: z.string().default(''),
24
24
  CLAUDE_CLI_PATH: z.string().default('claude'),
25
- // Knowledge base auto-update
26
- KB_AUTO_UPDATE: z
25
+ PERSISTENT_STORAGE_PATH: z.string().default(''),
26
+ // Base brain repo (user-scope config for Claude Code)
27
+ BASE_BRAIN_REPO: z.string().default('https://github.com/teamvibeai/poller-brain.git'),
28
+ BASE_BRAIN_BRANCH: z.string().default('main'),
29
+ BASE_BRAIN_PATH: z.string().default(''),
30
+ // Brain auto-update
31
+ BRAIN_AUTO_UPDATE: z
27
32
  .string()
28
33
  .default('true')
29
34
  .transform((v) => v.toLowerCase() === 'true'),
30
- KB_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
35
+ BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
31
36
  });
32
37
  export function loadConfig() {
33
38
  const result = configSchema.safeParse(process.env);
@@ -47,8 +52,9 @@ export function loadConfig() {
47
52
  }
48
53
  // Derive paths from TEAMVIBE_DATA_DIR if not explicitly set
49
54
  const dataDir = data.TEAMVIBE_DATA_DIR;
50
- data.KB_BASE_PATH = data.KB_BASE_PATH || `${dataDir}/knowledge-bases`;
51
- data.DEFAULT_KB_PATH = data.DEFAULT_KB_PATH || `${dataDir}/workspace`;
55
+ data.BRAINS_PATH = data.BRAINS_PATH || `${dataDir}/brains`;
56
+ data.DEFAULT_BRAIN_PATH = data.DEFAULT_BRAIN_PATH || `${dataDir}/workspace`;
57
+ data.BASE_BRAIN_PATH = data.BASE_BRAIN_PATH || `${dataDir}/base-brain`;
52
58
  return data;
53
59
  }
54
60
  export const config = loadConfig();
package/dist/index.js CHANGED
@@ -5,11 +5,11 @@ import { pollMessages, deleteMessage, extendVisibility, } from './sqs-poller.js'
5
5
  import { spawnClaudeCode, isAtCapacity, getActiveProcessCount } from './claude-spawner.js';
6
6
  import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
7
7
  import { acquireSessionLock, releaseSessionLock } from './session-store.js';
8
- import { getKnowledgeBasePath, ensureDirectories } from './kb-manager.js';
8
+ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
9
9
  import { initAuth, stopRefresh } from './auth-provider.js';
10
10
  logger.info('TeamVibe Poller starting...');
11
11
  logger.info(` Max concurrent: ${config.MAX_CONCURRENT_SESSIONS}`);
12
- logger.info(` KB base path: ${config.KB_BASE_PATH}`);
12
+ logger.info(` Brains path: ${config.BRAINS_PATH}`);
13
13
  // Track active message processing
14
14
  const processingMessages = new Set();
15
15
  // Per-thread completion signals
@@ -78,11 +78,31 @@ async function processMessage(received) {
78
78
  }
79
79
  sessionLog.info(`Processing message from ${queueMessage.sender.name} (${queueMessage.sender.id})`);
80
80
  sessionLog.info(`Thread: ${threadId}`);
81
- sessionLog.info(`Persona: ${queueMessage.teamvibe.persona?.name ?? 'none'}`);
81
+ sessionLog.info(`Brain: ${queueMessage.teamvibe.brain?.brainId ?? 'none'}`);
82
82
  sessionLog.info(`Type: ${queueMessage.type}`);
83
83
  sessionLog.info(`Log file: ${sessionLog.getLogFile()}`);
84
- // Get knowledge base path for this persona
85
- const kbPath = await getKnowledgeBasePath(queueMessage.teamvibe.persona?.knowledgeBase);
84
+ const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
85
+ queueMessage.response_context.slack?.message_ts);
86
+ // Get brain path for this channel
87
+ let kbPath;
88
+ try {
89
+ kbPath = await getBrainPath(queueMessage.teamvibe.brain);
90
+ }
91
+ catch (error) {
92
+ const errorMessage = error instanceof Error ? error.message : String(error);
93
+ sessionLog.error(`Failed to clone brain: ${errorMessage}`);
94
+ if (hasSlackContext) {
95
+ try {
96
+ await sendSlackError(queueMessage, `Failed to clone brain repository: ${errorMessage}`);
97
+ await addReaction(queueMessage, 'x');
98
+ }
99
+ catch (slackError) {
100
+ sessionLog.error(`Failed to send Slack error: ${slackError}`);
101
+ }
102
+ }
103
+ await deleteMessage(receiptHandle);
104
+ return;
105
+ }
86
106
  // Try to acquire session lock
87
107
  let lockResult = await acquireSessionLock(threadId, kbPath);
88
108
  if (!lockResult.success) {
@@ -108,17 +128,19 @@ async function processMessage(received) {
108
128
  logQueueState(`Lock acquired for ${threadId}`);
109
129
  processingMessages.add(messageId);
110
130
  const heartbeat = startHeartbeat(receiptHandle, sessionLog);
111
- const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
112
- queueMessage.response_context.slack?.message_ts);
113
131
  // Start typing indicator
114
132
  const stopTyping = hasSlackContext
115
133
  ? startTypingIndicator(queueMessage)
116
134
  : undefined;
117
135
  try {
118
- const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts);
136
+ const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => stopTyping?.());
119
137
  stopTyping?.();
120
138
  if (result.success) {
121
139
  sessionLog.info('Claude Code completed successfully');
140
+ // Push any changes in the channel brain repo
141
+ if (queueMessage.teamvibe.brain?.brainId) {
142
+ await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId);
143
+ }
122
144
  if (lockToken) {
123
145
  const lastMessageTs = queueMessage.response_context.slack?.message_ts;
124
146
  await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
@@ -230,6 +252,7 @@ async function main() {
230
252
  logger.info(` Sessions table: ${config.SESSIONS_TABLE}`);
231
253
  }
232
254
  await ensureDirectories();
255
+ await ensureBaseBrain();
233
256
  await pollLoop();
234
257
  }
235
258
  main().catch((error) => {
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Add an emoji reaction to the original message.
4
+ *
5
+ * Usage:
6
+ * node add-reaction.js emoji_name
7
+ * node add-reaction.js --channel C123 --message_ts 123.456 emoji_name
8
+ */
9
+ export {};
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Add an emoji reaction to the original message.
4
+ *
5
+ * Usage:
6
+ * node add-reaction.js emoji_name
7
+ * node add-reaction.js --channel C123 --message_ts 123.456 emoji_name
8
+ */
9
+ import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
10
+ const { channel, messageTs, positional } = parseArgs();
11
+ const emoji = positional[0];
12
+ if (!emoji)
13
+ fail('Emoji name required');
14
+ if (!channel)
15
+ fail('Channel required (--channel or SLACK_CHANNEL env)');
16
+ if (!messageTs)
17
+ fail('Message TS required (--message_ts or SLACK_MESSAGE_TS env)');
18
+ async function main() {
19
+ await slack.reactions.add({ channel: channel, timestamp: messageTs, name: emoji });
20
+ succeed();
21
+ }
22
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,16 @@
1
+ import { WebClient } from '@slack/web-api';
2
+ export declare function fail(error: string): never;
3
+ export declare function succeed(data?: Record<string, unknown>): never;
4
+ export declare const slack: WebClient;
5
+ /**
6
+ * Parse CLI args: extracts known --flags and returns remaining positional args.
7
+ * Supports both --flag value and --flag=value syntax.
8
+ * Falls back to env vars for channel/thread_ts/message_ts.
9
+ */
10
+ export declare function parseArgs(argv?: string[]): {
11
+ channel: string | undefined;
12
+ threadTs: string | undefined;
13
+ messageTs: string | undefined;
14
+ positional: string[];
15
+ textFromFlag: string | undefined;
16
+ };
@@ -0,0 +1,48 @@
1
+ import { WebClient } from '@slack/web-api';
2
+ const BOT_TOKEN = process.env['SLACK_BOT_TOKEN'];
3
+ export function fail(error) {
4
+ console.log(JSON.stringify({ ok: false, error }));
5
+ process.exit(1);
6
+ }
7
+ export function succeed(data = {}) {
8
+ console.log(JSON.stringify({ ok: true, ...data }));
9
+ process.exit(0);
10
+ }
11
+ if (!BOT_TOKEN)
12
+ fail('SLACK_BOT_TOKEN not set');
13
+ export const slack = new WebClient(BOT_TOKEN);
14
+ /**
15
+ * Parse CLI args: extracts known --flags and returns remaining positional args.
16
+ * Supports both --flag value and --flag=value syntax.
17
+ * Falls back to env vars for channel/thread_ts/message_ts.
18
+ */
19
+ export function parseArgs(argv = process.argv.slice(2)) {
20
+ const flags = {};
21
+ const positional = [];
22
+ const knownFlags = ['--channel', '--thread_ts', '--thread-ts', '--message_ts', '--message-ts', '--text'];
23
+ let i = 0;
24
+ while (i < argv.length) {
25
+ const arg = argv[i];
26
+ // --flag=value
27
+ const eqMatch = arg.match(/^(--[\w-]+)=(.*)$/);
28
+ if (eqMatch) {
29
+ flags[eqMatch[1]] = eqMatch[2];
30
+ i++;
31
+ continue;
32
+ }
33
+ // --flag value
34
+ if (knownFlags.includes(arg) && i + 1 < argv.length) {
35
+ flags[arg] = argv[i + 1];
36
+ i += 2;
37
+ continue;
38
+ }
39
+ positional.push(arg);
40
+ i++;
41
+ }
42
+ // If --text was provided, use it (and any remaining positional after it)
43
+ const textFromFlag = flags['--text'];
44
+ const channel = flags['--channel'] || process.env['SLACK_CHANNEL'];
45
+ const threadTs = flags['--thread_ts'] || flags['--thread-ts'] || process.env['SLACK_THREAD_TS'];
46
+ const messageTs = flags['--message_ts'] || flags['--message-ts'] || process.env['SLACK_MESSAGE_TS'];
47
+ return { channel, threadTs, messageTs, positional, textFromFlag };
48
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Read thread history from a Slack thread.
4
+ *
5
+ * Usage:
6
+ * node read-thread.js [limit]
7
+ * node read-thread.js --channel C123 --thread_ts 123.456 [limit]
8
+ */
9
+ export {};
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Read thread history from a Slack thread.
4
+ *
5
+ * Usage:
6
+ * node read-thread.js [limit]
7
+ * node read-thread.js --channel C123 --thread_ts 123.456 [limit]
8
+ */
9
+ import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
10
+ const { channel, threadTs, positional } = parseArgs();
11
+ const limit = parseInt(positional[0] || '20', 10);
12
+ if (!channel)
13
+ fail('Channel required (--channel or SLACK_CHANNEL env)');
14
+ if (!threadTs)
15
+ fail('Thread TS required (--thread_ts or SLACK_THREAD_TS env)');
16
+ async function main() {
17
+ const result = await slack.conversations.replies({ channel: channel, ts: threadTs, limit });
18
+ const messages = (result.messages || []).map((m) => ({
19
+ user: m.user || m.bot_id || 'unknown',
20
+ text: m.text || '',
21
+ ts: m.ts,
22
+ is_bot: Boolean(m.bot_id),
23
+ }));
24
+ succeed({ messages });
25
+ }
26
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Remove an emoji reaction from the original message.
4
+ *
5
+ * Usage:
6
+ * node remove-reaction.js emoji_name
7
+ * node remove-reaction.js --channel C123 --message_ts 123.456 emoji_name
8
+ */
9
+ export {};
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Remove an emoji reaction from the original message.
4
+ *
5
+ * Usage:
6
+ * node remove-reaction.js emoji_name
7
+ * node remove-reaction.js --channel C123 --message_ts 123.456 emoji_name
8
+ */
9
+ import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
10
+ const { channel, messageTs, positional } = parseArgs();
11
+ const emoji = positional[0];
12
+ if (!emoji)
13
+ fail('Emoji name required');
14
+ if (!channel)
15
+ fail('Channel required (--channel or SLACK_CHANNEL env)');
16
+ if (!messageTs)
17
+ fail('Message TS required (--message_ts or SLACK_MESSAGE_TS env)');
18
+ async function main() {
19
+ await slack.reactions.remove({ channel: channel, timestamp: messageTs, name: emoji });
20
+ succeed();
21
+ }
22
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Send a message to a Slack thread.
4
+ *
5
+ * Usage:
6
+ * node send-message.js "Your message here"
7
+ * node send-message.js --text "Your message here"
8
+ * node send-message.js --channel C123 --thread_ts 123.456 "Your message"
9
+ */
10
+ export {};
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Send a message to a Slack thread.
4
+ *
5
+ * Usage:
6
+ * node send-message.js "Your message here"
7
+ * node send-message.js --text "Your message here"
8
+ * node send-message.js --channel C123 --thread_ts 123.456 "Your message"
9
+ */
10
+ import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
11
+ const { channel, threadTs, positional, textFromFlag } = parseArgs();
12
+ const text = textFromFlag || positional.join(' ');
13
+ if (!text)
14
+ fail('Message text required');
15
+ if (!channel)
16
+ fail('Channel required (--channel or SLACK_CHANNEL env)');
17
+ if (!threadTs)
18
+ fail('Thread TS required (--thread_ts or SLACK_THREAD_TS env)');
19
+ async function main() {
20
+ console.error(`[slack] send_message: channel=${channel}, thread_ts=${threadTs}`);
21
+ const result = await slack.chat.postMessage({
22
+ channel: channel,
23
+ thread_ts: threadTs,
24
+ text,
25
+ unfurl_links: false,
26
+ unfurl_media: false,
27
+ });
28
+ console.error(`[slack] posted: ts=${result.ts}`);
29
+ succeed({ ts: result.ts });
30
+ }
31
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Set the assistant thread status.
4
+ *
5
+ * Usage:
6
+ * node set-status.js "Status text"
7
+ */
8
+ export {};
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Set the assistant thread status.
4
+ *
5
+ * Usage:
6
+ * node set-status.js "Status text"
7
+ */
8
+ import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
9
+ const { channel, threadTs, positional } = parseArgs();
10
+ const status = positional.join(' ');
11
+ if (!channel)
12
+ fail('Channel required');
13
+ if (!threadTs)
14
+ fail('Thread TS required');
15
+ async function main() {
16
+ await slack.apiCall('assistant.threads.setStatus', { channel_id: channel, thread_ts: threadTs, status });
17
+ succeed();
18
+ }
19
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
@@ -15,10 +15,33 @@ if (!BOT_TOKEN)
15
15
  fail('SLACK_BOT_TOKEN not set');
16
16
  const slack = new WebClient(BOT_TOKEN);
17
17
  const [, , command, ...args] = process.argv;
18
+ /**
19
+ * Strip --channel, --thread_ts, --text flags that Claude sometimes adds.
20
+ * These values come from env vars, not CLI args.
21
+ */
22
+ function stripFlags(rawArgs) {
23
+ const flagsToStrip = ['--channel', '--thread_ts', '--thread-ts', '--text'];
24
+ const cleaned = [];
25
+ let i = 0;
26
+ while (i < rawArgs.length) {
27
+ if (flagsToStrip.includes(rawArgs[i])) {
28
+ // If this is --text, everything after the next arg is the message
29
+ if (rawArgs[i] === '--text') {
30
+ return rawArgs.slice(i + 1).join(' ');
31
+ }
32
+ i += 2; // skip flag + value
33
+ }
34
+ else {
35
+ cleaned.push(rawArgs[i]);
36
+ i++;
37
+ }
38
+ }
39
+ return cleaned.join(' ');
40
+ }
18
41
  async function main() {
19
42
  switch (command) {
20
43
  case 'send_message': {
21
- const text = args.join(' ');
44
+ const text = stripFlags(args);
22
45
  if (!text)
23
46
  fail('Message text required');
24
47
  if (!CHANNEL)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Upload a code/text snippet to a Slack thread.
4
+ *
5
+ * Usage:
6
+ * node upload-snippet.js "title" "content" [filetype]
7
+ */
8
+ export {};
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Upload a code/text snippet to a Slack thread.
4
+ *
5
+ * Usage:
6
+ * node upload-snippet.js "title" "content" [filetype]
7
+ */
8
+ import { slack, fail, succeed, parseArgs } from './lib/slack-client.js';
9
+ const { channel, threadTs, positional } = parseArgs();
10
+ const [title, content, filetype = 'text'] = positional;
11
+ if (!title || !content)
12
+ fail('Title and content required');
13
+ if (!channel)
14
+ fail('Channel required');
15
+ if (!threadTs)
16
+ fail('Thread TS required');
17
+ async function main() {
18
+ await slack.filesUploadV2({ channel_id: channel, thread_ts: threadTs, title: title, content: content, filetype });
19
+ succeed();
20
+ }
21
+ main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
package/dist/types.d.ts CHANGED
@@ -29,16 +29,11 @@ export interface TeamVibeQueueMessage {
29
29
  botId: string;
30
30
  botToken: string;
31
31
  channelId: string;
32
- persona: {
33
- personaId: string;
34
- name: string;
35
- systemPrompt?: string;
36
- knowledgeBase?: {
37
- kbId: string;
38
- gitRepoUrl: string;
39
- branch: string;
40
- claudePath: string;
41
- };
32
+ brain?: {
33
+ brainId: string;
34
+ gitRepoUrl: string;
35
+ branch: string;
36
+ claudePath: string;
42
37
  };
43
38
  };
44
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {