context-mode 1.0.111 → 1.0.112

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 (150) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/index.ts +3 -2
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +152 -34
  7. package/bin/statusline.mjs +144 -127
  8. package/build/adapters/base.d.ts +8 -5
  9. package/build/adapters/base.js +8 -18
  10. package/build/adapters/claude-code/index.d.ts +24 -3
  11. package/build/adapters/claude-code/index.js +44 -11
  12. package/build/adapters/codex/hooks.d.ts +10 -5
  13. package/build/adapters/codex/hooks.js +10 -5
  14. package/build/adapters/codex/index.d.ts +17 -5
  15. package/build/adapters/codex/index.js +337 -37
  16. package/build/adapters/codex/paths.d.ts +1 -0
  17. package/build/adapters/codex/paths.js +12 -0
  18. package/build/adapters/cursor/index.d.ts +6 -0
  19. package/build/adapters/cursor/index.js +83 -2
  20. package/build/adapters/detect.d.ts +1 -1
  21. package/build/adapters/detect.js +29 -6
  22. package/build/adapters/omp/index.d.ts +65 -0
  23. package/build/adapters/omp/index.js +182 -0
  24. package/build/adapters/omp/plugin.d.ts +75 -0
  25. package/build/adapters/omp/plugin.js +220 -0
  26. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  27. package/build/adapters/openclaw/mcp-tools.js +198 -0
  28. package/build/adapters/openclaw/plugin.d.ts +130 -0
  29. package/build/adapters/openclaw/plugin.js +629 -0
  30. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  31. package/build/adapters/openclaw/workspace-router.js +64 -0
  32. package/build/adapters/opencode/plugin.d.ts +145 -0
  33. package/build/adapters/opencode/plugin.js +457 -0
  34. package/build/adapters/pi/extension.d.ts +26 -0
  35. package/build/adapters/pi/extension.js +552 -0
  36. package/build/adapters/pi/index.d.ts +57 -0
  37. package/build/adapters/pi/index.js +173 -0
  38. package/build/adapters/pi/mcp-bridge.d.ts +113 -0
  39. package/build/adapters/pi/mcp-bridge.js +251 -0
  40. package/build/adapters/types.d.ts +11 -6
  41. package/build/cli.js +186 -170
  42. package/build/db-base.d.ts +15 -2
  43. package/build/db-base.js +50 -5
  44. package/build/executor.d.ts +2 -0
  45. package/build/executor.js +15 -2
  46. package/build/runPool.d.ts +36 -0
  47. package/build/runPool.js +51 -0
  48. package/build/runtime.js +64 -5
  49. package/build/search/auto-memory.js +6 -4
  50. package/build/security.js +30 -10
  51. package/build/server.d.ts +23 -1
  52. package/build/server.js +652 -174
  53. package/build/session/analytics.d.ts +404 -1
  54. package/build/session/analytics.js +1347 -42
  55. package/build/session/db.d.ts +114 -5
  56. package/build/session/db.js +275 -27
  57. package/build/session/event-emit.d.ts +48 -0
  58. package/build/session/event-emit.js +101 -0
  59. package/build/session/extract.d.ts +1 -0
  60. package/build/session/extract.js +79 -12
  61. package/build/session/purge.d.ts +111 -0
  62. package/build/session/purge.js +138 -0
  63. package/build/store.d.ts +7 -0
  64. package/build/store.js +69 -6
  65. package/build/util/claude-config.d.ts +26 -0
  66. package/build/util/claude-config.js +91 -0
  67. package/build/util/hook-config.d.ts +4 -0
  68. package/build/util/hook-config.js +39 -0
  69. package/cli.bundle.mjs +411 -208
  70. package/configs/antigravity/GEMINI.md +0 -3
  71. package/configs/claude-code/CLAUDE.md +1 -4
  72. package/configs/codex/AGENTS.md +1 -4
  73. package/configs/codex/config.toml +3 -0
  74. package/configs/codex/hooks.json +8 -0
  75. package/configs/cursor/context-mode.mdc +0 -3
  76. package/configs/gemini-cli/GEMINI.md +0 -3
  77. package/configs/jetbrains-copilot/copilot-instructions.md +0 -3
  78. package/configs/kilo/AGENTS.md +0 -3
  79. package/configs/kiro/KIRO.md +0 -3
  80. package/configs/omp/SYSTEM.md +85 -0
  81. package/configs/omp/mcp.json +7 -0
  82. package/configs/openclaw/AGENTS.md +0 -3
  83. package/configs/opencode/AGENTS.md +0 -3
  84. package/configs/pi/AGENTS.md +0 -3
  85. package/configs/qwen-code/QWEN.md +1 -4
  86. package/configs/vscode-copilot/copilot-instructions.md +0 -3
  87. package/configs/zed/AGENTS.md +0 -3
  88. package/hooks/codex/posttooluse.mjs +9 -2
  89. package/hooks/codex/precompact.mjs +69 -0
  90. package/hooks/codex/sessionstart.mjs +13 -9
  91. package/hooks/codex/stop.mjs +1 -2
  92. package/hooks/codex/userpromptsubmit.mjs +1 -2
  93. package/hooks/core/routing.mjs +237 -18
  94. package/hooks/cursor/afteragentresponse.mjs +1 -1
  95. package/hooks/cursor/hooks.json +31 -0
  96. package/hooks/cursor/posttooluse.mjs +1 -1
  97. package/hooks/cursor/sessionstart.mjs +5 -5
  98. package/hooks/cursor/stop.mjs +1 -1
  99. package/hooks/ensure-deps.mjs +12 -13
  100. package/hooks/gemini-cli/aftertool.mjs +1 -1
  101. package/hooks/gemini-cli/beforeagent.mjs +1 -1
  102. package/hooks/gemini-cli/precompress.mjs +3 -2
  103. package/hooks/gemini-cli/sessionstart.mjs +9 -9
  104. package/hooks/jetbrains-copilot/posttooluse.mjs +1 -1
  105. package/hooks/jetbrains-copilot/precompact.mjs +3 -2
  106. package/hooks/jetbrains-copilot/sessionstart.mjs +9 -9
  107. package/hooks/kiro/agentspawn.mjs +5 -5
  108. package/hooks/kiro/posttooluse.mjs +2 -2
  109. package/hooks/kiro/userpromptsubmit.mjs +1 -1
  110. package/hooks/posttooluse.mjs +45 -0
  111. package/hooks/precompact.mjs +17 -0
  112. package/hooks/pretooluse.mjs +23 -0
  113. package/hooks/routing-block.mjs +0 -12
  114. package/hooks/run-hook.mjs +16 -3
  115. package/hooks/session-db.bundle.mjs +27 -18
  116. package/hooks/session-extract.bundle.mjs +2 -2
  117. package/hooks/session-helpers.mjs +101 -64
  118. package/hooks/sessionstart.mjs +51 -2
  119. package/hooks/vscode-copilot/posttooluse.mjs +1 -1
  120. package/hooks/vscode-copilot/precompact.mjs +3 -2
  121. package/hooks/vscode-copilot/sessionstart.mjs +9 -9
  122. package/openclaw.plugin.json +1 -1
  123. package/package.json +14 -8
  124. package/server.bundle.mjs +349 -147
  125. package/skills/UPSTREAM-CREDITS.md +0 -51
  126. package/skills/context-mode-ops/SKILL.md +0 -299
  127. package/skills/context-mode-ops/agent-teams.md +0 -198
  128. package/skills/context-mode-ops/communication.md +0 -224
  129. package/skills/context-mode-ops/marketing.md +0 -124
  130. package/skills/context-mode-ops/release.md +0 -214
  131. package/skills/context-mode-ops/review-pr.md +0 -269
  132. package/skills/context-mode-ops/tdd.md +0 -329
  133. package/skills/context-mode-ops/triage-issue.md +0 -266
  134. package/skills/context-mode-ops/validation.md +0 -307
  135. package/skills/diagnose/SKILL.md +0 -122
  136. package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
  137. package/skills/grill-me/SKILL.md +0 -15
  138. package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
  139. package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
  140. package/skills/grill-with-docs/SKILL.md +0 -93
  141. package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
  142. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
  143. package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
  144. package/skills/improve-codebase-architecture/SKILL.md +0 -76
  145. package/skills/tdd/SKILL.md +0 -114
  146. package/skills/tdd/deep-modules.md +0 -33
  147. package/skills/tdd/interface-design.md +0 -31
  148. package/skills/tdd/mocking.md +0 -59
  149. package/skills/tdd/refactoring.md +0 -10
  150. package/skills/tdd/tests.md +0 -61
@@ -8,8 +8,93 @@
8
8
  import { SQLiteBase } from "../db-base.js";
9
9
  import type { SessionEvent } from "../types.js";
10
10
  import type { ProjectAttribution } from "./project-attribution.js";
11
- export declare function getWorktreeSuffix(): string;
11
+ export declare function normalizeWorktreePath(path: string): string;
12
+ export declare function getWorktreeSuffix(projectDir?: string): string;
12
13
  export declare function _resetWorktreeSuffixCacheForTests(): void;
14
+ /**
15
+ * Hash a project directory the way the deployed code (≤ v1.0.111) did:
16
+ * normalize slashes only, preserve raw casing. Kept exported so the
17
+ * migration helper can locate pre-fix DB files for one-shot rename.
18
+ *
19
+ * Do NOT call this for new code paths — use {@link hashProjectDirCanonical}.
20
+ */
21
+ export declare function hashProjectDirLegacy(projectDir: string): string;
22
+ /**
23
+ * Hash a project directory case-stably. On case-insensitive filesystems
24
+ * (macOS HFS+/APFS, Windows NTFS) the path is lowercased so that
25
+ * `/Users/Mert/proj` and `/users/mert/proj` resolve to the same DB file.
26
+ * On Linux (case-sensitive) casing is preserved.
27
+ *
28
+ * Used as the base half of the SessionDB filename:
29
+ * <baseHash><worktreeSuffix>.db
30
+ */
31
+ export declare function hashProjectDirCanonical(projectDir: string): string;
32
+ /**
33
+ * Resolve the per-project FTS5 content store DB path, performing a one-shot
34
+ * migration from a legacy raw-casing filename to the canonical one when only
35
+ * the legacy file (with optional `-wal` / `-shm` SQLite sidecars) exists.
36
+ *
37
+ * Same dual-hash safety contract as {@link resolveSessionDbPath}:
38
+ * - Linux: canonical hash equals legacy hash → no migration attempted.
39
+ * - Mac/Win: rename legacy → canonical when canonical missing.
40
+ * - Both exist: leave legacy alone (data-loss safety). Caller picks
41
+ * canonical; reconciliation is a manual operation.
42
+ *
43
+ * Differs from `resolveSessionDbPath` in two ways:
44
+ * 1. No worktree suffix — the FTS5 store is per-project, not per-worktree.
45
+ * 2. The `-wal` / `-shm` sidecars travel with the main `.db` during
46
+ * migration so an active SQLite WAL checkpoint is not stranded behind.
47
+ */
48
+ export declare function resolveContentStorePath(opts: {
49
+ projectDir: string;
50
+ contentDir: string;
51
+ }): string;
52
+ /**
53
+ * Resolve the SessionDB file path for a project, performing a one-shot
54
+ * migration from legacy raw-casing filenames to canonical ones when only
55
+ * the legacy file exists.
56
+ *
57
+ * Migration rules:
58
+ * - Linux: `legacyHash === canonicalHash` so the resolver short-circuits;
59
+ * no migration ever runs (case-sensitive FS, never any drift).
60
+ * - macOS / Windows: if the canonical path does not exist but a legacy
61
+ * path does, rename in place. This preserves the user's session
62
+ * history across the casing-fix upgrade.
63
+ * - When BOTH paths exist (rare — usually only if the user previously
64
+ * ran two terminals with different casing) the legacy file is left
65
+ * UNTOUCHED. The canonical path wins; manual reconciliation needed.
66
+ * Avoiding the rename here is the data-loss safety guarantee.
67
+ *
68
+ * Worktree separation is preserved: each call only ever migrates the ONE
69
+ * legacy file matching THIS projectDir's hash. Different worktrees have
70
+ * different physical paths → different hashes → different DB files; the
71
+ * migration cannot collapse worktrees.
72
+ */
73
+ export declare function resolveSessionDbPath(opts: {
74
+ projectDir: string;
75
+ sessionsDir: string;
76
+ }): string;
77
+ /**
78
+ * Generalized resolver: same case-fold + one-shot legacy-rename semantics
79
+ * as {@link resolveSessionDbPath}, parameterised on the file extension so
80
+ * the SAME logic powers `.db`, `-events.md`, and `.cleanup` paths.
81
+ *
82
+ * Source of truth for hooks: `hooks/session-helpers.mjs` imports this
83
+ * function from the bundled output (`hooks/session-db.bundle.mjs`) so the
84
+ * JS hooks and the TS server can never drift again on hash, suffix, or
85
+ * migration policy.
86
+ *
87
+ * Optional `suffix` lets the hook layer inject its cross-process cached
88
+ * worktree suffix (the marker-file optimisation that amortises the
89
+ * `git worktree list` cost across hook forks). When omitted, falls back
90
+ * to {@link getWorktreeSuffix} which uses an in-process cache only.
91
+ */
92
+ export declare function resolveSessionPath(opts: {
93
+ projectDir: string;
94
+ sessionsDir: string;
95
+ ext: string;
96
+ suffix?: string;
97
+ }): string;
13
98
  /** A stored event row from the session_events table. */
14
99
  export interface StoredEvent {
15
100
  id: number;
@@ -21,10 +106,19 @@ export interface StoredEvent {
21
106
  project_dir: string;
22
107
  attribution_source: string;
23
108
  attribution_confidence: number;
109
+ bytes_avoided: number;
110
+ bytes_returned: number;
24
111
  source_hook: string;
25
112
  created_at: string;
26
113
  data_hash: string;
27
114
  }
115
+ /** Optional per-event byte accounting passed to {@link SessionDB.insertEvent}. */
116
+ export interface EventBytes {
117
+ /** Bytes context-mode prevented from entering the model context window. */
118
+ bytesAvoided?: number;
119
+ /** Bytes context-mode actually returned to the model. */
120
+ bytesReturned?: number;
121
+ }
28
122
  /** Session metadata row from the session_meta table. */
29
123
  export interface SessionMeta {
30
124
  session_id: string;
@@ -79,7 +173,7 @@ export declare class SessionDB extends SQLiteBase {
79
173
  */
80
174
  insertEvent(sessionId: string, event: Omit<SessionEvent, "data_hash"> & {
81
175
  data_hash?: string;
82
- }, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
176
+ }, sourceHook?: string, attribution?: Partial<ProjectAttribution>, bytes?: EventBytes): void;
83
177
  /**
84
178
  * Bulk-insert N events in a SINGLE transaction.
85
179
  *
@@ -91,7 +185,7 @@ export declare class SessionDB extends SQLiteBase {
91
185
  * Cross-platform: uses the same WAL-mode transaction primitive as
92
186
  * insertEvent — behavior identical on macOS / Linux / Windows.
93
187
  */
94
- bulkInsertEvents(sessionId: string, events: SessionEvent[], sourceHook?: string, attributions?: Array<Partial<ProjectAttribution> | undefined>): void;
188
+ bulkInsertEvents(sessionId: string, events: SessionEvent[], sourceHook?: string, attributions?: Array<Partial<ProjectAttribution> | undefined>, bytesList?: Array<EventBytes | undefined>): void;
95
189
  /**
96
190
  * Retrieve events for a session with optional filtering.
97
191
  */
@@ -104,6 +198,20 @@ export declare class SessionDB extends SQLiteBase {
104
198
  * Get the total event count for a session.
105
199
  */
106
200
  getEventCount(sessionId: string): number;
201
+ /**
202
+ * Aggregate per-event byte accounting for a session.
203
+ *
204
+ * Returns the total bytes context-mode kept OUT of the model context
205
+ * window (`bytesAvoided`) and the total it actually returned to the
206
+ * model (`bytesReturned`). Both default to 0 for unknown sessions.
207
+ *
208
+ * Used by the Insight dashboard to render the "saved vs returned"
209
+ * panel without scanning every event row in JS.
210
+ */
211
+ getEventBytesSummary(sessionId: string): {
212
+ bytesAvoided: number;
213
+ bytesReturned: number;
214
+ };
107
215
  /**
108
216
  * Return the most recently attributed project dir for a session.
109
217
  */
@@ -154,8 +262,9 @@ export declare class SessionDB extends SQLiteBase {
154
262
  * Atomically claim the most recent unconsumed resume snapshot in this DB,
155
263
  * EXCLUDING any row that belongs to `currentSessionId`.
156
264
  *
157
- * `SessionDB` is sharded per project (see `getSessionDBPath` — SHA-256 of
158
- * project dir), so "this DB" already implies "this project". The atomic
265
+ * `SessionDB` is sharded per project (see `resolveSessionDbPath` — SHA-256
266
+ * of canonical project dir), so "this DB" already implies "this project".
267
+ * The atomic
159
268
  * `UPDATE … RETURNING` ensures concurrent processes for the same project
160
269
  * cannot both inject the same snapshot (Mickey / PR #376 race).
161
270
  *
@@ -8,6 +8,8 @@
8
8
  import { SQLiteBase, defaultDBPath } from "../db-base.js";
9
9
  import { createHash } from "node:crypto";
10
10
  import { execFileSync } from "node:child_process";
11
+ import { existsSync, realpathSync, renameSync } from "node:fs";
12
+ import { join } from "node:path";
11
13
  // ─────────────────────────────────────────────────────────
12
14
  // Worktree isolation
13
15
  // ─────────────────────────────────────────────────────────
@@ -19,15 +21,61 @@ import { execFileSync } from "node:child_process";
19
21
  * (useful in CI environments or when git is unavailable).
20
22
  * Set to empty string to disable isolation entirely.
21
23
  */
22
- // Memoized per (cwd, env override) — recomputing on every tool call cost
24
+ // Memoized per (projectDir, env override) — recomputing on every tool call cost
23
25
  // ~12ms (git worktree list subprocess fork) on macOS, 50ms+ on Windows.
24
- // Key by cwd so a defensive `process.chdir()` invalidates rather than
25
- // returning stale data.
26
+ // Key by projectDir so callers can pass the actual workspace even when the
27
+ // MCP server has chdir'd into the installed package directory.
26
28
  let _wtCache;
27
- export function getWorktreeSuffix() {
29
+ export function normalizeWorktreePath(path) {
30
+ const normalized = path.replace(/\\/g, "/");
31
+ if (/^\/+$/.test(normalized))
32
+ return "/";
33
+ if (/^[A-Za-z]:\/+$/.test(normalized))
34
+ return `${normalized.slice(0, 2)}/`;
35
+ return normalized.replace(/\/+$/, "");
36
+ }
37
+ // Case-insensitive filesystems (macOS HFS+/APFS default, Windows NTFS default)
38
+ // can report `currentRoot` and `mainRoot` with different casing for the same
39
+ // physical directory — git itself sometimes preserves the on-disk casing while
40
+ // user-supplied paths use a different casing. Compare canonically by resolving
41
+ // symlinks via realpath and case-folding on these platforms. POSIX/Linux is
42
+ // strictly case-sensitive so this is a no-op there.
43
+ function canonicalizeForCompare(root) {
44
+ let resolved = root;
45
+ try {
46
+ resolved = realpathSync.native(root);
47
+ }
48
+ catch {
49
+ // Path may not exist (test fixtures, deleted dirs); fall back to as-given.
50
+ }
51
+ const normalized = normalizeWorktreePath(resolved);
52
+ if (process.platform === "win32" || process.platform === "darwin") {
53
+ return normalized.toLowerCase();
54
+ }
55
+ return normalized;
56
+ }
57
+ function gitOutput(projectDir, args) {
58
+ return execFileSync("git", ["-C", projectDir, ...args], {
59
+ encoding: "utf-8",
60
+ timeout: 2000,
61
+ stdio: ["ignore", "pipe", "ignore"],
62
+ }).trim();
63
+ }
64
+ function getCurrentWorktreeRoot(projectDir) {
65
+ const root = gitOutput(projectDir, ["rev-parse", "--show-toplevel"]);
66
+ return root.length > 0 ? normalizeWorktreePath(root) : null;
67
+ }
68
+ function getMainWorktreeRoot(projectDir) {
69
+ const root = gitOutput(projectDir, ["worktree", "list", "--porcelain"])
70
+ .split(/\r?\n/)
71
+ .find((line) => line.startsWith("worktree "))
72
+ ?.replace("worktree ", "")
73
+ ?.trim();
74
+ return root ? normalizeWorktreePath(root) : null;
75
+ }
76
+ export function getWorktreeSuffix(projectDir = process.cwd()) {
28
77
  const envSuffix = process.env.CONTEXT_MODE_SESSION_SUFFIX;
29
- const cwd = process.cwd();
30
- if (_wtCache && _wtCache.cwd === cwd && _wtCache.envSuffix === envSuffix) {
78
+ if (_wtCache && _wtCache.projectDir === projectDir && _wtCache.envSuffix === envSuffix) {
31
79
  return _wtCache.suffix;
32
80
  }
33
81
  let suffix = "";
@@ -36,24 +84,27 @@ export function getWorktreeSuffix() {
36
84
  }
37
85
  else {
38
86
  try {
39
- const mainWorktree = execFileSync("git", ["worktree", "list", "--porcelain"], {
40
- encoding: "utf-8",
41
- timeout: 2000,
42
- stdio: ["ignore", "pipe", "ignore"],
43
- })
44
- .split(/\r?\n/)
45
- .find((l) => l.startsWith("worktree "))
46
- ?.replace("worktree ", "")
47
- ?.trim();
48
- if (mainWorktree && cwd !== mainWorktree) {
49
- suffix = `__${createHash("sha256").update(cwd).digest("hex").slice(0, 8)}`;
87
+ const currentRoot = getCurrentWorktreeRoot(projectDir);
88
+ const mainRoot = getMainWorktreeRoot(projectDir);
89
+ if (currentRoot && mainRoot) {
90
+ // Use the canonicalized currentRoot for BOTH the comparison and the
91
+ // hash so the suffix DB filename stays stable across casing-variant
92
+ // calls on the same machine (round-5 finding). Previously the hash
93
+ // ate raw casing, so the same linked worktree could land at two
94
+ // different `__<8-hex>` files depending on which casing the caller
95
+ // passed in.
96
+ const canonicalCurrent = canonicalizeForCompare(currentRoot);
97
+ const canonicalMain = canonicalizeForCompare(mainRoot);
98
+ if (canonicalCurrent !== canonicalMain) {
99
+ suffix = `__${createHash("sha256").update(canonicalCurrent).digest("hex").slice(0, 8)}`;
100
+ }
50
101
  }
51
102
  }
52
103
  catch {
53
104
  // git not available or not a git repo — no suffix
54
105
  }
55
106
  }
56
- _wtCache = { cwd, envSuffix, suffix };
107
+ _wtCache = { projectDir, envSuffix, suffix };
57
108
  return suffix;
58
109
  }
59
110
  // Test-only helper: clear the memoization between cases.
@@ -61,12 +112,160 @@ export function _resetWorktreeSuffixCacheForTests() {
61
112
  _wtCache = undefined;
62
113
  }
63
114
  // ─────────────────────────────────────────────────────────
115
+ // SessionDB path resolution + case-fold migration
116
+ // ─────────────────────────────────────────────────────────
117
+ /**
118
+ * Hash a project directory the way the deployed code (≤ v1.0.111) did:
119
+ * normalize slashes only, preserve raw casing. Kept exported so the
120
+ * migration helper can locate pre-fix DB files for one-shot rename.
121
+ *
122
+ * Do NOT call this for new code paths — use {@link hashProjectDirCanonical}.
123
+ */
124
+ export function hashProjectDirLegacy(projectDir) {
125
+ return createHash("sha256")
126
+ .update(normalizeWorktreePath(projectDir))
127
+ .digest("hex")
128
+ .slice(0, 16);
129
+ }
130
+ /**
131
+ * Hash a project directory case-stably. On case-insensitive filesystems
132
+ * (macOS HFS+/APFS, Windows NTFS) the path is lowercased so that
133
+ * `/Users/Mert/proj` and `/users/mert/proj` resolve to the same DB file.
134
+ * On Linux (case-sensitive) casing is preserved.
135
+ *
136
+ * Used as the base half of the SessionDB filename:
137
+ * <baseHash><worktreeSuffix>.db
138
+ */
139
+ export function hashProjectDirCanonical(projectDir) {
140
+ const normalized = normalizeWorktreePath(projectDir);
141
+ const folded = (process.platform === "darwin" || process.platform === "win32")
142
+ ? normalized.toLowerCase()
143
+ : normalized;
144
+ return createHash("sha256").update(folded).digest("hex").slice(0, 16);
145
+ }
146
+ /**
147
+ * Resolve the per-project FTS5 content store DB path, performing a one-shot
148
+ * migration from a legacy raw-casing filename to the canonical one when only
149
+ * the legacy file (with optional `-wal` / `-shm` SQLite sidecars) exists.
150
+ *
151
+ * Same dual-hash safety contract as {@link resolveSessionDbPath}:
152
+ * - Linux: canonical hash equals legacy hash → no migration attempted.
153
+ * - Mac/Win: rename legacy → canonical when canonical missing.
154
+ * - Both exist: leave legacy alone (data-loss safety). Caller picks
155
+ * canonical; reconciliation is a manual operation.
156
+ *
157
+ * Differs from `resolveSessionDbPath` in two ways:
158
+ * 1. No worktree suffix — the FTS5 store is per-project, not per-worktree.
159
+ * 2. The `-wal` / `-shm` sidecars travel with the main `.db` during
160
+ * migration so an active SQLite WAL checkpoint is not stranded behind.
161
+ */
162
+ export function resolveContentStorePath(opts) {
163
+ const { projectDir, contentDir } = opts;
164
+ const canonicalHash = hashProjectDirCanonical(projectDir);
165
+ const canonicalPath = join(contentDir, `${canonicalHash}.db`);
166
+ if (existsSync(canonicalPath))
167
+ return canonicalPath;
168
+ const legacyHash = hashProjectDirLegacy(projectDir);
169
+ if (legacyHash === canonicalHash)
170
+ return canonicalPath; // Linux short-circuit
171
+ const legacyPath = join(contentDir, `${legacyHash}.db`);
172
+ if (existsSync(legacyPath)) {
173
+ try {
174
+ renameSync(legacyPath, canonicalPath);
175
+ // Travel the SQLite sidecars too so an active WAL is not orphaned.
176
+ for (const suffix of ["-wal", "-shm"]) {
177
+ try {
178
+ renameSync(legacyPath + suffix, canonicalPath + suffix);
179
+ }
180
+ catch { /* sidecar may not exist */ }
181
+ }
182
+ }
183
+ catch {
184
+ // Race or permission issue — caller will create canonicalPath fresh.
185
+ }
186
+ }
187
+ return canonicalPath;
188
+ }
189
+ /**
190
+ * Resolve the SessionDB file path for a project, performing a one-shot
191
+ * migration from legacy raw-casing filenames to canonical ones when only
192
+ * the legacy file exists.
193
+ *
194
+ * Migration rules:
195
+ * - Linux: `legacyHash === canonicalHash` so the resolver short-circuits;
196
+ * no migration ever runs (case-sensitive FS, never any drift).
197
+ * - macOS / Windows: if the canonical path does not exist but a legacy
198
+ * path does, rename in place. This preserves the user's session
199
+ * history across the casing-fix upgrade.
200
+ * - When BOTH paths exist (rare — usually only if the user previously
201
+ * ran two terminals with different casing) the legacy file is left
202
+ * UNTOUCHED. The canonical path wins; manual reconciliation needed.
203
+ * Avoiding the rename here is the data-loss safety guarantee.
204
+ *
205
+ * Worktree separation is preserved: each call only ever migrates the ONE
206
+ * legacy file matching THIS projectDir's hash. Different worktrees have
207
+ * different physical paths → different hashes → different DB files; the
208
+ * migration cannot collapse worktrees.
209
+ */
210
+ export function resolveSessionDbPath(opts) {
211
+ return resolveSessionPath({ ...opts, ext: ".db" });
212
+ }
213
+ /**
214
+ * Generalized resolver: same case-fold + one-shot legacy-rename semantics
215
+ * as {@link resolveSessionDbPath}, parameterised on the file extension so
216
+ * the SAME logic powers `.db`, `-events.md`, and `.cleanup` paths.
217
+ *
218
+ * Source of truth for hooks: `hooks/session-helpers.mjs` imports this
219
+ * function from the bundled output (`hooks/session-db.bundle.mjs`) so the
220
+ * JS hooks and the TS server can never drift again on hash, suffix, or
221
+ * migration policy.
222
+ *
223
+ * Optional `suffix` lets the hook layer inject its cross-process cached
224
+ * worktree suffix (the marker-file optimisation that amortises the
225
+ * `git worktree list` cost across hook forks). When omitted, falls back
226
+ * to {@link getWorktreeSuffix} which uses an in-process cache only.
227
+ */
228
+ export function resolveSessionPath(opts) {
229
+ const { projectDir, sessionsDir, ext } = opts;
230
+ const suffix = opts.suffix ?? getWorktreeSuffix(projectDir);
231
+ const canonicalHash = hashProjectDirCanonical(projectDir);
232
+ const canonicalPath = join(sessionsDir, `${canonicalHash}${suffix}${ext}`);
233
+ if (existsSync(canonicalPath))
234
+ return canonicalPath;
235
+ const legacyHash = hashProjectDirLegacy(projectDir);
236
+ if (legacyHash === canonicalHash)
237
+ return canonicalPath; // Linux or already canonical
238
+ const legacyPath = join(sessionsDir, `${legacyHash}${suffix}${ext}`);
239
+ if (existsSync(legacyPath)) {
240
+ try {
241
+ renameSync(legacyPath, canonicalPath);
242
+ }
243
+ catch {
244
+ // Race or permission issue — caller will create canonicalPath on first
245
+ // write. Better to lose this rename than to throw and break ctx_stats.
246
+ }
247
+ }
248
+ return canonicalPath;
249
+ }
250
+ // ─────────────────────────────────────────────────────────
64
251
  // Constants
65
252
  // ─────────────────────────────────────────────────────────
66
253
  /** Maximum events per session before FIFO eviction kicks in. */
67
254
  const MAX_EVENTS_PER_SESSION = 1000;
68
255
  /** Number of recent events to check for deduplication. */
69
256
  const DEDUP_WINDOW = 5;
257
+ /**
258
+ * Coerce an arbitrary input to a non-negative integer suitable for
259
+ * SQLite's INTEGER column. Accepts undefined / null / NaN / floats
260
+ * and returns 0 for invalid inputs so the column never violates its
261
+ * NOT NULL DEFAULT 0 contract.
262
+ */
263
+ function clampNonNegativeInt(value) {
264
+ const n = Number(value);
265
+ if (!Number.isFinite(n) || n <= 0)
266
+ return 0;
267
+ return Math.floor(n);
268
+ }
70
269
  // ─────────────────────────────────────────────────────────
71
270
  // Statement keys (typed enum to avoid string typos)
72
271
  // ─────────────────────────────────────────────────────────
@@ -96,6 +295,7 @@ const S = {
96
295
  incrementToolCall: "incrementToolCall",
97
296
  getToolCallTotals: "getToolCallTotals",
98
297
  getToolCallByTool: "getToolCallByTool",
298
+ getEventBytesSummary: "getEventBytesSummary",
99
299
  };
100
300
  // ─────────────────────────────────────────────────────────
101
301
  // SessionDB
@@ -133,6 +333,8 @@ export class SessionDB extends SQLiteBase {
133
333
  project_dir TEXT NOT NULL DEFAULT '',
134
334
  attribution_source TEXT NOT NULL DEFAULT 'unknown',
135
335
  attribution_confidence REAL NOT NULL DEFAULT 0,
336
+ bytes_avoided INTEGER NOT NULL DEFAULT 0,
337
+ bytes_returned INTEGER NOT NULL DEFAULT 0,
136
338
  source_hook TEXT NOT NULL,
137
339
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
138
340
  data_hash TEXT NOT NULL DEFAULT ''
@@ -184,6 +386,12 @@ export class SessionDB extends SQLiteBase {
184
386
  if (!cols.has("attribution_confidence")) {
185
387
  this.db.exec("ALTER TABLE session_events ADD COLUMN attribution_confidence REAL NOT NULL DEFAULT 0");
186
388
  }
389
+ if (!cols.has("bytes_avoided")) {
390
+ this.db.exec("ALTER TABLE session_events ADD COLUMN bytes_avoided INTEGER NOT NULL DEFAULT 0");
391
+ }
392
+ if (!cols.has("bytes_returned")) {
393
+ this.db.exec("ALTER TABLE session_events ADD COLUMN bytes_returned INTEGER NOT NULL DEFAULT 0");
394
+ }
187
395
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)");
188
396
  }
189
397
  catch {
@@ -199,23 +407,28 @@ export class SessionDB extends SQLiteBase {
199
407
  p(S.insertEvent, `INSERT INTO session_events (
200
408
  session_id, type, category, priority, data,
201
409
  project_dir, attribution_source, attribution_confidence,
410
+ bytes_avoided, bytes_returned,
202
411
  source_hook, data_hash
203
412
  )
204
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
413
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
205
414
  p(S.getEvents, `SELECT id, session_id, type, category, priority, data,
206
415
  project_dir, attribution_source, attribution_confidence,
416
+ bytes_avoided, bytes_returned,
207
417
  source_hook, created_at, data_hash
208
418
  FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT ?`);
209
419
  p(S.getEventsByType, `SELECT id, session_id, type, category, priority, data,
210
420
  project_dir, attribution_source, attribution_confidence,
421
+ bytes_avoided, bytes_returned,
211
422
  source_hook, created_at, data_hash
212
423
  FROM session_events WHERE session_id = ? AND type = ? ORDER BY id ASC LIMIT ?`);
213
424
  p(S.getEventsByPriority, `SELECT id, session_id, type, category, priority, data,
214
425
  project_dir, attribution_source, attribution_confidence,
426
+ bytes_avoided, bytes_returned,
215
427
  source_hook, created_at, data_hash
216
428
  FROM session_events WHERE session_id = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
217
429
  p(S.getEventsByTypeAndPriority, `SELECT id, session_id, type, category, priority, data,
218
430
  project_dir, attribution_source, attribution_confidence,
431
+ bytes_avoided, bytes_returned,
219
432
  source_hook, created_at, data_hash
220
433
  FROM session_events WHERE session_id = ? AND type = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
221
434
  p(S.getEventCount, `SELECT COUNT(*) AS cnt FROM session_events WHERE session_id = ?`);
@@ -297,6 +510,10 @@ export class SessionDB extends SQLiteBase {
297
510
  FROM tool_calls WHERE session_id = ?`);
298
511
  p(S.getToolCallByTool, `SELECT tool, calls, bytes_returned
299
512
  FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`);
513
+ // ── Event-level byte accounting (D2 PRD Phase 2) ──
514
+ p(S.getEventBytesSummary, `SELECT COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
515
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
516
+ FROM session_events WHERE session_id = ?`);
300
517
  }
301
518
  // ═══════════════════════════════════════════
302
519
  // Events
@@ -310,7 +527,7 @@ export class SessionDB extends SQLiteBase {
310
527
  * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
311
528
  * lowest-priority (then oldest) event.
312
529
  */
313
- insertEvent(sessionId, event, sourceHook = "PostToolUse", attribution) {
530
+ insertEvent(sessionId, event, sourceHook = "PostToolUse", attribution, bytes) {
314
531
  // SHA256-based dedup hash (first 16 hex chars = 8 bytes of entropy)
315
532
  const dataHash = createHash("sha256")
316
533
  .update(event.data)
@@ -329,6 +546,8 @@ export class SessionDB extends SQLiteBase {
329
546
  const attributionConfidence = Number.isFinite(rawConfidence)
330
547
  ? Math.max(0, Math.min(1, rawConfidence))
331
548
  : 0;
549
+ const bytesAvoided = clampNonNegativeInt(bytes?.bytesAvoided);
550
+ const bytesReturned = clampNonNegativeInt(bytes?.bytesReturned);
332
551
  // Atomic: dedup check + eviction + insert in a single transaction
333
552
  // to prevent race conditions from concurrent hook calls.
334
553
  const transaction = this.db.transaction(() => {
@@ -342,7 +561,7 @@ export class SessionDB extends SQLiteBase {
342
561
  this.stmt(S.evictLowestPriority).run(sessionId);
343
562
  }
344
563
  // Insert the event
345
- this.stmt(S.insertEvent).run(sessionId, event.type, event.category, event.priority, event.data, projectDir, attributionSource, attributionConfidence, sourceHook, dataHash);
564
+ this.stmt(S.insertEvent).run(sessionId, event.type, event.category, event.priority, event.data, projectDir, attributionSource, attributionConfidence, bytesAvoided, bytesReturned, sourceHook, dataHash);
346
565
  // Update meta if session exists
347
566
  this.stmt(S.updateMetaLastEvent).run(sessionId);
348
567
  });
@@ -359,12 +578,12 @@ export class SessionDB extends SQLiteBase {
359
578
  * Cross-platform: uses the same WAL-mode transaction primitive as
360
579
  * insertEvent — behavior identical on macOS / Linux / Windows.
361
580
  */
362
- bulkInsertEvents(sessionId, events, sourceHook = "PostToolUse", attributions) {
581
+ bulkInsertEvents(sessionId, events, sourceHook = "PostToolUse", attributions, bytesList) {
363
582
  if (!events || events.length === 0)
364
583
  return;
365
584
  if (events.length === 1) {
366
585
  // Cheaper to fall through to insertEvent (its own dedicated transaction).
367
- this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0]);
586
+ this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0], bytesList?.[0]);
368
587
  return;
369
588
  }
370
589
  // Pre-compute hashes + normalized attribution outside the transaction
@@ -382,7 +601,18 @@ export class SessionDB extends SQLiteBase {
382
601
  const attributionConfidence = Number.isFinite(rawConfidence)
383
602
  ? Math.max(0, Math.min(1, rawConfidence))
384
603
  : 0;
385
- return { event, dataHash, projectDir, attributionSource, attributionConfidence };
604
+ const eventBytes = bytesList?.[i];
605
+ const bytesAvoided = clampNonNegativeInt(eventBytes?.bytesAvoided);
606
+ const bytesReturned = clampNonNegativeInt(eventBytes?.bytesReturned);
607
+ return {
608
+ event,
609
+ dataHash,
610
+ projectDir,
611
+ attributionSource,
612
+ attributionConfidence,
613
+ bytesAvoided,
614
+ bytesReturned,
615
+ };
386
616
  });
387
617
  const transaction = this.db.transaction(() => {
388
618
  let cnt = this.stmt(S.getEventCount).get(sessionId).cnt;
@@ -396,7 +626,7 @@ export class SessionDB extends SQLiteBase {
396
626
  else {
397
627
  cnt++;
398
628
  }
399
- this.stmt(S.insertEvent).run(sessionId, row.event.type, row.event.category, row.event.priority, row.event.data, row.projectDir, row.attributionSource, row.attributionConfidence, sourceHook, row.dataHash);
629
+ this.stmt(S.insertEvent).run(sessionId, row.event.type, row.event.category, row.event.priority, row.event.data, row.projectDir, row.attributionSource, row.attributionConfidence, row.bytesAvoided, row.bytesReturned, sourceHook, row.dataHash);
400
630
  }
401
631
  this.stmt(S.updateMetaLastEvent).run(sessionId);
402
632
  });
@@ -427,6 +657,23 @@ export class SessionDB extends SQLiteBase {
427
657
  const row = this.stmt(S.getEventCount).get(sessionId);
428
658
  return row.cnt;
429
659
  }
660
+ /**
661
+ * Aggregate per-event byte accounting for a session.
662
+ *
663
+ * Returns the total bytes context-mode kept OUT of the model context
664
+ * window (`bytesAvoided`) and the total it actually returned to the
665
+ * model (`bytesReturned`). Both default to 0 for unknown sessions.
666
+ *
667
+ * Used by the Insight dashboard to render the "saved vs returned"
668
+ * panel without scanning every event row in JS.
669
+ */
670
+ getEventBytesSummary(sessionId) {
671
+ const row = this.stmt(S.getEventBytesSummary).get(sessionId);
672
+ return {
673
+ bytesAvoided: Number(row?.bytes_avoided ?? 0),
674
+ bytesReturned: Number(row?.bytes_returned ?? 0),
675
+ };
676
+ }
430
677
  /**
431
678
  * Return the most recently attributed project dir for a session.
432
679
  */
@@ -502,8 +749,9 @@ export class SessionDB extends SQLiteBase {
502
749
  * Atomically claim the most recent unconsumed resume snapshot in this DB,
503
750
  * EXCLUDING any row that belongs to `currentSessionId`.
504
751
  *
505
- * `SessionDB` is sharded per project (see `getSessionDBPath` — SHA-256 of
506
- * project dir), so "this DB" already implies "this project". The atomic
752
+ * `SessionDB` is sharded per project (see `resolveSessionDbPath` — SHA-256
753
+ * of canonical project dir), so "this DB" already implies "this project".
754
+ * The atomic
507
755
  * `UPDATE … RETURNING` ensures concurrent processes for the same project
508
756
  * cannot both inject the same snapshot (Mickey / PR #376 race).
509
757
  *
@@ -0,0 +1,48 @@
1
+ /**
2
+ * event-emit — Phase 5+7 of D2 PRD (stats-event-driven-architecture)
3
+ *
4
+ * Server-side helpers that record sandbox / index / cache work into
5
+ * `session_events` with the new `bytes_avoided` / `bytes_returned`
6
+ * columns so the renderer can compute the real $ saved instead of the
7
+ * conservative `events × 256` token estimate.
8
+ *
9
+ * Design notes
10
+ * ────────────
11
+ * - Uses the public `SessionDB.insertEvent(... , bytes)` API the schema
12
+ * engineer extended in this branch — same dedup + FIFO eviction +
13
+ * transaction wrapping you'd get from any other event source.
14
+ * - Best-effort error swallowing matches `persistToolCallCounter` in
15
+ * `persist-tool-calls.ts`. A stats-side failure must NEVER break the
16
+ * parent MCP tool call.
17
+ * - Resolves the latest `session_id` from `session_meta` so the wiring
18
+ * in `server.ts` is `setImmediate(() => emit*({...}))` — no need to
19
+ * plumb session ids through every handler.
20
+ */
21
+ /**
22
+ * Record a `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` run.
23
+ * `bytesReturned` is the size of the stdout text the user actually saw —
24
+ * the rest of the sandbox output stayed out of context.
25
+ */
26
+ export declare function emitSandboxExecuteEvent(opts: {
27
+ sessionDbPath: string;
28
+ toolName: string;
29
+ bytesReturned: number;
30
+ }): void;
31
+ /**
32
+ * Record a `ctx_index` / `trackIndexed` write — content kept out of
33
+ * context by being chunked into FTS5 instead of returned inline.
34
+ */
35
+ export declare function emitIndexWriteEvent(opts: {
36
+ sessionDbPath: string;
37
+ source: string;
38
+ bytesAvoided: number;
39
+ }): void;
40
+ /**
41
+ * Record a `ctx_fetch_and_index` TTL cache hit — bytes the user would
42
+ * have spent re-fetching the same URL within the 24h cache window.
43
+ */
44
+ export declare function emitCacheHitEvent(opts: {
45
+ sessionDbPath: string;
46
+ source: string;
47
+ bytesAvoided: number;
48
+ }): void;