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,210 @@
1
+ #!/usr/bin/env node
2
+ import { env, pipeline } from "@huggingface/transformers";
3
+ import { isUtf8 } from "node:buffer";
4
+ import {
5
+ conceptEmbeddingKey,
6
+ enforceConceptCacheLimit,
7
+ readConceptEmbedding,
8
+ writeConceptEmbedding,
9
+ type CachedConceptEmbedding,
10
+ } from "./concept-embedding-cache.js";
11
+ import {
12
+ cosineSimilarity,
13
+ embedConceptInputs,
14
+ meanNormalized,
15
+ type PendingConceptEmbedding,
16
+ } from "./concept-inference.js";
17
+ import {
18
+ CONCEPT_ASSETS,
19
+ CONCEPT_CACHE_MAX_BYTES,
20
+ MAX_CONCEPT_CHARS,
21
+ MAX_CONCEPT_WORKER_INPUT_BYTES,
22
+ conceptCacheDirectory,
23
+ conceptModelDirectory,
24
+ verifyConceptModel,
25
+ } from "./concept-model.js";
26
+ import { installConceptModel } from "./concept-setup.js";
27
+ import { isRecordValue } from "./record-value.js";
28
+
29
+ const CACHE_IO_CONCURRENCY = 64;
30
+
31
+ interface WorkerProgress {
32
+ phase: "cache-read" | "model-loading" | "embedding" | "cache-write" | "cache-cleanup";
33
+ completed?: number;
34
+ total?: number;
35
+ uniqueEmbeddings: number;
36
+ passages: number;
37
+ }
38
+
39
+ function emitProgress(progress: WorkerProgress): void {
40
+ process.stdout.write(`${JSON.stringify({ type: "progress", ...progress })}\n`);
41
+ }
42
+
43
+ async function readCachedEmbeddings(
44
+ root: string,
45
+ requested: readonly PendingConceptEmbedding[],
46
+ onProgress?: (completed: number, total: number) => void,
47
+ ) {
48
+ const cached = [];
49
+ for (let offset = 0; offset < requested.length; offset += CACHE_IO_CONCURRENCY) {
50
+ // oxlint-disable-next-line no-await-in-loop -- bounded batches avoid exhausting file descriptors.
51
+ const batch = await Promise.all(
52
+ requested
53
+ .slice(offset, offset + CACHE_IO_CONCURRENCY)
54
+ .map((item) => readConceptEmbedding(root, item.key)),
55
+ );
56
+ cached.push(...batch);
57
+ onProgress?.(Math.min(offset + batch.length, requested.length), requested.length);
58
+ }
59
+ return cached;
60
+ }
61
+
62
+ async function requestFromStdin(): Promise<{ query: string; passages: string[] }> {
63
+ const chunks: Buffer[] = [];
64
+ let bytes = 0;
65
+ for await (const chunk of process.stdin) {
66
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
67
+ chunks.push(buffer);
68
+ bytes += buffer.length;
69
+ if (bytes > MAX_CONCEPT_WORKER_INPUT_BYTES)
70
+ throw new Error("Concept worker input exceeds its 64 MiB source protocol budget");
71
+ }
72
+ const request: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
73
+ if (
74
+ !isRecordValue(request) ||
75
+ typeof request.query !== "string" ||
76
+ request.query.length === 0 ||
77
+ request.query.length > 256 ||
78
+ !request.query.isWellFormed() ||
79
+ /[\r\n\0]/u.test(request.query) ||
80
+ !Array.isArray(request.encodedPassages) ||
81
+ request.encodedPassages.some(
82
+ (item) =>
83
+ typeof item !== "string" ||
84
+ item.length === 0 ||
85
+ item.length % 4 !== 0 ||
86
+ item.length > Math.ceil((((MAX_CONCEPT_CHARS + 256) * 4) / 3) * 4) + 4 ||
87
+ !/^[A-Za-z0-9+/]+={0,2}$/u.test(item),
88
+ )
89
+ )
90
+ throw new Error("Invalid concept worker input");
91
+ const encodedPassages = request.encodedPassages.filter(
92
+ (item): item is string => typeof item === "string",
93
+ );
94
+ const buffers = encodedPassages.map((item) => Buffer.from(item, "base64"));
95
+ if (buffers.some((item) => !isUtf8(item)))
96
+ throw new Error("Invalid UTF-8 concept worker passage");
97
+ const passages = buffers.map((item) => item.toString("utf8"));
98
+ if (
99
+ passages.some(
100
+ (item) => !item.length || item.length > MAX_CONCEPT_CHARS + 256 || !item.isWellFormed(),
101
+ )
102
+ )
103
+ throw new Error("Invalid decoded concept worker passage");
104
+ return { query: request.query, passages };
105
+ }
106
+
107
+ async function search(): Promise<void> {
108
+ const request = await requestFromStdin();
109
+ const directory = conceptModelDirectory();
110
+ const cacheRoot = conceptCacheDirectory();
111
+ const stagingRoot = process.env.SIFT_LIGHT_CONCEPT_CACHE_STAGING_DIR ?? cacheRoot;
112
+ await verifyConceptModel(directory);
113
+ const requested: PendingConceptEmbedding[] = [
114
+ { key: conceptEmbeddingKey("query", request.query), role: "query", text: request.query },
115
+ ...request.passages.map((text): PendingConceptEmbedding => ({
116
+ key: conceptEmbeddingKey("passage", text),
117
+ role: "passage",
118
+ text,
119
+ })),
120
+ ];
121
+ const uniqueRequested = [...new Map(requested.map((item) => [item.key, item])).values()];
122
+ const progressContext = {
123
+ uniqueEmbeddings: uniqueRequested.length,
124
+ passages: request.passages.length,
125
+ };
126
+ const cached = await readCachedEmbeddings(cacheRoot, uniqueRequested, (completed, total) =>
127
+ emitProgress({ phase: "cache-read", completed, total, ...progressContext }),
128
+ );
129
+ const missing = uniqueRequested.filter((_item, index) => !cached[index]);
130
+ const warnings: string[] = [];
131
+ let created: CachedConceptEmbedding[] = [];
132
+ let cacheWriteFailed = false;
133
+ if (missing.length) {
134
+ env.allowRemoteModels = false;
135
+ env.useFSCache = false;
136
+ env.useBrowserCache = false;
137
+ env.localModelPath = "/";
138
+ emitProgress({ phase: "model-loading", ...progressContext });
139
+ const extractor = await pipeline("feature-extraction", directory, {
140
+ local_files_only: true,
141
+ dtype: "q8",
142
+ device: "cpu",
143
+ session_options: { intraOpNumThreads: 2, interOpNumThreads: 1 },
144
+ });
145
+ try {
146
+ created = await embedConceptInputs(extractor, missing, {
147
+ onCompletedEmbedding: async (embedding, completed, total) => {
148
+ try {
149
+ await writeConceptEmbedding(cacheRoot, embedding, stagingRoot);
150
+ } catch {
151
+ cacheWriteFailed = true;
152
+ }
153
+ emitProgress({ phase: "cache-write", completed, total, ...progressContext });
154
+ },
155
+ onBatch: (completed, total) =>
156
+ emitProgress({ phase: "embedding", completed, total, ...progressContext }),
157
+ });
158
+ } finally {
159
+ await extractor.dispose();
160
+ }
161
+ if (cacheWriteFailed)
162
+ warnings.push(
163
+ "Concept embedding cache write failed; ranking completed but some work may repeat",
164
+ );
165
+ }
166
+ const createdByKey = new Map(created.map((item) => [item.key, item]));
167
+ const embeddingsByKey = new Map(
168
+ uniqueRequested.map((item, index) => [item.key, cached[index] ?? createdByKey.get(item.key)]),
169
+ );
170
+ const embeddings = requested.map((item) => embeddingsByKey.get(item.key));
171
+ if (embeddings.some((item) => !item)) throw new Error("Missing concept embedding result");
172
+ const queryWindows = embeddings[0]?.windows;
173
+ if (!queryWindows?.length) throw new Error("Missing concept query embedding");
174
+ const query = meanNormalized(queryWindows.map((window) => window.vector));
175
+ const passageEmbeddings = embeddings.slice(1);
176
+ const scores = passageEmbeddings.map((embedding) => {
177
+ if (!embedding?.windows.length) throw new Error("Missing concept passage embedding");
178
+ return Math.max(...embedding.windows.map((window) => cosineSimilarity(query, window.vector)));
179
+ });
180
+ let cacheBytes: number | undefined;
181
+ try {
182
+ emitProgress({ phase: "cache-cleanup", ...progressContext });
183
+ cacheBytes = await enforceConceptCacheLimit(cacheRoot);
184
+ } catch {
185
+ warnings.push(
186
+ "Concept embedding cache cleanup failed; the configured 512 MiB bound was not verified",
187
+ );
188
+ }
189
+ process.stdout.write(
190
+ JSON.stringify({
191
+ type: "result",
192
+ scores,
193
+ cacheHits: cached.filter(Boolean).length,
194
+ cacheMisses: missing.length,
195
+ cacheMaxBytes: CONCEPT_CACHE_MAX_BYTES,
196
+ ...(cacheBytes === undefined ? {} : { cacheBytes }),
197
+ windowsRanked: passageEmbeddings.reduce(
198
+ (total, embedding) => total + (embedding?.windows.length ?? 0),
199
+ 0,
200
+ ),
201
+ warnings: [...new Set(warnings)],
202
+ peakRssBytes: process.resourceUsage().maxRSS * 1024,
203
+ modelBytes: CONCEPT_ASSETS.reduce((sum, asset) => sum + asset.bytes, 0),
204
+ }),
205
+ );
206
+ }
207
+
208
+ if (process.argv.includes("--install-model")) await installConceptModel();
209
+ else if (process.argv.includes("--infer")) await search();
210
+ else throw new Error("Usage: sift-light-model --install-model");
@@ -0,0 +1,283 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export const SIFT_LIGHT_CONFIG_FILE = "sift-light.json";
5
+ export const SIFT_LIGHT_CONFIG_ENV = "SIFT_LIGHT_CONFIG";
6
+
7
+ export type SiftLightLocale = "en" | "zh-CN";
8
+ export type SearchEnforcementMode = "hard" | "prefer" | "off";
9
+
10
+ export const SIFT_LIGHT_ENFORCEMENT_ENV = "SIFT_LIGHT_ENFORCE_SEARCH";
11
+
12
+ export interface SiftLightConfig {
13
+ locale: SiftLightLocale;
14
+ enforceSearch?: SearchEnforcementMode;
15
+ semanticJudge?: SemanticJudgeConfig;
16
+ }
17
+
18
+ export type SemanticJudgeProvider = "jev";
19
+
20
+ export const SEMANTIC_JUDGE_API_KEY_ENVS = ["TYPESAFE_API_KEY", "SIFT_LIGHT_JEV_API_KEY"] as const;
21
+
22
+ export interface SemanticJudgeConfig {
23
+ enabled: boolean;
24
+ provider: SemanticJudgeProvider;
25
+ endpoint: string;
26
+ apiKeyEnv: string;
27
+ model: string;
28
+ timeoutMs: number;
29
+ maxCandidates: number;
30
+ maxRetries: number;
31
+ }
32
+
33
+ export const DEFAULT_SEMANTIC_JUDGE_CONFIG: Readonly<SemanticJudgeConfig> = {
34
+ enabled: false,
35
+ provider: "jev",
36
+ endpoint: "https://api.typesafe.ai/v1/systemone",
37
+ apiKeyEnv: SEMANTIC_JUDGE_API_KEY_ENVS[0],
38
+ model: "jev-latest",
39
+ timeoutMs: 120_000,
40
+ maxCandidates: 20,
41
+ maxRetries: 2,
42
+ };
43
+
44
+ export const DEFAULT_SIFT_LIGHT_CONFIG: Readonly<SiftLightConfig> = {
45
+ locale: "en",
46
+ enforceSearch: "hard",
47
+ semanticJudge: DEFAULT_SEMANTIC_JUDGE_CONFIG,
48
+ };
49
+
50
+ interface RawSiftLightConfig {
51
+ locale?: unknown;
52
+ enforceSearch?: unknown;
53
+ semanticJudge?: unknown;
54
+ }
55
+
56
+ export interface ReadSiftLightConfigOptions {
57
+ missing?: "defaults" | "error";
58
+ }
59
+
60
+ export function resolveSiftLightConfigPath(
61
+ agentDirectory: string,
62
+ environment: NodeJS.ProcessEnv = process.env,
63
+ ): string {
64
+ const configured = environment[SIFT_LIGHT_CONFIG_ENV]?.trim();
65
+ return configured || join(agentDirectory, SIFT_LIGHT_CONFIG_FILE);
66
+ }
67
+
68
+ interface RawSemanticJudgeConfig {
69
+ enabled?: unknown;
70
+ provider?: unknown;
71
+ endpoint?: unknown;
72
+ apiKeyEnv?: unknown;
73
+ model?: unknown;
74
+ timeoutMs?: unknown;
75
+ maxCandidates?: unknown;
76
+ maxRetries?: unknown;
77
+ }
78
+
79
+ function hasErrorCode(error: unknown, codes: string[]): boolean {
80
+ return error instanceof Error && "code" in error && codes.includes(String(error.code));
81
+ }
82
+
83
+ function isMissingFile(error: unknown): boolean {
84
+ return hasErrorCode(error, ["ENOENT"]);
85
+ }
86
+
87
+ function isRawSiftLightConfig(value: unknown): value is RawSiftLightConfig {
88
+ return typeof value === "object" && value !== null && !Array.isArray(value);
89
+ }
90
+
91
+ function isRawSemanticJudgeConfig(value: unknown): value is RawSemanticJudgeConfig {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ function boundedInteger(
96
+ value: unknown,
97
+ fallback: number,
98
+ minimum: number,
99
+ maximum: number,
100
+ field: string,
101
+ ): number {
102
+ const candidate = value ?? fallback;
103
+ if (
104
+ typeof candidate !== "number" ||
105
+ !Number.isSafeInteger(candidate) ||
106
+ candidate < minimum ||
107
+ candidate > maximum
108
+ ) {
109
+ throw new Error(
110
+ `Invalid sift-light ${field}: expected an integer from ${String(minimum)} through ${String(maximum)}`,
111
+ );
112
+ }
113
+ return candidate;
114
+ }
115
+
116
+ function parseSemanticJudge(value: unknown, path: string): SemanticJudgeConfig {
117
+ if (value === undefined) return { ...DEFAULT_SEMANTIC_JUDGE_CONFIG };
118
+ if (!isRawSemanticJudgeConfig(value)) {
119
+ throw new Error(`Invalid sift-light config at ${path}: semanticJudge must be an object`);
120
+ }
121
+ const unknown = Object.keys(value).filter(
122
+ (key) =>
123
+ ![
124
+ "enabled",
125
+ "provider",
126
+ "endpoint",
127
+ "apiKeyEnv",
128
+ "model",
129
+ "timeoutMs",
130
+ "maxCandidates",
131
+ "maxRetries",
132
+ ].includes(key),
133
+ );
134
+ if (unknown.length > 0) {
135
+ throw new Error(
136
+ `Invalid sift-light config at ${path}: unsupported semanticJudge fields; accepted fields are enabled, provider, endpoint, apiKeyEnv, model, timeoutMs, maxCandidates and maxRetries`,
137
+ );
138
+ }
139
+ const enabled = value.enabled ?? DEFAULT_SEMANTIC_JUDGE_CONFIG.enabled;
140
+ if (typeof enabled !== "boolean") {
141
+ throw new Error(
142
+ `Invalid sift-light config at ${path}: semanticJudge.enabled must be a boolean`,
143
+ );
144
+ }
145
+ const provider = value.provider ?? DEFAULT_SEMANTIC_JUDGE_CONFIG.provider;
146
+ if (provider !== "jev") {
147
+ throw new Error(`Invalid sift-light config at ${path}: semanticJudge.provider must be "jev"`);
148
+ }
149
+ const endpoint = value.endpoint ?? DEFAULT_SEMANTIC_JUDGE_CONFIG.endpoint;
150
+ if (typeof endpoint !== "string" || endpoint.length === 0 || endpoint.length > 2_048) {
151
+ throw new Error(
152
+ `Invalid sift-light config at ${path}: semanticJudge.endpoint must be a nonempty URL`,
153
+ );
154
+ }
155
+ let parsedEndpoint: URL;
156
+ try {
157
+ parsedEndpoint = new URL(endpoint);
158
+ } catch (error) {
159
+ throw new Error(`Invalid sift-light config at ${path}: semanticJudge.endpoint must be a URL`, {
160
+ cause: error,
161
+ });
162
+ }
163
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
164
+ const secureEndpoint =
165
+ parsedEndpoint.protocol === "https:" ||
166
+ (parsedEndpoint.protocol === "http:" && loopbackHosts.has(parsedEndpoint.hostname));
167
+ if (!secureEndpoint || parsedEndpoint.username || parsedEndpoint.password) {
168
+ throw new Error(
169
+ `Invalid sift-light config at ${path}: semanticJudge.endpoint must use HTTPS, except that HTTP is allowed for localhost loopback development; URL credentials are not allowed`,
170
+ );
171
+ }
172
+ const apiKeyEnv = value.apiKeyEnv ?? DEFAULT_SEMANTIC_JUDGE_CONFIG.apiKeyEnv;
173
+ if (typeof apiKeyEnv !== "string" || !/^[A-Z][A-Z0-9_]{0,127}$/u.test(apiKeyEnv)) {
174
+ throw new Error(
175
+ `Invalid sift-light config at ${path}: semanticJudge.apiKeyEnv must be an uppercase environment variable name`,
176
+ );
177
+ }
178
+ const model = value.model ?? DEFAULT_SEMANTIC_JUDGE_CONFIG.model;
179
+ if (
180
+ typeof model !== "string" ||
181
+ model.length === 0 ||
182
+ model.length > 128 ||
183
+ /[\r\n\0]/u.test(model)
184
+ ) {
185
+ throw new Error(
186
+ `Invalid sift-light config at ${path}: semanticJudge.model must be bounded single-line text`,
187
+ );
188
+ }
189
+ return {
190
+ enabled,
191
+ provider,
192
+ endpoint,
193
+ apiKeyEnv,
194
+ model,
195
+ timeoutMs: boundedInteger(
196
+ value.timeoutMs,
197
+ DEFAULT_SEMANTIC_JUDGE_CONFIG.timeoutMs,
198
+ 1_000,
199
+ 1_200_000,
200
+ `config at ${path}: semanticJudge.timeoutMs`,
201
+ ),
202
+ maxCandidates: boundedInteger(
203
+ value.maxCandidates,
204
+ DEFAULT_SEMANTIC_JUDGE_CONFIG.maxCandidates,
205
+ 1,
206
+ 20,
207
+ `config at ${path}: semanticJudge.maxCandidates`,
208
+ ),
209
+ maxRetries: boundedInteger(
210
+ value.maxRetries,
211
+ DEFAULT_SEMANTIC_JUDGE_CONFIG.maxRetries,
212
+ 0,
213
+ 5,
214
+ `config at ${path}: semanticJudge.maxRetries`,
215
+ ),
216
+ };
217
+ }
218
+
219
+ function parseConfig(value: unknown, path: string): SiftLightConfig {
220
+ if (!isRawSiftLightConfig(value)) {
221
+ throw new Error(`Invalid sift-light config at ${path}: expected a JSON object`);
222
+ }
223
+ const unknown = Object.keys(value).filter(
224
+ (key) => !["locale", "enforceSearch", "semanticJudge"].includes(key),
225
+ );
226
+ if (unknown.length > 0) {
227
+ throw new Error(
228
+ `Invalid sift-light config at ${path}: unsupported configuration fields; only locale, enforceSearch and semanticJudge are accepted`,
229
+ );
230
+ }
231
+ const { locale, enforceSearch } = value;
232
+ if (locale !== undefined && locale !== "en" && locale !== "zh-CN") {
233
+ throw new Error(`Invalid sift-light config at ${path}: locale must be "en" or "zh-CN"`);
234
+ }
235
+ const enforcement = normalizeSearchEnforcement(enforceSearch, `config at ${path}`);
236
+ return {
237
+ locale: locale ?? DEFAULT_SIFT_LIGHT_CONFIG.locale,
238
+ enforceSearch: enforcement,
239
+ semanticJudge: parseSemanticJudge(value.semanticJudge, path),
240
+ };
241
+ }
242
+
243
+ export function normalizeSearchEnforcement(value: unknown, source: string): SearchEnforcementMode {
244
+ if (value === undefined || value === "hard") return "hard";
245
+ if (value === "prefer") return "prefer";
246
+ if (value === "off") return "off";
247
+ throw new Error(`Invalid sift-light ${source}: enforceSearch must be "hard", "prefer", or "off"`);
248
+ }
249
+
250
+ /** Native hooks inherit this setting from their host process; absent means fail-safe hard mode. */
251
+ export function readNativeSearchEnforcement(
252
+ environment: NodeJS.ProcessEnv = process.env,
253
+ ): SearchEnforcementMode {
254
+ const value = environment[SIFT_LIGHT_ENFORCEMENT_ENV];
255
+ return normalizeSearchEnforcement(value, `environment variable ${SIFT_LIGHT_ENFORCEMENT_ENV}`);
256
+ }
257
+
258
+ /** Read and validate one host-selected config path. */
259
+ export async function readSiftLightConfigFile(
260
+ path: string,
261
+ options: ReadSiftLightConfigOptions = {},
262
+ ): Promise<SiftLightConfig> {
263
+ try {
264
+ const content = await readFile(path, "utf8");
265
+ return parseConfig(JSON.parse(content), path);
266
+ } catch (error) {
267
+ if (isMissingFile(error)) {
268
+ if (options.missing === "error") {
269
+ throw new Error(
270
+ `sift-light config was not found at ${path}; create it or unset ${SIFT_LIGHT_CONFIG_ENV}`,
271
+ { cause: error },
272
+ );
273
+ }
274
+ return { ...DEFAULT_SIFT_LIGHT_CONFIG };
275
+ }
276
+ if (error instanceof SyntaxError) {
277
+ throw new Error(`Invalid sift-light config at ${path}: ${error.message}`, {
278
+ cause: error,
279
+ });
280
+ }
281
+ throw error;
282
+ }
283
+ }
package/src/config.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ readSiftLightConfigFile,
4
+ resolveSiftLightConfigPath,
5
+ SIFT_LIGHT_CONFIG_ENV,
6
+ type SiftLightConfig,
7
+ } from "./config-reader.js";
8
+
9
+ export {
10
+ DEFAULT_SIFT_LIGHT_CONFIG,
11
+ DEFAULT_SEMANTIC_JUDGE_CONFIG,
12
+ normalizeSearchEnforcement,
13
+ resolveSiftLightConfigPath,
14
+ SIFT_LIGHT_CONFIG_ENV,
15
+ type ReadSiftLightConfigOptions,
16
+ type SiftLightConfig,
17
+ type SiftLightLocale,
18
+ type SearchEnforcementMode,
19
+ type SemanticJudgeConfig,
20
+ type SemanticJudgeProvider,
21
+ } from "./config-reader.js";
22
+
23
+ export function siftLightConfigPath(
24
+ agentDir = getAgentDir(),
25
+ environment: NodeJS.ProcessEnv = process.env,
26
+ ): string {
27
+ return resolveSiftLightConfigPath(agentDir, environment);
28
+ }
29
+
30
+ export async function readSiftLightConfig(
31
+ agentDir = getAgentDir(),
32
+ environment: NodeJS.ProcessEnv = process.env,
33
+ ): Promise<SiftLightConfig> {
34
+ return readSiftLightConfigFile(siftLightConfigPath(agentDir, environment), {
35
+ missing: environment[SIFT_LIGHT_CONFIG_ENV]?.trim() ? "error" : "defaults",
36
+ });
37
+ }
@@ -0,0 +1,26 @@
1
+ import type { ContextUsage } from "@earendil-works/pi-coding-agent";
2
+ import { CONTEXT_BUDGET_POLICY, type ContextBudget, type ContextBudgetTier } from "./types.js";
3
+
4
+ function budgetTier(contextRemainderPercent: number): ContextBudgetTier {
5
+ if (contextRemainderPercent > CONTEXT_BUDGET_POLICY.fullAboveRemainderPercent) {
6
+ return "full";
7
+ }
8
+ if (contextRemainderPercent < CONTEXT_BUDGET_POLICY.criticalBelowRemainderPercent) {
9
+ return "critical";
10
+ }
11
+ return "tight";
12
+ }
13
+
14
+ /** Resolve one stable budget decision from the host's pre-call context usage. */
15
+ export function resolveContextBudget(usage: ContextUsage | undefined): ContextBudget | undefined {
16
+ if (!usage || usage.tokens === null || usage.percent === null) return undefined;
17
+ if (!Number.isFinite(usage.percent) || usage.percent < 0 || usage.percent > 100) return undefined;
18
+
19
+ const contextRemainderPercent = Number((100 - usage.percent).toFixed(1));
20
+ const tier = budgetTier(contextRemainderPercent);
21
+ return {
22
+ tier,
23
+ contextRemainderPercent,
24
+ resultTokenBudget: CONTEXT_BUDGET_POLICY.resultTokenBudgets[tier],
25
+ };
26
+ }
@@ -0,0 +1,2 @@
1
+ export const DISCOVERY_MODE_REQUIRED_ERROR =
2
+ 'query requires an explicit discovery mode: use mode=files for filename/path discovery or mode=concept for semantic discovery; for example {"mode":"files","query":"<filename-or-path>"}';
package/src/errors.ts ADDED
@@ -0,0 +1,95 @@
1
+ export class SiftLightError extends Error {
2
+ constructor(message: string, options?: ErrorOptions) {
3
+ super(message, options);
4
+ this.name = "SiftLightError";
5
+ }
6
+ }
7
+
8
+ export type SiftLightRecoveryAction = "retry" | "choose-capability" | "manual";
9
+
10
+ export interface SiftLightDiagnosticIssue {
11
+ field: string;
12
+ reason: string;
13
+ }
14
+
15
+ export interface SiftLightDiagnosticRecovery {
16
+ action: SiftLightRecoveryAction;
17
+ reason: string;
18
+ nextRequest?: Record<string, unknown>;
19
+ }
20
+
21
+ export interface SiftLightDiagnosticDetails {
22
+ code: string;
23
+ mode?: string;
24
+ issues: readonly SiftLightDiagnosticIssue[];
25
+ recovery: SiftLightDiagnosticRecovery;
26
+ }
27
+
28
+ export interface SiftLightDiagnosticError extends Error {
29
+ readonly details: SiftLightDiagnosticDetails;
30
+ }
31
+
32
+ /** A bounded Concept provider failure that hybrid retrieval may expose as skipped coverage. */
33
+ export class ConceptUnavailableError extends SiftLightError {
34
+ constructor(message: string, options?: ErrorOptions) {
35
+ super(message, options);
36
+ this.name = "ConceptUnavailableError";
37
+ }
38
+ }
39
+
40
+ export type RipgrepInputErrorCode = "E_REGEX_INVALID" | "E_SEARCH_PATH_NOT_FOUND";
41
+
42
+ /** A bounded, non-retrying diagnostic for an invalid search input at ripgrep's boundary. */
43
+ export class RipgrepInputError extends SiftLightError {
44
+ readonly code: RipgrepInputErrorCode;
45
+ readonly guidance: string;
46
+ readonly details: SiftLightDiagnosticDetails;
47
+
48
+ constructor(code: RipgrepInputErrorCode, message: string, guidance: string) {
49
+ super(`${message} ${guidance}`);
50
+ this.name = "RipgrepInputError";
51
+ this.code = code;
52
+ this.guidance = guidance;
53
+ this.details = {
54
+ code,
55
+ issues: [
56
+ {
57
+ field: code === "E_REGEX_INVALID" ? "pattern" : "path",
58
+ reason: guidance,
59
+ },
60
+ ],
61
+ recovery: { action: "manual", reason: guidance },
62
+ };
63
+ }
64
+ }
65
+
66
+ export class CursorError extends SiftLightError {
67
+ readonly code:
68
+ | "E_CURSOR_MALFORMED"
69
+ | "E_CURSOR_NOT_FOUND"
70
+ | "E_CURSOR_EXPIRED"
71
+ | "E_CURSOR_WRONG_KIND"
72
+ | "E_CURSOR_OPTIONS_CONFLICT"
73
+ | "E_CURSOR_OFFSET_INVALID";
74
+
75
+ constructor(
76
+ message: string,
77
+ code:
78
+ | "E_CURSOR_MALFORMED"
79
+ | "E_CURSOR_NOT_FOUND"
80
+ | "E_CURSOR_EXPIRED"
81
+ | "E_CURSOR_WRONG_KIND"
82
+ | "E_CURSOR_OPTIONS_CONFLICT"
83
+ | "E_CURSOR_OFFSET_INVALID" = "E_CURSOR_MALFORMED",
84
+ ) {
85
+ super(`${code}: ${message}`);
86
+ this.name = "CursorError";
87
+ this.code = code;
88
+ }
89
+ }
90
+
91
+ export function abortError(): Error {
92
+ const error = new Error("Operation aborted");
93
+ error.name = "AbortError";
94
+ return error;
95
+ }