@modusensus/dsh-mneme 0.7.31 → 0.8.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 (93) hide show
  1. package/README.md +65 -27
  2. package/lib/api.js +139 -6
  3. package/lib/client.js +369 -32
  4. package/lib/commands.js +36 -5
  5. package/lib/config.js +101 -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 +54 -13
  12. package/lib/lang.js +168 -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/scope.js +83 -0
  25. package/lib/service.js +374 -55
  26. package/lib/settings.js +43 -2
  27. package/lib/store.js +126 -13
  28. package/lib/summarize.js +108 -35
  29. package/lib/tools.js +205 -12
  30. package/package.json +7 -2
  31. package/runtime-manifest.json +596 -0
  32. package/scripts/build-runtime-manifest.mjs +217 -0
  33. package/scripts/mneme-runtime.mjs +277 -0
  34. package/src/api.js +139 -6
  35. package/src/commands.js +36 -5
  36. package/src/config.js +101 -14
  37. package/src/dream/decisions.js +147 -14
  38. package/src/dream/sleep.js +84 -34
  39. package/src/dream.js +250 -60
  40. package/src/embedding.js +22 -2
  41. package/src/index.js +68 -37
  42. package/src/inject.js +54 -13
  43. package/src/lang.js +168 -0
  44. package/src/local-embedder.js +23 -9
  45. package/src/mirror.js +17 -14
  46. package/src/reranker.js +23 -8
  47. package/src/runtime/adopt.js +176 -0
  48. package/src/runtime/closure.js +172 -0
  49. package/src/runtime/download.js +247 -0
  50. package/src/runtime/layout.js +235 -0
  51. package/src/runtime/loader.js +249 -0
  52. package/src/runtime/provision.js +237 -0
  53. package/src/runtime/tarball.js +130 -0
  54. package/src/runtime/verify.js +191 -0
  55. package/src/scope.js +83 -0
  56. package/src/service.js +374 -55
  57. package/src/settings.js +43 -2
  58. package/src/store.js +126 -13
  59. package/src/summarize.js +108 -35
  60. package/src/tools.js +205 -12
  61. package/test/api.test.js +175 -9
  62. package/test/client.test.js +152 -10
  63. package/test/commands.test.js +40 -3
  64. package/test/config.test.js +17 -1
  65. package/test/dream-candidate-hybrid.test.js +281 -0
  66. package/test/dream.test.js +172 -0
  67. package/test/embedding-ready.test.js +196 -0
  68. package/test/helpers/runtime-source.js +60 -0
  69. package/test/helpers/tar-builder.js +80 -0
  70. package/test/inject.test.js +130 -1
  71. package/test/lang.test.js +87 -0
  72. package/test/quality-filter.test.js +68 -0
  73. package/test/reasoning-effort.test.js +106 -15
  74. package/test/reindex-backfill.test.js +62 -0
  75. package/test/runtime-adopt.test.js +206 -0
  76. package/test/runtime-cli.test.js +89 -0
  77. package/test/runtime-closure.test.js +193 -0
  78. package/test/runtime-download.test.js +302 -0
  79. package/test/runtime-layout.test.js +163 -0
  80. package/test/runtime-loader.test.js +237 -0
  81. package/test/runtime-manifest.test.js +158 -0
  82. package/test/runtime-provision.test.js +101 -0
  83. package/test/runtime-tarball.test.js +82 -0
  84. package/test/runtime-verify.test.js +214 -0
  85. package/test/scope-retrieval.test.js +197 -0
  86. package/test/scope-storage.test.js +247 -0
  87. package/test/scope-strict.test.js +165 -0
  88. package/test/service.test.js +76 -0
  89. package/test/sleep-action-set.test.js +422 -0
  90. package/test/store.test.js +14 -0
  91. package/test/summarize.test.js +224 -0
  92. package/test/tools.test.js +30 -5
  93. package/test/webServer-optional.test.js +34 -1
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { createStore } from "../src/store.js";
4
4
  import { createService } from "../src/service.js";
5
5
  import { createSummarizer, parseSummaryJson } from "../src/summarize.js";
6
+ import { createSettings } from "../src/settings.js";
6
7
 
7
8
  function setup(over = {}, opts = {}) {
8
9
  const store = createStore(":memory:");
@@ -349,3 +350,226 @@ test("codingRetrospect stores coding memory types in the coding memory type set"
349
350
  // 编码模式用编码 prompt(含 rejected_solution 类型说明)。
350
351
  assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
351
352
  });
353
+
354
+ // ── Issue #127:节流 / 条数上限 / 同会话去重 ────────────────────────────────
355
+
356
+ /** A distill stream that always returns exactly these entries. */
357
+ function streamOf(entries) {
358
+ return () => (async function* () {
359
+ yield { type: "block-start", block: { type: "text" } };
360
+ yield { type: "text-delta", delta: JSON.stringify(entries) };
361
+ yield { type: "finish", kind: "ok" };
362
+ })();
363
+ }
364
+
365
+ function sessionFor(id) {
366
+ return {
367
+ id,
368
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
369
+ events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
370
+ };
371
+ }
372
+
373
+ test("issue#127: min-interval gate suppresses the second turn/end and audits it as skipped", async () => {
374
+ const { events, store, calls } = setup({ summarizeMinIntervalMinutes: 30 });
375
+ const handler = events.find((e) => e.name === "session/event").fn;
376
+ const session = sessionFor("t1");
377
+ await handler(session, { seq: 2, type: "turn/end" });
378
+ assert.equal(calls.length, 1, "the first turn/end distills");
379
+ assert.equal(store.count(), 2);
380
+ await handler(session, { seq: 3, type: "turn/end" });
381
+ assert.equal(calls.length, 1, "the second turn/end inside the window makes no LLM call");
382
+ assert.equal(store.count(), 2, "nothing new is stored");
383
+ // 可观测性:被节流不再静默——留一行 status='skipped',一条 SQL 可自查。
384
+ const audits = store.listLlmAudits({ source: "autoSummarize" });
385
+ assert.ok(
386
+ audits.some((a) => a.status === "skipped" && a.error_message === "min-interval"),
387
+ "the suppression is visible as a status='skipped' audit row"
388
+ );
389
+ });
390
+
391
+ test("issue#127: min-interval 0 keeps the historical every-turn behavior", async () => {
392
+ const { events, calls } = setup({ summarizeMinIntervalMinutes: 0 });
393
+ const handler = events.find((e) => e.name === "session/event").fn;
394
+ const session = sessionFor("t2");
395
+ await handler(session, { seq: 2, type: "turn/end" });
396
+ await handler(session, { seq: 3, type: "turn/end" });
397
+ assert.equal(calls.length, 2, "0 = no gate (zero behavior change)");
398
+ });
399
+
400
+ test("issue#127: the interval gate is per-session (one session never throttles another)", async () => {
401
+ const { events, calls } = setup({ summarizeMinIntervalMinutes: 30 });
402
+ const handler = events.find((e) => e.name === "session/event").fn;
403
+ await handler(sessionFor("ta"), { seq: 2, type: "turn/end" });
404
+ await handler(sessionFor("tb"), { seq: 2, type: "turn/end" });
405
+ assert.equal(calls.length, 2, "a different session is not throttled by another's lastRunAt");
406
+ });
407
+
408
+ test("issue#127: summarizeMaxEntriesPerRun caps the stored entries and audits parsed/capped", async () => {
409
+ const { events, store } = setup({ summarizeMaxEntriesPerRun: 1 }, {
410
+ stream: streamOf([
411
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
412
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
413
+ ])
414
+ });
415
+ const handler = events.find((e) => e.name === "session/event").fn;
416
+ await handler(sessionFor("t3"), { seq: 2, type: "turn/end" });
417
+ assert.equal(store.count(), 1, "only the first capped entry is stored");
418
+ const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
419
+ assert.equal(audit.metadata?.parsed, 2, "the raw parsed count is kept for tuning the cap");
420
+ assert.equal(audit.metadata?.capped, 1);
421
+ });
422
+
423
+ test("issue#127: title dedupe absorbs a normalized-same title", async () => {
424
+ const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
425
+ stream: streamOf([{ type: "pitfall", title: "Win7 OpenSSH 失效", content: "第二次记录", importance: 4 }])
426
+ });
427
+ // 既有条目:同会话、标题归一化后全等(大小写与空白差异)
428
+ const seeded = service.saveWithDedupe({
429
+ type: "pitfall", title: "win7 openssh 失效", content: "第一次记录", source: "session:t5"
430
+ }).memory;
431
+ const handler = events.find((e) => e.name === "session/event").fn;
432
+ await handler(sessionFor("t5"), { seq: 2, type: "turn/end" });
433
+ assert.equal(store.count(), 1, "the normalized-same title merged instead of creating a row");
434
+ assert.ok(store.getById(seeded.id).content.includes("第二次记录"), "content appended to the existing row");
435
+ });
436
+
437
+ test("issue#127: title dedupe leaves a rephrase alone (it only catches an exact same title)", async () => {
438
+ // 同一事实的另一种措辞 → title 档不命中,正常新建。这条界定了 title 档的边界,
439
+ // 避免被误当成 vector 档的替代(cos≥0.85 的簇里「归一化标题全同」为 0 个)。
440
+ const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
441
+ stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "新记录", importance: 4 }])
442
+ });
443
+ service.saveWithDedupe({
444
+ type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "旧记录", source: "session:other"
445
+ });
446
+ const handler = events.find((e) => e.name === "session/event").fn;
447
+ await handler(sessionFor("t7"), { seq: 2, type: "turn/end" });
448
+ assert.equal(store.count(), 2, "a rephrase (different title) is not merged by the title tier");
449
+ });
450
+
451
+ test("issue#127: vector dedupe merges a same-fact rephrase and keeps content_history", async () => {
452
+ const embedder = {
453
+ ready: true,
454
+ embedSingle: async (text) => (String(text).includes("OpenSSH") ? [1, 0, 0] : [0, 1, 0])
455
+ };
456
+ const { events, store, service } = setup(
457
+ { summarizeDedupeMode: "vector", summarizeDedupeMinSim: 0.92 },
458
+ { stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "重复踩坑", importance: 4 }]) }
459
+ );
460
+ service.setEmbedder(embedder);
461
+ // 同一事实的另一种措辞,已有向量 → 余弦 1.0 ≥ 0.92
462
+ const seeded = service.saveWithDedupe({
463
+ type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "第一次记录", source: "session:t8"
464
+ }).memory;
465
+ store.setEmbedding(seeded.id, [1, 0, 0]);
466
+ const handler = events.find((e) => e.name === "session/event").fn;
467
+ await handler(sessionFor("t8"), { seq: 2, type: "turn/end" });
468
+ assert.equal(store.count(), 1, "no new row: the rephrase was absorbed by cosine");
469
+ const merged = store.getById(seeded.id);
470
+ assert.ok(merged.content.includes("重复踩坑"), "content appended into the existing row");
471
+ assert.ok((merged.content_history ?? []).length >= 1, "content_history keeps the prior version");
472
+ const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
473
+ assert.equal(audit.metadata?.deduped, 1);
474
+ assert.equal(audit.metadata?.mode, "vector");
475
+ });
476
+
477
+ test("issue#127: no embedder means vector dedupe silently falls back to a normal write", async () => {
478
+ const { events, store } = setup(
479
+ { summarizeDedupeMode: "vector" },
480
+ { stream: streamOf([{ type: "pitfall", title: "无关的一条", content: "内容", importance: 4 }]) }
481
+ );
482
+ const handler = events.find((e) => e.name === "session/event").fn;
483
+ await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
484
+ assert.equal(store.count(), 1, "dedupe failure never blocks the write");
485
+ });
486
+
487
+ test("issue#127: the five summarize knobs are whitelisted and range-checked", () => {
488
+ const store = createStore(":memory:");
489
+ const settings = createSettings(store.db);
490
+ const merged = settings.setFeatureFlags({
491
+ summarizeMinIntervalMinutes: 30,
492
+ summarizeMaxEntriesPerRun: 5,
493
+ summarizeDedupeMode: "vector",
494
+ summarizeDedupeMinSim: 0.9,
495
+ summarizeDedupeWindowHours: 12
496
+ });
497
+ assert.equal(merged.summarizeMinIntervalMinutes, 30);
498
+ assert.equal(merged.summarizeDedupeMode, "vector");
499
+ assert.equal(merged.summarizeDedupeMinSim, 0.9, "float thresholds round-trip (new numbers whitelist)");
500
+ // 面板/API 写入走同一套逐键校验:越界与非法枚举必须被拒。
501
+ assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMinSim: 0.3 }), /number in \[0.5, 0.99\]/);
502
+ assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMode: "semantic" }), /one of: off, title, vector/);
503
+ store.close();
504
+ });
505
+
506
+ // ── Issue #127 补测:fail-safe 与打点回滚的边界路径 ────────────────────────
507
+
508
+ test("issue#127: a throwing audit writer never breaks the min-interval skip path", async () => {
509
+ const { events, store, service } = setup({ summarizeMinIntervalMinutes: 30 });
510
+ const handler = events.find((e) => e.name === "session/event").fn;
511
+ const session = sessionFor("t10");
512
+ await handler(session, { seq: 2, type: "turn/end" });
513
+ assert.equal(store.count(), 2);
514
+ // 审计写入器抛错:跳过路径必须吞掉异常——审计是观测手段,绝不能反过来打断蒸馏。
515
+ service.saveLlmAudit = () => { throw new Error("audit sink down"); };
516
+ await handler(session, { seq: 3, type: "turn/end" });
517
+ assert.equal(store.count(), 2, "the throttled turn stays a no-op instead of rejecting");
518
+ });
519
+
520
+ test("issue#127: an aborted run rolls the interval claim back so the next turn is admitted", async () => {
521
+ // 间隔取毫秒级小数值(schema 的粒度是 1 分钟,测试直接构造 config 绕开它),
522
+ // 这样"回滚到上一次打点"与"保留本次打点"才有可观察差异:
523
+ // 回滚 → 下一次落在 T1+gap 之外,应被接纳;
524
+ // 不滚 → 距本次打点才几毫秒,必被挡。
525
+ // 若沿用 gap=0,两种实现的对外行为完全相同,测试无法发现 else 分支写错。
526
+ let call = 0;
527
+ const { events } = setup({ summarizeMinIntervalMinutes: 0.01, distillRateLimitIntervalMs: 0 }, {
528
+ stream() {
529
+ call++;
530
+ if (call === 2) {
531
+ return (async function* () {
532
+ yield { type: "block-start", block: { type: "text" } };
533
+ yield { type: "text-delta", delta: "[]" };
534
+ yield { type: "finish", kind: "aborted" };
535
+ })();
536
+ }
537
+ return (async function* () {
538
+ yield { type: "block-start", block: { type: "text" } };
539
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "decision", title: `第${call}条`, content: "内容", importance: 3 }]) };
540
+ yield { type: "finish", kind: "ok" };
541
+ })();
542
+ }
543
+ });
544
+ const handler = events.find((e) => e.name === "session/event").fn;
545
+ const session = sessionFor("t11");
546
+ await handler(session, { seq: 2, type: "turn/end" }); // T1:成功,占用间隔
547
+ assert.equal(call, 1, "the first turn distills");
548
+ await new Promise((r) => setTimeout(r, 700)); // 越过 600ms 间隔窗
549
+ await handler(session, { seq: 3, type: "turn/end" }); // T2:aborted → 回滚到 T1
550
+ assert.equal(call, 2, "the aborted turn still reached the LLM call");
551
+ await handler(session, { seq: 4, type: "turn/end" }); // 立刻再发:只有回滚到 T1 才会被接纳
552
+ assert.equal(call, 3, "the aborted run did not consume the interval window");
553
+ });
554
+
555
+ test("issue#127: a throwing candidate lookup degrades to undefined (service fail-safe)", async () => {
556
+ const { service } = setup({ summarizeDedupeMode: "vector" });
557
+ // 候选查询在 try 内抛错 → findSessionDuplicate 吞掉并返回 undefined(不向外抛)。
558
+ const hostile = { get type() { throw new Error("store down"); }, title: "标题", content: "内容" };
559
+ const hit = await service.findSessionDuplicate(hostile, { mode: "vector", source: "session:z" });
560
+ assert.equal(hit, undefined, "a failed lookup yields no duplicate instead of throwing");
561
+ });
562
+
563
+ test("issue#127: a dedupe lookup that yields nothing still lands the entry (never dropped)", async () => {
564
+ // 走真实的 session/event 落库路径。只让去重判定不可用(service 层为何返回 undefined
565
+ // 由上一条覆盖)——蒸馏必须照常写入,不能因为拿不到去重结论就把这一条丢掉。
566
+ const { events, store, service } = setup(
567
+ { summarizeDedupeMode: "vector" },
568
+ { stream: streamOf([{ type: "decision", title: "必须落库", content: "内容", importance: 3 }]) }
569
+ );
570
+ service.findSessionDuplicate = async () => undefined;
571
+ const handler = events.find((e) => e.name === "session/event").fn;
572
+ await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
573
+ assert.equal(store.count(), 1, "the entry is stored through the normal write path");
574
+ assert.equal(store.all()[0].title, "必须落库");
575
+ });
@@ -3,9 +3,12 @@ import assert from "node:assert/strict";
3
3
  import { assertSupportedJsonSchema, validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
4
4
  import { createStore } from "../src/store.js";
5
5
  import { createService } from "../src/service.js";
6
+ import { existsSync, mkdtempSync, readdirSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
6
9
  import { createTools } from "../src/tools.js";
7
10
 
8
- function setup(embedder) {
11
+ function setup(embedder, config = {}) {
9
12
  const store = createStore(":memory:");
10
13
  const service = createService({ store, mirror: null, config: {} });
11
14
  const registered = [];
@@ -17,7 +20,7 @@ function setup(embedder) {
17
20
  }
18
21
  }
19
22
  };
20
- const tools = createTools(ctx, service, {}, embedder);
23
+ const tools = createTools(ctx, service, config, embedder);
21
24
  return { store, service, tools, registered };
22
25
  }
23
26
 
@@ -44,15 +47,15 @@ function walkSchema(node, path, problems) {
44
47
  }
45
48
  }
46
49
 
47
- test("registers eight tools with correct names", () => {
50
+ test("registers nine tools with correct names", () => {
48
51
  const { registered } = setup();
49
52
  const names = registered.map((t) => t.name).sort();
50
- assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_save", "memory_search", "memory_update"]);
53
+ assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_runtime", "memory_save", "memory_search", "memory_update"]);
51
54
  });
52
55
 
53
56
  test("compiled schemas pass the enforced DSH subset (defineTool projection)", () => {
54
57
  const { registered } = setup();
55
- assert.equal(registered.length, 8);
58
+ assert.equal(registered.length, 9);
56
59
  for (const tool of registered) {
57
60
  assertSupportedJsonSchema(tool.parameters);
58
61
  assertSupportedJsonSchema(tool.output.schema);
@@ -314,3 +317,25 @@ test("memory_archive on missing id rejects", async () => {
314
317
  const archive = registered.find((t) => t.name === "memory_archive");
315
318
  await assert.rejects(() => archive.execute({ id: "missing" }), /memory not found/);
316
319
  });
320
+
321
+ test("memory_runtime:status 只读且带代价说明;两条来源都不可用时如实回报(不联网)", async () => {
322
+ const runtimeDir = mkdtempSync(join(tmpdir(), "mneme-tool-rt-"));
323
+ // 与 api 路由测试同一做法:镜像钉到必然拒绝连接的本地端口,避免测试真的去 registry 拉整套闭包。
324
+ const { registered } = setup(undefined, { runtimeDir, runtimeMirror: "http://127.0.0.1:1/" });
325
+ const tool = registered.find((t) => t.name === "memory_runtime");
326
+ assert.ok(tool, "应当注册 memory_runtime");
327
+
328
+ // status 是 agent 的入口:只读,而且必须带上代价,否则 agent 没法先告诉用户再动手。
329
+ const status = await tool.execute({ action: "status" });
330
+ assert.deepEqual(validateJsonSchemaValue(tool.output.schema, status), []);
331
+ assert.equal(status.status, "missing", "临时目录里没有 payload");
332
+ assert.match(status.cost, /hundreds of MB/, "代价说明要带上体积量级(刻意不写死具体数值:各平台不同、也会漂移)");
333
+ assert.ok(status.summary.length > 0);
334
+
335
+ // provision:仓库布局下推导出的源不是 node_modules,下载又被钉死 —— 如实回报,不抛异常。
336
+ const provision = await tool.execute({ action: "provision" });
337
+ assert.deepEqual(validateJsonSchemaValue(tool.output.schema, provision), []);
338
+ assert.equal(provision.status, "failed");
339
+ assert.match(provision.reason, /收编失败|下载失败/);
340
+ assert.equal(existsSync(runtimeDir) ? readdirSync(runtimeDir).length : 0, 0, "失败后不该留下半份 payload");
341
+ });
@@ -4,7 +4,7 @@ import { Context } from "@deepseek-ai/cordis";
4
4
  import { mkdtempSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import * as mneme from "../lib/index.js";
7
+ import * as mneme from "../src/index.js";
8
8
 
9
9
  // webServer 可选依赖回归(v0.7.24 修复):v0.7.23 曾把 webServer 从 inject 声明
10
10
  // 中去掉想支持 headless,但 apply 里 `if (ctx.webServer)` 在 cordis 4 的 Proxy
@@ -47,3 +47,36 @@ test("headless host (no webServer): plugin must not throw", async () => {
47
47
  await fiber;
48
48
  assert.ok(true, "headless load must not throw");
49
49
  });
50
+
51
+ /** Boot the plugin with ctx.logger.warn captured. */
52
+ async function bootCapturingWarnings(config) {
53
+ const { ctx } = buildHost(true);
54
+ const warnings = [];
55
+ const original = ctx.logger.warn.bind(ctx.logger);
56
+ ctx.logger.warn = (...args) => { warnings.push(args.join(" ")); original(...args); };
57
+ await ctx.plugin(mneme, config);
58
+ return warnings;
59
+ }
60
+
61
+ // issue #135:向量层未配置时启动必须留下痕迹。此前 legacy OpenAI embedder 恒报
62
+ // ready=true,于是向量层「绿的但全哑」可以静默存在很久 —— 本机这样过了数周,
63
+ // 语义召回、语义去重、rerank、sleep 冲突检测全部失效而面板一切正常。
64
+ // embedProvider 默认就是 "openai",所以「默认配置 + 空 vector-config」必现。
65
+ test("unconfigured vector layer warns once at boot (issue #135)", async () => {
66
+ const warnings = await bootCapturingWarnings({ memoryDir: mkdtempSync(join(tmpdir(), "mneme-warn-")) });
67
+ const vectorWarnings = warnings.filter((w) => w.includes("向量层未配置"));
68
+ assert.equal(vectorWarnings.length, 1, "exactly one warning, not one per use:\n" + warnings.join("\n"));
69
+ // 告警必须点名实际受影响的能力,否则用户不知道该去修什么。
70
+ for (const feature of ["语义召回", "rerank", "sleep", "dream"]) {
71
+ assert.ok(vectorWarnings[0].includes(feature), `warning must name ${feature}: ${vectorWarnings[0]}`);
72
+ }
73
+ });
74
+
75
+ // 轻量模式是有意的选择、不是配置错误:不能刷这条告警(整条向量管线本就不装配)。
76
+ test("light mode does not warn about the vector layer (issue #135)", async () => {
77
+ const warnings = await bootCapturingWarnings({
78
+ memoryDir: mkdtempSync(join(tmpdir(), "mneme-light-")),
79
+ lightMode: true
80
+ });
81
+ assert.deepEqual(warnings.filter((w) => w.includes("向量层未配置")), []);
82
+ });