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
@@ -13,6 +13,42 @@ interface FileScore {
13
13
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
14
14
  /** Bracket classes remain valid query text, so dynamic route names stay searchable. */
15
15
  const FILE_QUERY_GLOB_WILDCARD = /[*?]/u;
16
+ /** Queries such as `*`, `**` or `.*` mean "every file" rather than filename text. */
17
+ const FILE_QUERY_MATCH_ALL = /^(?:[*/]+|\.\*|\.\+|\*\.\*)$/u;
18
+ /** Regex syntax that cannot be a simple glob; such a query is rejected with guidance. */
19
+ const FILE_QUERY_REGEX_SYNTAX = /[()|^$\\+]/u;
20
+ const IGNORED_MATCH_SAMPLES = 5;
21
+
22
+ interface NormalizedFileQuery {
23
+ query: string;
24
+ glob?: string;
25
+ note?: string;
26
+ }
27
+
28
+ /**
29
+ * Filename discovery matches text. A wildcard-only query means "all files" and a
30
+ * plain glob such as `*.py` becomes an explicit glob filter; both are disclosed.
31
+ */
32
+ function normalizeFileQuery(query: string, hasGlob: boolean): NormalizedFileQuery {
33
+ const trimmed = query.trim();
34
+ if (FILE_QUERY_MATCH_ALL.test(trimmed))
35
+ return {
36
+ query: "",
37
+ note: `Query ${JSON.stringify(query)} was treated as listing every file under path; omit query for the same result.`,
38
+ };
39
+ if (!FILE_QUERY_GLOB_WILDCARD.test(trimmed)) return { query };
40
+ if (!hasGlob && !FILE_QUERY_REGEX_SYNTAX.test(trimmed) && !/\s/u.test(trimmed)) {
41
+ const glob = trimmed.includes("/") ? trimmed : `**/${trimmed}`;
42
+ return {
43
+ query: "",
44
+ glob,
45
+ note: `Query ${JSON.stringify(query)} contains glob wildcards and was applied as glob ${JSON.stringify(glob)}; mode=files query otherwise matches filename text.`,
46
+ };
47
+ }
48
+ throw new SiftLightError(
49
+ `File query ${JSON.stringify(query)} uses wildcard or regex syntax; mode=files matches filename and path text, not patterns. Omit query to retain every file under path, use glob for one name pattern, or run one files request per name.`,
50
+ );
51
+ }
16
52
 
17
53
  function subsequenceScore(text: string, query: string): number | undefined {
18
54
  const characters = Array.from(graphemes.segment(text), (item) => item.segment);
@@ -70,26 +106,33 @@ export async function discoverFiles(
70
106
  cwd: string,
71
107
  signal?: AbortSignal,
72
108
  ): Promise<AnalysisResultSet> {
73
- const query = input.query ?? "";
74
- if (query.length > 256 || !query.isWellFormed() || /[\r\n\0]/.test(query))
109
+ const rawQuery = input.query ?? "";
110
+ if (rawQuery.length > 256 || !rawQuery.isWellFormed() || /[\r\n\0]/.test(rawQuery))
75
111
  throw new SiftLightError(
76
112
  "File query must be well-formed single-line text of at most 256 characters",
77
113
  );
78
- // A wildcard query can never match filename text, so scoring it would discard
79
- // every enumerated file and report an empty result as complete.
80
- if (FILE_QUERY_GLOB_WILDCARD.test(query))
81
- throw new SiftLightError(
82
- `File query ${JSON.stringify(query)} uses glob wildcards; mode=files matches filename and path text, not glob patterns. Omit query to retain every file under path, or use glob to filter by name pattern.`,
83
- );
84
- const request = normalizeRequest({ ...input, pattern: "" });
114
+ const requestedGlobs =
115
+ input.glob === undefined ? [] : Array.isArray(input.glob) ? input.glob : [input.glob];
116
+ const normalizedQuery = normalizeFileQuery(rawQuery, requestedGlobs.length > 0);
117
+ const query = normalizedQuery.query;
118
+ const request = normalizeRequest({
119
+ ...input,
120
+ pattern: "",
121
+ ...(normalizedQuery.glob ? { glob: [...requestedGlobs, normalizedQuery.glob] } : {}),
122
+ });
85
123
  const policy = new SearchPathPolicy(cwd);
86
124
  const discoveryRoot = request.path ?? ".";
87
125
  const scoringRoot = await policy.resolveSearchTarget(discoveryRoot);
88
- const files = await listWorkspaceFiles(cwd, signal, {
126
+ const includeIgnored = request.ignorePolicy === "include";
127
+ const listOptions = {
89
128
  path: scoringRoot,
90
129
  glob: request.glob,
91
130
  exclude: request.exclude,
92
131
  hidden: request.hidden,
132
+ };
133
+ const files = await listWorkspaceFiles(cwd, signal, {
134
+ ...listOptions,
135
+ ...(includeIgnored ? { ignore: false, ignoreParents: false } : {}),
93
136
  });
94
137
  const filtered = await filterPathsByModificationTime(
95
138
  cwd,
@@ -98,10 +141,12 @@ export async function discoverFiles(
98
141
  request.modifiedBeforeMs,
99
142
  signal,
100
143
  );
144
+ const rank = (path: string) =>
145
+ scoreFilePath(pathRelativeToDiscoveryRoot(cwd, scoringRoot, path), query);
101
146
  const selected = filtered.paths
102
147
  .flatMap((path) => {
103
- const rank = scoreFilePath(pathRelativeToDiscoveryRoot(cwd, scoringRoot, path), query);
104
- return rank ? [{ path, ...rank }] : [];
148
+ const score = rank(path);
149
+ return score ? [{ path, ...score }] : [];
105
150
  })
106
151
  .toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path));
107
152
  for (let offset = 0; offset < selected.length; offset += 16) {
@@ -111,15 +156,54 @@ export async function discoverFiles(
111
156
  );
112
157
  signal?.throwIfAborted();
113
158
  }
159
+ const reasons = new Set([...files.reasons, ...filtered.reasons]);
160
+ if (normalizedQuery.note) reasons.add(normalizedQuery.note);
161
+ // Issue #97: ignore rules must never turn a filtered absence into a complete one.
162
+ let ignoredFiles = 0;
163
+ let ignoredMatches = 0;
164
+ let ignoredComparisonPartial = false;
165
+ if (!includeIgnored) {
166
+ const everything = await listWorkspaceFiles(cwd, signal, {
167
+ ...listOptions,
168
+ ignore: false,
169
+ ignoreParents: false,
170
+ });
171
+ ignoredComparisonPartial = everything.partial;
172
+ const admitted = new Set(files.paths);
173
+ const ignored = everything.paths.filter((path) => !admitted.has(path));
174
+ ignoredFiles = ignored.length;
175
+ const ignoredCandidates = ignored
176
+ .flatMap((path) => {
177
+ const score = rank(path);
178
+ return score ? [{ path, ...score }] : [];
179
+ })
180
+ .toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path));
181
+ ignoredMatches = ignoredCandidates.length;
182
+ if (ignoredFiles > 0) {
183
+ const samples = ignoredCandidates
184
+ .slice(0, IGNORED_MATCH_SAMPLES)
185
+ .map((item) => `${JSON.stringify(item.path)} (${item.reason})`);
186
+ reasons.add(
187
+ ignoredMatches > 0
188
+ ? `${String(ignoredFiles)} file(s) were excluded by ignore rules; ${String(ignoredMatches)} of them match this query${samples.length ? `, e.g. ${samples.join(", ")}` : ""}. Retry with ignorePolicy "include" to list them.`
189
+ : `${String(ignoredFiles)} file(s) were excluded by ignore rules; none of them match this query.`,
190
+ );
191
+ }
192
+ if (ignoredComparisonPartial)
193
+ reasons.add(
194
+ "Ignored-file comparison was incomplete; the ignored-file count is a lower bound",
195
+ );
196
+ }
197
+ const enumerationPartial = files.partial || filtered.partial;
114
198
  return {
115
199
  kind: "files",
116
200
  unit: "files",
117
- partial: files.partial || filtered.partial,
118
- reasons: [...new Set([...files.reasons, ...filtered.reasons])],
201
+ partial: enumerationPartial,
202
+ reasons: [...reasons],
119
203
  items: selected.map((item) => ({
120
204
  path: item.path,
121
205
  line: 1,
122
- label: `File candidate (${item.reason})`,
206
+ label: `File candidate (score ${String(item.score)}: ${item.reason})`,
123
207
  details: {
124
208
  kind: "file",
125
209
  score: item.score,
@@ -127,8 +211,17 @@ export async function discoverFiles(
127
211
  inspect: { mode: "inspect", path: item.path, line: 1 },
128
212
  },
129
213
  })),
130
- coverage: { fileEnumeration: files.partial || filtered.partial ? "partial" : "complete" },
131
- stats: { filesEnumerated: files.paths.length },
214
+ coverage: {
215
+ fileEnumeration: enumerationPartial
216
+ ? "partial"
217
+ : ignoredFiles > 0 || ignoredComparisonPartial
218
+ ? "policy-filtered"
219
+ : "complete",
220
+ },
221
+ stats: {
222
+ filesEnumerated: files.paths.length,
223
+ ...(includeIgnored ? {} : { ignoredFiles, ignoredMatches }),
224
+ },
132
225
  scope: {
133
226
  path: request.path ?? ".",
134
227
  requestedPath: request.path ?? ".",
@@ -146,5 +239,6 @@ export async function discoverFiles(
146
239
  : {}),
147
240
  },
148
241
  redact: input.redact ?? false,
242
+ ...(input.limit !== undefined ? { pageSize: request.pageSize } : {}),
149
243
  };
150
244
  }
@@ -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
  );
package/src/inspect.ts CHANGED
@@ -78,6 +78,9 @@ export function resolveInspectionTarget(
78
78
  line = retainedMatch.lineNumber;
79
79
  }
80
80
  if (!path) throw new SiftLightError("path is required when mode=inspect");
81
+ // A direct path without a line opens the file from its first line. Cursor
82
+ // inspection still needs the exact retained line, so it keeps failing closed.
83
+ if (line === undefined && input.cursor === undefined) line = 1;
81
84
  if (line === undefined || !Number.isSafeInteger(line) || line < 1) {
82
85
  throw new SiftLightError("line must be a positive integer when mode=inspect");
83
86
  }
@@ -119,6 +119,15 @@ const PYTHON_LANGUAGE_CAPABILITIES: readonly LanguageCapabilitySpec[] = [
119
119
  availability: "implemented",
120
120
  load: "lazy",
121
121
  },
122
+ {
123
+ id: "python-lexical.roles",
124
+ name: "roles",
125
+ provider: "bounded Python lexical role scanner",
126
+ providerKind: "builtin",
127
+ evidence: "syntax",
128
+ availability: "implemented",
129
+ load: "lazy",
130
+ },
122
131
  ];
123
132
 
124
133
  export const DEFAULT_LANGUAGE_CAPABILITIES: readonly LanguageCapabilityDescriptor[] = [
@@ -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,
@@ -46,7 +87,10 @@ function compactRows(analysis: AnalysisDetails): string[] {
46
87
  rows.push(JSON.stringify(item.path));
47
88
  previousPath = item.path;
48
89
  }
49
- const label = analysis.kind === "outline" && analysis.modelOutput ? item.label : "metadata";
90
+ const label =
91
+ (analysis.kind === "outline" && analysis.modelOutput) || analysis.kind === "files"
92
+ ? item.label
93
+ : "metadata";
50
94
  const semanticJudge = item.details?.semanticJudge;
51
95
  const judgment =
52
96
  isRecord(semanticJudge) &&
@@ -108,6 +152,7 @@ export function compactMcpModelText(result: SiftLightResult): string {
108
152
  const inspect = compactInspectInstruction(analysis);
109
153
  const nextRequest = distinctNextRequest(result.details, analysis);
110
154
  const compact = [
155
+ ...(result.details.requestNotes ?? []).map((note) => `[Request note: ${note}]`),
111
156
  header,
112
157
  ...compactMetadata(result.details, analysis),
113
158
  ...compactRows(analysis),
@@ -115,6 +160,7 @@ export function compactMcpModelText(result: SiftLightResult): string {
115
160
  ...(nextRequest ? [`Next request: ${nextRequest}`] : []),
116
161
  ].join("\n");
117
162
  const standard = result.text.replace(" Structured output retains per-item evidence details.", "");
163
+ // Both projections carry request notes, so the byte comparison stays fair.
118
164
  if (analysis.semanticJudge) return compact;
119
165
  return Buffer.byteLength(compact) < Buffer.byteLength(standard) ? compact : standard;
120
166
  }
@@ -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(