dsh-prime-memory 0.26.14 → 0.26.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # 更新日志(Changelog)
2
2
 
3
+ ## [0.26.15] — 2026-10-28
4
+
5
+ > 真宿主验证(1197 条 L1 / 380 条 `work_method` 的真实库副本)发现 0.26.14 的**写放大**缺陷,本版修掉。单测没抱住它,因为夹具只有几条记录。
6
+
7
+ ### 修复
8
+
9
+ - **证据整批落盘**:`absorbGroup` 原先逐条 `await appendEvidence()`,而每次调用都是一次全文件重写 → 首轮回灌 379 条证据 = 379 次落盘。新增 `SkillStore.appendEvidenceBatch(rows)`(整批一次 persist),生产者改走该入口。
10
+ - **空跑不写盘**:对已存在候选原先无条件 `update({ steps })`,导致一轮回灌即使 `created=0 / appended=0` 仍把 sidecar 全量重写 240 次(实测 `rev` 从 1262 跳到 1742)。现在先 `sameSteps` 比对,未变则跳过。
11
+ - **晋升时序修正**:`tryPromote` 改到证据**整批落盘之后**逐个判,否则 `sessionCount` 看不到本轮新会话。
12
+ - 新增用例「空跑不写盘」(spy 断言 `update`/`appendEvidenceBatch` 零调用);全量 **1859 例**/186 文件绿。
13
+ - 功能面与 0.26.14 一致(生产者 + backfill + 诚实化),无配置变更、无默认行为变更。
14
+
3
15
  ## [0.26.14] — 2026-10-28
4
16
 
5
17
  > 修复 issue #15:「技能结晶」自上线以来**根本没有生产者**——`detectCandidates` 与 `SkillStore.create()` 在全仓**只有测试调用点**,所以 `skills.json` 永远不会被生成,看板恒为 0。用户打开 `skillsEnabled` 开关后拿到的是一个空盒子,而且不报错、不提示,只能翻源码才知道功能没接上。
@@ -93,6 +93,15 @@ function titleOf(g) {
93
93
  const label = g.hall && g.hall !== 'general' ? `${g.hall} 流程` : g.sceneName ? `${g.sceneName} 流程` : '通用流程';
94
94
  return `技能:${label}`;
95
95
  }
96
+ /** 两个 steps 数组逐字相等(用于跳过无变化的重写)。 */
97
+ function sameSteps(a, b) {
98
+ if (a.length !== b.length)
99
+ return false;
100
+ for (let i = 0; i < a.length; i++)
101
+ if (a[i] !== b[i])
102
+ return false;
103
+ return true;
104
+ }
96
105
  /**
97
106
  * 建一个技能生产者。
98
107
  *
@@ -116,15 +125,19 @@ export function createSkillProducer(deps) {
116
125
  errors: [],
117
126
  });
118
127
  /**
119
- * 把一组视图落成「一个候选 + 若干证据」,并按需尝试晋升。
128
+ * 把一组视图落成「一个候选 + 若干证据」。
129
+ *
130
+ * 写入节奏(真机量测后调整):本函数**不逐条落盘**——证据先攒进 `pending`
131
+ * 由调用方整批一次写,steps 只在真变化时才 `update`。否则一轮空跑也会把
132
+ * sidecar 全量重写几百次(`rev` 跳涨就是这么被发现的)。
120
133
  * 返回 false 表示应当中止外层循环(达 SKILLS_CAP)。
121
134
  */
122
- const absorbGroup = async (g, runId, cfg, s) => {
135
+ const absorbGroup = async (g, runId, s, pending, seen, touched, at) => {
123
136
  const { steps, recordIds } = dedupe(g.views);
124
137
  if (steps.length === 0)
125
138
  return true;
126
- let exists = skills.get(g.id) !== undefined;
127
- if (!exists) {
139
+ const existing = skills.get(g.id);
140
+ if (existing === undefined) {
128
141
  try {
129
142
  await skills.create({
130
143
  id: g.id,
@@ -137,7 +150,6 @@ export function createSkillProducer(deps) {
137
150
  evidenceRecordIds: recordIds,
138
151
  });
139
152
  s.created += 1;
140
- exists = true;
141
153
  }
142
154
  catch (err) {
143
155
  const msg = err instanceof Error ? err.message : String(err);
@@ -152,11 +164,13 @@ export function createSkillProducer(deps) {
152
164
  logger?.warn(`[memory] 技能候选创建失败(${g.id}): ${msg}`);
153
165
  return true;
154
166
  }
155
- // 并发下另一轮刚建好:当作已存在,继续走证据路径
156
- exists = true;
167
+ // 并发下另一轮刚建好:重新取一次,当作已存在继续走证据路径
168
+ const again = skills.get(g.id);
169
+ if (again && !sameSteps(again.steps, steps))
170
+ await skills.update(g.id, { steps });
157
171
  }
158
172
  }
159
- else {
173
+ else if (!sameSteps(existing.steps, steps)) {
160
174
  // 机器侧只重写 steps;body 是人编辑区,传进去就破坏了分列不变量。
161
175
  try {
162
176
  await skills.update(g.id, { steps });
@@ -166,36 +180,39 @@ export function createSkillProducer(deps) {
166
180
  }
167
181
  }
168
182
  s.candidatesTouched += 1;
169
- // 证据:sessionCount 的唯一来源。三元组去重,重复轮次不重复投。
170
- const at = new Date().toISOString();
183
+ touched.push(g.id);
184
+ // 证据:sessionCount 的唯一来源。三元组去重(含本批内去重),重复轮次不重复投。
171
185
  for (const v of g.views) {
172
186
  if (v.sessionId === '')
173
187
  continue;
174
- if (skills.hasEvidence(g.id, v.sessionId, v.id))
188
+ const key = `${g.id}\u0000${v.sessionId}\u0000${v.id}`;
189
+ if (seen.has(key) || skills.hasEvidence(g.id, v.sessionId, v.id))
175
190
  continue;
176
- try {
177
- await skills.appendEvidence({ skillId: g.id, runId, recordId: v.id, sessionId: v.sessionId, at, kind: EVIDENCE_KIND });
178
- s.appended += 1;
179
- }
180
- catch (err) {
181
- s.errors.push(`投证据失败: ${err instanceof Error ? err.message : String(err)}`);
182
- }
183
- }
184
- // 自动晋升:双阈同时满足才动(minEvidence 此前无人消费,这里让它真实生效)。
185
- // 人工晋升走 skills-action,只卡 sessionCount——人审是人的判断,不重复机械门。
186
- if (cfg.allowPromote && exists) {
187
- if (skills.sessionCount(g.id) >= cfg.minSessions && skills.evidenceCount(g.id) >= cfg.minEvidence) {
188
- if (await skills.tryPromote(g.id, cfg.minSessions, { allowPromote: true }))
189
- s.promoted += 1;
190
- }
191
+ seen.add(key);
192
+ pending.push({ skillId: g.id, runId, recordId: v.id, sessionId: v.sessionId, at, kind: EVIDENCE_KIND });
191
193
  }
192
194
  return true;
193
195
  };
194
196
  const drainGroups = async (groups, runId, cfg, s) => {
197
+ const pending = [];
198
+ const seen = new Set();
199
+ const touched = [];
200
+ const at = new Date().toISOString();
195
201
  for (const g of groups) {
196
- if (!(await absorbGroup(g, runId, cfg, s)))
202
+ if (!(await absorbGroup(g, runId, s, pending, seen, touched, at)))
197
203
  return;
198
204
  }
205
+ // 整批一次落盘;晋升必须在证据落盘后判,否则 sessionCount 看不到本轮新会话。
206
+ if (pending.length > 0)
207
+ s.appended = await skills.appendEvidenceBatch(pending);
208
+ if (!cfg.allowPromote)
209
+ return;
210
+ for (const id of touched) {
211
+ if (skills.sessionCount(id) >= cfg.minSessions && skills.evidenceCount(id) >= cfg.minEvidence) {
212
+ if (await skills.tryPromote(id, cfg.minSessions, { allowPromote: true }))
213
+ s.promoted += 1;
214
+ }
215
+ }
199
216
  };
200
217
  return {
201
218
  async ingestRound(records, sessionId, runId) {
@@ -98,6 +98,15 @@ export declare class SkillStore {
98
98
  evidenceCount(skillId: string): number;
99
99
  /** 某技能的 distinct session 计数(晋升判据的唯一来源,与 LLM 判断解耦)。 */
100
100
  sessionCount(skillId: string): number;
101
+ /**
102
+ * 批量追加证据:**整批一次持久化**,返回实际入库条数。
103
+ *
104
+ * 为何需要:`appendEvidence` 每条都 `await persist()`,而 persist 是全文件重写。
105
+ * 真机量测(1197 条 L1 / 380 条 work_method)下首轮回灌要投 379 条证据,
106
+ * 逐条写 = 379 次全量落盘(对已长到几百 KB 的 sidecar 是百 MB 级写放大)。
107
+ * 生产者改走本入口;逐条版保留给单次工具调用。
108
+ */
109
+ appendEvidenceBatch(rows: readonly SkillEvidence[]): Promise<number>;
101
110
  /**
102
111
  * 晋升:candidate→active,仅当功能开且该技能 distinct-session ≥ minSessions。
103
112
  * 返回是否发生晋升。不批量、不依赖 LLM(可解释性结构保证,同 conflictFreeze"不自动裁决"哲学)。
@@ -269,6 +269,34 @@ export class SkillStore {
269
269
  s.add(e.sessionId);
270
270
  return s.size;
271
271
  }
272
+ /**
273
+ * 批量追加证据:**整批一次持久化**,返回实际入库条数。
274
+ *
275
+ * 为何需要:`appendEvidence` 每条都 `await persist()`,而 persist 是全文件重写。
276
+ * 真机量测(1197 条 L1 / 380 条 work_method)下首轮回灌要投 379 条证据,
277
+ * 逐条写 = 379 次全量落盘(对已长到几百 KB 的 sidecar 是百 MB 级写放大)。
278
+ * 生产者改走本入口;逐条版保留给单次工具调用。
279
+ */
280
+ async appendEvidenceBatch(rows) {
281
+ this.ensureLoaded();
282
+ if (rows.length === 0)
283
+ return 0;
284
+ const known = new Set(this.skills.map((s) => s.id));
285
+ let added = 0;
286
+ for (const ev of rows) {
287
+ if (!ev || !known.has(ev.skillId) || !ev.sessionId || !ev.recordId)
288
+ continue;
289
+ this.evidence.push(ev);
290
+ added++;
291
+ }
292
+ if (added === 0)
293
+ return 0;
294
+ if (this.evidence.length > EVIDENCE_CAP)
295
+ this.evidence.splice(0, this.evidence.length - EVIDENCE_CAP);
296
+ this.rev++;
297
+ await this.persist();
298
+ return added;
299
+ }
272
300
  /**
273
301
  * 晋升:candidate→active,仅当功能开且该技能 distinct-session ≥ minSessions。
274
302
  * 返回是否发生晋升。不批量、不依赖 LLM(可解释性结构保证,同 conflictFreeze"不自动裁决"哲学)。
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-prime-memory",
3
3
  "name": "dsh-prime-memory",
4
- "version": "0.26.14",
4
+ "version": "0.26.15",
5
5
  "description": {
6
6
  "en": "Layered distilled memory plugin (L0 capture → L1 atomic memory → L2 scenes → L3 persona) with automatic recall injection before each model step.",
7
7
  "zh": "L0~L3 分层蒸馏记忆插件:自动捕获对话、抽取原子记忆、整合场景块、蒸馏核心画像,并在模型步骤前自动召回注入。"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prime-memory",
3
- "version": "0.26.14",
3
+ "version": "0.26.15",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",