@modusensus/dsh-mneme 0.8.4 → 0.8.6

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 (75) hide show
  1. package/README.md +45 -8
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +106 -5
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +128 -12
  9. package/lib/dream.js +46 -11
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/index.js +65 -3
  13. package/lib/inject.js +91 -15
  14. package/lib/lang.js +6 -0
  15. package/lib/local-embedder.js +44 -6
  16. package/lib/organize.js +450 -0
  17. package/lib/recall-stats.js +23 -1
  18. package/lib/runtime/layout.js +30 -0
  19. package/lib/runtime/loader.js +4 -1
  20. package/lib/runtime/verify.js +19 -6
  21. package/lib/search/adaptive.js +38 -0
  22. package/lib/service.js +165 -12
  23. package/lib/settings.js +35 -4
  24. package/lib/store.js +126 -8
  25. package/lib/summarize.js +253 -8
  26. package/lib/tools.js +136 -8
  27. package/package.json +6 -6
  28. package/runtime-manifest.json +104 -174
  29. package/scripts/build-runtime-manifest.mjs +8 -1
  30. package/scripts/sync-test-badge.mjs +18 -12
  31. package/src/api-standalone.js +11 -0
  32. package/src/api.js +21 -0
  33. package/src/config.js +106 -5
  34. package/src/document.js +221 -0
  35. package/src/dream/sleep.js +128 -12
  36. package/src/dream.js +46 -11
  37. package/src/guide.js +44 -0
  38. package/src/heat.js +2 -0
  39. package/src/index.js +65 -3
  40. package/src/inject.js +91 -15
  41. package/src/lang.js +6 -0
  42. package/src/local-embedder.js +44 -6
  43. package/src/organize.js +450 -0
  44. package/src/recall-stats.js +23 -1
  45. package/src/runtime/layout.js +30 -0
  46. package/src/runtime/loader.js +4 -1
  47. package/src/runtime/verify.js +19 -6
  48. package/src/search/adaptive.js +38 -0
  49. package/src/service.js +165 -12
  50. package/src/settings.js +35 -4
  51. package/src/store.js +126 -8
  52. package/src/summarize.js +253 -8
  53. package/src/tools.js +136 -8
  54. package/test/adaptive-inject-budget.test.js +85 -0
  55. package/test/api.test.js +66 -3
  56. package/test/client.test.js +63 -2
  57. package/test/document.test.js +523 -0
  58. package/test/dream.test.js +51 -0
  59. package/test/inject-content-cap.test.js +5 -3
  60. package/test/inject-pools.test.js +170 -0
  61. package/test/inject-recall.test.js +105 -0
  62. package/test/inject.test.js +35 -1
  63. package/test/lastrunat-persist.test.js +101 -0
  64. package/test/llm-audit.test.js +247 -0
  65. package/test/local-embedder.test.js +45 -2
  66. package/test/organize.test.js +418 -0
  67. package/test/recall-stats.test.js +7 -1
  68. package/test/runtime-loader.test.js +18 -0
  69. package/test/runtime-manifest.test.js +2 -1
  70. package/test/runtime-verify.test.js +65 -0
  71. package/test/sleep.test.js +154 -0
  72. package/test/store.test.js +13 -0
  73. package/test/summarize.test.js +407 -3
  74. package/test/tools-dto-schema-matrix.test.js +325 -0
  75. package/test/tools.test.js +36 -3
package/src/tools.js CHANGED
@@ -5,6 +5,7 @@ import { defaultRuntimeDir } from "./runtime/layout.js";
5
5
  import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
6
6
  import { matchesPlatform } from "./runtime/closure.js";
7
7
  import { verifyPayload } from "./runtime/verify.js";
8
+ import { TOOL_GUIDE } from "./guide.js";
8
9
 
9
10
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
10
11
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -35,6 +36,8 @@ const MEMORY_ITEM_SCHEMA = {
35
36
  scope_decided_at: { type: "string" },
36
37
  sensitivity: { type: "string" },
37
38
  occurred_at: { type: "string" },
39
+ // #230:document 指针行的文件定位——「全文按需读」的入口;普通行缺省。
40
+ doc_path: { type: "string" },
38
41
  created_at: { type: "string", required: true },
39
42
  updated_at: { type: "string", required: true }
40
43
  }
@@ -81,6 +84,11 @@ export function createTools(ctx, service, config, embedder) {
81
84
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
82
85
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
83
86
  const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
87
+ // #249 第一批:能力说明(injectGuidanceEnabled)开启时,给「何时用」最有歧义
88
+ // 的两个工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入
89
+ // 成本;关闭时描述逐字节不变,也不动其他工具的文案。
90
+ const withToolGuide = (name, description) =>
91
+ config?.injectGuidanceEnabled === true && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description;
84
92
 
85
93
  // 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按
86
94
  // 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope
@@ -104,11 +112,16 @@ export function createTools(ctx, service, config, embedder) {
104
112
  const tools = [
105
113
  defineTool({
106
114
  name: "memory_save",
107
- description:
115
+ description: withToolGuide(
116
+ "memory_save",
108
117
  "Persist one memory entry for future sessions (user preferences, project state, decisions). " +
109
- "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
110
- "Merges into an existing entry of the same type when the title matches.",
118
+ "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
119
+ "Merges into an existing entry of the same type when the title matches."
120
+ ),
111
121
  parameters: {
122
+ // document 不在此列(#230):document 行只能经 memory_register_document
123
+ // 铸造(注册校验 + doc_path + evidence),防止 memory_save 造出无指针
124
+ // 无回链的伪行。
112
125
  type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary; rejected_solution=rejected/abandoned implementation approach; pitfall=debugging lesson (symptom+root cause+fix); constraint=engineering constraint" },
113
126
  title: { type: "string", required: true, description: "Short unique title" },
114
127
  content: { type: "string", required: true, description: "Memory body" },
@@ -166,7 +179,10 @@ export function createTools(ctx, service, config, embedder) {
166
179
 
167
180
  defineTool({
168
181
  name: "memory_search",
169
- description: "Search the cross-session memory store. Use when you need past context: how a problem was solved, user preferences, project decisions. Substring-matches title/content/tags, and augments results with semantic (vector) recall + optional rerank when an embeddings provider is configured. Returns matching entries with source and timestamps.",
182
+ description: withToolGuide(
183
+ "memory_search",
184
+ "Search the cross-session memory store for past context (solved problems, user preferences, project decisions). IMPORTANT: relevant cross-session memories are already injected into your context every turn — only call this when the injected memory block does NOT contain what you need, or you need to look something specific up. Substring-matches title/content/tags, and augments results with semantic (vector) recall + optional rerank when an embeddings provider is configured. Returns matching entries with source and timestamps."
185
+ ),
170
186
  // A2 检索接线:occurred 时间窗 + 会话 scope 加成(见 execute)。
171
187
  parameters: {
172
188
  query: { type: "string", required: true, description: "Search text; substring match over title/content/tags" },
@@ -224,7 +240,7 @@ export function createTools(ctx, service, config, embedder) {
224
240
  name: "memory_list",
225
241
  description: "List memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored.",
226
242
  parameters: {
227
- type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "Filter by type; omit for all" },
243
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint", "document"], description: "Filter by type; omit for all. 'document' (#230) = agent-registered document pointer rows" },
228
244
  limit: { type: "integer", description: "Page size (default 50)" },
229
245
  offset: { type: "integer", description: "Page offset (default 0)" },
230
246
  include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" },
@@ -287,7 +303,9 @@ export function createTools(ctx, service, config, embedder) {
287
303
  },
288
304
  render: (_args, value) => {
289
305
  const m = value.memory;
290
- return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}${MEMORY_PROVENANCE(m)}\n\n${m.content}`);
306
+ // #230:document 指针行把文件路径亮在首行——「全文按需读」从这里拿路径。
307
+ const doc = m.doc_path ? ` | doc: ${m.doc_path}` : "";
308
+ return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}${doc}${MEMORY_PROVENANCE(m)}\n\n${m.content}`);
291
309
  }
292
310
  },
293
311
  async execute(args) {
@@ -311,6 +329,8 @@ export function createTools(ctx, service, config, embedder) {
311
329
  id: { type: "string", required: true, description: "Memory id" },
312
330
  title: { type: "string" },
313
331
  content: { type: "string" },
332
+ // document 不在此列(#230):type 不许改入 document——既有 document 行
333
+ // 的摘要修复(只改 content/title)不受影响。
314
334
  type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"] },
315
335
  tags: { type: "array", items: { type: "string" } },
316
336
  importance: { type: "integer", description: "1-5" },
@@ -415,7 +435,8 @@ export function createTools(ctx, service, config, embedder) {
415
435
  description:
416
436
  "Archive a memory (hide it from active lists, search, injection and dream consolidation) or restore it. " +
417
437
  "Archived entries stay in storage and are recoverable: pass archived=false to restore, and use memory_list with " +
418
- "include_archived=true to find archived entries.",
438
+ "include_archived=true to find archived entries. " +
439
+ "Only archive when the user asks, or an entry is clearly stale/no longer relevant — do not archive proactively mid-session to tidy up.",
419
440
  parameters: {
420
441
  id: { type: "string", required: true, description: "Memory id" },
421
442
  archived: { type: "boolean", description: "Archive (true, default) or restore (false) the entry" }
@@ -446,6 +467,101 @@ export function createTools(ctx, service, config, embedder) {
446
467
  }
447
468
  }),
448
469
 
470
+ // #230(#164 设计稿评审线):document 型记忆注册——agent 产长文档的指针
471
+ // 行铸造口。写入权分离:全文归 agent(管线对文件零读零写零改),库里只
472
+ // 存摘要 + doc_path + evidence 三样;C2 比对去重、supersede 记账在
473
+ // service.registerDocument(src/document.js)内完成。判断指引:调研报告/
474
+ // 设计稿/长整理产物「存外档」用本工具;原子事实(偏好/决策/教训)仍走
475
+ // memory_save;tag 与章节名建议用英文(跨语言稳定键),摘要正文可多语言。
476
+ defineTool({
477
+ name: "memory_register_document",
478
+ description:
479
+ "Register an agent-authored document (research report, design doc, long digest) into memory as a pointer row. " +
480
+ "Write-ownership split: the full text stays agent-owned on disk — the pipeline never reads or rewrites it; the DB " +
481
+ "stores only the summary + doc_path + evidence. Validates the file exists (absolute path, non-empty regular file), " +
482
+ "intersects evidence with real memory ids (all-fabricated evidence is rejected; unknown ids are dropped and the row " +
483
+ "is tagged evidence_degraded), and dedupes: re-registering the same path or title supersedes the old row (the old " +
484
+ "file is never touched; content_history stays traceable), while a merely near-duplicate summary of a different " +
485
+ "document row is rejected — update that row instead. Use for 'where is the conclusion doc for this project?' " +
486
+ "lookups; atomic facts still go to memory_save.",
487
+ parameters: {
488
+ path: { type: "string", required: true, description: "Absolute path of the document file (~ is expanded); must already exist as a non-empty regular file. The full text stays agent-owned — this pipeline never touches it." },
489
+ title: { type: "string", required: true, description: "Short unique title (English recommended: it is the cross-language stable dedupe/supersede key)" },
490
+ summary: { type: "string", required: true, description: "One-paragraph summary stored in the DB and used for injection (any language)" },
491
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags (English recommended)" },
492
+ importance: { type: "integer", description: "1-5 (default 3); the summary row injects at the next-priority tier within documentInjectBudget when importance >= threshold" },
493
+ evidence: { type: "array", items: { type: "string" }, description: "Memory ids this document is grounded in; each is verified against the store (fabricated evidence is rejected; unknown/archived ids are dropped and the row is tagged evidence_degraded)" },
494
+ source: { type: "string", description: "Optional provenance" },
495
+ sensitivity: { type: "string", description: "Optional sensitivity label (free-form, e.g. personal). Part of the supersede matching key — same path/title with a different sensitivity stays a separate document." },
496
+ agent_scope: { type: "string", description: "Optional explicit agent-scope declaration (issue #170): 'global' or '*' makes this document visible to every agent; any other value narrows it to that label. Overrides the automatic carrier label for this write; honored even when automatic scope labeling is disabled." },
497
+ workspace_scope: { type: "string", description: "Optional explicit workspace-scope declaration (issue #170): 'global' or '*' makes this document visible in every workspace; any other value narrows it to that label. Overrides the automatic carrier label for this write; honored even when automatic scope labeling is disabled." }
498
+ },
499
+ output: {
500
+ schema: {
501
+ type: "object",
502
+ additionalProperties: false,
503
+ properties: {
504
+ action: { type: "string", required: true, enum: ["created", "superseded"] },
505
+ id: { type: "string", required: true },
506
+ superseded_id: { type: "string" },
507
+ evidence_kept: { type: "integer", required: true },
508
+ evidence_dropped: { type: "integer", required: true },
509
+ degraded: { type: "boolean", required: true }
510
+ }
511
+ },
512
+ render: (_args, value) => {
513
+ const sup = value.superseded_id ? ` (supersedes ${value.superseded_id})` : "";
514
+ const ev = ` | evidence: ${value.evidence_kept} kept, ${value.evidence_dropped} dropped${value.degraded ? " [degraded]" : ""}`;
515
+ return TEXT_OUTPUT(`document ${value.action}: ${value.id}${sup}${ev}`);
516
+ }
517
+ },
518
+ async execute(args, exec) {
519
+ // scope 标注与 memory_save 同款(#170):默认会话身份自动标注,显式
520
+ // 参数逐维覆盖。校验/去重/supersede 全在 service.registerDocument。
521
+ const scope = resolveSessionScope(exec);
522
+ const autoStamping = config?.scopeEnabled === true;
523
+ const agentLabel = args.agent_scope !== undefined
524
+ ? { value: normalizeExplicitScope(args.agent_scope), source: "explicit" }
525
+ : autoStamping && scope ? { value: scope.agent_scope, source: "auto" } : null;
526
+ const workspaceLabel = args.workspace_scope !== undefined
527
+ ? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
528
+ : autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
529
+ // strictScope(#170 复核项 4 同款无存在性泄漏):他 scope 的 evidence id
530
+ // 按「不存在」处理——注册器用全局 getById 求交,看不见的行在这里先标出,
531
+ // 与 unknown/archived 同落 dropped,不进持久化 evidence(不得为跨 scope
532
+ // id 建立引用)。
533
+ const hiddenEvidence = [];
534
+ if (config?.strictScope === true && scope && Array.isArray(args.evidence)) {
535
+ for (const raw of args.evidence) {
536
+ const id = String(raw ?? "").trim();
537
+ if (!id) continue;
538
+ const row = service.getById(id);
539
+ if (row && !service.isVisibleInScope(row, scope)) hiddenEvidence.push(id);
540
+ }
541
+ }
542
+ const result = await service.registerDocument({
543
+ path: args.path,
544
+ title: args.title,
545
+ summary: args.summary,
546
+ tags: args.tags ?? [],
547
+ ...(args.importance !== undefined ? { importance: args.importance } : {}),
548
+ evidence: args.evidence ?? [],
549
+ source: args.source ?? "tool",
550
+ ...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
551
+ ...(agentLabel ? { agent_scope: agentLabel.value, agent_scope_source: agentLabel.source } : {}),
552
+ ...(workspaceLabel ? { workspace_scope: workspaceLabel.value, workspace_scope_source: workspaceLabel.source } : {})
553
+ }, { hiddenEvidenceIds: hiddenEvidence });
554
+ return {
555
+ action: result.action,
556
+ id: result.memory.id,
557
+ ...(result.superseded ? { superseded_id: result.superseded.id } : {}),
558
+ evidence_kept: result.evidence_kept,
559
+ evidence_dropped: result.evidence_dropped,
560
+ degraded: result.degraded
561
+ };
562
+ }
563
+ }),
564
+
449
565
  // 运行时口子(issue #131 / PR-C)。
450
566
  //
451
567
  // 为什么要有这个工具:mneme 的取向是「本体保持完善但轻量;重的功能存在、默认不开、不被依赖」。
@@ -561,8 +677,20 @@ export function createTools(ctx, service, config, embedder) {
561
677
  })
562
678
  ];
563
679
 
680
+ // Tool-exposure gate (v0.8.5): 跨会话记忆已自动注入,memory_search /
681
+ // memory_archive 在慢/轻量模型上是多余往返,面板开关可直接隐藏工具——
682
+ // 模型看不到就不会调,比在描述里劝它少调用更可靠。注意:live patch reload
683
+ // 下已注册的工具不会被宿主反注册,排除只对全新会话生效。
684
+ const hiddenTools = new Set();
685
+ if (config?.disableMemorySearch === true) hiddenTools.add("memory_search");
686
+ if (config?.disableMemoryArchive === true) hiddenTools.add("memory_archive");
687
+
564
688
  for (const tool of tools) {
565
- if (registeredTools.has(tool.name)) {
689
+ if (hiddenTools.has(tool.name)) {
690
+ ctx.logger?.info?.(`[dsh-mneme] tool "${tool.name}" hidden by config (disableMemorySearch/disableMemoryArchive)`);
691
+ continue;
692
+ }
693
+ if (registeredTools.has(tool.name)) {
566
694
  ctx.logger?.warn?.(`[dsh-mneme] tool "${tool.name}" already registered, skipping duplicate`);
567
695
  continue;
568
696
  }
@@ -0,0 +1,85 @@
1
+ // Issue #239(第 5 项):注入条数的查询自适应。
2
+ // 判据只看查询本身(零成本,不做探针检索——先探针检索等于白付一次 fuseRecall),
3
+ // 且只做**单向收缩**:模糊维持上限、确定收缩,绝不越过 maxInjectedItems——注入
4
+ // 相关却带偏生成的内容可能比不注入更糟。
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { needsBroadRecall, adaptiveInjectBudget } from "../src/search/adaptive.js";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createInjector } from "../src/inject.js";
11
+ import { createSettings } from "../src/settings.js";
12
+
13
+ function injectorSetup(over = {}) {
14
+ const store = createStore(":memory:");
15
+ const service = createService({ store, mirror: null, config: {} });
16
+ const settings = createSettings(store.db);
17
+ const contexts = [];
18
+ const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
19
+ createInjector(ctx, service, settings, { maxInjectedItems: 4, importanceThreshold: 3, ...over });
20
+ return { store, service, contexts };
21
+ }
22
+
23
+ // lastUserQuery 读的是 materialized 会话日志(DSH ≥0.1.2-rc 走 snapshotEvents)。
24
+ function ctxWithQuery(text) {
25
+ return {
26
+ agent: {
27
+ session: {
28
+ events: [{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text }] } }]
29
+ }
30
+ }
31
+ };
32
+ }
33
+
34
+ function seed(service, n = 6) {
35
+ for (let i = 0; i < n; i++) {
36
+ service.saveWithDedupe({ type: "project", title: `项目${i}`, content: `内容${i}`, importance: 5 });
37
+ }
38
+ }
39
+
40
+ const injectedCount = (text) => [0, 1, 2, 3, 4, 5].filter((i) => text.includes(`内容${i}`)).length;
41
+
42
+ test("#239-5 宽召回判据:回指/时间线索与极短查询算模糊,其余算确定", () => {
43
+ // 模糊:回指或时间线索(中英各有)
44
+ for (const q of ["上次那个方案怎么样了", "我们之前讨论过什么", "还记得我提过的偏好吗",
45
+ "what did we discuss last time", "do you remember my preference", "remind me"]) {
46
+ assert.equal(needsBroadRecall(q), true, `${q} 应判为模糊`);
47
+ }
48
+ // 模糊:极短(与 adaptiveThreshold 对 <5 字符的判断同源:几乎匹配任何东西)
49
+ assert.equal(needsBroadRecall("你好"), true);
50
+ assert.equal(needsBroadRecall(""), true, "拿不到查询时不猜,维持现状");
51
+ // 确定:具体技术问句
52
+ for (const q of ["pnpm MODULE_NOT_FOUND 怎么修", "把 settings.js 里的白名单登记补上", "node:sqlite 的 WAL 配置项在哪"]) {
53
+ assert.equal(needsBroadRecall(q), false, `${q} 应判为确定`);
54
+ }
55
+ });
56
+
57
+ test("#239-5 注入预算:确定收缩到一半(下限 1),模糊维持上限,绝不越界", () => {
58
+ assert.equal(adaptiveInjectBudget("pnpm MODULE_NOT_FOUND 怎么修", 5), 3, "确定 → 收缩到一半");
59
+ assert.equal(adaptiveInjectBudget("上次讨论的方案", 5), 5, "模糊 → 维持上限");
60
+ assert.equal(adaptiveInjectBudget("pnpm MODULE_NOT_FOUND 怎么修", 1), 1, "下限 1");
61
+ assert.equal(adaptiveInjectBudget("pnpm MODULE_NOT_FOUND 怎么修", 0), 0, "0 = 不注入,flag 打开也不该变 1");
62
+ assert.equal(adaptiveInjectBudget("any", undefined), undefined, "非法上限原样返回,不猜");
63
+ });
64
+
65
+ test("#239-5 默认关:开关未开时注入条数与查询无关(行为逐字节不变)", () => {
66
+ const { contexts, service } = injectorSetup();
67
+ seed(service);
68
+ assert.equal(injectedCount(contexts[0].text(ctxWithQuery("pnpm MODULE_NOT_FOUND 怎么修"))), 4);
69
+ assert.equal(injectedCount(contexts[0].text(ctxWithQuery("上次讨论的方案"))), 4);
70
+ });
71
+
72
+ test("#239-5 开关打开:确定查询少注入,模糊查询保持上限", () => {
73
+ const { contexts, service } = injectorSetup({ injectUncertaintyAdaptive: true });
74
+ seed(service);
75
+ assert.equal(injectedCount(contexts[0].text(ctxWithQuery("pnpm MODULE_NOT_FOUND 怎么修"))), 2,
76
+ "确定 → 注入条数减半");
77
+ assert.equal(injectedCount(contexts[0].text(ctxWithQuery("上次讨论的方案"))), 4,
78
+ "模糊 → 维持 maxInjectedItems");
79
+ });
80
+
81
+ test("#239-5 拿不到查询(无会话日志)时维持上限,不因开关而减少", () => {
82
+ const { contexts, service } = injectorSetup({ injectUncertaintyAdaptive: true });
83
+ seed(service);
84
+ assert.equal(injectedCount(contexts[0].text({})), 4, "退化路径不猜、不缩");
85
+ });
package/test/api.test.js CHANGED
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
5
5
  import { createStore } from "../src/store.js";
6
6
  import { createService } from "../src/service.js";
7
7
  import { createApi } from "../src/api.js";
8
+ import { createInjector } from "../src/inject.js";
8
9
  import { createSettings } from "../src/settings.js";
9
10
  import { createVectorIndex } from "../src/vector-index.js";
10
11
  import { Config } from "../src/config.js";
@@ -654,9 +655,17 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
654
655
  // scopeEnabled/strictScope、issue #194 新增的 resilientModelDownload),
655
656
  // 未覆盖时取 bundle 配置的解析默认值;
656
657
  // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
657
- // 不编造给前端 → 58 - 2 = 56(issue #164① 新增 injectContentMaxChars,
658
- // issue #164 叙述条批次新增 dreamNarrativeEnabled/dreamNarrativeMinCluster)
659
- assert.equal(Object.keys(data.effective).length, 56);
658
+ // 不编造给前端 → 62 - 2 = 60(issue #164① 新增 injectContentMaxChars,
659
+ // issue #164 叙述条批次新增 dreamNarrativeEnabled/dreamNarrativeMinCluster,
660
+ // issue #239 新增 summarizeMinWindowChars/summarizeMaxRunsPerSession、
661
+ // 第 4 项的 summarizePeakHours/summarizePeakMaxDeferMinutes 与
662
+ // 第 5 项的 injectUncertaintyAdaptive、
663
+ // issue #257 新增 sleepMaxTokens、issue #258 新增总览路由两键与
664
+ // dreamSummaryMaxInputs、issue #230 新增 documentMemoryEnabled/
665
+ // documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/
666
+ // pinnedInjectBudget,v0.8.5 新增 disableMemorySearch/disableMemoryArchive,
667
+ // 本地嵌入池化新增 localEmbedPooling)
668
+ assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1);
660
669
  assert.equal(data.effective.dreamSkipInvalid, true);
661
670
  assert.equal(data.effective.allowCrossTypeMerge, false);
662
671
  assert.equal(data.effective.dreamMinIntervalMinutes, 0);
@@ -1576,3 +1585,57 @@ test("inject-status: autoInject off is never suppressed (user's own choice, not
1576
1585
  await route.handler(req("/api/dsh-mneme/inject-status"), res);
1577
1586
  assert.deepEqual(JSON.parse(res.body), { autoInject: false, agentPreset: "minimal", suppressed: false });
1578
1587
  });
1588
+
1589
+ test("GET /api/dsh-mneme/inject-preview: returns last assembly snapshot (issue #179)", async () => {
1590
+ const store = createStore(":memory:");
1591
+ const service = createService({ store, mirror: null, config: {} });
1592
+ const settings = createSettings(store.db);
1593
+ const routes = [];
1594
+ const ctx = {
1595
+ webServer: {
1596
+ register(route) {
1597
+ routes.push(route);
1598
+ return () => {};
1599
+ }
1600
+ }
1601
+ };
1602
+ const api = createApi(
1603
+ ctx,
1604
+ service,
1605
+ settings,
1606
+ { add() {}, remove() {}, list() { return []; } },
1607
+ undefined,
1608
+ undefined,
1609
+ "",
1610
+ Config({})
1611
+ );
1612
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/inject-preview");
1613
+ assert.ok(route, "preview endpoint must be registered");
1614
+
1615
+ // 无渲染发生:snapshot=null(新宿主/新会话/autoInject 关闭同形)。
1616
+ let res = new FakeRes();
1617
+ await route.handler(req("/api/dsh-mneme/inject-preview"), res);
1618
+ assert.equal(res.statusCode, 200);
1619
+ assert.deepEqual(JSON.parse(res.body), { snapshot: null });
1620
+
1621
+ // 真实渲染一帧后:快照透传(api 层不做任何加工)。
1622
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
1623
+ const contexts = [];
1624
+ const promptCtx = {
1625
+ systemPrompt: { context(def) { contexts.push(def); return () => {}; } }
1626
+ };
1627
+ const injector = createInjector(promptCtx, service, settings, Config({ maxInjectedItems: 3, importanceThreshold: 3 }));
1628
+ contexts[0].text({});
1629
+ res = new FakeRes();
1630
+ await route.handler(req("/api/dsh-mneme/inject-preview"), res);
1631
+ const body = JSON.parse(res.body);
1632
+ assert.ok(body.snapshot, "snapshot present after a real render");
1633
+ assert.equal(body.snapshot.entries[0].title, "语言");
1634
+ assert.ok(body.snapshot.totalChars > 0);
1635
+
1636
+ // 非 GET → 404(与 inject-status 同款)。
1637
+ res = new FakeRes();
1638
+ await route.handler(req("/api/dsh-mneme/inject-preview", "POST"), res);
1639
+ assert.equal(res.statusCode, 404);
1640
+ injector(); // 快照是模块全局:用完即清,不污染后续用例
1641
+ });
@@ -665,7 +665,7 @@ test("settings feedback card: prefilled issue + mailto + browse, version from /i
665
665
  );
666
666
  // 2. GitHub 新建 issue:issues/new?title=&body= 预填环境信息(当前仓库无模板)
667
667
  assert.ok(
668
- clientSource.includes("https://github.com/modusensus/dsh-mneme/issues/new?title="),
668
+ clientSource.includes("https://github.com/slow-stack/dsh-mneme/issues/new?title="),
669
669
  "the issue link must prefill title+body on issues/new"
670
670
  );
671
671
  assert.ok(
@@ -679,7 +679,7 @@ test("settings feedback card: prefilled issue + mailto + browse, version from /i
679
679
  );
680
680
  // 4. 浏览已知问题:跳仓库 issues 列表页(去重前置步骤)
681
681
  assert.ok(
682
- clientSource.includes('href: "https://github.com/modusensus/dsh-mneme/issues"'),
682
+ clientSource.includes('href: "https://github.com/slow-stack/dsh-mneme/issues"'),
683
683
  "the browse link must open the repo issues list"
684
684
  );
685
685
  // 5. 双语 i18n
@@ -797,4 +797,65 @@ test("conflict queue ships a central review surface on the status tab", () => {
797
797
  assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
798
798
  assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
799
799
  assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
800
+ });
801
+
802
+ // --- issue #178 批次一:无障碍(aria-live 网络 / 语义标题 / 焦点管理 / 图摘要) ---
803
+
804
+ // 面板瞬时反馈(保存/裁决/刷新/复制)不能只靠纯视觉 span:announce() 单例
805
+ // polite live region 是唯一的播报通道,所有接线点都必须走它。
806
+ test("a11y: announce() live region exists and every transient feedback routes through it", () => {
807
+ assert.ok(clientSource.includes('function announce(text)'), "module-level announce() must exist");
808
+ assert.ok(clientSource.includes('"aria-live"'), "live region must set aria-live");
809
+ assert.ok(clientSource.includes('"role", "status"'), "live region must carry role=status");
810
+ // 接线点:功能开关保存、画像、向量、token、模式、extapi 保存+复制、
811
+ // 记忆编辑保存、队列刷新、裁决完成 —— 至少 9 处。
812
+ const wired = (clientSource.match(/\bannounce\(t\(/g) || []).length;
813
+ assert.ok(wired >= 9, `announce() wiring points expected >= 9, got ${wired}`);
814
+ });
815
+
816
+ // 状态卡标题原来是 div,读屏无法按标题导航;统一 h3(CSS margin 归零防回归)。
817
+ test("a11y: status card titles are real headings", () => {
818
+ assert.ok(clientSource.includes('h("h3", { className: "mneme-xcolhead" }'), "StatusCard title must be an h3");
819
+ const css = clientSource.match(/\.mneme-xcolhead\{[^}]*\}/);
820
+ assert.ok(css && css[0].includes("margin:0"), "h3 default margin must be neutralized in CSS");
821
+ });
822
+
823
+ // 弹层焦点管理:Tab 圈在面板内,关闭后焦点还给触发元素(两个 opener 都要标记)。
824
+ test("a11y: overlay focus trap and focus restore are wired", () => {
825
+ const openers = (clientSource.match(/"data-mneme-overlay-opener"/g) || []).length;
826
+ assert.equal(openers, 2, "both overlay entry buttons must carry the opener marker");
827
+ assert.ok(clientSource.includes('querySelectorAll(\'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])\')'),
828
+ "overlay must enumerate focusables for the Tab cycle");
829
+ assert.ok(/querySelector\('\[data-mneme-overlay-opener\]'\)/.test(clientSource), "closing must restore focus to the opener");
830
+ });
831
+
832
+ // 冲突裁决按钮「保留 A/B」在同文案多卡片下不可区分,必须带条目标题的 aria-label。
833
+ test("a11y: conflict keep buttons carry item-title aria-labels", () => {
834
+ const labels = (clientSource.match(/"aria-label": `\$\{t\("memory\.status\.conflictQueue\.keep/g) || []).length;
835
+ assert.equal(labels, 2, "keepA/keepB buttons must both set aria-label");
836
+ });
837
+
838
+ // ego 关系图对读屏是一块不可达 SVG,必须 role=img + 计数摘要(键中英各一)。
839
+ test("a11y: entity graph svg exposes a count summary", () => {
840
+ assert.ok(clientSource.includes('role: "img"'), "graph svg must be role=img");
841
+ for (const key of ["memory.graph.summary"]) {
842
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
843
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
844
+ }
845
+ });
846
+
847
+ // --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
848
+
849
+ test("a11y+preview: inject preview card is wired on the status tab", () => {
850
+ assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");
851
+ assert.ok(clientSource.includes("h(InjectPreviewCard, { t })"), "status grid must render the preview card");
852
+ for (const key of [
853
+ "memory.status.injectPreview",
854
+ "memory.status.injectPreviewNone",
855
+ "memory.status.injectPreview.chars",
856
+ "memory.status.injectPreview.empty"
857
+ ]) {
858
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
859
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
860
+ }
800
861
  });