agent-accelerator 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -14
- package/package.json +1 -1
- package/src/agent/agent.ts +194 -2
- package/src/agent/delegation.ts +388 -24
- package/src/agent/loop.ts +112 -5
- package/src/index.ts +34 -2
- package/src/models/catalog.ts +4 -3
- package/src/providers.ts +11 -9
- package/src/session/store.ts +806 -0
- package/src/tools/executor.ts +4 -1
- package/src/types/agent.ts +25 -1
- package/src/types/core.ts +2 -1
- package/src/types/response.ts +24 -0
- package/src/types/tool.ts +2 -0
- package/src/utils/session.ts +36 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation history + telemetry persistence for Agent Accelerator.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ~170 lines of manual JSONL / totals / cost code previously
|
|
5
|
+
* copied into `examples/05-chat.ts`. Devs now do:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { Agent, loadSessionFile, saveSessionFile, SessionTelemetry } from "agent-accelerator";
|
|
9
|
+
* const saved = loadSessionFile(".session.json");
|
|
10
|
+
* const telemetry = SessionTelemetry.fromSaved(saved);
|
|
11
|
+
* const agent = new Agent({ model: saved?.model ?? MODEL, sessionId: saved?.sessionId, ... });
|
|
12
|
+
* if (saved) agent.importSession(saved);
|
|
13
|
+
* const res = await agent.run(q);
|
|
14
|
+
* telemetry.add(res.usage, agent.modelStringOrSpec as string);
|
|
15
|
+
* saveSessionFile(".session.json", agent, telemetry);
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* For binary media (images, audio, video, files as bytes / data URLs /
|
|
19
|
+
* base64), prefer session folders — `saveSessionDir("sessions", agent,
|
|
20
|
+
* telemetry)` writes `sessions/<sessionId>/{session.json, media/*}` with
|
|
21
|
+
* parts rewritten to relative `media/…` paths, and
|
|
22
|
+
* `loadSessionDir(dir)` resolves them back.
|
|
23
|
+
*/
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import type { Message, ContentPart } from "../types/message.ts";
|
|
27
|
+
import type { TokenUsage, ThinkingLevel, CacheConfig } from "../types/core.ts";
|
|
28
|
+
import type { AgentResponse } from "../types/response.ts";
|
|
29
|
+
import { base64ToBytes } from "../utils/base64.ts";
|
|
30
|
+
import { getModelFromCatalog } from "../models/catalog.ts";
|
|
31
|
+
import type { SubAgentStep } from "../types/response.ts";
|
|
32
|
+
|
|
33
|
+
/** Cumulative per-session token + cost totals (JSON-safe). */
|
|
34
|
+
export interface SessionTotals {
|
|
35
|
+
input: number;
|
|
36
|
+
output: number;
|
|
37
|
+
cacheRead: number;
|
|
38
|
+
cacheWrite: number;
|
|
39
|
+
reasoning: number;
|
|
40
|
+
cost: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Persisted per-worker trace inside the same session file (dedicated section). */
|
|
44
|
+
export interface PersistedSubAgentTrace {
|
|
45
|
+
trackingId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
sessionId?: string;
|
|
48
|
+
parentSessionId?: string;
|
|
49
|
+
status: "running" | "done" | "error";
|
|
50
|
+
task: string;
|
|
51
|
+
role?: string;
|
|
52
|
+
model?: string;
|
|
53
|
+
provider?: string;
|
|
54
|
+
turns: number;
|
|
55
|
+
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
56
|
+
steps: SubAgentStep[];
|
|
57
|
+
text?: string;
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Single-file session snapshot (v1). Written as one JSON object. */
|
|
62
|
+
export interface PersistedAgentSession {
|
|
63
|
+
version: 1;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
model: string;
|
|
66
|
+
subagentModel?: string;
|
|
67
|
+
thinkingLevel?: string;
|
|
68
|
+
cache?: CacheConfig;
|
|
69
|
+
instructions?: string;
|
|
70
|
+
systemPrompt?: string;
|
|
71
|
+
cachedContentId?: string;
|
|
72
|
+
messages: Message[];
|
|
73
|
+
totals?: SessionTotals;
|
|
74
|
+
/** Parent session this snapshot was spawned from, when applicable. */
|
|
75
|
+
parentSessionId?: string;
|
|
76
|
+
/** Dedicated sub-agent traces, keyed by worker TrackingID (32-char hex). */
|
|
77
|
+
subagents?: Record<string, PersistedSubAgentTrace>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function emptyTotals(): SessionTotals {
|
|
81
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toNumber(n: unknown): number {
|
|
85
|
+
const v = typeof n === "number" ? n : Number(n);
|
|
86
|
+
return Number.isFinite(v) && v >= 0 ? v : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Best-effort model resolution without importing the registry (avoids cycles). */
|
|
90
|
+
function specFor(modelStr: string): ReturnType<typeof getModelFromCatalog> {
|
|
91
|
+
try {
|
|
92
|
+
const raw = String(modelStr ?? "").trim();
|
|
93
|
+
if (!raw) return undefined;
|
|
94
|
+
if (raw.includes("/")) {
|
|
95
|
+
const [prefix, ...rest] = raw.split("/");
|
|
96
|
+
const id = rest.join("/");
|
|
97
|
+
return (
|
|
98
|
+
getModelFromCatalog(prefix!, id) ||
|
|
99
|
+
getModelFromCatalog(prefix!.toLowerCase(), id) ||
|
|
100
|
+
getModelFromCatalog(id, id)
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return (
|
|
104
|
+
getModelFromCatalog("google", raw) ||
|
|
105
|
+
getModelFromCatalog("openai", raw) ||
|
|
106
|
+
getModelFromCatalog("openrouter", raw) ||
|
|
107
|
+
getModelFromCatalog(raw, raw)
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Context window for telemetry display. Falls back to 1M like the chat CLI. */
|
|
115
|
+
export function getSessionContextWindow(modelStr: string): number {
|
|
116
|
+
try {
|
|
117
|
+
const spec = specFor(modelStr);
|
|
118
|
+
if (spec?.limit?.context) return spec.limit.context;
|
|
119
|
+
if (spec?.contextWindow) return spec.contextWindow;
|
|
120
|
+
} catch {}
|
|
121
|
+
return 1_048_576;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Turn cost: prefers provider-reported total, else catalog pricing. */
|
|
125
|
+
export function computeSessionTurnCost(usage: TokenUsage, modelStr: string): number {
|
|
126
|
+
const reported = (usage as TokenUsage)?.cost?.totalCost ?? 0;
|
|
127
|
+
if (reported > 0) return reported;
|
|
128
|
+
try {
|
|
129
|
+
const spec = specFor(modelStr);
|
|
130
|
+
const inputP = spec?.pricing?.inputPerMillion ?? spec?.cost?.input ?? 0;
|
|
131
|
+
const outputP = spec?.pricing?.outputPerMillion ?? spec?.cost?.output ?? 0;
|
|
132
|
+
const crP = spec?.pricing?.cacheReadPerMillion ?? spec?.cost?.cache_read ?? 0;
|
|
133
|
+
const cwP = spec?.pricing?.cacheWritePerMillion ?? spec?.cost?.cache_write ?? 0;
|
|
134
|
+
if (!inputP && !outputP && !crP && !cwP) return 0;
|
|
135
|
+
const cr = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
|
|
136
|
+
const uncachedIn = Math.max(0, (usage.inputTokens ?? 0) - cr);
|
|
137
|
+
return (
|
|
138
|
+
(uncachedIn / 1e6) * inputP +
|
|
139
|
+
(cr / 1e6) * crP +
|
|
140
|
+
((usage.cacheWriteTokens ?? 0) / 1e6) * cwP +
|
|
141
|
+
((usage.outputTokens ?? 0) / 1e6) * outputP
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatSessionTokens(n: number): string {
|
|
149
|
+
const v = toNumber(n);
|
|
150
|
+
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
|
|
151
|
+
if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
|
|
152
|
+
return `${Math.floor(v)}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function formatSessionCost(c?: number): string {
|
|
156
|
+
if (!c || c <= 0) return "$0.00";
|
|
157
|
+
if (c < 0.0001) return `$${c.toFixed(6)}`;
|
|
158
|
+
if (c < 0.01) return `$${c.toFixed(4)}`;
|
|
159
|
+
if (c < 1.0) return `$${c.toFixed(3)}`;
|
|
160
|
+
return `$${c.toFixed(2)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Cumulative usage tracker for one chat session.
|
|
165
|
+
*
|
|
166
|
+
* @example `const t = SessionTelemetry.fromSaved(saved); t.add(res.usage, model);`
|
|
167
|
+
*/
|
|
168
|
+
export class SessionTelemetry {
|
|
169
|
+
totals: SessionTotals;
|
|
170
|
+
|
|
171
|
+
constructor(totals?: SessionTotals | null) {
|
|
172
|
+
this.totals = {
|
|
173
|
+
input: toNumber(totals?.input),
|
|
174
|
+
output: toNumber(totals?.output),
|
|
175
|
+
cacheRead: toNumber(totals?.cacheRead),
|
|
176
|
+
cacheWrite: toNumber(totals?.cacheWrite),
|
|
177
|
+
reasoning: toNumber(totals?.reasoning),
|
|
178
|
+
cost: toNumber(totals?.cost),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Restores totals from a loaded session (tolerates legacy `metrics` shape). */
|
|
183
|
+
static fromSaved(saved?: PersistedAgentSession | { totals?: SessionTotals; metrics?: Record<string, number> } | null): SessionTelemetry {
|
|
184
|
+
const raw: any = (saved as any)?.totals ?? (saved as any)?.metrics ?? null;
|
|
185
|
+
if (!raw) return new SessionTelemetry();
|
|
186
|
+
// Legacy metrics files used {input,output,cacheRead,cacheWrite,reasoning,cost}
|
|
187
|
+
// with identical keys — accept as-is.
|
|
188
|
+
return new SessionTelemetry(raw as SessionTotals);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Adds one turn's usage; returns the turn cost. */
|
|
192
|
+
add(usage: TokenUsage, modelStr: string): number {
|
|
193
|
+
this.totals.input += usage.inputTokens ?? 0;
|
|
194
|
+
this.totals.output += usage.outputTokens ?? 0;
|
|
195
|
+
this.totals.cacheRead += usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
|
|
196
|
+
this.totals.cacheWrite += usage.cacheWriteTokens ?? 0;
|
|
197
|
+
this.totals.reasoning += usage.thinkingTokens ?? 0;
|
|
198
|
+
const turnCost = computeSessionTurnCost(usage, modelStr);
|
|
199
|
+
this.totals.cost += turnCost;
|
|
200
|
+
return turnCost;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
reset(): void {
|
|
204
|
+
this.totals = emptyTotals();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
toJSON(): SessionTotals {
|
|
208
|
+
return { ...this.totals };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Per-turn cache-hit % (0-100) for one usage snapshot. */
|
|
212
|
+
turnHitRate(usage: TokenUsage): number {
|
|
213
|
+
const input = usage.inputTokens ?? 0;
|
|
214
|
+
if (input <= 0) return 0;
|
|
215
|
+
const cr = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
|
|
216
|
+
return Math.min(100, Math.max(0, (cr / input) * 100));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Session-total cache-hit % (0-100) across all added turns. */
|
|
220
|
+
totalHitRate(): number {
|
|
221
|
+
if (this.totals.input <= 0) return 0;
|
|
222
|
+
return Math.min(100, Math.max(0, (this.totals.cacheRead / this.totals.input) * 100));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One-line status bar with BOTH turn and session hit rates (03-multi_agent style).
|
|
227
|
+
* Shows per-turn tokens + `turn-CH%`, session totals + `total-CH%`, cost, context %.
|
|
228
|
+
* `thinkingLevel` defaults to the agent's level when omitted.
|
|
229
|
+
*/
|
|
230
|
+
formatBar(
|
|
231
|
+
res: AgentResponse,
|
|
232
|
+
opts?: { model?: string; thinkingLevel?: string }
|
|
233
|
+
): string {
|
|
234
|
+
const inTok = res.usage.inputTokens ?? 0;
|
|
235
|
+
const outTok = res.usage.outputTokens ?? 0;
|
|
236
|
+
const crTok = res.usage.cachedTokens ?? res.usage.cacheReadTokens ?? 0;
|
|
237
|
+
const cwTurnTok = res.usage.cacheWriteTokens ?? 0;
|
|
238
|
+
const turnRate = (this.turnHitRate(res.usage)).toFixed(1);
|
|
239
|
+
const totalRate = this.totalHitRate().toFixed(1);
|
|
240
|
+
const cwTurnLabel = cwTurnTok > 0 ? ` CW${formatSessionTokens(cwTurnTok)}` : "";
|
|
241
|
+
const totCW = this.totals.cacheWrite;
|
|
242
|
+
const totCWLabel = totCW > 0 ? ` CW${formatSessionTokens(totCW)}` : "";
|
|
243
|
+
const modelStr = opts?.model ?? `${res.provider}/${res.model}`;
|
|
244
|
+
const window = getSessionContextWindow(modelStr);
|
|
245
|
+
const usedTokens = this.totals.input + this.totals.output + this.totals.cacheRead;
|
|
246
|
+
const pct = window > 0 ? ((usedTokens / window) * 100).toFixed(1) : "0.0";
|
|
247
|
+
const turnCost = computeSessionTurnCost(res.usage, modelStr);
|
|
248
|
+
const costDelta = turnCost > 0 ? ` (+${formatSessionCost(turnCost)})` : "";
|
|
249
|
+
const level = opts?.thinkingLevel ?? "none";
|
|
250
|
+
return `\x1b[35m↑${formatSessionTokens(inTok)} ↓${formatSessionTokens(outTok)} CR${formatSessionTokens(crTok)}${cwTurnLabel} turn-CH${turnRate}% | ↑${formatSessionTokens(this.totals.input)} ↓${formatSessionTokens(this.totals.output)} CR${formatSessionTokens(this.totals.cacheRead)}${totCWLabel} total-CH${totalRate}% ${formatSessionCost(this.totals.cost)}${costDelta} ${pct}%/${formatSessionTokens(window)} • ${res.provider}/${res.model} • ${level} ${res.durationMs}ms ${res.finishReason ?? "STOP"}\x1b[0m`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Startup banner for the chat CLI: distinguishes resumed vs fresh sessions
|
|
256
|
+
* and surfaces the session-total hit rate immediately on resume.
|
|
257
|
+
*
|
|
258
|
+
* @example `console.log(formatSessionBanner(saved, telemetry, SESSION_FILE))`
|
|
259
|
+
*/
|
|
260
|
+
export function formatSessionBanner(
|
|
261
|
+
saved: PersistedAgentSession | null,
|
|
262
|
+
telemetry: SessionTelemetry,
|
|
263
|
+
filePath: string
|
|
264
|
+
): string {
|
|
265
|
+
const shortFile = filePath.split("/").pop() ?? filePath;
|
|
266
|
+
if (saved) {
|
|
267
|
+
const msgCount = saved.messages.length;
|
|
268
|
+
const msgLabel = `${msgCount} message${msgCount === 1 ? "" : "s"}`;
|
|
269
|
+
const modelLabel = saved.model || "unknown model";
|
|
270
|
+
const levelLabel = saved.thinkingLevel ? ` • ${saved.thinkingLevel}` : "";
|
|
271
|
+
const totalRate = telemetry.totalHitRate().toFixed(1);
|
|
272
|
+
const costLabel = formatSessionCost(telemetry.totals.cost);
|
|
273
|
+
const shortId = (saved.sessionId ?? "").slice(0, 8);
|
|
274
|
+
return `\x1b[32m↺ Previous session loaded\x1b[0m \x1b[90m• ${shortId}… (${shortFile}) • ${msgLabel} • ${modelLabel}${levelLabel} • total-CH${totalRate}% • ${costLabel} total\x1b[0m`;
|
|
275
|
+
}
|
|
276
|
+
return `\x1b[90mNew session • ${shortFile} (no prior history found)\x1b[0m`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Minimal agent surface needed for persistence (avoids an Agent import cycle). */
|
|
280
|
+
export interface SessionAgentLike {
|
|
281
|
+
sessionId: string;
|
|
282
|
+
modelStringOrSpec: unknown;
|
|
283
|
+
subagentModel?: unknown;
|
|
284
|
+
thinkingConfig?: { level?: string };
|
|
285
|
+
cacheConfig?: CacheConfig;
|
|
286
|
+
instructions?: string;
|
|
287
|
+
parentSessionId?: unknown;
|
|
288
|
+
subagentTraces?: Record<string, PersistedSubAgentTrace>;
|
|
289
|
+
context: { systemPrompt?: string; messages: Message[]; cachedContentId?: string };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | undefined): Record<string, PersistedSubAgentTrace> | undefined {
|
|
293
|
+
if (!traces) return undefined;
|
|
294
|
+
const keys = Object.keys(traces);
|
|
295
|
+
if (keys.length === 0) return undefined;
|
|
296
|
+
// Key by raw TrackingID (32-hex): the live registry also holds display keys
|
|
297
|
+
// (`NAME-{id}`), but the file keeps one lean key per worker.
|
|
298
|
+
const byId = new Map<string, PersistedSubAgentTrace>();
|
|
299
|
+
for (const k of keys) {
|
|
300
|
+
const t = traces[k]!;
|
|
301
|
+
if (!t || typeof t !== "object") continue;
|
|
302
|
+
let id = (typeof t.trackingId === "string" && t.trackingId) || k;
|
|
303
|
+
// Normalize display ids (`NAME-{32hex}`) to the raw TrackingID so the
|
|
304
|
+
// live trace and its merged copy collapse to one entry.
|
|
305
|
+
const tail = id.split("-").pop() ?? "";
|
|
306
|
+
if (/^[0-9a-f]{32}$/.test(tail)) id = tail;
|
|
307
|
+
else if (/^[0-9a-f]{32}$/.test(k)) id = k;
|
|
308
|
+
if (!byId.has(id)) byId.set(id, t);
|
|
309
|
+
}
|
|
310
|
+
if (byId.size === 0) return undefined;
|
|
311
|
+
const lean: Record<string, PersistedSubAgentTrace> = {};
|
|
312
|
+
for (const [id, t] of byId) {
|
|
313
|
+
// Drop assistant step text that merely repeats the final answer: the
|
|
314
|
+
// step marker (turn/type/timestamp) stays, the full text lives on trace.text.
|
|
315
|
+
const steps = (t.steps ?? []).map((s) =>
|
|
316
|
+
s.type === "assistant" && typeof s.text === "string" && typeof t.text === "string" && s.text === t.text
|
|
317
|
+
? { turn: s.turn, type: s.type, timestamp: s.timestamp }
|
|
318
|
+
: { ...s }
|
|
319
|
+
);
|
|
320
|
+
lean[id] = { ...t, steps };
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(JSON.stringify(lean)) as Record<string, PersistedSubAgentTrace>;
|
|
324
|
+
} catch {
|
|
325
|
+
return lean;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Canonical JSON for deep comparison (sorted keys; safe values only). */
|
|
330
|
+
function stableStringify(value: unknown): string {
|
|
331
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
332
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
333
|
+
const entries = Object.keys(value as Record<string, unknown>)
|
|
334
|
+
.sort()
|
|
335
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`);
|
|
336
|
+
return `{${entries.join(",")}}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Drops `rawArguments` from `tool_call` parts when it re-parses to the
|
|
341
|
+
* stored `arguments` (pure duplicate, the common case). Divergent or
|
|
342
|
+
* unparseable wire strings are kept — they carry info parsing lost.
|
|
343
|
+
*/
|
|
344
|
+
function stripRedundantRawArguments(messages: Message[]): Message[] {
|
|
345
|
+
return messages.map((m) => {
|
|
346
|
+
if (!Array.isArray((m as Message).content)) return m;
|
|
347
|
+
let changed = false;
|
|
348
|
+
const content = (m as Message).content as ContentPart[];
|
|
349
|
+
const next = content.map((p: any) => {
|
|
350
|
+
if (!p || p.type !== "tool_call" || typeof p.rawArguments !== "string") return p;
|
|
351
|
+
try {
|
|
352
|
+
const parsed: unknown = JSON.parse(p.rawArguments);
|
|
353
|
+
if (stableStringify(parsed) === stableStringify(p.arguments ?? {})) {
|
|
354
|
+
changed = true;
|
|
355
|
+
const { rawArguments: _dropped, ...rest } = p;
|
|
356
|
+
return rest;
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
// unparseable wire — keep it
|
|
360
|
+
}
|
|
361
|
+
return p;
|
|
362
|
+
});
|
|
363
|
+
return changed ? { ...m, content: next } : m;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Builds a storable snapshot from a live agent + optional telemetry. */
|
|
368
|
+
export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTelemetry | SessionTotals | null): PersistedAgentSession {
|
|
369
|
+
const rawModel = agent.modelStringOrSpec as any;
|
|
370
|
+
const model = typeof rawModel === "string" ? rawModel : (rawModel?.id ?? String(rawModel ?? ""));
|
|
371
|
+
const rawSub = agent.subagentModel as any;
|
|
372
|
+
const subagentModel =
|
|
373
|
+
typeof rawSub === "string" ? rawSub : rawSub?.model ?? rawSub?.id ?? undefined;
|
|
374
|
+
const totals = telemetry instanceof SessionTelemetry
|
|
375
|
+
? telemetry.toJSON()
|
|
376
|
+
: telemetry ?? undefined;
|
|
377
|
+
const parentSessionId =
|
|
378
|
+
typeof (agent as any)?.parentSessionId === "string" ? (agent as any).parentSessionId as string : undefined;
|
|
379
|
+
const subagents = snapshotSubagents((agent as any)?.subagentTraces as Record<string, PersistedSubAgentTrace> | undefined);
|
|
380
|
+
const messages = stripRedundantRawArguments(agent.context.messages);
|
|
381
|
+
return {
|
|
382
|
+
version: 1,
|
|
383
|
+
sessionId: agent.sessionId,
|
|
384
|
+
model,
|
|
385
|
+
...(subagentModel ? { subagentModel: String(subagentModel) } : {}),
|
|
386
|
+
...(agent.thinkingConfig?.level ? { thinkingLevel: agent.thinkingConfig.level as string } : {}),
|
|
387
|
+
...(agent.cacheConfig ? { cache: agent.cacheConfig } : {}),
|
|
388
|
+
...(agent.instructions ? { instructions: agent.instructions } : {}),
|
|
389
|
+
...(agent.context.systemPrompt ? { systemPrompt: agent.context.systemPrompt } : {}),
|
|
390
|
+
...(agent.context.cachedContentId ? { cachedContentId: agent.context.cachedContentId } : {}),
|
|
391
|
+
messages,
|
|
392
|
+
...(totals ? { totals } : {}),
|
|
393
|
+
...(parentSessionId ? { parentSessionId } : {}),
|
|
394
|
+
...(subagents ? { subagents } : {}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Serializes a snapshot to a pretty-printed `.json` document (2-space indent). */
|
|
399
|
+
export function serializeSession(data: PersistedAgentSession): string {
|
|
400
|
+
return JSON.stringify({ ...data, version: 1 }, null, 2);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Parses new pretty `.session.json` snapshots AND legacy `.session.jsonl` documents. */
|
|
404
|
+
export function deserializeSession(raw: string): PersistedAgentSession | null {
|
|
405
|
+
const text = (raw ?? "").trim();
|
|
406
|
+
if (!text) return null;
|
|
407
|
+
try {
|
|
408
|
+
// Legacy JSONL: lines shaped {type: session|mainModel|subagentModel|metrics|message|...}
|
|
409
|
+
if (text.startsWith('{"type"')) {
|
|
410
|
+
let sessionId = "";
|
|
411
|
+
let model = "";
|
|
412
|
+
let subagentModel: string | undefined;
|
|
413
|
+
let thinkingLevel: string | undefined;
|
|
414
|
+
let cache: CacheConfig | undefined;
|
|
415
|
+
let cachedContentId: string | undefined;
|
|
416
|
+
let systemPrompt: string | undefined;
|
|
417
|
+
let instructions: string | undefined;
|
|
418
|
+
let parentSessionId: string | undefined;
|
|
419
|
+
let subagents: Record<string, PersistedSubAgentTrace> | undefined;
|
|
420
|
+
const messages: Message[] = [];
|
|
421
|
+
let totals: SessionTotals | undefined;
|
|
422
|
+
for (const line of text.split("\n")) {
|
|
423
|
+
const t = line.trim();
|
|
424
|
+
if (!t) continue;
|
|
425
|
+
try {
|
|
426
|
+
const obj: any = JSON.parse(t);
|
|
427
|
+
if (obj.type === "session") {
|
|
428
|
+
sessionId = obj.id ?? obj.sessionId ?? sessionId;
|
|
429
|
+
cache = obj.cache ?? cache;
|
|
430
|
+
cachedContentId = obj.cachedContentId ?? cachedContentId;
|
|
431
|
+
model = obj.model ?? model;
|
|
432
|
+
thinkingLevel = obj.thinkingLevel ?? thinkingLevel;
|
|
433
|
+
parentSessionId = obj.parentSessionId ?? parentSessionId;
|
|
434
|
+
subagents = obj.subagents ?? subagents;
|
|
435
|
+
} else if (obj.type === "mainModel") {
|
|
436
|
+
model = obj.id ?? obj.model ?? model;
|
|
437
|
+
thinkingLevel = obj.thinkingLevel ?? thinkingLevel;
|
|
438
|
+
} else if (obj.type === "subagentModel") {
|
|
439
|
+
subagentModel = obj.id ?? obj.model ?? subagentModel;
|
|
440
|
+
} else if (obj.type === "metrics" || obj.type === "totals") {
|
|
441
|
+
totals = (obj.metrics ?? obj.totals ?? totals) as SessionTotals | undefined;
|
|
442
|
+
cache = obj.cache ?? cache;
|
|
443
|
+
cachedContentId = obj.cachedContentId ?? cachedContentId;
|
|
444
|
+
} else if (obj.type === "modelChange") {
|
|
445
|
+
model = obj.modelId ?? obj.model ?? model;
|
|
446
|
+
} else if (obj.type === "thinkingLevelChange") {
|
|
447
|
+
thinkingLevel = obj.thinkingLevel;
|
|
448
|
+
} else if (obj.type === "message" && obj.message) {
|
|
449
|
+
messages.push(obj.message as Message);
|
|
450
|
+
} else if (obj.type === "context") {
|
|
451
|
+
if (obj.systemPrompt) systemPrompt = obj.systemPrompt;
|
|
452
|
+
if (Array.isArray(obj.messages)) messages.push(...(obj.messages as Message[]));
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// skip corrupt lines — one bad turn must not drop the session
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!sessionId && messages.length === 0) return null;
|
|
459
|
+
return {
|
|
460
|
+
version: 1,
|
|
461
|
+
sessionId: sessionId || `accel-${Date.now()}`,
|
|
462
|
+
model,
|
|
463
|
+
...(subagentModel ? { subagentModel } : {}),
|
|
464
|
+
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
465
|
+
...(cache ? { cache } : {}),
|
|
466
|
+
...(instructions ? { instructions } : {}),
|
|
467
|
+
...(systemPrompt ? { systemPrompt } : {}),
|
|
468
|
+
...(cachedContentId ? { cachedContentId } : {}),
|
|
469
|
+
messages,
|
|
470
|
+
...(totals ? { totals } : {}),
|
|
471
|
+
...(parentSessionId ? { parentSessionId } : {}),
|
|
472
|
+
...(subagents ? { subagents } : {}),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const obj: any = JSON.parse(text);
|
|
476
|
+
if (!obj || typeof obj !== "object") return null;
|
|
477
|
+
// Very old single-JSON shape without version: {sessionId, model, context:{messages}}
|
|
478
|
+
if (obj.context && Array.isArray(obj.context.messages) && !Array.isArray(obj.messages)) {
|
|
479
|
+
return {
|
|
480
|
+
version: 1,
|
|
481
|
+
sessionId: obj.sessionId ?? obj.id ?? `accel-${Date.now()}`,
|
|
482
|
+
model: obj.model ?? "",
|
|
483
|
+
subagentModel: obj.subagentModel,
|
|
484
|
+
thinkingLevel: obj.thinkingLevel ?? obj.thinkingLevelChange,
|
|
485
|
+
cache: obj.cache,
|
|
486
|
+
instructions: obj.instructions,
|
|
487
|
+
systemPrompt: obj.context.systemPrompt,
|
|
488
|
+
cachedContentId: obj.cachedContentId ?? obj.context.cachedContentId,
|
|
489
|
+
messages: obj.context.messages as Message[],
|
|
490
|
+
totals: obj.totals ?? obj.metrics,
|
|
491
|
+
parentSessionId: obj.parentSessionId,
|
|
492
|
+
subagents: obj.subagents,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (!obj.sessionId && !Array.isArray(obj.messages)) return null;
|
|
496
|
+
return {
|
|
497
|
+
version: 1,
|
|
498
|
+
sessionId: obj.sessionId ?? `accel-${Date.now()}`,
|
|
499
|
+
model: obj.model ?? "",
|
|
500
|
+
subagentModel: obj.subagentModel,
|
|
501
|
+
thinkingLevel: obj.thinkingLevel,
|
|
502
|
+
cache: obj.cache,
|
|
503
|
+
instructions: obj.instructions,
|
|
504
|
+
systemPrompt: obj.systemPrompt,
|
|
505
|
+
cachedContentId: obj.cachedContentId,
|
|
506
|
+
messages: Array.isArray(obj.messages) ? (obj.messages as Message[]) : [],
|
|
507
|
+
totals: obj.totals,
|
|
508
|
+
parentSessionId: obj.parentSessionId,
|
|
509
|
+
subagents: obj.subagents,
|
|
510
|
+
};
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
|
|
517
|
+
export function loadSessionFile(filePath: string): PersistedAgentSession | null {
|
|
518
|
+
try {
|
|
519
|
+
const resolved = path.resolve(filePath);
|
|
520
|
+
if (!fs.existsSync(resolved)) return null;
|
|
521
|
+
return deserializeSession(fs.readFileSync(resolved, "utf8"));
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Atomic file write (temp + rename) so a crash never leaves half-written JSON. */
|
|
528
|
+
export function writeFileAtomic(filePath: string, content: string): void {
|
|
529
|
+
const dir = path.dirname(filePath);
|
|
530
|
+
try {
|
|
531
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
532
|
+
} catch {}
|
|
533
|
+
let tag = "tmp";
|
|
534
|
+
try {
|
|
535
|
+
const pid = (globalThis as any)?.process?.pid;
|
|
536
|
+
tag = `${typeof pid === "number" ? pid : "np"}-${Math.random().toString(36).slice(2, 8)}`;
|
|
537
|
+
} catch {
|
|
538
|
+
tag = Math.random().toString(36).slice(2, 8);
|
|
539
|
+
}
|
|
540
|
+
const tmp = `${filePath}.tmp-${tag}`;
|
|
541
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
542
|
+
fs.renameSync(tmp, filePath);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
|
|
546
|
+
export function saveSessionFile(
|
|
547
|
+
filePath: string,
|
|
548
|
+
agent: SessionAgentLike,
|
|
549
|
+
telemetry?: SessionTelemetry | SessionTotals | null
|
|
550
|
+
): void {
|
|
551
|
+
const resolved = path.resolve(filePath);
|
|
552
|
+
const data = buildSessionData(agent, telemetry);
|
|
553
|
+
try {
|
|
554
|
+
writeFileAtomic(resolved, serializeSession(data) + "\n");
|
|
555
|
+
} catch {
|
|
556
|
+
// persistence must never crash a chat turn
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Session directories: sessions/<sessionId>/{session.json, media/*}
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
const MEDIA_PART_KEYS = ["image", "audio", "video", "file"] as const;
|
|
565
|
+
|
|
566
|
+
function isHttpRef(value: string): boolean {
|
|
567
|
+
return value.startsWith("http://") || value.startsWith("https://");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined {
|
|
571
|
+
const m = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.+)$/s);
|
|
572
|
+
if (m && m[2]) return { mimeType: m[1] || "application/octet-stream", base64: m[2] };
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Same embedded-bytes heuristic as the media normalizer (paths fail it on their own). */
|
|
577
|
+
function looksLikeEmbeddedBase64(value: string): boolean {
|
|
578
|
+
if (value.includes("\\") || value.length <= 100) return false;
|
|
579
|
+
if (value.length % 4 !== 0) return false;
|
|
580
|
+
return /^[A-Za-z0-9+/=\n\r]+$/.test(value.slice(0, 500));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function extForMedia(mimeType?: string, filename?: string): string {
|
|
584
|
+
const mime = (mimeType || "").toLowerCase().split(";")[0]!.trim();
|
|
585
|
+
const known: Record<string, string> = {
|
|
586
|
+
"image/png": "png",
|
|
587
|
+
"image/jpeg": "jpg",
|
|
588
|
+
"image/webp": "webp",
|
|
589
|
+
"image/gif": "gif",
|
|
590
|
+
"audio/mpeg": "mp3",
|
|
591
|
+
"audio/mp3": "mp3",
|
|
592
|
+
"audio/wav": "wav",
|
|
593
|
+
"audio/x-wav": "wav",
|
|
594
|
+
"audio/ogg": "ogg",
|
|
595
|
+
"video/mp4": "mp4",
|
|
596
|
+
"video/webm": "webm",
|
|
597
|
+
"video/quicktime": "mov",
|
|
598
|
+
"application/pdf": "pdf",
|
|
599
|
+
"text/csv": "csv",
|
|
600
|
+
"text/plain": "txt",
|
|
601
|
+
};
|
|
602
|
+
if (mime && known[mime]) return known[mime]!;
|
|
603
|
+
if (filename) {
|
|
604
|
+
const ext = filename.split("?")[0]!.split(".").pop()?.toLowerCase() ?? "";
|
|
605
|
+
if (/^[a-z0-9]{1,5}$/.test(ext)) return ext;
|
|
606
|
+
}
|
|
607
|
+
if (mime) {
|
|
608
|
+
const sub = mime.split("/").pop()?.toLowerCase() ?? "";
|
|
609
|
+
if (/^[a-z0-9]{1,5}$/.test(sub)) return sub;
|
|
610
|
+
}
|
|
611
|
+
return "bin";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function cloneMessages(messages: Message[]): Message[] {
|
|
615
|
+
try {
|
|
616
|
+
const g: any = globalThis as any;
|
|
617
|
+
if (typeof g.structuredClone === "function") return g.structuredClone(messages);
|
|
618
|
+
} catch {}
|
|
619
|
+
try {
|
|
620
|
+
return JSON.parse(JSON.stringify(messages));
|
|
621
|
+
} catch {
|
|
622
|
+
return [...messages];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Extracts embedded media bytes (binary, `data:` URLs, raw base64) into
|
|
628
|
+
* `<sessionDir>/media/` and rewrites those parts to relative `media/…`
|
|
629
|
+
* paths. Remote URLs and filesystem paths are left as references.
|
|
630
|
+
*/
|
|
631
|
+
export function extractMediaToDir(
|
|
632
|
+
messages: Message[],
|
|
633
|
+
sessionDir: string
|
|
634
|
+
): { messages: Message[]; wrote: string[] } {
|
|
635
|
+
const cloned = cloneMessages(messages);
|
|
636
|
+
const mediaDir = path.join(sessionDir, "media");
|
|
637
|
+
try {
|
|
638
|
+
fs.rmSync(mediaDir, { recursive: true, force: true });
|
|
639
|
+
} catch {}
|
|
640
|
+
const wrote: string[] = [];
|
|
641
|
+
const counters: Record<string, number> = {};
|
|
642
|
+
let hasBinary = false;
|
|
643
|
+
|
|
644
|
+
for (const msg of cloned) {
|
|
645
|
+
if (!Array.isArray((msg as Message).content)) continue;
|
|
646
|
+
for (const part of (msg as Message).content as ContentPart[]) {
|
|
647
|
+
const p: any = part as any;
|
|
648
|
+
if (!p || typeof p.type !== "string") continue;
|
|
649
|
+
if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
|
|
650
|
+
const key = p.type as (typeof MEDIA_PART_KEYS)[number];
|
|
651
|
+
const raw = p[key] as unknown;
|
|
652
|
+
let bytes: Uint8Array | undefined;
|
|
653
|
+
let mimeType: string | undefined = typeof p.mimeType === "string" ? p.mimeType : undefined;
|
|
654
|
+
if (raw instanceof Uint8Array) bytes = raw;
|
|
655
|
+
else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
|
|
656
|
+
else if (typeof raw === "string") {
|
|
657
|
+
if (isHttpRef(raw)) continue; // remote reference — provider fetches per turn
|
|
658
|
+
const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
|
|
659
|
+
if (dataUrl) {
|
|
660
|
+
bytes = base64ToBytes(dataUrl.base64);
|
|
661
|
+
mimeType = mimeType ?? dataUrl.mimeType;
|
|
662
|
+
} else if (looksLikeEmbeddedBase64(raw)) {
|
|
663
|
+
bytes = base64ToBytes(raw.replace(/\s/g, ""));
|
|
664
|
+
} else continue; // local path or short label — keep as reference
|
|
665
|
+
} else continue;
|
|
666
|
+
|
|
667
|
+
hasBinary = true;
|
|
668
|
+
const n = (counters[key] = (counters[key] ?? 0) + 1);
|
|
669
|
+
const ext = extForMedia(mimeType, typeof p.filename === "string" ? p.filename : undefined);
|
|
670
|
+
const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
|
|
671
|
+
try {
|
|
672
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
673
|
+
fs.writeFileSync(path.join(mediaDir, file), bytes);
|
|
674
|
+
} catch {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
p[key] = `media/${file}`;
|
|
678
|
+
if (mimeType) p.mimeType = mimeType;
|
|
679
|
+
wrote.push(`media/${file}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// No embedded bytes: don't leave an empty media/ behind.
|
|
684
|
+
if (!hasBinary) {
|
|
685
|
+
try {
|
|
686
|
+
fs.rmSync(mediaDir, { recursive: true, force: true });
|
|
687
|
+
} catch {}
|
|
688
|
+
}
|
|
689
|
+
return { messages: cloned, wrote };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Resolves `media/…` relative refs to absolute paths anchored at the session
|
|
694
|
+
* folder, so a resumed session runs from any cwd. Absolute paths, URLs, and
|
|
695
|
+
* non-media strings pass through untouched.
|
|
696
|
+
*/
|
|
697
|
+
export function resolveMediaPaths(messages: Message[], sessionDir: string): Message[] {
|
|
698
|
+
const cloned = cloneMessages(messages);
|
|
699
|
+
for (const msg of cloned) {
|
|
700
|
+
if (!Array.isArray((msg as Message).content)) continue;
|
|
701
|
+
for (const part of (msg as Message).content as ContentPart[]) {
|
|
702
|
+
const p: any = part as any;
|
|
703
|
+
if (!p || typeof p.type !== "string") continue;
|
|
704
|
+
if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
|
|
705
|
+
const v = p[p.type] as unknown;
|
|
706
|
+
if (typeof v === "string" && (v === "media" || v.startsWith("media/"))) {
|
|
707
|
+
p[p.type] = path.join(sessionDir, v);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return cloned;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** `<rootDir>/<sessionId>` — one folder per session. */
|
|
715
|
+
export function sessionDirFor(rootDir: string, sessionId: string): string {
|
|
716
|
+
return path.join(path.resolve(rootDir), sessionId);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Saves `sessions/<sessionId>/session.json` (pretty, 2-space) plus
|
|
721
|
+
* `media/*` for embedded bytes. Returns the session folder. Re-saving the
|
|
722
|
+
* same session id rewrites the folder (no orphan accumulation).
|
|
723
|
+
*/
|
|
724
|
+
export function saveSessionDir(
|
|
725
|
+
rootDir: string,
|
|
726
|
+
agent: SessionAgentLike,
|
|
727
|
+
telemetry?: SessionTelemetry | SessionTotals | null
|
|
728
|
+
): string {
|
|
729
|
+
const sessionDir = sessionDirFor(rootDir, agent.sessionId);
|
|
730
|
+
try {
|
|
731
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
732
|
+
} catch {}
|
|
733
|
+
const { messages } = extractMediaToDir(agent.context.messages, sessionDir);
|
|
734
|
+
const data = buildSessionData(
|
|
735
|
+
{ ...agent, context: { ...agent.context, messages } },
|
|
736
|
+
telemetry
|
|
737
|
+
);
|
|
738
|
+
try {
|
|
739
|
+
writeFileAtomic(path.join(sessionDir, "session.json"), serializeSession(data) + "\n");
|
|
740
|
+
} catch {
|
|
741
|
+
// persistence must never crash a chat turn
|
|
742
|
+
}
|
|
743
|
+
return sessionDir;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A loaded session folder: snapshot (media paths resolved) + folder location. */
|
|
747
|
+
export interface LoadedSessionDir {
|
|
748
|
+
session: PersistedAgentSession;
|
|
749
|
+
dir: string;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Loads a session folder (`<dir>/` or `<dir>/session.json`, plus legacy
|
|
754
|
+
* single files). Relative `media/…` refs resolve against the folder.
|
|
755
|
+
* Returns null when nothing loadable exists.
|
|
756
|
+
*/
|
|
757
|
+
export function loadSessionDir(sessionPath: string): LoadedSessionDir | null {
|
|
758
|
+
try {
|
|
759
|
+
const resolved = path.resolve(sessionPath);
|
|
760
|
+
let dir = resolved;
|
|
761
|
+
try {
|
|
762
|
+
const st = fs.statSync(resolved);
|
|
763
|
+
if (st.isFile()) {
|
|
764
|
+
const single = loadSessionFile(resolved);
|
|
765
|
+
if (!single) return null;
|
|
766
|
+
dir = path.dirname(resolved);
|
|
767
|
+
if (single.messages) {
|
|
768
|
+
single.messages = resolveMediaPaths(single.messages, dir);
|
|
769
|
+
}
|
|
770
|
+
return { session: single, dir };
|
|
771
|
+
}
|
|
772
|
+
} catch {
|
|
773
|
+
// path does not exist yet — fall through to session.json lookup
|
|
774
|
+
}
|
|
775
|
+
const file = path.join(dir, "session.json");
|
|
776
|
+
if (!fs.existsSync(file)) return null;
|
|
777
|
+
const data = deserializeSession(fs.readFileSync(file, "utf8"));
|
|
778
|
+
if (!data) return null;
|
|
779
|
+
data.messages = resolveMediaPaths(data.messages, dir);
|
|
780
|
+
return { session: data, dir };
|
|
781
|
+
} catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Most-recently-modified `sessions/<id>` folder, for resume-latest CLIs. */
|
|
787
|
+
export function findLatestSessionDir(rootDir: string): string | null {
|
|
788
|
+
try {
|
|
789
|
+
const root = path.resolve(rootDir);
|
|
790
|
+
if (!fs.existsSync(root)) return null;
|
|
791
|
+
let best: { dir: string; mtime: number } | null = null;
|
|
792
|
+
for (const entry of fs.readdirSync(root)) {
|
|
793
|
+
const file = path.join(root, entry, "session.json");
|
|
794
|
+
try {
|
|
795
|
+
const st = fs.statSync(file);
|
|
796
|
+
if (!st.isFile()) continue;
|
|
797
|
+
if (!best || st.mtimeMs > best.mtime) best = { dir: path.join(root, entry), mtime: st.mtimeMs };
|
|
798
|
+
} catch {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return best?.dir ?? null;
|
|
803
|
+
} catch {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
}
|