sift-light 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/CONTRIBUTING.md +19 -0
  3. package/LICENSE +661 -0
  4. package/README.md +177 -0
  5. package/README.zh-CN.md +177 -0
  6. package/SECURITY.md +25 -0
  7. package/package.json +142 -0
  8. package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
  9. package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
  10. package/plugins/sift-light/.mcp.json +24 -0
  11. package/plugins/sift-light/LICENSE +661 -0
  12. package/plugins/sift-light/concept-worker.mjs +567 -0
  13. package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
  14. package/plugins/sift-light/hooks/hooks.json +16 -0
  15. package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
  16. package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
  17. package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
  18. package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
  19. package/plugins/sift-light/kimi.plugin.json +36 -0
  20. package/plugins/sift-light/mcp-server.mjs +1 -0
  21. package/plugins/sift-light/omp-extension.mjs +20914 -0
  22. package/plugins/sift-light/package.json +11 -0
  23. package/plugins/sift-light/syntax-worker.mjs +280 -0
  24. package/plugins/sift-light/syntax-worker.toml +2 -0
  25. package/src/analysis-evidence.ts +43 -0
  26. package/src/analysis-limits.ts +27 -0
  27. package/src/analysis-store.ts +559 -0
  28. package/src/analysis-term-pages.ts +73 -0
  29. package/src/analysis-types.ts +162 -0
  30. package/src/audit-search.ts +391 -0
  31. package/src/capped-lines.ts +112 -0
  32. package/src/concept-embedding-cache.ts +186 -0
  33. package/src/concept-inference.ts +218 -0
  34. package/src/concept-model.ts +96 -0
  35. package/src/concept-search.ts +577 -0
  36. package/src/concept-setup.ts +75 -0
  37. package/src/concept-source-generation.ts +268 -0
  38. package/src/concept-worker.mjs +567 -0
  39. package/src/concept-worker.ts +210 -0
  40. package/src/config-reader.ts +283 -0
  41. package/src/config.ts +37 -0
  42. package/src/context-budget.ts +26 -0
  43. package/src/discovery-errors.ts +2 -0
  44. package/src/errors.ts +95 -0
  45. package/src/evidence-candidates.ts +344 -0
  46. package/src/evidence-ranking.ts +48 -0
  47. package/src/evidence-service.ts +1047 -0
  48. package/src/evidence-validation.ts +466 -0
  49. package/src/evidence-validity.ts +33 -0
  50. package/src/excerpt.ts +53 -0
  51. package/src/file-discovery.ts +150 -0
  52. package/src/file-metadata-filter.ts +46 -0
  53. package/src/format.ts +407 -0
  54. package/src/git-diff.ts +195 -0
  55. package/src/git-process.ts +189 -0
  56. package/src/git-repository.ts +367 -0
  57. package/src/git-source.ts +385 -0
  58. package/src/historical-paths.ts +205 -0
  59. package/src/hybrid-search.ts +340 -0
  60. package/src/import-model.ts +611 -0
  61. package/src/import-navigation.ts +100 -0
  62. package/src/import-resolution.ts +240 -0
  63. package/src/index.ts +97 -0
  64. package/src/inspect-batch.ts +265 -0
  65. package/src/inspect.ts +271 -0
  66. package/src/language-capabilities.ts +157 -0
  67. package/src/language-capability-definitions.ts +204 -0
  68. package/src/literal-search.ts +25 -0
  69. package/src/mcp-cli.ts +21 -0
  70. package/src/mcp-model-output.ts +120 -0
  71. package/src/mcp-output.ts +9 -0
  72. package/src/mcp-semantic-judge.ts +39 -0
  73. package/src/mcp-server.mjs +14064 -0
  74. package/src/mcp-server.ts +157 -0
  75. package/src/mcp-stdio.ts +114 -0
  76. package/src/mcp.ts +631 -0
  77. package/src/model-error.ts +120 -0
  78. package/src/multi-term-search.ts +122 -0
  79. package/src/omp-index.ts +277 -0
  80. package/src/omp-marketplace-entry.ts +7 -0
  81. package/src/operation-lifecycle.ts +413 -0
  82. package/src/operation-output.ts +100 -0
  83. package/src/owned-parallel.ts +22 -0
  84. package/src/owned-process.ts +102 -0
  85. package/src/owned-task-queue.ts +38 -0
  86. package/src/package-version.ts +3 -0
  87. package/src/path-policy.ts +203 -0
  88. package/src/pi-search-policy.ts +33 -0
  89. package/src/prompt-guidelines.ts +54 -0
  90. package/src/python-outline.ts +231 -0
  91. package/src/record-value.ts +3 -0
  92. package/src/redaction.ts +95 -0
  93. package/src/request-contract-catalog.ts +215 -0
  94. package/src/request-contract-error.ts +96 -0
  95. package/src/request-contract.ts +481 -0
  96. package/src/request.ts +132 -0
  97. package/src/result-statistics.ts +178 -0
  98. package/src/rg.ts +574 -0
  99. package/src/ripgrep-diagnostics.ts +122 -0
  100. package/src/ripgrep-executable.ts +43 -0
  101. package/src/runtime.ts +53 -0
  102. package/src/scan-revisions.ts +148 -0
  103. package/src/script-runtime.ts +11 -0
  104. package/src/search-policy-commands.ts +170 -0
  105. package/src/search-policy-hook.ts +61 -0
  106. package/src/search-policy-recovery.ts +388 -0
  107. package/src/search-policy-shell.ts +280 -0
  108. package/src/search-policy.ts +102 -0
  109. package/src/search-retention.ts +77 -0
  110. package/src/semantic-judge-batches.ts +135 -0
  111. package/src/semantic-judge.ts +519 -0
  112. package/src/service.ts +945 -0
  113. package/src/session-summary.ts +100 -0
  114. package/src/snapshot-store.ts +203 -0
  115. package/src/source-access.ts +288 -0
  116. package/src/source-continuations.ts +168 -0
  117. package/src/source-document.ts +277 -0
  118. package/src/source-inspection.ts +731 -0
  119. package/src/source-pages.ts +112 -0
  120. package/src/source.ts +286 -0
  121. package/src/structural-search.ts +125 -0
  122. package/src/structure.ts +259 -0
  123. package/src/summary-previews.ts +96 -0
  124. package/src/summary.ts +79 -0
  125. package/src/syntax-facts.ts +464 -0
  126. package/src/syntax-fields.ts +161 -0
  127. package/src/syntax-search.ts +361 -0
  128. package/src/syntax-tree.ts +30 -0
  129. package/src/syntax-types.ts +72 -0
  130. package/src/syntax-worker.mjs +280 -0
  131. package/src/syntax-worker.toml +2 -0
  132. package/src/syntax-worker.ts +143 -0
  133. package/src/syntax.ts +259 -0
  134. package/src/test-navigation-facts.ts +261 -0
  135. package/src/test-navigation.ts +543 -0
  136. package/src/tool-schema.ts +321 -0
  137. package/src/tui/dashboard.ts +107 -0
  138. package/src/tui/layout.ts +291 -0
  139. package/src/tui/palette.ts +73 -0
  140. package/src/tui/presentation.ts +240 -0
  141. package/src/tui/renderers.ts +106 -0
  142. package/src/types.ts +368 -0
  143. package/src/validation-output.ts +148 -0
  144. package/src/validation-types.ts +48 -0
  145. package/src/workspace-files.ts +142 -0
@@ -0,0 +1,559 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ ANALYSIS_TTL_MS,
4
+ ANALYSIS_METADATA_RESERVE_BYTES,
5
+ MAX_ANALYSIS_REASON_BYTES,
6
+ MAX_ANALYSIS_REASONS,
7
+ MAX_ANALYSIS_RESULTS,
8
+ MAX_ANALYSIS_SNAPSHOTS,
9
+ MAX_ANALYSIS_STORAGE_BYTES,
10
+ } from "./analysis-limits.js";
11
+ import {
12
+ SEMANTIC_JUDGE_CLASSIFICATIONS,
13
+ SEMANTIC_JUDGE_NON_PROOF_CLAIM,
14
+ type AnalysisItem,
15
+ type AnalysisResultSet,
16
+ } from "./analysis-types.js";
17
+ import { CursorError, SiftLightError } from "./errors.js";
18
+ import type { SiftLightResult } from "./types.js";
19
+ import { MAX_RESULT_BYTES } from "./types.js";
20
+ import { modificationTimeBoundsText } from "./source.js";
21
+ import {
22
+ analysisTermPage,
23
+ MAX_INLINE_TERM_COUNT_BYTES,
24
+ termCountRequest,
25
+ } from "./analysis-term-pages.js";
26
+
27
+ import { analysisExtraGroups, statisticsForItems } from "./result-statistics.js";
28
+ interface StoredAnalysis {
29
+ id: string;
30
+ result: AnalysisResultSet;
31
+ bytes: number;
32
+ touched: number;
33
+ }
34
+
35
+ export type RetainedAnalysisSummary = (
36
+ items: readonly AnalysisItem[],
37
+ ) => Pick<AnalysisResultSet, "counts" | "termCounts">;
38
+
39
+ function boundedReasons(reasons: readonly string[]): string[] {
40
+ const unsupportedSuffix = ": syntax unsupported; this source remains unclassified";
41
+ const unsupported = reasons
42
+ .filter((reason) => reason.endsWith(unsupportedSuffix))
43
+ .map((reason) => reason.slice(0, -unsupportedSuffix.length));
44
+ const unique = [
45
+ ...new Set(reasons.filter((reason) => !reason.endsWith(unsupportedSuffix))),
46
+ ...(unsupported.length
47
+ ? [
48
+ `${String(unsupported.length)} matching file(s) skipped because syntax is unsupported${unsupported.length ? `; examples: ${unsupported.slice(0, 3).join(", ")}` : ""}`,
49
+ ]
50
+ : []),
51
+ ];
52
+ const retained: string[] = [];
53
+ let bytes = 2;
54
+ let omitted = 0;
55
+ for (const reason of unique) {
56
+ const reasonBytes = Buffer.byteLength(JSON.stringify(reason)) + 1;
57
+ if (
58
+ retained.length >= MAX_ANALYSIS_REASONS ||
59
+ bytes + reasonBytes > MAX_ANALYSIS_REASON_BYTES
60
+ ) {
61
+ omitted += 1;
62
+ continue;
63
+ }
64
+ retained.push(reason);
65
+ bytes += reasonBytes;
66
+ }
67
+ if (omitted === 0) return retained;
68
+ let notice = `${String(omitted)} additional analysis reasons omitted within the ${String(MAX_ANALYSIS_REASONS)}-reason / ${String(MAX_ANALYSIS_REASON_BYTES)}-byte diagnostic limit`;
69
+ while (
70
+ retained.length > 0 &&
71
+ bytes + Buffer.byteLength(JSON.stringify(notice)) + 1 > MAX_ANALYSIS_REASON_BYTES
72
+ ) {
73
+ const removed = retained.pop();
74
+ if (removed === undefined) break;
75
+ bytes -= Buffer.byteLength(JSON.stringify(removed)) + 1;
76
+ omitted += 1;
77
+ notice = `${String(omitted)} additional analysis reasons omitted within the ${String(MAX_ANALYSIS_REASONS)}-reason / ${String(MAX_ANALYSIS_REASON_BYTES)}-byte diagnostic limit`;
78
+ }
79
+ retained.push(notice);
80
+ return retained;
81
+ }
82
+
83
+ function publicAnalysisLabel(result: AnalysisResultSet): string {
84
+ switch (result.kind) {
85
+ case "files":
86
+ return "file metadata";
87
+ case "outline":
88
+ return "symbol metadata";
89
+ case "imports":
90
+ return "import relationship metadata";
91
+ case "tests":
92
+ return "test candidate metadata";
93
+ case "structure":
94
+ return "structure match metadata";
95
+ case "roles":
96
+ return "role match metadata";
97
+ case "function-and":
98
+ return "function match metadata";
99
+ case "file-and":
100
+ return "file match metadata";
101
+ case "changes":
102
+ return "change metadata";
103
+ case "concept":
104
+ return "semantic candidate metadata";
105
+ case "hybrid":
106
+ return "combined evidence metadata";
107
+ case "any-of":
108
+ return "condition match metadata";
109
+ case "validate":
110
+ return "validation metadata";
111
+ }
112
+ throw new Error("Unknown analysis result kind");
113
+ }
114
+
115
+ function publicStringArray(value: unknown): string[] | undefined {
116
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) return undefined;
117
+ return value;
118
+ }
119
+
120
+ function publicStructureDetails(item: AnalysisItem): Record<string, unknown> | undefined {
121
+ if (item.details?.kind !== "symbol") return undefined;
122
+ const details = item.details;
123
+ const scope = publicStringArray(details.scope);
124
+ return {
125
+ kind: "symbol",
126
+ ...(typeof details.language === "string" ? { language: details.language } : {}),
127
+ ...(typeof details.name === "string" ? { name: details.name } : {}),
128
+ ...(scope ? { scope } : {}),
129
+ ...(typeof details.hasBody === "boolean" ? { hasBody: details.hasBody } : {}),
130
+ ...(typeof details.exported === "boolean" ? { exported: details.exported } : {}),
131
+ ...(typeof details.syntax === "string" ? { syntax: details.syntax } : {}),
132
+ ...(typeof details.signatureTruncated === "boolean"
133
+ ? { signatureTruncated: details.signatureTruncated }
134
+ : {}),
135
+ };
136
+ }
137
+
138
+ function publicSemanticJudgeDetails(item: AnalysisItem): Record<string, unknown> | undefined {
139
+ const value = item.details?.semanticJudge;
140
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
141
+ const classification = Reflect.get(value, "classification");
142
+ const probability = Reflect.get(value, "probability");
143
+ const confidence = Reflect.get(value, "confidence");
144
+ const model = Reflect.get(value, "model");
145
+ const claim = Reflect.get(value, "claim");
146
+ if (
147
+ typeof classification !== "string" ||
148
+ !SEMANTIC_JUDGE_CLASSIFICATIONS.some((candidate) => candidate === classification) ||
149
+ typeof probability !== "number" ||
150
+ !Number.isFinite(probability) ||
151
+ probability < 0 ||
152
+ probability > 1 ||
153
+ (confidence !== undefined &&
154
+ (typeof confidence !== "number" ||
155
+ !Number.isFinite(confidence) ||
156
+ confidence < 0 ||
157
+ confidence > 1)) ||
158
+ typeof model !== "string" ||
159
+ model.length === 0 ||
160
+ model.length > 128 ||
161
+ /[\r\n\0]/u.test(model) ||
162
+ claim !== SEMANTIC_JUDGE_NON_PROOF_CLAIM
163
+ )
164
+ return undefined;
165
+ return {
166
+ classification,
167
+ probability,
168
+ ...(confidence === undefined ? {} : { confidence }),
169
+ model,
170
+ claim,
171
+ };
172
+ }
173
+
174
+ function publicItemDetails(item: AnalysisItem): Record<string, unknown> | undefined {
175
+ const structure = publicStructureDetails(item);
176
+ const semanticJudge = publicSemanticJudgeDetails(item);
177
+ const source =
178
+ item.details?.source === "literal" || item.details?.source === "concept"
179
+ ? item.details.source
180
+ : undefined;
181
+ if (!structure && !semanticJudge && !source) return undefined;
182
+ return {
183
+ ...structure,
184
+ ...(source ? { source } : {}),
185
+ ...(semanticJudge ? { semanticJudge } : {}),
186
+ };
187
+ }
188
+
189
+ function publicAnalysisItem(
190
+ result: AnalysisResultSet,
191
+ item: AnalysisItem,
192
+ index: number,
193
+ storedId: string,
194
+ modelOutput: boolean,
195
+ ): NonNullable<SiftLightResult["details"]["analysis"]>["items"][number] {
196
+ const inspect =
197
+ item.source && item.range
198
+ ? {
199
+ mode: "inspect" as const,
200
+ cursor: `${storedId}.analysis.0`,
201
+ matchIndex: index + 1,
202
+ ...(result.redact ? { redact: true } : {}),
203
+ }
204
+ : undefined;
205
+ const publicDetails = publicItemDetails(item);
206
+ return {
207
+ path: item.path,
208
+ line: item.line,
209
+ label: result.kind === "outline" && modelOutput ? item.label : publicAnalysisLabel(result),
210
+ index: index + 1,
211
+ ...(inspect ? { inspect } : {}),
212
+ ...(publicDetails ? { details: publicDetails } : {}),
213
+ };
214
+ }
215
+
216
+ function safeTermCounts(
217
+ termCounts: readonly { retainedOccurrences: number }[] | undefined,
218
+ ): { term: string; retainedOccurrences: number }[] | undefined {
219
+ return termCounts?.map((entry, index) => ({
220
+ term: `condition #${String(index + 1)}`,
221
+ retainedOccurrences: entry.retainedOccurrences,
222
+ }));
223
+ }
224
+
225
+ function hybridPreviewIndices(items: readonly AnalysisItem[]): number[] {
226
+ const literal: number[] = [];
227
+ const concept: number[] = [];
228
+ for (const [index, item] of items.entries()) {
229
+ if (item.details?.source === "literal" && literal.length < 3) literal.push(index);
230
+ if (item.details?.source === "concept") concept.push(index);
231
+ }
232
+ return [...literal, ...concept];
233
+ }
234
+
235
+ /** Stores only bounded display evidence and version references, never syntax trees. */
236
+ export class AnalysisStore {
237
+ readonly #items = new Map<string, StoredAnalysis>();
238
+ readonly #expired = new Set<string>();
239
+ readonly #now: () => number;
240
+ constructor(now: () => number = Date.now) {
241
+ this.#now = now;
242
+ }
243
+ clear(): void {
244
+ for (const id of this.#items.keys()) this.#rememberExpired(id);
245
+ this.#items.clear();
246
+ }
247
+
248
+ create(result: AnalysisResultSet, summarize?: RetainedAnalysisSummary): string {
249
+ this.#expire();
250
+ const bounded: AnalysisResultSet = {
251
+ ...result,
252
+ reasons: boundedReasons(result.reasons),
253
+ items: [],
254
+ coverage: {
255
+ ...result.coverage,
256
+ retention: result.coverage?.retention ?? "complete",
257
+ },
258
+ };
259
+ let bytes = Buffer.byteLength(JSON.stringify(bounded));
260
+ const candidates = result.items.map((item, index) => ({ item, index }));
261
+ const retainedIndices: number[] = [];
262
+ const rebuildItems = (): void => {
263
+ const retained = new Set(retainedIndices);
264
+ bounded.items = result.items
265
+ .filter((_item, index) => retained.has(index))
266
+ .map((item) => structuredClone(item));
267
+ };
268
+ for (const candidate of candidates) {
269
+ const { item } = candidate;
270
+ const itemBytes = Buffer.byteLength(JSON.stringify(item)) + 1;
271
+ if (
272
+ retainedIndices.length >= MAX_ANALYSIS_RESULTS ||
273
+ bytes + itemBytes > MAX_ANALYSIS_STORAGE_BYTES - ANALYSIS_METADATA_RESERVE_BYTES
274
+ ) {
275
+ bounded.partial = true;
276
+ if (bounded.coverage) bounded.coverage.retention = "partial";
277
+ bounded.reasons.push("Analysis storage limit: 50,000 items / 32 MiB; narrow the query");
278
+ break;
279
+ }
280
+ retainedIndices.push(candidate.index);
281
+ bytes += itemBytes;
282
+ }
283
+ rebuildItems();
284
+ if (summarize) Object.assign(bounded, summarize(bounded.items));
285
+ bytes = Buffer.byteLength(JSON.stringify(bounded));
286
+ while (bytes > MAX_ANALYSIS_STORAGE_BYTES - 1024 && bounded.items.length > 0) {
287
+ retainedIndices.pop();
288
+ rebuildItems();
289
+ bounded.partial = true;
290
+ if (bounded.coverage) bounded.coverage.retention = "partial";
291
+ if (
292
+ !bounded.reasons.includes("Analysis storage limit: 50,000 items / 32 MiB; narrow the query")
293
+ )
294
+ bounded.reasons.push("Analysis storage limit: 50,000 items / 32 MiB; narrow the query");
295
+ if (summarize) Object.assign(bounded, summarize(bounded.items));
296
+ bytes = Buffer.byteLength(JSON.stringify(bounded));
297
+ }
298
+ bounded.reasons = boundedReasons(bounded.reasons);
299
+ bytes = Buffer.byteLength(JSON.stringify(bounded));
300
+ if (bytes > MAX_ANALYSIS_STORAGE_BYTES - 1024)
301
+ throw new SiftLightError("Analysis metadata exceeds the storage budget");
302
+ while (
303
+ this.#items.size >= MAX_ANALYSIS_SNAPSHOTS ||
304
+ this.#totalBytes() + bytes > MAX_ANALYSIS_STORAGE_BYTES ||
305
+ this.#totalItems() + bounded.items.length > MAX_ANALYSIS_RESULTS
306
+ ) {
307
+ const oldest = [...this.#items.values()].toSorted((a, b) => a.touched - b.touched)[0];
308
+ if (!oldest) throw new SiftLightError("Analysis metadata exceeds the storage budget");
309
+ this.#items.delete(oldest.id);
310
+ this.#rememberExpired(oldest.id);
311
+ }
312
+ const id = randomUUID();
313
+ this.#items.set(id, { id, result: bounded, bytes, touched: this.#now() });
314
+ return `${id}.${result.kind === "hybrid" ? "analysis-hybrid" : "analysis"}.0`;
315
+ }
316
+
317
+ resolve(cursor: string): {
318
+ stored: StoredAnalysis;
319
+ offset: number;
320
+ kind: "analysis" | "analysis-hybrid" | "analysis-terms";
321
+ } {
322
+ this.#expire();
323
+ const match = /^([a-f0-9-]+)\.(analysis|analysis-hybrid|analysis-terms)\.([0-9a-z]+)$/.exec(
324
+ cursor,
325
+ );
326
+ if (!match) throw new CursorError("Invalid analysis cursor");
327
+ const id = match[1];
328
+ const kind = match[2];
329
+ const rawOffset = match[3];
330
+ if (kind !== "analysis" && kind !== "analysis-hybrid" && kind !== "analysis-terms")
331
+ throw new CursorError("Invalid analysis cursor");
332
+ if (!id || !rawOffset) throw new CursorError("Invalid analysis cursor");
333
+ const offset = Number.parseInt(rawOffset, 36);
334
+ const stored = this.#items.get(id);
335
+ if (!stored)
336
+ throw new CursorError(
337
+ this.#expired.has(id)
338
+ ? "Analysis cursor expired or was evicted; run the query again"
339
+ : "Analysis cursor was not found; run the query again",
340
+ this.#expired.has(id) ? "E_CURSOR_EXPIRED" : "E_CURSOR_NOT_FOUND",
341
+ );
342
+ if (
343
+ !Number.isSafeInteger(offset) ||
344
+ offset < 0 ||
345
+ offset.toString(36) !== rawOffset ||
346
+ (kind === "analysis"
347
+ ? offset > stored.result.items.length
348
+ : kind === "analysis-hybrid"
349
+ ? offset !== 0 || stored.result.kind !== "hybrid"
350
+ : offset >= (stored.result.termCounts?.length ?? 0))
351
+ )
352
+ throw new CursorError("Invalid analysis offset", "E_CURSOR_OFFSET_INVALID");
353
+ stored.touched = this.#now();
354
+ return { stored, offset, kind };
355
+ }
356
+
357
+ item(cursor: string, index: number): AnalysisItem {
358
+ const { stored, kind } = this.resolve(cursor);
359
+ if (kind !== "analysis")
360
+ throw new CursorError("Term inventories do not contain source items", "E_CURSOR_WRONG_KIND");
361
+ if (!Number.isSafeInteger(index) || index < 1)
362
+ throw new CursorError("matchIndex must be a positive analysis item index");
363
+ const item = stored.result.items[index - 1];
364
+ if (!item) throw new CursorError("Analysis item is outside the retained result");
365
+ return structuredClone(item);
366
+ }
367
+
368
+ page(cursor: string, modelOutput = false): SiftLightResult {
369
+ const { stored, offset, kind } = this.resolve(cursor);
370
+ const { result } = stored;
371
+ if (kind === "analysis-terms") return analysisTermPage(result, stored.id, offset);
372
+ const hybridPreview = kind === "analysis-hybrid";
373
+ const hybridMatchesRequest = hybridPreview
374
+ ? { cursor: `${stored.id}.analysis.0`, ...(result.redact ? { redact: true } : {}) }
375
+ : undefined;
376
+ 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);
380
+ const termsRequest = pagedTerms ? termCountRequest(stored.id, 0, result.redact) : undefined;
381
+ const items: NonNullable<SiftLightResult["details"]["analysis"]>["items"] = [];
382
+ const sources: NonNullable<SiftLightResult["details"]["analysis"]>["sources"] = [];
383
+ const sourceIds = new Map<string, number>();
384
+ const hybridInspectCursor = result.kind === "hybrid" ? `${stored.id}.analysis.0` : undefined;
385
+ const statistics = statisticsForItems(
386
+ result.items,
387
+ result.items.length,
388
+ result.unit,
389
+ analysisExtraGroups(result.counts, result.termCounts, result.items),
390
+ );
391
+ const scope = result.scope
392
+ ? ` 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)}`
393
+ : "";
394
+ const coverage = result.coverage ? ` Coverage: ${JSON.stringify(result.coverage)}.` : "";
395
+ const stats = result.stats ? ` Stats: ${JSON.stringify(result.stats)}.` : "";
396
+ const semanticJudge = result.semanticJudge
397
+ ? ` Semantic judge: ${result.semanticJudge.status}; provider=${result.semanticJudge.provider}; judged ${String(result.semanticJudge.judgedCandidates)} of ${String(result.semanticJudge.candidatesConsidered)} candidates.`
398
+ : "";
399
+ const hasItemDetails = result.items.some((item) => publicItemDetails(item) !== undefined);
400
+ const header = `${result.kind}: ${result.items.length} retained ${result.unit} (${result.partial ? "PARTIAL" : "complete"}). ${publicAnalysisLabel(result)}. ${result.counts ? `Counts: ${JSON.stringify(result.counts)}. ` : ""}${inlineTerms ? `Term counts: ${JSON.stringify(inlineTerms)}. ` : ""}${termsRequest ? `Term counts are paginated: ${JSON.stringify(termsRequest)}. ` : ""}Counts use ${result.unit}; they are not ordinary matching-line counts.${hasItemDetails ? " Structured output retains per-item evidence details." : ""}${semanticJudge}${scope}${coverage}${stats}`;
401
+ const notice = result.reasons.length
402
+ ? `\n${result.reasons.map((reason) => `[${reason}]`).join("\n")}`
403
+ : "";
404
+ const rows: string[] = [];
405
+ let bytes = Buffer.byteLength(header + notice) + 1200;
406
+ let next = offset;
407
+ const appendItem = (index: number): boolean => {
408
+ const item = result.items[index];
409
+ if (!item) throw new Error("Analysis item unavailable");
410
+ const publicItem = publicAnalysisItem(result, item, index, stored.id, modelOutput);
411
+ const inspect = publicItem.inspect;
412
+ const exposeExcerpt =
413
+ result.kind === "concept" ||
414
+ (result.kind === "hybrid" && item.details?.source === "concept");
415
+ const row = `#${index + 1} ${item.path}:${item.line} ${publicItem.label}${exposeExcerpt && item.excerpt ? `\n${item.excerpt}` : ""}${inspect && !hybridInspectCursor ? `\nInspect: ${JSON.stringify(inspect)}` : ""}`;
416
+ const rowBytes = Buffer.byteLength(row) + 2;
417
+ if (bytes + rowBytes > MAX_RESULT_BYTES) {
418
+ if (items.length === 0)
419
+ throw new SiftLightError("Analysis item exceeds the response limit; narrow its source");
420
+ return false;
421
+ }
422
+ rows.push(row);
423
+ bytes += rowBytes;
424
+ if (hybridInspectCursor) {
425
+ const { inspect: _inspect, ...sharedPublicItem } = publicItem;
426
+ const publicHybridItem = {
427
+ ...sharedPublicItem,
428
+ ...(item.excerpt ? { excerpt: item.excerpt } : {}),
429
+ };
430
+ if (item.source) {
431
+ const sourceKey = JSON.stringify(item.source);
432
+ let sourceId = sourceIds.get(sourceKey);
433
+ if (sourceId === undefined) {
434
+ sourceId = sources.length;
435
+ sources.push(item.source);
436
+ sourceIds.set(sourceKey, sourceId);
437
+ }
438
+ items.push({ ...publicHybridItem, sourceId });
439
+ } else {
440
+ items.push(publicHybridItem);
441
+ }
442
+ } else {
443
+ items.push({
444
+ ...item,
445
+ label: publicItem.label,
446
+ index: index + 1,
447
+ ...(inspect ? { inspect } : {}),
448
+ });
449
+ }
450
+ if (!hybridPreview) next = index + 1;
451
+ return true;
452
+ };
453
+ if (hybridPreview) {
454
+ for (const index of hybridPreviewIndices(result.items)) {
455
+ if (items.length >= 30 || !appendItem(index)) break;
456
+ }
457
+ } else {
458
+ for (let index = offset; index < result.items.length && items.length < 30; index += 1) {
459
+ if (!appendItem(index)) break;
460
+ }
461
+ }
462
+ const nextRequest =
463
+ hybridMatchesRequest ??
464
+ (next < result.items.length
465
+ ? {
466
+ cursor: `${stored.id}.analysis.${next.toString(36)}`,
467
+ ...(result.redact ? { redact: true } : {}),
468
+ }
469
+ : undefined);
470
+ const text = [
471
+ header + notice,
472
+ ...rows,
473
+ ...(hybridInspectCursor
474
+ ? [
475
+ `Inspect item #N: ${JSON.stringify({ mode: "inspect", cursor: hybridInspectCursor, matchIndex: "N" })}`,
476
+ ]
477
+ : []),
478
+ ...(nextRequest ? [`Next request: ${JSON.stringify(nextRequest)}`] : []),
479
+ ].join("\n\n");
480
+ if (Buffer.byteLength(text) > MAX_RESULT_BYTES)
481
+ throw new SiftLightError("Analysis metadata exceeds the output limit");
482
+ return {
483
+ text,
484
+ details: {
485
+ version: 1,
486
+ mode:
487
+ result.kind === "concept" ||
488
+ result.kind === "hybrid" ||
489
+ result.kind === "structure" ||
490
+ result.kind === "files" ||
491
+ result.kind === "outline" ||
492
+ result.kind === "imports" ||
493
+ result.kind === "tests"
494
+ ? result.kind
495
+ : "matches",
496
+ status: result.partial ? "partial" : "complete",
497
+ snapshotComplete: !result.partial,
498
+ totalMatches: result.items.length,
499
+ storedMatches: result.items.length,
500
+ returnedMatches: items.length,
501
+ totalFiles: statistics.files,
502
+ statistics,
503
+ cursor: nextRequest?.cursor ?? `${stored.id}.analysis.0`,
504
+ ...(nextRequest ? { nextRequest } : {}),
505
+ analysis: {
506
+ kind: result.kind,
507
+ unit: result.unit,
508
+ totalItems: result.items.length,
509
+ returnedItems: items.length,
510
+ ...(modelOutput ? { modelOutput: true } : {}),
511
+ statistics,
512
+ items,
513
+ ...(sources.length ? { sources } : {}),
514
+ reasons: result.reasons,
515
+ ...(result.semanticJudge ? { semanticJudge: result.semanticJudge } : {}),
516
+ ...(result.filesRead !== undefined ? { filesRead: result.filesRead } : {}),
517
+ ...(result.bytesRead !== undefined ? { bytesRead: result.bytesRead } : {}),
518
+ ...(result.changes ? { changes: result.changes } : {}),
519
+ ...(result.counts ? { counts: result.counts } : {}),
520
+ ...(inlineTerms ? { termCounts: inlineTerms } : {}),
521
+ ...(termsRequest
522
+ ? { totalTerms: result.termCounts?.length ?? 0, termCountsNextRequest: termsRequest }
523
+ : {}),
524
+ ...(result.scope ? { scope: result.scope } : {}),
525
+ ...(result.chunks !== undefined ? { chunks: result.chunks } : {}),
526
+ ...(result.coverage ? { coverage: result.coverage } : {}),
527
+ ...(result.stats ? { stats: result.stats } : {}),
528
+ ...(result.sourceGeneration ? { sourceGeneration: result.sourceGeneration } : {}),
529
+ ...(hybridMatchesRequest ? { matchesRequest: hybridMatchesRequest } : {}),
530
+ ...(hybridInspectCursor ? { inspectCursor: hybridInspectCursor } : {}),
531
+ },
532
+ ...(result.scope ? { scope: result.scope } : {}),
533
+ ...(result.redact ? { redactionRequested: true } : {}),
534
+ },
535
+ };
536
+ }
537
+
538
+ #expire(): void {
539
+ for (const [id, item] of this.#items)
540
+ if (this.#now() - item.touched >= ANALYSIS_TTL_MS) {
541
+ this.#items.delete(id);
542
+ this.#rememberExpired(id);
543
+ }
544
+ }
545
+ #totalBytes(): number {
546
+ return [...this.#items.values()].reduce((n, item) => n + item.bytes, 0);
547
+ }
548
+ #totalItems(): number {
549
+ return [...this.#items.values()].reduce((n, item) => n + item.result.items.length, 0);
550
+ }
551
+ #rememberExpired(id: string): void {
552
+ this.#expired.add(id);
553
+ while (this.#expired.size > MAX_ANALYSIS_SNAPSHOTS * 4) {
554
+ const oldest = this.#expired.values().next().value;
555
+ if (oldest === undefined) break;
556
+ this.#expired.delete(oldest);
557
+ }
558
+ }
559
+ }
@@ -0,0 +1,73 @@
1
+ import type { AnalysisResultSet } from "./analysis-types.js";
2
+ import type { SiftLightResult } from "./types.js";
3
+
4
+ export const MAX_INLINE_TERM_COUNT_BYTES = 8 * 1024;
5
+
6
+ export function termCountRequest(id: string, offset: number, redact = false) {
7
+ return {
8
+ cursor: `${id}.analysis-terms.${offset.toString(36)}`,
9
+ ...(redact ? { redact: true } : {}),
10
+ };
11
+ }
12
+
13
+ /** Term metadata has its own pages so even a complete empty result remains readable. */
14
+ export function analysisTermPage(
15
+ result: AnalysisResultSet,
16
+ id: string,
17
+ offset: number,
18
+ ): SiftLightResult {
19
+ const all = result.termCounts ?? [];
20
+ const terms: { term: string; retainedOccurrences: number }[] = [];
21
+ const rows: string[] = [];
22
+ let bytes = 0;
23
+ for (let index = offset; index < all.length; index++) {
24
+ const term = all[index];
25
+ if (!term) throw new Error("Term inventory index unavailable");
26
+ const label = `condition #${String(index + 1)}`;
27
+ const row = `${label}: ${String(term.retainedOccurrences)} retained occurrences`;
28
+ const size = Buffer.byteLength(row) + 2;
29
+ if (bytes + size > MAX_INLINE_TERM_COUNT_BYTES) break;
30
+ rows.push(row);
31
+ terms.push({ term: label, retainedOccurrences: term.retainedOccurrences });
32
+ bytes += size;
33
+ }
34
+ const nextOffset = offset + terms.length;
35
+ const nextRequest =
36
+ nextOffset < all.length ? termCountRequest(id, nextOffset, result.redact) : undefined;
37
+ const matchesRequest = { cursor: `${id}.analysis.0`, ...(result.redact ? { redact: true } : {}) };
38
+ return {
39
+ text: [
40
+ `Condition inventory ${String(offset + 1)}-${String(nextOffset)} of ${String(all.length)} (${result.partial ? "PARTIAL evidence" : "complete evidence"}). Counts refer to retained occurrences.`,
41
+ ...rows,
42
+ ...(nextRequest ? [`Next request: ${JSON.stringify(nextRequest)}`] : []),
43
+ `Match metadata request: ${JSON.stringify(matchesRequest)}`,
44
+ ].join("\n\n"),
45
+ details: {
46
+ version: 1,
47
+ mode: "matches",
48
+ status: result.partial ? "partial" : "complete",
49
+ snapshotComplete: !result.partial,
50
+ totalMatches: result.items.length,
51
+ storedMatches: result.items.length,
52
+ returnedMatches: 0,
53
+ totalFiles: new Set(result.items.map((item) => item.path)).size,
54
+ cursor: nextRequest?.cursor ?? matchesRequest.cursor,
55
+ ...(nextRequest ? { nextRequest } : {}),
56
+ ...(result.redact ? { redactionRequested: true } : {}),
57
+ analysis: {
58
+ kind: result.kind,
59
+ unit: result.unit,
60
+ totalItems: result.items.length,
61
+ returnedItems: 0,
62
+ items: [],
63
+ reasons: result.reasons,
64
+ termCounts: terms,
65
+ termCountsOffset: offset,
66
+ totalTerms: all.length,
67
+ ...(nextRequest ? { termCountsNextRequest: nextRequest } : {}),
68
+ matchesRequest,
69
+ ...(result.coverage ? { coverage: result.coverage } : {}),
70
+ },
71
+ },
72
+ };
73
+ }