sift-light 1.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.
Files changed (145) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/CONTRIBUTING.md +19 -0
  3. package/LICENSE +661 -0
  4. package/README.md +177 -0
  5. package/README.zh-CN.md +177 -0
  6. package/SECURITY.md +25 -0
  7. package/package.json +142 -0
  8. package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
  9. package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
  10. package/plugins/sift-light/.mcp.json +24 -0
  11. package/plugins/sift-light/LICENSE +661 -0
  12. package/plugins/sift-light/concept-worker.mjs +567 -0
  13. package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
  14. package/plugins/sift-light/hooks/hooks.json +16 -0
  15. package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
  16. package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
  17. package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
  18. package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
  19. package/plugins/sift-light/kimi.plugin.json +36 -0
  20. package/plugins/sift-light/mcp-server.mjs +1 -0
  21. package/plugins/sift-light/omp-extension.mjs +20914 -0
  22. package/plugins/sift-light/package.json +11 -0
  23. package/plugins/sift-light/syntax-worker.mjs +280 -0
  24. package/plugins/sift-light/syntax-worker.toml +2 -0
  25. package/src/analysis-evidence.ts +43 -0
  26. package/src/analysis-limits.ts +27 -0
  27. package/src/analysis-store.ts +559 -0
  28. package/src/analysis-term-pages.ts +73 -0
  29. package/src/analysis-types.ts +162 -0
  30. package/src/audit-search.ts +391 -0
  31. package/src/capped-lines.ts +112 -0
  32. package/src/concept-embedding-cache.ts +186 -0
  33. package/src/concept-inference.ts +218 -0
  34. package/src/concept-model.ts +96 -0
  35. package/src/concept-search.ts +577 -0
  36. package/src/concept-setup.ts +75 -0
  37. package/src/concept-source-generation.ts +268 -0
  38. package/src/concept-worker.mjs +567 -0
  39. package/src/concept-worker.ts +210 -0
  40. package/src/config-reader.ts +283 -0
  41. package/src/config.ts +37 -0
  42. package/src/context-budget.ts +26 -0
  43. package/src/discovery-errors.ts +2 -0
  44. package/src/errors.ts +95 -0
  45. package/src/evidence-candidates.ts +344 -0
  46. package/src/evidence-ranking.ts +48 -0
  47. package/src/evidence-service.ts +1047 -0
  48. package/src/evidence-validation.ts +466 -0
  49. package/src/evidence-validity.ts +33 -0
  50. package/src/excerpt.ts +53 -0
  51. package/src/file-discovery.ts +150 -0
  52. package/src/file-metadata-filter.ts +46 -0
  53. package/src/format.ts +407 -0
  54. package/src/git-diff.ts +195 -0
  55. package/src/git-process.ts +189 -0
  56. package/src/git-repository.ts +367 -0
  57. package/src/git-source.ts +385 -0
  58. package/src/historical-paths.ts +205 -0
  59. package/src/hybrid-search.ts +340 -0
  60. package/src/import-model.ts +611 -0
  61. package/src/import-navigation.ts +100 -0
  62. package/src/import-resolution.ts +240 -0
  63. package/src/index.ts +97 -0
  64. package/src/inspect-batch.ts +265 -0
  65. package/src/inspect.ts +271 -0
  66. package/src/language-capabilities.ts +157 -0
  67. package/src/language-capability-definitions.ts +204 -0
  68. package/src/literal-search.ts +25 -0
  69. package/src/mcp-cli.ts +21 -0
  70. package/src/mcp-model-output.ts +120 -0
  71. package/src/mcp-output.ts +9 -0
  72. package/src/mcp-semantic-judge.ts +39 -0
  73. package/src/mcp-server.mjs +14064 -0
  74. package/src/mcp-server.ts +157 -0
  75. package/src/mcp-stdio.ts +114 -0
  76. package/src/mcp.ts +631 -0
  77. package/src/model-error.ts +120 -0
  78. package/src/multi-term-search.ts +122 -0
  79. package/src/omp-index.ts +277 -0
  80. package/src/omp-marketplace-entry.ts +7 -0
  81. package/src/operation-lifecycle.ts +413 -0
  82. package/src/operation-output.ts +100 -0
  83. package/src/owned-parallel.ts +22 -0
  84. package/src/owned-process.ts +102 -0
  85. package/src/owned-task-queue.ts +38 -0
  86. package/src/package-version.ts +3 -0
  87. package/src/path-policy.ts +203 -0
  88. package/src/pi-search-policy.ts +33 -0
  89. package/src/prompt-guidelines.ts +54 -0
  90. package/src/python-outline.ts +231 -0
  91. package/src/record-value.ts +3 -0
  92. package/src/redaction.ts +95 -0
  93. package/src/request-contract-catalog.ts +215 -0
  94. package/src/request-contract-error.ts +96 -0
  95. package/src/request-contract.ts +481 -0
  96. package/src/request.ts +132 -0
  97. package/src/result-statistics.ts +178 -0
  98. package/src/rg.ts +574 -0
  99. package/src/ripgrep-diagnostics.ts +122 -0
  100. package/src/ripgrep-executable.ts +43 -0
  101. package/src/runtime.ts +53 -0
  102. package/src/scan-revisions.ts +148 -0
  103. package/src/script-runtime.ts +11 -0
  104. package/src/search-policy-commands.ts +170 -0
  105. package/src/search-policy-hook.ts +61 -0
  106. package/src/search-policy-recovery.ts +388 -0
  107. package/src/search-policy-shell.ts +280 -0
  108. package/src/search-policy.ts +102 -0
  109. package/src/search-retention.ts +77 -0
  110. package/src/semantic-judge-batches.ts +135 -0
  111. package/src/semantic-judge.ts +519 -0
  112. package/src/service.ts +945 -0
  113. package/src/session-summary.ts +100 -0
  114. package/src/snapshot-store.ts +203 -0
  115. package/src/source-access.ts +288 -0
  116. package/src/source-continuations.ts +168 -0
  117. package/src/source-document.ts +277 -0
  118. package/src/source-inspection.ts +731 -0
  119. package/src/source-pages.ts +112 -0
  120. package/src/source.ts +286 -0
  121. package/src/structural-search.ts +125 -0
  122. package/src/structure.ts +259 -0
  123. package/src/summary-previews.ts +96 -0
  124. package/src/summary.ts +79 -0
  125. package/src/syntax-facts.ts +464 -0
  126. package/src/syntax-fields.ts +161 -0
  127. package/src/syntax-search.ts +361 -0
  128. package/src/syntax-tree.ts +30 -0
  129. package/src/syntax-types.ts +72 -0
  130. package/src/syntax-worker.mjs +280 -0
  131. package/src/syntax-worker.toml +2 -0
  132. package/src/syntax-worker.ts +143 -0
  133. package/src/syntax.ts +259 -0
  134. package/src/test-navigation-facts.ts +261 -0
  135. package/src/test-navigation.ts +543 -0
  136. package/src/tool-schema.ts +321 -0
  137. package/src/tui/dashboard.ts +107 -0
  138. package/src/tui/layout.ts +291 -0
  139. package/src/tui/palette.ts +73 -0
  140. package/src/tui/presentation.ts +240 -0
  141. package/src/tui/renderers.ts +106 -0
  142. package/src/types.ts +368 -0
  143. package/src/validation-output.ts +148 -0
  144. package/src/validation-types.ts +48 -0
  145. package/src/workspace-files.ts +142 -0
package/src/service.ts ADDED
@@ -0,0 +1,945 @@
1
+ import { createHash } from "node:crypto";
2
+ import { EvidenceService, isEvidenceRequest } from "./evidence-service.js";
3
+ import { ConceptSourceChangedError } from "./concept-source-generation.js";
4
+ import type { GitChangeRequest } from "./git-source.js";
5
+ import type { SyntaxRoleName } from "./syntax.js";
6
+ import { resolve } from "node:path";
7
+ import { CursorError, SiftLightError } from "./errors.js";
8
+ import { DISCOVERY_MODE_REQUIRED_ERROR } from "./discovery-errors.js";
9
+ import { validateRequestContract } from "./request-contract.js";
10
+ import {
11
+ formatMatchMetadataPage,
12
+ formatMatchPage,
13
+ MatchPageSoftLimitError,
14
+ type MatchPageOptions,
15
+ } from "./format.js";
16
+ import { formatSummary } from "./summary.js";
17
+ import { summarySourcePreviews } from "./summary-previews.js";
18
+ import {
19
+ normalizeRequest,
20
+ type RawSearchInput,
21
+ validateRawSearchInput,
22
+ validateSearchPath,
23
+ } from "./request.js";
24
+ import { redactSiftLightResult } from "./redaction.js";
25
+ import type { RipgrepRunner } from "./rg.js";
26
+ import type { CodeStructureProvider } from "./structure.js";
27
+ import { ConceptWorkerExitError, type ConceptSearchRunner } from "./concept-search.js";
28
+ import type { SemanticJudgeIntegration } from "./semantic-judge.js";
29
+ import { SearchPathPolicy } from "./path-policy.js";
30
+ import { SnapshotStore } from "./snapshot-store.js";
31
+ import {
32
+ OPERATION_INITIAL_WAIT_MS,
33
+ OperationLifecycle,
34
+ type OperationProgress,
35
+ } from "./operation-lifecycle.js";
36
+ import {
37
+ completeOperationResult,
38
+ operationOutcome,
39
+ operationStateResult,
40
+ } from "./operation-output.js";
41
+ import { modificationTimeBoundsText } from "./source.js";
42
+ import { resolveConceptTimeoutMs } from "./concept-model.js";
43
+ import { runAuditSearch } from "./audit-search.js";
44
+ import {
45
+ LanguageCapabilityCatalog,
46
+ type LanguageCapabilityInventory,
47
+ } from "./language-capabilities.js";
48
+ import {
49
+ DEFAULT_SUMMARY_FILE_LIMIT,
50
+ MAX_INSPECT_TARGETS,
51
+ MAX_LINE_CHARACTERS,
52
+ MAX_SELECTED_PATHS,
53
+ type ContextBudget,
54
+ type AuditPatternInput,
55
+ type InspectTarget,
56
+ type SearchMode,
57
+ type SearchSnapshot,
58
+ type SearchScopeDetails,
59
+ type SiftLightDetails,
60
+ type SiftLightResult,
61
+ } from "./types.js";
62
+
63
+ export interface SiftLightInput extends RawSearchInput {
64
+ query?: string;
65
+ mode?: SearchMode;
66
+ cursor?: string;
67
+ paths?: string[];
68
+ matchIndex?: number;
69
+ matchIndices?: number[];
70
+ targets?: InspectTarget[];
71
+ line?: number;
72
+ sourceCursor?: string;
73
+ allOf?: string[];
74
+ anyOf?: string[];
75
+ within?: "file" | "function";
76
+ roles?: SyntaxRoleName[];
77
+ changes?: GitChangeRequest;
78
+ symbol?: string;
79
+ maxFilesToParse?: number;
80
+ conceptLimit?: number;
81
+ operationId?: string;
82
+ patterns?: AuditPatternInput[];
83
+ }
84
+
85
+ export interface SiftLightServiceOptions {
86
+ runRipgrep: RipgrepRunner;
87
+ snapshots?: SnapshotStore;
88
+ summaryFileLimit?: number;
89
+ structure?: CodeStructureProvider;
90
+ conceptSearch?: ConceptSearchRunner;
91
+ semanticJudge?: SemanticJudgeIntegration;
92
+ }
93
+
94
+ export interface SiftLightSearchOptions {
95
+ contextBudget?: ContextBudget;
96
+ modelOutput?: boolean;
97
+ modelSource?: boolean;
98
+ onProgress?: (progress: OperationProgress) => void;
99
+ }
100
+
101
+ function filterList(value: string | string[] | undefined): string[] {
102
+ return value === undefined ? [] : Array.isArray(value) ? [...value] : [value];
103
+ }
104
+
105
+ function capabilitiesResult(inventory: LanguageCapabilityInventory): SiftLightResult {
106
+ const languageText = inventory.languages.length
107
+ ? inventory.languages
108
+ .map((entry) => {
109
+ const providers = [
110
+ ...new Set(
111
+ inventory.capabilityDefinitions
112
+ .filter((capability) =>
113
+ entry.capabilities.some((reference) => reference.id === capability.id),
114
+ )
115
+ .map((capability) => capability.provider),
116
+ ),
117
+ ].join(" + ");
118
+ const load = entry.capabilities.length ? "lazy" : "none";
119
+ const names = entry.capabilities.map((reference) => reference.name).join(", ");
120
+ return `${entry.language} (${String(entry.files)} file${entry.files === 1 ? "" : "s"}): ${names || "no language analysis capability is currently registered"}; providers=${providers || "none"}; availability=${entry.availability}; readiness=${entry.readiness}; load=${load}`;
121
+ })
122
+ .join("\n")
123
+ : "No recognized language source files were found in the requested scope.";
124
+ const partial = inventory.partial ? "partial" : "complete";
125
+ const reason = inventory.reasons.length ? `\nReasons: ${inventory.reasons.join("; ")}` : "";
126
+ const text = `Project language capability inventory (${partial}; names-only; providers load lazily).\nLanguage-specific modes:\n${languageText}\nLanguage-neutral modes: ${inventory.neutral.map((capability) => `${capability.name} [${capability.availability}; ${capability.load}]`).join(", ")}.${reason}`;
127
+ return {
128
+ text,
129
+ details: {
130
+ version: 1,
131
+ mode: "capabilities",
132
+ status: inventory.partial ? "partial" : "complete",
133
+ totalMatches: 0,
134
+ storedMatches: 0,
135
+ totalFiles: inventory.languages.reduce((sum, entry) => sum + entry.files, 0),
136
+ returnedMatches: 0,
137
+ snapshotComplete: !inventory.partial,
138
+ capabilities: inventory,
139
+ },
140
+ };
141
+ }
142
+ interface PathSelection {
143
+ labels: string[];
144
+ absolutePaths: Set<string>;
145
+ key: string;
146
+ }
147
+
148
+ function cursorPathSelection(input: SiftLightInput, cwd: string): PathSelection | undefined {
149
+ if (input.path !== undefined && input.paths !== undefined) {
150
+ throw new SiftLightError("Use either path or paths with a cursor, not both");
151
+ }
152
+ const rawPaths = input.paths ?? (input.path === undefined ? [] : [input.path]);
153
+ if (input.paths !== undefined && rawPaths.length === 0) {
154
+ throw new SiftLightError("paths must contain at least one retained file");
155
+ }
156
+ if (rawPaths.length === 0) return undefined;
157
+ if (rawPaths.length > MAX_SELECTED_PATHS) {
158
+ throw new SiftLightError(
159
+ `paths cannot contain more than ${String(MAX_SELECTED_PATHS)} entries`,
160
+ );
161
+ }
162
+
163
+ const labels: string[] = [];
164
+ const absolutePaths = new Set<string>();
165
+ const policy = new SearchPathPolicy(cwd);
166
+ for (const rawPath of rawPaths) {
167
+ const label = rawPath.replace(/^@/, "");
168
+ validateSearchPath(label, input.paths !== undefined ? "paths" : "path");
169
+ if (label.length === 0) throw new SiftLightError("Cursor paths cannot be empty");
170
+ const absolutePath = resolve(cwd, label);
171
+ policy.assertPath(absolutePath);
172
+ if (absolutePaths.has(absolutePath)) continue;
173
+ absolutePaths.add(absolutePath);
174
+ labels.push(label);
175
+ }
176
+ const key = createHash("sha256")
177
+ .update([...absolutePaths].toSorted((left, right) => left.localeCompare(right)).join("\0"))
178
+ .digest("hex")
179
+ .slice(0, 16);
180
+ return { labels, absolutePaths, key };
181
+ }
182
+
183
+ function baseDetails(snapshot: SearchSnapshot, mode: SearchMode): SiftLightDetails {
184
+ const sourceUnverifiedFileCount = new Set(
185
+ snapshot.matches
186
+ .filter((match) => !snapshot.sourceRevisions.has(match.absolutePath))
187
+ .map((match) => match.absolutePath),
188
+ ).size;
189
+ return {
190
+ version: 1,
191
+ mode,
192
+ status:
193
+ snapshot.snapshotComplete && (snapshot.filesystemCoverage ?? "complete") === "complete"
194
+ ? "complete"
195
+ : "partial",
196
+ totalMatches: snapshot.totalMatches,
197
+ storedMatches: snapshot.matches.length,
198
+ totalFiles: snapshot.fileCounts.size,
199
+ returnedMatches: 0,
200
+ snapshotComplete: snapshot.snapshotComplete,
201
+ searchCoverage: {
202
+ retainedMatches: snapshot.snapshotComplete ? "complete" : "partial",
203
+ filesystem: snapshot.filesystemCoverage ?? "complete",
204
+ ignoredFiles: snapshot.ignoredFileCount ?? 0,
205
+ ignoredFileSamples: [...(snapshot.ignoredFileSamples ?? [])],
206
+ searchedFiles: snapshot.searchedFileCount ?? 0,
207
+ reasons: [...(snapshot.filesystemCoverageReasons ?? [])],
208
+ },
209
+ ...(snapshot.retention ? { retention: snapshot.retention } : {}),
210
+ scope: searchScope(snapshot.request),
211
+ ...(snapshot.request.redact ? { redactionRequested: true } : {}),
212
+ ...(snapshot.truncatedLines > 0 ? { lineContentTruncated: snapshot.truncatedLines } : {}),
213
+ ...(sourceUnverifiedFileCount > 0 ? { sourceUnverifiedFileCount } : {}),
214
+ };
215
+ }
216
+
217
+ function searchScope(request: SearchSnapshot["request"]): SearchScopeDetails {
218
+ const path = request.path ?? ".";
219
+ const requestedPath = request.expandedFromPath ?? path;
220
+ return {
221
+ path,
222
+ requestedPath,
223
+ glob: [...request.glob],
224
+ exclude: [...request.exclude],
225
+ hidden: request.hidden,
226
+ ignorePolicy: request.ignorePolicy ?? "respect",
227
+ expandedToProjectRoot: request.expandedFromPath !== undefined,
228
+ assertion: path === "." ? "project-wide" : "requested-scope",
229
+ ...(request.modifiedAfterMs !== undefined ? { modifiedAfterMs: request.modifiedAfterMs } : {}),
230
+ ...(request.modifiedBeforeMs !== undefined
231
+ ? { modifiedBeforeMs: request.modifiedBeforeMs }
232
+ : {}),
233
+ };
234
+ }
235
+
236
+ function emptyResultText(details: SiftLightDetails): string {
237
+ const scope = details.scope ?? {
238
+ path: ".",
239
+ requestedPath: ".",
240
+ glob: [],
241
+ exclude: [],
242
+ hidden: true,
243
+ ignorePolicy: "respect" as const,
244
+ expandedToProjectRoot: false,
245
+ assertion: "project-wide" as const,
246
+ };
247
+ const filters =
248
+ scope.glob.length ||
249
+ scope.exclude.length ||
250
+ !scope.hidden ||
251
+ scope.modifiedAfterMs !== undefined ||
252
+ scope.modifiedBeforeMs !== undefined
253
+ ? " Include/exclude and hidden-file filters were applied."
254
+ : "";
255
+ const expansion = scope.expandedToProjectRoot
256
+ ? ` after the requested path ${JSON.stringify(scope.requestedPath)} also returned no matches`
257
+ : "";
258
+ const range = scope.assertion === "project-wide" ? "project root" : "requested path";
259
+ const coverage = details.searchCoverage;
260
+ const conclusion =
261
+ coverage?.filesystem === "complete"
262
+ ? `No matches found anywhere in ${range}`
263
+ : `No matches found among files admitted in ${range}; absence is unknown outside this coverage`;
264
+ const ignored = coverage?.ignoredFiles
265
+ ? ` ${String(coverage.ignoredFiles)} file(s) were excluded by ignore rules.`
266
+ : "";
267
+ const reasons = coverage?.reasons.length
268
+ ? ` Filesystem coverage reasons: ${coverage.reasons.map((reason) => JSON.stringify(reason)).join("; ")}.`
269
+ : "";
270
+ return `${conclusion} ${JSON.stringify(scope.path)}${expansion}.${filters}${ignored}${reasons}${modificationTimeBoundsText(scope.modifiedAfterMs, scope.modifiedBeforeMs)}`;
271
+ }
272
+
273
+ function scopeExpansionNote(scope: SearchScopeDetails | undefined, totalMatches: number): string {
274
+ if (!scope?.expandedToProjectRoot) return "";
275
+ const outcome =
276
+ totalMatches > 0
277
+ ? "returned project-wide matches"
278
+ : "the project root was also searched and had no matches";
279
+ return `\n\n[Scope expanded: requested path ${JSON.stringify(scope.requestedPath)} had no matches; ${outcome} from ${JSON.stringify(scope.path)}.]`;
280
+ }
281
+
282
+ function completenessNote(snapshot: SearchSnapshot): string {
283
+ if (snapshot.snapshotComplete && (snapshot.filesystemCoverage ?? "complete") === "complete")
284
+ return "complete retained-match and filesystem snapshot";
285
+ if (snapshot.snapshotComplete && snapshot.filesystemCoverage === "policy-filtered")
286
+ return `complete retained-match snapshot; filesystem coverage is policy-filtered because ${String(snapshot.ignoredFileCount ?? 0)} file(s) were excluded by ignore rules`;
287
+ const reasons = [
288
+ ...(snapshot.retention?.reasons ?? []),
289
+ ...(snapshot.filesystemCoverageReasons ?? []),
290
+ ].join("; ");
291
+ if (snapshot.snapshotComplete)
292
+ return `complete retained-match snapshot; PARTIAL filesystem coverage${reasons ? `: ${reasons}` : ""}`;
293
+ return `PARTIAL snapshot: retained ${snapshot.matches.length} of ${snapshot.totalMatches} matches; ${reasons ? `${reasons}; ` : ""}narrow the search to retrieve all matches`;
294
+ }
295
+
296
+ function lineExcerptNote(snapshot: SearchSnapshot): string {
297
+ return snapshot.truncatedLines > 0
298
+ ? `\n\n[Line excerpts truncated: ${String(snapshot.truncatedLines)} matching lines in this snapshot; maximum ${String(MAX_LINE_CHARACTERS)} source characters per line. Complete snapshot describes retained matches, not complete source text.]`
299
+ : "";
300
+ }
301
+
302
+ function sourceVerificationNote(details: SiftLightDetails): string {
303
+ return details.sourceUnverifiedFileCount
304
+ ? `\n\n[Source revision unverified for ${String(details.sourceUnverifiedFileCount)} retained file(s); context and snapshot-scoped inspection require verified source.]`
305
+ : "";
306
+ }
307
+ function selectContextBudget(
308
+ input: SiftLightInput,
309
+ mode: SearchMode,
310
+ candidate: ContextBudget | undefined,
311
+ ): ContextBudget | undefined {
312
+ if (mode !== "auto" || input.limit !== undefined || input.cursor) return undefined;
313
+ return candidate;
314
+ }
315
+
316
+ function matchPageOptions(budget: ContextBudget | undefined): MatchPageOptions {
317
+ if (!budget) return {};
318
+ return { resultTokenBudget: budget.resultTokenBudget };
319
+ }
320
+
321
+ function pageMatchesOccurrences(snapshot: SearchSnapshot, first: number, last: number): number {
322
+ return snapshot.matches
323
+ .slice(first, last + 1)
324
+ .reduce((total, match) => total + match.occurrences.length, 0);
325
+ }
326
+
327
+ function attachContextBudget(
328
+ result: SiftLightResult,
329
+ budget: ContextBudget | undefined,
330
+ totalMatches: number,
331
+ ): SiftLightResult {
332
+ if (!budget || totalMatches === 0) return result;
333
+ let text = result.text;
334
+ if (budget.tier !== "full") {
335
+ text = `${result.text}\n\n[Budget: ${budget.tier}; context remainder ${budget.contextRemainderPercent}%; auto detail target ${budget.resultTokenBudget} estimated tokens.]`;
336
+ }
337
+ return {
338
+ ...result,
339
+ text,
340
+ details: {
341
+ ...result.details,
342
+ budgetTier: budget.tier,
343
+ contextRemainderPercent: budget.contextRemainderPercent,
344
+ resultTokenBudget: budget.resultTokenBudget,
345
+ },
346
+ };
347
+ }
348
+
349
+ async function waitForSourceRefresh(signal: AbortSignal, delayMs: number): Promise<void> {
350
+ if (signal.aborted)
351
+ throw signal.reason instanceof Error ? signal.reason : new Error("Operation aborted");
352
+ await new Promise<void>((resolveDelay, rejectDelay) => {
353
+ const timer = setTimeout(() => {
354
+ signal.removeEventListener("abort", onAbort);
355
+ resolveDelay();
356
+ }, delayMs);
357
+ const onAbort = (): void => {
358
+ clearTimeout(timer);
359
+ signal.removeEventListener("abort", onAbort);
360
+ rejectDelay(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"));
361
+ };
362
+ signal.addEventListener("abort", onAbort, { once: true });
363
+ });
364
+ }
365
+
366
+ export class SiftLightService {
367
+ readonly #runRipgrep: RipgrepRunner;
368
+ readonly #snapshots: SnapshotStore;
369
+ readonly #summaryFileLimit: number;
370
+ readonly #capabilities = new LanguageCapabilityCatalog();
371
+ readonly #evidence: EvidenceService;
372
+ #lifecycle = new AbortController();
373
+ readonly #active = new Set<Promise<SiftLightResult>>();
374
+ #operations: OperationLifecycle<SiftLightResult>;
375
+ readonly #reusableSummarySnapshots = new WeakSet<SearchSnapshot>();
376
+
377
+ constructor(options: SiftLightServiceOptions) {
378
+ this.#runRipgrep = options.runRipgrep;
379
+ this.#snapshots = options.snapshots ?? new SnapshotStore();
380
+ this.#summaryFileLimit = options.summaryFileLimit ?? DEFAULT_SUMMARY_FILE_LIMIT;
381
+ this.#operations = new OperationLifecycle({ deadlineMs: resolveConceptTimeoutMs() });
382
+ this.#evidence = new EvidenceService(
383
+ this.#runRipgrep,
384
+ this.#snapshots,
385
+ options.structure,
386
+ options.conceptSearch,
387
+ options.semanticJudge,
388
+ );
389
+ }
390
+
391
+ async search(
392
+ input: SiftLightInput,
393
+ cwd: string,
394
+ signal?: AbortSignal,
395
+ options: SiftLightSearchOptions = {},
396
+ ): Promise<SiftLightResult> {
397
+ validateRawSearchInput(input);
398
+ validateRequestContract(input);
399
+ let request: Promise<SiftLightResult>;
400
+ if (input.mode === "await" || input.mode === "cancel") {
401
+ request = this.#operationCommand(input, cwd, signal);
402
+ } else {
403
+ if (input.operationId !== undefined)
404
+ throw new SiftLightError("operationId is only valid with mode=await or mode=cancel");
405
+ for (const path of input.paths ?? []) validateSearchPath(path, "paths");
406
+ for (const target of input.targets ?? []) {
407
+ if (target && typeof target.path === "string")
408
+ validateSearchPath(target.path, "targets.path");
409
+ }
410
+ const combined = signal
411
+ ? AbortSignal.any([signal, this.#lifecycle.signal])
412
+ : this.#lifecycle.signal;
413
+ request = this.#isLongRunningQuery(input)
414
+ ? this.#searchOperation(input, cwd, signal, options)
415
+ : this.#search(input, cwd, combined, options);
416
+ }
417
+ this.#active.add(request);
418
+ try {
419
+ const result = await request;
420
+ return input.redact || result.details.redactionRequested
421
+ ? redactSiftLightResult(result)
422
+ : result;
423
+ } finally {
424
+ this.#active.delete(request);
425
+ }
426
+ }
427
+
428
+ #isLongRunningQuery(input: SiftLightInput): input is SiftLightInput & {
429
+ mode: "concept" | "hybrid";
430
+ } {
431
+ return (
432
+ (input.mode === "concept" || input.mode === "hybrid") &&
433
+ input.operationId === undefined &&
434
+ input.cursor === undefined
435
+ );
436
+ }
437
+
438
+ async #searchOperation(
439
+ input: SiftLightInput & { mode: "concept" | "hybrid" },
440
+ cwd: string,
441
+ signal: AbortSignal | undefined,
442
+ options: SiftLightSearchOptions,
443
+ ): Promise<SiftLightResult> {
444
+ const started = this.#operations.start(
445
+ async (operationSignal, operationId) => {
446
+ const combined = AbortSignal.any([operationSignal, this.#lifecycle.signal]);
447
+ const deadlineAt = this.#operations.get(operationId).deadlineAt;
448
+ this.#operations.updateProgress(operationId, { phase: "queued" });
449
+ let refreshAttempt = 0;
450
+ let workerRestarted = false;
451
+ while (true) {
452
+ try {
453
+ this.#operations.updateProgress(operationId, { phase: "running" });
454
+ // oxlint-disable-next-line no-await-in-loop -- retries share one operation deadline and signal.
455
+ return await this.#search(input, cwd, combined, {
456
+ ...options,
457
+ onProgress: (progress) => this.#operations.updateProgress(operationId, progress),
458
+ });
459
+ } catch (error) {
460
+ if (error instanceof ConceptWorkerExitError) {
461
+ if (workerRestarted || combined.aborted) throw error;
462
+ const remaining = deadlineAt - Date.now();
463
+ if (remaining <= 0) throw error;
464
+ workerRestarted = true;
465
+ this.#operations.updateProgress(operationId, {
466
+ phase: "refreshing",
467
+ detail:
468
+ "worker restart 1 after unexpected worker termination; completed cache entries are reusable",
469
+ });
470
+ // oxlint-disable-next-line no-await-in-loop -- bounded retry backoff preserves one deadline.
471
+ await waitForSourceRefresh(combined, Math.min(100, remaining));
472
+ continue;
473
+ }
474
+ if (!(error instanceof ConceptSourceChangedError)) throw error;
475
+ if (combined.aborted) throw error;
476
+ const remaining = deadlineAt - Date.now();
477
+ if (remaining <= 0)
478
+ throw new SiftLightError("Source did not stabilize before the operation deadline", {
479
+ cause: error,
480
+ });
481
+ this.#operations.updateProgress(operationId, {
482
+ phase: "refreshing",
483
+ detail: `generation retry ${String(++refreshAttempt)}: ${error.message}`,
484
+ });
485
+ // oxlint-disable-next-line no-await-in-loop -- bounded retry backoff preserves one deadline.
486
+ await waitForSourceRefresh(combined, Math.min(100, remaining));
487
+ }
488
+ }
489
+ },
490
+ { mode: input.mode, redact: input.redact ?? false, cwd },
491
+ );
492
+ const outcome = await this.#operations.wait(started.id, OPERATION_INITIAL_WAIT_MS, signal);
493
+ return operationOutcome(outcome, input.mode);
494
+ }
495
+
496
+ async #operationCommand(
497
+ input: SiftLightInput,
498
+ cwd: string,
499
+ signal: AbortSignal | undefined,
500
+ ): Promise<SiftLightResult> {
501
+ if (!input.operationId || typeof input.operationId !== "string")
502
+ throw new SiftLightError("mode=await and mode=cancel require operationId");
503
+ const existing = this.#operations.get(input.operationId);
504
+ const mode = existing.metadata.mode;
505
+ if (resolve(cwd) !== resolve(existing.metadata.cwd))
506
+ throw new SiftLightError("Operation belongs to a different working directory");
507
+ const forbidden = Object.keys(input).filter((key) => key !== "mode" && key !== "operationId");
508
+ if (forbidden.length > 0)
509
+ throw new SiftLightError(
510
+ `${input.mode} accepts only operationId; remove ${forbidden.join(", ")} and copy the returned nextRequest exactly`,
511
+ );
512
+ if (input.mode === "cancel") {
513
+ const cancelled = await this.#operations.cancelAndWait(input.operationId);
514
+ if (cancelled.state === "complete" && cancelled.result !== undefined)
515
+ return completeOperationResult(cancelled.result, cancelled);
516
+ return operationStateResult(cancelled, mode);
517
+ }
518
+ const outcome = await this.#operations.wait(
519
+ input.operationId,
520
+ OPERATION_INITIAL_WAIT_MS,
521
+ signal,
522
+ );
523
+ return operationOutcome(outcome, mode);
524
+ }
525
+
526
+ async #search(
527
+ input: SiftLightInput,
528
+ cwd: string,
529
+ signal?: AbortSignal,
530
+ options: SiftLightSearchOptions = {},
531
+ ): Promise<SiftLightResult> {
532
+ if (
533
+ input.cursor !== undefined &&
534
+ (typeof input.cursor !== "string" || input.cursor.trim().length === 0)
535
+ ) {
536
+ throw new CursorError("Invalid cursor. Copy a nonempty cursor from a previous result.");
537
+ }
538
+ const mode = input.mode ?? "auto";
539
+ if (mode === "capabilities") {
540
+ const inventory = await this.#capabilities.inspect({
541
+ cwd,
542
+ ...(input.path !== undefined ? { path: input.path } : {}),
543
+ glob: filterList(input.glob),
544
+ exclude: filterList(input.exclude),
545
+ ...(input.hidden !== undefined ? { hidden: input.hidden } : {}),
546
+ ...(signal ? { signal } : {}),
547
+ });
548
+ return capabilitiesResult(inventory);
549
+ }
550
+ if (mode === "audit") return runAuditSearch(input, cwd, this.#runRipgrep, signal);
551
+ const contextBudget = selectContextBudget(input, mode, options.contextBudget);
552
+ if (isEvidenceRequest(input)) return this.#evidence.search(input, cwd, signal, options);
553
+ if (input.query !== undefined) throw new SiftLightError(DISCOVERY_MODE_REQUIRED_ERROR);
554
+ if (input.maxFilesToParse !== undefined) {
555
+ throw new SiftLightError("maxFilesToParse is only valid for structural analysis requests");
556
+ }
557
+ if (input.cursor) return this.#continue(input, cwd, signal, options);
558
+ if (input.paths !== undefined) {
559
+ throw new SiftLightError("paths can only select retained files from a cursor");
560
+ }
561
+ if (input.matchIndex !== undefined) {
562
+ throw new SiftLightError("matchIndex requires mode=inspect with a cursor");
563
+ }
564
+ if (input.matchIndices !== undefined || input.targets !== undefined) {
565
+ throw new SiftLightError("matchIndices and targets require mode=inspect");
566
+ }
567
+ if (input.line !== undefined) throw new SiftLightError("line requires mode=inspect");
568
+
569
+ const request = normalizeRequest(input);
570
+ let scan = await this.#runRipgrep(request, cwd, signal);
571
+ if (scan.totalMatches === 0 && request.path !== undefined && request.scope !== "strict") {
572
+ const { path: requestedPath, ...projectRequest } = request;
573
+ scan = await this.#runRipgrep(
574
+ { ...projectRequest, expandedFromPath: requestedPath },
575
+ cwd,
576
+ signal,
577
+ );
578
+ }
579
+ const snapshot = this.#snapshots.create(scan);
580
+ try {
581
+ let result: SiftLightResult;
582
+
583
+ if (snapshot.totalMatches === 0) {
584
+ const details = baseDetails(snapshot, mode);
585
+ result = {
586
+ text: emptyResultText(details),
587
+ details,
588
+ };
589
+ } else if (snapshot.matches.length === 0) {
590
+ result = await this.#summary(snapshot, mode, cwd, signal);
591
+ } else if (mode === "summary") {
592
+ result = await this.#summary(snapshot, mode, cwd, signal);
593
+ } else if (mode === "matches") {
594
+ result = await this.#page(snapshot, 0, mode, signal, undefined, options);
595
+ } else {
596
+ if (input.limit !== undefined) {
597
+ result = await this.#page(snapshot, 0, mode, signal, undefined, options);
598
+ } else if (
599
+ contextBudget !== undefined &&
600
+ contextBudget.tier !== "full" &&
601
+ input.limit === undefined
602
+ ) {
603
+ result = await this.#summary(snapshot, mode, cwd, signal, 0, contextBudget);
604
+ } else {
605
+ try {
606
+ const page = await formatMatchPage(
607
+ snapshot,
608
+ 0,
609
+ signal,
610
+ matchPageOptions(input.limit === undefined ? contextBudget : undefined),
611
+ );
612
+ result =
613
+ snapshot.snapshotComplete && page.nextOffset === snapshot.matches.length
614
+ ? this.#pageResult(snapshot, 0, mode, page)
615
+ : await this.#summary(snapshot, mode, cwd, signal, 0, contextBudget);
616
+ } catch (error) {
617
+ if (!(error instanceof MatchPageSoftLimitError)) throw error;
618
+ result = await this.#summary(snapshot, mode, cwd, signal, 0, contextBudget);
619
+ }
620
+ }
621
+ }
622
+
623
+ result = {
624
+ ...result,
625
+ text: `${scopeExpansionNote(result.details.scope, result.details.totalMatches).trim()}${result.details.scope?.expandedToProjectRoot ? "\n\n" : ""}${result.text}`,
626
+ };
627
+ const budgetedResult = attachContextBudget(result, contextBudget, snapshot.totalMatches);
628
+ return this.#finalize(snapshot, budgetedResult);
629
+ } catch (error) {
630
+ this.#snapshots.delete(snapshot);
631
+ throw error;
632
+ }
633
+ }
634
+
635
+ clear(): void {
636
+ this.#lifecycle.abort();
637
+ this.#lifecycle = new AbortController();
638
+ this.#operations.reset();
639
+ this.#snapshots.clear();
640
+ this.#evidence.clear();
641
+ }
642
+
643
+ async shutdown(): Promise<void> {
644
+ this.#lifecycle.abort();
645
+ this.#operations.clear();
646
+ const pending = [...this.#active];
647
+ await Promise.allSettled(pending);
648
+ await this.#operations.shutdown();
649
+ this.#snapshots.clear();
650
+ this.#evidence.clear();
651
+ await this.#evidence.shutdown();
652
+ }
653
+
654
+ get snapshotCount(): number {
655
+ return this.#snapshots.size;
656
+ }
657
+
658
+ get storedMatches(): number {
659
+ return this.#snapshots.storedMatches;
660
+ }
661
+
662
+ async #continue(
663
+ input: SiftLightInput,
664
+ cwd: string,
665
+ signal?: AbortSignal,
666
+ options: SiftLightSearchOptions = {},
667
+ ): Promise<SiftLightResult> {
668
+ const cursor = input.cursor;
669
+ if (!cursor) throw new CursorError("A cursor is required to continue a search");
670
+ const { snapshot, offset, kind, selectionKey } = this.#snapshots.resolve(cursor);
671
+ const mode = input.mode ?? "auto";
672
+ if (mode === "summary") {
673
+ if (input.path !== undefined || input.paths !== undefined) {
674
+ throw new SiftLightError("path and paths are not valid while paging a file summary");
675
+ }
676
+ if (kind !== "summary") {
677
+ throw new CursorError(
678
+ "A summary cursor is required to continue a file summary.",
679
+ "E_CURSOR_WRONG_KIND",
680
+ );
681
+ }
682
+ if (offset >= snapshot.fileCounts.size) {
683
+ throw new CursorError("Cursor is already at the end of the file summary.");
684
+ }
685
+ return this.#summary(snapshot, mode, cwd, signal, offset);
686
+ }
687
+
688
+ const selection = cursorPathSelection(input, cwd);
689
+ const requestedSelectionKey = selection?.key ?? "all";
690
+ if (kind === "matches" && selectionKey !== requestedSelectionKey) {
691
+ throw new CursorError(
692
+ "A match cursor must continue with the same path selection.",
693
+ "E_CURSOR_OPTIONS_CONFLICT",
694
+ );
695
+ }
696
+ const pageOffset = kind === "summary" ? 0 : offset;
697
+ const result = await this.#page(snapshot, pageOffset, "matches", signal, selection, options);
698
+ return this.#finalize(snapshot, result, kind === "summary" || selection !== undefined);
699
+ }
700
+
701
+ #finalize(
702
+ snapshot: SearchSnapshot,
703
+ result: SiftLightResult,
704
+ retainSnapshot = false,
705
+ ): SiftLightResult {
706
+ if (
707
+ !result.details.cursor &&
708
+ !result.details.inspectRequest &&
709
+ !retainSnapshot &&
710
+ !this.#reusableSummarySnapshots.has(snapshot)
711
+ ) {
712
+ this.#snapshots.delete(snapshot);
713
+ }
714
+ return result;
715
+ }
716
+
717
+ async #summary(
718
+ snapshot: SearchSnapshot,
719
+ mode: SearchMode,
720
+ cwd: string,
721
+ signal: AbortSignal | undefined,
722
+ offset = 0,
723
+ budget?: ContextBudget,
724
+ ): Promise<SiftLightResult> {
725
+ this.#reusableSummarySnapshots.add(snapshot);
726
+ const summary = formatSummary(
727
+ snapshot,
728
+ this.#summaryFileLimit,
729
+ offset,
730
+ budget?.resultTokenBudget,
731
+ );
732
+ const details = baseDetails(snapshot, mode);
733
+ const cursor =
734
+ snapshot.fileCounts.size > 0
735
+ ? this.#snapshots.cursor(snapshot, summary.nextOffset, "summary")
736
+ : undefined;
737
+ const fileRange =
738
+ summary.shown > 0
739
+ ? `Files ${String(summary.offset + 1)}-${String(summary.nextOffset)} of ${String(snapshot.fileCounts.size)}, ordered by match count.`
740
+ : "No retained file summaries are available.";
741
+ const omitted =
742
+ summary.omitted > 0 ? `\n… ${String(summary.omitted)} lower-ranked files remain.` : "";
743
+ const preview = await summarySourcePreviews(
744
+ snapshot,
745
+ summary.shownPaths,
746
+ summary.previewByteBudget,
747
+ cwd,
748
+ signal,
749
+ );
750
+ const sampleText = preview.text;
751
+ const indices = preview.indices;
752
+ const samples = sampleText
753
+ ? `\n\nSamples: first retained match; bounded source windows; not relevance-ranked or exhaustive.\n${sampleText}`
754
+ : "";
755
+ const sampleOmissions = `\n[Preview limits: at most 5 source files, 2 non-overlapping windows/file, 7 lines/window. File rows and navigation take priority; shown ${preview.text ? preview.windows : summary.previewsShown} previews.]${preview.reasons.length ? `\n[${preview.reasons.map((reason) => reason.slice(0, 200)).join("; ")}]` : ""}`;
756
+ const redaction = snapshot.request.redact ? { redact: true } : {};
757
+ const nextRequest =
758
+ cursor && summary.hasNext ? { cursor, mode: "summary" as const, ...redaction } : undefined;
759
+ const inspectRequest =
760
+ cursor && indices.length
761
+ ? {
762
+ mode: "inspect" as const,
763
+ cursor,
764
+ matchIndices: indices.slice(0, MAX_INSPECT_TARGETS),
765
+ ...redaction,
766
+ }
767
+ : undefined;
768
+ const matchesRequest =
769
+ cursor && snapshot.matches.length > 0 && summary.shownPaths.length
770
+ ? { cursor, paths: summary.shownPaths.slice(0, 1), ...redaction }
771
+ : undefined;
772
+ const followUp = cursor
773
+ ? `\n\nSnapshot cursor="${cursor}". Snapshot cursor available: ${cursor}.${inspectRequest ? `\nInspect samples: ${JSON.stringify(inspectRequest)}` : ""}${matchesRequest ? `\nRetrieve matching lines: ${JSON.stringify(matchesRequest)}` : ""}${nextRequest ? `\nNext request: ${JSON.stringify(nextRequest)}` : ""}`
774
+ : "";
775
+ const complete =
776
+ snapshot.snapshotComplete && (snapshot.filesystemCoverage ?? "complete") === "complete";
777
+ const text = `Search summary (${complete ? "complete" : "PARTIAL"}). ${snapshot.totalMatches} matches across ${snapshot.fileCounts.size} files (${completenessNote(snapshot)}).\n${fileRange}\n\n${summary.body}${omitted}${samples}${sampleOmissions}${lineExcerptNote(snapshot)}${modificationTimeBoundsText(details.scope?.modifiedAfterMs, details.scope?.modifiedBeforeMs)}${followUp}${sourceVerificationNote(details)}`;
778
+ return {
779
+ text,
780
+ details: {
781
+ ...details,
782
+ ...(cursor ? { cursor } : {}),
783
+ ...(nextRequest ? { nextRequest } : {}),
784
+ summaryOffset: summary.offset,
785
+ summaryFilesShown: summary.shown,
786
+ summaryFilesOmitted: summary.omitted,
787
+ summaryPreviewsShown: preview.text ? preview.windows : summary.previewsShown,
788
+ summaryPreviewsOmitted: Math.max(
789
+ 0,
790
+ summary.shown -
791
+ (preview.text
792
+ ? new Set(indices.map((index) => snapshot.matches[index - 1]?.displayPath)).size
793
+ : summary.previewsShown),
794
+ ),
795
+ },
796
+ };
797
+ }
798
+
799
+ async #page(
800
+ snapshot: SearchSnapshot,
801
+ offset: number,
802
+ mode: SearchMode,
803
+ signal?: AbortSignal,
804
+ selection?: PathSelection,
805
+ options: SiftLightSearchOptions = {},
806
+ ): Promise<SiftLightResult> {
807
+ if (offset === snapshot.matches.length) {
808
+ throw new CursorError("Cursor is already at the end of the retained snapshot.");
809
+ }
810
+
811
+ const pageOptions = selection
812
+ ? {
813
+ metadataReserveBytes:
814
+ 1536 + Buffer.byteLength(JSON.stringify({ paths: selection.labels })),
815
+ include: (match: SearchSnapshot["matches"][number]) =>
816
+ selection.absolutePaths.has(match.absolutePath),
817
+ }
818
+ : {};
819
+ let page: Awaited<ReturnType<typeof formatMatchMetadataPage>>;
820
+ if (!options.modelSource) {
821
+ page = await formatMatchMetadataPage(snapshot, offset, signal, pageOptions);
822
+ } else {
823
+ try {
824
+ page = await formatMatchPage(snapshot, offset, signal, pageOptions);
825
+ } catch (error) {
826
+ if (!(error instanceof MatchPageSoftLimitError)) throw error;
827
+ page = await formatMatchMetadataPage(snapshot, offset, signal, pageOptions);
828
+ }
829
+ }
830
+ if (page.returnedMatches === 0 && selection) {
831
+ throw new CursorError("No retained matches exist for the selected paths.");
832
+ }
833
+ const missingPaths: string[] = [];
834
+ if (selection) {
835
+ const matchedAbsolutePaths = new Set<string>();
836
+ for (const match of snapshot.matches) {
837
+ if (selection.absolutePaths.has(match.absolutePath)) {
838
+ matchedAbsolutePaths.add(match.absolutePath);
839
+ }
840
+ }
841
+ const selectedAbsolutePaths = [...selection.absolutePaths];
842
+ for (const [index, label] of selection.labels.entries()) {
843
+ const absolutePath = selectedAbsolutePaths[index];
844
+ if (absolutePath !== undefined && !matchedAbsolutePaths.has(absolutePath)) {
845
+ missingPaths.push(label);
846
+ }
847
+ }
848
+ }
849
+ return this.#pageResult(
850
+ snapshot,
851
+ offset,
852
+ mode,
853
+ page,
854
+ selection?.labels,
855
+ missingPaths,
856
+ selection?.key ?? "all",
857
+ );
858
+ }
859
+
860
+ #pageResult(
861
+ snapshot: SearchSnapshot,
862
+ offset: number,
863
+ mode: SearchMode,
864
+ page: Awaited<ReturnType<typeof formatMatchMetadataPage>>,
865
+ selectedPaths?: string[],
866
+ selectionMissingPaths: string[] = [],
867
+ selectionKey = "all",
868
+ ): SiftLightResult {
869
+ if (page.returnedMatches === 0) {
870
+ throw new SiftLightError("The output budget could not fit a single match");
871
+ }
872
+ const cursor = page.hasNext
873
+ ? this.#snapshots.cursor(snapshot, page.nextOffset, "matches", selectionKey)
874
+ : undefined;
875
+ const firstMatch = page.firstMatchIndex ?? offset;
876
+ const lastMatch = page.lastMatchIndex ?? firstMatch;
877
+ const range = `${firstMatch + 1}-${lastMatch + 1}`;
878
+ const selection = selectedPaths ? `; selected ${String(selectedPaths.length)} path(s)` : "";
879
+ const missingSelectionNote =
880
+ selectionMissingPaths.length > 0
881
+ ? `\n\n[${String(selectionMissingPaths.length)} selected path(s) had no retained matches.]`
882
+ : "";
883
+ const details = baseDetails(snapshot, mode);
884
+ const next = cursor
885
+ ? `\n\nContinue with cursor="${cursor}".\nNext request: ${JSON.stringify({ cursor, ...(selectedPaths ? { paths: selectedPaths } : {}), ...(snapshot.request.redact ? { redact: true } : {}) })}`
886
+ : "";
887
+ const rangeNote = page.hasMatchRanges
888
+ ? `\n\n[Match columns are 1-based UTF-16 positions${page.hasByteRanges ? "; b ranges use raw UTF-8 bytes" : ""}.]`
889
+ : "";
890
+ const contextNotes: string[] = [];
891
+ if (page.contextChangedFiles.length > 0)
892
+ contextNotes.push(
893
+ `Context omitted for ${String(page.contextChangedFiles.length)} changed file(s); refresh the search before relying on surrounding lines.`,
894
+ );
895
+ if (page.contextOmittedFiles.length > 0)
896
+ contextNotes.push(
897
+ `Context unavailable for ${String(page.contextOmittedFiles.length)} file(s); retained matching lines are still shown.`,
898
+ );
899
+ const contextNote = contextNotes.length > 0 ? `\n\n[${contextNotes.join(" ")}]` : "";
900
+ const inspectRequest: SiftLightInput | undefined =
901
+ snapshot.truncatedLines > 0
902
+ ? {
903
+ mode: "inspect",
904
+ cursor: this.#snapshots.cursor(snapshot, 0, "matches"),
905
+ matchIndex: firstMatch + 1,
906
+ ...(snapshot.request.redact ? { redact: true } : {}),
907
+ }
908
+ : undefined;
909
+ const inspectNote = inspectRequest
910
+ ? `\nInspect source (choose a visible matchIndex): ${JSON.stringify(inspectRequest)}`
911
+ : "";
912
+ return {
913
+ text: `Match metadata ${range} of ${snapshot.totalMatches}${selection}; ${page.returnedMatches} returned.\nOccurrences: ${pageMatchesOccurrences(snapshot, firstMatch, lastMatch)} occurrences.\n\n${page.body}${rangeNote}${contextNote}${missingSelectionNote}\n\n[Matches ${range} of ${snapshot.totalMatches}${selection}; ${completenessNote(snapshot)}.]${lineExcerptNote(snapshot)}${inspectNote}${modificationTimeBoundsText(details.scope?.modifiedAfterMs, details.scope?.modifiedBeforeMs)}${next}${sourceVerificationNote(details)}`,
914
+ details: {
915
+ ...details,
916
+ returnedMatches: page.returnedMatches,
917
+ ...(inspectRequest ? { inspectRequest } : {}),
918
+ ...(page.occurrenceRangesOmitted > 0
919
+ ? { occurrenceRangesOmitted: page.occurrenceRangesOmitted }
920
+ : {}),
921
+ ...(page.occurrenceMatchesTruncated > 0
922
+ ? { occurrenceMatchesTruncated: page.occurrenceMatchesTruncated }
923
+ : {}),
924
+ ...(cursor ? { cursor } : {}),
925
+ ...(cursor
926
+ ? {
927
+ nextRequest: {
928
+ cursor,
929
+ ...(selectedPaths ? { paths: selectedPaths } : {}),
930
+ ...(snapshot.request.redact ? { redact: true } : {}),
931
+ },
932
+ }
933
+ : {}),
934
+ ...(selectedPaths ? { selectedPaths } : {}),
935
+ ...(selectionMissingPaths.length > 0 ? { selectionMissingPaths } : {}),
936
+ ...(page.contextOmittedFiles.length > 0
937
+ ? { contextOmittedFiles: page.contextOmittedFiles }
938
+ : {}),
939
+ ...(page.contextChangedFiles.length > 0
940
+ ? { contextChangedFiles: page.contextChangedFiles }
941
+ : {}),
942
+ },
943
+ };
944
+ }
945
+ }