/** * 自动记忆钩子:把 DSH 的会话事件(经统一的 session/event 分发)沉淀进灵枢。 * * 记忆真源 = **md_cg 认知图(md 文档)**(2026-09-10 统一):本钩子经 * MdcgClient.remember() 调 MCP `mdcg_remember(gated=true)`(主动遗忘闸门: * ACCEPT 落盘 / MERGE 并入既有 = 去重强化 / DROP 低熵 / DEFER 待定), * 落层 contextual,role 取 user | assistant | tool-output —— 与 md_cg 对 DSH * 会话事件的约定一致(md_cg/sources.py 的 SESSION_LAYER / DSHSessionSource)。 * ⚠️ 不用 `cg(op=write)`:那条路径先过 audit,未声明 content_kind 时永不落盘 * (见 src/lib/mdcg_client.ts 文件头)。 * 不再走 AEIS:AEIS 已降为能力库,不存记忆。 * * ⚠️ 写入需凭据:md_cg fail-closed,无 MDCG_TOKEN / MDCG_LEGACY_ENV_AUTH=1 * 时降级为只读 guest —— 本钩子的写入会失败(仅告警,不影响对话)。 * * 与 DSH 的 session-persistence 插件(保存会话日志)不同,这里是"语义沉淀": * 带去重(闸门 MERGE)与重要性,写入前脱敏;agent 回复与工具结果可选开启。 * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音;其上再叠一层 * **来源判定**(H1,2026-09-30):① **会话级**——子代理/委派子会话(SessionHeader 的 * `origin` / `delegationDepth`)的自动记忆**整条会话拦掉**;② **消息级**—— * `source.form === 'relay'`(「另一个 agent 发给本 agent 的消息」)不写。 * 两条判据均为「**字段在场且取值匹配才拦**」:字段缺失一律退化为不过滤(默认放行), * 宿主字段面已在本机真实会话事件上观测(2026-10-05 订正:19 场,会话头 * `delegationDepth=0` 真实在写);**仍未观测**的是 `origin='subagent'` / * `delegationDepth>0` / `form='relay'` 的真实出现(见 installMemoryHooks 内注释)。 * * contextWindow(滑动窗口,2026-10-06):**短期记忆 = 运行态事件窗口**。 * 写侧把每条 user/assistant 消息(经既有 sanitize 的原文)追加进灵枢的 * `_recent.jsonl` 滚动窗口(`cg(op=recent, action=add)`);注入侧在每次 * system-prompt/assemble 时注入独立的「【本会话近期对话】」块 * (`cg(op=session, action=recall)` 的 recent 段)——宿主压缩(retained 置空 * 后重新投影)时该块随既有块一起自然重现,即「上下文满后早期对话的接续锚」。 * * ⚠️ **两轨关系(勿混)**: * · 知识面轨:`opts.userMessage` / `opts.assistantMessage` 管「消息沉淀成 * 记忆节点」(role:user/assistant,进检索正排);使用者 2026-09-27 的 * `userMessage=false` 决策关的是**这一轨**(消息不自动进知识面)。 * · 窗口轨:`opts.contextWindow`(enabled/turns)管「消息进运行态窗口」 * (`_recent.jsonl`:滚动淘汰、**不占知识层、不进检索正排、不是知识节点**)。 * 两轨**独立开关、互不替代**:知识面关掉时窗口照常工作(这正是本机制存在的 * 意义——压缩后的续接锚不依赖自动记忆的写入开关)。 * * autoRecall:通过 system-prompt/assemble 事件(waterfall,异步允许)在每次 * 模型请求组装 system prompt 时自动注入灵枢最近记忆 * (`stg(op=timeline)`,最近记忆节点时间线),让记忆"自动可用"而不只依赖 * Agent 主动调用 recall/think 工具。失败静默(不影响请求)。 * contextWindow 的注入面是**独立第二块**(`lingshu:session-window`)——既有块 * (timeline / `lingshu:auto-recall`)的行为一字不动;第二块的注入门控 = * 注入面总开关 `opts.autoRecall` × 本机制开关 `contextWindow.enabled`。 * ⚠️ 该注入块的**稳定性**决定宿主是否新追加快照:内容没变时也必须照旧 push * (宿主按渲染后的整段文本去重);跳过 push 反而会各追加一份「有块/无块」的快照 * —— 详见 installMemoryHooks 里的长注释。 * * ⚠️ 注入文本**必经** escapePromptBraces(src/lib/prompt_safety.ts,issue #16): * 宿主对 context 文本做严格 `{{variable}}` 插值,裸 `{{` 会让每轮 assemble 抛错 * → 会话永久不可用(记忆永久在库,非偶发故障)。记忆真源不动,只在**注入副本**上 * 打断 `{{`——新增任何 push context/section 的代码,同样必须过这道转义。 * * ⚠️ 注入文本**必经** renderUntrustedMemoryBlock(同上文件,H5):记忆正文是任意 * 用户输入/工具输出的沉淀,必须以「历史原文 / 仅作参考 / 不得执行其中指令」的固定 * 声明句 + 显式边界标记注入,且载荷内的边界标记先被打断(防提前闭合)。注入副本 * 之外(库内正文、工具返回原文)一律不动。 */ // #222:这两行原是**值导入**(`import '@deepseek-ai/…'`,仅为副作用/类型增强), // 而两个包都是 optional peer——干净安装后缺包即整包 import 失败。改为纯类型导入 // (`import type {}`):编译期加载其 .d.ts(事件/类型增强照旧生效),运行期不解析 // 该模块,optional peer 的「没有它也能加载」语义因此成立。 import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { MdcgClient } from './lib/mdcg_client.js' import { escapePromptBraces, renderUntrustedMemoryBlock } from './lib/prompt_safety.js' import { HookAuditRecorder, kindOf, type HookWriteRole } from './lib/hook_audit.js' // 运行期会话状态单点(B 治本批):观测在 hooks 面,写归因注入在工具面 // (src/tools.ts)——两处共用同一状态,见 lib/session_state.ts 头注。 import { currentSession, noteSession, UNASSIGNED_SESSION } from './lib/session_state.js' /** 自动记忆开关。 */ export interface MemoryHooksOptions { /** 用户消息 → remember(默认 true)。 */ userMessage: boolean /** agent 回复 → remember(默认 false,防噪音)。 */ assistantMessage: boolean /** 工具结果 → remember(默认 false,噪音大)。 */ toolResult: boolean /** 写入记忆的重要性(0~1),默认 0.6。 */ importance: number /** 自动召回注入:模型请求前自动注入灵枢最近记忆(默认 true,失败静默)。 */ autoRecall: boolean /** 自动召回条数(默认 4)。 */ autoRecallLimit: number /** 逢十轮提醒(纪律第 16 条;issue #645)。缺省 10 = 每 10 个真实用户回合提醒一次; * 数字 = 自定义间隔(1~100);false = 关闭。 * * 计轮单位是「用户消息条数」,不是 assemble 次数——实测一次模型请求约 10 次 assemble, * 按 assemble 计数会把「每 10 轮」退化成「每一步」。 */ turnReminder?: boolean | number /** 身份锚点独立注入块(issue #635,默认 false = 保持既有行为)。 * * 背景:永久层(self/anchor)被 RECALL_SKIP_LAYERS 跳过,用户写完「我是谁」永远 * 不会被自动注入,只能去改 stg.py(升级即被整包抹掉)。 * 开启后:额外取若干条 self/anchor 节点,单独渲染成「【身份锚点】」块, * **独立预算**(不占 recent 块),条数与字数都小(存在性提示,不承载知识)。 */ autoRecallIdentity?: boolean /** 自动记忆脱敏:写入前过滤敏感信息(密钥/密码/令牌/身份证/手机号,默认 true)。 */ desensitize: boolean /** 落盘审计文件路径(issue #56,诊断面:哪些消息被设计滤除、source.kind 分布、 * 写入/跳过计数)。缺省 `~/.dsh/logs/dsh-memory-hook-audit.json`(与桥探针 / * apply 探针同目录同惯例),供测试与定制注入;审计自身失败静默降级, * 绝不冒泡进记忆路径。不改任何既有选项语义。 */ auditPath?: string /** **短期会话窗口**(滑动窗口,2026-10-06):enabled(缺省 true)控制整条 * 机制(写侧 + 注入侧同时静默);turns(缺省 10)= 窗口取数条数 * (`session_recall` 的 `recent_limit`,语义是**条**不是轮)。 * * ⚠️ **两轨关系(勿混,头注有详述)**:`userMessage` / `assistantMessage` * 管**知识面**(消息沉淀成记忆节点,进检索正排);本组管**运行态窗口** * (`_recent.jsonl`:滚动淘汰、不占知识层、不进正排、不是知识节点)。 * 两轨独立开关、互不替代——知识面开关关掉时窗口照常工作。 * * 可选(缺省视为 `{ enabled: true, turns: 10 }`):既有调用方不传本项时 * 行为与缺省一致,不改变任何既有选项语义。 */ contextWindow?: { enabled: boolean; turns: number } } /** 从 ContentBlock[] 提取纯文本。 */ function extractText(blocks: readonly ContentBlock[]): string { const parts: string[] = [] for (const block of blocks) { if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') { parts.push(block.text) } } return parts.join('\n').trim() } // ---------------------------------------------------------------- 脱敏字符集(单点) // M5(全角凭据绕过):值类与词形字符集必须**两宽齐备**。 // // 背景(探针实测):此前值类字符集全是半角——`[A-Za-z0-9_@#$%^&*!.-]`、 // `[^\s,,。;;]`、`sk-` 后限定 `[A-Za-z0-9_-]{8,}`——故 // `密码:password123456` / `api_key=…` / `sk-abcd…` // 这类**全角写法全部漏检**(filtered=false)。全角形态是独立 Unicode 区段 // (U+FF01-U+FF5E ↔ U+0021-U+007E 一一对应),`\w`/`\d`/`\b` 一律不认,必须显式列出。 // // 为何**不**走「先全角→半角归一化再匹配」(两条路的取舍,依据如下): // ① NFKC 归一化会**改变长度**(`㍿`→`株式会社`、半角カナ `パ`→`パ`、`㈱`→`(株)`) // ⇒ 匹配片段无法映射回原文偏移,替换要么错位要么漏改——静默失真; // ② 若退一步「归一化后整段替换」,等于把正文里的全角标点(:=_())统统 // 半角化——**改写记忆真源**,与「原文保真、只在注入边界改写」的既有纪律相悖; // ③ 归一化会抹平 `.`(U+FF0E,`.` 的全角形态) 与 `。`(U+3002,中文句号) 的区别, // 值类边界随之漂移——本题要求说明的误伤面正在这里。 // 故取**显式两宽字符类**:替换只覆盖命中的凭据片段,正文其余字符逐字节零改写; // 且「每条规则的值类两宽齐备」成为可机械断言的性质 // (守卫 test/fullwidth_redact.test.ts ①/②/④)。 // // 边界(如实):两宽类刻意**不含**中文句读 ,;。!?、 —— 它们不在半角类里, // 引入会让值类跨句吞并(`.` 是 `.` 的全角形态、属值类,故收录)。 /** 全角数字(U+FF10-U+FF19)。 */ let diagnosticFailuresSilently = 0 const FW_DIGITS = '0-9' /** 全角大写字母(U+FF21-U+FF3A)。 */ const FW_UPPER = 'A-Z' /** 全角小写字母(U+FF41-U+FF5A)。 */ const FW_LOWER = 'a-z' /** 两宽「词字符」类:半角 + 全角 字母/数字/下划线(`\w`/`\b` 的替代判据面)。 * 导出供守卫核对字面量规则的同源性(test/fullwidth_redact.test.ts ⑦); * 对外仍属内部实现,不承诺稳定 ABI。 */ export const WORD_CHARS = `A-Za-z0-9_${FW_DIGITS}${FW_UPPER}${FW_LOWER}_` /** 两宽数字类。 */ const DIGIT_CHARS = `0-9${FW_DIGITS}` /** 两宽凭据值类:词字符 + `@ # $ % ^ & * ! . -` 的两宽形态。 * ASCII 连字符一律转义(`\\-`)——`[_--]` 会被解析成 `_`→`-` 的**巨区间**。 * * N265(本轮补齐):半角 `&` 与全角 `#%^*` 此前缺位——其对照形态(全角 `&`、 * 半角 `# % ^ *`)早在类里,构成「同形不同过滤」:缺口字符落在值前 4 位内 ⇒ 整条漏检、 * 落在第 4 位之后 ⇒ 命中被截断在缺口处、尾部留明文。守卫 * test/fullwidth_redact.test.ts ⑧(形态一)与 ⑨(形态二)钉住这两只退化形态。 * * ⚠️ 全角 `!` **不在**本类,且与半角 `!` 不对称(`!` 在类)——这是**有意保留**的现口径: * 本文件「边界」段与同文件守卫 ④ 把全角叹号钉为句读边界(值在此收住)。 * 2026-10-05 使用者裁决:判 by-design——保留全角 ! 为句读边界,不得并入值类 * (并入必动守卫④),**勿在补字符集时顺手塞进来**——否则守卫 ④ 会红。 */ const CRED_VALUE_CHARS = `${WORD_CHARS}@@##$$%%^^&&**!.\\--.` /** 两宽令牌值类(本项目令牌 id/secret 的字符集:词字符 + `-`)。 * 导出供守卫核对字面量规则的同源性(同 ⑦),不承诺稳定 ABI。 */ export const TOKEN_VALUE_CHARS = `${WORD_CHARS}\\--` /** 两宽 Bearer 值类(原半角集 `A-Za-z0-9._~+/=-` + 其全角形态)。 */ const BEARER_VALUE_CHARS = `A-Za-z0-9._~+/=\\-${FW_DIGITS}${FW_UPPER}${FW_LOWER}_.~/+=-` /** ASCII 可见字符 → 全角等价(U+0021-U+007E ↔ U+FF01-U+FF5E);其余原样返回。 */ function toFullwidth(ch: string): string { const code = ch.charCodeAt(0) return code >= 0x21 && code <= 0x7e ? String.fromCharCode(code + 0xfee0) : ch } /** 半角 ASCII 词 → 「半角|全角」等价类(M5 单点:关键词的两宽形态只此一处生成)。 * 仅接受 `[A-Za-z0-9_-]`——含正则元字符的词会让等价类语法失真,故 fail-closed 抛错。 */ function twoWidth(word: string): string { let out = '' for (const ch of word) { if (!/[A-Za-z0-9_-]/.test(ch)) { throw new Error(`twoWidth 只接受半角字母数字/下划线/连字符,收到:${word}`) } out += `[${ch}${toFullwidth(ch)}]` } return out } /** * 敏感信息模式(GPT 审查·自动记忆脱敏):写入认知图前过滤凭据/个人标识。 * 命中 → 替换为 [已过滤:类别](保留对话主体);过滤后只剩占位符/空白 → 整条跳过。 * 纯内容过滤,不涉及身份认证——开源场景下的隐私保护。 * * M5:值类/词形两宽齐备(见上方字符集注释);`\b` 全部换成两宽 lookaround—— * `\b` 只认半角 `\w`,`[sk−…]` 这类以全角起首的串在串首**根本取不到词边界**。 */ // 导出供守卫使用(test/token_redact_parity.test.ts 需要按「交换序」复跑同一条链, // 以证明顺序不再是安全性质);对外仍属内部实现,不承诺稳定 ABI。 export const SENSITIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [ { re: new RegExp(`${twoWidth('sk')}[\\--][${TOKEN_VALUE_CHARS}]{8,}`, 'g'), label: 'API密钥' }, { re: new RegExp( `(?..` // (md_cg/tokens.py:make_token),若先匹配裸 token_id,secret 段会留成明文 // (实测:`…designer.[已过滤:id].SECRET…`),故整条令牌必须整段吃掉。 // // 2026-09-28 加固(PR#46 合并当批):**去 `\b` 词边界、role/secret 字符类放宽、 // secret 下限 16→8**——原式有三处「整条规则失配 ⇒ id 规则独吃 id、secret 留明文」 // 的触发面(实测复现):① 前导为词字符(`k_mdcg1.…`、`a mdcg1.…` 紧邻字母数字下划线 // 时 `\b` 失效);② role 含非字母(如 `sub-agent1`——`parse_token` 只要求非空, // 不校验字符集);③ secret 短于 16 字符(`make_token` 不校验长度)。三者都让整条规则 // 失配,而裸 id 规则照旧命中 ⇒ secret 明文落库(与顺序错配同一形态)。放宽后与禁表 // 第 11 条 `mdcg1\.[A-Za-z0-9._\-]{20,}`(本就无 `\b`)同口径。 // M5(两宽):四段令牌的**值类**(role/id/secret)与分隔点 `.,` 全部两宽;前缀写成 // 「半角|全角」两种**拼写**的互斥分支(`(?:mdcg1|mdcg1)`)——两分支是不同字符, // 故不是冗余、也不是字符类。 // // ⚠️ 这两条令牌规则**必须保持 `/…/g` 字面量、单行且 Python 兼容**:形态守卫 // `md_cg/test_token_lowercase_form.py` 的 G7d~G7g 逐行抽取本文件的 `/…/g` 字面, // 再用 Python `re` 复跑(定宽后顾 `(? knowledge 一次都不发生、knowledge 层恒空。 // 四槽必须齐全:md_cg/nodefile.condition_space_text(require_full=True) 缺任一槽即返回 ""。 const COND_ROLE_POS: Record = { user: 'dsh-hook/user-message', assistant: 'dsh-hook/assistant-message', tool: 'dsh-hook/tool-result', } function condSpace(role: string): Record { // 时间维取**小时粒度**:避免把每次调用都变成不同条件(那会让归纳的共享条件退化为单点)。 // 单位必须是**秒**:md_cg/nodefile.py 的 FULL_TIME_WINDOW_MAX = 9999999999.0 是秒级上限 // (约 2286 年)。用毫秒(Date.now() 原值约 1.76e12)会超过该上限,时间槽被判非法丢弃, // 于是只余三槽 ⇒ condition_space_text(require_full=True) 返回空串 ⇒ 归纳的 common_pos // 恒空、knowledge 层恒空 —— 即"补了 condition_space 与不补等价"(issue #538 实测)。 // issue #590:单位必须是**秒**(nodefile.FULL_TIME_WINDOW_MAX 为秒级上限)。 // 此前除以 3600000 得到「小时索引」,stg(op=timeline) 把它当秒 ⇒ 显示 1970 年、排序落到末尾。 const seconds = Math.floor(Date.now() / 1000) return { observation_position: COND_ROLE_POS[role] ?? ("dsh-hook/" + role), time_window: [seconds, seconds] as [number, number], observation_tool: 'dsh-memory-hook', existence_constraint: role + "-message-minlen1", } } export function desensitize(text: string): string | null { let out = text for (const { re, label } of SENSITIVE_PATTERNS) { out = out.replace(re, `[已过滤:${label}]`) } // 过滤后只剩占位符/空白 → 纯凭据消息,不写(或全部被替换) const residue = out.replace(/\[已过滤:[^\]]+\]/g, '').trim() if (!residue) return null return out } /** 与 `desensitize` 同规则,但**允许整条皆凭据**:返回占位串而非 null。 * * issue #563 ①:注入面的两条 render(timeline / 窗口)需要「原文一定被替换,且这一条 * 不丢」——整条皆凭据时用 `[已过滤:<类别>]` 占位保留类别与「这里有过一条」的信号, * 与 #354 P1-14 的诚实边界一致(保证的是原文不出现,不是不注入)。 */ function redactKeepPlaceholders(text: string): string { let out = text for (const { re, label } of SENSITIVE_PATTERNS) { out = out.replace(re, `[已过滤:${label}]`) } return out } /** 日志面脱敏(issue #354 [P1-13]):**无条件**过 desensitize。 * * 缺陷:`ctx.logger.warn(... ${err.message})` 把上游异常消息原样拼进日志——上游 * 把请求头/令牌回显进 message 时,凭据在控制台与日志文件里明文外泄。 * 为什么**不**看 `opts.desensitize`:那是**写入记忆库**的开关(数据面选择), * 日志是诊断面,泄漏到日志不因「用户选择不脱敏记忆」而变得可接受——日志面 * 一律 fail-closed。整条都是凭据(desensitize 返回 null)时给固定占位,不丢 * 「这里有过一条错误」的信号。 * * ⚠️ 形参是 `unknown`(复核意见 2):Promise 可以以**任意值**拒绝,`.catch((err) => …)` * 收到的未必是 Error——字符串/数字/undefined 时 `err.message` 是 undefined,若把它 * 直接交给 `desensitize`(内部 `text.replace`)会在**错误处理路径**上抛 TypeError, * 而该回调已不在任何 try 内 ⇒ unhandled rejection(Node 默认终止进程)。于是 * 「记一条 undefined 继续跑」被修成「进程退出」——比原缺陷更坏。 * 故此处一律先安全转成字符串:Error 取 message,带字符串 message 的对象取 message, * 其余(含 string/undefined/null)用 String(value)。 */ export function redactForLog(value: unknown): string { let text: string if (typeof value === 'string') { text = value } else if (value instanceof Error) { text = value.message } else if (value && typeof value === 'object' && typeof (value as { message?: unknown }).message === 'string') { text = (value as { message: string }).message } else { text = String(value) } const r = desensitize(text) return r === null ? '[已过滤:凭据]' : r } /** 时间线载荷 → 注入文本。 * `stg(op=timeline)` 返回 {count, limit, items:[{id, layer, start, end, preview}]}。 * (保留原始实现;自动召回改用下面的分级递减渲染) */ function formatTimeline(payload: unknown): string { const items = (payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items)) ? (payload as { items: Array> }).items : [] return items .map((it) => { const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim() if (!preview) return '' const layer = it['layer'] ? `[${String(it['layer'])}] ` : '' return `- ${layer}${preview}` }) .filter(Boolean) .join('\n') } // ---------------------------------------------------------------- 自动召回渲染 // 常量写死在此处(而非 config schema)——未知键会被 schema 剥离。 /** 第 1 档(最新 1 条)每条字符上限。 */ const RECALL_BASE_CHARS = 160 /** 每 N 条降一档。 */ const RECALL_DECAY_EVERY = 1 /** 每档缩放比例(−10%)。 */ const RECALL_DECAY_RATIO = 0.9 /** 最小保留字符。 */ const RECALL_MIN_CHARS = 24 /** 整块上限(与调用点 slice 对齐)。 */ const RECALL_MAX_CHARS = 1400 /** 永久层不自动注入(按需用 mdcg_recall / stg 取)。 */ const RECALL_SKIP_LAYERS = new Set(['anchor', 'self']) /** 分级递减渲染:按距当前的次序逐档收窄,早期条目信息量更大。 * * 动机:注入块总长受限,而"最近 N 条"里越靠前的越可能被用到;线性等宽分配 * 会让整块被最旧的一条挤掉。逐档递减后整块实测约 1133 字符(≈472 tok), * 9 条全部保留。(注意:整块仍远小于一条知识节点 500~800 tok,故本块定位是 * 「存在性索引/提醒」,不承载知识本身——要知识请显式 mdcg_recall 并给足预算。) */ // issue #563:导出仅为可测(注入面脱敏顺序的回归用例需要直接喂 payload)。 export function formatTimelineDecayed(payload: unknown, opts?: { includeIdentity?: boolean }): string { const items = (payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items)) ? (payload as { items: Array> }).items : [] const out: string[] = [] let rank = 0 for (const it of items) { const layer = String(it['layer'] ?? '') // issue #635:includeIdentity=true 时,永久层**在本块仍不渲染**(它们走独立块), // 这里只放宽"跳过"以便调用方取到它们——真正的分流在 renderIdentityAnchors。 if (RECALL_SKIP_LAYERS.has(layer) && !opts?.includeIdentity) continue if (RECALL_SKIP_LAYERS.has(layer)) continue const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim() if (!preview) continue const tier = Math.floor(rank / RECALL_DECAY_EVERY) const budget = Math.max( RECALL_MIN_CHARS, Math.round(RECALL_BASE_CHARS * Math.pow(RECALL_DECAY_RATIO, tier)), ) // issue #563 ①:**先脱敏、后截断**。截断会把证件/手机号切成残片,残片不再命中 // 规则(身份证 17+1 位、手机 11 位),于是原样进入注入块。 // 整条皆凭据(desensitize 返回 null)时**不丢该条**:用固定占位保留「这里有过一条」 // 的信号——注入面照常成立(与 #354 P1-14 的诚实边界一致:保证的是「原文不出现」, // 不是「不注入」)。 const safe = redactKeepPlaceholders(preview) out.push(`- [${layer}] ` + (safe.length > budget ? safe.slice(0, budget) + '…' : safe)) rank += 1 if (out.join('\n').length >= RECALL_MAX_CHARS) break } return out.join('\n').slice(0, RECALL_MAX_CHARS) } /** 身份锚点块(issue #635):只取永久层(self/anchor),独立预算。 */ export const IDENTITY_MAX_ITEMS = 3 export const IDENTITY_MAX_CHARS = 640 export const IDENTITY_ITEM_CHARS = 160 export function renderIdentityAnchors(payload: unknown): string { const items = (payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items)) ? (payload as { items: Array> }).items : [] const out: string[] = [] for (const it of items) { const layer = String(it['layer'] ?? '') if (!RECALL_SKIP_LAYERS.has(layer)) continue const preview = String(it['preview'] ?? '').replace(/\s+/g, ' ').trim() if (!preview) continue const safe = redactKeepPlaceholders(preview) out.push(' - [' + layer + '] ' + (safe.length > IDENTITY_ITEM_CHARS ? safe.slice(0, IDENTITY_ITEM_CHARS) + '…' : safe)) if (out.length >= IDENTITY_MAX_ITEMS) break if (out.join('\n').length >= IDENTITY_MAX_CHARS) break } return out.join('\n').slice(0, IDENTITY_MAX_CHARS) } // ---------------------------------------------------------------- 会话窗口渲染 // 「短期会话窗口」(contextWindow)的取数/渲染常量。与上方 RECALL_* 同款纪律: // 常量写死在此处(而非 config schema——未知键会被 schema 剥离)。 /** 窗口块取数的整包 token 预算(服务端 `session_recall` 的 budget_tokens)。 * * ⚠️ 这是**整包**预算,不是 recent 段的独立预算:notes/goals/tasks/self_state * 与 recent 共享(服务端裁剪循环交替丢 recent / notes 尾部,md_cg/mdcos.py: * 3351-3362)。取 600 是**有意保守**——本块定位是「存在性锚点 / 接续提示」, * 宁可少注入几条,也不挤占宿主上下文。实测(空库 + 10 条窗口条目): * budget_tokens=600 → recent 段 8 条;1200 → 10 条。 */ const WINDOW_BUDGET_TOKENS = 600 /** 单条窗口条目预览上限(字符)。 */ const WINDOW_ITEM_CHARS = 120 /** 窗口块总长上限(字符)。 * * issue #712(2026-10-30):归因前缀(`[MM-DD HH:mm|会话|密级] `,约 20 字符/行) * 会占预算,故由 800 上调到 **900**(10 行 × 20 = 200,实测内容量持平)。 * 该块定位仍是「存在性锚点 / 接续提示」——宁可少注入几条,不挤占宿主上下文。 */ const WINDOW_MAX_CHARS = 900 /** 单条窗口条目的 role 字段上限(字符,issue #354 [P1-12])。 * * 缺陷:`text` 有 120 字上限,`role` 却**完全不限长**——一条 3000 字的恶意 * role 使首行 3284 字(首行因 `used === 0` 绕过总预算判据),随后 `used > 800` * 让下一条正常历史立刻 break,整个窗口被单条记录劫持。 * role 只是 `user`/`assistant` 一类的短标签,32 字足够任何合法取值。 */ const WINDOW_ROLE_CHARS = 32 /** 会话窗口载荷 → 注入文本(限幅沿 RECALL 分级渲染的风格:单条 ≤120 字、 * 整块 ≤800 字)。 * * `cg(op=session, action=recall)` 的 `recent` 段 = `{role, text, t}` 列表, * 按**新→旧**排列(服务端 `recent_events` 的 newest_first)。渲染取**旧→新** * (对话流水的自然阅读序),但**裁剪保最新**:先按服务端序(新→旧)逐条 * 试放入上限(放不下就**停在更旧的条目上**,整条不放入),最后整体反转 * ——总长受限时丢掉的是**最旧**条目(近因优先),且**不切条目中间** * (逐条整放/整弃;最后才 slice 是错的——那会把最新一条切掉半截)。 * * 空载荷 / 无 recent 段 / 全空条目 → 返回空串(调用方据此**不 push** 第二块)。 */ // issue #563:同上,导出仅为可测。 export interface SessionWindowOptions { /** 当前会话 id;提供时用于标注「本会话」还是「其他会话」(不用于过滤—— * 过滤由服务端 `session_recall` 按 `meta.session` 完成,前端再滤一次 * 会与服务端判据分裂成两份,本仓已多次栽在这上面)。 */ session?: string /** 是否渲染归因前缀(默认 true)。设 false 可得旧格式(仅测试/回退用)。 */ attribution?: boolean } export function formatSessionWindow( payload: unknown, limit: number, opts: SessionWindowOptions = {}, ): string { const items = (payload && typeof payload === 'object' && Array.isArray((payload as { recent?: unknown }).recent)) ? (payload as { recent: Array> }).recent : [] const rows: string[] = [] const max = Math.max(1, Math.floor(limit) || 10) let used = 0 const curSid = String(opts.session ?? '').trim() const wantAttr = opts.attribution !== false // issue #712(2026-10-30):归因前缀——让模型能区分「本会话/其他会话」「私有/共有」 // 「刚刚/过去」。库层与注入面已带齐四要素(md_cg/mdcos.py 的 recent 段),此处负责 // **渲染出来**;不渲染等于模型永远看不到,等于没做(这正是 PR #712 的原病灶)。 // ⚠️ 本改动**变更输出格式**(BREAKING):原 `[role] text` → `[MM-DD HH:mm|会话|密级] [role] text`。 // 前缀会占预算,故 WINDOW_MAX_CHARS 同步上调(见该常量注释)。 const stamp = (t: unknown): string => { const n = Number(t) if (!Number.isFinite(n) || n <= 0) return '??-?? ??:??' const d = new Date(n * 1000) const p = (x: number): string => String(x).padStart(2, '0') return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` } const sessTag = (s: unknown): string => { const v = String(s ?? '').trim() if (!v) return '?' if (curSid && v === curSid) return '本' return `会话${v.slice(-4)}` } const sensTag = (s: unknown): string => { const v = String(s ?? '').trim().toLowerCase() if (v === 'private' || v === 'secret') return '私' if (v === 'public' || v === 'internal' || v === 'restricted') return '共' return '?' } for (const it of items.slice(0, max)) { // issue #354 [P1-12]:role 与 text 同受单条上限约束——只限 text 时一条超长 // role 就能让首行突破整块预算并把后续历史全部挤出(首行 used===0 绕过了 // 下方总预算判据)。role 截断后再判空,仍为空则回落 'user'。 const role = String(it['role'] ?? '').trim().slice(0, WINDOW_ROLE_CHARS) || 'user' const preview = String(it['text'] ?? '').replace(/\s+/g, ' ').trim() if (!preview) continue // issue #563 ①:同上——窗口块每行也**先脱敏、后截断**,否则截断残片绕过注入侧脱敏。 const safe = redactKeepPlaceholders(preview) const body = safe.length > WINDOW_ITEM_CHARS ? safe.slice(0, WINDOW_ITEM_CHARS) + '…' : safe const attr = wantAttr ? `[${stamp(it['t'])}|${sessTag(it['session'])}|${sensTag(it['sensitivity'])}] ` : '' const row = `${attr}[${role}] ${body}` const next = used === 0 ? row.length : used + 1 + row.length // issue #354 [P1-12]:单条超预算时不再「首条整放」——超过 WINDOW_MAX_CHARS 的 // 行一律不放(截断 body 已在上面做完,理论上不可达,作 fail-closed 兜底)。 if (next > WINDOW_MAX_CHARS) { if (used === 0) continue break } rows.push(row) used = next } return rows.reverse().join('\n') } /** 取宿主会话标识(只用于**归因/隔离**,不参与任何权限判断)。 * * 动机:记忆写入必须带会话身份才能区分不同会话;读取默认只看本会话(防串台), * 而「所有会话做了什么」用显式 session="*" 取。两侧都依赖这个标识。 * * 字段名按 DSH 既有形态(`id` / `sessionId`)防御式读取,取不到就返回空串—— * 空串在上游一律等同「不分会话」(退回旧行为),故宿主改字段名最坏只是失去 * 隔离能力,不会注入错块、不会抛错。 */ function sessionIdOf(raw: unknown): string { const s = raw as { id?: unknown; sessionId?: unknown } | null | undefined const v = s?.id ?? s?.sessionId return typeof v === 'string' ? v.trim() : '' } // UNASSIGNED_SESSION(会话归属未知时的显式占位常量)单点已移至 // lib/session_state.ts(B 治本批:本文件与工具面共用同一常量与同一会话状态)。 /** H1 **会话级**判据:这条 session 是否「子代理/委派子会话」。 * * 字段来源(DSH 类型面,dsh-session/lib/types/types.d.ts):实装 DSH 2.0 * (`dsh-0.2.0-rc.2`):81 `header.origin?: 'subagent'`「Coarse product * classification for a session created as a subagent child」;:87 * `header.delegationDepth?: number`「absent (zero) for a top-level session, * parent depth + 1 for a subagent child」(本仓 devDeps `0.1.0-rc.8` 同字段 * 在 :64 / :70)。 * * ⚠️ **观测面(2026-10-05 订正)**:本机**已装** DSH 2.0(`dsh-0.2.0-rc.2`); * 19 场真实会话(`sessions/…/session.v4.jsonl.zstd`)观测到会话头 * `delegationDepth=0` 真实在写(19/19),`origin` 键从未出现。**仍未观测**: * `origin='subagent'` / `delegationDepth>0` 的真实出现。故判据取 * 「**字段在场且取值匹配才拦**」的形态:header 缺失 / 非对象 / 两个字段都取不到 * 或不匹配 → 一律返回 false(**不拦**,安全退化为既有行为),绝不因字段缺失而 * 报错,也不因此改变既有写入行为。 * * 默认行为(显式声明):**子代理会话的自动记忆默认拦掉**(默认拦)——委派指令是 * 「另一个 agent 发给本 agent 的指令」,不是用户的长期记忆,写进来会污染真人记忆; * 而判据不确定时**默认放行**(字段缺失即不拦)——宁可多记,不可因宿主字段缺失 * 而静默丢掉真人记忆。 */ function isSubagentSession(session: unknown): boolean { const header = (session as { header?: unknown } | null | undefined)?.header if (!header || typeof header !== 'object') return false const h = header as { origin?: unknown; delegationDepth?: unknown } if (h.origin === 'subagent') return true const depth = h.delegationDepth return typeof depth === 'number' && Number.isFinite(depth) && depth > 0 } /** H1 **消息级**判据:这条消息是否是「另一个 agent 发给本 agent 的」(委派/中继)。 * * 字段来源(DSH 类型面,dsh-llm/lib/types/message.d.ts):实装 DSH 2.0 里 `'relay'` * 在 `ContextFormed` 判别联合上(:90 `readonly form: 'relay';`;:55-56 注释原文 * 「A message another agent addressed to this one」),该联合由各生产者按需混入自己的 * source 类型——2.0 的 `MessageSourceMap` 注释明确**无共享 catch-all `plugin` * 类别**,`kind` 由各生产者声明在自己的模块里(:94-100;本仓 devDeps * `0.1.0-rc.8` 同段在 :52 / :86)。 * * ⚠️ **观测面(2026-10-05 订正)**:本机**已装** DSH 2.0;19 场真实会话中 * `form='relay'` 从未出现(270 条 `user/message` 实测无 relay),即**真实出现 * 仍未观测**。故同样取「字段在场且取值匹配才拦」;source 缺失/非对象 → false。 * * 与既有 `kind !== 'user'` 判据的关系:`kind` 非 `'user'` 的中继**本就被**那条拦掉 * (19 场实测的注入类 kind 均非 `'user'`);本判据放在它**之前**,是为了 * ① 不把委派判定押在 `source.kind` 单点上、② 覆盖「生产者把中继标成 * `kind='user'` 且带 form」这一类型面之外的形态——子会话的**首轮用户提示**就可能 * 是这种:它与真人输入在 `kind` 上不可分,只有会话级判据(或这里的 form)能拦。 */ function isRelayedMessage(source: unknown): boolean { const s = source as { form?: unknown } | null | undefined return !!s && typeof s === 'object' && s.form === 'relay' } /** 安装自动记忆钩子(effect 作用域内,随插件卸载自动移除)。 * 返回幂等收尾函数,由调用方随插件关闭/更新调用,输出一次审计摘要(#606)。 * * mdcg 为 null(config.mdcg.enabled=false)时自动记忆整体停用:记忆真源是 * 认知图,没有它就没有可写的去处——**不会退回 AEIS**(AEIS 已不存记忆)。 */ export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts: MemoryHooksOptions): () => void { if (!mdcg) { ctx.logger.warn('dsh-memory: 认知图未启用(config.mdcg.enabled=false),自动记忆已停用') return () => {} } const graph = mdcg /** 落盘审计(issue #56,诊断面):三处过滤分支与写入/跳过路径各留一道**盘面** * 痕迹——宿主 logger 不落盘时,单看记忆侧无法区分「被设计滤除」与 * 「写入失败/漏记」。审计失败静默降级(见 src/lib/hook_audit.ts), * 绝不冒泡进记忆路径、不改任何写入/过滤判定。 */ const audit = new HookAuditRecorder(opts.auditPath) // ── 短期会话窗口(contextWindow):与知识面写入**独立成轨**(见文件头)── // 缺省开启({ enabled: true, turns: 10 });enabled=false → 写侧与注入侧 // **同时静默**。turns = 窗口取数条数(session_recall 的 recent_limit 语义是 // **条**不是轮);clamp 到 1~50(与既有 recallLimit 同款纪律,防配置失手)。 const cw = opts.contextWindow ?? { enabled: true, turns: 10 } const cwEnabled = cw.enabled !== false const cwTurns = Math.max(1, Math.min(50, Math.floor(cw.turns || 10))) /** 窗口条目判定(contextWindow 写侧):这条事件是否值得进「近期对话」窗口。 * 返回 null = 不写。 * * 过滤面与知识面**同源**(复用同一组谓词函数,故两处口径不会各自漂移): * · user/message:`form==='relay'`(H1 消息级)与 `kind!=='user'`(插件注入 / * 系统上下文)不写——与自动记忆同一判定;有文本才写; * · assistant/message:有文本即写; * · 其它事件类型(tool/result 等):一律不写(窗口是**对话**记录)。 * * ⚠️ 两处**有意不同门**(这是设计,不是遗漏):本判定**不看** * `opts.userMessage` / `opts.assistantMessage`——那两个开关管知识面(消息沉淀 * 成记忆节点),本机制由 `contextWindow.enabled` 管(运行态窗口)。若把窗口写 * 也挂到那两个开关上,使用者既有的 `userMessage=false` 就会连带关掉窗口, * 「知识面关、窗口开」的独立轨道即不成立(两轨关系见文件头)。 * * ⚠️ 子代理会话(H1 会话级)由调用点**更早**拦回(在取 sid 之前),不在此重判 * ——与自动记忆同口径:委派指令不进真人窗口。 */ const windowEntry = (event: SessionEvent): { role: 'user' | 'assistant'; text: string } | null => { if (event.type === 'user/message') { if (isRelayedMessage(event.data.source)) return null if (event.data.source?.kind !== 'user') return null const text = extractText(event.data.content) return text ? { role: 'user', text } : null } if (event.type === 'assistant/message') { const text = extractText(event.data.message.content) return text ? { role: 'assistant', text } : null } return null } /** 窗口写入(fire-and-forget):失败只记 warn,**绝不炸会话流**(沿 memorize * 的 catch 风格)。桥未就绪静默跳过(窗口是运行态面,不阻塞对话;「未就绪」 * 的告警已由 memorize 路径负责,不在此重复刷屏)。 * * ⚠️ 同步抛出也必须被吞(catch 两段):真实部署下 graph 是 MdcgClient 全量 * 实现;但桥替换实现 / 降级替身缺该方法时,抛错同样不得越过会话流边界。 */ const noteRecent = (role: 'user' | 'assistant', text: string, meta: Record): void => { if (!graph.isReady()) return try { void graph.recentAdd(role, text, meta, ['dsh', 'recent-window']) .catch((err: Error) => ctx.logger.warn(`dsh-memory: 短期窗口写入失败: ${redactForLog(err)}`)) } catch (err) { ctx.logger.warn(`dsh-memory: 短期窗口写入失败: ${redactForLog(err)}`) } } /** 本实例是否曾观测到会话(B 治本批)。 * * 会话状态本体是**进程级单点**(lib/session_state.ts;hooks 面观测、工具面 * src/tools.ts 的写归因注入共用);而「本实例有没有观测过」是**实例级**事实: * 多实例并存时(测试;或宿主重装钩子),不得让**别的实例**的观测替本实例决定 * 召回过滤——否则新装的钩子在尚未观测到会话时就按别的实例的会话去读 * (读错会话,正是 P45 会话隔离要防的形态)。真机单实例下两者等价:首个 * session/event 之前模块状态为空,之后回落值恒为同一单点值。 * * ⚠️ 这是**经 owner 裁定的契约字面偏离(dwfq-7b3a555e-1)**:契约给的形态是 * 下方回落处直接 `|| currentSession()`(无本门);但字面形态与硬边界 * 「test/session-attribution.test.ts 逐字未动且全绿」互斥——该守卫 ② 以 * 「新建 harness = 未观测」为前提,字面回落会读成前一实例的 sess_B。裁定 * 接受本门,两条理由:① 保留原闭包变量「新实例 = 干净状态」的**有意**语义 * (新装钩子未观测时不加召回过滤);② 冻结守卫零误伤。真机单实例与字面 * **逐位等价**(差异窗口「本实例未观测 ∧ 模块单点非空」单实例下不可达; * HMR 重载时新实例回落 '' 属更保守行为)。 */ let observedSession = false let warnedEmptySid = false /** 记忆沉淀(fire-and-forget)。认知图未就绪则跳过并告警(不退回 AEIS)。 * `role`=null 表示**读预热**(user-recall)——审计只统计写入路径, * 故读预热不参与 written/skipped 计数(它不写记忆)。 */ const memorize = (label: string, role: HookWriteRole | null, run: (g: MdcgClient) => Promise): void => { if (!graph.isReady()) { if (role) audit.skipped('not_ready', role) ctx.logger.warn(`dsh-memory: 认知图未就绪,跳过自动记忆(${label})`) return } if (role) audit.written(role) // issue #296:工具面把**未落盘**的回执以 ok:false 返回(不是 MCP isError, // 故下方 .catch 收不到它)。此前只接 .catch ⇒ 这类回执零告警,而上面那笔 // written 已经记下——审计文件对一次「服务端明确说没写」的调用显示「已写入」。 // 现补读回执:ok:false 且 verdict 不是 MERGE/DROP(前者并入既有节点、后者零新 // 信息,都不是失败)时记 skipped.failed 并告警;回执带 proposed 说明正文已 // 转审核队列(有去向),只告警、不计 failed。 void run(graph).then((res) => { if (!role || !res || typeof res !== 'object') return const r = res as { ok?: unknown; verdict?: unknown; proposed?: unknown; gate?: unknown; error?: unknown } if (r.ok !== false) return const v = typeof r.verdict === 'string' ? r.verdict : '' if (v === 'MERGE' || v === 'DROP') return const g = (r.gate && typeof r.gate === 'object') ? r.gate as { reason?: unknown } : {} const why = String(g.reason || (typeof r.gate === 'string' ? r.gate : '') || r.error || '') if (r.proposed) { ctx.logger.warn(`dsh-memory: 自动记忆 ${label} 未直接落盘,已转审核队列(${v || '?'} ${why})`) return } audit.skipped('failed', role) ctx.logger.warn(`dsh-memory: 自动记忆 ${label} 未写入(${v || 'ok:false'} ${why})`) }, (err: Error) => { if (role) audit.skipped('failed', role) ctx.logger.warn(`dsh-memory: 自动记忆 ${label} 失败: ${redactForLog(err)}`) }) } // P1 完善(GPT 审查·自动记忆脱敏):写入前过滤敏感信息(默认开启)。 // 命中敏感模式 → 替换为 [已过滤:类别];纯凭据消息 → 跳过写入(不落库)。 const sanitize = (text: string): string | null => { if (!opts.desensitize) return text return desensitize(text) } // P1 完善(自动 recall 注入):每次模型请求组装 system prompt 时,注入灵枢最近记忆。 // 用 system-prompt/assemble 事件(waterfall)而非 llm/stream——后者请求 deep-frozen 不可改写。 // // ⚠️ 必须**每步都 push**,哪怕内容与上一步逐字节相同。原因在宿主侧(dsh-agent-loop 的 // RuntimeContextProjection):assembly.contexts 会被渲染成一段「运行时上下文快照」, // 每个 step 拿渲染后的**整段文本**与上一份已提交的快照比对,**只有不同才**在会话里 // append 一条新的 user/message(append 语义,旧的不会被替换或移除)。于是: // · 内容不变 + 照旧 push → 渲染文本不变 → 宿主不追加任何东西(零开销、零增长); // · 内容不变 + 跳过 push → 渲染文本**变了**(少了本块)→ 宿主追加一份「没有本块」的 // 快照;下一步再 push 又把本块加回来 → **再**追加一份。跳过一次反而多花两份快照 // (实测每份 ~250 tok),这正是 v0.4.8「每 8 步强制补一次」的自愈刷新会把长会话的 // inject 推到 30k+ tok 的原因。 // 因此本实现把「要不要补」交还给宿主:压缩归档后宿主会把 retained 置空并重新投影快照 // (RuntimeContextProjection 的 retained === null 分支),本块自然跟着回来—— // 不需要插件自己数步数做自愈。 /** 真实轮次计数(session → 用户消息条数)。issue #645:计轮单位必须是「用户回合」, * 不是 assemble 次数——实测一步约 10 次 assemble,按 assemble 计数会把「每 10 轮」 * 退化成「每一步」,提醒于是常驻上下文、agent 反复空转归档。 */ const userTurns = new Map() /** 待推送提醒(session → 文本):用户回合计数命中时写入,assemble 时只消费一次; * 不做 sticky 重推(那是提醒常驻的直接原因)。 */ const pendingReminder = new Map() /** 提醒间隔:false 或 **<= 0 → 0(关闭)**;数字 → clamp 1..100;未设/非有限 → 10。 * issue #659 F26-21:旧实现把 0 钳成 1(每轮提醒一次),与「0 = 关」的直觉相反; * 负数同样落 0(关),而不是变成 1。非有限(NaN/Infinity)仍回落默认 10。 */ const reminderInterval = (() => { const cfg = opts.turnReminder if (cfg === false) return 0 if (typeof cfg !== 'number') return 10 if (!Number.isFinite(cfg)) return 10 const n = Math.floor(cfg) if (n <= 0) return 0 // 0 / 负数 = 关闭(#659 F26-21) return Math.min(100, n) })() { // 本 handler **恒定注册**(#657):提醒是独立配置(memory.turnReminder), // 关掉自动召回不该让它只写不读;召回部分在内部按 opts.autoRecall 门控。 const recallLimit = Math.max(1, Math.min(10, opts.autoRecallLimit || 4)) ctx.on('system-prompt/assemble', async (assembly, _ctx, next) => { try { // 异步取最近记忆节点(失败静默——不阻塞模型请求) // 会话标识:门控内外都要用(提醒消费 + 自动召回),故先解析。 const hostCtx = (_ctx as unknown) as { agent?: { session?: unknown } } | undefined const sid = sessionIdOf(hostCtx?.agent?.session) || (observedSession ? currentSession() : '') // 会话 id 为空时后端会退回全局滚动窗口(跨会话串台)。只告警一次, // 避免每个 assemble step 刷日志;不改注入内容或数据路径。 if (!sid && !warnedEmptySid) { warnedEmptySid = true ctx.logger.warn('dsh-memory: 会话 id 为空,短期窗口可能退回全局滚动窗口(跨会话串台);请检查 host.agent.session 或会话观测是否可用') } // 逢十轮提醒的消费面(#657):不受 autoRecall 门控。 if (sid) { const pending = pendingReminder.get(sid) if (pending) { pendingReminder.delete(sid) assembly.contexts.push({ name: 'lingshu:turn-reminder', text: pending }) // issue #659 F26-24:消费点留一条日志(旧实现完全静默——「提醒到底有没有 // 注入过」只能靠翻上下文猜)。 ctx.logger.info('dsh-memory: 逢十轮提醒已注入(消费一次,不重推)') } } if (opts.autoRecall && graph.isReady()) { // 会话隔离(P45):自动召回只注入**本会话**的记忆,防多会话串台; // 取不到会话标识则退回旧行为(不加过滤),不做半吊子猜测。 // ⚠️ 取值必须**每步稳定**:本块按 v0.4.8 契约每步都 push,内容一旦与上 // 一步不同宿主就 append 一份新快照——会话标识若中途才出现,会让「无过滤 // → 有过滤」翻转一次,白付两份快照。故优先取 ctx 上的会话(首步即在), // 退回「最近一次 session/event 的会话」。 // 想读**所有**会话做了什么:别走自动召回(它会串台),显式调 // `stg(op=timeline, session="*")`,返回项带 session 归属。 // sid 已在门控外解析(提醒消费与召回共用同一份,避免两次口径漂移)。 // 逢十轮提醒(纪律第 16 条;issue #645 修正):计数挪到 session/event 的真实 // 用户回合上,这里只消费一次待推文本。旧实现把 assemble 当轮次 + 段内永久重推 // ⇒ 提醒几乎常驻、agent 反复空转归档;sid 缺失时还回落共用键 __no_sid__, // 并行会话互相污染频率。现改为:无 sid 不提醒;提醒只在本回合第一次 assemble 出现。 // (提醒消费已在门控外做掉,见上;此处不再重复——重复块是 #657 修复的残留。) // 非永久层 → 既有「最近记忆」块;self/anchor → 独立「身份锚点」块(独立预算)。 const identityOn = opts.autoRecallIdentity === true const timelinePayload = await graph.timeline( recallLimit, sid ? { session: sid } : {}) // issue #715(2026-10-30 外部报告,逐行复现):**身份块必须单独取数**。 // // 病灶:此前身份块复用上面这份**带会话过滤**的 payload(还把 limit 加 3 试图 // 「多取 3 条」),但 `self`/`anchor` 节点的 `session` **恒为 `unattributed`** // ——身份锚点在语义上就注定跨会话(`identity` 写入不带 session_id 时即如此, // 状态卡与关系节点同理)。两者叠加 ⇒ 永久层被会话过滤全部滤掉 ⇒ // `renderIdentityAnchors` 拿到空集 ⇒ **块从不出现且无任何报错**(静默失效)。 // // 这正是《身份与人格》理论的 R3:**人格的可见性不该由会话决定**。 // 用会话过滤去筛人格节点是**类别错误**,不是参数没调好。 // // 故身份块改为**不带会话过滤**取数(不传 session ⇒ 跨会话视图), // 再由 `renderIdentityAnchors` 只挑 self/anchor(其余层照样被它跳过)。 // 「最近记忆」块保持会话隔离不变——那是工作记忆,本就该跟会话走。 const identityPayload = identityOn ? await graph.timeline(IDENTITY_MAX_ITEMS * 3, {}) : null const text = formatTimelineDecayed(timelinePayload) if (text) { // 注入边界转义(issue #16):宿主 system-prompt 对 context 文本做严格 // `{{variable}}` 插值,裸 `{{` 会 throw → 该轮请求整体失败。记忆原文 // (含用户命令里的 `{{.X}}`)必须保真落库,故只在注入副本上打断 `{{`。 // // H5(不可信内容边界):注入块**必经** renderUntrustedMemoryBlock—— // 固定声明句(UNTRUSTED_MEMORY_NOTICE,常量单点)+ 显式边界标记 + // 载荷内边界标记的打断。记忆是任意用户输入/工具输出的沉淀, // 无边界时模型无从区分「数据」与「指令」(提示注入面)。 // 三层顺序:边界渲染(含载荷打断)→ `{{` 转义;两者都只改注入副本。 // // issue #354 [P1-14]:注入副本还须**过脱敏**——旧实现只在写入侧 // (saveTurn → sanitize → remember)挂脱敏,注入侧直接把库内原文 // push 进系统提示词。历史遗留 / 直接写库 / 绕过写入脱敏的凭据因此 // 原样回显进模型上下文。脱敏与写入侧开关**解耦**:`opts.desensitize` // 是「记忆库里存不存原文」的数据面选择,注入是**导出面**——库里的 // 原文不得因该开关而被搬到模型上下文(读侧 fail-closed)。 // 脱敏只作用于**载荷正文**(标题是常量、不参与判据)。 // ⚠️ 诚实边界(复核意见 3):渲染文本自带层级/角色前缀(`- [contextual] `、 // `[user] `),故「正文整条皆凭据」时 desensitize 的残渣仍非空 ⇒ 该块**照常 // 注入**,内容是前缀 + `[已过滤:密钥]`。真正的保证是**凭据原文不出现在上下文** // (一定被替换成占位),而不是「不注入」;`if (recallBody)` 只是兜底, // 覆盖渲染文本本身即纯凭据(无前缀)的形态。 const recallBody = desensitize(text.slice(0, RECALL_MAX_CHARS)) if (recallBody) { assembly.contexts.push({ name: 'lingshu:auto-recall', text: escapePromptBraces(renderUntrustedMemoryBlock( `【灵枢最近记忆】\n${recallBody}`)), }) } } if (identityOn) { try { // issue #715:用**独立取数**的 payload(不带会话过滤),不再复用已滤会话的那份。 const idText = renderIdentityAnchors(identityPayload) if (idText) { assembly.contexts.push({ name: 'lingshu:identity-anchor', text: escapePromptBraces(renderUntrustedMemoryBlock( '【灵枢身份锚点】(self/anchor 层,独立预算)\n' + idText)), }) } else { // issue #715(静默失效):此前「开关开了但块从不出现」与「库里确实没有 // self/anchor 节点」**完全不可分辨**——catch 不触发(没抛错),也没有任何 // 提示。开着的功能不生效却无声,比报错更难查。故此处显式留痕。 ctx.logger.info( 'dsh-memory: autoRecallIdentity 已开启但无可注入的身份节点' + '(self/anchor 层为空——请用 cg(op=identity, action=anchor) 建立锚点)') } } catch (err) { // issue #128 同款纪律:fail-soft 但不静默——空 catch 会被 // test/session-declaration-report ③ 判红,也会让「身份块从未注入成功」 // 与「确实没有身份节点」不可分辨。失败不越过会话流边界(不抛)。 ctx.logger.warn(`dsh-memory: 身份锚点注入失败(已跳过该块): ${redactForLog(err)}`) } } // ── 独立第二块:「【本会话近期对话】」(contextWindow 注入面)── // 与上方 timeline 块**块名/取数/开关各自独立**(既有块一字未动)。 // 稳定性口径与既有块同等:**每步都 push**(内容随新轮增长属预期—— // 宿主对内容变化追加快照的既有行为不变;窗口不变时两块逐字节相同)。 // fail-soft:取数/渲染失败 → 静默、不 push 第二块,绝不抛——且因 // 上方既有块已先 push,本块的失败**不影响**既有块(反之亦然)。 // 门控 = 注入面总开关 autoRecall(本 handler 的注册条件)× 本机制开关 // contextWindow.enabled。 if (cwEnabled) { try { const win = await graph.sessionRecall(sid, cwTurns, WINDOW_BUDGET_TOKENS) // issue #712:传 sid 用于**标注**(本会话/其他会话),不用于过滤。 const winText = formatSessionWindow(win, cwTurns, { session: sid }) if (winText) { // 注入边界同规(文件头硬约束):不可信内容边界 + `{{` 转义, // 都只改注入副本——窗口原文在库内保真。 // issue #354 [P1-14] 同族:窗口块也是注入面,同样过脱敏(理由与 // 诚实边界见上方 auto-recall 块)——渲染文本自带 `[role] ` 前缀, // 故「整条皆凭据」时该块仍会注入,但凭据原文一定被替换成占位。 const winSafe = desensitize(winText) if (winSafe) { assembly.contexts.push({ name: 'lingshu:session-window', text: escapePromptBraces(renderUntrustedMemoryBlock( `【本会话近期对话】\n${winSafe}`)), }) } } } catch (err) { // issue #128(2026-10-12):**空 catch 改为显式回报**。此前这里是 // 一个静默空 catch——窗口取数失败(含桥未实现该方法、 // session_recall 报错)在盘面与日志面**双双无痕**,与「本会话确实 // 没有近期对话」不可分辨(自动召回块「从未注入成功」正是靠这个 // 静默吞掉的)。失败仍不得越过会话流边界(不抛),但必须留痕。 ctx.logger.warn(`dsh-memory: 本会话窗口注入失败(已跳过该块): ${redactForLog(err)}`) } } } } catch (err) { // issue #128:同上——召回取数/渲染失败此前被空 catch 吞成「无痕」, // 现改为显式回报(不抛,仍不阻塞模型请求)。 ctx.logger.warn(`dsh-memory: 自动召回注入失败(已跳过该块): ${redactForLog(err)}`) } return next() }) } ctx.on('session/event', (session, event: SessionEvent) => { // H1(2026-09-30)**会话级**判据:子代理/委派子会话的自动记忆**整条会话**拦掉。 // 位置在取 sid **之前**——子代理会话不得污染会话状态(lib/session_state.ts // 单点),否则顶层会话的自动召回会拿子代理的 session 去读(读错会话)。 // 字段缺失即不拦,见 isSubagentSession。 if (isSubagentSession(session)) { ctx.logger.info('dsh-memory: 子代理会话的自动记忆被拦(H1:header.origin/delegationDepth)') audit.filtered('subagent') return } // 会话归属(P45):记忆写入必须带会话身份,用来区分不同会话的记忆。 // 空串 = 宿主未给出会话标识 → **显式标注 unassigned**(H2③:不落内核的进程级 // 随机 sess_*,也不编造宿主会话 id——见 lib/session_state.ts 的常量注释)。 const sid = sessionIdOf(session) // 观测即记录(B 治本批):注入单点在 lib/session_state.ts——工具面 // (src/tools.ts 的写归因转发)读同一个状态;位置不动(H1 子代理闸之后)。 if (sid) { noteSession(sid) observedSession = true } const sessionTag = sid ? { session: sid } : { session: UNASSIGNED_SESSION } // 逢十轮提醒的**计轮点**(#645/#658):真实用户回合 = 用户消息条数。 // 位置在 opts.userMessage 之外——那个开关是「消息不自动进知识面」的正当决策, // 与「提醒还该不该跑」是两件事;旧实现把计轮塞在它里面,关知识面会连带静默杀死提醒。 if (event.type === 'user/message' && event.data.source?.kind === 'user' && !isRelayedMessage(event.data.source) && sid && reminderInterval > 0) { const n = (userTurns.get(sid) ?? 0) + 1 userTurns.set(sid, n) if (shouldRemindTurn(n, reminderInterval)) { // issue #659 F26-24:**不覆盖未消费**的提醒。旧实现直接 set:n=20 覆盖 n=10, // 若 n=10 那条还没被某个 assemble 取走,就永久丢失(用户只看到「20 轮」一次)。 // 现在保留最早一条(它承载的「该归档了」语义更强),并在已有待推时记一条日志。 const existed = pendingReminder.get(sid) if (existed) { ctx.logger.info('dsh-memory: 逢十轮提醒已有待推条目(保留最早一条,丢弃本轮重复)') } else { pendingReminder.set(sid, escapePromptBraces(formatTurnReminder(n))) } } } if (event.type === 'user/message' && opts.userMessage) { // 落盘审计(issue #56):source.kind 分布——先于两级判据记录**完整**输入分布 // (含被滤与放行),「宿主到底给这条消息标了什么 kind」是排障第一问。 audit.observeKind(kindOf(event.data.source)) // issue #695 诊断面:记录 source 的**键名集合**(不含任何值)。 // kind 只说「以什么形式提交」,键名集合才能回答「source 带哪些字段」—— // 外部桥接经 sessions.prompt() 注入的控制提示同样走 kind="user", // 靠这一维才有可核验的盘面读数(绝不记内容)。 audit.observeSourceShape(event.data.source) // H1 **消息级**判据:委派/中继消息(`form: 'relay'` 语义=「另一个 agent // 发给本 agent 的消息」)不写。先于 kind 判据,理由见 isRelayedMessage 注释。 if (isRelayedMessage(event.data.source)) { ctx.logger.info('dsh-memory: 委派/中继消息的自动记忆被拦(H1:source.form=relay)') audit.filtered('relay', kindOf(event.data.source)) return } // 只记真实用户输入(kind='user'),跳过插件注入/系统上下文 if (event.data.source?.kind !== 'user') { // T4 诊断(2026-08-30):dsh 端对话零写入排查——记录被滤事件的实际 // source.kind(若 dsh 新版改了 kind 值,此处日志可定位) ctx.logger.info(`dsh-memory: user/message 事件被滤(source.kind=${event.data.source?.kind ?? 'undefined'})`) audit.filtered('kind', kindOf(event.data.source)) return } const text = extractText(event.data.content) if (!text) return const safe = sanitize(text) // 脱敏:纯凭据消息 → null → 跳过写入 if (safe === null) { audit.skipped('sanitized', 'user'); return } memorize('user', 'user', (g) => g.remember(safe, { condition_space: condSpace('user'), role: 'user', tags: ['dsh', 'user'], importance: opts.importance, ...sessionTag, })) // T4:用用户消息做一次语义召回——md_cg 的读取会记 access log(复用观测, // 供 importance / scrub 陈旧度使用),同时预热检索路径。 // (AEIS 侧的 `_note_reuse` 在 md_cg 中不存在,其等价物就是这次记访问。) // // H2③(2026-09-30):**显式带会话**。此前不传 session,靠服务端「cg 读路径 // 丢弃请求 session」侥幸不串台;一旦读侧归一化在召回链路上生效,不传就等价于 // 跨会话(全库)召回。此处走 `read(query, {k, ...sessionTag})`——与 // `recall(query, k)` 是同一条 MCP 出口(`cg(op=read)`,见 mdcg_client.ts 的 // recall → read),差别只在能带上会话槽;`recall` 没有 extra 形参,给它加槽要 // 改 mdcg_client.ts(本件放行面之外),故在调用点改走等价出口。 // 带上它不构成越权:cg 读路径的 session 是**归因/视图**维度,不参与任何授权 // (issue #35 定稿「身份不可自报」,见 md_cg/mdcos.py 的 _candidates)。 memorize('user-recall', null, (g) => g.read(safe.slice(0, 200), { k: 3, ...sessionTag })) } else if (event.type === 'assistant/message' && opts.assistantMessage) { const text = extractText(event.data.message.content) if (!text) return const safe = sanitize(text) if (safe === null) { audit.skipped('sanitized', 'assistant'); return } memorize('assistant', 'assistant', (g) => g.remember(safe, { condition_space: condSpace('assistant'), role: 'assistant', tags: ['dsh', 'assistant'], importance: opts.importance * 0.8, ...sessionTag, })) } else if (event.type === 'tool/result' && opts.toolResult) { if (event.data.error) return const text = extractText(event.data.message.content) if (!text) return const safe = sanitize(text) if (safe === null) { audit.skipped('sanitized', 'tool'); return } memorize('tool', 'tool', (g) => g.remember(safe, { condition_space: condSpace('tool'), role: 'tool-output', tags: ['dsh', 'tool'], importance: opts.importance * 0.6, ...sessionTag, })) } // ── 短期窗口写侧(contextWindow):与上方知识面写入**独立成轨** ── // 位置在既有分支链**之外**:不受 opts.userMessage / opts.assistantMessage // 门控(那两个开关管知识面;本机制由 contextWindow.enabled 管——两轨关系 // 见文件头与 windowEntry 注释)。 // 过滤面与知识面同源:H1 会话级(子代理整条会话)已在函数首拦回;此处经 // windowEntry 复用同一组谓词(relay / kind),再经**同一个** sanitize 脱敏 // ——纯凭据消息(sanitize 返回 null)同样不写(不把明文凭据引进窗口)。 // 失败只记 warn(noteRecent),绝不冒泡进会话流。 if (cwEnabled) { const entry = windowEntry(event) if (entry) { const safe = sanitize(entry.text) if (safe !== null) noteRecent(entry.role, safe, { ...sessionTag }) } } }) let reported = false return () => { if (reported) return reported = true try { const summary = audit.summary() if (summary) ctx.logger.info(summary) } catch { diagnosticFailuresSilently += 1 /* 诊断输出失败不得阻断插件收尾 */ } } } /** 逢十轮提醒阈值(工作纪律第 16 条「长会话节奏」)。 * * 动机:真源第 16 条要求「每完成 10 轮交互做一次间歇归档(写入 + 读回确认), * 不等收尾」。zcode 端由 `scripts/zcode_window_hook.py` 注入提醒;**DSH 端此前 * 完全没有实现**——纪律在产物里、机制不在,代理只能靠自觉(实测:整场长会话 * 一次归档都没做)。本函数是该缺口的判定单点。 */ export function shouldRemindTurn(n: number, interval = 10): boolean { if (!Number.isFinite(n) || !Number.isFinite(interval)) return false const step = Math.floor(interval) if (step <= 0) return false return n >= step && n % step === 0 } /** 提醒文本(不含注入边界转义——转义在 push 站点做,与其它块同规)。 */ export function formatTurnReminder(n: number): string { return [ "【灵枢·逢十轮提醒】本会话已完成 " + String(n) + " 轮交互。", "按工作纪律第 16 条做一次**间歇归档**:CCG 六要素(功能名/生效条件/子功能/执行/验证方式/不适用条件)", "写入记忆(cg op=write,content_kind 用 text),**写入后发起一次读回查询确认可检索**,", "并在回复里给出节点 id 作为证据;没有 id 即视为未归档。", ].join(" ") }