omp-mobile 0.1.1 → 0.2.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/README.md +2 -0
- package/package.json +1 -1
- package/src/rpc-session.mjs +65 -0
- package/src/server.mjs +88 -0
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my-pi) (OMP), with a small daemon on each computer. Start, follow and steer OMP sessions from the phone: transcript, tool calls, approvals, and the sessions you started in a terminal.
|
|
15
15
|
|
|
16
16
|
- **Native app:** The app draws the transcript, tool cards, thinking, Markdown and composer itself. No WebView, no web page.
|
|
17
|
+
- **The whole transcript:** images the user sent and images tools produce (screenshots, opened files), edit diffs with their intent, tool results, Markdown tables, OMP's todo list, context usage and queued messages.
|
|
18
|
+
- **Model switching:** tap the model in the composer to pick from OMP's catalog — grouped by provider and searchable — and set its thinking level. The model chip follows the session when it changes.
|
|
17
19
|
- **Approvals on the phone:** OMP's own tool approvals and questions arrive as a sheet, and the turn waits for your answer.
|
|
18
20
|
- **Terminal sessions:** Sessions you started in a terminal on the computer are listed too and continue on the phone. One still open in a terminal waits until that terminal exits.
|
|
19
21
|
- **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
|
package/package.json
CHANGED
package/src/rpc-session.mjs
CHANGED
|
@@ -35,6 +35,36 @@ const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_te
|
|
|
35
35
|
* @property {boolean} [autoApprove]
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* One entry of OMP's model catalog, cut to what a phone lists: provider, name,
|
|
40
|
+
* context window and input kinds. The full object carries endpoints, cost and
|
|
41
|
+
* compatibility fields the app never shows.
|
|
42
|
+
* @param {Record<string, unknown>} model
|
|
43
|
+
*/
|
|
44
|
+
export function compactModel(model) {
|
|
45
|
+
const thinking = model.thinking;
|
|
46
|
+
const cost = model.cost;
|
|
47
|
+
return {
|
|
48
|
+
provider: typeof model.provider === "string" ? model.provider : "",
|
|
49
|
+
id: typeof model.id === "string" ? model.id : "",
|
|
50
|
+
name: typeof model.name === "string" ? model.name : typeof model.id === "string" ? model.id : "",
|
|
51
|
+
reasoning: model.reasoning === true,
|
|
52
|
+
input: Array.isArray(model.input) ? model.input.filter((kind) => typeof kind === "string") : [],
|
|
53
|
+
contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : undefined,
|
|
54
|
+
cost:
|
|
55
|
+
cost !== null && typeof cost === "object"
|
|
56
|
+
? {
|
|
57
|
+
input: typeof cost.input === "number" ? cost.input : undefined,
|
|
58
|
+
output: typeof cost.output === "number" ? cost.output : undefined,
|
|
59
|
+
}
|
|
60
|
+
: undefined,
|
|
61
|
+
efforts:
|
|
62
|
+
thinking !== null && typeof thinking === "object" && Array.isArray(thinking.efforts)
|
|
63
|
+
? thinking.efforts.filter((level) => typeof level === "string")
|
|
64
|
+
: undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
38
68
|
export class RpcSession extends EventEmitter {
|
|
39
69
|
/** @param {RpcSessionOptions} options */
|
|
40
70
|
constructor(options) {
|
|
@@ -228,6 +258,41 @@ export class RpcSession extends EventEmitter {
|
|
|
228
258
|
return response.data ?? { messages: [] };
|
|
229
259
|
}
|
|
230
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The models this session can switch to, compacted for a phone list.
|
|
263
|
+
* OMP waits out background discovery first, so this can take a few seconds.
|
|
264
|
+
* @returns {Promise<ReturnType<typeof compactModel>[]>}
|
|
265
|
+
*/
|
|
266
|
+
async availableModels() {
|
|
267
|
+
const response = await this.request("get_available_models");
|
|
268
|
+
const models = response.data?.models;
|
|
269
|
+
return Array.isArray(models) ? models.map(compactModel) : [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Switch this session to another model. Returns the state afterwards: the
|
|
274
|
+
* model's thinking efforts decide which levels the phone may offer.
|
|
275
|
+
* @param {string} provider
|
|
276
|
+
* @param {string} modelId
|
|
277
|
+
*/
|
|
278
|
+
async setModel(provider, modelId) {
|
|
279
|
+
await this.request("set_model", { provider, modelId });
|
|
280
|
+
return this.getState();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** @returns {Promise<{levels: string[]}>} */
|
|
284
|
+
async availableThinkingLevels() {
|
|
285
|
+
const response = await this.request("get_available_thinking_levels");
|
|
286
|
+
const levels = response.data?.levels;
|
|
287
|
+
return { levels: Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [] };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @param {string} level */
|
|
291
|
+
async setThinkingLevel(level) {
|
|
292
|
+
await this.request("set_thinking_level", { level });
|
|
293
|
+
return this.getState();
|
|
294
|
+
}
|
|
295
|
+
|
|
231
296
|
/** @param {string} text */
|
|
232
297
|
async prompt(text) {
|
|
233
298
|
return this.request("prompt", { message: text });
|
package/src/server.mjs
CHANGED
|
@@ -30,6 +30,20 @@ const DEFAULT_OMP_HOME = join(homedir(), ".omp");
|
|
|
30
30
|
const MAX_BODY = 256 * 1024;
|
|
31
31
|
/** Terminal sessions listed next to the daemon's own, newest first. */
|
|
32
32
|
const OMP_SESSION_LIMIT = 50;
|
|
33
|
+
/**
|
|
34
|
+
* Session events after which `get_state` differs from what the phone holds:
|
|
35
|
+
* the model or thinking level changed, or the todo/queue/goal panels moved.
|
|
36
|
+
*/
|
|
37
|
+
const STATE_EVENTS = new Set([
|
|
38
|
+
"model_changed",
|
|
39
|
+
"thinking_level_changed",
|
|
40
|
+
"queue_update",
|
|
41
|
+
"todo_reminder",
|
|
42
|
+
"todo_auto_clear",
|
|
43
|
+
"goal_updated",
|
|
44
|
+
]);
|
|
45
|
+
/** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
|
|
46
|
+
const BLOB_NAME = /^[0-9a-f]{64}$/;
|
|
33
47
|
|
|
34
48
|
/** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
|
|
35
49
|
|
|
@@ -225,6 +239,39 @@ function sendJson(res, status, body) {
|
|
|
225
239
|
res.end(data);
|
|
226
240
|
}
|
|
227
241
|
|
|
242
|
+
/** What a stored blob is, from its first bytes: OMP keeps the extension off the hash name. */
|
|
243
|
+
export function blobType(bytes) {
|
|
244
|
+
if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
|
|
245
|
+
return "image/webp";
|
|
246
|
+
}
|
|
247
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
248
|
+
return "image/png";
|
|
249
|
+
}
|
|
250
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
251
|
+
if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
|
|
252
|
+
return "application/octet-stream";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
|
|
256
|
+
function sendBlob(res, ompHome, name) {
|
|
257
|
+
if (!BLOB_NAME.test(name)) {
|
|
258
|
+
sendJson(res, 400, { error: "bad blob name" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const path = join(ompHome, "agent", "blobs", name);
|
|
262
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
263
|
+
sendJson(res, 404, { error: "no such blob" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const bytes = readFileSync(path);
|
|
267
|
+
res.writeHead(200, {
|
|
268
|
+
"content-type": blobType(bytes),
|
|
269
|
+
"content-length": bytes.length,
|
|
270
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
271
|
+
});
|
|
272
|
+
res.end(bytes);
|
|
273
|
+
}
|
|
274
|
+
|
|
228
275
|
function readBody(req) {
|
|
229
276
|
return new Promise((resolveBody, reject) => {
|
|
230
277
|
let size = 0;
|
|
@@ -359,6 +406,9 @@ class SessionEntry {
|
|
|
359
406
|
this.onChange?.(this);
|
|
360
407
|
}
|
|
361
408
|
}
|
|
409
|
+
if (STATE_EVENTS.has(event.type)) void this.refreshState();
|
|
410
|
+
// A `todo` tool call rewrites the list before any reminder fires.
|
|
411
|
+
if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
|
|
362
412
|
if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
|
|
363
413
|
this.broadcast({ type: "event", event });
|
|
364
414
|
});
|
|
@@ -488,6 +538,11 @@ class Daemon {
|
|
|
488
538
|
return;
|
|
489
539
|
}
|
|
490
540
|
this.noteClient(req);
|
|
541
|
+
const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
|
|
542
|
+
if (blob !== null && req.method === "GET") {
|
|
543
|
+
sendBlob(res, this.options.ompHome, blob[1]);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
491
546
|
if (url.pathname === "/status" && req.method === "GET") {
|
|
492
547
|
sendJson(res, 200, {
|
|
493
548
|
pid: process.pid,
|
|
@@ -730,6 +785,39 @@ class Daemon {
|
|
|
730
785
|
case "refresh":
|
|
731
786
|
await entry.refreshState();
|
|
732
787
|
return;
|
|
788
|
+
case "models": {
|
|
789
|
+
const models = await entry.rpc.availableModels();
|
|
790
|
+
ws.send(JSON.stringify({ type: "models", models }));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
case "set_model": {
|
|
794
|
+
if (typeof message.provider !== "string" || typeof message.modelId !== "string") {
|
|
795
|
+
throw new Error("set_model needs provider and modelId");
|
|
796
|
+
}
|
|
797
|
+
const state = await entry.rpc.setModel(message.provider, message.modelId);
|
|
798
|
+
entry.state = state;
|
|
799
|
+
entry.updatedAt = Date.now();
|
|
800
|
+
entry.onChange?.(entry);
|
|
801
|
+
entry.broadcast({ type: "state", state });
|
|
802
|
+
ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
case "thinking_levels": {
|
|
806
|
+
const { levels } = await entry.rpc.availableThinkingLevels();
|
|
807
|
+
ws.send(
|
|
808
|
+
JSON.stringify({ type: "thinking_levels", levels, current: entry.state?.thinkingLevel ?? null }),
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
case "set_thinking_level": {
|
|
813
|
+
if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
|
|
814
|
+
const state = await entry.rpc.setThinkingLevel(message.level);
|
|
815
|
+
entry.state = state;
|
|
816
|
+
entry.updatedAt = Date.now();
|
|
817
|
+
entry.broadcast({ type: "state", state });
|
|
818
|
+
ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
733
821
|
default:
|
|
734
822
|
throw new Error(`unknown command ${String(message.type)}`);
|
|
735
823
|
}
|