dsh-prime-memory 0.26.23 → 0.26.25

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # 更新日志(Changelog)
2
2
 
3
+ ## [0.26.25] — 2026-11-02
4
+
5
+ - **#22 根治:本地嵌入新增 token 维度护栏**。报告实测同一 5000 中文字符(≈3651 token)在纯 node 跑通(峰值 RSS **4.7GB**)、在宿主 Electron-node 被 **SIGTRAP 静默打死**(796ms、峰值仅 1.05GB),且崩溃瞬间 writable resident 只 3381K、机器 24GB 未吃满——**不是 OOM,是「单笔大块对齐分配被拒」**。本轮在本机(Windows/纯 node)实测内存峰值严格 **O(T²)**(750/1500/3003 token → ΔRSS +106/437/1635 MB,比 1:4.1:15.4 对应 T² 的 1:4:16),机制是 attention 张量 `[1,heads,T,T]`;外推 3651 token ≈4.6GB,与报告的 4.7GB 跨平台互证。**T 是唯一可控自变量,降 token 预算同时是崩溃护栏和内存护栏。**
6
+ - **护栏早就在子进程,错的是单位**(实读校正了一处长期误解):`resources/embedding-child.cjs` 一直在子进程内截断,但按**字符**截。bge-m3/embeddinggemma 是 Unigram/BPE,中文≈1 字/token 而 emoji/泰文可 >1.5,所以 5000 字符既可只 750 token 也可近 8192——**字符帽与崩溃条件不同源、不可换算**;库自带 `truncation:true` 只截到 `model_max_length=8192`,本身就在致死线 ≈2700 之上,**等于没兜**。另证:`src/util/context-occupancy.ts` 的 `CHARS_PER_TOKEN = 4`(官方同值)对中文低估 4 倍,很可能就是「5000 字符看着安全」长期未被怀疑的成因,**已明令禁止用于嵌入预算**。
7
+ - **新增 `embedding.maxInputTokens`(默认 `0`=自动)与唯一真值来源 `HARD_CAP_TOKENS = 2048`**(`src/store/model-catalog.ts` 的 `deriveTokenBudget`)。生效预算 `= min(模型 contextTokens, HARD_CAP_TOKENS)`:bge-small-zh(512) 不受影响、bge-m3(8192) 被压到 2048;用户显式超限只 **clamp + warn**,不抛错。`contextTokens` 原注释「仅 UI 展示」已升格为推导真值并同步改注释(不改就是下一轮声明漂移)。
8
+ - **配置面只加键不改键**:`maxInputChars` 的键名/边界(`min(100).max(100_000)`)/默认值 5000 **完全不动**。实测 schemastery **未知键静默透传不报错**,改名或换单位会让已部署 YAML 的值**无声失效并回落 `?:5000`**(新主进程 + 旧 `dist/embedding-child.cjs` 错配时同理);而收窄边界会让已部署 YAML **解析直接抛错**,故障半径是整个配置树。字符帽继续只管远程档与 IPC 粗筛、token 帽只管本地推理——**各自单一实施点,不做同层双旋钮**。
9
+ - **截断执行留在子进程**(新增 `resources/embedding-token-guard.cjs`,无副作用纯模块):tokenizer 只在加载后的 pipeline 对象上可达,且 ORT `run()` 同步阻塞、单条内部不可中断,预检只能放推理前;而子进程隔离本身是 #11(同进程 `libvips-42.dll` 撞名)的修复,不得为省一次 IPC 拆开。tokenize 成本实测占单次推理 ≈2.4%(121-136ms vs 5427ms)。旧逃生门 `embedding-worker.cjs` **有意不同步**并在文件头写明理由(硬同步 = 维护两份口径)。
10
+ - **实现期三个实测陷阱**(不避开就是静默错位):`encode()` 缺省返回 `_Tensor`(`.length === undefined` 且**不报错**,须显式 `return_tensor:false`);`decode()` 缺省 `skip_special_tokens=false`,slice→decode→re-encode 会 **+2 token**;`tokenizer.model_max_length` **只读**(实测 `has only a getter`),不能当旋钮。**主动删掉一个自己写出的缺陷**:初版带了「`raw.length * 0.5 < 预算` 即跳过 tokenize」的省开销优化,但 3800 中文字符×0.5=1900 会跳过、其实际≈2774 token 正落在致死档,属未验证跳跃 → 改为无条件实测。
11
+ - **可见性修复(#22 归因链路的前置)**:`child.on('exit',(code)=>…)` 只接 code、**丢弃 signalCode**,使 SIGTRAP / OOM-kill / 自然退出在日志里同为 `code=null`(本轮就是靠 macOS 4 份崩溃报告对毫秒时间戳才归因上的);现由 `describeExit()` 同时输出 `code` 与 `signal`,一处修三处可见(crashed 串 / warn / `getLoadError()`),worker 旧通道同型修正。子进程另上报本批**实测**进模型的最大 token 数(`tokensMax` 取输出张量 `[1,seq,hidden]` 的 seq,不用推断值);fork 时一条 info 记录 `execPath`/`process.release.name`/pid/模型/生效预算——之前仓内没有任何地方记录 execPath 差异,而那正是「同代码纯 node 能跑、Electron 崩」的唯一区分量。
12
+ - **补齐停摆不再静默**:`reindex` 首行 `if (!vectorReady()) return {0,0,0}` 与「本轮无事可做」不可区分,且 `index.ts` 的 `failed>0` / `missingAfter==0` 两分支都不进 → **一行日志都不打**(崩后熔断也不在环上,环上是 `isReady()` 短路)。现补齐链分流出「缺失数统计失败」与「本轮未写入且仍差 N 条」两条 warn;reindex 批次的 `catch {}` 补上日志并**带 record id**(否则无从定位是哪条长文本致崩)。**已收回一条调研结论**:`markEmbeddingSynced` 的「缺失全落 skip 集」并非假成功——`l1.ts` 注释明确写明 skipped 有意不阻塞同步标记(否则永不收敛、周期性全量重嵌死循环),照误报去改会引入新缺陷。
13
+ - **本版本不含崩溃自愈**:`startReindex` 前置 `vectorsReady()` 门使自愈在崩溃态根本进不去,而 respawn 的真实半径含六处长期引用重绑、状态机新分支、三腿 thundering herd 与 60s 冷却叠乘;且本刀目标是「不让毒形成」而非「毒已形成怎么办」。也不自动全库重建(该腿无超时无熔断、成本不可量化,且新帽只缩不扩、语义退化有界);历史向量口径如需严格一致可手动触发重建。
14
+ - **测试**:新增 `tests/embedding-token-guard.test.ts` 24 例(预算推导 4 组 + 精截与降级路径 + 两个实测陷阱 + 配置面回归 + 退出归因)。**红绿对拍已逐条验证有效性**:断开接线→1 红、不留 special 位→2 红、塞回字符启发式→4 红、预算不写进 cfg→1 红。过程中查出并修掉自己的两个测试缺陷:直接调纯函数**测不到调用点**(接线整行删掉时 19 例全绿)→ 补接线/产物哨兵;对拍脚本把 vitest「收集期报错」误读成「无失败」→ 判据改为区分 `Test Files failed`/`Tests no tests`。**跨平台限制如实声明**:`malloc_zone_memalign` 是 macOS 分配器符号,Windows 既不能复现 SIGTRAP 也发不出该信号(`EINVAL`),故本仓可证的是「进模型的 token 数受预算约束」与「被信号杀可归因」,崩溃是否消失需报告人在 macOS 回验。全量 **1923 例 / 191 文件**,typecheck/lint(0 error)/build/build:smoke/smoke 全 0;smoke 新增 3 条产物断言钉住 `dist/embedding-token-guard.cjs` 存在、child 相对 require 可达、guard 导出正确(漏拷是子进程起不来的硬故障)。
15
+
16
+ ## [0.26.24] — 2026-11-02
17
+
18
+ - **修掉 `0.26.23` 自己引入的回归:读面可能被永久堵死**。上一版规定「对端已升版但 `drsReadFacade()` 返回 undefined 时抛可重试错、不回退自开库」,用意是避免把自开库结果永久缓存。但**未就绪是无法与「本进程根本不会有人 activate collector」区分的**,两类真实场景落进后者:① 宿主外跑本仓读面做校验的脚本(新进程 import collector,`apply()` 从未运行,门面永远 undefined);② collector 装了但未进当前 profile 的装载清单。它们会每次读都报错,比 `0.26.21` 的直接自开库更糟。
19
+ - **改法:有界重试后再回退**(`src/clients/drsc-inproc.ts`)。预算 `FACADE_RETRY_BUDGET = 3`:前 3 次读抛可重试错且不缓存(给宿主内 collector 的 `setImmediate` 初始化留出窗口,保住「优先用门面」);第 4 次仍拿不到门面即认定本进程等不到,回退自开只读库。两条路径都读的是同一个 WAL 库、句柄只读,不存在读到错数据的风险,差别只是是否多占一份常驻 `summaries`。门面一旦就绪,计数器归零。
20
+ - **测试**:`tests/drsc-inproc.test.ts` 相应改为断言完整状态机——预算内逐次抛错(错误文本含 `1/3`、`2/3`、`3/3`)且期间**绝不**调用 `openProjectionStore`;第 4 次回退成功打开自有句柄并返回正常结果;`resetCollectorReadState()` 后门面就绪能拿回门面。全量 **1904 例 / 190 文件**,typecheck/lint/build/build:smoke/smoke 全 0。
21
+ - **验证口径提示**:宿主外脚本按设计走的是**回退路径**(会开自己的只读句柄),因此「脚本里出现自开库」不是缺陷;宿主内的判据是日志无「背景窗拉取失败」且蒸馏/`conversation_search` 出真数据。
22
+
3
23
  ## [0.26.23] — 2026-11-02
4
24
 
5
25
  - **方案 B:DRSC 读面改走 collector 的正式门面(`drsReadFacade`)**。`0.26.21` 的进程内直调虽然绕开了宿主鉴权,但代价是**耦合 collector 的五个内部导出名**(`openProjectionStore`/`createQueryContext`/`search`/`sessionDetail`/`listSessions`),并且**自己另开一份投影库**——多占一份常驻 `summaries` 内存与一个读事务。现在 collector `0.2.5` 导出 `drsReadFacade()`,返回绑它**自己活 QueryContext** 的只读对象;本仓优先用这个门面,只认一个稳定符号,也不再开第二份库。
package/README.de.md CHANGED
@@ -323,6 +323,7 @@ Drei Embedding-Quellen: **aus** (Standard, reine BM25-Schlüsselwortsuche), **re
323
323
  | `embedding.model` | leer | Embedding-Modellname |
324
324
  | `embedding.dimensions` | `0` | Vektordimension (bei Aktivierung Pflicht, muss zur Modellausgabe passen) |
325
325
  | `embedding.maxInputChars` | `5000` | Maximale Zeichen je Text (bei Überlänge abgeschnitten) |
326
+ | `embedding.maxInputTokens` | `0` | **Token**-Budget pro Text für lokale Einbettung: `0` (Standard) = min(Modellfenster, harte Grenze 2048); >0 = explizit, bei Überschreitung geklemmt inkl. warn. Andere Rolle als die Zeile oben – das Zeichenlimit betrifft nur Remote-Modus & IPC-Größe, die lokale Absturz-/Speichergrenze muss in Tokens stehen (#22) |
326
327
  | `embedding.timeoutMs` | `10000` | Timeout eines einzelnen Embedding-Aufrufs (ms) |
327
328
  | `embedding.allowLocalModels` | `true` | Lokale Embedding-Stufe erlauben (Deployment-Obergrenze: ausgeschaltet kann die Einstellungsseite keine Modelle laden und nicht auf Lokal schalten) |
328
329
  | `embedding.mirror` | `https://hf-mirror.com` | Wurzeladresse des Download-Spiegels für lokale Modelle (zurücksetzbar auf das offizielle `https://huggingface.co`) |
package/README.en.md CHANGED
@@ -330,6 +330,7 @@ the bundle layer appends and causes `duplicate loader entry id` startup failure)
330
330
  | `embedding.model` | empty | embedding model name |
331
331
  | `embedding.dimensions` | `0` | Vector dimensions (required when enabled; must match model output) |
332
332
  | `embedding.maxInputChars` | `5000` | Max characters per text (overlong inputs truncated) |
333
+ | `embedding.maxInputTokens` | `0` | Per-text **token** budget for local embedding: `0` (default) = min(model window, hard cap 2048); >0 = explicit, clamped with a warn above the cap. Not the same guard as the row above — the character cap only bounds the remote tier and IPC size, while local crash/memory limits must be in tokens (#22) |
333
334
  | `embedding.timeoutMs` | `10000` | Per-call embedding timeout (ms) |
334
335
  | `embedding.allowLocalModels` | `true` | Allow the local embedding tier (deployment ceiling; when off, no model downloads and no local tier in settings) |
335
336
  | `embedding.mirror` | `https://hf-mirror.com` | Download mirror root for local models (can be changed back to `https://huggingface.co`) |
package/README.es.md CHANGED
@@ -324,6 +324,7 @@ Los ajustes que pisan los valores por defecto se escriben en el `cordis.patch.ym
324
324
  | `embedding.model` | vacío | Nombre del modelo de embeddings |
325
325
  | `embedding.dimensions` | `0` | Dimensión de los vectores (obligatoria si está activada, debe coincidir con la salida del modelo) |
326
326
  | `embedding.maxInputChars` | `5000` | Número máximo de caracteres por texto (recorte si se supera) |
327
+ | `embedding.maxInputTokens` | `0` | Presupuesto de **tokens** por texto para la incrustación local: `0` (pred.) = min(ventana del modelo, límite 2048); >0 = explícito, recortado con warn si excede. No es el mismo límite que la fila de arriba: el de caracteres solo cubre el modo remoto y el tamaño de IPC, y la protección local contra fallos/memoria debe ir en tokens (#22) |
327
328
  | `embedding.timeoutMs` | `10000` | Tiempo máximo de una llamada de embeddings (ms) |
328
329
  | `embedding.allowLocalModels` | `true` | Permite el modo de embeddings local (techo de despliegue: desactivado, la página de ajustes no puede descargar modelos ni pasar a local) |
329
330
  | `embedding.mirror` | `https://hf-mirror.com` | Raíz del espejo de descarga de modelos locales (se puede devolver al oficial `https://huggingface.co`) |
package/README.fr.md CHANGED
@@ -322,6 +322,7 @@ Les réglages par-dessus les valeurs par défaut s'écrivent dans le `cordis.pat
322
322
  | `embedding.model` | vide | Nom du modèle d'embedding |
323
323
  | `embedding.dimensions` | `0` | Dimension des vecteurs (obligatoire si activé, doit correspondre à la sortie du modèle) |
324
324
  | `embedding.maxInputChars` | `5000` | Nombre maximal de caractères par texte (troncature au-delà) |
325
+ | `embedding.maxInputTokens` | `0` | Budget de **jetons** par texte pour l'intégration locale : `0` (défaut) = min(fenêtre du modèle, plafond 2048) ; >0 = explicite, réduit avec un warn s'il dépasse. Ce n'est pas la même limite que la ligne au-dessus : celle en caractères ne borne que le mode distant et la taille IPC, la protection locale doit être en jetons (#22) |
325
326
  | `embedding.timeoutMs` | `10000` | Délai maximal d'un appel d'embedding (ms) |
326
327
  | `embedding.allowLocalModels` | `true` | Autorise le mode embedding local (plafond de déploiement : désactivé, la page de paramètres ne peut plus télécharger de modèles ni passer en local) |
327
328
  | `embedding.mirror` | `https://hf-mirror.com` | Racine du miroir de téléchargement des modèles locaux (ramenérable à l'officiel `https://huggingface.co`) |
package/README.it.md CHANGED
@@ -324,6 +324,7 @@ Le impostazioni che scavalcano i predefiniti si scrivono nel `cordis.patch.yml`
324
324
  | `embedding.model` | vuoto | Nome del modello d'embedding |
325
325
  | `embedding.dimensions` | `0` | Dimensione dei vettori (obbligatoria se attiva, deve corrispondere all'output del modello) |
326
326
  | `embedding.maxInputChars` | `5000` | Numero massimo di caratteri per testo (troncamento oltre) |
327
+ | `embedding.maxInputTokens` | `0` | Budget di **token** per testo per l'embedding locale: `0` (default) = min(finestra del modello, limite 2048); >0 = esplicito, ridotto con un warn se oltre il limite. Non è lo stesso limite della riga sopra: quello in caratteri copre solo modalità remota e dimensione IPC, la protezione locale contro cadute/memoria deve essere in token (#22) |
327
328
  | `embedding.timeoutMs` | `10000` | Timeout di una singola chiamata d'embedding (ms) |
328
329
  | `embedding.allowLocalModels` | `true` | Consente la modalità d'embedding locale (tetto di distribuzione: disattivata, la pagina impostazioni non può scaricare modelli né passare in locale) |
329
330
  | `embedding.mirror` | `https://hf-mirror.com` | Radice del mirror di scaricamento dei modelli locali (riportabile all'ufficiale `https://huggingface.co`) |
package/README.ja.md CHANGED
@@ -274,6 +274,7 @@ node bench/harness/retrieval-metrics.mjs <runDir> --flood 200,600
274
274
  | `embedding.model` | 空 | 埋め込みモデル名 |
275
275
  | `embedding.dimensions` | `0` | ベクトル次元(有効時必須) |
276
276
  | `embedding.maxInputChars` | `5000` | 単テキスト最大文字数(超長は切り捨て) |
277
+ | `embedding.maxInputTokens` | `0` | ローカル埋め込みの 1 件あたり **トークン** 予算:`0`(既定)= モデル窓と上限 2048 の小さい方、>0 = 明示値(超過は clamp + warn)。上の行とは役割が異なる——文字上限はリモートモードと IPC サイズのみを制限し、ローカルのクラッシュ/メモリ防護はトークン単位が必須(#22) |
277
278
  | `embedding.timeoutMs` | `10000` | 単回埋め込み呼び出しタイムアウト(ms) |
278
279
  | `embedding.allowLocalModels` | `true` | ローカル埋め込み档を許可(デプロイ上限) |
279
280
  | `embedding.mirror` | `https://hf-mirror.com` | ローカルモデルダウンロードミラー根 |
package/README.ko.md CHANGED
@@ -274,6 +274,7 @@ node bench/harness/retrieval-metrics.mjs <runDir> --flood 200,600
274
274
  | `embedding.model` | 빈값 | 임베딩 모델명 |
275
275
  | `embedding.dimensions` | `0` | 벡터 차원(활성 시 필수) |
276
276
  | `embedding.maxInputChars` | `5000` | 단텍스트 최대 문자 수(초장 잘림) |
277
+ | `embedding.maxInputTokens` | `0` | 로컬 임베딩 1건당 **토큰** 예산: `0`(기본) = 모델 창과 상한 2048 중 작은 값, >0 = 명시값(초과 시 clamp + warn). 위 행과 역할이 다름 — 문자 상한은 원격 모드와 IPC 크기만 제한하고, 로컬 충돌/메모리 방지는 토큰 단위가 필수(#22) |
277
278
  | `embedding.timeoutMs` | `10000` | 단회 임베딩 호출 타임아웃(ms) |
278
279
  | `embedding.allowLocalModels` | `true` | 로컬 임베딩档 허용(배포 상한) |
279
280
  | `embedding.mirror` | `https://hf-mirror.com` | 로컬 모델 다운로드 미러 루트 |
package/README.md CHANGED
@@ -342,6 +342,7 @@ ONNX 量化 **CPU 推理**——无需 API Key,数据不出本机)。本地
342
342
  | `embedding.model` | 空 | embedding 模型名 |
343
343
  | `embedding.dimensions` | `0` | 向量维度(启用时必填,须与模型输出一致) |
344
344
  | `embedding.maxInputChars` | `5000` | 单条文本最大字符数(超长截断) |
345
+ | `embedding.maxInputTokens` | `0` | 本地嵌入的单条 **token** 预算:`0`(默认)= 按模型窗口与硬上限 2048 自动取小;>0 = 显式预算,超上限会被 clamp 并记 warn。与上一行不是同类护栏——字符帽只管远程档与 IPC 体积,本地推理的崩溃/内存护栏必须按 token(#22) |
345
346
  | `embedding.timeoutMs` | `10000` | 单次 embedding 调用超时(ms) |
346
347
  | `embedding.allowLocalModels` | `true` | 允许本地嵌入档(部署上限:关闭后设置页不能下载模型、不能切本地档) |
347
348
  | `embedding.mirror` | `https://hf-mirror.com` | 本地模型下载镜像根地址(可改回官方 `https://huggingface.co`) |
package/README.ru.md CHANGED
@@ -324,6 +324,7 @@ node bench/harness/retrieval-metrics.mjs <runDir> --flood 200,600
324
324
  | `embedding.model` | пусто | Имя модели эмбеддингов |
325
325
  | `embedding.dimensions` | `0` | Размерность векторов (при включении обязательна, должна совпадать с выходом модели) |
326
326
  | `embedding.maxInputChars` | `5000` | Максимум символов на один текст (сверх — усечение) |
327
+ | `embedding.maxInputTokens` | `0` | Бюджет **токенов** на один текст для локальных эмбеддингов: `0` (умолч.) = min(окно модели, жёсткий лимит 2048); >0 = явное значение, при превыщении усекается с warn. Это не тот же лимит, что строкой выше: символьный ограничивает только удалённый режим и размер IPC, а локальная защита от падений/памяти должна считаться в токенах (#22) |
327
328
  | `embedding.timeoutMs` | `10000` | Таймаут одного вызова эмбеддинга (мс) |
328
329
  | `embedding.allowLocalModels` | `true` | Разрешить локальный режим эмбеддингов (потолок развёртывания: выключено — страница настроек не может скачивать модели и переключаться в локальный) |
329
330
  | `embedding.mirror` | `https://hf-mirror.com` | Корень зеркала загрузки локальных моделей (можно вернуть на официальный `https://huggingface.co`) |
@@ -22,6 +22,14 @@
22
22
  const COLLECTOR_PACKAGE = 'dsh-reference-session-collector';
23
23
  const defaultLoader = async () => (await import(/* @vite-ignore */ COLLECTOR_PACKAGE));
24
24
  let modulePromise;
25
+ /**
26
+ * 对端已升版但库未就绪时的重试预算。不能无限重试:宿主**外**的脚本、以及
27
+ * 「collector 装了但未装载」的部署里,drsReadFacade() 永远返回 undefined,
28
+ * 死等会把读面永久堵死(比回退更糟)。也不能一上来就回退:那会把自开库
29
+ * 结果永久缓存,把“优先用门面”变成只在首轮赌运气。
30
+ */
31
+ const FACADE_RETRY_BUDGET = 3;
32
+ let facadeMisses = 0;
25
33
  /**
26
34
  * 兼容路径(旧 collector)的只读句柄(`openProjectionStore` 的返回值)。常驻 summaries
27
35
  * 是内存 Map,每请求重开等于重扫全表,故只开一次;undefined 是合法的未开哨兵。
@@ -31,6 +39,7 @@ let storeHandle;
31
39
  export function resetCollectorReadState() {
32
40
  modulePromise = undefined;
33
41
  storeHandle = undefined;
42
+ facadeMisses = 0;
34
43
  }
35
44
  /** 把门面对象适配成本模块统一的「模块形」,以便与兼容路径共用同一套参数映射。
36
45
  * 前三个成员在门面路径上永不会被调用:moduleOf 命中门面后直接返回,不走进兼容分支。
@@ -54,14 +63,18 @@ async function moduleOf(load) {
54
63
  const mod = (await load());
55
64
  if (typeof mod.drsReadFacade === 'function') {
56
65
  const facade = mod.drsReadFacade();
57
- // 对端是新版本但库未就绪(collector 的重活推迟在 setImmediate 臂):本轮**不**回退
58
- // 自开库,而是抛可重试错——否则会把自开库结果永久缓存,把“优先用门面”变成
59
- // 只在首轮赌运气。抛错走既有降级链(背景窗当轮空窗、工具面显式报错),
60
- // 且不缓存 rejection,下一次读会重取门面。
61
- if (facade === undefined) {
62
- throw new Error('collector 投影库尚未就绪(稍后自动重试)');
66
+ // 对端是新版本但库未就绪(collector 的重活推迟在 setImmediate 臂):先给它几次
67
+ // 重试机会(抛可重试错、不缓存 rejection,下一次读重取门面);超过预算就认定
68
+ // 本进程里不会有人 activate 它(宿主外的脚本、或 collector 装了但未装载),
69
+ // 回退自开只读库。两条路都不会读错数据:库是同一个 WAL 文件,自开句柄只读。
70
+ facadeMisses += 1;
71
+ if (facade !== undefined) {
72
+ facadeMisses = 0;
73
+ return fromFacade(facade);
74
+ }
75
+ if (facadeMisses <= FACADE_RETRY_BUDGET) {
76
+ throw new Error(`collector 投影库尚未就绪(第 ${facadeMisses}/${FACADE_RETRY_BUDGET} 次重试)`);
63
77
  }
64
- return fromFacade(facade);
65
78
  }
66
79
  return legacyStoreModule(mod);
67
80
  })().catch((err) => {
package/dist/config.d.ts CHANGED
@@ -360,6 +360,15 @@ export interface MemoryConfig {
360
360
  dimensions: number;
361
361
  /** 单条文本最大字符数(超长截断)。 */
362
362
  maxInputChars: number;
363
+ /**
364
+ * 本地嵌入的单条 **token** 预算(0 = 按模型窗口与硬上限自动推导)。
365
+ *
366
+ * 与 `maxInputChars` 不是同类护栏:字符帽只管 IPC 体积与远程档,bge-m3/embeddinggemma
367
+ * 是 Unigram/BPE、中文≈1 字/token 而 emoji/泰文>1.5,所以**字符帽约不到安全线**
368
+ * (issue #22:5000 字符≈3651 token,已在宿主分配器致死线 ≈2700 之上)。本键只管本地推理。
369
+ * 超上限会被 clamp 并留 warn(不抛错,见 model-catalog.ts `deriveTokenBudget`)。
370
+ */
371
+ maxInputTokens: number;
363
372
  /** 单次调用超时(ms)。 */
364
373
  timeoutMs: number;
365
374
  /** 允许本地嵌入模型档(部署上限:公司环境可禁下载与本地推理)。 */
package/dist/config.js CHANGED
@@ -341,6 +341,10 @@ export const memorySchema = Schema.object({
341
341
  // 0 = 纯 FTS 模式(合法值,勿设 min>0)
342
342
  dimensions: Schema.number().min(0).max(8192).default(0),
343
343
  maxInputChars: Schema.number().min(100).max(100_000).default(5000),
344
+ // 12-13 个已有 fixture 用 maxInputChars;本键**只增不改**(schemastery 实测未知键静默透传,
345
+ // 改名会让已部署 YAML 无声失效),且**不收窄边界**(越界值抛错的故障半径是整个配置树)。
346
+ // 上限交由 deriveTokenBudget 按模型窗口 clamp,这里给宽松外壁仅为防溢出输入。
347
+ maxInputTokens: Schema.number().min(0).max(100_000).default(0),
344
348
  timeoutMs: Schema.number().min(1000).max(300_000).default(10_000),
345
349
  allowLocalModels: Schema.boolean().default(true),
346
350
  mirror: Schema.string().default('https://hf-mirror.com'),
@@ -1,192 +1,223 @@
1
- /**
2
- * 本地嵌入子进程(fix #11):transformers.js 的模型加载与推理在 fork 出的独立
3
- * 子进程执行,主线程只投递文本、收回向量。取代 worker_threads 的原因:
4
- * 插件 runtime 的 sharp/libvips 与宿主 Electron 的 sharp/libvips 在同一进程内
5
- * 加载会撞 libvips-42.dll 名字(OS LoadLibrary 层冲突,同名不同路径行为未定义)
6
- * ——worker_thread 与主线程同进程,撞名前提仍在;子进程自带独立 DLL 表,物理隔离。
7
- *
8
- * 推理队列语义与 embedding-worker.cjs 完全一致(单线程串行、priority 插队、
9
- * 条间 setImmediate 让路),只有 IPC 传输层不同:
10
- *
11
- * 协议(与 src/store/local-embedding.ts 的 RealChildChannel 配对;自增 id):
12
- * - 启动配置:env DSH_EMBED_CFG(JSON 字符串:{runtimeDir,modelDir,pooling,dtype,
13
- * maxInputChars}——fork 的 env 只收标量,嵌套对象经序列化传递);
14
- * - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
15
- * - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?}(vectors 为
16
- * number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
17
- * Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)
18
- * | {id,ok:false,stage:'load'|'infer',error} | {type:'fatal',error}
19
- *
20
- * 信任边界(沿 AGENTS.md 审查记录):runtime/ 目录在数据目录信任边界内,
21
- * 本进程按其 package.json createRequire 加载 transformers——对数据目录有写
22
- * 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
23
- * 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
24
- */
25
- 'use strict';
26
-
27
- const path = require('node:path');
28
- const { createRequire } = require('node:module');
29
-
30
- // 必须由 fork 启动(需要 IPC channel);裸 node 执行时静默退出,不报 SyntaxError、不永挂。
31
- if (typeof process.send !== 'function') {
32
- console.error('[embedding-child] 未经 fork 启动,无 IPC 通道,退出');
33
- process.exit(0);
34
- }
35
-
36
- /** 错误消息归一化(Error 取 message,其余 String 化)。 */
37
- function errMsg(err) {
38
- return err && err.message ? err.message : String(err);
39
- }
40
-
41
- const cfg = JSON.parse(process.env.DSH_EMBED_CFG || '{}');
42
- const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
43
- const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
44
- const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
45
- const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
46
- const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
47
-
48
- let extractor = null; // pipeline('feature-extraction', …)
49
- let loadState = 'idle'; // idle | loading | ready | failed
50
- let loadPromise = null;
51
- let loadError = null;
52
-
53
- // ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
54
- function ensureLoaded() {
55
- if (loadState === 'ready') return Promise.resolve();
56
- if (loadState === 'loading' && loadPromise) return loadPromise;
57
- loadState = 'loading';
58
- loadError = null;
59
- loadPromise = (async () => {
60
- try {
61
- if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('DSH_EMBED_CFG 缺少 runtimeDir/modelDir');
62
- const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
63
- const mod = req('@huggingface/transformers');
64
- if (mod.env) {
65
- mod.env.allowRemoteModels = false;
66
- if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
67
- }
68
- extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
69
- loadState = 'ready';
70
- } catch (err) {
71
- loadState = 'failed';
72
- loadError = errMsg(err);
73
- loadPromise = null; // 失败后下一次 warmup 重新走加载
74
- throw err;
75
- }
76
- })();
77
- return loadPromise;
78
- }
79
-
80
- // ── 推理队列(逐条 job;priority 插队;条间让路) ──
81
- const queue = []; // [{ request, index, text }]
82
- let pumping = false;
83
-
84
- function postReply(msg) {
85
- // 通道已断(父进程退出/正在终止)时 send 抛错——静默即可,父进程侧有 exit 兜底
86
- try {
87
- process.send(msg);
88
- } catch {
89
- /* IPC 已关闭,回复无处可去 */
90
- }
91
- }
92
-
93
- /** 请求全部条目完成 → 回执(Float32Array → number[] 序列化,IPC JSON 通道不支持 TypedArray)。 */
94
- function settle(request) {
95
- if (request.failed || request.remaining !== 0) return;
96
- postReply({
97
- id: request.id,
98
- ok: true,
99
- type: 'embedded',
100
- vectors: request.vectors.map((v) => Array.from(v)),
101
- });
102
- }
103
-
104
- /** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
105
- function failRequest(request, error, stage) {
106
- if (request.failed) return;
107
- request.failed = true;
108
- for (let i = queue.length - 1; i >= 0; i--) {
109
- if (queue[i].request === request) queue.splice(i, 1);
110
- }
111
- postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
112
- }
113
-
114
- async function pump() {
115
- if (pumping) return;
116
- pumping = true;
117
- try {
118
- while (queue.length > 0) {
119
- const job = queue.shift();
120
- if (job.request.failed) continue;
121
- try {
122
- const result = await extractor([job.text.slice(0, MAX_INPUT_CHARS)], { pooling: POOLING, normalize: true });
123
- const data = result[0] && result[0].data;
124
- job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
125
- job.request.remaining -= 1;
126
- settle(job.request);
127
- } catch (err) {
128
- failRequest(job.request, err, 'infer');
129
- }
130
- // 条间让路:交还本进程事件循环,新消息(含 priority 插队)可在批间被处理
131
- await new Promise((resolve) => setImmediate(resolve));
132
- }
133
- } finally {
134
- pumping = false;
135
- }
136
- }
137
-
138
- function handleEmbed(msg) {
139
- const texts = Array.isArray(msg.texts) ? msg.texts : [];
140
- if (texts.length === 0) {
141
- postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
142
- return;
143
- }
144
- const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false };
145
- // 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
146
- const priority = msg.priority === true && texts.length === 1;
147
- for (let i = 0; i < texts.length; i++) {
148
- const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
149
- if (priority) queue.unshift(job);
150
- else queue.push(job);
151
- }
152
- void pump();
153
- }
154
-
155
- process.on('message', (msg) => {
156
- if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
157
- if (msg.type === 'ping') {
158
- postReply({ id: msg.id, ok: true, type: 'pong' });
159
- return;
160
- }
161
- if (msg.type === 'warmup') {
162
- ensureLoaded().then(
163
- () => postReply({ id: msg.id, ok: true, type: 'ready' }),
164
- () => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
165
- );
166
- return;
167
- }
168
- if (msg.type === 'embed') {
169
- void (async () => {
170
- try {
171
- await ensureLoaded();
172
- } catch {
173
- postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
174
- return;
175
- }
176
- handleEmbed(msg);
177
- })();
178
- return;
179
- }
180
- });
181
-
182
- // 父进程disconnect(通道被主动关闭,如宿主优雅退出/RealChildChannel 前置断开):
183
- // 模型与 onnxruntime 都只服务于这条通道,留着就是孤儿常驻——直接退出。
184
- // taskkill /T /F 兜底覆盖"父进程被硬杀、无 disconnect"的路径。
185
- process.on('disconnect', () => process.exit(0));
186
-
187
- // 未捕获异常:尽力通知父进程后强制退出(父进程的 exit 处理会拒绝全部 pending
188
- // 并转入 failed 态)。不保持半死状态——半死的子进程只会让调用方挂到超时。
189
- process.on('uncaughtException', (err) => {
190
- postReply({ type: 'fatal', error: errMsg(err) });
191
- setTimeout(() => process.exit(1), 50);
192
- });
1
+ /**
2
+ * 本地嵌入子进程(fix #11):transformers.js 的模型加载与推理在 fork 出的独立
3
+ * 子进程执行,主线程只投递文本、收回向量。取代 worker_threads 的原因:
4
+ * 插件 runtime 的 sharp/libvips 与宿主 Electron 的 sharp/libvips 在同一进程内
5
+ * 加载会撞 libvips-42.dll 名字(OS LoadLibrary 层冲突,同名不同路径行为未定义)
6
+ * ——worker_thread 与主线程同进程,撞名前提仍在;子进程自带独立 DLL 表,物理隔离。
7
+ *
8
+ * 推理队列语义与 embedding-worker.cjs 完全一致(单线程串行、priority 插队、
9
+ * 条间 setImmediate 让路),只有 IPC 传输层不同:
10
+ *
11
+ * 协议(与 src/store/local-embedding.ts 的 RealChildChannel 配对;自增 id):
12
+ * - 启动配置:env DSH_EMBED_CFG(JSON 字符串:{runtimeDir,modelDir,pooling,dtype,
13
+ * maxInputChars,maxInputTokens}——fork 的 env 只收标量,嵌套对象经序列化传递);
14
+ * - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
15
+ * - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?,tokensMax?}(vectors 为
16
+ * number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
17
+ * Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)。
18
+ * tokensMax = 本批真正进模型的最大 token 数(#22 护栏自证用,主线程不消费也不报错)
19
+ * | {id,ok:false,stage:'load'|'infer',error} | {type:'fatal',error}
20
+ *
21
+ * 信任边界(沿 AGENTS.md 审查记录):runtime/ 目录在数据目录信任边界内,
22
+ * 本进程按其 package.json createRequire 加载 transformers——对数据目录有写
23
+ * 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
24
+ * 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
25
+ */
26
+ 'use strict';
27
+
28
+ const path = require('node:path');
29
+ const { createRequire } = require('node:module');
30
+ // token 精截放在独立无副作用模块(而非本文件):本文件是 fork 入口,顶层有进程监听与
31
+ // 「无 IPC 就 exit」守卫,被测试 require 会把副作用带进来。截断逻辑本身必须零模型可验。
32
+ const { shrinkToBudget } = require('./embedding-token-guard.cjs');
33
+
34
+ // 必须由 fork 启动(需要 IPC channel);裸 node 执行时静默退出,不报 SyntaxError、不永挂。
35
+ if (typeof process.send !== 'function') {
36
+ console.error('[embedding-child] 未经 fork 启动,无 IPC 通道,退出');
37
+ process.exit(0);
38
+ }
39
+
40
+ /** 错误消息归一化(Error 取 message,其余 String 化)。 */
41
+ function errMsg(err) {
42
+ return err && err.message ? err.message : String(err);
43
+ }
44
+
45
+ const cfg = JSON.parse(process.env.DSH_EMBED_CFG || '{}');
46
+ const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
47
+ const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
48
+ const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
49
+ const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
50
+ const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
51
+ /**
52
+ * token 预算(issue #22)。预算值由主进程从模型窗口与硬上限推导后传进来(单一真值源
53
+ * 在 src/store/model-catalog.ts);本处只消费。**截断必须在子进程做**:tokenizer 只在
54
+ * pipeline 对象上可达,且 ORT run() 同步阻塞——单条内部不可中断,预检只能放推理前。
55
+ */
56
+ const MAX_INPUT_TOKENS = cfg.maxInputTokens > 0 ? cfg.maxInputTokens : 0; // 0 = 未给,不启用 token 截断
57
+
58
+ // 字符粗筛后的 token 精截需要 tokenizer(pipeline 对象上的 .tokenizer,加载后可达)
59
+ let tokenizer = null;
60
+
61
+ let extractor = null; // pipeline('feature-extraction', …)
62
+ let loadState = 'idle'; // idle | loading | ready | failed
63
+ let loadPromise = null;
64
+ let loadError = null;
65
+
66
+ // ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
67
+ function ensureLoaded() {
68
+ if (loadState === 'ready') return Promise.resolve();
69
+ if (loadState === 'loading' && loadPromise) return loadPromise;
70
+ loadState = 'loading';
71
+ loadError = null;
72
+ loadPromise = (async () => {
73
+ try {
74
+ if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('DSH_EMBED_CFG 缺少 runtimeDir/modelDir');
75
+ const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
76
+ const mod = req('@huggingface/transformers');
77
+ if (mod.env) {
78
+ mod.env.allowRemoteModels = false;
79
+ if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
80
+ }
81
+ extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
82
+ // token 截断要用到 pipeline 内部的 tokenizer(未单独暴露 AutoTokenizer,4.x 形态)
83
+ tokenizer = extractor && extractor.tokenizer ? extractor.tokenizer : null;
84
+ loadState = 'ready';
85
+ } catch (err) {
86
+ loadState = 'failed';
87
+ loadError = errMsg(err);
88
+ loadPromise = null; // 失败后下一次 warmup 重新走加载
89
+ throw err;
90
+ }
91
+ })();
92
+ return loadPromise;
93
+ }
94
+
95
+ // ── 推理队列(逐条 job;priority 插队;条间让路) ──
96
+ const queue = []; // [{ request, index, text }]
97
+ let pumping = false;
98
+
99
+ function postReply(msg) {
100
+ // 通道已断(父进程退出/正在终止)时 send 抛错——静默即可,父进程侧有 exit 兜底
101
+ try {
102
+ process.send(msg);
103
+ } catch {
104
+ /* IPC 已关闭,回复无处可去 */
105
+ }
106
+ }
107
+
108
+ /** 请求全部条目完成 → 回执(Float32Array → number[] 序列化,IPC JSON 通道不支持 TypedArray)。 */
109
+ function settle(request) {
110
+ if (request.failed || request.remaining !== 0) return;
111
+ postReply({
112
+ id: request.id,
113
+ ok: true,
114
+ type: 'embedded',
115
+ vectors: request.vectors.map((v) => Array.from(v)),
116
+ // #22 护栏自证:本批**实测**进模型的最大 token 数(不可测时为 -1,主线程不读此字段也不会坏)
117
+ tokensMax: request.tokensMax,
118
+ });
119
+ }
120
+
121
+ /** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
122
+ function failRequest(request, error, stage) {
123
+ if (request.failed) return;
124
+ request.failed = true;
125
+ for (let i = queue.length - 1; i >= 0; i--) {
126
+ if (queue[i].request === request) queue.splice(i, 1);
127
+ }
128
+ postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
129
+ }
130
+
131
+ async function pump() {
132
+ if (pumping) return;
133
+ pumping = true;
134
+ try {
135
+ while (queue.length > 0) {
136
+ const job = queue.shift();
137
+ if (job.request.failed) continue;
138
+ try {
139
+ // 字符粗筛(IPC 体积) + token 精截(#22 崩溃/内存护栏);截断只在推理前,run() 同步不可中断
140
+ const screened = job.text.slice(0, MAX_INPUT_CHARS);
141
+ const sent = shrinkToBudget(screened, MAX_INPUT_TOKENS, tokenizer);
142
+ const result = await extractor([sent], { pooling: POOLING, normalize: true });
143
+ const data = result[0] && result[0].data;
144
+ job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
145
+ // 实测优先于推断:特征抽取输出是 [1, seq, hidden],seq 就是**真正进了模型**的 token 数
146
+ // (含 special token、含 padding 与否取决于 pipeline)——比“截断后 id 长度”可靠,
147
+ // 后者在 decode 失败回退原文时会给出错的数。不可读时给 -1(=不可证,不是 0)。
148
+ let real = -1;
149
+ try {
150
+ const shape = result[0] && result[0].shape;
151
+ if (Array.isArray(shape) && shape.length >= 3) real = Number(shape[1]) || -1;
152
+ } catch {
153
+ /* shape 不可读不致命 */
154
+ }
155
+ if (real > job.request.tokensMax) job.request.tokensMax = real;
156
+ job.request.remaining -= 1;
157
+ settle(job.request);
158
+ } catch (err) {
159
+ failRequest(job.request, err, 'infer');
160
+ }
161
+ // 条间让路:交还本进程事件循环,新消息(含 priority 插队)可在批间被处理
162
+ await new Promise((resolve) => setImmediate(resolve));
163
+ }
164
+ } finally {
165
+ pumping = false;
166
+ }
167
+ }
168
+
169
+ function handleEmbed(msg) {
170
+ const texts = Array.isArray(msg.texts) ? msg.texts : [];
171
+ if (texts.length === 0) {
172
+ postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
173
+ return;
174
+ }
175
+ const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false, tokensMax: 0 };
176
+ // 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
177
+ const priority = msg.priority === true && texts.length === 1;
178
+ for (let i = 0; i < texts.length; i++) {
179
+ const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
180
+ if (priority) queue.unshift(job);
181
+ else queue.push(job);
182
+ }
183
+ void pump();
184
+ }
185
+
186
+ process.on('message', (msg) => {
187
+ if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
188
+ if (msg.type === 'ping') {
189
+ postReply({ id: msg.id, ok: true, type: 'pong' });
190
+ return;
191
+ }
192
+ if (msg.type === 'warmup') {
193
+ ensureLoaded().then(
194
+ () => postReply({ id: msg.id, ok: true, type: 'ready' }),
195
+ () => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
196
+ );
197
+ return;
198
+ }
199
+ if (msg.type === 'embed') {
200
+ void (async () => {
201
+ try {
202
+ await ensureLoaded();
203
+ } catch {
204
+ postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
205
+ return;
206
+ }
207
+ handleEmbed(msg);
208
+ })();
209
+ return;
210
+ }
211
+ });
212
+
213
+ // 父进程disconnect(通道被主动关闭,如宿主优雅退出/RealChildChannel 前置断开):
214
+ // 模型与 onnxruntime 都只服务于这条通道,留着就是孤儿常驻——直接退出。
215
+ // taskkill /T /F 兜底覆盖"父进程被硬杀、无 disconnect"的路径。
216
+ process.on('disconnect', () => process.exit(0));
217
+
218
+ // 未捕获异常:尽力通知父进程后强制退出(父进程的 exit 处理会拒绝全部 pending
219
+ // 并转入 failed 态)。不保持半死状态——半死的子进程只会让调用方挂到超时。
220
+ process.on('uncaughtException', (err) => {
221
+ postReply({ type: 'fatal', error: errMsg(err) });
222
+ setTimeout(() => process.exit(1), 50);
223
+ });
@@ -0,0 +1,41 @@
1
+ // token 精截(#22)。**独立无副作用模块**,不注册任何进程级监听:
2
+ // embedding-child.cjs 是 fork 入口(顶层有 process.on 与无 IPC 即 exit 的守卫),被测试
3
+ // require 会把副作用一并带进来(vitest worker 里实测崩在收集期),故截断逻辑只能放在这里
4
+ // 才能既被子进程用、又被测试用。**本文件同时被拷贝进 dist/**(见 scripts/copy-client.mjs)。
5
+ 'use strict';
6
+
7
+ /**
8
+ * 把一条文本裁到 token 预算内。返回**实际应送进 pipeline 的文本**。
9
+ *
10
+ * 为什么截断只能在子进程做:tokenizer 只在加载后的 pipeline 对象上可达,且 onnxruntime
11
+ * 的 run() 是同步阻塞调用——单条内部不可中断,预检只能放推理前。又因为「子进程隔离」本身
12
+ * 是 #11(libvips-42.dll 同进程撞名)的修复,不得为省一次 IPC 把 tokenize 挪回主线程。
13
+ *
14
+ * 预算留 special token 位([CLS]/[SEP] 实测 +2):截完要 decode 回文本再送 pipeline,
15
+ * pipeline 内部会重新分词并补上 special token。
16
+ *
17
+ * 不做「短文本跳过 tokenize」的优化:字符→token 比值按词表而变(CJK≈1、emoji/泰文>1.5、
18
+ * 英文≈0.27),任何启发式下限都可能放走真超限的文本(实测反例:3800 中文字符 ×0.5=1900
19
+ * 会跳过,而它实际约 2774 token,正落在 #22 致死档),而 tokenize 只占单次推理 ≈2.4%
20
+ * (121-136ms vs 5427ms)。护栏上省 2.4% 不值一个未验证的跳跃。
21
+ *
22
+ * 任何一步不可用(transformers 未挂 tokenizer、不认选项、decode 回空串、抛错)一律
23
+ * **退回粗筛原文保持旧行为**:护栏不得把能跑的链路搞坏。真实偏差由 tokensMax 实测上报兼证。
24
+ */
25
+ function shrinkToBudget(raw, maxTokens, tok) {
26
+ if (!tok || !(maxTokens > 0)) return raw;
27
+ try {
28
+ // 实测陷阱:return_tensor 缺省为 true,直取 .input_ids 得到的是 _Tensor(.length undefined
29
+ // 且不抛错) -> 必须显式 return_tensor:false
30
+ const ids = tok.encode(raw, { return_tensor: false });
31
+ if (!Array.isArray(ids) || ids.length <= maxTokens) return raw;
32
+ const kept = ids.slice(0, Math.max(1, maxTokens - 2));
33
+ // 实测陷阱:decode 缺省 skip_special_tokens=false,会把 [CLS]/[SEP] 编回文本再二次分词
34
+ const shrunk = tok.decode(kept, { skip_special_tokens: true });
35
+ return typeof shrunk === 'string' && shrunk.length > 0 ? shrunk : raw;
36
+ } catch {
37
+ return raw; // 降级:不截断,保持旧行为
38
+ }
39
+ }
40
+
41
+ module.exports = { shrinkToBudget };
@@ -1,5 +1,8 @@
1
1
  // DEPRECATED: use embedding-child.cjs (subprocess isolation, fix #11)
2
2
  // 保留一版兼容(opts.useWorker 逃生门),下版删除。不再主动修改。
3
+ // 决策记录:#22 的 token 预算护栏**有意不同步到这里**(本通道仍只按 maxInputChars 粗筛)。
4
+ // 逃生门本身就是「回到 #11 前的风险状态」,给它同步一份预算代码 = 维护两份口径;
5
+ // 真需护栏请用默认 child 通道。参 embedding-child.cjs `shrinkToBudget`。
3
6
  /**
4
7
  * 本地嵌入 worker:transformers.js 的模型加载与推理在本线程执行,主线程只
5
8
  * 投递文本、收回向量。onnxruntime-node 的 run/loadModel 是 setImmediate 回调里
package/dist/index.js CHANGED
@@ -161,7 +161,7 @@ export async function apply(ctx, config) {
161
161
  logger,
162
162
  proxy: config.embedding.proxy,
163
163
  });
164
- const makeLocalService = makeLocalServiceFactory(installer, downloader, logger, config.embedding.maxInputChars);
164
+ const makeLocalService = makeLocalServiceFactory(installer, downloader, logger, config.embedding.maxInputChars, undefined, config.embedding.maxInputTokens);
165
165
  let initial = { svc: new NoopEmbeddingService(), dims: 0 };
166
166
  /** 管理器引用:启动重嵌链/backfill 闭包在运行期解引用(声明早于创建避免 TDZ)。 */
167
167
  // eslint-disable-next-line prefer-const -- 声明早于创建,后续单点赋值(TS 不允许 const 无初值)
@@ -366,6 +366,17 @@ export async function apply(ctx, config) {
366
366
  else if (r1.failed > 0) {
367
367
  logger.warn(`[memory] 向量补齐未完成(L1 失败 ${r1.failed}),下个周期重试`);
368
368
  }
369
+ else if (missingAfter < 0) {
370
+ // 计数失败时 sqlite 层给 -1(它自己已 warn);本侧**不得**把「数不出」当「无缺失」
371
+ // 而滑进下方的停摆告警,更不能标 synced。
372
+ logger.warn('[memory] 向量补齐:缺失数统计失败,本轮不判定完成');
373
+ }
374
+ else {
375
+ // 本轮未写入且仍有缺失:典型形态是本地嵌入转入 failed 后 reindex 首行早退
376
+ // (`if (!vectorReady()) return 0/0/0`),与「本轮无事可做」在日志里原本不可区分。
377
+ logger.warn(`[memory] 向量补齐停摆:本轮未写入且仍差 ${missingAfter} 条` +
378
+ `(本地嵌入可能处于 failed 态,见上一条带 signal 的退出记录)`);
379
+ }
369
380
  }
370
381
  catch (err) {
371
382
  logger.warn(`[memory] 向量补齐失败: ${err instanceof Error ? err.message : String(err)}`);
@@ -63,10 +63,13 @@ export interface InitialEmbedding {
63
63
  export declare function remoteCeiling(cfg: MemoryConfig): boolean;
64
64
  export declare function resolveInitialEmbedding(cfg: MemoryConfig, sourceStore: EmbeddingSourceStore, downloader: ModelDownloadQueue, makeLocal: (modelId: string) => LocalEmbeddingService | null, logger?: MemoryLogger): Promise<InitialEmbedding>;
65
65
  /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
66
- * 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。 */
66
+ * 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。
67
+ * 新键一律**追加在末尾**:`childPath` 已有位置参数调用方(含测试),插中间会静默串位。 */
67
68
  export declare function makeLocalServiceFactory(installer: RuntimeInstaller, downloader: ModelDownloadQueue, logger?: MemoryLogger, maxInputChars?: number,
68
69
  /** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
69
- childPath?: string): (modelId: string) => LocalEmbeddingService | null;
70
+ childPath?: string,
71
+ /** 本地推理 token 预算(config `embedding.maxInputTokens`;0/缺省 = 按模型窗口与硬上限推导)。 */
72
+ maxInputTokens?: number): (modelId: string) => LocalEmbeddingService | null;
70
73
  export interface EmbeddingManagerDeps {
71
74
  dataDir: string;
72
75
  cfg: MemoryConfig;
@@ -153,10 +153,13 @@ export async function resolveInitialEmbedding(cfg, sourceStore, downloader, make
153
153
  return { svc, dims: cfg.embedding.dimensions, providerInfo: svc.getProviderInfo() };
154
154
  }
155
155
  /** 本地服务构造工厂(index.ts 的初始解析与 Manager 共用一份实现,防漂移)。
156
- * 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。 */
156
+ * 推理在 fork 子进程(见 local-embedding.ts,fix #11);此处只传 runtime 目录与模型目录。
157
+ * 新键一律**追加在末尾**:`childPath` 已有位置参数调用方(含测试),插中间会静默串位。 */
157
158
  export function makeLocalServiceFactory(installer, downloader, logger, maxInputChars,
158
159
  /** 测试注入缝:子进程资产路径(源码态下 dist 产物不存在,显式指向 resources 源文件)。 */
159
- childPath) {
160
+ childPath,
161
+ /** 本地推理 token 预算(config `embedding.maxInputTokens`;0/缺省 = 按模型窗口与硬上限推导)。 */
162
+ maxInputTokens) {
160
163
  return (modelId) => {
161
164
  const entry = catalogById(modelId);
162
165
  if (!entry)
@@ -166,6 +169,7 @@ childPath) {
166
169
  childPath,
167
170
  logger,
168
171
  maxInputChars,
172
+ maxInputTokens,
169
173
  });
170
174
  };
171
175
  }
@@ -245,7 +249,8 @@ export class EmbeddingManager {
245
249
  }
246
250
  /** 构造绑定真实运行时 loader 的本地服务(deps.makeLocal 可注入,测试替换)。 */
247
251
  makeLocalService(modelId) {
248
- const factory = this.deps.makeLocal ?? makeLocalServiceFactory(this.installer, this.downloader, this.deps.logger, this.deps.cfg.embedding.maxInputChars);
252
+ const factory = this.deps.makeLocal ??
253
+ makeLocalServiceFactory(this.installer, this.downloader, this.deps.logger, this.deps.cfg.embedding.maxInputChars, undefined, this.deps.cfg.embedding.maxInputTokens);
249
254
  return factory(modelId);
250
255
  }
251
256
  /** 活切换请求:验证通过即接受,后台执行应用链(进度轮询可见)。 */
package/dist/store/l1.js CHANGED
@@ -984,9 +984,14 @@ export class L1Store {
984
984
  try {
985
985
  vecs = await this.embedSvc.embedBatch(chunk.map((c) => c.content));
986
986
  }
987
- catch {
987
+ catch (err) {
988
+ // 必须带 id(#22 取证面):本腿直接调 embedSvc、绕过 EmbedHelper 的熔断/超时/截断,
989
+ // 一整批全失败是常态(进程被杀时后续 chunk 均秒拒);不带 id 就无从定位是哪条长文本致崩。
990
+ // 只打前 3 个:失败可能是整库堆积,全打会把日志挤掉。
988
991
  failed += chunk.length;
989
992
  done += chunk.length;
993
+ this.logger?.warn(`[memory] 重嵌批次失败(${chunk.length} 条,本 chunk 首三个 id=` +
994
+ `${chunk.slice(0, 3).map((c) => c.id).join(',')}…): ${err instanceof Error ? err.message : String(err)}`);
990
995
  opts?.onProgress?.(done, total);
991
996
  continue;
992
997
  }
@@ -1,5 +1,5 @@
1
1
  import type { MemoryLogger } from '../types.js';
2
- import type { CatalogEntry } from './model-catalog.js';
2
+ import { type CatalogEntry } from './model-catalog.js';
3
3
  import type { EmbedCallOptions, EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
4
4
  type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
5
5
  /** 推理侧启动参数(worker 经 workerData / 子进程经 env DSH_EMBED_CFG;构造后不可变)。 */
@@ -8,7 +8,13 @@ export interface EmbedWorkerData {
8
8
  modelDir: string;
9
9
  pooling: 'cls' | 'mean';
10
10
  dtype: string;
11
+ /** 字符粗筛上限(IPC 体积护栏;不是语义护栏,也**不是**崩溃护栏——token 帽才是)。 */
11
12
  maxInputChars: number;
13
+ /**
14
+ * 本地推理的单条 token 预算(issue #22)。主进程推导(`deriveTokenBudget`)、
15
+ * 子进程执行截断——因为 tokenizer 只在子进程可达,且 ORT `run()` 同步不可中断。
16
+ */
17
+ maxInputTokens: number;
12
18
  }
13
19
  /** 主线程 → worker 的调用(id 由通道分配)。 */
14
20
  export type EmbedWorkerCall = {
@@ -48,6 +54,12 @@ export interface EmbedWorkerChannel {
48
54
  /** worker 意外崩溃通知(此后所有未决请求已被通道拒绝)。 */
49
55
  setOnCrash(cb: (error: string) => void): void;
50
56
  }
57
+ /**
58
+ * 退出描述归一化(issue #22 取证面):被信号硬杀时 Node 给的是 `code===null` + `signal`,
59
+ * 只打 code 会让 SIGTRAP(分配器 trap)/OOM-kill(SIGKILL)/自然退出(code=0)在日志里长得一模一样。
60
+ * signal 同时进 crashed 串、向上传为 loadError,故一处修三处可见。
61
+ */
62
+ export declare function describeExit(what: string, code: number | null, signal: string | null): string;
51
63
  export interface LocalEmbeddingOptions {
52
64
  /** 数据目录 runtime/(推理侧据此 createRequire 加载 transformers)。 */
53
65
  runtimeDir: string;
@@ -61,6 +73,11 @@ export interface LocalEmbeddingOptions {
61
73
  channel?: EmbedWorkerChannel;
62
74
  logger?: MemoryLogger;
63
75
  maxInputChars?: number;
76
+ /**
77
+ * 用户显式 token 预算(config `embedding.maxInputTokens`);缺省/0 = 按模型窗口与
78
+ * `HARD_CAP_TOKENS` 自动推导。超限只 clamp + warn,不抛错。
79
+ */
80
+ maxInputTokens?: number;
64
81
  }
65
82
  export declare class LocalEmbeddingService implements EmbeddingService {
66
83
  private state;
@@ -22,6 +22,7 @@ import { fork, spawn } from 'node:child_process';
22
22
  import * as path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { Worker } from 'node:worker_threads';
25
+ import { deriveTokenBudget, HARD_CAP_TOKENS } from './model-catalog.js';
25
26
  /** 默认 worker 资产路径(旧通道,useWorker 逃生门):dist/store/ → dist/embedding-worker.cjs(构建期拷入)。 */
26
27
  function defaultWorkerPath() {
27
28
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'embedding-worker.cjs');
@@ -56,9 +57,12 @@ class RealWorkerChannel {
56
57
  });
57
58
  // error(未捕获异常且 worker 未自处理)与 exit(含 fatal 后的退出)都兜底拒绝
58
59
  this.worker.on('error', (err) => this.failAll(`本地嵌入 worker 线程错误: ${err.message}`));
59
- this.worker.on('exit', (code) => {
60
+ this.worker.on('exit', (code, signal) => {
61
+ // signal 必须留痕:被信号硬杀时 code===null,单看 code 无法区分「SIGTRAP/OOM-kill/自然退出」
62
+ // (issue #22 就是靠 macOS 崩溃报告对毫秒时间戳才归因上的)。
63
+ // worker 的 exit 事件在 typings 里无强类型重载,故 signal 取守性可选形参。
60
64
  if (!this.terminated)
61
- this.failAll(`本地嵌入 worker 线程退出(code=${code})`);
65
+ this.failAll(describeExit('本地嵌入 worker 线程', code, signal ?? null));
62
66
  });
63
67
  }
64
68
  request(call) {
@@ -96,6 +100,14 @@ class RealWorkerChannel {
96
100
  }
97
101
  }
98
102
  }
103
+ /**
104
+ * 退出描述归一化(issue #22 取证面):被信号硬杀时 Node 给的是 `code===null` + `signal`,
105
+ * 只打 code 会让 SIGTRAP(分配器 trap)/OOM-kill(SIGKILL)/自然退出(code=0)在日志里长得一模一样。
106
+ * signal 同时进 crashed 串、向上传为 loadError,故一处修三处可见。
107
+ */
108
+ export function describeExit(what, code, signal) {
109
+ return `${what}退出(code=${code ?? 'null'},signal=${signal ?? 'null'})`;
110
+ }
99
111
  /**
100
112
  * 真实子进程通道(fix #11 默认):fork 独立进程隔离 sharp/libvips DLL 冲突。
101
113
  * 与 RealWorkerChannel 同协议语义,差异仅在传输层:
@@ -140,9 +152,10 @@ class RealChildChannel {
140
152
  });
141
153
  // error(fork 启动失败等)与 exit(含 fatal 后自杀、OOM 被杀)都钉底拒绝
142
154
  this.child.on('error', (err) => this.failAll(`本地嵌入子进程错误: ${err.message}`));
143
- this.child.on('exit', (code) => {
155
+ this.child.on('exit', (code, signal) => {
156
+ // 同上:signal 是被硬杀的唯一线索,不得丢弃(#22)
144
157
  if (!this.terminated)
145
- this.failAll(`本地嵌入子进程退出(code=${code})`);
158
+ this.failAll(describeExit('本地嵌入子进程', code, signal));
146
159
  });
147
160
  }
148
161
  request(call) {
@@ -213,13 +226,25 @@ export class LocalEmbeddingService {
213
226
  this.entry = entry;
214
227
  this.logger = opts.logger;
215
228
  const maxInputChars = opts.maxInputChars && opts.maxInputChars > 0 ? opts.maxInputChars : 5000;
229
+ const budget = deriveTokenBudget(entry.contextTokens, opts.maxInputTokens);
230
+ if (budget.clamped) {
231
+ // 用户写了个必崩的值:压到上限并留痕(不抛错——拒绝启动比 clamp 更难排查)
232
+ this.logger?.warn(`[memory] 本地嵌入 token 预算已从 ${opts.maxInputTokens} 压到 ${budget.tokens}` +
233
+ `(模型窗口 ${entry.contextTokens} / 硬上限 ${HARD_CAP_TOKENS},#22 安全余量)`);
234
+ }
216
235
  const cfgObj = {
217
236
  runtimeDir: opts.runtimeDir,
218
237
  modelDir,
219
238
  pooling: entry.pooling,
220
239
  dtype: 'q8',
221
240
  maxInputChars,
241
+ maxInputTokens: budget.tokens,
222
242
  };
243
+ // #22 取证面:同一份代码在纯 node 能跑、在 Electron-node 崩,而仓内任何地方都没记录
244
+ // execPath 差异与生效预算 —— 下次不用靠系统崩溃报告对时间戳。
245
+ this.logger?.info(`[memory] 本地嵌入子进程拉起:execPath=${process.execPath},release=${process.release?.name ?? 'unknown'},` +
246
+ `model=${entry.id},contextTokens=${entry.contextTokens},tokenBudget=${budget.tokens},` +
247
+ `粗筛 chars=${maxInputChars},通道=${opts.useWorker ? 'worker(旧逃生门,无 token 护栏)' : 'child'}`);
223
248
  this.channel =
224
249
  opts.channel ??
225
250
  (opts.useWorker
@@ -31,7 +31,10 @@ export interface CatalogEntry {
31
31
  revision: string;
32
32
  /** 向量维度(vec0 建表与切换重嵌的判据)。 */
33
33
  dims: number;
34
- /** 上下文窗口(token),仅 UI 展示。 */
34
+ /**
35
+ * 上下文窗口(token)。0.26.25 起从「仅 UI 展示」升格为**本地推理预算的推导真值**
36
+ * (见 `deriveTokenBudget`)——改动这里就是改本地嵌入的截断口径,不再只是个展示字段。
37
+ */
35
38
  contextTokens: number;
36
39
  /** 池化方式:BGE 系 CLS;embeddinggemma 系(Gemma 解码器底座)MEAN。 */
37
40
  pooling: 'cls' | 'mean';
@@ -44,5 +47,32 @@ export interface CatalogEntry {
44
47
  export declare const MODEL_CATALOG: CatalogEntry[];
45
48
  /** 按 id 取目录项。 */
46
49
  export declare function catalogById(id: string): CatalogEntry | undefined;
50
+ /**
51
+ * 本地 ONNX 推理的单条 token 上限(**唯一硬编码源**,issue #22)。
52
+ *
53
+ * 取值 2048 的理由(不是拍脑袋):#22 实测 Electron 宿主下约 **2700 token** 即被分配器
54
+ * SIGTRAP 打死(栈:MatMul 输出张量 → BFCArena::Extend → malloc_zone_memalign),且峰值
55
+ * 内存按 **O(T²)** 增长(attention 张量 `[1,heads,T,T]`)——2048 在致死线下留 ≈24% 余量,
56
+ * 同时把单次峰值从 ≈4.7GB 压到 ≈0.43 倍。它是**崩溃护栏也是内存护栏**,两者同源。
57
+ *
58
+ * 不拿 `maxInputChars` 当护栏的原因:bge-m3/embeddinggemma 是 Unigram/BPE,中文≈**1 字/token**
59
+ * 而 emoji/泰文可 >1.5,所以 5000 字符**既可只 750 token(英文)也可近 8192(中文)**——
60
+ * 字符与 token 无稳定比值,字符帽永远约不到安全线。
61
+ */
62
+ export declare const HARD_CAP_TOKENS = 2048;
63
+ /**
64
+ * 推导生效的本地嵌入 token 预算(**单一实现点**,截断执行在 `resources/embedding-child.cjs`)。
65
+ *
66
+ * 默认(`userTokens` 缺省/0/负)= `min(modelContext, HARD_CAP_TOKENS)`:bge-small-zh(512)
67
+ * 不受 HARD_CAP 影响(本来就到 512 窗口),bge-m3(8192) 被压到 2048。`modelContext`
68
+ * 取不到(非目录模型)时回退 HARD_CAP_TOKENS,**取小不取大**。
69
+ *
70
+ * 用户显式超限只 **clamp 不抛错**(收窄 schema 边界会让已部署 YAML 解析直接失败,故障半径
71
+ * 是整个插件配置树),`clamped` 由调用方打 warn 使其可见。
72
+ */
73
+ export declare function deriveTokenBudget(modelContext: number | undefined, userTokens: number | undefined): {
74
+ tokens: number;
75
+ clamped: boolean;
76
+ };
47
77
  /** 模型总字节数(磁盘检查与整体进度分母)。 */
48
78
  export declare function catalogTotalBytes(entry: CatalogEntry): number;
@@ -75,6 +75,36 @@ export const MODEL_CATALOG = [
75
75
  export function catalogById(id) {
76
76
  return MODEL_CATALOG.find((m) => m.id === id);
77
77
  }
78
+ /**
79
+ * 本地 ONNX 推理的单条 token 上限(**唯一硬编码源**,issue #22)。
80
+ *
81
+ * 取值 2048 的理由(不是拍脑袋):#22 实测 Electron 宿主下约 **2700 token** 即被分配器
82
+ * SIGTRAP 打死(栈:MatMul 输出张量 → BFCArena::Extend → malloc_zone_memalign),且峰值
83
+ * 内存按 **O(T²)** 增长(attention 张量 `[1,heads,T,T]`)——2048 在致死线下留 ≈24% 余量,
84
+ * 同时把单次峰值从 ≈4.7GB 压到 ≈0.43 倍。它是**崩溃护栏也是内存护栏**,两者同源。
85
+ *
86
+ * 不拿 `maxInputChars` 当护栏的原因:bge-m3/embeddinggemma 是 Unigram/BPE,中文≈**1 字/token**
87
+ * 而 emoji/泰文可 >1.5,所以 5000 字符**既可只 750 token(英文)也可近 8192(中文)**——
88
+ * 字符与 token 无稳定比值,字符帽永远约不到安全线。
89
+ */
90
+ export const HARD_CAP_TOKENS = 2048;
91
+ /**
92
+ * 推导生效的本地嵌入 token 预算(**单一实现点**,截断执行在 `resources/embedding-child.cjs`)。
93
+ *
94
+ * 默认(`userTokens` 缺省/0/负)= `min(modelContext, HARD_CAP_TOKENS)`:bge-small-zh(512)
95
+ * 不受 HARD_CAP 影响(本来就到 512 窗口),bge-m3(8192) 被压到 2048。`modelContext`
96
+ * 取不到(非目录模型)时回退 HARD_CAP_TOKENS,**取小不取大**。
97
+ *
98
+ * 用户显式超限只 **clamp 不抛错**(收窄 schema 边界会让已部署 YAML 解析直接失败,故障半径
99
+ * 是整个插件配置树),`clamped` 由调用方打 warn 使其可见。
100
+ */
101
+ export function deriveTokenBudget(modelContext, userTokens) {
102
+ const cap = Math.min(modelContext && modelContext > 0 ? modelContext : HARD_CAP_TOKENS, HARD_CAP_TOKENS);
103
+ const raw = userTokens && userTokens > 0 ? Math.floor(userTokens) : 0;
104
+ if (raw === 0)
105
+ return { tokens: cap, clamped: false };
106
+ return { tokens: Math.min(raw, cap), clamped: raw > cap };
107
+ }
78
108
  /** 模型总字节数(磁盘检查与整体进度分母)。 */
79
109
  export function catalogTotalBytes(entry) {
80
110
  return entry.files.reduce((sum, f) => sum + f.size, 0);
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-prime-memory",
3
3
  "name": "dsh-prime-memory",
4
- "version": "0.26.23",
4
+ "version": "0.26.25",
5
5
  "description": {
6
6
  "en": "Layered distilled memory plugin (L0 capture → L1 atomic memory → L2 scenes → L3 persona) with automatic recall injection before each model step.",
7
7
  "zh": "L0~L3 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.26.23",
3
+ "version": "0.26.25",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",