@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/lib/tools.d.ts CHANGED
@@ -14,10 +14,19 @@ export declare const CORE_TOOLS: readonly ["remember", "recall", "search", "time
14
14
  * 保留灵枢全部心智能力(记忆/认知/推理/学习/飞轮/反思/长期记忆门),
15
15
  * 排除身体/视觉类工具(body/device_call/see/world3d 等)。
16
16
  */
17
- export declare const BRAIN_TOOLS: readonly ["remember", "recall", "search", "timeline", "session_note", "session_recall", "compact_context", "think", "relate", "reason", "predict_routes", "self_check", "gap_trend", "cognition", "cognition_report", "emotional_bias", "self_reliability", "action_log", "preflight", "recursive_reflect", "blindspots", "learn", "induce", "distill", "flywheel_report", "transfer_test", "calibrate", "ingest_text", "ingest_file", "ingest_url", "web_search", "step", "lifecycle_state", "longterm_snapshot", "promote_memories", "service_info"];
17
+ export declare const BRAIN_TOOLS: readonly ["remember", "recall", "search", "timeline", "session_note", "session_recall", "compact_context", "think", "relate", "reason", "predict_routes", "self_check", "gap_trend", "cognition", "cognition_report", "emotional_bias", "self_reliability", "action_log", "preflight", "recursive_reflect", "blindspots", "learn", "induce", "distill", "flywheel_metrics", "transfer_test", "calibrate", "ingest_text", "ingest_file", "ingest_url", "web_search", "lifecycle_step", "lifecycle_state", "longterm_snapshot", "promote_memories", "service_info"];
18
18
  /** tools 配置:'core' | 'brain' | 'all' | 显式名称数组。 */
19
19
  export type ToolSelection = 'core' | 'brain' | 'all' | string[];
20
- /** 按配置筛选工具名。 */
20
+ /** 按工具名判定并发安全(只读查询 true;写操作 false) */
21
+ export declare function isToolConcurrencySafe(name: string): boolean;
22
+ /**
23
+ * P1 完善(GPT 审查·tools:all 自动扩权):即使 selection='all' 也排除的宿主级
24
+ * 风险工具——此前后端新增工具即自动暴露给 Agent(动态扩权无 denylist)。
25
+ * 排除原则:宿主命令执行/权限终裁/外部设备/自主生命周期控制/角色卡写入。
26
+ * 显式名称数组(显式配置)不受此名单限制(配置者已明确选择)。
27
+ */
28
+ export declare const RISK_TOOLS: Set<string>;
29
+ /** 按配置筛选工具名('all' 时排除 RISK_TOOLS 宿主级危险工具)。 */
21
30
  export declare function selectTools(all: string[], selection: ToolSelection): string[];
22
31
  /** 把 MCP JSON Schema 的属性表转换为 ParameterSchemaSpec。 */
23
32
  export declare function schemaToParameters(inputSchema: Record<string, unknown> | undefined): ParameterSchemaSpec;
package/lib/tools.js CHANGED
@@ -39,20 +39,54 @@ export const BRAIN_TOOLS = [
39
39
  // 学习与盲区
40
40
  'blindspots', 'learn', 'induce',
41
41
  // 知识飞轮
42
- 'distill', 'flywheel_report', 'transfer_test', 'calibrate',
42
+ 'distill', 'flywheel_metrics', 'transfer_test', 'calibrate',
43
43
  // 外部知识摄取
44
44
  'ingest_text', 'ingest_file', 'ingest_url', 'web_search',
45
45
  // 生命周期
46
- 'step', 'lifecycle_state',
46
+ 'lifecycle_step', 'lifecycle_state',
47
47
  // 长期记忆门(v1.15:重要性评估主动沉淀)
48
48
  'longterm_snapshot', 'promote_memories',
49
49
  // 服务
50
50
  'service_info',
51
51
  ];
52
- /** 按配置筛选工具名。 */
52
+ /** P1 修复(GPT 审查):只读/无副作用工具才允许并发——写操作(记忆/关系/
53
+ * 生命周期/摄取/学习)标 false,防止 DSH 并行调用导致 SQLite 写入竞争、
54
+ * 状态顺序错乱、关系边重复等。 */
55
+ const READ_TOOLS = new Set([
56
+ 'recall', 'search', 'timeline', 'think', 'reason', 'predict_routes',
57
+ 'self_check', 'service_info', 'session_recall', 'gap_trend', 'transfer_test',
58
+ 'cognition_report', 'self_reliability', 'emotional_bias', 'flywheel_metrics',
59
+ 'distill', 'insight_report', 'prediction_stats', 'blindspots', 'pattern_separation',
60
+ ]);
61
+ /** 按工具名判定并发安全(只读查询 true;写操作 false) */
62
+ export function isToolConcurrencySafe(name) {
63
+ return READ_TOOLS.has(name);
64
+ }
65
+ /**
66
+ * P1 完善(GPT 审查·tools:all 自动扩权):即使 selection='all' 也排除的宿主级
67
+ * 风险工具——此前后端新增工具即自动暴露给 Agent(动态扩权无 denylist)。
68
+ * 排除原则:宿主命令执行/权限终裁/外部设备/自主生命周期控制/角色卡写入。
69
+ * 显式名称数组(显式配置)不受此名单限制(配置者已明确选择)。
70
+ */
71
+ export const RISK_TOOLS = new Set([
72
+ 'run_command', // 宿主命令执行(安全边界:不由 Agent 动态调用)
73
+ 'designer_decide', // 设计者裁决(fail-closed 权限,绝不由 Agent 调用)
74
+ 'device_call', // 外部设备统一调用(屏幕/进程/音频/浏览器)
75
+ 'see', // 视觉感知(身体工具)
76
+ 'world3d', // 时空 3D 重建(身体工具)
77
+ 'vprim', // 视觉原语(身体工具)
78
+ 'visual_check', // 视觉面检查(身体工具)
79
+ 'start_lifecycle', // 启动自主生命周期循环
80
+ 'stop_lifecycle', // 中断生命周期循环
81
+ 'web_ingest_search', // 外部搜索摄取(网络调用 + 写知识层)
82
+ 'role_create', // 角色卡创建(写角色数据)
83
+ 'role_import', // 角色导入(写角色数据)
84
+ 'role_block', // 角色扮演注入块组装
85
+ ]);
86
+ /** 按配置筛选工具名('all' 时排除 RISK_TOOLS 宿主级危险工具)。 */
53
87
  export function selectTools(all, selection) {
54
88
  if (selection === 'all')
55
- return all;
89
+ return all.filter((name) => !RISK_TOOLS.has(name));
56
90
  const allowed = new Set(selection === 'core' ? CORE_TOOLS
57
91
  : selection === 'brain' ? BRAIN_TOOLS
58
92
  : selection);
@@ -107,7 +141,11 @@ function toValueSpec(raw) {
107
141
  }
108
142
  case 'object': {
109
143
  const props = schemaToParameters(node);
110
- return { type: 'object', properties: props, additionalProperties: true, ...annotations };
144
+ // P1 修复(GPT 审查):尊重后端 additionalProperties 声明(false 保留),
145
+ // 不再无条件 true——此前后端写 false 也会被覆盖成 true,DSH 侧认为
146
+ // 参数合法但后端拒绝。
147
+ const additional = node['additionalProperties'] === false ? false : true;
148
+ return { type: 'object', properties: props, additionalProperties: additional, ...annotations };
111
149
  }
112
150
  default:
113
151
  return { type: 'json', ...annotations };
@@ -142,9 +180,16 @@ export async function registerLingshuTools(ctx, bridge, opts) {
142
180
  },
143
181
  },
144
182
  timeoutMs: 120_000,
145
- isConcurrencySafe: () => true,
146
- async execute(args) {
147
- const result = await bridge.callTool(tool.name, args);
183
+ // P1 修复(GPT 审查):按工具分类——只读查询可并发,写操作串行
184
+ isConcurrencySafe: () => isToolConcurrencySafe(tool.name),
185
+ // P1 修复(GPT 审查):接收 exec.signal(用户取消/上层超时)——
186
+ // 此前完全忽略取消,取消后写操作(remember/relate/ingest 等)仍可能产生副作用
187
+ async execute(args, exec) {
188
+ if (exec.signal.aborted)
189
+ throw new Error(`灵枢 ${tool.name} 已取消`);
190
+ const result = await bridge.callTool(tool.name, args, exec.signal);
191
+ if (exec.signal.aborted)
192
+ throw new Error(`灵枢 ${tool.name} 已取消`);
148
193
  if (result.isError) {
149
194
  throw new Error(extractText(result.content) || `灵枢 ${tool.name} 执行失败`);
150
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@furongjun1999/dsh-memory",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "灵枢(Lingshu·líng shū)DeepSeek Harness 插件:完整大脑——长期记忆/知识飞轮/自我认知/递归反思接入 DSH,对话自动沉淀进记忆库",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "lib",
16
+ "src",
16
17
  "README.md",
17
18
  "cordis.yml.example",
18
19
  "cordis.patch.yml",
@@ -34,12 +35,19 @@
34
35
  "license": "MIT",
35
36
  "peerDependencies": {
36
37
  "@deepseek-ai/cordis": "^4.0.1",
38
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
37
39
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
38
40
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
39
41
  "@deepseek-ai/schemastery": "^3.18.1"
40
42
  },
43
+ "peerDependenciesMeta": {
44
+ "@deepseek-ai/dsh-llm": {
45
+ "optional": true
46
+ }
47
+ },
41
48
  "devDependencies": {
42
49
  "@deepseek-ai/cordis": "^4.0.1",
50
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
43
51
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
44
52
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
45
53
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
@@ -51,6 +59,7 @@
51
59
  "scripts": {
52
60
  "build": "tsc -p tsconfig.json",
53
61
  "test": "npm run build && node --import tsx --test \"test/*.test.ts\"",
62
+ "prepare": "npm run build",
54
63
  "prepublishOnly": "npm run build && npm test"
55
64
  },
56
65
  "repository": {
@@ -58,7 +67,7 @@
58
67
  "url": "https://github.com/FuRongJun-1999/dsh-memory.git"
59
68
  },
60
69
  "engines": {
61
- "node": ">=18"
70
+ "node": ">=22.19"
62
71
  },
63
72
  "dsh": {
64
73
  "bundle": {
package/src/bridge.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * 灵枢 MCP stdio 桥:管理灵枢(AEIS)Python 子进程的生命周期,
3
+ * 通过逐行 JSON-RPC 完成握手、工具发现与调用。
4
+ *
5
+ * 与官方 @deepseek-ai/dsh-mcp-client 不同,本桥零运行时依赖(不引入
6
+ * @modelcontextprotocol/sdk),直接实现灵枢 server 使用的 2024-11-05 协议
7
+ * 子集——与灵枢库 D-005「核心零外部依赖」的工程哲学一致。
8
+ */
9
+
10
+ import { spawn, type ChildProcess } from 'node:child_process'
11
+ import { createInterface } from 'node:readline'
12
+ import { mkdirSync, appendFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+
16
+ /**
17
+ * 调试探针:记录桥生命周期到独立文件(绕过 DSH 日志系统,便于定位启动问题)。
18
+ * 路径从用户家目录动态解析(issue #5)——此前硬编码作者本机绝对路径,
19
+ * 其他用户机器上目录不存在且无权限,探针静默失效,恰在排查启动问题时失明。
20
+ */
21
+ const DEBUG_LOG = join(homedir(), '.dsh', 'logs', 'lingshu-bridge-debug.log')
22
+ let debugLogDirReady = false
23
+ function probe(msg: string): void {
24
+ try {
25
+ if (!debugLogDirReady) {
26
+ mkdirSync(dirname(DEBUG_LOG), { recursive: true })
27
+ debugLogDirReady = true
28
+ }
29
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`)
30
+ } catch { /* 探针失败忽略 */ }
31
+ }
32
+
33
+ /** 灵枢 MCP server 暴露的原始工具(tools/list 结果项)。 */
34
+ export interface McpTool {
35
+ name: string
36
+ description: string
37
+ inputSchema?: Record<string, unknown>
38
+ }
39
+
40
+ /** 一次 tools/call 的标准化结果。 */
41
+ export interface McpCallResult {
42
+ content: Array<{ type: string; text?: string; [key: string]: unknown }>
43
+ isError: boolean
44
+ }
45
+
46
+ /** 启动灵枢子进程的配置。 */
47
+ export interface BridgeOptions {
48
+ /** Python 可执行文件(或 aeis-mcp console script),默认 python。 */
49
+ python: string
50
+ /** 传给 python 的参数,默认 ['-m', 'aeis.mcp.server']。 */
51
+ args: string[]
52
+ /** 追加到子进程的环境变量(AEIS_DB / AEIS_IDENTITY / 密钥等)。 */
53
+ env: Record<string, string>
54
+ /** 子进程工作目录。 */
55
+ cwd?: string
56
+ /** 单次工具调用超时(毫秒),默认 60s。 */
57
+ timeoutMs: number
58
+ /** 断线重连的最大间隔(毫秒),默认 30s。 */
59
+ maxRetryDelayMs: number
60
+ }
61
+
62
+ interface PendingCall {
63
+ resolve: (value: unknown) => void
64
+ reject: (reason: Error) => void
65
+ timer: NodeJS.Timeout
66
+ }
67
+
68
+ /**
69
+ * 连续启动失败的最大重试次数(issue #6):指数退避到 maxRetryDelayMs 后
70
+ * 若无上限,python 环境损坏时会以 30s 间隔永久空转(假激活 + 资源浪费)。
71
+ * 达到上限即进入 failed 终态并停止调度;后续所有请求快速失败并给出
72
+ * 明确的修复指引。握手成功后计数归零——运行期偶发崩溃重启不受影响。
73
+ */
74
+ const MAX_RETRIES = 8
75
+
76
+ export class LingshuBridge {
77
+ private readonly options: BridgeOptions
78
+ private proc: ChildProcess | null = null
79
+ private rl: ReturnType<typeof createInterface> | null = null
80
+ private nextId = 1
81
+ private pending = new Map<number, PendingCall>()
82
+ private started = false
83
+ private disposed = false
84
+ private retryDelayMs = 1000
85
+ private retries = 0
86
+ private retryTimer: NodeJS.Timeout | null = null
87
+ private bootQueue: Array<(ok: boolean) => void> = []
88
+ private readyState: 'pending' | 'ok' | 'failed' = 'pending'
89
+
90
+ constructor(options: BridgeOptions) {
91
+ this.options = options
92
+ }
93
+
94
+ /**
95
+ * 启动灵枢进程并完成握手(initialize → notifications/initialized)。
96
+ * 进程崩溃后自动指数退避重启,并重新握手。
97
+ */
98
+ start(): void {
99
+ if (this.started || this.disposed) return
100
+ this.started = true
101
+ this.spawnAndHandshake()
102
+ }
103
+
104
+ /** 拉取灵枢的全部工具清单(每次实时请求,不缓存)。 */
105
+ async listTools(): Promise<McpTool[]> {
106
+ const result = await this.request('tools/list', {})
107
+ const tools = result as { tools?: McpTool[] }
108
+ return tools.tools ?? []
109
+ }
110
+
111
+ /** 调用灵枢的一个工具,返回标准化 MCP 结果。 */
112
+ async callTool(name: string, args: Record<string, unknown>,
113
+ signal?: AbortSignal): Promise<McpCallResult> {
114
+ const result = await this.request('tools/call', { name, arguments: args }, signal)
115
+ return result as McpCallResult
116
+ }
117
+
118
+ /** 关闭进程并释放资源(写 stdin EOF 优雅退出,超时兜底 kill)。 */
119
+ dispose(): void {
120
+ this.disposed = true
121
+ if (this.retryTimer) clearTimeout(this.retryTimer)
122
+ const proc = this.proc
123
+ if (!proc || proc.exitCode !== null) return
124
+ try {
125
+ proc.stdin?.end()
126
+ } catch {
127
+ /* 已关闭则忽略 */
128
+ }
129
+ const killer = setTimeout(() => proc.kill(), 2000)
130
+ killer.unref()
131
+ }
132
+
133
+ /** 进程是否存活。 */
134
+ get alive(): boolean {
135
+ return this.proc !== null && this.proc.exitCode === null
136
+ }
137
+
138
+ /** 桥是否已握手就绪(工具注册用;防止轮询访问 private readyState)。 */
139
+ isReady(): boolean {
140
+ return this.readyState === 'ok'
141
+ }
142
+
143
+ /** 是否已达放弃终态(连续启动失败超上限,不再自动重启)。 */
144
+ get gaveUp(): boolean {
145
+ return this.retries >= MAX_RETRIES
146
+ }
147
+
148
+ /** 等待握手完成(用于 apply 阶段同步就绪)。 */
149
+ waitReady(): Promise<boolean> {
150
+ if (this.readyState === 'ok') return Promise.resolve(true)
151
+ if (this.readyState === 'failed') return Promise.resolve(false)
152
+ return new Promise((resolve) => this.bootQueue.push(resolve))
153
+ }
154
+
155
+ private spawnAndHandshake(): void {
156
+ if (this.disposed) return
157
+ const { python, args, env, cwd } = this.options
158
+ const childEnv = { ...process.env, ...env }
159
+ // 确保 DB 目录存在(灵枢 server 也会防御性创建,这里提前为可读错误)
160
+ const dbPath = env['AEIS_DB']
161
+ if (dbPath && dbPath !== ':memory:') {
162
+ try {
163
+ mkdirSync(dirname(dbPath), { recursive: true })
164
+ } catch {
165
+ /* 目录创建失败由灵枢侧兜底 */
166
+ }
167
+ }
168
+ const proc = spawn(python, args, {
169
+ env: childEnv,
170
+ cwd,
171
+ stdio: ['pipe', 'pipe', 'pipe'],
172
+ windowsHide: true,
173
+ })
174
+ this.proc = proc
175
+ this.rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity })
176
+
177
+ proc.stderr?.on('data', (chunk: Buffer) => {
178
+ // stderr 透传日志(灵枢把日志写在 stderr,避免污染协议流)
179
+ const text = chunk.toString('utf8').trim()
180
+ if (text) console.error(`[lingshu-bridge] ${text}`)
181
+ })
182
+
183
+ this.rl.on('line', (line: string) => {
184
+ if (!line.trim()) return
185
+ let msg: Record<string, unknown>
186
+ try {
187
+ msg = JSON.parse(line)
188
+ } catch {
189
+ console.error(`[lingshu-bridge] 非 JSON 输出: ${line.slice(0, 200)}`)
190
+ return
191
+ }
192
+ if (typeof msg['id'] === 'number') {
193
+ this.settle(msg['id'] as number, msg)
194
+ }
195
+ })
196
+
197
+ proc.on('error', (err) => {
198
+ // 进程无法启动(python 不存在等)——响亮失败
199
+ if (!this.disposed) {
200
+ console.error(`[lingshu-bridge] 灵枢进程启动失败: ${err.message}`)
201
+ probe(`spawn error: ${String(err)}`)
202
+ this.readyState = 'failed'
203
+ this.flushBootQueue(false)
204
+ this.scheduleRetry()
205
+ }
206
+ })
207
+
208
+ proc.on('exit', (code, signal) => {
209
+ // 探针:写独立文件记录退出(绕过 DSH 日志系统,便于定位)
210
+ probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started}`)
211
+ console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}`)
212
+ this.rl?.close()
213
+ this.rl = null
214
+ this.rejectAll(new Error(`灵枢进程已退出(code=${code} signal=${signal ?? 'none'})`))
215
+ if (!this.disposed) {
216
+ // 有挂起请求的失败是异常的;仅启动失败的等待者得到 false
217
+ this.readyState = 'failed'
218
+ this.flushBootQueue(false)
219
+ this.scheduleRetry()
220
+ }
221
+ })
222
+
223
+ void this.handshake()
224
+ }
225
+
226
+ private async handshake(): Promise<void> {
227
+ try {
228
+ await this.request('initialize', {
229
+ protocolVersion: '2024-11-05',
230
+ capabilities: {},
231
+ clientInfo: { name: 'dsh-memory', version: '0.1.0' },
232
+ })
233
+ // 初始化通知:无 id、无响应
234
+ this.writeRaw({ jsonrpc: '2.0', method: 'notifications/initialized' })
235
+ // 握手成功:重置退避与失败计数(issue #6)——运行期偶发崩溃重启
236
+ // 不累积启动失败;只有「从未握手成功的连续失败」才走向放弃终态
237
+ this.retryDelayMs = 1000
238
+ this.retries = 0
239
+ this.readyState = 'ok'
240
+ this.flushBootQueue(true)
241
+ } catch (err) {
242
+ if (!this.disposed) {
243
+ console.error(`[lingshu-bridge] 握手失败: ${(err as Error).message}`)
244
+ probe(`handshake failed: ${String(err)}`)
245
+ this.readyState = 'failed'
246
+ this.flushBootQueue(false)
247
+ this.scheduleRetry()
248
+ }
249
+ }
250
+ }
251
+
252
+ private scheduleRetry(): void {
253
+ if (this.disposed || this.retryTimer || this.gaveUp) return
254
+ this.retries += 1
255
+ if (this.gaveUp) {
256
+ // issue #6:放弃机制——明确终态,停止后台空转;请求方收到清晰错误而非超时
257
+ this.readyState = 'failed'
258
+ this.flushBootQueue(false)
259
+ console.error(
260
+ `[lingshu-bridge] 连续启动失败 ${this.retries} 次,已停止自动重启(不再后台空转)。` +
261
+ '请检查 python 可执行文件与 aeis 安装(pip install aeis),修复后在 DSH 中重新启用 dsh-memory 插件。')
262
+ probe(`give up: ${this.retries} consecutive failures, entering failed terminal state`)
263
+ return
264
+ }
265
+ const delay = this.retryDelayMs
266
+ this.retryDelayMs = Math.min(this.retryDelayMs * 2, this.options.maxRetryDelayMs)
267
+ console.error(`[lingshu-bridge] 将进行第 ${this.retries}/${MAX_RETRIES} 次重试(${delay}ms 后)`)
268
+ probe(`schedule retry ${this.retries}/${MAX_RETRIES} in ${delay}ms`)
269
+ this.retryTimer = setTimeout(() => {
270
+ this.retryTimer = null
271
+ if (!this.disposed) this.spawnAndHandshake()
272
+ }, delay)
273
+ this.retryTimer.unref()
274
+ }
275
+
276
+ private writeRaw(msg: Record<string, unknown>): void {
277
+ const proc = this.proc
278
+ if (!proc || !proc.stdin?.writable) {
279
+ throw new Error('灵枢进程不可写')
280
+ }
281
+ proc.stdin.write(JSON.stringify(msg) + '\n')
282
+ }
283
+
284
+ private request(method: string, params: Record<string, unknown>,
285
+ signal?: AbortSignal): Promise<unknown> {
286
+ // issue #6:放弃终态下快速失败——之前进程不在且不再重启,请求只能白等超时
287
+ if (this.gaveUp) {
288
+ return Promise.reject(new Error(
289
+ '灵枢进程不可用:连续启动失败已达上限,已停止重试。' +
290
+ '请检查 python 可执行文件与 aeis 安装(pip install aeis),修复后重新启用 dsh-memory 插件。'))
291
+ }
292
+ const id = this.nextId++
293
+ const timeout = this.options.timeoutMs
294
+ // P0 修复(GPT 审查):定时器直接调 settle——settle 内部会 delete + clearTimeout
295
+ // 并 reject。此前先 pending.delete(id) 再 settle(),settle 找不到 entry 直接
296
+ // return,Promise 永不 resolve/reject(超时逻辑完全失效,子进程挂死时插件永久挂住)。
297
+ const timer = setTimeout(() => {
298
+ this.settle(id, {
299
+ error: { code: -32000, message: `灵枢调用超时(${timeout}ms):${method}` },
300
+ })
301
+ }, timeout)
302
+ if (signal?.aborted) {
303
+ clearTimeout(timer)
304
+ return Promise.reject(new Error(`已取消:${method}`))
305
+ }
306
+ const onAbort = (): void => {
307
+ this.settle(id, { error: { code: -32800, message: `已取消:${method}` } })
308
+ }
309
+ signal?.addEventListener('abort', onAbort, { once: true })
310
+ const promise = new Promise<unknown>((resolve, reject) => {
311
+ this.pending.set(id, { resolve, reject, timer })
312
+ })
313
+ // settle 后移除 abort 监听,避免内存积累
314
+ promise.finally(() => {
315
+ signal?.removeEventListener('abort', onAbort)
316
+ }).catch(() => { /* finally 链的 catch 防未处理拒绝 */ })
317
+ try {
318
+ this.writeRaw({ jsonrpc: '2.0', id, method, params })
319
+ } catch (err) {
320
+ clearTimeout(timer)
321
+ this.pending.delete(id)
322
+ signal?.removeEventListener('abort', onAbort)
323
+ return Promise.reject(err)
324
+ }
325
+ return promise
326
+ }
327
+
328
+ private settle(id: number, msg: Record<string, unknown>): void {
329
+ const entry = this.pending.get(id)
330
+ if (!entry) return
331
+ this.pending.delete(id)
332
+ clearTimeout(entry.timer)
333
+ if (msg['error']) {
334
+ const err = msg['error'] as { message?: string; code?: number }
335
+ entry.reject(new Error(`灵枢错误 ${err.code ?? ''}: ${err.message ?? 'unknown'}`))
336
+ } else {
337
+ entry.resolve(msg['result'])
338
+ }
339
+ }
340
+
341
+ private rejectAll(reason: Error): void {
342
+ for (const [, entry] of this.pending) {
343
+ clearTimeout(entry.timer)
344
+ entry.reject(reason)
345
+ }
346
+ this.pending.clear()
347
+ }
348
+
349
+ private flushBootQueue(ok: boolean): void {
350
+ const queue = this.bootQueue
351
+ this.bootQueue = []
352
+ for (const cb of queue) cb(ok)
353
+ }
354
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 自动记忆钩子:把 DSH 的会话事件(经统一的 session/event 分发)沉淀进灵枢。
3
+ *
4
+ * 与 DSH 的 session-persistence 插件(保存会话日志)不同,这里是"语义沉淀":
5
+ * 用户消息写入灵枢知识层(带去重与重要性),agent 回复与工具结果可选开启。
6
+ * 只记忆真实用户消息(source.kind === 'user'),过滤插件注入的噪音。
7
+ *
8
+ * P1 完善(GPT 审查·自动记忆只写不读):新增 autoRecall——通过
9
+ * system-prompt/assemble 事件(waterfall,异步允许)在每次模型请求组装
10
+ * system prompt 时自动注入灵枢最近记忆(timeline),让记忆"自动可用"
11
+ * 而不只依赖 Agent 主动调用 recall/think 工具。失败静默(不影响请求)。
12
+ */
13
+
14
+ import '@deepseek-ai/dsh-session'
15
+ import '@deepseek-ai/dsh-system-prompt'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
18
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
19
+ import type { LingshuBridge } from './bridge.js'
20
+
21
+ /** 自动记忆开关。 */
22
+ export interface MemoryHooksOptions {
23
+ /** 用户消息 → remember(默认 true)。 */
24
+ userMessage: boolean
25
+ /** agent 回复 → remember(默认 false,防噪音)。 */
26
+ assistantMessage: boolean
27
+ /** 工具结果 → remember(默认 false,噪音大)。 */
28
+ toolResult: boolean
29
+ /** 写入记忆的重要性(0~1),默认 0.6。 */
30
+ importance: number
31
+ /** 自动召回注入:模型请求前自动注入灵枢最近记忆(默认 true,失败静默)。 */
32
+ autoRecall: boolean
33
+ /** 自动召回条数(默认 4)。 */
34
+ autoRecallLimit: number
35
+ /** 自动记忆脱敏:写入前过滤敏感信息(密钥/密码/令牌/身份证/手机号,默认 true)。 */
36
+ desensitize: boolean
37
+ }
38
+
39
+ /** 从 ContentBlock[] 提取纯文本。 */
40
+ function extractText(blocks: ContentBlock[]): string {
41
+ const parts: string[] = []
42
+ for (const block of blocks) {
43
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
44
+ parts.push(block.text)
45
+ }
46
+ }
47
+ return parts.join('\n').trim()
48
+ }
49
+
50
+ /**
51
+ * 敏感信息模式(GPT 审查·自动记忆脱敏):写入记忆库前过滤凭据/个人标识。
52
+ * 命中 → 替换为 [已过滤:类别](保留对话主体);过滤后只剩占位符/空白 → 整条跳过。
53
+ * 纯内容过滤,不涉及身份认证——开源场景下的隐私保护。
54
+ */
55
+ const SENSITIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [
56
+ { re: /sk-[A-Za-z0-9_-]{8,}/g, label: 'API密钥' },
57
+ { re: /\b(?:api[_-]?key|apikey|access[_-]?token)\b\s*[:=]\s*[^\s,,。;;]+/gi, label: 'API密钥' },
58
+ { re: /\b(?:password|passwd|pwd)\b\s*[:=]\s*[^\s,,。;;]+/gi, label: '密码' },
59
+ { re: /Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, label: '令牌' },
60
+ // 中文密码:值限定非中文连续串(凭据特征),避免误伤「密码是重要的安全概念」
61
+ { re: /密码\s*[::是]\s*[A-Za-z0-9_@#$%^&*!.-]{4,}/g, label: '密码' },
62
+ { re: /\b\d{17}[\dXx]\b/g, label: '身份证号' },
63
+ { re: /\b1[3-9]\d{9}\b/g, label: '手机号' },
64
+ ]
65
+
66
+ /** 脱敏:替换敏感片段;返回 null 表示整条都是敏感内容(应跳过写入)。 */
67
+ export function desensitize(text: string): string | null {
68
+ let out = text
69
+ for (const { re, label } of SENSITIVE_PATTERNS) {
70
+ out = out.replace(re, `[已过滤:${label}]`)
71
+ }
72
+ // 过滤后只剩占位符/空白 → 纯凭据消息,不写(或全部被替换)
73
+ const residue = out.replace(/\[已过滤:[^\]]+\]/g, '').trim()
74
+ if (!residue) return null
75
+ return out
76
+ }
77
+
78
+ /** 安装自动记忆钩子(effect 作用域内,随插件卸载自动移除)。 */
79
+ export function installMemoryHooks(ctx: Context, bridge: LingshuBridge, opts: MemoryHooksOptions): void {
80
+ const memorize = (tool: string, args: Record<string, unknown>): void => {
81
+ void bridge
82
+ .callTool(tool, args)
83
+ .catch((err: Error) => ctx.logger.warn(`dsh-memory: ${tool} 自动记忆失败: ${err.message}`))
84
+ }
85
+
86
+ // P1 完善(GPT 审查·自动记忆脱敏):写入前过滤敏感信息(默认开启)。
87
+ // 命中敏感模式 → 替换为 [已过滤:类别];纯凭据消息 → 跳过写入(不落库)。
88
+ const sanitize = (text: string): string | null => {
89
+ if (!opts.desensitize) return text
90
+ return desensitize(text)
91
+ }
92
+
93
+ // P1 完善(自动 recall 注入):每次模型请求组装 system prompt 时,注入灵枢最近记忆。
94
+ // 用 system-prompt/assemble 事件(waterfall)而非 llm/stream——后者请求 deep-frozen 不可改写。
95
+ if (opts.autoRecall) {
96
+ const recallLimit = Math.max(1, Math.min(10, opts.autoRecallLimit || 4))
97
+ ctx.on('system-prompt/assemble', async (assembly, _ctx, next) => {
98
+ try {
99
+ // 异步取最近记忆(失败静默——不阻塞模型请求)
100
+ const r = await bridge.callTool('timeline', { limit: recallLimit })
101
+ const text = extractText(r.content as unknown as ContentBlock[])
102
+ if (text) {
103
+ assembly.contexts.push({
104
+ name: 'lingshu:auto-recall',
105
+ text: `【灵枢最近记忆】\n${text.slice(0, 600)}`,
106
+ })
107
+ }
108
+ }
109
+ catch { /* 静默:召回失败不影响请求 */ }
110
+ return next()
111
+ })
112
+ }
113
+
114
+ ctx.on('session/event', (_session, event: SessionEvent) => {
115
+ if (event.type === 'user/message' && opts.userMessage) {
116
+ // 只记真实用户输入(kind='user'),跳过插件注入/系统上下文
117
+ if (event.data.source?.kind !== 'user') return
118
+ const text = extractText(event.data.content)
119
+ if (!text) return
120
+ const safe = sanitize(text) // 脱敏:纯凭据消息 → null → 跳过写入
121
+ if (safe === null) return
122
+ memorize('remember', { content: safe, importance: opts.importance, tags: ['dsh', 'user'] })
123
+ } else if (event.type === 'assistant/message' && opts.assistantMessage) {
124
+ const text = extractText(event.data.message.content)
125
+ if (!text) return
126
+ const safe = sanitize(text)
127
+ if (safe === null) return
128
+ memorize('remember', { content: safe, importance: opts.importance * 0.8, tags: ['dsh', 'assistant'] })
129
+ } else if (event.type === 'tool/result' && opts.toolResult) {
130
+ if (event.data.error) return
131
+ const text = extractText(event.data.message.content)
132
+ if (!text) return
133
+ const safe = sanitize(text)
134
+ if (safe === null) return
135
+ memorize('remember', { content: safe, importance: opts.importance * 0.6, tags: ['dsh', 'tool'] })
136
+ }
137
+ })
138
+ }