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,186 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
3
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import {
6
+ CONCEPT_CACHE_MAX_BYTES,
7
+ CONCEPT_CACHE_VERSION,
8
+ CONCEPT_EMBEDDING_DIMENSIONS,
9
+ } from "./concept-model.js";
10
+ import { isRecordValue } from "./record-value.js";
11
+
12
+ export interface CachedConceptWindow {
13
+ start: number;
14
+ end: number;
15
+ vector: number[];
16
+ }
17
+
18
+ export interface CachedConceptEmbedding {
19
+ key: string;
20
+ windows: CachedConceptWindow[];
21
+ }
22
+
23
+ interface StoredEmbedding {
24
+ version: number;
25
+ key: string;
26
+ windows: { start: number; end: number; vector: string }[];
27
+ }
28
+
29
+ function errorCode(error: unknown): unknown {
30
+ return typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
31
+ }
32
+
33
+ export function conceptEmbeddingKey(role: "query" | "passage", text: string): string {
34
+ return createHash("sha256").update(role).update("\0").update(text).digest("hex");
35
+ }
36
+
37
+ function cachePath(root: string, key: string): string {
38
+ return join(root, key.slice(0, 2), `${key}.json`);
39
+ }
40
+
41
+ function encodeVector(vector: readonly number[]): string {
42
+ const buffer = Buffer.allocUnsafe(vector.length * Float32Array.BYTES_PER_ELEMENT);
43
+ for (const [index, value] of vector.entries())
44
+ buffer.writeFloatLE(value, index * Float32Array.BYTES_PER_ELEMENT);
45
+ return buffer.toString("base64");
46
+ }
47
+
48
+ function decodeVector(encoded: string): number[] | undefined {
49
+ const buffer = Buffer.from(encoded, "base64");
50
+ if (buffer.length !== CONCEPT_EMBEDDING_DIMENSIONS * Float32Array.BYTES_PER_ELEMENT)
51
+ return undefined;
52
+ const vector = Array.from({ length: CONCEPT_EMBEDDING_DIMENSIONS }, (_, index) =>
53
+ buffer.readFloatLE(index * Float32Array.BYTES_PER_ELEMENT),
54
+ );
55
+ return vector.every(Number.isFinite) ? vector : undefined;
56
+ }
57
+
58
+ function storedEmbedding(value: unknown, key: string): CachedConceptEmbedding | undefined {
59
+ if (
60
+ !isRecordValue(value) ||
61
+ value.version !== CONCEPT_CACHE_VERSION ||
62
+ value.key !== key ||
63
+ !Array.isArray(value.windows) ||
64
+ value.windows.length === 0
65
+ )
66
+ return undefined;
67
+ const windows: CachedConceptWindow[] = [];
68
+ for (const item of value.windows) {
69
+ if (
70
+ !isRecordValue(item) ||
71
+ typeof item.start !== "number" ||
72
+ !Number.isSafeInteger(item.start) ||
73
+ item.start < 0 ||
74
+ typeof item.end !== "number" ||
75
+ !Number.isSafeInteger(item.end) ||
76
+ item.end <= item.start ||
77
+ typeof item.vector !== "string"
78
+ )
79
+ return undefined;
80
+ const vector = decodeVector(item.vector);
81
+ if (!vector) return undefined;
82
+ windows.push({ start: item.start, end: item.end, vector });
83
+ }
84
+ return { key, windows };
85
+ }
86
+
87
+ export async function readConceptEmbedding(
88
+ root: string,
89
+ key: string,
90
+ ): Promise<CachedConceptEmbedding | undefined> {
91
+ try {
92
+ return storedEmbedding(JSON.parse(await readFile(cachePath(root, key), "utf8")), key);
93
+ } catch (error) {
94
+ const code = errorCode(error);
95
+ if (code === "ENOENT" || error instanceof SyntaxError) return undefined;
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ export async function writeConceptEmbedding(
101
+ root: string,
102
+ embedding: CachedConceptEmbedding,
103
+ stagingRoot = root,
104
+ ): Promise<void> {
105
+ const directory = join(root, embedding.key.slice(0, 2));
106
+ await mkdir(stagingRoot, { recursive: true });
107
+ await mkdir(directory, { recursive: true });
108
+ const destination = cachePath(root, embedding.key);
109
+ const temporary = join(stagingRoot, `.${embedding.key}.${randomUUID()}.tmp`);
110
+ const stored: StoredEmbedding = {
111
+ version: CONCEPT_CACHE_VERSION,
112
+ key: embedding.key,
113
+ windows: embedding.windows.map((window) => ({
114
+ start: window.start,
115
+ end: window.end,
116
+ vector: encodeVector(window.vector),
117
+ })),
118
+ };
119
+ try {
120
+ await writeFile(temporary, JSON.stringify(stored), { flag: "wx", mode: 0o600 });
121
+ try {
122
+ await rename(temporary, destination);
123
+ } catch (error) {
124
+ if (errorCode(error) !== "EEXIST") throw error;
125
+ if (await readConceptEmbedding(root, embedding.key)) return;
126
+ await rm(destination, { force: true });
127
+ try {
128
+ await rename(temporary, destination);
129
+ } catch (replacementError) {
130
+ if (errorCode(replacementError) !== "EEXIST") throw replacementError;
131
+ }
132
+ }
133
+ } finally {
134
+ await rm(temporary, { force: true });
135
+ }
136
+ }
137
+
138
+ export async function enforceConceptCacheLimit(
139
+ root: string,
140
+ maximumBytes = CONCEPT_CACHE_MAX_BYTES,
141
+ ): Promise<number> {
142
+ const entries: { path: string; bytes: number; modified: number }[] = [];
143
+ let shards: Dirent[];
144
+ try {
145
+ shards = await readdir(root, { withFileTypes: true });
146
+ } catch (error) {
147
+ if (errorCode(error) === "ENOENT") return 0;
148
+ throw error;
149
+ }
150
+ for (const shard of shards) {
151
+ if (!shard.isDirectory() || !/^[0-9a-f]{2}$/u.test(shard.name)) continue;
152
+ const directory = join(root, shard.name);
153
+ let files: Dirent[];
154
+ try {
155
+ // oxlint-disable-next-line no-await-in-loop -- bounded 256-shard cache inventory.
156
+ files = await readdir(directory, { withFileTypes: true });
157
+ } catch (error) {
158
+ if (errorCode(error) === "ENOENT") continue;
159
+ throw error;
160
+ }
161
+ for (const file of files) {
162
+ if (!file.isFile() || !file.name.endsWith(".json")) continue;
163
+ const path = join(directory, file.name);
164
+ let metadata;
165
+ try {
166
+ // oxlint-disable-next-line no-await-in-loop -- eviction needs authoritative file sizes and mtimes.
167
+ metadata = await stat(path);
168
+ } catch (error) {
169
+ if (errorCode(error) === "ENOENT") continue;
170
+ throw error;
171
+ }
172
+ entries.push({ path, bytes: metadata.size, modified: metadata.mtimeMs });
173
+ }
174
+ }
175
+ let bytes = entries.reduce((total, item) => total + item.bytes, 0);
176
+ if (bytes <= maximumBytes) return bytes;
177
+ for (const item of entries.toSorted(
178
+ (a, b) => a.modified - b.modified || a.path.localeCompare(b.path),
179
+ )) {
180
+ // oxlint-disable-next-line no-await-in-loop -- deterministic oldest-first eviction owns its files.
181
+ await rm(item.path, { force: true });
182
+ bytes -= item.bytes;
183
+ if (bytes <= maximumBytes) break;
184
+ }
185
+ return bytes;
186
+ }
@@ -0,0 +1,218 @@
1
+ import type { FeatureExtractionPipeline } from "@huggingface/transformers";
2
+ import type { CachedConceptEmbedding } from "./concept-embedding-cache.js";
3
+ import {
4
+ CONCEPT_EMBEDDING_DIMENSIONS,
5
+ CONCEPT_MODEL_TOKENS,
6
+ CONCEPT_WINDOW_OVERLAP_TOKENS,
7
+ } from "./concept-model.js";
8
+
9
+ export interface PendingConceptEmbedding {
10
+ key: string;
11
+ role: "query" | "passage";
12
+ text: string;
13
+ }
14
+
15
+ export interface ConceptEmbeddingCallbacks {
16
+ onBatch?: (completed: number, total: number) => void;
17
+ onCompletedEmbedding?: (
18
+ embedding: CachedConceptEmbedding,
19
+ completed: number,
20
+ total: number,
21
+ ) => void | Promise<void>;
22
+ }
23
+
24
+ const INFERENCE_BATCH_SIZE = 16;
25
+
26
+ /** Snap a candidate UTF-16 index so it never splits a surrogate pair. */
27
+ export function safeUtf16End(text: string, end: number): number {
28
+ if (end <= 0 || end >= text.length) return end;
29
+ const previous = text.charCodeAt(end - 1);
30
+ return previous >= 0xd800 && previous <= 0xdbff ? end - 1 : end;
31
+ }
32
+
33
+ function tokenCount(extractor: FeatureExtractionPipeline, text: string): number {
34
+ return extractor.tokenizer.encode(text).length;
35
+ }
36
+
37
+ /**
38
+ * Binary-search `low` must strictly advance even when `safeUtf16End`
39
+ * retreats a midpoint below the current bound (surrogate-pair interiors).
40
+ */
41
+ function advanceLow(low: number, middle: number): number {
42
+ return Math.max(low + 1, middle + 1);
43
+ }
44
+
45
+ function nextUtf16Boundary(text: string, start: number): number {
46
+ const next = start + 1;
47
+ if (next >= text.length) return next;
48
+ const first = text.charCodeAt(start);
49
+ const second = text.charCodeAt(next);
50
+ return first >= 0xd800 && first <= 0xdbff && second >= 0xdc00 && second <= 0xdfff
51
+ ? next + 1
52
+ : next;
53
+ }
54
+
55
+ function binarySearchBudget(span: number): number {
56
+ return 2 * Math.max(span, 1) + 32;
57
+ }
58
+
59
+ export function maximumTokenSafeEnd(
60
+ extractor: FeatureExtractionPipeline,
61
+ prefix: string,
62
+ text: string,
63
+ start: number,
64
+ ): number {
65
+ let low = start + 1;
66
+ let high = text.length;
67
+ let accepted = start;
68
+ let iterations = 0;
69
+ const maxIterations = binarySearchBudget(high - low + 1);
70
+ while (low <= high) {
71
+ iterations += 1;
72
+ if (iterations > maxIterations)
73
+ throw new Error("Concept token window search failed to make progress");
74
+ const middle = safeUtf16End(text, Math.floor((low + high) / 2));
75
+ if (middle <= start) {
76
+ low += 1;
77
+ continue;
78
+ }
79
+ if (tokenCount(extractor, `${prefix}${text.slice(start, middle)}`) <= CONCEPT_MODEL_TOKENS) {
80
+ accepted = middle;
81
+ low = advanceLow(low, middle);
82
+ } else {
83
+ high = middle - 1;
84
+ }
85
+ }
86
+ return accepted;
87
+ }
88
+
89
+ export function overlapStart(
90
+ extractor: FeatureExtractionPipeline,
91
+ prefix: string,
92
+ text: string,
93
+ start: number,
94
+ end: number,
95
+ ): number {
96
+ let low = start + 1;
97
+ let high = end;
98
+ let accepted = end;
99
+ let iterations = 0;
100
+ const maxIterations = binarySearchBudget(high - low + 1);
101
+ while (low <= high) {
102
+ iterations += 1;
103
+ if (iterations > maxIterations)
104
+ throw new Error("Concept overlap search failed to make progress");
105
+ const middle = safeUtf16End(text, Math.floor((low + high) / 2));
106
+ const tokens = tokenCount(extractor, `${prefix}${text.slice(middle, end)}`);
107
+ if (tokens <= CONCEPT_WINDOW_OVERLAP_TOKENS) {
108
+ accepted = middle;
109
+ high = middle - 1;
110
+ } else {
111
+ low = advanceLow(low, middle);
112
+ }
113
+ }
114
+ return Math.max(nextUtf16Boundary(text, start), accepted);
115
+ }
116
+
117
+ export function tokenSafeWindows(
118
+ extractor: FeatureExtractionPipeline,
119
+ pending: PendingConceptEmbedding,
120
+ ): { start: number; end: number; input: string }[] {
121
+ const prefix = `${pending.role}: `;
122
+ const windows: { start: number; end: number; input: string }[] = [];
123
+ let start = 0;
124
+ while (start < pending.text.length) {
125
+ const end = maximumTokenSafeEnd(extractor, prefix, pending.text, start);
126
+ if (end <= start)
127
+ throw new Error("Concept tokenizer could not admit one complete source character");
128
+ windows.push({ start, end, input: `${prefix}${pending.text.slice(start, end)}` });
129
+ if (end >= pending.text.length) break;
130
+ const next = overlapStart(extractor, prefix, pending.text, start, end);
131
+ if (next <= start)
132
+ throw new Error("Concept window overlap failed to advance past a complete window");
133
+ start = next;
134
+ }
135
+ return windows;
136
+ }
137
+
138
+ export async function embedConceptInputs(
139
+ extractor: FeatureExtractionPipeline,
140
+ pending: readonly PendingConceptEmbedding[],
141
+ callbacks: ConceptEmbeddingCallbacks = {},
142
+ ): Promise<CachedConceptEmbedding[]> {
143
+ const layouts = pending.map((item) => tokenSafeWindows(extractor, item));
144
+ const inputs = layouts.flatMap((windows) => windows.map((window) => window.input));
145
+ const vectors: number[][] = [];
146
+ const layoutEnds: number[] = [];
147
+ let layoutEnd = 0;
148
+ for (const windows of layouts) {
149
+ layoutEnd += windows.length;
150
+ layoutEnds.push(layoutEnd);
151
+ }
152
+ 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);
155
+ // oxlint-disable-next-line no-await-in-loop -- bounded batches cap native tensor memory.
156
+ const tensor = await extractor(batch, { pooling: "mean", normalize: true });
157
+ if (tensor.data.length !== batch.length * CONCEPT_EMBEDDING_DIMENSIONS)
158
+ throw new Error("Unexpected concept embedding dimensions");
159
+ for (let index = 0; index < batch.length; index += 1) {
160
+ const start = index * CONCEPT_EMBEDDING_DIMENSIONS;
161
+ vectors.push(
162
+ Array.from(tensor.data.slice(start, start + CONCEPT_EMBEDDING_DIMENSIONS), Number),
163
+ );
164
+ }
165
+ const completedWindows = Math.min(offset + batch.length, inputs.length);
166
+ while (
167
+ layoutEnds[completedEmbedding] !== undefined &&
168
+ layoutEnds[completedEmbedding]! <= completedWindows
169
+ ) {
170
+ const start = completedEmbedding === 0 ? 0 : layoutEnds[completedEmbedding - 1]!;
171
+ const item = pending[completedEmbedding];
172
+ if (!item) throw new Error("Missing completed concept embedding input");
173
+ // oxlint-disable-next-line no-await-in-loop -- cache writes must complete at each embedding boundary.
174
+ await callbacks.onCompletedEmbedding?.(
175
+ {
176
+ key: item.key,
177
+ windows: (layouts[completedEmbedding] ?? []).map((window, index) => ({
178
+ start: window.start,
179
+ end: window.end,
180
+ vector: vectors[start + index] ?? [],
181
+ })),
182
+ },
183
+ completedEmbedding + 1,
184
+ pending.length,
185
+ );
186
+ completedEmbedding += 1;
187
+ }
188
+ callbacks.onBatch?.(completedWindows, inputs.length);
189
+ }
190
+ let vectorIndex = 0;
191
+ return pending.map((item, index) => ({
192
+ key: item.key,
193
+ windows: (layouts[index] ?? []).map((window) => ({
194
+ start: window.start,
195
+ end: window.end,
196
+ vector: vectors[vectorIndex++] ?? [],
197
+ })),
198
+ }));
199
+ }
200
+
201
+ export function cosineSimilarity(left: readonly number[], right: readonly number[]): number {
202
+ if (left.length !== CONCEPT_EMBEDDING_DIMENSIONS || right.length !== left.length)
203
+ throw new Error("Inconsistent concept embedding dimensions");
204
+ const score = left.reduce((sum, value, index) => sum + value * (right[index] ?? 0), 0);
205
+ if (!Number.isFinite(score)) throw new Error("Non-finite concept similarity");
206
+ return score;
207
+ }
208
+
209
+ export function meanNormalized(vectors: readonly (readonly number[])[]): number[] {
210
+ if (!vectors.length) throw new Error("Cannot pool an empty concept embedding");
211
+ const pooled = Array.from({ length: CONCEPT_EMBEDDING_DIMENSIONS }, (_, index) =>
212
+ vectors.reduce((sum, vector) => sum + (vector[index] ?? 0), 0),
213
+ );
214
+ const magnitude = Math.sqrt(pooled.reduce((sum, value) => sum + value * value, 0));
215
+ if (!Number.isFinite(magnitude) || magnitude === 0)
216
+ throw new Error("Invalid pooled concept query embedding");
217
+ return pooled.map((value) => value / magnitude);
218
+ }
@@ -0,0 +1,96 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { SiftLightError } from "./errors.js";
6
+
7
+ export const CONCEPT_MODEL = "Xenova/multilingual-e5-small";
8
+ export const CONCEPT_REVISION = "761b726dd34fb83930e26aab4e9ac3899aa1fa78";
9
+ export const MAX_CONCEPT_CHARS = 1_000;
10
+ export const CONCEPT_PASSAGE_OVERLAP_CHARS = 160;
11
+ export const CONCEPT_MODEL_TOKENS = 512;
12
+ export const CONCEPT_WINDOW_OVERLAP_TOKENS = 64;
13
+ export const CONCEPT_EMBEDDING_DIMENSIONS = 384;
14
+ export const CONCEPT_CACHE_VERSION = 1;
15
+ export const CONCEPT_CACHE_MAX_BYTES = 512 * 1024 * 1024;
16
+ export const CONCEPT_TIMEOUT_MS = 10 * 60_000;
17
+ export const MIN_CONCEPT_TIMEOUT_MS = 1_000;
18
+ export const MAX_CONCEPT_TIMEOUT_MS = 60 * 60_000;
19
+ export const CONCEPT_TIMEOUT_ENV = "SIFT_LIGHT_CONCEPT_TIMEOUT_MS";
20
+ export const MAX_CONCEPT_WORKER_INPUT_BYTES = 64 * 1024 * 1024;
21
+ export const MAX_CONCEPT_WORKER_OUTPUT_BYTES = 4 * 1024 * 1024;
22
+ export const CONCEPT_ASSETS = [
23
+ {
24
+ path: "config.json",
25
+ bytes: 658,
26
+ sha256: "cb99455288675345e1a4f411438d5d0adbba5fbd3a67ea4fb03c015433b996c1",
27
+ },
28
+ {
29
+ path: "tokenizer_config.json",
30
+ bytes: 443,
31
+ sha256: "a1d6bc8734a6f635dc158508bef000f8e2e5a759c7d92f984b2c86e5ff53425b",
32
+ },
33
+ {
34
+ path: "tokenizer.json",
35
+ bytes: 17_082_730,
36
+ sha256: "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
37
+ },
38
+ {
39
+ path: "onnx/model_quantized.onnx",
40
+ bytes: 118_308_185,
41
+ sha256: "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
42
+ },
43
+ ] as const;
44
+ export function conceptModelDirectory(): string {
45
+ return resolve(
46
+ process.env.SIFT_LIGHT_MODEL_DIR ?? join(homedir(), ".cache", "sift-light", "models"),
47
+ CONCEPT_REVISION,
48
+ );
49
+ }
50
+
51
+ export function conceptCacheDirectory(): string {
52
+ return resolve(
53
+ process.env.SIFT_LIGHT_MODEL_DIR ?? join(homedir(), ".cache", "sift-light", "models"),
54
+ "concept-cache",
55
+ `${CONCEPT_REVISION}-v${String(CONCEPT_CACHE_VERSION)}`,
56
+ );
57
+ }
58
+
59
+ /** Interactive hosts may lower the deadline; invalid values fail closed with an explicit error. */
60
+ export function resolveConceptTimeoutMs(environment: NodeJS.ProcessEnv = process.env): number {
61
+ const raw = environment[CONCEPT_TIMEOUT_ENV];
62
+ if (raw === undefined || raw === "") return CONCEPT_TIMEOUT_MS;
63
+ const value = Number(raw);
64
+ if (
65
+ !Number.isSafeInteger(value) ||
66
+ value < MIN_CONCEPT_TIMEOUT_MS ||
67
+ value > MAX_CONCEPT_TIMEOUT_MS
68
+ ) {
69
+ throw new SiftLightError(
70
+ `${CONCEPT_TIMEOUT_ENV} must be an integer from ${String(MIN_CONCEPT_TIMEOUT_MS)} through ${String(MAX_CONCEPT_TIMEOUT_MS)}`,
71
+ );
72
+ }
73
+ return value;
74
+ }
75
+ export async function verifyConceptModel(directory = conceptModelDirectory()): Promise<void> {
76
+ for (const asset of CONCEPT_ASSETS) {
77
+ const hash = createHash("sha256");
78
+ let size = 0;
79
+ try {
80
+ // oxlint-disable-next-line no-await-in-loop -- bounded streaming integrity checks, one model asset at a time.
81
+ for await (const chunk of createReadStream(join(directory, asset.path))) {
82
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
83
+ size += bytes.length;
84
+ if (size > asset.bytes) throw new Error("Asset exceeds pinned size");
85
+ hash.update(bytes);
86
+ }
87
+ if (size !== asset.bytes || hash.digest("hex") !== asset.sha256)
88
+ throw new Error("Pinned hash mismatch");
89
+ } catch (error) {
90
+ throw new SiftLightError(
91
+ `Local concept model is missing or invalid (${asset.path}); run sift-light-model --install-model explicitly`,
92
+ { cause: error },
93
+ );
94
+ }
95
+ }
96
+ }