omp-mobile 0.4.2 → 0.4.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
5
5
  "keywords": [
6
6
  "mobile",
@@ -21,7 +21,7 @@ import { homedir } from "node:os";
21
21
  import { delimiter, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { renderQr } from "./qr.mjs";
24
- import { requestPath } from "./takeover.mjs";
24
+ import { requestPath, shellWord } from "./takeover.mjs";
25
25
 
26
26
  const HOME = join(homedir(), ".omp-mobile");
27
27
  const STATE_FILE = join(HOME, "daemon.json");
@@ -229,7 +229,7 @@ export default function ompMobile(pi) {
229
229
  rmSync(requestPath(file), { force: true });
230
230
  // OMP closes once the session is idle; the daemon signals it if that does not come soon,
231
231
  // and tells the terminal where the session went once omp is gone.
232
- ctx.ui.notify(`OMP Mobile: this session continues on ${request.device}. Closing omp here…`, "warning");
232
+ ctx.ui.notify(`OMP Mobile: this session continues on ${request.device}. Closing omp here; resume it later with: omp --resume ${shellWord(file)}`, "warning");
233
233
  if (!ctx.isIdle()) {
234
234
  ctx.abort();
235
235
  const deadline = Date.now() + TAKEOVER_IDLE_WAIT_MS;
@@ -16,7 +16,7 @@ const TITLE_SCAN_BYTES = 512 * 1024;
16
16
  const CHUNK_BYTES = 64 * 1024;
17
17
  const LSOF_TIMEOUT_MS = 5_000;
18
18
 
19
- /** @typedef {{id: string, title: string, cwd: string, createdAt: number, updatedAt: number, sessionFile: string}} OmpSession */
19
+ /** @typedef {{id: string, title: string, cwd: string, createdAt: number, updatedAt: number, size: number, sessionFile: string}} OmpSession */
20
20
 
21
21
  /** Text of an OMP message content field (string or content-part array). */
22
22
  function textOfContent(content) {
@@ -103,6 +103,8 @@ export async function readSessionFile(file) {
103
103
  cwd: header.cwd,
104
104
  createdAt: Number.isFinite(created) ? created : info.birthtimeMs,
105
105
  updatedAt: info.mtimeMs,
106
+ // With the time, what tells this daemon's own omp leaving the file from someone writing it.
107
+ size: info.size,
106
108
  sessionFile: file,
107
109
  };
108
110
  } finally {
package/src/server.mjs CHANGED
@@ -20,6 +20,7 @@ import { Admission, Tailscale, isLoopbackHost } from "./admission.mjs";
20
20
  import { AttentionTracker, serveEvents } from "./attention.mjs";
21
21
  import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
22
22
  import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
23
+ import { TranscriptStore, deltaBody, deltaQuery } from "./transcripts.mjs";
23
24
 
24
25
  const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
25
26
  const PROTOCOL = 1;
@@ -55,6 +56,8 @@ const HEARTBEAT_MS = 20_000;
55
56
  /** A live omp nobody has looked at for this long is closed; the next open resumes it. */
56
57
  const IDLE_SESSION_MS = 30 * 60_000;
57
58
  const REAP_INTERVAL_MS = 60_000;
59
+ /** Messages a live session keeps in memory; older ones leave its front (its `base` counts them). */
60
+ const MAX_LIVE_MESSAGES = 5_000;
58
61
  /**
59
62
  * The fields of a tool result's `details` the app reads: an edit's diff, a
60
63
  * read's line count, a command's run time, a screenshot's file. The rest
@@ -100,9 +103,11 @@ export function promptImages(value) {
100
103
  * the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
101
104
  * `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
102
105
  * `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
103
- * treat a silent link as gone.
106
+ * treat a silent link as gone; `transcript-sync`: a session socket's `since`/`last`
107
+ * ask for a delta snapshot (`from`, `total`), and `GET /sessions/:id/transcript`
108
+ * answers the same without starting an omp.
104
109
  */
105
- const FEATURES = ["prompt-images", "takeover", "heartbeat"];
110
+ const FEATURES = ["prompt-images", "takeover", "heartbeat", "transcript-sync"];
106
111
  /** Who a phone continues a session on, as the terminal it leaves is told. */
107
112
  const DEFAULT_DEVICE = "OMP Mobile";
108
113
  /** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
@@ -376,6 +381,46 @@ export function pruneBlobCache(blobDir, now = Date.now()) {
376
381
  return dropped;
377
382
  }
378
383
 
384
+ /**
385
+ * When the messages were last written: the newest timestamp OMP stamped on
386
+ * them, or undefined when none carries one.
387
+ * @param {readonly any[]} messages
388
+ */
389
+ export function lastActivity(messages) {
390
+ let latest;
391
+ for (let i = messages.length - 1; i >= 0 && i >= messages.length - 50; i -= 1) {
392
+ const at = Number(messages[i]?.timestamp);
393
+ if (Number.isFinite(at) && at > 0 && (latest === undefined || at > latest)) latest = at;
394
+ }
395
+ return latest;
396
+ }
397
+
398
+ /**
399
+ * When a session the daemon knows but has no omp for was last used: what the
400
+ * registry says, or its file's time when something wrote the file since --
401
+ * unless the file is exactly as this daemon's own omp left it on exit (omp
402
+ * rewrites the whole file as it closes; that is not anyone using it).
403
+ * @param {{updatedAt?: number, closedFile?: {mtimeMs: number, size: number}} | undefined} stored
404
+ * @param {{updatedAt: number, size?: number} | undefined} scanned
405
+ */
406
+ export function dormantActivity(stored, scanned) {
407
+ const known = Number(stored?.updatedAt) || 0;
408
+ if (scanned === undefined || !(scanned.updatedAt > known)) return known;
409
+ const closed = stored?.closedFile;
410
+ if (closed !== undefined && closed !== null && closed.mtimeMs === scanned.updatedAt && closed.size === scanned.size) return known;
411
+ return scanned.updatedAt;
412
+ }
413
+
414
+ /** A file's time and size, as `TranscriptStore` checks them; undefined when it cannot be read. */
415
+ function fileState(path) {
416
+ try {
417
+ const info = statSync(path);
418
+ return info.isFile() ? { mtimeMs: info.mtimeMs, size: info.size } : undefined;
419
+ } catch {
420
+ return undefined;
421
+ }
422
+ }
423
+
379
424
  function storedSummary(stored) {
380
425
  return {
381
426
  id: stored.id,
@@ -503,6 +548,10 @@ class SessionEntry {
503
548
  this.id = "";
504
549
  this.state = null;
505
550
  this.messages = [];
551
+ /** How many messages left the front of `messages` (the cap): `messages[0]` is the transcript's message `base`. */
552
+ this.base = 0;
553
+ /** The session file as this omp left it on exit, so its rewrite is not taken for activity. */
554
+ this.closedFile = undefined;
506
555
  this.createdAt = Date.now();
507
556
  this.updatedAt = this.createdAt;
508
557
  this.title = cwd.split("/").filter(Boolean).at(-1) ?? "session";
@@ -520,15 +569,26 @@ class SessionEntry {
520
569
  this.idleSince = undefined;
521
570
  /** Where inline images go (see `phoneMessage`); undefined keeps them inline. */
522
571
  this.blobDir = undefined;
572
+ /**
573
+ * Hears the omp process exit, once its session file is final (the daemon saves the transcript).
574
+ * @type {((entry: SessionEntry) => void) | undefined}
575
+ */
576
+ this.onClosed = undefined;
523
577
  }
524
578
 
525
- async hydrate() {
579
+ /**
580
+ * @param {number} [knownActivity] when the session was last used, as the daemon knew it before this omp
581
+ */
582
+ async hydrate(knownActivity) {
526
583
  const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
527
584
  this.state = state;
528
585
  this.messages = Array.isArray(messages.messages) ? messages.messages.map((message) => phoneMessage(message, this.blobDir)) : [];
586
+ this.base = 0;
529
587
  this.id = state.sessionId ?? "";
530
588
  this.title = titleOf(this.messages, state, this.cwd);
531
- this.updatedAt = Date.now();
589
+ // Opening a session is not using it: it is as recent as its last message (a new one: now).
590
+ const known = Math.max(lastActivity(this.messages) ?? 0, Number(knownActivity) || 0);
591
+ this.updatedAt = known > 0 ? known : Date.now();
532
592
  return this;
533
593
  }
534
594
 
@@ -548,12 +608,13 @@ class SessionEntry {
548
608
  };
549
609
  }
550
610
 
551
- snapshot() {
552
- return {
553
- session: this.summary(),
554
- state: this.state,
555
- messages: this.messages,
556
- };
611
+ /**
612
+ * What a phone is sent on open: everything, or only the messages after the
613
+ * ones it holds when its copy lines up (see `deltaBody`).
614
+ * @param {{since: number, last: string}} [held]
615
+ */
616
+ snapshot(held) {
617
+ return deltaBody({ session: this.summary(), state: this.state, messages: this.messages, base: this.base }, held);
557
618
  }
558
619
 
559
620
  broadcast(payload) {
@@ -567,7 +628,6 @@ class SessionEntry {
567
628
  async refreshState() {
568
629
  try {
569
630
  this.state = await this.rpc.getState();
570
- this.updatedAt = Date.now();
571
631
  this.broadcast({ type: "state", state: this.state });
572
632
  } catch {
573
633
  // The process may be exiting; the close event tells clients.
@@ -576,10 +636,17 @@ class SessionEntry {
576
636
 
577
637
  bind() {
578
638
  this.rpc.on("event", (event) => {
579
- this.updatedAt = Date.now();
580
639
  if (event.type === "message_end" && event.message) {
640
+ // A new message is what makes a session the most recent one; opening it,
641
+ // a lifecycle event from a resumed omp, or a state echo are not.
642
+ this.updatedAt = Date.now();
581
643
  this.messages.push(phoneMessage(event.message, this.blobDir));
582
- if (this.messages.length > 5_000) this.messages.splice(0, this.messages.length - 5_000);
644
+ if (this.messages.length > MAX_LIVE_MESSAGES) {
645
+ // Indices stay the transcript's: a phone's `since` still lines up after the front is dropped.
646
+ const dropped = this.messages.length - MAX_LIVE_MESSAGES;
647
+ this.messages.splice(0, dropped);
648
+ this.base += dropped;
649
+ }
583
650
  // The first prompt names a session that was created untitled.
584
651
  const title = titleOf(this.messages, this.state, this.cwd);
585
652
  if (title !== this.title) {
@@ -606,6 +673,11 @@ class SessionEntry {
606
673
  this.rpc.on("closed", ({ reason, stderr }) => {
607
674
  this.closed = true;
608
675
  this.broadcast({ type: "closed", reason, stderr: stderr.slice(-2_000) });
676
+ try {
677
+ this.onClosed?.(this);
678
+ } catch {
679
+ // Saving the transcript is a shortcut; the session file has everything.
680
+ }
609
681
  });
610
682
  this.rpc.on("protocol-error", (payload) => this.broadcast({ type: "protocol-error", ...payload }));
611
683
  }
@@ -667,6 +739,8 @@ class Daemon {
667
739
  this.takingOver = new Map();
668
740
  /** Images stashed out of snapshots, served by `/blobs` next to OMP's own store. */
669
741
  this.blobDir = options.blobDir ?? join(dirname(options.registryFile ?? DEFAULT_REGISTRY_FILE), "blobs");
742
+ /** Transcripts of sessions whose omp exited, served by `/transcript` without starting one. */
743
+ this.transcripts = new TranscriptStore(options.transcriptDir ?? join(dirname(options.registryFile ?? DEFAULT_REGISTRY_FILE), "transcripts"));
670
744
  this.server = createServer((req, res) => void this.admitHttp(req, res));
671
745
  // Compressed frames: a snapshot is JSON text, several times smaller deflated, and
672
746
  // the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
@@ -789,6 +863,11 @@ class Daemon {
789
863
  sendJson(res, 200, await this.takeOver(decodeURIComponent(takeover[1]), device));
790
864
  return;
791
865
  }
866
+ const transcript = /^\/sessions\/([^/]+)\/transcript$/.exec(url.pathname);
867
+ if (transcript !== null && req.method === "GET") {
868
+ sendJson(res, 200, this.transcript(decodeURIComponent(transcript[1]), deltaQuery(url.searchParams)));
869
+ return;
870
+ }
792
871
  const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
793
872
  if (match !== null) {
794
873
  const id = decodeURIComponent(match[1]);
@@ -799,8 +878,13 @@ class Daemon {
799
878
  }
800
879
  if (req.method === "DELETE") {
801
880
  const live = this.sessions.get(id);
802
- if (live !== undefined) this.attention.forget(live);
881
+ if (live !== undefined) {
882
+ this.attention.forget(live);
883
+ // Closed for good: its exit must not save it, or bring it back into the registry.
884
+ live.onClosed = undefined;
885
+ }
803
886
  live?.close();
887
+ this.transcripts.remove(id);
804
888
  this.forgetOwners();
805
889
  this.sessions.delete(id);
806
890
  this.registry.delete(id);
@@ -835,6 +919,10 @@ class Daemon {
835
919
  const others = found
836
920
  .filter((session) => !known.has(session.id))
837
921
  .map((session) => ({ ...session, status: "idle", origin: "omp" }));
922
+ // A dormant session is as recent as its file when a terminal omp wrote it since, not when our own omp's exit did.
923
+ for (const session of dormant) {
924
+ session.updatedAt = dormantActivity(this.registry.get(session.id), this.ompSessions.get(session.id));
925
+ }
838
926
  const marked = await Promise.all(
839
927
  [...dormant, ...others].map(async (session) => {
840
928
  const pid = owners?.get(session.id);
@@ -891,6 +979,31 @@ class Daemon {
891
979
  this.ownerLookup = { at: 0, owners: undefined };
892
980
  }
893
981
 
982
+ /**
983
+ * A session's transcript without starting an omp: a live session's from
984
+ * memory, a closed one's from its saved copy while its session file is
985
+ * unchanged; otherwise a 404 the phone skips.
986
+ * @param {string} id
987
+ * @param {{since: number, last: string}} held
988
+ */
989
+ transcript(id, held) {
990
+ const live = this.sessions.get(id);
991
+ if (live !== undefined && !live.closed) return live.snapshot(held);
992
+ const known = this.registry.get(id)?.sessionFile ?? this.ompSessions.get(id)?.sessionFile;
993
+ const saved = this.transcripts.read(id, typeof known === "string" ? known : undefined);
994
+ if (saved === undefined) throw httpError(404, "not cached");
995
+ return deltaBody(saved, held);
996
+ }
997
+
998
+ /** The omp of a session exited: save its transcript and when it was last active. */
999
+ sessionClosed(entry) {
1000
+ if (entry.id === "") return;
1001
+ const sessionFile = entry.state?.sessionFile;
1002
+ entry.closedFile = typeof sessionFile === "string" ? fileState(sessionFile) : undefined;
1003
+ this.transcripts.write(entry.id, { sessionFile, messages: entry.messages, base: entry.base, state: entry.state, session: entry.summary() });
1004
+ if (this.registry.has(entry.id)) this.remember(entry);
1005
+ }
1006
+
894
1007
  remember(entry) {
895
1008
  this.registry.set(entry.id, {
896
1009
  id: entry.id,
@@ -900,6 +1013,8 @@ class Daemon {
900
1013
  createdAt: entry.createdAt,
901
1014
  updatedAt: entry.updatedAt,
902
1015
  sessionFile: entry.state?.sessionFile,
1016
+ // Set once its omp exited: the file as that omp left it (see `dormantActivity`).
1017
+ ...(entry.closedFile !== undefined ? { closedFile: entry.closedFile } : {}),
903
1018
  });
904
1019
  saveRegistry(this.options.registryFile, this.registry);
905
1020
  }
@@ -953,10 +1068,13 @@ class Daemon {
953
1068
  await rpc.start();
954
1069
  const entry = new SessionEntry(stored.cwd, rpc);
955
1070
  entry.blobDir = this.blobDir;
956
- await entry.hydrate();
1071
+ await entry.hydrate(this.registry.has(id) ? dormantActivity(this.registry.get(id), this.ompSessions.get(id)) : stored.updatedAt);
957
1072
  entry.id = id;
1073
+ // Idle from now for the reaper, until a phone attaches: its last activity may be long ago.
1074
+ entry.idleSince = Date.now();
958
1075
  entry.onChange = (changed) => this.remember(changed);
959
1076
  entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
1077
+ entry.onClosed = (closed) => this.sessionClosed(closed);
960
1078
  entry.bind();
961
1079
  this.sessions.set(id, entry);
962
1080
  this.remember(entry);
@@ -1007,7 +1125,7 @@ class Daemon {
1007
1125
  const deadline = Date.now() + 3_000;
1008
1126
  while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
1009
1127
  // Whoever looks at the terminal sees why omp closed.
1010
- if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
1128
+ if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell({ device, sessionFile: stored.sessionFile }));
1011
1129
  const forks = forksOf(dirname(stored.sessionFile), id, since);
1012
1130
  if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
1013
1131
  outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
@@ -1032,8 +1150,10 @@ class Daemon {
1032
1150
  entry.blobDir = this.blobDir;
1033
1151
  await entry.hydrate();
1034
1152
  if (entry.id === "") throw new Error("omp returned no session id");
1153
+ entry.idleSince = Date.now();
1035
1154
  entry.onChange = (changed) => this.remember(changed);
1036
1155
  entry.onBroadcast = (source, payload) => this.attention.observe(source, payload);
1156
+ entry.onClosed = (closed) => this.sessionClosed(closed);
1037
1157
  entry.bind();
1038
1158
  this.sessions.set(entry.id, entry);
1039
1159
  this.remember(entry);
@@ -1059,6 +1179,8 @@ class Daemon {
1059
1179
  }
1060
1180
  this.noteClient(req);
1061
1181
  const id = decodeURIComponent(match[1]);
1182
+ // What the phone already holds, read now: the snapshot is cut against it once the session is open.
1183
+ const held = deltaQuery(url.searchParams);
1062
1184
  // Finish the handshake before the session is open: a slow omp can take
1063
1185
  // tens of seconds, and a phone's WebSocket client gives up after ~10 s --
1064
1186
  // then retries, starting another omp every time. The snapshot (or
@@ -1091,7 +1213,7 @@ class Daemon {
1091
1213
  if (ws.readyState !== 1) return;
1092
1214
  live.clients.add(ws);
1093
1215
  live.idleSince = undefined;
1094
- ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot() }));
1216
+ ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot(held) }));
1095
1217
  // A dialog opened before this client connected (it may have come from a notification tap).
1096
1218
  for (const request of live.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
1097
1219
  for (const data of early) void this.handleClientMessage(live, ws, data);
@@ -1131,6 +1253,8 @@ class Daemon {
1131
1253
  const text = typeof message.text === "string" ? message.text : "";
1132
1254
  if (text.trim() === "" && images.length === 0) throw new Error("empty prompt");
1133
1255
  await entry.rpc.prompt(text, images);
1256
+ entry.updatedAt = Date.now();
1257
+ entry.onChange?.(entry);
1134
1258
  ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
1135
1259
  return;
1136
1260
  }
@@ -1162,7 +1286,6 @@ class Daemon {
1162
1286
  }
1163
1287
  const state = await entry.rpc.setModel(message.provider, message.modelId);
1164
1288
  entry.state = state;
1165
- entry.updatedAt = Date.now();
1166
1289
  entry.onChange?.(entry);
1167
1290
  entry.broadcast({ type: "state", state });
1168
1291
  ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
@@ -1179,7 +1302,6 @@ class Daemon {
1179
1302
  if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
1180
1303
  const state = await entry.rpc.setThinkingLevel(message.level);
1181
1304
  entry.state = state;
1182
- entry.updatedAt = Date.now();
1183
1305
  entry.broadcast({ type: "state", state });
1184
1306
  ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
1185
1307
  return;
@@ -1222,6 +1344,7 @@ class Daemon {
1222
1344
  /** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
1223
1345
  startTimers() {
1224
1346
  pruneBlobCache(this.blobDir);
1347
+ this.transcripts.prune();
1225
1348
  this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
1226
1349
  this.heartbeat.unref?.();
1227
1350
  this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
@@ -1395,4 +1518,4 @@ if (isMain) {
1395
1518
  });
1396
1519
  }
1397
1520
 
1398
- export { Daemon, SessionEntry, VERSION, PROTOCOL, parseArgs, resolveIdentity };
1521
+ export { Daemon, SessionEntry, VERSION, PROTOCOL, FEATURES, parseArgs, resolveIdentity };
package/src/takeover.mjs CHANGED
@@ -175,9 +175,21 @@ export function tellTerminal(tty, text) {
175
175
  }
176
176
  }
177
177
 
178
- /** What the terminal is told once omp left it. */
179
- export function takeoverFarewell(device) {
180
- return `omp-mobile: this session continues on ${device}. omp closed here so the phone could take it over.`;
178
+ /** A path as a shell word: quoted only when it has to be. */
179
+ export function shellWord(value) {
180
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
181
+ }
182
+
183
+ /**
184
+ * What the terminal is told once omp left it: where the session went, and the
185
+ * command that brings it back -- the hint leaving omp by hand also leaves.
186
+ * @param {{device: string, sessionFile: string}} leaving
187
+ */
188
+ export function takeoverFarewell({ device, sessionFile }) {
189
+ const lines = [`This session continues on the phone (${device}).`, "omp closed here so the phone could take it over."];
190
+ const file = typeof sessionFile === "string" && sessionFile !== "" ? shellWord(sessionFile) : "";
191
+ if (file !== "") lines.push("", "Resume it in this terminal with:", ` omp --resume ${file}`);
192
+ return lines.join("\n");
181
193
  }
182
194
 
183
195
  const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The daemon's saved transcripts: the slim messages of each session it had
3
+ * open, written when the session's omp exits (the reaper, the daemon
4
+ * stopping, omp leaving on its own), so a phone can read a recent session
5
+ * without the daemon starting an omp for it.
6
+ *
7
+ * A record is valid while the session file it was read from has the same
8
+ * modification time and size: anything that wrote the session since (a
9
+ * terminal omp, a resume elsewhere) makes it stale, and it is not served.
10
+ * Records are kept while their session was active in the last
11
+ * {@link RECENT_MS}; nothing is evicted by count.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ /** How long after its last activity a session's transcript is kept, on the computer and on the phone. */
18
+ export const RECENT_MS = 24 * 60 * 60_000;
19
+ const VERSION = 1;
20
+ const NAME = /^[A-Za-z0-9_-]{1,140}\.json$/;
21
+ /** A temporary file left by a write that never finished (a crash) is dropped after this long. */
22
+ const TEMP_MAX_AGE_MS = 60_000;
23
+ let writes = 0;
24
+
25
+ /**
26
+ * What identifies one message well enough to line a phone's copy up with the
27
+ * daemon's: OMP stamps every message, and tool results and replies carry ids.
28
+ * The app computes the same string (`app/src/cache.ts`).
29
+ * @param {any} message
30
+ */
31
+ export function fingerprint(message) {
32
+ if (message === null || typeof message !== "object") return "";
33
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : typeof message.responseId === "string" ? message.responseId : "";
34
+ return `${String(message.role ?? "")}:${String(message.timestamp ?? "")}:${id}`;
35
+ }
36
+
37
+ /**
38
+ * Where a phone that holds the first `since` messages (the last of them
39
+ * fingerprinted `last`) can continue: `since` when its copy lines up with
40
+ * `messages` there, else 0 (send everything). Indices are the transcript's:
41
+ * `messages` start at `base`, the count a long session dropped from its front.
42
+ * @param {readonly any[]} messages
43
+ * @param {number} since
44
+ * @param {string} last
45
+ * @param {number} [base]
46
+ */
47
+ export function deltaFrom(messages, since, last, base = 0) {
48
+ if (!Array.isArray(messages) || !Number.isSafeInteger(since) || since <= base || since > base + messages.length) return 0;
49
+ if (typeof last !== "string" || last === "") return 0;
50
+ return fingerprint(messages[since - 1 - base]) === last ? since : 0;
51
+ }
52
+
53
+ /**
54
+ * The `since` and `last` query parameters, as a phone sends them; anything
55
+ * malformed reads as "nothing held" (a whole transcript).
56
+ * @param {URLSearchParams} searchParams
57
+ */
58
+ export function deltaQuery(searchParams) {
59
+ const raw = searchParams.get("since") ?? "";
60
+ const since = /^\d{1,9}$/.test(raw) ? Number(raw) : 0;
61
+ return { since, last: searchParams.get("last") ?? "" };
62
+ }
63
+
64
+ /**
65
+ * The body of a snapshot frame or a `/transcript` answer: the messages after
66
+ * what the phone holds when its copy lines up, else all of them. `from` is the
67
+ * index of the first message sent (0: the whole transcript), `total` how many
68
+ * the transcript has. A session past the daemon's cap holds only its last
69
+ * messages, from index `base` on: a delta is still cut at the phone's `since`,
70
+ * and everything is sent as a whole transcript of what is held, with `base`
71
+ * saying where in the session it starts.
72
+ * @param {{session: any, state: any, messages: any[], base?: number}} transcript
73
+ * @param {{since: number, last: string}} [held]
74
+ */
75
+ export function deltaBody({ session, state, messages, base = 0 }, held = { since: 0, last: "" }) {
76
+ const start = Number.isSafeInteger(base) && base > 0 ? base : 0;
77
+ const from = deltaFrom(messages, held.since, held.last, start);
78
+ if (from > 0) return { session, state, from, total: start + messages.length, messages: messages.slice(from - start) };
79
+ return { session, state, from: 0, total: messages.length, messages, ...(start > 0 ? { base: start } : {}) };
80
+ }
81
+
82
+ function fileState(path) {
83
+ try {
84
+ const info = statSync(path);
85
+ return info.isFile() ? { mtimeMs: info.mtimeMs, size: info.size } : undefined;
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** A record that still describes its session file as it is now. */
92
+ function current(record) {
93
+ if (typeof record?.sessionFile !== "string") return false;
94
+ const now = fileState(record.sessionFile);
95
+ return now !== undefined && now.mtimeMs === record.file?.mtimeMs && now.size === record.file?.size;
96
+ }
97
+
98
+ export class TranscriptStore {
99
+ /** @param {string} dir */
100
+ constructor(dir) {
101
+ this.dir = dir;
102
+ }
103
+
104
+ /** @param {string} id */
105
+ path(id) {
106
+ const safe = id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 120);
107
+ // Two ids that differ only in characters a file name cannot carry must not share a file.
108
+ const name = safe === id ? safe : `${safe}-${createHash("sha256").update(id).digest("hex").slice(0, 16)}`;
109
+ return join(this.dir, `${name}.json`);
110
+ }
111
+
112
+ /**
113
+ * Save a session's slim messages against the current state of its file:
114
+ * call it once omp has exited, when the file is final. Best effort: a full
115
+ * disk only loses the shortcut.
116
+ * @param {string} id
117
+ * @param {{sessionFile?: string, messages: any[], state: any, session: any, base?: number}} record
118
+ * @returns {boolean} whether it was saved
119
+ */
120
+ write(id, { sessionFile, messages, state, session, base = 0 }) {
121
+ if (typeof id !== "string" || id === "" || typeof sessionFile !== "string" || !Array.isArray(messages)) return false;
122
+ const file = fileState(sessionFile);
123
+ if (file === undefined) return false;
124
+ const path = this.path(id);
125
+ writes += 1;
126
+ const temporary = `${path}.${process.pid}-${writes}.tmp`;
127
+ // No omp runs for it any more: nothing streams and nothing waits for an answer.
128
+ const record = {
129
+ v: VERSION,
130
+ id,
131
+ sessionFile,
132
+ file,
133
+ savedAt: Date.now(),
134
+ // When the session was last used: omp rewrites its file as it exits, so the file's time is not that.
135
+ activeAt: Number(session?.updatedAt) || file.mtimeMs,
136
+ base: Number.isSafeInteger(base) && base > 0 ? base : 0,
137
+ messages,
138
+ state: state !== null && typeof state === "object" ? { ...state, isStreaming: false } : state,
139
+ session: { ...session, status: "idle", waiting: 0 },
140
+ };
141
+ try {
142
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
143
+ writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 });
144
+ renameSync(temporary, path);
145
+ return true;
146
+ } catch {
147
+ try {
148
+ unlinkSync(temporary);
149
+ } catch {
150
+ // Never written.
151
+ }
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * A saved transcript that still matches its session file, else undefined.
158
+ * Its session is dated by its last activity, not by the file: the file was
159
+ * last written by the omp exiting.
160
+ * @param {string} id
161
+ * @param {string | undefined} sessionFile the file the session is known by now, when known
162
+ */
163
+ read(id, sessionFile) {
164
+ let record;
165
+ try {
166
+ record = JSON.parse(readFileSync(this.path(id), "utf8"));
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ if (record?.v !== VERSION || record.id !== id || !Array.isArray(record.messages)) return undefined;
171
+ if (sessionFile !== undefined && record.sessionFile !== sessionFile) return undefined;
172
+ if (!current(record)) return undefined;
173
+ const session = record.session !== null && typeof record.session === "object" ? record.session : { id };
174
+ return { ...record, session: { ...session, updatedAt: Number(session.updatedAt) || record.file.mtimeMs } };
175
+ }
176
+
177
+ /** @param {string} id */
178
+ remove(id) {
179
+ try {
180
+ unlinkSync(this.path(id));
181
+ } catch {
182
+ // Never saved.
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Drop transcripts whose session has been quiet longer than
188
+ * {@link RECENT_MS}, and those that can never be served again (their
189
+ * session file changed or is gone, or the record is unreadable).
190
+ */
191
+ prune(now = Date.now()) {
192
+ let names;
193
+ try {
194
+ names = readdirSync(this.dir);
195
+ } catch {
196
+ return 0;
197
+ }
198
+ let dropped = 0;
199
+ for (const name of names) {
200
+ const path = join(this.dir, name);
201
+ if (name.endsWith(".tmp")) {
202
+ try {
203
+ if (now - statSync(path).mtimeMs > TEMP_MAX_AGE_MS) unlinkSync(path);
204
+ } catch {
205
+ // Gone already.
206
+ }
207
+ continue;
208
+ }
209
+ if (!NAME.test(name)) continue;
210
+ let record;
211
+ try {
212
+ record = JSON.parse(readFileSync(path, "utf8"));
213
+ } catch {
214
+ record = undefined;
215
+ }
216
+ const activeAt = Number(record?.activeAt) || 0;
217
+ if (record?.v === VERSION && now - activeAt <= RECENT_MS && current(record)) continue;
218
+ try {
219
+ unlinkSync(path);
220
+ dropped += 1;
221
+ } catch {
222
+ // Gone already.
223
+ }
224
+ }
225
+ return dropped;
226
+ }
227
+ }