Skip to content

[注入] pin 池靠类型推断,预算 5 却对每条 constraint/preference 生效;显式存储后判据、回执与候选池另有三处缺陷 #591

Closed

Description

@modusensus

背景

#249 的 P1 要把「约束/偏好类记忆 pin 住、逐字保真、不参与相关性竞争」落地,但注入侧现在只有「类型推断」:constraint / preference 这个类型本身就等于 pin,于是这些类型的每一条都自动具备「每轮必进」的资格,而预算只有 5(pinnedInjectBudget)。池子本身过大,稳定偏好只能靠相关性去挤那 10 个名额,挤不进去也无声。

这条线把 pin 从「类型推断」改成「用户亲手钉的显式事实」,顺带修掉三处同链缺陷。默认 pinnedInjectBudget = 0,预算关时注入序列与改前逐条相同,本线不改变任何人的现有行为。

缺陷 1(bug):pin 池被向量路合并的封顶挤出候选池

pin 筛选发生在 hybrid 合并之后,而 merged 已按 poolSize 封顶(生产 maxItems=5 → poolSize=10,向量路一次正好返回 10 条即 break)。规则窗口里的 constraint/preference 因此一条都进不了 candidates,pin 池只能从那 10 条里碰运气选,与「pin 不占 maxItems 名额」的既有承诺不一致。

修法:合并块之后、topic 重排之前,把被 poolSize 挤掉的 pin 类行补到候选池尾部;只在预算 > 0 时补,只补尾部,strictScope 与 scopeState 两道闸门照旧作用其上。

真库副本实测(6733 条有向量行、同一 query、1024 维、pinnedInjectBudget=5、maxItems=5):

臂 pin 注入 注入总条数
改前 2/5 7
改后 5/5 10

无向量路径改前改后都 5/5、10 条且逐条同序 —— 补丁不改本来正常的路径。

缺陷 2(bug):生效判据写成 countPinned() > 0,会让 pin 全灭且永不回落

pin 行会在 memories 那边被归档、遗忘、硬删,而 pinned_memories 对这三种一无所知。只要表里还剩一行(哪怕早已归档),判据就一直判 explicit,于是 eligible 恒为空 —— pin 全灭,且永不回落到旧的类型口径,无日志无报错。

四场景复现:正常 pin 不触发;setArchived 触发;setForget 触发;remove 触发。前两种最要紧,因为归档/遗忘是日常操作,比硬删常见得多 —— 所以只补 DELETE 堵不住,根因在判据。

改法:判据从「表里有几行」改成「有几条能用的」。pinnedStats.source 因此有三档:explicit / fallback-none-usable(钉过但一条都用不了,静默降级必须留痕)/ type-fallback(从没钉过,升级即等价)。

缺陷 3(bug):memory_pin 的 injectable 回执漏了「暂停作用域」

描述文案、README 与 render 文案三处都写着「遗忘 / 归档 / 暂停作用域都优先于 pin,回执如实报告」,但 execute 只算了两项:

const injectable = !target.forgotten && !target.archived;

被按下作用域(enforce 档)的行因此报 injectable: true 而实际一条都没注入。三组对照:按下暂停 → 回执 true、注入 0 条;不按暂停 → 回执 true、注入 1 条;暂停中钉、解除后重钉 → 两次都 true。报 true 的两种处境里一种真会注入、一种被挡住,回执分不出来。

改法:回执改用 service.filterPaused([target])(即 scopeStateGate.filter),与注入侧共用同一个出口,以后改闸不会漏掉回执这一侧。注入侧本身没坏 —— scopeStateGate 一直在拦,坏的是回执另写了一份判据。

落点

  • src/store.js:新增 pinned_memories(memory_id PRIMARY KEY, created_at, note) + idx_pinned_created。独立表而非主表布尔列 —— 回退只需一条 DROP,不碰主表 31 列;pin 是「用户与记忆之间的一条关系」,created_at 还能给出 pin 顺序。幂等靠 CREATE TABLE IF NOT EXISTS,不登记 applied_migrations(本表没有回填步骤)。外泄面为零:mirror / share 都不 SELECT 本表。
  • src/service.js:取数按 id 点查(pin 行可能不在 200 行候选窗口内),带列裁剪、不 SELECT *;两道闸不放宽。
  • src/tools.js:memory_pin 工具。

验收

  • 存量库升级后新表为空 → 回落旧类型口径,两臂在真库副本上逐条 id 相同(maxItems 5 与 10 都验过)
  • 预算 0 时任何路径都不改注入序列
  • 归档 / 遗忘 / 硬删三种场景各有用例
  • 向量路填满 poolSize 时 pin 仍拿满预算
  • 暂停作用域下回执如实为 false
  • npm test 全量 2858 用例 / 2857 通过 / 0 失败 / 1 跳过(src↔lib 一致性守门通过)

不做

PINNED_MEMORY_TYPES 保持原样不动 —— 它在 write-admission 还承担写入配额豁免语义,与注入侧是两个不同的事,本线只把注入侧解耦。

Activity

  1. modusensus commented on Nov 6, 2026

    @modusensus
    MemberAuthor

    复核结论(PR #593,head 8f9d8a95)

    已做源码级复核 + 独立复跑,并对 CodeRabbit 的 7 条行内评审逐条读源码核实。

    三处缺陷与本 PR 的对应

    缺陷 落点 状态
    ① pin 筛选在 hybrid 合并之后,而 merged 已按 poolSize 封顶 src/service.js:1887-1921 补池块在合并之后、topic 重排(:1927)之前 已修
    ② 判据 countPinned() > 0 在归档/遗忘/硬删后恒判 explicit、eligible 恒空 判据改「有几条能用的」,pinnedStats.source 三档 explicit / fallback-none-usable / type-fallback 已修
    ③ memory_pin 的 injectable 漏 scopeStateGate 的 enforce 档 改用 service.filterPaused([target]),与注入侧同一出口 已修

    次序已核验:补池(:1887-1921)在 topic 重排(:1927)、strictScope(:1969)、scopeStateGate.gateInjection(:1976)之前,pin 不是绕闸通道。

    CodeRabbit 7 条逐条核实

    成立、建议修(2 条)

    • src/service.js:2789 — 归档 evidence 时只按类型保护(src/document.js:297 的 pinnedTypes.has(row.type)),不查 pinned_memories 表。显式 pin 一条 pattern / decision 后,注册文档吸收它时仍会被 store.setArchived(id, true) 归档,之后注入跳过它——用户没取消 pin。这是本 PR 把 pin 从类型推断改成显式存储后新出现的缺口,属本 PR 应修未修。修法:src/document.js:297 追加 || store.isPinned?.(row.id)。
    • src/tools.js:502 — injectable 没把 pinnedInjectBudget === 0 算进去。pinnedInjectBudget 默认 0(src/service.js:1882),此时整段补池不执行、pin 完全不起作用,但回执照样报 injectable: true。回执在说谎,建议加上 pinnedBudget > 0 条件或在 render 里补一句说明。

    不成立(4 条)

    • src/service.js:1899 子系统开关 — eligible 是 pinRows.filter((m) => live.has(m.id) ...),live 来自已按 injectTypes / allowNarrative 过滤的 candidates,关掉的子系统行进不了 live。
    • src/service.js:1996 graphHint — 已实现 m.graphHint !== true(:1996、:1916、:1998 三处)。
    • src/store.js:3939 id trim — 工具层传的是 target.id(store 铸造的真实主键),不是用户原始输入。
    • src/store.js:3971 SQLite 变量上限 — 理论上限,pin 行数以个位计且 :1900 有 catch { pinRows = [] } 回落;附带那条「SQL 注入」告警是误报(${placeholders} 只由 ?, ? 拼成,${memoryColumnList} 来自 PRAGMA 白名单,实际值全部绑定)。

    成立但既有缺陷、被本 PR 新调用点暴露(1 条)

    • src/tools.js:536 — strictScope=true 且传入不存在的 id 时,visibleUnderStrictScope(:124-128)无 undefined 短路,service.isVisibleInScope(:130-143)直接读 m.agent_scope_source → TypeError 而非 "memory not found"。该缺陷在 origin/main 已有(memory_get 同样走它),本 PR 新增 memory_pin 又多一个触发面。修法一行:src/tools.js:125 加 if (memory === undefined) return true;,惠及 memory_get / update / delete / pin。

    schema 变更(需要用户拍板)

    本 PR 新增 CREATE TABLE IF NOT EXISTS pinned_memories (memory_id PRIMARY KEY, created_at, note) + idx_pinned_created,幂等靠 CREATE TABLE IF NOT EXISTS、不登记 applied_migrations。独立表而非主表布尔列,回退是一条 DROP。按长期纪律「线上库迁移压到考后」,这条需要用户本人确认是否例外放行。

    复跑(本机实测)

    项 结果
    scripts/check-sync.js ✓ 95 个文件一致
    npm test 2860 tests / 2859 pass / 0 fail / 1 skip(57.2s)

    环境 Windows 11 + Node 24.15.0;未跑 ubuntu / node22 / test-host-legacy(由 CI 覆盖)。CI:6 条 test 腿 + gitleaks + dependency-review + workflow-hygiene + zizmor + CodeRabbit + codecov/patch 全 success,osv-scanner skipping。codecov patch 97.64%,7 行未覆盖。

    未做

    未在真实记忆库上跑 pin 的端到端注入对比(用的是测试夹具);未做面板真机走查;未验证 document 类型被显式 pin 后与 documentMemoryEnabled 的交互。

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