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
@@ -0,0 +1,240 @@
1
+ import { posix } from "node:path";
2
+ import { MAX_IMPORT_FILES, MAX_IMPORT_HOPS } from "./analysis-limits.js";
3
+ import { SiftLightError } from "./errors.js";
4
+ import type { SourceReference } from "./source-document.js";
5
+ import {
6
+ NavigationContext,
7
+ NavigationFailure,
8
+ moduleRange,
9
+ navigationError,
10
+ nodeLine,
11
+ nodeText,
12
+ type ExportBinding,
13
+ type ImportBinding,
14
+ type ModuleFacts,
15
+ } from "./import-model.js";
16
+
17
+ const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
18
+ /** These are static source candidates, not Node/bundler runtime-resolution claims. */
19
+ export const STATIC_MODULE_RESOLUTION =
20
+ "Exact relative path; extensionless source and index candidates (.ts/.tsx/.js/.jsx/.mts/.cts/.mjs/.cjs); .js→.ts/.tsx, .jsx→.tsx, .mjs→.mts, .cjs→.cts source candidates. Every existing candidate is considered; multiple candidates are ambiguous. No configuration is executed.";
21
+
22
+ export interface ImportStep {
23
+ from: SourceReference;
24
+ line: number;
25
+ range: { start: number; end: number };
26
+ kind: "import" | "re-export" | "local-export";
27
+ specifier?: string;
28
+ imported?: string;
29
+ local?: string;
30
+ exported?: string;
31
+ to?: SourceReference;
32
+ resolution?: string;
33
+ }
34
+ export interface ImportDestination {
35
+ source: SourceReference;
36
+ line: number;
37
+ range: { start: number; end: number };
38
+ name: string;
39
+ kind: string;
40
+ }
41
+ export interface ImportTrace {
42
+ status: "resolved" | "module" | "unresolved";
43
+ reason?: string;
44
+ chain: ImportStep[];
45
+ destination?: ImportDestination;
46
+ module?: SourceReference;
47
+ candidates?: string[];
48
+ }
49
+
50
+ interface Resolution {
51
+ path?: string;
52
+ reason?: string;
53
+ candidates?: string[];
54
+ }
55
+ export async function resolveStaticModule(
56
+ context: NavigationContext,
57
+ from: string,
58
+ specifier: string | undefined,
59
+ ): Promise<Resolution> {
60
+ context.checkAbort();
61
+ // Navigation hosts can return native relative paths; resolution is always POSIX-based.
62
+ from = context.normalizePath(from);
63
+ if (specifier === undefined) return { reason: "nonliteral-module-specifier" };
64
+ if (!specifier.startsWith("./") && !specifier.startsWith("../"))
65
+ return { reason: "external-package-or-path-alias-unsupported" };
66
+ if (specifier.includes("\\") || specifier.includes("\0") || /[?#]/.test(specifier))
67
+ return { reason: "module-specifier-unsupported" };
68
+ const joined = posix.normalize(posix.join(posix.dirname(from), specifier));
69
+ let path: string;
70
+ try {
71
+ path = context.normalizePath(joined);
72
+ } catch (error) {
73
+ if (error instanceof SiftLightError) return { reason: "outside-workspace" };
74
+ throw error;
75
+ }
76
+ const candidates = new Set([path]);
77
+ const extension = posix.extname(path);
78
+ if (!extension) {
79
+ for (const suffix of EXTENSIONS) {
80
+ candidates.add(context.normalizePath(`${path}${suffix}`));
81
+ candidates.add(context.normalizePath(posix.join(path, `index${suffix}`)));
82
+ }
83
+ } else {
84
+ const mappings: Record<string, string[]> = {
85
+ ".js": [".ts", ".tsx"],
86
+ ".jsx": [".tsx"],
87
+ ".mjs": [".mts"],
88
+ ".cjs": [".cts"],
89
+ };
90
+ for (const suffix of mappings[extension] ?? [])
91
+ candidates.add(context.normalizePath(path.slice(0, -extension.length) + suffix));
92
+ }
93
+ const files = await context.files();
94
+ const existing = [...candidates].filter((candidate) => files.has(candidate)).toSorted();
95
+ if (existing.length === 0)
96
+ return { reason: "missing-or-excluded-module", candidates: [...candidates] };
97
+ if (existing.length > 1) return { reason: "ambiguous-module-candidates", candidates: existing };
98
+ const unique = existing[0];
99
+ if (unique === undefined) throw new Error("Missing unique module candidate");
100
+ return { path: unique };
101
+ }
102
+
103
+ function declaration(facts: ModuleFacts, node: number, name: string): ImportDestination {
104
+ return {
105
+ source: facts.document.reference,
106
+ line: nodeLine(facts, node),
107
+ range: moduleRange(facts, node),
108
+ name,
109
+ kind: facts.locations.get(node)?.kind ?? "unknown",
110
+ };
111
+ }
112
+
113
+ /** One path has its own hop/file budget even when E reuses a larger request context. */
114
+ export async function traceImport(
115
+ context: NavigationContext,
116
+ initial: ModuleFacts,
117
+ binding: ImportBinding | ExportBinding,
118
+ ): Promise<ImportTrace> {
119
+ const chain: ImportStep[] = [];
120
+ const visited = new Set<string>();
121
+ const paths = new Set([initial.document.path]);
122
+ let hops = 0;
123
+ const unresolved = (reason: string, candidates?: string[]): ImportTrace => ({
124
+ status: "unresolved",
125
+ reason,
126
+ chain,
127
+ ...(candidates ? { candidates } : {}),
128
+ });
129
+ const visit = (facts: ModuleFacts, name: string): void => {
130
+ const key = JSON.stringify([facts.document.path, name]);
131
+ if (visited.has(key)) throw new NavigationFailure("circular-re-export");
132
+ visited.add(key);
133
+ };
134
+ const followModule = async (
135
+ facts: ModuleFacts,
136
+ source: string | undefined,
137
+ step: ImportStep,
138
+ ): Promise<ModuleFacts | ImportTrace> => {
139
+ if (hops >= MAX_IMPORT_HOPS) return unresolved("hop-budget-exhausted");
140
+ hops++;
141
+ chain.push(step);
142
+ const resolved = await resolveStaticModule(context, facts.document.path, source);
143
+ if (!resolved.path)
144
+ return unresolved(resolved.reason ?? "module-unresolved", resolved.candidates);
145
+ paths.add(resolved.path);
146
+ if (paths.size > MAX_IMPORT_FILES) return unresolved("file-budget-exhausted");
147
+ const next = await context.module(resolved.path);
148
+ step.to = next.document.reference;
149
+ step.resolution = "static-source-candidates";
150
+ return next;
151
+ };
152
+ const followImport = async (facts: ModuleFacts, current: ImportBinding): Promise<ImportTrace> => {
153
+ const next = await followModule(facts, current.source, {
154
+ from: facts.document.reference,
155
+ line: nodeLine(facts, current.statement),
156
+ range: moduleRange(facts, current.statement),
157
+ kind: "import",
158
+ ...(current.source !== undefined ? { specifier: current.source } : {}),
159
+ imported: current.imported,
160
+ ...(current.local !== undefined ? { local: current.local } : {}),
161
+ });
162
+ if (!("document" in next)) return next;
163
+ if (current.kind === "namespace" || current.kind === "side-effect")
164
+ return { status: "module", chain, module: next.document.reference };
165
+ return followExportName(next, current.imported);
166
+ };
167
+ const followLocal = async (facts: ModuleFacts, name: string): Promise<ImportTrace> => {
168
+ const imported = facts.imports.filter((current) => current.local === name);
169
+ const declared = facts.declarations.get(name) ?? [];
170
+ if (imported.length + declared.length > 1) return unresolved("ambiguous-local-binding");
171
+ if (imported[0]) return followImport(facts, imported[0]);
172
+ if (declared[0] !== undefined)
173
+ return { status: "resolved", chain, destination: declaration(facts, declared[0], name) };
174
+ return unresolved("local-binding-unresolved");
175
+ };
176
+ const followExport = async (facts: ModuleFacts, current: ExportBinding): Promise<ImportTrace> => {
177
+ if (current.kind === "star") return unresolved("export-star-unsupported");
178
+ if (current.definition !== undefined)
179
+ return {
180
+ status: "resolved",
181
+ chain,
182
+ destination: declaration(facts, current.definition, current.exported),
183
+ };
184
+ const step: ImportStep = {
185
+ from: facts.document.reference,
186
+ line: nodeLine(facts, current.statement),
187
+ range: moduleRange(facts, current.statement),
188
+ kind: current.source !== undefined ? "re-export" : "local-export",
189
+ exported: current.exported,
190
+ ...(current.local !== undefined ? { local: current.local, imported: current.local } : {}),
191
+ ...(current.source !== undefined ? { specifier: current.source } : {}),
192
+ };
193
+ if (current.source !== undefined) {
194
+ const next = await followModule(facts, current.source, step);
195
+ if (!("document" in next)) return next;
196
+ if (current.kind === "namespace")
197
+ return { status: "module", chain, module: next.document.reference };
198
+ if (current.local === undefined) return unresolved("export-binding-unresolved");
199
+ return followExportName(next, current.local);
200
+ }
201
+ chain.push(step);
202
+ if (current.local === undefined) return unresolved("export-binding-unresolved");
203
+ return followLocal(facts, current.local);
204
+ };
205
+ const followExportName = async (facts: ModuleFacts, name: string): Promise<ImportTrace> => {
206
+ visit(facts, name);
207
+ const exported = facts.exports.filter(
208
+ (current) => current.exported === name && current.kind !== "star",
209
+ );
210
+ if (exported.length > 1) return unresolved("ambiguous-export-binding");
211
+ if (!exported[0])
212
+ return unresolved(
213
+ facts.exports.some((current) => current.kind === "star")
214
+ ? "export-star-unsupported"
215
+ : "export-not-found",
216
+ );
217
+ return followExport(facts, exported[0]);
218
+ };
219
+ try {
220
+ if ("imported" in binding) return await followImport(initial, binding);
221
+ visit(initial, binding.exported);
222
+ return await followExport(initial, binding);
223
+ } catch (error) {
224
+ const reason = navigationError(error);
225
+ if (!reason) throw error;
226
+ return unresolved(reason);
227
+ }
228
+ }
229
+
230
+ export function tracePaths(trace: ImportTrace): string[] {
231
+ const paths = trace.chain.flatMap((step) => [step.from.path, ...(step.to ? [step.to.path] : [])]);
232
+ if (trace.destination) paths.push(trace.destination.source.path);
233
+ if (trace.module) paths.push(trace.module.path);
234
+ return [...new Set(paths)];
235
+ }
236
+
237
+ export function importStatementExcerpt(facts: ModuleFacts, statement: number): string {
238
+ const text = nodeText(facts, statement) ?? "";
239
+ return text.length > 500 ? `${text.slice(0, 500)}… [statement excerpt truncated]` : text;
240
+ }
package/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { SIFT_LIGHT_DESCRIPTION, siftLightSchema } from "./tool-schema.js";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ normalizeSearchEnforcement,
5
+ readSiftLightConfig,
6
+ DEFAULT_SEMANTIC_JUDGE_CONFIG,
7
+ type SiftLightConfig,
8
+ } from "./config.js";
9
+ import { resolveContextBudget } from "./context-budget.js";
10
+ import { createRipgrepRunner } from "./rg.js";
11
+ import { createCtagsStructureProvider } from "./structure.js";
12
+ import { SiftLightRuntime } from "./runtime.js";
13
+ import { SESSION_STATUS_KEY } from "./session-summary.js";
14
+ import { SiftLightService } from "./service.js";
15
+ import { siftLightPromptGuidelines } from "./prompt-guidelines.js";
16
+ import type { SiftLightDetails } from "./types.js";
17
+ import { renderSiftLightCall, renderSiftLightResult } from "./tui/renderers.js";
18
+ import { registerPiSearchPolicy } from "./pi-search-policy.js";
19
+ import { modelErrorText } from "./model-error.js";
20
+ import { createSemanticJudgeIntegration } from "./semantic-judge.js";
21
+
22
+ const SIFT_LIGHT_LABEL = "sift-light";
23
+
24
+ export { siftLightPromptGuidelines };
25
+
26
+ export async function registerSiftLightExtension(
27
+ pi: ExtensionAPI,
28
+ config: SiftLightConfig,
29
+ ): Promise<void> {
30
+ const semanticJudge = createSemanticJudgeIntegration(
31
+ config.semanticJudge ?? DEFAULT_SEMANTIC_JUDGE_CONFIG,
32
+ );
33
+ const runtime = new SiftLightRuntime(
34
+ new SiftLightService({
35
+ runRipgrep: createRipgrepRunner(),
36
+ structure: createCtagsStructureProvider(),
37
+ semanticJudge,
38
+ }),
39
+ );
40
+ const { locale } = config;
41
+
42
+ pi.registerTool<typeof siftLightSchema, SiftLightDetails>({
43
+ name: "sift-light",
44
+ label: SIFT_LIGHT_LABEL,
45
+ description: SIFT_LIGHT_DESCRIPTION,
46
+ promptSnippet: "Search file contents without flooding context",
47
+ promptGuidelines: siftLightPromptGuidelines(),
48
+ parameters: siftLightSchema,
49
+
50
+ renderCall(params, theme) {
51
+ return renderSiftLightCall(params, locale, theme);
52
+ },
53
+
54
+ renderResult(result, { expanded, isPartial }, theme, context) {
55
+ return renderSiftLightResult(
56
+ result,
57
+ { expanded, isPartial, isError: context.isError },
58
+ locale,
59
+ theme,
60
+ );
61
+ },
62
+
63
+ async execute(...[_toolCallId, params, signal, _onUpdate, ctx]) {
64
+ try {
65
+ const result = await runtime.search(
66
+ params,
67
+ ctx.cwd,
68
+ signal,
69
+ resolveContextBudget(ctx.getContextUsage()),
70
+ );
71
+ ctx.ui.setStatus(SESSION_STATUS_KEY, runtime.formatSessionStatus(locale));
72
+ return {
73
+ content: [{ type: "text", text: result.text }],
74
+ details: result.details,
75
+ };
76
+ } catch (error) {
77
+ if (signal?.aborted) throw error;
78
+ runtime.recordFailure();
79
+ ctx.ui.setStatus(SESSION_STATUS_KEY, runtime.formatSessionStatus(locale));
80
+ // oxlint-disable-next-line preserve-caught-error -- the model boundary must not expose a recursive cause chain
81
+ throw new Error(modelErrorText(error));
82
+ }
83
+ },
84
+ });
85
+
86
+ const enforcement = normalizeSearchEnforcement(config.enforceSearch, "extension config");
87
+ if (enforcement !== "off") registerPiSearchPolicy(pi, enforcement);
88
+
89
+ pi.on("session_shutdown", async (_event, ctx) => {
90
+ await runtime.shutdown();
91
+ ctx.ui.setStatus(SESSION_STATUS_KEY, undefined);
92
+ });
93
+ }
94
+
95
+ export default async function siftLightExtension(pi: ExtensionAPI) {
96
+ await registerSiftLightExtension(pi, await readSiftLightConfig());
97
+ }
@@ -0,0 +1,265 @@
1
+ import { abortError, SiftLightError } from "./errors.js";
2
+ import {
3
+ inspectionDescription,
4
+ inspectSourceEvidence,
5
+ resolveInspectionTarget,
6
+ sourceExcerptDetails,
7
+ type InspectionEvidence,
8
+ type InspectInput,
9
+ type InspectOptions,
10
+ } from "./inspect.js";
11
+ import {
12
+ sameSourceRevision,
13
+ SourceBudgetTooSmallError,
14
+ SourceLineUnavailableError,
15
+ type SourceExcerptLine,
16
+ } from "./source.js";
17
+ import {
18
+ MAX_INSPECT_TARGETS,
19
+ MAX_LINE_CHARACTERS,
20
+ MAX_RESULT_BYTES,
21
+ type InspectBatchItemDetails,
22
+ type InspectRetry,
23
+ type InspectTarget,
24
+ type SiftLightResult,
25
+ type SourceRevision,
26
+ } from "./types.js";
27
+
28
+ export interface InspectBatchInput extends InspectInput {
29
+ matchIndices?: number[];
30
+ targets?: InspectTarget[];
31
+ }
32
+
33
+ interface InspectionBlock {
34
+ path: string;
35
+ absolutePath: string;
36
+ revision: SourceRevision;
37
+ lines: Map<number, SourceExcerptLine>;
38
+ }
39
+
40
+ function batchInputs(input: InspectBatchInput): InspectInput[] {
41
+ if (input.path !== undefined || input.line !== undefined || input.matchIndex !== undefined) {
42
+ throw new SiftLightError("Batch inspection cannot be combined with path, line, or matchIndex");
43
+ }
44
+ if (input.matchIndices !== undefined && input.targets !== undefined) {
45
+ throw new SiftLightError("Use either matchIndices or targets for batch inspection");
46
+ }
47
+ const count = input.matchIndices?.length ?? input.targets?.length ?? 0;
48
+ if (count < 1 || count > MAX_INSPECT_TARGETS) {
49
+ throw new SiftLightError(`Batch inspection requires 1-${String(MAX_INSPECT_TARGETS)} targets`);
50
+ }
51
+ if (input.matchIndices) {
52
+ const { cursor } = input;
53
+ if (!cursor) throw new SiftLightError("matchIndices requires a cursor when mode=inspect");
54
+ return input.matchIndices.map((matchIndex) => ({ cursor, matchIndex }));
55
+ }
56
+ if (input.cursor !== undefined) {
57
+ throw new SiftLightError("targets cannot be combined with a cursor; use matchIndices");
58
+ }
59
+ if (!input.targets) throw new SiftLightError("Batch inspection requires targets or matchIndices");
60
+ return input.targets;
61
+ }
62
+
63
+ function blockText(block: InspectionBlock, index: number): string {
64
+ const lines = [...block.lines.values()].toSorted((left, right) => left.line - right.line);
65
+ const output = [`[Block #${String(index + 1)}] ${block.path}`];
66
+ let previous: number | undefined;
67
+ for (const line of lines) {
68
+ if (previous !== undefined && line.line > previous + 1) {
69
+ output.push(`… omitted lines ${String(previous + 1)}-${String(line.line - 1)}`);
70
+ }
71
+ output.push(`${String(line.line)}: ${line.text}`);
72
+ previous = line.line;
73
+ }
74
+ return output.join("\n");
75
+ }
76
+
77
+ function batchText(
78
+ items: InspectBatchItemDetails[],
79
+ blocks: InspectionBlock[],
80
+ descriptions: Map<number, string>,
81
+ ): string {
82
+ const returned = items.filter((item) => item.status === "returned").length;
83
+ const header = `Batch inspection: ${String(returned)} of ${String(items.length)} targets returned (${returned === items.length ? "complete" : "PARTIAL"}); shared ${String(MAX_RESULT_BYTES)}-byte output limit.`;
84
+ const rows = items.map((item) => {
85
+ let row = `Target #${String(item.inputIndex)} ${item.path ?? ""}:${String(item.line ?? "")}${item.matchIndex !== undefined ? ` {match #${String(item.matchIndex)}}` : ""}: ${item.status}`;
86
+ if (item.block !== undefined) row += `; Block #${String(item.block)}`;
87
+ if (item.structure)
88
+ row += `; structure=${item.structure.status}${item.structure.provider ? ` via ${item.structure.provider}` : ""}`;
89
+ const description = descriptions.get(item.inputIndex);
90
+ if (description) row += `; ${description}`;
91
+ if (item.error) row += `; ${item.error}`;
92
+ if (item.source) {
93
+ row += `; source lines ${String(item.source.range.startLine)}-${String(item.source.range.endLine)}`;
94
+ if (item.source.omittedBefore > 0 || item.source.omittedAfter > 0) {
95
+ row += `; omitted ${String(item.source.omittedBefore)} lines before and ${String(item.source.omittedAfter)} after`;
96
+ }
97
+ if (item.source.truncatedLines.length > 0) {
98
+ row += `; line excerpts truncated at ${item.source.truncatedLines.join(",")} (max ${String(MAX_LINE_CHARACTERS)} source characters)`;
99
+ }
100
+ }
101
+ if (item.retry)
102
+ row += `\nRetry target #${String(item.inputIndex)}: ${JSON.stringify(item.retry)}`;
103
+ return row;
104
+ });
105
+ return [header, ...rows, ...blocks.map(blockText)].join("\n\n");
106
+ }
107
+
108
+ function addEvidence(
109
+ blocks: InspectionBlock[],
110
+ evidence: InspectionEvidence,
111
+ ): { blocks: InspectionBlock[]; block: number } {
112
+ const { source, revision, target } = evidence;
113
+ if (!source || !revision)
114
+ throw new Error("Only verified source evidence may enter a batch block");
115
+ const next = blocks.map((block) => ({ ...block, lines: new Map(block.lines) }));
116
+ let index = next.findIndex(
117
+ (block) =>
118
+ block.absolutePath === target.absolutePath && sameSourceRevision(block.revision, revision),
119
+ );
120
+ if (index < 0) {
121
+ index = next.length;
122
+ next.push({ path: target.path, absolutePath: target.absolutePath, revision, lines: new Map() });
123
+ }
124
+ const block = next[index];
125
+ if (!block) throw new Error("Batch block is unavailable");
126
+ for (const line of source.lines) {
127
+ const previous = block.lines.get(line.line);
128
+ if (previous && previous.text !== line.text) {
129
+ // A focused excerpt and a neighboring context excerpt can show different
130
+ // portions of the same long line. Keep the target's focused excerpt.
131
+ if (line.line !== target.line) continue;
132
+ }
133
+ block.lines.set(line.line, line);
134
+ }
135
+ return { blocks: next, block: index + 1 };
136
+ }
137
+
138
+ export async function inspectSourceBatch(
139
+ input: InspectBatchInput,
140
+ cwd: string,
141
+ signal: AbortSignal | undefined,
142
+ options: InspectOptions,
143
+ ): Promise<SiftLightResult> {
144
+ if (signal?.aborted) throw abortError();
145
+ const inputs = batchInputs(input);
146
+ const targets = inputs.map((item) => resolveInspectionTarget(item, cwd, options.snapshots));
147
+ const retries: InspectRetry[] = inputs.map((item) => ({ mode: "inspect", ...item }));
148
+ const items: InspectBatchItemDetails[] = targets.map((target, index) => ({
149
+ inputIndex: index + 1,
150
+ path: target.path,
151
+ line: target.line,
152
+ ...(inputs[index]?.matchIndex !== undefined ? { matchIndex: inputs[index]?.matchIndex } : {}),
153
+ status: "deferred",
154
+ error: "not returned within the shared byte budget",
155
+ ...(retries[index] ? { retry: retries[index] } : {}),
156
+ }));
157
+ const descriptions = new Map<number, string>();
158
+ let blocks: InspectionBlock[] = [];
159
+ const initialBytes = Buffer.byteLength(batchText(items, blocks, descriptions));
160
+ if (initialBytes + 512 > MAX_RESULT_BYTES) {
161
+ throw new SiftLightError(
162
+ "Batch target metadata exceeds the result byte budget; inspect fewer targets",
163
+ );
164
+ }
165
+
166
+ for (const [index, target] of targets.entries()) {
167
+ if (signal?.aborted) throw abortError();
168
+ const previous = items[index];
169
+ if (!previous) throw new Error("Batch item is unavailable");
170
+ const remaining = MAX_RESULT_BYTES - Buffer.byteLength(batchText(items, blocks, descriptions));
171
+ const maxSourceBytes = Math.floor((remaining - 512) / (targets.length - index)) - 512;
172
+ if (maxSourceBytes <= 0) continue;
173
+ let evidence: InspectionEvidence;
174
+ try {
175
+ // Each target consumes part of the same output budget. Sequential reads
176
+ // also give cancellation one owner and do not fan out child processes.
177
+ // oxlint-disable-next-line no-await-in-loop -- shared batch output budget.
178
+ evidence = await inspectSourceEvidence(target, cwd, signal, options, maxSourceBytes);
179
+ } catch (error) {
180
+ if (signal?.aborted || (error instanceof Error && error.name === "AbortError"))
181
+ throw abortError();
182
+ if (error instanceof SourceBudgetTooSmallError) continue;
183
+ if (!(error instanceof SourceLineUnavailableError)) throw error;
184
+ items[index] = { ...previous, status: "error", error: error.message };
185
+ delete items[index]?.retry;
186
+ continue;
187
+ }
188
+ if (!evidence.source) {
189
+ items[index] = {
190
+ inputIndex: previous.inputIndex,
191
+ path: target.path,
192
+ line: target.line,
193
+ ...(previous.matchIndex !== undefined ? { matchIndex: previous.matchIndex } : {}),
194
+ status: "error",
195
+ structure: evidence.structure,
196
+ error: `${evidence.structure.status}; ${target.retainedMatch ? "refresh the search before inspecting this match" : "source evidence is unavailable"}`,
197
+ };
198
+ continue;
199
+ }
200
+ const sourceRevision = evidence.revision;
201
+ if (
202
+ sourceRevision &&
203
+ blocks.some(
204
+ (block) =>
205
+ block.absolutePath === target.absolutePath &&
206
+ !sameSourceRevision(block.revision, sourceRevision),
207
+ )
208
+ ) {
209
+ items[index] = {
210
+ inputIndex: previous.inputIndex,
211
+ path: target.path,
212
+ line: target.line,
213
+ ...(previous.matchIndex !== undefined ? { matchIndex: previous.matchIndex } : {}),
214
+ status: "error",
215
+ structure: { status: "source-changed" },
216
+ error: "source-changed during batch inspection; refresh the source before retrying",
217
+ };
218
+ continue;
219
+ }
220
+ const candidate = addEvidence(blocks, evidence);
221
+ const source = sourceExcerptDetails(evidence.source);
222
+ items[index] = {
223
+ inputIndex: previous.inputIndex,
224
+ path: target.path,
225
+ line: target.line,
226
+ ...(previous.matchIndex !== undefined ? { matchIndex: previous.matchIndex } : {}),
227
+ status: "returned",
228
+ block: candidate.block,
229
+ structure: evidence.structure,
230
+ source,
231
+ ...((source.omittedBefore > 0 || source.omittedAfter > 0) && retries[index]
232
+ ? { retry: retries[index] }
233
+ : {}),
234
+ };
235
+ descriptions.set(index + 1, inspectionDescription(evidence));
236
+ if (Buffer.byteLength(batchText(items, candidate.blocks, descriptions)) > MAX_RESULT_BYTES) {
237
+ items[index] = previous;
238
+ descriptions.delete(index + 1);
239
+ } else {
240
+ blocks = candidate.blocks;
241
+ }
242
+ }
243
+ if (signal?.aborted) throw abortError();
244
+ const text = batchText(items, blocks, descriptions);
245
+ if (Buffer.byteLength(text) > MAX_RESULT_BYTES) {
246
+ throw new SiftLightError(
247
+ "Batch result metadata exceeds the byte budget; inspect fewer targets",
248
+ );
249
+ }
250
+ const complete = items.every((item) => item.status === "returned");
251
+ return {
252
+ text,
253
+ details: {
254
+ version: 1,
255
+ mode: "inspect",
256
+ status: complete ? "complete" : "partial",
257
+ snapshotComplete: complete,
258
+ totalMatches: 0,
259
+ storedMatches: 0,
260
+ totalFiles: new Set(targets.map((target) => target.absolutePath)).size,
261
+ returnedMatches: blocks.reduce((total, block) => total + block.lines.size, 0),
262
+ inspections: items,
263
+ },
264
+ };
265
+ }