omp-mobile 0.4.1 → 0.4.2
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/package.json +1 -1
- package/src/server.mjs +112 -10
package/package.json
CHANGED
package/src/server.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* to be exposed to the public internet.
|
|
9
9
|
*/
|
|
10
10
|
import { STATUS_CODES, createServer } from "node:http";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
13
|
import { homedir, hostname, platform } from "node:os";
|
|
14
14
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -55,6 +55,17 @@ const HEARTBEAT_MS = 20_000;
|
|
|
55
55
|
/** A live omp nobody has looked at for this long is closed; the next open resumes it. */
|
|
56
56
|
const IDLE_SESSION_MS = 30 * 60_000;
|
|
57
57
|
const REAP_INTERVAL_MS = 60_000;
|
|
58
|
+
/**
|
|
59
|
+
* The fields of a tool result's `details` the app reads: an edit's diff, a
|
|
60
|
+
* read's line count, a command's run time, a screenshot's file. The rest
|
|
61
|
+
* (an edit's old and new text, a read's display copy) repeats what the
|
|
62
|
+
* result already says, and was a fifth of a long session's snapshot.
|
|
63
|
+
*/
|
|
64
|
+
const PHONE_DETAILS = ["diff", "totalLines", "wallTimeMs", "mediaPath"];
|
|
65
|
+
/** Inline images shorter than this (base64 characters) stay inline: a reference would save nothing. */
|
|
66
|
+
const STASH_MIN_CHARS = 4_096;
|
|
67
|
+
/** Stashed images untouched this long are dropped at start; the session file still has them. */
|
|
68
|
+
const BLOB_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
58
69
|
/** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
|
|
59
70
|
const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
|
60
71
|
/** At most this many images per prompt, each at most this many decoded bytes. */
|
|
@@ -284,6 +295,87 @@ export function ensureWorkingDirectory(cwd) {
|
|
|
284
295
|
}
|
|
285
296
|
}
|
|
286
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Write an inline image to the daemon's blob cache under its SHA-256 and
|
|
300
|
+
* answer the `blob:sha256:` reference the app loads from `/blobs`; undefined
|
|
301
|
+
* leaves the image inline (too small, already a reference, not base64, no disk).
|
|
302
|
+
* @param {{data?: unknown}} part
|
|
303
|
+
* @param {string} blobDir
|
|
304
|
+
*/
|
|
305
|
+
function stashImage(part, blobDir) {
|
|
306
|
+
const data = part.data;
|
|
307
|
+
if (typeof data !== "string" || data.length < STASH_MIN_CHARS || data.startsWith("blob:") || data.startsWith("data:")) return undefined;
|
|
308
|
+
const bytes = Buffer.from(data, "base64");
|
|
309
|
+
if (bytes.length === 0) return undefined;
|
|
310
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
311
|
+
const path = join(blobDir, hash);
|
|
312
|
+
try {
|
|
313
|
+
if (!existsSync(path)) {
|
|
314
|
+
mkdirSync(blobDir, { recursive: true, mode: 0o700 });
|
|
315
|
+
writeFileSync(path, bytes, { mode: 0o600 });
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
return `blob:sha256:${hash}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A message as a snapshot carries it to the phone: inline images moved to
|
|
325
|
+
* the blob cache and sent as references (the app loads only the ones it
|
|
326
|
+
* shows), and tool details cut to {@link PHONE_DETAILS}. A long session's
|
|
327
|
+
* snapshot was mostly base64 screenshots and copies of edited text --
|
|
328
|
+
* megabytes the phone downloaded, parsed and cached before it showed anything.
|
|
329
|
+
* Live events still carry messages whole.
|
|
330
|
+
* @param {any} message
|
|
331
|
+
* @param {string | undefined} blobDir no stashing when undefined
|
|
332
|
+
*/
|
|
333
|
+
export function phoneMessage(message, blobDir) {
|
|
334
|
+
if (message === null || typeof message !== "object") return message;
|
|
335
|
+
let next = message;
|
|
336
|
+
if (blobDir !== undefined && Array.isArray(message.content)) {
|
|
337
|
+
let changed = false;
|
|
338
|
+
const content = message.content.map((part) => {
|
|
339
|
+
const ref = part !== null && typeof part === "object" && part.type === "image" ? stashImage(part, blobDir) : undefined;
|
|
340
|
+
if (ref === undefined) return part;
|
|
341
|
+
changed = true;
|
|
342
|
+
return { ...part, data: ref };
|
|
343
|
+
});
|
|
344
|
+
if (changed) next = { ...next, content };
|
|
345
|
+
}
|
|
346
|
+
const tool = message.role === "toolResult" || message.role === "tool";
|
|
347
|
+
if (tool && message.details !== null && typeof message.details === "object" && !Array.isArray(message.details)) {
|
|
348
|
+
const details = {};
|
|
349
|
+
for (const key of PHONE_DETAILS) if (key in message.details) details[key] = message.details[key];
|
|
350
|
+
next = { ...next, details };
|
|
351
|
+
}
|
|
352
|
+
return next;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Drop stashed images older than {@link BLOB_CACHE_MAX_AGE_MS}; the next open of their session stashes them again. */
|
|
356
|
+
export function pruneBlobCache(blobDir, now = Date.now()) {
|
|
357
|
+
let names;
|
|
358
|
+
try {
|
|
359
|
+
names = readdirSync(blobDir);
|
|
360
|
+
} catch {
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
let dropped = 0;
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
if (!BLOB_NAME.test(name)) continue;
|
|
366
|
+
const path = join(blobDir, name);
|
|
367
|
+
try {
|
|
368
|
+
if (now - statSync(path).mtimeMs > BLOB_CACHE_MAX_AGE_MS) {
|
|
369
|
+
unlinkSync(path);
|
|
370
|
+
dropped += 1;
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// Gone already.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return dropped;
|
|
377
|
+
}
|
|
378
|
+
|
|
287
379
|
function storedSummary(stored) {
|
|
288
380
|
return {
|
|
289
381
|
id: stored.id,
|
|
@@ -335,13 +427,14 @@ export function blobType(bytes) {
|
|
|
335
427
|
}
|
|
336
428
|
|
|
337
429
|
/** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
|
|
338
|
-
function sendBlob(res,
|
|
430
|
+
function sendBlob(res, dirs, name) {
|
|
339
431
|
if (!BLOB_NAME.test(name)) {
|
|
340
432
|
sendJson(res, 400, { error: "bad blob name" });
|
|
341
433
|
return;
|
|
342
434
|
}
|
|
343
|
-
|
|
344
|
-
|
|
435
|
+
// OMP's own store first, then the images this daemon stashed from snapshots.
|
|
436
|
+
const path = dirs.map((dir) => join(dir, name)).find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
437
|
+
if (path === undefined) {
|
|
345
438
|
sendJson(res, 404, { error: "no such blob" });
|
|
346
439
|
return;
|
|
347
440
|
}
|
|
@@ -425,12 +518,14 @@ class SessionEntry {
|
|
|
425
518
|
this.closed = false;
|
|
426
519
|
/** When the last phone left; undefined while one is connected. The reaper reads it. */
|
|
427
520
|
this.idleSince = undefined;
|
|
521
|
+
/** Where inline images go (see `phoneMessage`); undefined keeps them inline. */
|
|
522
|
+
this.blobDir = undefined;
|
|
428
523
|
}
|
|
429
524
|
|
|
430
525
|
async hydrate() {
|
|
431
526
|
const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
|
|
432
527
|
this.state = state;
|
|
433
|
-
this.messages = Array.isArray(messages.messages) ? messages.messages : [];
|
|
528
|
+
this.messages = Array.isArray(messages.messages) ? messages.messages.map((message) => phoneMessage(message, this.blobDir)) : [];
|
|
434
529
|
this.id = state.sessionId ?? "";
|
|
435
530
|
this.title = titleOf(this.messages, state, this.cwd);
|
|
436
531
|
this.updatedAt = Date.now();
|
|
@@ -483,7 +578,7 @@ class SessionEntry {
|
|
|
483
578
|
this.rpc.on("event", (event) => {
|
|
484
579
|
this.updatedAt = Date.now();
|
|
485
580
|
if (event.type === "message_end" && event.message) {
|
|
486
|
-
this.messages.push(event.message);
|
|
581
|
+
this.messages.push(phoneMessage(event.message, this.blobDir));
|
|
487
582
|
if (this.messages.length > 5_000) this.messages.splice(0, this.messages.length - 5_000);
|
|
488
583
|
// The first prompt names a session that was created untitled.
|
|
489
584
|
const title = titleOf(this.messages, this.state, this.cwd);
|
|
@@ -570,8 +665,12 @@ class Daemon {
|
|
|
570
665
|
this.starting = new Map();
|
|
571
666
|
/** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
|
|
572
667
|
this.takingOver = new Map();
|
|
668
|
+
/** Images stashed out of snapshots, served by `/blobs` next to OMP's own store. */
|
|
669
|
+
this.blobDir = options.blobDir ?? join(dirname(options.registryFile ?? DEFAULT_REGISTRY_FILE), "blobs");
|
|
573
670
|
this.server = createServer((req, res) => void this.admitHttp(req, res));
|
|
574
|
-
|
|
671
|
+
// Compressed frames: a snapshot is JSON text, several times smaller deflated, and
|
|
672
|
+
// the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
|
|
673
|
+
this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
|
|
575
674
|
this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
|
|
576
675
|
}
|
|
577
676
|
|
|
@@ -643,7 +742,7 @@ class Daemon {
|
|
|
643
742
|
this.noteClient(req);
|
|
644
743
|
const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
|
|
645
744
|
if (blob !== null && req.method === "GET") {
|
|
646
|
-
sendBlob(res, this.options.ompHome, blob[1]);
|
|
745
|
+
sendBlob(res, [join(this.options.ompHome, "agent", "blobs"), this.blobDir], blob[1]);
|
|
647
746
|
return;
|
|
648
747
|
}
|
|
649
748
|
if (url.pathname === "/status" && req.method === "GET") {
|
|
@@ -853,6 +952,7 @@ class Daemon {
|
|
|
853
952
|
try {
|
|
854
953
|
await rpc.start();
|
|
855
954
|
const entry = new SessionEntry(stored.cwd, rpc);
|
|
955
|
+
entry.blobDir = this.blobDir;
|
|
856
956
|
await entry.hydrate();
|
|
857
957
|
entry.id = id;
|
|
858
958
|
entry.onChange = (changed) => this.remember(changed);
|
|
@@ -929,6 +1029,7 @@ class Daemon {
|
|
|
929
1029
|
try {
|
|
930
1030
|
await rpc.start();
|
|
931
1031
|
const entry = new SessionEntry(cwd, rpc);
|
|
1032
|
+
entry.blobDir = this.blobDir;
|
|
932
1033
|
await entry.hydrate();
|
|
933
1034
|
if (entry.id === "") throw new Error("omp returned no session id");
|
|
934
1035
|
entry.onChange = (changed) => this.remember(changed);
|
|
@@ -1118,8 +1219,9 @@ class Daemon {
|
|
|
1118
1219
|
});
|
|
1119
1220
|
}
|
|
1120
1221
|
|
|
1121
|
-
/** Keepalive pings and the idle-session reaper, once the daemon listens. */
|
|
1222
|
+
/** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
|
|
1122
1223
|
startTimers() {
|
|
1224
|
+
pruneBlobCache(this.blobDir);
|
|
1123
1225
|
this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
|
|
1124
1226
|
this.heartbeat.unref?.();
|
|
1125
1227
|
this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
|