@geminixiang/mama 0.1.9 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +173 -16
  2. package/dist/adapter.d.ts +8 -1
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/context.d.ts.map +1 -1
  6. package/dist/adapters/discord/context.js +1 -0
  7. package/dist/adapters/discord/context.js.map +1 -1
  8. package/dist/adapters/slack/bot.d.ts +12 -0
  9. package/dist/adapters/slack/bot.d.ts.map +1 -1
  10. package/dist/adapters/slack/bot.js +86 -10
  11. package/dist/adapters/slack/bot.js.map +1 -1
  12. package/dist/adapters/slack/context.d.ts.map +1 -1
  13. package/dist/adapters/slack/context.js +48 -28
  14. package/dist/adapters/slack/context.js.map +1 -1
  15. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  16. package/dist/adapters/slack/tools/attach.js +4 -2
  17. package/dist/adapters/slack/tools/attach.js.map +1 -1
  18. package/dist/adapters/telegram/bot.d.ts +6 -3
  19. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  20. package/dist/adapters/telegram/bot.js +115 -63
  21. package/dist/adapters/telegram/bot.js.map +1 -1
  22. package/dist/adapters/telegram/context.d.ts +2 -2
  23. package/dist/adapters/telegram/context.d.ts.map +1 -1
  24. package/dist/adapters/telegram/context.js +92 -65
  25. package/dist/adapters/telegram/context.js.map +1 -1
  26. package/dist/agent.d.ts.map +1 -1
  27. package/dist/agent.js +214 -39
  28. package/dist/agent.js.map +1 -1
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +46 -13
  32. package/dist/config.js.map +1 -1
  33. package/dist/context.d.ts +15 -1
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js +29 -2
  36. package/dist/context.js.map +1 -1
  37. package/dist/events.d.ts +10 -5
  38. package/dist/events.d.ts.map +1 -1
  39. package/dist/events.js +44 -10
  40. package/dist/events.js.map +1 -1
  41. package/dist/instrument.d.ts +2 -0
  42. package/dist/instrument.d.ts.map +1 -0
  43. package/dist/instrument.js +7 -0
  44. package/dist/instrument.js.map +1 -0
  45. package/dist/log.d.ts +1 -0
  46. package/dist/log.d.ts.map +1 -1
  47. package/dist/log.js +6 -5
  48. package/dist/log.js.map +1 -1
  49. package/dist/main.d.ts +1 -1
  50. package/dist/main.d.ts.map +1 -1
  51. package/dist/main.js +137 -59
  52. package/dist/main.js.map +1 -1
  53. package/dist/sandbox.d.ts +7 -1
  54. package/dist/sandbox.d.ts.map +1 -1
  55. package/dist/sandbox.js +127 -27
  56. package/dist/sandbox.js.map +1 -1
  57. package/dist/sentry.d.ts +31 -0
  58. package/dist/sentry.d.ts.map +1 -0
  59. package/dist/sentry.js +205 -0
  60. package/dist/sentry.js.map +1 -0
  61. package/dist/session-store.d.ts +76 -0
  62. package/dist/session-store.d.ts.map +1 -0
  63. package/dist/session-store.js +189 -0
  64. package/dist/session-store.js.map +1 -0
  65. package/package.json +13 -12
package/dist/agent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Agent } from "@mariozechner/pi-agent-core";
2
2
  import { getModel } from "@mariozechner/pi-ai";
3
- import { AgentSession, AuthStorage, convertToLlm, createExtensionRuntime, formatSkillsForPrompt, loadSkillsFromDir, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
4
- import { existsSync, mkdirSync, readFileSync } from "fs";
3
+ import { AgentSession, AuthStorage, convertToLlm, DefaultResourceLoader, formatSkillsForPrompt, loadSkillsFromDir, ModelRegistry, } from "@mariozechner/pi-coding-agent";
4
+ import { existsSync, readFileSync } from "fs";
5
5
  import { mkdir, readFile, writeFile } from "fs/promises";
6
6
  import { homedir } from "os";
7
7
  import { join } from "path";
@@ -9,7 +9,10 @@ import { loadAgentConfig } from "./config.js";
9
9
  import { createMamaSettingsManager, syncLogToSessionManager } from "./context.js";
10
10
  import * as log from "./log.js";
11
11
  import { createExecutor } from "./sandbox.js";
12
+ import { addLifecycleBreadcrumb, metricAttributes } from "./sentry.js";
13
+ import { createManagedSessionFileAtPath, extractSessionSuffix, extractSessionUuid, forkThreadSessionFile, getSessionDir, getThreadSessionFile, openManagedSession, resolveChannelSessionFile, resolveManagedSessionFile, tryResolveThreadSession, } from "./session-store.js";
12
14
  import { createMamaTools } from "./tools/index.js";
15
+ import * as Sentry from "@sentry/node";
13
16
  const IMAGE_MIME_TYPES = {
14
17
  jpg: "image/jpeg",
15
18
  jpeg: "image/jpeg",
@@ -86,6 +89,7 @@ function loadMamaSkills(channelDir, workspacePath) {
86
89
  function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills) {
87
90
  const channelPath = `${workspacePath}/${channelId}`;
88
91
  const isDocker = sandboxConfig.type === "docker";
92
+ const isFirecracker = sandboxConfig.type === "firecracker";
89
93
  // Format channel mappings
90
94
  const channelMappings = platform.channels.length > 0
91
95
  ? platform.channels.map((c) => `${c.id}\t#${c.name}`).join("\n")
@@ -99,7 +103,12 @@ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, plat
99
103
  - Bash working directory: / (use cd or absolute paths)
100
104
  - Install tools with: apk add <package>
101
105
  - Your changes persist across sessions`
102
- : `You are running directly on the host machine.
106
+ : isFirecracker
107
+ ? `You are running inside a Firecracker microVM.
108
+ - Bash working directory: / (use cd or absolute paths)
109
+ - Install tools with: apt-get install <package> (Debian-based)
110
+ - Your changes persist across sessions`
111
+ : `You are running directly on the host machine.
103
112
  - Bash working directory: ${process.cwd()}
104
113
  - Be careful with system modifications`;
105
114
  return `You are mama, a ${platform.name} bot assistant. Be concise. No emojis.
@@ -108,6 +117,7 @@ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, plat
108
117
  - For current date/time, use: date
109
118
  - You have access to previous conversation context including tool results from prior turns.
110
119
  - For older history beyond your context, search log.jsonl (contains user messages and your final responses, but not tool results).
120
+ - User messages include a \`[in-thread:TS]\` marker when sent from within a Slack thread (TS is the root message timestamp). Without this marker, the message is a top-level channel message.
111
121
 
112
122
  ${platform.formattingGuide}
113
123
 
@@ -163,17 +173,17 @@ You can schedule events that wake you up at specific times or when external thin
163
173
 
164
174
  **Immediate** - Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.
165
175
  \`\`\`json
166
- {"type": "immediate", "channelId": "${channelId}", "text": "New GitHub issue opened"}
176
+ {"type": "immediate", "platform": "${platform.name}", "channelId": "${channelId}", "text": "New GitHub issue opened"}
167
177
  \`\`\`
168
178
 
169
179
  **One-shot** - Triggers once at a specific time. Use for reminders.
170
180
  \`\`\`json
171
- {"type": "one-shot", "channelId": "${channelId}", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
181
+ {"type": "one-shot", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
172
182
  \`\`\`
173
183
 
174
184
  **Periodic** - Triggers on a cron schedule. Use for recurring tasks.
175
185
  \`\`\`json
176
- {"type": "periodic", "channelId": "${channelId}", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
186
+ {"type": "periodic", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
177
187
  \`\`\`
178
188
 
179
189
  ### Cron Format
@@ -186,11 +196,14 @@ You can schedule events that wake you up at specific times or when external thin
186
196
  ### Timezones
187
197
  All \`at\` timestamps must include offset (e.g., \`+01:00\`). Periodic events use IANA timezone names. The harness runs in ${Intl.DateTimeFormat().resolvedOptions().timeZone}. When users mention times without timezone, assume ${Intl.DateTimeFormat().resolvedOptions().timeZone}.
188
198
 
199
+ ### Platform Routing
200
+ Set \`platform\` to the target bot platform (\`${platform.name}\` for this conversation). When only one platform is running, omitting \`platform\` is allowed for backward compatibility, but include it by default to avoid ambiguity.
201
+
189
202
  ### Creating Events
190
203
  Use unique filenames to avoid overwriting existing events. Include a timestamp or random suffix:
191
204
  \`\`\`bash
192
205
  cat > ${workspacePath}/events/dentist-reminder-$(date +%s).json << 'EOF'
193
- {"type": "one-shot", "channelId": "${channelId}", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
206
+ {"type": "one-shot", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
194
207
  EOF
195
208
  \`\`\`
196
209
  Or check if file exists first before creating.
@@ -238,6 +251,7 @@ Update this file whenever you modify the environment. On fresh container, read i
238
251
  Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
239
252
  The log contains user messages and your final responses (not tool calls/results).
240
253
  ${isDocker ? "Install jq: apk add jq" : ""}
254
+ ${isFirecracker ? "Install jq: apt-get install jq" : ""}
241
255
 
242
256
  \`\`\`bash
243
257
  # Recent messages
@@ -350,12 +364,45 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
350
364
  };
351
365
  const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, emptyPlatform, skills);
352
366
  // Create session manager and settings manager
353
- // Per-session context file: {channelDir}/sessions/{rootTs}/context.jsonl
354
- const rootTs = sessionKey.includes(":") ? sessionKey.split(":").pop() : sessionKey;
355
- const sessionDir = join(channelDir, "sessions", rootTs);
356
- mkdirSync(sessionDir, { recursive: true });
357
- const contextFile = join(sessionDir, "context.jsonl");
358
- const sessionManager = SessionManager.open(contextFile, channelDir);
367
+ // Channel sessions use {channelDir}/sessions/current.
368
+ // Thread sessions use fixed files: {channelDir}/sessions/{threadTs}.jsonl
369
+ const sessionDir = getSessionDir(channelDir, sessionKey);
370
+ const isThread = sessionKey.includes(":");
371
+ let sessionManager;
372
+ let contextFile;
373
+ if (isThread) {
374
+ const threadFile = getThreadSessionFile(channelDir, sessionKey);
375
+ const existing = tryResolveThreadSession(threadFile);
376
+ if (existing) {
377
+ contextFile = existing;
378
+ sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
379
+ }
380
+ else {
381
+ const channelSource = resolveChannelSessionFile(channelDir);
382
+ if (channelSource) {
383
+ try {
384
+ contextFile = forkThreadSessionFile(channelSource, threadFile, channelDir);
385
+ sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
386
+ }
387
+ catch {
388
+ contextFile = createManagedSessionFileAtPath(threadFile, channelDir);
389
+ sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
390
+ }
391
+ }
392
+ else {
393
+ contextFile = createManagedSessionFileAtPath(threadFile, channelDir);
394
+ sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
395
+ }
396
+ }
397
+ }
398
+ else {
399
+ // Channel/DM session: normal resolve
400
+ contextFile = resolveManagedSessionFile(sessionDir, channelDir);
401
+ sessionManager = openManagedSession(contextFile, sessionDir, channelDir);
402
+ }
403
+ const sessionUuid = extractSessionUuid(contextFile);
404
+ // Used for Slack thread filtering — for non-Slack platforms this is effectively a no-op
405
+ const rootTs = extractSessionSuffix(sessionKey);
359
406
  const settingsManager = createMamaSettingsManager(join(channelDir, ".."));
360
407
  // Create AuthStorage and ModelRegistry
361
408
  // Auth stored outside workspace so agent can't access it
@@ -371,7 +418,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
371
418
  },
372
419
  convertToLlm,
373
420
  getApiKey: async () => {
374
- const key = await modelRegistry.getApiKey(model);
421
+ const key = await modelRegistry.getApiKeyForProvider(model.provider);
375
422
  if (!key)
376
423
  throw new Error(`No API key for provider "${model.provider}". Set the appropriate environment variable or configure via auth.json`);
377
424
  return key;
@@ -383,25 +430,33 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
383
430
  agent.replaceMessages(loadedSession.messages);
384
431
  log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
385
432
  }
386
- const resourceLoader = {
387
- getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
388
- getSkills: () => ({ skills: [], diagnostics: [] }),
389
- getPrompts: () => ({ prompts: [], diagnostics: [] }),
390
- getThemes: () => ({ themes: [], diagnostics: [] }),
391
- getAgentsFiles: () => ({ agentsFiles: [] }),
392
- getSystemPrompt: () => systemPrompt,
393
- getAppendSystemPrompt: () => [],
394
- getPathMetadata: () => new Map(),
395
- extendResources: () => { },
396
- reload: async () => { },
397
- };
433
+ // Load extensions, skills, prompts, themes via DefaultResourceLoader
434
+ // This reads ~/.pi/agent/settings.json (packages, extensions enable/disable)
435
+ // and discovers resources from standard locations + npm/git packages.
436
+ const resourceLoader = new DefaultResourceLoader({
437
+ cwd: workspaceDir,
438
+ systemPrompt,
439
+ });
440
+ try {
441
+ await resourceLoader.reload();
442
+ const extResult = resourceLoader.getExtensions();
443
+ if (extResult.errors.length > 0) {
444
+ for (const err of extResult.errors) {
445
+ log.logWarning(`[${channelId}] Extension load error: ${err.path}`, err.error);
446
+ }
447
+ }
448
+ log.logInfo(`[${channelId}] Loaded ${extResult.extensions.length} extension(s): ${extResult.extensions.map((e) => e.path).join(", ")}`);
449
+ }
450
+ catch (error) {
451
+ log.logWarning(`[${channelId}] Failed to load resources`, String(error));
452
+ }
398
453
  const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
399
454
  // Create AgentSession wrapper
400
455
  const session = new AgentSession({
401
456
  agent,
402
457
  sessionManager,
403
458
  settingsManager,
404
- cwd: process.cwd(),
459
+ cwd: workspaceDir,
405
460
  modelRegistry,
406
461
  resourceLoader,
407
462
  baseToolsOverride,
@@ -419,6 +474,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
419
474
  cacheWrite: 0,
420
475
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
421
476
  },
477
+ llmCallCount: 0,
422
478
  stopReason: "stop",
423
479
  errorMessage: undefined,
424
480
  };
@@ -428,6 +484,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
428
484
  if (!runState.responseCtx || !runState.logCtx || !runState.queue)
429
485
  return;
430
486
  const { responseCtx, logCtx, queue, pendingTools } = runState;
487
+ const baseAttrs = { channel_id: logCtx.channelId, session_id: logCtx.sessionId };
431
488
  if (event.type === "tool_execution_start") {
432
489
  const agentEvent = event;
433
490
  const args = agentEvent.args;
@@ -437,12 +494,13 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
437
494
  args: agentEvent.args,
438
495
  startTime: Date.now(),
439
496
  });
497
+ addLifecycleBreadcrumb("agent.tool.started", {
498
+ tool: agentEvent.toolName,
499
+ ...baseAttrs,
500
+ });
440
501
  log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
441
- // Split long tool labels to avoid msg_too_long error
442
- const labelParts = splitForSlack(`_→ ${label}_`);
443
- for (const part of labelParts) {
444
- queue.enqueue(() => responseCtx.respond(part), "tool label");
445
- }
502
+ // Tool labels are omitted from the main message to reduce Slack noise.
503
+ // Tool execution details are still posted to the thread (see tool_execution_end).
446
504
  }
447
505
  else if (event.type === "tool_execution_end") {
448
506
  const agentEvent = event;
@@ -450,6 +508,26 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
450
508
  const pending = pendingTools.get(agentEvent.toolCallId);
451
509
  pendingTools.delete(agentEvent.toolCallId);
452
510
  const durationMs = pending ? Date.now() - pending.startTime : 0;
511
+ Sentry.metrics.count("agent.tool.calls", 1, {
512
+ attributes: metricAttributes({
513
+ tool: agentEvent.toolName,
514
+ error: String(agentEvent.isError),
515
+ ...baseAttrs,
516
+ }),
517
+ });
518
+ Sentry.metrics.distribution("agent.tool.duration", durationMs, {
519
+ unit: "millisecond",
520
+ attributes: metricAttributes({
521
+ tool: agentEvent.toolName,
522
+ ...baseAttrs,
523
+ }),
524
+ });
525
+ addLifecycleBreadcrumb("agent.tool.completed", {
526
+ tool: agentEvent.toolName,
527
+ error: agentEvent.isError,
528
+ duration_ms: durationMs,
529
+ ...baseAttrs,
530
+ });
453
531
  if (agentEvent.isError) {
454
532
  log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
455
533
  }
@@ -469,7 +547,12 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
469
547
  if (argsFormatted)
470
548
  threadMessage += `\`\`\`\n${argsFormatted}\n\`\`\`\n`;
471
549
  threadMessage += `*Result:*\n\`\`\`\n${resultStr}\n\`\`\``;
472
- queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
550
+ // Only post thread details for tools with meaningful output (bash, attach).
551
+ // Skip read/write/edit to reduce Slack noise — their results are in the log.
552
+ const quietTools = new Set(["read", "write", "edit"]);
553
+ if (!quietTools.has(agentEvent.toolName)) {
554
+ queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
555
+ }
473
556
  if (agentEvent.isError) {
474
557
  queue.enqueue(() => responseCtx.respond(`_Error: ${truncate(resultStr, 200)}_`), "tool error");
475
558
  }
@@ -477,6 +560,13 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
477
560
  else if (event.type === "message_start") {
478
561
  const agentEvent = event;
479
562
  if (agentEvent.message.role === "assistant") {
563
+ runState.llmCallCount += 1;
564
+ addLifecycleBreadcrumb("agent.llm.call.started", {
565
+ call_index: runState.llmCallCount,
566
+ provider: model.provider,
567
+ model: agentConfig.model,
568
+ ...baseAttrs,
569
+ });
480
570
  log.logResponseStart(logCtx);
481
571
  }
482
572
  }
@@ -500,6 +590,44 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
500
590
  runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
501
591
  runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
502
592
  runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
593
+ // Per-turn LLM metrics
594
+ const llmAttributes = metricAttributes({
595
+ provider: model.provider,
596
+ model: agentConfig.model,
597
+ ...baseAttrs,
598
+ stop_reason: assistantMsg.stopReason,
599
+ error: Boolean(assistantMsg.errorMessage),
600
+ });
601
+ Sentry.metrics.count("agent.llm.calls", 1, { attributes: llmAttributes });
602
+ Sentry.metrics.distribution("agent.llm.tokens_in", assistantMsg.usage.input, {
603
+ attributes: llmAttributes,
604
+ });
605
+ Sentry.metrics.distribution("agent.llm.tokens_out", assistantMsg.usage.output, {
606
+ attributes: llmAttributes,
607
+ });
608
+ if (assistantMsg.usage.cacheRead > 0) {
609
+ Sentry.metrics.distribution("agent.llm.cache_read", assistantMsg.usage.cacheRead, {
610
+ attributes: llmAttributes,
611
+ });
612
+ }
613
+ if (assistantMsg.usage.cacheWrite > 0) {
614
+ Sentry.metrics.distribution("agent.llm.cache_write", assistantMsg.usage.cacheWrite, {
615
+ attributes: llmAttributes,
616
+ });
617
+ }
618
+ Sentry.metrics.distribution("agent.llm.cost_per_turn", assistantMsg.usage.cost.total, {
619
+ attributes: llmAttributes,
620
+ });
621
+ addLifecycleBreadcrumb("agent.llm.call.completed", {
622
+ call_index: runState.llmCallCount,
623
+ provider: model.provider,
624
+ model: agentConfig.model,
625
+ stop_reason: assistantMsg.stopReason,
626
+ error: Boolean(assistantMsg.errorMessage),
627
+ input_tokens: assistantMsg.usage.input,
628
+ output_tokens: assistantMsg.usage.output,
629
+ cost_total_usd: assistantMsg.usage.cost.total,
630
+ });
503
631
  }
504
632
  const content = agentEvent.message.content;
505
633
  const thinkingParts = [];
@@ -521,15 +649,18 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
521
649
  if (text.trim()) {
522
650
  log.logResponse(logCtx, text);
523
651
  queue.enqueueMessage(text, "main", "response main");
524
- queue.enqueueMessage(text, "thread", "response thread", false);
652
+ // Only overflow to thread for texts that will be truncated in main
653
+ if (text.length > SLACK_MAX_LENGTH) {
654
+ queue.enqueueMessage(text, "thread", "response thread", false);
655
+ }
525
656
  }
526
657
  }
527
658
  }
528
- else if (event.type === "auto_compaction_start") {
659
+ else if (event.type === "compaction_start") {
529
660
  log.logInfo(`Auto-compaction started (reason: ${event.reason})`);
530
661
  queue.enqueue(() => responseCtx.respond("_Compacting context..._"), "compaction start");
531
662
  }
532
- else if (event.type === "auto_compaction_end") {
663
+ else if (event.type === "compaction_end") {
533
664
  const compEvent = event;
534
665
  if (compEvent.result) {
535
666
  log.logInfo(`Auto-compaction complete: ${compEvent.result.tokensBefore} tokens compacted`);
@@ -570,7 +701,11 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
570
701
  // Sync messages from log.jsonl that arrived while we were offline or busy
571
702
  // Exclude the current message (it will be added via prompt())
572
703
  // Default sync range is 10 days (handled by syncLogToSessionManager)
573
- const syncedCount = await syncLogToSessionManager(sessionManager, channelDir, message.id);
704
+ // Thread filter ensures only messages from this session's thread are synced
705
+ const threadFilter = message.sessionKey.includes(":")
706
+ ? { scope: "thread", rootTs, threadTs: message.threadTs }
707
+ : { scope: "top-level", rootTs };
708
+ const syncedCount = await syncLogToSessionManager(sessionManager, channelDir, message.id, undefined, threadFilter);
574
709
  if (syncedCount > 0) {
575
710
  log.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);
576
711
  }
@@ -597,6 +732,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
597
732
  channelId: sessionChannel,
598
733
  userName: message.userName,
599
734
  channelName: undefined,
735
+ sessionId: sessionUuid,
600
736
  };
601
737
  runState.pendingTools.clear();
602
738
  runState.totalUsage = {
@@ -606,6 +742,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
606
742
  cacheWrite: 0,
607
743
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
608
744
  };
745
+ runState.llmCallCount = 0;
609
746
  runState.stopReason = "stop";
610
747
  runState.errorMessage = undefined;
611
748
  // Create queue for this run
@@ -651,7 +788,8 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
651
788
  const offsetHours = pad(Math.floor(Math.abs(offset) / 60));
652
789
  const offsetMins = pad(Math.abs(offset) % 60);
653
790
  const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offsetSign}${offsetHours}:${offsetMins}`;
654
- let userMessage = `[${timestamp}] [${message.userName || "unknown"}]: ${message.text}`;
791
+ const threadContext = message.threadTs ? ` [in-thread:${message.threadTs}]` : "";
792
+ let userMessage = `[${timestamp}] [${message.userName || "unknown"}]${threadContext}: ${message.text}`;
655
793
  const imageAttachments = [];
656
794
  const nonImagePaths = [];
657
795
  for (const a of message.attachments || []) {
@@ -685,6 +823,14 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
685
823
  imageAttachmentCount: imageAttachments.length,
686
824
  };
687
825
  await writeFile(join(channelDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
826
+ addLifecycleBreadcrumb("agent.prompt.sent", {
827
+ provider: model.provider,
828
+ model: agentConfig.model,
829
+ channel_id: sessionChannel,
830
+ session_id: sessionUuid,
831
+ attachment_count: message.attachments?.length ?? 0,
832
+ image_attachment_count: imageAttachments.length,
833
+ });
688
834
  await session.prompt(userMessage, imageAttachments.length > 0 ? { images: imageAttachments } : undefined);
689
835
  // Wait for queued messages
690
836
  await queueChain;
@@ -750,11 +896,40 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
750
896
  lastAssistantMessage.usage.cacheWrite
751
897
  : 0;
752
898
  const contextWindow = model.contextWindow || 200000;
899
+ // Run-level Sentry metrics
900
+ const { totalUsage } = runState;
901
+ const runMetricAttributes = metricAttributes({
902
+ provider: model.provider,
903
+ model: agentConfig.model,
904
+ channel_id: sessionChannel,
905
+ session_id: sessionUuid,
906
+ stop_reason: runState.stopReason,
907
+ llm_calls: runState.llmCallCount,
908
+ });
909
+ Sentry.metrics.distribution("agent.run.tokens_in", totalUsage.input, {
910
+ attributes: runMetricAttributes,
911
+ });
912
+ Sentry.metrics.distribution("agent.run.tokens_out", totalUsage.output, {
913
+ attributes: runMetricAttributes,
914
+ });
915
+ Sentry.metrics.distribution("agent.run.cache_read", totalUsage.cacheRead, {
916
+ attributes: runMetricAttributes,
917
+ });
918
+ Sentry.metrics.distribution("agent.run.cache_write", totalUsage.cacheWrite, {
919
+ attributes: runMetricAttributes,
920
+ });
921
+ Sentry.metrics.distribution("agent.run.cost", totalUsage.cost.total, {
922
+ attributes: runMetricAttributes,
923
+ });
924
+ Sentry.metrics.gauge("agent.context.utilization", contextTokens / contextWindow, {
925
+ unit: "ratio",
926
+ attributes: runMetricAttributes,
927
+ });
753
928
  const summary = log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
754
929
  // Split long summaries to avoid msg_too_long
755
930
  const summaryParts = splitForSlack(summary);
756
931
  for (const part of summaryParts) {
757
- runState.queue.enqueue(() => responseCtx.respondInThread(part), "usage summary");
932
+ runState.queue.enqueue(() => responseCtx.respondInThread(part, { style: "muted" }), "usage summary");
758
933
  }
759
934
  await queueChain;
760
935
  }