@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/docs/new.md ADDED
@@ -0,0 +1,977 @@
1
+ # Mom Redesign: Multi-Platform Chat Support
2
+
3
+ ## Goals
4
+
5
+ 1. Support multiple chat platforms (Slack, Discord, WhatsApp, Telegram, etc.)
6
+ 2. Unified storage layer for all platforms
7
+ 3. Platform-agnostic agent that doesn't care where messages come from
8
+ 4. Adapters that are independently testable
9
+ 5. Agent that is independently testable
10
+
11
+ ## Current Architecture Problems
12
+
13
+ The current architecture tightly couples Slack-specific code throughout:
14
+
15
+ ```
16
+ main.ts → SlackBot → handler.handleEvent() → agent.run(SlackContext)
17
+
18
+ SlackContext.respond()
19
+ SlackContext.replaceMessage()
20
+ SlackContext.respondInThread()
21
+ etc.
22
+ ```
23
+
24
+ Problems:
25
+
26
+ - `SlackContext` interface leaks Slack concepts (threads, typing indicators)
27
+ - Agent code references Slack-specific formatting (mrkdwn, `<@user>` mentions)
28
+ - Storage uses Slack timestamps (`ts`) as message IDs
29
+ - Message logging assumes Slack's event structure
30
+ - The PR's Discord implementation duplicated most of this logic in a separate package
31
+
32
+ ## Proposed Architecture
33
+
34
+ ```
35
+ ┌─────────────────────────────────────────────────────────────────────────┐
36
+ │ CLI / Entry Point │
37
+ │ mom ./data │
38
+ │ (reads config.json, starts all configured adapters) │
39
+ └───────────────────────────────────┬─────────────────────────────────────┘
40
+
41
+
42
+ ┌─────────────────────────────────────────────────────────────────────────┐
43
+ │ Platform Adapter │
44
+ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
45
+ │ │ SlackAdapter │ │DiscordAdapter│ │ CLIAdapter │ (for testing) │
46
+ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
47
+ │ │ │ │ │
48
+ │ └────────────────┬┴─────────────────┘ │
49
+ │ │ │
50
+ │ ▼ │
51
+ │ ┌───────────────────────┐ │
52
+ │ │ PlatformAdapter │ (common interface) │
53
+ │ │ - onMessage() │ │
54
+ │ │ - onStop() │ │
55
+ │ │ - sendMessage() │ │
56
+ │ │ - updateMessage() │ │
57
+ │ │ - deleteMessage() │ │
58
+ │ │ - uploadFile() │ │
59
+ │ │ - getChannelInfo() │ │
60
+ │ │ - getUserInfo() │ │
61
+ │ └───────────┬───────────┘ │
62
+ └──────────────────────────┼──────────────────────────────────────────────┘
63
+
64
+
65
+ ┌─────────────────────────────────────────────────────────────────────────┐
66
+ │ MomAgent │
67
+ │ - Platform agnostic │
68
+ │ - Receives messages via handleMessage(message, context, onEvent) │
69
+ │ - Forwards AgentSessionEvent to adapter via callback │
70
+ │ - Provides: abort(), isRunning() │
71
+ └───────────────────────────────────┬─────────────────────────────────────┘
72
+
73
+
74
+ ┌─────────────────────────────────────────────────────────────────────────┐
75
+ │ ChannelStore │
76
+ │ - Unified storage schema for all platforms │
77
+ │ - log.jsonl: channel history (messages only) │
78
+ │ - context.jsonl: LLM context (messages + tool results) │
79
+ │ - attachments/: downloaded files │
80
+ └─────────────────────────────────────────────────────────────────────────┘
81
+ ```
82
+
83
+ ## Key Interfaces
84
+
85
+ ### 1. ChannelMessage (Unified Message Format)
86
+
87
+ ```typescript
88
+ interface ChannelMessage {
89
+ /** Unique ID within the channel (platform-specific format preserved) */
90
+ id: string;
91
+
92
+ /** Channel/conversation ID */
93
+ channelId: string;
94
+
95
+ /** Timestamp (ISO 8601) */
96
+ timestamp: string;
97
+
98
+ /** Sender info */
99
+ sender: {
100
+ id: string;
101
+ username: string;
102
+ displayName?: string;
103
+ isBot: boolean;
104
+ };
105
+
106
+ /** Message content (as received from platform) */
107
+ text: string;
108
+
109
+ /** Optional: original platform-specific text (for debugging) */
110
+ rawText?: string;
111
+
112
+ /** Attachments */
113
+ attachments: ChannelAttachment[];
114
+
115
+ /** Is this a direct mention/trigger of the bot? */
116
+ isMention: boolean;
117
+
118
+ /** Optional: reply-to message ID (for threaded conversations) */
119
+ replyTo?: string;
120
+
121
+ /** Platform-specific metadata (for platform-specific features) */
122
+ metadata?: Record<string, unknown>;
123
+ }
124
+
125
+ interface ChannelAttachment {
126
+ /** Original filename */
127
+ filename: string;
128
+
129
+ /** Local path (relative to channel dir) */
130
+ localPath: string;
131
+
132
+ /** MIME type if known */
133
+ mimeType?: string;
134
+
135
+ /** File size in bytes */
136
+ size?: number;
137
+ }
138
+ ```
139
+
140
+ ### 2. PlatformAdapter
141
+
142
+ Adapters handle platform connection and UI. They receive events from MomAgent and render however they want.
143
+
144
+ ```typescript
145
+ interface PlatformAdapter {
146
+ /** Adapter name (used in channel paths, e.g., "slack-acme") */
147
+ name: string;
148
+
149
+ /** Start the adapter (connect to platform) */
150
+ start(): Promise<void>;
151
+
152
+ /** Stop the adapter */
153
+ stop(): Promise<void>;
154
+
155
+ /** Get all known channels */
156
+ getChannels(): ChannelInfo[];
157
+
158
+ /** Get all known users */
159
+ getUsers(): UserInfo[];
160
+ }
161
+
162
+ interface ChannelInfo {
163
+ id: string;
164
+ name: string;
165
+ type: "channel" | "dm" | "group";
166
+ }
167
+
168
+ interface UserInfo {
169
+ id: string;
170
+ username: string;
171
+ displayName?: string;
172
+ }
173
+ ```
174
+
175
+ ### 3. MomAgent
176
+
177
+ MomAgent wraps `AgentSession` from coding-agent. Agent is platform-agnostic; it just forwards events to the adapter.
178
+
179
+ ```typescript
180
+ import { type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
181
+
182
+ interface MomAgent {
183
+ /**
184
+ * Handle an incoming message.
185
+ * Adapter receives events via callback and renders however it wants.
186
+ */
187
+ handleMessage(
188
+ message: ChannelMessage,
189
+ context: ChannelContext,
190
+ onEvent: (event: AgentSessionEvent) => Promise<void>
191
+ ): Promise<{ stopReason: string; errorMessage?: string }>;
192
+
193
+ /** Abort the current run for a channel */
194
+ abort(channelId: string): void;
195
+
196
+ /** Check if a channel is currently running */
197
+ isRunning(channelId: string): boolean;
198
+ }
199
+
200
+ interface ChannelContext {
201
+ /** Adapter name (for channel path: channels/<adapter>/<channelId>/) */
202
+ adapter: string;
203
+ users: UserInfo[];
204
+ channels: ChannelInfo[];
205
+ }
206
+ ```
207
+
208
+ ## Event Handling
209
+
210
+ Adapter receives `AgentSessionEvent` and renders however it wants:
211
+
212
+ ```typescript
213
+ // Slack adapter example
214
+ async function handleEvent(event: AgentSessionEvent, ctx: SlackContext) {
215
+ switch (event.type) {
216
+ case "tool_execution_start": {
217
+ const label = (event.args as any).label || event.toolName;
218
+ await ctx.updateMain(`_→ ${label}_`);
219
+ break;
220
+ }
221
+
222
+ case "tool_execution_end": {
223
+ // Format tool result for thread
224
+ const result = extractText(event.result);
225
+ const formatted = `**${event.toolName}** (${event.durationMs}ms)\n\`\`\`\n${result}\n\`\`\``;
226
+ await ctx.appendThread(this.toSlackFormat(formatted));
227
+ break;
228
+ }
229
+
230
+ case "message_end": {
231
+ if (event.message.role === "assistant") {
232
+ const text = extractAssistantText(event.message);
233
+ await ctx.replaceMain(this.toSlackFormat(text));
234
+ await ctx.appendThread(this.toSlackFormat(text));
235
+
236
+ // Usage from AssistantMessage
237
+ if (event.message.usage) {
238
+ await ctx.appendThread(formatUsage(event.message.usage));
239
+ }
240
+ }
241
+ break;
242
+ }
243
+
244
+ case "auto_compaction_start":
245
+ await ctx.updateMain("_Compacting context..._");
246
+ break;
247
+ }
248
+ }
249
+ ```
250
+
251
+ Each adapter decides:
252
+
253
+ - Message formatting (markdown → mrkdwn, embeds, etc.)
254
+ - Message splitting for platform limits
255
+ - What goes in main message vs thread
256
+ - How to show tool results, usage, errors
257
+
258
+ ## Storage Format
259
+
260
+ ### log.jsonl (Channel History)
261
+
262
+ Messages stored as received from platform:
263
+
264
+ ```jsonl
265
+ {"id":"1734567890.123456","ts":"2024-12-20T10:00:00.000Z","sender":{"id":"U123","username":"mario","displayName":"Mario Z","isBot":false},"text":"<@U789> what's the weather?","attachments":[],"isMention":true}
266
+ {"id":"1734567890.234567","ts":"2024-12-20T10:00:05.000Z","sender":{"id":"bot","username":"mom","isBot":true},"text":"The weather is sunny!","attachments":[]}
267
+ ```
268
+
269
+ ### context.jsonl (LLM Context)
270
+
271
+ Same format as current (coding-agent compatible):
272
+
273
+ ```jsonl
274
+ {"type":"session","id":"uuid","timestamp":"...","provider":"anthropic","modelId":"claude-sonnet-4-5"}
275
+ {"type":"message","timestamp":"...","message":{"role":"user","content":"[mario]: what's the weather?"}}
276
+ {"type":"message","timestamp":"...","message":{"role":"assistant","content":[{"type":"text","text":"The weather is sunny!"}]}}
277
+ ```
278
+
279
+ ## Directory Structure
280
+
281
+ ```
282
+ data/
283
+ ├── config.json # Host only - tokens, adapters, access control
284
+ └── workspace/ # Mounted as /workspace in Docker
285
+ ├── MEMORY.md
286
+ ├── skills/
287
+ ├── tools/
288
+ ├── events/
289
+ └── channels/
290
+ ├── slack-acme/
291
+ │ └── C0A34FL8PMH/
292
+ │ ├── MEMORY.md
293
+ │ ├── log.jsonl
294
+ │ ├── context.jsonl
295
+ │ ├── attachments/
296
+ │ ├── skills/
297
+ │ └── scratch/
298
+ └── discord-mybot/
299
+ └── 1234567890123456789/
300
+ └── ...
301
+ ```
302
+
303
+ **config.json** (not mounted, stays on host):
304
+
305
+ ```json
306
+ {
307
+ "adapters": {
308
+ "slack-acme": {
309
+ "type": "slack",
310
+ "botToken": "xoxb-...",
311
+ "appToken": "xapp-...",
312
+ "admins": ["U123", "U456"],
313
+ "dm": "everyone"
314
+ },
315
+ "discord-mybot": {
316
+ "type": "discord",
317
+ "botToken": "...",
318
+ "admins": ["123456789"],
319
+ "dm": "none"
320
+ }
321
+ }
322
+ }
323
+ ```
324
+
325
+ **Access control:**
326
+
327
+ - `admins`: User IDs with admin privileges. Can always DM.
328
+ - `dm`: Who else can DM. `"everyone"`, `"none"`, or `["U789", "U012"]`
329
+
330
+ **Channels** are namespaced by adapter name: `channels/<adapter>/<channelId>/`
331
+
332
+ **Events** use qualified channelId: `{"channelId": "slack-acme/C123", ...}`
333
+
334
+ **Security note:** Mom has bash access to all channel logs in the workspace. If mom is in a private channel, anyone who can talk to mom could potentially access that channel's history. For true isolation, run separate mom instances with separate data directories.
335
+
336
+ ### Channel Isolation via Bubblewrap (Linux/Docker)
337
+
338
+ In Linux-based execution environments (Docker), we can use [bubblewrap](https://github.com/containers/bubblewrap) to enforce per-user channel access at the OS level.
339
+
340
+ **How it works:**
341
+
342
+ 1. Adapter knows which channels the requesting user has access to
343
+ 2. Before executing bash, wrap command with bwrap
344
+ 3. Mount entire filesystem, then overlay denied channels with empty tmpfs
345
+ 4. Sandboxed process can't see files in denied channels
346
+
347
+ ```typescript
348
+ function wrapWithBwrap(command: string, deniedChannels: string[]): string {
349
+ const args = [
350
+ "--bind / /", // Mount everything
351
+ ...deniedChannels.map(
352
+ (ch) => `--tmpfs /workspace/channels/${ch}` // Hide denied channels
353
+ ),
354
+ "--dev /dev",
355
+ "--proc /proc",
356
+ "--die-with-parent",
357
+ ];
358
+ return `bwrap ${args.join(" ")} -- ${command}`;
359
+ }
360
+
361
+ // Usage
362
+ const userChannels = adapter.getUserChannels(userId); // ["public", "team-a"]
363
+ const allChannels = await fs.readdir("/workspace/channels/");
364
+ const denied = allChannels.filter((ch) => !userChannels.includes(ch));
365
+
366
+ const sandboxedCmd = wrapWithBwrap("cat /workspace/channels/private/log.jsonl", denied);
367
+ // Results in: "No such file or directory" - private channel hidden
368
+ ```
369
+
370
+ **Requirements:**
371
+
372
+ - Docker container needs `--cap-add=SYS_ADMIN` for bwrap to create namespaces
373
+ - Install in Dockerfile: `apk add bubblewrap`
374
+
375
+ **Limitations:**
376
+
377
+ - Linux only (not macOS host mode)
378
+ - Requires SYS_ADMIN capability in Docker
379
+ - Per-execution overhead (though minimal)
380
+
381
+ ## System Prompt Changes
382
+
383
+ The system prompt is platform-agnostic. Agent outputs standard markdown, adapter converts.
384
+
385
+ ```typescript
386
+ function buildSystemPrompt(
387
+ workspacePath: string,
388
+ channelId: string,
389
+ memory: string,
390
+ sandbox: SandboxConfig,
391
+ context: ChannelContext,
392
+ skills: Skill[]
393
+ ): string {
394
+ return `You are mom, a chat bot assistant. Be concise. No emojis.
395
+
396
+ ## Text Formatting
397
+ Use standard markdown: **bold**, *italic*, \`code\`, \`\`\`block\`\`\`, [text](url)
398
+ For mentions, use @username format.
399
+
400
+ ## Users
401
+ ${context.users.map((u) => `@${u.username}\t${u.displayName || ""}`).join("\n")}
402
+
403
+ ## Channels
404
+ ${context.channels.map((c) => `#${c.name}`).join("\n")}
405
+
406
+ ... rest of prompt ...
407
+ `;
408
+ }
409
+ ```
410
+
411
+ The adapter converts markdown to platform format internally:
412
+
413
+ ```typescript
414
+ // Inside SlackAdapter
415
+ private formatForSlack(markdown: string): string {
416
+ let text = markdown;
417
+
418
+ // Bold: **text** → *text*
419
+ text = text.replace(/\*\*(.+?)\*\*/g, '*$1*');
420
+
421
+ // Links: [text](url) → <url|text>
422
+ text = text.replace(/\[(.+?)\]\((.+?)\)/g, '<$2|$1>');
423
+
424
+ // Mentions: @username → <@U123>
425
+ text = text.replace(/@(\w+)/g, (match, username) => {
426
+ const user = this.users.find(u => u.username === username);
427
+ return user ? `<@${user.id}>` : match;
428
+ });
429
+
430
+ return text;
431
+ }
432
+ ```
433
+
434
+ ````
435
+
436
+ ## Testing Strategy
437
+
438
+ ### 1. Agent Tests (with temp Docker container)
439
+
440
+ ```typescript
441
+ // test/agent.test.ts
442
+ import { MomAgent } from '../src/agent.js';
443
+ import { createTestContainer, destroyTestContainer } from './docker-utils.js';
444
+
445
+ describe('MomAgent', () => {
446
+ let containerName: string;
447
+
448
+ beforeAll(async () => {
449
+ containerName = await createTestContainer();
450
+ });
451
+
452
+ afterAll(async () => {
453
+ await destroyTestContainer(containerName);
454
+ });
455
+
456
+ it('responds to user message', async () => {
457
+ const agent = new MomAgent({
458
+ workDir: tmpDir,
459
+ sandbox: { type: 'docker', container: containerName }
460
+ });
461
+
462
+ const events: AgentSessionEvent[] = [];
463
+
464
+ await agent.handleMessage(
465
+ {
466
+ id: '1',
467
+ channelId: 'test-channel',
468
+ timestamp: new Date().toISOString(),
469
+ sender: { id: 'u1', username: 'testuser', isBot: false },
470
+ text: 'hello',
471
+ attachments: [],
472
+ isMention: true,
473
+ },
474
+ { adapter: 'test', users: [], channels: [] },
475
+ async (event) => { events.push(event); }
476
+ );
477
+
478
+ const messageEnds = events.filter(e => e.type === 'message_end');
479
+ expect(messageEnds.length).toBeGreaterThan(0);
480
+ });
481
+ });
482
+ ````
483
+
484
+ ### 2. Adapter Tests (no agent)
485
+
486
+ ```typescript
487
+ // test/adapters/slack.test.ts
488
+ describe("SlackAdapter", () => {
489
+ it("converts Slack event to ChannelMessage", () => {
490
+ const slackEvent = {
491
+ type: "message",
492
+ text: "Hello <@U123>",
493
+ user: "U456",
494
+ channel: "C789",
495
+ ts: "1234567890.123456",
496
+ };
497
+
498
+ const message = SlackAdapter.parseEvent(slackEvent, userCache);
499
+
500
+ expect(message.text).toBe("Hello @someuser");
501
+ expect(message.channelId).toBe("C789");
502
+ expect(message.sender.id).toBe("U456");
503
+ });
504
+
505
+ it("converts markdown to Slack format", () => {
506
+ const slack = SlackAdapter.toSlackFormat("**bold** and [link](http://example.com)");
507
+ expect(slack).toBe("*bold* and <http://example.com|link>");
508
+ });
509
+
510
+ it("handles message_end event", async () => {
511
+ const mockClient = new MockSlackClient();
512
+ const adapter = new SlackAdapter({ client: mockClient });
513
+
514
+ await adapter.handleEvent(
515
+ {
516
+ type: "message_end",
517
+ message: { role: "assistant", content: [{ type: "text", text: "**Hello**" }] },
518
+ },
519
+ channelContext
520
+ );
521
+
522
+ // Verify Slack formatting applied
523
+ expect(mockClient.postMessage).toHaveBeenCalledWith("C123", "*Hello*");
524
+ });
525
+ });
526
+ ```
527
+
528
+ ### 3. Integration Tests
529
+
530
+ ```typescript
531
+ // test/integration.test.ts
532
+ describe("Mom Integration", () => {
533
+ let containerName: string;
534
+
535
+ beforeAll(async () => {
536
+ containerName = await createTestContainer();
537
+ });
538
+
539
+ afterAll(async () => {
540
+ await destroyTestContainer(containerName);
541
+ });
542
+
543
+ it("end-to-end with CLI adapter", async () => {
544
+ const agent = new MomAgent({
545
+ workDir: tmpDir,
546
+ sandbox: { type: "docker", container: containerName },
547
+ });
548
+ const adapter = new CLIAdapter({ agent, input: mockStdin, output: mockStdout });
549
+
550
+ await adapter.start();
551
+ mockStdin.emit("data", "Hello mom\n");
552
+
553
+ await waitFor(() => mockStdout.data.length > 0);
554
+ expect(mockStdout.data).toContain("Hello");
555
+ });
556
+ });
557
+ ```
558
+
559
+ ## Migration Path
560
+
561
+ 1. **Phase 1: Refactor storage** (non-breaking)
562
+
563
+ - Unify log.jsonl schema (ChannelMessage format)
564
+ - Add migration for existing Slack-format logs
565
+
566
+ 2. **Phase 2: Extract adapter interface** (non-breaking)
567
+
568
+ - Create SlackAdapter wrapping current SlackBot
569
+ - Agent emits events, adapter handles UI
570
+
571
+ 3. **Phase 3: Decouple agent** (non-breaking)
572
+
573
+ - Remove Slack-specific code from agent.ts
574
+ - Agent becomes fully platform-agnostic
575
+
576
+ 4. **Phase 4: Add Discord** (new feature)
577
+ - Implement DiscordAdapter
578
+ - Share all storage and agent code
579
+
580
+ ## Decisions
581
+
582
+ 1. **Channel ID collision**: Prefix with adapter name (`channels/slack-acme/C123/`).
583
+
584
+ 2. **Threads**: Adapter decides. Slack uses threads, Discord can use threads or embeds.
585
+
586
+ 3. **Mentions**: Store as-is from platform. Agent outputs `@username`, adapter converts.
587
+
588
+ 4. **Rate limiting**: Each adapter handles its own.
589
+
590
+ 5. **Config**: Single `config.json` with all adapter configs and tokens.
591
+
592
+ ## File Structure
593
+
594
+ ```
595
+ packages/mom/src/
596
+ ├── main.ts # CLI entry point
597
+ ├── agent.ts # MomAgent
598
+ ├── store.ts # ChannelStore
599
+ ├── context.ts # Session management
600
+ ├── sandbox.ts # Sandbox execution
601
+ ├── events.ts # Scheduled events
602
+ ├── log.ts # Console logging
603
+
604
+ ├── adapters/
605
+ │ ├── types.ts # PlatformAdapter, ChannelMessage interfaces
606
+ │ ├── slack.ts # SlackAdapter
607
+ │ ├── discord.ts # DiscordAdapter
608
+ │ └── cli.ts # CLIAdapter (for testing)
609
+
610
+ └── tools/
611
+ ├── index.ts
612
+ ├── bash.ts
613
+ ├── read.ts
614
+ ├── write.ts
615
+ ├── edit.ts
616
+ └── attach.ts
617
+ ```
618
+
619
+ ## Custom Tools (Host-Side Execution)
620
+
621
+ Mom runs bash commands inside a sandbox (Docker container), but sometimes you need tools that run on the host machine (e.g., accessing host APIs, credentials, or services that can't run in the container).
622
+
623
+ ### Architecture
624
+
625
+ ```
626
+ ┌─────────────────────────────────────────────────────────────────────────┐
627
+ │ Host Machine │
628
+ │ ┌───────────────────────────────────────────────────────────────────┐ │
629
+ │ │ Mom Process (Node.js) │ │
630
+ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐│ │
631
+ │ │ │ CustomTool │ │ CustomTool │ │ invoke_tool (AgentTool) ││ │
632
+ │ │ │ gmail │ │ calendar │ │ - receives tool name + args ││ │
633
+ │ │ │ (loaded via │ │ (loaded via │ │ - dispatches to custom tool ││ │
634
+ │ │ │ jiti) │ │ jiti) │ │ - returns result to agent ││ │
635
+ │ │ └─────────────┘ └─────────────┘ └─────────────────────────────┘│ │
636
+ │ │ ▲ │ │ │
637
+ │ │ │ execute() │ invoke_tool() │ │
638
+ │ │ │ ▼ │ │
639
+ │ │ ┌───────────────────────────────────────────────────────────────┐│ │
640
+ │ │ │ MomAgent ││ │
641
+ │ │ │ - System prompt describes all custom tools ││ │
642
+ │ │ │ - Has invoke_tool as one of its tools ││ │
643
+ │ │ │ - Mom calls invoke_tool("gmail", {action: "search", ...}) ││ │
644
+ │ │ └───────────────────────────────────────────────────────────────┘│ │
645
+ │ └───────────────────────────────────────────────────────────────────┘ │
646
+ │ │ │
647
+ │ │ bash tool (Docker exec) │
648
+ │ ▼ │
649
+ │ ┌───────────────────────────────────────────────────────────────────┐ │
650
+ │ │ Docker Container (Sandbox) │ │
651
+ │ │ - Mom's bash commands run here │ │
652
+ │ │ - Isolated from host (except mounted workspace) │ │
653
+ │ └───────────────────────────────────────────────────────────────────┘ │
654
+ └─────────────────────────────────────────────────────────────────────────┘
655
+ ```
656
+
657
+ ### Custom Tool Interface
658
+
659
+ ```typescript
660
+ // data/tools/gmail/index.ts
661
+ import type { MomCustomTool, ToolAPI } from "@oh-my-pi/pi-mom";
662
+ import { Type } from "@sinclair/typebox";
663
+ import { StringEnum } from "@oh-my-pi/pi-ai";
664
+
665
+ const tool: MomCustomTool = {
666
+ name: "gmail",
667
+ description: "Search, read, and send emails via Gmail",
668
+ parameters: Type.Object({
669
+ action: StringEnum(["search", "read", "send"]),
670
+ query: Type.Optional(Type.String({ description: "Search query" })),
671
+ messageId: Type.Optional(Type.String({ description: "Message ID to read" })),
672
+ to: Type.Optional(Type.String({ description: "Recipient email" })),
673
+ subject: Type.Optional(Type.String({ description: "Email subject" })),
674
+ body: Type.Optional(Type.String({ description: "Email body" })),
675
+ }),
676
+
677
+ async execute(toolCallId, params, signal) {
678
+ switch (params.action) {
679
+ case "search":
680
+ const results = await searchEmails(params.query);
681
+ return {
682
+ content: [{ type: "text", text: formatSearchResults(results) }],
683
+ details: { count: results.length },
684
+ };
685
+ case "read":
686
+ const email = await readEmail(params.messageId);
687
+ return {
688
+ content: [{ type: "text", text: email.body }],
689
+ details: { from: email.from, subject: email.subject },
690
+ };
691
+ case "send":
692
+ await sendEmail(params.to, params.subject, params.body);
693
+ return {
694
+ content: [{ type: "text", text: `Email sent to ${params.to}` }],
695
+ details: { sent: true },
696
+ };
697
+ }
698
+ },
699
+ };
700
+
701
+ export default tool;
702
+ ```
703
+
704
+ ### MomCustomTool Type
705
+
706
+ ```typescript
707
+ import type { TSchema, Static } from "@sinclair/typebox";
708
+
709
+ export interface MomToolResult<TDetails = any> {
710
+ content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
711
+ details?: TDetails;
712
+ }
713
+
714
+ export interface MomCustomTool<TParams extends TSchema = TSchema, TDetails = any> {
715
+ /** Tool name (must be unique) */
716
+ name: string;
717
+
718
+ /** Human-readable description for system prompt */
719
+ description: string;
720
+
721
+ /** TypeBox schema for parameters */
722
+ parameters: TParams;
723
+
724
+ /** Execute the tool */
725
+ execute: (toolCallId: string, params: Static<TParams>, signal?: AbortSignal) => Promise<MomToolResult<TDetails>>;
726
+
727
+ /** Optional: called when mom starts (for initialization) */
728
+ onStart?: () => Promise<void>;
729
+
730
+ /** Optional: called when mom stops (for cleanup) */
731
+ onStop?: () => Promise<void>;
732
+ }
733
+
734
+ /** Factory function for tools that need async initialization */
735
+ export type MomCustomToolFactory = (api: ToolAPI) => MomCustomTool | Promise<MomCustomTool>;
736
+
737
+ export interface ToolAPI {
738
+ /** Path to mom's data directory */
739
+ dataDir: string;
740
+
741
+ /** Execute a command on the host (not in sandbox) */
742
+ exec: (command: string, args: string[], options?: ExecOptions) => Promise<ExecResult>;
743
+
744
+ /** Read a file from the data directory */
745
+ readFile: (path: string) => Promise<string>;
746
+
747
+ /** Write a file to the data directory */
748
+ writeFile: (path: string, content: string) => Promise<void>;
749
+ }
750
+ ```
751
+
752
+ ### Tool Discovery and Loading
753
+
754
+ Tools are discovered from:
755
+
756
+ 1. `data/tools/**/index.ts` (workspace-local, recursive)
757
+ 2. `~/.omp/mom/tools/**/index.ts` (global, recursive)
758
+
759
+ ```typescript
760
+ // loader.ts
761
+ import { createJiti } from "jiti";
762
+
763
+ interface LoadedTool {
764
+ path: string;
765
+ tool: MomCustomTool;
766
+ }
767
+
768
+ async function loadCustomTools(dataDir: string): Promise<LoadedTool[]> {
769
+ const tools: LoadedTool[] = [];
770
+ const jiti = createJiti(import.meta.url, { alias: getAliases() });
771
+
772
+ // Discover tool directories
773
+ const toolDirs = [path.join(dataDir, "tools"), path.join(os.homedir(), ".omp", "mom", "tools")];
774
+
775
+ for (const dir of toolDirs) {
776
+ if (!fs.existsSync(dir)) continue;
777
+
778
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
779
+ if (!entry.isDirectory()) continue;
780
+
781
+ const indexPath = path.join(dir, entry.name, "index.ts");
782
+ if (!fs.existsSync(indexPath)) continue;
783
+
784
+ try {
785
+ const module = await jiti.import(indexPath, { default: true });
786
+ const toolOrFactory = module as MomCustomTool | MomCustomToolFactory;
787
+
788
+ const tool = typeof toolOrFactory === "function" ? await toolOrFactory(createToolAPI(dataDir)) : toolOrFactory;
789
+
790
+ tools.push({ path: indexPath, tool });
791
+ } catch (err) {
792
+ console.error(`Failed to load tool from ${indexPath}:`, err);
793
+ }
794
+ }
795
+ }
796
+
797
+ return tools;
798
+ }
799
+ ```
800
+
801
+ ### The invoke_tool Agent Tool
802
+
803
+ Mom has a single `invoke_tool` tool that dispatches to custom tools:
804
+
805
+ ```typescript
806
+ import { Type } from "@sinclair/typebox";
807
+
808
+ function createInvokeToolTool(loadedTools: LoadedTool[]): AgentTool {
809
+ const toolMap = new Map(loadedTools.map((t) => [t.tool.name, t.tool]));
810
+
811
+ return {
812
+ name: "invoke_tool",
813
+ label: "Invoke Tool",
814
+ description: "Invoke a custom tool running on the host machine",
815
+ parameters: Type.Object({
816
+ tool: Type.String({ description: "Name of the tool to invoke" }),
817
+ args: Type.Any({ description: "Arguments to pass to the tool (tool-specific)" }),
818
+ }),
819
+
820
+ async execute(toolCallId, params, signal) {
821
+ const tool = toolMap.get(params.tool);
822
+ if (!tool) {
823
+ return {
824
+ content: [{ type: "text", text: `Unknown tool: ${params.tool}` }],
825
+ details: { error: true },
826
+ isError: true,
827
+ };
828
+ }
829
+
830
+ try {
831
+ // Validate args against tool's schema
832
+ // (TypeBox validation here)
833
+
834
+ const result = await tool.execute(toolCallId, params.args, signal);
835
+ return {
836
+ content: result.content,
837
+ details: { tool: params.tool, ...result.details },
838
+ };
839
+ } catch (err) {
840
+ return {
841
+ content: [{ type: "text", text: `Tool error: ${err.message}` }],
842
+ details: { error: true, tool: params.tool },
843
+ isError: true,
844
+ };
845
+ }
846
+ },
847
+ };
848
+ }
849
+ ```
850
+
851
+ ### System Prompt Integration
852
+
853
+ Custom tools are described in the system prompt so mom knows what's available:
854
+
855
+ ```typescript
856
+ function formatCustomToolsForPrompt(tools: LoadedTool[]): string {
857
+ if (tools.length === 0) return "";
858
+
859
+ let section = `\n## Custom Tools (Host-Side)
860
+
861
+ These tools run on the host machine (not in your sandbox). Use the \`invoke_tool\` tool to call them.
862
+
863
+ `;
864
+
865
+ for (const { tool } of tools) {
866
+ section += `### ${tool.name}
867
+ ${tool.description}
868
+
869
+ **Parameters:**
870
+ \`\`\`json
871
+ ${JSON.stringify(schemaToSimpleJson(tool.parameters), null, 2)}
872
+ \`\`\`
873
+
874
+ **Example:**
875
+ \`\`\`
876
+ invoke_tool(tool: "${tool.name}", args: { ... })
877
+ \`\`\`
878
+
879
+ `;
880
+ }
881
+
882
+ return section;
883
+ }
884
+
885
+ // Convert TypeBox schema to simple JSON for display
886
+ function schemaToSimpleJson(schema: TSchema): object {
887
+ // Simplified schema representation for the LLM
888
+ // ...
889
+ }
890
+ ```
891
+
892
+ ### Example: Gmail Tool
893
+
894
+ ```typescript
895
+ // data/tools/gmail/index.ts
896
+ import type { MomCustomTool, ToolAPI } from "@oh-my-pi/pi-mom";
897
+ import { Type } from "@sinclair/typebox";
898
+ import { StringEnum } from "@oh-my-pi/pi-ai";
899
+ import Imap from "imap";
900
+ import nodemailer from "nodemailer";
901
+
902
+ export default async function (api: ToolAPI): Promise<MomCustomTool> {
903
+ // Load credentials from data directory
904
+ const credsPath = path.join(api.dataDir, "tools", "gmail", "credentials.json");
905
+ const creds = JSON.parse(await api.readFile(credsPath));
906
+
907
+ return {
908
+ name: "gmail",
909
+ description: "Search, read, and send emails via Gmail. Requires credentials.json in the tool directory.",
910
+ parameters: Type.Object({
911
+ action: StringEnum(["search", "read", "send", "list"]),
912
+ // ... other params
913
+ }),
914
+
915
+ async execute(toolCallId, params, signal) {
916
+ // Implementation using imap/nodemailer
917
+ },
918
+ };
919
+ }
920
+ ```
921
+
922
+ ### Security Considerations
923
+
924
+ 1. **Tools run on host**: Custom tools have full host access. Only install trusted tools.
925
+ 2. **Credential storage**: Tools should store credentials in the data directory, not in code.
926
+ 3. **Sandbox separation**: The sandbox (Docker) can't access host tools directly. Only mom's invoke_tool can call them.
927
+
928
+ ### Loading
929
+
930
+ Tools are loaded via jiti. They can import any 3rd party dependencies (install in the tool directory). Imports of `@oh-my-pi/pi-ai` and `@oh-my-pi/pi-mom` are aliased to the running mom bundle.
931
+
932
+ **Live reload**: In dev mode, tools are watched and reloaded on change. No restart needed.
933
+
934
+ ## Events System
935
+
936
+ Scheduled wake-ups via JSON files in `workspace/events/`.
937
+
938
+ ### Format
939
+
940
+ ```json
941
+ { "type": "one-shot", "channelId": "slack-acme/C123ABC", "text": "Reminder", "at": "2025-12-15T09:00:00+01:00" }
942
+ ```
943
+
944
+ Channel ID is qualified with adapter name so the event watcher knows which adapter to use.
945
+
946
+ ### Running
947
+
948
+ ```bash
949
+ mom ./data
950
+ ```
951
+
952
+ Reads `config.json`, starts all adapters defined there.
953
+
954
+ The shared workspace allows:
955
+
956
+ - Shared MEMORY.md (global knowledge)
957
+ - Shared skills
958
+ - Events can target any platform
959
+ - Per-channel data is still isolated by channel ID
960
+
961
+ ## Summary
962
+
963
+ The key insight is **separation of concerns**:
964
+
965
+ 1. **Storage**: Unified schema, messages stored as-is from platform
966
+ 2. **Agent**: Doesn't know about Slack/Discord, just processes messages and emits events
967
+ 3. **Adapters**: Handle platform-specific connection, formatting, and message splitting
968
+ 4. **Progress Rendering**: Each adapter decides how to display tool progress and results
969
+
970
+ This allows:
971
+
972
+ - Testing agent without any platform
973
+ - Testing adapters without agent
974
+ - Adding new platforms by implementing `PlatformAdapter`
975
+ - Sharing all storage, context management, and agent logic
976
+ - Rich UI on platforms that support it (embeds, buttons)
977
+ - Graceful degradation on simpler platforms (plain text)