import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { MEMORY_DB_DIR } from "../paths.ts";
// ══════════════════════════════════════════════════════════════
// L0 降噪规则(SanitizeRule 可扩展机制)
//
// 设计(对照 TencentDB-Agent-Memory sanitize.ts 裁剪 + pi 特有规则):
// - generic :跨框架通用(行首时间戳 / NUL / 换行压缩)
// - memory :记忆注入标签(防反馈循环)
// - framework :pi 宿主特有(/skill: 展开块剥离、粘贴日志压缩)
// - custom :外部 l0-sanitize-rules.json 追加的规则(后续遇新噪音改 JSON 不动代码)
// 有意不抄腾讯的:代码块剥离(编程场景代码块价值最高,用户拍板保留)、
// OpenClaw 特有噪音(inbound 元数据块 / [[reply_to]] / ¥¥[]¥¥ / bootstrap / NO_REPLY 等,
// 勘察确认 pi 不注入,设了反而误伤——真实 session 验证 7 处 SYSTEM 命中全是对话内容)。
// ══════════════════════════════════════════════════════════════
/** 规则作用对象:user / assistant / both。 */
export type RuleApplyTo = "user" | "assistant" | "both";
/** 规则来源分组。 */
export type RuleGroup = "generic" | "memory" | "framework" | "custom";
/**
* 单条降噪规则。
*
* 两种执行形态:
* - pattern + replace:普通正则替换(replace 可为字符串或函数)
* - handler:需要上下文的复杂规则(如跨行日志聚合),提供 text → text 转换
*/
export interface SanitizeRule {
/** 规则唯一标识(外部规则重名时内置表让位给外部)。 */
id: string;
/** 匹配正则(与 replace 配合使用)。 */
pattern?: RegExp;
/** 替换目标:字符串直接替换;函数接收 (全文, ...捕获组) 返回替换结果。 */
replace?: string | ((sub: string, ...groups: string[]) => string);
/** 复杂规则处理器(优先级高于 pattern+replace)。 */
handler?: (text: string) => string;
/** 作用消息角色。 */
applyTo: RuleApplyTo;
/** 来源分组。 */
group: RuleGroup;
/** 是否启用(默认 true)。 */
enabled?: boolean;
}
/** 外部规则文件(l0-sanitize-rules.json)的单项格式——JSON 无法表达函数,replace 仅字符串。 */
export interface ExternalSanitizeRule {
id: string;
pattern: string;
flags?: string;
replace?: string;
applyTo: RuleApplyTo;
enabled?: boolean;
}
/** 外部规则文件整体格式。 */
export interface ExternalRulesFile {
rules: ExternalSanitizeRule[];
}
// ── 日志块压缩配置(编程场景特有:用户常整段粘贴后端日志) ──
/** 连续日志行达到此数量才视为日志块(防止误伤含时间戳的普通文本)。 */
export const MIN_LOG_BLOCK_LINES = 5;
/** 日志块压缩后保留的头部行数(保住报错上下文)。 */
export const KEEP_LOG_LINES = 3;
/** 单行日志识别:`15:39:33 [http-nio-19091-exec-6] INFO c.i.f...`(允许行前有少量前缀文本)。 */
const LOG_LINE_RE = /(?:\d{1,2}:\d{2}:\d{2}(?:\.\d+)?)\s*\[[^\]]*\]\s+(?:INFO|WARN|ERROR|DEBUG|TRACE)\b/i;
/**
* 日志块压缩处理器:识别连续日志行,聚合为「保留头部 N 行 + 块标记」。
*
* 背景(真实验证 2026-08-17):A104527 会话中 user 消息含 46KB 整段后端日志,
* 单条注入会撑爆 L0 分片、污染后续 L1 提取。压缩后既防膨胀又保住报错关键行。
*/
function compressLogBlock(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim() && LOG_LINE_RE.test(line)) {
// 收集连续日志行
let j = i;
let totalChars = 0;
while (j < lines.length && lines[j].trim() && LOG_LINE_RE.test(lines[j])) {
totalChars += lines[j].length;
j++;
}
const blockCount = j - i;
if (blockCount >= MIN_LOG_BLOCK_LINES) {
out.push(...lines.slice(i, i + KEEP_LOG_LINES));
out.push(`[log-block: ${blockCount} lines, ${totalChars} chars]`);
} else {
out.push(...lines.slice(i, j));
}
i = j;
} else {
out.push(line);
i++;
}
}
return out.join("\n");
}
// ── 框架噪音剥离(pi 特有,2026-08-19) ──────────────────────────
/**
* pi TUI 终端状态回显噪音剥离。
*
* 背景(真实样本 2026-08-17 分片,12260 字 user 消息):pi 的终端 UI 会把
* 自身状态渲染(进度条、扩展 UI 请求、agent 生命周期事件)混入会话内容,
* 形成 `{"type":"extension_ui_request",...}` / `{"type":"agent_start"}` /
* `completed:──────` / `1.043 18.5%/1.0M (auto)` 这类框架噪音。对记忆/技能
* 提取均无价值,整行剥离。
*
* 实现:逐行判断(比单条正则稳),命中以下任一形态整行删除:
* ① 框架事件 JSON 行(行首 `{"type":"<框架类型>"`,白名单防误伤对话中的
* 普通 JSON 字面量,如 {"type":"text"})
* ② 进度条行(completed:──)
* ③ 终端状态栏行(`1.043 18.5%/1.0M (auto)` 形态)
* 注意:JSON 后跟状态栏杂讯(同一行)也会被整行删除——这类行本质就是 UI 回显。
*/
const FRAMEWORK_NOISE_TYPES = [
"extension_ui_request",
"agent_start",
"agent_end",
"turn_start",
"turn_end",
"message_start",
"message_delta",
"message_stop",
"content_block_start",
"content_block_delta",
"content_block_stop",
"response",
"request_start",
"request_stop",
"ping",
"stream_event",
"connection_init",
];
const FRAMEWORK_NOISE_JSON_RE = new RegExp(
`^\\{\\s*"type"\\s*:\\s*"(?:${FRAMEWORK_NOISE_TYPES.join("|")})"`,
);
const FRAMEWORK_PROGRESS_BAR_RE = /^completed:[─\-—]+/;
// 终端状态栏行:`1.043 18.5%/1.0M (auto)` / `USER.md: 3 entries (unchanged)19.8%/1.0M (auto)`
// 特征:`%/<容量> (auto)` 之后是行尾或 ≥3 个空白再跟模型名(如 deepseek-v4-flash • high)。
// 行中文字(如"占比 15%/1.0M (auto) 这个先不管")不含大段空白,不误伤。
const FRAMEWORK_STATUS_LINE_RE =
/^.*?\d+(?:\.\d+)?%\/[\d.]+[KMGT]?\s+\(auto\)(?:[ \t]{3,}.*)?$/;
function stripFrameworkNoise(text: string): string {
return text
.split("\n")
.map((line) => {
const t = line.trim();
if (!t) return line;
if (FRAMEWORK_NOISE_JSON_RE.test(t)) return "";
if (FRAMEWORK_PROGRESS_BAR_RE.test(t)) return "";
if (FRAMEWORK_STATUS_LINE_RE.test(t)) return "";
return line;
})
.join("\n");
}
// ── 内置规则表 ──
/** skill 块剥离:`...`,保留块后 args(真实用户输入)。 */
function stripSkillBlock(sub: string, args?: string): string {
return args ? args.trim() : "";
}
/** 内置默认规则(勘察收敛,2026-08-16/17)。 */
export const BUILTIN_RULES: SanitizeRule[] = [
// ── framework:pi 特有 ──
{
id: "pi-skill-block",
pattern: /[\s\S]*?<\/skill>(?:\n\n([\s\S]+))?/,
replace: stripSkillBlock,
applyTo: "user",
group: "framework",
},
{
id: "log-block-compress",
handler: compressLogBlock,
applyTo: "user",
group: "framework",
},
{
id: "pi-framework-noise",
// 2026-08-19:pi TUI 终端状态回显被混入 session(真实样本 12260 字 user 消息)。
// 剥离框架事件 JSON(extension_ui_request / agent_start / turn_start / response 等)
// 与终端进度条行(completed:── / 1.043 18.5%/1.0M (auto))。
handler: stripFrameworkNoise,
applyTo: "both",
group: "framework",
},
// ── memory:记忆注入标签(防反馈循环,照抄腾讯) ──
{
id: "tag-relevant-memories",
pattern: /]*)?>[\s\S]*?<\/relevant-memories>/g,
replace: "",
applyTo: "both",
group: "memory",
},
{
id: "tag-user-persona",
pattern: /]*)?>[\s\S]*?<\/user-persona>/g,
replace: "",
applyTo: "both",
group: "memory",
},
{
id: "tag-relevant-scenes",
pattern: /]*)?>[\s\S]*?<\/relevant-scenes>/g,
replace: "",
applyTo: "both",
group: "memory",
},
{
id: "tag-scene-navigation",
pattern: /]*)?>[\s\S]*?<\/scene-navigation>/g,
replace: "",
applyTo: "both",
group: "memory",
},
// ── generic:跨框架通用 ──
{
id: "line-leading-timestamp",
pattern: /^\[[\w\d\-:+ ]+\]\s*/gm,
replace: "",
applyTo: "both",
group: "generic",
},
{
id: "nul-chars",
pattern: /\0/g,
replace: "",
applyTo: "both",
group: "generic",
},
{
id: "collapse-newlines",
pattern: /\n{3,}/g,
replace: "\n\n",
applyTo: "both",
group: "generic",
},
];
// ── base64 图片处理 ──
/** base64 图片数据 → [image] 占位符(保留"该消息含图"的信号,防污染后续索引)。 */
export function replaceBase64WithImage(text: string): string {
return text.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]");
}
// ── 外部规则加载 ──
/**
* 从 {memoryDB}/l0-sanitize-rules.json 加载外部追加规则。
* 合并到内置表之后执行;重名 id 覆盖内置(外部优先)。
* 文件不存在或非法时返回空数组(静默)。
*/
export function loadExternalRules(profilePath: string): SanitizeRule[] {
const filePath = join(profilePath, MEMORY_DB_DIR, "l0-sanitize-rules.json");
if (!existsSync(filePath)) return [];
try {
const raw = readFileSync(filePath, "utf-8");
const data = JSON.parse(raw) as ExternalRulesFile;
if (!Array.isArray(data.rules)) return [];
return data.rules
.filter((r) => r && typeof r.id === "string" && typeof r.pattern === "string")
.map((r) => ({
id: r.id,
pattern: new RegExp(r.pattern, r.flags || ""),
replace: r.replace ?? "",
applyTo: r.applyTo === "assistant" ? "assistant" : r.applyTo === "both" ? "both" : "user",
group: "custom" as const,
enabled: r.enabled !== false,
}));
} catch {
return [];
}
}
// ── 主流程 ──
/**
* 组合规则集:内置表 + 外部表(外部重名覆盖内置)。
*/
export function buildRuleSet(profilePath?: string): SanitizeRule[] {
const merged = new Map();
for (const r of BUILTIN_RULES) merged.set(r.id, r);
if (profilePath) {
for (const r of loadExternalRules(profilePath)) merged.set(r.id, r);
}
return [...merged.values()];
}
/**
* 对单条消息应用降噪规则。
*
* @param text 原始文本(已做 base64 → [image])
* @param role 消息角色(决定 applyTo 过滤)
* @param rules 规则集(buildRuleSet 产物,可缓存复用)
* @returns 降噪后的文本(不做最终 trim——由调用方过滤层处理)
*/
export function sanitizeText(text: string, role: "user" | "assistant", rules: SanitizeRule[]): string {
let result = text;
for (const rule of rules) {
if (rule.enabled === false) continue;
if (rule.applyTo !== "both" && rule.applyTo !== role) continue;
if (rule.handler) {
result = rule.handler(result);
} else if (rule.pattern && rule.replace !== undefined) {
result = result.replace(rule.pattern, rule.replace as any);
}
}
return result;
}
// ── L0 过滤层(宽松,对齐腾讯 shouldCaptureL0) ──
/**
* L0 捕获过滤——刻意宽松:只丢"结构上无用"的。
* 内容级质量门(长度/符号/prompt 注入)留给 L1 的 shouldExtractL1。
*
* 规则:空消息、纯 [image] 消息;斜杠命令过滤仅对 user 生效
* (role 不传时按 user 处理,保持 L0 调用兼容;tool_call/tool_result
* 等工具输出以 `/` 开头很常见——如 /usr/bin/bash、/C:/dev/...——传
* 具体角色可避开误伤,2026-08-19 skill-feed 验证发现 17 条工具输出被误杀)。
*/
export function shouldCaptureL0(
content: string,
role?: "user" | "assistant" | "tool_call" | "tool_result",
): boolean {
if (!content || !content.trim()) return false;
const slashFilter = role === undefined || role === "user";
if (slashFilter && content.startsWith("/")) return false; // 斜杠命令是框架指令(仅 user)
if (/^\[image\]\s*$/.test(content)) return false; // 纯图片消息无文本价值
return true;
}