Skip to content
Open
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
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

## 🆕 新增

- **被动确认 Passive Confirmation(issue #24 块4)**:把「人工认可/删除/忽略」的确认门槛收敛成例外管理——正常触达(记忆被召回/注入)即视为对挂联关系边的被动确认,`store.getRelationsByMemory` 反查关联边后 `bumpRelationWeight` 抬一格(只加不减、封顶 1.0,自激回路由封顶遏制)。`graphPassiveConfirm` 与 heat 是独立闸门(`heatEnabled=false` 时仍可演化边权),且受块2 总闸 `graphWeightEnabled` 约束——两键同开才生效,单开通道键绕不过「演化默认关」。仅异常路径暴露给用户复核的复核 UI 留后续块。新增键注册进 feature_flags 白名单;lightMode 强制关。

## [0.8.11] - 2026-09-30

## 🐛 修复
Expand Down
6 changes: 6 additions & 0 deletions dsh-mneme/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,10 @@ export const Config = z.object({
graphWeightEnabled: z.boolean().default(false),
// 单次有效触达的抬升幅度。
graphWeightDelta: z.number().min(0).max(1).default(0.1),
// 被动确认(issue #24 块4):heat 触达(记忆被召回/注入=被用)即对该记忆
// 挂联的关系边做被动确认——bumpRelationWeight 抬一格。默认关=零行为变化;
// 开=正常触达演化边权,「关系是用出来的」闭环落地(异常路径复核留后续块)。
graphPassiveConfirm: z.boolean().default(false),

// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
Expand Down Expand Up @@ -720,6 +724,8 @@ const LIGHT_MODE_OFF = [
"entityRecallEnabled",
// 轻量模式不开边权重演化(#24 块2,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
"graphWeightEnabled",
// 轻量模式不开被动确认(#24 块4,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
"graphPassiveConfirm",
// 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。
"dreamNarrativeEnabled",
// 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
Expand Down
21 changes: 19 additions & 2 deletions dsh-mneme/lib/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,28 @@ export function createService({ store, mirror, config, onWrite, logger, document
* on the hot recall path). A touch failure must never break search/inject.
*/
function touchRecalled(memories) {
if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
if (!Array.isArray(memories) || memories.length === 0) return;
// heatEnabled=false 只关 touchLastAccess 的热度消费(既有语义),但 #24
// 块4 被动确认独立于 heat——graphPassiveConfirm 开时照常 bump 关联边。
// graphWeightEnabled 是块2 立的演化总闸:它关着时任何触达都不该改边权,被动
// 确认只是它底下的一个通道(后续块还会接别的确认通道),两键同开才 bump,
// 否则单开通道键就等于绕过「演化默认关」的承诺。
// 两条路径都 best-effort,失败绝不阻断检索/注入。
const heatOn = config?.heatEnabled !== false;
const passive = config?.graphPassiveConfirm === true && config?.graphWeightEnabled === true;
const delta = config?.graphWeightDelta ?? 0.1;
for (const m of memories) {
if (!m?.id) continue;
try {
store.touchLastAccess(m.id);
if (heatOn) store.touchLastAccess(m.id);
if (passive) {
// 正常触达=对该记忆挂联关系边的被动确认;bump 只加不减封顶 1.0,
// 自激回路由封顶天然遏制。「仅异常路径暴露给用户复核」的确认记录
// 侧在此落地,复核 UI 留后续块。
for (const rel of store.getRelationsByMemory(m.id)) {
store.bumpRelationWeight(rel.id, delta);
}
}
} catch { /* touch is best effort */ }
}
}
Expand Down
11 changes: 10 additions & 1 deletion dsh-mneme/lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ const FEATURE_FLAG_BOOLEANS = [
// Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
// (默认关;依赖实体抽取产出,lightMode 强制关闭)。
"entityRecallEnabled",
// Issue #24 块2:边权重演化总闸——关着时任何触达都不改边权(默认关;
// 依赖实体抽取产出,lightMode 强制关闭)。本块与树内 #341/#343 的键一并
// 登记,保证这条分支上出现在 config 里的键都进了白名单。
"graphWeightEnabled",
// Issue #24 块4:被动确认通道——正常触达即对该记忆挂联的边 bump 一格
// (默认关;需总闸 graphWeightEnabled 同开)。
"graphPassiveConfirm",
// Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source=
// narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。
"dreamNarrativeEnabled",
Expand Down Expand Up @@ -151,7 +158,9 @@ const FEATURE_FLAG_NUMBER_RANGES = {
// Issue #127:vector 去重档的并入阈值。
summarizeDedupeMinSim: [0.5, 0.99],
// Issue #125:hybrid 判"高相似"的阈值。
dreamCandidateMinSim: [0.5, 0.99]
dreamCandidateMinSim: [0.5, 0.99],
// Issue #24 块2:单次有效触达的边权抬升幅度(与 config.js 的闭区间对齐)。
graphWeightDelta: [0, 1]
};
// 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
// 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
Expand Down
14 changes: 14 additions & 0 deletions dsh-mneme/lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,19 @@ export function createStore(path) {
).all(entityId, entityId).map(toRelation);
}

/**
* 按记忆反查关联边(issue #24 块4 被动确认):一条记忆被召回/注入(触达)
* 时,挂在同一记忆上的关系行即「这条关系被用过了」的证据——正常触达视为
* 被动确认,调用方据此 bumpRelationWeight。返回 toRelation 行(含 weight/
* source)。索引 idx_relations_memory 已建(#219)。
*/
function getRelationsByMemory(memoryId) {
if (!memoryId) return [];
return db.prepare(
"SELECT * FROM entity_relations WHERE memory_id = ?"
).all(memoryId).map(toRelation);
}

/** All entities (optionally name-filtered, newest first). Used by sleep phase 4
* orphan detection: an entity with zero relations is a candidate for relation
* completion. */
Expand Down Expand Up @@ -2884,6 +2897,7 @@ export function createStore(path) {
bumpRelationWeight,
migrateAttrsToMemory,
getRelations,
getRelationsByMemory,
setMirrorState,
getMirrorState,
markMirrorDirty,
Expand Down
6 changes: 6 additions & 0 deletions dsh-mneme/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,10 @@ export const Config = z.object({
graphWeightEnabled: z.boolean().default(false),
// 单次有效触达的抬升幅度。
graphWeightDelta: z.number().min(0).max(1).default(0.1),
// 被动确认(issue #24 块4):heat 触达(记忆被召回/注入=被用)即对该记忆
// 挂联的关系边做被动确认——bumpRelationWeight 抬一格。默认关=零行为变化;
// 开=正常触达演化边权,「关系是用出来的」闭环落地(异常路径复核留后续块)。
graphPassiveConfirm: z.boolean().default(false),

// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
Expand Down Expand Up @@ -720,6 +724,8 @@ const LIGHT_MODE_OFF = [
"entityRecallEnabled",
// 轻量模式不开边权重演化(#24 块2,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
"graphWeightEnabled",
// 轻量模式不开被动确认(#24 块4,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
"graphPassiveConfirm",
// 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。
"dreamNarrativeEnabled",
// 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
Expand Down
21 changes: 19 additions & 2 deletions dsh-mneme/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,28 @@ export function createService({ store, mirror, config, onWrite, logger, document
* on the hot recall path). A touch failure must never break search/inject.
*/
function touchRecalled(memories) {
if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
if (!Array.isArray(memories) || memories.length === 0) return;
// heatEnabled=false 只关 touchLastAccess 的热度消费(既有语义),但 #24
// 块4 被动确认独立于 heat——graphPassiveConfirm 开时照常 bump 关联边。
// graphWeightEnabled 是块2 立的演化总闸:它关着时任何触达都不该改边权,被动
// 确认只是它底下的一个通道(后续块还会接别的确认通道),两键同开才 bump,
// 否则单开通道键就等于绕过「演化默认关」的承诺。
// 两条路径都 best-effort,失败绝不阻断检索/注入。
const heatOn = config?.heatEnabled !== false;
const passive = config?.graphPassiveConfirm === true && config?.graphWeightEnabled === true;
const delta = config?.graphWeightDelta ?? 0.1;
for (const m of memories) {
if (!m?.id) continue;
try {
store.touchLastAccess(m.id);
if (heatOn) store.touchLastAccess(m.id);
if (passive) {
// 正常触达=对该记忆挂联关系边的被动确认;bump 只加不减封顶 1.0,
// 自激回路由封顶天然遏制。「仅异常路径暴露给用户复核」的确认记录
// 侧在此落地,复核 UI 留后续块。
for (const rel of store.getRelationsByMemory(m.id)) {
store.bumpRelationWeight(rel.id, delta);
}
}
} catch { /* touch is best effort */ }
}
}
Expand Down
11 changes: 10 additions & 1 deletion dsh-mneme/src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ const FEATURE_FLAG_BOOLEANS = [
// Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
// (默认关;依赖实体抽取产出,lightMode 强制关闭)。
"entityRecallEnabled",
// Issue #24 块2:边权重演化总闸——关着时任何触达都不改边权(默认关;
// 依赖实体抽取产出,lightMode 强制关闭)。本块与树内 #341/#343 的键一并
// 登记,保证这条分支上出现在 config 里的键都进了白名单。
"graphWeightEnabled",
// Issue #24 块4:被动确认通道——正常触达即对该记忆挂联的边 bump 一格
// (默认关;需总闸 graphWeightEnabled 同开)。
"graphPassiveConfirm",
// Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source=
// narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。
"dreamNarrativeEnabled",
Expand Down Expand Up @@ -151,7 +158,9 @@ const FEATURE_FLAG_NUMBER_RANGES = {
// Issue #127:vector 去重档的并入阈值。
summarizeDedupeMinSim: [0.5, 0.99],
// Issue #125:hybrid 判"高相似"的阈值。
dreamCandidateMinSim: [0.5, 0.99]
dreamCandidateMinSim: [0.5, 0.99],
// Issue #24 块2:单次有效触达的边权抬升幅度(与 config.js 的闭区间对齐)。
graphWeightDelta: [0, 1]
};
// 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
// 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
Expand Down
14 changes: 14 additions & 0 deletions dsh-mneme/src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,19 @@ export function createStore(path) {
).all(entityId, entityId).map(toRelation);
}

/**
* 按记忆反查关联边(issue #24 块4 被动确认):一条记忆被召回/注入(触达)
* 时,挂在同一记忆上的关系行即「这条关系被用过了」的证据——正常触达视为
* 被动确认,调用方据此 bumpRelationWeight。返回 toRelation 行(含 weight/
* source)。索引 idx_relations_memory 已建(#219)。
*/
function getRelationsByMemory(memoryId) {
if (!memoryId) return [];
return db.prepare(
"SELECT * FROM entity_relations WHERE memory_id = ?"
).all(memoryId).map(toRelation);
}

/** All entities (optionally name-filtered, newest first). Used by sleep phase 4
* orphan detection: an entity with zero relations is a candidate for relation
* completion. */
Expand Down Expand Up @@ -2884,6 +2897,7 @@ export function createStore(path) {
bumpRelationWeight,
migrateAttrsToMemory,
getRelations,
getRelationsByMemory,
setMirrorState,
getMirrorState,
markMirrorDirty,
Expand Down
5 changes: 3 additions & 2 deletions dsh-mneme/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -669,8 +669,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
// issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes,
// issue #292 新增 autoDreamFailureBackoff、
// issue #254 新增 writeAdmission.enabled/writeAdmission.enforce,
// issue #34 恢复(#333)新增 injectTimePrefix)
assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1);
// issue #34 恢复(#333)新增 injectTimePrefix、
// issue #24 块2 新增 graphWeightEnabled/graphWeightDelta、块4 新增 graphPassiveConfirm)
assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3);
assert.equal(data.effective.dreamSkipInvalid, true);
assert.equal(data.effective.allowCrossTypeMerge, false);
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
Expand Down
92 changes: 92 additions & 0 deletions dsh-mneme/test/graph-passive-confirm.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// issue #24 块4 被动确认(graphPassiveConfirm)测试。
// 覆盖:默认关=零行为变化 / 开启=命中记忆的关联边 bump / heat 关但 passive
// 开仍 bump(独立闸门)/ bump 只加不减封顶 / 无关联边的记忆触达安全降级。
import test from "node:test";
import assert from "node:assert/strict";
import { createStore } from "../src/store.js";
import { createService } from "../src/service.js";

function setup(config = {}) {
const store = createStore(":memory:");
const service = createService({ store, mirror: null, config });
return { store, service };
}

function putMem(store, service, { title, content, importance = 3 }) {
return service.saveWithDedupe({ type: "project", title, content, importance }).memory;
}

function linkRelToMem(store, { fromName, toName, memoryId, source = "llm" }) {
const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type: "technology" });
const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type: "technology" });
return store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: memoryId, source });
}

/** 跑一次检索(内部 touchRecalled 触发触达),返回 rows。 */
async function searchOnce(service, query) {
return service.searchMemories(query, { mode: "auto" });
}

test("graphPassiveConfirm=false (default): touched memory does NOT bump relation weight", async () => {
const { store, service } = setup();
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
assert.equal(rel.weight, 0.4);
await searchOnce(service, "数据库迁移");
// 默认关:检索触达不 bump(weight 保持 0.4)。
assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "weight unchanged by default");
store.close();
});

test("graphPassiveConfirm=true: touched memory bumps its linked relation weight", async () => {
const { store, service } = setup({ graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
assert.equal(rel.weight, 0.4);
const rows = await searchOnce(service, "数据库迁移");
assert.ok(rows.some((r) => r.id === mem.id), "memory surfaced");
assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "weight bumped after touch");
store.close();
});

test("heatEnabled=false does NOT gate passive confirm (independent gates)", async () => {
const { store, service } = setup({ heatEnabled: false, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
await searchOnce(service, "数据库迁移");
assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "bumped even with heat off");
// 但 touchLastAccess 不写(heat 关的既有语义保留:last_accessed_at 仍为空)。
assert.equal(store.getById(mem.id).last_accessed_at, undefined);
store.close();
});

test("repeated touches cap at 1.0 (never exceeds)", () => {
const { store } = setup();
const a = store.createEntity({ name: "A", type: "technology" });
const b = store.createEntity({ name: "B", type: "technology" });
const rel = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" }); // 0.4
for (let i = 0; i < 5; i++) {
store.bumpRelationWeight(rel.id, 0.3); // 0.4→0.7→1.0→1.0…
}
assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped at 1.0");
store.close();
});

// 总闸(块2 的 graphWeightEnabled)关着时被动确认不许生效——单开通道键就能演化
// 边权的话,块2 立的「演化默认关」总闸形同虚设。
test("graphWeightEnabled=false gates passive confirm (master switch)", async () => {
const { store, service } = setup({ graphPassiveConfirm: true, graphWeightDelta: 0.1 });
const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
await searchOnce(service, "数据库迁移");
assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "master switch off → weight untouched");
store.close();
});

test("memory without relations survives touch (no crash)", async () => {
const { store, service } = setup({ graphPassiveConfirm: true });
const mem = putMem(store, service, { title: "孤立条", content: "无关联边" });
await searchOnce(service, "孤立条");
assert.ok(store.getById(mem.id), "memory still present");
store.close();
});