sift-light 1.0.0 → 1.0.1

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.
@@ -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
  }
@@ -274,6 +274,14 @@ export async function combineHybridSearch(
274
274
  const selectionReason = conceptCandidatesOmitted
275
275
  ? `Hybrid concept limit retained the top ${String(selectedConcept.length)} of ${String(eligibleConcept.length)} non-overlapping semantic candidates`
276
276
  : undefined;
277
+ const firstScore = Number(
278
+ eligibleConcept[0]?.details?.rankingScore ?? eligibleConcept[0]?.details?.score,
279
+ );
280
+ const secondScore = Number(
281
+ eligibleConcept[1]?.details?.rankingScore ?? eligibleConcept[1]?.details?.score,
282
+ );
283
+ const closeRanking =
284
+ Number.isFinite(firstScore) && Number.isFinite(secondScore) && firstScore - secondScore < 0.01;
277
285
  return {
278
286
  kind: "hybrid",
279
287
  unit: "evidence-items",
@@ -285,6 +293,11 @@ export async function combineHybridSearch(
285
293
  ...literal.reasons,
286
294
  ...execution.sourceGeneration.reasons,
287
295
  ...(selectionReason ? [selectionReason] : []),
296
+ ...(closeRanking
297
+ ? [
298
+ "Semantic ranks are close; verify the leading candidates with source inspection or literal terms.",
299
+ ]
300
+ : []),
288
301
  ...(judgedConcept.semanticJudge?.reason ? [judgedConcept.semanticJudge.reason] : []),
289
302
  ],
290
303
  filesRead: (concept.filesRead ?? 0) + access.filesRead,
package/src/index.ts CHANGED
@@ -1,11 +1,6 @@
1
1
  import { SIFT_LIGHT_DESCRIPTION, siftLightSchema } from "./tool-schema.js";
2
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";
3
+ import { normalizeSearchEnforcement, readSiftLightConfig, type SiftLightConfig } from "./config.js";
9
4
  import { resolveContextBudget } from "./context-budget.js";
10
5
  import { createRipgrepRunner } from "./rg.js";
11
6
  import { createCtagsStructureProvider } from "./structure.js";
@@ -17,7 +12,7 @@ import type { SiftLightDetails } from "./types.js";
17
12
  import { renderSiftLightCall, renderSiftLightResult } from "./tui/renderers.js";
18
13
  import { registerPiSearchPolicy } from "./pi-search-policy.js";
19
14
  import { modelErrorText } from "./model-error.js";
20
- import { createSemanticJudgeIntegration } from "./semantic-judge.js";
15
+ import { createConfiguredSemanticJudgeIntegration } from "./semantic-judge.js";
21
16
 
22
17
  const SIFT_LIGHT_LABEL = "sift-light";
23
18
 
@@ -27,13 +22,12 @@ export async function registerSiftLightExtension(
27
22
  pi: ExtensionAPI,
28
23
  config: SiftLightConfig,
29
24
  ): Promise<void> {
30
- const semanticJudge = createSemanticJudgeIntegration(
31
- config.semanticJudge ?? DEFAULT_SEMANTIC_JUDGE_CONFIG,
32
- );
25
+ const semanticJudge = createConfiguredSemanticJudgeIntegration(config);
33
26
  const runtime = new SiftLightRuntime(
34
27
  new SiftLightService({
35
28
  runRipgrep: createRipgrepRunner(),
36
29
  structure: createCtagsStructureProvider(),
30
+ vectorSearchEnabled: config.vectorSearchEnabled === true,
37
31
  semanticJudge,
38
32
  }),
39
33
  );
@@ -7,7 +7,48 @@ function isRecord(value: unknown): value is Record<string, unknown> {
7
7
  return typeof value === "object" && value !== null && !Array.isArray(value);
8
8
  }
9
9
 
10
+ function compactSemanticMetadata(details: SiftLightDetails, analysis: AnalysisDetails): string[] {
11
+ const counts = analysis.counts;
12
+ const stats = analysis.stats;
13
+ const judge = analysis.semanticJudge;
14
+ const coverage = analysis.coverage;
15
+ return [
16
+ ...(counts || stats
17
+ ? [
18
+ `Search: ${String(counts?.filesAdmitted ?? stats?.filesAdmitted ?? 0)} files, ${String(stats?.passagesRanked ?? counts?.passagesQueued ?? 0)} passages; ${String(stats?.elapsedMs ?? 0)} ms; peak inference RSS ${String(stats?.inferencePeakRssBytes ?? 0)} bytes; cache ${String(stats?.conceptCacheHits ?? 0)} hits/${String(stats?.conceptCacheMisses ?? 0)} misses.`,
19
+ ]
20
+ : []),
21
+ ...(coverage
22
+ ? [
23
+ `Coverage: ${Object.entries(coverage)
24
+ .map(([name, status]) => `${name}=${status}`)
25
+ .join(", ")}.`,
26
+ ]
27
+ : []),
28
+ ...(analysis.scope
29
+ ? [`Scope: ${analysis.scope.path}; ignore=${analysis.scope.ignorePolicy}.`]
30
+ : []),
31
+ ...(analysis.sourceGeneration
32
+ ? [
33
+ `Source: ${analysis.sourceGeneration.verification}; ${String(analysis.sourceGeneration.filesUnavailable)} unavailable.`,
34
+ ]
35
+ : []),
36
+ ...(judge
37
+ ? [
38
+ `Semantic judge: ${judge.status}; ${String(judge.judgedCandidates)}/${String(judge.candidatesConsidered)} judged, ${String(judge.candidatesUnjudged)} unjudged; batches ${String(judge.batchesCompleted)}/${String(judge.batchesAttempted)} completed; classes ${JSON.stringify(judge.classificationCounts)}${judge.reason ? `; ${judge.reason}` : ""}.`,
39
+ ]
40
+ : []),
41
+ ...(details.operation
42
+ ? [`Operation: ${details.operation.state}; id=${details.operation.id}.`]
43
+ : []),
44
+ ...analysis.reasons.map((reason) => `[${reason}]`),
45
+ ...(details.redactionApplied ? ["[Display redaction applied.]"] : []),
46
+ ];
47
+ }
48
+
10
49
  function compactMetadata(details: SiftLightDetails, analysis: AnalysisDetails): string[] {
50
+ if (analysis.kind === "concept" || analysis.kind === "hybrid")
51
+ return compactSemanticMetadata(details, analysis);
11
52
  return [
12
53
  ...(analysis.statistics ? formatStatistics(analysis.statistics) : []),
13
54
  analysis.counts ? `Counts: ${JSON.stringify(analysis.counts)}` : undefined,
@@ -1,11 +1,12 @@
1
1
  import {
2
+ DEFAULT_SIFT_LIGHT_CONFIG,
2
3
  DEFAULT_SEMANTIC_JUDGE_CONFIG,
3
4
  readSiftLightConfigFile,
4
5
  SIFT_LIGHT_CONFIG_ENV,
5
6
  } from "./config-reader.js";
6
7
  import {
7
8
  createDisabledSemanticJudgeIntegration,
8
- createSemanticJudgeIntegration,
9
+ createConfiguredSemanticJudgeIntegration,
9
10
  type SemanticJudgeIntegration,
10
11
  } from "./semantic-judge.js";
11
12
 
@@ -19,17 +20,27 @@ function configuredPath(environment: NodeJS.ProcessEnv): string | undefined {
19
20
  * The MCP process must receive the same config path explicitly in its own host
20
21
  * environment; an absent path remains an observable, local-only disabled state.
21
22
  */
22
- export async function createMcpSemanticJudgeIntegration(
23
+ export async function createMcpSearchFeatures(
23
24
  environment: NodeJS.ProcessEnv = process.env,
24
- ): Promise<SemanticJudgeIntegration> {
25
+ ): Promise<{ semanticJudge: SemanticJudgeIntegration; vectorSearchEnabled: boolean }> {
25
26
  const path = configuredPath(environment);
26
- if (!path) return createDisabledSemanticJudgeIntegration(DEFAULT_SEMANTIC_JUDGE_CONFIG);
27
+ if (!path)
28
+ return {
29
+ semanticJudge: createDisabledSemanticJudgeIntegration(DEFAULT_SEMANTIC_JUDGE_CONFIG),
30
+ vectorSearchEnabled: DEFAULT_SIFT_LIGHT_CONFIG.vectorSearchEnabled === true,
31
+ };
27
32
 
28
33
  const config = await readSiftLightConfigFile(path, { missing: "error" });
29
- return createSemanticJudgeIntegration(
30
- config.semanticJudge ?? DEFAULT_SEMANTIC_JUDGE_CONFIG,
31
- environment,
32
- );
34
+ return {
35
+ semanticJudge: createConfiguredSemanticJudgeIntegration(config, environment),
36
+ vectorSearchEnabled: config.vectorSearchEnabled === true,
37
+ };
38
+ }
39
+
40
+ export async function createMcpSemanticJudgeIntegration(
41
+ environment: NodeJS.ProcessEnv = process.env,
42
+ ): Promise<SemanticJudgeIntegration> {
43
+ return (await createMcpSearchFeatures(environment)).semanticJudge;
33
44
  }
34
45
 
35
46
  export function mcpSemanticJudgeConfigSource(