sift-light 1.0.5-6 → 1.1.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 (41) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +83 -1
  3. package/README.zh-CN.md +78 -1
  4. package/native/WorkerMemory.swift +18 -0
  5. package/native/media-search/Worker.swift +147 -0
  6. package/native/text-search/Worker.swift +129 -0
  7. package/package.json +11 -3
  8. package/plugins/sift-light/.claude-plugin/plugin.json +1 -1
  9. package/plugins/sift-light/.codex-plugin/plugin.json +1 -1
  10. package/plugins/sift-light/.mcp.json +1 -1
  11. package/plugins/sift-light/kimi.plugin.json +2 -2
  12. package/plugins/sift-light/omp-extension.mjs +10857 -4154
  13. package/plugins/sift-light/package.json +1 -1
  14. package/scripts/build-native-media.ts +3 -0
  15. package/scripts/build-native-text.ts +3 -0
  16. package/scripts/build-native-worker.ts +103 -0
  17. package/src/analysis-store.ts +85 -12
  18. package/src/analysis-types.ts +8 -0
  19. package/src/concept-search.ts +3 -0
  20. package/src/config-reader.ts +167 -47
  21. package/src/config.ts +1 -0
  22. package/src/evidence-service.ts +64 -5
  23. package/src/index.ts +2 -0
  24. package/src/mcp-model-output.ts +11 -1
  25. package/src/mcp-semantic-judge.ts +8 -0
  26. package/src/mcp-server.mjs +4971 -3612
  27. package/src/mcp-server.ts +44 -5
  28. package/src/mcp.ts +46 -1
  29. package/src/media-config.ts +21 -0
  30. package/src/media-search.ts +466 -0
  31. package/src/native-text-config.ts +30 -0
  32. package/src/native-text-search.ts +176 -0
  33. package/src/native-text-worker-client.ts +51 -0
  34. package/src/omp-index.ts +2 -0
  35. package/src/operation-lifecycle.ts +37 -32
  36. package/src/owned-process.ts +5 -1
  37. package/src/remote-media-client.ts +363 -0
  38. package/src/resident-worker-client.ts +203 -0
  39. package/src/retrieval-ranking.ts +23 -0
  40. package/src/semantic-judge.ts +217 -106
  41. package/src/service.ts +22 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-10-12
4
+
5
+ - Add opt-in BYO HTTP JSON providers for Jev-compatible semantic judging and multimodal retrieval.
6
+ - Keep local native text and visual workers as the default paths, with local source hashes, PDF page mapping, inspection and validation retained after remote ranking.
7
+ - Bound remote request and response sizes, timeouts, retries, file identifiers and credentials; API keys remain environment references rather than configuration values.
8
+ - Add configuration, protocol, MCP boundary and end-to-end local provider regression coverage.
9
+
10
+ ## 1.1.0-1 — 2026-10-12
11
+
12
+ - Add an independent native text retrieval lane and bounded rank fusion while retaining exact source inspection, validation and both text result lanes.
13
+ - Add opt-in MCP image search from text or an image, including image-only and mixed PDF scanned-page retrieval with original page numbers and rendered previews. Requires Apple Silicon and separately configured native workers and model files.
14
+ - Share resident worker lifecycle management with cancellation, recovery and idle shutdown; bound native GPU cache and batching to reduce memory use.
15
+ - Preserve default model-free exact search and explicit partial-coverage and stale-source diagnostics.
16
+
3
17
  ## 1.0.5-6 — 2026-10-10
4
18
 
5
19
  - Add structured `repairExamples` to invalid-regex and missing-path diagnostics. The examples preserve bounded search filters, never auto-convert regex input, and are omitted when redaction would make request data unsafe to echo.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Concept ranking covers every UTF-8 passage admitted by the request's documented
34
34
 
35
35
  Slow Concept and hybrid requests return within the default five-second wait window with `status: "waiting"` or `"running"`, an `operationId`, progress and an exact `nextRequest` such as `{ "mode": "await", "operationId": "..." }`. Copy that request unchanged: it resumes the same computation and never restarts the query or downgrades to a literal-only result. A final result remains available for stable re-fetch for 10 minutes, with up to 32 terminal results retained per service session, and `mode: "cancel"` stops the owned work and waits for cleanup. Each service session admits at most eight pending operations; an operation has one total deadline controlled by `SIFT_LIGHT_CONCEPT_TIMEOUT_MS` (integer milliseconds from 1000 through 3600000; default 600000) and a 120-second idle continuation lease. A real model, source or resource failure is returned as a failure with its diagnostic. Source generation is re-enumerated and re-verified before publication, so changes refresh the operation and mixed versions are never marked complete. Admission planning counts (`filesEnumerated`, `filesAdmitted`, `filesSkippedEmpty`, `filesUnavailable`, `passagesQueued`, `batchesPlanned`, `batchesCompleted`) stay visible. Empty files are a normal skip and do not mark the result partial.
36
36
 
37
- The first uncached Concept or hybrid search loads the local model and may take tens of seconds and more than 1 GiB of inference-worker memory, depending on the machine and search scope. Later searches reuse cached passage embeddings even when the query wording changes, although a new query still loads the model briefly to embed that query. Changed passages are recomputed; cache eviction, deletion or broad source changes can cause another cold run. Exact search modes do not load the model. These are workload-dependent observations, not a latency or memory guarantee. If the result is waiting during `model-loading`, follow its `nextRequest` to continue the same operation.
37
+ The first uncached Concept or hybrid search loads the local model and may take tens of seconds and more than 1 GiB of inference-worker memory, depending on the machine and search scope. Later searches reuse cached passage embeddings even when the query wording changes, and an active service reuses its loaded model. The E5 worker is released after 30 seconds of inactivity; the next query then loads it again. Changed passages are recomputed; cache eviction, deletion or broad source changes can cause another cold run. Exact search modes do not load the model. These are workload-dependent observations, not a latency or memory guarantee. If the result is waiting during `model-loading`, follow its `nextRequest` to continue the same operation.
38
38
 
39
39
  ### Give several search conditions together
40
40
 
@@ -73,6 +73,30 @@ Use `mode: "outline"` with a concrete JS/TS/TSX or Python file to see bounded sy
73
73
 
74
74
  The readable result keeps the main evidence compact. Per-item ranges, counts, coverage and continuation requests remain in structured `details`, so a client can use the structured fields without requiring a second search.
75
75
 
76
+ ### Independent text retrieval on Apple Silicon
77
+
78
+ Optional `nativeText` configuration makes `concept` and `hybrid` return two local retrieval lanes. Each keeps its own ranking; `details.analysis.retrievalLanes.current` and `.native` contain executable continuation requests. For `hybrid` with `ranking: "relevance"`, the default view ranks at most ten distinct files using weighted reciprocal file ranks (native 1, current 0.1). Raw scores across lanes are not comparable. Other dual-lane views interleave candidates. The default view and both lanes support `inspect` and `validate`, including source-change reporting.
79
+
80
+ On Apple Silicon macOS, prepare Xcode, Metal Toolchain, a local SDK checkout and model directory, then run from this project's checkout:
81
+
82
+ ```sh
83
+ bun run build:native-text -- /absolute/sdk-checkout /absolute/native-text-output
84
+ ```
85
+
86
+ The builder selects a fixed revision and installs the executable and required resource bundles. The model directory must contain `model.safetensors`, `config.json`, `tokenizer.json` and `tokenizer_config.json`. Keep the complete output directory and add this to the host's active `sift-light.json`:
87
+
88
+ ```json
89
+ {
90
+ "vectorSearchEnabled": true,
91
+ "nativeText": {
92
+ "executable": "/absolute/native-text-output/native-text",
93
+ "modelDirectory": "/absolute/model-directory"
94
+ }
95
+ }
96
+ ```
97
+
98
+ The existing E5 model is still required. The native process reads only private staged text admitted by the existing source policy. The service reuses its incremental index and removes it on shutdown; consecutive requests reuse the same loaded native model. After 60 seconds of inactivity the native worker exits and releases its memory; the next request starts a fresh worker. Cancellation, shutdown and protocol failures reap the owned worker before further work begins. `details.analysis.stats` reports `nativeModelLoads`, `nativeModelReused` and `nativeProcessId`. Existing admission controls hidden files, globs, exclusions and read budgets. Unknown text suffixes are normalized from verified UTF-8 bytes into plain text copies. Executable, model and protocol failures are explicit errors. Omit `nativeText` to retain existing behavior. This option enables text only, without image, audio or video retrieval.
99
+
76
100
  ## Common uses
77
101
 
78
102
  Tell your agent what you need, for example:
@@ -184,3 +208,61 @@ In hard mode, a direct search such as `grep warning report.txt` is denied. A rec
184
208
  Local searches stay on your machine. Only grant access to files your agent is allowed to read. HTTP deployments need an authenticated gateway before public exposure; see [Security](SECURITY.md).
185
209
 
186
210
  [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [AGPL-3.0-only license](LICENSE)
211
+
212
+ ### Visual search (MCP)
213
+
214
+ Configure `nativeMedia` with absolute `executable` and `modelDirectory` paths, then restart the MCP host. Build the Apple Silicon worker with `bun run build:native-media <sdk-checkout> <output-directory>`. The worker uses the local model weights with vision enabled and audio disabled.
215
+
216
+ The additional `sift-light-media` tool accepts `mode: "search"`, an exact local `path`, and exactly one `query` (text description) or `image` (local image path). `kind` is `all`, `image`, or `scan`. File filters `glob`, `exclude`, `hidden`, and `ignorePolicy` use the same admission policy as workspace search. Text and source navigation continue through `sift-light`.
217
+
218
+ ```json
219
+ {
220
+ "mode": "search",
221
+ "path": "/absolute/project/assets",
222
+ "query": "a dog running on grass",
223
+ "kind": "image"
224
+ }
225
+ ```
226
+
227
+ ```json
228
+ {
229
+ "mode": "search",
230
+ "path": "/absolute/project/assets",
231
+ "image": "/absolute/query.jpg",
232
+ "kind": "all"
233
+ }
234
+ ```
235
+
236
+ Results retain up to ten distinct files, content hashes, original PDF page numbers, and exact `inspect`/`validate` requests for this tool. `inspect` returns actual PNG pixels at a maximum dimension of 1024; scan results render the corresponding original PDF page. Changed sources reject inspection and validation reports stale evidence. Scanned pages in mixed PDFs retain their original page numbers. Visual similarity remains candidate evidence; small print recognition and region-level text location are not guaranteed.
237
+
238
+ ### Bring your own provider
239
+
240
+ The local model path remains available through `nativeMedia`. To use a provider you supply, add `media` to the same `sift-light.json`. The endpoint must implement the HTTP JSON contract below; it may be a third-party service or a local gateway that translates another vendor's API. A local service can omit `apiKeyEnv`; remote services should use HTTPS.
241
+
242
+ ```json
243
+ {
244
+ "media": {
245
+ "provider": "http-json",
246
+ "endpoint": "https://your-provider.example/v1/media-search",
247
+ "model": "your-vision-model",
248
+ "apiKeyEnv": "YOUR_MEDIA_KEY",
249
+ "timeoutMs": 120000,
250
+ "maxRetries": 2,
251
+ "maxRequestBytes": 33554432,
252
+ "renderer": {
253
+ "executable": "/absolute/native-media",
254
+ "modelDirectory": "/absolute/model-directory"
255
+ }
256
+ }
257
+ }
258
+ ```
259
+
260
+ The plugin sends admitted files as bounded Base64 content with opaque `fileId` values; it never sends local absolute paths. A response must contain `version: 1`, `indexedFiles`, and `hits`. Each hit contains `fileId`, `score`, `kind`, `chunkCount`, `width`, and `height`; scanned PDF hits also contain a one-based `page`. The plugin maps IDs back to local sources and then performs hash, page, `inspect`, and `validate` checks. The remote service does not decide whether source evidence is trustworthy.
261
+
262
+ Jev also accepts `semanticJudge.provider: "http-json"`. The request is `{ "version": 1, "model", "query", "candidates" }`; the response is `{ "version": 1, "model", "judgments", "usage" }`. Each judgment contains a candidate `id`, a `choice`, and a `probability` from 0 to 1. When `apiKeyEnv` is configured, the key is read only from that environment variable and is never written to configuration, logs, or result content. A keyless local provider is valid. Remote failures retain local candidates and mark coverage as partial.
263
+
264
+ The remote path uses the same `sift-light-media` tool and source verification as the local path. PNG hits can be inspected directly; PDF or other image previews require a local renderer in `media.renderer`. Validation does not require a renderer.
265
+
266
+ Cold indexing returns `nextRequest` with `mode: "await"`; copy it to the same tool. `mode: "cancel"` and its `operationId` stop that work. The local provider reuses one resident visual model for consecutive requests and releases it after 60 seconds idle; remote provider timeouts, retries, and request limits come from its configuration. The service owns shutdown and cancellation. Each request admits at most 512 media files, 20 MiB per file and 256 MiB total; corpus limits and enumeration limits are explicit partial coverage. Request deadline is five minutes. Results expire after ten minutes and are scoped to the originating workspace. Office documents, audio and video are outside this tool's current contract.
267
+
268
+ Native text and visual workers reserve 1 GB of inference headroom above the loaded model and its working space, and return unused compute cache after each query. This is not a hard limit on total process memory. The model still uses several GB, with additional allocations during indexing and inference. Each MCP service session owns its models; multiple connections and simultaneous text/visual workers add their memory use. Ordinary exact search does not load these models. Measure memory and latency for the actual model, machine and file scope.
package/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ Concept 排名会覆盖请求所声明源码预算内接纳的全部 UTF-8 段
34
34
 
35
35
  Concept 或 hybrid 较慢时,会在默认五秒等待窗口内返回 `status: "waiting"` 或 `"running"`、`operationId`、进度和精确的 `nextRequest`,例如 `{ "mode": "await", "operationId": "..." }`。请原样复制这个请求:它会续接同一个计算,不会重启查询,也不会降级成只有字面的结果。最终结果可稳定复取十分钟;每个服务会话最多保留 32 个终态结果。`mode: "cancel"` 会停止自有任务并等待清理完成。每个服务会话最多同时接纳八个 pending operation;单个 operation 使用 `SIFT_LIGHT_CONCEPT_TIMEOUT_MS` 指定一个总执行时限(整数毫秒,1000–3600000,默认 600000),另有 120 秒无人续接租期。真实模型、来源或资源故障会以明确失败返回。发布结果前会重新枚举并校验同一来源 generation;源文件变化会刷新 operation,混合版本不会被标成 complete。接纳计划计数(`filesEnumerated`、`filesAdmitted`、`filesSkippedEmpty`、`filesUnavailable`、`passagesQueued`、`batchesPlanned`、`batchesCompleted`)会在结果里明确显示。空文件属于正常跳过,不会把结果标成 partial。
36
36
 
37
- 首次运行尚未缓存的 Concept 或 hybrid 搜索时需要加载本地模型;耗时可能达到数十秒,推理 worker 内存也可能超过 1 GiB,具体取决于机器和搜索范围。后续搜索即使换了问题,也会复用已缓存的段落向量;新问题仍会短暂加载模型来计算问题向量。修改过的段落需要重算;缓存被清理、淘汰或大量源码变化时可能再次冷运行。精确搜索模式不会加载模型。这些都是随负载变化的观察,不是延迟或内存保证。结果在 `model-loading` 阶段等待时,请按返回的 `nextRequest` 续接同一个 operation。
37
+ 首次运行尚未缓存的 Concept 或 hybrid 搜索时需要加载本地模型;耗时可能达到数十秒,推理 worker 内存也可能超过 1 GiB,具体取决于机器和搜索范围。后续搜索即使换了问题,也会复用已缓存的段落向量;活跃服务会复用已加载的模型。E5 worker 空闲 30 秒后释放,之后的查询会再次加载。修改过的段落需要重算;缓存被清理、淘汰或大量源码变化时可能再次冷运行。精确搜索模式不会加载模型。这些都是随负载变化的观察,不是延迟或内存保证。结果在 `model-loading` 阶段等待时,请按返回的 `nextRequest` 续接同一个 operation。
38
38
 
39
39
  ### 几个条件,可以一起交代
40
40
 
@@ -73,6 +73,30 @@ Pi 和 OMP 的被动 session 状态会显示当前加载的包版本,统计已
73
73
 
74
74
  可读正文会保持精简;每项证据的范围、计数、覆盖状态和继续请求仍保留在结构化 `details` 中,客户端无需为了拿到这些字段再次搜索。
75
75
 
76
+ ### Apple Silicon 独立文本检索
77
+
78
+ 可选的 `nativeText` 配置让 `concept` 和 `hybrid` 同时返回两路本地结果:现有检索和原生文本检索。两路各自保留排名,`details.analysis.retrievalLanes.current` 与 `.native` 给出可直接执行的读取请求。`hybrid` 配合 `ranking: "relevance"` 时,默认结果按文件倒数排名加权,原生路权重为 1、现有路为 0.1,最多返回十个不同文件;其他双路视图交错排列,两路原始分数不作直接比较。默认结果和两路结果都可继续 `inspect` 与 `validate`,源码变化会被报告。
79
+
80
+ 在 Apple Silicon macOS 上,准备 Xcode、Metal Toolchain、本地 SDK checkout 和模型目录,然后在项目 checkout 中运行:
81
+
82
+ ```sh
83
+ bun run build:native-text -- /absolute/sdk-checkout /absolute/native-text-output
84
+ ```
85
+
86
+ 构建器使用固定版本,输出程序及必需的资源 bundle。模型目录应包含 `model.safetensors`、`config.json`、`tokenizer.json` 和 `tokenizer_config.json`。将完整输出目录保留在本机,并在宿主实际读取的 `sift-light.json` 中配置:
87
+
88
+ ```json
89
+ {
90
+ "vectorSearchEnabled": true,
91
+ "nativeText": {
92
+ "executable": "/absolute/native-text-output/native-text",
93
+ "modelDirectory": "/absolute/model-directory"
94
+ }
95
+ }
96
+ ```
97
+
98
+ 现有 E5 模型仍需安装。原生程序只读取现有源码准入策略允许的私有临时文本副本,索引在服务关闭时删除;同一服务内增量重用索引,连续请求复用同一个已加载的原生模型;空闲 60 秒后退出 worker 并释放内存,之后的请求会重新启动。取消、关闭和协议失败都会等待自有 worker 退出后再继续。`details.analysis.stats` 中的 `nativeModelLoads`、`nativeModelReused` 和 `nativeProcessId` 可用于核对复用状态。隐藏文件、glob、exclude 与读取预算由现有准入层决定,未识别的文本后缀从已核验的 UTF-8 字节转换为纯文本副本。程序、模型或协议失败会明确报错。省略 `nativeText` 即使用现有行为。这一配置仅启用文本,不启用图片、音频或视频检索。
99
+
76
100
  ## 常见用法
77
101
 
78
102
  直接向 Agent 表达需求即可,例如:
@@ -184,3 +208,56 @@ Kimi Code 的 web 模式可能从安装目录启动插件 MCP 服务。如果相
184
208
  本地搜索在你的机器上进行。请只允许 Agent 读取已获授权的文件。HTTP 服务对外开放前需要认证网关,详见[安全说明](SECURITY.md)。
185
209
 
186
210
  [更新记录](CHANGELOG.md) · [参与贡献](CONTRIBUTING.md) · [AGPL-3.0-only 许可证](LICENSE)
211
+
212
+ ### 图片与扫描页检索(MCP)
213
+
214
+ 在配置中设置 `nativeMedia.executable` 和 `nativeMedia.modelDirectory` 的绝对路径,然后重新连接 MCP。Apple Silicon 上的构建命令为 `bun run build:native-media <sdk-checkout> <output-directory>`。模型在本地运行,启用视觉编码,不启用音频编码。
215
+
216
+ 新增的 `sift-light-media` 工具使用 `mode: "search"`、一个明确的本地 `path`,以及二选一的 `query`(文字描述)或 `image`(本地图片路径)。`kind` 可为 `all`、`image` 或 `scan`。`glob`、`exclude`、`hidden`、`ignorePolicy` 沿用工作区文件准入规则。文字、代码和源码导航继续使用 `sift-light`。
217
+
218
+ ```json
219
+ { "mode": "search", "path": "/absolute/project/assets", "query": "草地上奔跑的狗", "kind": "image" }
220
+ ```
221
+
222
+ ```json
223
+ {
224
+ "mode": "search",
225
+ "path": "/absolute/project/assets",
226
+ "image": "/absolute/query.jpg",
227
+ "kind": "all"
228
+ }
229
+ ```
230
+
231
+ 结果最多保留十个不同文件,包含原文件内容哈希、原 PDF 页码和可直接执行的 `inspect`、`validate` 请求。把这些请求继续交给本工具:`inspect` 返回最长边不超过 1024 像素的真实 PNG;扫描页返回原 PDF 中对应页面。原文件发生变化时,查看会明确拒绝,校验会报告过期。混合 PDF 中的扫描页也保留原页码。相似度只是候选依据,不保证识别所有小字,也不提供文字区域坐标。
232
+
233
+ ### 第三方模型(BYO)
234
+
235
+ 本地模型仍然使用 `nativeMedia`。如果要接入自己提供的多模态服务,在同一份 `sift-light.json` 中增加 `media`。服务必须接受下面约定的 HTTP JSON 请求;它可以是你的第三方服务,也可以是你在本机部署的转换网关。没有 API Key 的本机服务可以省略 `apiKeyEnv`;远程服务应使用 HTTPS。
236
+
237
+ ```json
238
+ {
239
+ "media": {
240
+ "provider": "http-json",
241
+ "endpoint": "https://your-provider.example/v1/media-search",
242
+ "model": "your-vision-model",
243
+ "apiKeyEnv": "YOUR_MEDIA_KEY",
244
+ "timeoutMs": 120000,
245
+ "maxRetries": 2,
246
+ "maxRequestBytes": 33554432,
247
+ "renderer": {
248
+ "executable": "/absolute/native-media",
249
+ "modelDirectory": "/absolute/model-directory"
250
+ }
251
+ }
252
+ }
253
+ ```
254
+
255
+ 插件会把准入的文件以受限大小的 Base64 内容发送,并使用不含本地绝对路径的 `fileId`;返回结果必须包含 `version: 1`、`indexedFiles` 和 `hits`,每个命中使用 `fileId`、`score`、`kind`、`chunkCount`、`width`、`height`,扫描 PDF 还要返回从 1 开始的 `page`。插件收到结果后才把 ID 映射回本地原文件,并继续做哈希、页码、`inspect` 和 `validate` 校验。远程服务不会决定源码证据是否可信。
256
+
257
+ Jev 也支持 `semanticJudge.provider: "http-json"`。它接收 `{ "version": 1, "model", "query", "candidates" }`,返回 `{ "version": 1, "model", "judgments", "usage" }`;每个 judgment 包含候选 `id`、分类 `choice` 和 0 到 1 的 `probability`。如果配置了 `apiKeyEnv`,插件只从该环境变量读取密钥,不把密钥写入配置、日志或请求内容以外的地方。没有密钥的本机 provider 可以直接使用;远程 provider 失败时,本地候选会保留并标记为 partial。
258
+
259
+ 远程多模态结果仍沿用同一个 `sift-light-media` 工具和同一套源文件核验。PNG 命中可以直接 inspect;PDF 或其他图片格式要返回预览时,请在 `media.renderer` 中配置本地渲染 worker。只做 `validate` 不需要 renderer。
260
+
261
+ 首次索引较慢时,工具返回 `nextRequest` 和 `mode: "await"`;照原请求继续等待即可。使用 `mode: "cancel"` 和对应 `operationId` 可以取消。本地 provider 的连续请求复用一个视觉模型,空闲 60 秒后释放;远程 provider 的超时、重试和请求大小由配置控制。取消和关闭由服务回收自有资源。每次最多准入 512 个媒体文件,每个文件不超过 20 MiB,总量不超过 256 MiB;数量、总量和枚举限制会明确标为部分覆盖。单次任务期限为五分钟。结果保留十分钟,且只在原工作区中可用。当前入口不包含 Office、音频或视频。
262
+
263
+ 原生文本和视觉程序在已加载模型之外设置 1 GB 推理余量,使用计算所需的工作空间,并在每次查询后归还空闲计算缓存;这不是进程总内存的硬上限。模型本身仍会占用数 GB,索引和计算期间还有额外内存。每个 MCP 服务会话分别持有自己的模型;多个连接或文本与视觉同时工作时,占用会相加。普通精确搜索不加载这些模型。运行内存和速度必须按实际模型、机器与文件范围测量。
@@ -0,0 +1,18 @@
1
+ import Foundation
2
+ import OmniKit
3
+
4
+ /// Bounds transient inference allocations without putting the loaded model below its working floor.
5
+ enum WorkerMemory {
6
+ static let headroom = 1_000_000_000
7
+
8
+ static func prepare(modelDirectory: String) throws {
9
+ let weights = URL(fileURLWithPath: modelDirectory).appendingPathComponent("model.safetensors")
10
+ let bytes = try weights.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
11
+ omniSetMemoryHeadroom(headroom, residentBytes: bytes)
12
+ }
13
+
14
+ static func settle() {
15
+ omniClearGPUCache()
16
+ omniSetMemoryHeadroom(headroom, residentBytes: omniGPUActiveMemory())
17
+ }
18
+ }
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+ import OmniKit
3
+ import PDFKit
4
+ import ImageIO
5
+ import UniformTypeIdentifiers
6
+ import CryptoKit
7
+
8
+ struct MediaRequest: Decodable {
9
+ let id: String
10
+ let query: String?
11
+ let image: String?
12
+ let expectedFiles: Int
13
+ let kind: String
14
+ }
15
+ struct MediaHit: Encodable {
16
+ let path: String
17
+ let score: Float
18
+ let kind: String
19
+ let page: Int?
20
+ let chunkCount: Int
21
+ let width: Int
22
+ let height: Int
23
+ }
24
+ struct MediaResponse: Encodable {
25
+ let version = 1
26
+ let id: String
27
+ let indexedFiles: Int
28
+ let modelLoads = 1
29
+ let modelReused: Bool
30
+ let processId = ProcessInfo.processInfo.processIdentifier
31
+ let hits: [MediaHit]
32
+ }
33
+ @main struct MediaWorker {
34
+ enum Failure: Error { case arguments, inventory, query, locator, render }
35
+ static func main() async throws {
36
+ let args = CommandLine.arguments
37
+ if args.count == 5 && args[1] == "--render" {
38
+ try render(path: args[2], page: Int(args[3]) ?? 0, output: args[4]); return
39
+ }
40
+ guard args.count == 5, args[4] == "--serve" else { throw Failure.arguments }
41
+ try WorkerMemory.prepare(modelDirectory: args[1])
42
+ let engine = try await OmniEngine(modelDir: URL(fileURLWithPath: args[1]), keepVision: true, keepAudio: false)
43
+ WorkerMemory.settle()
44
+ let store = try VectorStore(dbURL: URL(fileURLWithPath: args[3]))
45
+ defer { store.close() }
46
+ var completed = 0
47
+ var fingerprints: [String: String] = [:]
48
+ while let line = readLine() {
49
+ guard line.utf8.count <= 4096 else { throw Failure.arguments }
50
+ let r = try JSONDecoder().decode(MediaRequest.self, from: Data(line.utf8))
51
+ guard !r.id.isEmpty, r.expectedFiles > 0, ["all", "image", "scan"].contains(r.kind),
52
+ (r.query == nil) != (r.image == nil) else { throw Failure.arguments }
53
+ let corpus = URL(fileURLWithPath: args[2])
54
+ let originals = try inventory(corpus)
55
+ let scanRoot = corpus.deletingLastPathComponent().appendingPathComponent("scan-pages")
56
+ let aliases = try prepareMixedScans(originals, scanRoot: scanRoot)
57
+ let admitted = originals.union(aliases.keys)
58
+ let currentFingerprints = try Dictionary(uniqueKeysWithValues: admitted.map { path in
59
+ (path, SHA256.hash(data: try Data(contentsOf: URL(fileURLWithPath: path))).map { String(format: "%02x", $0) }.joined())
60
+ })
61
+ var stale = Set<String>()
62
+ store.knownFiles().forEach { path, _ in if !admitted.contains(path) || (fingerprints[path] != nil && fingerprints[path] != currentFingerprints[path]) { stale.insert(path) } }
63
+ if !stale.isEmpty { store.deletePaths(stale) }
64
+ var settings = IndexSettings(enabledKinds: [.image, .text])
65
+ settings.ignore = OmniIgnore(text: "")
66
+ Indexer(store: store, embedder: engine).index(roots: [corpus, scanRoot], settings: settings) { _ in }
67
+ guard store.fileCount == r.expectedFiles + aliases.count else { throw Failure.inventory }
68
+ fingerprints = currentFingerprints
69
+ let vector: [Float]
70
+ if let query = r.query {
71
+ guard !query.isEmpty, query.count <= 256 else { throw Failure.query }
72
+ vector = engine.embedText(query, as: .query)
73
+ } else {
74
+ guard let image = r.image, let v = engine.embedFileQuery(URL(fileURLWithPath: image), asDocument: true) else { throw Failure.query }
75
+ vector = v
76
+ }
77
+ var filter = SearchFilter()
78
+ filter.kinds = r.kind == "all" ? ["image", "scan"] : [r.kind]
79
+ let hits = try store.search(vector, filter: filter, topK: 10, textQuery: r.query).map { h in
80
+ var page: Int? = nil
81
+ if h.kind == "scan" {
82
+ guard h.locator.hasPrefix("Page "), let p = Int(h.locator.dropFirst(5)), p > 0, p == h.chunkIndex + 1 else { throw Failure.locator }
83
+ page = p
84
+ }
85
+ if let alias = aliases[h.path] {
86
+ guard let p = page, p <= alias.pages.count else { throw Failure.locator }
87
+ return MediaHit(path: alias.path, score: h.score, kind: h.kind, page: alias.pages[p - 1], chunkCount: alias.total, width: h.width, height: h.height)
88
+ }
89
+ return MediaHit(path: h.path, score: h.score, kind: h.kind, page: page, chunkCount: h.chunkCount, width: h.width, height: h.height)
90
+ }
91
+ let result = MediaResponse(id: r.id, indexedFiles: originals.count, modelReused: completed > 0, hits: hits)
92
+ WorkerMemory.settle()
93
+ FileHandle.standardOutput.write(try JSONEncoder().encode(result)); FileHandle.standardOutput.write(Data([10]))
94
+ completed += 1
95
+ }
96
+ }
97
+ struct ScanAlias { let path: String; let pages: [Int]; let total: Int }
98
+ static func prepareMixedScans(_ paths: Set<String>, scanRoot: URL) throws -> [String: ScanAlias] {
99
+ let fm = FileManager.default
100
+ try fm.createDirectory(at: scanRoot, withIntermediateDirectories: true)
101
+ var aliases: [String: ScanAlias] = [:]
102
+ for path in paths.sorted() where URL(fileURLWithPath: path).pathExtension.lowercased() == "pdf" {
103
+ let url = URL(fileURLWithPath: path)
104
+ guard let doc = PDFDocument(url: url), !doc.isLocked else { throw Failure.inventory }
105
+ // Entire scans retain the same extraction and streaming path.
106
+ guard case .pagedText = try FileExtractor.extract(url) else { continue }
107
+ let scanPages = (0..<doc.pageCount).filter { (doc.page(at: $0)?.string ?? "").trimmingCharacters(in: .whitespacesAndNewlines).count < FileExtractor.minCharsPerPage }
108
+ guard !scanPages.isEmpty else { continue }
109
+ let key = SHA256.hash(data: try Data(contentsOf: url) + Data(path.utf8)).map { String(format: "%02x", $0) }.joined()
110
+ let output = scanRoot.appendingPathComponent(key + ".pdf")
111
+ if !fm.fileExists(atPath: output.path) {
112
+ let scans = PDFDocument()
113
+ for (i, pageIndex) in scanPages.enumerated() {
114
+ guard let page = doc.page(at: pageIndex)?.copy() as? PDFPage else { throw Failure.render }
115
+ scans.insert(page, at: i)
116
+ }
117
+ guard scans.write(to: output) else { throw Failure.render }
118
+ }
119
+ aliases[output.path] = ScanAlias(path: path, pages: scanPages.map { $0 + 1 }, total: doc.pageCount)
120
+ }
121
+ for url in try fm.contentsOfDirectory(at: scanRoot, includingPropertiesForKeys: nil) where aliases[url.path] == nil { try fm.removeItem(at: url) }
122
+ return aliases
123
+ }
124
+ static func inventory(_ corpus: URL) throws -> Set<String> {
125
+ guard let files = FileManager.default.enumerator(at: corpus, includingPropertiesForKeys: [.isRegularFileKey]) else { throw Failure.inventory }
126
+ var admitted = Set<String>()
127
+ for case let url as URL in files {
128
+ if try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true { admitted.insert(url.path) }
129
+ }
130
+ return admitted
131
+ }
132
+ static func render(path: String, page: Int, output: String) throws {
133
+ let image: CGImage
134
+ let url = URL(fileURLWithPath: path)
135
+ if page > 0 {
136
+ guard let doc = PDFDocument(url: url), page <= doc.pageCount,
137
+ let img = FileExtractor.renderPDFPage(doc, index: page - 1, maxDimension: 1024) else { throw Failure.render }
138
+ image = img
139
+ } else {
140
+ guard case .images(let imgs) = try FileExtractor.extract(url, maxImageDimension: 1024), let img = imgs.first else { throw Failure.render }
141
+ image = img
142
+ }
143
+ guard let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: output) as CFURL, UTType.png.identifier as CFString, 1, nil) else { throw Failure.render }
144
+ CGImageDestinationAddImage(dest, image, nil)
145
+ guard CGImageDestinationFinalize(dest) else { throw Failure.render }
146
+ }
147
+ }
@@ -0,0 +1,129 @@
1
+ import Foundation
2
+ import OmniKit
3
+ import CryptoKit
4
+
5
+ struct Request: Decodable {
6
+ let id: String?
7
+ let query: String
8
+ let expectedFiles: Int
9
+ }
10
+
11
+ struct Hit: Encodable {
12
+ let path: String
13
+ let score: Float
14
+ let line: Int
15
+ let chunkIndex: Int
16
+ let locator: String
17
+ }
18
+
19
+ struct Response: Encodable {
20
+ let version = 2
21
+ let id: String
22
+ let modelLoads = 1
23
+ let modelReused: Bool
24
+ let processId = ProcessInfo.processInfo.processIdentifier
25
+ let indexedFiles: Int
26
+ let hits: [Hit]
27
+ }
28
+
29
+ @main struct TextSearchWorker {
30
+ static func main() async throws {
31
+ let args = CommandLine.arguments
32
+ guard args.count == 4 || (args.count == 5 && args[4] == "--serve") else { throw Failure.invalidArguments }
33
+ let serving = args.count == 5
34
+ try WorkerMemory.prepare(modelDirectory: args[1])
35
+ let engine = try await OmniEngine(modelDir: URL(fileURLWithPath: args[1]), keepVision: false, keepAudio: false)
36
+ WorkerMemory.settle()
37
+ let store = try VectorStore(dbURL: URL(fileURLWithPath: args[3]))
38
+ defer { store.close() }
39
+ var completed = 0
40
+ while let data = nextRequest(serving: serving, completed: completed) {
41
+ guard data.count <= 4096 else { throw Failure.invalidArguments }
42
+ let request = try JSONDecoder().decode(Request.self, from: data)
43
+ guard !request.query.isEmpty, request.query.count <= 256, request.expectedFiles > 0,
44
+ !serving || (request.id?.isEmpty == false) else { throw Failure.invalidArguments }
45
+ let result = try search(request, engine: engine, store: store, path: args[2], reused: completed > 0)
46
+ WorkerMemory.settle()
47
+ FileHandle.standardOutput.write(try JSONEncoder().encode(result))
48
+ if serving { FileHandle.standardOutput.write(Data([10])) }
49
+ completed += 1
50
+ }
51
+ }
52
+
53
+ static func nextRequest(serving: Bool, completed: Int) -> Data? {
54
+ if serving { return readLine(strippingNewline: true).map { Data($0.utf8) } }
55
+ return completed == 0 ? FileHandle.standardInput.readDataToEndOfFile() : nil
56
+ }
57
+
58
+ static func search(_ request: Request, engine: OmniEngine, store: VectorStore, path: String, reused: Bool) throws -> Response {
59
+ let (corpus, normalized, aliases, settings, admitted) = try prepareCorpus(path)
60
+ // A private snapshot has a complete inventory; empty subroots are intentional.
61
+ var stale = Set<String>()
62
+ store.knownFiles().forEach { path, _ in
63
+ if !admitted.contains(path) { stale.insert(path) }
64
+ }
65
+ if !stale.isEmpty { store.deletePaths(stale) }
66
+ let indexer = Indexer(store: store, embedder: engine)
67
+ indexer.index(roots: [corpus, normalized], settings: settings) { _ in }
68
+ guard store.fileCount == request.expectedFiles else {
69
+ FileHandle.standardError.write(Data("Native index inventory: indexed=\(store.fileCount), expected=\(request.expectedFiles)\n".utf8))
70
+ throw Failure.incompleteIndex
71
+ }
72
+ let vector = engine.embedText(request.query, as: .query)
73
+ let hits = try store.search(vector, topK: 10, textQuery: request.query).map { hit in
74
+ guard hit.kind == "text", hit.locator.hasPrefix("Line "),
75
+ let line = Int(hit.locator.dropFirst(5)), line > 0 else { throw Failure.invalidLocator }
76
+ return Hit(path: aliases[hit.path] ?? hit.path, score: hit.score, line: line, chunkIndex: hit.chunkIndex, locator: hit.locator)
77
+ }
78
+ return Response(id: request.id ?? "", modelReused: reused, indexedFiles: store.fileCount, hits: hits)
79
+ }
80
+
81
+ static func prepareCorpus(_ path: String) throws -> (URL, URL, [String: String], IndexSettings, Set<String>) {
82
+ let corpus = URL(fileURLWithPath: path)
83
+ let normalized = corpus.deletingLastPathComponent().appendingPathComponent("plain-text")
84
+ let fm = FileManager.default
85
+ try fm.createDirectory(at: normalized, withIntermediateDirectories: true)
86
+ var aliases: [String: String] = [:]
87
+ var admitted = Set<String>()
88
+ guard let files = fm.enumerator(at: corpus, includingPropertiesForKeys: [.isRegularFileKey]) else {
89
+ throw Failure.incompleteIndex
90
+ }
91
+ for case let url as URL in files {
92
+ guard try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue }
93
+ if FileExtractor.textExtensions.contains(url.pathExtension.lowercased()) {
94
+ admitted.insert(url.path)
95
+ continue
96
+ }
97
+ let key = SHA256.hash(data: Data(url.path.utf8)).map { String(format: "%02x", $0) }.joined()
98
+ let folder = normalized.appendingPathComponent(key)
99
+ try fm.createDirectory(at: folder, withIntermediateDirectories: true)
100
+ let alias = folder.appendingPathComponent(url.lastPathComponent + ".txt")
101
+ let bytes = try Data(contentsOf: url)
102
+ if (try? Data(contentsOf: alias)) != bytes { try bytes.write(to: alias) }
103
+ aliases[alias.path] = url.path
104
+ admitted.insert(alias.path)
105
+ }
106
+ if let old = fm.enumerator(at: normalized, includingPropertiesForKeys: [.isRegularFileKey]) {
107
+ for case let url as URL in old where aliases[url.path] == nil {
108
+ if try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true {
109
+ try fm.removeItem(at: url)
110
+ }
111
+ }
112
+ }
113
+ var settings = IndexSettings(enabledKinds: [.text])
114
+ settings.ignore = OmniIgnore(text: "")
115
+ // Source admission is complete before this process receives its private corpus.
116
+ // Non-plain suffixes are normalized from already verified UTF-8 source bytes.
117
+ // Only normalized plain text may enter the index for these files.
118
+ if !aliases.isEmpty {
119
+ settings.ignore = OmniIgnore(text: aliases.values.map { path in
120
+ path.map { ch in "\\*?[]#!".contains(ch) ? "\\" + String(ch) : String(ch) }.joined()
121
+ }.joined(separator: "\n"))
122
+ }
123
+ return (corpus, normalized, aliases, settings, admitted)
124
+ }
125
+
126
+ enum Failure: Error {
127
+ case invalidArguments, incompleteIndex, invalidLocator
128
+ }
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.5-6",
3
+ "version": "1.1.0",
4
4
  "description": "Context-efficient local search for files, documents, notes and logs across Pi, OMP and MCP clients",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -46,7 +46,13 @@
46
46
  "CHANGELOG.md",
47
47
  "SECURITY.md",
48
48
  "src/concept-worker.mjs",
49
- "plugins/sift-light/**"
49
+ "plugins/sift-light/**",
50
+ "native/text-search/Worker.swift",
51
+ "native/WorkerMemory.swift",
52
+ "scripts/build-native-text.ts",
53
+ "native/media-search/Worker.swift",
54
+ "scripts/build-native-media.ts",
55
+ "scripts/build-native-worker.ts"
50
56
  ],
51
57
  "type": "module",
52
58
  "publishConfig": {
@@ -82,7 +88,9 @@
82
88
  "format:local:check": "bun run doc/testing/scripts/format-local.ts --check",
83
89
  "lint:local": "oxlint --no-ignore --type-aware --tsconfig doc/testing/tsconfig.json --deny-warnings --report-unused-disable-directives src scripts doc/testing/test doc/testing/scripts",
84
90
  "typecheck:local": "tsc --noEmit --project doc/testing/tsconfig.json",
85
- "check:local": "bun run format:check && bun run format:local:check && bun run check:search-plugin && bun run check:concept-worker && bun run check:worker && bun run check:mcp && bun run lint:local && bun run typecheck:local && bun run test:repo && bun run test:node:repo && bun run benchmark"
91
+ "check:local": "bun run format:check && bun run format:local:check && bun run check:search-plugin && bun run check:concept-worker && bun run check:worker && bun run check:mcp && bun run lint:local && bun run typecheck:local && bun run test:repo && bun run test:node:repo && bun run benchmark",
92
+ "build:native-text": "bun run scripts/build-native-text.ts",
93
+ "build:native-media": "bun run scripts/build-native-media.ts"
86
94
  },
87
95
  "dependencies": {
88
96
  "@ast-grep/lang-go": "0.0.6",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.5-6",
3
+ "version": "1.1.0",
4
4
  "description": "Require sift-light for conventional local searches while keeping development tools available.",
5
5
  "author": {
6
6
  "name": "baoer"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.5-6",
3
+ "version": "1.1.0",
4
4
  "description": "Require sift-light for conventional local searches while keeping development tools available.",
5
5
  "author": {
6
6
  "name": "baoer"
@@ -5,7 +5,7 @@
5
5
  "args": [
6
6
  "--yes",
7
7
  "--package",
8
- "sift-light-runtime@npm:sift-light@1.0.5-6",
8
+ "sift-light-runtime@npm:sift-light@1.1.0",
9
9
  "sift-light-mcp",
10
10
  "--stdio"
11
11
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.5-6",
3
+ "version": "1.1.0",
4
4
  "description": "Require sift-light for conventional local searches while keeping development tools available.",
5
5
  "author": {
6
6
  "name": "baoer"
@@ -13,7 +13,7 @@
13
13
  "args": [
14
14
  "--yes",
15
15
  "--package",
16
- "sift-light-runtime@npm:sift-light@1.0.5-6",
16
+ "sift-light-runtime@npm:sift-light@1.1.0",
17
17
  "sift-light-mcp",
18
18
  "--stdio"
19
19
  ],