oh-my-pi Sharpshooter 决策提取解析:从用户消息到项目记忆 Delta 的输入信封与证据门控
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
导读
本篇技术指南以 oh-my-pi 项目中的sharpshooter-extract-input.md提示词模板为核心,剖析 Sharpshooter —— 一个"摩擦门控"(friction-gated)的项目决策记忆子系统——如何从编码会话的单条用户消息中异步提取可持久化的项目决策 Delta。读者将掌握提取阶段输入信封(envelope)的三段式构建逻辑、record_deltas工具与证据(evidence)硬性校验规则、Delta 队列的落盘与消费机制,以及它与 5 分钟合并(consolidation)流水线之间的完整数据流,并可直接在配置中启用该记忆后端。
Sharpshooter 在 oh-my-pi 记忆体系中的定位
oh-my-pi 的编码 Agent(位于 packages/coding-agent)面临一个普遍难题:会话中用户做出的项目级决策("用 X 不用 Y""这是有意为之,不是 bug")会在会话结束后丢失,下次会话的 Agent 可能把代码里已反映的决策再次做错。为此,项目在 记忆后端选择器 中提供了off / local / hindsight / mnemopi / sharpshooter五种后端,其中 Sharpshooter 的定位是:
Friction-gated project decision files (architecture/product/style), consolidated in the background
即以摩擦为门控的项目决策记忆:只有"踩过坑"的决策才值得记住,记忆沉淀为architecture.md、product.md、style.md三个 Markdown 决策文件,并注入后续编码会话的上下文中。从源码结构看,整个子系统分为三个阶段,本文聚焦第一阶段:
- 提取(extract)——每条用户消息后异步触发,产出决策 Delta,写入按会话分组的队列(见 extract.ts);
- 排队(queue)——每个 Delta 一个 JSON 文件,生产者无锁追加(见 queue.ts);
- 合并(consolidate)——按项目每 5 分钟(可配)执行一次,将队列 Delta 按"准入法"合并进三个记忆文件(见 consolidate.ts)。
提取输入信封:关联文档的完整结构
本指南的关联文档 sharpshooter-extract-input.md 是提取阶段的用户消息模板(Handlebars 模板,运行期由prompt.render(extractInputTemplate, { ...envelope })渲染,见 extract.ts)。其完整内容如下:
{{#if previousHuman}} <previous-user-message purpose="referent context only, never evidence"> {{previousHuman}} </previous-user-message> {{/if}} {{#if assistantContext}} <assistant-context purpose="referent context only, never evidence"> {{assistantContext}} </assistant-context> {{/if}} <user-prompt> {{prompt}} </user-prompt>模板刻意将输入拆成三个区块,每个区块的用途各不相同:
| 区块 | 变量 | purpose 标注 | 在提取中的作用 |
|---|---|---|---|
<user-prompt> | prompt | (无标注,即唯一证据来源) | 触发本次提取的当前用户消息原文,是唯一可被引用为evidence的文本 |
<previous-user-message> | previousHuman | referent context only, never evidence | 上一条用户消息,仅用于理解"短回复"类决策的语义 |
<assistant-context> | assistantContext | referent context only, never evidence | 紧邻的助手回复(如选项列表),仅用于解析"opt 2""go for it"选择了什么 |
这种"证据与参照物严格分离"的设计,是整条证据链防伪的第一道防线:参照上下文只辅助理解,绝不允许充当证据。对应地,提取系统提示词 sharpshooter-extract-system.md 明确要求"previous user message and assistant context are referents for interpretation ONLY; they are never evidence, and nothing stated only by the assistant may become a delta"。
信封在源码中如何构建
输入信封由 buildSharpshooterEnvelope 从会话转录快照构建,其关键行为包括:
- 定位触发消息:以当前用户消息为起点,向前扫描;若消息尚未追加进转录(
message_start事件先于转录落盘),则退化为取转录中最后一条用户消息。 - 反向查找参照物:从触发消息向前,取最近一条用户消息作为
previousHuman、最近一条助手消息作为assistantContext,两者都找到即停止。 - 上下文清洗与截断:
cleanEnvelopeContext会将代码块(```或~~~)整体替换为[code omitted],压缩空白后截断——previousHuman上限 400 字符、assistantContext上限 800 字符。这既防止大段代码撑爆上下文,也从源头杜绝"从粘贴的日志/代码中提取决策"。 - 空消息不触发:当前消息为空或不存在时直接返回
undefined,不启动提取。
系统提示词:什么才算一个 Delta
提取阶段的系统提示词 sharpshooter-extract-system.md 定义了 Delta 的六种类型(与 types.ts 的SharpshooterDeltaKind完全一致):
architecture_decision—— 运行时/组件边界、选定的抽象、协议或存储方向、显式的"X 优于 Y";product_decision—— 行为、UX、默认值、命名/术语、产品范围(含什么、不含什么);style_decision—— 视觉/审美语言、呈现约定、面向项目文本的措辞/语气规则;constraint—— 用户声明的不容协商项(隐私、性能包络、兼容性、部署);rejected_approach—— 被用户拒绝的尝试过或提议过的方案,以及拒绝理由(若给出);correction—— 用户纠正先前已定论的行为("这不是我们约定的""X 是有意为之,Y 是 bug")。
同时规定了两类来源(SharpshooterDeltaSource):用户在消息中直接陈述的用explicit_user;短回复("opt 2""go for it""split + airgap""no X plz")需要借助助手上下文解析出选择了什么时,用contextual_resolution。
证据规则(硬性要求)
提示词规定:evidence必须是当前用户提示词的精确连续子串,逐字节复制。这条规则不是模型自律,而是有宿主端强制校验兜底的——见下文"准入门控"。
摩擦标签(Friction tags)
每个 Delta 必须诚实打上三个布尔摩擦标签,因为合并阶段"按摩擦准入,而非按存在准入":
corrective: true—— 用户在重申或纠正先前已定论的内容;regression: true—— 用户报告原本正常的行为破坏、漂移或"回退"了;subtle: true—— 仅凭代码难以一眼看出的非显式不变量(跨组件预期、"A 与 B 有别"的区分、代码无法自描述的意图)。
三个全为false也是合法结果,代表一次干净的首决决策。
陈述规则
- 永恒规范式:"Status bar uses powerline-style segments",绝不用"用户想要""目前""我们刚修了";
- 不含文件路径、行号、函数/类型名、提交 ID 或 issue 号;产品组件词汇(composer、status bar、daemon 名)允许;
- 排除当前任务态:正在修的 bug 不是决策;但关于先前已定论行为的 bug 报告是
correction且regression: true; - 不提取与项目决策无关的个人品味(偏好语言、通用编码哲学、提交风格);
- 不从不带引号的粘贴材料(日志、diff、文档)提取,只从人类话语中提取;
rejectedAlternative/rationale仅在用户确实给出时才填,严禁臆造。
record_deltas 工具与准入门控
提取模型被要求恰好调用一次record_deltas工具(toolChoice: "required"),空数组是合法且常见的结果——大多数消息不包含持久决策。工具的参数 schema 在 extract.ts 中由 omptype 定义:
const deltaSchema = type({ kind: "'architecture_decision' | 'product_decision' | 'style_decision' | 'constraint' | 'rejected_approach' | 'correction'", statement: "string", "rejectedAlternative?": "string", "rationale?": "string", source: "'explicit_user' | 'contextual_resolution'", evidence: "string", friction: { corrective: "boolean", regression: "boolean", subtle: "boolean", }, });模型吐出的每个候选 Delta 都要经过 admitDelta 的宿主端强制校验(而非仅依赖模型自觉),任一条件不满足即被丢弃:
statement必须是非空字符串;evidence必须是非空字符串,且必须是当前 prompt 的包含子串(currentPrompt.includes(raw.evidence))——直接实现"证据硬规则",防止模型用参照上下文或自编内容充当证据;kind必须在六种类别白名单内(SHARPSHOOTER_DELTA_KINDS);source必须在两类来源白名单内(SHARPSHOOTER_DELTA_SOURCES);friction三个字段必须均为布尔值(parseFriction)。
通过校验后,Delta 被落盘为结构化对象(v: 1版本号 +sessionId+ts时间戳),进入队列。另外,提取模型解析、推理开销被刻意压低(maxTokens: 2048、Effort.Low思维级别),并默认回退到smol角色模型,且整个流程不阻塞主会话(fire-and-forget,maybeStartSharpshooterExtraction只记录 in-flight Promise)。
队列:无锁、幂等、可重投递
队列实现见 queue.ts,核心设计是每个 Delta 一个 JSON 文件,路径为queue/<sessionId>/<ts36>-<nonce>.json:
- 文件名即排序键:时间戳以 36 进制、零填充 10 位,保证字典序等于时间序;4 位随机 nonce 化解同一毫秒的并发碰撞;
- 生产者无锁:没有共享的追加 fd 与消费者的重命名竞争,崩溃安全;
- 幂等消费:合并是先全量重写记忆文件再删除队列文件,若"应用后、删除前"崩溃,只会导致同批 Delta 被重投递一次,而合并本身"最新者胜"(newest-wins),重复应用无副作用;
- 脏数据免疫:读取时校验
v === 1且statement为字符串,无法解析的撕裂文件跳过不消费(listSharpshooterDeltas),不会卡死队列; - 会话 ID 净化:
sanitizeSessionId把路径不友好字符替换为-,作为目录名防线; - 消费清理:
consumeSharpshooterDeltas删除已消费文件并递归清理空会话目录。
队列深度的聚合由sharpshooterQueueDepth提供,供状态展示与调度器判断是否到期。
合并:摩擦准入法与三文件记忆库
提取只是上游,真正决定"什么值得记住"的是合并阶段,其系统提示词 sharpshooter-consolidate-system.md 定义了三条核心法则:
- 准入法(Admission law)——记忆靠摩擦挣得,而非靠"决策存在":只有血统中至少出现以下之一才准入:
- 回归(Regression):已定论的行为在任何时点被破坏或漂移(一次即可);
- 微妙性(Subtlety):仅凭代码看不出的规则——隐形不变量、跨组件预期、"A 与 B 有别"、代码无法自描述的观感意图;
- 重复(Repetition):跨会话两次以上纠正,或一次明确反转且被拒方案仍是"活诱惑"。
- 反例:"一次性决策、实现后再没做错过"不是记忆——代码已反映它,单次交流存储细节正是错误记忆的温床。拿不准就略过。
- 具体性测试(Concreteness test):每条要点必须能改变新 Agent 的行为。"提供可配置、兼容引用的交互"是删除级;"状态栏:powerline 分段、实心右缘、内收左三角、不加粗、耗时 ≤3 字符"是保留级。
- 单归属规则(Single-home rule):每条决策只住一个文件——
architecture.md(结构/运行时)、product.md(行为/UX/范围/命名)、style.md(视觉/排版/语气)。
此外还有硬排除项(与项目自身文档重复的内容、全局个人品味、进行中的任务态、路径/行号/符号/提交号)、更新语义(最新者胜、跨会话同主题合并为一条、保留仍有效的旧条目、##分节、每条要点一行、每文件硬性 ≤120 行预算(SHARPSHOOTER_MAX_FILE_LINES,见 types.ts)、无状态陈述)。
合并实现(consolidate.ts)还包含多项工程化保障:
- 跨进程单写者锁:
withFileLock("consolidate.lock"),重试 1 次,拿不到锁即返回locked; - 完整重写而非补丁:模型必须恰好一次调用
replace_memory_files并给出三个文件的完整新内容(toolChoice: "required"、maxTokens: 8192、Effort.Medium); - 防清库护栏:若现有记忆文件非空而模型返回全空内容,直接拒绝("refusing to wipe memory files");
- 密钥脱敏:
redactSecrets对 sk/pk/token/password 类长串、JWT 形串、AWS/GitHub/npm/Slack/Google API 密钥模式统一替换为[REDACTED]; - 临时文件原子替换:先写
.name.pid.uuid.tmp,再rename覆盖,失败时清理残留; - 项目文档去重:读取
cwd下AGENTS.md/CLAUDE.md(截断至 6000 token)作为"已文档化内容",禁止记忆重复; - 调度器(scheduler.ts):按
bankDir共享定时器、引用计数释放,每分钟 tick,队列非空或距上次合并超过intervalMinutes才执行。
配置启用与存储布局
要在 oh-my-pi 编码 Agent 中启用 Sharpshooter,配置项位于 settings-schema.ts:
| 配置键 | 类型/默认值 | 说明 |
|---|---|---|
memory.backend | enum,默认off | 设为sharpshooter即启用该决策记忆后端 |
sharpshooter.model | string,默认空 | 提取/合并使用的模型选择器,留空回退smol角色 |
sharpshooter.intervalMinutes | number,默认5 | 合并轮询间隔(分钟),与调度器DEFAULT_INTERVAL_MINUTES一致 |
sharpshooter.injectionTokenLimit | number,默认15000 | 记忆文件注入会话上下文的 token 上限 |
存储全部位于主目录作用域下的<agentDir>/memories/sharpshooter/<bank>/(见 paths.ts),不写入项目工作树;<bank>由cwd通过projectBankSegment稳定推导,同一项目跨会话共享同一记忆库。布局为:
architecture.md/product.md/style.md—— 三个决策记忆文件(按序注入上下文);queue/<sessionId>/<ts36>-<nonce>.json—— 每个排队 Delta 一个文件;state.json—— 合并记账(lastConsolidatedAt、lastResult、lastError,供/memory stats与/memory diagnose使用);consolidate.lock—— 跨进程单写者锁。
小结
从 sharpshooter-extract-input.md 这个短小的输入模板出发,可以看到 oh-my-pi Sharpshooter 记忆后端的一整套严谨设计:三段式输入信封严格隔离"证据"与"参照物",宿主端 evidence 子串校验与摩擦标签共同杜绝错误记忆,无锁文件队列 + 幂等消费保证崩溃安全,5 分钟合并 + 准入法 + 120 行预算把记忆收敛为精炼、可改变新 Agent 行为的项目决策文件。对于希望为编码 Agent 构建"会记住项目约定"的记忆系统的开发者,这套"提取 → 排队 → 摩擦门控合并 → 注入上下文"的流水线,是一个可复用的完整参考实现。
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考