Skip to content

[feature] 作用域级可逆闸门:让单路记忆能被单独按下与松开,而不必改配置重启 #471

Closed

Description

@modusensus

与 issue #47 / #170 的关系

先说明分工,避免重复提案。

即:#17 立了隔离本体 → #170 补了事实轴的人工入口(逐条)→ 本提案补政策轴(按作用域)。三者落在同一个判定出口上,可以一并设计;若维护者认为该收拢进 #47 的设计批次,本提案可直接并入,代码不冲突。


要解决的问题 / Problem

场景

同一个 mneme 库里通常并行着好几条互不相干的工作线(例如:一个长期项目的记忆、一个一次性实验的临时记录、一个只在某个工作目录下才该生效的约定)。现在想让其中一路安静下来——不注入、不参与巩固——只有两条路:

  1. 改配置。把 injectGuidanceEnabled / selectiveInjectEnabled / hotMemoryEnabled / entityRecallEnabled 之类关掉——但那是全局的,会连带把其他几路也关掉;而且改配置要重启才生效,期间那一路照样在注入。
  2. 逐条处理。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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions