@geminixiang/mama 0.1.10 → 0.2.0-beta.1

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 (151) hide show
  1. package/README.md +80 -23
  2. package/dist/adapter.d.ts +11 -9
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +2 -2
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +33 -21
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +20 -13
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/slack/bot.d.ts +13 -4
  13. package/dist/adapters/slack/bot.d.ts.map +1 -1
  14. package/dist/adapters/slack/bot.js +98 -43
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/context.d.ts.map +1 -1
  17. package/dist/adapters/slack/context.js +25 -20
  18. package/dist/adapters/slack/context.js.map +1 -1
  19. package/dist/adapters/telegram/bot.d.ts +4 -2
  20. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  21. package/dist/adapters/telegram/bot.js +143 -58
  22. package/dist/adapters/telegram/bot.js.map +1 -1
  23. package/dist/adapters/telegram/context.d.ts +1 -1
  24. package/dist/adapters/telegram/context.d.ts.map +1 -1
  25. package/dist/adapters/telegram/context.js +124 -29
  26. package/dist/adapters/telegram/context.js.map +1 -1
  27. package/dist/agent.d.ts +7 -4
  28. package/dist/agent.d.ts.map +1 -1
  29. package/dist/agent.js +303 -89
  30. package/dist/agent.js.map +1 -1
  31. package/dist/bindings.d.ts +63 -0
  32. package/dist/bindings.d.ts.map +1 -0
  33. package/dist/bindings.js +94 -0
  34. package/dist/bindings.js.map +1 -0
  35. package/dist/config.d.ts +34 -4
  36. package/dist/config.d.ts.map +1 -1
  37. package/dist/config.js +98 -38
  38. package/dist/config.js.map +1 -1
  39. package/dist/context.d.ts +8 -6
  40. package/dist/context.d.ts.map +1 -1
  41. package/dist/context.js +23 -14
  42. package/dist/context.js.map +1 -1
  43. package/dist/events.d.ts +4 -0
  44. package/dist/events.d.ts.map +1 -1
  45. package/dist/events.js +20 -5
  46. package/dist/events.js.map +1 -1
  47. package/dist/execution-resolver.d.ts +20 -0
  48. package/dist/execution-resolver.d.ts.map +1 -0
  49. package/dist/execution-resolver.js +51 -0
  50. package/dist/execution-resolver.js.map +1 -0
  51. package/dist/instrument.d.ts +2 -0
  52. package/dist/instrument.d.ts.map +1 -0
  53. package/dist/instrument.js +14 -0
  54. package/dist/instrument.js.map +1 -0
  55. package/dist/link-server.d.ts +16 -0
  56. package/dist/link-server.d.ts.map +1 -0
  57. package/dist/link-server.js +839 -0
  58. package/dist/link-server.js.map +1 -0
  59. package/dist/link-token.d.ts +32 -0
  60. package/dist/link-token.d.ts.map +1 -0
  61. package/dist/link-token.js +68 -0
  62. package/dist/link-token.js.map +1 -0
  63. package/dist/log.d.ts +3 -2
  64. package/dist/log.d.ts.map +1 -1
  65. package/dist/log.js +10 -9
  66. package/dist/log.js.map +1 -1
  67. package/dist/login.d.ts +29 -0
  68. package/dist/login.d.ts.map +1 -0
  69. package/dist/login.js +164 -0
  70. package/dist/login.js.map +1 -0
  71. package/dist/main.d.ts +1 -1
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +322 -82
  74. package/dist/main.js.map +1 -1
  75. package/dist/provisioner.d.ts +93 -0
  76. package/dist/provisioner.d.ts.map +1 -0
  77. package/dist/provisioner.js +336 -0
  78. package/dist/provisioner.js.map +1 -0
  79. package/dist/sandbox/container.d.ts +15 -0
  80. package/dist/sandbox/container.d.ts.map +1 -0
  81. package/dist/sandbox/container.js +122 -0
  82. package/dist/sandbox/container.js.map +1 -0
  83. package/dist/sandbox/errors.d.ts +6 -0
  84. package/dist/sandbox/errors.d.ts.map +1 -0
  85. package/dist/sandbox/errors.js +11 -0
  86. package/dist/sandbox/errors.js.map +1 -0
  87. package/dist/sandbox/firecracker.d.ts +16 -0
  88. package/dist/sandbox/firecracker.d.ts.map +1 -0
  89. package/dist/sandbox/firecracker.js +206 -0
  90. package/dist/sandbox/firecracker.js.map +1 -0
  91. package/dist/sandbox/host.d.ts +12 -0
  92. package/dist/sandbox/host.d.ts.map +1 -0
  93. package/dist/sandbox/host.js +89 -0
  94. package/dist/sandbox/host.js.map +1 -0
  95. package/dist/sandbox/image.d.ts +5 -0
  96. package/dist/sandbox/image.d.ts.map +1 -0
  97. package/dist/sandbox/image.js +30 -0
  98. package/dist/sandbox/image.js.map +1 -0
  99. package/dist/sandbox/index.d.ts +20 -0
  100. package/dist/sandbox/index.d.ts.map +1 -0
  101. package/dist/sandbox/index.js +51 -0
  102. package/dist/sandbox/index.js.map +1 -0
  103. package/dist/sandbox/types.d.ts +51 -0
  104. package/dist/sandbox/types.d.ts.map +1 -0
  105. package/dist/sandbox/types.js +2 -0
  106. package/dist/sandbox/types.js.map +1 -0
  107. package/dist/sandbox/utils.d.ts +4 -0
  108. package/dist/sandbox/utils.d.ts.map +1 -0
  109. package/dist/sandbox/utils.js +51 -0
  110. package/dist/sandbox/utils.js.map +1 -0
  111. package/dist/sandbox.d.ts +1 -39
  112. package/dist/sandbox.d.ts.map +1 -1
  113. package/dist/sandbox.js +1 -286
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/sentry.d.ts +31 -0
  116. package/dist/sentry.d.ts.map +1 -0
  117. package/dist/sentry.js +205 -0
  118. package/dist/sentry.js.map +1 -0
  119. package/dist/session-store.d.ts +72 -0
  120. package/dist/session-store.d.ts.map +1 -0
  121. package/dist/session-store.js +186 -0
  122. package/dist/session-store.js.map +1 -0
  123. package/dist/store.d.ts +1 -1
  124. package/dist/store.d.ts.map +1 -1
  125. package/dist/store.js +8 -8
  126. package/dist/store.js.map +1 -1
  127. package/dist/tools/event.d.ts +21 -0
  128. package/dist/tools/event.d.ts.map +1 -0
  129. package/dist/tools/event.js +103 -0
  130. package/dist/tools/event.js.map +1 -0
  131. package/dist/tools/index.d.ts +6 -1
  132. package/dist/tools/index.d.ts.map +1 -1
  133. package/dist/tools/index.js +5 -1
  134. package/dist/tools/index.js.map +1 -1
  135. package/dist/ui-copy.d.ts +11 -0
  136. package/dist/ui-copy.d.ts.map +1 -0
  137. package/dist/ui-copy.js +33 -0
  138. package/dist/ui-copy.js.map +1 -0
  139. package/dist/vault-routing.d.ts +10 -0
  140. package/dist/vault-routing.d.ts.map +1 -0
  141. package/dist/vault-routing.js +58 -0
  142. package/dist/vault-routing.js.map +1 -0
  143. package/dist/vault.d.ts +106 -0
  144. package/dist/vault.d.ts.map +1 -0
  145. package/dist/vault.js +389 -0
  146. package/dist/vault.js.map +1 -0
  147. package/dist/vault.test.d.ts +2 -0
  148. package/dist/vault.test.d.ts.map +1 -0
  149. package/dist/vault.test.js +67 -0
  150. package/dist/vault.test.js.map +1 -0
  151. package/package.json +13 -11
package/dist/agent.js CHANGED
@@ -1,15 +1,19 @@
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, getAgentDir, 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";
8
8
  import { loadAgentConfig } from "./config.js";
9
9
  import { createMamaSettingsManager, syncLogToSessionManager } from "./context.js";
10
+ import { ActorExecutionResolver } from "./execution-resolver.js";
10
11
  import * as log from "./log.js";
11
12
  import { createExecutor } from "./sandbox.js";
13
+ import { addLifecycleBreadcrumb, metricAttributes } from "./sentry.js";
14
+ import { createManagedSessionFileAtPath, extractSessionSuffix, extractSessionUuid, forkThreadSessionFile, getChannelSessionDir, getThreadSessionFile, openManagedSession, resolveChannelSessionFile, resolveManagedSessionFile, tryResolveThreadSession, } from "./session-store.js";
12
15
  import { createMamaTools } from "./tools/index.js";
16
+ import * as Sentry from "@sentry/node";
13
17
  const IMAGE_MIME_TYPES = {
14
18
  jpg: "image/jpeg",
15
19
  jpeg: "image/jpeg",
@@ -20,10 +24,10 @@ const IMAGE_MIME_TYPES = {
20
24
  function getImageMimeType(filename) {
21
25
  return IMAGE_MIME_TYPES[filename.toLowerCase().split(".").pop() || ""];
22
26
  }
23
- async function getMemory(channelDir) {
27
+ async function getMemory(conversationDir) {
24
28
  const parts = [];
25
- // Read workspace-level memory (shared across all channels)
26
- const workspaceMemoryPath = join(channelDir, "..", "MEMORY.md");
29
+ // Read workspace-level memory (shared across all conversations)
30
+ const workspaceMemoryPath = join(conversationDir, "..", "MEMORY.md");
27
31
  if (existsSync(workspaceMemoryPath)) {
28
32
  try {
29
33
  const content = (await readFile(workspaceMemoryPath, "utf-8")).trim();
@@ -35,17 +39,17 @@ async function getMemory(channelDir) {
35
39
  log.logWarning("Failed to read workspace memory", `${workspaceMemoryPath}: ${error}`);
36
40
  }
37
41
  }
38
- // Read channel-specific memory
39
- const channelMemoryPath = join(channelDir, "MEMORY.md");
40
- if (existsSync(channelMemoryPath)) {
42
+ // Read conversation-specific memory
43
+ const conversationMemoryPath = join(conversationDir, "MEMORY.md");
44
+ if (existsSync(conversationMemoryPath)) {
41
45
  try {
42
- const content = (await readFile(channelMemoryPath, "utf-8")).trim();
46
+ const content = (await readFile(conversationMemoryPath, "utf-8")).trim();
43
47
  if (content) {
44
- parts.push(`### Channel-Specific Memory\n${content}`);
48
+ parts.push(`### Conversation-Specific Memory\n${content}`);
45
49
  }
46
50
  }
47
51
  catch (error) {
48
- log.logWarning("Failed to read channel memory", `${channelMemoryPath}: ${error}`);
52
+ log.logWarning("Failed to read conversation memory", `${conversationMemoryPath}: ${error}`);
49
53
  }
50
54
  }
51
55
  if (parts.length === 0) {
@@ -53,12 +57,12 @@ async function getMemory(channelDir) {
53
57
  }
54
58
  return parts.join("\n\n");
55
59
  }
56
- function loadMamaSkills(channelDir, workspacePath) {
60
+ function loadMamaSkills(conversationDir, workspacePath) {
57
61
  const skillMap = new Map();
58
- // channelDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)
62
+ // conversationDir is the host path (e.g., /Users/.../data/C0A34FL8PMH)
59
63
  // hostWorkspacePath is the parent directory on host
60
64
  // workspacePath is the container path (e.g., /workspace)
61
- const hostWorkspacePath = join(channelDir, "..");
65
+ const hostWorkspacePath = join(conversationDir, "..");
62
66
  // Helper to translate host paths to container paths
63
67
  const translatePath = (hostPath) => {
64
68
  if (hostPath.startsWith(hostWorkspacePath)) {
@@ -74,20 +78,20 @@ function loadMamaSkills(channelDir, workspacePath) {
74
78
  skill.baseDir = translatePath(skill.baseDir);
75
79
  skillMap.set(skill.name, skill);
76
80
  }
77
- // Load channel-specific skills (override workspace skills on collision)
78
- const channelSkillsDir = join(channelDir, "skills");
79
- for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" }).skills) {
81
+ // Load conversation-specific skills (override workspace skills on collision)
82
+ const conversationSkillsDir = join(conversationDir, "skills");
83
+ for (const skill of loadSkillsFromDir({ dir: conversationSkillsDir, source: "channel" }).skills) {
80
84
  skill.filePath = translatePath(skill.filePath);
81
85
  skill.baseDir = translatePath(skill.baseDir);
82
86
  skillMap.set(skill.name, skill);
83
87
  }
84
88
  return Array.from(skillMap.values());
85
89
  }
86
- function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills) {
87
- const channelPath = `${workspacePath}/${channelId}`;
88
- const isDocker = sandboxConfig.type === "docker";
90
+ function buildSystemPrompt(workspacePath, conversationId, currentUserId, memory, sandboxConfig, platform, skills) {
91
+ const conversationPath = `${workspacePath}/${conversationId}`;
92
+ const isContainer = sandboxConfig.type === "container" || sandboxConfig.type === "image";
89
93
  const isFirecracker = sandboxConfig.type === "firecracker";
90
- // Format channel mappings
94
+ // Format platform conversation mappings
91
95
  const channelMappings = platform.channels.length > 0
92
96
  ? platform.channels.map((c) => `${c.id}\t#${c.name}`).join("\n")
93
97
  : "(no channels loaded)";
@@ -95,8 +99,8 @@ function buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, plat
95
99
  const userMappings = platform.users.length > 0
96
100
  ? platform.users.map((u) => `${u.id}\t@${u.userName}\t${u.displayName}`).join("\n")
97
101
  : "(no users loaded)";
98
- const envDescription = isDocker
99
- ? `You are running inside a Docker container (Alpine Linux).
102
+ const envDescription = isContainer
103
+ ? `You are running inside a container (Docker runtime, Alpine Linux).
100
104
  - Bash working directory: / (use cd or absolute paths)
101
105
  - Install tools with: apk add <package>
102
106
  - Your changes persist across sessions`
@@ -132,18 +136,18 @@ ${envDescription}
132
136
  ${workspacePath}/
133
137
  ├── MEMORY.md # Global memory (all channels)
134
138
  ├── skills/ # Global CLI tools you create
135
- └── ${channelId}/ # This channel
136
- ├── MEMORY.md # Channel-specific memory
139
+ └── ${conversationId}/ # This conversation
140
+ ├── MEMORY.md # Conversation-specific memory
137
141
  ├── log.jsonl # Message history (no tool results)
138
142
  ├── attachments/ # User-shared files
139
143
  ├── scratch/ # Your working directory
140
- └── skills/ # Channel-specific tools
144
+ └── skills/ # Conversation-specific tools
141
145
 
142
146
  ## Skills (Custom CLI Tools)
143
147
  You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
144
148
 
145
149
  ### Creating Skills
146
- Store in \`${workspacePath}/skills/<name>/\` (global) or \`${channelPath}/skills/<name>/\` (channel-specific).
150
+ Store in \`${workspacePath}/skills/<name>/\` (global) or \`${conversationPath}/skills/<name>/\` (conversation-specific).
147
151
  Each skill directory needs a \`SKILL.md\` with YAML frontmatter:
148
152
 
149
153
  \`\`\`markdown
@@ -170,19 +174,21 @@ You can schedule events that wake you up at specific times or when external thin
170
174
 
171
175
  **Immediate** - Triggers as soon as harness sees the file. Use in scripts/webhooks to signal external events.
172
176
  \`\`\`json
173
- {"type": "immediate", "platform": "${platform.name}", "channelId": "${channelId}", "text": "New GitHub issue opened"}
177
+ {"type": "immediate", "platform": "${platform.name}", "channelId": "${conversationId}", "userId": "<requester userId>", "text": "New GitHub issue opened"}
174
178
  \`\`\`
175
179
 
176
180
  **One-shot** - Triggers once at a specific time. Use for reminders.
177
181
  \`\`\`json
178
- {"type": "one-shot", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
182
+ {"type": "one-shot", "platform": "${platform.name}", "channelId": "${conversationId}", "userId": "<requester userId>", "text": "Remind Mario about dentist", "at": "2025-12-15T09:00:00+01:00"}
179
183
  \`\`\`
180
184
 
181
185
  **Periodic** - Triggers on a cron schedule. Use for recurring tasks.
182
186
  \`\`\`json
183
- {"type": "periodic", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
187
+ {"type": "periodic", "platform": "${platform.name}", "channelId": "${conversationId}", "userId": "<requester userId>", "text": "Check inbox and summarize", "schedule": "0 9 * * 1-5", "timezone": "${Intl.DateTimeFormat().resolvedOptions().timeZone}"}
184
188
  \`\`\`
185
189
 
190
+ Set \`userId\` to the platform userId of whoever asked for the event (look it up in the user mappings above). When the event fires, tool execution will route to the sandbox vault selection for that user so the right credentials are available. In shared container mode, all events use the container's single shared vault.
191
+
186
192
  ### Cron Format
187
193
  \`minute hour day-of-month month day-of-week\`
188
194
  - \`0 9 * * *\` = daily at 9:00
@@ -197,10 +203,19 @@ All \`at\` timestamps must include offset (e.g., \`+01:00\`). Periodic events us
197
203
  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.
198
204
 
199
205
  ### Creating Events
206
+ Prefer the \`event\` tool. It automatically writes to the correct events directory and fills the current \`platform\`, \`channelId\`, and requester \`userId\`.
207
+ Do not use \`bash\` or \`write\` to hand-create JSON files in \`/events\` unless the user explicitly asks for manual file editing.
208
+
209
+ Current conversation defaults:
210
+ - \`platform\`: \`${platform.name}\`
211
+ - \`channelId\`: \`${conversationId}\`
212
+ - \`userId\`: \`${currentUserId ?? "unknown"}\`
213
+
214
+ Manual file creation is fallback only:
200
215
  Use unique filenames to avoid overwriting existing events. Include a timestamp or random suffix:
201
216
  \`\`\`bash
202
217
  cat > ${workspacePath}/events/dentist-reminder-$(date +%s).json << 'EOF'
203
- {"type": "one-shot", "platform": "${platform.name}", "channelId": "${channelId}", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
218
+ {"type": "one-shot", "platform": "${platform.name}", "channelId": "${conversationId}", "userId": "<requester userId>", "text": "Dentist tomorrow", "at": "2025-12-14T09:00:00+01:00"}
204
219
  EOF
205
220
  \`\`\`
206
221
  Or check if file exists first before creating.
@@ -229,7 +244,7 @@ Maximum 5 events can be queued. Don't create excessive immediate or periodic eve
229
244
  ## Memory
230
245
  Write to MEMORY.md files to persist context across conversations.
231
246
  - Global (${workspacePath}/MEMORY.md): skills, preferences, project info
232
- - Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work
247
+ - Conversation (${conversationPath}/MEMORY.md): conversation-specific decisions, ongoing work
233
248
  Update when you learn something important or when asked to remember something.
234
249
 
235
250
  ### Current Memory
@@ -247,7 +262,7 @@ Update this file whenever you modify the environment. On fresh container, read i
247
262
  ## Log Queries (for older history)
248
263
  Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
249
264
  The log contains user messages and your final responses (not tool calls/results).
250
- ${isDocker ? "Install jq: apk add jq" : ""}
265
+ ${isContainer ? "Install jq: apk add jq" : ""}
251
266
  ${isFirecracker ? "Install jq: apt-get install jq" : ""}
252
267
 
253
268
  \`\`\`bash
@@ -328,50 +343,106 @@ function formatToolArgsForSlack(_toolName, args) {
328
343
  // Agent runner
329
344
  // ============================================================================
330
345
  /**
331
- * Create a new AgentRunner for a channel.
346
+ * Create a new AgentRunner for a conversation.
332
347
  * Sets up the session and subscribes to events once.
333
348
  *
334
- * Runner caching is handled by the caller (channelStates in main.ts).
349
+ * Runner caching is handled by the caller (conversationStates in main.ts).
335
350
  * This is a stateless factory function.
336
351
  */
337
- export async function createRunner(sandboxConfig, sessionKey, channelId, channelDir, workspaceDir) {
338
- const agentConfig = loadAgentConfig(workspaceDir);
352
+ export async function createRunner(sandboxConfig, sessionKey, conversationId, conversationDir, workspaceDir, vaultManager, bindingStore, provisioner, stateDir) {
353
+ const agentConfig = loadAgentConfig(stateDir ?? workspaceDir);
339
354
  // Initialize logger with settings from config
340
355
  log.initLogger({
341
356
  logFormat: agentConfig.logFormat,
342
357
  logLevel: agentConfig.logLevel,
343
358
  });
344
- const executor = createExecutor(sandboxConfig);
345
- const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
359
+ const executionResolver = vaultManager &&
360
+ (vaultManager.isEnabled() ||
361
+ !!bindingStore ||
362
+ sandboxConfig.type === "image" ||
363
+ sandboxConfig.type === "container")
364
+ ? new ActorExecutionResolver(sandboxConfig, vaultManager, bindingStore, provisioner)
365
+ : undefined;
366
+ let activeExecutor = executionResolver !== undefined
367
+ ? createExecutor({ type: "host" })
368
+ : createExecutor(sandboxConfig);
369
+ const executor = {
370
+ exec(command, options) {
371
+ return activeExecutor.exec(command, options);
372
+ },
373
+ getWorkspacePath(hostPath) {
374
+ return activeExecutor.getWorkspacePath(hostPath);
375
+ },
376
+ getSandboxConfig() {
377
+ return activeExecutor.getSandboxConfig();
378
+ },
379
+ };
380
+ const workspaceBase = conversationDir.replace(`/${conversationId}`, "");
381
+ // Compute workspace path from the current executor. This may change per run.
382
+ const getWorkspacePath = () => executor.getWorkspacePath(workspaceBase);
383
+ let workspacePath = getWorkspacePath();
346
384
  // Create tools (per-runner, with per-runner upload function setter)
347
- const { tools, setUploadFunction } = createMamaTools(executor);
385
+ const { tools, setUploadFunction, setEventContext } = createMamaTools(executor, workspaceDir);
348
386
  // Resolve model from config
349
387
  // Use 'as any' cast because agentConfig.provider/model are plain strings,
350
388
  // while getModel() has constrained generic types for known providers.
351
389
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
352
390
  const model = getModel(agentConfig.provider, agentConfig.model);
353
391
  // Initial system prompt (will be updated each run with fresh memory/channels/users/skills)
354
- const memory = await getMemory(channelDir);
355
- const skills = loadMamaSkills(channelDir, workspacePath);
392
+ const memory = await getMemory(conversationDir);
393
+ const skills = loadMamaSkills(conversationDir, workspacePath);
356
394
  const emptyPlatform = {
357
395
  name: "slack",
358
396
  formattingGuide: "",
359
397
  channels: [],
360
398
  users: [],
361
399
  };
362
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, emptyPlatform, skills);
363
- // Create session manager and settings manager
364
- // Per-session context file: {channelDir}/sessions/{rootTs}/context.jsonl
365
- const rootTs = sessionKey.includes(":") ? sessionKey.split(":").pop() : sessionKey;
366
- const sessionDir = join(channelDir, "sessions", rootTs);
367
- mkdirSync(sessionDir, { recursive: true });
368
- const contextFile = join(sessionDir, "context.jsonl");
369
- const sessionManager = SessionManager.open(contextFile, channelDir);
370
- const settingsManager = createMamaSettingsManager(join(channelDir, ".."));
400
+ const systemPrompt = buildSystemPrompt(workspacePath, conversationId, undefined, memory, sandboxConfig, emptyPlatform, skills);
401
+ // Create session manager and settings manager.
402
+ // Top-level conversation sessions use {conversationDir}/sessions/current.
403
+ // Thread sessions use fixed files: {conversationDir}/sessions/{threadTs}.jsonl.
404
+ const sessionDir = getChannelSessionDir(conversationDir);
405
+ const isThread = sessionKey.includes(":");
406
+ let sessionManager;
407
+ let sessionFile;
408
+ if (isThread) {
409
+ const threadFile = getThreadSessionFile(conversationDir, sessionKey);
410
+ const existing = tryResolveThreadSession(threadFile);
411
+ if (existing) {
412
+ sessionFile = existing;
413
+ sessionManager = openManagedSession(sessionFile, sessionDir, conversationDir);
414
+ }
415
+ else {
416
+ const conversationSource = resolveChannelSessionFile(conversationDir);
417
+ if (conversationSource) {
418
+ try {
419
+ sessionFile = forkThreadSessionFile(conversationSource, threadFile, conversationDir);
420
+ sessionManager = openManagedSession(sessionFile, sessionDir, conversationDir);
421
+ }
422
+ catch {
423
+ sessionFile = createManagedSessionFileAtPath(threadFile, conversationDir);
424
+ sessionManager = openManagedSession(sessionFile, sessionDir, conversationDir);
425
+ }
426
+ }
427
+ else {
428
+ sessionFile = createManagedSessionFileAtPath(threadFile, conversationDir);
429
+ sessionManager = openManagedSession(sessionFile, sessionDir, conversationDir);
430
+ }
431
+ }
432
+ }
433
+ else {
434
+ // Top-level conversation session: resolve the current session file.
435
+ sessionFile = resolveManagedSessionFile(sessionDir, conversationDir);
436
+ sessionManager = openManagedSession(sessionFile, sessionDir, conversationDir);
437
+ }
438
+ const sessionUuid = extractSessionUuid(sessionFile);
439
+ // Used for Slack thread filtering — for non-Slack platforms this is effectively a no-op
440
+ const rootTs = extractSessionSuffix(sessionKey);
441
+ const settingsManager = createMamaSettingsManager(join(conversationDir, ".."));
371
442
  // Create AuthStorage and ModelRegistry
372
443
  // Auth stored outside workspace so agent can't access it
373
444
  const authStorage = AuthStorage.create(join(homedir(), ".pi", "mama", "auth.json"));
374
- const modelRegistry = new ModelRegistry(authStorage);
445
+ const modelRegistry = ModelRegistry.create(authStorage);
375
446
  // Create agent
376
447
  const agent = new Agent({
377
448
  initialState: {
@@ -391,27 +462,37 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
391
462
  // Load existing messages
392
463
  const loadedSession = sessionManager.buildSessionContext();
393
464
  if (loadedSession.messages.length > 0) {
394
- agent.replaceMessages(loadedSession.messages);
395
- log.logInfo(`[${channelId}] Loaded ${loadedSession.messages.length} messages from context.jsonl`);
465
+ agent.state.messages = loadedSession.messages;
466
+ log.logInfo(`[${conversationId}] Loaded ${loadedSession.messages.length} messages from session file`);
467
+ }
468
+ // Load extensions, skills, prompts, themes via DefaultResourceLoader
469
+ // This reads ~/.pi/agent/settings.json (packages, extensions enable/disable)
470
+ // and discovers resources from standard locations + npm/git packages.
471
+ const resourceLoader = new DefaultResourceLoader({
472
+ cwd: workspaceDir,
473
+ agentDir: getAgentDir(),
474
+ systemPrompt,
475
+ });
476
+ try {
477
+ await resourceLoader.reload();
478
+ const extResult = resourceLoader.getExtensions();
479
+ if (extResult.errors.length > 0) {
480
+ for (const err of extResult.errors) {
481
+ log.logWarning(`[${conversationId}] Extension load error: ${err.path}`, err.error);
482
+ }
483
+ }
484
+ log.logInfo(`[${conversationId}] Loaded ${extResult.extensions.length} extension(s): ${extResult.extensions.map((e) => e.path).join(", ")}`);
485
+ }
486
+ catch (error) {
487
+ log.logWarning(`[${conversationId}] Failed to load resources`, String(error));
396
488
  }
397
- const resourceLoader = {
398
- getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
399
- getSkills: () => ({ skills: [], diagnostics: [] }),
400
- getPrompts: () => ({ prompts: [], diagnostics: [] }),
401
- getThemes: () => ({ themes: [], diagnostics: [] }),
402
- getAgentsFiles: () => ({ agentsFiles: [] }),
403
- getSystemPrompt: () => systemPrompt,
404
- getAppendSystemPrompt: () => [],
405
- extendResources: () => { },
406
- reload: async () => { },
407
- };
408
489
  const baseToolsOverride = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
409
490
  // Create AgentSession wrapper
410
491
  const session = new AgentSession({
411
492
  agent,
412
493
  sessionManager,
413
494
  settingsManager,
414
- cwd: process.cwd(),
495
+ cwd: workspaceDir,
415
496
  modelRegistry,
416
497
  resourceLoader,
417
498
  baseToolsOverride,
@@ -429,6 +510,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
429
510
  cacheWrite: 0,
430
511
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
431
512
  },
513
+ llmCallCount: 0,
432
514
  stopReason: "stop",
433
515
  errorMessage: undefined,
434
516
  };
@@ -438,6 +520,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
438
520
  if (!runState.responseCtx || !runState.logCtx || !runState.queue)
439
521
  return;
440
522
  const { responseCtx, logCtx, queue, pendingTools } = runState;
523
+ const baseAttrs = { channel_id: logCtx.conversationId, session_id: logCtx.sessionId };
441
524
  if (event.type === "tool_execution_start") {
442
525
  const agentEvent = event;
443
526
  const args = agentEvent.args;
@@ -447,6 +530,10 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
447
530
  args: agentEvent.args,
448
531
  startTime: Date.now(),
449
532
  });
533
+ addLifecycleBreadcrumb("agent.tool.started", {
534
+ tool: agentEvent.toolName,
535
+ ...baseAttrs,
536
+ });
450
537
  log.logToolStart(logCtx, agentEvent.toolName, label, agentEvent.args);
451
538
  // Tool labels are omitted from the main message to reduce Slack noise.
452
539
  // Tool execution details are still posted to the thread (see tool_execution_end).
@@ -457,6 +544,26 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
457
544
  const pending = pendingTools.get(agentEvent.toolCallId);
458
545
  pendingTools.delete(agentEvent.toolCallId);
459
546
  const durationMs = pending ? Date.now() - pending.startTime : 0;
547
+ Sentry.metrics.count("agent.tool.calls", 1, {
548
+ attributes: metricAttributes({
549
+ tool: agentEvent.toolName,
550
+ error: String(agentEvent.isError),
551
+ ...baseAttrs,
552
+ }),
553
+ });
554
+ Sentry.metrics.distribution("agent.tool.duration", durationMs, {
555
+ unit: "millisecond",
556
+ attributes: metricAttributes({
557
+ tool: agentEvent.toolName,
558
+ ...baseAttrs,
559
+ }),
560
+ });
561
+ addLifecycleBreadcrumb("agent.tool.completed", {
562
+ tool: agentEvent.toolName,
563
+ error: agentEvent.isError,
564
+ duration_ms: durationMs,
565
+ ...baseAttrs,
566
+ });
460
567
  if (agentEvent.isError) {
461
568
  log.logToolError(logCtx, agentEvent.toolName, durationMs, resultStr);
462
569
  }
@@ -489,6 +596,13 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
489
596
  else if (event.type === "message_start") {
490
597
  const agentEvent = event;
491
598
  if (agentEvent.message.role === "assistant") {
599
+ runState.llmCallCount += 1;
600
+ addLifecycleBreadcrumb("agent.llm.call.started", {
601
+ call_index: runState.llmCallCount,
602
+ provider: model.provider,
603
+ model: agentConfig.model,
604
+ ...baseAttrs,
605
+ });
492
606
  log.logResponseStart(logCtx);
493
607
  }
494
608
  }
@@ -512,6 +626,44 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
512
626
  runState.totalUsage.cost.cacheRead += assistantMsg.usage.cost.cacheRead;
513
627
  runState.totalUsage.cost.cacheWrite += assistantMsg.usage.cost.cacheWrite;
514
628
  runState.totalUsage.cost.total += assistantMsg.usage.cost.total;
629
+ // Per-turn LLM metrics
630
+ const llmAttributes = metricAttributes({
631
+ provider: model.provider,
632
+ model: agentConfig.model,
633
+ ...baseAttrs,
634
+ stop_reason: assistantMsg.stopReason,
635
+ error: Boolean(assistantMsg.errorMessage),
636
+ });
637
+ Sentry.metrics.count("agent.llm.calls", 1, { attributes: llmAttributes });
638
+ Sentry.metrics.distribution("agent.llm.tokens_in", assistantMsg.usage.input, {
639
+ attributes: llmAttributes,
640
+ });
641
+ Sentry.metrics.distribution("agent.llm.tokens_out", assistantMsg.usage.output, {
642
+ attributes: llmAttributes,
643
+ });
644
+ if (assistantMsg.usage.cacheRead > 0) {
645
+ Sentry.metrics.distribution("agent.llm.cache_read", assistantMsg.usage.cacheRead, {
646
+ attributes: llmAttributes,
647
+ });
648
+ }
649
+ if (assistantMsg.usage.cacheWrite > 0) {
650
+ Sentry.metrics.distribution("agent.llm.cache_write", assistantMsg.usage.cacheWrite, {
651
+ attributes: llmAttributes,
652
+ });
653
+ }
654
+ Sentry.metrics.distribution("agent.llm.cost_per_turn", assistantMsg.usage.cost.total, {
655
+ attributes: llmAttributes,
656
+ });
657
+ addLifecycleBreadcrumb("agent.llm.call.completed", {
658
+ call_index: runState.llmCallCount,
659
+ provider: model.provider,
660
+ model: agentConfig.model,
661
+ stop_reason: assistantMsg.stopReason,
662
+ error: Boolean(assistantMsg.errorMessage),
663
+ input_tokens: assistantMsg.usage.input,
664
+ output_tokens: assistantMsg.usage.output,
665
+ cost_total_usd: assistantMsg.usage.cost.total,
666
+ });
515
667
  }
516
668
  const content = agentEvent.message.content;
517
669
  const thinkingParts = [];
@@ -578,41 +730,65 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
578
730
  };
579
731
  return {
580
732
  async run(message, responseCtx, platform) {
581
- // Extract channelId from sessionKey (format: "channelId:rootTs" or just "channelId")
582
- const sessionChannel = message.sessionKey.split(":")[0];
583
- // Ensure channel directory exists
584
- await mkdir(channelDir, { recursive: true });
733
+ // Extract conversationId from sessionKey (format: "conversationId:rootTs" or just "conversationId")
734
+ const sessionConversationId = message.sessionKey.split(":")[0];
735
+ // Ensure the conversation directory exists
736
+ await mkdir(conversationDir, { recursive: true });
737
+ // Refresh vault config and clear executor cache so credential changes
738
+ // (env file updates, vault.json edits, token rotations) take effect.
739
+ // Then set the active actor BEFORE building system prompt, so workspacePath
740
+ // reflects the actor's sandbox type.
741
+ if (executionResolver) {
742
+ executionResolver.refresh();
743
+ activeExecutor = await executionResolver.resolve({
744
+ platform: platform.name,
745
+ userId: message.userId,
746
+ });
747
+ workspacePath = getWorkspacePath();
748
+ }
585
749
  // Sync messages from log.jsonl that arrived while we were offline or busy
586
750
  // Exclude the current message (it will be added via prompt())
587
751
  // Default sync range is 10 days (handled by syncLogToSessionManager)
588
752
  // Thread filter ensures only messages from this session's thread are synced
589
- const syncedCount = await syncLogToSessionManager(sessionManager, channelDir, message.id, undefined, { rootTs, threadTs: message.threadTs });
753
+ const threadFilter = message.sessionKey.includes(":")
754
+ ? { scope: "thread", rootTs, threadTs: message.threadTs }
755
+ : { scope: "top-level", rootTs };
756
+ const syncedCount = await syncLogToSessionManager(sessionManager, conversationDir, message.id, undefined, threadFilter);
590
757
  if (syncedCount > 0) {
591
- log.logInfo(`[${channelId}] Synced ${syncedCount} messages from log.jsonl`);
758
+ log.logInfo(`[${conversationId}] Synced ${syncedCount} messages from log.jsonl`);
592
759
  }
593
- // Reload messages from context.jsonl
594
- // This picks up any messages synced above
760
+ // Reload messages from the session file.
761
+ // This picks up any messages synced above.
595
762
  const reloadedSession = sessionManager.buildSessionContext();
596
763
  if (reloadedSession.messages.length > 0) {
597
- agent.replaceMessages(reloadedSession.messages);
598
- log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`);
764
+ agent.state.messages = reloadedSession.messages;
765
+ log.logInfo(`[${conversationId}] Reloaded ${reloadedSession.messages.length} messages from context`);
599
766
  }
600
767
  // Update system prompt with fresh memory, channel/user info, and skills
601
- const memory = await getMemory(channelDir);
602
- const skills = loadMamaSkills(channelDir, workspacePath);
603
- const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, platform, skills);
604
- session.agent.setSystemPrompt(systemPrompt);
768
+ // Use the actual executor's sandbox config, not the initial config,
769
+ // to ensure accurate environment description for the model
770
+ const memory = await getMemory(conversationDir);
771
+ const skills = loadMamaSkills(conversationDir, workspacePath);
772
+ const actualSandboxConfig = executor.getSandboxConfig();
773
+ const systemPrompt = buildSystemPrompt(workspacePath, conversationId, message.userId, memory, actualSandboxConfig, platform, skills);
774
+ session.agent.state.systemPrompt = systemPrompt;
605
775
  // Set up file upload function
606
776
  setUploadFunction(async (filePath, title) => {
607
- const hostPath = translateToHostPath(filePath, channelDir, workspacePath, channelId);
777
+ const hostPath = translateToHostPath(filePath, conversationDir, workspacePath, conversationId);
608
778
  await responseCtx.uploadFile(hostPath, title);
609
779
  });
780
+ setEventContext({
781
+ platform: platform.name,
782
+ conversationId,
783
+ userId: message.userId,
784
+ });
610
785
  // Reset per-run state
611
786
  runState.responseCtx = responseCtx;
612
787
  runState.logCtx = {
613
- channelId: sessionChannel,
788
+ conversationId: sessionConversationId,
614
789
  userName: message.userName,
615
- channelName: undefined,
790
+ conversationName: undefined,
791
+ sessionId: sessionUuid,
616
792
  };
617
793
  runState.pendingTools.clear();
618
794
  runState.totalUsage = {
@@ -622,6 +798,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
622
798
  cacheWrite: 0,
623
799
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
624
800
  };
801
+ runState.llmCallCount = 0;
625
802
  runState.stopReason = "stop";
626
803
  runState.errorMessage = undefined;
627
804
  // Create queue for this run
@@ -672,7 +849,7 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
672
849
  const imageAttachments = [];
673
850
  const nonImagePaths = [];
674
851
  for (const a of message.attachments || []) {
675
- // a.localPath is the path relative to the workspace (same as old a.local)
852
+ // a.localPath is the path relative to the workspace
676
853
  const fullPath = `${workspacePath}/${a.localPath}`;
677
854
  const mimeType = getImageMimeType(a.localPath);
678
855
  if (mimeType && existsSync(fullPath)) {
@@ -701,7 +878,15 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
701
878
  newUserMessage: userMessage,
702
879
  imageAttachmentCount: imageAttachments.length,
703
880
  };
704
- await writeFile(join(channelDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
881
+ await writeFile(join(conversationDir, "last_prompt.jsonl"), JSON.stringify(debugContext, null, 2));
882
+ addLifecycleBreadcrumb("agent.prompt.sent", {
883
+ provider: model.provider,
884
+ model: agentConfig.model,
885
+ channel_id: sessionConversationId,
886
+ session_id: sessionUuid,
887
+ attachment_count: message.attachments?.length ?? 0,
888
+ image_attachment_count: imageAttachments.length,
889
+ });
705
890
  await session.prompt(userMessage, imageAttachments.length > 0 ? { images: imageAttachments } : undefined);
706
891
  // Wait for queued messages
707
892
  await queueChain;
@@ -767,6 +952,35 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
767
952
  lastAssistantMessage.usage.cacheWrite
768
953
  : 0;
769
954
  const contextWindow = model.contextWindow || 200000;
955
+ // Run-level Sentry metrics
956
+ const { totalUsage } = runState;
957
+ const runMetricAttributes = metricAttributes({
958
+ provider: model.provider,
959
+ model: agentConfig.model,
960
+ channel_id: sessionConversationId,
961
+ session_id: sessionUuid,
962
+ stop_reason: runState.stopReason,
963
+ llm_calls: runState.llmCallCount,
964
+ });
965
+ Sentry.metrics.distribution("agent.run.tokens_in", totalUsage.input, {
966
+ attributes: runMetricAttributes,
967
+ });
968
+ Sentry.metrics.distribution("agent.run.tokens_out", totalUsage.output, {
969
+ attributes: runMetricAttributes,
970
+ });
971
+ Sentry.metrics.distribution("agent.run.cache_read", totalUsage.cacheRead, {
972
+ attributes: runMetricAttributes,
973
+ });
974
+ Sentry.metrics.distribution("agent.run.cache_write", totalUsage.cacheWrite, {
975
+ attributes: runMetricAttributes,
976
+ });
977
+ Sentry.metrics.distribution("agent.run.cost", totalUsage.cost.total, {
978
+ attributes: runMetricAttributes,
979
+ });
980
+ Sentry.metrics.gauge("agent.context.utilization", contextTokens / contextWindow, {
981
+ unit: "ratio",
982
+ attributes: runMetricAttributes,
983
+ });
770
984
  const summary = log.logUsageSummary(runState.logCtx, runState.totalUsage, contextTokens, contextWindow);
771
985
  // Split long summaries to avoid msg_too_long
772
986
  const summaryParts = splitForSlack(summary);
@@ -802,14 +1016,14 @@ export async function createRunner(sandboxConfig, sessionKey, channelId, channel
802
1016
  /**
803
1017
  * Translate container path back to host path for file operations
804
1018
  */
805
- function translateToHostPath(containerPath, channelDir, workspacePath, channelId) {
1019
+ function translateToHostPath(containerPath, conversationDir, workspacePath, conversationId) {
806
1020
  if (workspacePath === "/workspace") {
807
- const prefix = `/workspace/${channelId}/`;
1021
+ const prefix = `/workspace/${conversationId}/`;
808
1022
  if (containerPath.startsWith(prefix)) {
809
- return join(channelDir, containerPath.slice(prefix.length));
1023
+ return join(conversationDir, containerPath.slice(prefix.length));
810
1024
  }
811
1025
  if (containerPath.startsWith("/workspace/")) {
812
- return join(channelDir, "..", containerPath.slice("/workspace/".length));
1026
+ return join(conversationDir, "..", containerPath.slice("/workspace/".length));
813
1027
  }
814
1028
  }
815
1029
  return containerPath;