context-mode 1.0.111 → 1.0.113

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 (153) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/index.ts +3 -2
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +152 -34
  7. package/bin/statusline.mjs +144 -127
  8. package/build/adapters/base.d.ts +8 -5
  9. package/build/adapters/base.js +8 -18
  10. package/build/adapters/claude-code/index.d.ts +24 -3
  11. package/build/adapters/claude-code/index.js +44 -11
  12. package/build/adapters/codex/hooks.d.ts +10 -5
  13. package/build/adapters/codex/hooks.js +10 -5
  14. package/build/adapters/codex/index.d.ts +17 -5
  15. package/build/adapters/codex/index.js +337 -37
  16. package/build/adapters/codex/paths.d.ts +1 -0
  17. package/build/adapters/codex/paths.js +12 -0
  18. package/build/adapters/cursor/index.d.ts +6 -0
  19. package/build/adapters/cursor/index.js +83 -2
  20. package/build/adapters/detect.d.ts +1 -1
  21. package/build/adapters/detect.js +29 -6
  22. package/build/adapters/omp/index.d.ts +65 -0
  23. package/build/adapters/omp/index.js +182 -0
  24. package/build/adapters/omp/plugin.d.ts +75 -0
  25. package/build/adapters/omp/plugin.js +220 -0
  26. package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
  27. package/build/adapters/openclaw/mcp-tools.js +198 -0
  28. package/build/adapters/openclaw/plugin.d.ts +130 -0
  29. package/build/adapters/openclaw/plugin.js +629 -0
  30. package/build/adapters/openclaw/workspace-router.d.ts +29 -0
  31. package/build/adapters/openclaw/workspace-router.js +64 -0
  32. package/build/adapters/opencode/plugin.d.ts +145 -0
  33. package/build/adapters/opencode/plugin.js +457 -0
  34. package/build/adapters/pi/extension.d.ts +26 -0
  35. package/build/adapters/pi/extension.js +552 -0
  36. package/build/adapters/pi/index.d.ts +57 -0
  37. package/build/adapters/pi/index.js +173 -0
  38. package/build/adapters/pi/mcp-bridge.d.ts +113 -0
  39. package/build/adapters/pi/mcp-bridge.js +251 -0
  40. package/build/adapters/types.d.ts +11 -6
  41. package/build/cli.js +186 -170
  42. package/build/db-base.d.ts +15 -2
  43. package/build/db-base.js +50 -5
  44. package/build/executor.d.ts +2 -0
  45. package/build/executor.js +15 -2
  46. package/build/runPool.d.ts +36 -0
  47. package/build/runPool.js +51 -0
  48. package/build/runtime.js +64 -5
  49. package/build/search/auto-memory.js +6 -4
  50. package/build/security.js +30 -10
  51. package/build/server.d.ts +23 -1
  52. package/build/server.js +662 -182
  53. package/build/session/analytics.d.ts +404 -1
  54. package/build/session/analytics.js +1347 -42
  55. package/build/session/db.d.ts +114 -5
  56. package/build/session/db.js +275 -27
  57. package/build/session/event-emit.d.ts +48 -0
  58. package/build/session/event-emit.js +101 -0
  59. package/build/session/extract.d.ts +1 -0
  60. package/build/session/extract.js +79 -12
  61. package/build/session/purge.d.ts +111 -0
  62. package/build/session/purge.js +138 -0
  63. package/build/store.d.ts +7 -0
  64. package/build/store.js +69 -6
  65. package/build/util/claude-config.d.ts +26 -0
  66. package/build/util/claude-config.js +91 -0
  67. package/build/util/hook-config.d.ts +4 -0
  68. package/build/util/hook-config.js +39 -0
  69. package/build/util/project-dir.d.ts +49 -0
  70. package/build/util/project-dir.js +67 -0
  71. package/cli.bundle.mjs +411 -208
  72. package/configs/antigravity/GEMINI.md +0 -3
  73. package/configs/claude-code/CLAUDE.md +1 -4
  74. package/configs/codex/AGENTS.md +1 -4
  75. package/configs/codex/config.toml +3 -0
  76. package/configs/codex/hooks.json +8 -0
  77. package/configs/cursor/context-mode.mdc +0 -3
  78. package/configs/gemini-cli/GEMINI.md +0 -3
  79. package/configs/jetbrains-copilot/copilot-instructions.md +0 -3
  80. package/configs/kilo/AGENTS.md +0 -3
  81. package/configs/kiro/KIRO.md +0 -3
  82. package/configs/omp/SYSTEM.md +85 -0
  83. package/configs/omp/mcp.json +7 -0
  84. package/configs/openclaw/AGENTS.md +0 -3
  85. package/configs/opencode/AGENTS.md +0 -3
  86. package/configs/pi/AGENTS.md +0 -3
  87. package/configs/qwen-code/QWEN.md +1 -4
  88. package/configs/vscode-copilot/copilot-instructions.md +0 -3
  89. package/configs/zed/AGENTS.md +0 -3
  90. package/hooks/codex/posttooluse.mjs +9 -2
  91. package/hooks/codex/precompact.mjs +69 -0
  92. package/hooks/codex/sessionstart.mjs +13 -9
  93. package/hooks/codex/stop.mjs +1 -2
  94. package/hooks/codex/userpromptsubmit.mjs +1 -2
  95. package/hooks/core/routing.mjs +237 -18
  96. package/hooks/cursor/afteragentresponse.mjs +1 -1
  97. package/hooks/cursor/hooks.json +31 -0
  98. package/hooks/cursor/posttooluse.mjs +1 -1
  99. package/hooks/cursor/sessionstart.mjs +5 -5
  100. package/hooks/cursor/stop.mjs +1 -1
  101. package/hooks/ensure-deps.mjs +12 -13
  102. package/hooks/gemini-cli/aftertool.mjs +1 -1
  103. package/hooks/gemini-cli/beforeagent.mjs +1 -1
  104. package/hooks/gemini-cli/precompress.mjs +3 -2
  105. package/hooks/gemini-cli/sessionstart.mjs +9 -9
  106. package/hooks/jetbrains-copilot/posttooluse.mjs +1 -1
  107. package/hooks/jetbrains-copilot/precompact.mjs +3 -2
  108. package/hooks/jetbrains-copilot/sessionstart.mjs +9 -9
  109. package/hooks/kiro/agentspawn.mjs +5 -5
  110. package/hooks/kiro/posttooluse.mjs +2 -2
  111. package/hooks/kiro/userpromptsubmit.mjs +1 -1
  112. package/hooks/posttooluse.mjs +45 -0
  113. package/hooks/precompact.mjs +17 -0
  114. package/hooks/pretooluse.mjs +23 -0
  115. package/hooks/routing-block.mjs +0 -12
  116. package/hooks/run-hook.mjs +16 -3
  117. package/hooks/session-db.bundle.mjs +27 -18
  118. package/hooks/session-extract.bundle.mjs +2 -2
  119. package/hooks/session-helpers.mjs +101 -64
  120. package/hooks/sessionstart.mjs +51 -2
  121. package/hooks/vscode-copilot/posttooluse.mjs +1 -1
  122. package/hooks/vscode-copilot/precompact.mjs +3 -2
  123. package/hooks/vscode-copilot/sessionstart.mjs +9 -9
  124. package/openclaw.plugin.json +1 -1
  125. package/package.json +14 -8
  126. package/server.bundle.mjs +349 -147
  127. package/start.mjs +16 -4
  128. package/skills/UPSTREAM-CREDITS.md +0 -51
  129. package/skills/context-mode-ops/SKILL.md +0 -299
  130. package/skills/context-mode-ops/agent-teams.md +0 -198
  131. package/skills/context-mode-ops/communication.md +0 -224
  132. package/skills/context-mode-ops/marketing.md +0 -124
  133. package/skills/context-mode-ops/release.md +0 -214
  134. package/skills/context-mode-ops/review-pr.md +0 -269
  135. package/skills/context-mode-ops/tdd.md +0 -329
  136. package/skills/context-mode-ops/triage-issue.md +0 -266
  137. package/skills/context-mode-ops/validation.md +0 -307
  138. package/skills/diagnose/SKILL.md +0 -122
  139. package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
  140. package/skills/grill-me/SKILL.md +0 -15
  141. package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
  142. package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
  143. package/skills/grill-with-docs/SKILL.md +0 -93
  144. package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
  145. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
  146. package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
  147. package/skills/improve-codebase-architecture/SKILL.md +0 -76
  148. package/skills/tdd/SKILL.md +0 -114
  149. package/skills/tdd/deep-modules.md +0 -33
  150. package/skills/tdd/interface-design.md +0 -31
  151. package/skills/tdd/mocking.md +0 -59
  152. package/skills/tdd/refactoring.md +0 -10
  153. package/skills/tdd/tests.md +0 -61
@@ -0,0 +1,101 @@
1
+ /**
2
+ * event-emit — Phase 5+7 of D2 PRD (stats-event-driven-architecture)
3
+ *
4
+ * Server-side helpers that record sandbox / index / cache work into
5
+ * `session_events` with the new `bytes_avoided` / `bytes_returned`
6
+ * columns so the renderer can compute the real $ saved instead of the
7
+ * conservative `events × 256` token estimate.
8
+ *
9
+ * Design notes
10
+ * ────────────
11
+ * - Uses the public `SessionDB.insertEvent(... , bytes)` API the schema
12
+ * engineer extended in this branch — same dedup + FIFO eviction +
13
+ * transaction wrapping you'd get from any other event source.
14
+ * - Best-effort error swallowing matches `persistToolCallCounter` in
15
+ * `persist-tool-calls.ts`. A stats-side failure must NEVER break the
16
+ * parent MCP tool call.
17
+ * - Resolves the latest `session_id` from `session_meta` so the wiring
18
+ * in `server.ts` is `setImmediate(() => emit*({...}))` — no need to
19
+ * plumb session ids through every handler.
20
+ */
21
+ import { existsSync } from "node:fs";
22
+ import { SessionDB } from "./db.js";
23
+ /**
24
+ * Open the SessionDB at `dbPath`, find the latest session_id, and run
25
+ * `fn` with both. Wraps everything in try/catch so callers stay
26
+ * fire-and-forget.
27
+ */
28
+ function withLatestSession(dbPath, fn) {
29
+ try {
30
+ if (!existsSync(dbPath))
31
+ return;
32
+ const sdb = new SessionDB({ dbPath });
33
+ try {
34
+ const sid = sdb.getLatestSessionId();
35
+ if (!sid)
36
+ return;
37
+ fn(sdb, sid);
38
+ }
39
+ finally {
40
+ try {
41
+ sdb.close();
42
+ }
43
+ catch { /* ignore */ }
44
+ }
45
+ }
46
+ catch {
47
+ // Best-effort: never break the parent MCP tool call.
48
+ }
49
+ }
50
+ /**
51
+ * Record a `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` run.
52
+ * `bytesReturned` is the size of the stdout text the user actually saw —
53
+ * the rest of the sandbox output stayed out of context.
54
+ */
55
+ export function emitSandboxExecuteEvent(opts) {
56
+ withLatestSession(opts.sessionDbPath, (sdb, sid) => {
57
+ sdb.insertEvent(sid, {
58
+ type: "sandbox-execute",
59
+ category: "sandbox",
60
+ priority: 1,
61
+ data: opts.toolName,
62
+ project_dir: "",
63
+ attribution_source: "server",
64
+ attribution_confidence: 1,
65
+ }, "ctx-server", undefined, { bytesReturned: opts.bytesReturned });
66
+ });
67
+ }
68
+ /**
69
+ * Record a `ctx_index` / `trackIndexed` write — content kept out of
70
+ * context by being chunked into FTS5 instead of returned inline.
71
+ */
72
+ export function emitIndexWriteEvent(opts) {
73
+ withLatestSession(opts.sessionDbPath, (sdb, sid) => {
74
+ sdb.insertEvent(sid, {
75
+ type: "index-write",
76
+ category: "sandbox",
77
+ priority: 1,
78
+ data: opts.source,
79
+ project_dir: "",
80
+ attribution_source: "server",
81
+ attribution_confidence: 1,
82
+ }, "ctx-server", undefined, { bytesAvoided: opts.bytesAvoided });
83
+ });
84
+ }
85
+ /**
86
+ * Record a `ctx_fetch_and_index` TTL cache hit — bytes the user would
87
+ * have spent re-fetching the same URL within the 24h cache window.
88
+ */
89
+ export function emitCacheHitEvent(opts) {
90
+ withLatestSession(opts.sessionDbPath, (sdb, sid) => {
91
+ sdb.insertEvent(sid, {
92
+ type: "cache-hit",
93
+ category: "cache",
94
+ priority: 1,
95
+ data: opts.source,
96
+ project_dir: "",
97
+ attribution_source: "server",
98
+ attribution_confidence: 1,
99
+ }, "ctx-server", undefined, { bytesAvoided: opts.bytesAvoided });
100
+ });
101
+ }
@@ -33,6 +33,7 @@ export interface HookInput {
33
33
  /** Optional structured output from the tool (may carry isError) */
34
34
  tool_output?: {
35
35
  isError?: boolean;
36
+ is_error?: boolean;
36
37
  };
37
38
  }
38
39
  /** Reset error-resolution state (for testing). */
@@ -17,6 +17,48 @@ function safeStringAny(value) {
17
17
  return "";
18
18
  return typeof value === "string" ? value : JSON.stringify(value);
19
19
  }
20
+ function isToolError(input) {
21
+ const response = String(input.tool_response ?? "");
22
+ const isErrorFlag = input.tool_output?.isError === true || input.tool_output?.is_error === true;
23
+ const isBashError = input.tool_name === "Bash" &&
24
+ /exit code [1-9]|error:|Error:|FAIL|failed/i.test(response);
25
+ return isBashError || isErrorFlag;
26
+ }
27
+ function extractApplyPatchTargets(command) {
28
+ if (!command)
29
+ return [];
30
+ const targets = [];
31
+ for (const line of command.split(/\r?\n/)) {
32
+ if (line.startsWith("*** Add File: ")) {
33
+ targets.push({ path: line.slice(14).trim(), type: "file_write" });
34
+ continue;
35
+ }
36
+ if (line.startsWith("*** Update File: ")) {
37
+ targets.push({ path: line.slice(17).trim(), type: "file_edit" });
38
+ continue;
39
+ }
40
+ if (line.startsWith("*** Delete File: ")) {
41
+ targets.push({ path: line.slice(17).trim(), type: "file_edit" });
42
+ continue;
43
+ }
44
+ if (line.startsWith("*** Move to: ")) {
45
+ targets.push({ path: line.slice(13).trim(), type: "file_edit" });
46
+ }
47
+ }
48
+ const seen = new Set();
49
+ return targets.filter((target) => {
50
+ if (!target.path)
51
+ return false;
52
+ const key = `${target.type}:${target.path}`;
53
+ if (seen.has(key))
54
+ return false;
55
+ seen.add(key);
56
+ return true;
57
+ });
58
+ }
59
+ function isPlanFilePath(filePath) {
60
+ return /(?:^|[/\\])\.claude[/\\]plans[/\\]/.test(filePath);
61
+ }
20
62
  // ── Category extractors ────────────────────────────────────────────────────
21
63
  /**
22
64
  * Category 1 & 2: rule + file
@@ -105,6 +147,20 @@ function extractFileAndRule(input) {
105
147
  });
106
148
  return events;
107
149
  }
150
+ if (tool_name === "apply_patch") {
151
+ if (isToolError(input))
152
+ return [];
153
+ const patchTargets = extractApplyPatchTargets(String(tool_input["command"] ?? tool_input["patch"] ?? ""));
154
+ for (const target of patchTargets) {
155
+ events.push({
156
+ type: target.type,
157
+ category: "file",
158
+ data: safeString(target.path),
159
+ priority: 1,
160
+ });
161
+ }
162
+ return events;
163
+ }
108
164
  // Glob — file pattern exploration
109
165
  if (tool_name === "Glob") {
110
166
  const pattern = String(tool_input["pattern"] ?? "");
@@ -156,12 +212,9 @@ function extractCwd(input) {
156
212
  * isError flag in tool_output.
157
213
  */
158
214
  function extractError(input) {
159
- const { tool_name, tool_input, tool_response, tool_output } = input;
215
+ const { tool_response } = input;
160
216
  const response = String(tool_response ?? "");
161
- const isErrorFlag = tool_output?.isError === true;
162
- const isBashError = tool_name === "Bash" &&
163
- /exit code [1-9]|error:|Error:|FAIL|failed/i.test(response);
164
- if (!isBashError && !isErrorFlag)
217
+ if (!isToolError(input))
165
218
  return [];
166
219
  return [{
167
220
  type: "error_tool",
@@ -287,7 +340,7 @@ function extractPlan(input) {
287
340
  // Detect plan file writes (Write/Edit to ~/.claude/plans/)
288
341
  if (input.tool_name === "Write" || input.tool_name === "Edit") {
289
342
  const filePath = String(input.tool_input["file_path"] ?? "");
290
- if (/[/\\]\.claude[/\\]plans[/\\]/.test(filePath)) {
343
+ if (isPlanFilePath(filePath)) {
291
344
  return [{
292
345
  type: "plan_file_write",
293
346
  category: "plan",
@@ -296,6 +349,19 @@ function extractPlan(input) {
296
349
  }];
297
350
  }
298
351
  }
352
+ if (input.tool_name === "apply_patch") {
353
+ if (isToolError(input))
354
+ return [];
355
+ const patchTargets = extractApplyPatchTargets(String(input.tool_input["command"] ?? input.tool_input["patch"] ?? ""));
356
+ return patchTargets
357
+ .filter((target) => isPlanFilePath(target.path))
358
+ .map((target) => ({
359
+ type: "plan_file_write",
360
+ category: "plan",
361
+ data: safeString(`plan file: ${target.path.split(/[/\\]/).pop() ?? target.path}`),
362
+ priority: 2,
363
+ }));
364
+ }
299
365
  return [];
300
366
  }
301
367
  /**
@@ -766,13 +832,10 @@ function extractData(message) {
766
832
  */
767
833
  let lastError = null;
768
834
  function extractErrorResolution(input) {
769
- const { tool_name, tool_response, tool_output } = input;
835
+ const { tool_name, tool_response } = input;
770
836
  const response = String(tool_response ?? "");
771
- const isErrorFlag = tool_output?.isError === true;
772
- const isBashError = tool_name === "Bash" &&
773
- /exit code [1-9]|error:|Error:|FAIL|failed/i.test(response);
774
837
  // If this call is an error, store it and return
775
- if (isBashError || isErrorFlag) {
838
+ if (isToolError(input)) {
776
839
  lastError = { tool: tool_name, error: response.slice(0, 200), callsSince: 0 };
777
840
  return [];
778
841
  }
@@ -786,9 +849,13 @@ function extractErrorResolution(input) {
786
849
  lastError = null;
787
850
  return [];
788
851
  }
852
+ const callSucceeded = !isToolError(input);
853
+ if (!callSucceeded)
854
+ return [];
789
855
  // Check if this is a resolution: same tool, or Edit/Write after a Read error
790
856
  const sameTool = tool_name === lastError.tool;
791
- const editAfterReadError = lastError.tool === "Read" && (tool_name === "Edit" || tool_name === "Write");
857
+ const editAfterReadError = lastError.tool === "Read"
858
+ && (tool_name === "Edit" || tool_name === "Write" || tool_name === "apply_patch");
792
859
  if (sameTool || editAfterReadError) {
793
860
  const event = {
794
861
  type: "error_resolved",
@@ -0,0 +1,111 @@
1
+ /**
2
+ * purgeSession — deep module that wipes ALL session-related on-disk artifacts
3
+ * for a single project directory.
4
+ *
5
+ * Why a deep module instead of an inline handler:
6
+ * - The previous inline ctx_purge handler was 100+ lines split across three
7
+ * try/catch blocks. Only ONE of those blocks knew about the case-fold
8
+ * migration's dual-hash legacy filenames, so a partial upgrade could
9
+ * leak orphaned events.md / .cleanup files on macOS / Windows.
10
+ * - Centralizing the logic here means: one canonical sidecar list, one
11
+ * uniform dual-hash sweep, one place to add new file kinds.
12
+ *
13
+ * Worktree separation guarantee (carried over from the case-fold migration):
14
+ * Every path this module touches is derived deterministically from the input
15
+ * `projectDir`. There is NO `readdirSync` + glob-filter loop. Different
16
+ * worktrees → different physical paths → different canonical hashes →
17
+ * different file names → cannot collapse worktrees on disk.
18
+ *
19
+ * SQLite sidecar handling:
20
+ * Each `.db` file may be accompanied by `-wal` (write-ahead log) and `-shm`
21
+ * (shared memory index) sidecars. We unlink the triple unconditionally —
22
+ * missing sidecars are not an error. This matches the canonical SQLite
23
+ * sidecar naming used elsewhere (see refs/platforms/zed/crates/sqlez:
24
+ * `[main, "{main}-wal", "{main}-shm"]`).
25
+ *
26
+ * Cross-platform notes:
27
+ * - All paths are joined via `node:path.join` so Windows backslash
28
+ * separators and POSIX forward slashes both work.
29
+ * - On macOS / Windows (case-insensitive FS) we sweep BOTH the canonical
30
+ * (lowercased) and legacy (raw-cased) project-dir hash variants for the
31
+ * session-related kinds. On Linux the two hashes coincide, so the dual
32
+ * sweep collapses into a single unique-path pass.
33
+ */
34
+ export interface PurgeOpts {
35
+ /**
36
+ * Absolute path to the project root. Drives every other path the module
37
+ * touches via the project-dir hash. MUST be the same string the rest of
38
+ * the system uses (e.g. `getProjectDir()`); otherwise the wrong DB is
39
+ * targeted. Worktree separation is preserved — only files matching
40
+ * THIS projectDir's hash are unlinked.
41
+ */
42
+ projectDir: string;
43
+ /**
44
+ * Adapter-specific session directory (e.g. `~/.claude/context-mode/sessions`).
45
+ * Holds: `<hash><suffix>.db`, `<hash><suffix>-events.md`,
46
+ * `<hash><suffix>.cleanup`.
47
+ */
48
+ sessionsDir: string;
49
+ /**
50
+ * Absolute path to the per-project FTS5 knowledge-base DB
51
+ * (e.g. `~/.claude/context-mode/content/<hash>.db`). When omitted no
52
+ * FTS5 wipe runs. Caller is responsible for closing any open handle
53
+ * BEFORE invoking purgeSession (Windows file locks).
54
+ *
55
+ * Use `contentDir` instead for new code — it dual-sweeps the canonical
56
+ * AND legacy raw-casing variants, mirroring the session events pattern.
57
+ * `storePath` remains for callers that have already pre-resolved a single
58
+ * absolute path and only want to wipe that exact file.
59
+ */
60
+ storePath?: string;
61
+ /**
62
+ * Per-platform FTS5 content directory (e.g.
63
+ * `~/.claude/context-mode/content`). When provided, purgeSession sweeps
64
+ * BOTH the canonical and legacy raw-casing hash variants of the FTS5
65
+ * store inside this directory plus their `-wal` / `-shm` sidecars. This
66
+ * is the recommended input — covers a partial upgrade where the user
67
+ * had been writing to a legacy raw-casing FTS5 file before the case-fold
68
+ * migration landed.
69
+ *
70
+ * Mutually-additive with `storePath`: if both are passed, both are swept
71
+ * (de-duped on path). Closing FTS5 handles before invoking is still the
72
+ * caller's responsibility.
73
+ */
74
+ contentDir?: string;
75
+ /**
76
+ * Legacy shared content directory at `~/.context-mode/content`. When
77
+ * omitted, the legacy content sweep is skipped.
78
+ */
79
+ legacyContentDir?: string;
80
+ /**
81
+ * Hash used to locate the legacy shared content DB. Required when
82
+ * `legacyContentDir` is provided. Computed by the caller because the
83
+ * legacy code-path uses a different hash function than the canonical
84
+ * session DB hash.
85
+ */
86
+ contentHash?: string;
87
+ }
88
+ export interface PurgeResult {
89
+ /**
90
+ * Human-readable labels rendered to the user by the ctx_purge handler.
91
+ * MUST stay backward-compatible with the existing UI strings:
92
+ * "knowledge base (FTS5)", "session events DB", "session events markdown".
93
+ * Each label appears at most once, and only when at least one matching
94
+ * file was actually unlinked.
95
+ */
96
+ deleted: string[];
97
+ /**
98
+ * Every full path that was successfully `unlink`ed. Surfaced for tests
99
+ * and for diagnostic logging — NEVER shown to end users (the labels
100
+ * above carry the human story).
101
+ */
102
+ wipedPaths: string[];
103
+ }
104
+ /**
105
+ * Wipe every session-related on-disk artifact for `projectDir`.
106
+ *
107
+ * This function never throws on missing files (a fresh install is a no-op).
108
+ * It throws only when given an invalid argument (e.g. `legacyContentDir`
109
+ * without `contentHash`), which is a programmer bug not a runtime concern.
110
+ */
111
+ export declare function purgeSession(opts: PurgeOpts): PurgeResult;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * purgeSession — deep module that wipes ALL session-related on-disk artifacts
3
+ * for a single project directory.
4
+ *
5
+ * Why a deep module instead of an inline handler:
6
+ * - The previous inline ctx_purge handler was 100+ lines split across three
7
+ * try/catch blocks. Only ONE of those blocks knew about the case-fold
8
+ * migration's dual-hash legacy filenames, so a partial upgrade could
9
+ * leak orphaned events.md / .cleanup files on macOS / Windows.
10
+ * - Centralizing the logic here means: one canonical sidecar list, one
11
+ * uniform dual-hash sweep, one place to add new file kinds.
12
+ *
13
+ * Worktree separation guarantee (carried over from the case-fold migration):
14
+ * Every path this module touches is derived deterministically from the input
15
+ * `projectDir`. There is NO `readdirSync` + glob-filter loop. Different
16
+ * worktrees → different physical paths → different canonical hashes →
17
+ * different file names → cannot collapse worktrees on disk.
18
+ *
19
+ * SQLite sidecar handling:
20
+ * Each `.db` file may be accompanied by `-wal` (write-ahead log) and `-shm`
21
+ * (shared memory index) sidecars. We unlink the triple unconditionally —
22
+ * missing sidecars are not an error. This matches the canonical SQLite
23
+ * sidecar naming used elsewhere (see refs/platforms/zed/crates/sqlez:
24
+ * `[main, "{main}-wal", "{main}-shm"]`).
25
+ *
26
+ * Cross-platform notes:
27
+ * - All paths are joined via `node:path.join` so Windows backslash
28
+ * separators and POSIX forward slashes both work.
29
+ * - On macOS / Windows (case-insensitive FS) we sweep BOTH the canonical
30
+ * (lowercased) and legacy (raw-cased) project-dir hash variants for the
31
+ * session-related kinds. On Linux the two hashes coincide, so the dual
32
+ * sweep collapses into a single unique-path pass.
33
+ */
34
+ import { unlinkSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { getWorktreeSuffix, hashProjectDirCanonical, hashProjectDirLegacy, } from "./db.js";
37
+ /** Canonical SQLite sidecar suffixes. The empty string is the main DB. */
38
+ const SQLITE_SIDECARS = ["", "-wal", "-shm"];
39
+ /** Try to unlink one path; report success without throwing on ENOENT etc. */
40
+ function tryUnlink(p, wipedPaths) {
41
+ try {
42
+ unlinkSync(p);
43
+ wipedPaths.push(p);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /**
51
+ * Unlink a SQLite db at `path` plus its `-wal` / `-shm` sidecars.
52
+ * Returns true when the MAIN db file (not a sidecar) was removed.
53
+ */
54
+ function tryUnlinkSqliteTriple(path, wipedPaths) {
55
+ let mainRemoved = false;
56
+ for (const suffix of SQLITE_SIDECARS) {
57
+ const removed = tryUnlink(`${path}${suffix}`, wipedPaths);
58
+ if (removed && suffix === "")
59
+ mainRemoved = true;
60
+ }
61
+ return mainRemoved;
62
+ }
63
+ /**
64
+ * Wipe every session-related on-disk artifact for `projectDir`.
65
+ *
66
+ * This function never throws on missing files (a fresh install is a no-op).
67
+ * It throws only when given an invalid argument (e.g. `legacyContentDir`
68
+ * without `contentHash`), which is a programmer bug not a runtime concern.
69
+ */
70
+ export function purgeSession(opts) {
71
+ const { projectDir, sessionsDir, storePath, contentDir, legacyContentDir, contentHash } = opts;
72
+ const deleted = [];
73
+ const wipedPaths = [];
74
+ // ── 1. Knowledge base FTS5 store (per-platform). ──────────────────────
75
+ // Two input modes:
76
+ // - `storePath`: single absolute path; pre-resolved by caller. Wipes
77
+ // exactly that file plus -wal / -shm sidecars. Back-compat path.
78
+ // - `contentDir`: directory; purgeSession derives BOTH canonical and
79
+ // legacy raw-casing variants of the FTS5 store filename (matches
80
+ // the case-fold migration pattern from `resolveContentStorePath`)
81
+ // and sweeps each with sidecars. Recommended for new callers.
82
+ // Both inputs may be supplied; paths are de-duped via the unlink-or-fail
83
+ // semantics of `tryUnlinkSqliteTriple`. The "knowledge base (FTS5)"
84
+ // label appears at most once.
85
+ let storeFound = false;
86
+ if (storePath && tryUnlinkSqliteTriple(storePath, wipedPaths))
87
+ storeFound = true;
88
+ if (contentDir) {
89
+ const canonicalHash = hashProjectDirCanonical(projectDir);
90
+ const legacyHash = hashProjectDirLegacy(projectDir);
91
+ const storeHashes = canonicalHash === legacyHash
92
+ ? [canonicalHash]
93
+ : [canonicalHash, legacyHash];
94
+ for (const h of storeHashes) {
95
+ const path = join(contentDir, `${h}.db`);
96
+ if (tryUnlinkSqliteTriple(path, wipedPaths))
97
+ storeFound = true;
98
+ }
99
+ }
100
+ if (storeFound)
101
+ deleted.push("knowledge base (FTS5)");
102
+ // ── 2. Legacy shared content DB at ~/.context-mode/content/<hash>.db.
103
+ // Same reasoning as (1) — single hash, legacy code-path only.
104
+ if (legacyContentDir) {
105
+ if (!contentHash) {
106
+ throw new TypeError("purgeSession: contentHash is required when legacyContentDir is provided");
107
+ }
108
+ const legacyPath = join(legacyContentDir, `${contentHash}.db`);
109
+ tryUnlinkSqliteTriple(legacyPath, wipedPaths);
110
+ // No user-facing label — this is a silent legacy cleanup.
111
+ }
112
+ // ── 3. Session-events kinds at BOTH canonical AND legacy hashes. ─────
113
+ // This is the bug fix: the prior handler only dual-hashed the .db file
114
+ // (after migration commit a32cc29). events.md and .cleanup were left
115
+ // single-hash, so a casing-drift project on macOS/Windows could leak
116
+ // orphan files past a purge. We now sweep all three uniformly.
117
+ const worktreeSuffix = getWorktreeSuffix(projectDir);
118
+ const canonicalHash = hashProjectDirCanonical(projectDir);
119
+ const legacyHash = hashProjectDirLegacy(projectDir);
120
+ const hashes = canonicalHash === legacyHash
121
+ ? [canonicalHash]
122
+ : [canonicalHash, legacyHash];
123
+ let sessDbFound = false;
124
+ let eventsFound = false;
125
+ for (const h of hashes) {
126
+ const base = join(sessionsDir, `${h}${worktreeSuffix}`);
127
+ if (tryUnlinkSqliteTriple(`${base}.db`, wipedPaths))
128
+ sessDbFound = true;
129
+ if (tryUnlink(`${base}-events.md`, wipedPaths))
130
+ eventsFound = true;
131
+ tryUnlink(`${base}.cleanup`, wipedPaths); // no user-facing label
132
+ }
133
+ if (sessDbFound)
134
+ deleted.push("session events DB");
135
+ if (eventsFound)
136
+ deleted.push("session events markdown");
137
+ return { deleted, wipedPaths };
138
+ }
package/build/store.d.ts CHANGED
@@ -30,6 +30,13 @@ export declare class ContentStore {
30
30
  constructor(dbPath?: string);
31
31
  /** Delete this session's DB files. Call on process exit. */
32
32
  cleanup(): void;
33
+ /**
34
+ * Register a deny-policy checker. When set, #refreshStaleSources
35
+ * calls it before re-reading any file_path during auto-refresh.
36
+ * Returning `true` causes the source to be skipped (kept in cache,
37
+ * not re-indexed). server.ts wires this to the Read deny patterns.
38
+ */
39
+ setDenyChecker(fn: ((filePath: string) => boolean) | undefined): void;
33
40
  index(options: {
34
41
  content?: string;
35
42
  path?: string;
package/build/store.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  var _a;
11
11
  import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError } from "./db-base.js";
12
- import { readFileSync, readdirSync, unlinkSync, existsSync, statSync } from "node:fs";
12
+ import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync, fstatSync, closeSync } from "node:fs";
13
13
  import { createHash } from "node:crypto";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
@@ -290,6 +290,13 @@ function findMinSpan(positionLists) {
290
290
  export class ContentStore {
291
291
  #db;
292
292
  #dbPath;
293
+ // Optional deny-policy callback. When set (by server.ts at startup),
294
+ // #refreshStaleSources consults it before re-reading file_path during
295
+ // auto-refresh. This catches policy edits between initial indexing and
296
+ // a later search: a file that was allowed at index time may have been
297
+ // added to the Read deny list afterwards. Without this hook, refresh
298
+ // would re-read and re-expose the file. See #442 round-3.
299
+ #denyChecker;
293
300
  // ── Cached Prepared Statements ──
294
301
  // Prepared once at construction, reused on every call to avoid
295
302
  // re-compiling SQL on each invocation.
@@ -695,6 +702,16 @@ export class ContentStore {
695
702
  this.#stmtCleanupChunksTrigram = this.#db.prepare("DELETE FROM chunks_trigram WHERE source_id IN (SELECT id FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days'))");
696
703
  this.#stmtCleanupSources = this.#db.prepare("DELETE FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days')");
697
704
  }
705
+ // ── Deny Policy Hook ──
706
+ /**
707
+ * Register a deny-policy checker. When set, #refreshStaleSources
708
+ * calls it before re-reading any file_path during auto-refresh.
709
+ * Returning `true` causes the source to be skipped (kept in cache,
710
+ * not re-indexed). server.ts wires this to the Read deny patterns.
711
+ */
712
+ setDenyChecker(fn) {
713
+ this.#denyChecker = fn;
714
+ }
698
715
  // ── Index ──
699
716
  index(options) {
700
717
  const { content, path, source } = options;
@@ -707,7 +724,31 @@ export class ContentStore {
707
724
  if (!hasContent && !path) {
708
725
  throw new Error("Either content or path must be provided");
709
726
  }
710
- const text = hasContent ? content : readFileSync(path, "utf-8");
727
+ // Read file via fd to close the TOCTOU window between the security
728
+ // gate (security.ts evaluateFilePath calls realpathSync) and the read
729
+ // here. Lexical re-read by path string allowed an attacker to swap a
730
+ // symlink to a denied target (e.g. ~/.ssh/id_rsa) AFTER gate passed.
731
+ // openSync + fstat + readFileSync(fd) binds the read to the inode
732
+ // captured at gate-time. fstat also rejects non-regular files
733
+ // (directories, character devices) which would otherwise read as ""
734
+ // or throw inconsistently. See #442 round-3.
735
+ let text;
736
+ if (hasContent) {
737
+ text = content;
738
+ }
739
+ else {
740
+ const fd = openSync(path, "r");
741
+ try {
742
+ const st = fstatSync(fd);
743
+ if (!st.isFile()) {
744
+ throw new Error(`refusing to index ${path}: not a regular file`);
745
+ }
746
+ text = readFileSync(fd, "utf-8");
747
+ }
748
+ finally {
749
+ closeSync(fd);
750
+ }
751
+ }
711
752
  const label = source ?? path ?? "untitled";
712
753
  const chunks = this.#chunkMarkdown(text);
713
754
  // Stale detection: store file_path + SHA-256 for file-backed sources
@@ -1028,17 +1069,39 @@ export class ContentStore {
1028
1069
  try {
1029
1070
  if (!existsSync(src.file_path))
1030
1071
  continue; // file deleted — keep cached results
1072
+ // Re-check deny policy before re-reading. The Read deny list may
1073
+ // have been edited after this source was originally indexed; a
1074
+ // file that was allowed then may now be denied. Without this
1075
+ // gate, refresh would happily re-read and re-expose it. #442 r3.
1076
+ if (this.#denyChecker && this.#denyChecker(src.file_path))
1077
+ continue;
1031
1078
  const mtime = statSync(src.file_path).mtime;
1032
1079
  const indexedAt = new Date(src.indexed_at + "Z");
1033
1080
  if (mtime <= indexedAt)
1034
1081
  continue; // file unchanged — fast path
1035
- // mtime advanced — check hash to confirm real change (not just touch)
1036
- const newContent = readFileSync(src.file_path, "utf-8");
1082
+ // mtime advanced — fd-bound read for hash + indexing in one go.
1083
+ // Open once, fstat, read from fd. Closes the swap-mid-flight
1084
+ // window between hash read and re-index. #442 round-3.
1085
+ const fd = openSync(src.file_path, "r");
1086
+ let newContent;
1087
+ try {
1088
+ const st = fstatSync(fd);
1089
+ if (!st.isFile())
1090
+ continue; // skip non-regular targets
1091
+ newContent = readFileSync(fd, "utf-8");
1092
+ }
1093
+ finally {
1094
+ closeSync(fd);
1095
+ }
1037
1096
  const newHash = createHash("sha256").update(newContent).digest("hex");
1038
1097
  if (newHash === src.content_hash)
1039
1098
  continue; // content identical — skip
1040
- // File genuinely changed — re-index
1041
- this.index({ path: src.file_path, source: src.label });
1099
+ // File genuinely changed — re-index using already-read content
1100
+ // (avoids a second open/read race) but preserve file_path/hash
1101
+ // by going through index() which stores them. Since we pass
1102
+ // content, index() does NOT re-read; the bytes hashed above
1103
+ // are exactly the bytes indexed.
1104
+ this.index({ content: newContent, path: src.file_path, source: src.label });
1042
1105
  this.lastRefreshCount++;
1043
1106
  }
1044
1107
  catch {
@@ -0,0 +1,26 @@
1
+ export declare function resolveClaudeConfigDir(env?: NodeJS.ProcessEnv): string;
2
+ /** Resolve the global settings.json path, honoring CLAUDE_CONFIG_DIR. */
3
+ export declare function resolveClaudeGlobalSettingsPath(env?: NodeJS.ProcessEnv): string;
4
+ /**
5
+ * Issue #451 round-3: cross-adapter deny-policy parity.
6
+ *
7
+ * `resolveClaudeGlobalSettingsPath` hardcodes the `.claude` segment, so
8
+ * non-Claude adapters (cursor, codex, qwen-code, gemini-cli, jetbrains-copilot,
9
+ * vscode-copilot, etc.) never had their global settings consulted by the
10
+ * security policy reader. This helper returns the union of:
11
+ *
12
+ * 1. The currently-detected adapter's home-rooted settings.json (when the
13
+ * adapter is non-claude — claude is already covered by entry 2).
14
+ * 2. The claude global settings.json (always — defense in depth).
15
+ *
16
+ * Lazy import of `./adapters/detect.js` keeps this file free of any direct
17
+ * adapter dependency: the detect module itself only `import type`s adapter
18
+ * types at the top level (concrete adapters are loaded dynamically inside
19
+ * `getAdapter()`), so a static import is safe — but we use `createRequire`
20
+ * to make the dependency direction crystal clear and to avoid surprising
21
+ * future maintainers who add eager adapter imports to detect.ts.
22
+ *
23
+ * The returned array is deduplicated and order-stable: adapter-specific path
24
+ * first (most specific), claude global second (fallback).
25
+ */
26
+ export declare function resolveAdapterGlobalSettingsPaths(env?: NodeJS.ProcessEnv): string[];