@modusensus/dsh-mneme 0.7.31 → 0.7.32

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 (86) hide show
  1. package/README.md +24 -8
  2. package/lib/api.js +79 -6
  3. package/lib/client.js +217 -30
  4. package/lib/commands.js +6 -2
  5. package/lib/config.js +80 -14
  6. package/lib/dream/decisions.js +147 -14
  7. package/lib/dream/sleep.js +84 -34
  8. package/lib/dream.js +250 -60
  9. package/lib/embedding.js +22 -2
  10. package/lib/index.js +68 -37
  11. package/lib/inject.js +16 -9
  12. package/lib/lang.js +156 -0
  13. package/lib/local-embedder.js +23 -9
  14. package/lib/mirror.js +17 -14
  15. package/lib/reranker.js +23 -8
  16. package/lib/runtime/adopt.js +176 -0
  17. package/lib/runtime/closure.js +172 -0
  18. package/lib/runtime/download.js +247 -0
  19. package/lib/runtime/layout.js +235 -0
  20. package/lib/runtime/loader.js +249 -0
  21. package/lib/runtime/provision.js +237 -0
  22. package/lib/runtime/tarball.js +130 -0
  23. package/lib/runtime/verify.js +191 -0
  24. package/lib/service.js +102 -8
  25. package/lib/settings.js +36 -2
  26. package/lib/store.js +26 -8
  27. package/lib/summarize.js +108 -35
  28. package/lib/tools.js +119 -0
  29. package/package.json +7 -2
  30. package/runtime-manifest.json +596 -0
  31. package/scripts/build-runtime-manifest.mjs +217 -0
  32. package/scripts/mneme-runtime.mjs +277 -0
  33. package/src/api.js +79 -6
  34. package/src/commands.js +6 -2
  35. package/src/config.js +80 -14
  36. package/src/dream/decisions.js +147 -14
  37. package/src/dream/sleep.js +84 -34
  38. package/src/dream.js +250 -60
  39. package/src/embedding.js +22 -2
  40. package/src/index.js +68 -37
  41. package/src/inject.js +16 -9
  42. package/src/lang.js +156 -0
  43. package/src/local-embedder.js +23 -9
  44. package/src/mirror.js +17 -14
  45. package/src/reranker.js +23 -8
  46. package/src/runtime/adopt.js +176 -0
  47. package/src/runtime/closure.js +172 -0
  48. package/src/runtime/download.js +247 -0
  49. package/src/runtime/layout.js +235 -0
  50. package/src/runtime/loader.js +249 -0
  51. package/src/runtime/provision.js +237 -0
  52. package/src/runtime/tarball.js +130 -0
  53. package/src/runtime/verify.js +191 -0
  54. package/src/service.js +102 -8
  55. package/src/settings.js +36 -2
  56. package/src/store.js +26 -8
  57. package/src/summarize.js +108 -35
  58. package/src/tools.js +119 -0
  59. package/test/api.test.js +89 -9
  60. package/test/client.test.js +94 -10
  61. package/test/config.test.js +17 -1
  62. package/test/dream-candidate-hybrid.test.js +281 -0
  63. package/test/dream.test.js +172 -0
  64. package/test/embedding-ready.test.js +196 -0
  65. package/test/helpers/runtime-source.js +60 -0
  66. package/test/helpers/tar-builder.js +80 -0
  67. package/test/inject.test.js +37 -1
  68. package/test/lang.test.js +87 -0
  69. package/test/quality-filter.test.js +68 -0
  70. package/test/reasoning-effort.test.js +106 -15
  71. package/test/reindex-backfill.test.js +62 -0
  72. package/test/runtime-adopt.test.js +206 -0
  73. package/test/runtime-cli.test.js +89 -0
  74. package/test/runtime-closure.test.js +193 -0
  75. package/test/runtime-download.test.js +302 -0
  76. package/test/runtime-layout.test.js +163 -0
  77. package/test/runtime-loader.test.js +237 -0
  78. package/test/runtime-manifest.test.js +158 -0
  79. package/test/runtime-provision.test.js +101 -0
  80. package/test/runtime-tarball.test.js +82 -0
  81. package/test/runtime-verify.test.js +214 -0
  82. package/test/sleep-action-set.test.js +422 -0
  83. package/test/store.test.js +14 -0
  84. package/test/summarize.test.js +224 -0
  85. package/test/tools.test.js +30 -5
  86. package/test/webServer-optional.test.js +34 -1
package/src/tools.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
+ import { describeLocalRuntime, resolveRuntimeEntry } from "./runtime/loader.js";
3
+ import { defaultRuntimeDir } from "./runtime/layout.js";
4
+ import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
5
+ import { matchesPlatform } from "./runtime/closure.js";
6
+ import { verifyPayload } from "./runtime/verify.js";
2
7
 
3
8
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
4
9
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -331,6 +336,120 @@ export function createTools(ctx, service, config, embedder) {
331
336
  const memory = service.setArchived(args.id, args.archived ?? true);
332
337
  return { memory: { id: memory.id, archived: memory.archived } };
333
338
  }
339
+ }),
340
+
341
+ // 运行时口子(issue #131 / PR-C)。
342
+ //
343
+ // 为什么要有这个工具:mneme 的取向是「本体保持完善但轻量;重的功能存在、默认不开、不被依赖」。
344
+ // 本地向量化就是那个重功能 —— 它需要额外一份运行时(解包后数百 MB)。面板上有按钮,但用户
345
+ // 可能根本不看面板;这时 agent 需要一个入口,能先查状态、把代价告诉用户,再按需取回。
346
+ //
347
+ // 关于「要不要直接 provision」:它会联网、可能传几十到上百 MB、耗时数分钟。所以工具描述里
348
+ // 明确要求先告知用户;只有在用户已经表示要本地向量化时才直接动手。
349
+ defineTool({
350
+ name: "memory_runtime",
351
+ description:
352
+ "Inspect or provision the LOCAL inference runtime that dsh-mneme's local (offline) embedding and rerank need: " +
353
+ "the transformers + onnxruntime dependency closure, hundreds of MB unpacked. It is deliberately NOT a default " +
354
+ "dependency — mneme's core stays lightweight and local vectorization is opt-in — so this tool is the hook that " +
355
+ "makes the heavy capability available on demand. Use action=status first: it is read-only and reports whether " +
356
+ "the runtime is ready plus what provisioning would cost. action=provision tries, in order: (1) adopting an " +
357
+ "existing copy from this profile's node_modules (no network; hardlinked when on the same volume, so no extra " +
358
+ "disk), (2) a configured local .tgz directory, (3) downloading from the npm registry against a pinned manifest, " +
359
+ "verifying every tarball's sha512. Provisioning can transfer tens to hundreds of MB and take minutes: unless " +
360
+ "the user has already asked for local vectorization, tell them that cost before calling it. action=verify loads " +
361
+ "the runtime and runs one real inference (strictly offline, so the model must already be cached).",
362
+ parameters: {
363
+ action: { type: "string", enum: ["status", "provision", "verify"], required: true, description: "status = read-only; provision = fetch/adopt the runtime; verify = run one real inference" },
364
+ overwrite: { type: "boolean", description: "provision only: replace an existing payload (use when repairing a broken one)" }
365
+ },
366
+ output: {
367
+ schema: {
368
+ type: "object",
369
+ additionalProperties: false,
370
+ properties: {
371
+ summary: { type: "string", required: true },
372
+ status: { type: "string", required: true },
373
+ cost: { type: "string", required: true },
374
+ strategy: { type: "string" },
375
+ payloadId: { type: "string" },
376
+ packages: { type: "integer" },
377
+ files: { type: "integer" },
378
+ bytes: { type: "integer" },
379
+ dimension: { type: "integer" },
380
+ reason: { type: "string" }
381
+ }
382
+ },
383
+ render: (_args, value) => TEXT_OUTPUT(value.summary)
384
+ },
385
+ async execute(args) {
386
+ const runtimeDir = config?.runtimeDir ?? "";
387
+ const manifest = loadRuntimeManifest();
388
+ const forPlatform = Array.isArray(manifest?.packages) ? manifest.packages.filter((pkg) => matchesPlatform(pkg, process.platform, process.arch)) : null;
389
+ const cost =
390
+ `Local vectorization needs an extra local inference runtime (transformers + onnxruntime closure, ` +
391
+ `hundreds of MB unpacked${forPlatform ? `, ${forPlatform.length} packages for this platform` : ""}). ` +
392
+ `It is not a default dependency: provisioning adopts an existing copy when possible, otherwise downloads it.`;
393
+
394
+ if (args.action === "provision") {
395
+ const result = await provisionRuntime({
396
+ hostModulesDir: hostModulesDir(import.meta.url),
397
+ runtimeDir,
398
+ localTarballDir: config?.runtimeTarballDir ?? "",
399
+ mirror: config?.runtimeMirror ?? "",
400
+ overwrite: args.overwrite === true,
401
+ manifest
402
+ });
403
+ return {
404
+ summary: result.ok
405
+ ? `Runtime ${result.strategy === "download" ? "downloaded from npm" : "adopted from this machine"}: ` +
406
+ `${result.packages} packages / ${result.files} files. Local embedding can be used after a DSH reload.`
407
+ : `Could not provision the runtime: ${result.reason}`,
408
+ status: result.ok ? "available" : "failed",
409
+ cost,
410
+ strategy: result.strategy,
411
+ ...(result.payloadId ? { payloadId: result.payloadId } : {}),
412
+ ...(result.packages === undefined ? {} : { packages: result.packages }),
413
+ ...(result.files === undefined ? {} : { files: result.files }),
414
+ ...(result.bytes === undefined ? {} : { bytes: result.bytes }),
415
+ ...(result.reason ? { reason: result.reason } : {})
416
+ };
417
+ }
418
+
419
+ if (args.action === "verify") {
420
+ const candidate = resolveRuntimeEntry({ runtimeDir: runtimeDir || undefined });
421
+ if (candidate === null) {
422
+ return { summary: "No usable runtime payload to verify.", status: "missing", cost };
423
+ }
424
+ const report = await verifyPayload(candidate.dir, { cacheDir: config?.embedModelCacheDir || undefined });
425
+ return {
426
+ summary: report.ok
427
+ ? `Runtime verified: real inference succeeded (dim ${report.functional.dim}, ${report.functional.rows} rows, ` +
428
+ `${report.functional.elapsedMs}ms, offline). Integrity: ${report.integrity.status}.`
429
+ : `Runtime verification failed — structural: ${report.structural.ok}, functional: ${report.functional.ok} (${report.functional.reason}).`,
430
+ status: report.ok ? "verified" : "failed",
431
+ cost,
432
+ payloadId: candidate.payloadId,
433
+ ...(report.functional.dim ? { dimension: report.functional.dim } : {}),
434
+ ...(report.ok ? {} : { reason: report.functional.reason })
435
+ };
436
+ }
437
+
438
+ // status:只读,且刻意不加载模型(那要几百毫秒且碰缓存)。报告要给到 agent 足够的信息去
439
+ // 决定「要不要打扰用户」。
440
+ const report = describeLocalRuntime({ runtimeDir: runtimeDir || defaultRuntimeDir() });
441
+ return {
442
+ summary:
443
+ `Local inference runtime: ${report.status}` +
444
+ (report.payloadId ? ` (${report.payloadId}, ${report.procedure ?? "?"}, ${report.materialize ?? "?"})` : "") +
445
+ `. ${report.status === "available" ? "Local embedding/rerank can be used." : "Local embedding is unavailable — " + (report.hint ?? "")}`,
446
+ status: report.status,
447
+ cost,
448
+ ...(report.payloadId ? { payloadId: report.payloadId } : {}),
449
+ ...(forPlatform ? { packages: forPlatform.length } : {}),
450
+ ...(report.reason ? { reason: report.reason } : {})
451
+ };
452
+ }
334
453
  })
335
454
  ];
336
455
 
package/test/api.test.js CHANGED
@@ -33,7 +33,7 @@ function req(path, method = "GET", body = null) {
33
33
  return r;
34
34
  }
35
35
 
36
- function setup(embedder, apiToken = "", config = null) {
36
+ function setup(embedder, apiToken = "", config = null, semantic = undefined) {
37
37
  const store = createStore(":memory:");
38
38
  const service = createService({ store, mirror: null, config: {} });
39
39
  const settings = createSettings(store.db);
@@ -51,7 +51,7 @@ function setup(embedder, apiToken = "", config = null) {
51
51
  }
52
52
  }
53
53
  };
54
- const api = createApi(ctx, service, settings, commands, embedder, undefined, apiToken, config);
54
+ const api = createApi(ctx, service, settings, commands, embedder, semantic, apiToken, config);
55
55
  return { store, service, routes, api, settings, apiToken };
56
56
  }
57
57
 
@@ -470,6 +470,73 @@ test("GET /api/dsh-mneme/semantic reports ready state per embedder", async () =>
470
470
  assert.equal(named.ready, true);
471
471
  });
472
472
 
473
+ // --- #135: /semantic must tell "not configured" apart from "degraded" ---------
474
+
475
+ test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async () => {
476
+ const fetchSem = async (embedder, semantic) => {
477
+ const { routes } = setup(embedder, "", null, semantic);
478
+ const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
479
+ const res = new FakeRes();
480
+ await sem.handler(req("/api/dsh-mneme/semantic"), res);
481
+ assert.equal(res.statusCode, 200);
482
+ return JSON.parse(res.body);
483
+ };
484
+ // Stats double: `total` is the memory count, `embedded` the indexed count.
485
+ const index = (embeddedCount, totalCount) => ({
486
+ vectorIndex: { getStats: () => ({ embeddedCount, totalCount }) }
487
+ });
488
+ const openai = (ready, configured) => ({
489
+ name: "OpenAI",
490
+ get ready() { return ready; },
491
+ get configured() { return configured; },
492
+ embed() {}
493
+ });
494
+
495
+ // No embedder at all → everything reports absence, not a false green.
496
+ const none = await fetchSem(undefined, index(0, 0));
497
+ assert.equal(none.configured, null);
498
+ assert.equal(none.reason, "no-embedder");
499
+ assert.equal(none.degraded, false);
500
+ assert.deepEqual(none.coverage, { embedded: 0, total: 0, ratio: null },
501
+ "empty store → ratio null (0 would read as 'nothing embedded')");
502
+
503
+ // Configured embedder still initializing: unreachable ≠ unconfigured.
504
+ assert.equal((await fetchSem(openai(false, true), index(0, 346))).reason, "initializing");
505
+
506
+ // Never configured: reported as such even though the index has memories.
507
+ const unconfigured = await fetchSem(openai(false, false), index(0, 346));
508
+ assert.equal(unconfigured.ready, false);
509
+ assert.equal(unconfigured.configured, false);
510
+ assert.equal(unconfigured.reason, "not-configured");
511
+ assert.equal(unconfigured.degraded, false, "unconfigured was never meant to index");
512
+ assert.equal(unconfigured.coverage.ratio, 0);
513
+
514
+ // Configured, memories present, zero vectors — the state this machine sat in
515
+ // for weeks while the card showed a healthy provider.
516
+ const degraded = await fetchSem(openai(true, true), index(0, 346));
517
+ assert.equal(degraded.reason, null, "a complete config has no abnormal reason");
518
+ assert.equal(degraded.degraded, true);
519
+ assert.equal(degraded.coverage.ratio, 0);
520
+
521
+ // Healthy index.
522
+ const ok = await fetchSem(openai(true, true), index(173, 346));
523
+ assert.equal(ok.degraded, false);
524
+ assert.equal(ok.coverage.ratio, 0.5);
525
+
526
+ // Adapters without a `configured` field (third-party/test doubles) keep the
527
+ // old contract: assumed configured, `ready` decides.
528
+ const legacy = await fetchSem({ embed() {} }, index(5, 10));
529
+ assert.equal(legacy.configured, true);
530
+ assert.equal(legacy.reason, null);
531
+ assert.equal(legacy.ready, true);
532
+
533
+ // No stats provider (no vector index) must not throw — coverage falls back
534
+ // to zeros and an empty store is never "degraded".
535
+ const noIndex = await fetchSem(openai(true, true), null);
536
+ assert.deepEqual(noIndex.coverage, { embedded: 0, total: 0, ratio: null });
537
+ assert.equal(noIndex.degraded, false);
538
+ });
539
+
473
540
  // --- Bug8: llm-audit API (pagination + stats) --------------------------------
474
541
 
475
542
  test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
@@ -577,19 +644,20 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
577
644
  assert.equal(res.statusCode, 200);
578
645
  const data = JSON.parse(res.body);
579
646
  assert.deepEqual(data.overrides, {});
580
- // effective 覆盖全部 42 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
647
+ // effective 覆盖全部 51 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
581
648
  // dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
582
649
  // dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
583
- // recallFusion/signalTransparency,以及 issue #109 新增的实体抽取路由
584
- // entityExtractionProvider/entityExtractionModel/entityExtractionReasoning),
585
- // 未覆盖时取 bundle 配置的解析默认值;
650
+ // recallFusion/signalTransparency、issue #109 新增的实体抽取路由
651
+ // entityExtractionProvider/entityExtractionModel/entityExtractionReasoning、
652
+ // issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,以及
653
+ // issue #125 新增的候选集三键),未覆盖时取 bundle 配置的解析默认值;
586
654
  // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
587
- // 不编造给前端 → 42 - 2 = 40
588
- assert.equal(Object.keys(data.effective).length, 40);
655
+ // 不编造给前端 → 51 - 2 = 49
656
+ assert.equal(Object.keys(data.effective).length, 49);
589
657
  assert.equal(data.effective.dreamSkipInvalid, true);
590
658
  assert.equal(data.effective.allowCrossTypeMerge, false);
591
659
  assert.equal(data.effective.dreamMinIntervalMinutes, 0);
592
- assert.equal(data.effective.dreamMaxTokens, 32768);
660
+ assert.equal(data.effective.dreamMaxTokens, 131072);
593
661
  assert.equal(data.effective.sleepProvider, "");
594
662
  assert.equal(data.effective.sleepModel, "");
595
663
  assert.equal(data.effective.autoInject, true);
@@ -615,6 +683,18 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
615
683
  assert.equal(data.effective.entityExtractionProvider, "");
616
684
  assert.equal(data.effective.entityExtractionModel, "");
617
685
  assert.equal(data.effective.entityExtractionReasoning, "none");
686
+ // issue #127:summarize 节流五键(均有默认值,故计入 effective 计数)
687
+ assert.equal(data.effective.summarizeMinIntervalMinutes, 0);
688
+ assert.equal(data.effective.summarizeMaxEntriesPerRun, 0);
689
+ assert.equal(data.effective.summarizeDedupeMode, "off");
690
+ assert.equal(data.effective.summarizeDedupeMinSim, 0.92);
691
+ assert.equal(data.effective.summarizeDedupeWindowHours, 24);
692
+ // issue #126:sleep 动作集(默认 conflict = 现状;面板可切 full)
693
+ assert.equal(data.effective.sleepActionSet, "conflict");
694
+ // issue #125:候选集三键(默认 window / 0 / 0.85)
695
+ assert.equal(data.effective.dreamCandidateMode, "window");
696
+ assert.equal(data.effective.dreamCandidateMax, 0);
697
+ assert.equal(data.effective.dreamCandidateMinSim, 0.85);
618
698
  });
619
699
 
620
700
  test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
@@ -121,10 +121,10 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
121
121
  );
122
122
  });
123
123
 
124
- // The entry lives ABOVE the workspaces region: the real button is portalled
125
- // just before the host's regionArea container (below New Session, above the
126
- // workspace list), while the sidebar.footer.action registration remains as
127
- // the React anchor and the in-place fallback when the host markup changes.
124
+ // The entry lives at the top of the plugin-entry group: the real button is
125
+ // portalled right after the host's New-Session row (above the entries other
126
+ // plugins inject there), while the sidebar.footer.action registration remains
127
+ // as the React anchor and the in-place fallback when the host markup changes.
128
128
  test("sidebar entry portals above the workspaces region with footer fallback", () => {
129
129
  assert.ok(
130
130
  clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
@@ -139,8 +139,8 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
139
139
  "the portal must anchor at the host's regionArea container"
140
140
  );
141
141
  assert.ok(
142
- clientSource.includes("insertBefore(created, region)"),
143
- "the entry must sit immediately above the workspaces region"
142
+ clientSource.includes("insertBefore(created, target)"),
143
+ "the entry must be placed through the idempotent place() helper"
144
144
  );
145
145
  assert.ok(
146
146
  clientSource.includes("if (!host) return fallback;"),
@@ -160,13 +160,40 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
160
160
  );
161
161
  });
162
162
 
163
+ // Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
164
+ // own entries right after the New-Session row, and late inserters land above
165
+ // earlier ones — a fixed "before regionArea" slot degrades into the tail of
166
+ // that group, detaching the memory button from its familiar top spot. The
167
+ // entry therefore (a) resolves the same anchor as the ecosystem's shared
168
+ // sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
169
+ // its slot from the existing observer, and (c) stays idempotent — the anchor
170
+ // check short-circuits before any DOM write, so observers never ping-pong.
171
+ test("entry re-asserts the New-Session-adjacent slot against ecosystem entries", () => {
172
+ assert.ok(
173
+ clientSource.includes(`btn.closest('[class*="logoRow"]')`),
174
+ "the anchor must resolve the New-Session logo row like the ecosystem core"
175
+ );
176
+ assert.ok(
177
+ clientSource.includes("const target = anchor ? anchor.nextSibling : region;"),
178
+ "the entry must sit right after the New-Session row, falling back before regionArea"
179
+ );
180
+ assert.ok(
181
+ clientSource.includes("if (created.parentElement === parent && created.nextSibling === target) return;"),
182
+ "placement must be idempotent so the observer never writes on steady state"
183
+ );
184
+ assert.ok(
185
+ /place\(cur\);\s*\n\s*const cls = findNative\(cur\)/.test(clientSource),
186
+ "the observer must re-place the entry on childList churn, not just re-sync the class"
187
+ );
188
+ });
189
+
163
190
  // Three alignment/softness guarantees born from field feedback: (a) the
164
191
  // portalled entry keeps tracking the live New-Session class (host and skin
165
192
  // rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
166
- // fills the same width as that button and inherits its native centering,
193
+ // inherits that button's width and margins along with its native centering,
167
194
  // (c) the toolbar dropdown escapes the transform stacking trap — the
168
195
  // container needs the z-index because transform creates the context.
169
- test("entry tracks native class, fills native width, and lifts the toolbar dropdown", () => {
196
+ test("entry tracks native class, inherits native sizing, and lifts the toolbar dropdown", () => {
170
197
  assert.ok(
171
198
  clientSource.includes("new MutationObserver"),
172
199
  "the entry must re-sync the copied class via MutationObserver"
@@ -175,9 +202,14 @@ test("entry tracks native class, fills native width, and lifts the toolbar dropd
175
202
  clientSource.includes('attributeFilter: ["class"]'),
176
203
  "the observer must watch class attribute changes"
177
204
  );
205
+ assert.doesNotMatch(
206
+ clientSource,
207
+ /\.mneme-topentry-native\{(?:[^}]*;)?width:/,
208
+ "the entry must use the native button width, which reserves its horizontal margins"
209
+ );
178
210
  assert.ok(
179
- /\.mneme-topentry-native\{width:100%;/.test(clientSource),
180
- "the entry button must fill the same width as the New-Session row"
211
+ clientSource.includes(".mneme-topentry{display:flex;flex-direction:column}"),
212
+ "the entry wrapper must stretch the native button within a column flex layout"
181
213
  );
182
214
  assert.equal(
183
215
  clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
@@ -593,3 +625,55 @@ test("settings feedback card: prefilled issue + mailto + browse, version from /i
593
625
  assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
594
626
  }
595
627
  });
628
+
629
+ // UI 文案必须双语齐全(中英各一条)。用户明确要求所有 UI 文字都做 i18n,
630
+ // 这条守卫用「每个键恰好出现两次」兜住:出现奇数次就是漏译,>2 次说明重复。
631
+ test("i18n keys are bilingual: every key appears exactly twice (zh + en)", () => {
632
+ const counts = new Map();
633
+ for (const match of clientSource.matchAll(/^\s*"([a-zA-Z0-9_.]+)"\s*:\s*"/gm)) {
634
+ counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
635
+ }
636
+ const odd = [...counts.entries()].filter(([, n]) => n !== 2).map(([key, n]) => `${key}×${n}`);
637
+ assert.deepEqual(odd, [], `这些 i18n 键没有做到中英各一条:${odd.join(", ")}`);
638
+ assert.ok(counts.size > 100, `解析到的键太少(${counts.size}),正则可能没匹配到 i18n 表`);
639
+ });
640
+
641
+ // issue #135:向量状态卡必须区分「没配」与「配了却一条都没嵌上」。此前卡片只看
642
+ // ready,而未配置的 legacy embedder(ready 恒 true)一路显示成正常 provider。
643
+ test("vector status card surfaces the configured/degraded split", () => {
644
+ // 1. 读后端新字段,缺失(旧服务端)时退化成旧行为而不是误报未配置
645
+ assert.ok(
646
+ clientSource.includes('typeof j?.configured === "boolean" ? j.configured : null'),
647
+ "the card must read `configured` and tolerate older servers that omit it"
648
+ );
649
+ assert.ok(
650
+ clientSource.includes("j?.degraded === true"),
651
+ "the card must read `degraded`"
652
+ );
653
+ // 2. 三态互斥:未配置优先于初始化中,降级只在既非未配置也非初始化时成立
654
+ assert.ok(
655
+ clientSource.includes("const unconfigured = !off && state.configured === false;"),
656
+ "unconfigured must be its own state, not folded into ready"
657
+ );
658
+ assert.ok(
659
+ clientSource.includes("const pending = !off && !unconfigured && state.ready !== true;"),
660
+ "an unconfigured embedder must not be reported as initializing"
661
+ );
662
+ assert.ok(
663
+ clientSource.includes("const degraded = !off && !unconfigured && !pending && state.degraded === true;"),
664
+ "degraded must only apply to a configured, settled embedder"
665
+ );
666
+ // 3. 三个状态各自的标签/说明走 i18n,双语齐备
667
+ for (const key of ["vectorUnconfigured", "vectorUnconfiguredHint", "vectorDegradedHint"]) {
668
+ const occurrences = clientSource.split(`"memory.status.${key}"`).length - 1;
669
+ assert.ok(occurrences >= 2, `i18n key memory.status.${key} must exist in both zh and en (got ${occurrences})`);
670
+ }
671
+ assert.ok(
672
+ clientSource.includes('t("memory.status.vectorUnconfigured")'),
673
+ "the unconfigured state must render its own label"
674
+ );
675
+ assert.ok(
676
+ clientSource.includes('t("memory.status.vectorDegradedHint")'),
677
+ "the degraded state must render its own caption (0 of {m} indexed)"
678
+ );
679
+ });
@@ -46,5 +46,21 @@ test("startup probe: the reranker module never statically imports transformers/o
46
46
  !src.includes('from "@huggingface/transformers"'),
47
47
  "no static transformers.js import in reranker.js"
48
48
  );
49
- assert.match(src, /await import\("@huggingface\/transformers"\)/, "transformers.js loads lazily via dynamic import");
49
+ // issue #131 之后,懒加载的落点从 reranker 内部搬到了 src/runtime/loader.js
50
+ // (三层解析:自管 payload → 宿主裸 specifier)。判据随之改成两条,意图不变:
51
+ // reranker 不得静态导入,且必须经由 loader 间接加载;动态 import 只允许出现在
52
+ // loader 里(它是唯一知道「运行时从哪来」的模块)。
53
+ assert.match(src, /from "\.\/runtime\/loader\.js"/, "reranker 经 runtime/loader.js 取运行时");
54
+ assert.match(src, /loadTransformers\(/, "reranker 必须走三层解析,而不是自己拼 import");
55
+ assert.ok(
56
+ !/^\s*import[^\n]*@huggingface\/transformers/m.test(src),
57
+ "reranker 不允许任何形式的静态 transformers 导入"
58
+ );
59
+
60
+ const loaderSrc = readFileSync(new URL("../src/runtime/loader.js", import.meta.url), "utf8");
61
+ assert.match(loaderSrc, /import\(specifier\)/, "loader 用动态 import 保持懒加载");
62
+ assert.ok(
63
+ !/^\s*import[^\n]*@huggingface\/transformers/m.test(loaderSrc),
64
+ "loader 不得静态导入 transformers,否则惰性加载就失效了"
65
+ );
50
66
  });