From de9b8d55948b79cdbfb4eebe3892379449d3e91c Mon Sep 17 00:00:00 2001 From: modusensus Date: Wed, 30 Sep 2026 16:33:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?scope=20=E8=BD=AF=E5=8A=A0=E6=9D=83?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=88=B0=E6=B3=A8=E5=85=A5=E9=80=9A=E9=81=93?= =?UTF-8?q?=EF=BC=88issue=20#339=20/=20E7=20=E8=80=83=E5=8D=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同(80/80)——A2 的 ×0.5/×1.25 只作用于 searchMemories,自动注入这个主泄露面上软档形同虚设, 而 config.js:688 与 README 本就承诺「关闭时全部为软隔离」。本提交补齐语义: - injectCandidates:scopeEnabled 且会话至少一维可解析时,规则路比较器在 层内数值积乘 scopeMultiplier(priority 档位不动),selectiveInject 相似度 重排对真实 sim 乘同乘数(未命中项不乘,避免 foreign 缺失项反超自己的缺失项); 未激活时乘 1,排序与改动前逐字节一致 - strictScope 硬过滤保持在软加权之前,与检索侧叠加顺序一致 - inject.js 过时注释修正(scope 解析器恒返回对象,门控在 injectCandidates 内) - 测试:foreign 降序 / 未标注同列 BOOST / flag 关与匿名身份平价锁 / 硬墙先行 / 加权序流入 pin 池(5 例) - 文档:CONFIGURATION.md / README / config.js 注释同步 --- dsh-mneme/README.md | 2 +- dsh-mneme/docs/CONFIGURATION.md | 4 +- dsh-mneme/lib/config.js | 3 +- dsh-mneme/lib/inject.js | 5 +- dsh-mneme/lib/service.js | 21 +++++- dsh-mneme/src/config.js | 3 +- dsh-mneme/src/inject.js | 5 +- dsh-mneme/src/service.js | 21 +++++- dsh-mneme/test/scope-inject-soft.test.js | 93 ++++++++++++++++++++++++ 9 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 dsh-mneme/test/scope-inject-soft.test.js diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 042fe83..5bd0854 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -430,7 +430,7 @@ dsh web | `searchSemanticDedupThreshold` | `0.95` | 语义去重相似度阈值(v0.5.0,默认 0.95,范围 0.5-1.0):`searchSemanticDedup=true` 时生效,调整可防小模型误折叠 | | `memoryQualityFilter` | `{enabled:true, archiveThreshold:30, degradeThreshold:60, minContentLength:10, exemptImportance:4}` | 记忆质量过滤(v0.4.6,默认开):写库前启发式打分 0-100,元记忆词汇/自指/过短/重复/近似重复扣分;≥60 正常存储,30-60 降权(注入排序按 importance×quality/100),<30 归档标记 `low_quality`(显式搜索仍可召回,永不自动注入;`exemptImportance` 豁免:importance ≥ 该值只降权不归档,#135) | | `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(`autoDream` / `autoSummarize` / `sleep` / `entityExtract`,对应 `operation_type` 为 `dream_consolidate` / `dream_summarize` / `dream_narrative`、`summarize_compress`、`sleep_conflict` / `sleep_pattern`、`entity_extract`)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` | -| `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见)。也走 feature_flags 白名单(面板可启停) | +| `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索与注入排序按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见,issue #339 补齐注入通道)。也走 feature_flags 白名单(面板可启停) | | `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 | | `writeAdmission` | `{enabled:false, enforce:false}` | 写入准入(issue #254,默认关):把「这条该不该进库」前移到 LLM 之前。第 1 级是零 LLM 的确定性判据——空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入,本批只定义接口)。`enabled` 跑判据并落审计行(`llm_audit_logs` 的 `metadata.deny`),`enforce` 才真的拒(`store.save` 之前返回,`memory_save` 返回 `action:"denied"` + `reason`);只开 `enabled` = 仅告警、写入不拦。去重键命中不进第 1 级(归 write-update 放行),G1/G2 阈值只计量。也走 feature_flags 白名单 | | `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) | diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 0f72331..b72be5c 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -25,8 +25,8 @@ | 键 | 默认 | 作用 | 开启后果 / 冲突 | |---|---|---|---| -| `scopeEnabled` | `false` | 存储层总开关:写入标注 agent_scope / workspace_scope,去重键扩展 | opt-in;关 = 写入不标注、行为与 A1 前逐字节一致 | -| `strictScope` | `false` | 硬过滤模式(A3):他 scope 完全不可见、未标注恒可见(fail-closed) | 依赖 `scopeEnabled` 打开才有意义;关 = A2 软隔离(他 scope 降权保留可见) | +| `scopeEnabled` | `false` | 存储层总开关:写入标注 agent_scope / workspace_scope,去重键扩展;检索与注入排序按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见,issue #339 补齐注入通道) | opt-in;关 = 写入不标注、行为与 A1 前逐字节一致 | +| `strictScope` | `false` | 硬过滤模式(A3):他 scope 完全不可见、未标注恒可见(fail-closed);硬过滤先于软加权执行 | 依赖 `scopeEnabled` 打开才有意义;关 = A2 软隔离(他 scope 降权保留可见,检索与注入同权重) | ## 审计 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index b2018c7..c38fb3b 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -714,7 +714,8 @@ export const Config = z.object({ // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。 // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。 scopeEnabled: z.boolean().default(false), - // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见); + // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见, + // 检索与注入排序同权重,issue #339 补齐注入通道); // 开启后检索/注入/list/get 按 issue #17 四象限可见性公式硬过滤——带他 scope // 的记忆完全不可见,未标注(NULL)恒可见;当前会话某维度解析不到时该维度 // 带标注的记忆一律不可见(fail-closed:身份不明只见全局)。依赖 scopeEnabled diff --git a/dsh-mneme/lib/inject.js b/dsh-mneme/lib/inject.js index 43e5414..6ec3e84 100644 --- a/dsh-mneme/lib/inject.js +++ b/dsh-mneme/lib/inject.js @@ -374,8 +374,9 @@ export function createInjector(ctx, service, settings, config) { const query = lastUserQuery(ctx); if (query) prefetchQueryVector(query); const queryVector = queryVectorCache.get(query); - // v0.8.0 A3:scope 随请求解析(strict 开启时 injectCandidates 内硬过滤; - // flag 关闭时解析器返回 null,注入行为不变)。 + // v0.8.0 A3:scope 随请求解析(解析器恒返回对象,身份取不到时两维为 + // null);硬过滤与软加权的门控都在 injectCandidates 内——strict 看 + // strictScope,软加权看 scopeEnabled 且至少一维可解析(issue #339)。 const scope = resolveSessionScope(ctx); // Issue #205:跨轮轮换——最近 N 个查询轮次注入过的 id 本轮不再优先。 const sessionId = ctx?.agent?.session?.id ?? "_"; diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index ed7fd12..24e319c 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1507,6 +1507,14 @@ export function createService({ store, mirror, config, onWrite, logger, document ? new Map(filtered.map((m) => [m.id, computeHeat(m, Date.now(), config)])) : null; const heatOf = (m) => (heatMap ? heatMap.get(m.id) ?? 1 : 1); + // v0.8.x(issue #339 / E7 实测):A2 软加权补齐到注入通道。此前 ×0.5/×1.25 + // 只作用于 searchMemories——E7 考卷里「explicit 标注 + 软档」的注入集与无标注 + // 逐条相同(80/80),主泄露面上软档形同虚设。门控与检索侧同款:scopeEnabled + // 且当前会话至少一维可解析;strictScope 硬过滤在下方先行,硬墙开启时被滤行 + // 不会到这里被二次降权。未激活时乘 1,排序与改动前逐字节一致。 + const softScopeActive = config?.scopeEnabled === true && scope != null && + Boolean(scope.agent_scope || scope.workspace_scope); + const scopeMultOf = (m) => (softScopeActive ? scopeMultiplier(m, scope) : 1); const items = filtered.sort((a, b) => { // 编码记忆在编码任务时优先于普通 decision(与 preference 同级), // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。 @@ -1527,8 +1535,11 @@ export function createService({ store, mirror, config, onWrite, logger, document : m.importance; const pa = priority(a); const pb = priority(b); + // 软加权乘在层内数值积上(priority 档位不动):foreign ×0.5 后压不过 + // 同档自己行——E7 的同 importance 档设计正是这个场景。 return pa - pb || - (effImportance(b) * qualityWeight(b) * heatOf(b)) - (effImportance(a) * qualityWeight(a) * heatOf(a)); + (effImportance(b) * qualityWeight(b) * heatOf(b) * scopeMultOf(b)) - + (effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a)); }); let candidates = items; if (config.hybridInject !== false && q) { @@ -1603,7 +1614,13 @@ export function createService({ store, mirror, config, onWrite, logger, document const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 }); const sim = new Map(hits.map((m) => [m.id, m.score ?? 0])); if (sim.size) { - candidates = [...candidates].sort((a, b) => (sim.get(b.id) ?? -1) - (sim.get(a.id) ?? -1)); + // 软加权作用在真实 sim 上;未命中的规则候选保持 -1 沉底(缺失项 + // 乘乘数会让 foreign 缺失项反而排到自己的缺失项之上,故不乘)。 + const scopedSim = (m) => { + const s = sim.get(m.id); + return s === undefined ? -1 : s * scopeMultOf(m); + }; + candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a)); } } catch { /* topic re-rank unavailable: keep rule-based order */ } } diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index b2018c7..c38fb3b 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -714,7 +714,8 @@ export const Config = z.object({ // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。 // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。 scopeEnabled: z.boolean().default(false), - // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见); + // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见, + // 检索与注入排序同权重,issue #339 补齐注入通道); // 开启后检索/注入/list/get 按 issue #17 四象限可见性公式硬过滤——带他 scope // 的记忆完全不可见,未标注(NULL)恒可见;当前会话某维度解析不到时该维度 // 带标注的记忆一律不可见(fail-closed:身份不明只见全局)。依赖 scopeEnabled diff --git a/dsh-mneme/src/inject.js b/dsh-mneme/src/inject.js index 43e5414..6ec3e84 100644 --- a/dsh-mneme/src/inject.js +++ b/dsh-mneme/src/inject.js @@ -374,8 +374,9 @@ export function createInjector(ctx, service, settings, config) { const query = lastUserQuery(ctx); if (query) prefetchQueryVector(query); const queryVector = queryVectorCache.get(query); - // v0.8.0 A3:scope 随请求解析(strict 开启时 injectCandidates 内硬过滤; - // flag 关闭时解析器返回 null,注入行为不变)。 + // v0.8.0 A3:scope 随请求解析(解析器恒返回对象,身份取不到时两维为 + // null);硬过滤与软加权的门控都在 injectCandidates 内——strict 看 + // strictScope,软加权看 scopeEnabled 且至少一维可解析(issue #339)。 const scope = resolveSessionScope(ctx); // Issue #205:跨轮轮换——最近 N 个查询轮次注入过的 id 本轮不再优先。 const sessionId = ctx?.agent?.session?.id ?? "_"; diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index ed7fd12..24e319c 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1507,6 +1507,14 @@ export function createService({ store, mirror, config, onWrite, logger, document ? new Map(filtered.map((m) => [m.id, computeHeat(m, Date.now(), config)])) : null; const heatOf = (m) => (heatMap ? heatMap.get(m.id) ?? 1 : 1); + // v0.8.x(issue #339 / E7 实测):A2 软加权补齐到注入通道。此前 ×0.5/×1.25 + // 只作用于 searchMemories——E7 考卷里「explicit 标注 + 软档」的注入集与无标注 + // 逐条相同(80/80),主泄露面上软档形同虚设。门控与检索侧同款:scopeEnabled + // 且当前会话至少一维可解析;strictScope 硬过滤在下方先行,硬墙开启时被滤行 + // 不会到这里被二次降权。未激活时乘 1,排序与改动前逐字节一致。 + const softScopeActive = config?.scopeEnabled === true && scope != null && + Boolean(scope.agent_scope || scope.workspace_scope); + const scopeMultOf = (m) => (softScopeActive ? scopeMultiplier(m, scope) : 1); const items = filtered.sort((a, b) => { // 编码记忆在编码任务时优先于普通 decision(与 preference 同级), // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。 @@ -1527,8 +1535,11 @@ export function createService({ store, mirror, config, onWrite, logger, document : m.importance; const pa = priority(a); const pb = priority(b); + // 软加权乘在层内数值积上(priority 档位不动):foreign ×0.5 后压不过 + // 同档自己行——E7 的同 importance 档设计正是这个场景。 return pa - pb || - (effImportance(b) * qualityWeight(b) * heatOf(b)) - (effImportance(a) * qualityWeight(a) * heatOf(a)); + (effImportance(b) * qualityWeight(b) * heatOf(b) * scopeMultOf(b)) - + (effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a)); }); let candidates = items; if (config.hybridInject !== false && q) { @@ -1603,7 +1614,13 @@ export function createService({ store, mirror, config, onWrite, logger, document const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 }); const sim = new Map(hits.map((m) => [m.id, m.score ?? 0])); if (sim.size) { - candidates = [...candidates].sort((a, b) => (sim.get(b.id) ?? -1) - (sim.get(a.id) ?? -1)); + // 软加权作用在真实 sim 上;未命中的规则候选保持 -1 沉底(缺失项 + // 乘乘数会让 foreign 缺失项反而排到自己的缺失项之上,故不乘)。 + const scopedSim = (m) => { + const s = sim.get(m.id); + return s === undefined ? -1 : s * scopeMultOf(m); + }; + candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a)); } } catch { /* topic re-rank unavailable: keep rule-based order */ } } diff --git a/dsh-mneme/test/scope-inject-soft.test.js b/dsh-mneme/test/scope-inject-soft.test.js new file mode 100644 index 0000000..b297e24 --- /dev/null +++ b/dsh-mneme/test/scope-inject-soft.test.js @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; + +// issue #339 / E7 考卷补齐:A2 软加权(foreign ×0.5 / 命中 ×1.25)此前只作用于 +// searchMemories——E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同 +// (80/80),主泄露面上软档形同虚设。本文件锁注入通道的软加权语义: +// - softScope 激活(scopeEnabled + scope 至少一维可解析)→ foreign 行在层内 +// 数值积上 ×0.5,压不过同档自己行;未标注行同列吃 BOOST 不被压制; +// - flag 关 / scope 两维全空 → 注入序与改动前逐字节一致(锁平价); +// - strictScope 硬过滤先行,被滤行不会被二次降权; +// - 加权后的序流入 pin 池选取(pin 拿到的是加权后次序)。 + +function setup(config) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config }); + return { store, service }; +} + +const SCOPE_ME = { agent_scope: "me", workspace_scope: null }; + +test("injectCandidates soft weight demotes explicit foreign rows below same-importance own rows", () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: false }); + // 同 importance 档(E7 的同档设计):无软加权时全靠插入序,foreign 在前。 + store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" }); + store.save({ type: "decision", title: "foreign-b", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + + const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME }); + const titles = injected.map((m) => m.title); + // foreign ×0.5 = 2.0 < 自己 ×1.25 = 5.0:自己行第一;两条 foreign 同分, + // 相对序随 store.list 的同分序(updated_at),不锁。 + assert.equal(titles[0], "mine-a"); + assert.deepEqual(titles.slice(1).sort(), ["foreign-a", "foreign-b"]); +}); + +test("injectCandidates soft tier keeps unlabeled rows ranked with matches, not suppressed", () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: false }); + store.save({ type: "decision", title: "global", content: "x", importance: 4 }); + store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + + const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME }); + // 未标注 = 全局可见,与命中行同列(×1.25)而非被压到 foreign 之后。 + assert.deepEqual(injected.map((m) => m.title), ["global", "foreign"]); +}); + +test("injectCandidates order is byte-identical when scopeEnabled is off or scope is anonymous", () => { + const seed = (store) => { + store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" }); + store.save({ type: "decision", title: "global", content: "x", importance: 3 }); + }; + // flag 关:不传 scope(改动前后必须同序——锁平价)。 + const off = setup({ scopeEnabled: false, strictScope: false }); + seed(off.store); + // flag 开但身份两维全空:软加权门不激活,也不得改序。 + const anon = setup({ scopeEnabled: true, strictScope: false }); + seed(anon.store); + + // 平价锁:两种情况的注入序必须一致(比较器与改动前逐字节相同)。 + const baseline = off.service.injectCandidates({ maxItems: 5, threshold: 3 }).map((m) => m.title); + assert.deepEqual( + anon.service.injectCandidates({ maxItems: 5, threshold: 3, scope: { agent_scope: null, workspace_scope: null } }).map((m) => m.title), + baseline + ); +}); + +test("strictScope hard filter runs before soft weighting: filtered rows are not double-demoted", () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: true }); + store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + store.save({ type: "decision", title: "global", content: "x", importance: 3 }); + + const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME }); + // 硬墙先删 foreign;剩下的 global(未标注)吃 BOOST 保留——若软加权先跑会把 + // foreign 压到后面再被删,成员虽同但语义不同;此处锁成员与顺序。 + assert.deepEqual(injected.map((m) => m.title), ["global"]); +}); + +test("soft-weighted order feeds the pin pool (pin picks the weighted-first candidates)", () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: false, pinnedInjectBudget: 1 }); + // pin 池只收 constraint/preference——两条都用 preference,同档靠软加权分序。 + store.save({ type: "preference", title: "foreign-pin", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + store.save({ type: "preference", title: "mine", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" }); + + const pinnedStats = {}; + const injected = service.injectCandidates({ + maxItems: 5, threshold: 3, scope: SCOPE_ME, pinnedStats, + }); + // 加权后 mine 在前:pin 池取的是加权后次序的第一条。 + assert.equal(pinnedStats.shown, 1); + assert.equal(injected[0].title, "mine"); +}); From 131e9f5ddd09907cedc84c9fa5fb68dca564a482 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 1 Oct 2026 02:32:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E8=92=B8=E9=A6=8F=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=A4=B1=E5=AE=88=E4=BF=AE=E5=A4=8D=EF=BC=9AJSON=20=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=20salvage=20+=20dreamMergeGuard=EF=BC=88issue=20#339?= =?UTF-8?q?=20/=20E8=20=E8=80=83=E5=8D=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseSummaryJsonResult:解析失败时括号配对双遍扫描(字符串感知 + 盲扫, 序列化键去重合并)截出完整顶层对象逐个 parse——E8 实测 10-20% 窗口输出了 含约束的完整数组只因中段一处语法错误被整窗拒收,生产行为(游标不推进 + 温度 0 重试同文同错)等于静默丢失;救活条目走正常白名单校验后照常写库 推进游标,审计 metadata.json_salvaged 留痕;截出 0 条不视为显式空数组, 窗口保持可重试。盲扫第二遍是必需的:坏对象的奇数引号会让字符串感知扫描 的奇偶失配把后续对象的收尾 } 吞进字符串 - dreamMergeGuard(opt-in 默认关):合并对象命中长保留类型(与 archive 护栏 同表)的 merge 决策整条跳过——E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉;dream/sleep 两链路同判据,skipInvalid 时进 dream_runs.skipped;settings 白名单 + api.test.js 计数锁 +1 - 测试:salvage 单测 ×2 + 集成 ×2(救回写库/审计留痕、全灭保持可重试), mergeGuard ×4(skipped/严格拒绝/非 guarded 不受影响/guard 关平价), 顺带补上 archive 护栏此前缺失的单测 - 文档:CONFIGURATION.md 巩固节新行;CHANGELOG [Unreleased] --- dsh-mneme/CHANGELOG.md | 25 ++++++++ dsh-mneme/docs/CONFIGURATION.md | 1 + dsh-mneme/lib/config.js | 8 +++ dsh-mneme/lib/dream.js | 4 +- dsh-mneme/lib/dream/decisions.js | 16 ++++++ dsh-mneme/lib/dream/sleep.js | 3 + dsh-mneme/lib/settings.js | 3 + dsh-mneme/lib/summarize.js | 75 ++++++++++++++++++++++-- dsh-mneme/src/config.js | 8 +++ dsh-mneme/src/dream.js | 4 +- dsh-mneme/src/dream/decisions.js | 16 ++++++ dsh-mneme/src/dream/sleep.js | 3 + dsh-mneme/src/settings.js | 3 + dsh-mneme/src/summarize.js | 75 ++++++++++++++++++++++-- dsh-mneme/test/api.test.js | 6 +- dsh-mneme/test/dream.test.js | 75 ++++++++++++++++++++++++ dsh-mneme/test/summarize.test.js | 98 ++++++++++++++++++++++++++++++++ 17 files changed, 411 insertions(+), 12 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index b84476e..52cc444 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -17,6 +17,31 @@ ## 🧹 工程 - **基线持久化回归集 9 条,全量测试 1441 → 1450**:无种子对照组与播种组同库同写入(一个仍触发、一个被拦下——锁的正是这个差异,否则播种被忽略时两条断言同真同假)、阈值照常累积(+10 条仍触发)、run 自报基线优先于现场计算(运行后再长 15 条仍不触发)、成功轮落库 / 失败轮不落、sleep 行不进 auto 基线、半个基线被拒、老库补列幂等、非法种子(半个 / 负数 / 非整数 / 非对象)一律按「没有基线」处理、收尾取证失败既不反噬审计行也不写假基线、`index.js` 接线源码锁。**变异检验**:把播种改成忽略种子(`toBaseline(baselineSeed)` 恒为空)→ 2 条变红;把「成功轮落基线」分支短路 → 1 条变红。 +- **蒸馏 JSON 崩溃窗口的 salvage(issue #339,E8 考卷)**:E8 实测 10-20% 的蒸馏窗口输出了 + 含记忆条目的完整 JSON 数组,只因中段一处语法错误(如杂散引号)被 `parseSummaryJsonResult` + 整窗拒收——失败路径的生产行为是游标不推进 + 温度 0 重试同文同错 = 该窗口记忆静默丢失, + 无任何用户可见信号;再蒸馏场景(摘要再当输入)崩溃率翻倍。修复:解析失败时做括号配对 + 双遍扫描(字符串感知 + 盲扫,去重合并)截出完整的顶层对象逐个 parse,救活的条目走正常 + 白名单校验后照常写库、推进游标;审计 `metadata.json_salvaged: true` 留痕。截出 0 条不视为 + 「显式空数组」,窗口保持可重试;lib-smoke 的三类退化输入行为不变。 + +## 🆕 新增 + +- **`dreamMergeGuard`(issue #339,E8 考卷,opt-in 默认关)**:巩固 merge 护栏——合并对象命中 + 长保留类型(与 archive 护栏同表:preference/pattern/rejected_solution/constraint/pitfall)的 + merge 决策整条跳过。E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉 + (archive 护栏只挡 archive 不挡 merge,「更精炼的摘要」恰是约束失真的主通道)。 + `dreamSkipInvalid`(默认开)时被跳条目进 `dream_runs.skipped`、run 记 degraded;关闭时整单拒绝。 + dream 与 sleep 两条链路同一判据;白名单 + 计数锁 +1。 + +### scope + +- **A2 软加权补齐到注入通道(issue #339,E7 考卷)**:此前 ×0.5/×1.25 只作用于 searchMemories, + E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同(80/80)——自动注入这个主泄露面上 + 软档形同虚设,而文档本就承诺「关闭时全部为软隔离」。现 `scopeEnabled` 开启且会话至少一维 + 可解析时,注入规则路比较器在层内数值积乘 `scopeMultiplier`(priority 档位不动)、selectiveInject + 相似度重排乘同乘数;未激活时乘 1,排序与改动前逐字节一致。strictScope 硬过滤保持在软加权 + 之前,与检索侧叠加顺序一致。 - **宿主开发依赖对齐 0.2 线(PR #344)**:`peerDependencies` 自 #121 起就声明覆盖 0.2.x,`devDependencies` 却停在 `^0.1.0-rc.6`(0.x 语义 = `>=0.1.0-rc.6 <0.2.0`,永远装不到 0.2 线)——**声明支持 0.2、CI 却从没跑过 0.2**,而桌面端 DSH NEXT 内置内核已是 0.2.0-rc.1。开发依赖对齐 0.2.0-rc.1,lockfile 与 CI 真落在声明覆盖的版本上。 - **brace-expansion 钉到 5.0.12(PR #345,CVE-2026-102276/102277/102278)**:osv-scanner 三条告警(两条 high 栈耗尽 DoS + 一条 medium 二次时间 DoS)全落在 devDependency 链的同一包上——实际打不着(不被仓库自身 import),但 lock 合规仍要清;overrides 抬 pin 号即清零,`npm audit` 双口径 0 vulnerabilities。 diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index b72be5c..5b9a39f 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -118,6 +118,7 @@ | `dreamMinExplicitCoverage` | `0.5` | 显式决策覆盖率下限(0–1) | 防截断输出被隐式 keep 洗白 | | `dreamSkipInvalid` | `true` | 跳过单条非法决策、应用合法子集、run 记 degraded(#89) | `false` = 恢复整单拒绝 | | `allowCrossTypeMerge` | `false` | 放宽跨类型合并检查 | opt-in;`dreamSkipInvalid` 关时跨类型 merge 直接整单拒绝 | +| `dreamMergeGuard` | `false` | guarded 类型 merge 护栏(#339/E8):合并对象命中长保留类型(与 archive 护栏同表:preference/pattern/rejected_solution/constraint/pitfall)的 merge 决策整条跳过——E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉 | opt-in;`dreamSkipInvalid`(默认开)时被跳条目进 `dream_runs.skipped`、run 记 degraded,关闭时整单拒绝 | | `dreamNarrativeEnabled` **light** | `false` | dream 期间按共享 tag 聚类合成叙述条(#164 对齐) | opt-in;按需检索、不常驻注入 | | `dreamNarrativeMinCluster` | `3` | 成簇门槛(共享同一 tag 的记忆数,2–20) | — | | `documentMemoryEnabled` **light** | `false` | document 型记忆:长文档指针行,全文归 agent(#230) | opt-in;注册校验 + C2 去重 + supersede 记账 | diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index c38fb3b..551e253 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -281,6 +281,14 @@ export const Config = z.object({ // 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合 // dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。 allowCrossTypeMerge: z.boolean().default(false), + // issue #339 / E8 考卷:merge 护栏(opt-in,默认关 = 现状)。开启后合并对象 + // 命中长保留类型(preference/pattern/rejected_solution/constraint/pitfall, + // 与 archive 护栏同一张表)的 merge 决策整条跳过——E8 实测巩固损耗里 + // 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉,而 archive 护栏只挡 + // archive 不挡 merge,「更精炼的摘要」恰是约束失真的主通道。skipInvalid + // (默认开)时被跳条目进 dream_runs.skipped、run 记 degraded;关闭 + // skipInvalid 时整单拒绝。与 allowCrossTypeMerge 同用时长 guard 先判。 + dreamMergeGuard: z.boolean().default(false), // Rule version for dream adjudication: when this bumps, older dream_runs // degrade to historical evidence (their receipts no longer drive live // decisions). Default 0 = no versioning in use yet. diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index 7560660..0a0c7da 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -1181,7 +1181,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar // Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条 // 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。 skipInvalid: config.dreamSkipInvalid !== false, - allowCrossTypeMerge: config.allowCrossTypeMerge === true + allowCrossTypeMerge: config.allowCrossTypeMerge === true, + // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard)。 + mergeGuard: config.dreamMergeGuard === true }); // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。 // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info diff --git a/dsh-mneme/lib/dream/decisions.js b/dsh-mneme/lib/dream/decisions.js index 18deafd..0bfedb2 100644 --- a/dsh-mneme/lib/dream/decisions.js +++ b/dsh-mneme/lib/dream/decisions.js @@ -210,6 +210,22 @@ export function validateDecisions(decisions, snapshot, options = {}) { if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) { local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`); } + // issue #339 / E8 考卷:merge 护栏(opt-in,dreamMergeGuard)。巩固损耗 + // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive + // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是 + // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的 + // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped + // 明细、严格模式整单拒绝)。 + if (local.length === 0 && options.mergeGuard === true) { + const guardedTypes = new Set(); + for (const id of d.ids) { + const mem = snapshot.get(id); + if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type); + } + if (guardedTypes.size > 0) { + local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`); + } + } } } if (local.length > 0) { diff --git a/dsh-mneme/lib/dream/sleep.js b/dsh-mneme/lib/dream/sleep.js index ed02867..4133525 100644 --- a/dsh-mneme/lib/dream/sleep.js +++ b/dsh-mneme/lib/dream/sleep.js @@ -373,6 +373,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu minAgeHours: config.reflectionUpdateMinAgeHours, skipInvalid: config.dreamSkipInvalid !== false, allowCrossTypeMerge: config.allowCrossTypeMerge === true, + // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard), + // 与 dream 主链路同一判据。 + mergeGuard: config.dreamMergeGuard === true, // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住 // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。 allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index 7f8aba0..ed4b0ff 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -100,6 +100,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。 "dreamSkipInvalid", "allowCrossTypeMerge", + // Issue #339 / E8:guarded 类型 merge 护栏(默认关)——合并对象命中 + // 长保留类型的 merge 决策整条跳过。 + "dreamMergeGuard", // Issue #17(v0.8.0 A1):scope 隔离存储层总开关。开启后写入标注 // agent_scope/workspace_scope、去重键扩展(含 sensitivity);检索侧过滤在 // A2/A3。默认关=行为与 A1 前逐字节一致。 diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 8b158e1..7d1f331 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -15,10 +15,17 @@ function parseSummaryJsonResult(raw) { const end = text.lastIndexOf("]"); if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] }; let arr; + let salvaged = false; try { arr = JSON.parse(text.slice(start, end + 1)); } catch { - return { ok: false, entries: [] }; + // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段 + // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 + + // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对 + // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型 + // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。 + arr = salvageArrayItems(text.slice(start, end + 1)); + salvaged = true; } if (!Array.isArray(arr)) return { ok: false, entries: [] }; const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]); @@ -38,8 +45,67 @@ function parseSummaryJsonResult(raw) { importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3 })); // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效, - // 则不能消费窗口,否则无效输出会永久推进 seq 游标。 - return { ok: arr.length === 0 || entries.length > 0, entries }; + // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径 + // 没有「显式空数组」可言——ok 只看是否救回了条目。 + return { + ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0), + entries, + salvaged + }; +} + +/** + * Salvage scanner for malformed JSON arrays (issue #339): extracts every + * complete top-level {...} span, parsing each independently. Two passes, + * merged with de-duplication: + * - pass 1 is string-aware (handles valid objects whose string values + * legitimately contain braces); + * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号 + * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里, + * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然 + * 被丢弃;两遍的去重靠序列化键。 + */ +function salvageArrayItems(chunk) { + const out = []; + const seen = new Set(); + const push = (obj) => { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return; + const key = JSON.stringify(obj); + if (!seen.has(key)) { seen.add(key); out.push(obj); } + }; + for (const obj of scanBraceSpans(chunk, true)) push(obj); + for (const obj of scanBraceSpans(chunk, false)) push(obj); + return out; +} + +function scanBraceSpans(chunk, respectStrings) { + const items = []; + let depth = 0; + let inString = false; + let escape = false; + let objStart = -1; + for (let i = 0; i < chunk.length; i++) { + const ch = chunk[i]; + if (respectStrings && inString) { + if (escape) escape = false; + else if (ch === "\\") escape = true; + else if (ch === '"') inString = false; + continue; + } + if (respectStrings && ch === '"') { inString = true; continue; } + if (ch === "{") { + if (depth === 0) objStart = i; + depth++; + } else if (ch === "}") { + depth--; + if (depth === 0 && objStart !== -1) { + try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ } + objStart = -1; + } + if (depth < 0) depth = 0; + } + } + return items; } /** Extract a JSON array from LLM output that may contain prose around it. */ @@ -678,9 +744,10 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); } - if (audit && (capped > 0 || deduped > 0)) { + if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) { audit.metadata = { parsed: parsed.length, + ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {}) }; diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index c38fb3b..551e253 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -281,6 +281,14 @@ export const Config = z.object({ // 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合 // dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。 allowCrossTypeMerge: z.boolean().default(false), + // issue #339 / E8 考卷:merge 护栏(opt-in,默认关 = 现状)。开启后合并对象 + // 命中长保留类型(preference/pattern/rejected_solution/constraint/pitfall, + // 与 archive 护栏同一张表)的 merge 决策整条跳过——E8 实测巩固损耗里 + // 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉,而 archive 护栏只挡 + // archive 不挡 merge,「更精炼的摘要」恰是约束失真的主通道。skipInvalid + // (默认开)时被跳条目进 dream_runs.skipped、run 记 degraded;关闭 + // skipInvalid 时整单拒绝。与 allowCrossTypeMerge 同用时长 guard 先判。 + dreamMergeGuard: z.boolean().default(false), // Rule version for dream adjudication: when this bumps, older dream_runs // degrade to historical evidence (their receipts no longer drive live // decisions). Default 0 = no versioning in use yet. diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index 7560660..0a0c7da 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -1181,7 +1181,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar // Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条 // 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。 skipInvalid: config.dreamSkipInvalid !== false, - allowCrossTypeMerge: config.allowCrossTypeMerge === true + allowCrossTypeMerge: config.allowCrossTypeMerge === true, + // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard)。 + mergeGuard: config.dreamMergeGuard === true }); // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。 // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info diff --git a/dsh-mneme/src/dream/decisions.js b/dsh-mneme/src/dream/decisions.js index 18deafd..0bfedb2 100644 --- a/dsh-mneme/src/dream/decisions.js +++ b/dsh-mneme/src/dream/decisions.js @@ -210,6 +210,22 @@ export function validateDecisions(decisions, snapshot, options = {}) { if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) { local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`); } + // issue #339 / E8 考卷:merge 护栏(opt-in,dreamMergeGuard)。巩固损耗 + // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive + // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是 + // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的 + // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped + // 明细、严格模式整单拒绝)。 + if (local.length === 0 && options.mergeGuard === true) { + const guardedTypes = new Set(); + for (const id of d.ids) { + const mem = snapshot.get(id); + if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type); + } + if (guardedTypes.size > 0) { + local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`); + } + } } } if (local.length > 0) { diff --git a/dsh-mneme/src/dream/sleep.js b/dsh-mneme/src/dream/sleep.js index ed02867..4133525 100644 --- a/dsh-mneme/src/dream/sleep.js +++ b/dsh-mneme/src/dream/sleep.js @@ -373,6 +373,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu minAgeHours: config.reflectionUpdateMinAgeHours, skipInvalid: config.dreamSkipInvalid !== false, allowCrossTypeMerge: config.allowCrossTypeMerge === true, + // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard), + // 与 dream 主链路同一判据。 + mergeGuard: config.dreamMergeGuard === true, // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住 // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。 allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index 7f8aba0..ed4b0ff 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -100,6 +100,9 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。 "dreamSkipInvalid", "allowCrossTypeMerge", + // Issue #339 / E8:guarded 类型 merge 护栏(默认关)——合并对象命中 + // 长保留类型的 merge 决策整条跳过。 + "dreamMergeGuard", // Issue #17(v0.8.0 A1):scope 隔离存储层总开关。开启后写入标注 // agent_scope/workspace_scope、去重键扩展(含 sensitivity);检索侧过滤在 // A2/A3。默认关=行为与 A1 前逐字节一致。 diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 8b158e1..7d1f331 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -15,10 +15,17 @@ function parseSummaryJsonResult(raw) { const end = text.lastIndexOf("]"); if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] }; let arr; + let salvaged = false; try { arr = JSON.parse(text.slice(start, end + 1)); } catch { - return { ok: false, entries: [] }; + // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段 + // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 + + // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对 + // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型 + // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。 + arr = salvageArrayItems(text.slice(start, end + 1)); + salvaged = true; } if (!Array.isArray(arr)) return { ok: false, entries: [] }; const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]); @@ -38,8 +45,67 @@ function parseSummaryJsonResult(raw) { importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3 })); // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效, - // 则不能消费窗口,否则无效输出会永久推进 seq 游标。 - return { ok: arr.length === 0 || entries.length > 0, entries }; + // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径 + // 没有「显式空数组」可言——ok 只看是否救回了条目。 + return { + ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0), + entries, + salvaged + }; +} + +/** + * Salvage scanner for malformed JSON arrays (issue #339): extracts every + * complete top-level {...} span, parsing each independently. Two passes, + * merged with de-duplication: + * - pass 1 is string-aware (handles valid objects whose string values + * legitimately contain braces); + * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号 + * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里, + * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然 + * 被丢弃;两遍的去重靠序列化键。 + */ +function salvageArrayItems(chunk) { + const out = []; + const seen = new Set(); + const push = (obj) => { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return; + const key = JSON.stringify(obj); + if (!seen.has(key)) { seen.add(key); out.push(obj); } + }; + for (const obj of scanBraceSpans(chunk, true)) push(obj); + for (const obj of scanBraceSpans(chunk, false)) push(obj); + return out; +} + +function scanBraceSpans(chunk, respectStrings) { + const items = []; + let depth = 0; + let inString = false; + let escape = false; + let objStart = -1; + for (let i = 0; i < chunk.length; i++) { + const ch = chunk[i]; + if (respectStrings && inString) { + if (escape) escape = false; + else if (ch === "\\") escape = true; + else if (ch === '"') inString = false; + continue; + } + if (respectStrings && ch === '"') { inString = true; continue; } + if (ch === "{") { + if (depth === 0) objStart = i; + depth++; + } else if (ch === "}") { + depth--; + if (depth === 0 && objStart !== -1) { + try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ } + objStart = -1; + } + if (depth < 0) depth = 0; + } + } + return items; } /** Extract a JSON array from LLM output that may contain prose around it. */ @@ -678,9 +744,10 @@ export function createSummarizer(ctx, service, config, deps = {}) { // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。 persistCursor(session.id, nextSeq); } - if (audit && (capped > 0 || deduped > 0)) { + if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) { audit.metadata = { parsed: parsed.length, + ...(parsedResult.salvaged ? { json_salvaged: true } : {}), ...(capped > 0 ? { capped } : {}), ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {}) }; diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 4603cc9..6900b9b 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -672,10 +672,12 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #34 恢复(#333)新增 injectTimePrefix、 // issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、 // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、 - // 块4 新增 graphPassiveConfirm) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1); + // 块4 新增 graphPassiveConfirm、 + // issue #339 新增 dreamMergeGuard) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); + assert.equal(data.effective.dreamMergeGuard, false); assert.equal(data.effective.dreamMinIntervalMinutes, 0); assert.equal(data.effective.autoDreamFailureBackoff, false); assert.equal(data.effective.dreamMaxTokens, 131072); diff --git a/dsh-mneme/test/dream.test.js b/dsh-mneme/test/dream.test.js index 250e2cb..b524dab 100644 --- a/dsh-mneme/test/dream.test.js +++ b/dsh-mneme/test/dream.test.js @@ -1306,3 +1306,78 @@ test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 = assert.match(overview().content, /整理后 5 条/, "uncapped run covers the whole library"); store.close(); }); + +// --- issue #339 / E8 考卷:merge 护栏(dreamMergeGuard,opt-in)--------------- +// E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉—— +// archive 护栏只挡 archive 不挡 merge。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES +// 的 merge 决策走与 archive 护栏同款通道:skipInvalid 时 skipped、严格时整单拒绝。 + +test("validateDecisions mergeGuard: guarded-type merge is skipped under skipInvalid", () => { + const snap = new Map([ + ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }], + ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }], + ["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }], + ["h2", { id: "h2", type: "history", title: "旧事(近似)", content: "内容2", importance: 3, archived: false, forgotten: false }] + ]); + const decisions = [ + { action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "$12,400 上下", importance: 4 }, + { action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 } + ]; + const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true }); + assert.equal(ok, true, "history merge survives, guarded merge skipped"); + assert.equal(skipped.length, 1); + assert.match(skipped[0].error, /dreamMergeGuard/, "skip reason names the guard"); + assert.match(skipped[0].error, /constraint/, "skip reason names the guarded type"); + // guarded merge 被 splice 掉,history merge 与两条隐式 keep 存活。 + assert.deepEqual(decisions.map((d) => d.action), ["merge", "keep", "keep"]); + assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("c1")), "c1 auto-kept"); +}); + +test("validateDecisions mergeGuard: strict mode rejects the whole batch", () => { + const guardedSnap = new Map([ + ["g1", { id: "g1", type: "pitfall", title: "坑", content: "内容", importance: 3, archived: false, forgotten: false }], + ["g2", { id: "g2", type: "pitfall", title: "坑2", content: "内容2", importance: 3, archived: false, forgotten: false }] + ]); + const strict = validateDecisions( + [{ action: "merge", ids: ["g1", "g2"], keepSource: "g1", title: "t", content: "c" }], + guardedSnap, + { skipInvalid: false, mergeGuard: true } + ); + assert.equal(strict.ok, false, "strict mode rejects guarded merge"); + assert.ok(strict.errors.some((e) => /dreamMergeGuard/.test(e))); +}); + +test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = legacy behavior", () => { + const snap = new Map([ + ["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }], + ["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }] + ]); + const decisions = [{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "合并", importance: 4 }]; + // guard 关 = 现行为(merge 照常通过)。 + assert.equal(validateDecisions(decisions, snap, { skipInvalid: true }).ok, true); + // guard 开但类型非 guarded(history 用 snapshot 默认 type=project 亦非 guarded)。 + const plainSnap = snapshot(["x", "y"]); + assert.equal( + validateDecisions( + [{ action: "merge", ids: ["x", "y"], keepSource: "x", title: "t", content: "c" }], + plainSnap, + { skipInvalid: true, mergeGuard: true } + ).ok, + true, + "non-guarded merge passes under guard" + ); +}); + +test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => { + const snap = new Map([ + ["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }], + ["p", { id: "p", type: "project", title: "项目", content: "内容", importance: 3, archived: false, forgotten: false }] + ]); + const { ok, skipped } = validateDecisions([ + { action: "archive", ids: ["c"], reason: "keeps the store tidy" }, + { action: "archive", ids: ["p"], reason: "outdated" } + ], snap, { skipInvalid: true }); + assert.equal(ok, true, "project archive with stale rationale survives"); + assert.equal(skipped.length, 1, "constraint archive without rationale skipped"); + assert.match(skipped[0].error, /long-retention type/); +}); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index 4b351ef..effefc5 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -143,6 +143,32 @@ test("parseSummaryJson extracts valid entries and skips malformed ones", () => { assert.equal(parsed[1].type, "preference"); }); +// --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)----- +// E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗 +// JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。 + +test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => { + // E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。 + const raw = `[ + {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4}, + { " "type": "history", "title": "坏对象", "content": "语法错误" }, + {"type":"preference","title":"硬件","content":"只要 brushed brass"} + ]`; + const parsed = parseSummaryJson(raw); + assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept"); + assert.equal(parsed[0].content, "总额 $12,400"); + assert.equal(parsed[1].title, "硬件"); +}); + +test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => { + // 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。 + const raw = `[ + { " "type": "history", "title": "坏", "content": "语法错误" } + ]`; + const parsed = parseSummaryJson(raw); + assert.deepEqual(parsed, []); +}); + test("subscribes to session/event when autoSummarize enabled", () => { const { events } = setup(); assert.ok(events.some((e) => e.name === "session/event")); @@ -673,6 +699,78 @@ test("invalid summary JSON leaves the seq window retryable", async () => { assert.equal(store.count(), 0, "the valid empty retry remains a no-op"); }); +test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => { + const malformed = `[ + {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4}, + { " "type": "history", "title": "坏对象", "content": "语法错误" }, + {"type":"preference","title":"硬件","content":"只要 brushed brass"} + ]`; + const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, { + stream() { + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: malformed }; + yield { type: "finish", kind: "ok" }; + })(); + } + }); + t.after(() => { summarizer.dispose(); store.close(); }); + const handler = events.find((e) => e.name === "session/event").fn; + const session = { + id: "s-salvage", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }] + }; + + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped"); + const titles = service.list().map((m) => m.title).sort(); + assert.deepEqual(titles, ["硬件", "预算"]); + assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry"); + const audit = service.listLlmAudits().find((r) => r.status === "success"); + assert.ok(audit, "salvaged run is audited as a success"); + assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail"); + + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 1, "the consumed window is not re-distilled"); + assert.equal(service.count(), 2, "no duplicate writes on the second turn/end"); +}); + +test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => { + const hopeless = `[ + { " "type": "history", "title": "坏", "content": "语法错误" } + ]`; + let attempt = 0; + const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, { + stream() { + const output = attempt++ === 0 ? hopeless : `[ + {"type":"history","title":"重试成功","content":"干净窗口","importance":3} + ]`; + return (async function* () { + yield { type: "block-start", block: { type: "text" } }; + yield { type: "text-delta", delta: output }; + yield { type: "finish", kind: "ok" }; + })(); + } + }); + t.after(() => { summarizer.dispose(); store.close(); }); + const handler = events.find((e) => e.name === "session/event").fn; + const session = { + id: "s-salvage-none", + requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }), + events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }] + }; + + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(service.count(), 0, "zero salvage is not a fake success"); + await handler(session, { seq: 2, type: "turn/end" }); + assert.equal(calls.length, 2, "the window stays retryable"); + assert.equal(service.count(), 1); + assert.ok(service.listLlmAudits().some( + (r) => r.status === "error" && r.error_message === "invalid summary JSON" + ), "zero-salvage run keeps the error audit"); +}); + test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => { for (const invalid of [ [{}], From 08a1af71d21d44d582e8265e56c8f0d7a5f213bb Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 1 Oct 2026 13:07:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E8=AF=84=E5=AE=A1=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=EF=BC=88CodeRabbit=20on=20#350=EF=BC=89=EF=BC=9ABM25=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E8=A1=A5=E8=BD=AF=E5=8A=A0=E6=9D=83=20+=20sa?= =?UTF-8?q?lvage=20=E5=8F=AA=E5=8F=96=E5=A4=96=E5=B1=82=E6=95=B0=E7=BB=84?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=AD=90=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - injectCandidates 首轮 BM25 兜底领跑合并池且 selectiveInject 重排要等查询 向量——软加权在 BM25 收集后就位(score × scopeMultOf 重排),否则 foreign 命中绕过降权;回归用例同内容同长度构造同分,锁加权翻转 - scanBraceSpans 只接受最外层数组的直接子对象(arrayDepth===1 && objDepth===0 且前一非空白 token 为 [ 或 ,):嵌套子数组对象不再误捞、盲扫遇到字符串值 里的 } 不再把属性值对象当独立记忆落库;误拦只少救回(安全侧),误捞是写 假记忆。锁定用例 ×2 --- dsh-mneme/CHANGELOG.md | 30 ++++++++++++++++++ dsh-mneme/lib/service.js | 7 +++++ dsh-mneme/lib/summarize.js | 39 +++++++++++++++++++----- dsh-mneme/src/service.js | 7 +++++ dsh-mneme/src/summarize.js | 39 +++++++++++++++++++----- dsh-mneme/test/scope-inject-soft.test.js | 13 ++++++++ dsh-mneme/test/summarize.test.js | 19 ++++++++++++ 7 files changed, 140 insertions(+), 14 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 52cc444..29d53c7 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [Unreleased] + +## 🐛 修复 + +- **蒸馏 JSON 崩溃窗口的 salvage(issue #339,E8 考卷)**:E8 实测 10-20% 的蒸馏窗口输出了 + 含记忆条目的完整 JSON 数组,只因中段一处语法错误(如杂散引号)被 `parseSummaryJsonResult` + 整窗拒收——失败路径的生产行为是游标不推进 + 温度 0 重试同文同错 = 该窗口记忆静默丢失, + 无任何用户可见信号;再蒸馏场景(摘要再当输入)崩溃率翻倍。修复:解析失败时做括号配对 + 双遍扫描(字符串感知 + 盲扫,去重合并)截出完整的顶层对象逐个 parse,救活的条目走正常 + 白名单校验后照常写库、推进游标;审计 `metadata.json_salvaged: true` 留痕。截出 0 条不视为 + 「显式空数组」,窗口保持可重试;lib-smoke 的三类退化输入行为不变。 + +## 🆕 新增 + +- **`dreamMergeGuard`(issue #339,E8 考卷,opt-in 默认关)**:巩固 merge 护栏——合并对象命中 + 长保留类型(与 archive 护栏同表:preference/pattern/rejected_solution/constraint/pitfall)的 + merge 决策整条跳过。E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉 + (archive 护栏只挡 archive 不挡 merge,「更精炼的摘要」恰是约束失真的主通道)。 + `dreamSkipInvalid`(默认开)时被跳条目进 `dream_runs.skipped`、run 记 degraded;关闭时整单拒绝。 + dream 与 sleep 两条链路同一判据;白名单 + 计数锁 +1。 + +### scope + +- **A2 软加权补齐到注入通道(issue #339,E7 考卷)**:此前 ×0.5/×1.25 只作用于 searchMemories, + E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同(80/80)——自动注入这个主泄露面上 + 软档形同虚设,而文档本就承诺「关闭时全部为软隔离」。现 `scopeEnabled` 开启且会话至少一维 + 可解析时,注入规则路比较器在层内数值积乘 `scopeMultiplier`(priority 档位不动)、selectiveInject + 相似度重排乘同乘数;未激活时乘 1,排序与改动前逐字节一致。strictScope 硬过滤保持在软加权 + 之前,与检索侧叠加顺序一致。 + ## [0.8.12] - 2026-10-01 ## 🐛 修复 diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 24e319c..144d106 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1584,6 +1584,13 @@ export function createService({ store, mirror, config, onWrite, logger, document semanticItems.push(hit); } } + // issue #339(CodeRabbit review on #350):首轮 BM25 兜底领跑合并池, + // 而 selectiveInject 相似度重排要等查询向量就绪才执行——软加权必须 + // 在这里就位,否则 foreign 命中绕过降权直接占注入位。 + if (softScopeActive) { + semanticItems.sort((a, b) => + (b.score ?? 0) * scopeMultOf(b) - (a.score ?? 0) * scopeMultOf(a)); + } } if (semanticItems.length) { const seen = new Set(); diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 7d1f331..bfe2d65 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -79,11 +79,18 @@ function salvageArrayItems(chunk) { } function scanBraceSpans(chunk, respectStrings) { + // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」—— + // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或 + // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值 + // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回 + // (安全侧),误捞的代价是写进假记忆。 const items = []; - let depth = 0; + let arrayDepth = 0; + let objDepth = 0; let inString = false; let escape = false; let objStart = -1; + let prev = ""; for (let i = 0; i < chunk.length; i++) { const ch = chunk[i]; if (respectStrings && inString) { @@ -93,17 +100,35 @@ function scanBraceSpans(chunk, respectStrings) { continue; } if (respectStrings && ch === '"') { inString = true; continue; } + if (ch === "[") { + arrayDepth++; + prev = "["; + continue; + } + if (ch === "]") { + arrayDepth = Math.max(0, arrayDepth - 1); + prev = "]"; + continue; + } if (ch === "{") { - if (depth === 0) objStart = i; - depth++; - } else if (ch === "}") { - depth--; - if (depth === 0 && objStart !== -1) { + if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) { + objStart = i; + } + objDepth++; + prev = "{"; + continue; + } + if (ch === "}") { + objDepth = Math.max(0, objDepth - 1); + if (objDepth === 0 && objStart !== -1) { try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ } objStart = -1; } - if (depth < 0) depth = 0; + prev = "}"; + continue; } + if (ch === ",") { prev = ","; continue; } + if (!/\s/.test(ch)) prev = ch; } return items; } diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 24e319c..144d106 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1584,6 +1584,13 @@ export function createService({ store, mirror, config, onWrite, logger, document semanticItems.push(hit); } } + // issue #339(CodeRabbit review on #350):首轮 BM25 兜底领跑合并池, + // 而 selectiveInject 相似度重排要等查询向量就绪才执行——软加权必须 + // 在这里就位,否则 foreign 命中绕过降权直接占注入位。 + if (softScopeActive) { + semanticItems.sort((a, b) => + (b.score ?? 0) * scopeMultOf(b) - (a.score ?? 0) * scopeMultOf(a)); + } } if (semanticItems.length) { const seen = new Set(); diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 7d1f331..bfe2d65 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -79,11 +79,18 @@ function salvageArrayItems(chunk) { } function scanBraceSpans(chunk, respectStrings) { + // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」—— + // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或 + // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值 + // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回 + // (安全侧),误捞的代价是写进假记忆。 const items = []; - let depth = 0; + let arrayDepth = 0; + let objDepth = 0; let inString = false; let escape = false; let objStart = -1; + let prev = ""; for (let i = 0; i < chunk.length; i++) { const ch = chunk[i]; if (respectStrings && inString) { @@ -93,17 +100,35 @@ function scanBraceSpans(chunk, respectStrings) { continue; } if (respectStrings && ch === '"') { inString = true; continue; } + if (ch === "[") { + arrayDepth++; + prev = "["; + continue; + } + if (ch === "]") { + arrayDepth = Math.max(0, arrayDepth - 1); + prev = "]"; + continue; + } if (ch === "{") { - if (depth === 0) objStart = i; - depth++; - } else if (ch === "}") { - depth--; - if (depth === 0 && objStart !== -1) { + if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) { + objStart = i; + } + objDepth++; + prev = "{"; + continue; + } + if (ch === "}") { + objDepth = Math.max(0, objDepth - 1); + if (objDepth === 0 && objStart !== -1) { try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ } objStart = -1; } - if (depth < 0) depth = 0; + prev = "}"; + continue; } + if (ch === ",") { prev = ","; continue; } + if (!/\s/.test(ch)) prev = ch; } return items; } diff --git a/dsh-mneme/test/scope-inject-soft.test.js b/dsh-mneme/test/scope-inject-soft.test.js index b297e24..3c26e98 100644 --- a/dsh-mneme/test/scope-inject-soft.test.js +++ b/dsh-mneme/test/scope-inject-soft.test.js @@ -91,3 +91,16 @@ test("soft-weighted order feeds the pin pool (pin picks the weighted-first candi assert.equal(pinnedStats.shown, 1); assert.equal(injected[0].title, "mine"); }); + +test("first-round BM25 fallback applies the soft weight (no query vector yet)", () => { + const { store, service } = setup({ scopeEnabled: true, strictScope: false }); + // 同内容同长度(标题各 1 token 且不在查询词表)→ BM25 原始分相同; + // 未加权时同分序随 store.list(updated desc,foreign 后存先出)。 + store.save({ type: "decision", title: "alpha", content: "plan the dinner party menu with care", importance: 4, agent_scope: "me", agent_scope_source: "explicit" }); + store.save({ type: "decision", title: "bravo", content: "plan the dinner party menu with care", importance: 4, agent_scope: "other", agent_scope_source: "explicit" }); + + const injected = service.injectCandidates({ + query: "dinner party menu", maxItems: 5, threshold: 3, scope: SCOPE_ME, + }); + assert.equal(injected[0].title, "alpha", "BM25 hits are soft-weighted before the semantic-first merge"); +}); diff --git a/dsh-mneme/test/summarize.test.js b/dsh-mneme/test/summarize.test.js index effefc5..ad09a42 100644 --- a/dsh-mneme/test/summarize.test.js +++ b/dsh-mneme/test/summarize.test.js @@ -1384,3 +1384,22 @@ test("issue#127: a dedupe lookup that yields nothing still lands the entry (neve assert.equal(store.count(), 1, "the entry is stored through the normal write path"); assert.equal(store.all()[0].title, "必须落库"); }); + +test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => { + // 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。 + const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`; + assert.deepEqual(parseSummaryJson(nested), []); +}); + +test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => { + // 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状: + // 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。 + const raw = `[ + {"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"}, + { " "type": "history", "title": "坏", "content": "x" } + ]`; + const parsed = parseSummaryJson(raw); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].title, "good"); + assert.match(parsed[0].content, /fake/); +});