@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/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
+ }