context-mode 1.0.157 → 1.0.159

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.157"
9
+ "version": "1.0.159"
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.157",
16
+ "version": "1.0.159",
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.157",
3
+ "version": "1.0.159",
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.157",
3
+ "version": "1.0.159",
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.157",
6
+ "version": "1.0.159",
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.157",
3
+ "version": "1.0.159",
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",
@@ -42,7 +42,7 @@ export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
42
42
  generateHookConfig(_pluginRoot: string): HookRegistration;
43
43
  readSettings(): Record<string, unknown> | null;
44
44
  writeSettings(_settings: Record<string, unknown>): void;
45
- validateHooks(_pluginRoot: string): DiagnosticResult[];
45
+ validateHooks(pluginRoot: string): DiagnosticResult[];
46
46
  checkPluginRegistration(): DiagnosticResult;
47
47
  getInstalledVersion(): string;
48
48
  configureAllHooks(pluginRoot: string): string[];
@@ -63,6 +63,9 @@ export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
63
63
  private readHooksConfig;
64
64
  private writeHooksConfig;
65
65
  private upsertManagedHookEntry;
66
+ private removeManagedHookEntries;
67
+ private hasCodexPluginHookManifest;
68
+ private pruneStaleUserHookTrustState;
66
69
  private isExpectedHookEntry;
67
70
  private isManagedContextModeEntry;
68
71
  private entryContainsManagedCommand;
@@ -14,7 +14,7 @@
14
14
  * Track: https://github.com/openai/codex/issues/18491
15
15
  */
16
16
  import { execFileSync } from "node:child_process";
17
- import { readFileSync, writeFileSync, accessSync, copyFileSync, constants, mkdirSync, } from "node:fs";
17
+ import { existsSync, readFileSync, writeFileSync, accessSync, copyFileSync, constants, mkdirSync, } from "node:fs";
18
18
  import { resolve, dirname, join } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { BaseAdapter, resolveContextModeDataRoot } from "../base.js";
@@ -100,6 +100,13 @@ function hasDeprecatedCodexHooksFeature(raw) {
100
100
  const features = getTomlSection(raw, "features");
101
101
  return features !== null && /^\s*codex_hooks\s*=\s*true\s*(?:#.*)?$/mi.test(features);
102
102
  }
103
+ function hasCodexPluginEnabled(raw) {
104
+ const plugin = getTomlSection(raw, 'plugins."context-mode@context-mode"');
105
+ return plugin !== null && /^\s*enabled\s*=\s*true\s*(?:#.*)?$/mi.test(plugin);
106
+ }
107
+ function hasStandaloneContextModeMcp(raw) {
108
+ return getTomlSection(raw, "mcp_servers.context-mode") !== null;
109
+ }
103
110
  function ensureCodexHooksFeature(raw) {
104
111
  if (hasCodexHooksFeature(raw))
105
112
  return { text: raw, changed: false };
@@ -129,6 +136,56 @@ function ensureCodexHooksFeature(raw) {
129
136
  lines.splice(featuresIndex + 1, 0, "hooks = true");
130
137
  return { text: lines.join(newline), changed: true };
131
138
  }
139
+ function removeTomlSections(raw, shouldRemove) {
140
+ const newline = raw.includes("\r\n") ? "\r\n" : "\n";
141
+ const lines = raw.split(/\r?\n/);
142
+ const out = [];
143
+ const removed = [];
144
+ let skipping = false;
145
+ for (const line of lines) {
146
+ const section = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
147
+ if (section) {
148
+ const sectionName = section[1]?.trim() ?? "";
149
+ skipping = shouldRemove(sectionName);
150
+ if (skipping)
151
+ removed.push(sectionName);
152
+ }
153
+ if (!skipping)
154
+ out.push(line);
155
+ }
156
+ return { text: out.join(newline), removed };
157
+ }
158
+ function parseTomlQuotedString(raw) {
159
+ const trimmed = raw.trim();
160
+ if (!trimmed.startsWith('"') || !trimmed.endsWith('"'))
161
+ return null;
162
+ try {
163
+ const parsed = JSON.parse(trimmed);
164
+ return typeof parsed === "string" ? parsed : null;
165
+ }
166
+ catch {
167
+ // Codex hook-state keys are TOML quoted keys, not guaranteed JSON strings.
168
+ // Preserve Windows backslashes such as C:\Users\... even when they are not
169
+ // valid JSON escapes, while still handling the common escaped quote/slash.
170
+ let out = "";
171
+ let escaping = false;
172
+ for (const ch of trimmed.slice(1, -1)) {
173
+ if (escaping) {
174
+ out += ch === '"' || ch === "\\" ? ch : `\\${ch}`;
175
+ escaping = false;
176
+ }
177
+ else if (ch === "\\") {
178
+ escaping = true;
179
+ }
180
+ else {
181
+ out += ch;
182
+ }
183
+ }
184
+ if (escaping)
185
+ out += "\\";
186
+ return out;
187
+ }
188
+ }
132
189
  // ─────────────────────────────────────────────────────────
133
190
  // Adapter implementation
134
191
  // ─────────────────────────────────────────────────────────
@@ -384,9 +441,11 @@ export class CodexAdapter extends BaseAdapter {
384
441
  // manually or via the `codex` CLI tool.
385
442
  }
386
443
  // ── Diagnostics (doctor) ─────────────────────────────────
387
- validateHooks(_pluginRoot) {
444
+ validateHooks(pluginRoot) {
388
445
  const results = [];
389
446
  const codexCliVersion = probeCodexCliVersion();
447
+ let settingsRaw = "";
448
+ let settingsReadable = false;
390
449
  results.push({
391
450
  check: "Codex CLI binary",
392
451
  status: codexCliVersion ? "pass" : "warn",
@@ -396,9 +455,10 @@ export class CodexAdapter extends BaseAdapter {
396
455
  ...(codexCliVersion ? {} : { fix: "Install Codex CLI or make codex available on PATH" }),
397
456
  });
398
457
  try {
399
- const raw = readFileSync(this.getSettingsPath(), "utf-8");
400
- const enabled = hasCodexHooksFeature(raw);
401
- const deprecatedOnly = !enabled && hasDeprecatedCodexHooksFeature(raw);
458
+ settingsRaw = readFileSync(this.getSettingsPath(), "utf-8");
459
+ settingsReadable = true;
460
+ const enabled = hasCodexHooksFeature(settingsRaw);
461
+ const deprecatedOnly = !enabled && hasDeprecatedCodexHooksFeature(settingsRaw);
402
462
  results.push({
403
463
  check: "Codex hooks feature flag",
404
464
  status: enabled ? "pass" : "fail",
@@ -418,8 +478,35 @@ export class CodexAdapter extends BaseAdapter {
418
478
  fix: "context-mode upgrade",
419
479
  });
420
480
  }
481
+ const expected = this.generateHookConfig("");
482
+ const codexPluginEnabled = settingsReadable && hasCodexPluginEnabled(settingsRaw);
483
+ const codexPluginHooksAvailable = codexPluginEnabled && this.hasCodexPluginHookManifest(pluginRoot);
484
+ if (codexPluginEnabled && !codexPluginHooksAvailable) {
485
+ results.push({
486
+ check: "Codex plugin hooks",
487
+ status: "fail",
488
+ message: `context-mode Codex plugin is enabled, but ${join(pluginRoot, ".codex-plugin", "hooks.json")} is missing`,
489
+ fix: "Reinstall or upgrade the context-mode Codex plugin",
490
+ });
491
+ }
492
+ if (codexPluginEnabled && hasStandaloneContextModeMcp(settingsRaw)) {
493
+ results.push({
494
+ check: "Standalone MCP duplicate",
495
+ status: "warn",
496
+ message: "[mcp_servers.context-mode] is still registered while context-mode@context-mode is enabled; Codex may start both plugin and standalone MCP surfaces",
497
+ fix: "context-mode upgrade (removes the standalone Codex MCP registration when the plugin owns context-mode)",
498
+ });
499
+ }
421
500
  const hookConfig = this.readHooksConfig();
422
501
  if (!hookConfig.ok) {
502
+ if (hookConfig.reason === "missing" && codexPluginHooksAvailable) {
503
+ const pluginHookChecks = Object.keys(expected).map((hookName) => ({
504
+ check: `${hookName} hook`,
505
+ status: "pass",
506
+ message: `${hookName} hook provided by context-mode@context-mode plugin`,
507
+ }));
508
+ return results.concat(pluginHookChecks);
509
+ }
423
510
  if (hookConfig.reason === "missing") {
424
511
  return results.concat([{
425
512
  check: "Hooks config",
@@ -443,7 +530,7 @@ export class CodexAdapter extends BaseAdapter {
443
530
  fix: "Check permissions and file accessibility for hooks.json, then rerun context-mode upgrade if needed",
444
531
  }]);
445
532
  }
446
- if (!hookConfig.config.hooks) {
533
+ if (!hookConfig.config.hooks && !codexPluginHooksAvailable) {
447
534
  return results.concat([{
448
535
  check: "Hooks config",
449
536
  status: "fail",
@@ -451,24 +538,29 @@ export class CodexAdapter extends BaseAdapter {
451
538
  fix: `Update ${this.getHooksPath()} to match configs/codex/hooks.json`,
452
539
  }]);
453
540
  }
454
- const expected = this.generateHookConfig("");
455
- const hookChecks = Object.entries(expected).map(([hookName, entries]) => {
456
- const actualEntries = hookConfig.config.hooks?.[hookName];
457
- const expectedEntry = entries[0];
458
- const ok = Array.isArray(actualEntries)
459
- && actualEntries.some((entry) => this.isExpectedHookEntry(hookName, entry, expectedEntry));
460
- const missingStatus = hookName === "PreCompact" ? "warn" : "fail";
461
- return {
541
+ const hookChecks = codexPluginHooksAvailable
542
+ ? Object.keys(expected).map((hookName) => ({
462
543
  check: `${hookName} hook`,
463
- status: (ok ? "pass" : missingStatus),
464
- message: ok
465
- ? `${hookName} hook configured in ${this.getHooksPath()}`
466
- : hookName === "PreCompact"
467
- ? `${hookName} hook missing or not pointing to context-mode; compaction snapshots require a Codex build that emits PreCompact`
468
- : `${hookName} hook missing or not pointing to context-mode`,
469
- fix: ok ? undefined : `Update ${this.getHooksPath()} to match configs/codex/hooks.json`,
470
- };
471
- });
544
+ status: "pass",
545
+ message: `${hookName} hook provided by context-mode@context-mode plugin`,
546
+ }))
547
+ : Object.entries(expected).map(([hookName, entries]) => {
548
+ const actualEntries = hookConfig.config.hooks?.[hookName];
549
+ const expectedEntry = entries[0];
550
+ const ok = Array.isArray(actualEntries)
551
+ && actualEntries.some((entry) => this.isExpectedHookEntry(hookName, entry, expectedEntry));
552
+ const missingStatus = hookName === "PreCompact" ? "warn" : "fail";
553
+ return {
554
+ check: `${hookName} hook`,
555
+ status: (ok ? "pass" : missingStatus),
556
+ message: ok
557
+ ? `${hookName} hook configured in ${this.getHooksPath()}`
558
+ : hookName === "PreCompact"
559
+ ? `${hookName} hook missing or not pointing to context-mode; compaction snapshots require a Codex build that emits PreCompact`
560
+ : `${hookName} hook missing or not pointing to context-mode`,
561
+ fix: ok ? undefined : `Update ${this.getHooksPath()} to match configs/codex/hooks.json`,
562
+ };
563
+ });
472
564
  // #603: surface duplicate context-mode entries per hook event. Codex fires
473
565
  // every matching entry, so duplicates double the work, can saturate the
474
566
  // MCP transport (`Transport closed`), and have been observed to inflate
@@ -488,16 +580,39 @@ export class CodexAdapter extends BaseAdapter {
488
580
  fix: "context-mode upgrade (collapses duplicate context-mode entries; preserves unrelated hooks)",
489
581
  });
490
582
  }
583
+ else if (codexPluginHooksAvailable && managedCount === 1) {
584
+ duplicateChecks.push({
585
+ check: `${hookName} plugin duplicate`,
586
+ status: "warn",
587
+ message: `${hookName} is configured in both ${this.getHooksPath()} and the context-mode Codex plugin; Codex will fire both hooks`,
588
+ fix: "context-mode upgrade (removes user config context-mode hooks; preserves unrelated hooks)",
589
+ });
590
+ }
491
591
  }
492
592
  return results.concat(hookChecks, duplicateChecks);
493
593
  }
494
594
  checkPluginRegistration() {
495
- // Check for context-mode in [mcp_servers] section of config.toml
496
595
  try {
497
596
  const raw = readFileSync(this.getSettingsPath(), "utf-8");
498
- const hasContextMode = raw.includes("context-mode");
597
+ const pluginEnabled = hasCodexPluginEnabled(raw);
598
+ const standaloneMcp = hasStandaloneContextModeMcp(raw);
499
599
  const hasMcpSection = raw.includes("[mcp_servers]") || raw.includes("[mcp_servers.");
500
- if (hasContextMode && hasMcpSection) {
600
+ if (pluginEnabled && standaloneMcp) {
601
+ return {
602
+ check: "MCP registration",
603
+ status: "warn",
604
+ message: "context-mode@context-mode plugin is enabled, but standalone [mcp_servers.context-mode] is also configured",
605
+ fix: "context-mode upgrade",
606
+ };
607
+ }
608
+ if (pluginEnabled) {
609
+ return {
610
+ check: "MCP registration",
611
+ status: "pass",
612
+ message: "context-mode@context-mode plugin enabled",
613
+ };
614
+ }
615
+ if (standaloneMcp) {
501
616
  return {
502
617
  check: "MCP registration",
503
618
  status: "pass",
@@ -536,6 +651,15 @@ export class CodexAdapter extends BaseAdapter {
536
651
  configureAllHooks(pluginRoot) {
537
652
  const hookConfig = this.readHooksConfig();
538
653
  const changes = [];
654
+ const settingsPath = this.getSettingsPath();
655
+ let settingsRaw = "";
656
+ try {
657
+ settingsRaw = readFileSync(settingsPath, "utf-8");
658
+ }
659
+ catch {
660
+ settingsRaw = "";
661
+ }
662
+ const codexPluginOwnsHooks = hasCodexPluginEnabled(settingsRaw) && this.hasCodexPluginHookManifest(pluginRoot);
539
663
  let hookFile;
540
664
  if (hookConfig.ok) {
541
665
  hookFile = hookConfig.config;
@@ -555,31 +679,48 @@ export class CodexAdapter extends BaseAdapter {
555
679
  ? hookFile.hooks
556
680
  : {};
557
681
  const desiredHooks = this.generateHookConfig(pluginRoot);
558
- for (const [hookName, entries] of Object.entries(desiredHooks)) {
559
- this.upsertManagedHookEntry(hooks, hookName, entries[0], changes);
682
+ const hookChangeStart = changes.length;
683
+ if (codexPluginOwnsHooks) {
684
+ for (const hookName of Object.keys(desiredHooks)) {
685
+ this.removeManagedHookEntries(hooks, hookName, changes);
686
+ }
560
687
  }
561
- if (changes.length > 0) {
688
+ else {
689
+ for (const [hookName, entries] of Object.entries(desiredHooks)) {
690
+ this.upsertManagedHookEntry(hooks, hookName, entries[0], changes);
691
+ }
692
+ }
693
+ if (changes.length > hookChangeStart) {
562
694
  hookFile.hooks = hooks;
563
695
  this.writeHooksConfig(hookFile);
564
- changes.push(`Wrote native Codex hooks to ${this.getHooksPath()}`);
565
- }
566
- const settingsPath = this.getSettingsPath();
567
- let settingsRaw = "";
568
- try {
569
- settingsRaw = readFileSync(settingsPath, "utf-8");
570
- }
571
- catch {
572
- settingsRaw = "";
696
+ changes.push(codexPluginOwnsHooks
697
+ ? `Removed duplicate context-mode user hooks from ${this.getHooksPath()}`
698
+ : `Wrote native Codex hooks to ${this.getHooksPath()}`);
699
+ }
700
+ let settingsText = ensureCodexHooksFeature(settingsRaw).text;
701
+ const enabledSettingsChanged = settingsText !== settingsRaw;
702
+ if (codexPluginOwnsHooks) {
703
+ const removedMcp = removeTomlSections(settingsText, (sectionName) => sectionName === "mcp_servers.context-mode"
704
+ || sectionName.startsWith("mcp_servers.context-mode.tools."));
705
+ if (removedMcp.removed.length > 0) {
706
+ settingsText = removedMcp.text;
707
+ changes.push("Removed standalone Codex context-mode MCP registration");
708
+ }
709
+ const prunedTrust = this.pruneStaleUserHookTrustState(settingsText, hooks);
710
+ if (prunedTrust.removed.length > 0) {
711
+ settingsText = prunedTrust.text;
712
+ changes.push(`Removed ${prunedTrust.removed.length} stale Codex hook trust entr${prunedTrust.removed.length === 1 ? "y" : "ies"}`);
713
+ }
573
714
  }
574
- const enabledSettings = ensureCodexHooksFeature(settingsRaw);
575
- if (enabledSettings.changed) {
576
- const newline = enabledSettings.text.includes("\r\n") ? "\r\n" : "\n";
577
- const text = enabledSettings.text.endsWith("\n")
578
- ? enabledSettings.text
579
- : `${enabledSettings.text}${newline}`;
715
+ if (settingsText !== settingsRaw) {
716
+ const newline = settingsText.includes("\r\n") ? "\r\n" : "\n";
717
+ const text = settingsText.endsWith("\n")
718
+ ? settingsText
719
+ : `${settingsText}${newline}`;
580
720
  mkdirSync(dirname(settingsPath), { recursive: true });
581
721
  writeFileSync(settingsPath, text, "utf-8");
582
- changes.push("Enabled Codex hooks feature flag");
722
+ if (enabledSettingsChanged)
723
+ changes.push("Enabled Codex hooks feature flag");
583
724
  }
584
725
  return changes;
585
726
  }
@@ -681,6 +822,57 @@ export class CodexAdapter extends BaseAdapter {
681
822
  }
682
823
  hooks[hookName] = currentEntries;
683
824
  }
825
+ removeManagedHookEntries(hooks, hookName, changes) {
826
+ const currentEntries = Array.isArray(hooks[hookName]) ? [...hooks[hookName]] : [];
827
+ const filtered = currentEntries.filter((entry) => !this.isManagedContextModeEntry(hookName, entry));
828
+ const removed = currentEntries.length - filtered.length;
829
+ if (removed === 0)
830
+ return;
831
+ if (filtered.length > 0) {
832
+ hooks[hookName] = filtered;
833
+ }
834
+ else {
835
+ delete hooks[hookName];
836
+ }
837
+ changes.push(`Removed ${removed} ${hookName} context-mode user hook${removed === 1 ? "" : "s"}`);
838
+ }
839
+ hasCodexPluginHookManifest(pluginRoot) {
840
+ return existsSync(join(pluginRoot, ".codex-plugin", "hooks.json"));
841
+ }
842
+ pruneStaleUserHookTrustState(settingsRaw, hooks) {
843
+ const hooksPath = this.normalizeCommand(this.getHooksPath());
844
+ const eventNames = {
845
+ post_compact: "PostCompact",
846
+ post_tool_use: "PostToolUse",
847
+ pre_compact: "PreCompact",
848
+ pre_tool_use: "PreToolUse",
849
+ session_start: "SessionStart",
850
+ stop: "Stop",
851
+ user_prompt_submit: "UserPromptSubmit",
852
+ };
853
+ return removeTomlSections(settingsRaw, (sectionName) => {
854
+ const prefix = "hooks.state.";
855
+ if (!sectionName.startsWith(prefix))
856
+ return false;
857
+ const key = parseTomlQuotedString(sectionName.slice(prefix.length));
858
+ if (key === null)
859
+ return false;
860
+ const normalized = this.normalizeCommand(key);
861
+ const parts = normalized.split(":");
862
+ const hookIndex = Number(parts.pop());
863
+ const entryIndex = Number(parts.pop());
864
+ const eventName = eventNames[parts.pop() ?? ""];
865
+ const stateHooksPath = parts.join(":");
866
+ if (stateHooksPath !== hooksPath
867
+ || !eventName
868
+ || !Number.isInteger(entryIndex)
869
+ || !Number.isInteger(hookIndex)) {
870
+ return false;
871
+ }
872
+ const entry = hooks[eventName]?.[entryIndex];
873
+ return !entry || !Array.isArray(entry.hooks) || !entry.hooks[hookIndex];
874
+ });
875
+ }
684
876
  isExpectedHookEntry(hookName, entry, expectedEntry) {
685
877
  if (!entry || typeof entry !== "object")
686
878
  return false;
@@ -164,6 +164,28 @@ export interface SessionMeta {
164
164
  event_count: number;
165
165
  compact_count: number;
166
166
  }
167
+ /**
168
+ * Session rollup snapshot (seed-parity aggregate).
169
+ *
170
+ * 12 fields that mirror the platform's `session_summary` + `session_metadata`
171
+ * stamps from src/routes/seed.ts. Each outgoing canonical event carries
172
+ * this snapshot computed at the moment of forward so the analytics engine
173
+ * can run its SUM/AVG/MAX rollups across per-event rows.
174
+ */
175
+ export interface SessionRollup {
176
+ tool_calls: number;
177
+ errors: number;
178
+ unique_tools: number;
179
+ unique_files: number;
180
+ max_file_edits: number;
181
+ has_commit: 0 | 1;
182
+ edit_test_cycles: number;
183
+ duration_min: number;
184
+ compact_count: number;
185
+ sources_indexed: number;
186
+ total_chunks: number;
187
+ search_queries: number;
188
+ }
167
189
  /** Resume snapshot row from the session_resume table. */
168
190
  export interface ResumeRow {
169
191
  snapshot: string;
@@ -340,6 +362,19 @@ export declare class SessionDB extends SQLiteBase {
340
362
  * Get session statistics/metadata.
341
363
  */
342
364
  getSessionStats(sessionId: string): SessionMeta | null;
365
+ /**
366
+ * Session rollup snapshot — 12 aggregate fields the analytics platform
367
+ * stamps onto every outgoing event row (seed.ts shape parity).
368
+ *
369
+ * Called from session-loaders BEFORE `maybeForward`; the snapshot is
370
+ * computed against the LOCAL SessionDB and threaded into the canonical
371
+ * event so the platform-side Zod schema receives the rich shape without
372
+ * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
373
+ *
374
+ * Returns zeroed defaults for unknown sessions — callers MUST tolerate
375
+ * a snapshot from an empty session (first event into a fresh DB).
376
+ */
377
+ getSessionRollup(sessionId: string): SessionRollup;
343
378
  /**
344
379
  * Increment the compact_count for a session (tracks snapshot rebuilds).
345
380
  */
@@ -477,6 +477,8 @@ const S = {
477
477
  updateMetaLastEvent: "updateMetaLastEvent",
478
478
  ensureSession: "ensureSession",
479
479
  getSessionStats: "getSessionStats",
480
+ getSessionRollup: "getSessionRollup",
481
+ getMaxFileEdits: "getMaxFileEdits",
480
482
  incrementCompactCount: "incrementCompactCount",
481
483
  upsertResume: "upsertResume",
482
484
  getResume: "getResume",
@@ -716,6 +718,35 @@ export class SessionDB extends SQLiteBase {
716
718
  p(S.ensureSession, `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
717
719
  p(S.getSessionStats, `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count
718
720
  FROM session_meta WHERE session_id = ?`);
721
+ // ── Session rollup (seed-parity aggregator) ────────────────────────
722
+ // Single query producing 9 of the 12 platform-side session_summary +
723
+ // session_metadata fields. Computed against the local SessionDB
724
+ // session_events table at forward time so every outgoing canonical
725
+ // event carries a session-wide snapshot at that moment — matches the
726
+ // seed.ts shape where each event row has tool_calls/errors/etc. stamped.
727
+ // max_file_edits and edit_test_cycles need separate GROUP BY queries
728
+ // (below). compact_count is read from session_meta (already in getSessionStats).
729
+ p(S.getSessionRollup, `SELECT
730
+ COUNT(*) AS tool_calls,
731
+ COALESCE(SUM(CASE WHEN category = 'error' THEN 1 ELSE 0 END), 0) AS errors,
732
+ COUNT(DISTINCT type) AS unique_tools,
733
+ COUNT(DISTINCT CASE WHEN category = 'file' THEN data END) AS unique_files,
734
+ CASE WHEN SUM(CASE WHEN category = 'git' THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS has_commit,
735
+ CAST(COALESCE((MAX(strftime('%s', created_at)) - MIN(strftime('%s', created_at))) / 60.0, 0) AS INTEGER) AS duration_min,
736
+ COALESCE(SUM(CASE WHEN type = 'external_ref' THEN 1 ELSE 0 END), 0) AS sources_indexed,
737
+ CAST(COALESCE(SUM(bytes_avoided) / 1024.0, 0) AS INTEGER) AS total_chunks,
738
+ COALESCE(SUM(CASE WHEN type IN ('file_search', 'file_glob') THEN 1 ELSE 0 END), 0) AS search_queries
739
+ FROM session_events
740
+ WHERE session_id = ?`);
741
+ // max_file_edits: max edits on any single file path in the session.
742
+ // Two-level aggregation — GROUP BY data first, then MAX of those counts.
743
+ p(S.getMaxFileEdits, `SELECT COALESCE(MAX(c), 0) AS max_file_edits
744
+ FROM (
745
+ SELECT COUNT(*) AS c
746
+ FROM session_events
747
+ WHERE session_id = ? AND category = 'file' AND type IN ('file_edit', 'file_write')
748
+ GROUP BY data
749
+ )`);
719
750
  p(S.incrementCompactCount, `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
720
751
  // ── Resume ──
721
752
  p(S.upsertResume, `INSERT INTO session_resume (session_id, snapshot, event_count)
@@ -1017,6 +1048,46 @@ export class SessionDB extends SQLiteBase {
1017
1048
  const row = this.stmt(S.getSessionStats).get(sessionId);
1018
1049
  return row ?? null;
1019
1050
  }
1051
+ /**
1052
+ * Session rollup snapshot — 12 aggregate fields the analytics platform
1053
+ * stamps onto every outgoing event row (seed.ts shape parity).
1054
+ *
1055
+ * Called from session-loaders BEFORE `maybeForward`; the snapshot is
1056
+ * computed against the LOCAL SessionDB and threaded into the canonical
1057
+ * event so the platform-side Zod schema receives the rich shape without
1058
+ * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
1059
+ *
1060
+ * Returns zeroed defaults for unknown sessions — callers MUST tolerate
1061
+ * a snapshot from an empty session (first event into a fresh DB).
1062
+ */
1063
+ getSessionRollup(sessionId) {
1064
+ const main = this.stmt(S.getSessionRollup).get(sessionId);
1065
+ const maxRow = this.stmt(S.getMaxFileEdits).get(sessionId);
1066
+ const meta = this.getSessionStats(sessionId);
1067
+ // edit_test_cycles: heuristic — min(file edits, errors) approximates
1068
+ // the number of edit-then-test attempts in a session. Exact pattern
1069
+ // detection (consecutive file_edit followed by error_tool) would need
1070
+ // a windowed query; this scalar pair under-counts but never overshoots.
1071
+ const fileEdits = (main?.tool_calls ?? 0) > 0
1072
+ ? (main?.unique_files ?? 0)
1073
+ : 0;
1074
+ const errors = main?.errors ?? 0;
1075
+ const editTestCycles = Math.min(fileEdits, errors);
1076
+ return {
1077
+ tool_calls: main?.tool_calls ?? 0,
1078
+ errors: main?.errors ?? 0,
1079
+ unique_tools: main?.unique_tools ?? 0,
1080
+ unique_files: main?.unique_files ?? 0,
1081
+ max_file_edits: maxRow?.max_file_edits ?? 0,
1082
+ has_commit: main?.has_commit ?? 0,
1083
+ edit_test_cycles: editTestCycles,
1084
+ duration_min: main?.duration_min ?? 0,
1085
+ compact_count: meta?.compact_count ?? 0,
1086
+ sources_indexed: main?.sources_indexed ?? 0,
1087
+ total_chunks: main?.total_chunks ?? 0,
1088
+ search_queries: main?.search_queries ?? 0,
1089
+ };
1090
+ }
1020
1091
  /**
1021
1092
  * Increment the compact_count for a session (tracks snapshot rebuilds).
1022
1093
  */