@modusensus/dsh-mneme 0.7.28 → 0.7.29

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/src/service.js CHANGED
@@ -416,6 +416,171 @@ export function createService({ store, mirror, config, onWrite, logger }) {
416
416
  }
417
417
  }
418
418
 
419
+ /**
420
+ * Recall fusion (plan #1). Turns the three ranked signal lists (keyword,
421
+ * vector, BM25) into a single merged list. Three recipes, selected by
422
+ * config.recallFusion:
423
+ * - blend (default): legacy behavior — weighted sum for vector/hybrid, union
424
+ * backfill for auto. Byte-identical to pre-fusion code, so enabling the
425
+ * config never regresses anybody.
426
+ * - rrf: Reciprocal Rank Fusion — Σ 1/(k + rank + 1) over each list a row
427
+ * appears in. Rank-based, so the unit mismatch (raw cosine vs keyword
428
+ * score vs normalized IDF) is irrelevant.
429
+ * - minmax: min-max normalize each source list's scores to [0,1] then take
430
+ * the weighted sum — a scale-aware version of `blend`.
431
+ * Returns { merged, signals }, where signals is Map<id, {keyword, vector,
432
+ * bm25}> so searchMemories can decorate rows when signalTransparency is on.
433
+ */
434
+ function fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb }) {
435
+ const recipe = config?.recallFusion ?? "blend";
436
+
437
+ // Per-source scores are recorded for every recipe so signalTransparency
438
+ // works regardless of how the ranking was produced.
439
+ const signals = new Map();
440
+ const addSig = (id, field, sc) => {
441
+ const cur = signals.get(id) ?? {};
442
+ cur[field] = sc;
443
+ signals.set(id, cur);
444
+ };
445
+ for (const m of keyword) addSig(m.id, "keyword", m.score ?? 0);
446
+ for (const m of vector) addSig(m.id, "vector", m.score ?? 0);
447
+ for (const m of bm25) addSig(m.id, "bm25", m.score ?? 0);
448
+
449
+ const vectorIds = new Set(vector.map((m) => m.id));
450
+ const keywordIds = new Set(keyword.map((m) => m.id));
451
+
452
+ // Mode contract (aligns rrf/minmax with blend): keyword-only searches must
453
+ // stay keyword-only regardless of recipe, so enabling an opt-in recipe can
454
+ // never pull vector/BM25 rows into a mode="keyword" request. This mirrors
455
+ // the blend branch's `mode === "keyword"` short-circuit (byte-for-byte).
456
+ if (mode === "keyword") {
457
+ return { merged: keyword.slice(0, lim), signals };
458
+ }
459
+
460
+ let merged;
461
+ if (recipe === "rrf") {
462
+ // Rank-based: only the position of a row inside each surviving source
463
+ // list matters, so no cross-signal scale calibration is needed.
464
+ const k = 60; // standard RRF constant (plan #1 documents k=60)
465
+ const rows = new Map();
466
+ const addList = (list) => list.forEach((m, idx) => {
467
+ const s = 1 / (k + idx + 1);
468
+ const cur = rows.get(m.id);
469
+ if (cur) cur.score += s;
470
+ else rows.set(m.id, { ...m, score: s });
471
+ });
472
+ addList(keyword);
473
+ addList(vector);
474
+ addList(bm25);
475
+ merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
476
+ } else if (recipe === "minmax") {
477
+ // Scale-aware weighted sum: each source list is min-max normalized to
478
+ // [0,1] before blending, so raw cosine and keyword score live on the
479
+ // same footing.
480
+ const norm = (list) => {
481
+ if (!list.length) return new Map();
482
+ let min = Infinity, max = -Infinity;
483
+ for (const m of list) { const s = m.score ?? 0; if (s < min) min = s; if (s > max) max = s; }
484
+ const range = max - min;
485
+ const out = new Map();
486
+ for (const m of list) out.set(m.id, range > 0 ? ((m.score ?? 0) - min) / range : 0.5);
487
+ return out;
488
+ };
489
+ const kw = norm(keyword), ve = norm(vector), bm = norm(bm25);
490
+ const rows = new Map();
491
+ const seed = (m) => { if (!rows.has(m.id)) rows.set(m.id, { ...m, score: 0 }); };
492
+ for (const m of keyword) seed(m);
493
+ for (const m of vector) seed(m);
494
+ for (const m of bm25) seed(m);
495
+ for (const [id, row] of rows) {
496
+ const k = kw.get(id) ?? 0;
497
+ const v = ve.get(id) ?? 0;
498
+ const b = bm.get(id) ?? 0;
499
+ row.score = v * wv + k * wk + b * wb;
500
+ }
501
+ merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
502
+ } else {
503
+ // blend — the pre-existing per-mode behavior, extracted verbatim.
504
+ if (mode === "keyword") {
505
+ merged = keyword;
506
+ } else if (mode === "vector" || mode === "hybrid") {
507
+ const byId = new Map();
508
+ for (const m of vector) {
509
+ const rec = byId.get(m.id);
510
+ byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
511
+ }
512
+ for (const m of keyword) {
513
+ const rec = byId.get(m.id);
514
+ if (rec) {
515
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
516
+ } else {
517
+ byId.set(m.id, m);
518
+ }
519
+ }
520
+ for (const m of bm25) {
521
+ const rec = byId.get(m.id);
522
+ if (rec) {
523
+ if (keywordIds.has(m.id)) continue;
524
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
525
+ } else {
526
+ byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
527
+ }
528
+ }
529
+ const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
530
+ merged = ranked.slice(0, lim);
531
+ if (merged.length < lim && !merged.length) {
532
+ merged = keyword.slice(0, lim);
533
+ }
534
+ } else {
535
+ // auto: keyword leads, vector + BM25 fill remaining slots.
536
+ merged = keyword.slice(0, lim);
537
+ const seen = new Set(merged.map((m) => m.id));
538
+ for (const m of vector) {
539
+ if (merged.length >= lim) break;
540
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
541
+ }
542
+ for (const m of bm25) {
543
+ if (merged.length >= lim) break;
544
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
545
+ }
546
+ }
547
+ }
548
+
549
+ // Mode contract, auto (aligns rrf/minmax with blend): keyword leads, the
550
+ // recipe fills the remaining slots. blend.auto already front-loads keyword;
551
+ // rrf/minmax rank across sources, so re-apply the same "keyword first"
552
+ // ordering here to preserve the pre-fusion auto contract — the keyword
553
+ // hit list keeps its power, and only slots it couldn't fill go to the
554
+ // recipe's ranking.
555
+ if (recipe !== "blend" && mode === "auto" && keyword.length) {
556
+ const head = keyword.slice(0, lim);
557
+ const seen = new Set(head.map((m) => m.id));
558
+ const tail = merged.filter((m) => !seen.has(m.id)).slice(0, Math.max(0, lim - head.length));
559
+ merged = head.concat(tail);
560
+ }
561
+
562
+ return { merged, signals };
563
+ }
564
+
565
+ /**
566
+ * Search memories for a query. Merges up to three recall sources (keyword,
567
+ * vector, BM25) according to config.recallFusion (blend/rrf/minmax — see
568
+ * fuseRecall), then optionally decorates rows with per-source signals
569
+ * (config.signalTransparency), applies semantic dedup (non-keyword modes),
570
+ * reranking, and epistemic trust re-weighting, and finally hands the merged
571
+ * list to the recall-layer recorder.
572
+ *
573
+ * options:
574
+ * mode — 'auto' (default) | 'keyword' | 'vector' | 'hybrid'
575
+ * topK — max rows (default 20)
576
+ * threshold — explicit vector score floor (overrides adaptive)
577
+ * useRerank — apply the reranker if available (default true)
578
+ * recordRecall — write a recall_runs audit row (default from config)
579
+ *
580
+ * Returns an array of memory rows { id, title, content, score, source, ... },
581
+ * with `signals` added when config.signalTransparency is on. Never throws:
582
+ * a vector/rerank failure degrades to keyword results.
583
+ */
419
584
  async function searchMemories(query, options = {}) {
420
585
  const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true) } = options;
421
586
  const q = String(query ?? "").trim();
@@ -477,69 +642,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
477
642
  // Loose blend weight: BM25 confirms and backfills, never dominates the
478
643
  // semantic signal. Same-memory overlap boosts, unseen ids backfill.
479
644
  const wb = 0.3;
480
- // Path bookkeeping for the boost rule below: which ids each semantic
481
- // recall path surfaced.
482
- const vectorIds = new Set(vector.map((m) => m.id));
483
- const keywordIds = new Set(keyword.map((m) => m.id));
484
645
 
485
646
  // Hybrid blending weights from config when provided.
486
647
  const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
487
648
  const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
488
649
 
489
- let merged;
490
- if (mode === "keyword") {
491
- merged = keyword;
492
- } else if (mode === "vector" || mode === "hybrid") {
493
- // semantic-first: vector recalls lead, keyword + BM25 fill remaining
494
- // slots. Weighted blend when sides scored the same memory; otherwise
495
- // vector order leads (it is the semantic signal), lexical paths
496
- // backfill.
497
- const byId = new Map();
498
- for (const m of vector) {
499
- const rec = byId.get(m.id);
500
- byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
501
- }
502
- for (const m of keyword) {
503
- const rec = byId.get(m.id);
504
- if (rec) {
505
- // Same memory from both sides: blend the scores.
506
- byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
507
- } else {
508
- byId.set(m.id, m);
509
- }
510
- }
511
- for (const m of bm25) {
512
- const rec = byId.get(m.id);
513
- if (rec) {
514
- // Boost rule: a row the LIKE keyword path already hit carries the
515
- // query as a substring, so BM25 tokens are trivially present —
516
- // boosting it double-counts lexical evidence. Only vector-recalled
517
- // rows (lexical hit is genuinely new information) get the boost.
518
- if (keywordIds.has(m.id)) continue;
519
- byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
520
- } else {
521
- byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
522
- }
523
- }
524
- const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
525
- merged = ranked.slice(0, lim);
526
- if (merged.length < lim && !merged.length) {
527
- // Vector unavailable entirely: fall back to plain keyword.
528
- merged = keyword.slice(0, lim);
529
- }
530
- } else {
531
- // auto: keyword leads, vector + BM25 fill remaining slots (legacy
532
- // behavior, extended with the third path)
533
- merged = keyword.slice(0, lim);
534
- const seen = new Set(merged.map((m) => m.id));
535
- for (const m of vector) {
536
- if (merged.length >= lim) break;
537
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
538
- }
539
- for (const m of bm25) {
540
- if (merged.length >= lim) break;
541
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
542
- }
650
+ const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb });
651
+ let merged = fusedMerged;
652
+ // Signal transparency (#2): decorate each returned row with its per-source
653
+ // scores and the final fused score. Purely additive never changes rank.
654
+ if (config?.signalTransparency === true) {
655
+ merged = merged.map((m) => ({ ...m, signals: { ...(signals.get(m.id) ?? {}), final: m.score ?? 0 } }));
543
656
  }
544
657
 
545
658
  // Search-time semantic dedup (v0.5.0 2.3): near-duplicate rows are
package/src/settings.js CHANGED
@@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [
58
58
  "bm25SearchEnabled",
59
59
  "conflictFreezeEnabled",
60
60
  "trustEpistemicWeighting",
61
+ // Plan #2: attach per-source {keyword, vector, bm25, final} signals to each
62
+ // search result for transparency/debugging. Default off, purely decorative.
63
+ "signalTransparency",
61
64
  // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
62
65
  "dreamSkipInvalid",
63
66
  "allowCrossTypeMerge",
@@ -86,6 +89,10 @@ const FEATURE_FLAG_STRINGS = [
86
89
  // /llm-providers 端点一起提供,留空 = 用巩固模型或当前模型。
87
90
  "sleepProvider",
88
91
  "sleepModel",
92
+ // 实体抽取侧专用路由(issue #109):provider/model 显式指定,
93
+ // 留空 = 用当前默认模型。
94
+ "entityExtractionProvider",
95
+ "entityExtractionModel",
89
96
  "localEmbedModel",
90
97
  "ollamaModel"
91
98
  ];
@@ -94,7 +101,12 @@ const FEATURE_FLAG_STRINGS = [
94
101
  const FEATURE_FLAG_URLS = ["ollamaBaseUrl"];
95
102
  // 枚举开关(与 config.js 的 z.union(z.const(...)) 对齐):仅允许列出的值。
96
103
  const FEATURE_FLAG_ENUMS = {
97
- embedProvider: ["openai", "local", "ollama"]
104
+ embedProvider: ["openai", "local", "ollama"],
105
+ // Plan #1: recall fusion recipe. blend = legacy (default); rrf / minmax are
106
+ // rank/scale-aware alternatives selected by the panel.
107
+ recallFusion: ["blend", "rrf", "minmax"],
108
+ // 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
109
+ entityExtractionReasoning: ["low", "medium", "high", "none"]
98
110
  };
99
111
  const FEATURE_FLAG_STRING_MAX = 200;
100
112
 
package/test/api.test.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { EventEmitter } from "node:events";
4
+ import { readFileSync } from "node:fs";
4
5
  import { createStore } from "../src/store.js";
5
6
  import { createService } from "../src/service.js";
6
7
  import { createApi } from "../src/api.js";
@@ -517,6 +518,20 @@ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder return
517
518
  assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
518
519
  });
519
520
 
521
+ // --- /info(反馈预填的插件版本)-----------------------------------------------
522
+
523
+ test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
524
+ const { routes } = setup(undefined);
525
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
526
+ const res = new FakeRes();
527
+ await route.handler(req("/api/dsh-mneme/info"), res);
528
+ assert.equal(res.statusCode, 200);
529
+ const data = JSON.parse(res.body);
530
+ // 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
531
+ const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
532
+ assert.equal(data.version, expected);
533
+ });
534
+
520
535
  // --- feature flags(/features:overrides + effective)------------------------
521
536
 
522
537
  test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
@@ -527,12 +542,15 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
527
542
  assert.equal(res.statusCode, 200);
528
543
  const data = JSON.parse(res.body);
529
544
  assert.deepEqual(data.overrides, {});
530
- // effective 覆盖全部 37 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
545
+ // effective 覆盖全部 42 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
531
546
  // dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
532
- // dreamMaxTokens 与本轮睡眠路由 sleepProvider/sleepModel),未覆盖时取
533
- // bundle 配置的解析默认值;dreamProvider/dreamModel schema 默认值
534
- // (Config({}) 解析为 undefined),不编造给前端 → 37 - 2 = 35
535
- assert.equal(Object.keys(data.effective).length, 35);
547
+ // dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
548
+ // recallFusion/signalTransparency,以及 issue #109 新增的实体抽取路由
549
+ // entityExtractionProvider/entityExtractionModel/entityExtractionReasoning),
550
+ // 未覆盖时取 bundle 配置的解析默认值;
551
+ // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
552
+ // 不编造给前端 → 42 - 2 = 40
553
+ assert.equal(Object.keys(data.effective).length, 40);
536
554
  assert.equal(data.effective.dreamSkipInvalid, true);
537
555
  assert.equal(data.effective.allowCrossTypeMerge, false);
538
556
  assert.equal(data.effective.dreamMinIntervalMinutes, 0);
@@ -555,6 +573,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
555
573
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
556
574
  assert.equal(data.effective.ollamaModel, "nomic-embed-text");
557
575
  assert.equal(data.effective.embedProvider, "openai");
576
+ // PR1:融合配方枚举 + 信号透明布尔(均有默认值,故计入 effective 计数)
577
+ assert.equal(data.effective.recallFusion, "blend");
578
+ assert.equal(data.effective.signalTransparency, false);
579
+ // issue #109:实体抽取路由三键(均有默认值,故计入 effective 计数)
580
+ assert.equal(data.effective.entityExtractionProvider, "");
581
+ assert.equal(data.effective.entityExtractionModel, "");
582
+ assert.equal(data.effective.entityExtractionReasoning, "none");
558
583
  });
559
584
 
560
585
  test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { runBenchmark, TEST_CASES } from "../scripts/benchmark-recall.js";
3
+ import { runBenchmark, runFusionBenchmark, seedService, TEST_CASES } from "../scripts/benchmark-recall.js";
4
4
 
5
5
  // The benchmark harness must stay a working evaluation: it runs the real
6
6
  // searchMemories pipeline over the seeded store and the fused configuration
@@ -33,3 +33,62 @@ test("test cases cover the scattered-term BM25 territory", () => {
33
33
  assert.ok(tc.query && tc.expected.length > 0);
34
34
  }
35
35
  });
36
+
37
+ // --- PR1: recall fusion recipes + signal transparency ---------------------
38
+
39
+ test("fusion-recipe A/B runs blend/rrf/minmax with valid recall on the seed corpus", async () => {
40
+ const report = await runFusionBenchmark({ topK: 5 });
41
+ assert.deepEqual(report.runs.map((r) => r.config), ["blend", "rrf", "minmax"]);
42
+ for (const run of report.runs) {
43
+ assert.equal(run.rows.length, TEST_CASES.length, `${run.config} covers every query`);
44
+ assert.ok(run.recallAtK >= 0 && run.recallAtK <= 1, `${run.config} recallAtK in [0,1]`);
45
+ assert.ok(run.avgMrr >= 0 && run.avgMrr <= 1, `${run.config} avgMrr in [0,1]`);
46
+ }
47
+ });
48
+
49
+ test("signalTransparency decorates rows with per-source signals", async () => {
50
+ const svc = seedService({ recallFusion: "blend", signalTransparency: true });
51
+ const rows = await svc.searchMemories("rust 异步", { mode: "auto", topK: 5, useRerank: false });
52
+ assert.ok(rows.length > 0, "the seed corpus returns hits for a scattered-term query");
53
+ for (const r of rows) {
54
+ assert.equal(typeof r.signals, "object", "each row carries a signals object");
55
+ assert.equal(typeof r.signals.final, "number", "signals carries the fused final score");
56
+ assert.ok([
57
+ "keyword" in r.signals,
58
+ "vector" in r.signals,
59
+ "bm25" in r.signals
60
+ ].some(Boolean), "at least one source signal is present");
61
+ }
62
+ });
63
+
64
+ test("recallFusion recipes produce distinct fused scores for the same memory", async () => {
65
+ const scores = {};
66
+ for (const recipe of ["blend", "rrf", "minmax"]) {
67
+ const svc = seedService({ recallFusion: recipe });
68
+ const rows = await svc.searchMemories("rust 异步", { mode: "auto", topK: 5, useRerank: false });
69
+ const hit = rows.find((r) => r.id === "mem_async_pattern") ?? rows[0];
70
+ assert.ok(hit, `${recipe} surfaces a hit`);
71
+ scores[recipe] = hit.score ?? 0;
72
+ }
73
+ // RRF is rank-based (Σ 1/(k+rank+1)) and minmax normalizes before blending,
74
+ // so they must not all collapse onto the same numeric score.
75
+ assert.ok(new Set(Object.values(scores)).size > 1, `recipes score differently: ${JSON.stringify(scores)}`);
76
+ });
77
+
78
+ // PR1 + CodeRabbit: an opt-in recipe must never change keyword-only behavior.
79
+ // mode="keyword" is the documented text-only path; regardless of recipe, the
80
+ // result must contain only keyword-sourced rows — no vector/BM25 bleed-in.
81
+ // (Note: on a scattered-CJK query the keyword source can itself be empty, in
82
+ // which case auto correctly falls back to BM25 — that is the pre-fusion blend
83
+ // behavior and is NOT a regression. Only mode="keyword" is a hard text path.)
84
+ test("opt-in recipes keep mode=keyword keyword-only (no vector/BM25 bleed)", async () => {
85
+ for (const recipe of ["blend", "rrf", "minmax"]) {
86
+ const svc = seedService({ recallFusion: recipe });
87
+ const rows = await svc.searchMemories("rust 异步", { mode: "keyword", topK: 5, useRerank: false });
88
+ const keywordOnly = rows.every((r) => r.source === "keyword");
89
+ assert.ok(
90
+ keywordOnly,
91
+ `${recipe} under mode=keyword must keep only keyword rows; got sources: ${[...new Set(rows.map((r) => r.source))].join(",")}`
92
+ );
93
+ }
94
+ });
@@ -554,3 +554,42 @@ test("consolidation/sleep model routing: provider dropdowns from /llm-providers,
554
554
  "the route selects must share the string-input width budget"
555
555
  );
556
556
  });
557
+
558
+ // 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
559
+ // (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
560
+ // /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
561
+ test("settings feedback card: prefilled issue + mailto + browse, version from /info", () => {
562
+ // 1. 版本预填端点
563
+ assert.ok(
564
+ clientSource.includes('apiFetch("/api/dsh-mneme/info")'),
565
+ "the feedback card must fetch the plugin version from /info"
566
+ );
567
+ assert.ok(
568
+ clientSource.includes("setPkgVersion"),
569
+ "the fetched version must land in component state"
570
+ );
571
+ // 2. GitHub 新建 issue:issues/new?title=&body= 预填环境信息(当前仓库无模板)
572
+ assert.ok(
573
+ clientSource.includes("https://github.com/modusensus/dsh-mneme/issues/new?title="),
574
+ "the issue link must prefill title+body on issues/new"
575
+ );
576
+ assert.ok(
577
+ clientSource.includes("**插件版本**") && clientSource.includes("**平台**"),
578
+ "the prefill body must carry plugin version and platform"
579
+ );
580
+ // 3. 邮件反馈:官方邮箱(对外不写个人邮箱),mailto 预填 subject+body
581
+ assert.ok(
582
+ clientSource.includes("mailto:work@modusensus.space?subject="),
583
+ "the mailto link must point at the public support address"
584
+ );
585
+ // 4. 浏览已知问题:跳仓库 issues 列表页(去重前置步骤)
586
+ assert.ok(
587
+ clientSource.includes('href: "https://github.com/modusensus/dsh-mneme/issues"'),
588
+ "the browse link must open the repo issues list"
589
+ );
590
+ // 5. 双语 i18n
591
+ for (const key of ["feedback.title", "feedback.newIssue", "feedback.email", "feedback.browse", "feedback.hint"]) {
592
+ const occurrences = clientSource.split(`"memory.settings.${key}"`).length - 1;
593
+ assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
594
+ }
595
+ });
@@ -520,3 +520,28 @@ test("extractor fails safe when callLLM rejects → {ok:false}", async () => {
520
520
  assert.ok(result.error);
521
521
  store.close();
522
522
  });
523
+
524
+ // --- issue #109: provider/model override + reasoning effort pass-through -----
525
+
526
+ test("extractor passes provider/model/reasoningEffort through to callLLM options", async () => {
527
+ const store = openStore();
528
+ let captured = null;
529
+ const callLLM = async (_messages, options) => {
530
+ captured = options;
531
+ return JSON.stringify({ entities: [{ name: "Vite", type: "technology", attrs: [] }], relations: [] });
532
+ };
533
+ const config = { entityExtractionProvider: "openai", entityExtractionModel: "gpt-x", entityExtractionReasoning: "high" };
534
+ const result = await extractEntities({ id: "m1", content: "Vite 是前端构建工具" }, { store, config, callLLM });
535
+ assert.equal(result.ok, true);
536
+ assert.deepEqual(captured, { provider: "openai", model: "gpt-x", reasoningEffort: "high" });
537
+ store.close();
538
+ });
539
+
540
+ test("extractor omits empty provider/model and 'none' reasoning from options", async () => {
541
+ const store = openStore();
542
+ let captured = "unset";
543
+ const callLLM = async (_m, options) => { captured = options; return JSON.stringify({ entities: [], relations: [] }); };
544
+ await extractEntities({ id: "m2", content: "空文本" }, { store, config: {}, callLLM });
545
+ assert.deepEqual(captured, {}, "no provider/model/reasoning keys when unset");
546
+ store.close();
547
+ });
@@ -13,6 +13,7 @@ import { runSleep } from "../src/dream/sleep.js";
13
13
  import { createStore } from "../src/store.js";
14
14
  import { createService } from "../src/service.js";
15
15
  import { createVectorIndex } from "../src/vector-index.js";
16
+ import { createEntityStreamAdapter } from "../src/index.js";
16
17
 
17
18
  const embedder = {
18
19
  embedSingle: async () => [1, 0, 0],
@@ -576,3 +577,79 @@ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async
576
577
  }
577
578
  store.close();
578
579
  });
580
+
581
+ // ------------------------------------------------- entity extraction adapter (issue #108/#109)
582
+ // The streamEntityText adapter lives in index.js; it maps the extractor's
583
+ // options onto a dsh-llm stream route and retries once without the effort
584
+ // when the first attempt is rejected. These tests drive the real exported
585
+ // factory, not a mock of it.
586
+
587
+ test("issue#109: entity extraction effort rejection retries once without the effort", async () => {
588
+ const calls = [];
589
+ const warnings = [];
590
+ const streamEntityText = createEntityStreamAdapter({
591
+ llm: {
592
+ async *stream(options) {
593
+ calls.push(options);
594
+ // First attempt carries the effort: the provider rejects it via an
595
+ // error finish chunk (the realistic stream-level rejection).
596
+ if (options.reasoningEffort) {
597
+ yield { type: "finish", reason: { kind: "error", message: "UNSUPPORTED_REASONING_EFFORT" } };
598
+ return;
599
+ }
600
+ yield { type: "text-delta", index: 0, text: "{\"entities\":[{\"name\":\"张三\",\"type\":\"person\",\"attrs\":[{\"key\":\"职业\",\"value\":\"工程师\",\"confidence\":0.9}]}],\"relations\":[]}" };
601
+ yield { type: "finish", reason: { kind: "stop" } };
602
+ }
603
+ },
604
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
605
+ logger: { warn: (m) => warnings.push(String(m)) }
606
+ });
607
+ const text = await streamEntityText(
608
+ [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
609
+ { reasoningEffort: "low" }
610
+ );
611
+ assert.equal(calls.length, 2, "attempt (rejected) + retry without effort");
612
+ assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
613
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
614
+ assert.equal(calls[0].provider, "mock", "route resolved from agentDefaultModel");
615
+ assert.equal(calls[0].model, "mock-model", "route model from agentDefaultModel");
616
+ assert.equal(calls[0].maxTokens, 4096, "extraction caps its output");
617
+ assert.ok(text.includes("张三"), "retry stream text is returned");
618
+ assert.ok(warnings.some((w) => w.includes("rejected, retrying without it")), "rejection is logged");
619
+ });
620
+
621
+ test("issue#109: entity extraction never retries blindly without an effort configured", async () => {
622
+ const calls = [];
623
+ const streamEntityText = createEntityStreamAdapter({
624
+ llm: {
625
+ async *stream(options) {
626
+ calls.push(options);
627
+ yield { type: "finish", reason: { kind: "error", message: "overloaded" } };
628
+ }
629
+ },
630
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
631
+ logger: { warn: () => {} }
632
+ });
633
+ const text = await streamEntityText([{ role: "user", content: [] }], {});
634
+ assert.equal(text, undefined, "failure yields no text");
635
+ assert.equal(calls.length, 1, "no blind retry when no effort was requested");
636
+ });
637
+
638
+ test("issue#109: entity extraction explicit provider/model win over the default route", async () => {
639
+ const calls = [];
640
+ const streamEntityText = createEntityStreamAdapter({
641
+ llm: {
642
+ async *stream(options) {
643
+ calls.push(options);
644
+ yield { type: "text-delta", index: 0, text: "{}" };
645
+ yield { type: "finish", reason: { kind: "stop" } };
646
+ }
647
+ },
648
+ agentDefaultModel: { currentSelection: () => ({ provider: "default", model: "default-model" }) },
649
+ logger: { warn: () => {} }
650
+ });
651
+ await streamEntityText([{ role: "user", content: [] }], { provider: "volcano", model: "deepseek-v3" });
652
+ assert.equal(calls.length, 1);
653
+ assert.equal(calls[0].provider, "volcano", "explicit provider beats the default");
654
+ assert.equal(calls[0].model, "deepseek-v3", "explicit model beats the default");
655
+ });