dsh-prime-memory 0.26.12 → 0.26.13

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.13] — 2026-10-28
4
+
5
+ > 修复 issue #19:单条 L1 priority 越界(如 LLM 回 `-1`)经无夹取写点落库后,`memoryRecall` 投影的 `viewSchema` 严格校验(判非负整数)抛错 → 宿主把整会话包成 `SESSION_QUERY_CORRUPT_SESSION` → **一条脏数据打死整个会话历史**。
6
+
7
+ ### 修复
8
+
9
+ - **双防线**:
10
+ - **写侧夹取(防新增)**:新建 `src/util/priority.ts` 单源 `clampPriority`(非有限→回落缺省、小数 `Math.round`、夹到整数 `[0,100]`),L1 抽取/合并构造(`pipeline/l1.ts` 两处)、upsert 绑定层(`store/sqlite.ts`,保留历史缺省 50)、MD 导入解析(`store/memory-md-parse.ts`)统一走同一夹取。
11
+ - **读侧兜底(自愈存量)**:投影 `makeView` 遇脏 priority 就地夹取并 `warn`(带 record id + 原值),绝不让单条脏字段打死整会话。因升级必重启宿主、`viewMemo`(进程内 WeakMap)随之清空,脏行读时被夹取 → **存量脏数据无需手动改库、无需迁移**。
12
+ - 保留 `viewSchema` 严格校验(纵深防御仍在,只是喂进去的已是干净值);合法 0–100 整数读写逐字不变;`slots.ts` 的同名私有 `clampPriority`(floor/50,memorySlots 另一条链)不受影响。
13
+ - 本版叠加在 0.26.12(DSH 0.2.1-rc.4 适配)之上,`latest` 与 `dsh-0.2.1-rc.4` dist-tag 同指本版。
14
+
3
15
  ## [0.26.12] — 2026-10-28
4
16
 
5
17
  > 0.26.12: host enumeration addendum for DSH 0.2.1-rc.4 (declaration-only bump; contract plane zero diff). `engines.dsh`/`dsh.plugin.json`/README 同串补 `0.2.1-rc.4` 枚举行(19 档)。
package/dist/index.js CHANGED
@@ -497,7 +497,7 @@ export async function apply(ctx, config) {
497
497
  // memoryRecall 投影:本会话“已注入且未被压缩覆盖”的召回集(会话感知,init 取 header.id),
498
498
  // 与常驻槽位是两条轴:那个是手动 pin 的硬注入约定,这个是非常驻的相关性浮现结果。
499
499
  // 带上反馈面:条目要显示人已经打过的那一笔(rating)。
500
- registerRecallProjection(ctx, stores.recalled, stores.l1, recallFeedback);
500
+ registerRecallProjection(ctx, stores.recalled, stores.l1, recallFeedback, logger);
501
501
  // 人打 -1 声明过时后,下一轮向模型一次性播报“这条已退场、别再依据它作答”。
502
502
  registerObsoleteNotice(ctx, config, obsoleteNotices, logger);
503
503
  void slotRecall;
@@ -14,6 +14,7 @@ import { buildConflictPair, conflictRejectId, DEFER_MAX, normalizeClaimKey, norm
14
14
  import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
15
15
  import { normTags, normUsageScenario, readCognitiveHall, readWing } from '../metadata-validators.js';
16
16
  import { acceptTitle } from '../util/md-name.js';
17
+ import { clampPriority } from '../util/priority.js';
17
18
  import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
18
19
  import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
19
20
  import { stampValidity, validToSourceOf } from '../store/validity.js';
@@ -196,7 +197,7 @@ export function toStoreRecord(m, now, ts, anchorMap) {
196
197
  id: m.record_id,
197
198
  content: m.content,
198
199
  type: m.type,
199
- priority: Number(m.priority) || 60,
200
+ priority: clampPriority(m.priority),
200
201
  scene_name: m.scene_name,
201
202
  timestamps: [Number.isNaN(ts) ? now : ts],
202
203
  createdAt: now,
@@ -714,7 +715,7 @@ routeHealth) {
714
715
  id: m.record_id,
715
716
  content: mergedContent,
716
717
  type: decision.merged_type || m.type,
717
- priority: Number(decision.merged_priority) || Number(m.priority) || 60,
718
+ priority: clampPriority(decision.merged_priority ?? m.priority),
718
719
  scene_name: m.scene_name,
719
720
  timestamps: Array.from(new Set(mergedTs)).sort((a, b) => a - b),
720
721
  createdAt: now,
@@ -116,5 +116,7 @@ export interface RecallFeedbackFace {
116
116
  * apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
117
117
  * @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
118
118
  */
119
- export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face, feedback?: RecallFeedbackFace): void;
119
+ export declare function registerRecallProjection(ctx: Context, store: SessionRecalledStore, l1?: RecallL1Face, feedback?: RecallFeedbackFace, log?: {
120
+ warn(message: string): void;
121
+ }): void;
120
122
  export {};
@@ -1,5 +1,6 @@
1
1
  import { isRetired } from '../store/supersede.js';
2
2
  import { stateOfRecord } from '../store/validity.js';
3
+ import { clampPriority, isDirtyPriority } from '../util/priority.js';
3
4
  export const MEMORY_RECALL_KEY = 'memoryRecall';
4
5
  /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
5
6
  export const RECALL_VIEW_CAP = 20;
@@ -141,7 +142,7 @@ function sameIds(a, b) {
141
142
  }
142
143
  /** view 引用稳定:同一 state 恒返回同一对象(registry 按 Object.is 判发布)。 */
143
144
  const viewMemo = new WeakMap();
144
- function makeView(state, l1, feedback) {
145
+ function makeView(state, l1, feedback, log) {
145
146
  const memoized = viewMemo.get(state);
146
147
  if (memoized !== undefined)
147
148
  return memoized;
@@ -163,11 +164,16 @@ function makeView(state, l1, feedback) {
163
164
  // 未退场行才会真判过时两档(退场行由 retired 键表达,validity 不再叠加)。
164
165
  const vState = isRetired(r) ? undefined : stateOfRecord(r, Date.now());
165
166
  const showValidity = vState === 'expired' || vState === 'expired_suspect';
167
+ // #19 读侧兜底:脏 priority(负/超界/非整数/非有限)就地夹取,绝不让 viewSchema
168
+ // 因单条脏字段抛错打死整会话;命中时 warn 留痕(静默夹取会掩盖上游 LLM 异常)。
169
+ const rawPriority = r.priority;
170
+ if (isDirtyPriority(rawPriority))
171
+ log?.warn(`[memory][recall] 脏 priority 回落 record=${r.id} raw=${String(rawPriority)}`);
166
172
  items.push({
167
173
  id: r.id,
168
174
  type: r.type,
169
175
  title: r.name || firstLine(r.content),
170
- priority: r.priority,
176
+ priority: clampPriority(rawPriority),
171
177
  body: clampBody(r.content),
172
178
  ...(scenario !== undefined && scenario.length > 0 ? { scenario } : {}),
173
179
  ...(typeof rating === 'number' ? { rating } : {}),
@@ -196,7 +202,7 @@ function sessionIdOf(header) {
196
202
  * apply 是 O(1) 判脏(store.revision() 与反馈序号比对),任意已提交事件都会带进来看一眼。
197
203
  * @param feedback - 相关度反馈只读面;缺席时 view 不带 rating(旧行为)。
198
204
  */
199
- export function registerRecallProjection(ctx, store, l1, feedback) {
205
+ export function registerRecallProjection(ctx, store, l1, feedback, log) {
200
206
  ctx.inject(['sessionProjections'], (injected) => {
201
207
  const registry = injected.sessionProjections;
202
208
  if (registry === undefined || typeof registry.register !== 'function')
@@ -208,9 +214,9 @@ export function registerRecallProjection(ctx, store, l1, feedback) {
208
214
  stateSchema,
209
215
  init: (header) => buildState(sessionIdOf(header), store, fbRev()),
210
216
  apply: (state) => buildState(state.sessionId, store, fbRev(), state),
211
- wire: { viewSchema, view: (state) => makeView(state, l1, feedback) },
217
+ wire: { viewSchema, view: (state) => makeView(state, l1, feedback, log) },
212
218
  schema: viewSchema,
213
- view: (state) => makeView(state, l1, feedback),
219
+ view: (state) => makeView(state, l1, feedback, log),
214
220
  stateVersion: 0,
215
221
  };
216
222
  registry.register(definition);
@@ -13,6 +13,7 @@
13
13
  * 与正文本体是否一致(`hashValid`),不替调用方决定怎么处置——那属于同步侧的裁决。
14
14
  */
15
15
  import { contentHash } from './memory-md.js';
16
+ import { clampPriority } from '../util/priority.js';
16
17
  /** 取 frontmatter 块:首行必须是 `---`,到下一个 `---` 为止。 */
17
18
  function splitFrontmatter(text) {
18
19
  // BOM 与 CRLF 都要先吃掉:人在记事本里改一行就会引入 \r,而 `\r` 会让 key 匹配不上。
@@ -71,7 +72,7 @@ export function parseMdFile(text) {
71
72
  return {
72
73
  name: str(fields.get('name')),
73
74
  type: str(fields.get('type')),
74
- priority: Number.isFinite(prio) ? prio : 60,
75
+ priority: clampPriority(prio),
75
76
  createdAt: str(fields.get('createdAt')),
76
77
  updatedAt: str(fields.get('updatedAt')),
77
78
  usageScenario: scenario,
@@ -26,6 +26,7 @@ import { normApplicability } from '../repo-scope.js';
26
26
  import { normalizeWorkspacePath } from '../workspace.js';
27
27
  import { bm25RankToScore, buildFtsQuery, tokenizeForFts } from './search-utils.js';
28
28
  import { describeTokenizer, ensureTokenizer, tokenizerStamp } from '../util/tokenizer.js';
29
+ import { clampPriority } from '../util/priority.js';
29
30
  const require = createRequire(import.meta.url);
30
31
  const TAG = '[memory][sqlite]';
31
32
  /**
@@ -992,7 +993,7 @@ export class MemoryDb {
992
993
  // 无法绑定(node:sqlite 拒绝绑定),曾致旧版导入逐条全挂、每次启动无限重试。
993
994
  // 主表与 FTS 两条语句共用同源归一化值;type 归一化后 familyForType 也不再收到 undefined。
994
995
  const type = record.type ?? '';
995
- const priority = record.priority ?? 50;
996
+ const priority = clampPriority(record.priority, 50);
996
997
  const sceneName = record.scene_name ?? '';
997
998
  const family = record.family ?? familyForType(type);
998
999
  // §E 写入侧兜底归一,并维持一条**不变量**:`scope='global'` 的记录 `workspace_id` 恒为空串。
@@ -0,0 +1,22 @@
1
+ /**
2
+ * L1 记忆 priority 的归一单源(issue #19)。
3
+ *
4
+ * 存在理由:priority 是磁盘契约列,且被 memoryRecall 投影的 `viewSchema` 严格校验
5
+ * (`requireCounter` 判非负整数)。任一写入口把 LLM 回传的负数/超界/小数原样落库,
6
+ * 投影出网即抛 → 宿主把整会话包成 `SESSION_QUERY_CORRUPT_SESSION` → **一条脏数据
7
+ * 打死整个会话历史**。故所有喂进 `l1_records.priority` 的路径必须过同一夹取。
8
+ *
9
+ * 语义(用户选定):非有限值→`fallback`;小数 `Math.round`;结果夹到整数 `[0,100]`。
10
+ * `fallback` 形参是为保留各写点既有的缺省差异(upsert 绑定层历史上用 50,抽取用 60),
11
+ * 避免为修范围而把某处的默认值静默漂移。
12
+ *
13
+ * 注意:**不动** `store/slots.ts` 里的同名私有 `clampPriority`(floor/50,服务 memorySlots
14
+ * 另一条链);本文件只服务 L1 记忆 priority。
15
+ */
16
+ export declare const PRIORITY_DEFAULT = 60;
17
+ export declare const PRIORITY_MIN = 0;
18
+ export declare const PRIORITY_MAX = 100;
19
+ /** 夹取到 `[PRIORITY_MIN, PRIORITY_MAX]` 的整数;非有限值回落 `fallback`。 */
20
+ export declare function clampPriority(value: unknown, fallback?: number): number;
21
+ /** 是否会被夹取改变(负 / 超界 / 非整数 / 非有限 / 缺失)——供读侧判定要不要 warn。 */
22
+ export declare function isDirtyPriority(value: unknown): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * L1 记忆 priority 的归一单源(issue #19)。
3
+ *
4
+ * 存在理由:priority 是磁盘契约列,且被 memoryRecall 投影的 `viewSchema` 严格校验
5
+ * (`requireCounter` 判非负整数)。任一写入口把 LLM 回传的负数/超界/小数原样落库,
6
+ * 投影出网即抛 → 宿主把整会话包成 `SESSION_QUERY_CORRUPT_SESSION` → **一条脏数据
7
+ * 打死整个会话历史**。故所有喂进 `l1_records.priority` 的路径必须过同一夹取。
8
+ *
9
+ * 语义(用户选定):非有限值→`fallback`;小数 `Math.round`;结果夹到整数 `[0,100]`。
10
+ * `fallback` 形参是为保留各写点既有的缺省差异(upsert 绑定层历史上用 50,抽取用 60),
11
+ * 避免为修范围而把某处的默认值静默漂移。
12
+ *
13
+ * 注意:**不动** `store/slots.ts` 里的同名私有 `clampPriority`(floor/50,服务 memorySlots
14
+ * 另一条链);本文件只服务 L1 记忆 priority。
15
+ */
16
+ export const PRIORITY_DEFAULT = 60;
17
+ export const PRIORITY_MIN = 0;
18
+ export const PRIORITY_MAX = 100;
19
+ /** 夹取到 `[PRIORITY_MIN, PRIORITY_MAX]` 的整数;非有限值回落 `fallback`。 */
20
+ export function clampPriority(value, fallback = PRIORITY_DEFAULT) {
21
+ const sanitize = (x) => Math.max(PRIORITY_MIN, Math.min(PRIORITY_MAX, Math.round(x)));
22
+ // null/undefined 显式回落(Number(null) 得 0,若不当缺省处理会把旧 `?? 50` 漂移成 0)。
23
+ if (value === null || value === undefined)
24
+ return sanitize(fallback);
25
+ const n = typeof value === 'number' ? value : Number(value);
26
+ if (!Number.isFinite(n))
27
+ return sanitize(fallback);
28
+ return sanitize(n);
29
+ }
30
+ /** 是否会被夹取改变(负 / 超界 / 非整数 / 非有限 / 缺失)——供读侧判定要不要 warn。 */
31
+ export function isDirtyPriority(value) {
32
+ if (value === null || value === undefined)
33
+ return true;
34
+ const n = typeof value === 'number' ? value : Number(value);
35
+ return !Number.isInteger(n) || n < PRIORITY_MIN || n > PRIORITY_MAX;
36
+ }
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.12",
4
+ "version": "0.26.13",
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.12",
3
+ "version": "0.26.13",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",