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