@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/v86.md ADDED
@@ -0,0 +1,319 @@
1
+ # v86 Sandbox Evaluation
2
+
3
+ v86 is an x86 emulator written in JavaScript/WebAssembly that can run Linux in the browser or Node.js. This document details our evaluation for using it as a sandboxed execution environment.
4
+
5
+ ## Overview
6
+
7
+ - **What it is**: x86 PC emulator (32-bit, Pentium 4 level)
8
+ - **How it works**: Translates machine code to WebAssembly at runtime
9
+ - **Guest OS**: Alpine Linux 3.21 (32-bit x86)
10
+ - **Available packages**: Node.js 22, Python 3.12, git, curl, etc. (full Alpine repos)
11
+
12
+ ## Key Findings
13
+
14
+ ### What Works
15
+
16
+ | Feature | Status | Notes |
17
+ |---------|--------|-------|
18
+ | Outbound TCP | ✅ | HTTP, HTTPS, TLS all work |
19
+ | Outbound UDP | ✅ | DNS queries work |
20
+ | WebSocket client | ✅ | Can connect to external WebSocket servers |
21
+ | File I/O | ✅ | 9p filesystem for host<->guest file exchange |
22
+ | State save/restore | ✅ | ~80-100MB state files, instant resume |
23
+ | Package persistence | ✅ | Installed packages persist in saved state |
24
+ | npm install | ✅ | Works (outbound HTTPS) |
25
+ | git clone | ✅ | Works (outbound HTTPS) |
26
+
27
+ ### What Doesn't Work
28
+
29
+ | Feature | Status | Notes |
30
+ |---------|--------|-------|
31
+ | Inbound connections | ❌ | VM is behind NAT (10.0.2.x), needs port forwarding |
32
+ | ICMP ping | ❌ | Userspace network stack limitation |
33
+ | 64-bit | ❌ | v86 only emulates 32-bit x86 |
34
+
35
+ ## Architecture
36
+
37
+ ```
38
+ ┌─────────────────────────────────────────────────────────┐
39
+ │ Host (Node.js) │
40
+ │ │
41
+ │ ┌──────────────┐ ┌─────────────────────────────┐ │
42
+ │ │ rootlessRelay│◄───►│ v86 │ │
43
+ │ │ (WebSocket) │ │ ┌─────────────────────┐ │ │
44
+ │ │ │ │ │ Alpine Linux │ │ │
45
+ │ │ - DHCP │ │ │ - Node.js 22 │ │ │
46
+ │ │ - DNS proxy │ │ │ - Python 3.12 │ │ │
47
+ │ │ - NAT │ │ │ - etc. │ │ │
48
+ │ └──────────────┘ │ └─────────────────────┘ │ │
49
+ │ │ │ │ │ │
50
+ │ │ │ 9p filesystem │ │
51
+ │ ▼ │ │ │ │
52
+ │ Internet │ ▼ │ │
53
+ │ │ Host filesystem │ │
54
+ │ └─────────────────────────────┘ │
55
+ └─────────────────────────────────────────────────────────┘
56
+ ```
57
+
58
+ ## Components & Sizes
59
+
60
+ | Component | Size | Purpose |
61
+ |-----------|------|---------|
62
+ | v86.wasm | ~2 MB | x86 emulator |
63
+ | libv86.mjs | ~330 KB | JavaScript runtime |
64
+ | seabios.bin | ~128 KB | BIOS |
65
+ | vgabios.bin | ~36 KB | VGA BIOS |
66
+ | Alpine rootfs | ~57 MB | Compressed filesystem (loaded on-demand) |
67
+ | alpine-fs.json | ~160 KB | Filesystem index |
68
+ | rootlessRelay | ~75 KB | Network relay |
69
+ | **Total** | **~60 MB** | Without saved state |
70
+ | Saved state | ~80-100 MB | Optional, for instant resume |
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ npm install v86 ws
76
+ ```
77
+
78
+ ## Building the Alpine Image
79
+
80
+ v86 provides Docker tooling to build the Alpine image:
81
+
82
+ ```bash
83
+ git clone https://github.com/copy/v86.git
84
+ cd v86/tools/docker/alpine
85
+
86
+ # Edit Dockerfile to add packages:
87
+ # ENV ADDPKGS=nodejs,npm,python3,git,curl
88
+
89
+ ./build.sh
90
+ ```
91
+
92
+ This creates:
93
+ - `images/alpine-fs.json` - Filesystem index
94
+ - `images/alpine-rootfs-flat/` - Compressed file chunks
95
+
96
+ ## Network Relay Setup
97
+
98
+ v86 needs a network relay for TCP/UDP connectivity. We use rootlessRelay:
99
+
100
+ ```bash
101
+ git clone https://github.com/obegron/rootlessRelay.git
102
+ cd rootlessRelay
103
+ npm install
104
+ ```
105
+
106
+ ### Required Patches for Host Access
107
+
108
+ To allow the VM to connect to host services via the gateway IP (10.0.2.2), apply these patches to `relay.js`:
109
+
110
+ **Patch 1: Disable reverse TCP handling for gateway (line ~684)**
111
+ ```javascript
112
+ // Change:
113
+ if (protocol === 6 && dstIP === GATEWAY_IP) {
114
+ this.handleReverseTCP(ipPacket);
115
+ return;
116
+ }
117
+
118
+ // To:
119
+ if (false && protocol === 6 && dstIP === GATEWAY_IP) { // PATCHED
120
+ this.handleReverseTCP(ipPacket);
121
+ return;
122
+ }
123
+ ```
124
+
125
+ **Patch 2: Redirect gateway TCP to localhost (line ~792)**
126
+ ```javascript
127
+ // Change:
128
+ const socket = net.connect(dstPort, dstIP, () => {
129
+
130
+ // To:
131
+ const actualDstIP = dstIP === GATEWAY_IP ? "127.0.0.1" : dstIP;
132
+ const socket = net.connect(dstPort, actualDstIP, () => {
133
+ ```
134
+
135
+ **Patch 3: Redirect gateway UDP to localhost (lines ~1431 and ~1449)**
136
+ ```javascript
137
+ // Change:
138
+ this.udpSocket.send(payload, dstPort, dstIP, (err) => {
139
+
140
+ // To:
141
+ const actualUdpDstIP = dstIP === GATEWAY_IP ? "127.0.0.1" : dstIP;
142
+ this.udpSocket.send(payload, dstPort, actualUdpDstIP, (err) => {
143
+ ```
144
+
145
+ ### Starting the Relay
146
+
147
+ ```bash
148
+ ENABLE_WSS=false LOG_LEVEL=1 node relay.js
149
+ # Listens on ws://127.0.0.1:8086/
150
+ ```
151
+
152
+ ## Basic Usage
153
+
154
+ ```javascript
155
+ import { V86 } from "v86";
156
+ import path from "node:path";
157
+
158
+ const emulator = new V86({
159
+ wasm_path: path.join(__dirname, "node_modules/v86/build/v86.wasm"),
160
+ bios: { url: path.join(__dirname, "bios/seabios.bin") },
161
+ vga_bios: { url: path.join(__dirname, "bios/vgabios.bin") },
162
+ filesystem: {
163
+ basefs: path.join(__dirname, "images/alpine-fs.json"),
164
+ baseurl: path.join(__dirname, "images/alpine-rootfs-flat/"),
165
+ },
166
+ autostart: true,
167
+ memory_size: 512 * 1024 * 1024,
168
+ bzimage_initrd_from_filesystem: true,
169
+ cmdline: "rw root=host9p rootfstype=9p rootflags=trans=virtio,cache=loose modules=virtio_pci tsc=reliable console=ttyS0",
170
+ net_device: {
171
+ type: "virtio",
172
+ relay_url: "ws://127.0.0.1:8086/",
173
+ },
174
+ });
175
+
176
+ // Capture output
177
+ emulator.add_listener("serial0-output-byte", (byte) => {
178
+ process.stdout.write(String.fromCharCode(byte));
179
+ });
180
+
181
+ // Send commands
182
+ emulator.serial0_send("echo hello\n");
183
+ ```
184
+
185
+ ## Communication Methods
186
+
187
+ ### 1. Serial Console (stdin/stdout)
188
+
189
+ ```javascript
190
+ // Send command
191
+ emulator.serial0_send("ls -la\n");
192
+
193
+ // Receive output
194
+ let output = "";
195
+ emulator.add_listener("serial0-output-byte", (byte) => {
196
+ output += String.fromCharCode(byte);
197
+ });
198
+ ```
199
+
200
+ ### 2. 9p Filesystem (file I/O)
201
+
202
+ ```javascript
203
+ // Write file to VM
204
+ const data = new TextEncoder().encode("#!/bin/sh\necho hello\n");
205
+ await emulator.create_file("/tmp/script.sh", data);
206
+
207
+ // Read file from VM
208
+ const result = await emulator.read_file("/tmp/output.txt");
209
+ console.log(new TextDecoder().decode(result));
210
+ ```
211
+
212
+ ### 3. Network (TCP to host services)
213
+
214
+ From inside the VM, connect to `10.0.2.2:PORT` to reach `localhost:PORT` on the host (requires patched relay).
215
+
216
+ ```bash
217
+ # Inside VM
218
+ wget http://10.0.2.2:8080/ # Connects to host's localhost:8080
219
+ ```
220
+
221
+ ## State Save/Restore
222
+
223
+ ```javascript
224
+ // Save state (includes all installed packages, files, etc.)
225
+ const state = await emulator.save_state();
226
+ fs.writeFileSync("vm-state.bin", Buffer.from(state));
227
+
228
+ // Restore state (instant resume, ~2 seconds)
229
+ const stateBuffer = fs.readFileSync("vm-state.bin");
230
+ await emulator.restore_state(stateBuffer.buffer);
231
+ ```
232
+
233
+ ## Network Setup Inside VM
234
+
235
+ After boot, run these commands to enable networking:
236
+
237
+ ```bash
238
+ modprobe virtio-net
239
+ ip link set eth0 up
240
+ udhcpc -i eth0
241
+ ```
242
+
243
+ Or as a one-liner:
244
+ ```bash
245
+ modprobe virtio-net && ip link set eth0 up && udhcpc -i eth0
246
+ ```
247
+
248
+ The VM will get IP `10.0.2.15` (or similar) via DHCP from the relay.
249
+
250
+ ## Performance
251
+
252
+ | Metric | Value |
253
+ |--------|-------|
254
+ | Cold boot | ~20-25 seconds |
255
+ | State restore | ~2-3 seconds |
256
+ | Memory usage | ~512 MB (configurable) |
257
+
258
+ ## Typical Workflow for Mom
259
+
260
+ 1. **First run**:
261
+ - Start rootlessRelay
262
+ - Boot v86 with Alpine (~25s)
263
+ - Setup network
264
+ - Install needed packages (`apk add nodejs npm python3 git`)
265
+ - Save state
266
+
267
+ 2. **Subsequent runs**:
268
+ - Start rootlessRelay
269
+ - Restore saved state (~2s)
270
+ - Ready to execute commands
271
+
272
+ 3. **Command execution**:
273
+ - Send commands via `serial0_send()`
274
+ - Capture output via `serial0-output-byte` listener
275
+ - Exchange files via 9p filesystem
276
+
277
+ ## Alternative: fetch Backend (No Relay Needed)
278
+
279
+ For HTTP-only networking, v86 has a built-in `fetch` backend:
280
+
281
+ ```javascript
282
+ net_device: {
283
+ type: "virtio",
284
+ relay_url: "fetch",
285
+ }
286
+ ```
287
+
288
+ This uses the browser/Node.js `fetch()` API for HTTP requests. Limitations:
289
+ - Only HTTP/HTTPS (no raw TCP/UDP)
290
+ - No WebSocket
291
+ - Host access via `http://<port>.external` (e.g., `http://8080.external`)
292
+
293
+ ## Files Reference
294
+
295
+ After building, you need these files:
296
+
297
+ ```
298
+ project/
299
+ ├── node_modules/v86/build/
300
+ │ ├── v86.wasm
301
+ │ └── libv86.mjs
302
+ ├── bios/
303
+ │ ├── seabios.bin
304
+ │ └── vgabios.bin
305
+ ├── images/
306
+ │ ├── alpine-fs.json
307
+ │ └── alpine-rootfs-flat/
308
+ │ └── *.bin.zst (many files)
309
+ └── rootlessRelay/
310
+ └── relay.js (patched)
311
+ ```
312
+
313
+ ## Resources
314
+
315
+ - [v86 GitHub](https://github.com/copy/v86)
316
+ - [v86 Networking Docs](https://github.com/copy/v86/blob/master/docs/networking.md)
317
+ - [v86 Alpine Setup](https://github.com/copy/v86/tree/master/tools/docker/alpine)
318
+ - [rootlessRelay](https://github.com/obegron/rootlessRelay)
319
+ - [v86 npm package](https://www.npmjs.com/package/v86)
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@oh-my-pi/pi-mom",
3
+ "version": "0.1.0",
4
+ "description": "Slack bot that delegates messages to the omp coding agent",
5
+ "type": "module",
6
+ "bin": {
7
+ "mom": "src/main.ts"
8
+ },
9
+ "scripts": {
10
+ "prepublishOnly": "echo 'Publishing bun-native package'"
11
+ },
12
+ "dependencies": {
13
+ "@anthropic-ai/sandbox-runtime": "^0.0.16",
14
+ "@oh-my-pi/pi-agent-core": "0.1.0",
15
+ "@oh-my-pi/pi-ai": "0.1.0",
16
+ "@oh-my-pi/pi-coding-agent": "0.1.0",
17
+ "@sinclair/typebox": "^0.34.0",
18
+ "@slack/socket-mode": "^2.0.0",
19
+ "@slack/web-api": "^7.0.0",
20
+ "async-mutex": "^0.5.0",
21
+ "chalk": "^5.6.2",
22
+ "croner": "^9.1.0",
23
+ "diff": "^8.0.2"
24
+ },
25
+ "devDependencies": {
26
+ "@types/diff": "^7.0.2",
27
+ "@types/node": "^24.3.0"
28
+ },
29
+ "keywords": [
30
+ "slack",
31
+ "bot",
32
+ "ai",
33
+ "agent"
34
+ ],
35
+ "author": "Mario Zechner",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
40
+ "directory": "packages/mom"
41
+ },
42
+ "engines": {
43
+ "bun": ">=1.0.0"
44
+ }
45
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Migrate log.jsonl timestamps from milliseconds to Slack format (seconds.microseconds)
4
+ *
5
+ * Usage: npx tsx scripts/migrate-timestamps.ts <data-dir>
6
+ * Example: npx tsx scripts/migrate-timestamps.ts ./data
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ function isMillisecondTimestamp(ts: string): boolean {
13
+ // Slack timestamps are seconds.microseconds, like "1764279530.533489"
14
+ // Millisecond timestamps are just big numbers, like "1764279320398"
15
+ //
16
+ // Key insight:
17
+ // - Slack ts from 2025: ~1.7 billion (10 digits before decimal)
18
+ // - Millisecond ts from 2025: ~1.7 trillion (13 digits)
19
+
20
+ // If it has a decimal and the integer part is < 10^12, it's Slack format
21
+ if (ts.includes(".")) {
22
+ const intPart = parseInt(ts.split(".")[0], 10);
23
+ return intPart > 1e12; // Unlikely to have decimal AND be millis, but check anyway
24
+ }
25
+
26
+ // No decimal - check if it's too big to be seconds
27
+ const num = parseInt(ts, 10);
28
+ return num > 1e12; // If > 1 trillion, it's milliseconds
29
+ }
30
+
31
+ function convertToSlackTs(msTs: string): string {
32
+ const ms = parseInt(msTs, 10);
33
+ const seconds = Math.floor(ms / 1000);
34
+ const micros = (ms % 1000) * 1000;
35
+ return `${seconds}.${micros.toString().padStart(6, "0")}`;
36
+ }
37
+
38
+ function migrateFile(filePath: string): { total: number; migrated: number } {
39
+ const content = readFileSync(filePath, "utf-8");
40
+ const lines = content.split("\n").filter(Boolean);
41
+
42
+ let migrated = 0;
43
+ const newLines: string[] = [];
44
+
45
+ for (const line of lines) {
46
+ try {
47
+ const msg = JSON.parse(line);
48
+ if (msg.ts && isMillisecondTimestamp(msg.ts)) {
49
+ const oldTs = msg.ts;
50
+ msg.ts = convertToSlackTs(msg.ts);
51
+ console.log(` Converted: ${oldTs} -> ${msg.ts}`);
52
+ migrated++;
53
+ }
54
+ newLines.push(JSON.stringify(msg));
55
+ } catch (e) {
56
+ // Keep malformed lines as-is
57
+ console.log(` Warning: Could not parse line: ${line.substring(0, 50)}...`);
58
+ newLines.push(line);
59
+ }
60
+ }
61
+
62
+ if (migrated > 0) {
63
+ writeFileSync(filePath, newLines.join("\n") + "\n", "utf-8");
64
+ }
65
+
66
+ return { total: lines.length, migrated };
67
+ }
68
+
69
+ function findLogFiles(dir: string): string[] {
70
+ const logFiles: string[] = [];
71
+
72
+ if (!existsSync(dir)) {
73
+ console.error(`Directory not found: ${dir}`);
74
+ return [];
75
+ }
76
+
77
+ const entries = readdirSync(dir);
78
+ for (const entry of entries) {
79
+ const fullPath = join(dir, entry);
80
+ const stat = statSync(fullPath);
81
+
82
+ if (stat.isDirectory()) {
83
+ // Check for log.jsonl in subdirectory
84
+ const logPath = join(fullPath, "log.jsonl");
85
+ if (existsSync(logPath)) {
86
+ logFiles.push(logPath);
87
+ }
88
+ }
89
+ }
90
+
91
+ return logFiles;
92
+ }
93
+
94
+ // Main
95
+ const dataDir = process.argv[2];
96
+ if (!dataDir) {
97
+ console.error("Usage: npx tsx scripts/migrate-timestamps.ts <data-dir>");
98
+ console.error("Example: npx tsx scripts/migrate-timestamps.ts ./data");
99
+ process.exit(1);
100
+ }
101
+
102
+ console.log(`Scanning for log.jsonl files in: ${dataDir}\n`);
103
+
104
+ const logFiles = findLogFiles(dataDir);
105
+ if (logFiles.length === 0) {
106
+ console.log("No log.jsonl files found.");
107
+ process.exit(0);
108
+ }
109
+
110
+ let totalMigrated = 0;
111
+ let totalMessages = 0;
112
+
113
+ for (const logFile of logFiles) {
114
+ console.log(`Processing: ${logFile}`);
115
+ const { total, migrated } = migrateFile(logFile);
116
+ totalMessages += total;
117
+ totalMigrated += migrated;
118
+ console.log(` ${migrated}/${total} messages migrated\n`);
119
+ }
120
+
121
+ console.log(`Done! Migrated ${totalMigrated}/${totalMessages} total messages across ${logFiles.length} files.`);