@teamvibe/poller 0.1.64 → 0.1.66

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.
@@ -109,7 +109,8 @@ export function buildPrompt(msg, resumeHint) {
109
109
  return 'heartbeat';
110
110
  }
111
111
  const isEmail = msg.source === 'email';
112
- let prompt = isEmail ? '## Incoming Email\n\n' : '## Incoming Slack Message\n\n';
112
+ const isWebhook = msg.source === 'webhook';
113
+ let prompt = isEmail ? '## Incoming Email\n\n' : isWebhook ? '## Incoming Webhook\n\n' : '## Incoming Slack Message\n\n';
113
114
  prompt += `**From:** ${msg.sender.name}`;
114
115
  if (msg.sender.id && msg.sender.id !== msg.sender.name) {
115
116
  prompt += ` (${msg.sender.id})`;
@@ -226,7 +227,8 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
226
227
  const isCronMessage = msg.source === 'cron';
227
228
  const isHeartbeat = msg.source === 'heartbeat';
228
229
  const isEmailMessage = msg.source === 'email';
229
- if (!slackContext && !isCronMessage && !isHeartbeat && !isEmailMessage) {
230
+ const isWebhookMessage = msg.source === 'webhook';
231
+ if (!slackContext && !isCronMessage && !isHeartbeat && !isEmailMessage && !isWebhookMessage) {
230
232
  throw new Error('No Slack context in message');
231
233
  }
232
234
  // Build resume hint
@@ -273,7 +275,11 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
273
275
  }
274
276
  // Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
275
277
  const inboxNewDir = `.inbox/${msg.thread_id}/new`;
278
+ const now = new Date();
279
+ const todayLine = `Today: ${now.toISOString().slice(0, 10)} (${now.toLocaleDateString('en-US', { weekday: 'long' })})`;
276
280
  args.push('--append-system-prompt', [
281
+ todayLine,
282
+ ``,
277
283
  `CRITICAL — INBOX PROTOCOL: You MUST check for new user messages after EVERY tool call.`,
278
284
  `Run: ls ${inboxNewDir}/ 2>/dev/null`,
279
285
  `If files exist: read each file (oldest first), delete it, and act on the message IMMEDIATELY — it takes priority over your current task.`,
@@ -1,5 +1,6 @@
1
1
  export type ErrorCategory = 'sqs' | 'brain' | 'auth' | 'session' | 'slack' | 'maintenance';
2
2
  export type ErrorSeverity = 'warn' | 'error';
3
+ export declare function emitEvent(eventType: string, channelId: string, metadata?: Record<string, unknown>): void;
3
4
  export declare function flush(): Promise<void>;
4
5
  export declare function startEventQueue(): void;
5
6
  export declare function stopEventQueue(): void;
@@ -10,16 +10,22 @@ let isFlushing = false;
10
10
  function dedupKey(channelId, category, message) {
11
11
  return `${channelId}:${category}:${message}`;
12
12
  }
13
+ function isErrorEvent(event) {
14
+ return event.eventType === 'poller.error';
15
+ }
13
16
  function enqueue(event) {
14
- // Dedup: collapse identical category+message within window
15
- const key = dedupKey(event.channelId, event.metadata.category, event.metadata.message);
16
- const now = Date.now();
17
- const windowStart = new Date(now - DEDUP_WINDOW_MS).toISOString();
18
- const existing = queue.find((e) => dedupKey(e.channelId, e.metadata.category, e.metadata.message) === key &&
19
- e.timestamp >= windowStart);
20
- if (existing) {
21
- existing.metadata.count += 1;
22
- return;
17
+ // Dedup only applies to error events
18
+ if (isErrorEvent(event)) {
19
+ const key = dedupKey(event.channelId, event.metadata.category, event.metadata.message);
20
+ const now = Date.now();
21
+ const windowStart = new Date(now - DEDUP_WINDOW_MS).toISOString();
22
+ const existing = queue.find((e) => isErrorEvent(e) &&
23
+ dedupKey(e.channelId, e.metadata.category, e.metadata.message) === key &&
24
+ e.timestamp >= windowStart);
25
+ if (existing && isErrorEvent(existing)) {
26
+ existing.metadata.count += 1;
27
+ return;
28
+ }
23
29
  }
24
30
  if (queue.length >= MAX_QUEUE_SIZE) {
25
31
  queue.shift(); // Drop oldest
@@ -47,6 +53,17 @@ async function flushOne(event) {
47
53
  return false;
48
54
  }
49
55
  }
56
+ export function emitEvent(eventType, channelId, metadata) {
57
+ if (!config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN) {
58
+ return;
59
+ }
60
+ enqueue({
61
+ eventType,
62
+ channelId,
63
+ metadata,
64
+ timestamp: new Date().toISOString(),
65
+ });
66
+ }
50
67
  export async function flush() {
51
68
  if (queue.length === 0 || isFlushing)
52
69
  return;
@@ -12,6 +12,10 @@ export declare function pickNextMessage(brainPath: string, threadId: string): {
12
12
  filename: string;
13
13
  content: string;
14
14
  } | null;
15
+ /**
16
+ * Move a message from processing/ to failed/ after exhausting retries.
17
+ */
18
+ export declare function markFailed(brainPath: string, threadId: string, filename: string): void;
15
19
  /**
16
20
  * Delete a message file from processing/ (marks it as done).
17
21
  */
@@ -4,6 +4,7 @@ import { logger } from './logger.js';
4
4
  const INBOX_DIR = '.inbox';
5
5
  const NEW_DIR = 'new';
6
6
  const PROCESSING_DIR = 'processing';
7
+ const FAILED_DIR = 'failed';
7
8
  function getInboxPath(brainPath, threadId) {
8
9
  return join(brainPath, INBOX_DIR, threadId);
9
10
  }
@@ -56,6 +57,27 @@ export function pickNextMessage(brainPath, threadId) {
56
57
  }
57
58
  return null;
58
59
  }
60
+ /**
61
+ * Move a message from processing/ to failed/ after exhausting retries.
62
+ */
63
+ export function markFailed(brainPath, threadId, filename) {
64
+ const inbox = getInboxPath(brainPath, threadId);
65
+ const srcPath = join(inbox, PROCESSING_DIR, filename);
66
+ const failedDir = join(inbox, FAILED_DIR);
67
+ mkdirSync(failedDir, { recursive: true });
68
+ const dstPath = join(failedDir, filename);
69
+ try {
70
+ if (existsSync(srcPath)) {
71
+ renameSync(srcPath, dstPath);
72
+ logger.info(`[Inbox] Moved exhausted message to failed/: ${filename}`);
73
+ }
74
+ }
75
+ catch (error) {
76
+ logger.warn(`[Inbox] Failed to move ${filename} to failed/: ${error}`);
77
+ // Fallback: delete so the loop doesn't get stuck
78
+ markProcessed(brainPath, threadId, filename);
79
+ }
80
+ }
59
81
  /**
60
82
  * Delete a message file from processing/ (marks it as done).
61
83
  */
package/dist/poller.js CHANGED
@@ -8,8 +8,8 @@ import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } fr
8
8
  import { postMaintenanceReports } from './maintenance-reporter.js';
9
9
  import { initAuth, stopRefresh } from './auth-provider.js';
10
10
  import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
11
- import { startEventQueue, stopEventQueue, flush as flushEventQueue } from './event-queue.js';
12
- import { writeMessage, pickNextMessage, markProcessed, hasUnprocessed } from './inbox-manager.js';
11
+ import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
12
+ import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
13
13
  // Track active message processing
14
14
  const processingMessages = new Set();
15
15
  // Track which threads have an active processing loop
@@ -21,6 +21,7 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
21
21
  }
22
22
  activeLoops.add(threadId);
23
23
  let iteration = 0;
24
+ const failureCounts = new Map();
24
25
  try {
25
26
  let lockResult = await acquireSessionLock(threadId, kbPath);
26
27
  if (!lockResult.success) {
@@ -28,6 +29,7 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
28
29
  return;
29
30
  }
30
31
  const { session, lockToken } = lockResult;
32
+ let currentSessionId = session.session_id || undefined;
31
33
  while (true) {
32
34
  const message = pickNextMessage(kbPath, threadId);
33
35
  if (!message) {
@@ -44,6 +46,7 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
44
46
  ? startTypingIndicator(queueMessage)
45
47
  : undefined;
46
48
  let succeeded = false;
49
+ let lastExitCode = null;
47
50
  try {
48
51
  // Reconstruct queue message from stored inbox data
49
52
  const storedData = JSON.parse(message.content);
@@ -51,10 +54,21 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
51
54
  ...queueMessage,
52
55
  ...storedData,
53
56
  };
54
- const result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
57
+ const result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, currentSessionId, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
55
58
  typing?.stop();
56
- if (result.newSessionId && lockToken) {
57
- await updateSessionId(threadId, lockToken, result.newSessionId);
59
+ if (result.newSessionId) {
60
+ // Update local state first — DynamoDB writes may fail (expired token,
61
+ // network), but subsequent iterations MUST use the fresh session ID
62
+ // to avoid "session already in use" retry loops.
63
+ currentSessionId = result.newSessionId;
64
+ if (lockToken) {
65
+ try {
66
+ await updateSessionId(threadId, lockToken, result.newSessionId);
67
+ }
68
+ catch (err) {
69
+ sessionLog.error(`[Loop] Failed to persist new session ID to DynamoDB: ${err instanceof Error ? err.message : err}`);
70
+ }
71
+ }
58
72
  }
59
73
  if (result.metrics) {
60
74
  sessionLog.info(`[Loop] Iteration #${iteration} complete — ` +
@@ -64,12 +78,27 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
64
78
  if (result.success) {
65
79
  succeeded = true;
66
80
  sessionLog.info(`[Loop] Claude completed iteration #${iteration}`);
81
+ if (result.metrics) {
82
+ emitEvent('session.completed', queueMessage.teamvibe.channelId, {
83
+ threadId: queueMessage.thread_id,
84
+ total_cost_usd: result.metrics.total_cost_usd,
85
+ input_tokens: result.metrics.input_tokens,
86
+ output_tokens: result.metrics.output_tokens,
87
+ cache_read_input_tokens: result.metrics.cache_read_input_tokens,
88
+ cache_creation_input_tokens: result.metrics.cache_creation_input_tokens,
89
+ duration_ms: result.metrics.duration_ms,
90
+ duration_api_ms: result.metrics.duration_api_ms,
91
+ num_turns: result.metrics.num_turns,
92
+ model: result.metrics.model,
93
+ });
94
+ }
67
95
  if (queueMessage.teamvibe.brain?.brainId) {
68
96
  await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId, queueMessage.teamvibe.workspaceId);
69
97
  }
70
98
  await postMaintenanceReports(kbPath, queueMessage.teamvibe.channelId);
71
99
  }
72
100
  else {
101
+ lastExitCode = result.exitCode;
73
102
  sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
74
103
  if (hasSlackContext) {
75
104
  const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
@@ -83,10 +112,27 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
83
112
  typing?.stop();
84
113
  sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
85
114
  }
86
- // Only delete processing file on success — failed/killed sessions get retried
87
115
  if (succeeded) {
88
116
  markProcessed(kbPath, threadId, message.filename);
89
117
  }
118
+ else {
119
+ // SIGTERM (143) or SIGKILL (137) = intentional kill → retry immediately, no backoff
120
+ const isKilledSignal = lastExitCode === 143 || lastExitCode === 137;
121
+ if (!isKilledSignal) {
122
+ const failures = (failureCounts.get(message.filename) || 0) + 1;
123
+ failureCounts.set(message.filename, failures);
124
+ const MAX_RETRIES = 3;
125
+ if (failures >= MAX_RETRIES) {
126
+ sessionLog.error(`[Loop] Message ${message.filename} failed ${failures} times, giving up`);
127
+ markFailed(kbPath, threadId, message.filename);
128
+ }
129
+ else {
130
+ const backoffMs = Math.min(10_000 * Math.pow(2, failures - 1), 60_000);
131
+ sessionLog.info(`[Loop] Retry ${failures}/${MAX_RETRIES - 1} for ${message.filename}, backing off ${backoffMs}ms`);
132
+ await sleep(backoffMs);
133
+ }
134
+ }
135
+ }
90
136
  if (!hasUnprocessed(kbPath, threadId)) {
91
137
  break;
92
138
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export interface TeamVibeQueueMessage {
2
2
  id: string;
3
- source: 'slack' | 'email' | 'cron' | 'heartbeat';
3
+ source: 'slack' | 'email' | 'cron' | 'heartbeat' | 'webhook';
4
4
  thread_id: string;
5
5
  sender: {
6
6
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {