@oh-my-pi/pi-mom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +338 -0
- package/README.md +517 -0
- package/dev.sh +30 -0
- package/docker.sh +95 -0
- package/docs/artifacts-server.md +475 -0
- package/docs/events.md +307 -0
- package/docs/new.md +977 -0
- package/docs/sandbox.md +153 -0
- package/docs/slack-bot-minimal-guide.md +399 -0
- package/docs/v86.md +319 -0
- package/package.json +45 -0
- package/scripts/migrate-timestamps.ts +121 -0
- package/src/agent.ts +887 -0
- package/src/context.ts +666 -0
- package/src/download.ts +117 -0
- package/src/events.ts +385 -0
- package/src/log.ts +271 -0
- package/src/main.ts +334 -0
- package/src/sandbox.ts +238 -0
- package/src/slack.ts +635 -0
- package/src/store.ts +253 -0
- package/src/tools/attach.ts +47 -0
- package/src/tools/bash.ts +99 -0
- package/src/tools/edit.ts +165 -0
- package/src/tools/index.ts +19 -0
- package/src/tools/read.ts +165 -0
- package/src/tools/truncate.ts +236 -0
- package/src/tools/write.ts +45 -0
- package/tsconfig.build.json +9 -0
package/src/context.ts
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context management for mom.
|
|
3
|
+
*
|
|
4
|
+
* Mom uses two files per channel:
|
|
5
|
+
* - context.jsonl: Structured API messages for LLM context (same format as coding-agent sessions)
|
|
6
|
+
* - log.jsonl: Human-readable channel history for grep (no tool results)
|
|
7
|
+
*
|
|
8
|
+
* This module provides:
|
|
9
|
+
* - MomSessionManager: Adapts coding-agent's SessionManager for channel-based storage
|
|
10
|
+
* - MomSettingsManager: Simple settings for mom (compaction, retry, model preferences)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
16
|
+
import {
|
|
17
|
+
buildSessionContext,
|
|
18
|
+
type CompactionEntry,
|
|
19
|
+
type FileEntry,
|
|
20
|
+
logger,
|
|
21
|
+
type ModelChangeEntry,
|
|
22
|
+
type SessionContext,
|
|
23
|
+
type SessionEntry,
|
|
24
|
+
type SessionEntryBase,
|
|
25
|
+
type SessionMessageEntry,
|
|
26
|
+
type ThinkingLevelChangeEntry,
|
|
27
|
+
} from "@oh-my-pi/pi-coding-agent";
|
|
28
|
+
import { Mutex } from "async-mutex";
|
|
29
|
+
|
|
30
|
+
function uuidv4(): string {
|
|
31
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
32
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
33
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
34
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// MomSessionManager - Channel-based session management
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Session manager for mom, storing context per Slack channel.
|
|
44
|
+
*
|
|
45
|
+
* Unlike coding-agent which creates timestamped session files, mom uses
|
|
46
|
+
* a single context.jsonl per channel that persists across all @mentions.
|
|
47
|
+
*/
|
|
48
|
+
export class MomSessionManager {
|
|
49
|
+
private sessionId: string;
|
|
50
|
+
private contextFile: string;
|
|
51
|
+
private logFile: string;
|
|
52
|
+
private channelDir: string;
|
|
53
|
+
private flushed: boolean = false;
|
|
54
|
+
private inMemoryEntries: FileEntry[] = [];
|
|
55
|
+
private leafId: string | null = null;
|
|
56
|
+
private readonly mutex = new Mutex();
|
|
57
|
+
|
|
58
|
+
constructor(channelDir: string) {
|
|
59
|
+
this.channelDir = channelDir;
|
|
60
|
+
this.contextFile = join(channelDir, "context.jsonl");
|
|
61
|
+
this.logFile = join(channelDir, "log.jsonl");
|
|
62
|
+
|
|
63
|
+
// Ensure channel directory exists
|
|
64
|
+
if (!existsSync(channelDir)) {
|
|
65
|
+
mkdirSync(channelDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load existing session or create new
|
|
69
|
+
if (existsSync(this.contextFile)) {
|
|
70
|
+
this.inMemoryEntries = this.loadEntriesFromFile();
|
|
71
|
+
this.sessionId = this.extractSessionId() || uuidv4();
|
|
72
|
+
this._updateLeafId();
|
|
73
|
+
this.flushed = true;
|
|
74
|
+
} else {
|
|
75
|
+
this.sessionId = uuidv4();
|
|
76
|
+
this.inMemoryEntries = [
|
|
77
|
+
{
|
|
78
|
+
type: "session",
|
|
79
|
+
version: 2,
|
|
80
|
+
id: this.sessionId,
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
cwd: this.channelDir,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
// Note: syncFromLog() is called explicitly from agent.ts with excludeTimestamp
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private _updateLeafId(): void {
|
|
90
|
+
for (let i = this.inMemoryEntries.length - 1; i >= 0; i--) {
|
|
91
|
+
const entry = this.inMemoryEntries[i];
|
|
92
|
+
if (entry.type !== "session") {
|
|
93
|
+
this.leafId = entry.id;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.leafId = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private _createEntryBase(): Omit<SessionEntryBase, "type"> {
|
|
101
|
+
const id = uuidv4();
|
|
102
|
+
const base = {
|
|
103
|
+
id,
|
|
104
|
+
parentId: this.leafId,
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
};
|
|
107
|
+
this.leafId = id;
|
|
108
|
+
return base;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private _persistLocked(entry: SessionEntry): void {
|
|
112
|
+
const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
|
113
|
+
if (!hasAssistant) return;
|
|
114
|
+
|
|
115
|
+
if (!this.flushed) {
|
|
116
|
+
for (const e of this.inMemoryEntries) {
|
|
117
|
+
appendFileSync(this.contextFile, `${JSON.stringify(e)}\n`);
|
|
118
|
+
}
|
|
119
|
+
this.flushed = true;
|
|
120
|
+
} else {
|
|
121
|
+
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sync user messages from log.jsonl that aren't in context.jsonl.
|
|
127
|
+
*
|
|
128
|
+
* log.jsonl and context.jsonl must have the same user messages.
|
|
129
|
+
* This handles:
|
|
130
|
+
* - Backfilled messages (mom was offline)
|
|
131
|
+
* - Messages that arrived while mom was processing a previous turn
|
|
132
|
+
* - Channel chatter between @mentions
|
|
133
|
+
*
|
|
134
|
+
* Channel chatter is formatted as "[username]: message" to distinguish from direct @mentions.
|
|
135
|
+
*
|
|
136
|
+
* Called before each agent run.
|
|
137
|
+
*
|
|
138
|
+
* @param excludeSlackTs Slack timestamp of current message (will be added via prompt(), not sync)
|
|
139
|
+
*/
|
|
140
|
+
syncFromLog(excludeSlackTs?: string): Promise<void> {
|
|
141
|
+
return this.mutex.runExclusive(() => {
|
|
142
|
+
if (!existsSync(this.logFile)) return;
|
|
143
|
+
|
|
144
|
+
// Build set of Slack timestamps already in context
|
|
145
|
+
// We store slackTs in the message content or can extract from formatted messages
|
|
146
|
+
// For messages synced from log, we use the log's date as the entry timestamp
|
|
147
|
+
// For messages added via prompt(), they have different timestamps
|
|
148
|
+
// So we need to match by content OR by stored slackTs
|
|
149
|
+
const contextSlackTimestamps = new Set<string>();
|
|
150
|
+
const contextMessageTexts = new Set<string>();
|
|
151
|
+
|
|
152
|
+
for (const entry of this.inMemoryEntries) {
|
|
153
|
+
if (entry.type === "message") {
|
|
154
|
+
const msgEntry = entry as SessionMessageEntry;
|
|
155
|
+
// Store the entry timestamp (which is the log date for synced messages)
|
|
156
|
+
contextSlackTimestamps.add(entry.timestamp);
|
|
157
|
+
|
|
158
|
+
// Also store message text to catch duplicates added via prompt()
|
|
159
|
+
// AgentMessage has different shapes, check for content property
|
|
160
|
+
const msg = msgEntry.message as { role: string; content?: unknown };
|
|
161
|
+
if (msg.role === "user" && msg.content !== undefined) {
|
|
162
|
+
const content = msg.content;
|
|
163
|
+
if (typeof content === "string") {
|
|
164
|
+
contextMessageTexts.add(content);
|
|
165
|
+
} else if (Array.isArray(content)) {
|
|
166
|
+
for (const part of content) {
|
|
167
|
+
if (
|
|
168
|
+
typeof part === "object" &&
|
|
169
|
+
part !== null &&
|
|
170
|
+
"type" in part &&
|
|
171
|
+
part.type === "text" &&
|
|
172
|
+
"text" in part
|
|
173
|
+
) {
|
|
174
|
+
contextMessageTexts.add((part as { type: "text"; text: string }).text);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Read log.jsonl and find user messages not in context
|
|
183
|
+
const logContent = readFileSync(this.logFile, "utf-8");
|
|
184
|
+
const logLines = logContent.trim().split("\n").filter(Boolean);
|
|
185
|
+
|
|
186
|
+
interface LogMessage {
|
|
187
|
+
date?: string;
|
|
188
|
+
ts?: string;
|
|
189
|
+
user?: string;
|
|
190
|
+
userName?: string;
|
|
191
|
+
text?: string;
|
|
192
|
+
isBot?: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const newMessages: Array<{ timestamp: string; slackTs: string; message: AgentMessage }> = [];
|
|
196
|
+
|
|
197
|
+
for (const line of logLines) {
|
|
198
|
+
try {
|
|
199
|
+
const logMsg: LogMessage = JSON.parse(line);
|
|
200
|
+
|
|
201
|
+
const slackTs = logMsg.ts;
|
|
202
|
+
const date = logMsg.date;
|
|
203
|
+
if (!slackTs || !date) continue;
|
|
204
|
+
|
|
205
|
+
// Skip the current message being processed (will be added via prompt())
|
|
206
|
+
if (excludeSlackTs && slackTs === excludeSlackTs) continue;
|
|
207
|
+
|
|
208
|
+
// Skip bot messages - added through agent flow
|
|
209
|
+
if (logMsg.isBot) continue;
|
|
210
|
+
|
|
211
|
+
// Skip if this date is already in context (was synced before)
|
|
212
|
+
if (contextSlackTimestamps.has(date)) continue;
|
|
213
|
+
|
|
214
|
+
// Build the message text as it would appear in context
|
|
215
|
+
const messageText = `[${logMsg.userName || logMsg.user || "unknown"}]: ${logMsg.text || ""}`;
|
|
216
|
+
|
|
217
|
+
// Skip if this exact message text is already in context (added via prompt())
|
|
218
|
+
if (contextMessageTexts.has(messageText)) continue;
|
|
219
|
+
|
|
220
|
+
const msgTime = new Date(date).getTime() || Date.now();
|
|
221
|
+
const userMessage: AgentMessage = {
|
|
222
|
+
role: "user",
|
|
223
|
+
content: messageText,
|
|
224
|
+
timestamp: msgTime,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
newMessages.push({ timestamp: date, slackTs, message: userMessage });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logger.debug("Context parsing error", { error: String(err) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (newMessages.length === 0) return;
|
|
234
|
+
|
|
235
|
+
// Sort by timestamp and add to context
|
|
236
|
+
newMessages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
237
|
+
|
|
238
|
+
for (const { timestamp, message } of newMessages) {
|
|
239
|
+
const id = uuidv4();
|
|
240
|
+
const entry: SessionMessageEntry = {
|
|
241
|
+
type: "message",
|
|
242
|
+
id,
|
|
243
|
+
parentId: this.leafId,
|
|
244
|
+
timestamp, // Use log date as entry timestamp for consistent deduplication
|
|
245
|
+
message,
|
|
246
|
+
};
|
|
247
|
+
this.leafId = id;
|
|
248
|
+
|
|
249
|
+
this.inMemoryEntries.push(entry);
|
|
250
|
+
appendFileSync(this.contextFile, `${JSON.stringify(entry)}\n`);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private extractSessionId(): string | null {
|
|
256
|
+
for (const entry of this.inMemoryEntries) {
|
|
257
|
+
if (entry.type === "session") {
|
|
258
|
+
return entry.id;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private loadEntriesFromFile(): FileEntry[] {
|
|
265
|
+
if (!existsSync(this.contextFile)) return [];
|
|
266
|
+
|
|
267
|
+
const content = readFileSync(this.contextFile, "utf8");
|
|
268
|
+
const entries: FileEntry[] = [];
|
|
269
|
+
const lines = content.trim().split("\n");
|
|
270
|
+
|
|
271
|
+
for (const line of lines) {
|
|
272
|
+
if (!line.trim()) continue;
|
|
273
|
+
try {
|
|
274
|
+
const entry = JSON.parse(line) as FileEntry;
|
|
275
|
+
entries.push(entry);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
logger.debug("Context parsing error", { error: String(err) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return entries;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
saveMessage(message: AgentMessage): Promise<void> {
|
|
285
|
+
return this.mutex.runExclusive(() => {
|
|
286
|
+
const entry: SessionMessageEntry = { ...this._createEntryBase(), type: "message", message };
|
|
287
|
+
this.inMemoryEntries.push(entry);
|
|
288
|
+
this._persistLocked(entry);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
saveThinkingLevelChange(thinkingLevel: string): Promise<void> {
|
|
293
|
+
return this.mutex.runExclusive(() => {
|
|
294
|
+
const entry: ThinkingLevelChangeEntry = {
|
|
295
|
+
...this._createEntryBase(),
|
|
296
|
+
type: "thinking_level_change",
|
|
297
|
+
thinkingLevel,
|
|
298
|
+
};
|
|
299
|
+
this.inMemoryEntries.push(entry);
|
|
300
|
+
this._persistLocked(entry);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
saveModelChange(model: string, role?: string): Promise<void> {
|
|
305
|
+
return this.mutex.runExclusive(() => {
|
|
306
|
+
const entry: ModelChangeEntry = { ...this._createEntryBase(), type: "model_change", model, role };
|
|
307
|
+
this.inMemoryEntries.push(entry);
|
|
308
|
+
this._persistLocked(entry);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
saveCompaction(entry: CompactionEntry): Promise<void> {
|
|
313
|
+
return this.mutex.runExclusive(() => {
|
|
314
|
+
this.inMemoryEntries.push(entry);
|
|
315
|
+
this._persistLocked(entry);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Load session with compaction support */
|
|
320
|
+
async buildSessionContex(): Promise<SessionContext> {
|
|
321
|
+
const entries = await this.loadEntries();
|
|
322
|
+
return buildSessionContext(entries);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
loadEntries(): Promise<SessionEntry[]> {
|
|
326
|
+
return this.mutex.runExclusive(() => {
|
|
327
|
+
// Re-read from file to get latest state
|
|
328
|
+
const entries = existsSync(this.contextFile) ? this.loadEntriesFromFile() : this.inMemoryEntries;
|
|
329
|
+
return entries.filter((e): e is SessionEntry => e.type !== "session");
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getSessionId(): string {
|
|
334
|
+
return this.sessionId;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
getSessionFile(): string {
|
|
338
|
+
return this.contextFile;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Reset session (clears context.jsonl) */
|
|
342
|
+
reset(): Promise<void> {
|
|
343
|
+
return this.mutex.runExclusive(() => {
|
|
344
|
+
this.sessionId = uuidv4();
|
|
345
|
+
this.flushed = false;
|
|
346
|
+
this.inMemoryEntries = [
|
|
347
|
+
{
|
|
348
|
+
type: "session",
|
|
349
|
+
id: this.sessionId,
|
|
350
|
+
timestamp: new Date().toISOString(),
|
|
351
|
+
cwd: this.channelDir,
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
// Truncate the context file
|
|
355
|
+
if (existsSync(this.contextFile)) {
|
|
356
|
+
writeFileSync(this.contextFile, "");
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Compatibility methods for AgentSession
|
|
362
|
+
isPersisted(): boolean {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
setSessionFile(_path: string): void {
|
|
367
|
+
// No-op for mom - we always use the channel's context.jsonl
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async loadModel(): Promise<{ provider: string; modelId: string } | null> {
|
|
371
|
+
const session = await this.buildSessionContex();
|
|
372
|
+
const defaultModel = session.models.default;
|
|
373
|
+
if (!defaultModel) return null;
|
|
374
|
+
const slashIdx = defaultModel.indexOf("/");
|
|
375
|
+
if (slashIdx <= 0) return null;
|
|
376
|
+
return { provider: defaultModel.slice(0, slashIdx), modelId: defaultModel.slice(slashIdx + 1) };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async loadThinkingLevel(): Promise<string> {
|
|
380
|
+
const session = await this.buildSessionContex();
|
|
381
|
+
return session.thinkingLevel;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Not used by mom but required by AgentSession interface */
|
|
385
|
+
createBranchedSession(_leafId: string): string | null {
|
|
386
|
+
return null; // Mom doesn't support branching
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// MomSettingsManager - Simple settings for mom
|
|
392
|
+
// ============================================================================
|
|
393
|
+
|
|
394
|
+
export interface MomCompactionSettings {
|
|
395
|
+
enabled: boolean;
|
|
396
|
+
reserveTokens: number;
|
|
397
|
+
keepRecentTokens: number;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface MomRetrySettings {
|
|
401
|
+
enabled: boolean;
|
|
402
|
+
maxRetries: number;
|
|
403
|
+
baseDelayMs: number;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface MomSettings {
|
|
407
|
+
defaultProvider?: string;
|
|
408
|
+
defaultModel?: string;
|
|
409
|
+
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
|
410
|
+
compaction?: Partial<MomCompactionSettings>;
|
|
411
|
+
retry?: Partial<MomRetrySettings>;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const DEFAULT_COMPACTION: MomCompactionSettings = {
|
|
415
|
+
enabled: true,
|
|
416
|
+
reserveTokens: 16384,
|
|
417
|
+
keepRecentTokens: 20000,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const DEFAULT_RETRY: MomRetrySettings = {
|
|
421
|
+
enabled: true,
|
|
422
|
+
maxRetries: 3,
|
|
423
|
+
baseDelayMs: 2000,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Settings manager for mom.
|
|
428
|
+
* Stores settings in the workspace root directory.
|
|
429
|
+
*/
|
|
430
|
+
export class MomSettingsManager {
|
|
431
|
+
private settingsPath: string;
|
|
432
|
+
private settings: MomSettings;
|
|
433
|
+
|
|
434
|
+
constructor(workspaceDir: string) {
|
|
435
|
+
this.settingsPath = join(workspaceDir, "settings.json");
|
|
436
|
+
this.settings = this.load();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private load(): MomSettings {
|
|
440
|
+
if (!existsSync(this.settingsPath)) {
|
|
441
|
+
return {};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const content = readFileSync(this.settingsPath, "utf-8");
|
|
446
|
+
return JSON.parse(content);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
logger.debug("Context parsing error", { error: String(err) });
|
|
449
|
+
return {};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private save(): void {
|
|
454
|
+
try {
|
|
455
|
+
const dir = dirname(this.settingsPath);
|
|
456
|
+
if (!existsSync(dir)) {
|
|
457
|
+
mkdirSync(dir, { recursive: true });
|
|
458
|
+
}
|
|
459
|
+
writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8");
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.error(`Warning: Could not save settings file: ${error}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
getCompactionSettings(): MomCompactionSettings {
|
|
466
|
+
return {
|
|
467
|
+
...DEFAULT_COMPACTION,
|
|
468
|
+
...this.settings.compaction,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
getCompactionEnabled(): boolean {
|
|
473
|
+
return this.settings.compaction?.enabled ?? DEFAULT_COMPACTION.enabled;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
setCompactionEnabled(enabled: boolean): void {
|
|
477
|
+
this.settings.compaction = { ...this.settings.compaction, enabled };
|
|
478
|
+
this.save();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
getRetrySettings(): MomRetrySettings {
|
|
482
|
+
return {
|
|
483
|
+
...DEFAULT_RETRY,
|
|
484
|
+
...this.settings.retry,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
getRetryEnabled(): boolean {
|
|
489
|
+
return this.settings.retry?.enabled ?? DEFAULT_RETRY.enabled;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
setRetryEnabled(enabled: boolean): void {
|
|
493
|
+
this.settings.retry = { ...this.settings.retry, enabled };
|
|
494
|
+
this.save();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
getDefaultModel(): string | undefined {
|
|
498
|
+
return this.settings.defaultModel;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
getDefaultProvider(): string | undefined {
|
|
502
|
+
return this.settings.defaultProvider;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setDefaultModelAndProvider(provider: string, modelId: string): void {
|
|
506
|
+
this.settings.defaultProvider = provider;
|
|
507
|
+
this.settings.defaultModel = modelId;
|
|
508
|
+
this.save();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
getDefaultThinkingLevel(): string {
|
|
512
|
+
return this.settings.defaultThinkingLevel || "off";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
setDefaultThinkingLevel(level: string): void {
|
|
516
|
+
this.settings.defaultThinkingLevel = level as MomSettings["defaultThinkingLevel"];
|
|
517
|
+
this.save();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Compatibility methods for AgentSession
|
|
521
|
+
getQueueMode(): "all" | "one-at-a-time" {
|
|
522
|
+
return "one-at-a-time"; // Mom processes one message at a time
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
setQueueMode(_mode: "all" | "one-at-a-time"): void {
|
|
526
|
+
// No-op for mom
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
getHookPaths(): string[] {
|
|
530
|
+
return []; // Mom doesn't use hooks
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
getHookTimeout(): number {
|
|
534
|
+
return 30000;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// Sync log.jsonl to context.jsonl
|
|
540
|
+
// ============================================================================
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Sync user messages from log.jsonl to context.jsonl.
|
|
544
|
+
*
|
|
545
|
+
* This ensures that messages logged while mom wasn't running (channel chatter,
|
|
546
|
+
* backfilled messages, messages while busy) are added to the LLM context.
|
|
547
|
+
*
|
|
548
|
+
* @param channelDir - Path to channel directory
|
|
549
|
+
* @param excludeAfterTs - Don't sync messages with ts >= this value (they'll be handled by agent)
|
|
550
|
+
* @returns Number of messages synced
|
|
551
|
+
*/
|
|
552
|
+
export function syncLogToContext(channelDir: string, excludeAfterTs?: string): number {
|
|
553
|
+
const logFile = join(channelDir, "log.jsonl");
|
|
554
|
+
const contextFile = join(channelDir, "context.jsonl");
|
|
555
|
+
|
|
556
|
+
if (!existsSync(logFile)) return 0;
|
|
557
|
+
|
|
558
|
+
// Read all user messages from log.jsonl
|
|
559
|
+
const logContent = readFileSync(logFile, "utf-8");
|
|
560
|
+
const logLines = logContent.trim().split("\n").filter(Boolean);
|
|
561
|
+
|
|
562
|
+
interface LogEntry {
|
|
563
|
+
ts: string;
|
|
564
|
+
user: string;
|
|
565
|
+
userName?: string;
|
|
566
|
+
text: string;
|
|
567
|
+
isBot: boolean;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const logMessages: LogEntry[] = [];
|
|
571
|
+
for (const line of logLines) {
|
|
572
|
+
try {
|
|
573
|
+
const entry = JSON.parse(line) as LogEntry;
|
|
574
|
+
// Only sync user messages (not bot responses)
|
|
575
|
+
if (!entry.isBot && entry.ts && entry.text) {
|
|
576
|
+
// Skip if >= excludeAfterTs
|
|
577
|
+
if (excludeAfterTs && entry.ts >= excludeAfterTs) continue;
|
|
578
|
+
logMessages.push(entry);
|
|
579
|
+
}
|
|
580
|
+
} catch (err) {
|
|
581
|
+
logger.debug("Context parsing error", { error: String(err) });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (logMessages.length === 0) return 0;
|
|
586
|
+
|
|
587
|
+
// Read existing timestamps from context.jsonl
|
|
588
|
+
if (existsSync(contextFile)) {
|
|
589
|
+
const contextContent = readFileSync(contextFile, "utf-8");
|
|
590
|
+
const contextLines = contextContent.trim().split("\n").filter(Boolean);
|
|
591
|
+
for (const line of contextLines) {
|
|
592
|
+
try {
|
|
593
|
+
const entry = JSON.parse(line);
|
|
594
|
+
if (entry.type === "message" && entry.message?.role === "user" && entry.message?.timestamp) {
|
|
595
|
+
// Extract ts from timestamp (ms -> slack ts format for comparison)
|
|
596
|
+
// We store the original slack ts in a way we can recover
|
|
597
|
+
// Actually, let's just check by content match since ts formats differ
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
logger.debug("Context parsing error", { error: String(err) });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// For deduplication, we need to track what's already in context
|
|
606
|
+
// Read context and extract user message content (strip attachments section for comparison)
|
|
607
|
+
const existingMessages = new Set<string>();
|
|
608
|
+
if (existsSync(contextFile)) {
|
|
609
|
+
const contextContent = readFileSync(contextFile, "utf-8");
|
|
610
|
+
const contextLines = contextContent.trim().split("\n").filter(Boolean);
|
|
611
|
+
for (const line of contextLines) {
|
|
612
|
+
try {
|
|
613
|
+
const entry = JSON.parse(line);
|
|
614
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
615
|
+
let content =
|
|
616
|
+
typeof entry.message.content === "string" ? entry.message.content : entry.message.content?.[0]?.text;
|
|
617
|
+
if (content) {
|
|
618
|
+
// Strip timestamp prefix for comparison (live messages have it, log messages don't)
|
|
619
|
+
// Format: [YYYY-MM-DD HH:MM:SS+HH:MM] [username]: text
|
|
620
|
+
content = content.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\] /, "");
|
|
621
|
+
// Strip attachments section for comparison (live messages have it, log messages don't)
|
|
622
|
+
const attachmentsIdx = content.indexOf("\n\n<slack_attachments>\n");
|
|
623
|
+
if (attachmentsIdx !== -1) {
|
|
624
|
+
content = content.substring(0, attachmentsIdx);
|
|
625
|
+
}
|
|
626
|
+
existingMessages.add(content);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
logger.debug("Context parsing error", { error: String(err) });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Add missing messages to context.jsonl
|
|
636
|
+
let syncedCount = 0;
|
|
637
|
+
for (const msg of logMessages) {
|
|
638
|
+
const userName = msg.userName || msg.user;
|
|
639
|
+
const content = `[${userName}]: ${msg.text}`;
|
|
640
|
+
|
|
641
|
+
// Skip if already in context
|
|
642
|
+
if (existingMessages.has(content)) continue;
|
|
643
|
+
|
|
644
|
+
const timestamp = Math.floor(parseFloat(msg.ts) * 1000);
|
|
645
|
+
const entry = {
|
|
646
|
+
type: "message",
|
|
647
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
648
|
+
message: {
|
|
649
|
+
role: "user",
|
|
650
|
+
content,
|
|
651
|
+
timestamp,
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Ensure directory exists
|
|
656
|
+
if (!existsSync(channelDir)) {
|
|
657
|
+
mkdirSync(channelDir, { recursive: true });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
appendFileSync(contextFile, `${JSON.stringify(entry)}\n`);
|
|
661
|
+
existingMessages.add(content); // Track to avoid duplicates within this sync
|
|
662
|
+
syncedCount++;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return syncedCount;
|
|
666
|
+
}
|