sift-light 1.0.0 → 1.0.2

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 (49) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +11 -4
  3. package/README.zh-CN.md +11 -4
  4. package/package.json +1 -1
  5. package/plugins/sift-light/.claude-plugin/plugin.json +1 -1
  6. package/plugins/sift-light/.codex-plugin/plugin.json +1 -1
  7. package/plugins/sift-light/.mcp.json +1 -1
  8. package/plugins/sift-light/concept-worker.mjs +39 -12
  9. package/plugins/sift-light/kimi.plugin.json +2 -2
  10. package/plugins/sift-light/omp-extension.mjs +558 -83
  11. package/plugins/sift-light/package.json +1 -1
  12. package/src/analysis-limits.ts +1 -1
  13. package/src/analysis-store.ts +6 -2
  14. package/src/analysis-types.ts +10 -1
  15. package/src/concept-inference.ts +44 -11
  16. package/src/concept-model.ts +2 -2
  17. package/src/concept-search.ts +12 -2
  18. package/src/concept-worker.mjs +39 -12
  19. package/src/concept-worker.ts +1 -1
  20. package/src/config-reader.ts +11 -3
  21. package/src/evidence-service.ts +117 -13
  22. package/src/file-discovery.ts +111 -17
  23. package/src/hybrid-search.ts +13 -0
  24. package/src/index.ts +4 -10
  25. package/src/inspect.ts +3 -0
  26. package/src/language-capability-definitions.ts +9 -0
  27. package/src/mcp-model-output.ts +47 -1
  28. package/src/mcp-semantic-judge.ts +19 -8
  29. package/src/mcp-server.mjs +754 -247
  30. package/src/mcp-server.ts +11 -13
  31. package/src/mcp.ts +2 -0
  32. package/src/model-error.ts +6 -16
  33. package/src/omp-index.ts +4 -6
  34. package/src/prompt-guidelines.ts +5 -5
  35. package/src/python-roles.ts +247 -0
  36. package/src/redaction.ts +9 -2
  37. package/src/request-aliases.ts +48 -0
  38. package/src/request-contract-catalog.ts +24 -7
  39. package/src/request-contract.ts +60 -6
  40. package/src/request.ts +3 -1
  41. package/src/runtime.ts +2 -2
  42. package/src/semantic-judge.ts +17 -3
  43. package/src/service.ts +31 -1
  44. package/src/session-summary.ts +4 -4
  45. package/src/source-inspection.ts +27 -2
  46. package/src/tool-schema.ts +18 -18
  47. package/src/tui/layout.ts +2 -2
  48. package/src/tui/renderers.ts +2 -2
  49. package/src/types.ts +4 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "omp": {
@@ -13,7 +13,7 @@ export const MAX_ANALYSIS_STORAGE_BYTES = 32 * 1024 * 1024;
13
13
  export const ANALYSIS_METADATA_RESERVE_BYTES = 64 * 1024;
14
14
  export const MAX_ANALYSIS_REASONS = 64;
15
15
  export const MAX_ANALYSIS_REASON_BYTES = 4 * 1024;
16
- export const MIN_ANY_OF_TERMS = 2;
16
+ export const MIN_ANY_OF_TERMS = 1;
17
17
  export const MAX_ANY_OF_TERMS = 8;
18
18
  /** Public union size; execution keeps the proven eight-term scan boundary per chunk. */
19
19
  export const MAX_ANY_OF_TOTAL_TERMS = 64;
@@ -206,7 +206,10 @@ function publicAnalysisItem(
206
206
  return {
207
207
  path: item.path,
208
208
  line: item.line,
209
- label: result.kind === "outline" && modelOutput ? item.label : publicAnalysisLabel(result),
209
+ label:
210
+ (result.kind === "outline" && modelOutput) || result.kind === "files"
211
+ ? item.label
212
+ : publicAnalysisLabel(result),
210
213
  index: index + 1,
211
214
  ...(inspect ? { inspect } : {}),
212
215
  ...(publicDetails ? { details: publicDetails } : {}),
@@ -455,7 +458,8 @@ export class AnalysisStore {
455
458
  if (items.length >= 30 || !appendItem(index)) break;
456
459
  }
457
460
  } else {
458
- for (let index = offset; index < result.items.length && items.length < 30; index += 1) {
461
+ const pageSize = result.pageSize ?? 30;
462
+ for (let index = offset; index < result.items.length && items.length < pageSize; index += 1) {
459
463
  if (!appendItem(index)) break;
460
464
  }
461
465
  }
@@ -4,7 +4,12 @@ import type { SearchScopeDetails, ResultStatistics } from "./types.js";
4
4
  import type { ValidationDetails } from "./validation-types.js";
5
5
  import type { ConceptSourceSummary } from "./concept-source-generation.js";
6
6
 
7
- export type CoverageStatus = "complete" | "partial" | "skipped" | "not-applicable";
7
+ export type CoverageStatus =
8
+ | "complete"
9
+ | "partial"
10
+ | "policy-filtered"
11
+ | "skipped"
12
+ | "not-applicable";
8
13
 
9
14
  export const SEMANTIC_JUDGE_CLASSIFICATIONS = [
10
15
  "implementation-candidate",
@@ -125,6 +130,8 @@ export interface AnalysisDetails {
125
130
  passagesRanked?: number;
126
131
  elapsedMs?: number;
127
132
  filesEnumerated?: number;
133
+ ignoredFiles?: number;
134
+ ignoredMatches?: number;
128
135
  filesAdmitted?: number;
129
136
  filesParsed?: number;
130
137
  filesSkipped?: number;
@@ -159,4 +166,6 @@ export interface AnalysisResultSet {
159
166
  stats?: AnalysisDetails["stats"];
160
167
  sourceGeneration?: ConceptSourceSummary;
161
168
  redact?: boolean;
169
+ /** Explicit output page size (`limit`); pages default to 30 items. */
170
+ pageSize?: number;
162
171
  }
@@ -21,7 +21,9 @@ export interface ConceptEmbeddingCallbacks {
21
21
  ) => void | Promise<void>;
22
22
  }
23
23
 
24
- const INFERENCE_BATCH_SIZE = 16;
24
+ // Bound each ONNX call to four model windows. Larger native batches raised peak
25
+ // RSS without a useful cold-start gain on the measured workload.
26
+ const INFERENCE_BATCH_SIZE = 4;
25
27
 
26
28
  /** Snap a candidate UTF-16 index so it never splits a surrogate pair. */
27
29
  export function safeUtf16End(text: string, end: number): number {
@@ -62,6 +64,10 @@ export function maximumTokenSafeEnd(
62
64
  text: string,
63
65
  start: number,
64
66
  ): number {
67
+ // Most source passages already fit the model window. Avoid repeatedly
68
+ // tokenizing prefixes of the same passage in the binary-search path.
69
+ if (tokenCount(extractor, `${prefix}${text.slice(start)}`) <= CONCEPT_MODEL_TOKENS)
70
+ return text.length;
65
71
  let low = start + 1;
66
72
  let high = text.length;
67
73
  let accepted = start;
@@ -142,30 +148,56 @@ export async function embedConceptInputs(
142
148
  ): Promise<CachedConceptEmbedding[]> {
143
149
  const layouts = pending.map((item) => tokenSafeWindows(extractor, item));
144
150
  const inputs = layouts.flatMap((windows) => windows.map((window) => window.input));
145
- const vectors: number[][] = [];
151
+ const vectors: (number[] | undefined)[] = Array(inputs.length);
146
152
  const layoutEnds: number[] = [];
153
+ const remainingWindows = layouts.map((windows) => windows.length);
147
154
  let layoutEnd = 0;
148
155
  for (const windows of layouts) {
149
156
  layoutEnd += windows.length;
150
157
  layoutEnds.push(layoutEnd);
151
158
  }
159
+ const orderedInputs = inputs
160
+ .map((input, index) => ({ input, index, tokens: tokenCount(extractor, input) }))
161
+ // Start with the largest shapes so the native arena can reuse allocations
162
+ // for smaller batches instead of retaining a growing sequence of arenas.
163
+ .toSorted((left, right) => right.tokens - left.tokens || left.index - right.index);
164
+ const vectorFor = (index: number): number[] => {
165
+ const vector = vectors[index];
166
+ if (!vector) throw new Error("Missing concept embedding vector");
167
+ return vector;
168
+ };
169
+ const ownerOf = (index: number): number => {
170
+ let low = 0;
171
+ let high = layoutEnds.length;
172
+ while (low < high) {
173
+ const middle = Math.floor((low + high) / 2);
174
+ if (index < layoutEnds[middle]!) high = middle;
175
+ else low = middle + 1;
176
+ }
177
+ return low;
178
+ };
152
179
  let completedEmbedding = 0;
153
- for (let offset = 0; offset < inputs.length; offset += INFERENCE_BATCH_SIZE) {
154
- const batch = inputs.slice(offset, offset + INFERENCE_BATCH_SIZE);
180
+ let completedWindows = 0;
181
+ for (let offset = 0; offset < orderedInputs.length;) {
182
+ const orderedBatch = orderedInputs.slice(offset, offset + INFERENCE_BATCH_SIZE);
183
+ const batch = orderedBatch.map((item) => item.input);
155
184
  // oxlint-disable-next-line no-await-in-loop -- bounded batches cap native tensor memory.
156
185
  const tensor = await extractor(batch, { pooling: "mean", normalize: true });
157
186
  if (tensor.data.length !== batch.length * CONCEPT_EMBEDDING_DIMENSIONS)
158
187
  throw new Error("Unexpected concept embedding dimensions");
159
188
  for (let index = 0; index < batch.length; index += 1) {
160
189
  const start = index * CONCEPT_EMBEDDING_DIMENSIONS;
161
- vectors.push(
162
- Array.from(tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS), Number),
190
+ const inputIndex = orderedBatch[index]!.index;
191
+ vectors[inputIndex] = Array.from(
192
+ tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS),
193
+ Number,
163
194
  );
195
+ remainingWindows[ownerOf(inputIndex)]!--;
164
196
  }
165
- const completedWindows = Math.min(offset + batch.length, inputs.length);
197
+ completedWindows += batch.length;
166
198
  while (
167
- layoutEnds[completedEmbedding] !== undefined &&
168
- layoutEnds[completedEmbedding]! <= completedWindows
199
+ remainingWindows[completedEmbedding] !== undefined &&
200
+ remainingWindows[completedEmbedding] === 0
169
201
  ) {
170
202
  const start = completedEmbedding === 0 ? 0 : layoutEnds[completedEmbedding - 1]!;
171
203
  const item = pending[completedEmbedding];
@@ -177,7 +209,7 @@ export async function embedConceptInputs(
177
209
  windows: (layouts[completedEmbedding] ?? []).map((window, index) => ({
178
210
  start: window.start,
179
211
  end: window.end,
180
- vector: vectors[start + index] ?? [],
212
+ vector: vectorFor(start + index),
181
213
  })),
182
214
  },
183
215
  completedEmbedding + 1,
@@ -186,6 +218,7 @@ export async function embedConceptInputs(
186
218
  completedEmbedding += 1;
187
219
  }
188
220
  callbacks.onBatch?.(completedWindows, inputs.length);
221
+ offset += batch.length;
189
222
  }
190
223
  let vectorIndex = 0;
191
224
  return pending.map((item, index) => ({
@@ -193,7 +226,7 @@ export async function embedConceptInputs(
193
226
  windows: (layouts[index] ?? []).map((window) => ({
194
227
  start: window.start,
195
228
  end: window.end,
196
- vector: vectors[vectorIndex++] ?? [],
229
+ vector: vectorFor(vectorIndex++),
197
230
  })),
198
231
  }));
199
232
  }
@@ -8,10 +8,10 @@ export const CONCEPT_MODEL = "Xenova/multilingual-e5-small";
8
8
  export const CONCEPT_REVISION = "761b726dd34fb83930e26aab4e9ac3899aa1fa78";
9
9
  export const MAX_CONCEPT_CHARS = 1_000;
10
10
  export const CONCEPT_PASSAGE_OVERLAP_CHARS = 160;
11
- export const CONCEPT_MODEL_TOKENS = 512;
11
+ export const CONCEPT_MODEL_TOKENS = 256;
12
12
  export const CONCEPT_WINDOW_OVERLAP_TOKENS = 64;
13
13
  export const CONCEPT_EMBEDDING_DIMENSIONS = 384;
14
- export const CONCEPT_CACHE_VERSION = 1;
14
+ export const CONCEPT_CACHE_VERSION = 2;
15
15
  export const CONCEPT_CACHE_MAX_BYTES = 512 * 1024 * 1024;
16
16
  export const CONCEPT_TIMEOUT_MS = 10 * 60_000;
17
17
  export const MIN_CONCEPT_TIMEOUT_MS = 1_000;
@@ -86,6 +86,14 @@ function scoreProfile(scores: readonly number[]): ConceptScoreProfile {
86
86
  };
87
87
  }
88
88
 
89
+ /** E5 cosine scores favor very short, generic passages in close races. Keep the
90
+ * raw cosine as evidence and apply a bounded length correction only to rank. */
91
+ export function conceptRankingScore(cosine: number, passageLength: number): number {
92
+ const referenceLength = 500;
93
+ const maximumCorrection = 0.02;
94
+ return cosine - maximumCorrection * (1 - Math.sqrt(Math.min(passageLength / referenceLength, 1)));
95
+ }
96
+
89
97
  function passage(document: SourceDocument, start: number): { value: Passage; next: number } {
90
98
  let end = Math.min(document.text.length, start + MAX_CONCEPT_CHARS);
91
99
  if (end < document.text.length) {
@@ -433,6 +441,7 @@ async function runConceptSearch(
433
441
  const batchItems = passages.map((item, index) => {
434
442
  const similarity = inferred.scores[index];
435
443
  if (similarity === undefined) throw new Error("Missing concept similarity");
444
+ const rankingScore = conceptRankingScore(similarity, item.text.length);
436
445
  const evidence = rangeEvidence(item.document, item.range);
437
446
  return {
438
447
  path: item.document.path,
@@ -445,8 +454,9 @@ async function runConceptSearch(
445
454
  kind: "concept-candidate",
446
455
  certainty: "candidate",
447
456
  score: similarity,
457
+ rankingScore,
448
458
  rankingReason:
449
- "local multilingual E5 cosine similarity; relevance candidate, no binding or execution claim",
459
+ "local multilingual E5 cosine similarity with bounded short-passage rank correction; relevance candidate, no binding or execution claim",
450
460
  model: CONCEPT_MODEL,
451
461
  revision: CONCEPT_REVISION,
452
462
  tokenTruncated: false,
@@ -457,7 +467,7 @@ async function runConceptSearch(
457
467
  });
458
468
  const ranked = [...result.items, ...batchItems].toSorted(
459
469
  (a, b) =>
460
- Number(b.details?.score) - Number(a.details?.score) ||
470
+ Number(b.details?.rankingScore) - Number(a.details?.rankingScore) ||
461
471
  a.path.localeCompare(b.path) ||
462
472
  a.line - b.line,
463
473
  );
@@ -27,10 +27,10 @@ class SiftLightError extends Error {
27
27
  var CONCEPT_MODEL = "Xenova/multilingual-e5-small";
28
28
  var CONCEPT_REVISION = "761b726dd34fb83930e26aab4e9ac3899aa1fa78";
29
29
  var MAX_CONCEPT_CHARS = 1000;
30
- var CONCEPT_MODEL_TOKENS = 512;
30
+ var CONCEPT_MODEL_TOKENS = 256;
31
31
  var CONCEPT_WINDOW_OVERLAP_TOKENS = 64;
32
32
  var CONCEPT_EMBEDDING_DIMENSIONS = 384;
33
- var CONCEPT_CACHE_VERSION = 1;
33
+ var CONCEPT_CACHE_VERSION = 2;
34
34
  var CONCEPT_CACHE_MAX_BYTES = 512 * 1024 * 1024;
35
35
  var CONCEPT_TIMEOUT_MS = 10 * 60000;
36
36
  var MAX_CONCEPT_TIMEOUT_MS = 60 * 60000;
@@ -222,7 +222,7 @@ async function enforceConceptCacheLimit(root, maximumBytes = CONCEPT_CACHE_MAX_B
222
222
  }
223
223
 
224
224
  // src/concept-inference.ts
225
- var INFERENCE_BATCH_SIZE = 16;
225
+ var INFERENCE_BATCH_SIZE = 4;
226
226
  function safeUtf16End(text, end) {
227
227
  if (end <= 0 || end >= text.length)
228
228
  return end;
@@ -247,6 +247,8 @@ function binarySearchBudget(span) {
247
247
  return 2 * Math.max(span, 1) + 32;
248
248
  }
249
249
  function maximumTokenSafeEnd(extractor, prefix, text, start) {
250
+ if (tokenCount(extractor, `${prefix}${text.slice(start)}`) <= CONCEPT_MODEL_TOKENS)
251
+ return text.length;
250
252
  let low = start + 1;
251
253
  let high = text.length;
252
254
  let accepted = start;
@@ -312,25 +314,49 @@ function tokenSafeWindows(extractor, pending) {
312
314
  async function embedConceptInputs(extractor, pending, callbacks = {}) {
313
315
  const layouts = pending.map((item) => tokenSafeWindows(extractor, item));
314
316
  const inputs = layouts.flatMap((windows) => windows.map((window) => window.input));
315
- const vectors = [];
317
+ const vectors = Array(inputs.length);
316
318
  const layoutEnds = [];
319
+ const remainingWindows = layouts.map((windows) => windows.length);
317
320
  let layoutEnd = 0;
318
321
  for (const windows of layouts) {
319
322
  layoutEnd += windows.length;
320
323
  layoutEnds.push(layoutEnd);
321
324
  }
325
+ const orderedInputs = inputs.map((input, index) => ({ input, index, tokens: tokenCount(extractor, input) })).toSorted((left, right) => right.tokens - left.tokens || left.index - right.index);
326
+ const vectorFor = (index) => {
327
+ const vector = vectors[index];
328
+ if (!vector)
329
+ throw new Error("Missing concept embedding vector");
330
+ return vector;
331
+ };
332
+ const ownerOf = (index) => {
333
+ let low = 0;
334
+ let high = layoutEnds.length;
335
+ while (low < high) {
336
+ const middle = Math.floor((low + high) / 2);
337
+ if (index < layoutEnds[middle])
338
+ high = middle;
339
+ else
340
+ low = middle + 1;
341
+ }
342
+ return low;
343
+ };
322
344
  let completedEmbedding = 0;
323
- for (let offset = 0;offset < inputs.length; offset += INFERENCE_BATCH_SIZE) {
324
- const batch = inputs.slice(offset, offset + INFERENCE_BATCH_SIZE);
345
+ let completedWindows = 0;
346
+ for (let offset = 0;offset < orderedInputs.length; ) {
347
+ const orderedBatch = orderedInputs.slice(offset, offset + INFERENCE_BATCH_SIZE);
348
+ const batch = orderedBatch.map((item) => item.input);
325
349
  const tensor = await extractor(batch, { pooling: "mean", normalize: true });
326
350
  if (tensor.data.length !== batch.length * CONCEPT_EMBEDDING_DIMENSIONS)
327
351
  throw new Error("Unexpected concept embedding dimensions");
328
352
  for (let index = 0;index < batch.length; index += 1) {
329
353
  const start = index * CONCEPT_EMBEDDING_DIMENSIONS;
330
- vectors.push(Array.from(tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS), Number));
354
+ const inputIndex = orderedBatch[index].index;
355
+ vectors[inputIndex] = Array.from(tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS), Number);
356
+ remainingWindows[ownerOf(inputIndex)]--;
331
357
  }
332
- const completedWindows = Math.min(offset + batch.length, inputs.length);
333
- while (layoutEnds[completedEmbedding] !== undefined && layoutEnds[completedEmbedding] <= completedWindows) {
358
+ completedWindows += batch.length;
359
+ while (remainingWindows[completedEmbedding] !== undefined && remainingWindows[completedEmbedding] === 0) {
334
360
  const start = completedEmbedding === 0 ? 0 : layoutEnds[completedEmbedding - 1];
335
361
  const item = pending[completedEmbedding];
336
362
  if (!item)
@@ -340,12 +366,13 @@ async function embedConceptInputs(extractor, pending, callbacks = {}) {
340
366
  windows: (layouts[completedEmbedding] ?? []).map((window, index) => ({
341
367
  start: window.start,
342
368
  end: window.end,
343
- vector: vectors[start + index] ?? []
369
+ vector: vectorFor(start + index)
344
370
  }))
345
371
  }, completedEmbedding + 1, pending.length);
346
372
  completedEmbedding += 1;
347
373
  }
348
374
  callbacks.onBatch?.(completedWindows, inputs.length);
375
+ offset += batch.length;
349
376
  }
350
377
  let vectorIndex = 0;
351
378
  return pending.map((item, index) => ({
@@ -353,7 +380,7 @@ async function embedConceptInputs(extractor, pending, callbacks = {}) {
353
380
  windows: (layouts[index] ?? []).map((window) => ({
354
381
  start: window.start,
355
382
  end: window.end,
356
- vector: vectors[vectorIndex++] ?? []
383
+ vector: vectorFor(vectorIndex++)
357
384
  }))
358
385
  }));
359
386
  }
@@ -504,7 +531,7 @@ async function search() {
504
531
  local_files_only: true,
505
532
  dtype: "q8",
506
533
  device: "cpu",
507
- session_options: { intraOpNumThreads: 2, interOpNumThreads: 1 }
534
+ session_options: { intraOpNumThreads: 4, interOpNumThreads: 1 }
508
535
  });
509
536
  try {
510
537
  created = await embedConceptInputs(extractor, missing, {
@@ -140,7 +140,7 @@ async function search(): Promise<void> {
140
140
  local_files_only: true,
141
141
  dtype: "q8",
142
142
  device: "cpu",
143
- session_options: { intraOpNumThreads: 2, interOpNumThreads: 1 },
143
+ session_options: { intraOpNumThreads: 4, interOpNumThreads: 1 },
144
144
  });
145
145
  try {
146
146
  created = await embedConceptInputs(extractor, missing, {
@@ -12,6 +12,7 @@ export const SIFT_LIGHT_ENFORCEMENT_ENV = "SIFT_LIGHT_ENFORCE_SEARCH";
12
12
  export interface SiftLightConfig {
13
13
  locale: SiftLightLocale;
14
14
  enforceSearch?: SearchEnforcementMode;
15
+ vectorSearchEnabled?: boolean;
15
16
  semanticJudge?: SemanticJudgeConfig;
16
17
  }
17
18
 
@@ -44,12 +45,14 @@ export const DEFAULT_SEMANTIC_JUDGE_CONFIG: Readonly<SemanticJudgeConfig> = {
44
45
  export const DEFAULT_SIFT_LIGHT_CONFIG: Readonly<SiftLightConfig> = {
45
46
  locale: "en",
46
47
  enforceSearch: "hard",
48
+ vectorSearchEnabled: false,
47
49
  semanticJudge: DEFAULT_SEMANTIC_JUDGE_CONFIG,
48
50
  };
49
51
 
50
52
  interface RawSiftLightConfig {
51
53
  locale?: unknown;
52
54
  enforceSearch?: unknown;
55
+ vectorSearchEnabled?: unknown;
53
56
  semanticJudge?: unknown;
54
57
  }
55
58
 
@@ -221,21 +224,26 @@ function parseConfig(value: unknown, path: string): SiftLightConfig {
221
224
  throw new Error(`Invalid sift-light config at ${path}: expected a JSON object`);
222
225
  }
223
226
  const unknown = Object.keys(value).filter(
224
- (key) => !["locale", "enforceSearch", "semanticJudge"].includes(key),
227
+ (key) => !["locale", "enforceSearch", "vectorSearchEnabled", "semanticJudge"].includes(key),
225
228
  );
226
229
  if (unknown.length > 0) {
227
230
  throw new Error(
228
- `Invalid sift-light config at ${path}: unsupported configuration fields; only locale, enforceSearch and semanticJudge are accepted`,
231
+ `Invalid sift-light config at ${path}: unsupported configuration fields; only locale, enforceSearch, vectorSearchEnabled and semanticJudge are accepted`,
229
232
  );
230
233
  }
231
- const { locale, enforceSearch } = value;
234
+ const { locale, enforceSearch, vectorSearchEnabled } = value;
232
235
  if (locale !== undefined && locale !== "en" && locale !== "zh-CN") {
233
236
  throw new Error(`Invalid sift-light config at ${path}: locale must be "en" or "zh-CN"`);
234
237
  }
238
+ if (vectorSearchEnabled !== undefined && typeof vectorSearchEnabled !== "boolean") {
239
+ throw new Error(`Invalid sift-light config at ${path}: vectorSearchEnabled must be a boolean`);
240
+ }
235
241
  const enforcement = normalizeSearchEnforcement(enforceSearch, `config at ${path}`);
236
242
  return {
237
243
  locale: locale ?? DEFAULT_SIFT_LIGHT_CONFIG.locale,
238
244
  enforceSearch: enforcement,
245
+ vectorSearchEnabled:
246
+ vectorSearchEnabled ?? DEFAULT_SIFT_LIGHT_CONFIG.vectorSearchEnabled ?? false,
239
247
  semanticJudge: parseSemanticJudge(value.semanticJudge, path),
240
248
  };
241
249
  }
@@ -39,7 +39,6 @@ import {
39
39
  continueSource,
40
40
  inspectDocuments,
41
41
  matchInspectionTarget,
42
- inspectDocumentsMetadata,
43
42
  type SourceInspectionTarget,
44
43
  } from "./source-inspection.js";
45
44
  import { validateSavedEvidence } from "./evidence-validation.js";
@@ -48,6 +47,7 @@ import type { CodeStructureProvider } from "./structure.js";
48
47
  import { filterRoleOccurrences, findFunctionConjunctions } from "./syntax-search.js";
49
48
  import { syntaxLanguage } from "./syntax.js";
50
49
  import { parsePythonOutline } from "./python-outline.js";
50
+ import { pythonRoleAnalysis } from "./python-roles.js";
51
51
  import {
52
52
  combineHybridSearch,
53
53
  hybridConceptLimit,
@@ -66,10 +66,12 @@ import {
66
66
  MAX_STRUCTURE_FILES,
67
67
  } from "./analysis-limits.js";
68
68
  import {
69
+ MAX_INSPECT_REQUEST_TARGETS,
69
70
  MAX_INSPECT_TARGETS,
70
71
  type SearchRequest,
71
72
  type SearchScopeDetails,
72
73
  type SiftLightResult,
74
+ type StructureDetails,
73
75
  } from "./types.js";
74
76
 
75
77
  export function isEvidenceRequest(input: SiftLightInput): boolean {
@@ -95,6 +97,100 @@ export function isEvidenceRequest(input: SiftLightInput): boolean {
95
97
  );
96
98
  }
97
99
 
100
+ /**
101
+ * Splits one inspect request into the page served now and an exact request for
102
+ * the rest. `paths` without a cursor opens each file from line 1.
103
+ */
104
+ function pageInspectRequest(input: SiftLightInput): {
105
+ current: SiftLightInput;
106
+ remaining?: SiftLightInput;
107
+ } {
108
+ if (input.scope === "expand")
109
+ throw new SiftLightError(
110
+ "mode=inspect reads exact locations; scope=expand does not apply (omit scope)",
111
+ );
112
+ const { scope: _scope, paths, ...rest } = input;
113
+ let request: SiftLightInput = rest;
114
+ if (paths !== undefined) {
115
+ if (
116
+ input.cursor !== undefined ||
117
+ input.targets !== undefined ||
118
+ input.matchIndices !== undefined ||
119
+ input.path !== undefined ||
120
+ input.line !== undefined ||
121
+ input.matchIndex !== undefined
122
+ )
123
+ throw new SiftLightError(
124
+ "mode=inspect paths opens files from line 1 and cannot be combined with cursor, targets, matchIndices, path, line or matchIndex",
125
+ );
126
+ request = { ...rest, targets: paths.map((path) => ({ path, line: 1 })) };
127
+ }
128
+ const redact = input.redact ? { redact: true } : {};
129
+ const requested = request.targets?.length ?? request.matchIndices?.length ?? 0;
130
+ if (requested > MAX_INSPECT_REQUEST_TARGETS)
131
+ throw new SiftLightError(
132
+ `mode=inspect accepts at most ${String(MAX_INSPECT_REQUEST_TARGETS)} targets per request; ${String(MAX_INSPECT_TARGETS)} are inspected per response`,
133
+ );
134
+ if (request.targets && request.targets.length > MAX_INSPECT_TARGETS)
135
+ return {
136
+ current: { ...request, targets: request.targets.slice(0, MAX_INSPECT_TARGETS) },
137
+ remaining: {
138
+ mode: "inspect",
139
+ targets: request.targets.slice(MAX_INSPECT_TARGETS),
140
+ ...redact,
141
+ },
142
+ };
143
+ if (request.matchIndices && request.matchIndices.length > MAX_INSPECT_TARGETS)
144
+ return {
145
+ current: { ...request, matchIndices: request.matchIndices.slice(0, MAX_INSPECT_TARGETS) },
146
+ remaining: {
147
+ mode: "inspect",
148
+ ...(request.cursor !== undefined ? { cursor: request.cursor } : {}),
149
+ matchIndices: request.matchIndices.slice(MAX_INSPECT_TARGETS),
150
+ ...redact,
151
+ },
152
+ };
153
+ return { current: request };
154
+ }
155
+
156
+ /** The unserved targets stay explicit: the page is partial and names its exact continuation. */
157
+ function withRemainingInspection(
158
+ result: SiftLightResult,
159
+ remaining: SiftLightInput,
160
+ ): SiftLightResult {
161
+ const count = remaining.targets?.length ?? remaining.matchIndices?.length ?? 0;
162
+ return {
163
+ text: `${result.text}\n\n[${String(count)} more target(s) were not inspected; one response inspects ${String(MAX_INSPECT_TARGETS)}.]\nNext request: ${JSON.stringify(remaining)}`,
164
+ details: {
165
+ ...result.details,
166
+ status: "partial",
167
+ snapshotComplete: false,
168
+ nextRequest: remaining,
169
+ },
170
+ };
171
+ }
172
+
173
+ /** Structure facts already proven by the analysis that produced a bounded item. */
174
+ function analysisItemStructure(item: AnalysisItem): StructureDetails {
175
+ const details = item.details ?? {};
176
+ const language = typeof details.language === "string" ? details.language : undefined;
177
+ const name = typeof details.name === "string" ? details.name : undefined;
178
+ const scope = Array.isArray(details.scope)
179
+ ? details.scope.filter((entry): entry is string => typeof entry === "string")
180
+ : typeof details.scope === "string"
181
+ ? [details.scope]
182
+ : [];
183
+ const kind = details.kind === "function" ? "function" : (item.label.split(" ", 1)[0] ?? "symbol");
184
+ return {
185
+ status: "available",
186
+ provider: language === "python" ? "python-outline" : "tree-sitter",
187
+ ...(language ? { language } : {}),
188
+ ...(name
189
+ ? { symbol: { name, kind, scope, range: { startLine: item.line, endLine: item.line } } }
190
+ : {}),
191
+ };
192
+ }
193
+
98
194
  export interface EvidenceSearchOptions {
99
195
  modelOutput?: boolean;
100
196
  onProgress?: (progress: OperationProgress) => void;
@@ -126,12 +222,12 @@ function validateTerms(input: SiftLightInput): string[] | undefined {
126
222
  }
127
223
  if (
128
224
  !Array.isArray(terms) ||
129
- terms.length < 2 ||
225
+ terms.length < 1 ||
130
226
  terms.length > 3 ||
131
227
  terms.some((term) => typeof term !== "string" || !term.trim() || /[\r\n\0]/.test(term)) ||
132
228
  new Set(terms).size !== terms.length
133
229
  )
134
- throw new SiftLightError("allOf requires 2–3 distinct, nonempty, single-line literal terms");
230
+ throw new SiftLightError("allOf requires 1–3 distinct, nonempty, single-line literal terms");
135
231
  if (
136
232
  input.pattern !== undefined ||
137
233
  input.roles !== undefined ||
@@ -396,13 +492,13 @@ export class EvidenceService {
396
492
  if (typeof input.sourceCursor !== "string" || !input.sourceCursor.trim())
397
493
  throw new CursorError("A nonempty sourceCursor is required");
398
494
  if (input.mode !== "inspect") throw new SiftLightError("sourceCursor requires mode=inspect");
399
- return continueSource(input.sourceCursor, access, this.#continuations);
495
+ return continueSource(input.sourceCursor, access, this.#continuations, input.path);
400
496
  }
401
497
  if (input.mode === "inspect") {
402
- const targets = this.#inspectionTargets(input, cwd);
403
- return targets.some((target) => target.range !== undefined)
404
- ? inspectDocumentsMetadata(targets, access, this.#structure)
405
- : inspectDocuments(targets, access, this.#continuations, this.#structure);
498
+ const { current, remaining } = pageInspectRequest(input);
499
+ const targets = this.#inspectionTargets(current, cwd);
500
+ const result = await inspectDocuments(targets, access, this.#continuations, this.#structure);
501
+ return remaining ? withRemainingInspection(result, remaining) : result;
406
502
  }
407
503
  if (input.mode === "concept") {
408
504
  const execution = await this.#conceptSearch(input, access, options.onProgress);
@@ -518,7 +614,7 @@ export class EvidenceService {
518
614
  "anyOf is an explicit case-sensitive literal union; omit pattern, allOf, within, roles, literal and ignoreCase",
519
615
  );
520
616
  if (input.mode !== undefined && input.mode !== "auto" && input.mode !== "matches")
521
- throw new SiftLightError("anyOf mode must be omitted, auto, or matches");
617
+ throw new SiftLightError("anyOf mode must be omitted, auto, matches or summary");
522
618
  const chunks = Array.from(
523
619
  { length: Math.ceil(anyOf.length / MAX_ANY_OF_TERMS) },
524
620
  (_, index) => anyOf.slice(index * MAX_ANY_OF_TERMS, (index + 1) * MAX_ANY_OF_TERMS),
@@ -696,8 +792,12 @@ export class EvidenceService {
696
792
  );
697
793
  if (item) result.items.push(item);
698
794
  } else if (terms || input.roles) {
699
- if (syntaxLanguage(file.document.path)) syntaxCapableFiles += 1;
700
- const syntax = await access.syntax(file.document);
795
+ // Python roles come from a bounded lexical scanner; other languages parse.
796
+ const pythonRoles = !terms && /\.py$/iu.test(file.document.path);
797
+ if (pythonRoles || syntaxLanguage(file.document.path)) syntaxCapableFiles += 1;
798
+ const syntax = pythonRoles
799
+ ? pythonRoleAnalysis(file.document)
800
+ : await access.syntax(file.document);
701
801
  const classified = terms
702
802
  ? findFunctionConjunctions(
703
803
  file.document,
@@ -790,12 +890,16 @@ export class EvidenceService {
790
890
  const item = this.#analyses.item(input.cursor, input.matchIndex);
791
891
  if (!item.source || !item.range)
792
892
  throw new CursorError("This analysis item has no verified source range");
793
- const metadataOnly = item.details?.kind === "symbol" || item.details?.kind === "function";
893
+ // Outline symbols and function conjunctions carry their exact syntax range,
894
+ // so inspection returns that version-checked range as source (issue #96).
895
+ const bounded = item.details?.kind === "symbol" || item.details?.kind === "function";
794
896
  return {
795
897
  path: item.path,
796
898
  line: item.line,
797
899
  reference: item.source,
798
- ...(metadataOnly ? { range: item.range } : { absoluteFocus: item.range.start }),
900
+ ...(bounded
901
+ ? { range: item.range, structure: analysisItemStructure(item) }
902
+ : { absoluteFocus: item.range.start }),
799
903
  };
800
904
  }
801
905
  return {