@modusensus/dsh-mneme 0.6.8 → 0.6.9

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 (138) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/.release-notes-v0.6.9.md +13 -0
  3. package/CHANGELOG.md +89 -0
  4. package/README.md +148 -392
  5. package/SECURITY.md +544 -0
  6. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  7. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  12. package/dsh-mneme/CHANGELOG.md +248 -0
  13. package/dsh-mneme/LICENSE +21 -0
  14. package/dsh-mneme/README.md +465 -0
  15. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  16. package/dsh-mneme/docs/ENTITIES.md +245 -0
  17. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  18. package/dsh-mneme/docs/MIGRATION.md +127 -0
  19. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  20. package/dsh-mneme/docs/SLEEP.md +163 -0
  21. package/{lib → dsh-mneme/lib}/config.js +10 -0
  22. package/{src → dsh-mneme/lib}/dream/decisions.js +143 -94
  23. package/{lib → dsh-mneme/lib}/dream.js +32 -9
  24. package/dsh-mneme/package-lock.json +1936 -0
  25. package/dsh-mneme/package.json +80 -0
  26. package/{src → dsh-mneme/src}/config.js +10 -0
  27. package/{lib → dsh-mneme/src}/dream/decisions.js +143 -94
  28. package/{src → dsh-mneme/src}/dream.js +32 -9
  29. package/{test → dsh-mneme/test}/dream.test.js +157 -0
  30. package/package.json +58 -80
  31. package//346/250/252/345/271/205.png +0 -0
  32. /package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +0 -0
  33. /package/{lib → dsh-mneme/lib}/api.js +0 -0
  34. /package/{lib → dsh-mneme/lib}/client.js +0 -0
  35. /package/{lib → dsh-mneme/lib}/commands.js +0 -0
  36. /package/{lib → dsh-mneme/lib}/dream/clustering.js +0 -0
  37. /package/{lib → dsh-mneme/lib}/dream/sleep.js +0 -0
  38. /package/{lib → dsh-mneme/lib}/dream/tag-extractor.js +0 -0
  39. /package/{lib → dsh-mneme/lib}/embedding.js +0 -0
  40. /package/{lib → dsh-mneme/lib}/entities/extractor.js +0 -0
  41. /package/{lib → dsh-mneme/lib}/hot-memory.js +0 -0
  42. /package/{lib → dsh-mneme/lib}/index.js +0 -0
  43. /package/{lib → dsh-mneme/lib}/inject.js +0 -0
  44. /package/{lib → dsh-mneme/lib}/local-embedder.js +0 -0
  45. /package/{lib → dsh-mneme/lib}/mirror.js +0 -0
  46. /package/{lib → dsh-mneme/lib}/parser/tag.js +0 -0
  47. /package/{lib → dsh-mneme/lib}/parser/wiki-link.js +0 -0
  48. /package/{lib → dsh-mneme/lib}/quality-filter.js +0 -0
  49. /package/{lib → dsh-mneme/lib}/reranker.js +0 -0
  50. /package/{lib → dsh-mneme/lib}/search/adaptive.js +0 -0
  51. /package/{lib → dsh-mneme/lib}/search/bm25.js +0 -0
  52. /package/{lib → dsh-mneme/lib}/search/tag-boost.js +0 -0
  53. /package/{lib → dsh-mneme/lib}/service.js +0 -0
  54. /package/{lib → dsh-mneme/lib}/settings.js +0 -0
  55. /package/{lib → dsh-mneme/lib}/store.js +0 -0
  56. /package/{lib → dsh-mneme/lib}/summarize.js +0 -0
  57. /package/{lib → dsh-mneme/lib}/tools.js +0 -0
  58. /package/{lib → dsh-mneme/lib}/vector-index.js +0 -0
  59. /package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +0 -0
  60. /package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +0 -0
  61. /package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +0 -0
  62. /package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +0 -0
  63. /package/{scripts → dsh-mneme/scripts}/stress-dsh.js +0 -0
  64. /package/{scripts → dsh-mneme/scripts}/sync-lib.js +0 -0
  65. /package/{src → dsh-mneme/src}/api.js +0 -0
  66. /package/{src → dsh-mneme/src}/commands.js +0 -0
  67. /package/{src → dsh-mneme/src}/dream/clustering.js +0 -0
  68. /package/{src → dsh-mneme/src}/dream/sleep.js +0 -0
  69. /package/{src → dsh-mneme/src}/dream/tag-extractor.js +0 -0
  70. /package/{src → dsh-mneme/src}/embedding.js +0 -0
  71. /package/{src → dsh-mneme/src}/entities/extractor.js +0 -0
  72. /package/{src → dsh-mneme/src}/hot-memory.js +0 -0
  73. /package/{src → dsh-mneme/src}/index.js +0 -0
  74. /package/{src → dsh-mneme/src}/inject.js +0 -0
  75. /package/{src → dsh-mneme/src}/local-embedder.js +0 -0
  76. /package/{src → dsh-mneme/src}/mirror.js +0 -0
  77. /package/{src → dsh-mneme/src}/parser/tag.js +0 -0
  78. /package/{src → dsh-mneme/src}/parser/wiki-link.js +0 -0
  79. /package/{src → dsh-mneme/src}/quality-filter.js +0 -0
  80. /package/{src → dsh-mneme/src}/reranker.js +0 -0
  81. /package/{src → dsh-mneme/src}/search/adaptive.js +0 -0
  82. /package/{src → dsh-mneme/src}/search/bm25.js +0 -0
  83. /package/{src → dsh-mneme/src}/search/tag-boost.js +0 -0
  84. /package/{src → dsh-mneme/src}/service.js +0 -0
  85. /package/{src → dsh-mneme/src}/settings.js +0 -0
  86. /package/{src → dsh-mneme/src}/store.js +0 -0
  87. /package/{src → dsh-mneme/src}/summarize.js +0 -0
  88. /package/{src → dsh-mneme/src}/tools.js +0 -0
  89. /package/{src → dsh-mneme/src}/vector-index.js +0 -0
  90. /package/{test → dsh-mneme/test}/api.test.js +0 -0
  91. /package/{test → dsh-mneme/test}/audit.test.js +0 -0
  92. /package/{test → dsh-mneme/test}/benchmark.test.js +0 -0
  93. /package/{test → dsh-mneme/test}/boundary-v0625.test.js +0 -0
  94. /package/{test → dsh-mneme/test}/client.test.js +0 -0
  95. /package/{test → dsh-mneme/test}/clustering.test.js +0 -0
  96. /package/{test → dsh-mneme/test}/commands.test.js +0 -0
  97. /package/{test → dsh-mneme/test}/config.test.js +0 -0
  98. /package/{test → dsh-mneme/test}/conflict-freeze.test.js +0 -0
  99. /package/{test → dsh-mneme/test}/directory.test.js +0 -0
  100. /package/{test → dsh-mneme/test}/entities.test.js +0 -0
  101. /package/{test → dsh-mneme/test}/epistemic.test.js +0 -0
  102. /package/{test → dsh-mneme/test}/fnew-0112.test.js +0 -0
  103. /package/{test → dsh-mneme/test}/fnew-03.test.js +0 -0
  104. /package/{test → dsh-mneme/test}/graph-api.test.js +0 -0
  105. /package/{test → dsh-mneme/test}/helpers/dream-mock.js +0 -0
  106. /package/{test → dsh-mneme/test}/hot-memory.test.js +0 -0
  107. /package/{test → dsh-mneme/test}/inject.test.js +0 -0
  108. /package/{test → dsh-mneme/test}/llm-audit.test.js +0 -0
  109. /package/{test → dsh-mneme/test}/local-embedder.test.js +0 -0
  110. /package/{test → dsh-mneme/test}/mirror-dirty.test.js +0 -0
  111. /package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +0 -0
  112. /package/{test → dsh-mneme/test}/mirror-generation.test.js +0 -0
  113. /package/{test → dsh-mneme/test}/mirror.test.js +0 -0
  114. /package/{test → dsh-mneme/test}/normalize-decisions.test.js +0 -0
  115. /package/{test → dsh-mneme/test}/peer-blockers.test.js +0 -0
  116. /package/{test → dsh-mneme/test}/policy-epoch.test.js +0 -0
  117. /package/{test → dsh-mneme/test}/provenance.test.js +0 -0
  118. /package/{test → dsh-mneme/test}/quality-filter.test.js +0 -0
  119. /package/{test → dsh-mneme/test}/reasoning-effort.test.js +0 -0
  120. /package/{test → dsh-mneme/test}/recall-evals.test.js +0 -0
  121. /package/{test → dsh-mneme/test}/recall-layer.test.js +0 -0
  122. /package/{test → dsh-mneme/test}/receipt-chain.test.js +0 -0
  123. /package/{test → dsh-mneme/test}/reflection.test.js +0 -0
  124. /package/{test → dsh-mneme/test}/reranker.test.js +0 -0
  125. /package/{test → dsh-mneme/test}/search-fusion.test.js +0 -0
  126. /package/{test → dsh-mneme/test}/semantic.test.js +0 -0
  127. /package/{test → dsh-mneme/test}/service-search.test.js +0 -0
  128. /package/{test → dsh-mneme/test}/service.test.js +0 -0
  129. /package/{test → dsh-mneme/test}/settings.test.js +0 -0
  130. /package/{test → dsh-mneme/test}/sleep.test.js +0 -0
  131. /package/{test → dsh-mneme/test}/store.test.js +0 -0
  132. /package/{test → dsh-mneme/test}/stress.test.js +0 -0
  133. /package/{test → dsh-mneme/test}/summarize.test.js +0 -0
  134. /package/{test → dsh-mneme/test}/tag-boost.test.js +0 -0
  135. /package/{test → dsh-mneme/test}/tag.test.js +0 -0
  136. /package/{test → dsh-mneme/test}/tools.test.js +0 -0
  137. /package/{test → dsh-mneme/test}/vector-index.test.js +0 -0
  138. /package/{test → dsh-mneme/test}/wiki-link.test.js +0 -0
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@modusensus/dsh-mneme",
3
+ "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
+ "version": "0.6.9",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/modusensus/dsh-mneme.git"
9
+ },
10
+ "homepage": "https://github.com/modusensus/dsh-mneme#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/modusensus/dsh-mneme/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": {
18
+ "default": "./lib/index.js"
19
+ },
20
+ "./client": {
21
+ "default": "./lib/client.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "lib",
27
+ "src",
28
+ "scripts",
29
+ "test",
30
+ "cordis.patch.yml"
31
+ ],
32
+ "dsh": {
33
+ "client": {
34
+ "inject": [
35
+ "slots",
36
+ "locale",
37
+ "layout",
38
+ "connection"
39
+ ],
40
+ "platform": "web"
41
+ },
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.1",
48
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
49
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
50
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
51
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
52
+ "@deepseek-ai/schemastery": "^3.18.1"
53
+ },
54
+ "devDependencies": {
55
+ "@deepseek-ai/cordis": "^4.0.1",
56
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
57
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
59
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
60
+ "@deepseek-ai/schemastery": "^3.18.1",
61
+ "c8": "^12.0.0"
62
+ },
63
+ "scripts": {
64
+ "sync": "node scripts/sync-lib.js",
65
+ "prepack": "npm run sync",
66
+ "test": "node --test test/*.test.js",
67
+ "test:coverage": "c8 node --test test/*.test.js",
68
+ "e2e": "node scripts/e2e-dsh.js",
69
+ "stress": "node scripts/stress-dsh.js"
70
+ },
71
+ "dependencies": {
72
+ "@huggingface/transformers": "^4.2.0"
73
+ },
74
+ "overrides": {
75
+ "adm-zip": "0.6.0"
76
+ },
77
+ "c8": {
78
+ "reporter": ["text", "lcov"]
79
+ }
80
+ }
@@ -49,6 +49,16 @@ export const Config = z.object({
49
49
  // → 整单拒绝,防止残缺输出被隐式 keep 洗白成 ok 后再被真实 apply。0-1,
50
50
  // 默认 0.5(至少显式覆盖一半 snapshot)。
51
51
  dreamMinExplicitCoverage: z.number().min(0).max(1).default(0.5),
52
+ // 跳过非法决策(Issue #26 P0,默认开):跨类型 merge 等"单条非法"决策不再
53
+ // 让整批校验失败 → 跳过该决策、应用合法子集,run 记为 degraded(applied>0)。
54
+ // 关闭后恢复旧的"任意非法即整单拒绝"(applied=0)。防洗白语义不受影响——
55
+ // 显式覆盖率不足/update 超量等全局错误仍整单拒绝。
56
+ dreamSkipInvalid: z.boolean().default(true),
57
+ // 允许跨类型合并(Issue #26 P1,默认关):类型有语义边界(preference 注入
58
+ // 权重更高、decision/project 注入上下文不同),跨类型合并会丢类型信息,故
59
+ // 默认禁止并在 skipInvalid 下被跳过;显式开启后放宽该检查,类型边界由用户
60
+ // 自行承担(需与 dreamSkipInvalid 配合:开启后跨类型 merge 视为合法、可应用)。
61
+ allowCrossTypeMerge: z.boolean().default(false),
52
62
  // Rule version for dream adjudication: when this bumps, older dream_runs
53
63
  // degrade to historical evidence (their receipts no longer drive live
54
64
  // decisions). Default 0 = no versioning in use yet.
@@ -7,120 +7,163 @@ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
7
7
 
8
8
  /**
9
9
  * Validate a dream decision list against a snapshot of eligible memories.
10
- * @param decisions - LLM-produced decision list.
10
+ *
11
+ * Issue #26 (skipInvalid): 模型几乎必然为了语义相关性而产出跨类型 merge
12
+ * (type 不同,硬性禁止),这类"单条非法"此前让整批校验 ok:false、整单拒绝
13
+ * (applied=0、白耗一次 LLM 调用)。`options.skipInvalid` 开启后,逐条非法的
14
+ * 决策被跳过(从 decisions 中移除、不 claim 任何 id、记录到 `skipped`),
15
+ * 只有全局性错误(update/create 超量、显式覆盖率不足——
16
+ * 这些是"模型失控/输出被截断"信号)仍整单拒绝,防洗白语义不变。
17
+ * `options.allowCrossTypeMerge`(Issue #26 P1)显式放宽跨类型合并检查——
18
+ * 默认 false 保持现有类型边界,true 时跨类型 merge 被允许(类型边界由
19
+ * 用户自行承担)。
20
+ *
21
+ * @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
22
+ * entries are spliced out in place so the caller's downstream apply/audit
23
+ * operates on the surviving subset only.
11
24
  * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
12
- * @returns {{ok: boolean, errors: string[]}}
25
+ * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>}}
13
26
  */
14
27
  export function validateDecisions(decisions, snapshot, options = {}) {
15
28
  const errors = [];
29
+ const skipped = [];
16
30
  const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
17
31
  const minAgeHours = options.minAgeHours ?? 24;
32
+ const skipInvalid = options.skipInvalid === true;
18
33
  if (!Array.isArray(decisions) || decisions.length === 0) {
19
34
  return { ok: false, errors: ["decision list must be a non-empty array"] };
20
35
  }
21
36
  const claimed = new Set();
37
+ const survivors = [];
22
38
  for (const [index, d] of decisions.entries()) {
23
39
  const at = `decision[${index}]`;
40
+ const local = [];
41
+ const ids = d && d.action === "conflict" ? [d.winner, d.loser] : (d?.ids ?? []);
24
42
  if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
25
- errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
26
- continue;
27
- }
28
- const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
29
- if (d.action === "conflict") {
30
- if (!d.winner || !d.loser || d.winner === d.loser) {
31
- errors.push(`${at}: conflict needs distinct winner and loser`);
32
- continue;
33
- }
34
- } else if (d.action === "create") {
35
- // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
36
- // so it skips the claiming loop below; evidence is optional provenance
37
- // (already filtered to real ids by the caller) and is stored in content.
38
- if (typeof d.title !== "string" || !d.title.trim()) {
39
- errors.push(`${at}: create needs non-empty title`);
40
- continue;
41
- }
42
- if (typeof d.content !== "string" || !d.content.trim()) {
43
- errors.push(`${at}: create needs non-empty content`);
44
- continue;
45
- }
46
- if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
47
- errors.push(`${at}: create importance must be an integer 1-5 when provided`);
48
- }
49
- if (typeof d.type !== "string" || !d.type.trim()) {
50
- errors.push(`${at}: create needs non-empty type`);
51
- }
52
- continue;
53
- } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
54
- errors.push(`${at}: ${d.action} needs non-empty ids`);
55
- continue;
56
- }
57
- // update-specific field validation runs BEFORE claiming ids, so a failing
58
- // update never pollutes the claimed set (which drives the "every id must
59
- // appear in a decision" check below).
60
- if (d.action === "update") {
61
- // 只能更新单条
62
- if (!Array.isArray(d.ids) || d.ids.length !== 1) {
63
- errors.push(`${at}: update must target exactly one id`);
64
- continue;
65
- }
66
- // 必须产生实际变化
67
- const mem = snapshot.get(d.ids[0]);
68
- const hasChange = (d.title !== undefined && d.title !== mem?.title)
69
- || (d.content !== undefined && d.content !== mem?.content)
70
- || (d.importance !== undefined && d.importance !== mem?.importance);
71
- if (!hasChange) {
72
- errors.push(`${at}: update must change at least one field`);
73
- continue;
43
+ local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
44
+ } else {
45
+ if (d.action === "conflict") {
46
+ if (!d.winner || !d.loser || d.winner === d.loser) {
47
+ local.push(`${at}: conflict needs distinct winner and loser`);
48
+ }
49
+ } else if (d.action === "create") {
50
+ // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
51
+ // so it skips the claiming loop below; evidence is optional provenance
52
+ // (already filtered to real ids by the caller) and is stored in content.
53
+ if (typeof d.title !== "string" || !d.title.trim()) {
54
+ local.push(`${at}: create needs non-empty title`);
55
+ }
56
+ if (typeof d.content !== "string" || !d.content.trim()) {
57
+ local.push(`${at}: create needs non-empty content`);
58
+ }
59
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
60
+ local.push(`${at}: create importance must be an integer 1-5 when provided`);
61
+ }
62
+ if (typeof d.type !== "string" || !d.type.trim()) {
63
+ local.push(`${at}: create needs non-empty type`);
64
+ }
65
+ } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
66
+ local.push(`${at}: ${d.action} needs non-empty ids`);
74
67
  }
75
- // 不能更新 summary
76
- if (mem?.type === "summary") {
77
- errors.push(`${at}: cannot update summary via update action`);
78
- continue;
68
+ // update-specific field validation runs BEFORE claiming ids, so a failing
69
+ // update never pollutes the claimed set (which drives the "every id must
70
+ // appear in a decision" check below).
71
+ if (d.action === "update") {
72
+ // 只能更新单条
73
+ if (!Array.isArray(d.ids) || d.ids.length !== 1) {
74
+ local.push(`${at}: update must target exactly one id`);
75
+ } else {
76
+ // 必须产生实际变化
77
+ const mem = snapshot.get(d.ids[0]);
78
+ const hasChange = (d.title !== undefined && d.title !== mem?.title)
79
+ || (d.content !== undefined && d.content !== mem?.content)
80
+ || (d.importance !== undefined && d.importance !== mem?.importance);
81
+ if (!hasChange) {
82
+ local.push(`${at}: update must change at least one field`);
83
+ }
84
+ // 不能更新 summary
85
+ if (mem?.type === "summary") {
86
+ local.push(`${at}: cannot update summary via update action`);
87
+ }
88
+ // 保护期:新建记忆不可立即被 update(可配置)
89
+ const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
90
+ if (ageHours < minAgeHours) {
91
+ local.push(`${at}: memory too young (< ${minAgeHours}h)`);
92
+ }
93
+ }
79
94
  }
80
- // 保护期:新建记忆不可立即被 update(可配置)
81
- const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
82
- if (ageHours < minAgeHours) {
83
- errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
84
- continue;
95
+ if (d.action !== "create") {
96
+ const seen = new Set();
97
+ for (const id of ids) {
98
+ const mem = snapshot.get(id);
99
+ if (!mem) {
100
+ local.push(`${at}: unknown id ${JSON.stringify(id)}`);
101
+ } else if (mem.archived || mem.type === "summary") {
102
+ local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
103
+ }
104
+ // 单决策内重复 id(同一决策引用同一记忆两次)→ 非法;跨决策重复由
105
+ // 全局 claimed 检测(只含已通过校验的幸存决策的 claim)。
106
+ if (seen.has(id)) {
107
+ local.push(`${at}: duplicate id ${JSON.stringify(id)} within one decision`);
108
+ }
109
+ if (claimed.has(id)) {
110
+ local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
111
+ }
112
+ seen.add(id);
113
+ }
114
+ if (d.action === "merge") {
115
+ if (!d.keepSource || !d.ids.includes(d.keepSource)) {
116
+ local.push(`${at}: merge keepSource must be one of ids`);
117
+ }
118
+ if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
119
+ local.push(`${at}: merge needs non-empty title and content`);
120
+ }
121
+ if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
122
+ local.push(`${at}: merge importance must be an integer 1-5 when provided`);
123
+ }
124
+ // Merging across types would blur preference/project/decision boundaries
125
+ // in the injected context; the snapshot carries each entry's type.
126
+ // Issue #26 (P1): 默认禁止跨类型合并(类型有语义作用——preference 注入
127
+ // 权重更高、decision/project 注入上下文不同,合并会丢类型边界)。用户显式
128
+ // 开启 allowCrossTypeMerge 后放宽该检查,类型边界由用户自行承担。
129
+ const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
130
+ if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
131
+ local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
132
+ }
133
+ }
85
134
  }
86
135
  }
87
- for (const id of ids) {
88
- const mem = snapshot.get(id);
89
- if (!mem) {
90
- errors.push(`${at}: unknown id ${JSON.stringify(id)}`);
91
- } else if (mem.archived || mem.type === "summary") {
92
- errors.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
93
- }
94
- if (claimed.has(id)) {
95
- errors.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
136
+ if (local.length > 0) {
137
+ if (skipInvalid) {
138
+ // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/
139
+ // 隐式 keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不
140
+ // 写入全局 errors,否则会误触发下方的整单拒绝。
141
+ skipped.push({
142
+ index,
143
+ action: d?.action,
144
+ ids,
145
+ error: local.join("; ")
146
+ });
147
+ } else {
148
+ errors.push(...local);
96
149
  }
97
- claimed.add(id);
150
+ continue;
98
151
  }
99
- if (d.action === "merge") {
100
- if (!d.keepSource || !d.ids.includes(d.keepSource)) {
101
- errors.push(`${at}: merge keepSource must be one of ids`);
102
- }
103
- if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
104
- errors.push(`${at}: merge needs non-empty title and content`);
105
- }
106
- if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
107
- errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
108
- }
109
- // Merging across types would blur preference/project/decision boundaries
110
- // in the injected context; the snapshot carries each entry's type.
111
- const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
112
- if (mergeTypes.size > 1) {
113
- errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
114
- }
152
+ if (d.action !== "create") {
153
+ for (const id of ids) claimed.add(id);
115
154
  }
155
+ survivors.push(d);
116
156
  }
117
- // Cap update churn: too many edits in one cycle signals a runaway model
118
- const updateCount = decisions.filter((d) => d.action === "update").length;
157
+ // Cap update churn: too many edits in one cycle signals a runaway model.
158
+ // These are GLOBAL signals in skipInvalid mode they still reject the whole
159
+ // batch, because a model that floods updates/creates is broken, not just
160
+ // slightly schema-drifting.
161
+ const updateCount = survivors.filter((d) => d.action === "update").length;
119
162
  if (updateCount > maxUpdatePerRun) {
120
163
  errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
121
164
  }
122
165
  // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
123
- const createCount = decisions.filter((d) => d.action === "create").length;
166
+ const createCount = survivors.filter((d) => d.action === "create").length;
124
167
  const maxCreatePerRun = options.maxCreatePerRun ?? 5;
125
168
  if (createCount > maxCreatePerRun) {
126
169
  errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
@@ -137,25 +180,31 @@ export function validateDecisions(decisions, snapshot, options = {}) {
137
180
  // snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
138
181
  // 而不是用 keep 把绝大部分 snapshot 全部"通过"。
139
182
  if (errors.length > 0) {
140
- return { ok: false, errors };
183
+ return { ok: false, errors, skipped };
141
184
  }
142
185
  const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
143
186
  if (options.dreamImplicitKeep !== false) {
144
187
  const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
145
188
  if (coverage < minCoverage) {
146
189
  errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
147
- return { ok: false, errors };
190
+ return { ok: false, errors, skipped };
148
191
  }
149
192
  for (const id of snapshot.keys()) {
150
- if (!claimed.has(id)) decisions.push({ action: "keep", ids: [id] });
193
+ if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
151
194
  }
152
195
  } else {
153
196
  for (const id of snapshot.keys()) {
154
197
  if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
155
198
  }
156
- if (errors.length > 0) return { ok: false, errors };
199
+ if (errors.length > 0) return { ok: false, errors, skipped };
200
+ }
201
+ // 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
202
+ // 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
203
+ // 内容一致时(无跳过、无补齐)为 no-op。
204
+ if (survivors.length !== decisions.length) {
205
+ decisions.splice(0, decisions.length, ...survivors);
157
206
  }
158
- return { ok: true, errors };
207
+ return { ok: true, errors, skipped };
159
208
  }
160
209
 
161
210
  /** Marker thrown when a decision target changed since the run snapshot. */
@@ -720,18 +720,27 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
720
720
  logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0})`);
721
721
  return finish({ ok: false, error: "no json array in llm output", summary: false });
722
722
  }
723
- const { ok, errors } = validateDecisions(decisions, snapshot, {
723
+ const { ok, errors, skipped = [] } = validateDecisions(decisions, snapshot, {
724
724
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
725
725
  minAgeHours: config.reflectionUpdateMinAgeHours,
726
726
  // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
727
727
  // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
728
728
  dreamImplicitKeep: config.dreamImplicitKeep,
729
- dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
729
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage,
730
+ // Issue #26 (P0):跨类型 merge 等"单条非法"决策不再拖垮整批——默认开启
731
+ // skipInvalid,非法决策跳过、合法子集照常应用(run 记为 degraded)。
732
+ // dreamSkipInvalid:false 可恢复旧的"任意非法即整单拒绝"。
733
+ skipInvalid: config.dreamSkipInvalid !== false,
734
+ // Issue #26 (P1):显式开启 allowCrossTypeMerge 后放宽跨类型合并检查。
735
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true
730
736
  });
731
737
  if (!ok) {
732
738
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
733
739
  return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
734
740
  }
741
+ for (const s of skipped) {
742
+ logger?.warn?.(`dsh-mneme dream: skipping invalid decision[${s.index}] (${s.action}): ${s.error}`);
743
+ }
735
744
 
736
745
  // Capture pre-update snapshots so the audit records what each update changed.
737
746
  const updateSnapshots = {};
@@ -804,9 +813,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
804
813
  );
805
814
  // Outcome is derived from the ACTUALLY committed sub-steps, never from the
806
815
  // raw LLM decision list — a merge whose archive step rolled back must not
807
- // claim "merge-archived" (item ②). Conflicts/failures ride along so the
808
- // audit row records why the run diverged.
809
- const outcome = { ...buildOutcome(committed), conflicts, failures };
816
+ // claim "merge-archived" (item ②). Conflicts/failures/skipped ride along
817
+ // so the audit row records why the run diverged (Issue #26: skipped =
818
+ // decisions dropped by skipInvalid because they were individually invalid,
819
+ // e.g. cross-type merge).
820
+ const outcome = { ...buildOutcome(committed), conflicts, failures, skipped };
810
821
  // Frozen conflicts were not adjudicated: mark both sides pending in the
811
822
  // per-id outcome so the audit row shows they were parked, not skipped.
812
823
  if (frozenIds.length) {
@@ -903,9 +914,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
903
914
  // run. ok:false keeps the scheduler from moving the baseline.
904
915
  // ok — either real changes landed, or a fresh summary was stored
905
916
  // (all-keep + summary is a substantive summary refresh).
906
- // degraded — real consolidation landed but the summary came back empty/
907
- // missing: the store was absorbed (ok for the baseline) but
908
- // the run did not produce its full output (marked, not faked).
917
+ // degraded — real consolidation landed but the run did not produce its
918
+ // full output: the summary came back empty/missing, or
919
+ // (Issue #26) some individually-invalid decisions were
920
+ // skipped by skipInvalid. The valid subset was absorbed (ok
921
+ // for the baseline — a future run won't re-fail on the same
922
+ // permanently-invalid pairs), but the run is marked, not faked.
909
923
  let status;
910
924
  let okResult;
911
925
  if (partial) {
@@ -914,6 +928,13 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
914
928
  } else if (noChange) {
915
929
  status = summaryStored ? "ok" : "noop";
916
930
  okResult = summaryStored;
931
+ } else if (skipped.length > 0) {
932
+ // Issue #26: valid subset landed but at least one decision was dropped as
933
+ // invalid (e.g. cross-type merge). degraded (not ok) — never a fake ok.
934
+ // autoTag still runs on degraded runs, so it is not blocked behind a
935
+ // "success" that a skipped-invalid run can never reach.
936
+ status = "degraded";
937
+ okResult = true;
917
938
  } else {
918
939
  status = summaryStored ? "ok" : "degraded";
919
940
  okResult = true;
@@ -926,8 +947,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
926
947
  outcome,
927
948
  conflicts,
928
949
  failures,
950
+ skipped,
929
951
  frozen: frozenCount,
930
- summary: summaryStored
952
+ summary: summaryStored,
953
+ error: skipped.length > 0 ? `skipped ${skipped.length} invalid decision(s)` : undefined
931
954
  });
932
955
  }
933
956