Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [Unreleased]

- **两个「默认开」的开关用 `=== true` 读取,绕过 Config 的裸配置调用方下默认值落不了地(#692)**。`dreamMergeGuard`(`src/dream.js` 的 dream 主链路、`src/dream/sleep.js` 的 sleep 链路各一处)与 `resilientModelDownload` 的默认值都只写在 zod schema(`src/config.js`)里:走宿主没问题——`src/index.js` 先经 `Config(config)` 解析,默认值已落进对象;但**绕过 Config 的调用方**(测试、第三方程序化 `createService`、直接 import 单元的装配)拿到的是 `undefined`,`=== true` 把它当 false,于是「默认开」等于默认关。两处统一改成 `!== false`(与 `autoInject` / `autoDream` / `hybridInject` / `heatEnabled` / `summarizeBatchCrossTypeDedupe` 同一惯例)。`resilientModelDownload` 的落点特意**不在构造函数**:`LocalEmbedder` / `Reranker` 刻意「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见 `src/local-embedder.js` 的 issue #194 注释,`test/local-embedder.test.js:79` 锁着这个契约),所以判据收成 `resilientDownloadEnabled(cfg)` 放在装配层(`src/semantic.js`),两条装配路径共用。schema 里的默认值一律不动,关掉仍然靠显式 `false`。

**第三处 `memoryQualityFilter.enabled` 本批不动**,理由与上面两处不同:它挂在一个**自身带 `.default({})` 的对象**里,经 Config 的调用方(也就是宿主与所有正常装配)一直是开的;`=== true` 与 `!== false` 的差别只出现在「整个对象都不传」的裸配置调用方身上。把那半边也翻过来,等于重定义「程序化 `createService` 的默认契约」——实测会让既有断言 **421 条变红**(大量 fixture 用两三字短正文,靠过滤器默认关才能留在库里被检索到)。这属于要单独拍板的接口决定,留在 issue #692 里跟进,不与本批两处混在一起。

**测试** 3,496 → **3,499**(+3):`test/issue-692-default-on-reads.test.js` 三条——裸配置下 guarded 类型(`constraint`)的 merge 被 skip 且理由点名 `dreamMergeGuard`、显式 `dreamMergeGuard: false` 时同一条 merge 落库、装配判据三态(`{}` / `undefined` / `true` 为开,`false` 为关)。变异自证:两处各自改回 `=== true` → 2 红(默认开那两条),还原 → 3/3 绿;「改成恒真」会被那两条反向对照抓住。

- **知识库未启用时的空态文案指错了分组名(#668 尾巴)**。空态一直写「设置 → 记忆(mneme) → 功能 → 知识库」,但 v0.13 起 `group.ext` 是独立分组、标签是「扩展功能」(英文 `Extensions`),「知识库」只是组里的一项——照这句话去设置页找「知识库」分组会找不到。中英两版都改成「功能 → 扩展功能」并点名组内那一项,`test/client.test.js` 的文案锁同步跟上。

- **跨类型同义双录:蒸馏提示词里「宁可拆成多条」把同一件事记了两遍,而三处去重各守一道类型闸(#687);实体表 75% 的实体只被提过一次、名字只按大小写敏感精确匹配(#688)**。两条 issue 报告的是同一个根病:**记忆的「类型」被当成了「对象身份」**。#687 上报者(xueyue33)给的分布很刺眼——他自己库里 `sim>=0.85` 的 81 对近重复中 **72 对跨类型**、`>=0.90` 的 7 对中 6 对跨类型;`sim>=0.88` 的 24 对里 21 对跨类型。四处夹缝逐个核过都成立:`src/lang.js` 的 `codingSummary` 明确让模型「宁可拆成多条」而 `pitfall`(现象+根因+规避)与 `constraint`(约束+来源)本就是同一结论的两种写法;`src/service.js:1480` 的 `findSessionDuplicate` 用 `store.list({ type: memory.type, source, limit: 200 })` 锁死同类型同会话;`src/dream/clustering.js:117` 的 `findPotentialConflicts` 首行 `if (memories[i].type !== memories[j].type) continue;`;dream 巩固提示词同样写着「仅合并同类型」。**我们不开常开类型闸**:本机活跃库 9,275 条带向量的记忆里跨类型 `cosine>=0.90` 共 45 对,逐对读正文后 **36 对(80%)确为同义,9 对合并会丢信息**(5 对时间序列/状态快照、2 对症状 vs 修复、1 对结论反转、1 对版本号不同)——也就是说「凡是跨类型高相似就合并」每 5 对要吞掉 1 对真互补的信息,而这类对只占活跃总量 0.97%,收益与风险不对称。落地的三层是**源头互斥 + 批内去重 + 存量一次性处置**:① `codingSummary` 提示词(中英双语)补一条互斥约束——同一结论只记一条,`pitfall` 与 `constraint` 不双写,时间序列/状态快照写进同一条;② 蒸馏落盘前做**批内**跨类型去重,只对 `pitfall↔constraint`、`history↔project` 两组生效(正是实测最集中的应用对:9 对 + 8 对),`planBatchCrossTypeDedupe(entries, { minSim })` 是纯函数——按相似度降序、每条最多被退休一次、不许链式藏行(A 取代 B 的同时 B 又取代过 C 会把 C 永久埋在链底);门槛 `BATCH_CROSS_TYPE_MIN_SIM = 0.85`(textSim,不是余弦,词面重合是这两组对的主特征)、`BATCH_CROSS_TYPE_MIN_CHARS = 24`、`BATCH_CROSS_TYPE_MIN_LEN_RATIO = 0.5`;③ 处置走 `store.supersedeChain` 只写 `superseded_by` / `superseded_at`,**保留正文更全的一条、被取代者正文零改写、不归档、不 merge**——判错时旧行仍在库里、显式搜索照旧读得到,且 `supersededIds()` 的 `JOIN ... AND w.archived=0` 让继任者一旦被归档旧行自动回到可见,等于自带回退。刻意**没有**复用 `src/dream/decisions.js` 的 `supersedeInfoFirstPair`:它走 `service.update` 会在正文尾部追加「已被取代」前缀,与「正文零改写」直接冲突。**默认开**(2026-11-13 拍板):这个开关存在的意义就是让新写入不再双录,默认关等于修了不生效,所以 `src/config.js` 给 `true`、设 `false` 是 kill switch,且关时审计行的 `batchDeduped` / `batchPairs` 两个键根本不出现——加开关之前的行为逐字节不变。两个键同时登记进 `settings.js` 的 `FEATURE_FLAG_BOOLEANS` 与 `FEATURE_FLAG_NUMBER_RANGES`,面板上能改。存量 17 对**不由代码处置**:只读脚本 `scripts/cross-type-dedupe-dryrun.mjs` 出清单(输出**禁止落在仓库内**,清单里含记忆原文),复核后由人决定、按纯 SQL 打链。
Expand Down
5 changes: 4 additions & 1 deletion dsh-mneme/lib/dream.js
Original file line number Diff line number Diff line change
Expand Up @@ -1437,7 +1437,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
skipInvalid: config.dreamSkipInvalid !== false,
allowCrossTypeMerge: config.allowCrossTypeMerge === true,
// issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard)。
mergeGuard: config.dreamMergeGuard === true
// issue #692:按 `!== false` 读——裸配置调用方(测试、程序化建 service)
// 拿到的是 undefined,用 `=== true` 会让「默认开」落不了地。与 autoInject /
// autoDream / hybridInject / heatEnabled 那批默认开的键同一写法。
mergeGuard: config?.dreamMergeGuard !== false
});
// Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。
// 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info
Expand Down
4 changes: 2 additions & 2 deletions dsh-mneme/lib/dream/sleep.js
Original file line number Diff line number Diff line change
Expand Up @@ -848,8 +848,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
skipInvalid: config.dreamSkipInvalid !== false,
allowCrossTypeMerge: config.allowCrossTypeMerge === true,
// issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
// 与 dream 主链路同一判据。
mergeGuard: config.dreamMergeGuard === true,
// 与 dream 主链路同一判据。issue #692:`!== false` 读,裸配置下默认开同样生效。
mergeGuard: config?.dreamMergeGuard !== false,
// Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
// 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
Expand Down
16 changes: 14 additions & 2 deletions dsh-mneme/lib/semantic.js
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,16 @@ export function scheduleBootBackfill({
* init 异步失败经 #118 重试后 service 侧降级,此处引用仍在(dream/sleep 语义面
* 与搬移前一致)。dispose 清两个引导期计时器。
*/
/**
* issue #692:`resilientModelDownload` 在 schema 里默认开,裸配置调用方(测试、
* 程序化建 service)拿到的是 undefined。LocalEmbedder / Reranker 的构造函数刻意
* 「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见 local-embedder.js
* 的 issue #194 注释),所以默认值必须在装配层落定——判据收在这里,两处装配共用。
*/
export function resilientDownloadEnabled(cfg) {
return cfg?.resilientModelDownload !== false;
}

export function createSemantic({ store, service, settings, cfg, logger, vectorIndex, applyHumanEdits, lightMode = false }) {
let embedder = null;
let reranker = null;
Expand Down Expand Up @@ -442,7 +452,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
runtimeDir: cfg.runtimeDir,
// #188:embedModelMirror 接成 transformers 的下载镜像(此前死配置)。
remoteHost: cfg.embedModelMirror,
resilientModelDownload: cfg.resilientModelDownload,
// issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。
resilientModelDownload: resilientDownloadEnabled(cfg),
baseUrl: cfg.ollamaBaseUrl,
logger
});
Expand Down Expand Up @@ -499,7 +510,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
// embedModelMirror 此前是死配置,现接成 transformers 的下载镜像。
useDtype: cfg.rerankDtype,
remoteHost: cfg.embedModelMirror,
resilientModelDownload: cfg.resilientModelDownload,
// issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。
resilientModelDownload: resilientDownloadEnabled(cfg),
logger
});
service.setReranker(reranker);
Expand Down
5 changes: 4 additions & 1 deletion dsh-mneme/src/dream.js
Original file line number Diff line number Diff line change
Expand Up @@ -1437,7 +1437,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
skipInvalid: config.dreamSkipInvalid !== false,
allowCrossTypeMerge: config.allowCrossTypeMerge === true,
// issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard)。
mergeGuard: config.dreamMergeGuard === true
// issue #692:按 `!== false` 读——裸配置调用方(测试、程序化建 service)
// 拿到的是 undefined,用 `=== true` 会让「默认开」落不了地。与 autoInject /
// autoDream / hybridInject / heatEnabled 那批默认开的键同一写法。
mergeGuard: config?.dreamMergeGuard !== false
});
// Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。
// 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info
Expand Down
4 changes: 2 additions & 2 deletions dsh-mneme/src/dream/sleep.js
Original file line number Diff line number Diff line change
Expand Up @@ -848,8 +848,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
skipInvalid: config.dreamSkipInvalid !== false,
allowCrossTypeMerge: config.allowCrossTypeMerge === true,
// issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
// 与 dream 主链路同一判据。
mergeGuard: config.dreamMergeGuard === true,
// 与 dream 主链路同一判据。issue #692:`!== false` 读,裸配置下默认开同样生效。
mergeGuard: config?.dreamMergeGuard !== false,
// Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
// 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
Expand Down
16 changes: 14 additions & 2 deletions dsh-mneme/src/semantic.js
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,16 @@ export function scheduleBootBackfill({
* init 异步失败经 #118 重试后 service 侧降级,此处引用仍在(dream/sleep 语义面
* 与搬移前一致)。dispose 清两个引导期计时器。
*/
/**
* issue #692:`resilientModelDownload` 在 schema 里默认开,裸配置调用方(测试、
* 程序化建 service)拿到的是 undefined。LocalEmbedder / Reranker 的构造函数刻意
* 「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见 local-embedder.js
* 的 issue #194 注释),所以默认值必须在装配层落定——判据收在这里,两处装配共用。
*/
export function resilientDownloadEnabled(cfg) {
return cfg?.resilientModelDownload !== false;
}

export function createSemantic({ store, service, settings, cfg, logger, vectorIndex, applyHumanEdits, lightMode = false }) {
let embedder = null;
let reranker = null;
Expand Down Expand Up @@ -442,7 +452,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
runtimeDir: cfg.runtimeDir,
// #188:embedModelMirror 接成 transformers 的下载镜像(此前死配置)。
remoteHost: cfg.embedModelMirror,
resilientModelDownload: cfg.resilientModelDownload,
// issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。
resilientModelDownload: resilientDownloadEnabled(cfg),
baseUrl: cfg.ollamaBaseUrl,
logger
});
Expand Down Expand Up @@ -499,7 +510,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn
// embedModelMirror 此前是死配置,现接成 transformers 的下载镜像。
useDtype: cfg.rerankDtype,
remoteHost: cfg.embedModelMirror,
resilientModelDownload: cfg.resilientModelDownload,
// issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。
resilientModelDownload: resilientDownloadEnabled(cfg),
logger
});
service.setReranker(reranker);
Expand Down
101 changes: 101 additions & 0 deletions dsh-mneme/test/issue-692-default-on-reads.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// issue #692:「默认开」的开关在代码里用 `=== true` 读,而默认值只写在 zod schema
// (src/config.js)里。走宿主(index.js 先经 Config(config))没问题,但绕过 Config 的
// 裸配置调用方拿到的是 undefined,于是「默认开」落不了地。
//
// 这里守的是读侧口径:`!== false`(与 autoInject / autoDream / hybridInject /
// heatEnabled / summarizeBatchCrossTypeDedupe 同一惯例),并各验一次显式关掉的反向
// 对照——否则「改成恒真」也会让默认开那半条通过。
//
// 第三处 `memoryQualityFilter.enabled` 刻意不在本文件覆盖:它挂在**自身带
// `.default({})` 的对象**里,经 Config 的调用方一直是开的;若把裸配置也翻成开,
// 等于重定义「程序化 createService 的默认契约」,现有断言里有 421 条按旧契约
// 写(短正文不被归档、检索能命中),属于要单独拍板的事,见 issue #692 的说明。
import test from "node:test";
import assert from "node:assert/strict";

import { createStore } from "../src/store.js";
import { createService } from "../src/service.js";
import { createDreamScheduler } from "../src/dream.js";
import { resilientDownloadEnabled } from "../src/semantic.js";

/** 裸配置:不经 Config,键缺失(= undefined),与第三方程序化调用同形。 */
function bareService(config = {}) {
const store = createStore(":memory:");
const service = createService({ store, mirror: null, config });
return { store, service };
}

/**
* audit.test.js 同款最小 ctx:consolidation 提示词以 "id=" 开头,summary 提示词
* 以 "- title: content" 开头,用这个形状区分两次 LLM 调用。
*/
function mockCtx({ onConsolidation, summaryText = "记忆库总览:用户偏好中文。" } = {}) {
return {
logger: { warn: () => {} },
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
llm: {
async *stream(options) {
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
if (userText.includes("| type=")) {
yield { type: "block-start", index: 0, blockType: "text" };
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
yield { type: "block-end", index: 0, block: { type: "text" } };
yield { type: "finish", reason: { kind: "stop" } };
return;
}
yield { type: "text-delta", index: 0, text: summaryText };
yield { type: "finish", reason: { kind: "stop" } };
}
}
};
}

/**
* 一次 dream run,模型把两条 constraint 合并成一条。constraint 在
* ARCHIVE_GUARDED_TYPES 里(src/dream/decisions.js:20),所以 mergeGuard 生效时
* 这条决策必须被 skip 并写明理由。
*/
async function runGuardedMerge(config) {
const { store, service } = bareService(config);
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
const { memory: a } = service.saveWithDedupe({ type: "constraint", title: "部署顺序", content: "先跑迁移再起服务。", importance: 4 });
const { memory: b } = service.saveWithDedupe({ type: "constraint", title: "部署顺序(补)", content: "先跑迁移,再起服务,最后冒烟。", importance: 4 });
const ctx = mockCtx({
onConsolidation: () => JSON.stringify([
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "部署顺序", content: "先跑迁移,再起服务,最后冒烟。", importance: 4 }
])
});
const result = await dream.runDream(ctx, service, config);
const run = store.listDreamRuns().at(-1);
// skip 明细:整单被判无效时在 result.decisions[].skipped,落了半批时在 run.skipped_reasons。
const errors = [
...(result.decisions ?? []).flatMap((d) => (d.skipped ?? []).map((s) => s.error ?? "")),
...(run.skipped_reasons ?? []).map((r) => r.error ?? "")
].join("\n");
return { result, run, errors, store };
}

// ---------------------------------------------------- dreamMergeGuard(默认开)

test("#692 dreamMergeGuard: 裸配置下默认开,guarded 类型 merge 被 skip", async () => {
const { result, errors } = await runGuardedMerge({});
assert.equal(result.applied ?? 0, 0, "guarded merge 不该落库");
assert.match(errors, /dreamMergeGuard/, "skip 理由要点名护栏");
assert.match(errors, /constraint/, "skip 理由要点名被保护的类型");
});

test("#692 dreamMergeGuard: 显式 false 才放行(反向对照)", async () => {
const { result, errors } = await runGuardedMerge({ dreamMergeGuard: false });
assert.equal(result.ok, true);
assert.equal(result.applied, 1, "显式关掉后同一条 merge 应当落库");
assert.doesNotMatch(errors, /dreamMergeGuard/, "关掉后不该再出现护栏理由");
});

// ------------------------------------------------ resilientModelDownload(默认开)

test("#692 resilientModelDownload: 装配层判据在裸配置下为开,显式 false 才关", () => {
assert.equal(resilientDownloadEnabled({}), true);
assert.equal(resilientDownloadEnabled(undefined), true);
assert.equal(resilientDownloadEnabled({ resilientModelDownload: true }), true);
assert.equal(resilientDownloadEnabled({ resilientModelDownload: false }), false);
});
Loading