@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.3

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 (149) hide show
  1. package/README.md +133 -78
  2. package/dist/adapter.d.ts +22 -10
  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 +10 -7
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +228 -69
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +92 -34
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/shared.d.ts +23 -0
  13. package/dist/adapters/shared.d.ts.map +1 -0
  14. package/dist/adapters/shared.js +57 -0
  15. package/dist/adapters/shared.js.map +1 -0
  16. package/dist/adapters/slack/bot.d.ts +19 -11
  17. package/dist/adapters/slack/bot.d.ts.map +1 -1
  18. package/dist/adapters/slack/bot.js +356 -96
  19. package/dist/adapters/slack/bot.js.map +1 -1
  20. package/dist/adapters/slack/branch-manager.d.ts +21 -0
  21. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  22. package/dist/adapters/slack/branch-manager.js +96 -0
  23. package/dist/adapters/slack/branch-manager.js.map +1 -0
  24. package/dist/adapters/slack/context.d.ts.map +1 -1
  25. package/dist/adapters/slack/context.js +100 -67
  26. package/dist/adapters/slack/context.js.map +1 -1
  27. package/dist/adapters/slack/session.d.ts +3 -0
  28. package/dist/adapters/slack/session.d.ts.map +1 -0
  29. package/dist/adapters/slack/session.js +16 -0
  30. package/dist/adapters/slack/session.js.map +1 -0
  31. package/dist/adapters/telegram/bot.d.ts +4 -2
  32. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  33. package/dist/adapters/telegram/bot.js +141 -74
  34. package/dist/adapters/telegram/bot.js.map +1 -1
  35. package/dist/adapters/telegram/context.d.ts.map +1 -1
  36. package/dist/adapters/telegram/context.js +49 -109
  37. package/dist/adapters/telegram/context.js.map +1 -1
  38. package/dist/adapters/telegram/html.d.ts +3 -0
  39. package/dist/adapters/telegram/html.d.ts.map +1 -0
  40. package/dist/adapters/telegram/html.js +98 -0
  41. package/dist/adapters/telegram/html.js.map +1 -0
  42. package/dist/agent.d.ts +4 -11
  43. package/dist/agent.d.ts.map +1 -1
  44. package/dist/agent.js +116 -196
  45. package/dist/agent.js.map +1 -1
  46. package/dist/bindings.d.ts +1 -20
  47. package/dist/bindings.d.ts.map +1 -1
  48. package/dist/bindings.js +1 -21
  49. package/dist/bindings.js.map +1 -1
  50. package/dist/config.d.ts +9 -27
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/config.js +89 -63
  53. package/dist/config.js.map +1 -1
  54. package/dist/context.d.ts +13 -3
  55. package/dist/context.d.ts.map +1 -1
  56. package/dist/context.js +102 -18
  57. package/dist/context.js.map +1 -1
  58. package/dist/events.d.ts +18 -6
  59. package/dist/events.d.ts.map +1 -1
  60. package/dist/events.js +86 -35
  61. package/dist/events.js.map +1 -1
  62. package/dist/execution-resolver.d.ts.map +1 -1
  63. package/dist/execution-resolver.js +1 -3
  64. package/dist/execution-resolver.js.map +1 -1
  65. package/dist/instrument.d.ts.map +1 -1
  66. package/dist/instrument.js +5 -11
  67. package/dist/instrument.js.map +1 -1
  68. package/dist/{login.d.ts → login/index.d.ts} +2 -2
  69. package/dist/login/index.d.ts.map +1 -0
  70. package/dist/{login.js → login/index.js} +2 -2
  71. package/dist/login/index.js.map +1 -0
  72. package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
  73. package/dist/login/portal.d.ts.map +1 -0
  74. package/dist/login/portal.js +1453 -0
  75. package/dist/login/portal.js.map +1 -0
  76. package/dist/{link-token.d.ts → login/session.d.ts} +1 -1
  77. package/dist/login/session.d.ts.map +1 -0
  78. package/dist/{link-token.js → login/session.js} +1 -1
  79. package/dist/login/session.js.map +1 -0
  80. package/dist/main.d.ts.map +1 -1
  81. package/dist/main.js +175 -119
  82. package/dist/main.js.map +1 -1
  83. package/dist/provisioner.d.ts +17 -43
  84. package/dist/provisioner.d.ts.map +1 -1
  85. package/dist/provisioner.js +84 -50
  86. package/dist/provisioner.js.map +1 -1
  87. package/dist/sandbox/host.d.ts +0 -2
  88. package/dist/sandbox/host.d.ts.map +1 -1
  89. package/dist/sandbox/host.js +1 -5
  90. package/dist/sandbox/host.js.map +1 -1
  91. package/dist/sentry.d.ts.map +1 -1
  92. package/dist/sentry.js +2 -0
  93. package/dist/sentry.js.map +1 -1
  94. package/dist/session-policy.d.ts +13 -0
  95. package/dist/session-policy.d.ts.map +1 -0
  96. package/dist/session-policy.js +23 -0
  97. package/dist/session-policy.js.map +1 -0
  98. package/dist/session-store.d.ts +27 -1
  99. package/dist/session-store.d.ts.map +1 -1
  100. package/dist/session-store.js +162 -9
  101. package/dist/session-store.js.map +1 -1
  102. package/dist/session-view/command.d.ts +5 -0
  103. package/dist/session-view/command.d.ts.map +1 -0
  104. package/dist/session-view/command.js +11 -0
  105. package/dist/session-view/command.js.map +1 -0
  106. package/dist/session-view/portal.d.ts +9 -0
  107. package/dist/session-view/portal.d.ts.map +1 -0
  108. package/dist/session-view/portal.js +766 -0
  109. package/dist/session-view/portal.js.map +1 -0
  110. package/dist/session-view/service.d.ts +34 -0
  111. package/dist/session-view/service.d.ts.map +1 -0
  112. package/dist/session-view/service.js +380 -0
  113. package/dist/session-view/service.js.map +1 -0
  114. package/dist/session-view/store.d.ts +16 -0
  115. package/dist/session-view/store.d.ts.map +1 -0
  116. package/dist/session-view/store.js +38 -0
  117. package/dist/session-view/store.js.map +1 -0
  118. package/dist/store.d.ts +3 -6
  119. package/dist/store.d.ts.map +1 -1
  120. package/dist/store.js +15 -35
  121. package/dist/store.js.map +1 -1
  122. package/dist/tools/event.d.ts +3 -0
  123. package/dist/tools/event.d.ts.map +1 -1
  124. package/dist/tools/event.js +27 -8
  125. package/dist/tools/event.js.map +1 -1
  126. package/dist/tools/index.d.ts +3 -0
  127. package/dist/tools/index.d.ts.map +1 -1
  128. package/dist/tools/index.js +2 -2
  129. package/dist/tools/index.js.map +1 -1
  130. package/dist/ui-copy.d.ts +1 -0
  131. package/dist/ui-copy.d.ts.map +1 -1
  132. package/dist/ui-copy.js +3 -0
  133. package/dist/ui-copy.js.map +1 -1
  134. package/dist/vault-routing.d.ts +1 -2
  135. package/dist/vault-routing.d.ts.map +1 -1
  136. package/dist/vault-routing.js +1 -7
  137. package/dist/vault-routing.js.map +1 -1
  138. package/package.json +1 -1
  139. package/dist/link-server.d.ts.map +0 -1
  140. package/dist/link-server.js +0 -839
  141. package/dist/link-server.js.map +0 -1
  142. package/dist/link-token.d.ts.map +0 -1
  143. package/dist/link-token.js.map +0 -1
  144. package/dist/login.d.ts.map +0 -1
  145. package/dist/login.js.map +0 -1
  146. package/dist/vault.test.d.ts +0 -2
  147. package/dist/vault.test.d.ts.map +0 -1
  148. package/dist/vault.test.js +0 -67
  149. package/dist/vault.test.js.map +0 -1
@@ -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,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 the 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 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 (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"]}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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","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 { Message, 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\nexport interface ConversationLogMessage {\n date?: string;\n ts?: string;\n threadTs?: string;\n user?: string;\n userName?: string;\n text?: string;\n isBot?: boolean;\n}\n\ninterface ExistingSessionMessage {\n timestamp?: number;\n rawText: string;\n normalizedText: string;\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 - Current platform message ID/timestamp (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 a list of existing session messages for dedupe.\n // Live user prompts carry a formatted timestamp in the text and use Date.now(),\n // while log.jsonl uses the platform event timestamp. We therefore need a small\n // fuzzy match window in addition to the exact timestamp/content match used for\n // already-synced log entries.\n const existingMessages: ExistingSessionMessage[] = [];\n const existingMessageKeys = new Set<string>();\n for (const entry of sessionManager.getEntries()) {\n if (entry.type !== \"message\") continue;\n const msgEntry = entry as SessionMessageEntry;\n const message = msgEntry.message as Message;\n const contentText = Array.isArray(message.content)\n ? message.content\n .filter((part): part is { type: \"text\"; text: string } => part.type === \"text\")\n .map((part) => part.text)\n .join(\"\\n\\n\")\n : typeof message.content === \"string\"\n ? message.content\n : \"\";\n existingMessages.push({\n timestamp: typeof message.timestamp === \"number\" ? message.timestamp : undefined,\n rawText: contentText,\n normalizedText: normalizeComparableUserText(contentText),\n });\n if (typeof message.timestamp === \"number\") {\n existingMessageKeys.add(`${message.timestamp}:${contentText}`);\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: ConversationLogMessage = 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 // While queued messages are being processed, newer messages may already be present\n // in log.jsonl. Do not look ahead into those future messages when building the\n // current turn's context.\n if (!isMessageAtOrBeforeCurrent(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 // 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 const messageKey = `${msgTime}:${messageText}`;\n if (existingMessageKeys.has(messageKey)) continue;\n if (hasExistingSessionMessage(existingMessages, msgTime, messageText)) continue;\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 existingMessages.push({\n timestamp: msgTime,\n rawText: messageText,\n normalizedText: normalizeComparableUserText(messageText),\n });\n existingMessageKeys.add(messageKey); // 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\nexport async function findLogMessageById(\n conversationDir: string,\n messageId: string,\n): Promise<ConversationLogMessage | null> {\n const logFile = join(conversationDir, \"log.jsonl\");\n if (!existsSync(logFile)) return null;\n\n const logContent = await readFile(logFile, \"utf-8\");\n const logLines = logContent.trim().split(\"\\n\").filter(Boolean);\n\n for (let i = logLines.length - 1; i >= 0; i--) {\n try {\n const entry = JSON.parse(logLines[i]) as ConversationLogMessage;\n if (entry.ts === messageId) {\n return entry;\n }\n } catch {\n // Skip malformed lines\n }\n }\n\n return null;\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\n /^\\[[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2}\\]\\s+(?=\\[[^\\]]+\\](?:\\s+\\[in-thread:[^\\]]+\\])?:\\s)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction hasExistingSessionMessage(\n existingMessages: ExistingSessionMessage[],\n timestamp: number,\n text: string,\n): boolean {\n const normalizedText = normalizeComparableUserText(text);\n return existingMessages.some((existing) => {\n if (existing.timestamp === timestamp && existing.rawText === text) {\n return true;\n }\n if (existing.normalizedText !== normalizedText || existing.timestamp === undefined) {\n return false;\n }\n return existing.timestamp >= timestamp;\n });\n}\n\nfunction isMessageAtOrBeforeCurrent(messageId: string, currentMessageId?: string): boolean {\n if (!currentMessageId) return true;\n const comparison = compareMessageIds(messageId, currentMessageId);\n return comparison === null || comparison <= 0;\n}\n\nfunction compareMessageIds(a: string, b: string): number | null {\n if (/^\\d+$/.test(a) && /^\\d+$/.test(b)) {\n const left = BigInt(a);\n const right = BigInt(b);\n return left < right ? -1 : left > right ? 1 : 0;\n }\n\n const left = Number(a);\n const right = Number(b);\n if (Number.isFinite(left) && Number.isFinite(right)) {\n return left < right ? -1 : left > right ? 1 : 0;\n }\n\n return null;\n}\n"]}
package/dist/events.d.ts CHANGED
@@ -1,16 +1,22 @@
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;
6
- /** Creator userId — routes tool execution to the sandbox's vault selection for that user when fired. */
5
+ conversationId: string;
6
+ conversationKind: ConversationKind;
7
+ /** Creator userId — routes tool execution to that user's vault selection when fired. */
7
8
  userId?: string;
8
9
  text: string;
10
+ /** Determines which AgentRunner handles the event. */
11
+ sessionKey?: string;
12
+ /** Sub-conversation target (Slack thread ts, Discord thread id, Telegram reply-to id). */
13
+ threadTs?: string;
9
14
  }
10
15
  export interface OneShotEvent {
11
16
  type: "one-shot";
12
17
  platform: string;
13
- channelId: string;
18
+ conversationId: string;
19
+ conversationKind: ConversationKind;
14
20
  userId?: string;
15
21
  text: string;
16
22
  at: string;
@@ -18,17 +24,21 @@ export interface OneShotEvent {
18
24
  export interface PeriodicEvent {
19
25
  type: "periodic";
20
26
  platform: string;
21
- channelId: string;
27
+ conversationId: string;
28
+ conversationKind: ConversationKind;
22
29
  userId?: string;
23
30
  text: string;
24
31
  schedule: string;
25
32
  timezone: string;
33
+ /** Determines which AgentRunner handles the event. */
34
+ sessionKey?: string;
26
35
  }
27
36
  export type MamaEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
28
37
  export interface PeriodicEventInfo {
29
38
  filename: string;
30
39
  platform: string;
31
- channelId: string;
40
+ conversationId: string;
41
+ conversationKind: ConversationKind;
32
42
  text: string;
33
43
  schedule: string;
34
44
  timezone: string;
@@ -38,6 +48,7 @@ export declare class EventsWatcher {
38
48
  private eventsDir;
39
49
  private botsByPlatform;
40
50
  private timers;
51
+ private timerEventTypes;
41
52
  private crons;
42
53
  private debounceTimers;
43
54
  private startTime;
@@ -64,6 +75,7 @@ export declare class EventsWatcher {
64
75
  private handleFile;
65
76
  private parseEvent;
66
77
  private resolvePlatform;
78
+ private resolveConversationKind;
67
79
  private handleImmediate;
68
80
  private handleOneShot;
69
81
  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,wGAAwG;IACxG,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,SAAS,EAAE,MAAM,CAAC;IAClB,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,SAAS,EAAE,MAAM,CAAC;IAClB,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,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;IAwDlB,OAAO,CAAC,eAAe;IAsBvB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,OAAO;IAwDf,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 /** Creator userId — routes tool execution to the sandbox's vault selection for that user when fired. */\n userId?: string;\n text: string;\n}\n\nexport interface OneShotEvent {\n type: \"one-shot\";\n platform: string;\n channelId: string;\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 channelId: string;\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 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 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 channelId: data.channelId,\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 channelId: data.channelId,\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 channelId: data.channelId,\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 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 channel 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 // `user` falls back to \"EVENT\" when the event file omits a creator; vault\n // routing then resolves to an empty auto-created entry or shared container vault\n // with no credentials configured yet.\n const syntheticEvent: BotEvent = {\n type: \"mention\",\n conversationId: event.channelId,\n user: event.userId ?? \"EVENT\",\n text: message,\n ts: `event:${filename}`,\n sessionKey: event.channelId,\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":"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","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\";\nimport { inferConversationKind } from \"./session-policy.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 /** Determines which AgentRunner handles the event. */\n sessionKey?: string;\n /** Sub-conversation target (Slack thread ts, Discord thread id, Telegram reply-to id). */\n threadTs?: 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 // No sessionKey or threadTs: reminders fire as top-level messages regardless of where they were created.\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 /** Determines which AgentRunner handles the event. */\n sessionKey?: string;\n // No threadTs: recurring events always fire as top-level messages.\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 timerEventTypes: Map<string, \"one-shot\"> = 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 log.logInfo(\n `Events watcher fs event: ${String(eventType)} ${filename} (exists=${existsSync(join(this.eventsDir, filename))})`,\n );\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 this.timerEventTypes.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 const exists = existsSync(filePath);\n const known = this.knownFiles.has(filename);\n log.logInfo(`Handling event file change: ${filename} (exists=${exists}, known=${known})`);\n\n if (!exists) {\n // fs.watch can briefly report a file as missing during create/rename churn.\n // Confirm deletion before canceling scheduled events.\n void this.handleDelete(filename);\n } else if (known) {\n // File was modified - cancel existing and re-schedule\n this.cancelScheduled(filename, \"file-modified\");\n void this.handleFile(filename);\n } else {\n // New file\n void this.handleFile(filename);\n }\n }\n\n private async handleDelete(filename: string): Promise<void> {\n if (!this.knownFiles.has(filename)) return;\n\n const filePath = join(this.eventsDir, filename);\n for (let i = 0; i < MAX_RETRIES; i++) {\n const delay = RETRY_BASE_MS * 2 ** i;\n await this.sleep(delay);\n const exists = existsSync(filePath);\n log.logInfo(`Confirming event deletion: ${filename} after ${delay}ms (exists=${exists})`);\n if (exists) {\n return;\n }\n }\n\n if (this.timerEventTypes.get(filename) === \"one-shot\" && this.timers.has(filename)) {\n log.logInfo(\n `Ignoring deleted one-shot file after scheduling: ${filename} (timer remains active)`,\n );\n return;\n }\n\n log.logInfo(`Event file deleted: ${filename}`);\n this.cancelScheduled(filename, \"confirmed-delete\");\n this.knownFiles.delete(filename);\n }\n\n private cancelScheduled(filename: string, reason = \"unspecified\"): void {\n const timer = this.timers.get(filename);\n const cron = this.crons.get(filename);\n log.logInfo(\n `Canceling scheduled event: ${filename} (reason=${reason}, timer=${Boolean(timer)}, cron=${Boolean(cron)})`,\n );\n if (timer) {\n clearTimeout(timer);\n this.timers.delete(filename);\n this.timerEventTypes.delete(filename);\n }\n\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 log.logInfo(`Loading event file: ${filename} from ${filePath}`);\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, \"parse-failed\");\n return;\n }\n\n this.knownFiles.add(filename);\n log.logInfo(\n `Parsed event file: ${filename} (${event.type} for ${event.platform}/${event.conversationId})`,\n );\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 const sessionKey = typeof data.sessionKey === \"string\" ? data.sessionKey : undefined;\n const threadTs = typeof data.threadTs === \"string\" ? data.threadTs : 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 sessionKey,\n threadTs,\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 sessionKey,\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 return inferConversationKind(platform, conversationId);\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, \"stale-immediate\");\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, \"one-shot-in-past\");\n return;\n }\n\n const delay = atTime - now;\n log.logInfo(\n `Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s (at=${event.at}, now=${new Date(now).toISOString()})`,\n );\n\n const timer = setTimeout(() => {\n this.timers.delete(filename);\n this.timerEventTypes.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 this.timerEventTypes.set(filename, \"one-shot\");\n log.logInfo(`Stored one-shot timer: ${filename} (active timers=${this.timers.size})`);\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, \"invalid-cron\");\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, \"missing-bot\");\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 scopedEvent = event as { sessionKey?: string; threadTs?: string };\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 thread_ts: scopedEvent.threadTs,\n sessionKey: scopedEvent.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, \"executed-and-enqueued\");\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, \"queue-full-discarded\");\n }\n }\n }\n\n private deleteFile(filename: string, reason = \"unspecified\"): void {\n const filePath = join(this.eventsDir, filename);\n log.logInfo(`Deleting event file: ${filename} (reason=${reason})`);\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
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync,
3
3
  import { readFile } from "fs/promises";
4
4
  import { join } from "path";
5
5
  import * as log from "./log.js";
6
+ import { inferConversationKind } from "./session-policy.js";
6
7
  // ============================================================================
7
8
  // EventsWatcher
8
9
  // ============================================================================
@@ -14,6 +15,7 @@ export class EventsWatcher {
14
15
  this.eventsDir = eventsDir;
15
16
  this.botsByPlatform = botsByPlatform;
16
17
  this.timers = new Map();
18
+ this.timerEventTypes = new Map();
17
19
  this.crons = new Map();
18
20
  this.debounceTimers = new Map();
19
21
  this.watcher = null;
@@ -32,9 +34,10 @@ export class EventsWatcher {
32
34
  // Scan existing files
33
35
  this.scanExisting();
34
36
  // Watch for changes
35
- this.watcher = watch(this.eventsDir, (_eventType, filename) => {
37
+ this.watcher = watch(this.eventsDir, (eventType, filename) => {
36
38
  if (!filename || !filename.endsWith(".json"))
37
39
  return;
40
+ log.logInfo(`Events watcher fs event: ${String(eventType)} ${filename} (exists=${existsSync(join(this.eventsDir, filename))})`);
38
41
  this.debounce(filename, () => this.handleFileChange(filename));
39
42
  });
40
43
  log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);
@@ -58,6 +61,7 @@ export class EventsWatcher {
58
61
  clearTimeout(timer);
59
62
  }
60
63
  this.timers.clear();
64
+ this.timerEventTypes.clear();
61
65
  // Cancel all cron jobs
62
66
  for (const cron of this.crons.values()) {
63
67
  cron.stop();
@@ -83,7 +87,8 @@ export class EventsWatcher {
83
87
  results.push({
84
88
  filename,
85
89
  platform: data.platform,
86
- channelId: data.channelId,
90
+ conversationId: data.conversationId,
91
+ conversationKind: data.conversationKind,
87
92
  text: data.text,
88
93
  schedule: data.schedule,
89
94
  timezone: data.timezone,
@@ -121,34 +126,54 @@ export class EventsWatcher {
121
126
  }
122
127
  handleFileChange(filename) {
123
128
  const filePath = join(this.eventsDir, filename);
124
- if (!existsSync(filePath)) {
125
- // File was deleted
126
- this.handleDelete(filename);
127
- }
128
- else if (this.knownFiles.has(filename)) {
129
+ const exists = existsSync(filePath);
130
+ const known = this.knownFiles.has(filename);
131
+ log.logInfo(`Handling event file change: ${filename} (exists=${exists}, known=${known})`);
132
+ if (!exists) {
133
+ // fs.watch can briefly report a file as missing during create/rename churn.
134
+ // Confirm deletion before canceling scheduled events.
135
+ void this.handleDelete(filename);
136
+ }
137
+ else if (known) {
129
138
  // File was modified - cancel existing and re-schedule
130
- this.cancelScheduled(filename);
131
- this.handleFile(filename);
139
+ this.cancelScheduled(filename, "file-modified");
140
+ void this.handleFile(filename);
132
141
  }
133
142
  else {
134
143
  // New file
135
- this.handleFile(filename);
144
+ void this.handleFile(filename);
136
145
  }
137
146
  }
138
- handleDelete(filename) {
147
+ async handleDelete(filename) {
139
148
  if (!this.knownFiles.has(filename))
140
149
  return;
150
+ const filePath = join(this.eventsDir, filename);
151
+ for (let i = 0; i < MAX_RETRIES; i++) {
152
+ const delay = RETRY_BASE_MS * 2 ** i;
153
+ await this.sleep(delay);
154
+ const exists = existsSync(filePath);
155
+ log.logInfo(`Confirming event deletion: ${filename} after ${delay}ms (exists=${exists})`);
156
+ if (exists) {
157
+ return;
158
+ }
159
+ }
160
+ if (this.timerEventTypes.get(filename) === "one-shot" && this.timers.has(filename)) {
161
+ log.logInfo(`Ignoring deleted one-shot file after scheduling: ${filename} (timer remains active)`);
162
+ return;
163
+ }
141
164
  log.logInfo(`Event file deleted: ${filename}`);
142
- this.cancelScheduled(filename);
165
+ this.cancelScheduled(filename, "confirmed-delete");
143
166
  this.knownFiles.delete(filename);
144
167
  }
145
- cancelScheduled(filename) {
168
+ cancelScheduled(filename, reason = "unspecified") {
146
169
  const timer = this.timers.get(filename);
170
+ const cron = this.crons.get(filename);
171
+ log.logInfo(`Canceling scheduled event: ${filename} (reason=${reason}, timer=${Boolean(timer)}, cron=${Boolean(cron)})`);
147
172
  if (timer) {
148
173
  clearTimeout(timer);
149
174
  this.timers.delete(filename);
175
+ this.timerEventTypes.delete(filename);
150
176
  }
151
- const cron = this.crons.get(filename);
152
177
  if (cron) {
153
178
  cron.stop();
154
179
  this.crons.delete(filename);
@@ -156,6 +181,7 @@ export class EventsWatcher {
156
181
  }
157
182
  async handleFile(filename) {
158
183
  const filePath = join(this.eventsDir, filename);
184
+ log.logInfo(`Loading event file: ${filename} from ${filePath}`);
159
185
  // Parse with retries
160
186
  let event = null;
161
187
  let lastError = null;
@@ -174,10 +200,11 @@ export class EventsWatcher {
174
200
  }
175
201
  if (!event) {
176
202
  log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
177
- this.deleteFile(filename);
203
+ this.deleteFile(filename, "parse-failed");
178
204
  return;
179
205
  }
180
206
  this.knownFiles.add(filename);
207
+ log.logInfo(`Parsed event file: ${filename} (${event.type} for ${event.platform}/${event.conversationId})`);
181
208
  // Schedule based on type
182
209
  switch (event.type) {
183
210
  case "immediate":
@@ -193,19 +220,30 @@ export class EventsWatcher {
193
220
  }
194
221
  parseEvent(content, filename) {
195
222
  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}`);
223
+ const conversationId = typeof data.conversationId === "string"
224
+ ? data.conversationId
225
+ : typeof data.channelId === "string"
226
+ ? data.channelId
227
+ : undefined;
228
+ if (!data.type || !conversationId || !data.text) {
229
+ throw new Error(`Missing required fields (type, conversationId, text) in ${filename}`);
198
230
  }
199
231
  const platform = this.resolvePlatform(data.platform, filename);
232
+ const conversationKind = this.resolveConversationKind(platform, conversationId, data.conversationKind);
200
233
  const userId = typeof data.userId === "string" ? data.userId : undefined;
234
+ const sessionKey = typeof data.sessionKey === "string" ? data.sessionKey : undefined;
235
+ const threadTs = typeof data.threadTs === "string" ? data.threadTs : undefined;
201
236
  switch (data.type) {
202
237
  case "immediate":
203
238
  return {
204
239
  type: "immediate",
205
240
  platform,
206
- channelId: data.channelId,
241
+ conversationId,
242
+ conversationKind,
207
243
  userId,
208
244
  text: data.text,
245
+ sessionKey,
246
+ threadTs,
209
247
  };
210
248
  case "one-shot":
211
249
  if (!data.at) {
@@ -214,7 +252,8 @@ export class EventsWatcher {
214
252
  return {
215
253
  type: "one-shot",
216
254
  platform,
217
- channelId: data.channelId,
255
+ conversationId,
256
+ conversationKind,
218
257
  userId,
219
258
  text: data.text,
220
259
  at: data.at,
@@ -229,11 +268,13 @@ export class EventsWatcher {
229
268
  return {
230
269
  type: "periodic",
231
270
  platform,
232
- channelId: data.channelId,
271
+ conversationId,
272
+ conversationKind,
233
273
  userId,
234
274
  text: data.text,
235
275
  schedule: data.schedule,
236
276
  timezone: data.timezone,
277
+ sessionKey,
237
278
  };
238
279
  default:
239
280
  throw new Error(`Unknown event type '${data.type}' in ${filename}`);
@@ -253,6 +294,12 @@ export class EventsWatcher {
253
294
  }
254
295
  throw new Error(`Missing required field 'platform' in ${filename}. Available platforms: ${availablePlatforms.join(", ")}`);
255
296
  }
297
+ resolveConversationKind(platform, conversationId, conversationKindValue) {
298
+ if (conversationKindValue === "direct" || conversationKindValue === "shared") {
299
+ return conversationKindValue;
300
+ }
301
+ return inferConversationKind(platform, conversationId);
302
+ }
256
303
  handleImmediate(filename, event) {
257
304
  const filePath = join(this.eventsDir, filename);
258
305
  // Check if stale (created before harness started)
@@ -260,7 +307,7 @@ export class EventsWatcher {
260
307
  const stat = statSync(filePath);
261
308
  if (stat.mtimeMs < this.startTime) {
262
309
  log.logInfo(`Stale immediate event, deleting: ${filename}`);
263
- this.deleteFile(filename);
310
+ this.deleteFile(filename, "stale-immediate");
264
311
  return;
265
312
  }
266
313
  }
@@ -277,17 +324,20 @@ export class EventsWatcher {
277
324
  if (atTime <= now) {
278
325
  // Past - delete without executing
279
326
  log.logInfo(`One-shot event in the past, deleting: ${filename}`);
280
- this.deleteFile(filename);
327
+ this.deleteFile(filename, "one-shot-in-past");
281
328
  return;
282
329
  }
283
330
  const delay = atTime - now;
284
- log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
331
+ log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s (at=${event.at}, now=${new Date(now).toISOString()})`);
285
332
  const timer = setTimeout(() => {
286
333
  this.timers.delete(filename);
334
+ this.timerEventTypes.delete(filename);
287
335
  log.logInfo(`Executing one-shot event: ${filename}`);
288
336
  this.execute(filename, event);
289
337
  }, delay);
290
338
  this.timers.set(filename, timer);
339
+ this.timerEventTypes.set(filename, "one-shot");
340
+ log.logInfo(`Stored one-shot timer: ${filename} (active timers=${this.timers.size})`);
291
341
  }
292
342
  handlePeriodic(filename, event) {
293
343
  try {
@@ -301,7 +351,7 @@ export class EventsWatcher {
301
351
  }
302
352
  catch (err) {
303
353
  log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
304
- this.deleteFile(filename);
354
+ this.deleteFile(filename, "invalid-cron");
305
355
  }
306
356
  }
307
357
  execute(filename, event, deleteAfter = true) {
@@ -323,40 +373,41 @@ export class EventsWatcher {
323
373
  if (!bot) {
324
374
  log.logWarning(`No bot configured for event platform '${event.platform}'`, filename);
325
375
  if (deleteAfter) {
326
- this.deleteFile(filename);
376
+ this.deleteFile(filename, "missing-bot");
327
377
  }
328
378
  return;
329
379
  }
330
- // Create synthetic BotEvent. Keep a stable channel session key so recurring
380
+ // Create synthetic BotEvent. Keep a stable conversation session key so recurring
331
381
  // reminders share context, but use a unique synthetic message id because
332
- // some adapters treat `ts`/message id as a reply target.
333
- // `user` falls back to "EVENT" when the event file omits a creator; vault
334
- // routing then resolves to an empty auto-created entry or shared container vault
335
- // with no credentials configured yet.
382
+ // some adapters treat ts/message id as a reply target.
383
+ const scopedEvent = event;
336
384
  const syntheticEvent = {
337
385
  type: "mention",
338
- conversationId: event.channelId,
386
+ conversationId: event.conversationId,
387
+ conversationKind: event.conversationKind,
339
388
  user: event.userId ?? "EVENT",
340
389
  text: message,
341
390
  ts: `event:${filename}`,
342
- sessionKey: event.channelId,
391
+ thread_ts: scopedEvent.threadTs,
392
+ sessionKey: scopedEvent.sessionKey ?? event.conversationId,
343
393
  };
344
394
  // Enqueue for processing
345
395
  const enqueued = bot.enqueueEvent(syntheticEvent);
346
396
  if (enqueued && deleteAfter) {
347
397
  // Delete file after successful enqueue (immediate and one-shot)
348
- this.deleteFile(filename);
398
+ this.deleteFile(filename, "executed-and-enqueued");
349
399
  }
350
400
  else if (!enqueued) {
351
401
  log.logWarning(`Event queue full, discarded: ${filename}`);
352
402
  // Still delete immediate/one-shot even if discarded
353
403
  if (deleteAfter) {
354
- this.deleteFile(filename);
404
+ this.deleteFile(filename, "queue-full-discarded");
355
405
  }
356
406
  }
357
407
  }
358
- deleteFile(filename) {
408
+ deleteFile(filename, reason = "unspecified") {
359
409
  const filePath = join(this.eventsDir, filename);
410
+ log.logInfo(`Deleting event file: ${filename} (reason=${reason})`);
360
411
  try {
361
412
  unlinkSync(filePath);
362
413
  }