/** * mdcg_client.ts —— 认知图显式客户端(LIB 本地库) * * 架构定位(2026-09-10 决定) * -------------------------- * · md_cg = 记忆操作系统 / **唯一真源**(记忆的写、读、裁决、留痕)。 * · AEIS = **能力库**(白箱引擎、角色扮演生成等),不再是记忆真源。 * * 本模块是插件侧调用 md_cg 的**唯一显式入口**:只暴露 `cg` / `stg` 两个 * 认知基元,每个方法 = 一条 MCP 调用,不做隐式魔法。 * * 显式调用映射(详见 docs/功能调用映射表_v0.1.md) * ------------------------------------------------ * 路由/召回 → MdcgClient.route() → MCP cg(op=route) * 读取节点 → MdcgClient.read() → MCP cg(op=read) * 记忆沉淀 → MdcgClient.remember() → MCP mdcg_remember(gated=true) * 语义召回 → MdcgClient.recall() → MCP cg(op=read, query) * 写入记忆 → MdcgClient.write() → MCP mdcg_remember(gated=false) * 外部裁决回填 → MdcgClient.verify() → MCP cg(op=verify) * 最近记忆时间线 → MdcgClient.timeline() → MCP stg(op=timeline) * 近期事件窗口 → MdcgClient.recent() → MCP cg(op=recent) * 窗口追加事件 → MdcgClient.recentAdd() → MCP cg(op=recent, action=add) * 会话续接包 → MdcgClient.sessionRecall() → MCP cg(op=session, action=recall) * 身份读取 → MdcgClient.identity() → MCP cg(op=identity) * 白箱能力验证 → MdcgClient.whitebox() → MCP cg(op=whitebox) * 服务信息 → MdcgClient.serviceInfo()→ MCP cg(op=info) * 互维主张核验 → MdcgClient.verifyClaim()→ cg(op=read) + 依据强度判定 * 角色/转录落图 → MdcgClient.writeRole() / writeTranscript() → mdcg_remember * * 写入为什么不能走 cg(op=write)(关键约束,勿回退) * ------------------------------------------------ * `cg(op=write)` 是**带审核**的写路径:先过 `audit.audit(content_kind)`, * 未声明 content_kind 且未配置 `MDCG_POLICY_FILE` 时恒判 BLINDSPOT/DEFER —— * 只进审核队列、**永不落盘**(自动记忆会静默全失败,实测 0 篇 md 文档)。 * 而 `mdcg_remember`(`md_cg/mcp_server.py:1691`)直接调 `MdCG.remember_gated` * / `MdCG.add`,跳过审核,是 mcp_server 顶部文档定义的「写」入口。故本客户端 * 的**所有写入**(记忆沉淀 / 角色定义 / 对话转录)统一经 `mdcg_remember`。 */ import { delimiter } from 'node:path' import { LingshuBridge, type McpCallResult } from '../bridge.js' import { pythonPathValue, runRoot } from './datapath.js' // 租户单点(#224):签发侧(token_store)与运行侧(本文件的 mdcgChildEnv)必须 // 用同一函数算出租户——N62 之后服务端对「令牌记录租户 ≠ 入参租户」fail-closed, // 两侧各算一遍就必然分叉(签发按 default、运行按 work ⇒ rc=3 拒启)。 import { effectiveTenant } from './token_store.js' /** md_cg 子进程与根目录配置。 */ export interface MdcgOptions { /** Python 可执行文件,默认取 config.python。 */ python: string /** 启动参数,默认 ['-m', 'md_cg.mcp_server']。 */ args?: string[] /** 认知图根目录(MDCG_ROOT)。 */ root: string /** Python 子进程工作目录,默认 `runRoot()`——**插件包目录之外**的稳定目录 * (issue #18:Windows 不允许删除/改名「正被某进程当作 cwd」的目录, * cwd 落在包内会让 pnpm 更新本包必然 `ERR_PNPM_EBUSY` 且永不自愈)。 * 模块解析**不依赖 cwd**:`python -m` 靠 `PYTHONPATH` * (见 `pythonPathValue()`)解析随包 md_cg,issue #12 口径不变。 * 显式传入本项时完全尊重原值。 */ cwd?: string /** 调用主体标识(MDCG_ACTOR)。私有内容按 (tenant, actor) 派生 DEK, * 故与迁移脚本 --actor 必须一致,否则读不到已迁移节点。 */ actor?: string /** 租户(MDCG_TENANT),默认 default。 */ tenant?: string /** 调用方密级(MDCG_CLEARANCE):只能读写 ≤ 该密级的节点,默认 private。 */ clearance?: string /** 身份主体(MDCG_IDENTITY)。 */ identity?: string /** 额外环境变量。 */ env?: Record /** MCP 工具面(MDCG_MCP_SURFACE):'kernel' = 仅 cg/stg 两基元; * 'full' = 另含 `mdcg_*` 细粒度工具。 * * ⚠️ 本客户端**必须**用 full:唯一写入通道是 `mdcg_remember`(细粒度), * kernel 面下该工具不存在 → 写入静默失败(角色/转录/自动记忆全部不落盘)。 * 默认 'full',勿改。 */ surface?: 'kernel' | 'full' timeoutMs?: number maxRetryDelayMs?: number } const DEFAULT_ARGS = ['-m', 'md_cg.mcp_server'] /** 子进程环境构造参数(`MdcgOptions` 中与环境相关的那半)。 */ export interface MdcgChildEnvOptions { root: string surface?: 'kernel' | 'full' tenant?: string clearance?: string actor?: string identity?: string env?: Record } /** * MCP 子进程环境:**唯一构造点**(导出即为了让机械守卫能断言它—— * 见 `test/python-utf8-mode.test.ts`)。勿在别处另拼 env。 * * ⚠️ 两条编码注入是**硬约束**,不是可选项: * * ① `PYTHONIOENCODING=utf-8`(子进程**自身** stdio):Windows 下 piped 子进程默认 * gbk + surrogateescape,Node 写出的 UTF-8 中文会被解成孤立代理字符(\udcXX), * md_cg 在落盘 / 回写 stdout 时抛 UnicodeEncodeError——中文记忆(主场景)全失败。 * * ② `PYTHONUTF8=1`(子进程**后代**的默认 text 编码,PEP 540):`PYTHONIOENCODING` * 会被后代继承(后代于是往管道写 UTF-8),但**文本解码口径不被继承**——后代读 * `subprocess.run(..., text=True)` 时取的是 locale(本机 cp936),于是 * 「子进程写 UTF-8、父进程按 gbk 读」→ 读线程崩死、诊断静默丢失。 * 2026-09-20 实证现场(`npm test` 周期复现,来源为子进程的后代代码单元): * Exception in thread Thread-N (_readerthread): * UnicodeDecodeError: 'gbk' codec can't decode byte 0x82 in position 181 * UTF-8 模式把默认 text 编码改为 UTF-8,读写两侧同口径(且解码结果正确, * 而非 `errors="replace"` 那种替换字符)。 * * 确定性对照实验(P1 复现 / P3 消除)见 `test/python-utf8-mode.test.ts`。 * `opts.env` 最后展开——显式覆盖优先。**唯一例外是 `MDCG_TENANT`**(#224): * 它由 `effectiveTenant()` 单点决定(`opts.env.MDCG_TENANT` > `opts.tenant` > * default),放在 `opts.env` 之后写入——否则 `opts.env` 里的空串/其它值会把 * 「签发侧算出的租户」再覆盖一次,两侧分叉。其余键的覆盖顺序不变。 */ export function mdcgChildEnv(opts: MdcgChildEnvOptions): Record { return { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', MDCG_ROOT: opts.root, // 工具面必须是 full:写入通道 mdcg_remember 属细粒度工具(见 MdcgOptions.surface)。 MDCG_MCP_SURFACE: opts.surface ?? 'full', MDCG_CLEARANCE: opts.clearance ?? 'private', PYTHONPATH: pythonPathValue(), ...(opts.actor ? { MDCG_ACTOR: opts.actor } : {}), ...(opts.identity ? { MDCG_IDENTITY: opts.identity } : {}), ...(opts.env ?? {}), // 租户单点(见 effectiveTenant):与 token_store 签发/复核同一口径。 MDCG_TENANT: effectiveTenant(opts.tenant, opts.env), } } /** 认知图依据强度:这些 basis 视为「强依据」,可支撑 pass。 * 取值须在 md_cg.mdcg.VERIFICATION_BASIS 允许集内: * compiler | test | measurement | formal_proof | data | other(other 不算强依据)。 */ const STRONG_BASIS = new Set(['compiler', 'test', 'measurement', 'formal_proof', 'data']) /** * 请求级**单元身份**(MCP 参数 `as_unit`)。 * * 与 `md_cg/tokens.py` 的 `POSITION_ROLES` 同源(record/reflect/verify/ * output/sustain);大脑侧 `narrowed_principal` 对未知值 fail-closed 报错。 * * 语义(关键):`as_unit` 只能**收窄**权限、不能放大——大脑侧以「与 owner 求交 + * 管理权恒 False」保证,故填错的最坏结果等于不填(owner 全权),**不可能提权**。 * * 本客户端**只在写入 / 裁决路径注入**,且只注入与落层确定匹配的单元: * · 记忆沉淀 / 转录 / 角色定义 → `record`(落 contextual / knowledge,在 record * 的 layers_allow 内); * · 外部裁决回填 → `verify`(cg op=verify)。 * **只读调用一律不注入**:record / output 的 `clearance_cap=internal`,注入会把 * owner 的 private 读能力一并压低(读不到私有记忆=功能退化),而读无副作用, * 收窄无收益。`write()` 同样不注入——它被用于写 self / structural 层(角色锚点 * 需 designer 权限),注入 `record` 会直接打断角色落图。 */ export type LingshuUnit = 'record' | 'reflect' | 'verify' | 'output' | 'sustain' function collectItems(payload: unknown): Array> { if (Array.isArray(payload)) return payload as Array> if (payload && typeof payload === 'object') { const obj = payload as Record for (const key of ['results', 'items', 'nodes', 'hits', 'data', 'records']) { if (Array.isArray(obj[key])) return obj[key] as Array> } } return [] } /** * 认知图显式客户端。 * * 生命周期与桥一致:懒启动 + 自动重连;未就绪时调用会快速失败。 * 未就绪的处置:互维 verify 走 fail-closed(见 index.ts 的注入), * 自动记忆静默跳过——**不再有"回退 aeis 能力库"路径**(该库已剥离)。 */ export class MdcgClient { readonly bridge: LingshuBridge constructor(opts: MdcgOptions) { // 子进程 cwd 必须落在插件包目录之外,否则 pnpm 更新本插件时 // rmdir 包目录会撞上 Windows 的「目录被当作 CWD」共享冲突 → ERR_PNPM_EBUSY。 // 模块解析不依赖 cwd:PYTHONPATH(pythonPathValue())已锚定随包 md_cg; // opts.env 显式提供 PYTHONPATH 时完全接管(其展开在最后)。详见 datapath.runRoot()。 const cwd = opts.cwd ?? runRoot() // 编码与模块解析口径见 mdcgChildEnv() 头注(含 2026-09-20 读线程崩溃现场)。 const env = mdcgChildEnv(opts) this.bridge = new LingshuBridge({ python: opts.python, args: opts.args ?? DEFAULT_ARGS, env, cwd, timeoutMs: opts.timeoutMs ?? 60_000, maxRetryDelayMs: opts.maxRetryDelayMs ?? 30_000, }) } start(): void { this.bridge.start() } async waitReady(): Promise { return this.bridge.waitReady() } /** 桥是否已握手就绪。 * * ⚠️ 不可缓存 waitReady() 的结果:waitReady() 在**首次心跳失败**时会立即 * resolve(false)(见 bridge.ts 的 failed 分支),但桥仍会后台重连成功—— * 若把这次 false 缓存下来,isReady() 将永久为假,自动记忆 / 互维核验会在 * 重连成功后静默失效。故一律以桥的实时状态为准。 */ isReady(): boolean { return this.bridge.isReady() } dispose(): void { this.bridge.dispose() } /** 原始 cg 调用(逃生口;显式传参,不做推断)。 * `asUnit` = 本次调用的请求级单元身份(见 LingshuUnit);只收窄、不提权。 */ async cg(args: Record, asUnit?: LingshuUnit): Promise { return this.call('cg', args, asUnit) } /** 原始 stg 调用(时间线/关系/锚点/一致性)。 */ async stg(args: Record, asUnit?: LingshuUnit): Promise { return this.call('stg', args, asUnit) } private async call(tool: string, args: Record, asUnit?: LingshuUnit): Promise { // as_unit 由大脑侧 mcp_server.call_tool 在同一进程内消费并收窄 principal, // 不从 args 向下透传,故不会污染各 tool handler 的参数解析。 const payload = asUnit ? { ...args, as_unit: asUnit } : args const result: McpCallResult = await this.bridge.callTool(tool, payload) const text = (result.content ?? []) .filter((c) => c.type === 'text') .map((c) => c.text ?? '') .join('') if (result.isError) throw new Error(text || `md_cg ${tool} 调用失败`) try { return JSON.parse(text) } catch { return text } } // -- 显式认知能力(每个方法一条 MCP 调用) -------------------------------- /** 路由:意图 → 相关知识 + 建议能力。 */ route(intent: string, extra: Record = {}): Promise { return this.cg({ op: 'route', intent, ...extra }) } /** 读取:按语义召回节点。 */ read(query: string, extra: Record = {}): Promise { return this.cg({ op: 'read', query, ...extra }) } /** 按 id 精确读取单个节点。 */ get(nodeId: string): Promise { return this.cg({ op: 'read', node_id: nodeId }) } /** 认知图写入入口(MCP `mdcg_remember`)。 * * ⚠️ 唯一写入通道:**不可**改用 `cg(op=write)`(那条路径会被 audit 拦住, * 见文件头「写入为什么不能走 cg(op=write)」)。gated=false 直写 `MdCG.add` * (默认 layer=knowledge),gated=true 走主动遗忘闸门。 */ private writeNode(args: Record, asUnit?: LingshuUnit): Promise { return this.call('mdcg_remember', args, asUnit) } /** 写入:新增/覆盖一个记忆节点(直写 `MdCG.add`,不经审核队列)。 */ write(content: string, extra: Record = {}): Promise { return this.writeNode({ content, ...extra }) } /** 记忆沉淀(AEIS `remember` 的对应物):写入情景层并过**主动遗忘闸门**。 * * `mdcg_remember(gated=true)` → `MdCG.remember_gated`:三问 → 四态, * ACCEPT 落盘 / MERGE 并入既有(= 去重强化,不新增节点)/ DROP 丢弃低熵 * 噪音 / DEFER 待定;四种结果都写 `_forgetting.jsonl`,可审计。 * `importance` 作为 importance_hint 传入(≥0.7 时保护优先、直接 ACCEPT)。 * * layer 默认 contextual:md_cg 对 DSH 会话事件约定的落层就是 contextual, * role 取 user | assistant | tool-output(见 md_cg/sources.py 的 * SESSION_LAYER / DSHSessionSource 事件映射)。 */ remember(content: string, extra: Record = {}): Promise { // 落层 contextual 固定在 record 白名单内 → 声明 record 单元(不得写 self/anchor)。 return this.writeNode({ content, gated: true, layer: 'contextual', ...extra }, 'record') } /** 语义召回(AEIS `recall` 的对应物):`cg(op=read, query)` → md_cg 检索。 * 读取会记 access log(复用观测),供 importance / scrub 陈旧度使用。 */ recall(query: string, k = 5): Promise { return this.read(query, { k }) } /** 外部裁决回填:为已有节点写 confirmed/weakened/falsified。 */ verify(nodeId: string, evidence: string, verdict: string): Promise { // 裁决回填声明 verify 单元:只写 rejected/contextual,不得改被验证内容。 return this.cg({ op: 'verify', node_id: nodeId, evidence, verdict }, 'verify') } /** 最近记忆。 */ recent(limit = 20): Promise { return this.cg({ op: 'recent', limit }) } /** 追加一条**近期事件窗口**记录(`cg(op=recent, action=add)` → * `md_cg` 的 `remember_event(role, text, tags, meta, window)`)。 * * 这是「滑动窗口」写侧:事件落 `_recent.jsonl`(**运行态面**),按窗口滚动 * 淘汰(服务端缺省 200 条,`mdcg.py:91 DEFAULT_RECENT_WINDOW`)——它**不是 * 知识节点**:不占 knowledge 层、不进检索正排,与 `remember()` 的知识面沉淀 * 是两条独立的轨道(见 src/hooks.ts 文件头的 contextWindow 头注)。 * * ⚠️ **不注入 as_unit**(与 `write()` 同款理由的反面):本调用不写任何层的 * 节点(服务端 `remember_event` 不做层白名单校验),收窄单元无收益且可能压低 * 事件密级(缺省 internal);`meta.session` 由调用方显式给出(沿本文件 * `remember()` 的 sessionTag 口径)。 * * ⚠️ 服务端对 `meta` 做 `setdefault`(tenant/session/harness/unit,见 * `mdcos.py:5057 remember_event`):调用方已写的键**不被覆盖**。 */ recentAdd(role: string, text: string, meta: Record = {}, tags: string[] = []): Promise { return this.cg({ op: 'recent', action: 'add', role, text, meta, tags }) } /** **会话续接包**(`cg(op=session, action=recall)` → `md_cg` 的 * `session_recall`):一次取回 notes / goals / tasks / **recent 事件窗口** / * unresolved / self_state,按 `budget_tokens` 整包裁剪(服务端 * `mdcos.py:3351-3362`:交替丢 recent / notes 尾部)。 * * 本插件消费其中的 `recent` 段——滑动窗口的**注入面**(见 src/hooks.ts)。 * `recent_limit` 语义 = 近期事件**条数**(非「轮数」);服务端读取时会按 * `max(1, recent_limit or 10)` 归一(`mdcos.py:3309`)。 * * ⚠️ 两条服务端事实(决定注入侧的可达性,勿据本方法名臆测): * ① `recent` 段取 `recent_events(limit=recent_limit)` —— **不按 session * 过滤**(近期事件是运行态滚动窗口,会话归属只写在每条事件的 meta 里); * ② budget 是**整包**预算:库内 notes/tasks/self_state 占位越多,同样 * budget 下 recent 段被裁得越短(实测:空库 + 10 条窗口条目, * budget_tokens=600 → recent 8 条;1200 → 10 条)。 * * 只读调用,不注入 as_unit(同其它读路径:读无副作用,收窄只会压低 owner * 的 private 读能力)。 */ sessionRecall(session: string, recentLimit: number, budgetTokens: number): Promise { return this.cg({ op: 'session', action: 'recall', session, recent_limit: recentLimit, budget_tokens: budgetTokens, }) } /** 最近记忆**时间线**(AEIS `timeline` 的对应物):`stg(op=timeline)` → * `{count, limit, items:[{id, layer, start, end, preview}]}`,按时间倒序。 * * 只收录带时间区间的节点;而 `cg.add()` 在调用方未给 time_window 时会以 * **写入时刻**自动填充(见 mdcg.py 的 OBSERVATION_WINDOW_SEC 分支),故经 * 本客户端写入的记忆都能进入时间线。 * 要原始近期**事件**(未结构化对话窗口)请用 `recent()`。 */ timeline(limit = 4, extra: Record = {}): Promise { return this.stg({ op: 'timeline', limit, desc: true, ...extra }) } /** 身份维度读取。 */ identity(subjectId?: string, extra: Record = {}): Promise { return this.cg({ op: 'identity', ...(subjectId ? { subject_id: subjectId } : {}), ...extra }) } /** 白箱能力库:显式调用 + 能力验证。 */ whitebox(action: string, extra: Record = {}): Promise { return this.cg({ op: 'whitebox', action, ...extra }) } /** 服务信息(信任透明度)。 */ serviceInfo(): Promise { return this.cg({ op: 'info' }) } // -- 语义化封装(互维 / 角色扮演显式使用认知图) -------------------------- /** * 互维主张核验(memory 通道):用认知图替代 AEIS `wisdom_verify`。 * 返回结构与 VerifyResult['whitebox'] 对齐,便于直接注入 mutual。 */ async verifyClaim(claim: string): Promise<{ judgment: string best: string d_norm: number record_id: string }> { const payload = await this.read(claim, { k: 4 }) const scored = collectItems(payload) .map((it) => { const node = (it['node'] as Record) ?? it const fm = (node['frontmatter'] as Record) ?? node const score = typeof it['score'] === 'number' ? it['score'] as number : Number(it['score'] ?? 0) return { id: String(node['id'] ?? it['id'] ?? ''), score: Number.isFinite(score) ? score : 0, basis: String(fm['verification_basis'] ?? node['verification_basis'] ?? ''), content: String(node['content'] ?? ''), } }) .filter((x) => x.id) .sort((a, b) => b.score - a.score) const best = scored[0] if (best && best.score > 0 && STRONG_BASIS.has(best.basis)) { return { judgment: `采纳:认知图已有强依据节点 ${best.id}(basis=${best.basis})`, best: best.content.slice(0, 200), d_norm: Math.min(1, best.score), record_id: best.id, } } if (best && best.score > 0) { return { judgment: `待定:命中节点 ${best.id} 但依据不足(basis=${best.basis || 'unknown'})`, best: best.content.slice(0, 200), d_norm: -1, record_id: best.id, } } return { judgment: '未命中:认知图中无相关节点', best: '', d_norm: -1, record_id: '' } } /** 角色定义落图:layer=knowledge,tags=['roleplay','role:']。 */ writeRole(roleId: string, content: string, extra: Record = {}): Promise { // 落层固定 knowledge(在 record 白名单内)→ 声明 record 单元。 // ⚠️ 不经 this.write():那是不注入单元的通用直写口(还要写 self 层锚点)。 return this.writeNode({ content, node_id: `roleplay_role_${roleId}`, layer: 'knowledge', tags: ['roleplay', `role:${roleId}`, 'roleplay:role'], importance: 0.7, ...extra, }, 'record') } /** 角色对话转录落图:layer=contextual,tags=['roleplay','role:','session:']。 */ writeTranscript(roleId: string, sessionId: string, who: 'user' | 'assistant', content: string, extra: Record = {}): Promise { const stamp = Date.now() // 落层固定 contextual(在 record 白名单内)→ 声明 record 单元。 return this.writeNode({ content, node_id: `roleplay_turn_${roleId}_${sessionId}_${stamp}`, layer: 'contextual', tags: ['roleplay', `role:${roleId}`, `session:${sessionId}`, `turn:${who}`], importance: 0.4, ...extra, }, 'record') } }