dsh-context-mode 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSING.md +37 -0
  2. package/README.md +40 -14
  3. package/lib/types/cjk.d.ts +54 -0
  4. package/lib/types/cjk.d.ts.map +1 -0
  5. package/lib/types/cjk.js +64 -0
  6. package/lib/types/index.d.ts.map +1 -1
  7. package/lib/types/index.js +71 -22
  8. package/lib/types/output-containment.d.ts +35 -0
  9. package/lib/types/output-containment.d.ts.map +1 -0
  10. package/lib/types/output-containment.js +103 -0
  11. package/lib/types/routing.d.ts +3 -1
  12. package/lib/types/routing.d.ts.map +1 -1
  13. package/lib/types/routing.js +81 -6
  14. package/lib/types/session-memory.d.ts.map +1 -1
  15. package/lib/types/session-memory.js +14 -3
  16. package/package.json +9 -5
  17. package/skills/context-mode/SKILL.md +104 -11
  18. package/vendor/context-mode/LICENSE +94 -0
  19. package/vendor/context-mode/server.bundle.mjs +1126 -0
  20. package/vendor/context-mode/src/cli.ts +2040 -0
  21. package/vendor/context-mode/src/db-base.ts +617 -0
  22. package/vendor/context-mode/src/executor.ts +785 -0
  23. package/vendor/context-mode/src/exit-classify.ts +33 -0
  24. package/vendor/context-mode/src/fetch-cache.ts +15 -0
  25. package/vendor/context-mode/src/lifecycle.ts +305 -0
  26. package/vendor/context-mode/src/platform/client-map.ts +45 -0
  27. package/vendor/context-mode/src/platform/detect.ts +645 -0
  28. package/vendor/context-mode/src/platform/dsh.ts +206 -0
  29. package/vendor/context-mode/src/platform/types.ts +503 -0
  30. package/vendor/context-mode/src/runPool.ts +81 -0
  31. package/vendor/context-mode/src/runtime.ts +765 -0
  32. package/vendor/context-mode/src/search/auto-memory.ts +200 -0
  33. package/vendor/context-mode/src/search/ctx-search-schema.ts +143 -0
  34. package/vendor/context-mode/src/search/flood-guard.ts +111 -0
  35. package/vendor/context-mode/src/search/unified.ts +176 -0
  36. package/vendor/context-mode/src/security.ts +889 -0
  37. package/vendor/context-mode/src/server.ts +4991 -0
  38. package/vendor/context-mode/src/session/analytics.ts +3085 -0
  39. package/vendor/context-mode/src/session/db.ts +1726 -0
  40. package/vendor/context-mode/src/session/error-classifier.ts +392 -0
  41. package/vendor/context-mode/src/session/event-emit.ts +132 -0
  42. package/vendor/context-mode/src/session/extract.ts +2958 -0
  43. package/vendor/context-mode/src/session/index.ts +130 -0
  44. package/vendor/context-mode/src/session/model-prices.json +429 -0
  45. package/vendor/context-mode/src/session/persist-tool-calls.ts +128 -0
  46. package/vendor/context-mode/src/session/pricing.ts +191 -0
  47. package/vendor/context-mode/src/session/project-attribution.ts +309 -0
  48. package/vendor/context-mode/src/session/purge.ts +338 -0
  49. package/vendor/context-mode/src/session/retrieval-marker.ts +65 -0
  50. package/vendor/context-mode/src/session/snapshot.ts +577 -0
  51. package/vendor/context-mode/src/store-directory.ts +290 -0
  52. package/vendor/context-mode/src/store.ts +2071 -0
  53. package/vendor/context-mode/src/truncate.ts +154 -0
  54. package/vendor/context-mode/src/types.ts +147 -0
  55. package/vendor/context-mode/src/util/claude-config.ts +95 -0
  56. package/vendor/context-mode/src/util/hook-config.ts +78 -0
  57. package/vendor/context-mode/src/util/jsonc.ts +70 -0
  58. package/vendor/context-mode/src/util/plugin-cache-integrity.ts +167 -0
  59. package/vendor/context-mode/src/util/project-dir.ts +347 -0
  60. package/vendor/context-mode/src/util/sibling-mcp.ts +228 -0
@@ -0,0 +1,1726 @@
1
+ /**
2
+ * SessionDB — Persistent per-project SQLite database for session events.
3
+ *
4
+ * Stores raw events captured by hooks during a Claude Code session,
5
+ * session metadata, and resume snapshots. Extends SQLiteBase from
6
+ * the shared package.
7
+ */
8
+
9
+ import { SQLiteBase, defaultDBPath } from "../db-base.js";
10
+ import type { PreparedStatement } from "../db-base.js";
11
+ import type { SessionEvent } from "../types.js";
12
+ import type { ProjectAttribution } from "./project-attribution.js";
13
+ import { createHash } from "node:crypto";
14
+ import { execFileSync } from "node:child_process";
15
+ import { accessSync, constants, existsSync, mkdirSync, realpathSync, renameSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, isAbsolute, join, resolve } from "node:path";
18
+
19
+ // ─────────────────────────────────────────────────────────
20
+ // Storage root resolution
21
+ // ─────────────────────────────────────────────────────────
22
+ //
23
+ // This lives beside the session DB path helpers because packaged hooks and the
24
+ // statusline already consume `hooks/session-db.bundle.mjs` as their no-build
25
+ // runtime bridge. Keeping the storage resolver here avoids adding a second
26
+ // generated hook bundle just to share CONTEXT_MODE_DIR behavior.
27
+
28
+ const STORAGE_ROOT_ENV = "CONTEXT_MODE_DIR" as const;
29
+ const STORAGE_SESSIONS_SUBDIR = "sessions";
30
+ const STORAGE_CONTENT_SUBDIR = "content";
31
+
32
+ export type StorageDirectoryKind = "session" | "content" | "stats";
33
+ export type StorageOverrideEnvVar = typeof STORAGE_ROOT_ENV;
34
+ export type StorageDirectorySource = "default" | "override";
35
+ export type IgnoredStorageOverrideReason = "empty";
36
+
37
+ export interface ResolvedStorageDir {
38
+ kind: StorageDirectoryKind;
39
+ path: string;
40
+ envVar: StorageOverrideEnvVar | null;
41
+ source: StorageDirectorySource;
42
+ ignoredEnvVar?: StorageOverrideEnvVar;
43
+ ignoredReason?: IgnoredStorageOverrideReason;
44
+ }
45
+
46
+ export class StorageDirectoryError extends Error {
47
+ readonly kind: StorageDirectoryKind;
48
+ readonly path: string;
49
+ readonly overrideEnvVar: StorageOverrideEnvVar;
50
+ readonly ignoredEnvVar?: StorageOverrideEnvVar;
51
+ readonly ignoredReason?: IgnoredStorageOverrideReason;
52
+
53
+ constructor(
54
+ kind: StorageDirectoryKind,
55
+ path: string,
56
+ overrideEnvVar: StorageOverrideEnvVar = STORAGE_ROOT_ENV,
57
+ cause?: unknown,
58
+ message?: string,
59
+ metadata: Pick<ResolvedStorageDir, "ignoredEnvVar" | "ignoredReason"> = {},
60
+ ) {
61
+ super(message ?? storageDirectoryErrorMessage(kind, path, metadata), { cause });
62
+ this.name = "StorageDirectoryError";
63
+ this.kind = kind;
64
+ this.path = path;
65
+ this.overrideEnvVar = overrideEnvVar;
66
+ this.ignoredEnvVar = metadata.ignoredEnvVar;
67
+ this.ignoredReason = metadata.ignoredReason;
68
+ }
69
+ }
70
+
71
+ type OverrideRoot =
72
+ | { kind: "unset" }
73
+ | { kind: "ignored-empty"; ignoredEnvVar: StorageOverrideEnvVar; ignoredReason: IgnoredStorageOverrideReason }
74
+ | { kind: "override"; root: string };
75
+
76
+ const writableStorageCache = new Map<string, string | StorageDirectoryError>();
77
+
78
+ export interface DefaultSessionDirOptions {
79
+ configDir: string;
80
+ configDirEnv?: string;
81
+ legacySessionDirEnv?: string;
82
+ onLegacySessionDir?: (envVar: string, dir: string) => void;
83
+ env?: NodeJS.ProcessEnv;
84
+ }
85
+
86
+ export function resolveDefaultSessionDir(opts: DefaultSessionDirOptions): string {
87
+ const env = opts.env ?? process.env;
88
+ const legacyEnvVar = opts.legacySessionDirEnv;
89
+ const legacy = legacyEnvVar ? env[legacyEnvVar]?.trim() : undefined;
90
+ if (legacy && legacyEnvVar) {
91
+ opts.onLegacySessionDir?.(legacyEnvVar, legacy);
92
+ return legacy;
93
+ }
94
+
95
+ return join(resolveConfigDirForDefaultSession(opts.configDir, opts.configDirEnv, env), "context-mode", "sessions");
96
+ }
97
+
98
+ function resolveConfigDirForDefaultSession(
99
+ configDir: string,
100
+ configDirEnv: string | undefined,
101
+ env: NodeJS.ProcessEnv,
102
+ ): string {
103
+ const envValue = configDirEnv ? env[configDirEnv] : undefined;
104
+ if (envValue && envValue.trim() !== "") {
105
+ return resolveConfigDirValue(envValue.trim());
106
+ }
107
+ return resolveConfigDirValue(configDir, homedir());
108
+ }
109
+
110
+ function resolveConfigDirValue(value: string, baseDir?: string): string {
111
+ if (value.startsWith("~")) return resolve(homedir(), value.replace(/^~[/\\]?/, ""));
112
+ if (isAbsolute(value)) return resolve(value);
113
+ return baseDir ? resolve(baseDir, value) : resolve(value);
114
+ }
115
+
116
+ function invalidStorageOverride(kind: StorageDirectoryKind, path: string, detail: string): StorageDirectoryError {
117
+ return new StorageDirectoryError(
118
+ kind,
119
+ path,
120
+ STORAGE_ROOT_ENV,
121
+ undefined,
122
+ [`Invalid ${STORAGE_ROOT_ENV} for context-mode ${kind} directory: ${detail}`, storageDirectoryHint()].join("\n"),
123
+ );
124
+ }
125
+
126
+ function storageOverrideRoot(kind: StorageDirectoryKind): OverrideRoot {
127
+ const raw = process.env[STORAGE_ROOT_ENV];
128
+ if (raw === undefined) return { kind: "unset" };
129
+
130
+ const trimmed = raw.trim();
131
+ if (!trimmed) {
132
+ return { kind: "ignored-empty", ignoredEnvVar: STORAGE_ROOT_ENV, ignoredReason: "empty" };
133
+ }
134
+ if (!isAbsolute(trimmed)) {
135
+ throw invalidStorageOverride(kind, trimmed, `${STORAGE_ROOT_ENV} must be an absolute path.`);
136
+ }
137
+
138
+ return { kind: "override", root: resolve(trimmed) };
139
+ }
140
+
141
+ function ignoredStorageMetadata(root: OverrideRoot): Pick<ResolvedStorageDir, "ignoredEnvVar" | "ignoredReason"> {
142
+ return root.kind === "ignored-empty"
143
+ ? { ignoredEnvVar: root.ignoredEnvVar, ignoredReason: root.ignoredReason }
144
+ : {};
145
+ }
146
+
147
+ function overrideStorageDir(kind: StorageDirectoryKind, subdir: string): ResolvedStorageDir | null {
148
+ const root = storageOverrideRoot(kind);
149
+ if (root.kind !== "override") return null;
150
+
151
+ return {
152
+ kind,
153
+ path: join(root.root, subdir),
154
+ envVar: STORAGE_ROOT_ENV,
155
+ source: "override",
156
+ };
157
+ }
158
+
159
+ function defaultStorageDir(
160
+ kind: StorageDirectoryKind,
161
+ getDefaultDir: () => string,
162
+ metadata: Pick<ResolvedStorageDir, "ignoredEnvVar" | "ignoredReason">,
163
+ ): ResolvedStorageDir {
164
+ return {
165
+ kind,
166
+ path: resolve(getDefaultDir()),
167
+ envVar: null,
168
+ source: "default",
169
+ ...metadata,
170
+ };
171
+ }
172
+
173
+ export function resolveSessionStorageDir(getDefaultDir: () => string): ResolvedStorageDir {
174
+ const root = storageOverrideRoot("session");
175
+ if (root.kind === "override") {
176
+ return {
177
+ kind: "session",
178
+ path: join(root.root, STORAGE_SESSIONS_SUBDIR),
179
+ envVar: STORAGE_ROOT_ENV,
180
+ source: "override",
181
+ };
182
+ }
183
+
184
+ return defaultStorageDir("session", getDefaultDir, ignoredStorageMetadata(root));
185
+ }
186
+
187
+ export function resolveContentStorageDir(getSessionDir: () => string): ResolvedStorageDir {
188
+ const override = overrideStorageDir("content", STORAGE_CONTENT_SUBDIR);
189
+ if (override) return override;
190
+
191
+ const session = resolveSessionStorageDir(getSessionDir);
192
+ return {
193
+ kind: "content",
194
+ path: join(dirname(session.path), STORAGE_CONTENT_SUBDIR),
195
+ envVar: session.envVar,
196
+ source: session.source,
197
+ ignoredEnvVar: session.ignoredEnvVar,
198
+ ignoredReason: session.ignoredReason,
199
+ };
200
+ }
201
+
202
+ export function resolveStatsStorageDir(getDefaultSessionDir: () => string): ResolvedStorageDir {
203
+ const override = overrideStorageDir("stats", STORAGE_SESSIONS_SUBDIR);
204
+ if (override) return override;
205
+
206
+ const session = resolveSessionStorageDir(getDefaultSessionDir);
207
+ return {
208
+ kind: "stats",
209
+ path: session.path,
210
+ envVar: session.envVar,
211
+ source: session.source,
212
+ ignoredEnvVar: session.ignoredEnvVar,
213
+ ignoredReason: session.ignoredReason,
214
+ };
215
+ }
216
+
217
+ export function formatStorageDirectoryError(err: StorageDirectoryError): string {
218
+ return err.message;
219
+ }
220
+
221
+ export function describeStorageDirectorySource(dir: ResolvedStorageDir): string {
222
+ if (dir.source === "override" && dir.envVar) return `via ${dir.envVar}`;
223
+ if (dir.ignoredEnvVar && dir.ignoredReason === "empty") return `default; ignored empty ${dir.ignoredEnvVar}`;
224
+ return "default";
225
+ }
226
+
227
+ export function clearStorageDirectoryCheckCacheForTests(): void {
228
+ writableStorageCache.clear();
229
+ }
230
+
231
+ export function ensureWritableStorageDir(dir: ResolvedStorageDir): string {
232
+ const key = [
233
+ dir.kind,
234
+ dir.path,
235
+ dir.source,
236
+ dir.envVar ?? "",
237
+ dir.ignoredEnvVar ?? "",
238
+ dir.ignoredReason ?? "",
239
+ ].join("\0");
240
+ const cached = writableStorageCache.get(key);
241
+ if (cached instanceof StorageDirectoryError) throw cached;
242
+ if (cached === dir.path) return cached;
243
+
244
+ try {
245
+ mkdirSync(dir.path, { recursive: true });
246
+ accessSync(dir.path, constants.W_OK);
247
+ writableStorageCache.set(key, dir.path);
248
+ return dir.path;
249
+ } catch (err) {
250
+ const storageErr = new StorageDirectoryError(
251
+ dir.kind,
252
+ pathFromStorageError(err) ?? dir.path,
253
+ STORAGE_ROOT_ENV,
254
+ err,
255
+ undefined,
256
+ { ignoredEnvVar: dir.ignoredEnvVar, ignoredReason: dir.ignoredReason },
257
+ );
258
+ writableStorageCache.set(key, storageErr);
259
+ throw storageErr;
260
+ }
261
+ }
262
+
263
+ function storageDirectoryErrorMessage(
264
+ kind: StorageDirectoryKind,
265
+ path: string,
266
+ metadata: Pick<ResolvedStorageDir, "ignoredEnvVar" | "ignoredReason"> = {},
267
+ ): string {
268
+ return [
269
+ `context-mode ${kind} directory is not writable: ${path}`,
270
+ ignoredStorageOverrideHint(metadata),
271
+ storageDirectoryHint(),
272
+ ].filter(Boolean).join("\n");
273
+ }
274
+
275
+ function ignoredStorageOverrideHint(metadata: Pick<ResolvedStorageDir, "ignoredEnvVar" | "ignoredReason">): string | null {
276
+ if (metadata.ignoredEnvVar && metadata.ignoredReason === "empty") {
277
+ return `Ignored empty ${metadata.ignoredEnvVar}; using adapter default.`;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ function storageDirectoryHint(): string {
283
+ return `Set ${STORAGE_ROOT_ENV} to a writable absolute path.`;
284
+ }
285
+
286
+ function pathFromStorageError(err: unknown): string | null {
287
+ if (!err || typeof err !== "object") return null;
288
+ const path = (err as { path?: unknown }).path;
289
+ return typeof path === "string" && path.length > 0 ? path : null;
290
+ }
291
+
292
+ // ─────────────────────────────────────────────────────────
293
+ // Worktree isolation
294
+ // ─────────────────────────────────────────────────────────
295
+
296
+ /**
297
+ * Returns the worktree suffix to append to session identifiers.
298
+ * Returns empty string when running in the main working tree.
299
+ *
300
+ * Set CONTEXT_MODE_SESSION_SUFFIX to an explicit value to override
301
+ * (useful in CI environments or when git is unavailable).
302
+ * Set to empty string to disable isolation entirely.
303
+ */
304
+ // Memoized per (projectDir, env override) — recomputing on every tool call cost
305
+ // ~12ms (git worktree list subprocess fork) on macOS, 50ms+ on Windows.
306
+ // Key by projectDir so callers can pass the actual workspace even when the
307
+ // MCP server has chdir'd into the installed package directory.
308
+ let _wtCache: { projectDir: string; envSuffix: string | undefined; suffix: string } | undefined;
309
+
310
+ export function normalizeWorktreePath(path: string): string {
311
+ const normalized = path.replace(/\\/g, "/");
312
+ if (/^\/+$/.test(normalized)) return "/";
313
+ if (/^[A-Za-z]:\/+$/.test(normalized)) return `${normalized.slice(0, 2)}/`;
314
+ return normalized.replace(/\/+$/, "");
315
+ }
316
+
317
+ // Case-insensitive filesystems (macOS HFS+/APFS default, Windows NTFS default)
318
+ // can report `currentRoot` and `mainRoot` with different casing for the same
319
+ // physical directory — git itself sometimes preserves the on-disk casing while
320
+ // user-supplied paths use a different casing. Compare canonically by resolving
321
+ // symlinks via realpath and case-folding on these platforms. POSIX/Linux is
322
+ // strictly case-sensitive so this is a no-op there.
323
+ function canonicalizeForCompare(root: string): string {
324
+ let resolved = root;
325
+ try {
326
+ resolved = realpathSync.native(root);
327
+ } catch {
328
+ // Path may not exist (test fixtures, deleted dirs); fall back to as-given.
329
+ }
330
+ const normalized = normalizeWorktreePath(resolved);
331
+ if (process.platform === "win32" || process.platform === "darwin") {
332
+ return normalized.toLowerCase();
333
+ }
334
+ return normalized;
335
+ }
336
+
337
+ function gitOutput(projectDir: string, args: string[]): string {
338
+ return execFileSync(
339
+ "git",
340
+ ["-C", projectDir, ...args],
341
+ {
342
+ encoding: "utf-8",
343
+ timeout: 2000,
344
+ stdio: ["ignore", "pipe", "ignore"],
345
+ },
346
+ ).trim();
347
+ }
348
+
349
+ function getCurrentWorktreeRoot(projectDir: string): string | null {
350
+ const root = gitOutput(projectDir, ["rev-parse", "--show-toplevel"]);
351
+ return root.length > 0 ? normalizeWorktreePath(root) : null;
352
+ }
353
+
354
+ function getMainWorktreeRoot(projectDir: string): string | null {
355
+ const root = gitOutput(projectDir, ["worktree", "list", "--porcelain"])
356
+ .split(/\r?\n/)
357
+ .find((line) => line.startsWith("worktree "))
358
+ ?.replace("worktree ", "")
359
+ ?.trim();
360
+ return root ? normalizeWorktreePath(root) : null;
361
+ }
362
+
363
+ export function getWorktreeSuffix(projectDir = process.cwd()): string {
364
+ const envSuffix = process.env.CONTEXT_MODE_SESSION_SUFFIX;
365
+ if (_wtCache && _wtCache.projectDir === projectDir && _wtCache.envSuffix === envSuffix) {
366
+ return _wtCache.suffix;
367
+ }
368
+
369
+ let suffix = "";
370
+ if (envSuffix !== undefined) {
371
+ suffix = envSuffix ? `__${envSuffix}` : "";
372
+ } else {
373
+ try {
374
+ const currentRoot = getCurrentWorktreeRoot(projectDir);
375
+ const mainRoot = getMainWorktreeRoot(projectDir);
376
+ if (currentRoot && mainRoot) {
377
+ // Use the canonicalized currentRoot for BOTH the comparison and the
378
+ // hash so the suffix DB filename stays stable across casing-variant
379
+ // calls on the same machine (round-5 finding). Previously the hash
380
+ // ate raw casing, so the same linked worktree could land at two
381
+ // different `__<8-hex>` files depending on which casing the caller
382
+ // passed in.
383
+ const canonicalCurrent = canonicalizeForCompare(currentRoot);
384
+ const canonicalMain = canonicalizeForCompare(mainRoot);
385
+ if (canonicalCurrent !== canonicalMain) {
386
+ suffix = `__${createHash("sha256").update(canonicalCurrent).digest("hex").slice(0, 8)}`;
387
+ }
388
+ }
389
+ } catch {
390
+ // git not available or not a git repo — no suffix
391
+ }
392
+ }
393
+
394
+ _wtCache = { projectDir, envSuffix, suffix };
395
+ return suffix;
396
+ }
397
+
398
+ // Test-only helper: clear the memoization between cases.
399
+ export function _resetWorktreeSuffixCacheForTests(): void {
400
+ _wtCache = undefined;
401
+ }
402
+
403
+ // ─────────────────────────────────────────────────────────
404
+ // SessionDB path resolution + case-fold migration
405
+ // ─────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Hash a project directory the way the deployed code (≤ v1.0.111) did:
409
+ * normalize slashes only, preserve raw casing. Kept exported so the
410
+ * migration helper can locate pre-fix DB files for one-shot rename.
411
+ *
412
+ * Do NOT call this for new code paths — use {@link hashProjectDirCanonical}.
413
+ */
414
+ export function hashProjectDirLegacy(projectDir: string): string {
415
+ return createHash("sha256")
416
+ .update(normalizeWorktreePath(projectDir))
417
+ .digest("hex")
418
+ .slice(0, 16);
419
+ }
420
+
421
+ /**
422
+ * Hash a project directory case-stably. On case-insensitive filesystems
423
+ * (macOS HFS+/APFS, Windows NTFS) the path is lowercased so that
424
+ * `/Users/Mert/proj` and `/users/mert/proj` resolve to the same DB file.
425
+ * On Linux (case-sensitive) casing is preserved.
426
+ *
427
+ * Used as the base half of the SessionDB filename:
428
+ * <baseHash><worktreeSuffix>.db
429
+ */
430
+ export function hashProjectDirCanonical(projectDir: string): string {
431
+ const normalized = normalizeWorktreePath(projectDir);
432
+ const folded = (process.platform === "darwin" || process.platform === "win32")
433
+ ? normalized.toLowerCase()
434
+ : normalized;
435
+ return createHash("sha256").update(folded).digest("hex").slice(0, 16);
436
+ }
437
+
438
+ /**
439
+ * Resolve the per-project FTS5 content store DB path, performing a one-shot
440
+ * migration from a legacy raw-casing filename to the canonical one when only
441
+ * the legacy file (with optional `-wal` / `-shm` SQLite sidecars) exists.
442
+ *
443
+ * Same dual-hash safety contract as {@link resolveSessionDbPath}:
444
+ * - Linux: canonical hash equals legacy hash → no migration attempted.
445
+ * - Mac/Win: rename legacy → canonical when canonical missing.
446
+ * - Both exist: leave legacy alone (data-loss safety). Caller picks
447
+ * canonical; reconciliation is a manual operation.
448
+ *
449
+ * Differs from `resolveSessionDbPath` in two ways:
450
+ * 1. No worktree suffix — the FTS5 store is per-project, not per-worktree.
451
+ * 2. The `-wal` / `-shm` sidecars travel with the main `.db` during
452
+ * migration so an active SQLite WAL checkpoint is not stranded behind.
453
+ */
454
+ export function resolveContentStorePath(opts: {
455
+ projectDir: string;
456
+ contentDir: string;
457
+ }): string {
458
+ const { projectDir, contentDir } = opts;
459
+ const canonicalHash = hashProjectDirCanonical(projectDir);
460
+ const canonicalPath = join(contentDir, `${canonicalHash}.db`);
461
+ if (existsSync(canonicalPath)) return canonicalPath;
462
+
463
+ const legacyHash = hashProjectDirLegacy(projectDir);
464
+ if (legacyHash === canonicalHash) return canonicalPath; // Linux short-circuit
465
+
466
+ const legacyPath = join(contentDir, `${legacyHash}.db`);
467
+ if (existsSync(legacyPath)) {
468
+ try {
469
+ renameSync(legacyPath, canonicalPath);
470
+ // Travel the SQLite sidecars too so an active WAL is not orphaned.
471
+ for (const suffix of ["-wal", "-shm"]) {
472
+ try { renameSync(legacyPath + suffix, canonicalPath + suffix); } catch { /* sidecar may not exist */ }
473
+ }
474
+ } catch {
475
+ // Race or permission issue — caller will create canonicalPath fresh.
476
+ }
477
+ }
478
+ return canonicalPath;
479
+ }
480
+
481
+ /**
482
+ * Resolve the SessionDB file path for a project, performing a one-shot
483
+ * migration from legacy raw-casing filenames to canonical ones when only
484
+ * the legacy file exists.
485
+ *
486
+ * Migration rules:
487
+ * - Linux: `legacyHash === canonicalHash` so the resolver short-circuits;
488
+ * no migration ever runs (case-sensitive FS, never any drift).
489
+ * - macOS / Windows: if the canonical path does not exist but a legacy
490
+ * path does, rename in place. This preserves the user's session
491
+ * history across the casing-fix upgrade.
492
+ * - When BOTH paths exist (rare — usually only if the user previously
493
+ * ran two terminals with different casing) the legacy file is left
494
+ * UNTOUCHED. The canonical path wins; manual reconciliation needed.
495
+ * Avoiding the rename here is the data-loss safety guarantee.
496
+ *
497
+ * Worktree separation is preserved: each call only ever migrates the ONE
498
+ * legacy file matching THIS projectDir's hash. Different worktrees have
499
+ * different physical paths → different hashes → different DB files; the
500
+ * migration cannot collapse worktrees.
501
+ */
502
+ export function resolveSessionDbPath(opts: {
503
+ projectDir: string;
504
+ sessionsDir: string;
505
+ }): string {
506
+ return resolveSessionPath({ ...opts, ext: ".db" });
507
+ }
508
+
509
+ /**
510
+ * Generalized resolver: same case-fold + one-shot legacy-rename semantics
511
+ * as {@link resolveSessionDbPath}, parameterised on the file extension so
512
+ * the SAME logic powers `.db`, `-events.md`, and `.cleanup` paths.
513
+ *
514
+ * Source of truth for hooks: `hooks/session-helpers.mjs` imports this
515
+ * function from the bundled output (`hooks/session-db.bundle.mjs`) so the
516
+ * JS hooks and the TS server can never drift again on hash, suffix, or
517
+ * migration policy.
518
+ *
519
+ * Optional `suffix` lets the hook layer inject its cross-process cached
520
+ * worktree suffix (the marker-file optimisation that amortises the
521
+ * `git worktree list` cost across hook forks). When omitted, falls back
522
+ * to {@link getWorktreeSuffix} which uses an in-process cache only.
523
+ */
524
+ export function resolveSessionPath(opts: {
525
+ projectDir: string;
526
+ sessionsDir: string;
527
+ ext: string;
528
+ suffix?: string;
529
+ }): string {
530
+ const { projectDir, sessionsDir, ext } = opts;
531
+ const suffix = opts.suffix ?? getWorktreeSuffix(projectDir);
532
+ const canonicalHash = hashProjectDirCanonical(projectDir);
533
+ const canonicalPath = join(sessionsDir, `${canonicalHash}${suffix}${ext}`);
534
+
535
+ if (existsSync(canonicalPath)) return canonicalPath;
536
+
537
+ const legacyHash = hashProjectDirLegacy(projectDir);
538
+ if (legacyHash === canonicalHash) return canonicalPath; // Linux or already canonical
539
+
540
+ const legacyPath = join(sessionsDir, `${legacyHash}${suffix}${ext}`);
541
+ if (existsSync(legacyPath)) {
542
+ try {
543
+ renameSync(legacyPath, canonicalPath);
544
+ } catch {
545
+ // Race or permission issue — caller will create canonicalPath on first
546
+ // write. Better to lose this rename than to throw and break ctx_stats.
547
+ }
548
+ }
549
+ return canonicalPath;
550
+ }
551
+
552
+ // ─────────────────────────────────────────────────────────
553
+ // Types
554
+ // ─────────────────────────────────────────────────────────
555
+
556
+ /** A stored event row from the session_events table. */
557
+ export interface StoredEvent {
558
+ id: number;
559
+ session_id: string;
560
+ type: string;
561
+ category: string;
562
+ priority: number;
563
+ data: string;
564
+ project_dir: string;
565
+ attribution_source: string;
566
+ attribution_confidence: number;
567
+ bytes_avoided: number;
568
+ bytes_returned: number;
569
+ source_hook: string;
570
+ created_at: string;
571
+ data_hash: string;
572
+ }
573
+
574
+ /** Optional per-event byte accounting passed to {@link SessionDB.insertEvent}. */
575
+ export interface EventBytes {
576
+ /** Bytes context-mode prevented from entering the model context window. */
577
+ bytesAvoided?: number;
578
+ /** Bytes context-mode actually returned to the model. */
579
+ bytesReturned?: number;
580
+ }
581
+
582
+ /** Session metadata row from the session_meta table. */
583
+ export interface SessionMeta {
584
+ session_id: string;
585
+ project_dir: string;
586
+ started_at: string;
587
+ last_event_at: string | null;
588
+ event_count: number;
589
+ compact_count: number;
590
+ }
591
+
592
+ /**
593
+ * Session rollup snapshot (seed-parity aggregate).
594
+ *
595
+ * 12 fields that mirror the platform's `session_summary` + `session_metadata`
596
+ * stamps from src/routes/seed.ts. Each outgoing canonical event carries
597
+ * this snapshot computed at the moment of forward so the analytics engine
598
+ * can run its SUM/AVG/MAX rollups across per-event rows.
599
+ */
600
+ export interface SessionRollup {
601
+ tool_calls: number;
602
+ errors: number;
603
+ unique_tools: number;
604
+ unique_files: number;
605
+ max_file_edits: number;
606
+ has_commit: 0 | 1;
607
+ // v1.0.161 (Bug 2): latest commit subject from this session's type='git_commit'
608
+ // events — stamped onto every outgoing event via the rollup spread so
609
+ // has_commit=1 rows always carry a meaningful commit_message. Empty string
610
+ // when the session has no commit events yet.
611
+ commit_message: string;
612
+ edit_test_cycles: number;
613
+ duration_min: number;
614
+ compact_count: number;
615
+ sources_indexed: number;
616
+ total_chunks: number;
617
+ search_queries: number;
618
+ }
619
+
620
+ /** Resume snapshot row from the session_resume table. */
621
+ export interface ResumeRow {
622
+ snapshot: string;
623
+ event_count: number;
624
+ consumed: number;
625
+ }
626
+
627
+ /** Aggregated tool-call stats for a single session. */
628
+ export interface ToolCallStats {
629
+ totalCalls: number;
630
+ totalBytesReturned: number;
631
+ byTool: Record<string, { calls: number; bytesReturned: number }>;
632
+ }
633
+
634
+ // ─────────────────────────────────────────────────────────
635
+ // Constants
636
+ // ─────────────────────────────────────────────────────────
637
+
638
+ /** Maximum events per session before FIFO eviction kicks in. */
639
+ const MAX_EVENTS_PER_SESSION = 1000;
640
+
641
+ /** Number of recent events to check for deduplication. */
642
+ const DEDUP_WINDOW = 5;
643
+
644
+ /**
645
+ * Coerce an arbitrary input to a non-negative integer suitable for
646
+ * SQLite's INTEGER column. Accepts undefined / null / NaN / floats
647
+ * and returns 0 for invalid inputs so the column never violates its
648
+ * NOT NULL DEFAULT 0 contract.
649
+ */
650
+ function clampNonNegativeInt(value: unknown): number {
651
+ const n = Number(value);
652
+ if (!Number.isFinite(n) || n <= 0) return 0;
653
+ return Math.floor(n);
654
+ }
655
+
656
+ // ─────────────────────────────────────────────────────────
657
+ // Statement keys (typed enum to avoid string typos)
658
+ // ─────────────────────────────────────────────────────────
659
+
660
+ const S = {
661
+ insertEvent: "insertEvent",
662
+ getEvents: "getEvents",
663
+ getEventsByType: "getEventsByType",
664
+ getEventsByPriority: "getEventsByPriority",
665
+ getEventsByTypeAndPriority: "getEventsByTypeAndPriority",
666
+ getEventCount: "getEventCount",
667
+ getLatestAttributedProject: "getLatestAttributedProject",
668
+ checkDuplicate: "checkDuplicate",
669
+ evictLowestPriority: "evictLowestPriority",
670
+ updateMetaLastEvent: "updateMetaLastEvent",
671
+ ensureSession: "ensureSession",
672
+ getSessionStats: "getSessionStats",
673
+ getSessionRollup: "getSessionRollup",
674
+ getMaxFileEdits: "getMaxFileEdits",
675
+ getLatestCommitMessage: "getLatestCommitMessage",
676
+ incrementCompactCount: "incrementCompactCount",
677
+ getUsageCursor: "getUsageCursor",
678
+ setUsageCursor: "setUsageCursor",
679
+ upsertResume: "upsertResume",
680
+ getResume: "getResume",
681
+ markResumeConsumed: "markResumeConsumed",
682
+ claimLatestUnconsumedResume: "claimLatestUnconsumedResume",
683
+ deleteEvents: "deleteEvents",
684
+ deleteMeta: "deleteMeta",
685
+ deleteResume: "deleteResume",
686
+ getOldSessions: "getOldSessions",
687
+ searchEvents: "searchEvents",
688
+ incrementToolCall: "incrementToolCall",
689
+ getToolCallTotals: "getToolCallTotals",
690
+ getToolCallByTool: "getToolCallByTool",
691
+ getEventBytesSummary: "getEventBytesSummary",
692
+ } as const;
693
+
694
+ // ─────────────────────────────────────────────────────────
695
+ // Schema migration helpers (shared with the analytics aggregator)
696
+ // ─────────────────────────────────────────────────────────
697
+
698
+ /**
699
+ * Columns that the current `session_events` schema requires but earlier
700
+ * versions of context-mode did not write. Older DBs on disk are missing
701
+ * these — the analytics aggregator opens every DB it finds across all
702
+ * adapters, so without an in-place migration the SUM queries below fail
703
+ * the entire DB (the catch at the top of the read loop swallows the
704
+ * "no such column" error and the DB contributes zero to every column,
705
+ * not just the new ones). v1.0.148 hotfix.
706
+ */
707
+ const SESSION_EVENTS_REQUIRED_COLUMNS: ReadonlyArray<readonly [string, string]> = [
708
+ ["project_dir", "TEXT NOT NULL DEFAULT ''"],
709
+ ["attribution_source", "TEXT NOT NULL DEFAULT 'unknown'"],
710
+ ["attribution_confidence", "REAL NOT NULL DEFAULT 0"],
711
+ ["bytes_avoided", "INTEGER NOT NULL DEFAULT 0"],
712
+ ["bytes_returned", "INTEGER NOT NULL DEFAULT 0"],
713
+ ];
714
+
715
+ /**
716
+ * Apply any missing post-v1.0.130 `session_events` columns to an already-
717
+ * open writable database handle. Idempotent — each ALTER is guarded by a
718
+ * PRAGMA table_xinfo check, and the project_dir index is created only
719
+ * when a migration actually ran. Returns true if any column was added.
720
+ *
721
+ * Used by both the SessionDB constructor (for the active DB) and the
722
+ * analytics aggregator (for the 100+ historical DBs that never get
723
+ * opened through SessionDB). ADR-0001 compatible: no EXCLUSIVE pragma,
724
+ * no acquireDbLock — relies on the SQLite busy_timeout + WAL semantics
725
+ * already provided by SQLiteBase.
726
+ */
727
+ export function applyMissingSessionEventsColumns(db: {
728
+ pragma: (q: string) => Array<{ name: string }>;
729
+ exec: (sql: string) => void;
730
+ }): boolean {
731
+ const colInfo = db.pragma("table_xinfo(session_events)") as Array<{ name: string }>;
732
+ const cols = new Set(colInfo.map((c) => c.name));
733
+ let changed = false;
734
+ for (const [name, spec] of SESSION_EVENTS_REQUIRED_COLUMNS) {
735
+ if (!cols.has(name)) {
736
+ db.exec(`ALTER TABLE session_events ADD COLUMN ${name} ${spec}`);
737
+ changed = true;
738
+ }
739
+ }
740
+ if (changed) {
741
+ db.exec(
742
+ "CREATE INDEX IF NOT EXISTS idx_session_events_project ON session_events(session_id, project_dir)",
743
+ );
744
+ }
745
+ return changed;
746
+ }
747
+
748
+ /**
749
+ * Open a session DB file briefly, run any missing schema migrations,
750
+ * and close. Best-effort: missing tables, file-locks, corrupt files,
751
+ * and any DatabaseCtor error are swallowed silently — the caller
752
+ * (analytics aggregator) handles the readonly query that follows and
753
+ * will skip the DB if it remains unreadable.
754
+ *
755
+ * Lazy migration entry point for the analytics aggregator, which would
756
+ * otherwise read 100+ historical DBs with the old (pre-v1.0.130) schema
757
+ * and lose every signal (not just bytes_avoided) because the SELECT
758
+ * statement references columns that don't exist on legacy schemas.
759
+ *
760
+ * Two open/close cycles in the worst case (one readonly probe to detect
761
+ * legacy schema, one writable to migrate). For already-migrated DBs
762
+ * (the common case after first read), this opens writable once and
763
+ * exits without writing — cheaper than always-writable.
764
+ */
765
+ export function ensureSessionEventsSchema(
766
+ dbPath: string,
767
+ DatabaseCtor: new (path: string, opts?: { readonly?: boolean }) => {
768
+ pragma: (q: string) => Array<{ name: string }>;
769
+ exec: (sql: string) => void;
770
+ close: () => void;
771
+ },
772
+ ): void {
773
+ let db: { pragma: (q: string) => Array<{ name: string }>; exec: (sql: string) => void; close: () => void } | null = null;
774
+ try {
775
+ db = new DatabaseCtor(dbPath);
776
+ applyMissingSessionEventsColumns(db);
777
+ } catch {
778
+ // best-effort — missing table, file lock, corrupt DB, or DatabaseCtor
779
+ // load failure. The aggregator's existing skip-on-error handles the
780
+ // downstream readonly query.
781
+ } finally {
782
+ try { db?.close(); } catch { /* ignore */ }
783
+ }
784
+ }
785
+
786
+ // ─────────────────────────────────────────────────────────
787
+ // SessionDB
788
+ // ─────────────────────────────────────────────────────────
789
+
790
+ export class SessionDB extends SQLiteBase {
791
+ /**
792
+ * Cached prepared statements. Stored in a Map to avoid the JS private-field
793
+ * inheritance issue where `#field` declarations in a subclass are not
794
+ * accessible during base-class constructor calls.
795
+ *
796
+ * `declare` ensures TypeScript does NOT emit a field initializer at runtime.
797
+ * Without `declare`, even `stmts!: Map<...>` emits `this.stmts = undefined`
798
+ * after super() returns, wiping what prepareStatements() stored. The Map
799
+ * is created inside prepareStatements() instead.
800
+ */
801
+ private declare stmts: Map<string, PreparedStatement>;
802
+
803
+ constructor(opts?: { dbPath?: string }) {
804
+ super(opts?.dbPath ?? defaultDBPath("session"));
805
+ }
806
+
807
+ /** Shorthand to retrieve a cached statement. */
808
+ private stmt(key: string): PreparedStatement {
809
+ return this.stmts.get(key)!;
810
+ }
811
+
812
+ // ── Schema ──
813
+
814
+ protected initSchema(): void {
815
+ // ── Migration: fix data_hash generated column from older schema ──
816
+ // Old schema had data_hash as GENERATED ALWAYS AS — new schema uses explicit INSERT.
817
+ // Detect and recreate table if needed (session data is ephemeral, safe to drop).
818
+ try {
819
+ const colInfo = this.db.pragma("table_xinfo(session_events)") as Array<{ name: string; hidden: number }>;
820
+ const hashCol = colInfo.find((c) => c.name === "data_hash");
821
+ if (hashCol && hashCol.hidden !== 0) {
822
+ // hidden != 0 means generated column — must recreate
823
+ this.db.exec("DROP TABLE session_events");
824
+ }
825
+ } catch { /* table doesn't exist yet — fine */ }
826
+
827
+ this.db.exec(`
828
+ CREATE TABLE IF NOT EXISTS session_events (
829
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
830
+ session_id TEXT NOT NULL,
831
+ type TEXT NOT NULL,
832
+ category TEXT NOT NULL,
833
+ priority INTEGER NOT NULL DEFAULT 2,
834
+ data TEXT NOT NULL,
835
+ project_dir TEXT NOT NULL DEFAULT '',
836
+ attribution_source TEXT NOT NULL DEFAULT 'unknown',
837
+ attribution_confidence REAL NOT NULL DEFAULT 0,
838
+ bytes_avoided INTEGER NOT NULL DEFAULT 0,
839
+ bytes_returned INTEGER NOT NULL DEFAULT 0,
840
+ source_hook TEXT NOT NULL,
841
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
842
+ data_hash TEXT NOT NULL DEFAULT ''
843
+ );
844
+
845
+ CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events(session_id);
846
+ CREATE INDEX IF NOT EXISTS idx_session_events_type ON session_events(session_id, type);
847
+ CREATE INDEX IF NOT EXISTS idx_session_events_priority ON session_events(session_id, priority);
848
+
849
+ CREATE TABLE IF NOT EXISTS session_meta (
850
+ session_id TEXT PRIMARY KEY,
851
+ project_dir TEXT NOT NULL,
852
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
853
+ last_event_at TEXT,
854
+ event_count INTEGER NOT NULL DEFAULT 0,
855
+ compact_count INTEGER NOT NULL DEFAULT 0
856
+ );
857
+
858
+ CREATE TABLE IF NOT EXISTS session_resume (
859
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
860
+ session_id TEXT NOT NULL UNIQUE,
861
+ snapshot TEXT NOT NULL,
862
+ event_count INTEGER NOT NULL,
863
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
864
+ consumed INTEGER NOT NULL DEFAULT 0
865
+ );
866
+
867
+ CREATE TABLE IF NOT EXISTS tool_calls (
868
+ session_id TEXT NOT NULL,
869
+ tool TEXT NOT NULL,
870
+ calls INTEGER NOT NULL DEFAULT 0,
871
+ bytes_returned INTEGER NOT NULL DEFAULT 0,
872
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
873
+ PRIMARY KEY (session_id, tool)
874
+ );
875
+
876
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id);
877
+ `);
878
+
879
+ // Migration: add per-event attribution columns for existing DBs.
880
+ // Shared helper — the analytics aggregator (analytics.ts) runs the
881
+ // SAME migration against every historical DB it scans, so the column
882
+ // list lives in one place at the top of this module.
883
+ try {
884
+ applyMissingSessionEventsColumns(this.db as unknown as {
885
+ pragma: (q: string) => Array<{ name: string }>;
886
+ exec: (sql: string) => void;
887
+ });
888
+ } catch {
889
+ // best-effort migration only
890
+ }
891
+
892
+ // Migration: per-session usage high-water cursor for the Stop hook's
893
+ // cursor-aware main-turn capture (extractTranscriptUsageSince). Stores the
894
+ // uuid of the last assistant turn already emitted so the next Stop forwards
895
+ // only NEW spend. Idempotent — guarded by a table_xinfo column check.
896
+ try {
897
+ const metaCols = this.db.pragma("table_xinfo(session_meta)") as Array<{ name: string }>;
898
+ if (!metaCols.some((c) => c.name === "usage_cursor")) {
899
+ this.db.exec("ALTER TABLE session_meta ADD COLUMN usage_cursor TEXT");
900
+ }
901
+ } catch {
902
+ // best-effort migration only
903
+ }
904
+
905
+ }
906
+
907
+ protected prepareStatements(): void {
908
+ this.stmts = new Map<string, PreparedStatement>();
909
+
910
+ const p = (key: string, sql: string) => {
911
+ this.stmts.set(key, this.db.prepare(sql) as PreparedStatement);
912
+ };
913
+
914
+ // ── Events ──
915
+ p(S.insertEvent,
916
+ `INSERT INTO session_events (
917
+ session_id, type, category, priority, data,
918
+ project_dir, attribution_source, attribution_confidence,
919
+ bytes_avoided, bytes_returned,
920
+ source_hook, data_hash
921
+ )
922
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
923
+
924
+ p(S.getEvents,
925
+ `SELECT id, session_id, type, category, priority, data,
926
+ project_dir, attribution_source, attribution_confidence,
927
+ bytes_avoided, bytes_returned,
928
+ source_hook, created_at, data_hash
929
+ FROM session_events WHERE session_id = ? ORDER BY id ASC LIMIT ?`);
930
+
931
+ p(S.getEventsByType,
932
+ `SELECT id, session_id, type, category, priority, data,
933
+ project_dir, attribution_source, attribution_confidence,
934
+ bytes_avoided, bytes_returned,
935
+ source_hook, created_at, data_hash
936
+ FROM session_events WHERE session_id = ? AND type = ? ORDER BY id ASC LIMIT ?`);
937
+
938
+ p(S.getEventsByPriority,
939
+ `SELECT id, session_id, type, category, priority, data,
940
+ project_dir, attribution_source, attribution_confidence,
941
+ bytes_avoided, bytes_returned,
942
+ source_hook, created_at, data_hash
943
+ FROM session_events WHERE session_id = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
944
+
945
+ p(S.getEventsByTypeAndPriority,
946
+ `SELECT id, session_id, type, category, priority, data,
947
+ project_dir, attribution_source, attribution_confidence,
948
+ bytes_avoided, bytes_returned,
949
+ source_hook, created_at, data_hash
950
+ FROM session_events WHERE session_id = ? AND type = ? AND priority >= ? ORDER BY id ASC LIMIT ?`);
951
+
952
+ p(S.getEventCount,
953
+ `SELECT COUNT(*) AS cnt FROM session_events WHERE session_id = ?`);
954
+
955
+ p(S.getLatestAttributedProject,
956
+ `SELECT project_dir
957
+ FROM session_events
958
+ WHERE session_id = ? AND project_dir != ''
959
+ ORDER BY id DESC
960
+ LIMIT 1`);
961
+
962
+ p(S.checkDuplicate,
963
+ `SELECT 1 FROM (
964
+ SELECT type, data_hash FROM session_events
965
+ WHERE session_id = ? ORDER BY id DESC LIMIT ?
966
+ ) AS recent
967
+ WHERE recent.type = ? AND recent.data_hash = ?
968
+ LIMIT 1`);
969
+
970
+ p(S.evictLowestPriority,
971
+ `DELETE FROM session_events WHERE id = (
972
+ SELECT id FROM session_events WHERE session_id = ?
973
+ ORDER BY priority ASC, id ASC LIMIT 1
974
+ )`);
975
+
976
+ p(S.updateMetaLastEvent,
977
+ `UPDATE session_meta
978
+ SET last_event_at = datetime('now'), event_count = event_count + 1
979
+ WHERE session_id = ?`);
980
+
981
+ // ── Meta ──
982
+ p(S.ensureSession,
983
+ `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
984
+
985
+ p(S.getSessionStats,
986
+ `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count
987
+ FROM session_meta WHERE session_id = ?`);
988
+
989
+ // ── Session rollup (seed-parity aggregator) ────────────────────────
990
+ // Single query producing 9 of the 12 platform-side session_summary +
991
+ // session_metadata fields. Computed against the local SessionDB
992
+ // session_events table at forward time so every outgoing canonical
993
+ // event carries a session-wide snapshot at that moment — matches the
994
+ // seed.ts shape where each event row has tool_calls/errors/etc. stamped.
995
+ // max_file_edits and edit_test_cycles need separate GROUP BY queries
996
+ // (below). compact_count is read from session_meta (already in getSessionStats).
997
+ p(S.getSessionRollup,
998
+ `SELECT
999
+ COUNT(*) AS tool_calls,
1000
+ COALESCE(SUM(CASE WHEN category = 'error' THEN 1 ELSE 0 END), 0) AS errors,
1001
+ COUNT(DISTINCT type) AS unique_tools,
1002
+ COUNT(DISTINCT CASE WHEN category = 'file' THEN data END) AS unique_files,
1003
+ CASE WHEN SUM(CASE WHEN type = 'git_commit' THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS has_commit,
1004
+ CAST(COALESCE((MAX(strftime('%s', created_at)) - MIN(strftime('%s', created_at))) / 60.0, 0) AS INTEGER) AS duration_min,
1005
+ COALESCE(SUM(CASE WHEN type = 'external_ref' THEN 1 ELSE 0 END), 0) AS sources_indexed,
1006
+ CAST(COALESCE(SUM(bytes_avoided) / 1024.0, 0) AS INTEGER) AS total_chunks,
1007
+ COALESCE(SUM(CASE WHEN type IN ('file_search', 'file_glob') THEN 1 ELSE 0 END), 0) AS search_queries
1008
+ FROM session_events
1009
+ WHERE session_id = ?`);
1010
+
1011
+ // max_file_edits: max edits on any single file path in the session.
1012
+ // Two-level aggregation — GROUP BY data first, then MAX of those counts.
1013
+ p(S.getMaxFileEdits,
1014
+ `SELECT COALESCE(MAX(c), 0) AS max_file_edits
1015
+ FROM (
1016
+ SELECT COUNT(*) AS c
1017
+ FROM session_events
1018
+ WHERE session_id = ? AND category = 'file' AND type IN ('file_edit', 'file_write')
1019
+ GROUP BY data
1020
+ )`);
1021
+
1022
+ // v1.0.161 (Bug 2): latest commit message from session's type='git_commit'
1023
+ // events. Used by rollup spread to stamp commit_message symmetric with
1024
+ // has_commit on every outgoing event. Separate prepared statement (vs.
1025
+ // sub-select in getSessionRollup) keeps the binding shape uniform — every
1026
+ // rollup query takes a single sessionId parameter.
1027
+ p(S.getLatestCommitMessage,
1028
+ `SELECT data
1029
+ FROM session_events
1030
+ WHERE session_id = ? AND type = 'git_commit'
1031
+ ORDER BY id DESC
1032
+ LIMIT 1`);
1033
+
1034
+ p(S.incrementCompactCount,
1035
+ `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
1036
+
1037
+ p(S.getUsageCursor,
1038
+ `SELECT usage_cursor FROM session_meta WHERE session_id = ?`);
1039
+
1040
+ p(S.setUsageCursor,
1041
+ `UPDATE session_meta SET usage_cursor = ? WHERE session_id = ?`);
1042
+
1043
+ // ── Resume ──
1044
+ p(S.upsertResume,
1045
+ `INSERT INTO session_resume (session_id, snapshot, event_count)
1046
+ VALUES (?, ?, ?)
1047
+ ON CONFLICT(session_id) DO UPDATE SET
1048
+ snapshot = excluded.snapshot,
1049
+ event_count = excluded.event_count,
1050
+ created_at = datetime('now'),
1051
+ consumed = 0`);
1052
+
1053
+ p(S.getResume,
1054
+ `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
1055
+
1056
+ p(S.markResumeConsumed,
1057
+ `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
1058
+
1059
+ // Atomic "pick newest unconsumed snapshot AND mark it consumed in one
1060
+ // statement". Required for race-safe cross-session resume injection
1061
+ // (Mickey / PR #376) — two parallel chat-turn hooks must not both read
1062
+ // the same row before either one writes consumed=1.
1063
+ //
1064
+ // The `session_id != ?` clause prevents self-injection (v1.0.106): when
1065
+ // Session B compacts mid-flight and produces its own row, B's next chat
1066
+ // turn must NOT claim that row back into its own prompt — that's wasted
1067
+ // tokens and steals the snapshot meant for the next fresh session.
1068
+ p(S.claimLatestUnconsumedResume,
1069
+ `UPDATE session_resume
1070
+ SET consumed = 1
1071
+ WHERE id = (
1072
+ SELECT id FROM session_resume
1073
+ WHERE consumed = 0
1074
+ AND session_id != ?
1075
+ ORDER BY created_at DESC, id DESC
1076
+ LIMIT 1
1077
+ )
1078
+ RETURNING session_id, snapshot`);
1079
+
1080
+ // ── Delete ──
1081
+ p(S.deleteEvents, `DELETE FROM session_events WHERE session_id = ?`);
1082
+ p(S.deleteMeta, `DELETE FROM session_meta WHERE session_id = ?`);
1083
+ p(S.deleteResume, `DELETE FROM session_resume WHERE session_id = ?`);
1084
+
1085
+ // ── Search ──
1086
+ p(S.searchEvents,
1087
+ `SELECT id, session_id, category, type, data, created_at
1088
+ FROM session_events
1089
+ WHERE (project_dir = ? OR project_dir = '')
1090
+ AND (data LIKE '%' || ? || '%' ESCAPE '\\' OR category LIKE '%' || ? || '%' ESCAPE '\\')
1091
+ AND (? IS NULL OR category = ?)
1092
+ ORDER BY id ASC
1093
+ LIMIT ?`);
1094
+
1095
+ // ── Cleanup ──
1096
+ p(S.getOldSessions,
1097
+ `SELECT session_id FROM session_meta WHERE started_at < datetime('now', ? || ' days')`);
1098
+
1099
+ // ── Tool calls (persistent counter) ──
1100
+ p(S.incrementToolCall,
1101
+ `INSERT INTO tool_calls (session_id, tool, calls, bytes_returned)
1102
+ VALUES (?, ?, 1, ?)
1103
+ ON CONFLICT(session_id, tool) DO UPDATE SET
1104
+ calls = calls + 1,
1105
+ bytes_returned = bytes_returned + excluded.bytes_returned,
1106
+ updated_at = datetime('now')`);
1107
+
1108
+ p(S.getToolCallTotals,
1109
+ `SELECT COALESCE(SUM(calls), 0) AS calls,
1110
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
1111
+ FROM tool_calls WHERE session_id = ?`);
1112
+
1113
+ p(S.getToolCallByTool,
1114
+ `SELECT tool, calls, bytes_returned
1115
+ FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`);
1116
+
1117
+ // ── Event-level byte accounting (D2 PRD Phase 2) ──
1118
+ p(S.getEventBytesSummary,
1119
+ `SELECT COALESCE(SUM(bytes_avoided), 0) AS bytes_avoided,
1120
+ COALESCE(SUM(bytes_returned), 0) AS bytes_returned
1121
+ FROM session_events WHERE session_id = ?`);
1122
+ }
1123
+
1124
+ // ═══════════════════════════════════════════
1125
+ // Events
1126
+ // ═══════════════════════════════════════════
1127
+
1128
+ /**
1129
+ * Insert a session event with deduplication and FIFO eviction.
1130
+ *
1131
+ * Deduplication: skips if the same type + data_hash appears in the
1132
+ * last DEDUP_WINDOW events for this session.
1133
+ *
1134
+ * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
1135
+ * lowest-priority (then oldest) event.
1136
+ */
1137
+ insertEvent(
1138
+ sessionId: string,
1139
+ event: Omit<SessionEvent, "data_hash"> & { data_hash?: string },
1140
+ sourceHook: string = "PostToolUse",
1141
+ attribution?: Partial<ProjectAttribution>,
1142
+ bytes?: EventBytes,
1143
+ ): void {
1144
+ // SHA256-based dedup hash (first 16 hex chars = 8 bytes of entropy)
1145
+ const dataHash = createHash("sha256")
1146
+ .update(event.data)
1147
+ .digest("hex")
1148
+ .slice(0, 16)
1149
+ .toUpperCase();
1150
+ const projectDir = String(
1151
+ attribution?.projectDir
1152
+ ?? event.project_dir
1153
+ ?? this._getSessionProjectDir(sessionId),
1154
+ ).trim();
1155
+ const attributionSource = String(
1156
+ attribution?.source
1157
+ ?? event.attribution_source
1158
+ ?? "unknown",
1159
+ );
1160
+ const rawConfidence = Number(
1161
+ attribution?.confidence
1162
+ ?? event.attribution_confidence
1163
+ ?? 0,
1164
+ );
1165
+ const attributionConfidence = Number.isFinite(rawConfidence)
1166
+ ? Math.max(0, Math.min(1, rawConfidence))
1167
+ : 0;
1168
+ const bytesAvoided = clampNonNegativeInt(bytes?.bytesAvoided);
1169
+ const bytesReturned = clampNonNegativeInt(bytes?.bytesReturned);
1170
+
1171
+ // Atomic: dedup check + eviction + insert in a single transaction
1172
+ // to prevent race conditions from concurrent hook calls.
1173
+ const transaction = this.db.transaction(() => {
1174
+ // Deduplication check: same type + data_hash in last N events
1175
+ const dup = this.stmt(S.checkDuplicate).get(sessionId, DEDUP_WINDOW, event.type, dataHash);
1176
+ if (dup) return;
1177
+
1178
+ // Enforce max events with FIFO eviction of lowest priority
1179
+ const countRow = this.stmt(S.getEventCount).get(sessionId) as { cnt: number };
1180
+ if (countRow.cnt >= MAX_EVENTS_PER_SESSION) {
1181
+ this.stmt(S.evictLowestPriority).run(sessionId);
1182
+ }
1183
+
1184
+ // Insert the event
1185
+ this.stmt(S.insertEvent).run(
1186
+ sessionId,
1187
+ event.type,
1188
+ event.category,
1189
+ event.priority,
1190
+ event.data,
1191
+ projectDir,
1192
+ attributionSource,
1193
+ attributionConfidence,
1194
+ bytesAvoided,
1195
+ bytesReturned,
1196
+ sourceHook,
1197
+ dataHash,
1198
+ );
1199
+
1200
+ // Update meta if session exists
1201
+ this.stmt(S.updateMetaLastEvent).run(sessionId);
1202
+ });
1203
+
1204
+ this.withRetry(() => transaction());
1205
+ }
1206
+
1207
+ /**
1208
+ * Bulk-insert N events in a SINGLE transaction.
1209
+ *
1210
+ * PostToolUse hooks emit 5–15 events per tool call. Calling insertEvent()
1211
+ * in a loop runs N transactions = N WAL commits = N fsync candidates,
1212
+ * which is painful on Windows NTFS where commit latency dominates.
1213
+ * One transaction = one commit, dedup/evict checks reuse cached statements.
1214
+ *
1215
+ * Cross-platform: uses the same WAL-mode transaction primitive as
1216
+ * insertEvent — behavior identical on macOS / Linux / Windows.
1217
+ */
1218
+ bulkInsertEvents(
1219
+ sessionId: string,
1220
+ events: SessionEvent[],
1221
+ sourceHook: string = "PostToolUse",
1222
+ attributions?: Array<Partial<ProjectAttribution> | undefined>,
1223
+ bytesList?: Array<EventBytes | undefined>,
1224
+ ): void {
1225
+ if (!events || events.length === 0) return;
1226
+ if (events.length === 1) {
1227
+ // Cheaper to fall through to insertEvent (its own dedicated transaction).
1228
+ this.insertEvent(sessionId, events[0], sourceHook, attributions?.[0], bytesList?.[0]);
1229
+ return;
1230
+ }
1231
+
1232
+ // Pre-compute hashes + normalized attribution outside the transaction
1233
+ // so the SQL transaction holds only DB work (shorter lock window).
1234
+ const prepared = events.map((event, i) => {
1235
+ const dataHash = createHash("sha256")
1236
+ .update(event.data)
1237
+ .digest("hex")
1238
+ .slice(0, 16)
1239
+ .toUpperCase();
1240
+ const attribution = attributions?.[i];
1241
+ // #827: store project_dir in canonical path shape so the search-time
1242
+ // allow-set lookup (getSessionIdsForProject) matches regardless of the
1243
+ // separator / trailing-slash form the host adapter happened to emit.
1244
+ // normalizeWorktreePath is the same rule used for project-hash stability.
1245
+ const rawProjectDir = String(
1246
+ attribution?.projectDir ?? event.project_dir ?? this._getSessionProjectDir(sessionId) ?? "",
1247
+ ).trim();
1248
+ const projectDir = rawProjectDir === "" ? "" : normalizeWorktreePath(rawProjectDir);
1249
+ const attributionSource = String(
1250
+ attribution?.source ?? event.attribution_source ?? "unknown",
1251
+ );
1252
+ const rawConfidence = Number(
1253
+ attribution?.confidence ?? event.attribution_confidence ?? 0,
1254
+ );
1255
+ const attributionConfidence = Number.isFinite(rawConfidence)
1256
+ ? Math.max(0, Math.min(1, rawConfidence))
1257
+ : 0;
1258
+ const eventBytes = bytesList?.[i];
1259
+ const bytesAvoided = clampNonNegativeInt(eventBytes?.bytesAvoided);
1260
+ const bytesReturned = clampNonNegativeInt(eventBytes?.bytesReturned);
1261
+ return {
1262
+ event,
1263
+ dataHash,
1264
+ projectDir,
1265
+ attributionSource,
1266
+ attributionConfidence,
1267
+ bytesAvoided,
1268
+ bytesReturned,
1269
+ };
1270
+ });
1271
+
1272
+ const transaction = this.db.transaction(() => {
1273
+ let cnt = (this.stmt(S.getEventCount).get(sessionId) as { cnt: number }).cnt;
1274
+ for (const row of prepared) {
1275
+ const dup = this.stmt(S.checkDuplicate).get(
1276
+ sessionId, DEDUP_WINDOW, row.event.type, row.dataHash,
1277
+ );
1278
+ if (dup) continue;
1279
+ if (cnt >= MAX_EVENTS_PER_SESSION) {
1280
+ this.stmt(S.evictLowestPriority).run(sessionId);
1281
+ } else {
1282
+ cnt++;
1283
+ }
1284
+ this.stmt(S.insertEvent).run(
1285
+ sessionId,
1286
+ row.event.type,
1287
+ row.event.category,
1288
+ row.event.priority,
1289
+ row.event.data,
1290
+ row.projectDir,
1291
+ row.attributionSource,
1292
+ row.attributionConfidence,
1293
+ row.bytesAvoided,
1294
+ row.bytesReturned,
1295
+ sourceHook,
1296
+ row.dataHash,
1297
+ );
1298
+ }
1299
+ this.stmt(S.updateMetaLastEvent).run(sessionId);
1300
+ });
1301
+
1302
+ this.withRetry(() => transaction());
1303
+ }
1304
+
1305
+ /**
1306
+ * Retrieve events for a session with optional filtering.
1307
+ */
1308
+ getEvents(
1309
+ sessionId: string,
1310
+ opts?: { type?: string; minPriority?: number; limit?: number },
1311
+ ): StoredEvent[] {
1312
+ const limit = opts?.limit ?? 1000;
1313
+ const type = opts?.type;
1314
+ const minPriority = opts?.minPriority;
1315
+
1316
+ if (type && minPriority !== undefined) {
1317
+ return this.stmt(S.getEventsByTypeAndPriority).all(sessionId, type, minPriority, limit) as StoredEvent[];
1318
+ }
1319
+ if (type) {
1320
+ return this.stmt(S.getEventsByType).all(sessionId, type, limit) as StoredEvent[];
1321
+ }
1322
+ if (minPriority !== undefined) {
1323
+ return this.stmt(S.getEventsByPriority).all(sessionId, minPriority, limit) as StoredEvent[];
1324
+ }
1325
+ return this.stmt(S.getEvents).all(sessionId, limit) as StoredEvent[];
1326
+ }
1327
+
1328
+ /**
1329
+ * Get the total event count for a session.
1330
+ */
1331
+ getEventCount(sessionId: string): number {
1332
+ const row = this.stmt(S.getEventCount).get(sessionId) as { cnt: number };
1333
+ return row.cnt;
1334
+ }
1335
+
1336
+ /**
1337
+ * Aggregate per-event byte accounting for a session.
1338
+ *
1339
+ * Returns the total bytes context-mode kept OUT of the model context
1340
+ * window (`bytesAvoided`) and the total it actually returned to the
1341
+ * model (`bytesReturned`). Both default to 0 for unknown sessions.
1342
+ *
1343
+ * Used by the Insight dashboard to render the "saved vs returned"
1344
+ * panel without scanning every event row in JS.
1345
+ */
1346
+ getEventBytesSummary(sessionId: string): { bytesAvoided: number; bytesReturned: number } {
1347
+ const row = this.stmt(S.getEventBytesSummary).get(sessionId) as
1348
+ | { bytes_avoided: number | null; bytes_returned: number | null }
1349
+ | undefined;
1350
+ return {
1351
+ bytesAvoided: Number(row?.bytes_avoided ?? 0),
1352
+ bytesReturned: Number(row?.bytes_returned ?? 0),
1353
+ };
1354
+ }
1355
+
1356
+ /**
1357
+ * Return the most recently attributed project dir for a session.
1358
+ */
1359
+ getLatestAttributedProjectDir(sessionId: string): string | null {
1360
+ const row = this.stmt(S.getLatestAttributedProject).get(sessionId) as { project_dir: string } | undefined;
1361
+ return row?.project_dir || null;
1362
+ }
1363
+
1364
+ /**
1365
+ * Look up the project_dir from session_meta as a last-resort fallback
1366
+ * for event attribution. Prevents project_dir='' orphans when the caller
1367
+ * (e.g. pi adapter) omits the attribution parameter.
1368
+ */
1369
+ _getSessionProjectDir(sessionId: string): string {
1370
+ try {
1371
+ const row = this.db.prepare("SELECT project_dir FROM session_meta WHERE session_id = ?").get(sessionId) as { project_dir: string } | undefined;
1372
+ return row?.project_dir || "";
1373
+ } catch {
1374
+ return "";
1375
+ }
1376
+ }
1377
+
1378
+ /**
1379
+ * Search events by text query scoped to a project directory.
1380
+ *
1381
+ * Performs a case-insensitive LIKE search across the `data` and `category`
1382
+ * columns. An optional `source` parameter filters by exact category match.
1383
+ * Returns results ordered by monotonic id (chronological).
1384
+ *
1385
+ * Best-effort: returns empty array on any error.
1386
+ */
1387
+ searchEvents(
1388
+ query: string,
1389
+ limit: number,
1390
+ projectDir: string,
1391
+ source?: string,
1392
+ ): Array<{
1393
+ id: number;
1394
+ session_id: string;
1395
+ category: string;
1396
+ type: string;
1397
+ data: string;
1398
+ created_at: string;
1399
+ }> {
1400
+ try {
1401
+ const escapedQuery = query.replace(/[%_]/g, (char) => "\\" + char);
1402
+ const sourceParam = source ?? null;
1403
+ return this.stmt(S.searchEvents).all(
1404
+ projectDir,
1405
+ escapedQuery,
1406
+ escapedQuery,
1407
+ sourceParam,
1408
+ sourceParam,
1409
+ limit,
1410
+ ) as Array<{
1411
+ id: number;
1412
+ session_id: string;
1413
+ category: string;
1414
+ type: string;
1415
+ data: string;
1416
+ created_at: string;
1417
+ }>;
1418
+ } catch {
1419
+ return [];
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * Return the distinct list of session ids whose events were attributed
1425
+ * to a given `project_dir`. Powers the ctx_search `project:` filter
1426
+ * (#737) via the 2-step IN-clause strategy — ATTACH DATABASE is avoided
1427
+ * because SQLite's WAL + ATTACH combination has known correctness
1428
+ * trade-offs flagged in the upstream docs.
1429
+ *
1430
+ * Backed by the `idx_session_events_project(session_id, project_dir)`
1431
+ * composite index, so 1000-session lookups complete in single-digit
1432
+ * milliseconds. Best-effort: returns `[]` on any error.
1433
+ */
1434
+ getSessionIdsForProject(projectDir: string): string[] {
1435
+ try {
1436
+ // #827: match by canonical path shape, not raw bytes. The host adapter
1437
+ // may store `project_dir` in a different separator / trailing-slash
1438
+ // shape than the search path resolves the scope in — most visibly on
1439
+ // Windows, where attribution often carries `C:\Users\me\proj` while the
1440
+ // server resolves `C:/Users/me/proj`. An exact `project_dir = ?` match
1441
+ // then returned an EMPTY allow-set and ctx_search reported "No results
1442
+ // found" even though the content was present. We fold BOTH sides through
1443
+ // the same canonical rule used for project-hash stability
1444
+ // (normalizeWorktreePath): backslash → forward slash, then strip the
1445
+ // trailing slash. Normalizing in SQL (RTRIM(REPLACE(...))) covers rows
1446
+ // already written un-normalized without a migration, while the JS-side
1447
+ // normalize keeps the bound parameter in the identical shape. This
1448
+ // preserves the #737 project scope — distinct directories still differ
1449
+ // after normalization, so cross-project isolation is intact.
1450
+ const normalized = normalizeWorktreePath(projectDir);
1451
+ const rows = this.db
1452
+ .prepare(
1453
+ `SELECT DISTINCT session_id
1454
+ FROM session_events
1455
+ WHERE RTRIM(REPLACE(project_dir, '\\', '/'), '/') = ?`,
1456
+ )
1457
+ .all(normalized) as Array<{ session_id: string }>;
1458
+ return rows.map((r) => r.session_id);
1459
+ } catch {
1460
+ return [];
1461
+ }
1462
+ }
1463
+
1464
+ // ═══════════════════════════════════════════
1465
+ // Meta
1466
+ // ═══════════════════════════════════════════
1467
+
1468
+ /**
1469
+ * Ensure a session metadata entry exists. Idempotent (INSERT OR IGNORE).
1470
+ * `projectDir` is the session origin directory, not per-event attribution.
1471
+ */
1472
+ ensureSession(sessionId: string, projectDir: string): void {
1473
+ this.stmt(S.ensureSession).run(sessionId, projectDir);
1474
+ }
1475
+
1476
+ /**
1477
+ * Get session statistics/metadata.
1478
+ */
1479
+ getSessionStats(sessionId: string): SessionMeta | null {
1480
+ const row = this.stmt(S.getSessionStats).get(sessionId) as SessionMeta | undefined;
1481
+ return row ?? null;
1482
+ }
1483
+
1484
+ /**
1485
+ * Session rollup snapshot — 12 aggregate fields the analytics platform
1486
+ * stamps onto every outgoing event row (seed.ts shape parity).
1487
+ *
1488
+ * Called from session-loaders BEFORE `maybeForward`; the snapshot is
1489
+ * computed against the LOCAL SessionDB and threaded into the canonical
1490
+ * event so the platform-side Zod schema receives the rich shape without
1491
+ * the bridge ever hand-mapping fields (PRD §5.4 ABI passthrough).
1492
+ *
1493
+ * Returns zeroed defaults for unknown sessions — callers MUST tolerate
1494
+ * a snapshot from an empty session (first event into a fresh DB).
1495
+ */
1496
+ getSessionRollup(sessionId: string): SessionRollup {
1497
+ const main = this.stmt(S.getSessionRollup).get(sessionId) as Partial<SessionRollup> | undefined;
1498
+ const maxRow = this.stmt(S.getMaxFileEdits).get(sessionId) as { max_file_edits?: number } | undefined;
1499
+ const commitRow = this.stmt(S.getLatestCommitMessage).get(sessionId) as { data?: string } | undefined;
1500
+ const meta = this.getSessionStats(sessionId);
1501
+
1502
+ // edit_test_cycles: heuristic — min(file edits, errors) approximates
1503
+ // the number of edit-then-test attempts in a session. Exact pattern
1504
+ // detection (consecutive file_edit followed by error_tool) would need
1505
+ // a windowed query; this scalar pair under-counts but never overshoots.
1506
+ const fileEdits =
1507
+ ((main as { tool_calls?: number })?.tool_calls ?? 0) > 0
1508
+ ? ((main as { unique_files?: number })?.unique_files ?? 0)
1509
+ : 0;
1510
+ const errors = (main as { errors?: number })?.errors ?? 0;
1511
+ const editTestCycles = Math.min(fileEdits, errors);
1512
+
1513
+ return {
1514
+ tool_calls: main?.tool_calls ?? 0,
1515
+ errors: main?.errors ?? 0,
1516
+ unique_tools: main?.unique_tools ?? 0,
1517
+ unique_files: main?.unique_files ?? 0,
1518
+ max_file_edits: maxRow?.max_file_edits ?? 0,
1519
+ has_commit: main?.has_commit ?? 0,
1520
+ commit_message: commitRow?.data ?? "",
1521
+ edit_test_cycles: editTestCycles,
1522
+ duration_min: main?.duration_min ?? 0,
1523
+ compact_count: meta?.compact_count ?? 0,
1524
+ sources_indexed: main?.sources_indexed ?? 0,
1525
+ total_chunks: main?.total_chunks ?? 0,
1526
+ search_queries: main?.search_queries ?? 0,
1527
+ };
1528
+ }
1529
+
1530
+ /**
1531
+ * Increment the compact_count for a session (tracks snapshot rebuilds).
1532
+ */
1533
+ incrementCompactCount(sessionId: string): void {
1534
+ this.stmt(S.incrementCompactCount).run(sessionId);
1535
+ }
1536
+
1537
+ /**
1538
+ * Read the per-session usage high-water cursor — the uuid of the last
1539
+ * assistant turn already emitted by the Stop hook's main-turn capture.
1540
+ * Returns null when unset (first Stop) or the session row is absent.
1541
+ */
1542
+ getUsageCursor(sessionId: string): string | null {
1543
+ const row = this.stmt(S.getUsageCursor).get(sessionId) as { usage_cursor: string | null } | undefined;
1544
+ return row?.usage_cursor ?? null;
1545
+ }
1546
+
1547
+ /**
1548
+ * Advance the per-session usage high-water cursor to `uuid`. No-op when the
1549
+ * session_meta row does not exist yet (callers ensureSession first).
1550
+ */
1551
+ setUsageCursor(sessionId: string, uuid: string): void {
1552
+ this.stmt(S.setUsageCursor).run(uuid, sessionId);
1553
+ }
1554
+
1555
+ // ═══════════════════════════════════════════
1556
+ // Resume
1557
+ // ═══════════════════════════════════════════
1558
+
1559
+ /**
1560
+ * Upsert a resume snapshot for a session. Resets consumed flag on update.
1561
+ */
1562
+ upsertResume(sessionId: string, snapshot: string, eventCount?: number): void {
1563
+ this.stmt(S.upsertResume).run(sessionId, snapshot, eventCount ?? 0);
1564
+ }
1565
+
1566
+ /**
1567
+ * Retrieve the resume snapshot for a session.
1568
+ */
1569
+ getResume(sessionId: string): ResumeRow | null {
1570
+ const row = this.stmt(S.getResume).get(sessionId) as ResumeRow | undefined;
1571
+ return row ?? null;
1572
+ }
1573
+
1574
+ /**
1575
+ * Mark the resume snapshot as consumed (already injected into conversation).
1576
+ */
1577
+ markResumeConsumed(sessionId: string): void {
1578
+ this.stmt(S.markResumeConsumed).run(sessionId);
1579
+ }
1580
+
1581
+ /**
1582
+ * Atomically claim the most recent unconsumed resume snapshot in this DB,
1583
+ * EXCLUDING any row that belongs to `currentSessionId`.
1584
+ *
1585
+ * `SessionDB` is sharded per project (see `resolveSessionDbPath` — SHA-256
1586
+ * of canonical project dir), so "this DB" already implies "this project".
1587
+ * The atomic
1588
+ * `UPDATE … RETURNING` ensures concurrent processes for the same project
1589
+ * cannot both inject the same snapshot (Mickey / PR #376 race).
1590
+ *
1591
+ * The `currentSessionId` parameter prevents self-injection: when a session
1592
+ * compacts mid-flight and produces its own row, that session's next chat
1593
+ * turn must NOT claim that row back (wasted tokens AND it would consume
1594
+ * the snapshot meant for the next fresh session).
1595
+ *
1596
+ * Pass an empty string to allow self-claim (legacy behaviour, only useful
1597
+ * in tests or one-off harnesses).
1598
+ *
1599
+ * Returns null when no unconsumed snapshot exists for any other session.
1600
+ */
1601
+ claimLatestUnconsumedResume(
1602
+ currentSessionId: string,
1603
+ ): { sessionId: string; snapshot: string } | null {
1604
+ const row = this.stmt(S.claimLatestUnconsumedResume).get(currentSessionId) as
1605
+ | { session_id: string; snapshot: string }
1606
+ | undefined;
1607
+ if (!row) return null;
1608
+ return { sessionId: row.session_id, snapshot: row.snapshot };
1609
+ }
1610
+
1611
+ /**
1612
+ * Return the most recent session_id from session_meta, or null if none.
1613
+ * Used by the runtime to attach persistent counters to the right session
1614
+ * after a process restart.
1615
+ */
1616
+ getLatestSessionId(): string | null {
1617
+ try {
1618
+ const row = this.db.prepare(
1619
+ "SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1",
1620
+ ).get() as { session_id?: string } | undefined;
1621
+ return row?.session_id ?? null;
1622
+ } catch {
1623
+ return null;
1624
+ }
1625
+ }
1626
+
1627
+ // ═══════════════════════════════════════════
1628
+ // Tool call counters (Bug #1 + #2 — survive restart, --continue, upgrade)
1629
+ // ═══════════════════════════════════════════
1630
+
1631
+ /**
1632
+ * Increment the persistent tool-call counter for `tool` in `sessionId`.
1633
+ * Adds `bytesReturned` to the cumulative total. Idempotent across
1634
+ * SessionDB instances — counters survive process restart.
1635
+ */
1636
+ incrementToolCall(sessionId: string, tool: string, bytesReturned: number = 0): void {
1637
+ const safeBytes = Number.isFinite(bytesReturned) && bytesReturned > 0 ? Math.round(bytesReturned) : 0;
1638
+ try {
1639
+ this.stmt(S.incrementToolCall).run(sessionId, tool, safeBytes);
1640
+ } catch {
1641
+ // best-effort: counter must never throw and break the parent call
1642
+ }
1643
+ }
1644
+
1645
+ /**
1646
+ * Get aggregated tool-call stats for `sessionId`. Returns zero-stats
1647
+ * when the session has no recorded calls.
1648
+ */
1649
+ getToolCallStats(sessionId: string): ToolCallStats {
1650
+ try {
1651
+ const totals = this.stmt(S.getToolCallTotals).get(sessionId) as
1652
+ | { calls: number; bytes_returned: number }
1653
+ | undefined;
1654
+ const rows = this.stmt(S.getToolCallByTool).all(sessionId) as Array<{
1655
+ tool: string;
1656
+ calls: number;
1657
+ bytes_returned: number;
1658
+ }>;
1659
+
1660
+ const byTool: ToolCallStats["byTool"] = {};
1661
+ for (const row of rows) {
1662
+ byTool[row.tool] = {
1663
+ calls: row.calls,
1664
+ bytesReturned: row.bytes_returned,
1665
+ };
1666
+ }
1667
+
1668
+ return {
1669
+ totalCalls: totals?.calls ?? 0,
1670
+ totalBytesReturned: totals?.bytes_returned ?? 0,
1671
+ byTool,
1672
+ };
1673
+ } catch {
1674
+ return { totalCalls: 0, totalBytesReturned: 0, byTool: {} };
1675
+ }
1676
+ }
1677
+
1678
+ // ═══════════════════════════════════════════
1679
+ // Lifecycle
1680
+ // ═══════════════════════════════════════════
1681
+
1682
+ /**
1683
+ * Delete all data for a session (events, meta, resume).
1684
+ */
1685
+ deleteSession(sessionId: string): void {
1686
+ this.db.transaction(() => {
1687
+ this.stmt(S.deleteEvents).run(sessionId);
1688
+ this.stmt(S.deleteResume).run(sessionId);
1689
+ this.stmt(S.deleteMeta).run(sessionId);
1690
+ })();
1691
+ }
1692
+
1693
+ /**
1694
+ * Remove sessions older than maxAgeDays. Returns the count of deleted sessions.
1695
+ */
1696
+ cleanupOldSessions(maxAgeDays: number = 7): number {
1697
+ const negDays = `-${maxAgeDays}`;
1698
+ const oldSessions = this.stmt(S.getOldSessions).all(negDays) as Array<{ session_id: string }>;
1699
+
1700
+ for (const { session_id } of oldSessions) {
1701
+ this.deleteSession(session_id);
1702
+ }
1703
+
1704
+ return oldSessions.length;
1705
+ }
1706
+
1707
+ /**
1708
+ * Delete event rows whose session_id has no matching session_meta row.
1709
+ *
1710
+ * Orphaned events accumulate when meta rows were aged out by an older
1711
+ * version of `cleanupOldSessions` but the matching events were left
1712
+ * behind (or when callers wrote events without a meta upsert). The Kimi
1713
+ * Code sessionstart hook calls this on every startup as a self-healing
1714
+ * step; surfacing it as a SessionDB method keeps the SQL definition in
1715
+ * one place instead of letting hook scripts reach through to
1716
+ * `db.db.exec(...)` and re-encode schema knowledge in mjs files.
1717
+ */
1718
+ pruneOrphanedEvents(): number {
1719
+ const result = this.db
1720
+ .prepare(
1721
+ `DELETE FROM session_events WHERE session_id NOT IN (SELECT session_id FROM session_meta)`,
1722
+ )
1723
+ .run();
1724
+ return Number(result.changes ?? 0);
1725
+ }
1726
+ }