context-doctor 0.31.0 → 0.32.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.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. package/package.json +17 -2
@@ -20,22 +20,31 @@
20
20
  import { existsSync, readFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { join } from "node:path";
23
- import { forEachLine, listSessions } from "./session.js";
24
- import { pricingFor } from "./pricing.js";
23
+ import { forEachLine, listSessions, replyCounter } from "./session.js";
24
+ import { cacheWriteSplit, pricingFor, writeMultiplier } from "./pricing.js";
25
+ import { realModel } from "./tokens.js";
25
26
  import { readJson, writeJsonWithBackup } from "./install.js";
26
27
  /** Claude Code compacts at about this share of the window (970k of 1M, measured). */
27
28
  const TRIGGER_SHARE = 0.97;
28
29
  /** Context right after a compaction: system prompt, tools and the summary (median 75,447, measured). */
29
30
  const AFTER_COMPACT = 75_000;
30
- /** Output tokens a compaction summary costs, priced at output's 5x input rate. */
31
+ /** Output tokens a compaction summary costs, at the model's output price. */
31
32
  const SUMMARY_OUTPUT = 20_000;
32
- const OUTPUT_RATIO = 5;
33
33
  const TTL_MS = 3_600_000;
34
34
  export const MIN_WINDOW = 100_000;
35
35
  export const MAX_WINDOW = 1_000_000;
36
- function readRequests(path) {
36
+ /**
37
+ * `writeMult`: what this session pays to write a token to the cache,
38
+ * relative to input, from the 5-minute and 1-hour writes its usage records.
39
+ * The replay charges writes that never happened (a cold re-read, a fresh
40
+ * context after a simulated compaction); the harness would have written
41
+ * those at the TTL it writes everything else at, mostly 1 hour (2x) for
42
+ * Claude Code's main thread, not the 1.25x a constant assumed.
43
+ */
44
+ function readRequests(path, runRows) {
37
45
  const reqs = [];
38
- let lastId, model;
46
+ let model, writes5m = 0, writes1h = 0, priced = false;
47
+ const sighting = replyCounter(runRows);
39
48
  forEachLine(path, (line) => {
40
49
  let e;
41
50
  try {
@@ -47,55 +56,70 @@ function readRequests(path) {
47
56
  if (!e || e.isSidechain)
48
57
  return;
49
58
  if (e.type === "system" && e.subtype === "compact_boundary") {
50
- reqs.push("compact");
59
+ const copy = typeof e.uuid === "string" && !!runRows?.has(e.uuid);
60
+ if (typeof e.uuid === "string")
61
+ runRows?.add(e.uuid);
62
+ reqs.push({ compact: true, copy });
51
63
  return;
52
64
  }
53
- if (e.type !== "assistant" || !e.message || e.message.id === lastId)
65
+ if (e.type !== "assistant" || !e.message)
66
+ return;
67
+ const seen = sighting(e);
68
+ if (seen === "repeat")
54
69
  return;
55
- lastId = e.message.id;
56
70
  const u = e.message.usage ?? {};
57
71
  const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
58
72
  const at = Date.parse(e.timestamp);
59
- if (prompt > 0 && Number.isFinite(at)) {
60
- reqs.push({ at, prompt });
61
- model ??= e.message.model;
62
- }
73
+ if (!(prompt > 0) || !Number.isFinite(at))
74
+ return;
75
+ // Each request at its own model's price; "<synthetic>" rows carry no usage.
76
+ model = realModel(e.message.model) ?? model;
77
+ const p = pricingFor(model);
78
+ if (p)
79
+ priced = true;
80
+ const w = cacheWriteSplit(u);
81
+ writes5m += w.fiveMinute;
82
+ writes1h += w.oneHour;
83
+ reqs.push({ at, prompt, inPerM: p?.inputPerM ?? 0, readPerM: p?.cacheReadPerM ?? 0, outPerM: p?.outputPerM ?? 0, copy: seen === "copy" });
63
84
  });
64
- return { model, reqs };
85
+ return { reqs, writeMult: writeMultiplier(writes5m, writes1h), priced };
65
86
  }
66
87
  /**
67
88
  * Replay one session twice: as it ran, and with compaction whenever the
68
89
  * context reaches the window's trigger. Both arms are priced as the prompt
69
- * cache bills (warm: 0.1x re-read + 1.25x for what is new; cold after an hour
70
- * idle: 1.25x for everything); each simulated compaction also pays its own
71
- * request and summary.
72
- */
73
- /**
90
+ * cache bills, in USD at each request's own model (warm: the cache-read rate
91
+ * on what was there + a write for what is new; cold after an hour idle: a
92
+ * write for everything, writes at the session's own `writeMult`); each
93
+ * simulated compaction also pays its own request and summary.
94
+ *
74
95
  * `reread`: tokens a session fetches again after a compaction because the
75
96
  * summary dropped them (measured by `context-doctor compactions`). Each
76
97
  * simulated compaction writes them to the cache and carries them onward.
77
98
  */
78
- function replay(reqs, window, since, reread = 0) {
99
+ function replay(reqs, window, since, reread = 0, writeMult = 1.25) {
79
100
  let base = 0, sim = 0, extra = 0, actual = 0;
80
101
  let removed = 0, prevActual, prevSim, prevAt;
81
102
  for (const r of reqs) {
82
- if (r === "compact") {
83
- if (prevAt !== undefined && prevAt >= since)
103
+ if ("compact" in r) {
104
+ if (prevAt !== undefined && prevAt >= since && !r.copy)
84
105
  actual++;
85
106
  // A real compaction in the transcript: the simulated arm compacts there too.
86
- if (prevAt !== undefined && prevAt >= since && removed === 0)
107
+ if (prevAt !== undefined && prevAt >= since && removed === 0 && !r.copy)
87
108
  extra++;
88
109
  removed = 0;
89
110
  prevActual = prevSim = undefined;
90
111
  continue;
91
112
  }
92
- const counted = r.at >= since;
113
+ // Requests before the window, or already counted from a forked copy,
114
+ // still shape both arms; only the rest are billed.
115
+ const counted = r.at >= since && !r.copy;
93
116
  const cold = prevAt === undefined || r.at - prevAt > TTL_MS;
117
+ const write = writeMult * r.inPerM;
94
118
  const cost = (ctx, prev) => {
95
119
  if (cold)
96
- return 1.25 * ctx;
120
+ return write * ctx;
97
121
  const fresh = prev === undefined || ctx < prev ? ctx : ctx - prev;
98
- return 0.1 * (ctx - fresh) + 1.25 * fresh;
122
+ return r.readPerM * (ctx - fresh) + write * fresh;
99
123
  };
100
124
  if (counted)
101
125
  base += cost(r.prompt, prevActual);
@@ -109,7 +133,7 @@ function replay(reqs, window, since, reread = 0) {
109
133
  let simPrev = prevSim;
110
134
  if (ctx >= TRIGGER_SHARE * window) {
111
135
  if (counted) {
112
- sim += 0.1 * ctx + OUTPUT_RATIO * SUMMARY_OUTPUT + 1.25 * reread;
136
+ sim += r.readPerM * ctx + r.outPerM * SUMMARY_OUTPUT + write * reread;
113
137
  extra++;
114
138
  }
115
139
  removed = r.prompt - AFTER_COMPACT - reread;
@@ -129,23 +153,24 @@ export function estimateCompactWindows(windows, days = 30, paths, rereadTokens =
129
153
  const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
130
154
  const acc = windows.map((w) => ({ window: w, base: 0, sim: 0, extra: 0 }));
131
155
  let sessions = 0, actualCompactions = 0;
156
+ // Shared by every transcript of the run, so a fork's copy counts once.
157
+ const runRows = new Set();
132
158
  for (const p of targets) {
133
159
  let parsed;
134
160
  try {
135
- parsed = readRequests(p);
161
+ parsed = readRequests(p, runRows);
136
162
  }
137
163
  catch {
138
164
  continue;
139
165
  }
140
- const perM = pricingFor(parsed.model)?.inputPerM;
141
- if (!perM || parsed.reqs.filter((r) => r !== "compact" && r.at >= since).length < 10)
166
+ if (!parsed.priced || parsed.reqs.filter((r) => !("compact" in r) && r.at >= since && !r.copy).length < 10)
142
167
  continue;
143
168
  sessions++;
144
- actualCompactions += replay(parsed.reqs, Number.MAX_SAFE_INTEGER, since).actual;
169
+ actualCompactions += replay(parsed.reqs, Number.MAX_SAFE_INTEGER, since, 0, parsed.writeMult).actual;
145
170
  for (const a of acc) {
146
- const r = replay(parsed.reqs, a.window, since, rereadTokens);
147
- a.base += (r.base * perM) / 1e6;
148
- a.sim += (r.sim * perM) / 1e6;
171
+ const r = replay(parsed.reqs, a.window, since, rereadTokens, parsed.writeMult);
172
+ a.base += r.base / 1e6;
173
+ a.sim += r.sim / 1e6;
149
174
  a.extra += r.extra;
150
175
  }
151
176
  }
package/dist/config.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface LoadedConfig {
43
43
  path?: string;
44
44
  /** Settings that will be silently ignored, if any. */
45
45
  warnings?: string[];
46
+ /**
47
+ * Set when the rc file at `path` exists but cannot be used at all (not
48
+ * JSON, or not an object): none of its settings are in effect. `path` is
49
+ * still given, so a caller can name the file instead of reporting none.
50
+ */
51
+ error?: string;
46
52
  }
47
53
  /**
48
54
  * Report anything in an rc file that will be silently ignored.
package/dist/config.js CHANGED
@@ -119,11 +119,14 @@ export function loadConfig(startDir = process.cwd(), onWarn) {
119
119
  return { config: parsed, path, warnings };
120
120
  }
121
121
  onWarn?.(`${path}: expected a JSON object — ignoring`);
122
+ return { config: {}, path, error: "expected a JSON object" };
122
123
  }
123
124
  catch (e) {
124
125
  onWarn?.(`${path}: ${e.message} — ignoring`);
126
+ // Returning no path made `doctor` report "no .contextdoctorrc" for a
127
+ // file that exists with a typo in it: the one case worth reporting.
128
+ return { config: {}, path, error: e.message };
125
129
  }
126
- return { config: {} };
127
130
  }
128
131
  return { config: {} };
129
132
  }
package/dist/cursor.d.ts CHANGED
@@ -23,6 +23,15 @@ export declare function findCursorDatabases(): string[];
23
23
  interface Row {
24
24
  [column: string]: string | number | null;
25
25
  }
26
+ /**
27
+ * This runtime cannot read SQLite at all: no `node:sqlite` and no sqlite3
28
+ * command. Distinct from one database failing, which listing skips, because
29
+ * it applies to every database: swallowing it per database showed "No Cursor
30
+ * chats found" with every chat still there (Node 20, and 22.5-22.12 without
31
+ * the flag, when sqlite3 is not on PATH).
32
+ */
33
+ export declare class SqliteUnavailableError extends Error {
34
+ }
26
35
  /**
27
36
  * Run a read-only query against a Cursor database.
28
37
  *
package/dist/cursor.js CHANGED
@@ -65,6 +65,24 @@ export function findCursorDatabases() {
65
65
  }
66
66
  return dbs.sort((a, b) => b.mtime - a.mtime).map((d) => d.path);
67
67
  }
68
+ /**
69
+ * This runtime cannot read SQLite at all: no `node:sqlite` and no sqlite3
70
+ * command. Distinct from one database failing, which listing skips, because
71
+ * it applies to every database: swallowing it per database showed "No Cursor
72
+ * chats found" with every chat still there (Node 20, and 22.5-22.12 without
73
+ * the flag, when sqlite3 is not on PATH).
74
+ */
75
+ export class SqliteUnavailableError extends Error {
76
+ }
77
+ /** Node's built-in SQLite, when this runtime has it (22.13+, or 22.5+ with --experimental-sqlite). */
78
+ function nodeSqlite() {
79
+ try {
80
+ return require("node:sqlite");
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
68
86
  /**
69
87
  * Run a read-only query against a Cursor database.
70
88
  *
@@ -77,31 +95,40 @@ export function findCursorDatabases() {
77
95
  * safe while Cursor is running.
78
96
  */
79
97
  export function queryRows(dbPath, sql) {
80
- try {
81
- // Preferred: Node's built-in SQLite (22.5+).
82
- const { DatabaseSync } = require("node:sqlite");
83
- const db = new DatabaseSync(dbPath, { readOnly: true });
98
+ // Preferred: Node's built-in SQLite (22.5+).
99
+ const sqlite = nodeSqlite();
100
+ let nodeSqliteErr;
101
+ if (sqlite) {
84
102
  try {
85
- return db.prepare(sql).all();
103
+ const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
104
+ try {
105
+ return db.prepare(sql).all();
106
+ }
107
+ finally {
108
+ db.close();
109
+ }
86
110
  }
87
- finally {
88
- db.close();
111
+ catch (e) {
112
+ nodeSqliteErr = e;
89
113
  }
90
114
  }
91
- catch (nodeSqliteErr) {
92
- // Fallback: the sqlite3 CLI, if the user has it.
93
- try {
94
- const out = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], {
95
- encoding: "utf8",
96
- maxBuffer: 512 * 1024 * 1024,
97
- stdio: ["ignore", "pipe", "ignore"], // a missing table is expected on some DBs
98
- });
99
- return out.trim() ? JSON.parse(out) : [];
100
- }
101
- catch {
102
- throw new Error("reading Cursor chats needs SQLite: Node 22.5+ (built-in) or the sqlite3 command. " +
103
- `Neither worked here (${nodeSqliteErr.message}).`);
115
+ // Fallback: the sqlite3 CLI, if the user has it.
116
+ try {
117
+ const out = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], {
118
+ encoding: "utf8",
119
+ maxBuffer: 512 * 1024 * 1024,
120
+ stdio: ["ignore", "pipe", "ignore"], // a missing table is expected on some DBs
121
+ });
122
+ return out.trim() ? JSON.parse(out) : [];
123
+ }
124
+ catch (cliErr) {
125
+ // Neither backend exists: the same answer for every database, so say it once.
126
+ if (!sqlite && cliErr.code === "ENOENT") {
127
+ throw new SqliteUnavailableError("reading Cursor chats needs SQLite: Node 22.13+ (built-in; 22.5+ with --experimental-sqlite) or the sqlite3 command. " +
128
+ "Neither is available here.");
104
129
  }
130
+ // A backend ran and this database failed (no such table, locked, corrupt).
131
+ throw new Error(`${dbPath}: ${(nodeSqliteErr ?? cliErr).message}`);
105
132
  }
106
133
  }
107
134
  /** List Cursor chats, biggest first. */
@@ -121,8 +148,12 @@ export function listCursorChats(limit = 20) {
121
148
  // "No Cursor chats found" with every real chat sitting right there.
122
149
  "FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND json_valid(value)");
123
150
  }
124
- catch {
125
- continue; // no composer table (older Cursor) or no SQLite — skip
151
+ catch (e) {
152
+ // No SQLite at all fails every database alike: report it (the CLI says
153
+ // what to install) instead of "No Cursor chats found".
154
+ if (e instanceof SqliteUnavailableError)
155
+ throw e;
156
+ continue; // no composer table (older Cursor), or this database is unreadable — skip
126
157
  }
127
158
  for (const row of rows) {
128
159
  const count = Number(row.n ?? 0);
@@ -29,6 +29,7 @@ export interface DashboardData {
29
29
  model?: string;
30
30
  }>;
31
31
  proxy: {
32
+ startedAt?: string;
32
33
  requests: number;
33
34
  optimizedRequests: number;
34
35
  tokensSaved: number;
@@ -41,6 +42,15 @@ export interface DashboardData {
41
42
  } | null;
42
43
  }
43
44
  export declare function collectDashboardData(proxyPort?: number): Promise<DashboardData>;
45
+ /**
46
+ * Whether a request's Host header names this machine's loopback.
47
+ *
48
+ * Listening on 127.0.0.1 is not enough on its own: a web page can rebind its
49
+ * own hostname to 127.0.0.1 (DNS rebinding) and then read /api/data, session
50
+ * titles included, as same-origin. Such a request still carries the attacker's
51
+ * hostname in Host, so only loopback names are answered.
52
+ */
53
+ export declare function isLoopbackHost(host: string | undefined, port: number): boolean;
44
54
  export declare function startDashboard(opts?: {
45
55
  port?: number;
46
56
  proxyPort?: number;
package/dist/dashboard.js CHANGED
@@ -13,6 +13,7 @@ import { parseConversation } from "./parse.js";
13
13
  import { profileConversation } from "./profile.js";
14
14
  import { inputCostUsd, pricingFor } from "./pricing.js";
15
15
  import { checkBudget, loadConfig } from "./config.js";
16
+ import { proxySavings } from "./impact.js";
16
17
  /** Sessions bigger than this are skipped so the page stays responsive. */
17
18
  const MAX_SESSION_BYTES = 30 * 1024 * 1024;
18
19
  async function fetchProxyStats(port) {
@@ -86,6 +87,9 @@ export async function collectDashboardData(proxyPort = 8787) {
86
87
  }
87
88
  }
88
89
  const proxy = await fetchProxyStats(proxyPort);
90
+ // The same lifetime figure `report` prints: ledger checkpoints (so a stopped
91
+ // proxy's savings still show) plus what the live run has not checkpointed.
92
+ const proxySaved = proxySavings(ledger, proxy);
89
93
  const loaded = loadConfig();
90
94
  let budget = null;
91
95
  if (loaded.path && loaded.config.budget && sessions.length > 0) {
@@ -98,9 +102,9 @@ export async function collectDashboardData(proxyPort = 8787) {
98
102
  totals: {
99
103
  // What context-doctor removed itself; compaction shrinkage is reported
100
104
  // separately (it was counted here until 0.24, which overclaimed).
101
- tokensSaved: optimizeSaved + (proxy?.tokensSaved ?? 0),
105
+ tokensSaved: optimizeSaved + proxySaved.tokens,
102
106
  shrinkage,
103
- usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
107
+ usdSaved: usdSaved + proxySaved.usd,
104
108
  checks: checks.length + carried.checks,
105
109
  warnings: checks.filter((c) => c.warn).length + carried.warnings,
106
110
  optimizeRuns: optimizes.length,
@@ -111,9 +115,30 @@ export async function collectDashboardData(proxyPort = 8787) {
111
115
  budget,
112
116
  };
113
117
  }
118
+ /**
119
+ * Whether a request's Host header names this machine's loopback.
120
+ *
121
+ * Listening on 127.0.0.1 is not enough on its own: a web page can rebind its
122
+ * own hostname to 127.0.0.1 (DNS rebinding) and then read /api/data, session
123
+ * titles included, as same-origin. Such a request still carries the attacker's
124
+ * hostname in Host, so only loopback names are answered.
125
+ */
126
+ export function isLoopbackHost(host, port) {
127
+ if (!host)
128
+ return false;
129
+ const h = host.toLowerCase();
130
+ return ["127.0.0.1", "localhost", "[::1]"].some((name) => h === name || h === `${name}:${port}`);
131
+ }
114
132
  export function startDashboard(opts = {}) {
115
133
  const port = opts.port ?? 8790;
116
134
  const server = http.createServer(async (req, res) => {
135
+ // The bound port, not the requested one: port 0 picks a free port.
136
+ const boundPort = server.address()?.port ?? port;
137
+ if (!isLoopbackHost(req.headers.host, boundPort)) {
138
+ res.statusCode = 403;
139
+ res.end("context-doctor dashboard: open it as http://127.0.0.1 or http://localhost");
140
+ return;
141
+ }
117
142
  try {
118
143
  if ((req.url ?? "").startsWith("/api/data")) {
119
144
  const data = await collectDashboardData(opts.proxyPort);
package/dist/diff.js CHANGED
@@ -16,10 +16,17 @@ import { profileConversation } from "./profile.js";
16
16
  import { parseSessionFile } from "./session.js";
17
17
  import { formatTokens } from "./tokens.js";
18
18
  import { formatUsd } from "./pricing.js";
19
- /** Load either a conversation file or a Claude Code / ChatGPT transcript. */
19
+ /**
20
+ * Load either a conversation file or a Claude Code / Codex / ChatGPT / Gemini
21
+ * transcript. The session reader goes first whatever the extension: ChatGPT's
22
+ * conversations.json and Gemini chats are .json, and read as a plain
23
+ * conversation they became one message of raw text. A file it finds no
24
+ * messages in is a plain conversation (OpenAI/Anthropic messages JSON), as in
25
+ * `watch`.
26
+ */
20
27
  function loadProfile(path, model) {
21
- if (path.endsWith(".jsonl")) {
22
- const session = parseSessionFile(path);
28
+ const session = parseSessionFile(path);
29
+ if (session.messageCount > 0) {
23
30
  return profileConversation(parseConversation(session.conversationJson), model ?? session.model);
24
31
  }
25
32
  return profileConversation(parseConversation(readFileSync(path, "utf8")), model);