/** * L1 项目标签(`metadata.project`)+ 适用范围标签(`metadata.scope`)的**契约模块**。 * * 职责:定义两个标签的取值空间与归一化规则,并回答「一条记忆最终该挂到哪个项目路径」。 * 计划出处:`plan/memory-system/completed/L1-项目与范围标签-计划.md` 任务 0(先立契约,后写回填器)。 * * 边界(刻意收紧,调用方不得越界): * - **纯函数 + 纯常量,零 import**:不读文件、不写文件、不发网络请求、不调模型。 * 任何路径判定的重活都交给调用方,本模块只管**取值空间与形态归一**。 * - **不 import 任何需要 pi 上下文的模块**(无 pi 运行时依赖,可被脚本/子进程/测试直接 import)。 * - **不依赖 `node:path`**:`path.normalize` 在这里反而碍事(它按宿主平台把 `/` 翻回 `\`、 * 并可能吞掉盘符尾斜杠),而标签必须在 Windows 盘符 + 正斜杠之间**稳定**表示, * 所以归一化全部自己用字符串做,保证在任何平台上跑出同一个结果。 * * 两条标签的正交关系: * `scope` 决定「这条记忆适用的范围有多宽」,`project` 决定「它挂在哪个项目路径下」。 * 两者由 {@link projectForScope} 合成 —— 调用方不需要自己写分支,也就不会写出 * 「scope 是 global 却带着 project」这种自相矛盾的组合。 * * ## 取值空间:三档(2026-09-30,**用户最终裁定**) * * | 值 | 含义 | `project` 填什么 | * |---|---|---| * | `self` | 属于某个项目(出了那个目录就不成立) | 判定出的项目路径 | * | `global` | **没有项目**(换个项目依然成立) | 空串(强制) | * | `unknown` | **判不了** | 空串(不作过滤依据) | * * `global` **不是第三种项目,而是"没有项目"**(见 `project-attribution-model` 的定义)。 * 那些硬塞进某个仓库就一定是错的记忆——如横跨 4 个目录的「协作规范」类——必须能表达成 `global`。 * * ⚠ **`subtree` 档已作废,不要再加回来。** 它的本意是「挂到上层路径,让子项目之间互相可见」, * 但这与"召回判定 = 路径包含"那套设计冲突,且实测难判(30 条尺子里 12 条 = 40% 拿不准)。 * 需要更宽的可见性时,走 `project` 本身指向更上层路径,不要新造 scope 档位。 * * ## 「项目」的定义不在本文件 * * **权威定义见仓库内 `agent/extensions/memory-system/项目归属定义.md`**(2026-10-01 落盘): * 项目 = 一个有专有身份、能独立交付的**最小工作单元**;`C:\dev\code_java\wangxin` 是仓库/工作区、 * **不是**项目。要改判定口径就改那份文档,并同步重跑评测尺子。 * ⚠ **别拿"这是不是项目"来质疑 `project` 字段里的某个路径值** —— 该字段是判定结果, * 且容器目录(仓库)是合法的兜底取值;用那三条机械规则去反推已判好的字段会得出错误结论。 * * `unknown` 是**显式的弃权档**,不是占位符:原先「判不了就不写字段」的 fail-open 行为会让 * 「判不了」在数据里彻底不可见——将来召回过滤遇到没有 `scope` 的记录,代码只能自己猜怎么处理。 * 显式取值可计数、可被消费者识别、可与「确定没有项目」区分: * `global` = 确定没有项目(无条件注入)|`unknown` = 不知道(不过滤,但可标出来)。 * 这两者召回行为不同,混成一个就丢了信息。 */ // ══════════════════════════════════════════════════════════════ // 取值空间 // ══════════════════════════════════════════════════════════════ /** * `metadata.scope` 的全部合法取值(闭集,三值,不多不少)。 * * 用 `as const` 保住字面量类型,`ScopeValue` 由它推导,杜绝「类型里手抄一份、值里又写一份」 * 这种两处不同步的经典错误。`self` / `global` 沿用 `project-attribution-model` 的定义, * `unknown` 是「判不了」的显式弃权档。 */ export const SCOPE_VALUES = ["self", "global", "unknown"] as const; /** `scope` 的合法取值类型。 */ export type ScopeValue = (typeof SCOPE_VALUES)[number]; /** * `v` 是否**严格**等于某个合法 scope 值(区分大小写,不做任何宽松处理)。 * * 参数:任意值(`unknown`)。 * 返回:true 表示 `v` 可直接当 `ScopeValue` 用。 * 设计意图:这是「零容错」的那道闸门,供**类型收窄**(`v is ScopeValue`)与 * 已归一数据的快路径使用。凡是来路不明的数据(模型输出、存量 JSON)都应先过 * {@link normalizeScope},不要直接用它。 */ export function isScopeValue(v: unknown): v is ScopeValue { return typeof v === "string" && (SCOPE_VALUES as readonly string[]).includes(v); } /** * 把任意输入归一成一个合法 `ScopeValue`,**归不了一律返回 `null`**。 * * 接受的宽松写法只有两种:**首尾空白**、**大小写差异**(`" SELF "` / `"Global"`)。 * 这两类是传输与手抄造成的噪声,除掉它们不改变语义。 * * 刻意**不做**的:同义词映射(`subtree` → `self`、`project` → `self`、`global-only` → `global` …)。 * 理由见设计意图:存量 199 条 `metadata.scope` 是自由文本,取值里混着 * `subtree`(已作废的旧档) / `project`(41) / `memory-system`(36) / `workflow`(32) / `module`(19) —— * 一半填的是项目名或模块名。一旦开了同义词这张表,这些垃圾会被"洗"成看似合法的标签, * 从此再也无法与真值区分;而 `null` 是诚实的:它把"这里有脏数据"这件事显式交给上层处理 * (丢弃 / 标 unknown 重判 / 报警),代价只是丢一条标签,换来的是词表第一天就受控。 * * 参数:任意值(模型输出、JSON 字段、命令行参数…)。 * 返回:合法 scope 值,或 `null`。**绝不回退到 `"self"`** —— 静默兜底会让脏数据不可见, * 且把一条 `global` 记忆错挂到项目上(错误归属比没有归属更难查)。 */ export function normalizeScope(v: unknown): ScopeValue | null { if (typeof v !== "string") return null; const s = v.trim().toLowerCase(); return isScopeValue(s) ? s : null; } // ══════════════════════════════════════════════════════════════ // 项目路径标签 // ══════════════════════════════════════════════════════════════ /** 单趟归一化的最大迭代次数(只为吃掉嵌套引号,见 {@link normalizeProjectTag})。 */ const MAX_NORMALIZE_PASSES = 3; /** * 单趟归一化:去首尾空白 → 去**一对**成对引号(再 trim 一次)→ 反斜杠全转正斜杠并压缩重复斜杠 * → 去尾部多余 `/`(盘符根 `C:/` 除外)。**不做绝对路径校验**。 * * 私有函数,单独拆出来是为了让 {@link normalizeProjectTag} 能反复套用到不动点。 * 形状校验必须留到不动点之后:嵌套引号(`""C:/x""`)要剥两轮才露相, * 若每趟就判"非绝对路径 → 作废",第一趟就会把 `""C:/x""` 误杀成空串。 */ function normalizeOnce(raw: string): string { let s = raw.trim(); // 成对引号:模型经常把路径当字符串值再包一层引号("C:/dev/x"),引号不是路径的一部分。 // 必须成对且长度 ≥ 2 才剥,避免把 `C:/a/"b` 这类真带引号的合法目录名误伤。 if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) { s = s.slice(1, -1).trim(); } if (!s) return ""; s = s.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); // 反斜杠全转正斜杠;`//` 压成 `/`(UNC 前缀不适用于本机形态) // 盘符根 `C:/` 必须原样保留:削成 `C:` 语义完全不同("当前盘的当前目录"), // 且不再匹配 `^[A-Za-z]:/`,会被形状校验当成非绝对路径而作废。 if (!/^[A-Za-z]:\/$/.test(s)) s = s.replace(/\/+$/, ""); return s; } /** * 归一化项目路径标签:产出**唯一形态**的绝对路径字符串,或 `""`(表示"没有项目")。 * * 步骤:去首尾空白 → 反斜杠全转正斜杠 → 去尾部多余 `/`(盘符根 `C:/` 除外)→ 去首尾成对引号 * → 形状校验。非绝对路径一律作废(返回 `""`),**不做任何猜测式补全**。 * * 参数:任意值(可能是 `undefined` / 非字符串 / 相对路径 / 带引号的脏串)。 * 返回:`C:/dev/code/xianhua` 形态的路径,或 `""`。 * 设计意图与边界: * - **必须幂等**:`normalizeProjectTag(normalizeProjectTag(x)) === normalizeProjectTag(x)`。 * 标签会被反复读写(回填器重跑、写侧再归一一次、召回侧比较),不幂等就会每次跑出一批新字符串, * 同一项目被拆成两条记录。做法是:把单趟 {@link normalizeOnce} 套用到**不动点**为止 * (最多 3 趟,每趟只会让字符串变短,必然收敛),这样 `""C:/a""` 这种嵌套引号也会被吃干净; * 绝对路径校验放在不动点**之后**,否则第一趟就会把还带引号的串误杀成空串。 * - **相对路径返回 `""`**:`xianhua/src`、`./A104527` 这类既不是合法 `project` 标签, * 又会在不同会话 cwd 下指向不同东西 —— 存进标签等于存了一颗定时炸弹,宁可不写。 * - 保留原始大小写(不 lower),因为标签要原样回显给用户看;大小写不敏感的比较 * 由既有的 `projectKey` / `isUnder` 负责,不在这里做。 */ export function normalizeProjectTag(v: unknown): string { if (typeof v !== "string") return ""; let s = v.trim(); if (!s) return ""; // 套用到不动点 ⇒ 幂等(单趟已稳定时只跑一趟,代价可忽略) for (let i = 0; i < MAX_NORMALIZE_PASSES; i++) { const next = normalizeOnce(s); if (next === s) break; s = next; if (!s) return ""; } // 形状校验:相对路径不是项目 if (!/^[A-Za-z]:\//.test(s)) return ""; return s; } // ══════════════════════════════════════════════════════════════ // 归属决策 // ══════════════════════════════════════════════════════════════ /** * 决定一条记忆最终该挂到哪个项目路径(`scope` + `project` ⇒ 单一、自洽的取值)。 * * 参数: * - `scope` 已归一的适用范围标签。 * - `project` 调用方选定的项目路径(来源为核验后的逐条 L0 证据或可用会话判定),可能为空或脏。 * 返回:归一化后的项目路径,或 `""`(= 没有项目 / 判不了)。 * 三个分支: * - `self` → 归一化后的 project(归一后为空就是空,不猜) * - `global` → `""`(**确定没有项目**;即使调用方传了路径也照样抹掉) * - `unknown` → `""`(**判不了**;同样抹掉,避免下游把一个不可信的路径当过滤依据) * * 设计意图 —— 为什么把这条不变量收进一个函数,而不是让每个调用方自己判: * `scope` 与 `project` 两个字段必须始终自洽,否则下游必然出错。集中在这里, * `global`/`unknown` 不会带着 project;self 路径为空时由提交方降为 unknown。 * 而且这两种写反**不是小错**:召回时按 project 过滤,一写反就把一条该通用的记忆 * 藏进某个仓库、或把一条项目专属记忆当通用件到处注入。 */ export function projectForScope(scope: ScopeValue, project: string): string { return scope === "self" ? normalizeProjectTag(project) : ""; } // ══════════════════════════════════════════════════════════════ // 提示词片段 // ══════════════════════════════════════════════════════════════ /** * 给模型看的 scope 三值词表(拼进 `l1-extractor` 提示词用)。 * * 每个值都写成「问自己一句什么 → 答是则选它」,因为判据比定义好用; * 并显式交代三个字段的联动(`global` / `unknown` ⇒ project 必须留空)。 * **刻意不写「拿不准就选 self」这类兜底话术**:那会把不确定性变成系统性偏移, * 全部落进某一个项目;要表达"定不了"就用 `unknown`。 * 多行模板字符串:便于在提示词里原样缩进排版。 */ export const SCOPE_HELP = `scope 三选一(配合 project 字段一起填): - self:这条记忆属于某一个具体项目。问自己「换个项目还成立吗?」——不成立,就选 self。project 是该条记忆的项目候选目录;显式填写时必须提供 projectEvidence,由服务端核验后写入。 - global:这条记忆没有项目(协作纪律、工具用法、通用方法这类换个项目依然成立的)。问自己「这是通用做法 / 个人习惯 / 通用约定?」——是,就选它。project 必须留空字符串,不要填会话目录。 - unknown:判不了。问自己「正文里有没有出现能定位项目的路径或文件?」——没有,或有多个候选分不清,就选它。project 必须留空字符串。宁可标 unknown 也不要猜一个项目。`;