@modusensus/dsh-mneme 0.4.1 → 0.4.3-beta.1

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-447%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-450%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
 
10
10
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
11
11
 
@@ -54,6 +54,34 @@
54
54
  - **Fail-safe**:非法 LLM 输出(未知 id / 非法 action / 跨类型合并 / 越界 importance)拒绝整单,绝不破坏记忆库
55
55
  - **裁决审计**:每次运行写入 `dream_runs` 审计表(输入快照 sha256 digest + 完整输入快照 + 决策清单 + 逐 id 去向 + receipt),可离线回放;merge / conflict / update 幂等应用,重放/并发重复执行无累积副作用;update 记录 `_before` 快照
56
56
 
57
+ #### dreamMaxTokens 调优指南
58
+
59
+ 默认 `4096` 已覆盖常规记忆库。当**记忆量大**(数万字符以上)时,决策清单与摘要可能超过默认预算,建议按规模调大:
60
+
61
+ | 记忆库规模 | 建议 `dreamMaxTokens` |
62
+ |-----------|----------------------|
63
+ | 常规(<1 万字) | `4096`(默认) |
64
+ | 中等(1 万-5 万字) | `65536` |
65
+ | 大型(5 万字以上) | `131072`(上限) |
66
+
67
+ > 若使用**思考型模型**(如 DeepSeek-R1 类),模型可能把全部预算花在 reasoning 上导致正文为空(日志出现 `no json array in llm output`)。此时把 `dreamReasoningEffort` 设为 `low` 可压制推理开销、把预算留给正文输出;sleep 侧对应 `sleepReasoningEffort`。默认 `none` 不传该字段,完全沿用模型自身默认,行为与旧版本一致。
68
+
69
+ ### Sleep Mode 系统级睡眠 💤(v0.4.0,opt-in)
70
+
71
+ 从 autoDream 的"被动阈值触发"升级为"主动定时维护 + 分层压缩"。系统空闲 `sleepIdleMinutes` 分钟自动执行深度维护,**默认关闭**(`sleepModeEnabled: false`),开启后行为:
72
+
73
+ - **可中断**:AbortController 实现,用户恢复活动即中止当前周期(`noteWrite` 重置空闲计时 + 中断信号)
74
+ - **串行安全**:睡眠周期走 `service.enqueue` 串行队列,与 autoDream 严格不重叠;`minRefTimeMs` 防止快照后被召回的记忆被误降级
75
+ - **四阶段深度维护**:
76
+ 1. `conflict_resolution`:全库冲突消解,strictness 三级可配(gentle 0.92 / normal 0.85 / aggressive 0.75)
77
+ 2. `archival_demotion`:按 `last_accessed_at` 分层——30 天未召回压成摘要(原文进 `_full_content`,可无损恢复)、90 天完全归档
78
+ 3. `pattern_discovery`:LLM 扫描近期记忆提炼规律,产出 `type=pattern` 记忆,evidence 强校验防伪造
79
+ 4. `relation_completion`:检测孤立实体并补全隐含关系(共现 `related_to` / 项目 `part_of` / 技术 `depends_on`)
80
+ - **Fail-safe**:每阶段独立 try/catch,LLM 故障只跳过对应阶段;无 LLM 路由时纯规则降级(demotion/relations)照常执行
81
+ - **审计延续**:睡眠周期写入 `dream_runs`,`run_type='sleep'`,与 autoDream 共用审计表可追溯
82
+
83
+ > 配置详见 `docs/SLEEP.md`;迁移说明见 `docs/MIGRATION.md`。
84
+
57
85
  ### Web 记忆面板
58
86
 
59
87
  官方设置面板 → 「记忆库设置」→「记忆」标签:按类型浏览、全文搜索;启用向量搜索后可用「语义」切换做向量召回。
@@ -115,6 +143,9 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
115
143
 
116
144
  | 版本 | 亮点 |
117
145
  |------|------|
146
+ | **v0.4.2** | autoSummarize 自定义模型:`summarizeProvider`/`summarizeModel` 配置项,可独立指定轻量模型(如 qwen3.6-plus)用于会话摘要,节省主模型 token;473 测试全绿 |
147
+ | **v0.4.0** | 系统级睡眠 Sleep Mode:空闲触发的四阶段深度维护(冲突消解 / 归档降级 / 模式发现 / 关系补全),可中断、串行安全、fail-safe,分层压缩释放冷记忆;471 测试全绿 |
148
+ | **v0.3.9** | 修复第三方审计 4 项 FAIL:CAS 同事务原子化、Mirror 降级回执透传、逐 type 物理终态收敛、Generation 强整数校验与并发初始化稳定化 |
118
149
  | **v0.3.8** | audit peer 复验 6 项运行时阻断全部修复:desired generation 同事务原子递增(崩溃窗口不再静默跳过)、同步失败不静默、原子 generation 增量(多进程零丢失)、逐 type committed/failed/pending 回执、读取失败显式 unknown、generation 上界/负数 CHECK |
119
150
  | **v0.3.7** | 启动竞态修复:人工编辑 md 镜像后重启向量重建失败(回灌移入 init 就绪后 + scheduleEmbed 就绪门) |
120
151
  | **v0.3.6** | mirror 同步状态机:generation/applied_generation 债务建模、F-NEW-03 mirror 健康状态、持久 dirty + 启动 recoverMirror |
@@ -126,12 +157,13 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
126
157
  |------|------|------|------|
127
158
  | v0.2.x | ✅ 完成 | 语义增强 + 反思更新 | 本地 Embedding/Rerank/聚类、`failure_memories` 失败追踪 |
128
159
  | v0.3.0 | ✅ 完成 | 记忆基因 | entities/attrs/relations 三表 + 时间轴 + 实体搜索 |
129
- | v0.3.6–0.3.8 | ✅ 完成 | 镜像一致性 + 审计加固 | generation 同步状态机、audit peer 6 项运行时阻断修复、447 测试全绿 |
130
- | **v0.4.0** | 规划 | 反思性成长 | 纠错双向回流(改记忆同时反思"为什么记错/召回错")+ 规则演进(从 failure 提炼规律注入系统提示)+ 自适应参数 |
131
- | **v0.4.1** | ✅ 完成 | 系统级睡眠 | Sleep 调度器(空闲检测 + 最小间隔,无 cron)+ 三层深度整理(冲突消解 / 归档降级 / 模式发现)+ create 决策 + 访问 touch 免生成号,471 测试全绿 |
160
+ | v0.3.6–0.3.8 | ✅ 完成 | 镜像一致性 + 审计加固 | generation 同步状态机、audit peer 6 项运行时阻断修复、450 测试全绿 |
161
+ | v0.3.9 | 完成 | 审计加固 A/B/D/F | compareAndUpdate 同事务原子性、degraded 回执、逐 type 物理终态、整数 fail-closed、并发初始化稳定 |
162
+ | **v0.4.0** | ✅ 完成 | 系统级睡眠 Sleep Mode | 空闲触发的四阶段深度维护(冲突消解 / 归档降级 / 模式发现 / 关系补全)、分层压缩、可中断串行 fail-safe;471 测试全绿 |
163
+ | **v0.4.2** | ✅ 完成 | autoSummarize 自定义模型 | `summarizeProvider`/`summarizeModel` 配置项支持,可独立指定轻量模型(如 qwen3.6-plus)用于会话摘要,节省主模型 token;473 测试全绿 |
132
164
  | **v0.5.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
133
165
 
134
- > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为 v0.4.0 铺好路。
166
+ > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
135
167
 
136
168
  ## 📦 安装
137
169
 
@@ -191,6 +223,7 @@ dsh web
191
223
  | `memoryDir` | `~/.dsh/memory` | 记忆存储目录(SQLite + Markdown) |
192
224
  | `autoInject` | `true` | 会话启动自动注入记忆 |
193
225
  | `autoSummarize` | `true` | 会话结束自动提炼摘要 |
226
+ | `summarizeProvider` / `summarizeModel` | 空 | 摘要的 LLM 路由覆盖(空=使用当前会话模型);推荐轻量模型节省主模型 token |
194
227
  | `maxInjectedItems` | `5` | 最多注入几条记忆 |
195
228
  | `importanceThreshold` | `3` | 注入的最低重要性(1-5) |
196
229
  | `autoDream` | `true` | 自动记忆整理开关 |
@@ -198,7 +231,8 @@ dsh web
198
231
  | `dreamThresholdChars` | `5000` | 触发整理的总字符阈值 |
199
232
  | `dreamDelayMs` | `2000` | 整理异步延迟(去抖) |
200
233
  | `dreamProvider` / `dreamModel` | 空 | dream 的 LLM 路由回退(默认用 agent 默认模型) |
201
- | `dreamMaxTokens` | `4096` | dream LLM 调用最大 token |
234
+ | `dreamMaxTokens` | `4096` | dream LLM 调用最大 token 数(上限 131072;大记忆量建议调大,见下方调优指南) |
235
+ | `dreamReasoningEffort` | `none` | dream LLM 推理强度透传:`low` / `medium` / `high` / `none`(`none`=不传该字段,使用模型默认;思考型模型预算被推理耗尽导致正文为空时可设 `low`) |
202
236
  | `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer <apiToken>` |
203
237
  | `embedProvider` | `openai` | 语义后端:`openai`(默认,兼容 v0.1)/ `local`(ONNX 离线)/ `ollama` |
204
238
  | `localEmbedModel` | `Xenova/bge-small-zh-v1.5` | 本地 ONNX embedding 模型 |
@@ -270,7 +304,7 @@ src/
270
304
  lib/
271
305
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
272
306
  └── *.js # src 的同步分发产物
273
- test/ # 447 个 node:test 测试(含审计与三轴线压测不变量)
307
+ test/ # 450 个 node:test 测试(含审计与三轴线压测不变量)
274
308
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
275
309
  ```
276
310
 
@@ -279,7 +313,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
279
313
  ```bash
280
314
  cd dsh-mneme
281
315
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
282
- npm test # 运行 447 个测试
316
+ npm test # 运行 450 个测试
283
317
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
284
318
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
285
319
  ```
package/lib/api.js CHANGED
@@ -354,7 +354,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
354
354
  });
355
355
 
356
356
  return {
357
- routes: 7,
357
+ routes: 9,
358
358
  dispose: () => {
359
359
  for (const dispose of disposers) dispose();
360
360
  }
package/lib/config.js CHANGED
@@ -4,6 +4,11 @@ export const Config = z.object({
4
4
  memoryDir: z.string().default("~/.dsh/memory"),
5
5
  autoInject: z.boolean().default(true),
6
6
  autoSummarize: z.boolean().default(true),
7
+ // Optional model override for summarization. When both are non-empty, they
8
+ // take priority over the session's current model. Empty = use the session's
9
+ // active provider/model (same as before).
10
+ summarizeProvider: z.string().default(""),
11
+ summarizeModel: z.string().default(""),
7
12
  maxInjectedItems: z.natural().min(1).max(20).default(5),
8
13
  importanceThreshold: z.natural().min(1).max(5).default(3),
9
14
  autoDream: z.boolean().default(true),
@@ -12,7 +17,18 @@ export const Config = z.object({
12
17
  dreamDelayMs: z.natural().min(0).max(60000).default(2000),
13
18
  dreamProvider: z.string(),
14
19
  dreamModel: z.string(),
15
- dreamMaxTokens: z.natural().min(256).max(32768).default(4096),
20
+ dreamMaxTokens: z.natural().min(256).max(131072).default(4096),
21
+ // Pass-through reasoning effort for dream's LLM calls. 'none' (default)
22
+ // omits the field so the provider's own default applies; low/medium/high
23
+ // are forwarded verbatim. Useful to cap reasoning spend on thinking-type
24
+ // models that would otherwise drain the whole token budget and return an
25
+ // empty body ("no json array in llm output").
26
+ dreamReasoningEffort: z.union([
27
+ z.const("low"),
28
+ z.const("medium"),
29
+ z.const("high"),
30
+ z.const("none")
31
+ ]).default("none"),
16
32
  // Rule version for dream adjudication: when this bumps, older dream_runs
17
33
  // degrade to historical evidence (their receipts no longer drive live
18
34
  // decisions). Default 0 = no versioning in use yet.
@@ -92,31 +108,50 @@ export const Config = z.object({
92
108
  // Prefix/semantic search over entity names (used by recall).
93
109
  entitySearchEnabled: z.boolean().default(true),
94
110
 
95
- // --- system-level sleep (v0.4.1) -----------------------------------------
96
- // Opt-in: when false (default) the plugin never runs a sleep cycle, so the
97
- // access-touch bookkeeping on recall/inject paths stays off too. Sleep is
98
- // three phases: conflict resolution (reuses the dream conflict machinery),
99
- // archival demotion (unrecalled memories tier down to summary then archive),
100
- // and pattern discovery (LLM scans recent memories and mints type=pattern
101
- // entries with evidence references).
102
- sleepEnabled: z.boolean().default(false),
103
- // A sleep run only fires when the store has been idle for this long and the
104
- // last run is older than sleepMinIntervalHours. Idle detection replaces a
105
- // cron-like schedule (DSH plugins have no resident crontab).
106
- sleepIdleMinutes: z.natural().min(1).max(1440).default(30),
111
+ // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
112
+ // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
113
+ // sleep fires when the store has been quiet for sleepIdleMinutes and deep-
114
+ // maintains the whole library: conflict resolution, archival demotion,
115
+ // pattern discovery and entity relation completion. Abortable on user
116
+ // activity, audited into dream_runs (run_type='sleep'), and serialized with
117
+ // autoDream so the two never overlap.
118
+ sleepModeEnabled: z.boolean().default(false),
119
+ // Quiet window before a cycle fires (minutes).
120
+ sleepIdleMinutes: z.natural().min(1).max(60).default(5),
121
+ // Minimum gap between two sleep runs (hours) a second idle window within
122
+ // this interval does not retrigger.
107
123
  sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
108
- // Unrecalled (COALESCE(last_accessed_at, updated_at, created_at)) beyond
109
- // sleepArchiveDays demote: full body moves to _full_content, content
110
- // becomes a one-line summary. Beyond sleepDeepArchiveDays → archive.
111
- sleepArchiveDays: z.natural().min(1).max(365).default(30),
112
- sleepDeepArchiveDays: z.natural().min(1).max(3650).default(90),
113
- // How many most-recent memories the pattern-discovery pass scans.
114
- sleepPatternScanCount: z.natural().min(10).max(500).default(100),
115
- // Max patterns minted per sleep run (mirrors decisions maxCreatePerRun).
116
- sleepMaxPatterns: z.natural().min(1).max(20).default(5),
117
- // Optional LLM route override; empty = fall back to agentDefaultModel then
118
- // the dream route. Distinct from dreamProvider/dreamModel so the sleep pass
119
- // can pin a cheaper model for its bulk summarization.
124
+ // Conflict adjudication strictness:
125
+ // gentle only high-confidence conflicts (threshold 0.92) are resolved
126
+ // normal standard dream-level (threshold 0.85)
127
+ // aggressive low-confidence pairs are also adjudicated (threshold 0.75)
128
+ sleepConflictStrictness: z.union([
129
+ z.const("gentle"),
130
+ z.const("normal"),
131
+ z.const("aggressive")
132
+ ]).default("normal"),
133
+ // Archival demotion tiering (days since last access):
134
+ // >= sleepArchiveDays → shrink to summary, full body kept in _full_content
135
+ // >= sleepCompressDays archived outright (entity relations preserved)
136
+ sleepArchiveDays: z.natural().min(7).max(365).default(30),
137
+ sleepCompressDays: z.natural().min(7).max(365).default(90),
138
+ // Pattern discovery scan window (most recent memories to scan).
139
+ sleepPatternMinMemories: z.natural().min(10).max(1000).default(100),
140
+ // How far back pattern discovery considers entity attr changes (days).
141
+ sleepPatternLookbackDays: z.natural().min(1).max(90).default(30),
142
+ // Max pattern memories minted per run (0 = disabled).
143
+ sleepMaxPatternPerRun: z.natural().min(0).max(10).default(3),
144
+ // Optional LLM route override for sleep's bulk passes (empty = use dream
145
+ // route / agent default model).
120
146
  sleepProvider: z.string().default(""),
121
147
  sleepModel: z.string().default(""),
148
+ // Pass-through reasoning effort for sleep's LLM passes, same semantics as
149
+ // dreamReasoningEffort: 'none' (default) omits the field; low/medium/high
150
+ // are forwarded verbatim.
151
+ sleepReasoningEffort: z.union([
152
+ z.const("low"),
153
+ z.const("medium"),
154
+ z.const("high"),
155
+ z.const("none")
156
+ ]).default("none"),
122
157
  });
@@ -1,9 +1,5 @@
1
1
  const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
2
2
 
3
- // create is used by sleep pattern discovery (v0.4.1). It fabricates a new
4
- // memory of any known type (default pattern) rather than touching existing ids.
5
- const CREATE_TYPES = new Set(["pattern", "preference", "project", "decision", "history", "summary"]);
6
-
7
3
  /**
8
4
  * Validate a dream decision list against a snapshot of eligible memories.
9
5
  * @param decisions - LLM-produced decision list.
@@ -13,7 +9,6 @@ const CREATE_TYPES = new Set(["pattern", "preference", "project", "decision", "h
13
9
  export function validateDecisions(decisions, snapshot, options = {}) {
14
10
  const errors = [];
15
11
  const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
16
- const maxCreatePerRun = options.maxCreatePerRun ?? 5;
17
12
  const minAgeHours = options.minAgeHours ?? 24;
18
13
  if (!Array.isArray(decisions) || decisions.length === 0) {
19
14
  return { ok: false, errors: ["decision list must be a non-empty array"] };
@@ -25,9 +20,16 @@ export function validateDecisions(decisions, snapshot, options = {}) {
25
20
  errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
26
21
  continue;
27
22
  }
28
- // create claims no existing id: it fabricates a new memory, so it runs its
29
- // own field validation and skips the ids-required check + the claimed set.
30
- if (d.action === "create") {
23
+ const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
24
+ if (d.action === "conflict") {
25
+ if (!d.winner || !d.loser || d.winner === d.loser) {
26
+ errors.push(`${at}: conflict needs distinct winner and loser`);
27
+ continue;
28
+ }
29
+ } else if (d.action === "create") {
30
+ // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
31
+ // so it skips the claiming loop below; evidence is optional provenance
32
+ // (already filtered to real ids by the caller) and is stored in content.
31
33
  if (typeof d.title !== "string" || !d.title.trim()) {
32
34
  errors.push(`${at}: create needs non-empty title`);
33
35
  continue;
@@ -38,28 +40,11 @@ export function validateDecisions(decisions, snapshot, options = {}) {
38
40
  }
39
41
  if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
40
42
  errors.push(`${at}: create importance must be an integer 1-5 when provided`);
41
- continue;
42
- }
43
- if (d.type !== undefined && (typeof d.type !== "string" || !CREATE_TYPES.has(d.type))) {
44
- errors.push(`${at}: create type must be one of ${[...CREATE_TYPES].join(", ")}`);
45
- continue;
46
43
  }
47
- if (d.evidence !== undefined && !Array.isArray(d.evidence)) {
48
- errors.push(`${at}: create evidence must be an array of memory ids`);
49
- continue;
50
- }
51
- if (d.tags !== undefined && !Array.isArray(d.tags)) {
52
- errors.push(`${at}: create tags must be an array`);
53
- continue;
44
+ if (typeof d.type !== "string" || !d.type.trim()) {
45
+ errors.push(`${at}: create needs non-empty type`);
54
46
  }
55
47
  continue;
56
- }
57
- const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
58
- if (d.action === "conflict") {
59
- if (!d.winner || !d.loser || d.winner === d.loser) {
60
- errors.push(`${at}: conflict needs distinct winner and loser`);
61
- continue;
62
- }
63
48
  } else if (!Array.isArray(d.ids) || d.ids.length === 0) {
64
49
  errors.push(`${at}: ${d.action} needs non-empty ids`);
65
50
  continue;
@@ -129,9 +114,9 @@ export function validateDecisions(decisions, snapshot, options = {}) {
129
114
  if (updateCount > maxUpdatePerRun) {
130
115
  errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
131
116
  }
132
- // Cap create churn: a pattern-discovery loop fabricating endless new memories
133
- // would bloat the store, so a run can mint at most maxCreatePerRun.
117
+ // Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
134
118
  const createCount = decisions.filter((d) => d.action === "create").length;
119
+ const maxCreatePerRun = options.maxCreatePerRun ?? 5;
135
120
  if (createCount > maxCreatePerRun) {
136
121
  errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
137
122
  }
@@ -242,46 +227,31 @@ function applyOne(d, service, snapshot, config = {}) {
242
227
  case "archive": return applyArchive(d, service, snapshot);
243
228
  case "merge": return applyMerge(d, service, snapshot, config);
244
229
  case "conflict": return applyConflict(d, service, snapshot);
245
- case "create": return applyCreate(d, service, snapshot);
230
+ case "create": return applyCreate(d, service, config);
246
231
  default: return applyUpdate(d, service, snapshot, config);
247
232
  }
248
233
  }
249
234
 
250
235
  /**
251
- * Mint a new memory (sleep pattern discovery). saveWithDedupe dedupes by
252
- * (type, title) so a replayed create merges instead of duplicating — the
253
- * idempotency guard. Evidence ids are folded into tags as `ev:<id>` so a
254
- * pattern's provenance stays queryable after creation.
236
+ * Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
237
+ * Evidence ids ride in the content so a pattern stays traceable to its source
238
+ * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
255
239
  */
256
- function applyCreate(d, service, snapshot) {
257
- const evidence = Array.isArray(d.evidence) ? d.evidence : [];
258
- const tags = [
259
- ...(Array.isArray(d.tags) ? d.tags : []),
260
- ...evidence.map((id) => `ev:${id}`)
261
- ];
262
- const result = service.saveWithDedupe({
263
- type: d.type ?? "pattern",
264
- title: d.title,
265
- content: d.content,
266
- importance: d.importance ?? 3,
267
- tags,
268
- source: "dream-create"
269
- });
270
- if (!result?.memory) return "skipped";
271
- return {
272
- applied: 1,
273
- committed: {
274
- action: "create",
275
- id: result.memory.id,
276
- type: d.type ?? "pattern",
277
- title: d.title,
278
- content: d.content,
279
- importance: d.importance ?? 3,
280
- evidence,
281
- count_before: 0,
282
- count_after: 1
283
- }
284
- };
240
+ function applyCreate(d, service, config = {}) {
241
+ const title = String(d.title ?? "").trim();
242
+ const content = String(d.content ?? "").trim();
243
+ const importance = Number.isInteger(d.importance) ? d.importance : 3;
244
+ const type = typeof d.type === "string" ? d.type : "pattern";
245
+ const evidence = Array.isArray(d.evidence)
246
+ ? d.evidence.filter((id) => typeof id === "string")
247
+ : [];
248
+ const body = evidence.length > 0
249
+ ? `${content}\n\n[证据: ${evidence.join(", ")}]`
250
+ : content;
251
+ const created = service.saveWithDedupe({ type, title, content: body, importance });
252
+ const memory = created?.memory;
253
+ if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
254
+ return { applied: 1, committed: { action: "create", id: memory.id, type } };
285
255
  }
286
256
 
287
257
  function applyArchive(d, service, snapshot) {