sift-light 1.0.1 → 1.0.3-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.
Files changed (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -3
  3. package/README.zh-CN.md +5 -3
  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 +3 -2
  7. package/plugins/sift-light/.mcp.json +1 -1
  8. package/plugins/sift-light/kimi.plugin.json +2 -2
  9. package/plugins/sift-light/omp-extension.mjs +517 -72
  10. package/plugins/sift-light/package.json +1 -1
  11. package/plugins/sift-light/skills/local-search/SKILL.md +12 -0
  12. package/src/analysis-limits.ts +1 -1
  13. package/src/analysis-store.ts +16 -10
  14. package/src/analysis-term-pages.ts +2 -1
  15. package/src/analysis-types.ts +10 -1
  16. package/src/evidence-service.ts +118 -14
  17. package/src/file-discovery.ts +111 -17
  18. package/src/inspect.ts +3 -0
  19. package/src/language-capability-definitions.ts +9 -0
  20. package/src/mcp-model-output.ts +6 -1
  21. package/src/mcp-server.mjs +525 -77
  22. package/src/prompt-guidelines.ts +11 -5
  23. package/src/python-roles.ts +247 -0
  24. package/src/redaction.ts +9 -2
  25. package/src/request-aliases.ts +48 -0
  26. package/src/request-contract-catalog.ts +24 -7
  27. package/src/request-contract.ts +21 -3
  28. package/src/request.ts +3 -1
  29. package/src/result-statistics.ts +3 -3
  30. package/src/runtime.ts +2 -2
  31. package/src/service.ts +23 -1
  32. package/src/session-summary.ts +4 -4
  33. package/src/source-document.ts +4 -2
  34. package/src/source-inspection.ts +27 -2
  35. package/src/term-count-label.ts +6 -0
  36. package/src/tool-schema.ts +15 -15
  37. package/src/tui/layout.ts +2 -2
  38. package/src/tui/renderers.ts +2 -2
  39. package/src/types.ts +4 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.1",
3
+ "version": "1.0.3-1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "omp": {
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: local-search
3
+ description: Use sift-light for local project content, filenames, symbols and related tests. Before reporting its MCP unavailable, inspect the complete runtime registry including ALL_TOOLS deferred entries and load mcp__sift_light__sift_light. Applies when searching local files or diagnosing sift-light discovery; ordinary exact-path reads, edits, builds and tests do not require a search.
4
+ ---
5
+
6
+ # Local search with sift-light
7
+
8
+ Before reporting sift-light absent, check the complete runtime registry, including ALL_TOOLS deferred entries. Read mcp__sift_light__sift_light's declaration and call it via tools in functions.exec; use host tool search otherwise. Unexpanded does not mean absent; call failures are connection/capability failures.
9
+
10
+ Use exact pattern searches for names and error text; files + query for unknown filenames; anyOf/allOf for several literal conditions. Use applicable structural modes for symbols and relationships. Select concept/hybrid only when semantic recall is needed and enabled.
11
+
12
+ Read the live tool declaration before constructing the request. Copy continuation and argument-repair requests exactly; repair rejected arguments once. Inspect exact source when it is needed for the authorized task. Report partial coverage and connection failures accurately. This skill does not authorize edits, installations, configuration changes or remote actions.
@@ -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;
@@ -25,6 +25,7 @@ import {
25
25
  } from "./analysis-term-pages.js";
26
26
 
27
27
  import { analysisExtraGroups, statisticsForItems } from "./result-statistics.js";
28
+ import { termCountLabel } from "./term-count-label.js";
28
29
  interface StoredAnalysis {
29
30
  id: string;
30
31
  result: AnalysisResultSet;
@@ -206,7 +207,10 @@ function publicAnalysisItem(
206
207
  return {
207
208
  path: item.path,
208
209
  line: item.line,
209
- label: result.kind === "outline" && modelOutput ? item.label : publicAnalysisLabel(result),
210
+ label:
211
+ (result.kind === "outline" && modelOutput) || result.kind === "files"
212
+ ? item.label
213
+ : publicAnalysisLabel(result),
210
214
  index: index + 1,
211
215
  ...(inspect ? { inspect } : {}),
212
216
  ...(publicDetails ? { details: publicDetails } : {}),
@@ -214,10 +218,11 @@ function publicAnalysisItem(
214
218
  }
215
219
 
216
220
  function safeTermCounts(
217
- termCounts: readonly { retainedOccurrences: number }[] | undefined,
221
+ termCounts: readonly { term: string; retainedOccurrences: number }[] | undefined,
222
+ redact = false,
218
223
  ): { term: string; retainedOccurrences: number }[] | undefined {
219
224
  return termCounts?.map((entry, index) => ({
220
- term: `condition #${String(index + 1)}`,
225
+ term: termCountLabel(entry.term, index, redact),
221
226
  retainedOccurrences: entry.retainedOccurrences,
222
227
  }));
223
228
  }
@@ -365,18 +370,18 @@ export class AnalysisStore {
365
370
  return structuredClone(item);
366
371
  }
367
372
 
368
- page(cursor: string, modelOutput = false): SiftLightResult {
373
+ page(cursor: string, modelOutput = false, redact = false): SiftLightResult {
369
374
  const { stored, offset, kind } = this.resolve(cursor);
370
- const { result } = stored;
375
+ const result = redact ? { ...stored.result, redact: true } : stored.result;
371
376
  if (kind === "analysis-terms") return analysisTermPage(result, stored.id, offset);
372
377
  const hybridPreview = kind === "analysis-hybrid";
373
378
  const hybridMatchesRequest = hybridPreview
374
379
  ? { cursor: `${stored.id}.analysis.0`, ...(result.redact ? { redact: true } : {}) }
375
380
  : undefined;
381
+ const publicTerms = safeTermCounts(result.termCounts, result.redact);
376
382
  const pagedTerms =
377
- result.termCounts &&
378
- Buffer.byteLength(JSON.stringify(result.termCounts)) > MAX_INLINE_TERM_COUNT_BYTES;
379
- const inlineTerms = pagedTerms ? undefined : safeTermCounts(result.termCounts);
383
+ publicTerms && Buffer.byteLength(JSON.stringify(publicTerms)) > MAX_INLINE_TERM_COUNT_BYTES;
384
+ const inlineTerms = pagedTerms ? undefined : publicTerms;
380
385
  const termsRequest = pagedTerms ? termCountRequest(stored.id, 0, result.redact) : undefined;
381
386
  const items: NonNullable<SiftLightResult["details"]["analysis"]>["items"] = [];
382
387
  const sources: NonNullable<SiftLightResult["details"]["analysis"]>["sources"] = [];
@@ -386,7 +391,7 @@ export class AnalysisStore {
386
391
  result.items,
387
392
  result.items.length,
388
393
  result.unit,
389
- analysisExtraGroups(result.counts, result.termCounts, result.items),
394
+ analysisExtraGroups(result.counts, inlineTerms, result.items),
390
395
  );
391
396
  const scope = result.scope
392
397
  ? ` Scope: ${result.scope.assertion === "project-wide" ? "project root" : "requested path"} ${JSON.stringify(result.scope.path)}${result.scope.expandedToProjectRoot ? `, expanded after ${JSON.stringify(result.scope.requestedPath)} had no matches` : ""}.${modificationTimeBoundsText(result.scope.modifiedAfterMs, result.scope.modifiedBeforeMs)}`
@@ -455,7 +460,8 @@ export class AnalysisStore {
455
460
  if (items.length >= 30 || !appendItem(index)) break;
456
461
  }
457
462
  } else {
458
- for (let index = offset; index < result.items.length && items.length < 30; index += 1) {
463
+ const pageSize = result.pageSize ?? 30;
464
+ for (let index = offset; index < result.items.length && items.length < pageSize; index += 1) {
459
465
  if (!appendItem(index)) break;
460
466
  }
461
467
  }
@@ -1,5 +1,6 @@
1
1
  import type { AnalysisResultSet } from "./analysis-types.js";
2
2
  import type { SiftLightResult } from "./types.js";
3
+ import { termCountLabel } from "./term-count-label.js";
3
4
 
4
5
  export const MAX_INLINE_TERM_COUNT_BYTES = 8 * 1024;
5
6
 
@@ -23,7 +24,7 @@ export function analysisTermPage(
23
24
  for (let index = offset; index < all.length; index++) {
24
25
  const term = all[index];
25
26
  if (!term) throw new Error("Term inventory index unavailable");
26
- const label = `condition #${String(index + 1)}`;
27
+ const label = termCountLabel(term.term, index, result.redact);
27
28
  const row = `${label}: ${String(term.retainedOccurrences)} retained occurrences`;
28
29
  const size = Buffer.byteLength(row) + 2;
29
30
  if (bytes + size > MAX_INLINE_TERM_COUNT_BYTES) break;
@@ -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
  }
@@ -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);
@@ -499,7 +595,7 @@ export class EvidenceService {
499
595
  "Analysis cursor cannot continue in the requested mode",
500
596
  "E_CURSOR_WRONG_KIND",
501
597
  );
502
- return this.#analyses.page(input.cursor, options.modelOutput);
598
+ return this.#analyses.page(input.cursor, options.modelOutput, input.redact);
503
599
  }
504
600
  if (input.mode === "outline" || input.mode === "imports" || input.mode === "tests")
505
601
  return this.#navigate(input, access, options);
@@ -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 {
@@ -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
  }
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[] = [
@@ -87,7 +87,10 @@ function compactRows(analysis: AnalysisDetails): string[] {
87
87
  rows.push(JSON.stringify(item.path));
88
88
  previousPath = item.path;
89
89
  }
90
- 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";
91
94
  const semanticJudge = item.details?.semanticJudge;
92
95
  const judgment =
93
96
  isRecord(semanticJudge) &&
@@ -149,6 +152,7 @@ export function compactMcpModelText(result: SiftLightResult): string {
149
152
  const inspect = compactInspectInstruction(analysis);
150
153
  const nextRequest = distinctNextRequest(result.details, analysis);
151
154
  const compact = [
155
+ ...(result.details.requestNotes ?? []).map((note) => `[Request note: ${note}]`),
152
156
  header,
153
157
  ...compactMetadata(result.details, analysis),
154
158
  ...compactRows(analysis),
@@ -156,6 +160,7 @@ export function compactMcpModelText(result: SiftLightResult): string {
156
160
  ...(nextRequest ? [`Next request: ${nextRequest}`] : []),
157
161
  ].join("\n");
158
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.
159
164
  if (analysis.semanticJudge) return compact;
160
165
  return Buffer.byteLength(compact) < Buffer.byteLength(standard) ? compact : standard;
161
166
  }