context-mode 1.0.147 → 1.0.148

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.147"
9
+ "version": "1.0.148"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.147",
16
+ "version": "1.0.148",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.147",
3
+ "version": "1.0.148",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.147",
3
+ "version": "1.0.148",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.147",
6
+ "version": "1.0.148",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.147",
3
+ "version": "1.0.148",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -15,6 +15,12 @@
15
15
  */
16
16
  import { BaseAdapter } from "../base.js";
17
17
  import { type HookAdapter, type HookParadigm, type PlatformCapabilities, type DiagnosticResult, type PreToolUseEvent, type PostToolUseEvent, type PreCompactEvent, type SessionStartEvent, type PreToolUseResponse, type PostToolUseResponse, type PreCompactResponse, type SessionStartResponse, type HookRegistration } from "../types.js";
18
+ type CodexVersionRunner = (file: string, args: string[], options: {
19
+ encoding: BufferEncoding;
20
+ stdio: ["ignore", "pipe", "ignore"];
21
+ timeout: number;
22
+ }) => string | Buffer;
23
+ export declare function probeCodexCliVersion(runCommand?: CodexVersionRunner): string | null;
18
24
  export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
19
25
  constructor();
20
26
  readonly name = "Codex CLI";
@@ -67,3 +73,4 @@ export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
67
73
  */
68
74
  private extractSessionId;
69
75
  }
76
+ export {};
@@ -13,6 +13,7 @@
13
13
  * while input rewriting remains blocked on upstream updatedInput support.
14
14
  * Track: https://github.com/openai/codex/issues/18491
15
15
  */
16
+ import { execFileSync } from "node:child_process";
16
17
  import { readFileSync, writeFileSync, accessSync, copyFileSync, constants, mkdirSync, } from "node:fs";
17
18
  import { resolve, dirname, join } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
@@ -54,6 +55,26 @@ const LEGACY_HOOK_PATH_SUFFIXES = {
54
55
  UserPromptSubmit: ["hooks/userpromptsubmit.mjs", "hooks/codex/userpromptsubmit.mjs"],
55
56
  Stop: ["hooks/stop.mjs", "hooks/codex/stop.mjs"],
56
57
  };
58
+ export function probeCodexCliVersion(runCommand = execFileSync) {
59
+ try {
60
+ const output = process.platform === "win32"
61
+ ? runCommand("cmd.exe", ["/d", "/s", "/c", "codex --version"], {
62
+ encoding: "utf-8",
63
+ stdio: ["ignore", "pipe", "ignore"],
64
+ timeout: 5000,
65
+ })
66
+ : runCommand("codex", ["--version"], {
67
+ encoding: "utf-8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ timeout: 1500,
70
+ });
71
+ const version = String(output).trim();
72
+ return version.length > 0 ? version : "available (version output empty)";
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
57
78
  function getTomlSection(raw, sectionName) {
58
79
  const lines = raw.split(/\r?\n/);
59
80
  let inSection = false;
@@ -365,6 +386,15 @@ export class CodexAdapter extends BaseAdapter {
365
386
  // ── Diagnostics (doctor) ─────────────────────────────────
366
387
  validateHooks(_pluginRoot) {
367
388
  const results = [];
389
+ const codexCliVersion = probeCodexCliVersion();
390
+ results.push({
391
+ check: "Codex CLI binary",
392
+ status: codexCliVersion ? "pass" : "warn",
393
+ message: codexCliVersion
394
+ ? `codex --version resolved to ${codexCliVersion}`
395
+ : "Could not run codex --version; hooks need the Codex CLI available on PATH",
396
+ ...(codexCliVersion ? {} : { fix: "Install Codex CLI or make codex available on PATH" }),
397
+ });
368
398
  try {
369
399
  const raw = readFileSync(this.getSettingsPath(), "utf-8");
370
400
  const enabled = hasCodexHooksFeature(raw);
@@ -498,8 +528,9 @@ export class CodexAdapter extends BaseAdapter {
498
528
  }
499
529
  }
500
530
  getInstalledVersion() {
501
- // Codex CLI has no marketplace or plugin system
502
- return "not installed";
531
+ // Codex uses standalone MCP registration; there is no platform-owned
532
+ // plugin version to compare against the context-mode npm package.
533
+ return "standalone";
503
534
  }
504
535
  // ── Upgrade ────────────────────────────────────────────
505
536
  configureAllHooks(pluginRoot) {
@@ -229,7 +229,10 @@ export interface HookAdapter {
229
229
  getHealthChecks?(pluginRoot: string): readonly HealthCheck[];
230
230
  /** Check if the plugin is registered/enabled on this platform. */
231
231
  checkPluginRegistration(): DiagnosticResult;
232
- /** Get the installed version from this platform's registry/marketplace. */
232
+ /**
233
+ * Get the installed version from this platform's registry/marketplace, or
234
+ * "standalone" when no platform-owned plugin version exists.
235
+ */
233
236
  getInstalledVersion(): string;
234
237
  /** Configure all hooks for this platform. Returns change descriptions. */
235
238
  configureAllHooks(pluginRoot: string): string[];
package/build/cli.js CHANGED
@@ -763,7 +763,11 @@ async function doctor() {
763
763
  ` — local v${localVersion}, latest v${latestVersion}` +
764
764
  color.dim("\n Run: /context-mode:ctx-upgrade"));
765
765
  }
766
- if (installedVersion === "not installed") {
766
+ if (installedVersion === "standalone") {
767
+ p.log.info(color.dim(`${adapter.name}: standalone MCP mode`) +
768
+ " — no platform plugin version to compare");
769
+ }
770
+ else if (installedVersion === "not installed") {
767
771
  p.log.info(color.dim(`${adapter.name}: not installed`) +
768
772
  " — using standalone MCP mode");
769
773
  }
package/build/server.js CHANGED
@@ -3106,7 +3106,47 @@ server.registerTool("ctx_stats", {
3106
3106
  // 49 MB of indexed content sitting in the content DB.
3107
3107
  // Render-time read-only — no DB mutation, no backfill.
3108
3108
  const contentDbPath = getStorePath();
3109
- const convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
3109
+ // v1.0.148 Bug E+F: a conversation typically spans many
3110
+ // session_ids (resume cycles, /compact rebirths, PID
3111
+ // sub-process sessions launched by ctx_execute). Scoping
3112
+ // per-session loses sandbox-burst bytes_avoided that the
3113
+ // PID-sessions own. Look up THIS session's project_dir
3114
+ // from META and aggregate via META subquery so all
3115
+ // sibling sessions in the same cwd attribute together.
3116
+ // Fallback to sessionId scope if the META lookup fails
3117
+ // (best-effort — the original metric is still defensible).
3118
+ let convReal;
3119
+ try {
3120
+ const Database = loadDatabase();
3121
+ const dbFiles = (await import("node:fs"))
3122
+ .readdirSync(getSessionDir())
3123
+ .filter((f) => f.endsWith(".db") && (!dbHash || f.startsWith(dbHash)));
3124
+ let projectDirForSid;
3125
+ for (const file of dbFiles) {
3126
+ try {
3127
+ const sdb = new Database((await import("node:path")).join(getSessionDir(), file), { readonly: true });
3128
+ try {
3129
+ const r = sdb
3130
+ .prepare("SELECT project_dir FROM session_meta WHERE session_id = ?")
3131
+ .get(sid);
3132
+ if (r?.project_dir) {
3133
+ projectDirForSid = r.project_dir;
3134
+ break;
3135
+ }
3136
+ }
3137
+ finally {
3138
+ sdb.close();
3139
+ }
3140
+ }
3141
+ catch { /* skip unreadable DB */ }
3142
+ }
3143
+ convReal = projectDirForSid
3144
+ ? getRealBytesStats({ projectDir: projectDirForSid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath })
3145
+ : getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
3146
+ }
3147
+ catch {
3148
+ convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
3149
+ }
3110
3150
  const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
3111
3151
  // v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
3112
3152
  // session_id filter). Without this fold, lifetime "kept out"
@@ -427,6 +427,25 @@ export declare function getRealBytesStats(opts: {
427
427
  sessionId?: string;
428
428
  sessionsDir?: string;
429
429
  worktreeHash?: string;
430
+ /**
431
+ * v1.0.148 follow-up (Bug E+F): when set, the function aggregates across
432
+ * EVERY session whose `session_meta.project_dir` matches this value, not
433
+ * just one session_id. Resolves the per-conversation under-attribution:
434
+ * one Claude Code conversation typically spans many session_ids (resume
435
+ * cycles, /compact rebirths, PID sub-process sessions spawned by
436
+ * ctx_execute), so a single-session_id filter loses the sandbox-burst
437
+ * bytes_avoided that all live under the conversation's cwd.
438
+ *
439
+ * Uses a META subquery (`session_id IN (SELECT session_id FROM
440
+ * session_meta WHERE project_dir = ?)`), then sums ALL events for
441
+ * matching sessions regardless of their event-level project_dir
442
+ * (sandbox-burst events write `project_dir = ''` even when the
443
+ * META row carries the parent cwd — see Bug F).
444
+ *
445
+ * Mutually exclusive with `sessionId`. When both are set, `sessionId`
446
+ * wins for back-compat.
447
+ */
448
+ projectDir?: string;
430
449
  /**
431
450
  * v1.0.133 Slice 3: when set alongside `sessionId`, the function joins
432
451
  * the FTS5 content DB at this path and folds chunk bytes into
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { join, sep } from "node:path";
15
15
  import { loadDatabase as loadDatabaseImpl } from "../db-base.js";
16
+ import { ensureSessionEventsSchema } from "./db.js";
16
17
  import { resolveClaudeConfigDir } from "../util/claude-config.js";
17
18
  function semverNewer(a, b) {
18
19
  const pa = a.split(".").map(Number);
@@ -857,6 +858,15 @@ export function getRealBytesStats(opts) {
857
858
  // don't need to type-narrow per row.
858
859
  for (const file of dbFiles) {
859
860
  const dbPath = join(sessionsDir, file);
861
+ // v1.0.148 hotfix: historical DBs were created with pre-v1.0.130
862
+ // schema (no bytes_avoided / bytes_returned / project_dir columns).
863
+ // The SELECT below references those columns, so without an in-place
864
+ // migration the prepare() throws and the surrounding catch silently
865
+ // skips the WHOLE DB — losing even the LENGTH(data) signal. Run the
866
+ // shared migration helper before opening readonly. Idempotent: a
867
+ // PRAGMA check inside the helper short-circuits when the DB is
868
+ // already current, so post-first-read calls are cheap.
869
+ ensureSessionEventsSchema(dbPath, DatabaseCtor);
860
870
  try {
861
871
  const sdb = new DatabaseCtor(dbPath, { readonly: true });
862
872
  try {
@@ -878,6 +888,36 @@ export function getRealBytesStats(opts) {
878
888
  }
879
889
  catch { /* old schema */ }
880
890
  }
891
+ else if (opts.projectDir) {
892
+ // Bug E+F: META-scoped aggregation. Take every session_id whose
893
+ // session_meta.project_dir matches, then sum ALL of those
894
+ // sessions' events regardless of the events' own project_dir
895
+ // (sandbox-burst PID sessions write empty event-level project_dir
896
+ // even when their META carries the parent cwd).
897
+ const row = sdb.prepare(`SELECT
898
+ COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
899
+ COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
900
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
901
+ FROM session_events
902
+ WHERE session_id IN (
903
+ SELECT session_id FROM session_meta WHERE project_dir = ?
904
+ )`).get(opts.projectDir);
905
+ if (row) {
906
+ eventDataBytes += Number(row.data_bytes ?? 0);
907
+ bytesAvoided += Number(row.bytes_avoided ?? 0);
908
+ bytesReturned += Number(row.bytes_returned ?? 0);
909
+ }
910
+ try {
911
+ const snap = sdb.prepare(`SELECT COALESCE(SUM(LENGTH(snapshot)), 0) AS bytes
912
+ FROM session_resume
913
+ WHERE session_id IN (
914
+ SELECT session_id FROM session_meta WHERE project_dir = ?
915
+ )`).get(opts.projectDir);
916
+ if (snap?.bytes)
917
+ snapshotBytes += Number(snap.bytes);
918
+ }
919
+ catch { /* old schema */ }
920
+ }
881
921
  else {
882
922
  const row = sdb.prepare(`SELECT
883
923
  COALESCE(SUM(LENGTH(data)), 0) AS data_bytes,
@@ -1504,34 +1544,44 @@ function renderNarrative5Section(args) {
1504
1544
  out.push(` Without that, you'd be re-explaining everything to a blank model right now.`);
1505
1545
  }
1506
1546
  out.push("");
1507
- // Without/With bars — measured from real per-event bytes_returned / bytes_avoided.
1547
+ // Without/With bars — strict compression (v1.0.148, Bug G / ADR-0004).
1508
1548
  //
1509
- // Honest definitions (v1.0.134 SLICE B — eventDataBytes floor):
1510
- // Without = bytes the model WOULD have re-seen with no filtering
1511
- // = bytes_avoided + bytes_returned + eventDataBytes
1549
+ // Honest definitions:
1550
+ // Without = bytes the model WOULD have re-seen if context-mode
1551
+ // had not diverted them
1552
+ // = bytesAvoided + bytesReturned
1512
1553
  // With = bytes the model ACTUALLY re-saw after context-mode
1513
- // = bytes_returned + eventDataBytes
1554
+ // = max(1, bytesReturned)
1514
1555
  //
1515
- // Why eventDataBytes belongs on BOTH sides:
1516
- // `eventDataBytes` is the raw payload captured by the hook (tool args,
1517
- // prompt body, etc). Those bytes were "kept out" never inflated back
1518
- // into contextbut they still represent real measured signal. Pre-fix
1519
- // the formula was `with = max(1, bytesReturned)`, which collapsed to 1
1520
- // whenever the conversation hadn't accumulated any re-served bytes yet
1521
- // (early in a session, or for tool-heavy work that never re-hits index).
1522
- // That produced a degenerate ~100% kept-out bar even when the only
1523
- // honest signal we had was a few KB of event payloads.
1556
+ // Why eventDataBytes is excluded from this ratio:
1557
+ // `eventDataBytes` is the raw hook payload (tool args, prompt
1558
+ // body) we captured for the knowledge base. Those bytes are
1559
+ // analytics infrastructure — they NEVER enter the model context
1560
+ // window. Including them on either side (as v1.0.134 SLICE B did
1561
+ // to dodge a degenerate 100% bar) misrepresents context cost.
1562
+ // SLICE B was an incidental fix that crushed the displayed
1563
+ // percentage from ~95% (the true compression ratio) to ~56% on
1564
+ // live conversations. eventDataBytes is rendered in Section 2
1565
+ // (captures count), not in this Section 1 Without/With bar.
1524
1566
  //
1525
- // No fallback to heuristic. If the schema has zero signal for this
1526
- // conversation (no hook ever populated any of the three columns),
1527
- // the section is skipped entirely. Honesty over decoration.
1567
+ // Empty-state branch:
1568
+ // If neither bytesAvoided nor bytesReturned has been measured yet
1569
+ // (early in a session, schema-migration recovery in progress, or
1570
+ // tool-heavy work that hasn't re-hit the index), we do NOT draw
1571
+ // a degenerate 0% / 100% bar. We emit one honest hint line and
1572
+ // skip the bar — honesty over decoration.
1528
1573
  const realConv = realBytes?.conversation;
1529
1574
  const measuredAvoided = realConv?.bytesAvoided ?? 0;
1530
1575
  const measuredReturned = realConv?.bytesReturned ?? 0;
1531
- const measuredEvent = realConv?.eventDataBytes ?? 0;
1532
- if (measuredAvoided + measuredReturned + measuredEvent > 0) {
1533
- const convBytesWithout = measuredAvoided + measuredReturned + measuredEvent;
1534
- const convBytesWith = Math.max(1, measuredReturned + measuredEvent);
1576
+ if (measuredAvoided + measuredReturned === 0) {
1577
+ // No measurable redirect activity yet captures may exist, but
1578
+ // nothing has been diverted from the model context window.
1579
+ out.push(" No measurable redirect activity captured yet — bars will appear once context-mode diverts its first payload.");
1580
+ out.push("");
1581
+ }
1582
+ else {
1583
+ const convBytesWithout = measuredAvoided + measuredReturned;
1584
+ const convBytesWith = Math.max(1, measuredReturned);
1535
1585
  const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
1536
1586
  const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
1537
1587
  const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);
@@ -179,6 +179,50 @@ export interface ToolCallStats {
179
179
  bytesReturned: number;
180
180
  }>;
181
181
  }
182
+ /**
183
+ * Apply any missing post-v1.0.130 `session_events` columns to an already-
184
+ * open writable database handle. Idempotent — each ALTER is guarded by a
185
+ * PRAGMA table_xinfo check, and the project_dir index is created only
186
+ * when a migration actually ran. Returns true if any column was added.
187
+ *
188
+ * Used by both the SessionDB constructor (for the active DB) and the
189
+ * analytics aggregator (for the 100+ historical DBs that never get
190
+ * opened through SessionDB). ADR-0001 compatible: no EXCLUSIVE pragma,
191
+ * no acquireDbLock — relies on the SQLite busy_timeout + WAL semantics
192
+ * already provided by SQLiteBase.
193
+ */
194
+ export declare function applyMissingSessionEventsColumns(db: {
195
+ pragma: (q: string) => Array<{
196
+ name: string;
197
+ }>;
198
+ exec: (sql: string) => void;
199
+ }): boolean;
200
+ /**
201
+ * Open a session DB file briefly, run any missing schema migrations,
202
+ * and close. Best-effort: missing tables, file-locks, corrupt files,
203
+ * and any DatabaseCtor error are swallowed silently — the caller
204
+ * (analytics aggregator) handles the readonly query that follows and
205
+ * will skip the DB if it remains unreadable.
206
+ *
207
+ * Lazy migration entry point for the analytics aggregator, which would
208
+ * otherwise read 100+ historical DBs with the old (pre-v1.0.130) schema
209
+ * and lose every signal (not just bytes_avoided) because the SELECT
210
+ * statement references columns that don't exist on legacy schemas.
211
+ *
212
+ * Two open/close cycles in the worst case (one readonly probe to detect
213
+ * legacy schema, one writable to migrate). For already-migrated DBs
214
+ * (the common case after first read), this opens writable once and
215
+ * exits without writing — cheaper than always-writable.
216
+ */
217
+ export declare function ensureSessionEventsSchema(dbPath: string, DatabaseCtor: new (path: string, opts?: {
218
+ readonly?: boolean;
219
+ }) => {
220
+ pragma: (q: string) => Array<{
221
+ name: string;
222
+ }>;
223
+ exec: (sql: string) => void;
224
+ close: () => void;
225
+ }): void;
182
226
  export declare class SessionDB extends SQLiteBase {
183
227
  /**
184
228
  * Cached prepared statements. Stored in a Map to avoid the JS private-field
@@ -493,6 +493,87 @@ const S = {
493
493
  getEventBytesSummary: "getEventBytesSummary",
494
494
  };
495
495
  // ─────────────────────────────────────────────────────────
496
+ // Schema migration helpers (shared with the analytics aggregator)
497
+ // ─────────────────────────────────────────────────────────
498
+ /**
499
+ * Columns that the current `session_events` schema requires but earlier
500
+ * versions of context-mode did not write. Older DBs on disk are missing
501
+ * these — the analytics aggregator opens every DB it finds across all
502
+ * adapters, so without an in-place migration the SUM queries below fail
503
+ * the entire DB (the catch at the top of the read loop swallows the
504
+ * "no such column" error and the DB contributes zero to every column,
505
+ * not just the new ones). v1.0.148 hotfix.
506
+ */
507
+ const SESSION_EVENTS_REQUIRED_COLUMNS = [
508
+ ["project_dir", "TEXT NOT NULL DEFAULT ''"],
509
+ ["attribution_source", "TEXT NOT NULL DEFAULT 'unknown'"],
510
+ ["attribution_confidence", "REAL NOT NULL DEFAULT 0"],
511
+ ["bytes_avoided", "INTEGER NOT NULL DEFAULT 0"],
512
+ ["bytes_returned", "INTEGER NOT NULL DEFAULT 0"],
513
+ ];
514
+ /**
515
+ * Apply any missing post-v1.0.130 `session_events` columns to an already-
516
+ * open writable database handle. Idempotent — each ALTER is guarded by a
517
+ * PRAGMA table_xinfo check, and the project_dir index is created only
518
+ * when a migration actually ran. Returns true if any column was added.
519
+ *
520
+ * Used by both the SessionDB constructor (for the active DB) and the
521
+ * analytics aggregator (for the 100+ historical DBs that never get
522
+ * opened through SessionDB). ADR-0001 compatible: no EXCLUSIVE pragma,
523
+ * no acquireDbLock — relies on the SQLite busy_timeout + WAL semantics
524
+ * already provided by SQLiteBase.
525
+ */
526
+ export function applyMissingSessionEventsColumns(db) {
527
+ const colInfo = db.pragma("table_xinfo(session_events)");
528
+ const cols = new Set(colInfo.map((c) => c.name));
529
+ let changed = false;
530
+ for (const [name, spec] of SESSION_EVENTS_REQUIRED_COLUMNS) {
531
+ if (!cols.has(name)) {
532
+ db.exec(`ALTER TABLE session_events ADD COLUMN ${name} ${spec}`);
533
+ changed = true;
534
+ }
535
+ }
536
+ if (changed) {
537
+ db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
538
+ }
539
+ return changed;
540
+ }
541
+ /**
542
+ * Open a session DB file briefly, run any missing schema migrations,
543
+ * and close. Best-effort: missing tables, file-locks, corrupt files,
544
+ * and any DatabaseCtor error are swallowed silently — the caller
545
+ * (analytics aggregator) handles the readonly query that follows and
546
+ * will skip the DB if it remains unreadable.
547
+ *
548
+ * Lazy migration entry point for the analytics aggregator, which would
549
+ * otherwise read 100+ historical DBs with the old (pre-v1.0.130) schema
550
+ * and lose every signal (not just bytes_avoided) because the SELECT
551
+ * statement references columns that don't exist on legacy schemas.
552
+ *
553
+ * Two open/close cycles in the worst case (one readonly probe to detect
554
+ * legacy schema, one writable to migrate). For already-migrated DBs
555
+ * (the common case after first read), this opens writable once and
556
+ * exits without writing — cheaper than always-writable.
557
+ */
558
+ export function ensureSessionEventsSchema(dbPath, DatabaseCtor) {
559
+ let db = null;
560
+ try {
561
+ db = new DatabaseCtor(dbPath);
562
+ applyMissingSessionEventsColumns(db);
563
+ }
564
+ catch {
565
+ // best-effort — missing table, file lock, corrupt DB, or DatabaseCtor
566
+ // load failure. The aggregator's existing skip-on-error handles the
567
+ // downstream readonly query.
568
+ }
569
+ finally {
570
+ try {
571
+ db?.close();
572
+ }
573
+ catch { /* ignore */ }
574
+ }
575
+ }
576
+ // ─────────────────────────────────────────────────────────
496
577
  // SessionDB
497
578
  // ─────────────────────────────────────────────────────────
498
579
  export class SessionDB extends SQLiteBase {
@@ -569,25 +650,11 @@ export class SessionDB extends SQLiteBase {
569
650
  CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
570
651
  `);
571
652
  // Migration: add per-event attribution columns for existing DBs.
653
+ // Shared helper — the analytics aggregator (analytics.ts) runs the
654
+ // SAME migration against every historical DB it scans, so the column
655
+ // list lives in one place at the top of this module.
572
656
  try {
573
- const colInfo = this.db.pragma("table_xinfo(session_events)");
574
- const cols = new Set(colInfo.map((c) => c.name));
575
- if (!cols.has("project_dir")) {
576
- this.db.exec("ALTER TABLE session_events ADD COLUMN project_dir TEXT NOT NULL DEFAULT ''");
577
- }
578
- if (!cols.has("attribution_source")) {
579
- this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_source TEXT NOT NULL DEFAULT 'unknown'");
580
- }
581
- if (!cols.has("attribution_confidence")) {
582
- this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_confidence REAL NOT NULL DEFAULT 0");
583
- }
584
- if (!cols.has("bytes_avoided")) {
585
- this.db.exec("ALTER TABLE session_events ADD COLUMN bytes_avoided INTEGER NOT NULL DEFAULT 0");
586
- }
587
- if (!cols.has("bytes_returned")) {
588
- this.db.exec("ALTER TABLE session_events ADD COLUMN bytes_returned INTEGER NOT NULL DEFAULT 0");
589
- }
590
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
657
+ applyMissingSessionEventsColumns(this.db);
591
658
  }
592
659
  catch {
593
660
  // best-effort migration only