sweet-search 2.6.17 → 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.
@@ -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.17",
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.17",
171
- "@sweet-search/native-darwin-x64": "2.6.17",
172
- "@sweet-search/native-linux-arm64-gnu": "2.6.17",
173
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.17",
174
- "@sweet-search/native-linux-x64-gnu": "2.6.17",
175
- "@sweet-search/native-linux-x64-gnu-cuda": "2.6.17",
176
- "@sweet-search/bg-priority": "2.6.17"
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',
@@ -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
  /**