sweet-search 2.6.16 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +7 -3
  2. package/core/graph/structural-context-format.js +5 -0
  3. package/core/graph/structural-context.js +13 -3
  4. package/core/infrastructure/code-graph-repository.js +58 -0
  5. package/core/infrastructure/language-patterns/registry-core.js +14 -0
  6. package/core/infrastructure/model-fetcher.js +7 -0
  7. package/core/infrastructure/structural-context-repository.js +73 -7
  8. package/core/infrastructure/structural-context-utils.js +11 -0
  9. package/core/infrastructure/structural-qualified-resolution.js +29 -0
  10. package/core/infrastructure/tree-sitter-provider.js +77 -3
  11. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt-mcp.md +12 -6
  12. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +4 -2
  13. package/core/search/agent-pack-completion.js +493 -0
  14. package/core/search/agent-span-client.js +87 -0
  15. package/core/search/agent-span-ledger.js +447 -0
  16. package/core/search/context-expander.js +29 -0
  17. package/core/search/grep-output-shaping.js +28 -0
  18. package/core/search/regex-dialect.js +328 -0
  19. package/core/search/search-format.js +96 -0
  20. package/core/search/search-pattern.js +62 -10
  21. package/core/search/search-read.js +57 -10
  22. package/core/search/search-server.js +375 -7
  23. package/core/search/session-daemon-prewarm.mjs +75 -0
  24. package/core/search/sweet-search.js +7 -1
  25. package/core/search/unread-symbol-ranking.js +90 -0
  26. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +249 -45
  27. package/mcp/read-tool.js +34 -3
  28. package/mcp/server.js +55 -9
  29. package/mcp/tool-handlers.js +60 -7
  30. package/package.json +9 -9
  31. package/scripts/init.js +30 -5
  32. package/scripts/inject-agent-instructions.js +4 -2
  33. package/scripts/uninstall.js +21 -19
package/mcp/server.js CHANGED
@@ -12,6 +12,12 @@ import { existsSync, statSync, readFileSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { launchMaintainer } from '../core/indexing/maintainer-launcher.mjs';
15
+ import {
16
+ collectSemanticShownSpans,
17
+ exactRereadOmissionEnabled,
18
+ validAgentSessionId,
19
+ } from '../core/search/agent-span-ledger.js';
20
+ import { sendAgentSpanOperation } from '../core/search/agent-span-client.js';
15
21
 
16
22
  import {
17
23
  SearchOutputSchema,
@@ -104,13 +110,26 @@ async function getConfig() {
104
110
 
105
111
  const coreDir = path.join(__dirname, '..', 'core');
106
112
 
107
- const searchDeps = { getSearcher };
113
+ const searchDeps = { getSearcher, PROJECT_ROOT };
108
114
  const traceDeps = { PROJECT_ROOT };
109
115
  const indexDeps = { PROJECT_ROOT, coreDir };
110
116
  const healthDeps = { getConfig, PROJECT_ROOT };
111
117
  const repoMapDeps = { coreDir, PROJECT_ROOT };
112
118
  const vocabDeps = { coreDir };
113
119
 
120
+ function requestAgentSessionId(extra) {
121
+ // A long-lived MCP process does not receive later shell-environment rotation
122
+ // after clear/compact. Never fall back to inherited process session ids.
123
+ return validAgentSessionId(extra?.sessionId) ? extra.sessionId : null;
124
+ }
125
+
126
+ async function touchAgentCall(extra, spans = [], { query, regex } = {}) {
127
+ if (!exactRereadOmissionEnabled()) return;
128
+ const sessionId = requestAgentSessionId(extra);
129
+ if (!sessionId) return;
130
+ await sendAgentSpanOperation({ operation: 'observe', sessionId, spans, query, regex });
131
+ }
132
+
114
133
  // ---------------------------------------------------------------------------
115
134
  // MCP Server
116
135
  // ---------------------------------------------------------------------------
@@ -171,7 +190,10 @@ server.registerTool('search', {
171
190
  idempotentHint: true,
172
191
  openWorldHint: false,
173
192
  },
174
- }, async (args) => handleSearch(args, searchDeps));
193
+ }, async (args, extra) => handleSearch(args, {
194
+ ...searchDeps,
195
+ agentSessionId: requestAgentSessionId(extra),
196
+ }));
175
197
 
176
198
  server.registerTool('trace', {
177
199
  description: 'Trace callers, callees, and transitive impact paths for one specific symbol — returns a single structural-context package adapted to the token budget. USE WHEN the question is "who calls X", "what does X depend on", or "what would break if I changed X". For general code discovery use `search` instead; for navigation around an unfamiliar repo use `repo-map` first.',
@@ -194,7 +216,12 @@ server.registerTool('trace', {
194
216
  idempotentHint: true,
195
217
  openWorldHint: false,
196
218
  },
197
- }, async (args) => handleTrace(args, traceDeps));
219
+ }, async (args, extra) => {
220
+ await touchAgentCall(extra, [], {
221
+ query: `${args.symbol} ${args.query || ''}`.trim(),
222
+ });
223
+ return handleTrace(args, traceDeps);
224
+ });
198
225
 
199
226
  server.registerTool('index', {
200
227
  description: 'Index or re-index the codebase. USE BEFORE first search if the project has not been indexed yet, or after large source changes (`mode="full"`). The Claude Code SessionStart hook installed by `sweet-search init` keeps the incremental index fresh during normal sessions, so manual re-indexing is rarely needed.',
@@ -209,7 +236,10 @@ server.registerTool('index', {
209
236
  idempotentHint: false,
210
237
  openWorldHint: false,
211
238
  },
212
- }, async (args) => handleIndex(args, indexDeps));
239
+ }, async (args, extra) => {
240
+ await touchAgentCall(extra);
241
+ return handleIndex(args, indexDeps);
242
+ });
213
243
 
214
244
  server.registerTool('health', {
215
245
  description: 'Check health of every sweet-search subsystem (index, embedding model, late-interaction reranker, structural graph, daemon). USE WHEN searches return empty unexpectedly, results look stale, or latency is unusual — diagnoses missing index, model load failures, daemon issues. Read-only, fast.',
@@ -220,7 +250,8 @@ server.registerTool('health', {
220
250
  idempotentHint: true,
221
251
  openWorldHint: false,
222
252
  },
223
- }, async () => {
253
+ }, async (_args, extra) => {
254
+ await touchAgentCall(extra);
224
255
  const structured = await checkHealth(healthDeps);
225
256
 
226
257
  const statusLines = Object.entries(structured.subsystems).map(
@@ -251,7 +282,10 @@ server.registerTool('repo-map', {
251
282
  idempotentHint: true,
252
283
  openWorldHint: false,
253
284
  },
254
- }, async (args) => handleRepoMap(args, repoMapDeps));
285
+ }, async (args, extra) => {
286
+ await touchAgentCall(extra);
287
+ return handleRepoMap(args, repoMapDeps);
288
+ });
255
289
 
256
290
  server.registerTool('vocab-prewarm', {
257
291
  description: 'Pre-warm sweet-search caches by mining the codebase for project-specific vocabulary across lexical / semantic / hybrid modes. USE ONCE after a fresh index to make the first batch of searches faster; generally not needed for one-off queries because the daemon-prewarm hook handles cold-start warmup automatically.',
@@ -272,7 +306,10 @@ server.registerTool('vocab-prewarm', {
272
306
  idempotentHint: true,
273
307
  openWorldHint: true,
274
308
  },
275
- }, async (args) => handleVocabPrewarm(args, vocabDeps));
309
+ }, async (args, extra) => {
310
+ await touchAgentCall(extra);
311
+ return handleVocabPrewarm(args, vocabDeps);
312
+ });
276
313
 
277
314
  server.registerTool('read', {
278
315
  description: 'Read 1-20 files (with optional line ranges) for exact code understanding. USE INSTEAD OF the native Read tool for code-discovery reads — batches multiple files in one call, attaches symbol-aware chunk metadata when the file is indexed, and returns the exact bytes from disk. Native Read remains fine for files you are about to Edit (Edit needs a prior Read of that exact file).',
@@ -284,10 +321,14 @@ server.registerTool('read', {
284
321
  })).min(1).max(20).describe('Files to read (1-20)'),
285
322
  includeMetadata: z.boolean().default(true).optional()
286
323
  .describe('Attach symbol-aware chunk metadata when the file is indexed'),
324
+ force: z.boolean().default(false).optional(),
287
325
  },
288
326
  outputSchema: ReadOutputSchema,
289
327
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
290
- }, async (args) => handleRead(args, { PROJECT_ROOT }));
328
+ }, async (args, extra) => handleRead(args, {
329
+ PROJECT_ROOT,
330
+ agentSessionId: requestAgentSessionId(extra),
331
+ }));
291
332
 
292
333
  server.registerTool('read-semantic', {
293
334
  description: 'Read only the spans of a file relevant to a question. USE WHEN you know the file but the relevant span is unclear — selects spans via hybrid retrieval (lexical + symbol + ColBERT MaxSim, RRF-fused and LI-reranked), then re-reads exact lines from disk. Returns 1-N small spans instead of the full file. Avoid running this on multiple files unless the task is explicitly multi-file — call `search` with the question instead. Falls back to a plain read when the file is not indexed.',
@@ -309,7 +350,12 @@ server.registerTool('read-semantic', {
309
350
  },
310
351
  outputSchema: ReadSemanticOutputSchema,
311
352
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
312
- }, async (args) => handleReadSemantic(args, { PROJECT_ROOT }));
353
+ }, async (args, extra) => {
354
+ const result = await handleReadSemantic(args, { PROJECT_ROOT });
355
+ const spans = collectSemanticShownSpans(result.structuredContent, { projectRoot: PROJECT_ROOT });
356
+ await touchAgentCall(extra, spans, { query: args.query });
357
+ return result;
358
+ });
313
359
 
314
360
  // ---------------------------------------------------------------------------
315
361
  // Resources
@@ -1,15 +1,15 @@
1
1
  // mcp/tool-handlers.js — Extracted tool handler functions (SOLID refactor)
2
2
  // Each handler receives its dependencies via a `deps` parameter.
3
-
4
3
  import { z } from 'zod';
5
4
  import { existsSync } from 'node:fs';
6
5
  import { spawn } from 'node:child_process';
7
6
  import path from 'node:path';
8
-
7
+ import { collectAgentShownSpans, exactRereadOmissionEnabled, renderShownFullTrailer, shownSpanTrailerEnabled } from '../core/search/agent-span-ledger.js';
8
+ import { sendAgentSpanOperation } from '../core/search/agent-span-client.js';
9
+ import { renderRegexDialectHint } from '../core/search/regex-dialect.js';
9
10
  // ---------------------------------------------------------------------------
10
11
  // Output schemas (Zod — SDK converts to JSON Schema for clients)
11
12
  // ---------------------------------------------------------------------------
12
-
13
13
  const SearchResultSchema = z.object({
14
14
  file: z.string(),
15
15
  score: z.number(),
@@ -25,6 +25,22 @@ const SearchResultSchema = z.object({
25
25
  presentation: z.enum(['full', 'preview', 'summary']).optional(),
26
26
  code: z.string().nullable().optional(),
27
27
  codeTokens: z.number().int().optional(),
28
+ continuation: z.object({
29
+ kind: z.enum(['symbol', 'trailer']),
30
+ file: z.string(),
31
+ startLine: z.number().int(),
32
+ endLine: z.number().int(),
33
+ symbol: z.string(),
34
+ symbolType: z.string().nullable().optional(),
35
+ code: z.string().nullable().optional(),
36
+ rendered: z.string(),
37
+ tokens: z.number().int(),
38
+ }).optional(),
39
+ familyManifest: z.object({
40
+ rendered: z.string(),
41
+ tokens: z.number().int(),
42
+ groups: z.array(z.string()),
43
+ }).optional(),
28
44
  });
29
45
 
30
46
  export const SearchOutputSchema = z.object({
@@ -102,10 +118,11 @@ let _healthDb = null;
102
118
 
103
119
  /**
104
120
  * @param {{ query: string, k: number, mode: string, structural?: boolean, regex?: string, format?: string, tokenBudget?: number }} args
105
- * @param {{ getSearcher: () => Promise<object> }} deps
121
+ * @param {{ getSearcher: () => Promise<object>, PROJECT_ROOT?: string, agentSessionId?: string }} deps
106
122
  */
107
- export async function handleSearch({ query, k, mode, structural, regex, format, tokenBudget }, { getSearcher }) {
123
+ export async function handleSearch({ query, k, mode, structural, regex, format, tokenBudget }, deps) {
108
124
  try {
125
+ const { getSearcher } = deps;
109
126
  const searcher = await getSearcher();
110
127
  const searchMode = structural ? 'structural' : mode;
111
128
  const searchResult = await searcher.search(query, {
@@ -116,11 +133,25 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
116
133
  ...(regex && { regex }),
117
134
  ...(format && { format }),
118
135
  ...(tokenBudget && { tokenBudget }),
136
+ _isAgentFormat: format === 'agent',
119
137
  });
120
138
 
121
139
  // Agent mode: return the fully packaged response directly
122
140
  if (searchResult.format === 'agent') {
123
141
  const agentResults = searchResult.results || [];
142
+ const exactReread = exactRereadOmissionEnabled() && deps.agentSessionId;
143
+ const shownTrailerEnabled = shownSpanTrailerEnabled();
144
+ const shownSpans = (exactReread || shownTrailerEnabled)
145
+ ? collectAgentShownSpans(agentResults, { projectRoot: deps.PROJECT_ROOT }) : [];
146
+ if (exactReread) {
147
+ await sendAgentSpanOperation({
148
+ operation: 'observe',
149
+ sessionId: deps.agentSessionId,
150
+ spans: shownSpans,
151
+ query,
152
+ regex,
153
+ });
154
+ }
124
155
  const lines = agentResults
125
156
  .filter(r => r.presentation !== 'summary')
126
157
  .map((r) => {
@@ -134,7 +165,14 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
134
165
  .map(n => `${n.name} (${n.type === 'function' ? 'fn' : (n.type || 'sym')} ${n.startLine}-${n.endLine} ${n.position})`)
135
166
  .join(' · ')} — sweep: read-semantic ${r.file}`
136
167
  : '';
137
- return header + symbolInfo + code + sameFile;
168
+ const continuation = r.continuation?.rendered
169
+ ? `\n ${r.continuation.rendered.replace(/^#\s*/, '')}`
170
+ + (r.continuation.kind === 'symbol' && r.continuation.code
171
+ ? `\n\`\`\`\n${r.continuation.code}\n\`\`\`` : '')
172
+ : '';
173
+ const family = r.familyManifest?.rendered
174
+ ? `\n ${r.familyManifest.rendered.replace(/^#\s*/, '')}` : '';
175
+ return header + symbolInfo + code + sameFile + continuation + family;
138
176
  });
139
177
 
140
178
  const summaries = agentResults
@@ -144,9 +182,13 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
144
182
  const confidence = `Confidence: ${searchResult.confidence} (${searchResult.confidenceReason})`;
145
183
  const budget = `Tokens: ${searchResult.tokensUsed}/${searchResult.tokenBudget}`;
146
184
 
147
- const text = lines.length > 0
185
+ let text = lines.length > 0
148
186
  ? `${confidence} | ${budget}\n\n${lines.join('\n\n')}${summaries.length ? '\n\nAlso found:\n' + summaries.join('\n') : ''}`
149
187
  : `No results found for "${query}"`;
188
+ const regexDialectNote = renderRegexDialectHint(searchResult.stats?.regexDialectHint);
189
+ if (regexDialectNote) text += `\n\n${regexDialectNote}`;
190
+ const shownTrailer = shownTrailerEnabled ? renderShownFullTrailer(shownSpans) : '';
191
+ if (shownTrailer) text += `\n\n${shownTrailer}`;
150
192
 
151
193
  // Shape structuredContent to conform to SearchOutputSchema
152
194
  const structured = {
@@ -161,6 +203,14 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
161
203
  presentation: r.presentation,
162
204
  code: r.code ?? null,
163
205
  codeTokens: r.codeTokens,
206
+ ...(r.continuation ? { continuation: r.continuation } : {}),
207
+ ...(r.familyManifest ? {
208
+ familyManifest: {
209
+ rendered: r.familyManifest.rendered,
210
+ tokens: r.familyManifest.tokens,
211
+ groups: r.familyManifest.groups,
212
+ },
213
+ } : {}),
164
214
  })),
165
215
  totalFound: searchResult.totalResults,
166
216
  mode: searchResult.mode,
@@ -189,6 +239,9 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
189
239
  }
190
240
 
191
241
  // Standard benchmark mode
242
+ if (exactRereadOmissionEnabled() && deps.agentSessionId) {
243
+ await sendAgentSpanOperation({ operation: 'observe', sessionId: deps.agentSessionId, spans: [] });
244
+ }
192
245
  const { results, stats } = searchResult;
193
246
  const structured = {
194
247
  format: 'benchmark',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sweet-search",
3
- "version": "2.6.16",
4
- "description": "Sweet Search - SOTA Hybrid Code Search Engine with WASM CatBoost Query Router, Semantic/Lexical/Structural Search, and Multilingual Support",
3
+ "version": "2.7.0",
4
+ "description": "Local code search for AI agents: six fast, purpose-built tools that return ranked answers, not raw grep. Because maybe grep isn't all you need... 🍬",
5
5
  "type": "module",
6
6
  "main": "core/search/sweet-search.js",
7
7
  "exports": {
@@ -167,13 +167,13 @@
167
167
  "vitest": "^4.0.16"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@sweet-search/native-darwin-arm64": "2.6.16",
171
- "@sweet-search/native-darwin-x64": "2.6.16",
172
- "@sweet-search/native-linux-arm64-gnu": "2.6.16",
173
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.16",
174
- "@sweet-search/native-linux-x64-gnu": "2.6.16",
175
- "@sweet-search/native-linux-x64-gnu-cuda": "2.6.16",
176
- "@sweet-search/bg-priority": "2.6.16"
170
+ "@sweet-search/native-darwin-arm64": "2.7.0",
171
+ "@sweet-search/native-darwin-x64": "2.7.0",
172
+ "@sweet-search/native-linux-arm64-gnu": "2.7.0",
173
+ "@sweet-search/native-linux-arm64-gnu-cuda": "2.7.0",
174
+ "@sweet-search/native-linux-x64-gnu": "2.7.0",
175
+ "@sweet-search/native-linux-x64-gnu-cuda": "2.7.0",
176
+ "@sweet-search/bg-priority": "2.7.0"
177
177
  },
178
178
  "engines": {
179
179
  "node": ">=18.0.0"
package/scripts/init.js CHANGED
@@ -994,7 +994,8 @@ export function registerPrewarmSessionStartHook({
994
994
  };
995
995
  }
996
996
 
997
- const command = `node ${hookPath}`;
997
+ const command = `node ${hookPath} --agent-session-hook`;
998
+ const dropCommand = `node ${hookPath} --agent-session-drop`;
998
999
 
999
1000
  const settingsDir = join(projectRoot, '.claude');
1000
1001
  const settingsPath = join(settingsDir, 'settings.json');
@@ -1012,6 +1013,7 @@ export function registerPrewarmSessionStartHook({
1012
1013
  const sessionStart = Array.isArray(settings.hooks.SessionStart) ? settings.hooks.SessionStart : [];
1013
1014
 
1014
1015
  const entry = {
1016
+ matcher: 'startup|resume|clear|compact',
1015
1017
  hooks: [
1016
1018
  {
1017
1019
  type: 'command',
@@ -1035,6 +1037,29 @@ export function registerPrewarmSessionStartHook({
1035
1037
  }
1036
1038
  settings.hooks.SessionStart = sessionStart;
1037
1039
 
1040
+ // Claude exposes a real SessionEnd event. Drop the bounded RAM receipt
1041
+ // bucket immediately instead of waiting for daemon/session LRU eviction.
1042
+ const sessionEnd = Array.isArray(settings.hooks.SessionEnd) ? settings.hooks.SessionEnd : [];
1043
+ const endEntry = {
1044
+ hooks: [
1045
+ {
1046
+ type: 'command',
1047
+ command: dropCommand,
1048
+ timeout: 2000,
1049
+ continueOnError: true,
1050
+ },
1051
+ ],
1052
+ };
1053
+ const ownedEndIdx = sessionEnd.findIndex((group) =>
1054
+ Array.isArray(group?.hooks) &&
1055
+ group.hooks.some((h) => typeof h?.command === 'string'
1056
+ && h.command.includes(PREWARM_HOOK_FILENAME)
1057
+ && h.command.includes('--agent-session-drop'))
1058
+ );
1059
+ if (ownedEndIdx >= 0) sessionEnd[ownedEndIdx] = endEntry;
1060
+ else sessionEnd.push(endEntry);
1061
+ settings.hooks.SessionEnd = sessionEnd;
1062
+
1038
1063
  try {
1039
1064
  mkdirSync(settingsDir, { recursive: true });
1040
1065
  const tmpPath = settingsPath + '.tmp';
@@ -1103,7 +1128,7 @@ export function registerCodexSessionStartHook({ projectRoot, packageRoot, skippe
1103
1128
  // a repo), which fixes both path resolution AND the launcher's own
1104
1129
  // `process.cwd()` project-root detection, while staying machine-portable
1105
1130
  // (no absolute path is written into the often-committed hooks.json).
1106
- const command = `cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && node ${JSON.stringify(hookPath)}`;
1131
+ const command = `cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && node ${JSON.stringify(hookPath)} --agent-session-hook`;
1107
1132
  const codexDir = join(projectRoot, '.codex');
1108
1133
  const hooksPath = join(codexDir, CODEX_HOOKS_FILENAME);
1109
1134
 
@@ -1119,12 +1144,12 @@ export function registerCodexSessionStartHook({ projectRoot, packageRoot, skippe
1119
1144
  doc.hooks = doc.hooks || {};
1120
1145
  const sessionStart = Array.isArray(doc.hooks.SessionStart) ? doc.hooks.SessionStart : [];
1121
1146
 
1122
- // `matcher` mirrors the official Codex SessionStart example: fire on a new
1123
- // session and on resume (the cases where the daemon may not be running yet).
1147
+ // Start/resume prewarm the daemon; clear/compact also reset shown-span
1148
+ // receipts for the new context epoch.
1124
1149
  // `timeout` is in seconds (per Codex's hooks docs); the launcher detaches and
1125
1150
  // returns in well under a second, so a small budget is plenty.
1126
1151
  const entry = {
1127
- matcher: 'startup|resume',
1152
+ matcher: 'startup|resume|clear|compact',
1128
1153
  hooks: [
1129
1154
  {
1130
1155
  type: 'command',
@@ -54,8 +54,10 @@ function escapeRegex(s) {
54
54
  // champion (PHASE7 M++ — held-out 0.988 Maximin, OOD 0.952, HOMP/SCS/counter
55
55
  // all pass, 5-cell cross-harness validated — plus the stop-discipline scoping
56
56
  // edits that fix M++'s over-stopping on FIX tasks, plus the verdict-gated
57
- // trust line that scans already-delivered lower ranks before any new search;
58
- // tool routing unchanged), injected VERBATIM into the harness files.
57
+ // trust line that scans already-delivered lower ranks before any new search,
58
+ // plus the P2 fix-surface paragraph that maps a multi-site edit surface in
59
+ // ONE call before the first edit; tool routing unchanged), injected VERBATIM
60
+ // into the harness files.
59
61
  // Per-harness shims (Cursor frontmatter, @imports) are applied at write-time,
60
62
  // not embedded here.
61
63
  //
@@ -406,7 +406,7 @@ function pruneEmptyAncestors(start, stopAt) {
406
406
  }
407
407
 
408
408
  /**
409
- * Remove the sweet-search-owned SessionStart entry from `.claude/settings.json`,
409
+ * Remove the sweet-search-owned SessionStart and SessionEnd entries from `.claude/settings.json`,
410
410
  * preserving every other hook, permission, and top-level key. Detection is
411
411
  * filename-based (see PREWARM_HOOK_FILENAME) — only entries whose command
412
412
  * references the sweet-search preheat script are removed.
@@ -437,31 +437,33 @@ export function removePrewarmSessionStartHook(projectRoot, { dryRun = false } =
437
437
  return { status: 'error', detail: `settings.json is not valid JSON: ${err.message}` };
438
438
  }
439
439
 
440
- const sessionStart = settings?.hooks?.SessionStart;
441
- if (!Array.isArray(sessionStart) || sessionStart.length === 0) {
442
- return { status: 'not-found', detail: 'no SessionStart entries' };
440
+ const changes = [];
441
+ for (const event of ['SessionStart', 'SessionEnd']) {
442
+ const groups = settings?.hooks?.[event];
443
+ if (!Array.isArray(groups)) continue;
444
+ const filtered = groups.filter((group) =>
445
+ !(Array.isArray(group?.hooks) &&
446
+ group.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(PREWARM_HOOK_FILENAME)))
447
+ );
448
+ if (filtered.length !== groups.length) changes.push({ event, groups, filtered });
443
449
  }
444
450
 
445
- const filtered = sessionStart.filter((group) =>
446
- !(Array.isArray(group?.hooks) &&
447
- group.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(PREWARM_HOOK_FILENAME)))
448
- );
449
-
450
- if (filtered.length === sessionStart.length) {
451
+ if (changes.length === 0) {
451
452
  return { status: 'not-found', detail: 'no matching entry' };
452
453
  }
453
454
 
455
+ const removedCount = changes.reduce((sum, change) => sum + change.groups.length - change.filtered.length, 0);
456
+
454
457
  if (dryRun) {
455
- return { status: 'dry-run', detail: `would remove ${sessionStart.length - filtered.length} entry` };
458
+ return { status: 'dry-run', detail: `would remove ${removedCount} entries` };
456
459
  }
457
460
 
458
- if (filtered.length === 0) {
459
- delete settings.hooks.SessionStart;
460
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
461
- delete settings.hooks;
462
- }
463
- } else {
464
- settings.hooks.SessionStart = filtered;
461
+ for (const { event, filtered } of changes) {
462
+ if (filtered.length === 0) delete settings.hooks[event];
463
+ else settings.hooks[event] = filtered;
464
+ }
465
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
466
+ delete settings.hooks;
465
467
  }
466
468
 
467
469
  try {
@@ -472,7 +474,7 @@ export function removePrewarmSessionStartHook(projectRoot, { dryRun = false } =
472
474
  return { status: 'error', detail: `write failed: ${err.message}` };
473
475
  }
474
476
 
475
- return { status: 'removed', detail: `spliced out ${sessionStart.length - filtered.length} entry` };
477
+ return { status: 'removed', detail: `spliced out ${removedCount} entries` };
476
478
  }
477
479
 
478
480
  /**