@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.2

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 (147) hide show
  1. package/README.md +94 -27
  2. package/dist/adapter.d.ts +9 -5
  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.map +1 -1
  6. package/dist/adapters/discord/bot.js +9 -6
  7. package/dist/adapters/discord/bot.js.map +1 -1
  8. package/dist/adapters/discord/context.d.ts.map +1 -1
  9. package/dist/adapters/discord/context.js +16 -13
  10. package/dist/adapters/discord/context.js.map +1 -1
  11. package/dist/adapters/slack/bot.d.ts +10 -2
  12. package/dist/adapters/slack/bot.d.ts.map +1 -1
  13. package/dist/adapters/slack/bot.js +196 -32
  14. package/dist/adapters/slack/bot.js.map +1 -1
  15. package/dist/adapters/slack/context.d.ts.map +1 -1
  16. package/dist/adapters/slack/context.js +24 -17
  17. package/dist/adapters/slack/context.js.map +1 -1
  18. package/dist/adapters/telegram/bot.d.ts +2 -0
  19. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  20. package/dist/adapters/telegram/bot.js +109 -29
  21. package/dist/adapters/telegram/bot.js.map +1 -1
  22. package/dist/adapters/telegram/context.d.ts.map +1 -1
  23. package/dist/adapters/telegram/context.js +8 -43
  24. package/dist/adapters/telegram/context.js.map +1 -1
  25. package/dist/adapters/telegram/html.d.ts +3 -0
  26. package/dist/adapters/telegram/html.d.ts.map +1 -0
  27. package/dist/adapters/telegram/html.js +98 -0
  28. package/dist/adapters/telegram/html.js.map +1 -0
  29. package/dist/agent.d.ts +4 -9
  30. package/dist/agent.d.ts.map +1 -1
  31. package/dist/agent.js +141 -92
  32. package/dist/agent.js.map +1 -1
  33. package/dist/bindings.d.ts +44 -0
  34. package/dist/bindings.d.ts.map +1 -0
  35. package/dist/bindings.js +74 -0
  36. package/dist/bindings.js.map +1 -0
  37. package/dist/config.d.ts +7 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +53 -12
  40. package/dist/config.js.map +1 -1
  41. package/dist/context.d.ts +7 -7
  42. package/dist/context.d.ts.map +1 -1
  43. package/dist/context.js +9 -9
  44. package/dist/context.js.map +1 -1
  45. package/dist/events.d.ts +14 -5
  46. package/dist/events.d.ts.map +1 -1
  47. package/dist/events.js +45 -10
  48. package/dist/events.js.map +1 -1
  49. package/dist/execution-resolver.d.ts +20 -0
  50. package/dist/execution-resolver.d.ts.map +1 -0
  51. package/dist/execution-resolver.js +49 -0
  52. package/dist/execution-resolver.js.map +1 -0
  53. package/dist/instrument.d.ts.map +1 -1
  54. package/dist/instrument.js +2 -1
  55. package/dist/instrument.js.map +1 -1
  56. package/dist/link-server.d.ts +17 -0
  57. package/dist/link-server.d.ts.map +1 -0
  58. package/dist/link-server.js +899 -0
  59. package/dist/link-server.js.map +1 -0
  60. package/dist/link-token.d.ts +32 -0
  61. package/dist/link-token.d.ts.map +1 -0
  62. package/dist/link-token.js +68 -0
  63. package/dist/link-token.js.map +1 -0
  64. package/dist/log.d.ts +2 -2
  65. package/dist/log.d.ts.map +1 -1
  66. package/dist/log.js +7 -7
  67. package/dist/log.js.map +1 -1
  68. package/dist/login.d.ts +29 -0
  69. package/dist/login.d.ts.map +1 -0
  70. package/dist/login.js +164 -0
  71. package/dist/login.js.map +1 -0
  72. package/dist/main.d.ts.map +1 -1
  73. package/dist/main.js +226 -55
  74. package/dist/main.js.map +1 -1
  75. package/dist/provisioner.d.ts +52 -0
  76. package/dist/provisioner.d.ts.map +1 -0
  77. package/dist/provisioner.js +291 -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 +10 -0
  92. package/dist/sandbox/host.d.ts.map +1 -0
  93. package/dist/sandbox/host.js +85 -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 +1 -1
  116. package/dist/sentry.d.ts.map +1 -1
  117. package/dist/sentry.js +4 -2
  118. package/dist/sentry.js.map +1 -1
  119. package/dist/session-store.d.ts +2 -6
  120. package/dist/session-store.d.ts.map +1 -1
  121. package/dist/session-store.js +3 -10
  122. package/dist/session-store.js.map +1 -1
  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 +22 -0
  128. package/dist/tools/event.d.ts.map +1 -0
  129. package/dist/tools/event.js +104 -0
  130. package/dist/tools/event.js.map +1 -0
  131. package/dist/tools/index.d.ts +7 -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 +12 -0
  136. package/dist/ui-copy.d.ts.map +1 -0
  137. package/dist/ui-copy.js +36 -0
  138. package/dist/ui-copy.js.map +1 -0
  139. package/dist/vault-routing.d.ts +9 -0
  140. package/dist/vault-routing.d.ts.map +1 -0
  141. package/dist/vault-routing.js +52 -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/package.json +12 -11
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, join } from "path";
2
+ import { homedir } from "os";
3
+ import { dirname, join, resolve } from "path";
3
4
  const DEFAULTS = {
4
5
  provider: "anthropic",
5
6
  model: "claude-sonnet-4-5",
@@ -8,10 +9,9 @@ const DEFAULTS = {
8
9
  logFormat: "console",
9
10
  logLevel: "info",
10
11
  };
11
- function loadRawAgentConfig(workspaceDir) {
12
- const settingsPath = join(workspaceDir, "settings.json");
12
+ function loadConfigFile(settingsPath) {
13
13
  if (!existsSync(settingsPath)) {
14
- return {};
14
+ return undefined;
15
15
  }
16
16
  try {
17
17
  const raw = readFileSync(settingsPath, "utf-8");
@@ -21,7 +21,25 @@ function loadRawAgentConfig(workspaceDir) {
21
21
  }
22
22
  }
23
23
  catch {
24
- // Ignore parse errors, fall through to env/defaults
24
+ // Ignore parse errors, fall through to next candidate
25
+ }
26
+ return undefined;
27
+ }
28
+ function getConfiguredStateDir() {
29
+ const raw = process.env.MAMA_STATE_DIR?.trim();
30
+ return raw ? resolve(raw) : undefined;
31
+ }
32
+ function loadRawAgentConfig(workspaceDir) {
33
+ const stateDir = getConfiguredStateDir();
34
+ const candidates = [
35
+ ...(stateDir ? [join(stateDir, "settings.json")] : []),
36
+ ...(workspaceDir ? [join(workspaceDir, "settings.json")] : []),
37
+ ];
38
+ for (const settingsPath of candidates) {
39
+ const config = loadConfigFile(settingsPath);
40
+ if (config) {
41
+ return config;
42
+ }
25
43
  }
26
44
  return {};
27
45
  }
@@ -39,14 +57,16 @@ export function loadAgentConfig(workspaceDir) {
39
57
  export function resolveWorkspaceDirFromArgv(args = process.argv.slice(2)) {
40
58
  for (let i = 0; i < args.length; i++) {
41
59
  const arg = args[i];
42
- if (arg === "--sandbox" || arg === "--download") {
60
+ if (arg === "--sandbox" || arg === "--download" || arg === "--state-dir") {
43
61
  i += 1;
44
62
  continue;
45
63
  }
46
64
  if (arg === "--version" || arg === "-v" || arg === "-V") {
47
65
  continue;
48
66
  }
49
- if (arg.startsWith("--sandbox=") || arg.startsWith("--download=")) {
67
+ if (arg.startsWith("--sandbox=") ||
68
+ arg.startsWith("--download=") ||
69
+ arg.startsWith("--state-dir=")) {
50
70
  continue;
51
71
  }
52
72
  if (!arg.startsWith("-")) {
@@ -55,15 +75,36 @@ export function resolveWorkspaceDirFromArgv(args = process.argv.slice(2)) {
55
75
  }
56
76
  return undefined;
57
77
  }
58
- export function resolveSentryDsn(workspaceDir) {
59
- if (workspaceDir) {
60
- const fromFile = loadRawAgentConfig(workspaceDir);
61
- if (fromFile.sentryDsn) {
62
- return fromFile.sentryDsn;
78
+ export function resolveStateDirFromArgv(args = process.argv.slice(2)) {
79
+ for (let i = 0; i < args.length; i++) {
80
+ const arg = args[i];
81
+ if (arg.startsWith("--state-dir=")) {
82
+ return resolve(arg.slice("--state-dir=".length));
63
83
  }
84
+ if (arg === "--state-dir") {
85
+ return resolve(args[++i] || "");
86
+ }
87
+ }
88
+ return join(homedir(), ".mama");
89
+ }
90
+ export function resolveSentryDsn(workspaceDir) {
91
+ const fromFile = loadRawAgentConfig(workspaceDir);
92
+ if (fromFile.sentryDsn) {
93
+ return fromFile.sentryDsn;
64
94
  }
65
95
  return process.env.SENTRY_DSN;
66
96
  }
97
+ /**
98
+ * Externally-visible base URL of the link/OAuth server, e.g.
99
+ * `https://mama.example.com` (no trailing slash). Read from `MOM_LINK_URL`,
100
+ * the same env var the bot uses to build credential onboarding links.
101
+ */
102
+ export function resolveLinkBaseUrl() {
103
+ const raw = process.env.MOM_LINK_URL?.trim();
104
+ if (!raw)
105
+ return undefined;
106
+ return raw.replace(/\/+$/, "");
107
+ }
67
108
  export function saveAgentConfig(workspaceDir, config) {
68
109
  const settingsPath = join(workspaceDir, "settings.json");
69
110
  let existing = {};
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { dirname, join } from \"path\";\n\nexport interface AgentConfig {\n provider: string;\n model: string;\n thinkingLevel?: string;\n sessionScope?: \"thread\" | \"channel\";\n logFormat?: \"console\" | \"json\";\n logLevel?: \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\";\n sentryDsn?: string;\n}\n\nconst DEFAULTS: AgentConfig = {\n provider: \"anthropic\",\n model: \"claude-sonnet-4-5\",\n thinkingLevel: \"off\",\n sessionScope: \"thread\",\n logFormat: \"console\",\n logLevel: \"info\",\n};\n\nfunction loadRawAgentConfig(workspaceDir: string): Partial<AgentConfig> {\n const settingsPath = join(workspaceDir, \"settings.json\");\n\n if (!existsSync(settingsPath)) {\n return {};\n }\n\n try {\n const raw = readFileSync(settingsPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\") {\n return parsed as Partial<AgentConfig>;\n }\n } catch {\n // Ignore parse errors, fall through to env/defaults\n }\n\n return {};\n}\n\nexport function loadAgentConfig(workspaceDir: string): AgentConfig {\n const fromFile = loadRawAgentConfig(workspaceDir);\n\n const provider = fromFile.provider || process.env.MOM_AI_PROVIDER || DEFAULTS.provider;\n const model = fromFile.model || process.env.MOM_AI_MODEL || DEFAULTS.model;\n const thinkingLevel = fromFile.thinkingLevel ?? DEFAULTS.thinkingLevel;\n const sessionScope = fromFile.sessionScope ?? DEFAULTS.sessionScope;\n const logFormat = fromFile.logFormat ?? DEFAULTS.logFormat;\n const logLevel = fromFile.logLevel ?? DEFAULTS.logLevel;\n const sentryDsn = fromFile.sentryDsn ?? process.env.SENTRY_DSN;\n\n return { provider, model, thinkingLevel, sessionScope, logFormat, logLevel, sentryDsn };\n}\n\nexport function resolveWorkspaceDirFromArgv(args = process.argv.slice(2)): string | undefined {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i];\n\n if (arg === \"--sandbox\" || arg === \"--download\") {\n i += 1;\n continue;\n }\n\n if (arg === \"--version\" || arg === \"-v\" || arg === \"-V\") {\n continue;\n }\n\n if (arg.startsWith(\"--sandbox=\") || arg.startsWith(\"--download=\")) {\n continue;\n }\n\n if (!arg.startsWith(\"-\")) {\n return arg;\n }\n }\n\n return undefined;\n}\n\nexport function resolveSentryDsn(workspaceDir?: string): string | undefined {\n if (workspaceDir) {\n const fromFile = loadRawAgentConfig(workspaceDir);\n if (fromFile.sentryDsn) {\n return fromFile.sentryDsn;\n }\n }\n\n return process.env.SENTRY_DSN;\n}\n\nexport function saveAgentConfig(workspaceDir: string, config: Partial<AgentConfig>): void {\n const settingsPath = join(workspaceDir, \"settings.json\");\n\n let existing: Partial<AgentConfig> = {};\n if (existsSync(settingsPath)) {\n try {\n const raw = readFileSync(settingsPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\") {\n existing = parsed as Partial<AgentConfig>;\n }\n } catch {\n // Start fresh if file is malformed\n }\n }\n\n const merged = { ...existing, ...config };\n\n const dir = dirname(settingsPath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n\n writeFileSync(settingsPath, JSON.stringify(merged, null, 2), \"utf-8\");\n}\n"]}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { dirname, join, resolve } from \"path\";\n\nexport interface AgentConfig {\n provider: string;\n model: string;\n thinkingLevel?: string;\n sessionScope?: \"thread\" | \"channel\";\n logFormat?: \"console\" | \"json\";\n logLevel?: \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\";\n sentryDsn?: string;\n}\n\nconst DEFAULTS: AgentConfig = {\n provider: \"anthropic\",\n model: \"claude-sonnet-4-5\",\n thinkingLevel: \"off\",\n sessionScope: \"thread\",\n logFormat: \"console\",\n logLevel: \"info\",\n};\n\nfunction loadConfigFile(settingsPath: string): Partial<AgentConfig> | undefined {\n if (!existsSync(settingsPath)) {\n return undefined;\n }\n\n try {\n const raw = readFileSync(settingsPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\") {\n return parsed as Partial<AgentConfig>;\n }\n } catch {\n // Ignore parse errors, fall through to next candidate\n }\n\n return undefined;\n}\n\nfunction getConfiguredStateDir(): string | undefined {\n const raw = process.env.MAMA_STATE_DIR?.trim();\n return raw ? resolve(raw) : undefined;\n}\n\nfunction loadRawAgentConfig(workspaceDir?: string): Partial<AgentConfig> {\n const stateDir = getConfiguredStateDir();\n const candidates = [\n ...(stateDir ? [join(stateDir, \"settings.json\")] : []),\n ...(workspaceDir ? [join(workspaceDir, \"settings.json\")] : []),\n ];\n\n for (const settingsPath of candidates) {\n const config = loadConfigFile(settingsPath);\n if (config) {\n return config;\n }\n }\n\n return {};\n}\n\nexport function loadAgentConfig(workspaceDir: string): AgentConfig {\n const fromFile = loadRawAgentConfig(workspaceDir);\n\n const provider = fromFile.provider || process.env.MOM_AI_PROVIDER || DEFAULTS.provider;\n const model = fromFile.model || process.env.MOM_AI_MODEL || DEFAULTS.model;\n const thinkingLevel = fromFile.thinkingLevel ?? DEFAULTS.thinkingLevel;\n const sessionScope = fromFile.sessionScope ?? DEFAULTS.sessionScope;\n const logFormat = fromFile.logFormat ?? DEFAULTS.logFormat;\n const logLevel = fromFile.logLevel ?? DEFAULTS.logLevel;\n const sentryDsn = fromFile.sentryDsn ?? process.env.SENTRY_DSN;\n\n return { provider, model, thinkingLevel, sessionScope, logFormat, logLevel, sentryDsn };\n}\n\nexport function resolveWorkspaceDirFromArgv(args = process.argv.slice(2)): string | undefined {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i];\n\n if (arg === \"--sandbox\" || arg === \"--download\" || arg === \"--state-dir\") {\n i += 1;\n continue;\n }\n\n if (arg === \"--version\" || arg === \"-v\" || arg === \"-V\") {\n continue;\n }\n\n if (\n arg.startsWith(\"--sandbox=\") ||\n arg.startsWith(\"--download=\") ||\n arg.startsWith(\"--state-dir=\")\n ) {\n continue;\n }\n\n if (!arg.startsWith(\"-\")) {\n return arg;\n }\n }\n\n return undefined;\n}\n\nexport function resolveStateDirFromArgv(args = process.argv.slice(2)): string {\n for (let i = 0; i < args.length; i++) {\n const arg = args[i];\n if (arg.startsWith(\"--state-dir=\")) {\n return resolve(arg.slice(\"--state-dir=\".length));\n }\n if (arg === \"--state-dir\") {\n return resolve(args[++i] || \"\");\n }\n }\n\n return join(homedir(), \".mama\");\n}\n\nexport function resolveSentryDsn(workspaceDir?: string): string | undefined {\n const fromFile = loadRawAgentConfig(workspaceDir);\n if (fromFile.sentryDsn) {\n return fromFile.sentryDsn;\n }\n\n return process.env.SENTRY_DSN;\n}\n\n/**\n * Externally-visible base URL of the link/OAuth server, e.g.\n * `https://mama.example.com` (no trailing slash). Read from `MOM_LINK_URL`,\n * the same env var the bot uses to build credential onboarding links.\n */\nexport function resolveLinkBaseUrl(): string | undefined {\n const raw = process.env.MOM_LINK_URL?.trim();\n if (!raw) return undefined;\n return raw.replace(/\\/+$/, \"\");\n}\n\nexport function saveAgentConfig(workspaceDir: string, config: Partial<AgentConfig>): void {\n const settingsPath = join(workspaceDir, \"settings.json\");\n\n let existing: Partial<AgentConfig> = {};\n if (existsSync(settingsPath)) {\n try {\n const raw = readFileSync(settingsPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\") {\n existing = parsed as Partial<AgentConfig>;\n }\n } catch {\n // Start fresh if file is malformed\n }\n }\n\n const merged = { ...existing, ...config };\n\n const dir = dirname(settingsPath);\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n\n writeFileSync(settingsPath, JSON.stringify(merged, null, 2), \"utf-8\");\n}\n"]}
package/dist/context.d.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Context management for mama.
3
3
  *
4
- * Mama uses two files per channel:
5
- * - context.jsonl: Structured API messages for LLM context (same format as coding-agent sessions)
6
- * - log.jsonl: Human-readable channel history for grep (no tool results)
4
+ * Mama uses two data sources per conversation:
5
+ * - sessions/*.jsonl: Structured session history for agent context
6
+ * - log.jsonl: Human-readable conversation history for grep (no tool results)
7
7
  *
8
8
  * This module provides:
9
9
  * - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager
10
- * - createMamaSettingsManager: Creates a SettingsManager backed by workspace settings.json
10
+ * - createMamaSettingsManager: Creates an in-memory SettingsManager for AgentSession
11
11
  */
12
12
  import { type SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
13
13
  /**
@@ -33,16 +33,16 @@ export interface ThreadFilter {
33
33
  /**
34
34
  * Sync user messages from log.jsonl to SessionManager.
35
35
  *
36
- * This ensures that messages logged while mama wasn't running (channel chatter,
36
+ * This ensures that messages logged while mama wasn't running (conversation chatter,
37
37
  * backfilled messages, messages while busy) are added to the LLM context.
38
38
  *
39
39
  * @param sessionManager - The SessionManager to sync to
40
- * @param channelDir - Path to channel directory containing log.jsonl
40
+ * @param conversationDir - Path to the conversation directory containing log.jsonl
41
41
  * @param excludeSlackTs - Slack timestamp of current message (will be added via prompt(), not sync)
42
42
  * @param timeRange - Optional time range to filter log entries (defaults to last 10 days)
43
43
  * @param threadFilter - Optional thread filter to scope sync to a specific thread
44
44
  * @returns Number of messages synced
45
45
  */
46
- export declare function syncLogToSessionManager(sessionManager: SessionManager, channelDir: string, excludeSlackTs?: string, timeRange?: TimeRange, threadFilter?: ThreadFilter): Promise<number>;
46
+ export declare function syncLogToSessionManager(sessionManager: SessionManager, conversationDir: string, excludeSlackTs?: string, timeRange?: TimeRange, threadFilter?: ThreadFilter): Promise<number>;
47
47
  export declare function createMamaSettingsManager(_workspaceDir: string): SettingsManager;
48
48
  //# sourceMappingURL=context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EACL,KAAK,cAAc,EAEnB,eAAe,EAChB,MAAM,+BAA+B,CAAC;AASvC;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAiBD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,sGAAsG;IACtG,KAAK,CAAC,EAAE,QAAQ,GAAG,WAAW,CAAC;IAC/B,uFAAuF;IACvF,MAAM,EAAE,MAAM,CAAC;IACf,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAC3C,cAAc,EAAE,cAAc,EAC9B,UAAU,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,MAAM,EACvB,SAAS,CAAC,EAAE,SAAS,EACrB,YAAY,CAAC,EAAE,YAAY,GAC1B,OAAO,CAAC,MAAM,CAAC,CAyGjB;AASD,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,eAAe,CAEhF","sourcesContent":["/**\n * Context management for mama.\n *\n * Mama uses two files per channel:\n * - context.jsonl: Structured API messages for LLM context (same format as coding-agent sessions)\n * - log.jsonl: Human-readable channel history for grep (no tool results)\n *\n * This module provides:\n * - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager\n * - createMamaSettingsManager: Creates a SettingsManager backed by workspace settings.json\n */\n\nimport type { UserMessage } from \"@mariozechner/pi-ai\";\nimport {\n type SessionManager,\n type SessionMessageEntry,\n SettingsManager,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\n\n// ============================================================================\n// Sync log.jsonl to SessionManager\n// ============================================================================\n\n/**\n * Time range for filtering log messages\n */\nexport interface TimeRange {\n start: number; // Unix timestamp in ms\n end: number;\n}\n\n/**\n * Default number of days to sync when no time range is specified\n */\nconst DEFAULT_SYNC_DAYS = 10;\n\ninterface LogMessage {\n date?: string;\n ts?: string;\n threadTs?: string;\n user?: string;\n userName?: string;\n text?: string;\n isBot?: boolean;\n}\n\n/**\n * Thread filter for scoping log sync to a specific thread session.\n * When provided, only messages belonging to this thread are synced,\n * preventing cross-thread context contamination.\n */\nexport interface ThreadFilter {\n /** Filter mode: a specific thread, or top-level messages only for persistent channel/chat sessions */\n scope?: \"thread\" | \"top-level\";\n /** The root message timestamp (user's original message ts, derived from sessionKey) */\n rootTs: string;\n /** The thread anchor timestamp (bot's first reply ts, used as thread_ts by Slack replies) */\n threadTs?: string;\n}\n\n/**\n * Sync user messages from log.jsonl to SessionManager.\n *\n * This ensures that messages logged while mama wasn't running (channel chatter,\n * backfilled messages, messages while busy) are added to the LLM context.\n *\n * @param sessionManager - The SessionManager to sync to\n * @param channelDir - Path to channel directory containing log.jsonl\n * @param excludeSlackTs - Slack timestamp of current message (will be added via prompt(), not sync)\n * @param timeRange - Optional time range to filter log entries (defaults to last 10 days)\n * @param threadFilter - Optional thread filter to scope sync to a specific thread\n * @returns Number of messages synced\n */\nexport async function syncLogToSessionManager(\n sessionManager: SessionManager,\n channelDir: string,\n excludeSlackTs?: string,\n timeRange?: TimeRange,\n threadFilter?: ThreadFilter,\n): Promise<number> {\n // Calculate default time range (last 10 days) if not provided\n const now = Date.now();\n const defaultStart = now - DEFAULT_SYNC_DAYS * 24 * 60 * 60 * 1000;\n const range = timeRange ?? { start: defaultStart, end: now };\n const logFile = join(channelDir, \"log.jsonl\");\n\n if (!existsSync(logFile)) return 0;\n\n // Build set of existing timestamps from session entries\n // We use ts (Slack timestamp) as the unique key instead of message content\n const existingTimestamps = new Set<string>();\n for (const entry of sessionManager.getEntries()) {\n if (entry.type === \"message\") {\n const msgEntry = entry as SessionMessageEntry;\n // SessionMessageEntry has a timestamp field (number, Unix ms)\n if (msgEntry.timestamp) {\n existingTimestamps.add(msgEntry.timestamp.toString());\n }\n }\n }\n\n // Read log.jsonl and find user messages not in context\n const logContent = await readFile(logFile, \"utf-8\");\n const logLines = logContent.trim().split(\"\\n\").filter(Boolean);\n\n const newMessages: Array<{ timestamp: number; message: UserMessage }> = [];\n\n for (const line of logLines) {\n try {\n const logMsg: LogMessage = JSON.parse(line);\n\n const slackTs = logMsg.ts;\n const date = logMsg.date;\n if (!slackTs || !date) continue;\n\n // Skip the current message being processed (will be added via prompt())\n if (excludeSlackTs && slackTs === excludeSlackTs) continue;\n\n // Skip bot messages - added through agent flow\n if (logMsg.isBot) continue;\n\n // Thread filtering: only sync messages belonging to this session's thread\n if (threadFilter) {\n if (threadFilter.scope === \"top-level\") {\n // Persistent channel/chat sessions should only ingest top-level messages.\n // This avoids pulling in unrelated replies from other threads.\n if (logMsg.threadTs) {\n continue;\n }\n } else {\n if (logMsg.threadTs) {\n // Thread reply: only include if threadTs matches our thread anchor or rootTs\n if (\n logMsg.threadTs !== threadFilter.threadTs &&\n logMsg.threadTs !== threadFilter.rootTs\n ) {\n continue;\n }\n } else {\n // Top-level message: only include if it's this session's root message\n if (slackTs !== threadFilter.rootTs) {\n continue;\n }\n }\n }\n }\n\n // Skip if this Slack timestamp is already in the session (dedupe by ts, not content)\n // Convert Slack ts (e.g., \"1234567890.123456\") to Unix ms for comparison\n const slackTsMs = Math.floor(parseFloat(slackTs) * 1000).toString();\n if (existingTimestamps.has(slackTsMs)) continue;\n\n // Build the message text as it would appear in context\n const threadContext = logMsg.threadTs ? ` [in-thread:${logMsg.threadTs}]` : \"\";\n const messageText = `[${logMsg.userName || logMsg.user || \"unknown\"}]${threadContext}: ${logMsg.text || \"\"}`;\n\n const msgTime = new Date(date).getTime() || Date.now();\n\n // Skip messages outside the time range\n if (msgTime < range.start || msgTime > range.end) continue;\n\n const userMessage: UserMessage = {\n role: \"user\",\n content: [{ type: \"text\", text: messageText }],\n timestamp: msgTime,\n };\n\n newMessages.push({ timestamp: msgTime, message: userMessage });\n existingTimestamps.add(slackTsMs); // Track to avoid duplicates within this sync\n } catch {\n // Skip malformed lines\n }\n }\n\n if (newMessages.length === 0) return 0;\n\n // Sort by timestamp and add to session\n newMessages.sort((a, b) => a.timestamp - b.timestamp);\n\n for (const { message } of newMessages) {\n sessionManager.appendMessage(message);\n }\n\n return newMessages.length;\n}\n\n// ============================================================================\n// Settings manager for mama\n// ============================================================================\n\n// Mama manages model/provider config through its own config.ts / settings.json.\n// We use an in-memory SettingsManager so AgentSession has valid defaults\n// without interfering with coding-agent's global settings files.\nexport function createMamaSettingsManager(_workspaceDir: string): SettingsManager {\n return SettingsManager.inMemory();\n}\n"]}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EACL,KAAK,cAAc,EAEnB,eAAe,EAChB,MAAM,+BAA+B,CAAC;AASvC;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAiBD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,sGAAsG;IACtG,KAAK,CAAC,EAAE,QAAQ,GAAG,WAAW,CAAC;IAC/B,uFAAuF;IACvF,MAAM,EAAE,MAAM,CAAC;IACf,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,uBAAuB,CAC3C,cAAc,EAAE,cAAc,EAC9B,eAAe,EAAE,MAAM,EACvB,cAAc,CAAC,EAAE,MAAM,EACvB,SAAS,CAAC,EAAE,SAAS,EACrB,YAAY,CAAC,EAAE,YAAY,GAC1B,OAAO,CAAC,MAAM,CAAC,CAyGjB;AASD,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,eAAe,CAEhF","sourcesContent":["/**\n * Context management for mama.\n *\n * Mama uses two data sources per conversation:\n * - sessions/*.jsonl: Structured session history for agent context\n * - log.jsonl: Human-readable conversation history for grep (no tool results)\n *\n * This module provides:\n * - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager\n * - createMamaSettingsManager: Creates an in-memory SettingsManager for AgentSession\n */\n\nimport type { UserMessage } from \"@mariozechner/pi-ai\";\nimport {\n type SessionManager,\n type SessionMessageEntry,\n SettingsManager,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\n\n// ============================================================================\n// Sync log.jsonl to SessionManager\n// ============================================================================\n\n/**\n * Time range for filtering log messages\n */\nexport interface TimeRange {\n start: number; // Unix timestamp in ms\n end: number;\n}\n\n/**\n * Default number of days to sync when no time range is specified\n */\nconst DEFAULT_SYNC_DAYS = 10;\n\ninterface LogMessage {\n date?: string;\n ts?: string;\n threadTs?: string;\n user?: string;\n userName?: string;\n text?: string;\n isBot?: boolean;\n}\n\n/**\n * Thread filter for scoping log sync to a specific thread session.\n * When provided, only messages belonging to this thread are synced,\n * preventing cross-thread context contamination.\n */\nexport interface ThreadFilter {\n /** Filter mode: a specific thread, or top-level messages only for persistent channel/chat sessions */\n scope?: \"thread\" | \"top-level\";\n /** The root message timestamp (user's original message ts, derived from sessionKey) */\n rootTs: string;\n /** The thread anchor timestamp (bot's first reply ts, used as thread_ts by Slack replies) */\n threadTs?: string;\n}\n\n/**\n * Sync user messages from log.jsonl to SessionManager.\n *\n * This ensures that messages logged while mama wasn't running (conversation chatter,\n * backfilled messages, messages while busy) are added to the LLM context.\n *\n * @param sessionManager - The SessionManager to sync to\n * @param conversationDir - Path to the conversation directory containing log.jsonl\n * @param excludeSlackTs - Slack timestamp of current message (will be added via prompt(), not sync)\n * @param timeRange - Optional time range to filter log entries (defaults to last 10 days)\n * @param threadFilter - Optional thread filter to scope sync to a specific thread\n * @returns Number of messages synced\n */\nexport async function syncLogToSessionManager(\n sessionManager: SessionManager,\n conversationDir: string,\n excludeSlackTs?: string,\n timeRange?: TimeRange,\n threadFilter?: ThreadFilter,\n): Promise<number> {\n // Calculate default time range (last 10 days) if not provided\n const now = Date.now();\n const defaultStart = now - DEFAULT_SYNC_DAYS * 24 * 60 * 60 * 1000;\n const range = timeRange ?? { start: defaultStart, end: now };\n const logFile = join(conversationDir, \"log.jsonl\");\n\n if (!existsSync(logFile)) return 0;\n\n // Build set of existing timestamps from session entries\n // We use ts (Slack timestamp) as the unique key instead of message content\n const existingTimestamps = new Set<string>();\n for (const entry of sessionManager.getEntries()) {\n if (entry.type === \"message\") {\n const msgEntry = entry as SessionMessageEntry;\n // SessionMessageEntry has a timestamp field (number, Unix ms)\n if (msgEntry.timestamp) {\n existingTimestamps.add(msgEntry.timestamp.toString());\n }\n }\n }\n\n // Read log.jsonl and find user messages not in context\n const logContent = await readFile(logFile, \"utf-8\");\n const logLines = logContent.trim().split(\"\\n\").filter(Boolean);\n\n const newMessages: Array<{ timestamp: number; message: UserMessage }> = [];\n\n for (const line of logLines) {\n try {\n const logMsg: LogMessage = JSON.parse(line);\n\n const slackTs = logMsg.ts;\n const date = logMsg.date;\n if (!slackTs || !date) continue;\n\n // Skip the current message being processed (will be added via prompt())\n if (excludeSlackTs && slackTs === excludeSlackTs) continue;\n\n // Skip bot messages - added through agent flow\n if (logMsg.isBot) continue;\n\n // Thread filtering: only sync messages belonging to this session's thread\n if (threadFilter) {\n if (threadFilter.scope === \"top-level\") {\n // Persistent top-level sessions should only ingest top-level messages.\n // This avoids pulling in unrelated replies from other threads.\n if (logMsg.threadTs) {\n continue;\n }\n } else {\n if (logMsg.threadTs) {\n // Thread reply: only include if threadTs matches our thread anchor or rootTs\n if (\n logMsg.threadTs !== threadFilter.threadTs &&\n logMsg.threadTs !== threadFilter.rootTs\n ) {\n continue;\n }\n } else {\n // Top-level message: only include if it's this session's root message\n if (slackTs !== threadFilter.rootTs) {\n continue;\n }\n }\n }\n }\n\n // Skip if this Slack timestamp is already in the session (dedupe by ts, not content)\n // Convert Slack ts (e.g., \"1234567890.123456\") to Unix ms for comparison\n const slackTsMs = Math.floor(parseFloat(slackTs) * 1000).toString();\n if (existingTimestamps.has(slackTsMs)) continue;\n\n // Build the message text as it would appear in context\n const threadContext = logMsg.threadTs ? ` [in-thread:${logMsg.threadTs}]` : \"\";\n const messageText = `[${logMsg.userName || logMsg.user || \"unknown\"}]${threadContext}: ${logMsg.text || \"\"}`;\n\n const msgTime = new Date(date).getTime() || Date.now();\n\n // Skip messages outside the time range\n if (msgTime < range.start || msgTime > range.end) continue;\n\n const userMessage: UserMessage = {\n role: \"user\",\n content: [{ type: \"text\", text: messageText }],\n timestamp: msgTime,\n };\n\n newMessages.push({ timestamp: msgTime, message: userMessage });\n existingTimestamps.add(slackTsMs); // Track to avoid duplicates within this sync\n } catch {\n // Skip malformed lines\n }\n }\n\n if (newMessages.length === 0) return 0;\n\n // Sort by timestamp and add to session\n newMessages.sort((a, b) => a.timestamp - b.timestamp);\n\n for (const { message } of newMessages) {\n sessionManager.appendMessage(message);\n }\n\n return newMessages.length;\n}\n\n// ============================================================================\n// Settings manager for mama\n// ============================================================================\n\n// Mama manages model/provider config through its own config.ts / settings.json.\n// We use an in-memory SettingsManager so AgentSession has valid defaults\n// without interfering with coding-agent's global settings files.\nexport function createMamaSettingsManager(_workspaceDir: string): SettingsManager {\n return SettingsManager.inMemory();\n}\n"]}
package/dist/context.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Context management for mama.
3
3
  *
4
- * Mama uses two files per channel:
5
- * - context.jsonl: Structured API messages for LLM context (same format as coding-agent sessions)
6
- * - log.jsonl: Human-readable channel history for grep (no tool results)
4
+ * Mama uses two data sources per conversation:
5
+ * - sessions/*.jsonl: Structured session history for agent context
6
+ * - log.jsonl: Human-readable conversation history for grep (no tool results)
7
7
  *
8
8
  * This module provides:
9
9
  * - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager
10
- * - createMamaSettingsManager: Creates a SettingsManager backed by workspace settings.json
10
+ * - createMamaSettingsManager: Creates an in-memory SettingsManager for AgentSession
11
11
  */
12
12
  import { SettingsManager, } from "@mariozechner/pi-coding-agent";
13
13
  import { existsSync } from "fs";
@@ -20,22 +20,22 @@ const DEFAULT_SYNC_DAYS = 10;
20
20
  /**
21
21
  * Sync user messages from log.jsonl to SessionManager.
22
22
  *
23
- * This ensures that messages logged while mama wasn't running (channel chatter,
23
+ * This ensures that messages logged while mama wasn't running (conversation chatter,
24
24
  * backfilled messages, messages while busy) are added to the LLM context.
25
25
  *
26
26
  * @param sessionManager - The SessionManager to sync to
27
- * @param channelDir - Path to channel directory containing log.jsonl
27
+ * @param conversationDir - Path to the conversation directory containing log.jsonl
28
28
  * @param excludeSlackTs - Slack timestamp of current message (will be added via prompt(), not sync)
29
29
  * @param timeRange - Optional time range to filter log entries (defaults to last 10 days)
30
30
  * @param threadFilter - Optional thread filter to scope sync to a specific thread
31
31
  * @returns Number of messages synced
32
32
  */
33
- export async function syncLogToSessionManager(sessionManager, channelDir, excludeSlackTs, timeRange, threadFilter) {
33
+ export async function syncLogToSessionManager(sessionManager, conversationDir, excludeSlackTs, timeRange, threadFilter) {
34
34
  // Calculate default time range (last 10 days) if not provided
35
35
  const now = Date.now();
36
36
  const defaultStart = now - DEFAULT_SYNC_DAYS * 24 * 60 * 60 * 1000;
37
37
  const range = timeRange ?? { start: defaultStart, end: now };
38
- const logFile = join(channelDir, "log.jsonl");
38
+ const logFile = join(conversationDir, "log.jsonl");
39
39
  if (!existsSync(logFile))
40
40
  return 0;
41
41
  // Build set of existing timestamps from session entries
@@ -70,7 +70,7 @@ export async function syncLogToSessionManager(sessionManager, channelDir, exclud
70
70
  // Thread filtering: only sync messages belonging to this session's thread
71
71
  if (threadFilter) {
72
72
  if (threadFilter.scope === "top-level") {
73
- // Persistent channel/chat sessions should only ingest top-level messages.
73
+ // Persistent top-level sessions should only ingest top-level messages.
74
74
  // This avoids pulling in unrelated replies from other threads.
75
75
  if (logMsg.threadTs) {
76
76
  continue;
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAGL,eAAe,GAChB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAc5B;;GAEG;AACH,MAAM,iBAAiB,GAAG,EAAE,CAAC;AA0B7B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,cAA8B,EAC9B,UAAkB,EAClB,cAAuB,EACvB,SAAqB,EACrB,YAA2B;IAE3B,8DAA8D;IAC9D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,YAAY,GAAG,GAAG,GAAG,iBAAiB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IACnE,MAAM,KAAK,GAAG,SAAS,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;IAC7D,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,EAAE,WAAW,CAAC,CAAC;IAE9C,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,CAAC,CAAC;IAEnC,wDAAwD;IACxD,2EAA2E;IAC3E,MAAM,kBAAkB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC7C,KAAK,MAAM,KAAK,IAAI,cAAc,CAAC,UAAU,EAAE,EAAE,CAAC;QAChD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,MAAM,QAAQ,GAAG,KAA4B,CAAC;YAC9C,8DAA8D;YAC9D,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;gBACvB,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;YACxD,CAAC;QACH,CAAC;IACH,CAAC;IAED,uDAAuD;IACvD,MAAM,UAAU,GAAG,MAAM,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACpD,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAE/D,MAAM,WAAW,GAAuD,EAAE,CAAC;IAE3E,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,MAAM,MAAM,GAAe,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAE5C,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;YAC1B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;YACzB,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI;gBAAE,SAAS;YAEhC,wEAAwE;YACxE,IAAI,cAAc,IAAI,OAAO,KAAK,cAAc;gBAAE,SAAS;YAE3D,+CAA+C;YAC/C,IAAI,MAAM,CAAC,KAAK;gBAAE,SAAS;YAE3B,0EAA0E;YAC1E,IAAI,YAAY,EAAE,CAAC;gBACjB,IAAI,YAAY,CAAC,KAAK,KAAK,WAAW,EAAE,CAAC;oBACvC,0EAA0E;oBAC1E,+DAA+D;oBAC/D,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;wBACpB,SAAS;oBACX,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;wBACpB,6EAA6E;wBAC7E,IACE,MAAM,CAAC,QAAQ,KAAK,YAAY,CAAC,QAAQ;4BACzC,MAAM,CAAC,QAAQ,KAAK,YAAY,CAAC,MAAM,EACvC,CAAC;4BACD,SAAS;wBACX,CAAC;oBACH,CAAC;yBAAM,CAAC;wBACN,sEAAsE;wBACtE,IAAI,OAAO,KAAK,YAAY,CAAC,MAAM,EAAE,CAAC;4BACpC,SAAS;wBACX,CAAC;oBACH,CAAC;gBACH,CAAC;YACH,CAAC;YAED,qFAAqF;YACrF,yEAAyE;YACzE,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;YACpE,IAAI,kBAAkB,CAAC,GAAG,CAAC,SAAS,CAAC;gBAAE,SAAS;YAEhD,uDAAuD;YACvD,MAAM,aAAa,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,eAAe,MAAM,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/E,MAAM,WAAW,GAAG,IAAI,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,IAAI,IAAI,SAAS,IAAI,aAAa,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;YAE7G,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YAEvD,uCAAuC;YACvC,IAAI,OAAO,GAAG,KAAK,CAAC,KAAK,IAAI,OAAO,GAAG,KAAK,CAAC,GAAG;gBAAE,SAAS;YAE3D,MAAM,WAAW,GAAgB;gBAC/B,IAAI,EAAE,MAAM;gBACZ,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;gBAC9C,SAAS,EAAE,OAAO;aACnB,CAAC;YAEF,WAAW,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;YAC/D,kBAAkB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,6CAA6C;QAClF,CAAC;QAAC,MAAM,CAAC;YACP,uBAAuB;QACzB,CAAC;IACH,CAAC;IAED,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAEvC,uCAAuC;IACvC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC;IAEtD,KAAK,MAAM,EAAE,OAAO,EAAE,IAAI,WAAW,EAAE,CAAC;QACtC,cAAc,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC,CAAC;IAED,OAAO,WAAW,CAAC,MAAM,CAAC;AAC5B,CAAC;AAED,+EAA+E;AAC/E,4BAA4B;AAC5B,+EAA+E;AAE/E,gFAAgF;AAChF,yEAAyE;AACzE,iEAAiE;AACjE,MAAM,UAAU,yBAAyB,CAAC,aAAqB;IAC7D,OAAO,eAAe,CAAC,QAAQ,EAAE,CAAC;AACpC,CAAC","sourcesContent":["/**\n * Context management for mama.\n *\n * Mama uses two files per channel:\n * - context.jsonl: Structured API messages for LLM context (same format as coding-agent sessions)\n * - log.jsonl: Human-readable channel history for grep (no tool results)\n *\n * This module provides:\n * - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager\n * - createMamaSettingsManager: Creates a SettingsManager backed by workspace settings.json\n */\n\nimport type { UserMessage } from \"@mariozechner/pi-ai\";\nimport {\n type SessionManager,\n type SessionMessageEntry,\n SettingsManager,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\n\n// ============================================================================\n// Sync log.jsonl to SessionManager\n// ============================================================================\n\n/**\n * Time range for filtering log messages\n */\nexport interface TimeRange {\n start: number; // Unix timestamp in ms\n end: number;\n}\n\n/**\n * Default number of days to sync when no time range is specified\n */\nconst DEFAULT_SYNC_DAYS = 10;\n\ninterface LogMessage {\n date?: string;\n ts?: string;\n threadTs?: string;\n user?: string;\n userName?: string;\n text?: string;\n isBot?: boolean;\n}\n\n/**\n * Thread filter for scoping log sync to a specific thread session.\n * When provided, only messages belonging to this thread are synced,\n * preventing cross-thread context contamination.\n */\nexport interface ThreadFilter {\n /** Filter mode: a specific thread, or top-level messages only for persistent channel/chat sessions */\n scope?: \"thread\" | \"top-level\";\n /** The root message timestamp (user's original message ts, derived from sessionKey) */\n rootTs: string;\n /** The thread anchor timestamp (bot's first reply ts, used as thread_ts by Slack replies) */\n threadTs?: string;\n}\n\n/**\n * Sync user messages from log.jsonl to SessionManager.\n *\n * This ensures that messages logged while mama wasn't running (channel chatter,\n * backfilled messages, messages while busy) are added to the LLM context.\n *\n * @param sessionManager - The SessionManager to sync to\n * @param channelDir - Path to channel directory containing log.jsonl\n * @param excludeSlackTs - Slack timestamp of current message (will be added via prompt(), not sync)\n * @param timeRange - Optional time range to filter log entries (defaults to last 10 days)\n * @param threadFilter - Optional thread filter to scope sync to a specific thread\n * @returns Number of messages synced\n */\nexport async function syncLogToSessionManager(\n sessionManager: SessionManager,\n channelDir: string,\n excludeSlackTs?: string,\n timeRange?: TimeRange,\n threadFilter?: ThreadFilter,\n): Promise<number> {\n // Calculate default time range (last 10 days) if not provided\n const now = Date.now();\n const defaultStart = now - DEFAULT_SYNC_DAYS * 24 * 60 * 60 * 1000;\n const range = timeRange ?? { start: defaultStart, end: now };\n const logFile = join(channelDir, \"log.jsonl\");\n\n if (!existsSync(logFile)) return 0;\n\n // Build set of existing timestamps from session entries\n // We use ts (Slack timestamp) as the unique key instead of message content\n const existingTimestamps = new Set<string>();\n for (const entry of sessionManager.getEntries()) {\n if (entry.type === \"message\") {\n const msgEntry = entry as SessionMessageEntry;\n // SessionMessageEntry has a timestamp field (number, Unix ms)\n if (msgEntry.timestamp) {\n existingTimestamps.add(msgEntry.timestamp.toString());\n }\n }\n }\n\n // Read log.jsonl and find user messages not in context\n const logContent = await readFile(logFile, \"utf-8\");\n const logLines = logContent.trim().split(\"\\n\").filter(Boolean);\n\n const newMessages: Array<{ timestamp: number; message: UserMessage }> = [];\n\n for (const line of logLines) {\n try {\n const logMsg: LogMessage = JSON.parse(line);\n\n const slackTs = logMsg.ts;\n const date = logMsg.date;\n if (!slackTs || !date) continue;\n\n // Skip the current message being processed (will be added via prompt())\n if (excludeSlackTs && slackTs === excludeSlackTs) continue;\n\n // Skip bot messages - added through agent flow\n if (logMsg.isBot) continue;\n\n // Thread filtering: only sync messages belonging to this session's thread\n if (threadFilter) {\n if (threadFilter.scope === \"top-level\") {\n // Persistent channel/chat sessions should only ingest top-level messages.\n // This avoids pulling in unrelated replies from other threads.\n if (logMsg.threadTs) {\n continue;\n }\n } else {\n if (logMsg.threadTs) {\n // Thread reply: only include if threadTs matches our thread anchor or rootTs\n if (\n logMsg.threadTs !== threadFilter.threadTs &&\n logMsg.threadTs !== threadFilter.rootTs\n ) {\n continue;\n }\n } else {\n // Top-level message: only include if it's this session's root message\n if (slackTs !== threadFilter.rootTs) {\n continue;\n }\n }\n }\n }\n\n // Skip if this Slack timestamp is already in the session (dedupe by ts, not content)\n // Convert Slack ts (e.g., \"1234567890.123456\") to Unix ms for comparison\n const slackTsMs = Math.floor(parseFloat(slackTs) * 1000).toString();\n if (existingTimestamps.has(slackTsMs)) continue;\n\n // Build the message text as it would appear in context\n const threadContext = logMsg.threadTs ? ` [in-thread:${logMsg.threadTs}]` : \"\";\n const messageText = `[${logMsg.userName || logMsg.user || \"unknown\"}]${threadContext}: ${logMsg.text || \"\"}`;\n\n const msgTime = new Date(date).getTime() || Date.now();\n\n // Skip messages outside the time range\n if (msgTime < range.start || msgTime > range.end) continue;\n\n const userMessage: UserMessage = {\n role: \"user\",\n content: [{ type: \"text\", text: messageText }],\n timestamp: msgTime,\n };\n\n newMessages.push({ timestamp: msgTime, message: userMessage });\n existingTimestamps.add(slackTsMs); // Track to avoid duplicates within this sync\n } catch {\n // Skip malformed lines\n }\n }\n\n if (newMessages.length === 0) return 0;\n\n // Sort by timestamp and add to session\n newMessages.sort((a, b) => a.timestamp - b.timestamp);\n\n for (const { message } of newMessages) {\n sessionManager.appendMessage(message);\n }\n\n return newMessages.length;\n}\n\n// ============================================================================\n// Settings manager for mama\n// ============================================================================\n\n// Mama manages model/provider config through its own config.ts / settings.json.\n// We use an in-memory SettingsManager so AgentSession has valid defaults\n// without interfering with coding-agent's global settings files.\nexport function createMamaSettingsManager(_workspaceDir: string): SettingsManager {\n return SettingsManager.inMemory();\n}\n"]}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAGL,eAAe,GAChB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,UAAU,EAAE,MAAM,IAAI,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAc5B;;GAEG;AACH,MAAM,iBAAiB,GAAG,EAAE,CAAC;AA0B7B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,cAA8B,EAC9B,eAAuB,EACvB,cAAuB,EACvB,SAAqB,EACrB,YAA2B;IAE3B,8DAA8D;IAC9D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,YAAY,GAAG,GAAG,GAAG,iBAAiB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;IACnE,MAAM,KAAK,GAAG,SAAS,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;IAC7D,MAAM,OAAO,GAAG,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC;IAEnD,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,CAAC,CAAC;IAEnC,wDAAwD;IACxD,2EAA2E;IAC3E,MAAM,kBAAkB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC7C,KAAK,MAAM,KAAK,IAAI,cAAc,CAAC,UAAU,EAAE,EAAE,CAAC;QAChD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;YAC7B,MAAM,QAAQ,GAAG,KAA4B,CAAC;YAC9C,8DAA8D;YAC9D,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;gBACvB,kBAAkB,CAAC,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;YACxD,CAAC;QACH,CAAC;IACH,CAAC;IAED,uDAAuD;IACvD,MAAM,UAAU,GAAG,MAAM,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACpD,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAE/D,MAAM,WAAW,GAAuD,EAAE,CAAC;IAE3E,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,MAAM,MAAM,GAAe,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAE5C,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;YAC1B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;YACzB,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI;gBAAE,SAAS;YAEhC,wEAAwE;YACxE,IAAI,cAAc,IAAI,OAAO,KAAK,cAAc;gBAAE,SAAS;YAE3D,+CAA+C;YAC/C,IAAI,MAAM,CAAC,KAAK;gBAAE,SAAS;YAE3B,0EAA0E;YAC1E,IAAI,YAAY,EAAE,CAAC;gBACjB,IAAI,YAAY,CAAC,KAAK,KAAK,WAAW,EAAE,CAAC;oBACvC,uEAAuE;oBACvE,+DAA+D;oBAC/D,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;wBACpB,SAAS;oBACX,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;wBACpB,6EAA6E;wBAC7E,IACE,MAAM,CAAC,QAAQ,KAAK,YAAY,CAAC,QAAQ;4BACzC,MAAM,CAAC,QAAQ,KAAK,YAAY,CAAC,MAAM,EACvC,CAAC;4BACD,SAAS;wBACX,CAAC;oBACH,CAAC;yBAAM,CAAC;wBACN,sEAAsE;wBACtE,IAAI,OAAO,KAAK,YAAY,CAAC,MAAM,EAAE,CAAC;4BACpC,SAAS;wBACX,CAAC;oBACH,CAAC;gBACH,CAAC;YACH,CAAC;YAED,qFAAqF;YACrF,yEAAyE;YACzE,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;YACpE,IAAI,kBAAkB,CAAC,GAAG,CAAC,SAAS,CAAC;gBAAE,SAAS;YAEhD,uDAAuD;YACvD,MAAM,aAAa,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,eAAe,MAAM,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/E,MAAM,WAAW,GAAG,IAAI,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,IAAI,IAAI,SAAS,IAAI,aAAa,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;YAE7G,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YAEvD,uCAAuC;YACvC,IAAI,OAAO,GAAG,KAAK,CAAC,KAAK,IAAI,OAAO,GAAG,KAAK,CAAC,GAAG;gBAAE,SAAS;YAE3D,MAAM,WAAW,GAAgB;gBAC/B,IAAI,EAAE,MAAM;gBACZ,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;gBAC9C,SAAS,EAAE,OAAO;aACnB,CAAC;YAEF,WAAW,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;YAC/D,kBAAkB,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,6CAA6C;QAClF,CAAC;QAAC,MAAM,CAAC;YACP,uBAAuB;QACzB,CAAC;IACH,CAAC;IAED,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAEvC,uCAAuC;IACvC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC;IAEtD,KAAK,MAAM,EAAE,OAAO,EAAE,IAAI,WAAW,EAAE,CAAC;QACtC,cAAc,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IACxC,CAAC;IAED,OAAO,WAAW,CAAC,MAAM,CAAC;AAC5B,CAAC;AAED,+EAA+E;AAC/E,4BAA4B;AAC5B,+EAA+E;AAE/E,gFAAgF;AAChF,yEAAyE;AACzE,iEAAiE;AACjE,MAAM,UAAU,yBAAyB,CAAC,aAAqB;IAC7D,OAAO,eAAe,CAAC,QAAQ,EAAE,CAAC;AACpC,CAAC","sourcesContent":["/**\n * Context management for mama.\n *\n * Mama uses two data sources per conversation:\n * - sessions/*.jsonl: Structured session history for agent context\n * - log.jsonl: Human-readable conversation history for grep (no tool results)\n *\n * This module provides:\n * - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager\n * - createMamaSettingsManager: Creates an in-memory SettingsManager for AgentSession\n */\n\nimport type { UserMessage } from \"@mariozechner/pi-ai\";\nimport {\n type SessionManager,\n type SessionMessageEntry,\n SettingsManager,\n} from \"@mariozechner/pi-coding-agent\";\nimport { existsSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\n\n// ============================================================================\n// Sync log.jsonl to SessionManager\n// ============================================================================\n\n/**\n * Time range for filtering log messages\n */\nexport interface TimeRange {\n start: number; // Unix timestamp in ms\n end: number;\n}\n\n/**\n * Default number of days to sync when no time range is specified\n */\nconst DEFAULT_SYNC_DAYS = 10;\n\ninterface LogMessage {\n date?: string;\n ts?: string;\n threadTs?: string;\n user?: string;\n userName?: string;\n text?: string;\n isBot?: boolean;\n}\n\n/**\n * Thread filter for scoping log sync to a specific thread session.\n * When provided, only messages belonging to this thread are synced,\n * preventing cross-thread context contamination.\n */\nexport interface ThreadFilter {\n /** Filter mode: a specific thread, or top-level messages only for persistent channel/chat sessions */\n scope?: \"thread\" | \"top-level\";\n /** The root message timestamp (user's original message ts, derived from sessionKey) */\n rootTs: string;\n /** The thread anchor timestamp (bot's first reply ts, used as thread_ts by Slack replies) */\n threadTs?: string;\n}\n\n/**\n * Sync user messages from log.jsonl to SessionManager.\n *\n * This ensures that messages logged while mama wasn't running (conversation chatter,\n * backfilled messages, messages while busy) are added to the LLM context.\n *\n * @param sessionManager - The SessionManager to sync to\n * @param conversationDir - Path to the conversation directory containing log.jsonl\n * @param excludeSlackTs - Slack timestamp of current message (will be added via prompt(), not sync)\n * @param timeRange - Optional time range to filter log entries (defaults to last 10 days)\n * @param threadFilter - Optional thread filter to scope sync to a specific thread\n * @returns Number of messages synced\n */\nexport async function syncLogToSessionManager(\n sessionManager: SessionManager,\n conversationDir: string,\n excludeSlackTs?: string,\n timeRange?: TimeRange,\n threadFilter?: ThreadFilter,\n): Promise<number> {\n // Calculate default time range (last 10 days) if not provided\n const now = Date.now();\n const defaultStart = now - DEFAULT_SYNC_DAYS * 24 * 60 * 60 * 1000;\n const range = timeRange ?? { start: defaultStart, end: now };\n const logFile = join(conversationDir, \"log.jsonl\");\n\n if (!existsSync(logFile)) return 0;\n\n // Build set of existing timestamps from session entries\n // We use ts (Slack timestamp) as the unique key instead of message content\n const existingTimestamps = new Set<string>();\n for (const entry of sessionManager.getEntries()) {\n if (entry.type === \"message\") {\n const msgEntry = entry as SessionMessageEntry;\n // SessionMessageEntry has a timestamp field (number, Unix ms)\n if (msgEntry.timestamp) {\n existingTimestamps.add(msgEntry.timestamp.toString());\n }\n }\n }\n\n // Read log.jsonl and find user messages not in context\n const logContent = await readFile(logFile, \"utf-8\");\n const logLines = logContent.trim().split(\"\\n\").filter(Boolean);\n\n const newMessages: Array<{ timestamp: number; message: UserMessage }> = [];\n\n for (const line of logLines) {\n try {\n const logMsg: LogMessage = JSON.parse(line);\n\n const slackTs = logMsg.ts;\n const date = logMsg.date;\n if (!slackTs || !date) continue;\n\n // Skip the current message being processed (will be added via prompt())\n if (excludeSlackTs && slackTs === excludeSlackTs) continue;\n\n // Skip bot messages - added through agent flow\n if (logMsg.isBot) continue;\n\n // Thread filtering: only sync messages belonging to this session's thread\n if (threadFilter) {\n if (threadFilter.scope === \"top-level\") {\n // Persistent top-level sessions should only ingest top-level messages.\n // This avoids pulling in unrelated replies from other threads.\n if (logMsg.threadTs) {\n continue;\n }\n } else {\n if (logMsg.threadTs) {\n // Thread reply: only include if threadTs matches our thread anchor or rootTs\n if (\n logMsg.threadTs !== threadFilter.threadTs &&\n logMsg.threadTs !== threadFilter.rootTs\n ) {\n continue;\n }\n } else {\n // Top-level message: only include if it's this session's root message\n if (slackTs !== threadFilter.rootTs) {\n continue;\n }\n }\n }\n }\n\n // Skip if this Slack timestamp is already in the session (dedupe by ts, not content)\n // Convert Slack ts (e.g., \"1234567890.123456\") to Unix ms for comparison\n const slackTsMs = Math.floor(parseFloat(slackTs) * 1000).toString();\n if (existingTimestamps.has(slackTsMs)) continue;\n\n // Build the message text as it would appear in context\n const threadContext = logMsg.threadTs ? ` [in-thread:${logMsg.threadTs}]` : \"\";\n const messageText = `[${logMsg.userName || logMsg.user || \"unknown\"}]${threadContext}: ${logMsg.text || \"\"}`;\n\n const msgTime = new Date(date).getTime() || Date.now();\n\n // Skip messages outside the time range\n if (msgTime < range.start || msgTime > range.end) continue;\n\n const userMessage: UserMessage = {\n role: \"user\",\n content: [{ type: \"text\", text: messageText }],\n timestamp: msgTime,\n };\n\n newMessages.push({ timestamp: msgTime, message: userMessage });\n existingTimestamps.add(slackTsMs); // Track to avoid duplicates within this sync\n } catch {\n // Skip malformed lines\n }\n }\n\n if (newMessages.length === 0) return 0;\n\n // Sort by timestamp and add to session\n newMessages.sort((a, b) => a.timestamp - b.timestamp);\n\n for (const { message } of newMessages) {\n sessionManager.appendMessage(message);\n }\n\n return newMessages.length;\n}\n\n// ============================================================================\n// Settings manager for mama\n// ============================================================================\n\n// Mama manages model/provider config through its own config.ts / settings.json.\n// We use an in-memory SettingsManager so AgentSession has valid defaults\n// without interfering with coding-agent's global settings files.\nexport function createMamaSettingsManager(_workspaceDir: string): SettingsManager {\n return SettingsManager.inMemory();\n}\n"]}
package/dist/events.d.ts CHANGED
@@ -1,21 +1,28 @@
1
- import type { Bot } from "./adapter.js";
1
+ import type { Bot, ConversationKind } from "./adapter.js";
2
2
  export interface ImmediateEvent {
3
3
  type: "immediate";
4
4
  platform: string;
5
- channelId: string;
5
+ conversationId: string;
6
+ conversationKind: ConversationKind;
7
+ /** Creator userId — routes tool execution to that user's vault selection when fired. */
8
+ userId?: string;
6
9
  text: string;
7
10
  }
8
11
  export interface OneShotEvent {
9
12
  type: "one-shot";
10
13
  platform: string;
11
- channelId: string;
14
+ conversationId: string;
15
+ conversationKind: ConversationKind;
16
+ userId?: string;
12
17
  text: string;
13
18
  at: string;
14
19
  }
15
20
  export interface PeriodicEvent {
16
21
  type: "periodic";
17
22
  platform: string;
18
- channelId: string;
23
+ conversationId: string;
24
+ conversationKind: ConversationKind;
25
+ userId?: string;
19
26
  text: string;
20
27
  schedule: string;
21
28
  timezone: string;
@@ -24,7 +31,8 @@ export type MamaEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
24
31
  export interface PeriodicEventInfo {
25
32
  filename: string;
26
33
  platform: string;
27
- channelId: string;
34
+ conversationId: string;
35
+ conversationKind: ConversationKind;
28
36
  text: string;
29
37
  schedule: string;
30
38
  timezone: string;
@@ -60,6 +68,7 @@ export declare class EventsWatcher {
60
68
  private handleFile;
61
69
  private parseEvent;
62
70
  private resolvePlatform;
71
+ private resolveConversationKind;
63
72
  private handleImmediate;
64
73
  private handleOneShot;
65
74
  private handlePeriodic;
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,GAAG,EAAY,MAAM,cAAc,CAAC;AAOlD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,SAAS,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAAC;AAEtE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAUD,qBAAa,aAAa;IAStB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,cAAc;IATxB,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,KAAK,CAAgC;IAC7C,OAAO,CAAC,cAAc,CAA0C;IAChE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAA0B;IAE5C,YACU,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAG5C;IAED;;OAEG;IACH,KAAK,IAAI,IAAI,CAkBZ;IAED;;OAEG;IACH,IAAI,IAAI,IAAI,CA2BX;IAED;;OAEG;IACH,iBAAiB,IAAI,iBAAiB,EAAE,CAyBvC;IAED,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,eAAe;YAcT,UAAU;IA6CxB,OAAO,CAAC,UAAU;IA8ClB,OAAO,CAAC,eAAe;IAsBvB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,OAAO;IAkDf,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,KAAK;CAGd;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClC,aAAa,CAGf","sourcesContent":["import { Cron } from \"croner\";\nimport {\n existsSync,\n type FSWatcher,\n mkdirSync,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n watch,\n} from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport type { Bot, BotEvent } from \"./adapter.js\";\nimport * as log from \"./log.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n type: \"immediate\";\n platform: string;\n channelId: string;\n text: string;\n}\n\nexport interface OneShotEvent {\n type: \"one-shot\";\n platform: string;\n channelId: string;\n text: string;\n at: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n type: \"periodic\";\n platform: string;\n channelId: string;\n text: string;\n schedule: string; // cron syntax\n timezone: string; // IANA timezone\n}\n\nexport type MamaEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\nexport interface PeriodicEventInfo {\n filename: string;\n platform: string;\n channelId: string;\n text: string;\n schedule: string;\n timezone: string;\n nextRun: string | null; // ISO 8601\n}\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n private timers: Map<string, NodeJS.Timeout> = new Map();\n private crons: Map<string, Cron> = new Map();\n private debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n private startTime: number;\n private watcher: FSWatcher | null = null;\n private knownFiles: Set<string> = new Set();\n\n constructor(\n private eventsDir: string,\n private botsByPlatform: Record<string, Bot>,\n ) {\n this.startTime = Date.now();\n }\n\n /**\n * Start watching for events. Call this after platform bots are initialized.\n */\n start(): void {\n // Ensure events directory exists\n if (!existsSync(this.eventsDir)) {\n mkdirSync(this.eventsDir, { recursive: true });\n }\n\n log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n // Scan existing files\n this.scanExisting();\n\n // Watch for changes\n this.watcher = watch(this.eventsDir, (_eventType, filename) => {\n if (!filename || !filename.endsWith(\".json\")) return;\n this.debounce(filename, () => this.handleFileChange(filename));\n });\n\n log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n }\n\n /**\n * Stop watching and cancel all scheduled events.\n */\n stop(): void {\n // Stop fs watcher\n if (this.watcher) {\n this.watcher.close();\n this.watcher = null;\n }\n\n // Cancel all debounce timers\n for (const timer of this.debounceTimers.values()) {\n clearTimeout(timer);\n }\n this.debounceTimers.clear();\n\n // Cancel all scheduled timers\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n\n // Cancel all cron jobs\n for (const cron of this.crons.values()) {\n cron.stop();\n }\n this.crons.clear();\n\n this.knownFiles.clear();\n log.logInfo(\"Events watcher stopped\");\n }\n\n /**\n * Return all active periodic (cron) events with their next run time.\n */\n getPeriodicEvents(): PeriodicEventInfo[] {\n const results: PeriodicEventInfo[] = [];\n for (const [filename, cron] of this.crons) {\n const filePath = join(this.eventsDir, filename);\n try {\n const content = readFileSync(filePath, \"utf-8\");\n const data = this.parseEvent(content, filename);\n if (!data || data.type !== \"periodic\") {\n continue;\n }\n const next = cron.nextRun();\n results.push({\n filename,\n platform: data.platform,\n channelId: data.channelId,\n text: data.text,\n schedule: data.schedule,\n timezone: data.timezone,\n nextRun: next?.toISOString() ?? null,\n });\n } catch {\n // File may have been deleted or corrupted, skip\n }\n }\n return results;\n }\n\n private debounce(filename: string, fn: () => void): void {\n const existing = this.debounceTimers.get(filename);\n if (existing) {\n clearTimeout(existing);\n }\n this.debounceTimers.set(\n filename,\n setTimeout(() => {\n this.debounceTimers.delete(filename);\n fn();\n }, DEBOUNCE_MS),\n );\n }\n\n private scanExisting(): void {\n let files: string[];\n try {\n files = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n } catch (err) {\n log.logWarning(\"Failed to read events directory\", String(err));\n return;\n }\n\n for (const filename of files) {\n this.handleFile(filename);\n }\n }\n\n private handleFileChange(filename: string): void {\n const filePath = join(this.eventsDir, filename);\n\n if (!existsSync(filePath)) {\n // File was deleted\n this.handleDelete(filename);\n } else if (this.knownFiles.has(filename)) {\n // File was modified - cancel existing and re-schedule\n this.cancelScheduled(filename);\n this.handleFile(filename);\n } else {\n // New file\n this.handleFile(filename);\n }\n }\n\n private handleDelete(filename: string): void {\n if (!this.knownFiles.has(filename)) return;\n\n log.logInfo(`Event file deleted: ${filename}`);\n this.cancelScheduled(filename);\n this.knownFiles.delete(filename);\n }\n\n private cancelScheduled(filename: string): void {\n const timer = this.timers.get(filename);\n if (timer) {\n clearTimeout(timer);\n this.timers.delete(filename);\n }\n\n const cron = this.crons.get(filename);\n if (cron) {\n cron.stop();\n this.crons.delete(filename);\n }\n }\n\n private async handleFile(filename: string): Promise<void> {\n const filePath = join(this.eventsDir, filename);\n\n // Parse with retries\n let event: MamaEvent | null = null;\n let lastError: Error | null = null;\n\n for (let i = 0; i < MAX_RETRIES; i++) {\n try {\n const content = await readFile(filePath, \"utf-8\");\n event = this.parseEvent(content, filename);\n break;\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (i < MAX_RETRIES - 1) {\n await this.sleep(RETRY_BASE_MS * 2 ** i);\n }\n }\n }\n\n if (!event) {\n log.logWarning(\n `Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`,\n lastError?.message,\n );\n this.deleteFile(filename);\n return;\n }\n\n this.knownFiles.add(filename);\n\n // Schedule based on type\n switch (event.type) {\n case \"immediate\":\n this.handleImmediate(filename, event);\n break;\n case \"one-shot\":\n this.handleOneShot(filename, event);\n break;\n case \"periodic\":\n this.handlePeriodic(filename, event);\n break;\n }\n }\n\n private parseEvent(content: string, filename: string): MamaEvent | null {\n const data = JSON.parse(content);\n\n if (!data.type || !data.channelId || !data.text) {\n throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);\n }\n\n const platform = this.resolvePlatform(data.platform, filename);\n\n switch (data.type) {\n case \"immediate\":\n return { type: \"immediate\", platform, channelId: data.channelId, text: data.text };\n\n case \"one-shot\":\n if (!data.at) {\n throw new Error(`Missing 'at' field for one-shot event in ${filename}`);\n }\n return {\n type: \"one-shot\",\n platform,\n channelId: data.channelId,\n text: data.text,\n at: data.at,\n };\n\n case \"periodic\":\n if (!data.schedule) {\n throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n }\n if (!data.timezone) {\n throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n }\n return {\n type: \"periodic\",\n platform,\n channelId: data.channelId,\n text: data.text,\n schedule: data.schedule,\n timezone: data.timezone,\n };\n\n default:\n throw new Error(`Unknown event type '${data.type}' in ${filename}`);\n }\n }\n\n private resolvePlatform(platformValue: unknown, filename: string): string {\n const availablePlatforms = Object.keys(this.botsByPlatform);\n\n if (typeof platformValue === \"string\" && platformValue.trim().length > 0) {\n const platform = platformValue.trim().toLowerCase();\n if (!this.botsByPlatform[platform]) {\n throw new Error(\n `Unknown platform '${platformValue}' in ${filename}. Expected one of: ${availablePlatforms.join(\", \")}`,\n );\n }\n return platform;\n }\n\n if (availablePlatforms.length === 1) {\n return availablePlatforms[0];\n }\n\n throw new Error(\n `Missing required field 'platform' in ${filename}. Available platforms: ${availablePlatforms.join(\", \")}`,\n );\n }\n\n private handleImmediate(filename: string, event: ImmediateEvent): void {\n const filePath = join(this.eventsDir, filename);\n\n // Check if stale (created before harness started)\n try {\n const stat = statSync(filePath);\n if (stat.mtimeMs < this.startTime) {\n log.logInfo(`Stale immediate event, deleting: ${filename}`);\n this.deleteFile(filename);\n return;\n }\n } catch {\n // File may have been deleted\n return;\n }\n\n log.logInfo(`Executing immediate event: ${filename}`);\n this.execute(filename, event);\n }\n\n private handleOneShot(filename: string, event: OneShotEvent): void {\n const atTime = new Date(event.at).getTime();\n const now = Date.now();\n\n if (atTime <= now) {\n // Past - delete without executing\n log.logInfo(`One-shot event in the past, deleting: ${filename}`);\n this.deleteFile(filename);\n return;\n }\n\n const delay = atTime - now;\n log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n const timer = setTimeout(() => {\n this.timers.delete(filename);\n log.logInfo(`Executing one-shot event: ${filename}`);\n this.execute(filename, event);\n }, delay);\n\n this.timers.set(filename, timer);\n }\n\n private handlePeriodic(filename: string, event: PeriodicEvent): void {\n try {\n const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n log.logInfo(`Executing periodic event: ${filename}`);\n this.execute(filename, event, false); // Don't delete periodic events\n });\n\n this.crons.set(filename, cron);\n\n const next = cron.nextRun();\n log.logInfo(\n `Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`,\n );\n } catch (err) {\n log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n this.deleteFile(filename);\n }\n }\n\n private execute(filename: string, event: MamaEvent, deleteAfter: boolean = true): void {\n // Format the message\n let scheduleInfo: string;\n switch (event.type) {\n case \"immediate\":\n scheduleInfo = \"immediate\";\n break;\n case \"one-shot\":\n scheduleInfo = event.at;\n break;\n case \"periodic\":\n scheduleInfo = event.schedule;\n break;\n }\n\n const message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n const bot = this.botsByPlatform[event.platform];\n\n if (!bot) {\n log.logWarning(`No bot configured for event platform '${event.platform}'`, filename);\n if (deleteAfter) {\n this.deleteFile(filename);\n }\n return;\n }\n\n // Create synthetic BotEvent - use channelId as ts for stable session key\n const syntheticEvent: BotEvent = {\n type: \"mention\",\n channel: event.channelId,\n user: \"EVENT\",\n text: message,\n ts: event.channelId, // Stable key: same channel uses same ts for all events\n };\n\n // Enqueue for processing\n const enqueued = bot.enqueueEvent(syntheticEvent);\n\n if (enqueued && deleteAfter) {\n // Delete file after successful enqueue (immediate and one-shot)\n this.deleteFile(filename);\n } else if (!enqueued) {\n log.logWarning(`Event queue full, discarded: ${filename}`);\n // Still delete immediate/one-shot even if discarded\n if (deleteAfter) {\n this.deleteFile(filename);\n }\n }\n }\n\n private deleteFile(filename: string): void {\n const filePath = join(this.eventsDir, filename);\n try {\n unlinkSync(filePath);\n } catch (err) {\n // ENOENT is fine (file already deleted), other errors are warnings\n if (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n log.logWarning(`Failed to delete event file: ${filename}`, String(err));\n }\n }\n this.knownFiles.delete(filename);\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n\n/**\n * Create an events watcher for all configured platforms.\n */\nexport function createEventsWatcher(\n workspaceDir: string,\n botsByPlatform: Record<string, Bot>,\n): EventsWatcher {\n const eventsDir = join(workspaceDir, \"events\");\n return new EventsWatcher(eventsDir, botsByPlatform);\n}\n"]}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,GAAG,EAAY,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAOpE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,wFAAwF;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,SAAS,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAAC;AAEtE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAUD,qBAAa,aAAa;IAStB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,cAAc;IATxB,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,KAAK,CAAgC;IAC7C,OAAO,CAAC,cAAc,CAA0C;IAChE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAA0B;IAE5C,YACU,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAG5C;IAED;;OAEG;IACH,KAAK,IAAI,IAAI,CAkBZ;IAED;;OAEG;IACH,IAAI,IAAI,IAAI,CA2BX;IAED;;OAEG;IACH,iBAAiB,IAAI,iBAAiB,EAAE,CA0BvC;IAED,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,eAAe;YAcT,UAAU;IA6CxB,OAAO,CAAC,UAAU;IAqElB,OAAO,CAAC,eAAe;IAsBvB,OAAO,CAAC,uBAAuB;IAoB/B,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,OAAO;IAsDf,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,KAAK;CAGd;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EACpB,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAClC,aAAa,CAGf","sourcesContent":["import { Cron } from \"croner\";\nimport {\n existsSync,\n type FSWatcher,\n mkdirSync,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n watch,\n} from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport type { Bot, BotEvent, ConversationKind } from \"./adapter.js\";\nimport * as log from \"./log.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n type: \"immediate\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n /** Creator userId — routes tool execution to that user's vault selection when fired. */\n userId?: string;\n text: string;\n}\n\nexport interface OneShotEvent {\n type: \"one-shot\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId?: string;\n text: string;\n at: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n type: \"periodic\";\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n userId?: string;\n text: string;\n schedule: string; // cron syntax\n timezone: string; // IANA timezone\n}\n\nexport type MamaEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\nexport interface PeriodicEventInfo {\n filename: string;\n platform: string;\n conversationId: string;\n conversationKind: ConversationKind;\n text: string;\n schedule: string;\n timezone: string;\n nextRun: string | null; // ISO 8601\n}\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n private timers: Map<string, NodeJS.Timeout> = new Map();\n private crons: Map<string, Cron> = new Map();\n private debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n private startTime: number;\n private watcher: FSWatcher | null = null;\n private knownFiles: Set<string> = new Set();\n\n constructor(\n private eventsDir: string,\n private botsByPlatform: Record<string, Bot>,\n ) {\n this.startTime = Date.now();\n }\n\n /**\n * Start watching for events. Call this after platform bots are initialized.\n */\n start(): void {\n // Ensure events directory exists\n if (!existsSync(this.eventsDir)) {\n mkdirSync(this.eventsDir, { recursive: true });\n }\n\n log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n // Scan existing files\n this.scanExisting();\n\n // Watch for changes\n this.watcher = watch(this.eventsDir, (_eventType, filename) => {\n if (!filename || !filename.endsWith(\".json\")) return;\n this.debounce(filename, () => this.handleFileChange(filename));\n });\n\n log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n }\n\n /**\n * Stop watching and cancel all scheduled events.\n */\n stop(): void {\n // Stop fs watcher\n if (this.watcher) {\n this.watcher.close();\n this.watcher = null;\n }\n\n // Cancel all debounce timers\n for (const timer of this.debounceTimers.values()) {\n clearTimeout(timer);\n }\n this.debounceTimers.clear();\n\n // Cancel all scheduled timers\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n\n // Cancel all cron jobs\n for (const cron of this.crons.values()) {\n cron.stop();\n }\n this.crons.clear();\n\n this.knownFiles.clear();\n log.logInfo(\"Events watcher stopped\");\n }\n\n /**\n * Return all active periodic (cron) events with their next run time.\n */\n getPeriodicEvents(): PeriodicEventInfo[] {\n const results: PeriodicEventInfo[] = [];\n for (const [filename, cron] of this.crons) {\n const filePath = join(this.eventsDir, filename);\n try {\n const content = readFileSync(filePath, \"utf-8\");\n const data = this.parseEvent(content, filename);\n if (!data || data.type !== \"periodic\") {\n continue;\n }\n const next = cron.nextRun();\n results.push({\n filename,\n platform: data.platform,\n conversationId: data.conversationId,\n conversationKind: data.conversationKind,\n text: data.text,\n schedule: data.schedule,\n timezone: data.timezone,\n nextRun: next?.toISOString() ?? null,\n });\n } catch {\n // File may have been deleted or corrupted, skip\n }\n }\n return results;\n }\n\n private debounce(filename: string, fn: () => void): void {\n const existing = this.debounceTimers.get(filename);\n if (existing) {\n clearTimeout(existing);\n }\n this.debounceTimers.set(\n filename,\n setTimeout(() => {\n this.debounceTimers.delete(filename);\n fn();\n }, DEBOUNCE_MS),\n );\n }\n\n private scanExisting(): void {\n let files: string[];\n try {\n files = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n } catch (err) {\n log.logWarning(\"Failed to read events directory\", String(err));\n return;\n }\n\n for (const filename of files) {\n this.handleFile(filename);\n }\n }\n\n private handleFileChange(filename: string): void {\n const filePath = join(this.eventsDir, filename);\n\n if (!existsSync(filePath)) {\n // File was deleted\n this.handleDelete(filename);\n } else if (this.knownFiles.has(filename)) {\n // File was modified - cancel existing and re-schedule\n this.cancelScheduled(filename);\n this.handleFile(filename);\n } else {\n // New file\n this.handleFile(filename);\n }\n }\n\n private handleDelete(filename: string): void {\n if (!this.knownFiles.has(filename)) return;\n\n log.logInfo(`Event file deleted: ${filename}`);\n this.cancelScheduled(filename);\n this.knownFiles.delete(filename);\n }\n\n private cancelScheduled(filename: string): void {\n const timer = this.timers.get(filename);\n if (timer) {\n clearTimeout(timer);\n this.timers.delete(filename);\n }\n\n const cron = this.crons.get(filename);\n if (cron) {\n cron.stop();\n this.crons.delete(filename);\n }\n }\n\n private async handleFile(filename: string): Promise<void> {\n const filePath = join(this.eventsDir, filename);\n\n // Parse with retries\n let event: MamaEvent | null = null;\n let lastError: Error | null = null;\n\n for (let i = 0; i < MAX_RETRIES; i++) {\n try {\n const content = await readFile(filePath, \"utf-8\");\n event = this.parseEvent(content, filename);\n break;\n } catch (err) {\n lastError = err instanceof Error ? err : new Error(String(err));\n if (i < MAX_RETRIES - 1) {\n await this.sleep(RETRY_BASE_MS * 2 ** i);\n }\n }\n }\n\n if (!event) {\n log.logWarning(\n `Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`,\n lastError?.message,\n );\n this.deleteFile(filename);\n return;\n }\n\n this.knownFiles.add(filename);\n\n // Schedule based on type\n switch (event.type) {\n case \"immediate\":\n this.handleImmediate(filename, event);\n break;\n case \"one-shot\":\n this.handleOneShot(filename, event);\n break;\n case \"periodic\":\n this.handlePeriodic(filename, event);\n break;\n }\n }\n\n private parseEvent(content: string, filename: string): MamaEvent | null {\n const data = JSON.parse(content);\n const conversationId =\n typeof data.conversationId === \"string\"\n ? data.conversationId\n : typeof data.channelId === \"string\"\n ? data.channelId\n : undefined;\n\n if (!data.type || !conversationId || !data.text) {\n throw new Error(`Missing required fields (type, conversationId, text) in ${filename}`);\n }\n\n const platform = this.resolvePlatform(data.platform, filename);\n const conversationKind = this.resolveConversationKind(\n platform,\n conversationId,\n data.conversationKind,\n );\n const userId = typeof data.userId === \"string\" ? data.userId : undefined;\n\n switch (data.type) {\n case \"immediate\":\n return {\n type: \"immediate\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text: data.text,\n };\n\n case \"one-shot\":\n if (!data.at) {\n throw new Error(`Missing 'at' field for one-shot event in ${filename}`);\n }\n return {\n type: \"one-shot\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text: data.text,\n at: data.at,\n };\n\n case \"periodic\":\n if (!data.schedule) {\n throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n }\n if (!data.timezone) {\n throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n }\n return {\n type: \"periodic\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text: data.text,\n schedule: data.schedule,\n timezone: data.timezone,\n };\n\n default:\n throw new Error(`Unknown event type '${data.type}' in ${filename}`);\n }\n }\n\n private resolvePlatform(platformValue: unknown, filename: string): string {\n const availablePlatforms = Object.keys(this.botsByPlatform);\n\n if (typeof platformValue === \"string\" && platformValue.trim().length > 0) {\n const platform = platformValue.trim().toLowerCase();\n if (!this.botsByPlatform[platform]) {\n throw new Error(\n `Unknown platform '${platformValue}' in ${filename}. Expected one of: ${availablePlatforms.join(\", \")}`,\n );\n }\n return platform;\n }\n\n if (availablePlatforms.length === 1) {\n return availablePlatforms[0];\n }\n\n throw new Error(\n `Missing required field 'platform' in ${filename}. Available platforms: ${availablePlatforms.join(\", \")}`,\n );\n }\n\n private resolveConversationKind(\n platform: string,\n conversationId: string,\n conversationKindValue: unknown,\n ): ConversationKind {\n if (conversationKindValue === \"direct\" || conversationKindValue === \"shared\") {\n return conversationKindValue;\n }\n\n if (platform === \"slack\") {\n return conversationId.startsWith(\"D\") ? \"direct\" : \"shared\";\n }\n\n if (platform === \"telegram\") {\n return conversationId.startsWith(\"-\") ? \"shared\" : \"direct\";\n }\n\n return \"shared\";\n }\n\n private handleImmediate(filename: string, event: ImmediateEvent): void {\n const filePath = join(this.eventsDir, filename);\n\n // Check if stale (created before harness started)\n try {\n const stat = statSync(filePath);\n if (stat.mtimeMs < this.startTime) {\n log.logInfo(`Stale immediate event, deleting: ${filename}`);\n this.deleteFile(filename);\n return;\n }\n } catch {\n // File may have been deleted\n return;\n }\n\n log.logInfo(`Executing immediate event: ${filename}`);\n this.execute(filename, event);\n }\n\n private handleOneShot(filename: string, event: OneShotEvent): void {\n const atTime = new Date(event.at).getTime();\n const now = Date.now();\n\n if (atTime <= now) {\n // Past - delete without executing\n log.logInfo(`One-shot event in the past, deleting: ${filename}`);\n this.deleteFile(filename);\n return;\n }\n\n const delay = atTime - now;\n log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n const timer = setTimeout(() => {\n this.timers.delete(filename);\n log.logInfo(`Executing one-shot event: ${filename}`);\n this.execute(filename, event);\n }, delay);\n\n this.timers.set(filename, timer);\n }\n\n private handlePeriodic(filename: string, event: PeriodicEvent): void {\n try {\n const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n log.logInfo(`Executing periodic event: ${filename}`);\n this.execute(filename, event, false); // Don't delete periodic events\n });\n\n this.crons.set(filename, cron);\n\n const next = cron.nextRun();\n log.logInfo(\n `Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`,\n );\n } catch (err) {\n log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n this.deleteFile(filename);\n }\n }\n\n private execute(filename: string, event: MamaEvent, deleteAfter: boolean = true): void {\n // Format the message\n let scheduleInfo: string;\n switch (event.type) {\n case \"immediate\":\n scheduleInfo = \"immediate\";\n break;\n case \"one-shot\":\n scheduleInfo = event.at;\n break;\n case \"periodic\":\n scheduleInfo = event.schedule;\n break;\n }\n\n const message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n const bot = this.botsByPlatform[event.platform];\n\n if (!bot) {\n log.logWarning(`No bot configured for event platform '${event.platform}'`, filename);\n if (deleteAfter) {\n this.deleteFile(filename);\n }\n return;\n }\n\n // Create synthetic BotEvent. Keep a stable conversation session key so recurring\n // reminders share context, but use a unique synthetic message id because\n // some adapters treat ts/message id as a reply target.\n const syntheticEvent: BotEvent = {\n type: \"mention\",\n conversationId: event.conversationId,\n conversationKind: event.conversationKind,\n user: event.userId ?? \"EVENT\",\n text: message,\n ts: `event:${filename}`,\n sessionKey: event.conversationId,\n };\n\n // Enqueue for processing\n const enqueued = bot.enqueueEvent(syntheticEvent);\n\n if (enqueued && deleteAfter) {\n // Delete file after successful enqueue (immediate and one-shot)\n this.deleteFile(filename);\n } else if (!enqueued) {\n log.logWarning(`Event queue full, discarded: ${filename}`);\n // Still delete immediate/one-shot even if discarded\n if (deleteAfter) {\n this.deleteFile(filename);\n }\n }\n }\n\n private deleteFile(filename: string): void {\n const filePath = join(this.eventsDir, filename);\n try {\n unlinkSync(filePath);\n } catch (err) {\n // ENOENT is fine (file already deleted), other errors are warnings\n if (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n log.logWarning(`Failed to delete event file: ${filename}`, String(err));\n }\n }\n this.knownFiles.delete(filename);\n }\n\n private sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n\n/**\n * Create an events watcher for all configured platforms.\n */\nexport function createEventsWatcher(\n workspaceDir: string,\n botsByPlatform: Record<string, Bot>,\n): EventsWatcher {\n const eventsDir = join(workspaceDir, \"events\");\n return new EventsWatcher(eventsDir, botsByPlatform);\n}\n"]}
package/dist/events.js CHANGED
@@ -83,7 +83,8 @@ export class EventsWatcher {
83
83
  results.push({
84
84
  filename,
85
85
  platform: data.platform,
86
- channelId: data.channelId,
86
+ conversationId: data.conversationId,
87
+ conversationKind: data.conversationKind,
87
88
  text: data.text,
88
89
  schedule: data.schedule,
89
90
  timezone: data.timezone,
@@ -193,13 +194,27 @@ export class EventsWatcher {
193
194
  }
194
195
  parseEvent(content, filename) {
195
196
  const data = JSON.parse(content);
196
- if (!data.type || !data.channelId || !data.text) {
197
- throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
197
+ const conversationId = typeof data.conversationId === "string"
198
+ ? data.conversationId
199
+ : typeof data.channelId === "string"
200
+ ? data.channelId
201
+ : undefined;
202
+ if (!data.type || !conversationId || !data.text) {
203
+ throw new Error(`Missing required fields (type, conversationId, text) in ${filename}`);
198
204
  }
199
205
  const platform = this.resolvePlatform(data.platform, filename);
206
+ const conversationKind = this.resolveConversationKind(platform, conversationId, data.conversationKind);
207
+ const userId = typeof data.userId === "string" ? data.userId : undefined;
200
208
  switch (data.type) {
201
209
  case "immediate":
202
- return { type: "immediate", platform, channelId: data.channelId, text: data.text };
210
+ return {
211
+ type: "immediate",
212
+ platform,
213
+ conversationId,
214
+ conversationKind,
215
+ userId,
216
+ text: data.text,
217
+ };
203
218
  case "one-shot":
204
219
  if (!data.at) {
205
220
  throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
@@ -207,7 +222,9 @@ export class EventsWatcher {
207
222
  return {
208
223
  type: "one-shot",
209
224
  platform,
210
- channelId: data.channelId,
225
+ conversationId,
226
+ conversationKind,
227
+ userId,
211
228
  text: data.text,
212
229
  at: data.at,
213
230
  };
@@ -221,7 +238,9 @@ export class EventsWatcher {
221
238
  return {
222
239
  type: "periodic",
223
240
  platform,
224
- channelId: data.channelId,
241
+ conversationId,
242
+ conversationKind,
243
+ userId,
225
244
  text: data.text,
226
245
  schedule: data.schedule,
227
246
  timezone: data.timezone,
@@ -244,6 +263,18 @@ export class EventsWatcher {
244
263
  }
245
264
  throw new Error(`Missing required field 'platform' in ${filename}. Available platforms: ${availablePlatforms.join(", ")}`);
246
265
  }
266
+ resolveConversationKind(platform, conversationId, conversationKindValue) {
267
+ if (conversationKindValue === "direct" || conversationKindValue === "shared") {
268
+ return conversationKindValue;
269
+ }
270
+ if (platform === "slack") {
271
+ return conversationId.startsWith("D") ? "direct" : "shared";
272
+ }
273
+ if (platform === "telegram") {
274
+ return conversationId.startsWith("-") ? "shared" : "direct";
275
+ }
276
+ return "shared";
277
+ }
247
278
  handleImmediate(filename, event) {
248
279
  const filePath = join(this.eventsDir, filename);
249
280
  // Check if stale (created before harness started)
@@ -318,13 +349,17 @@ export class EventsWatcher {
318
349
  }
319
350
  return;
320
351
  }
321
- // Create synthetic BotEvent - use channelId as ts for stable session key
352
+ // Create synthetic BotEvent. Keep a stable conversation session key so recurring
353
+ // reminders share context, but use a unique synthetic message id because
354
+ // some adapters treat ts/message id as a reply target.
322
355
  const syntheticEvent = {
323
356
  type: "mention",
324
- channel: event.channelId,
325
- user: "EVENT",
357
+ conversationId: event.conversationId,
358
+ conversationKind: event.conversationKind,
359
+ user: event.userId ?? "EVENT",
326
360
  text: message,
327
- ts: event.channelId, // Stable key: same channel uses same ts for all events
361
+ ts: `event:${filename}`,
362
+ sessionKey: event.conversationId,
328
363
  };
329
364
  // Enqueue for processing
330
365
  const enqueued = bot.enqueueEvent(syntheticEvent);