Repository navigation
[feature] 作用域级可逆闸门:让单路记忆能被单独按下与松开,而不必改配置重启 #471
Description
与 issue #47 / #170 的关系
先说明分工,避免重复提案。
- 如何做到临时关闭记忆模式? #47(OPEN,无痕模式集中讨论帖) 讨论的是无痕会话:某个会话自身「什么都不进不出」——它是一次会话的行为,随会话结束而消失。本提案讨论的是作用域级政策闸门:某个
agent_scope/workspace_scope下的记忆「现在不参与进出」——它是一份跨会话存续的政策,由人按下、由人松开,与哪次会话无关。 - [Feature Request] scope 标注缺人工入口与提升回路:自动载体标签不该直接吃硬过滤效力 #170(CLOSED,v0.8.1 落地) 补的是事实轴的人工入口:
memory_save/memory_update的显式 scope 参数(把某条记忆钉死在某个作用域或提升为全局)+scope_changes审计表。本提案补的是政策轴:不逐条改归属,而是把整个作用域按下去。两者不重叠——[Feature Request] scope 标注缺人工入口与提升回路:自动载体标签不该直接吃硬过滤效力 #170 之后你依然无法「让这一路先安静一会儿」。
即:#17 立了隔离本体 → #170 补了事实轴的人工入口(逐条)→ 本提案补政策轴(按作用域)。三者落在同一个判定出口上,可以一并设计;若维护者认为该收拢进 #47 的设计批次,本提案可直接并入,代码不冲突。
要解决的问题 / Problem
场景
同一个 mneme 库里通常并行着好几条互不相干的工作线(例如:一个长期项目的记忆、一个一次性实验的临时记录、一个只在某个工作目录下才该生效的约定)。现在想让其中一路安静下来——不注入、不参与巩固——只有两条路:
- 改配置。把
injectGuidanceEnabled/selectiveInjectEnabled/hotMemoryEnabled/entityRecallEnabled之类关掉——但那是全局的,会连带把其他几路也关掉;而且改配置要重启才生效,期间那一路照样在注入。 - 逐条处理。
memory_forget或memory_archive一条一条按下去——粒度太细,一路记忆动辄几十上百条,而且按下之后要恢复得再一条条捞回来(memory_archive得配include_archived=true找)。
两条路都不好用,于是实际结果往往是:要么忍着,要么关掉全局。
为什么这是缺口而不是「已有开关不够多」
本仓的闸门并不少——src/config.js 里共有 59 处 z.boolean().default(...)(其中 24 个名字以 Enabled 结尾,另有若干嵌在 writeAdmission / preInjectGate 等子对象里的 enabled / enforce)。但没有一个是按作用域分档的,全都是全局布尔。
(可数一遍:grep -cE "z\.boolean\(\)\.default\(" src/config.js → 59。代表性开关:injectGuidanceEnabled:92、selectiveInjectEnabled:449、hotMemoryEnabled:443、rerankEnabled:476、entityRecallEnabled:537、conflictFreezeEnabled:503、scopeEnabled:856、strictScope:864,以及 writeAdmission.enforce:731、preInjectGate.enforce:752。)
也就是说:本仓已经有完整的事实轴(src/scope.js 的 agent_scope / workspace_scope 标注,memory_save 的显式声明参数,scope_changes 表的人工修正审计),却缺一条政策轴——「现在这一刻,这个作用域允不允许」这种由人给出、可逆、存在运行期状态里、且不改身份不改配置的判断。
这两条轴的性质不同:
- 事实轴回答「这条记忆属于谁」——它是持久的、跟着记忆行走的。
- 政策轴回答「这个作用域现在通不通」——它是暂时的、可以被单方面按下的、松开即恢复的。
把政策需求硬塞进事实轴(逐条 forget/archive)会污染归属数据;塞进配置(全局开关)会误伤其他路。所以它需要自己的存储位置。
一个具体点的例子
假设有一个工作目录下的会话在跑一个一次性实验,往里写了四十来条临时结论。现在实验暂停了,你不想让这些结论继续影响注入排序。今天你只能:
- 关
selectiveInjectEnabled(全局,其他项目跟着变),或 - 写个脚本把这四十条按
workspace_scope捞出来逐条 archive(能行,但要写脚本,而且恢复时要再来一遍)。
一个作用域级闸门能让这件事变成一句 set_scope_state(workspace_scope="<那个目录>", state="paused")。
期望的方案 / Proposed solution
一张运行期状态表,键 = 作用域维度 + 作用域值,值 = active / paused。
按下即:该作用域下的候选不进注入、不参与巩固。松开即恢复。全程不改记忆行的 agent_scope / workspace_scope,不改任何配置键,不重启。
落点(三处,都照本仓既有模式写,不引入新架构)
1. 存储 —— src/store.js 加一张表
照 scope_changes(src/store.js:169-187)的写法:
CREATE TABLE IF NOT EXISTS scope_state (
id TEXT PRIMARY KEY,
dimension TEXT NOT NULL, -- 'agent' | 'workspace'
scope_value TEXT NOT NULL, -- 具体标签值;'*' 表示该维度全局
state TEXT NOT NULL, -- 'active' | 'paused'
note TEXT,
decided_at TEXT NOT NULL,
UNIQUE(dimension, scope_value)
);唯一索引 (dimension, scope_value) 保证幂等:同一作用域重复按下不新增行,只更新 state。这与本仓 setArchived 刻意选「重复归档不刷新时刻」的取舍方向一致(src/store.js:1394,注释在 :1402-1403),建议这里也照做:重复按下同一状态不刷新 decided_at,否则「最后一次真正变更」的时刻会被噪声盖掉。
2. 判定 —— src/service.js:397-400 的 gateByScope 旁边
这是全仓唯一的过滤实现。该处注释已写明抽成单一实现的原因:
抽成单一实现是为了让「过滤点写在哪」不再漂移
作用域闸门必须挂在同一处,与新闸门共用同一个 rows.filter(...) 出口。否则本仓刚修好的「过滤点漂移」问题(entity: 与 attr: 两条前缀路曾在入口就 return,整条绕过硬墙)会以新形态重演一遍。
建议形状:
function gateByScope(rows, scope) {
if (!scope) return rows;
const gated = gateByScopeState(rows); // 新:政策轴
if (config?.strictScope !== true) return gated; // 既有:事实轴
return gated.filter((m) => isVisibleInScope(m, scope));
}顺序上政策轴在前——它是更便宜的判断(一次内存查表 vs 逐行比较),且语义上「现在通不通」应当先于「你看不看得见」。
3. 工具 —— src/tools.js 加 set_scope_state
照 memory_forget(src/tools.js:432-463)的写法,同为可逆布尔语义:
dimension:"agent"|"workspace"scope_value: 具体标签;"*"表示该维度全局state:"active"|"paused"- 回执里如实报当前值(
scope_value与state都回显),让「我按的是哪个」不必靠记忆
开关语义
直接复用本仓已有的两级(观察档 / 拦截档),见 src/write-admission.js:11:
两个开关都默认关:enabled 打开判定,enforce 打开拦截(关 = 仅告警 + 留审计)
src/pre-inject-gate.js:20-23 已经明确复用同一套语义。建议作用域闸门也分两级:
enabled打开时:被按下的作用域,其候选照常注入但留一条审计行,说清「这条本应被闸门拦下」enforce打开时:真的不注入
这样能先在真实流量里看「如果按下,会少掉多少」,再决定要不要真按。本仓在 writeAdmission 与 preInjectGate 上都是这么分两次上线的,作用域闸门没有理由例外。
与既有机制的关系
- 不改
strictScope。strictScope(src/config.js:864)是可见性硬墙,问的是「你看不看得见」;作用域闸门是政策闸门,问的是「现在通不通」。两者可叠加:看不见的本来就看不见,看得见的现在可能被按下。 - 不替代
conflictFreezeEnabled。那个(src/config.js:503)是逐条冲突的停车裁决;作用域闸门是按作用域的整体闸门,粒度不同。 - 不动
memory_forget/memory_archive。那两条是事实轴上的单条操作,语义是「这条记忆不再算数」;作用域闸门是「这一路现在不参与」,松开后那些记忆原样回来,不需要任何恢复动作。
考虑过的替代方案 / Alternatives considered
A. 直接用配置开关按作用域分档(把 59 个布尔改成按 scope 的映射)
否决理由:配置是启动期读的、改完要重启;而且会把「长期策略」和「临时状态」混进同一个文件。本仓对这两类东西已有清晰的分野——scope_changes 是运行期表、配置键是启动期读的,不该打破。
B. 逐条 memory_archive(用脚本按 scope 批量)
否决理由:能用,但把政策需求写进了事实轴——被 archive 的行在恢复时要一条条捞(且 include_archived=true 才能看见),而且这会在归档面上留下本不该有的行。更麻烦的是语义混淆:日后看到那些归档行,没人分得清「这条被判定为不再算数」还是「这条所在的那一路当时被按下了」。
C. 在 agent/pre-step 钩子里做动态判定(不落表,纯内存)
否决理由:内存态一重启就没了,而「按下一路」恰恰是个人会跨重启保留的意图。本仓对这一点已有教训:src/pre-inject-gate.js:86-88 的三个缓存都是 new Map(),src/inject.js:220 注释也写明「never persists anywhere」——那是有意为之的(缓存是热数据),但政策意图不是热数据。
D. 给每条记忆加一个 paused 列
否决理由:作用域闸门的效果是「这一路」,逐行加列会让同一个状态在几百行上各存一份;松开时要么全表扫、要么漏掉中途新写的行。按作用域存一行是唯一不会漏的形式。
实现口径确认 / Implementation gates (维护者会逐项核对)
- 新行为开关应 opt-in 默认关,注册进 settings 白名单
scopeState.enabled/scopeState.enforce都默认false;打开前行为与今天逐字段一致(对齐writeAdmission的验收第 1 条「默认路径零行为变化」,src/write-admission.js:130)
- 涉及 schema 变更时需配幂等迁移
- 新表用
CREATE TABLE IF NOT EXISTS,与src/store.js既有建表写法一致;UNIQUE(dimension, scope_value)使重复按下幂等
- 新表用
- 接受带回归测试的验收线
- 建议三条:①默认关时
gateByScope输出与今天逐字段一致 ②按下后同 scope 的候选不进注入、松开后原样回来 ③enabled开enforce关时留审计行但注入照常
- 建议三条:①默认关时
备注:本提案涉及的「作用域级可逆闸门」这一形式,灵感来自另一个 DSH 插件 dsh-domain-role(MIT,1Lany2a/dsh-domain-role)的四级暂停机制——它的作用域是 domain / session / project / global,判定 OR 叠加、任一级按下即拦、两端都查(发送端与接收端)、恢复时逐条重评。本提案没有照搬它的四级作用域,原因是它的 project 级存在一个已知缺口(其 docs/06-limitations.md 的 B1 写明「项目级暂停不读会话存档 ⇒ 判不出就不拦」);而本仓的 agent_scope / workspace_scope 都是确定值,不存在「判不出归属」的情况,所以作用域维度直接取本仓已有的两个即可。
Activity
fix(scope): 睡眠侧也按政策轴过滤——闸门不能只管白天 (#471)
docs(scope): 覆盖范围补上睡眠侧四个相位 (#471)
fix(scope): 巩固总览的输入也过政策轴——漏了这处等于一直在洗记忆 (#471)
docs(scope): 覆盖范围写全总览一处,含「为何只能在源头挡」 (#471)
docs(scope): 改掉一句与实现不符的注释——检视面本来就不过闸 (#471)
fix(scope): 睡眠侧也按政策轴过滤——闸门不能只管白天 (#471)
docs(scope): 覆盖范围补上睡眠侧四个相位 (#471)
fix(scope): 巩固总览的输入也过政策轴——漏了这处等于一直在洗记忆 (#471)
docs(scope): 覆盖范围写全总览一处,含「为何只能在源头挡」 (#471)
docs(scope): 改掉一句与实现不符的注释——检视面本来就不过闸 (#471)
feat(scope): 作用域级可逆闸门——把一路记忆单独按下与松开 (slow-stack#471)
docs(readme): the tool table was missing two tools, and nothing could…