omp-mobile 0.4.2 → 0.4.3

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.3",
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",
@@ -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,10 @@ 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;
61
+ /** Events that are a session being used: a turn and what it streams. State and setting echoes are not. */
62
+ const ACTIVITY_EVENT = /^(agent_|turn_|message_|tool_execution_|auto_compaction_|auto_retry_)/;
58
63
  /**
59
64
  * The fields of a tool result's `details` the app reads: an edit's diff, a
60
65
  * read's line count, a command's run time, a screenshot's file. The rest
@@ -100,9 +105,11 @@ export function promptImages(value) {
100
105
  * the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
101
106
  * `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
102
107
  * `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
103
- * treat a silent link as gone.
108
+ * treat a silent link as gone; `transcript-sync`: a session socket's `since`/`last`
109
+ * ask for a delta snapshot (`from`, `total`), and `GET /sessions/:id/transcript`
110
+ * answers the same without starting an omp.
104
111
  */
105
- const FEATURES = ["prompt-images", "takeover", "heartbeat"];
112
+ const FEATURES = ["prompt-images", "takeover", "heartbeat", "transcript-sync"];
106
113
  /** Who a phone continues a session on, as the terminal it leaves is told. */
107
114
  const DEFAULT_DEVICE = "OMP Mobile";
108
115
  /** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
@@ -376,6 +383,46 @@ export function pruneBlobCache(blobDir, now = Date.now()) {
376
383
  return dropped;
377
384
  }
378
385
 
386
+ /**
387
+ * When the messages were last written: the newest timestamp OMP stamped on
388
+ * them, or undefined when none carries one.
389
+ * @param {readonly any[]} messages
390
+ */
391
+ export function lastActivity(messages) {
392
+ let latest;
393
+ for (let i = messages.length - 1; i >= 0 && i >= messages.length - 50; i -= 1) {
394
+ const at = Number(messages[i]?.timestamp);
395
+ if (Number.isFinite(at) && at > 0 && (latest === undefined || at > latest)) latest = at;
396
+ }
397
+ return latest;
398
+ }
399
+
400
+ /**
401
+ * When a session the daemon knows but has no omp for was last used: what the
402
+ * registry says, or its file's time when something wrote the file since --
403
+ * unless the file is exactly as this daemon's own omp left it on exit (omp
404
+ * rewrites the whole file as it closes; that is not anyone using it).
405
+ * @param {{updatedAt?: number, closedFile?: {mtimeMs: number, size: number}} | undefined} stored
406
+ * @param {{updatedAt: number, size?: number} | undefined} scanned
407
+ */
408
+ export function dormantActivity(stored, scanned) {
409
+ const known = Number(stored?.updatedAt) || 0;
410
+ if (scanned === undefined || !(scanned.updatedAt > known)) return known;
411
+ const closed = stored?.closedFile;
412
+ if (closed !== undefined && closed !== null && closed.mtimeMs === scanned.updatedAt && closed.size === scanned.size) return known;
413
+ return scanned.updatedAt;
414
+ }
415
+
416
+ /** A file's time and size, as `TranscriptStore` checks them; undefined when it cannot be read. */
417
+ function fileState(path) {
418
+ try {
419
+ const info = statSync(path);
420
+ return info.isFile() ? { mtimeMs: info.mtimeMs, size: info.size } : undefined;
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+
379
426
  function storedSummary(stored) {
380
427
  return {
381
428
  id: stored.id,
@@ -503,6 +550,10 @@ class SessionEntry {
503
550
  this.id = "";
504
551
  this.state = null;
505
552
  this.messages = [];
553
+ /** How many messages left the front of `messages` (the cap): `messages[0]` is the transcript's message `base`. */
554
+ this.base = 0;
555
+ /** The session file as this omp left it on exit, so its rewrite is not taken for activity. */
556
+ this.closedFile = undefined;
506
557
  this.createdAt = Date.now();
507
558
  this.updatedAt = this.createdAt;
508
559
  this.title = cwd.split("/").filter(Boolean).at(-1) ?? "session";
@@ -520,15 +571,26 @@ class SessionEntry {
520
571
  this.idleSince = undefined;
521
572
  /** Where inline images go (see `phoneMessage`); undefined keeps them inline. */
522
573
  this.blobDir = undefined;
574
+ /**
575
+ * Hears the omp process exit, once its session file is final (the daemon saves the transcript).
576
+ * @type {((entry: SessionEntry) => void) | undefined}
577
+ */
578
+ this.onClosed = undefined;
523
579
  }
524
580
 
525
- async hydrate() {
581
+ /**
582
+ * @param {number} [knownActivity] when the session was last used, as the daemon knew it before this omp
583
+ */
584
+ async hydrate(knownActivity) {
526
585
  const [state, messages] = await Promise.all([this.rpc.getState(), this.rpc.getMessages()]);
527
586
  this.state = state;
528
587
  this.messages = Array.isArray(messages.messages) ? messages.messages.map((message) => phoneMessage(message, this.blobDir)) : [];
588
+ this.base = 0;
529
589
  this.id = state.sessionId ?? "";
530
590
  this.title = titleOf(this.messages, state, this.cwd);
531
- this.updatedAt = Date.now();
591
+ // Opening a session is not using it: it is as recent as its last message (a new one: now).
592
+ const known = Math.max(lastActivity(this.messages) ?? 0, Number(knownActivity) || 0);
593
+ this.updatedAt = known > 0 ? known : Date.now();
532
594
  return this;
533
595
  }
534
596
 
@@ -548,12 +610,13 @@ class SessionEntry {
548
610
  };
549
611
  }
550
612
 
551
- snapshot() {
552
- return {
553
- session: this.summary(),
554
- state: this.state,
555
- messages: this.messages,
556
- };
613
+ /**
614
+ * What a phone is sent on open: everything, or only the messages after the
615
+ * ones it holds when its copy lines up (see `deltaBody`).
616
+ * @param {{since: number, last: string}} [held]
617
+ */
618
+ snapshot(held) {
619
+ return deltaBody({ session: this.summary(), state: this.state, messages: this.messages, base: this.base }, held);
557
620
  }
558
621
 
559
622
  broadcast(payload) {
@@ -567,7 +630,6 @@ class SessionEntry {
567
630
  async refreshState() {
568
631
  try {
569
632
  this.state = await this.rpc.getState();
570
- this.updatedAt = Date.now();
571
633
  this.broadcast({ type: "state", state: this.state });
572
634
  } catch {
573
635
  // The process may be exiting; the close event tells clients.
@@ -576,10 +638,15 @@ class SessionEntry {
576
638
 
577
639
  bind() {
578
640
  this.rpc.on("event", (event) => {
579
- this.updatedAt = Date.now();
641
+ if (typeof event?.type === "string" && ACTIVITY_EVENT.test(event.type)) this.updatedAt = Date.now();
580
642
  if (event.type === "message_end" && event.message) {
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);
@@ -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);
@@ -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 };
@@ -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
+ }