@furongjun1999/dsh-memory 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @furongjun1999/dsh-memory —— 灵枢(AEIS)DeepSeek Harness 插件
3
+ *
4
+ * 把灵枢的时空记忆/知识飞轮/自我认知接入 DSH:
5
+ * - 工具桥接:Agent 可调用 lingshu_remember / recall / search / think 等
6
+ * - 自动记忆:DSH 对话自动沉淀进灵枢记忆库(去重+重要性)
7
+ *
8
+ * 用法(cordis.yml):
9
+ * ```yaml
10
+ * - id: lingshu-memory
11
+ * name: '@furongjun1999/dsh-memory'
12
+ * config:
13
+ * dbPath: 'D:/path/to/lingshu.db'
14
+ * identity: '灵枢'
15
+ * memory:
16
+ * userMessage: true
17
+ * ```
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import z from '@deepseek-ai/schemastery'
22
+ import { appendFileSync, mkdirSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import { homedir } from 'node:os'
25
+ import { LingshuBridge, type McpCallResult } from './bridge.js'
26
+ import { registerLingshuTools, type ToolSelection } from './tools.js'
27
+ import { installMemoryHooks, type MemoryHooksOptions } from './hooks.js'
28
+ import { installRoleplayWeb } from './roleplay_web.js'
29
+
30
+ /**
31
+ * 调试探针:记录 apply 失败到独立文件(绕过 DSH 日志系统)。
32
+ * 路径从用户家目录动态解析(issue #5,与 bridge.ts 同因同修)。
33
+ */
34
+ const APPLY_ERROR_LOG = join(homedir(), '.dsh', 'logs', 'dsh-memory-apply-error.log')
35
+ let applyLogDirReady = false
36
+ function probeApplyError(err: unknown): void {
37
+ try {
38
+ if (!applyLogDirReady) {
39
+ mkdirSync(dirname(APPLY_ERROR_LOG), { recursive: true })
40
+ applyLogDirReady = true
41
+ }
42
+ appendFileSync(APPLY_ERROR_LOG, `[${new Date().toISOString()}] apply failed: ${String(err)}\n${(err as Error).stack ?? ''}\n`)
43
+ } catch { /* 探针失败忽略 */ }
44
+ }
45
+
46
+ export const name = 'dsh-memory'
47
+
48
+ /** P1 修复(GPT 审查):MCP 调用的标准结果在 content[].text,
49
+ * 代码此前误读不存在的 .result 字段导致互维拿不到 judgment/best/复核文本 */
50
+ function mcpText(r: McpCallResult): string {
51
+ return (r.content ?? []).map((c) => c.text ?? '').join('\n')
52
+ }
53
+
54
+ /** 本插件依赖的工具注册服务。
55
+ * P1 修复(GPT 审查):timer/webServer 是可选增强(互维/角色网页),此前强声明
56
+ * 导致最小 host(只有 tools)插件永远 PENDING 不激活。只强依赖 tools;
57
+ * timer/webServer 在 apply 内动态检测(存在则启用,缺失则告警跳过)。 */
58
+ export const inject = ['tools']
59
+
60
+ /** 插件配置。 */
61
+ export interface Config {
62
+ /** 工具命名空间前缀(默认 lingshu → lingshu_remember)。 */
63
+ serverName: string
64
+ /** Python 可执行文件(或 aeis-mcp console script)。 */
65
+ python: string
66
+ /** 传给 python 的参数(默认启动灵枢 MCP server)。 */
67
+ moduleArgs: string[]
68
+ /** 灵枢记忆库 SQLite 路径(目录自动创建)。 */
69
+ dbPath: string
70
+ /** 灵枢身份标识(写入记忆的自我模型)。 */
71
+ identity: string
72
+ /** 额外环境变量(BOCHA_API_KEY / AEIS_DESIGNER_KEY 等,可 !!js 注入)。 */
73
+ env: Record<string, string>
74
+ /** 暴露的工具集合:'core' | 'brain' | 'all' | 工具名数组。 */
75
+ tools: ToolSelection
76
+ /** 护栏宪章版本声明(接入即接受宪章约束,docs/guardrail-charter.md)。 */
77
+ charter: string
78
+ /** 自动记忆开关。 */
79
+ memory: MemoryHooksOptions
80
+ /** 单次工具调用超时(毫秒)。 */
81
+ toolCallTimeoutMs: number
82
+ /** 断线重连最大间隔(毫秒)。 */
83
+ maxRetryDelayMs: number
84
+ /** 启动失败是否让插件激活失败(否则告警后继续重试)。 */
85
+ failOnStartupError: boolean
86
+ /** 互维维护(Mutual Sustain Loop v1.1):心跳写戳 + 守护 A + 任务验证。 */
87
+ mutual: {
88
+ enabled: boolean
89
+ heartbeatMs: number
90
+ }
91
+ }
92
+
93
+ export const Config: z<Config> = z.object({
94
+ serverName: z.string().default('lingshu'),
95
+ python: z.string().default('python'),
96
+ moduleArgs: z.array(String).default(['-m', 'aeis.mcp.server']),
97
+ dbPath: z.string().default('data/lingshu.db'),
98
+ identity: z.string().default('灵枢'),
99
+ env: z.dict(String).default({}),
100
+ tools: z.union([z.const('core'), z.const('brain'), z.const('all'), z.array(String)]).default('brain'),
101
+ /** 护栏宪章版本声明(接入即接受宪章约束,docs/guardrail-charter.md)。 */
102
+ charter: z.string().default('v2.0-published'),
103
+ memory: z
104
+ .object({
105
+ userMessage: z.boolean().default(true),
106
+ assistantMessage: z.boolean().default(false),
107
+ toolResult: z.boolean().default(false),
108
+ importance: z.number().default(0.6),
109
+ autoRecall: z.boolean().default(true),
110
+ autoRecallLimit: z.number().default(4),
111
+ desensitize: z.boolean().default(true),
112
+ })
113
+ .default({ userMessage: true, assistantMessage: false, toolResult: false, importance: 0.6, autoRecall: true, autoRecallLimit: 4, desensitize: true }),
114
+ toolCallTimeoutMs: z.number().default(60_000),
115
+ maxRetryDelayMs: z.number().default(30_000),
116
+ failOnStartupError: z.boolean().default(false),
117
+ /** 互维维护(v1.1):心跳 10min / 任务验证双通道。 */
118
+ mutual: z
119
+ .object({
120
+ enabled: z.boolean().default(false),
121
+ heartbeatMs: z.number().default(10 * 60 * 1000),
122
+ })
123
+ .default({ enabled: false, heartbeatMs: 10 * 60 * 1000 }),
124
+ })
125
+
126
+ /**
127
+ * 插件激活:启动灵枢子进程 → 注册工具 → 安装自动记忆钩子。
128
+ * 卸载时清理全部资源(effect 作用域内自动回收)。
129
+ */
130
+ export async function apply(ctx: Context, config: Config): Promise<void> {
131
+ // 宪章宣告(接入即接受宪章约束——docs/guardrail-charter.md v2.0-published)
132
+ ctx.logger.info(
133
+ `dsh-memory: 灵枢插件激活(大脑模式)· 接受护栏宪章 ${config.charter} —— ` +
134
+ '接入即接受宪章约束(公开/可执行/可审计/设计者终裁)',
135
+ )
136
+ const bridge = new LingshuBridge({
137
+ python: config.python,
138
+ args: config.moduleArgs,
139
+ env: {
140
+ AEIS_DB: config.dbPath,
141
+ AEIS_IDENTITY: config.identity,
142
+ ...config.env,
143
+ },
144
+ timeoutMs: config.toolCallTimeoutMs,
145
+ maxRetryDelayMs: config.maxRetryDelayMs,
146
+ })
147
+ bridge.start()
148
+ const ready = await bridge.waitReady()
149
+ if (!ready) {
150
+ const message = '灵枢进程无法启动(检查 python 是否可用、aeis 是否安装:pip install aeis)'
151
+ if (config.failOnStartupError) {
152
+ // P1 修复(GPT 审查):启动失败抛错前必须 dispose——此前 throw 在 try 之前,
153
+ // 桥接对象泄漏 + 后台重试计时器继续跑
154
+ bridge.dispose()
155
+ throw new Error(message)
156
+ }
157
+ ctx.logger.warn(`dsh-memory: ${message},继续后台重试`)
158
+ }
159
+
160
+ const disposers: Array<() => void> = []
161
+ let toolsPoll: NodeJS.Timeout | null = null
162
+ try {
163
+ // 工具注册:初始就绪立即注册;若启动时未就绪(python 暂不可用/aeis 未装等
164
+ // 竞态),桥重连成功后自动补注册——修复"工具永久缺失"问题。
165
+ let toolsRegistered = false
166
+ const tryRegister = async () => {
167
+ if (toolsRegistered || !bridge.isReady()) return
168
+ try {
169
+ const dispose = await registerLingshuTools(ctx, bridge, {
170
+ selection: config.tools,
171
+ toolPrefix: `${config.serverName}_`,
172
+ })
173
+ disposers.push(dispose)
174
+ toolsRegistered = true
175
+ // P1 修复(GPT 审查):注册成功后清除轮询——此前 setInterval 永久保留
176
+ if (toolsPoll) {
177
+ clearInterval(toolsPoll)
178
+ toolsPoll = null
179
+ }
180
+ ctx.logger.info('dsh-memory: 灵枢工具已注册(就绪后补注册)')
181
+ }
182
+ catch (err) {
183
+ ctx.logger.warn(`dsh-memory: 工具注册失败,稍后重试: ${String(err)}`)
184
+ }
185
+ }
186
+ if (ready) await tryRegister()
187
+ if (!toolsRegistered) {
188
+ toolsPoll = setInterval(() => { void tryRegister() }, 2000)
189
+ disposers.push(() => { if (toolsPoll) clearInterval(toolsPoll) })
190
+ }
191
+ installMemoryHooks(ctx, bridge, config.memory)
192
+ // 角色扮演网页(同源挂载 /roleplay,复用本插件 bridge)
193
+ await installRoleplayWeb(ctx, bridge, config, disposers)
194
+
195
+ // 白箱 LLM 服务商(v0.4 新能力):把灵枢注册为 DSH 的 provider,
196
+ // Web/QQ/飞书等所有会话可选「白箱灵枢」模型——白箱直答(零 LLM),
197
+ // 输入/输出/缓存命中 token 计数对齐 dsh-llm 协议。动态检测 llm 服务。
198
+ {
199
+ const { installWhiteboxLlm } = await import('./llm_adapter.js')
200
+ disposers.push(installWhiteboxLlm(ctx, bridge))
201
+ }
202
+
203
+ // 互维维护(v1.1):心跳写戳 + 守护 A + 任务验证双通道
204
+ if (config.mutual.enabled) {
205
+ // P1 修复(GPT 审查):timer 是可选服务——缺失时告警跳过互维,
206
+ // 不让插件因互维而阻塞(inject 已不再强声明 timer)。
207
+ // 注意:不能直接读 ctx.timer——Cordis 未声明 inject 的属性访问会抛
208
+ // "cannot get property without inject"(getter 严格);ctx.get() 安全。
209
+ const hasTimer = ctx.get('timer') !== undefined
210
+ if (!hasTimer) {
211
+ ctx.logger.warn('dsh-memory: timer 服务不可用,跳过互维维护(mutual.enabled=true 但无 timer)')
212
+ } else {
213
+ const { installMutualMaintenance } = await import('./mutual.js')
214
+ // 双通道 hooks:白箱 base_verify(走 bridge 调灵枢)+ DeepSeek 复核
215
+ installMutualMaintenance(
216
+ ctx as never,
217
+ { heartbeatMs: config.mutual.heartbeatMs },
218
+ {
219
+ verify: async (claim: string) => {
220
+ const r = await bridge.callTool('wisdom_verify', { knowledge: claim, limit: 4 })
221
+ // P1 修复(GPT 审查):结果在 content[].text(JSON),非 .result
222
+ const text = mcpText(r)
223
+ let data: { judgment?: string; best?: { name?: string }; D_norm?: number; record_id?: string } = {}
224
+ try {
225
+ data = JSON.parse(text) as typeof data
226
+ } catch { /* 非 JSON 时用默认 */ }
227
+ return {
228
+ judgment: data.judgment ?? '分析中',
229
+ best: data.best?.name ?? '',
230
+ d_norm: typeof data.D_norm === 'number' ? data.D_norm : -1,
231
+ record_id: data.record_id ?? '',
232
+ }
233
+ },
234
+ review: async (claim: string, w) => {
235
+ // DeepSeek 复核(在白箱判定之上,不重复白箱工作)
236
+ const reviewResult = await bridge.callTool('think', {
237
+ query: `复核以下主张(白箱判定已给出,请独立评估是否同意):${claim.slice(0, 200)}。白箱判定:${w.judgment},best=${w.best}。只输出 同意/质疑/不同意 + 一句话理由`,
238
+ })
239
+ // P1 修复(GPT 审查):文本在 content[].text;结论解析必须先查
240
+ // 「不同意/不通过」再「质疑」再「同意」——「不同意」含子串「同意」,
241
+ // 此前先匹配「同意」→ 不同意被误判为同意
242
+ const text = mcpText(reviewResult)
243
+ const conclusion = text.includes('不同意') || text.includes('不通过')
244
+ ? '不同意'
245
+ : text.includes('质疑') ? '质疑'
246
+ : text.includes('同意') ? '同意' : '不同意'
247
+ return { conclusion, reason: text.slice(0, 120) }
248
+ },
249
+ },
250
+ )
251
+ ctx.logger.info('dsh-memory: 互维维护已启用(心跳 10min + 守护 A + 任务验证双通道)')
252
+ }
253
+ }
254
+ } catch (err) {
255
+ // 探针:记录 apply 失败的具体错误(定位插件加载失败根因)
256
+ probeApplyError(err)
257
+ bridge.dispose()
258
+ throw err
259
+ }
260
+
261
+ ctx.effect(() => {
262
+ return () => {
263
+ for (const dispose of disposers) dispose()
264
+ bridge.dispose()
265
+ ctx.logger.info('dsh-memory: 已卸载(工具已注销,灵枢进程已退出)')
266
+ }
267
+ }, 'dsh-memory')
268
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * llm_adapter.ts —— 白箱 LLM 服务商(WhiteboxLlmAdapter)
3
+ *
4
+ * 设计哲学:白箱本身就是 LLM 模型(对外完全对齐 dsh-llm 协议),内部白箱化。
5
+ * - provider: 'lingshu-whitebox'(设置页「模型」可见,可被 agent 默认选用)
6
+ * - stream(): 调灵枢 wisdom_chat(白箱 CCG 条件路由/组合生成/自校验)
7
+ * - token 计数:输入/输出/缓存命中(白箱直答 = 全部 cacheRead,零推理成本)
8
+ * - 降级:白箱无把握(route=llm / 桥未就绪)→ 可选 fallback(后续接 deepseek)
9
+ *
10
+ * 结构仿官方 @deepseek-ai/dsh-llm-deepseek adapter。
11
+ */
12
+
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type { LingshuBridge } from './bridge.js'
15
+
16
+ // ---- dsh-llm 类型(运行时仅用结构;类型从包引入保持协议对齐)----
17
+ // 不 import 运行时符号,仅引用类型,避免强依赖 dsh-llm 未装时报错。
18
+ export interface LlmStreamChunk {
19
+ type: 'block-start' | 'text-delta' | 'reasoning-delta' | 'tool-call-delta' | 'block-end' | 'usage' | 'finish'
20
+ index?: number
21
+ blockType?: string
22
+ text?: string
23
+ id?: string
24
+ name?: string
25
+ argumentsDelta?: string
26
+ block?: { type: string; text?: string; [k: string]: unknown }
27
+ usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number }
28
+ reason?: string
29
+ replayState?: unknown
30
+ }
31
+
32
+ export interface LlmGenerateOptions {
33
+ provider: string
34
+ model: string
35
+ reasoningEffort?: string
36
+ messages: Array<{ role: string; content?: Array<{ type?: string; text?: string; arguments?: string; [k: string]: unknown }> }>
37
+ system?: string
38
+ tools?: Array<{ name: string; description: string; parameters: Record<string, unknown> }>
39
+ temperature?: number
40
+ maxTokens?: number
41
+ stop?: string[]
42
+ signal?: AbortSignal
43
+ sessionId?: string
44
+ purpose?: 'compaction' | 'session-title'
45
+ }
46
+
47
+ // ---- token 估算(确定性启发式:CJK 0.6/字符,其余 /4)----
48
+ export function estimateTokens(text: string | undefined | null): number {
49
+ if (!text) return 0
50
+ let cjk = 0
51
+ let other = 0
52
+ for (const ch of text) {
53
+ const code = ch.codePointAt(0)!
54
+ if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3040 && code <= 0x30ff)) cjk++
55
+ else other++
56
+ }
57
+ return Math.ceil(cjk * 0.6 + other / 4)
58
+ }
59
+
60
+ /** 从 GenerateOptions 汇总输入 token(system + messages 全部文本)。 */
61
+ export function countInputTokens(options: LlmGenerateOptions): number {
62
+ let total = 0
63
+ if (options.system) total += estimateTokens(options.system)
64
+ for (const msg of options.messages ?? []) {
65
+ for (const block of msg.content ?? []) {
66
+ if (typeof block?.text === 'string') total += estimateTokens(block.text)
67
+ if (typeof block?.arguments === 'string') total += estimateTokens(block.arguments)
68
+ }
69
+ }
70
+ return total
71
+ }
72
+
73
+ /** 把 DSH 会话消息折叠成白箱可读的对话文本。 */
74
+ export function serializeMessages(options: LlmGenerateOptions): string {
75
+ const parts: string[] = []
76
+ if (options.system) parts.push(`[系统] ${options.system}`)
77
+ for (const msg of options.messages ?? []) {
78
+ const text = (msg.content ?? [])
79
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
80
+ .map((b) => b.text as string)
81
+ .join('')
82
+ if (!text) continue
83
+ const role = msg.role === 'assistant' ? '灵枢' : msg.role === 'user' ? '用户' : msg.role
84
+ parts.push(`[${role}] ${text}`)
85
+ }
86
+ return parts.join('\n')
87
+ }
88
+
89
+ /** 把一段完整文本包装成协议流(确定性整段生成 → 单次流)。 */
90
+ export async function* wrapTextStream(
91
+ text: string,
92
+ inputTokens: number,
93
+ outputTokens: number,
94
+ cacheReadTokens: number,
95
+ ): AsyncIterable<LlmStreamChunk> {
96
+ yield { type: 'block-start', index: 0, blockType: 'text' }
97
+ yield { type: 'text-delta', index: 0, text }
98
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } }
99
+ yield {
100
+ type: 'usage',
101
+ usage: {
102
+ inputTokens,
103
+ outputTokens,
104
+ ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
105
+ },
106
+ }
107
+ yield { type: 'finish', reason: 'stop' }
108
+ }
109
+
110
+ /**
111
+ * 白箱知识卡片格式检测:REVERSE_DAILY 直答是知识库内部格式——
112
+ * 「X是什么…,是『A』vs『B』的矛盾——X(是…(…(…)…)…真相:①…」
113
+ * 特征(任一强信号命中即判定):
114
+ * ① 卡片开头句式:内容含「,是『X』vs『Y』的矛盾——」(矛盾标记+破折号)
115
+ * ② 「真相:」结构化标记(卡片分段标题)+ 括号密集(>8)
116
+ * ③ 长文本(>300)+ 高括号密度(>20)+ 无自然语言标点分隔(顿号密集)
117
+ */
118
+ export function isCardFormat(text: string): boolean {
119
+ if (!text) return false
120
+
121
+ // ① 强信号:矛盾标记句式(「,是『A』vs『B』的矛盾——」)——卡片开头的
122
+ // 标志性结构,不受长度门槛限制(短卡片也判定)
123
+ if (/,是『[^』]{1,12}』vs『[^』]{1,12}』的矛盾[——\-]/.test(text)) return true
124
+
125
+ // 短文本(<150)无矛盾标记 → 正常文本
126
+ if (text.length < 150) return false
127
+
128
+ // ② 「真相:」+ 括号密集(卡片的分段结构)
129
+ if (text.includes('真相:')) {
130
+ const parens = (text.match(/(/g) ?? []).length
131
+ if (parens >= 8) return true
132
+ }
133
+
134
+ // ③ 超长 + 极高括号密度(卡片展开体)
135
+ const parens2 = (text.match(/(/g) ?? []).length
136
+ if (text.length > 300 && parens2 > 20) return true
137
+
138
+ return false
139
+ }
140
+
141
+ /** 白箱 adapter 依赖注入。 */
142
+ export interface WhiteboxAdapterDeps {
143
+ /** 灵枢桥(MCP stdio)——白箱引擎通道。 */
144
+ bridge?: LingshuBridge
145
+ /**
146
+ * 降级 LLM:白箱未命中(route=llm / 桥未就绪)时转发。由 installWhiteboxLlm
147
+ * 注入 DSH 的 llm 服务(走 fallbackProvider 路由),实现「白箱优先 + LLM 降级」。
148
+ */
149
+ fallback?: { stream(o: LlmGenerateOptions): AsyncIterable<LlmStreamChunk> }
150
+ }
151
+
152
+ /** 白箱 LLM adapter —— 注册为 DSH 的 provider。 */
153
+ export class WhiteboxLlmAdapter {
154
+ private readonly bridge?: LingshuBridge
155
+ private readonly fallback?: WhiteboxAdapterDeps['fallback']
156
+
157
+ constructor(deps: WhiteboxAdapterDeps = {}) {
158
+ this.bridge = deps.bridge
159
+ this.fallback = deps.fallback
160
+ }
161
+
162
+ providerInfo(provider: string): { id: string; name: string } {
163
+ return { id: provider, name: '白箱灵枢 (lingshu-whitebox)' }
164
+ }
165
+
166
+ providerRetryPolicy(): undefined {
167
+ return undefined
168
+ }
169
+
170
+ async listModels(): Promise<Array<{ provider: string; id: string; name: string }>> {
171
+ return [
172
+ { provider: 'lingshu-whitebox', id: 'whitebox-v1', name: '白箱引擎 v1 (CCG 条件路由)' },
173
+ { provider: 'lingshu-whitebox', id: 'whitebox-wisdom', name: '白箱智慧之书' },
174
+ ]
175
+ }
176
+
177
+ async resolveModel(provider: string, model: string): Promise<{ provider: string; id: string; name: string }> {
178
+ return { provider, id: model, name: model }
179
+ }
180
+
181
+ /**
182
+ * 核心:一次模型调用。白箱优先,无把握降级。
183
+ * @param options 完全装配的请求(必须 honor options.signal)
184
+ */
185
+ async *stream(options: LlmGenerateOptions): AsyncIterable<LlmStreamChunk> {
186
+ const inputTokens = countInputTokens(options)
187
+ const conversation = serializeMessages(options)
188
+ // 取最后一条用户文本作为白箱输入(完整对话太长时截尾)
189
+ const lastUserText = [...(options.messages ?? [])]
190
+ .reverse()
191
+ .flatMap((m) => m.content ?? [])
192
+ .filter((b) => b?.type === 'text')
193
+ .map((b) => b.text as string)
194
+ .join('')
195
+ .slice(-2000)
196
+
197
+ // ---- 白箱主路径:调灵枢 wisdom_chat ----
198
+ let whitebox: { text: string; route: string } | null = null
199
+ if (this.bridge?.isReady?.()) {
200
+ try {
201
+ const r = await this.bridge.callTool('wisdom_chat', {
202
+ message: lastUserText || conversation.slice(-4000),
203
+ session_id: (options.sessionId as string) ?? 'default',
204
+ }, options.signal)
205
+ const raw = (r?.content ?? []).map((c) => (c as { text?: string }).text ?? '').join('\n')
206
+ // 白箱返回结构:{"reply": "...", "route": "self|llm", "hits": [...], "honest": bool}
207
+ let text = raw
208
+ let route = 'self'
209
+ try {
210
+ const parsed = JSON.parse(raw) as { route?: string; reply?: unknown }
211
+ if (parsed.route) route = parsed.route
212
+ if (typeof parsed.reply === 'string') text = parsed.reply
213
+ else if (parsed.reply !== undefined) text = JSON.stringify(parsed.reply)
214
+ }
215
+ catch { /* 非 JSON = 纯文本回复 */ }
216
+ // 质量门槛(v0.4.1):REVERSE_DAILY 卡片直答是知识库内部格式——
217
+ // 「X是什么…是『A』vs『B』的矛盾——X(是…(…)」重复+括号嵌套,
218
+ // 直接当 LLM 输出喂给用户=「重复回答」。检测到卡片特征 → 视为
219
+ // 未命中,走降级 LLM(白箱不输出内部格式垃圾)。
220
+ if (route !== 'llm' && text && text.trim() && !isCardFormat(text)) {
221
+ whitebox = { text: text.trim(), route }
222
+ }
223
+ else if (text && isCardFormat(text)) {
224
+ console.warn('[whitebox-llm] 白箱返回知识卡片格式,降级 LLM(内部格式不直出)')
225
+ }
226
+ }
227
+ catch (err) {
228
+ // 白箱异常不阻塞——记日志后走降级
229
+ console.warn('[whitebox-llm] 白箱调用失败,降级:', String(err))
230
+ }
231
+ }
232
+
233
+ if (whitebox) {
234
+ const outputTokens = estimateTokens(whitebox.text)
235
+ // 白箱直答 = 全缓存命中(条件路由固化命中,零推理成本)
236
+ yield* wrapTextStream(whitebox.text, inputTokens, outputTokens, inputTokens)
237
+ return
238
+ }
239
+
240
+ // ---- 降级路径:白箱无把握 / 桥未就绪 ----
241
+ if (this.fallback) {
242
+ console.warn('[whitebox-llm] 白箱未命中,转降级 LLM')
243
+ yield* this.fallback.stream(options)
244
+ return
245
+ }
246
+ // 无降级可用:诚实声明(盲区 28:未来承诺不可检验——不假装回答)
247
+ const honest = '(白箱引擎未命中且降级 LLM 未配置——按白箱纪律,我诚实声明不知道,不编造答案。)'
248
+ yield* wrapTextStream(honest, inputTokens, estimateTokens(honest), 0)
249
+ }
250
+ }
251
+
252
+ /** provider 路由名(设置页「模型」下拉可见)。 */
253
+ export const WHITEBOX_PROVIDER = 'lingshu-whitebox'
254
+
255
+ /**
256
+ * 注册白箱 provider 到 DSH llm 服务(动态检测,不强依赖 llm 服务——
257
+ * 最小 host 无 llm 时跳过,插件其余功能不受影响)。
258
+ * @returns 注销函数;未注册返回 noop。
259
+ */
260
+ export function installWhiteboxLlm(ctx: Context, bridge: LingshuBridge | undefined): () => void {
261
+ // 动态检测 llm 服务(P1 模式:timer/webServer 同理)。
262
+ // 注意:不能直接读 ctx.llm——Cordis 未声明 inject 的属性访问会抛
263
+ // "cannot get property without inject";ctx.get('llm') 安全返回 undefined。
264
+ const llm = ctx.get('llm') as
265
+ | {
266
+ registerAdapter?: (p: string[], a: unknown) => { replace?: (p: string[]) => void }
267
+ registerConfigurableProviders?: (e: unknown[]) => void
268
+ stream?: (o: Record<string, unknown>) => AsyncIterable<LlmStreamChunk>
269
+ listProviders?: () => Array<{ id: string }>
270
+ }
271
+ | undefined
272
+ if (!llm?.registerAdapter) {
273
+ ctx.logger?.info?.('dsh-memory: llm 服务不可用,跳过白箱 provider 注册(最小 host)')
274
+ return () => {}
275
+ }
276
+
277
+ // 降级 LLM:白箱未命中时转发给 fallbackProvider 路由(默认 deepseek-official,
278
+ // 可在 settings.yaml 的 llm-whitebox.fallbackProvider 覆盖)。通过 llm.stream
279
+ // 重路由——保持 DSH 的全链路(重试/瀑布流/用量计量)。
280
+ const fallbackProvider = 'deepseek-official'
281
+ const fallback: WhiteboxAdapterDeps['fallback'] | undefined = llm.stream
282
+ ? {
283
+ stream(options) {
284
+ return llm.stream!({ ...options, provider: fallbackProvider })
285
+ },
286
+ }
287
+ : undefined
288
+
289
+ const adapter = new WhiteboxLlmAdapter({ bridge, fallback })
290
+ try {
291
+ llm.registerConfigurableProviders?.([{
292
+ provider: WHITEBOX_PROVIDER,
293
+ displayName: '白箱灵枢 (Whitebox)',
294
+ settingsNs: 'llm-whitebox',
295
+ settingsPath: [],
296
+ }])
297
+ const registration = llm.registerAdapter([WHITEBOX_PROVIDER], adapter)
298
+ ctx.logger?.info?.('dsh-memory: 白箱 LLM provider 已注册: %s(降级→%s)', WHITEBOX_PROVIDER, fallbackProvider)
299
+ return () => { try { registration?.replace?.([]) } catch { /* 忽略注销异常 */ } }
300
+ }
301
+ catch (err) {
302
+ ctx.logger?.warn?.('dsh-memory: 白箱 provider 注册失败: %s', String(err))
303
+ return () => {}
304
+ }
305
+ }