agent-accelerator 0.5.2 → 0.5.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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Append-only step log for a persisted session (dir mode). `session.json` stays the snapshot; every step after it
3
+ * adds ONE short line to `journal.ndjson` instead of rewriting the whole file, so a long chat costs the same per step
4
+ * as a short one. When history changes any other way (rollback, repair, reset, import) or the journal grows past a
5
+ * limit, a fresh snapshot replaces both. The snapshot carries an `epoch` id and the journal header repeats it, so a
6
+ * journal left over from before a snapshot is ignored on load (see `replayJournal` in `store.ts`).
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import type { Message, ContentPart } from "../types/message.ts";
12
+ import { writeFileDurable } from "../utils/durable.ts";
13
+ import {
14
+ JOURNAL_FILE,
15
+ buildSessionData,
16
+ embeddedMedia,
17
+ quarantineIfCorrupt,
18
+ serializeSession,
19
+ stripRedundantRawArguments,
20
+ type SessionAgentLike,
21
+ type SessionPending,
22
+ type SessionPendingPrompt,
23
+ type SessionToolRecord,
24
+ } from "./store.ts";
25
+
26
+ /** A journal past this many lines is folded into a fresh snapshot. */
27
+ const MAX_JOURNAL_LINES = 500;
28
+ /** A journal past this many bytes is folded into a fresh snapshot. */
29
+ const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
30
+
31
+ const EMPTY_PENDING = JSON.stringify({ steer: [], queue: [] });
32
+
33
+ // One `tool` journal line; the result is saved as given (tool results are JSON-safe by the time the executor returns them); in: a finished tool call, out: the journal.
34
+ function toolLine(t: SessionToolRecord): string {
35
+ return JSON.stringify({ t: "tool", call: t.id, name: t.name, result: t.result, ...(t.isError ? { isError: true } : {}), ...(t.stopped ? { stopped: true } : {}) });
36
+ }
37
+
38
+ /** Persists one session folder (`sessions/<id>/`) step by step. */
39
+ export class SessionJournal {
40
+ /** Epoch of the snapshot the journal belongs to; unset until the first snapshot (and after a failed append). */
41
+ private epoch: string | undefined;
42
+ /** How many messages are on disk, and the last one (identity check: history only grew since the last sync). */
43
+ private count = 0;
44
+ private last: Message | undefined;
45
+ private lines = 0;
46
+ private bytes = 0;
47
+ /** JSON of the last written non-message state (config, system prompt, worker traces). */
48
+ private meta = "";
49
+ /** How many run marks are on disk, and the last one. */
50
+ private runCount = 0;
51
+ private lastRun: unknown;
52
+ /** JSON of the last journaled waiting prompts. */
53
+ private pending = EMPTY_PENDING;
54
+ /** JSON of the last journaled spending totals. */
55
+ private usage = "";
56
+ /** Run ids whose ending is on disk. */
57
+ private readonly endsOnDisk = new Set<string>();
58
+ /** How many of the current run's finished tool results are on disk, and the last one. */
59
+ private toolCount = 0;
60
+ private lastTool: unknown;
61
+ /** Disk form of each message already prepared (media replaced by `media/` files, redundant raw arguments dropped). */
62
+ private readonly onDisk = new WeakMap<Message, Message>();
63
+ /** Same for waiting prompts made of content parts. */
64
+ private readonly promptOnDisk = new WeakMap<ContentPart[], ContentPart[]>();
65
+
66
+ constructor(readonly dir: string) {}
67
+
68
+ // Brings the folder up to date with the agent: appends new messages and changed state, or writes a fresh snapshot when history changed another way; in: Agent.doPersist after every step, out: files in this.dir.
69
+ sync(agent: SessionAgentLike): void {
70
+ const messages = agent.context.messages;
71
+ const runs = agent.runMarks ?? [];
72
+ const grew =
73
+ this.epoch !== undefined &&
74
+ messages.length >= this.count &&
75
+ (this.count === 0 || messages[this.count - 1] === this.last) &&
76
+ runs.length >= this.runCount &&
77
+ (this.runCount === 0 || runs[this.runCount - 1] === this.lastRun);
78
+ if (!grew) {
79
+ this.snapshot(agent);
80
+ return;
81
+ }
82
+ const records: string[] = [];
83
+ for (let i = this.count; i < messages.length; i++) records.push(JSON.stringify({ t: "msg", i, m: this.toDisk(messages[i]!) }));
84
+ for (let r = this.runCount; r < runs.length; r++) records.push(JSON.stringify({ t: "run", id: runs[r]!.id, i: runs[r]!.i, at: runs[r]!.at }));
85
+ for (const mark of runs) {
86
+ if (mark.end && !this.endsOnDisk.has(mark.id)) records.push(JSON.stringify({ t: "end", id: mark.id, status: mark.end }));
87
+ }
88
+ const tools = agent.toolRecords ?? [];
89
+ const firstNewTool = this.toolCount > 0 && tools[this.toolCount - 1] === this.lastTool ? this.toolCount : 0; // a new run starts the list over
90
+ for (let k = firstNewTool; k < tools.length; k++) records.push(toolLine(tools[k]!));
91
+ const meta = this.metaOf(agent);
92
+ if (meta !== this.meta) records.push(`{"t":"meta","m":${meta}}`);
93
+ const pending = this.pendingOf(agent);
94
+ if (pending !== this.pending) records.push(`{"t":"pending","p":${pending}}`);
95
+ const usage = this.usageOf(agent);
96
+ if (usage !== this.usage) records.push(`{"t":"usage","u":${usage}}`);
97
+ if (records.length === 0) return;
98
+ const text = records.join("\n") + "\n";
99
+ try {
100
+ const fd = fs.openSync(path.join(this.dir, JOURNAL_FILE), "a", 0o600);
101
+ try {
102
+ fs.writeFileSync(fd, text);
103
+ fs.fsyncSync(fd);
104
+ } finally {
105
+ fs.closeSync(fd);
106
+ }
107
+ } catch (err) {
108
+ this.epoch = undefined; // a half-written line may be on disk: the next sync starts over with a snapshot
109
+ throw err;
110
+ }
111
+ this.count = messages.length;
112
+ this.last = messages[messages.length - 1];
113
+ this.meta = meta;
114
+ this.runCount = runs.length;
115
+ this.lastRun = runs[runs.length - 1];
116
+ this.pending = pending;
117
+ this.usage = usage;
118
+ this.toolCount = tools.length;
119
+ this.lastTool = tools[tools.length - 1];
120
+ for (const mark of runs) if (mark.end) this.endsOnDisk.add(mark.id);
121
+ this.lines += records.length;
122
+ this.bytes += text.length;
123
+ if (this.lines >= MAX_JOURNAL_LINES || this.bytes >= MAX_JOURNAL_BYTES) this.snapshot(agent);
124
+ }
125
+
126
+ // Writes `session.json` (new epoch) and then restarts the journal under that epoch; a crash in between leaves a journal with the old epoch, which load ignores; in: sync, out: both files.
127
+ private snapshot(agent: SessionAgentLike): void {
128
+ const messages = agent.context.messages;
129
+ const epoch = randomUUID();
130
+ const target = path.join(this.dir, "session.json");
131
+ if (this.epoch === undefined) quarantineIfCorrupt(target);
132
+ const data = buildSessionData({ ...agent, context: { ...agent.context, messages: messages.map((m) => this.toDisk(m)) } }, null);
133
+ writeFileDurable(target, serializeSession({ ...data, epoch }) + "\n");
134
+ const pending = this.pendingOf(agent);
135
+ const tools = agent.toolRecords ?? [];
136
+ // Waiting prompts and the current run's finished tool results live only in the journal, so a new journal starts with them.
137
+ const header =
138
+ JSON.stringify({ t: "base", e: epoch }) + "\n" + (pending === EMPTY_PENDING ? "" : `{"t":"pending","p":${pending}}\n`) + tools.map((t) => toolLine(t) + "\n").join("");
139
+ writeFileDurable(path.join(this.dir, JOURNAL_FILE), header, { backup: false });
140
+ this.epoch = epoch;
141
+ this.pending = pending;
142
+ this.usage = this.usageOf(agent); // the snapshot holds it
143
+ this.runs(agent);
144
+ this.count = messages.length;
145
+ this.last = messages[messages.length - 1];
146
+ this.lines = (pending === EMPTY_PENDING ? 0 : 1) + tools.length;
147
+ this.toolCount = tools.length;
148
+ this.lastTool = tools[tools.length - 1];
149
+ this.endsOnDisk.clear();
150
+ for (const mark of agent.runMarks ?? []) if (mark.end) this.endsOnDisk.add(mark.id); // the snapshot holds them
151
+ this.bytes = 0;
152
+ this.meta = this.metaOf(agent);
153
+ }
154
+
155
+ // Remembers which run marks the snapshot already holds; in: snapshot, out: the next sync's "which marks are new".
156
+ private runs(agent: SessionAgentLike): void {
157
+ const runs = agent.runMarks ?? [];
158
+ this.runCount = runs.length;
159
+ this.lastRun = runs[runs.length - 1];
160
+ }
161
+
162
+ // JSON of the waiting prompts with media moved to files; in: the agent's steer inbox + follow-up queue, out: change detection + the `pending` record.
163
+ private pendingOf(agent: SessionAgentLike): string {
164
+ const waiting = agent.pendingPrompts?.();
165
+ if (!waiting || waiting.steer.length + waiting.queue.length === 0) return EMPTY_PENDING;
166
+ const parts = (prompt: ContentPart[]): ContentPart[] => {
167
+ let disk = this.promptOnDisk.get(prompt);
168
+ if (!disk) this.promptOnDisk.set(prompt, (disk = this.toDisk({ role: "user", content: prompt }).content as ContentPart[]));
169
+ return disk;
170
+ };
171
+ const slim = (list: SessionPendingPrompt[]): SessionPendingPrompt[] =>
172
+ list.map((w) => ({ ...w, prompt: typeof w.prompt === "string" ? w.prompt : parts(w.prompt) }));
173
+ return JSON.stringify({ steer: slim(waiting.steer), queue: slim(waiting.queue) } satisfies SessionPending);
174
+ }
175
+
176
+ // JSON of the agent's spending totals (`null` before its first model call); in: the live agent, out: change detection + the `usage` record.
177
+ private usageOf(agent: SessionAgentLike): string {
178
+ return agent.usageTotals && agent.usageTotals.turns > 0 ? JSON.stringify(agent.usageTotals) : "null";
179
+ }
180
+
181
+ // JSON of everything in the snapshot that is not a message, identity or totals; in: the live agent, out: change detection + the `meta` record.
182
+ private metaOf(agent: SessionAgentLike): string {
183
+ const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, usage: _u, ...rest } = buildSessionData(
184
+ { ...agent, context: { ...agent.context, messages: [] } },
185
+ null
186
+ );
187
+ return JSON.stringify(rest);
188
+ }
189
+
190
+ // The form of a message that goes to disk: embedded media written once as `media/<kind>-h<hash>.<ext>` (content-addressed, never deleted or renumbered), redundant raw arguments dropped; in: a live message, out: journal lines and snapshots.
191
+ private toDisk(message: Message): Message {
192
+ const known = this.onDisk.get(message);
193
+ if (known) return known;
194
+ const slim = stripRedundantRawArguments([message])[0]!;
195
+ let out = slim;
196
+ if (Array.isArray(slim.content)) {
197
+ let changed = false;
198
+ const content = (slim.content as ContentPart[]).map((part) => {
199
+ const found = embeddedMedia(part);
200
+ if (!found) return part;
201
+ const file = `${found.key}-h${createHash("sha256").update(found.bytes).digest("hex").slice(0, 16)}.${found.ext}`;
202
+ const dest = path.join(this.dir, "media", file);
203
+ if (!fs.existsSync(dest)) writeFileDurable(dest, found.bytes, { backup: false });
204
+ changed = true;
205
+ return { ...(part as unknown as Record<string, unknown>), [found.key]: `media/${file}`, ...(found.mimeType ? { mimeType: found.mimeType } : {}) } as unknown as ContentPart;
206
+ });
207
+ if (changed) out = { ...slim, content };
208
+ }
209
+ this.onDisk.set(message, out);
210
+ return out;
211
+ }
212
+ }
@@ -32,6 +32,8 @@ import { getModelFromCatalog } from "../models/catalog.ts";
32
32
  import type { SubAgentStep } from "../types/response.ts";
33
33
  import { SessionLoadError } from "../types/errors.ts";
34
34
  import { createSessionId } from "../utils/session.ts";
35
+ import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
36
+ import type { UsageTotals } from "./usage.ts";
35
37
 
36
38
  /** Cumulative per-session token + cost totals (JSON-safe). */
37
39
  export interface SessionTotals {
@@ -61,6 +63,40 @@ export interface PersistedSubAgentTrace {
61
63
  error?: string;
62
64
  }
63
65
 
66
+ /** How a run ended; a run with no ending was cut off (a crash). `abandoned` = a newer run started instead of resuming it. */
67
+ export type SessionRunEnd = "done" | "failed" | "aborted" | "stopped" | "abandoned";
68
+
69
+ /** Where one run began: `i` is the index of its user message in `messages` (turn boundaries for later phases). */
70
+ export interface SessionRunMark {
71
+ id: string;
72
+ i: number;
73
+ at: number;
74
+ /** Unset while the run is active or when it was cut off. */
75
+ end?: SessionRunEnd;
76
+ }
77
+
78
+ /** The result of a tool call that finished, saved the moment it finished so a crash never repeats it. */
79
+ export interface SessionToolRecord {
80
+ id: string;
81
+ name: string;
82
+ result: unknown;
83
+ isError?: boolean;
84
+ /** The tool ended the run with `ctx.stop`. */
85
+ stopped?: boolean;
86
+ }
87
+
88
+ /** A prompt that was waiting when the process stopped (its options other than `additionalContext` are not saved). */
89
+ export interface SessionPendingPrompt {
90
+ prompt: string | ContentPart[];
91
+ additionalContext?: string;
92
+ }
93
+
94
+ /** Steers not yet injected into the active run, and queued follow-ups, as last journaled. */
95
+ export interface SessionPending {
96
+ steer: SessionPendingPrompt[];
97
+ queue: SessionPendingPrompt[];
98
+ }
99
+
64
100
  /** Single-file session snapshot (v1). Written as one JSON object. */
65
101
  export interface PersistedAgentSession {
66
102
  version: 1;
@@ -79,6 +115,16 @@ export interface PersistedAgentSession {
79
115
  parentSessionId?: string;
80
116
  /** Dedicated sub-agent traces, keyed by worker TrackingID (32-char hex). */
81
117
  subagents?: Record<string, PersistedSubAgentTrace>;
118
+ /** Written with a journal (dir mode): only a `journal.ndjson` whose header names this id belongs to this snapshot. */
119
+ epoch?: string;
120
+ /** Where each run began, oldest first. */
121
+ runs?: SessionRunMark[];
122
+ /** What this agent spent on its own model calls (see `usage.ts`); the journal keeps it current with `usage` records. */
123
+ usage?: UsageTotals;
124
+ /** Read from the journal only (never in `session.json`): prompts that were still waiting. */
125
+ pending?: SessionPending;
126
+ /** Read from the journal only: results of tool calls finished in the current run (used to resume without repeating them). */
127
+ tools?: SessionToolRecord[];
82
128
  }
83
129
 
84
130
  export function emptyTotals(): SessionTotals {
@@ -286,6 +332,14 @@ export interface SessionAgentLike {
286
332
  instructions?: string;
287
333
  parentSessionId?: unknown;
288
334
  subagentTraces?: Record<string, PersistedSubAgentTrace>;
335
+ /** Run boundaries (see {@link SessionRunMark}). */
336
+ runMarks?: SessionRunMark[];
337
+ /** Prompts waiting in the steer inbox / follow-up queue; called by the journal on every sync. */
338
+ pendingPrompts?: () => SessionPending;
339
+ /** Tool calls finished in the current run (cleared when the next run starts). */
340
+ toolRecords?: SessionToolRecord[];
341
+ /** What this agent spent on its own model calls. */
342
+ usageTotals?: UsageTotals;
289
343
  context: { systemPrompt?: string; messages: Message[] };
290
344
  }
291
345
 
@@ -354,7 +408,7 @@ function stableStringify(value: unknown): string {
354
408
  * stored `arguments` (pure duplicate, the common case). Divergent or
355
409
  * unparseable wire strings are kept — they carry info parsing lost.
356
410
  */
357
- function stripRedundantRawArguments(messages: Message[]): Message[] {
411
+ export function stripRedundantRawArguments(messages: Message[]): Message[] {
358
412
  return messages.map((m) => {
359
413
  if (!Array.isArray((m as Message).content)) return m;
360
414
  let changed = false;
@@ -432,6 +486,8 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
432
486
  messages,
433
487
  ...(parentSessionId ? { parentSessionId } : {}),
434
488
  ...(subagents ? { subagents } : {}),
489
+ ...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
490
+ ...(agent.usageTotals && agent.usageTotals.turns > 0 ? { usage: JSON.parse(JSON.stringify(agent.usageTotals)) as UsageTotals } : {}),
435
491
  };
436
492
  }
437
493
 
@@ -619,6 +675,9 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
619
675
  totals: obj.totals,
620
676
  parentSessionId: obj.parentSessionId,
621
677
  subagents: obj.subagents,
678
+ ...(typeof obj.epoch === "string" ? { epoch: obj.epoch } : {}),
679
+ ...(Array.isArray(obj.runs) ? { runs: obj.runs as SessionRunMark[] } : {}),
680
+ ...(obj.usage && typeof obj.usage === "object" ? { usage: obj.usage as UsageTotals } : {}),
622
681
  };
623
682
  } catch {
624
683
  return null;
@@ -654,14 +713,14 @@ export function inspectSessionPayload(
654
713
  /** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
655
714
  export function loadSessionFile(filePath: string, limits?: SessionLimits): PersistedAgentSession | null {
656
715
  const resolved = path.resolve(filePath);
657
- let raw: string;
658
- try {
659
- if (!fs.existsSync(resolved)) return null;
660
- raw = fs.readFileSync(resolved, "utf8");
661
- } catch {
662
- return null;
663
- }
664
- return sessionFromPayload(raw, resolved, limits);
716
+ const read = readWithBackup(resolved, isLoadableSession);
717
+ return read ? sessionFromPayload(read.text, resolved, limits) : null;
718
+ }
719
+
720
+ // True when a stored payload is a usable snapshot (a valid one over the load limits counts); in: raw file text, out: readWithBackup's fallback decision.
721
+ function isLoadableSession(raw: string): boolean {
722
+ const status = inspectSessionPayload(raw).status;
723
+ return status === "ok" || status === "over_limit";
665
724
  }
666
725
 
667
726
  /**
@@ -680,25 +739,8 @@ function sessionFromPayload(raw: string, file: string, limits?: SessionLimits):
680
739
  return res.session ?? null;
681
740
  }
682
741
 
683
- /** Atomic file write (temp + rename) so a crash never leaves half-written JSON. */
684
- export function writeFileAtomic(filePath: string, content: string): void {
685
- const dir = path.dirname(filePath);
686
- try {
687
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
688
- } catch {}
689
- let tag = "tmp";
690
- try {
691
- const pid = (globalThis as any)?.process?.pid;
692
- const rand = (globalThis as any)?.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
693
- tag = `${typeof pid === "number" ? pid : "np"}-${rand}`;
694
- } catch {
695
- tag = Math.random().toString(36).slice(2, 8);
696
- }
697
- const tmp = `${filePath}.tmp-${tag}`;
698
- fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
699
- try { fs.chmodSync(tmp, 0o600); } catch {}
700
- fs.renameSync(tmp, filePath);
701
- }
742
+ /** Public name of the durable writer (temp file, fsync, `.bak`, atomic rename): a crash or power cut never leaves half-written JSON. */
743
+ export const writeFileAtomic = writeFileDurable;
702
744
 
703
745
  /** Session id grammar: no traversal, no slashes, no hidden files. */
704
746
  export function assertValidSessionId(sessionId: string): void {
@@ -707,6 +749,15 @@ export function assertValidSessionId(sessionId: string): void {
707
749
  }
708
750
  }
709
751
 
752
+ /** Renames a genuinely corrupt snapshot to `*.corrupt-<ts>` before it is overwritten (a healthy one over the load limits is our own prior save and is just rewritten). */
753
+ export function quarantineIfCorrupt(file: string): void {
754
+ try {
755
+ if (inspectSessionPayload(fs.readFileSync(file, "utf8")).status === "corrupt") fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
756
+ } catch {
757
+ // why: no file yet, or it cannot be read: nothing to quarantine.
758
+ }
759
+ }
760
+
710
761
  /** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
711
762
  export function saveSessionFile(
712
763
  filePath: string,
@@ -714,20 +765,7 @@ export function saveSessionFile(
714
765
  telemetry?: SessionTelemetry | SessionTotals | null
715
766
  ): void {
716
767
  const resolved = path.resolve(filePath);
717
- // Q-28-partial: never overwrite a corrupt file in place — quarantine it
718
- // as `*.corrupt-<ts>` first (load returns null for corrupt payloads).
719
- try {
720
- if (fs.existsSync(resolved)) {
721
- try {
722
- const raw = fs.readFileSync(resolved, "utf8");
723
- // Only genuinely corrupt payloads are quarantined; a healthy snapshot
724
- // over the load limits is simply rewritten (it is our own prior save).
725
- if (inspectSessionPayload(raw).status === "corrupt") {
726
- try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
727
- }
728
- } catch {}
729
- }
730
- } catch {}
768
+ quarantineIfCorrupt(resolved);
731
769
  const data = buildSessionData(agent, telemetry);
732
770
  try {
733
771
  writeFileAtomic(resolved, serializeSession(data) + "\n");
@@ -790,6 +828,28 @@ function extForMedia(mimeType?: string, filename?: string): string {
790
828
  return "bin";
791
829
  }
792
830
 
831
+ // Finds embedded media bytes in a part (binary, `data:` URL or raw base64); remote URLs and local paths are references and give undefined; in: any content part, out: extractMediaToDir and the session journal.
832
+ export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS)[number]; bytes: Uint8Array; mimeType?: string; ext: string } | undefined {
833
+ const p = part as unknown as Record<string, unknown> | null;
834
+ if (!p || typeof p["type"] !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p["type"])) return undefined;
835
+ const key = p["type"] as (typeof MEDIA_PART_KEYS)[number];
836
+ const raw = p[key];
837
+ let bytes: Uint8Array;
838
+ let mimeType = typeof p["mimeType"] === "string" ? (p["mimeType"] as string) : undefined;
839
+ if (raw instanceof Uint8Array) bytes = raw;
840
+ else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
841
+ else if (typeof raw === "string") {
842
+ if (isHttpRef(raw)) return undefined; // remote reference: the provider fetches it per turn
843
+ const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
844
+ if (dataUrl) {
845
+ bytes = base64ToBytes(dataUrl.base64);
846
+ mimeType = mimeType ?? dataUrl.mimeType;
847
+ } else if (looksLikeEmbeddedBase64(raw)) bytes = base64ToBytes(raw.replace(/\s/g, ""));
848
+ else return undefined; // local path or short label: keep as reference
849
+ } else return undefined;
850
+ return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
851
+ }
852
+
793
853
  function cloneMessages(messages: Message[]): Message[] {
794
854
  try {
795
855
  const g: any = globalThis as any;
@@ -830,35 +890,15 @@ export function extractMediaToDir(
830
890
  } catch {}
831
891
  const wrote: string[] = [];
832
892
  const counters: Record<string, number> = {};
833
- let hasBinary = false;
834
893
 
835
894
  for (const msg of cloned) {
836
895
  if (!Array.isArray((msg as Message).content)) continue;
837
896
  for (const part of (msg as Message).content as ContentPart[]) {
838
- const p: any = part as any;
839
- if (!p || typeof p.type !== "string") continue;
840
- if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
841
- const key = p.type as (typeof MEDIA_PART_KEYS)[number];
842
- const raw = p[key] as unknown;
843
- let bytes: Uint8Array | undefined;
844
- let mimeType: string | undefined = typeof p.mimeType === "string" ? p.mimeType : undefined;
845
- if (raw instanceof Uint8Array) bytes = raw;
846
- else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
847
- else if (typeof raw === "string") {
848
- if (isHttpRef(raw)) continue; // remote reference — provider fetches per turn
849
- const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
850
- if (dataUrl) {
851
- bytes = base64ToBytes(dataUrl.base64);
852
- mimeType = mimeType ?? dataUrl.mimeType;
853
- } else if (looksLikeEmbeddedBase64(raw)) {
854
- bytes = base64ToBytes(raw.replace(/\s/g, ""));
855
- } else continue; // local path or short label — keep as reference
856
- } else continue;
857
-
858
- hasBinary = true;
897
+ const found = embeddedMedia(part);
898
+ if (!found) continue;
899
+ const { key, bytes, mimeType } = found;
859
900
  const n = (counters[key] = (counters[key] ?? 0) + 1);
860
- const ext = extForMedia(mimeType, typeof p.filename === "string" ? p.filename : undefined);
861
- const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
901
+ const file = `${key}-${String(n).padStart(3, "0")}.${found.ext}`;
862
902
  try {
863
903
  fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
864
904
  const dest = path.resolve(mediaDir, file);
@@ -868,8 +908,9 @@ export function extractMediaToDir(
868
908
  } catch {
869
909
  continue;
870
910
  }
911
+ const p = part as unknown as Record<string, unknown>;
871
912
  p[key] = `media/${file}`;
872
- if (mimeType) p.mimeType = mimeType;
913
+ if (mimeType) p["mimeType"] = mimeType;
873
914
  wrote.push(`media/${file}`);
874
915
  }
875
916
  }
@@ -937,17 +978,7 @@ export function saveSessionDir(
937
978
  );
938
979
  try {
939
980
  const target = path.join(sessionDir, "session.json");
940
- // Q-28-partial: quarantine corrupt targets instead of overwriting.
941
- try {
942
- if (fs.existsSync(target)) {
943
- try {
944
- const raw = fs.readFileSync(target, "utf8");
945
- if (inspectSessionPayload(raw).status === "corrupt") {
946
- try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
947
- }
948
- } catch {}
949
- }
950
- } catch {}
981
+ quarantineIfCorrupt(target);
951
982
  writeFileAtomic(target, serializeSession(data) + "\n");
952
983
  } catch {
953
984
  // persistence must never crash a chat turn
@@ -955,6 +986,69 @@ export function saveSessionDir(
955
986
  return sessionDir;
956
987
  }
957
988
 
989
+ /** Name of the append-only step log next to `session.json` (see `session/journal.ts`). */
990
+ export const JOURNAL_FILE = "journal.ndjson";
991
+
992
+ // Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
993
+ const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
994
+
995
+ // Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `usage` replaces the agent's spending totals, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
996
+ function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
997
+ if (!snapshot.epoch) return snapshot;
998
+ const lines = text.split("\n");
999
+ const session: PersistedAgentSession = { ...snapshot, messages: [...snapshot.messages] };
1000
+ let sawHeader = false;
1001
+ const stop = (reason: string, line: number): void => {
1002
+ telemetryBus.emitWarning("session.journal_corrupt", `[Agent Accelerator] ${path.basename(file)} line ${line + 1}: ${reason}; the rest of the journal is ignored.`, { file, line: line + 1 });
1003
+ };
1004
+ for (let k = 0; k < lines.length; k++) {
1005
+ const line = lines[k]!.trim();
1006
+ if (!line) continue;
1007
+ let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; u?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
1008
+ try {
1009
+ rec = JSON.parse(line);
1010
+ } catch {
1011
+ if (lines.slice(k + 1).some((l) => l.trim())) stop("not valid JSON", k); // a torn LAST line is just a cut-off write
1012
+ break;
1013
+ }
1014
+ if (!sawHeader) {
1015
+ sawHeader = true;
1016
+ if (rec.t !== "base" || rec.e !== snapshot.epoch) return snapshot; // a journal from before this snapshot
1017
+ continue;
1018
+ }
1019
+ if (rec.t === "msg" && typeof rec.i === "number") {
1020
+ if (rec.i < session.messages.length) continue; // already in the snapshot
1021
+ if (rec.i > session.messages.length || findInvalidMessage([rec.m]) !== -1) {
1022
+ stop(rec.i > session.messages.length ? "a message is missing before this one" : "invalid message", k);
1023
+ break;
1024
+ }
1025
+ session.messages.push(rec.m as Message);
1026
+ } else if (rec.t === "run" && typeof rec.id === "string" && typeof rec.i === "number" && typeof rec.at === "number") {
1027
+ if (!session.runs?.some((r) => r.id === rec.id)) session.runs = [...(session.runs ?? []), { id: rec.id, i: rec.i, at: rec.at }];
1028
+ } else if (rec.t === "end" && typeof rec.id === "string" && typeof rec.status === "string") {
1029
+ const mark = session.runs?.find((r) => r.id === rec.id);
1030
+ if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
1031
+ } else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
1032
+ session.tools = [...(session.tools ?? []).filter((x) => x.id !== rec.call), { id: rec.call, name: rec.name, result: rec.result, ...(rec.isError ? { isError: true } : {}), ...(rec.stopped ? { stopped: true } : {}) }];
1033
+ } else if (rec.t === "usage" && rec.u && typeof rec.u === "object") {
1034
+ session.usage = rec.u as UsageTotals; // latest record wins
1035
+ } else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
1036
+ const p = rec.p as SessionPending; // latest record wins
1037
+ if (Array.isArray(p.steer) && Array.isArray(p.queue)) {
1038
+ if (p.steer.length + p.queue.length > 0) session.pending = p;
1039
+ else delete session.pending;
1040
+ }
1041
+ } else if (rec.t === "meta" && rec.m && typeof rec.m === "object") {
1042
+ const meta = rec.m as Record<string, unknown>;
1043
+ for (const key of JOURNAL_META_KEYS) {
1044
+ if (key in meta) (session as unknown as Record<string, unknown>)[key] = meta[key];
1045
+ else delete session[key];
1046
+ }
1047
+ }
1048
+ }
1049
+ return session;
1050
+ }
1051
+
958
1052
  /** A loaded session folder: snapshot (media paths resolved) + folder location. */
959
1053
  export interface LoadedSessionDir {
960
1054
  session: PersistedAgentSession;
@@ -985,9 +1079,17 @@ export function loadSessionDir(sessionPath: string, limits?: SessionLimits): Loa
985
1079
  // path does not exist yet — fall through to session.json lookup
986
1080
  }
987
1081
  const file = path.join(dir, "session.json");
988
- if (!fs.existsSync(file)) return null;
989
- const data = sessionFromPayload(fs.readFileSync(file, "utf8"), file, limits);
990
- if (!data) return null;
1082
+ const read = readWithBackup(file, isLoadableSession);
1083
+ if (!read) return null;
1084
+ const snapshot = sessionFromPayload(read.text, file, limits);
1085
+ if (!snapshot) return null;
1086
+ let journal: string | undefined;
1087
+ try {
1088
+ journal = fs.readFileSync(path.join(dir, JOURNAL_FILE), "utf8");
1089
+ } catch {
1090
+ journal = undefined; // no journal: the snapshot is the whole session
1091
+ }
1092
+ const data = journal ? replayJournal(snapshot, journal, path.join(dir, JOURNAL_FILE)) : snapshot;
991
1093
  data.messages = resolveMediaPaths(data.messages, dir);
992
1094
  return { session: data, dir };
993
1095
  } catch (err) {
@@ -1007,7 +1109,13 @@ export function findLatestSessionDir(rootDir: string): string | null {
1007
1109
  try {
1008
1110
  const st = fs.statSync(file);
1009
1111
  if (!st.isFile()) continue;
1010
- if (!best || st.mtimeMs > best.mtime) best = { dir: path.join(root, entry), mtime: st.mtimeMs };
1112
+ let mtime = st.mtimeMs;
1113
+ try {
1114
+ mtime = Math.max(mtime, fs.statSync(path.join(root, entry, JOURNAL_FILE)).mtimeMs); // steps land in the journal
1115
+ } catch {
1116
+ // why: no journal yet.
1117
+ }
1118
+ if (!best || mtime > best.mtime) best = { dir: path.join(root, entry), mtime };
1011
1119
  } catch {
1012
1120
  continue;
1013
1121
  }
@@ -1229,15 +1337,16 @@ function toDollars(microUSD: number): number {
1229
1337
  * `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
1230
1338
  * `pricing.*` (falling back to legacy `cost.*`) applies.
1231
1339
  */
1340
+ // Cache prices stay undefined when the catalog has none: priceUsage then bills those tokens at the input rate (never free).
1232
1341
  function resolveEffectivePricing(
1233
1342
  spec: ModelSpec | undefined,
1234
1343
  inputTokens: number
1235
- ): { input: number; output: number; cacheRead: number; cacheWrite: number; audio: number; tier: string } {
1344
+ ): { input: number; output: number; cacheRead: number | undefined; cacheWrite: number | undefined; audio: number; tier: string } {
1236
1345
  const base = {
1237
1346
  input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
1238
1347
  output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
1239
- cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined) ?? 0,
1240
- cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined) ?? 0,
1348
+ cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined),
1349
+ cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined),
1241
1350
  audio:
1242
1351
  spec?.pricing?.inputAudioPerMillion ?? (spec?.cost as { input_audio?: number } | undefined)?.input_audio ?? 0,
1243
1352
  };
@@ -1289,8 +1398,9 @@ function resolveEffectivePricing(
1289
1398
  * - Audio tokens (`usage.audioTokens` / `usage.inputAudioTokens` when present)
1290
1399
  * price at `pricing.inputAudioPerMillion` / `cost.input_audio` and fold into
1291
1400
  * the total (separately itemized in `breakdown.audioMicroUSD`).
1292
- * - `opts.serviceTier` (`flex` ~0.5x, `priority` ~1.5x, estimates) scales the
1401
+ * - `opts.serviceTier` (`flex` 0.5x, `priority` 2x, estimates) scales the
1293
1402
  * catalog total; provider-reported costs are never rescaled.
1403
+ * - Cache reads and writes are subsets of the input tokens; a missing cache price bills at the input rate.
1294
1404
  */
1295
1405
  export function priceUsage(
1296
1406
  usage: TokenUsage,
@@ -1339,12 +1449,15 @@ export function priceUsage(
1339
1449
  return undefined;
1340
1450
  }
1341
1451
  const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
1342
- const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
1452
+ // Cache reads and writes are both a SUBSET of the input tokens (OpenRouter `prompt_tokens` includes them), so the
1453
+ // plain input rate applies only to what is neither read from nor written to the cache.
1454
+ const freshInput = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens);
1343
1455
  // Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
1344
1456
  // ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
1345
- const inputMicroUSD = Math.round(nonCachedInput * pricing.input * multiplier);
1346
- const cacheReadMicroUSD = Math.round(cachedTokens * pricing.cacheRead * multiplier);
1347
- const cacheWriteMicroUSD = Math.round(cacheWriteTokens * pricing.cacheWrite * multiplier);
1457
+ // A catalog entry without a cache price bills those tokens at the input rate: cache reads/writes are never free.
1458
+ const inputMicroUSD = Math.round(freshInput * pricing.input * multiplier);
1459
+ const cacheReadMicroUSD = Math.round(cachedTokens * (pricing.cacheRead ?? pricing.input) * multiplier);
1460
+ const cacheWriteMicroUSD = Math.round(cacheWriteTokens * (pricing.cacheWrite ?? pricing.input) * multiplier);
1348
1461
  const outputMicroUSD = Math.round(outputTokens * pricing.output * multiplier);
1349
1462
  const audioMicroUSD = Math.round(audioCount * pricing.audio * multiplier);
1350
1463
  const totalMicroUSD = inputMicroUSD + cacheReadMicroUSD + cacheWriteMicroUSD + outputMicroUSD + audioMicroUSD;