@geminixiang/mama 0.2.0-beta.6 → 0.2.0-beta.8
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.
- package/README.md +20 -14
- package/dist/adapter.d.ts +5 -2
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +1 -0
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +29 -9
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/discord/context.d.ts +1 -1
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +1 -2
- package/dist/adapters/discord/context.js.map +1 -1
- package/dist/adapters/slack/bot.d.ts +8 -0
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +177 -11
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/slack/branch-manager.d.ts +1 -0
- package/dist/adapters/slack/branch-manager.d.ts.map +1 -1
- package/dist/adapters/slack/branch-manager.js +9 -8
- package/dist/adapters/slack/branch-manager.js.map +1 -1
- package/dist/adapters/slack/context.d.ts +1 -1
- package/dist/adapters/slack/context.d.ts.map +1 -1
- package/dist/adapters/slack/context.js +10 -8
- package/dist/adapters/slack/context.js.map +1 -1
- package/dist/adapters/slack/tools/attach.d.ts +1 -1
- package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
- package/dist/adapters/slack/tools/attach.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +33 -2
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/agent.d.ts +1 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +507 -422
- package/dist/agent.js.map +1 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +41 -2
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/model.d.ts +1 -1
- package/dist/commands/model.d.ts.map +1 -1
- package/dist/commands/model.js +25 -7
- package/dist/commands/model.js.map +1 -1
- package/dist/commands/new.d.ts.map +1 -1
- package/dist/commands/new.js +1 -1
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/sandbox.d.ts +10 -0
- package/dist/commands/sandbox.d.ts.map +1 -0
- package/dist/commands/sandbox.js +88 -0
- package/dist/commands/sandbox.js.map +1 -0
- package/dist/commands/session-view.d.ts.map +1 -1
- package/dist/commands/session-view.js +34 -10
- package/dist/commands/session-view.js.map +1 -1
- package/dist/commands/types.d.ts +1 -3
- package/dist/commands/types.d.ts.map +1 -1
- package/dist/commands/types.js.map +1 -1
- package/dist/commands/utils.d.ts +3 -0
- package/dist/commands/utils.d.ts.map +1 -1
- package/dist/commands/utils.js +5 -0
- package/dist/commands/utils.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +64 -23
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -44
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +7 -210
- package/dist/context.js.map +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +15 -14
- package/dist/events.js.map +1 -1
- package/dist/execution-resolver.d.ts +3 -2
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +40 -7
- package/dist/execution-resolver.js.map +1 -1
- package/dist/file-guards.d.ts +6 -0
- package/dist/file-guards.d.ts.map +1 -0
- package/dist/file-guards.js +48 -0
- package/dist/file-guards.js.map +1 -0
- package/dist/log.d.ts +1 -5
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +13 -38
- package/dist/log.js.map +1 -1
- package/dist/login/index.d.ts +14 -2
- package/dist/login/index.d.ts.map +1 -1
- package/dist/login/index.js +40 -13
- package/dist/login/index.js.map +1 -1
- package/dist/login/portal.d.ts +2 -1
- package/dist/login/portal.d.ts.map +1 -1
- package/dist/login/portal.js +12 -12
- package/dist/login/portal.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +33 -28
- package/dist/main.js.map +1 -1
- package/dist/provisioner.d.ts +12 -2
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +43 -14
- package/dist/provisioner.js.map +1 -1
- package/dist/runtime/conversation-orchestrator.d.ts +42 -0
- package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
- package/dist/runtime/conversation-orchestrator.js +150 -0
- package/dist/runtime/conversation-orchestrator.js.map +1 -0
- package/dist/runtime/session-runtime.d.ts +1 -1
- package/dist/runtime/session-runtime.d.ts.map +1 -1
- package/dist/runtime/session-runtime.js +49 -148
- package/dist/runtime/session-runtime.js.map +1 -1
- package/dist/sandbox/cloudflare.d.ts.map +1 -1
- package/dist/sandbox/cloudflare.js +2 -2
- package/dist/sandbox/cloudflare.js.map +1 -1
- package/dist/sandbox/container.d.ts.map +1 -1
- package/dist/sandbox/container.js +1 -1
- package/dist/sandbox/container.js.map +1 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +4 -4
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sentry.d.ts +1 -1
- package/dist/sentry.d.ts.map +1 -1
- package/dist/sentry.js +2 -2
- package/dist/sentry.js.map +1 -1
- package/dist/session-store.d.ts +2 -1
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +19 -15
- package/dist/session-store.js.map +1 -1
- package/dist/session-view/portal.d.ts +6 -1
- package/dist/session-view/portal.d.ts.map +1 -1
- package/dist/session-view/portal.js +829 -71
- package/dist/session-view/portal.js.map +1 -1
- package/dist/session-view/service.d.ts.map +1 -1
- package/dist/session-view/service.js +5 -4
- package/dist/session-view/service.js.map +1 -1
- package/dist/session-view/store.d.ts +2 -1
- package/dist/session-view/store.d.ts.map +1 -1
- package/dist/session-view/store.js +2 -1
- package/dist/session-view/store.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +7 -13
- package/dist/store.js.map +1 -1
- package/dist/tool-diagnostics.d.ts +2 -0
- package/dist/tool-diagnostics.d.ts.map +1 -0
- package/dist/tool-diagnostics.js +7 -0
- package/dist/tool-diagnostics.js.map +1 -0
- package/dist/tools/bash.d.ts +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +1 -1
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/event.d.ts +1 -1
- package/dist/tools/event.d.ts.map +1 -1
- package/dist/tools/event.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/read.d.ts +1 -1
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js.map +1 -1
- package/dist/vault-routing.d.ts +0 -3
- package/dist/vault-routing.d.ts.map +1 -1
- package/dist/vault-routing.js +0 -24
- package/dist/vault-routing.js.map +1 -1
- package/dist/vault.d.ts +21 -57
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +114 -246
- package/dist/vault.js.map +1 -1
- package/package.json +6 -4
- package/dist/bindings.d.ts +0 -45
- package/dist/bindings.d.ts.map +0 -1
- package/dist/bindings.js +0 -75
- package/dist/bindings.js.map +0 -1
package/dist/context.js
CHANGED
|
@@ -1,226 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Context management for mama.
|
|
3
|
-
*
|
|
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
|
-
*
|
|
8
|
-
* This module provides:
|
|
9
|
-
* - syncLogToSessionManager: Syncs messages from log.jsonl to SessionManager
|
|
10
|
-
* - createMamaSettingsManager: Creates an in-memory SettingsManager for AgentSession
|
|
11
|
-
*/
|
|
12
|
-
import { SettingsManager, } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import { existsSync } from "fs";
|
|
14
|
-
import { readFile } from "fs/promises";
|
|
15
1
|
import { join } from "path";
|
|
2
|
+
import { isRecord, parseJsonValue, readTextFileIfExists } from "./file-guards.js";
|
|
16
3
|
import * as log from "./log.js";
|
|
17
|
-
/**
|
|
18
|
-
* Default number of days to sync when no time range is specified
|
|
19
|
-
*/
|
|
20
|
-
const DEFAULT_SYNC_DAYS = 10;
|
|
21
|
-
/**
|
|
22
|
-
* Sync user messages from log.jsonl to SessionManager.
|
|
23
|
-
*
|
|
24
|
-
* This ensures that messages logged while mama wasn't running (conversation chatter,
|
|
25
|
-
* backfilled messages, messages while busy) are added to the LLM context.
|
|
26
|
-
*
|
|
27
|
-
* @param sessionManager - The SessionManager to sync to
|
|
28
|
-
* @param conversationDir - Path to the conversation directory containing log.jsonl
|
|
29
|
-
* @param excludeSlackTs - Current platform message ID/timestamp (will be added via prompt(), not sync)
|
|
30
|
-
* @param timeRange - Optional time range to filter log entries (defaults to last 10 days)
|
|
31
|
-
* @param threadFilter - Optional thread filter to scope sync to a specific thread
|
|
32
|
-
* @returns Number of messages synced
|
|
33
|
-
*/
|
|
34
|
-
export async function syncLogToSessionManager(sessionManager, conversationDir, excludeSlackTs, timeRange, threadFilter) {
|
|
35
|
-
// Calculate default time range (last 10 days) if not provided
|
|
36
|
-
const now = Date.now();
|
|
37
|
-
const defaultStart = now - DEFAULT_SYNC_DAYS * 24 * 60 * 60 * 1000;
|
|
38
|
-
const range = timeRange ?? { start: defaultStart, end: now };
|
|
39
|
-
const logFile = join(conversationDir, "log.jsonl");
|
|
40
|
-
if (!existsSync(logFile))
|
|
41
|
-
return 0;
|
|
42
|
-
// Build a list of existing session messages for dedupe.
|
|
43
|
-
// Live user prompts carry a formatted timestamp in the text and use Date.now(),
|
|
44
|
-
// while log.jsonl uses the platform event timestamp. We therefore need a small
|
|
45
|
-
// fuzzy match window in addition to the exact timestamp/content match used for
|
|
46
|
-
// already-synced log entries.
|
|
47
|
-
const existingMessages = [];
|
|
48
|
-
const existingMessageKeys = new Set();
|
|
49
|
-
for (const entry of sessionManager.getEntries()) {
|
|
50
|
-
if (entry.type !== "message")
|
|
51
|
-
continue;
|
|
52
|
-
const msgEntry = entry;
|
|
53
|
-
const message = msgEntry.message;
|
|
54
|
-
const contentText = Array.isArray(message.content)
|
|
55
|
-
? message.content
|
|
56
|
-
.filter((part) => part.type === "text")
|
|
57
|
-
.map((part) => part.text)
|
|
58
|
-
.join("\n\n")
|
|
59
|
-
: typeof message.content === "string"
|
|
60
|
-
? message.content
|
|
61
|
-
: "";
|
|
62
|
-
existingMessages.push({
|
|
63
|
-
timestamp: typeof message.timestamp === "number" ? message.timestamp : undefined,
|
|
64
|
-
rawText: contentText,
|
|
65
|
-
normalizedText: normalizeComparableUserText(contentText),
|
|
66
|
-
});
|
|
67
|
-
if (typeof message.timestamp === "number") {
|
|
68
|
-
existingMessageKeys.add(`${message.timestamp}:${contentText}`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// Read log.jsonl and find user messages not in context
|
|
72
|
-
const logContent = await readFile(logFile, "utf-8");
|
|
73
|
-
const logLines = logContent.trim().split("\n").filter(Boolean);
|
|
74
|
-
const newMessages = [];
|
|
75
|
-
for (let lineIdx = 0; lineIdx < logLines.length; lineIdx++) {
|
|
76
|
-
const line = logLines[lineIdx];
|
|
77
|
-
let logMsg;
|
|
78
|
-
try {
|
|
79
|
-
logMsg = JSON.parse(line);
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
log.logWarning(`Skipping malformed log entry at ${logFile}:${lineIdx + 1}`, err instanceof Error ? err.message : String(err));
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const slackTs = logMsg.ts;
|
|
86
|
-
const date = logMsg.date;
|
|
87
|
-
if (!slackTs || !date)
|
|
88
|
-
continue;
|
|
89
|
-
// Skip the current message being processed (will be added via prompt())
|
|
90
|
-
if (excludeSlackTs && slackTs === excludeSlackTs)
|
|
91
|
-
continue;
|
|
92
|
-
// While queued messages are being processed, newer messages may already be present
|
|
93
|
-
// in log.jsonl. Do not look ahead into those future messages when building the
|
|
94
|
-
// current turn's context.
|
|
95
|
-
if (!isMessageAtOrBeforeCurrent(slackTs, excludeSlackTs))
|
|
96
|
-
continue;
|
|
97
|
-
// Skip bot messages - added through agent flow
|
|
98
|
-
if (logMsg.isBot)
|
|
99
|
-
continue;
|
|
100
|
-
// Thread filtering: only sync messages belonging to this session's thread
|
|
101
|
-
if (threadFilter) {
|
|
102
|
-
if (threadFilter.scope === "top-level") {
|
|
103
|
-
// Persistent top-level sessions should only ingest top-level messages.
|
|
104
|
-
// This avoids pulling in unrelated replies from other threads.
|
|
105
|
-
if (logMsg.threadTs) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
if (logMsg.threadTs) {
|
|
111
|
-
// Thread reply: only include if threadTs matches our thread anchor or rootTs
|
|
112
|
-
if (logMsg.threadTs !== threadFilter.threadTs &&
|
|
113
|
-
logMsg.threadTs !== threadFilter.rootTs) {
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
else {
|
|
118
|
-
// Top-level message: only include if it's this session's root message
|
|
119
|
-
if (slackTs !== threadFilter.rootTs) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
// Build the message text as it would appear in context
|
|
126
|
-
const threadContext = logMsg.threadTs ? ` [in-thread:${logMsg.threadTs}]` : "";
|
|
127
|
-
const messageText = `[${logMsg.userName || logMsg.user || "unknown"}]${threadContext}: ${logMsg.text || ""}`;
|
|
128
|
-
const msgTime = new Date(date).getTime() || Date.now();
|
|
129
|
-
const messageKey = `${msgTime}:${messageText}`;
|
|
130
|
-
if (existingMessageKeys.has(messageKey))
|
|
131
|
-
continue;
|
|
132
|
-
if (hasExistingSessionMessage(existingMessages, msgTime, messageText))
|
|
133
|
-
continue;
|
|
134
|
-
// Skip messages outside the time range
|
|
135
|
-
if (msgTime < range.start || msgTime > range.end)
|
|
136
|
-
continue;
|
|
137
|
-
const userMessage = {
|
|
138
|
-
role: "user",
|
|
139
|
-
content: [{ type: "text", text: messageText }],
|
|
140
|
-
timestamp: msgTime,
|
|
141
|
-
};
|
|
142
|
-
newMessages.push({ timestamp: msgTime, message: userMessage });
|
|
143
|
-
existingMessages.push({
|
|
144
|
-
timestamp: msgTime,
|
|
145
|
-
rawText: messageText,
|
|
146
|
-
normalizedText: normalizeComparableUserText(messageText),
|
|
147
|
-
});
|
|
148
|
-
existingMessageKeys.add(messageKey); // Track to avoid duplicates within this sync
|
|
149
|
-
}
|
|
150
|
-
if (newMessages.length === 0)
|
|
151
|
-
return 0;
|
|
152
|
-
// Sort by timestamp and add to session
|
|
153
|
-
newMessages.sort((a, b) => a.timestamp - b.timestamp);
|
|
154
|
-
for (const { message } of newMessages) {
|
|
155
|
-
sessionManager.appendMessage(message);
|
|
156
|
-
}
|
|
157
|
-
return newMessages.length;
|
|
158
|
-
}
|
|
159
|
-
// ============================================================================
|
|
160
|
-
// Settings manager for mama
|
|
161
|
-
// ============================================================================
|
|
162
|
-
// Mama manages model/provider config through its own config.ts / settings.json.
|
|
163
|
-
// We use an in-memory SettingsManager so AgentSession has valid defaults
|
|
164
|
-
// without interfering with coding-agent's global settings files.
|
|
165
|
-
export function createMamaSettingsManager(_workspaceDir) {
|
|
166
|
-
return SettingsManager.inMemory();
|
|
167
|
-
}
|
|
168
4
|
export async function findLogMessageById(conversationDir, messageId) {
|
|
169
5
|
const logFile = join(conversationDir, "log.jsonl");
|
|
170
|
-
|
|
6
|
+
const logContent = readTextFileIfExists(logFile);
|
|
7
|
+
if (logContent === undefined)
|
|
171
8
|
return null;
|
|
172
|
-
const logContent = await readFile(logFile, "utf-8");
|
|
173
9
|
const logLines = logContent.trim().split("\n").filter(Boolean);
|
|
174
10
|
for (let i = logLines.length - 1; i >= 0; i--) {
|
|
175
|
-
let entry;
|
|
176
11
|
try {
|
|
177
|
-
entry =
|
|
12
|
+
const entry = parseJsonValue(logLines[i], (value) => isRecord(value), (detail) => (detail === "unexpected JSON shape" ? "expected a JSON object" : detail));
|
|
13
|
+
if (entry.ts === messageId) {
|
|
14
|
+
return entry;
|
|
15
|
+
}
|
|
178
16
|
}
|
|
179
17
|
catch (err) {
|
|
180
18
|
log.logWarning(`Skipping malformed log entry at ${logFile}:${i + 1}`, err instanceof Error ? err.message : String(err));
|
|
181
19
|
continue;
|
|
182
20
|
}
|
|
183
|
-
if (entry.ts === messageId) {
|
|
184
|
-
return entry;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
function stripSlackAttachmentBlock(text) {
|
|
190
|
-
return text.replace(/\n*<slack_attachments>\n[\s\S]*?\n<\/slack_attachments>\s*$/g, "");
|
|
191
|
-
}
|
|
192
|
-
function normalizeComparableUserText(text) {
|
|
193
|
-
const withoutTimestamp = text.replace(/^\[[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)/, "");
|
|
194
|
-
return stripSlackAttachmentBlock(withoutTimestamp).trim();
|
|
195
|
-
}
|
|
196
|
-
function hasExistingSessionMessage(existingMessages, timestamp, text) {
|
|
197
|
-
const normalizedText = normalizeComparableUserText(text);
|
|
198
|
-
return existingMessages.some((existing) => {
|
|
199
|
-
if (existing.timestamp === timestamp && existing.rawText === text) {
|
|
200
|
-
return true;
|
|
201
|
-
}
|
|
202
|
-
if (existing.normalizedText !== normalizedText || existing.timestamp === undefined) {
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
return existing.timestamp >= timestamp;
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
function isMessageAtOrBeforeCurrent(messageId, currentMessageId) {
|
|
209
|
-
if (!currentMessageId)
|
|
210
|
-
return true;
|
|
211
|
-
const comparison = compareMessageIds(messageId, currentMessageId);
|
|
212
|
-
return comparison === null || comparison <= 0;
|
|
213
|
-
}
|
|
214
|
-
function compareMessageIds(a, b) {
|
|
215
|
-
if (/^\d+$/.test(a) && /^\d+$/.test(b)) {
|
|
216
|
-
const left = BigInt(a);
|
|
217
|
-
const right = BigInt(b);
|
|
218
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
219
|
-
}
|
|
220
|
-
const left = Number(a);
|
|
221
|
-
const right = Number(b);
|
|
222
|
-
if (Number.isFinite(left) && Number.isFinite(right)) {
|
|
223
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
224
21
|
}
|
|
225
22
|
return null;
|
|
226
23
|
}
|
package/dist/context.js.map
CHANGED
|
@@ -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,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAchC;;GAEG;AACH,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAgC7B;;;;;;;;;;;;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,gFAAgF;IAChF,+EAA+E;IAC/E,+EAA+E;IAC/E,8BAA8B;IAC9B,MAAM,gBAAgB,GAA6B,EAAE,CAAC;IACtD,MAAM,mBAAmB,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9C,KAAK,MAAM,KAAK,IAAI,cAAc,CAAC,UAAU,EAAE,EAAE,CAAC;QAChD,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,SAAS;QACvC,MAAM,QAAQ,GAAG,KAA4B,CAAC;QAC9C,MAAM,OAAO,GAAG,QAAQ,CAAC,OAAkB,CAAC;QAC5C,MAAM,WAAW,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC;YAChD,CAAC,CAAC,OAAO,CAAC,OAAO;iBACZ,MAAM,CAAC,CAAC,IAAI,EAA0C,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC;iBAC9E,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;iBACxB,IAAI,CAAC,MAAM,CAAC;YACjB,CAAC,CAAC,OAAO,OAAO,CAAC,OAAO,KAAK,QAAQ;gBACnC,CAAC,CAAC,OAAO,CAAC,OAAO;gBACjB,CAAC,CAAC,EAAE,CAAC;QACT,gBAAgB,CAAC,IAAI,CAAC;YACpB,SAAS,EAAE,OAAO,OAAO,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS;YAChF,OAAO,EAAE,WAAW;YACpB,cAAc,EAAE,2BAA2B,CAAC,WAAW,CAAC;SACzD,CAAC,CAAC;QACH,IAAI,OAAO,OAAO,CAAC,SAAS,KAAK,QAAQ,EAAE,CAAC;YAC1C,mBAAmB,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,SAAS,IAAI,WAAW,EAAE,CAAC,CAAC;QACjE,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,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAAC;QAC3D,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;QAC/B,IAAI,MAA8B,CAAC;QACnC,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAA2B,CAAC;QACtD,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,UAAU,CACZ,mCAAmC,OAAO,IAAI,OAAO,GAAG,CAAC,EAAE,EAC3D,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CACjD,CAAC;YACF,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;QACzB,IAAI,CAAC,OAAO,IAAI,CAAC,IAAI;YAAE,SAAS;QAEhC,wEAAwE;QACxE,IAAI,cAAc,IAAI,OAAO,KAAK,cAAc;YAAE,SAAS;QAE3D,mFAAmF;QACnF,+EAA+E;QAC/E,0BAA0B;QAC1B,IAAI,CAAC,0BAA0B,CAAC,OAAO,EAAE,cAAc,CAAC;YAAE,SAAS;QAEnE,+CAA+C;QAC/C,IAAI,MAAM,CAAC,KAAK;YAAE,SAAS;QAE3B,0EAA0E;QAC1E,IAAI,YAAY,EAAE,CAAC;YACjB,IAAI,YAAY,CAAC,KAAK,KAAK,WAAW,EAAE,CAAC;gBACvC,uEAAuE;gBACvE,+DAA+D;gBAC/D,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;oBACpB,SAAS;gBACX,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC;oBACpB,6EAA6E;oBAC7E,IACE,MAAM,CAAC,QAAQ,KAAK,YAAY,CAAC,QAAQ;wBACzC,MAAM,CAAC,QAAQ,KAAK,YAAY,CAAC,MAAM,EACvC,CAAC;wBACD,SAAS;oBACX,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,sEAAsE;oBACtE,IAAI,OAAO,KAAK,YAAY,CAAC,MAAM,EAAE,CAAC;wBACpC,SAAS;oBACX,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;QAED,uDAAuD;QACvD,MAAM,aAAa,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,eAAe,MAAM,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/E,MAAM,WAAW,GAAG,IAAI,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,IAAI,IAAI,SAAS,IAAI,aAAa,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;QAE7G,MAAM,OAAO,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;QACvD,MAAM,UAAU,GAAG,GAAG,OAAO,IAAI,WAAW,EAAE,CAAC;QAC/C,IAAI,mBAAmB,CAAC,GAAG,CAAC,UAAU,CAAC;YAAE,SAAS;QAClD,IAAI,yBAAyB,CAAC,gBAAgB,EAAE,OAAO,EAAE,WAAW,CAAC;YAAE,SAAS;QAEhF,uCAAuC;QACvC,IAAI,OAAO,GAAG,KAAK,CAAC,KAAK,IAAI,OAAO,GAAG,KAAK,CAAC,GAAG;YAAE,SAAS;QAE3D,MAAM,WAAW,GAAgB;YAC/B,IAAI,EAAE,MAAM;YACZ,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;YAC9C,SAAS,EAAE,OAAO;SACnB,CAAC;QAEF,WAAW,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;QAC/D,gBAAgB,CAAC,IAAI,CAAC;YACpB,SAAS,EAAE,OAAO;YAClB,OAAO,EAAE,WAAW;YACpB,cAAc,EAAE,2BAA2B,CAAC,WAAW,CAAC;SACzD,CAAC,CAAC;QACH,mBAAmB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,6CAA6C;IACpF,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;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,eAAuB,EACvB,SAAiB;IAEjB,MAAM,OAAO,GAAG,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC;IACnD,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAEtC,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,KAAK,IAAI,CAAC,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC9C,IAAI,KAA6B,CAAC;QAClC,IAAI,CAAC;YACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAA2B,CAAC;QAC5D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,UAAU,CACZ,mCAAmC,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,EACrD,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CACjD,CAAC;YACF,SAAS;QACX,CAAC;QACD,IAAI,KAAK,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;YAC3B,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,yBAAyB,CAAC,IAAY;IAC7C,OAAO,IAAI,CAAC,OAAO,CAAC,8DAA8D,EAAE,EAAE,CAAC,CAAC;AAC1F,CAAC;AAED,SAAS,2BAA2B,CAAC,IAAY;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,OAAO,CACnC,iIAAiI,EACjI,EAAE,CACH,CAAC;IACF,OAAO,yBAAyB,CAAC,gBAAgB,CAAC,CAAC,IAAI,EAAE,CAAC;AAC5D,CAAC;AAED,SAAS,yBAAyB,CAChC,gBAA0C,EAC1C,SAAiB,EACjB,IAAY;IAEZ,MAAM,cAAc,GAAG,2BAA2B,CAAC,IAAI,CAAC,CAAC;IACzD,OAAO,gBAAgB,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE;QACxC,IAAI,QAAQ,CAAC,SAAS,KAAK,SAAS,IAAI,QAAQ,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;YAClE,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,QAAQ,CAAC,cAAc,KAAK,cAAc,IAAI,QAAQ,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;YACnF,OAAO,KAAK,CAAC;QACf,CAAC;QACD,OAAO,QAAQ,CAAC,SAAS,IAAI,SAAS,CAAC;IACzC,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,0BAA0B,CAAC,SAAiB,EAAE,gBAAyB;IAC9E,IAAI,CAAC,gBAAgB;QAAE,OAAO,IAAI,CAAC;IACnC,MAAM,UAAU,GAAG,iBAAiB,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC;IAClE,OAAO,UAAU,KAAK,IAAI,IAAI,UAAU,IAAI,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,iBAAiB,CAAC,CAAS,EAAE,CAAS;IAC7C,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QACvC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACvB,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACxB,OAAO,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAClD,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACvB,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACxB,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QACpD,OAAO,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAClD,CAAC;IAED,OAAO,IAAI,CAAC;AACd,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 { 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\";\nimport * as log from \"./log.js\";\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 (let lineIdx = 0; lineIdx < logLines.length; lineIdx++) {\n const line = logLines[lineIdx];\n let logMsg: ConversationLogMessage;\n try {\n logMsg = JSON.parse(line) as ConversationLogMessage;\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logFile}:${lineIdx + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n continue;\n }\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 }\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 let entry: ConversationLogMessage;\n try {\n entry = JSON.parse(logLines[i]) as ConversationLogMessage;\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logFile}:${i + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n continue;\n }\n if (entry.ts === messageId) {\n return entry;\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"]}
|
|
1
|
+
{"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAClF,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAiBhC,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,eAAuB,EACvB,SAAiB;IAEjB,MAAM,OAAO,GAAG,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC;IACnD,MAAM,UAAU,GAAG,oBAAoB,CAAC,OAAO,CAAC,CAAC;IACjD,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAC1C,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAE/D,KAAK,IAAI,CAAC,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC9C,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,cAAc,CAC1B,QAAQ,CAAC,CAAC,CAAC,EACX,CAAC,KAAK,EAAmC,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,EAC3D,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,KAAK,uBAAuB,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,MAAM,CAAC,CACrF,CAAC;YACF,IAAI,KAAK,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;gBAC3B,OAAO,KAAK,CAAC;YACf,CAAC;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,UAAU,CACZ,mCAAmC,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,EACrD,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CACjD,CAAC;YACF,SAAS;QACX,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["import { join } from \"path\";\nimport { isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\n\n/**\n * Human-readable log.jsonl entry used for grep/debugging and a few metadata lookups.\n *\n * Runtime conversation context comes from sessions/*.jsonl, not from this file.\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\nexport async function findLogMessageById(\n conversationDir: string,\n messageId: string,\n): Promise<ConversationLogMessage | null> {\n const logFile = join(conversationDir, \"log.jsonl\");\n const logContent = readTextFileIfExists(logFile);\n if (logContent === undefined) return null;\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 = parseJsonValue(\n logLines[i],\n (value): value is ConversationLogMessage => isRecord(value),\n (detail) => (detail === \"unexpected JSON shape\" ? \"expected a JSON object\" : detail),\n );\n if (entry.ts === messageId) {\n return entry;\n }\n } catch (err) {\n log.logWarning(\n `Skipping malformed log entry at ${logFile}:${i + 1}`,\n err instanceof Error ? err.message : String(err),\n );\n continue;\n }\n }\n\n return null;\n}\n"]}
|
package/dist/events.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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"]}
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,GAAG,EAAY,gBAAgB,EAAE,MAAM,cAAc,CAAC;AASpE,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;IACb,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;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;CAEZ;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;IACjB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;CAErB;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;IAUtB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,cAAc;IAVxB,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,eAAe,CAAsC;IAC7D,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,CAmBZ;IAED;;OAEG;IACH,IAAI,IAAI,IAAI,CA4BX;IAED;;OAEG;IACH,iBAAiB,IAAI,iBAAiB,EAAE,CA0BvC;IAED,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,gBAAgB;YAoBV,YAAY;IA0B1B,OAAO,CAAC,eAAe;YAkBT,UAAU;IAiDxB,OAAO,CAAC,UAAU;IA8ElB,OAAO,CAAC,eAAe;IAsBvB,OAAO,CAAC,uBAAuB;IAY/B,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IA4BrB,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,OAAO;IAwDf,OAAO,CAAC,UAAU;IAclB,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 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 { ensureDirExists, isRecord, parseJsonValue } from \"./file-guards.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 ensureDirExists(this.eventsDir);\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 = parseJsonValue(content, isRecord, (detail) =>\n detail === \"unexpected JSON shape\" ? `Expected top-level JSON object in ${filename}` : detail,\n );\n const conversationId =\n typeof data.conversationId === \"string\"\n ? data.conversationId\n : typeof data.channelId === \"string\"\n ? data.channelId\n : undefined;\n const type = typeof data.type === \"string\" ? data.type : undefined;\n const text = typeof data.text === \"string\" ? data.text : undefined;\n\n if (!type || !conversationId || !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 (type) {\n case \"immediate\":\n return {\n type: \"immediate\",\n platform,\n conversationId,\n conversationKind,\n userId,\n text,\n sessionKey,\n threadTs,\n };\n\n case \"one-shot\":\n if (typeof data.at !== \"string\" || data.at.length === 0) {\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,\n at: data.at,\n };\n\n case \"periodic\":\n if (typeof data.schedule !== \"string\" || data.schedule.length === 0) {\n throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n }\n if (typeof data.timezone !== \"string\" || data.timezone.length === 0) {\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,\n schedule: data.schedule,\n timezone: data.timezone,\n sessionKey,\n };\n\n default:\n throw new Error(`Unknown event type '${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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Cron } from "croner";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, watch, } from "fs";
|
|
3
3
|
import { readFile } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
|
+
import { ensureDirExists, isRecord, parseJsonValue } from "./file-guards.js";
|
|
5
6
|
import * as log from "./log.js";
|
|
6
7
|
import { inferConversationKind } from "./session-policy.js";
|
|
7
8
|
// ============================================================================
|
|
@@ -27,9 +28,7 @@ export class EventsWatcher {
|
|
|
27
28
|
*/
|
|
28
29
|
start() {
|
|
29
30
|
// Ensure events directory exists
|
|
30
|
-
|
|
31
|
-
mkdirSync(this.eventsDir, { recursive: true });
|
|
32
|
-
}
|
|
31
|
+
ensureDirExists(this.eventsDir);
|
|
33
32
|
log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);
|
|
34
33
|
// Scan existing files
|
|
35
34
|
this.scanExisting();
|
|
@@ -219,13 +218,15 @@ export class EventsWatcher {
|
|
|
219
218
|
}
|
|
220
219
|
}
|
|
221
220
|
parseEvent(content, filename) {
|
|
222
|
-
const data =
|
|
221
|
+
const data = parseJsonValue(content, isRecord, (detail) => detail === "unexpected JSON shape" ? `Expected top-level JSON object in ${filename}` : detail);
|
|
223
222
|
const conversationId = typeof data.conversationId === "string"
|
|
224
223
|
? data.conversationId
|
|
225
224
|
: typeof data.channelId === "string"
|
|
226
225
|
? data.channelId
|
|
227
226
|
: undefined;
|
|
228
|
-
|
|
227
|
+
const type = typeof data.type === "string" ? data.type : undefined;
|
|
228
|
+
const text = typeof data.text === "string" ? data.text : undefined;
|
|
229
|
+
if (!type || !conversationId || !text) {
|
|
229
230
|
throw new Error(`Missing required fields (type, conversationId, text) in ${filename}`);
|
|
230
231
|
}
|
|
231
232
|
const platform = this.resolvePlatform(data.platform, filename);
|
|
@@ -233,7 +234,7 @@ export class EventsWatcher {
|
|
|
233
234
|
const userId = typeof data.userId === "string" ? data.userId : undefined;
|
|
234
235
|
const sessionKey = typeof data.sessionKey === "string" ? data.sessionKey : undefined;
|
|
235
236
|
const threadTs = typeof data.threadTs === "string" ? data.threadTs : undefined;
|
|
236
|
-
switch (
|
|
237
|
+
switch (type) {
|
|
237
238
|
case "immediate":
|
|
238
239
|
return {
|
|
239
240
|
type: "immediate",
|
|
@@ -241,12 +242,12 @@ export class EventsWatcher {
|
|
|
241
242
|
conversationId,
|
|
242
243
|
conversationKind,
|
|
243
244
|
userId,
|
|
244
|
-
text
|
|
245
|
+
text,
|
|
245
246
|
sessionKey,
|
|
246
247
|
threadTs,
|
|
247
248
|
};
|
|
248
249
|
case "one-shot":
|
|
249
|
-
if (
|
|
250
|
+
if (typeof data.at !== "string" || data.at.length === 0) {
|
|
250
251
|
throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
|
|
251
252
|
}
|
|
252
253
|
return {
|
|
@@ -255,14 +256,14 @@ export class EventsWatcher {
|
|
|
255
256
|
conversationId,
|
|
256
257
|
conversationKind,
|
|
257
258
|
userId,
|
|
258
|
-
text
|
|
259
|
+
text,
|
|
259
260
|
at: data.at,
|
|
260
261
|
};
|
|
261
262
|
case "periodic":
|
|
262
|
-
if (
|
|
263
|
+
if (typeof data.schedule !== "string" || data.schedule.length === 0) {
|
|
263
264
|
throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
|
|
264
265
|
}
|
|
265
|
-
if (
|
|
266
|
+
if (typeof data.timezone !== "string" || data.timezone.length === 0) {
|
|
266
267
|
throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);
|
|
267
268
|
}
|
|
268
269
|
return {
|
|
@@ -271,13 +272,13 @@ export class EventsWatcher {
|
|
|
271
272
|
conversationId,
|
|
272
273
|
conversationKind,
|
|
273
274
|
userId,
|
|
274
|
-
text
|
|
275
|
+
text,
|
|
275
276
|
schedule: data.schedule,
|
|
276
277
|
timezone: data.timezone,
|
|
277
278
|
sessionKey,
|
|
278
279
|
};
|
|
279
280
|
default:
|
|
280
|
-
throw new Error(`Unknown event type '${
|
|
281
|
+
throw new Error(`Unknown event type '${type}' in ${filename}`);
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
resolvePlatform(platformValue, filename) {
|