@furongjun1999/dsh-memory 0.5.0 → 0.6.0

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.
Files changed (156) hide show
  1. package/README.md +572 -465
  2. package/docs/README.md +1 -0
  3. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  4. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  5. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  6. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  7. package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
  8. package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
  9. package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
  10. package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
  11. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  12. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
  13. package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
  14. package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
  15. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v1.md +156 -0
  16. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v10.md +210 -0
  17. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v11.md +227 -0
  18. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v12.md +203 -0
  19. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v13.md +233 -0
  20. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v14.md +191 -0
  21. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v15.md +213 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v16.md +214 -0
  23. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v17.md +199 -0
  24. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
  25. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
  26. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v2.md +156 -0
  27. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v3.md +152 -0
  28. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v4.md +128 -0
  29. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v5.md +114 -0
  30. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v6.md +192 -0
  31. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v7.md +187 -0
  32. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v8.md +207 -0
  33. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v9.md +203 -0
  34. package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
  35. package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
  36. package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
  37. package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
  38. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  39. package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
  40. package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.d.ts +9 -0
  43. package/lib/bridge.js +35 -0
  44. package/lib/cli.d.ts +3 -0
  45. package/lib/cli.js +66 -0
  46. package/lib/index.js +7 -1
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +287 -0
  49. package/lib/lib/roleplay_web.js +530 -443
  50. package/lib/lib/token_store.d.ts +7 -1
  51. package/lib/lib/token_store.js +12 -3
  52. package/md_cg/audit.py +12 -1
  53. package/md_cg/backfill.py +16 -15
  54. package/md_cg/backfill_bucket_zh.py +35 -0
  55. package/md_cg/bench_e2e_judge.py +532 -0
  56. package/md_cg/bench_e2e_locomo_qa.py +368 -0
  57. package/md_cg/bench_e2e_qa.py +256 -0
  58. package/md_cg/branches.py +18 -2
  59. package/md_cg/ccgc.py +3 -2
  60. package/md_cg/chain.py +19 -4
  61. package/md_cg/consolidate.py +7 -6
  62. package/md_cg/crosscheck.py +4 -3
  63. package/md_cg/crypto.py +439 -437
  64. package/md_cg/datapath.py +395 -335
  65. package/md_cg/evidence.py +8 -3
  66. package/md_cg/export.py +3 -1
  67. package/md_cg/forgetting.py +2 -2
  68. package/md_cg/fsutil.py +48 -0
  69. package/md_cg/hotcache.py +255 -238
  70. package/md_cg/interop.py +161 -22
  71. package/md_cg/judgment_manifest.py +177 -0
  72. package/md_cg/links.py +655 -622
  73. package/md_cg/mcp_server.py +282 -31
  74. package/md_cg/mdcg.py +702 -126
  75. package/md_cg/mdcos.py +454 -68
  76. package/md_cg/mreview/govern.py +5 -4
  77. package/md_cg/postings.py +4 -2
  78. package/md_cg/readcache.py +76 -18
  79. package/md_cg/reconcile.py +228 -0
  80. package/md_cg/review_cli.py +215 -0
  81. package/md_cg/routing.py +28 -0
  82. package/md_cg/run_tests.py +211 -0
  83. package/md_cg/scrub.py +862 -852
  84. package/md_cg/security.py +385 -275
  85. package/md_cg/selfreport.py +3 -2
  86. package/md_cg/signer.py +565 -562
  87. package/md_cg/sources.py +3 -2
  88. package/md_cg/stg.py +6 -0
  89. package/md_cg/sustain.py +1168 -1138
  90. package/md_cg/test_access_hints.py +147 -0
  91. package/md_cg/test_branch_discard_tombstone.py +136 -0
  92. package/md_cg/test_branches.py +259 -249
  93. package/md_cg/test_chain_read_isolate.py +168 -0
  94. package/md_cg/test_datapath_device_name.py +203 -0
  95. package/md_cg/test_emit_negtail_cache.py +156 -0
  96. package/md_cg/test_en_pipeline.py +186 -166
  97. package/md_cg/test_govern_directread.py +421 -0
  98. package/md_cg/test_i32_hotcache_env_key.py +218 -0
  99. package/md_cg/test_identity_attribution.py +228 -147
  100. package/md_cg/test_index_crossprocess_reload.py +301 -0
  101. package/md_cg/test_index_durability.py +238 -224
  102. package/md_cg/test_interop.py +4 -2
  103. package/md_cg/test_interop_judgment.py +228 -0
  104. package/md_cg/test_issue39_utf8_stdio.py +273 -0
  105. package/md_cg/test_links_concurrent_write.py +188 -0
  106. package/md_cg/test_merge_upsert.py +168 -0
  107. package/md_cg/test_n123_derive_expiry_chain.py +205 -0
  108. package/md_cg/test_n130_verify_falsified_protect.py +185 -0
  109. package/md_cg/test_n131_merge_gate.py +205 -0
  110. package/md_cg/test_none_id_write_guard.py +165 -0
  111. package/md_cg/test_p1x_ref_root.py +160 -0
  112. package/md_cg/test_p27_docindex.py +774 -765
  113. package/md_cg/test_p2_mcp.py +3 -0
  114. package/md_cg/test_p32_backfill.py +304 -298
  115. package/md_cg/test_p39_verify_flow.py +90 -50
  116. package/md_cg/test_p47_session_view.py +60 -25
  117. package/md_cg/test_propose_tail_index.py +157 -0
  118. package/md_cg/test_read_scope_b27.py +277 -0
  119. package/md_cg/test_readcache_default_on.py +168 -0
  120. package/md_cg/test_readcache_precise_inval.py +270 -0
  121. package/md_cg/test_readcache_prodpath.py +55 -7
  122. package/md_cg/test_reconcile_v0.py +294 -0
  123. package/md_cg/test_retr_s1.py +6 -2
  124. package/md_cg/test_retr_s1b.py +67 -0
  125. package/md_cg/test_retr_s7.py +8 -0
  126. package/md_cg/test_retr_s9_entity_ctx.py +181 -175
  127. package/md_cg/test_retr_score_once.py +208 -0
  128. package/md_cg/test_review_cli_attribution.py +177 -0
  129. package/md_cg/test_review_cli_visibility.py +235 -0
  130. package/md_cg/test_review_onepass.py +170 -0
  131. package/md_cg/test_rrf_graph_seed_cache.py +154 -0
  132. package/md_cg/test_security_audit.py +155 -0
  133. package/md_cg/test_security_audit_b26.py +161 -0
  134. package/md_cg/test_security_audit_v21.py +250 -0
  135. package/md_cg/test_semantic_canonical.py +255 -241
  136. package/md_cg/test_session_isolation.py +168 -0
  137. package/md_cg/test_snapshot_autoclose.py +187 -0
  138. package/md_cg/test_tail_watermark_race.py +208 -0
  139. package/md_cg/test_tenant_env_override_warn.py +139 -0
  140. package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
  141. package/md_cg/test_v14_fixes.py +415 -397
  142. package/md_cg/test_verify_dirty_reconcile.py +157 -0
  143. package/md_cg/theory.py +276 -273
  144. package/md_cg/tokens.py +734 -677
  145. package/md_cg/units.py +3 -2
  146. package/md_cg/vision_evidence.py +4 -3
  147. package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
  148. package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
  149. package/md_cg/writepipe.py +554 -550
  150. package/package.json +11 -2
  151. package/src/bridge.ts +434 -401
  152. package/src/cli.ts +65 -0
  153. package/src/index.ts +526 -518
  154. package/src/init.ts +347 -0
  155. package/src/lib/roleplay_web.ts +1019 -932
  156. package/src/lib/token_store.ts +202 -192
package/src/bridge.ts CHANGED
@@ -1,401 +1,434 @@
1
- /**
2
- * 灵枢 MCP stdio 桥:管理大脑(md_cg)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
- // issue #19:自检命令文案按平台给出(Windows python / 其它 python3)、
16
- // 并在 ENOENT 时把「解释器名不匹配」这一第一因直接写进日志。
17
- import { explainMissingPython, selfCheckCommand } from './lib/python_path.js'
18
-
19
- /**
20
- * 调试探针:记录桥生命周期到独立文件(绕过 DSH 日志系统,便于定位启动问题)。
21
- * 路径从用户家目录动态解析(issue #5)——此前硬编码作者本机绝对路径,
22
- * 其他用户机器上目录不存在且无权限,探针静默失效,恰在排查启动问题时失明。
23
- */
24
- const DEBUG_LOG = join(homedir(), '.dsh', 'logs', 'lingshu-bridge-debug.log')
25
- let debugLogDirReady = false
26
- function probe(msg: string): void {
27
- try {
28
- if (!debugLogDirReady) {
29
- mkdirSync(dirname(DEBUG_LOG), { recursive: true })
30
- debugLogDirReady = true
31
- }
32
- appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`)
33
- } catch { /* 探针失败忽略 */ }
34
- }
35
-
36
- /** 灵枢 MCP server 暴露的原始工具(tools/list 结果项)。 */
37
- export interface McpTool {
38
- name: string
39
- description: string
40
- inputSchema?: Record<string, unknown>
41
- }
42
-
43
- /** 一次 tools/call 的标准化结果。 */
44
- export interface McpCallResult {
45
- content: Array<{ type: string; text?: string; [key: string]: unknown }>
46
- isError: boolean
47
- }
48
-
49
- /** 启动灵枢子进程的配置。 */
50
- export interface BridgeOptions {
51
- /** Python 可执行文件(或 md_cg-mcp console script),默认 python。 */
52
- python: string
53
- /** 传给 python 的参数,默认 ['-m', 'md_cg.mcp_server']。 */
54
- args: string[]
55
- /** 追加到子进程的环境变量(MDCG_* / MDCG_MCP_SURFACE / 密钥等)。 */
56
- env: Record<string, string>
57
- /** 子进程工作目录。 */
58
- cwd?: string
59
- /** 单次工具调用超时(毫秒),默认 60s。 */
60
- timeoutMs: number
61
- /** 断线重连的最大间隔(毫秒),默认 30s。 */
62
- maxRetryDelayMs: number
63
- }
64
-
65
- interface PendingCall {
66
- resolve: (value: unknown) => void
67
- reject: (reason: Error) => void
68
- timer: NodeJS.Timeout
69
- }
70
-
71
- /**
72
- * 连续启动失败的最大重试次数(issue #6):指数退避到 maxRetryDelayMs 后
73
- * 若无上限,python 环境损坏时会以 30s 间隔永久空转(假激活 + 资源浪费)。
74
- * 达到上限即进入 failed 终态并停止调度;后续所有请求快速失败并给出
75
- * 明确的修复指引。握手成功后计数归零——运行期偶发崩溃重启不受影响。
76
- */
77
- const MAX_RETRIES = 8
78
-
79
- export class LingshuBridge {
80
- private readonly options: BridgeOptions
81
- private proc: ChildProcess | null = null
82
- private rl: ReturnType<typeof createInterface> | null = null
83
- private nextId = 1
84
- private pending = new Map<number, PendingCall>()
85
- private started = false
86
- private disposed = false
87
- private retryDelayMs = 1000
88
- private retries = 0
89
- private retryTimer: NodeJS.Timeout | null = null
90
- private bootQueue: Array<(ok: boolean) => void> = []
91
- private readyState: 'pending' | 'ok' | 'failed' = 'pending'
92
- /** issue #7:当前子进程启动时间(计算 uptime,区分秒退与长存后外部关闭) */
93
- private procStartedAt = 0
94
- /** issue #7:运行期反复退出的滑动窗口(时间戳列表)——防无限重启刷屏 */
95
- private unexpectedExits: number[] = []
96
-
97
- constructor(options: BridgeOptions) {
98
- this.options = options
99
- }
100
-
101
- /**
102
- * 启动灵枢进程并完成握手(initialize → notifications/initialized)。
103
- * 进程崩溃后自动指数退避重启,并重新握手。
104
- */
105
- start(): void {
106
- if (this.started || this.disposed) return
107
- this.started = true
108
- this.spawnAndHandshake()
109
- }
110
-
111
- /** 拉取灵枢的全部工具清单(每次实时请求,不缓存)。 */
112
- async listTools(): Promise<McpTool[]> {
113
- const result = await this.request('tools/list', {})
114
- const tools = result as { tools?: McpTool[] }
115
- return tools.tools ?? []
116
- }
117
-
118
- /** 调用灵枢的一个工具,返回标准化 MCP 结果。 */
119
- async callTool(name: string, args: Record<string, unknown>,
120
- signal?: AbortSignal): Promise<McpCallResult> {
121
- const result = await this.request('tools/call', { name, arguments: args }, signal)
122
- return result as McpCallResult
123
- }
124
-
125
- /** 关闭进程并释放资源(写 stdin EOF 优雅退出,超时兜底 kill)。 */
126
- dispose(): void {
127
- this.disposed = true
128
- if (this.retryTimer) clearTimeout(this.retryTimer)
129
- const proc = this.proc
130
- if (!proc || proc.exitCode !== null) return
131
- try {
132
- proc.stdin?.end()
133
- } catch {
134
- /* 已关闭则忽略 */
135
- }
136
- const killer = setTimeout(() => proc.kill(), 2000)
137
- killer.unref()
138
- }
139
-
140
- /** 进程是否存活。 */
141
- get alive(): boolean {
142
- return this.proc !== null && this.proc.exitCode === null
143
- }
144
-
145
- /** 桥是否已握手就绪(工具注册用;防止轮询访问 private readyState)。 */
146
- isReady(): boolean {
147
- return this.readyState === 'ok'
148
- }
149
-
150
- /** 是否已达放弃终态(连续启动失败超上限,不再自动重启)。 */
151
- get gaveUp(): boolean {
152
- return this.retries >= MAX_RETRIES
153
- }
154
-
155
- /** 等待握手完成(用于 apply 阶段同步就绪)。 */
156
- waitReady(): Promise<boolean> {
157
- if (this.readyState === 'ok') return Promise.resolve(true)
158
- if (this.readyState === 'failed') return Promise.resolve(false)
159
- return new Promise((resolve) => this.bootQueue.push(resolve))
160
- }
161
-
162
- private spawnAndHandshake(): void {
163
- if (this.disposed) return
164
- this.procStartedAt = Date.now()
165
- const { python, args, env, cwd } = this.options
166
- const childEnv = { ...process.env, ...env }
167
- // 确保 DB 目录存在(灵枢 server 也会防御性创建,这里提前为可读错误)
168
- const dbPath = env['AEIS_DB']
169
- if (dbPath && dbPath !== ':memory:') {
170
- try {
171
- mkdirSync(dirname(dbPath), { recursive: true })
172
- } catch {
173
- /* 目录创建失败由灵枢侧兜底 */
174
- }
175
- }
176
- const proc = spawn(python, args, {
177
- env: childEnv,
178
- cwd,
179
- stdio: ['pipe', 'pipe', 'pipe'],
180
- windowsHide: true,
181
- })
182
- this.proc = proc
183
- this.rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity })
184
-
185
- proc.stderr?.on('data', (chunk: Buffer) => {
186
- // stderr 透传日志(灵枢把日志写在 stderr,避免污染协议流)
187
- const text = chunk.toString('utf8').trim()
188
- if (text) console.error(`[lingshu-bridge] ${text}`)
189
- })
190
-
191
- this.rl.on('line', (line: string) => {
192
- if (!line.trim()) return
193
- let msg: Record<string, unknown>
194
- try {
195
- msg = JSON.parse(line)
196
- } catch {
197
- console.error(`[lingshu-bridge] 非 JSON 输出: ${line.slice(0, 200)}`)
198
- return
199
- }
200
- if (typeof msg['id'] === 'number') {
201
- this.settle(msg['id'] as number, msg)
202
- }
203
- })
204
-
205
- proc.on('error', (err) => {
206
- // 进程无法启动(python 不存在等)——响亮失败
207
- if (!this.disposed) {
208
- console.error(`[lingshu-bridge] 灵枢进程启动失败: ${err.message}`)
209
- // issue #19:ENOENT 的第一因通常不是「环境损坏」,而是解释器名与平台不匹配
210
- // (Linux/macOS 按 PEP 394 只有 python3)。把成因与修法写进同一条日志——
211
- // 原始日志把该缺陷伪装成「调用超时」,用户按超时方向排查只会白耗一轮。
212
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
213
- console.error(`[lingshu-bridge] ${explainMissingPython(python)}`)
214
- }
215
- probe(`spawn error: ${String(err)}`)
216
- this.readyState = 'failed'
217
- this.flushBootQueue(false)
218
- this.scheduleRetry()
219
- }
220
- })
221
-
222
- proc.on('exit', (code, signal) => {
223
- // 探针:写独立文件记录退出(绕过 DSH 日志系统,便于定位)
224
- const uptimeS = this.procStartedAt
225
- ? Math.round((Date.now() - this.procStartedAt) / 1000)
226
- : -1
227
- probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started} uptime=${uptimeS}s`)
228
- console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}(存活 ${uptimeS}s)`)
229
- this.rl?.close()
230
- this.rl = null
231
- this.rejectAll(new Error(`灵枢进程已退出(code=${code} signal=${signal ?? 'none'})`))
232
- if (!this.disposed) {
233
- // issue #7:长存后 code=0 退出 = stdin 被外部关闭(非崩溃)。
234
- // 记录滑动窗口(10 分钟内 ≥3 次)→ 进入冷却,防「外部反复关闭 +
235
- // 无限重启」刷屏;冷却结束自动恢复,不进入放弃终态。
236
- this.unexpectedExits = this.unexpectedExits.filter(
237
- (t) => Date.now() - t < 600_000,
238
- )
239
- this.unexpectedExits.push(Date.now())
240
- if (uptimeS >= 0 && uptimeS < 5) {
241
- console.error(
242
- `[lingshu-bridge] 进程启动后 5 秒内即退出——请检查 ${python} 可执行文件与 md_cg 依赖。` +
243
- `自检 \`${selfCheckCommand(python)}\` 须在插件包根目录运行` +
244
- '(插件已自动锚定 cwd 与 PYTHONPATH,issue #12)。')
245
- } else if (this.unexpectedExits.length >= 3) {
246
- console.error(
247
- `[lingshu-bridge] 10 分钟内已意外退出 ${this.unexpectedExits.length} 次,` +
248
- '冷却 5 分钟后自动恢复。若持续出现,请检查是否有多个插件/脚本'
249
- + '同时管理灵枢进程(重复 spawn 会互相关闭对方子进程的 stdin)。')
250
- probe(`cooldown: ${this.unexpectedExits.length} unexpected exits in 10min`)
251
- this.retryTimer = setTimeout(() => {
252
- this.retryTimer = null
253
- this.unexpectedExits = []
254
- if (!this.disposed) this.spawnAndHandshake()
255
- }, 300_000)
256
- this.retryTimer.unref()
257
- return
258
- }
259
- // 有挂起请求的失败是异常的;仅启动失败的等待者得到 false
260
- this.readyState = 'failed'
261
- this.flushBootQueue(false)
262
- this.scheduleRetry()
263
- }
264
- })
265
-
266
- void this.handshake()
267
- }
268
-
269
- private async handshake(): Promise<void> {
270
- try {
271
- await this.request('initialize', {
272
- protocolVersion: '2024-11-05',
273
- capabilities: {},
274
- clientInfo: { name: 'dsh-memory', version: '0.1.0' },
275
- })
276
- // 初始化通知:无 id、无响应
277
- this.writeRaw({ jsonrpc: '2.0', method: 'notifications/initialized' })
278
- // 握手成功:重置退避与失败计数(issue #6)——运行期偶发崩溃重启
279
- // 不累积启动失败;只有「从未握手成功的连续失败」才走向放弃终态
280
- this.retryDelayMs = 1000
281
- this.retries = 0
282
- this.readyState = 'ok'
283
- this.flushBootQueue(true)
284
- } catch (err) {
285
- if (!this.disposed) {
286
- console.error(`[lingshu-bridge] 握手失败: ${(err as Error).message}`)
287
- probe(`handshake failed: ${String(err)}`)
288
- this.readyState = 'failed'
289
- this.flushBootQueue(false)
290
- this.scheduleRetry()
291
- }
292
- }
293
- }
294
-
295
- private scheduleRetry(): void {
296
- if (this.disposed || this.retryTimer || this.gaveUp) return
297
- this.retries += 1
298
- if (this.gaveUp) {
299
- // issue #6:放弃机制——明确终态,停止后台空转;请求方收到清晰错误而非超时
300
- this.readyState = 'failed'
301
- this.flushBootQueue(false)
302
- console.error(
303
- `[lingshu-bridge] 连续启动失败 ${this.retries} 次,已停止自动重启(不再后台空转)。` +
304
- `请检查 ${this.options.python} 可执行文件与 md_cg 依赖;` +
305
- `自检 \`${selfCheckCommand(this.options.python)}\` 须在插件包根目录运行` +
306
- '(插件已自动锚定 cwd 与 PYTHONPATH,issue #12),修复后在 DSH 中重新启用 dsh-memory 插件。')
307
- probe(`give up: ${this.retries} consecutive failures, entering failed terminal state`)
308
- return
309
- }
310
- const delay = this.retryDelayMs
311
- this.retryDelayMs = Math.min(this.retryDelayMs * 2, this.options.maxRetryDelayMs)
312
- console.error(`[lingshu-bridge] 将进行第 ${this.retries}/${MAX_RETRIES} 次重试(${delay}ms 后)`)
313
- probe(`schedule retry ${this.retries}/${MAX_RETRIES} in ${delay}ms`)
314
- this.retryTimer = setTimeout(() => {
315
- this.retryTimer = null
316
- if (!this.disposed) this.spawnAndHandshake()
317
- }, delay)
318
- this.retryTimer.unref()
319
- }
320
-
321
- private writeRaw(msg: Record<string, unknown>): void {
322
- const proc = this.proc
323
- if (!proc || !proc.stdin?.writable) {
324
- throw new Error('灵枢进程不可写')
325
- }
326
- proc.stdin.write(JSON.stringify(msg) + '\n')
327
- }
328
-
329
- private request(method: string, params: Record<string, unknown>,
330
- signal?: AbortSignal): Promise<unknown> {
331
- // issue #6:放弃终态下快速失败——之前进程不在且不再重启,请求只能白等超时
332
- if (this.gaveUp) {
333
- return Promise.reject(new Error(
334
- '灵枢进程不可用:连续启动失败已达上限,已停止重试。' +
335
- `请检查 ${this.options.python} 可执行文件与 md_cg 依赖` +
336
- `(自检 \`${selfCheckCommand(this.options.python)}\` 须在插件包根目录运行),` +
337
- '修复后重新启用 dsh-memory 插件。'))
338
- }
339
- const id = this.nextId++
340
- const timeout = this.options.timeoutMs
341
- // P0 修复(GPT 审查):定时器直接调 settle——settle 内部会 delete + clearTimeout
342
- // 并 reject。此前先 pending.delete(id) 再 settle(),settle 找不到 entry 直接
343
- // return,Promise 永不 resolve/reject(超时逻辑完全失效,子进程挂死时插件永久挂住)。
344
- const timer = setTimeout(() => {
345
- this.settle(id, {
346
- error: { code: -32000, message: `灵枢调用超时(${timeout}ms):${method}` },
347
- })
348
- }, timeout)
349
- if (signal?.aborted) {
350
- clearTimeout(timer)
351
- return Promise.reject(new Error(`已取消:${method}`))
352
- }
353
- const onAbort = (): void => {
354
- this.settle(id, { error: { code: -32800, message: `已取消:${method}` } })
355
- }
356
- signal?.addEventListener('abort', onAbort, { once: true })
357
- const promise = new Promise<unknown>((resolve, reject) => {
358
- this.pending.set(id, { resolve, reject, timer })
359
- })
360
- // settle 后移除 abort 监听,避免内存积累
361
- promise.finally(() => {
362
- signal?.removeEventListener('abort', onAbort)
363
- }).catch(() => { /* finally 链的 catch 防未处理拒绝 */ })
364
- try {
365
- this.writeRaw({ jsonrpc: '2.0', id, method, params })
366
- } catch (err) {
367
- clearTimeout(timer)
368
- this.pending.delete(id)
369
- signal?.removeEventListener('abort', onAbort)
370
- return Promise.reject(err)
371
- }
372
- return promise
373
- }
374
-
375
- private settle(id: number, msg: Record<string, unknown>): void {
376
- const entry = this.pending.get(id)
377
- if (!entry) return
378
- this.pending.delete(id)
379
- clearTimeout(entry.timer)
380
- if (msg['error']) {
381
- const err = msg['error'] as { message?: string; code?: number }
382
- entry.reject(new Error(`灵枢错误 ${err.code ?? ''}: ${err.message ?? 'unknown'}`))
383
- } else {
384
- entry.resolve(msg['result'])
385
- }
386
- }
387
-
388
- private rejectAll(reason: Error): void {
389
- for (const [, entry] of this.pending) {
390
- clearTimeout(entry.timer)
391
- entry.reject(reason)
392
- }
393
- this.pending.clear()
394
- }
395
-
396
- private flushBootQueue(ok: boolean): void {
397
- const queue = this.bootQueue
398
- this.bootQueue = []
399
- for (const cb of queue) cb(ok)
400
- }
401
- }
1
+ /**
2
+ * 灵枢 MCP stdio 桥:管理大脑(md_cg)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
+ // issue #19:自检命令文案按平台给出(Windows python / 其它 python3)、
16
+ // 并在 ENOENT 时把「解释器名不匹配」这一第一因直接写进日志。
17
+ import { explainMissingPython, selfCheckCommand } from './lib/python_path.js'
18
+
19
+ /**
20
+ * 调试探针:记录桥生命周期到独立文件(绕过 DSH 日志系统,便于定位启动问题)。
21
+ * 路径从用户家目录动态解析(issue #5)——此前硬编码作者本机绝对路径,
22
+ * 其他用户机器上目录不存在且无权限,探针静默失效,恰在排查启动问题时失明。
23
+ */
24
+ const DEBUG_LOG = join(homedir(), '.dsh', 'logs', 'lingshu-bridge-debug.log')
25
+ let debugLogDirReady = false
26
+ function probe(msg: string): void {
27
+ try {
28
+ if (!debugLogDirReady) {
29
+ mkdirSync(dirname(DEBUG_LOG), { recursive: true })
30
+ debugLogDirReady = true
31
+ }
32
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`)
33
+ } catch { /* 探针失败忽略 */ }
34
+ }
35
+
36
+ /** 灵枢 MCP server 暴露的原始工具(tools/list 结果项)。 */
37
+ export interface McpTool {
38
+ name: string
39
+ description: string
40
+ inputSchema?: Record<string, unknown>
41
+ }
42
+
43
+ /** 一次 tools/call 的标准化结果。 */
44
+ export interface McpCallResult {
45
+ content: Array<{ type: string; text?: string; [key: string]: unknown }>
46
+ isError: boolean
47
+ }
48
+
49
+ /** 启动灵枢子进程的配置。 */
50
+ export interface BridgeOptions {
51
+ /** Python 可执行文件(或 md_cg-mcp console script),默认 python。 */
52
+ python: string
53
+ /** 传给 python 的参数,默认 ['-m', 'md_cg.mcp_server']。 */
54
+ args: string[]
55
+ /** 追加到子进程的环境变量(MDCG_* / MDCG_MCP_SURFACE / 密钥等)。 */
56
+ env: Record<string, string>
57
+ /** 子进程工作目录。 */
58
+ cwd?: string
59
+ /** 单次工具调用超时(毫秒),默认 60s。 */
60
+ timeoutMs: number
61
+ /** 断线重连的最大间隔(毫秒),默认 30s。 */
62
+ maxRetryDelayMs: number
63
+ }
64
+
65
+ interface PendingCall {
66
+ resolve: (value: unknown) => void
67
+ reject: (reason: Error) => void
68
+ timer: NodeJS.Timeout
69
+ }
70
+
71
+ /**
72
+ * 连续启动失败的最大重试次数(issue #6):指数退避到 maxRetryDelayMs 后
73
+ * 若无上限,python 环境损坏时会以 30s 间隔永久空转(假激活 + 资源浪费)。
74
+ * 达到上限即进入 failed 终态并停止调度;后续所有请求快速失败并给出
75
+ * 明确的修复指引。握手成功后计数归零——运行期偶发崩溃重启不受影响。
76
+ */
77
+ const MAX_RETRIES = 8
78
+
79
+ export class LingshuBridge {
80
+ private readonly options: BridgeOptions
81
+ private proc: ChildProcess | null = null
82
+ private rl: ReturnType<typeof createInterface> | null = null
83
+ private nextId = 1
84
+ private pending = new Map<number, PendingCall>()
85
+ private started = false
86
+ private disposed = false
87
+ private retryDelayMs = 1000
88
+ private retries = 0
89
+ private retryTimer: NodeJS.Timeout | null = null
90
+ private bootQueue: Array<(ok: boolean) => void> = []
91
+ private readyState: 'pending' | 'ok' | 'failed' = 'pending'
92
+ /** issue #7:当前子进程启动时间(计算 uptime,区分秒退与长存后外部关闭) */
93
+ private procStartedAt = 0
94
+ /** issue #7:运行期反复退出的滑动窗口(时间戳列表)——防无限重启刷屏 */
95
+ private unexpectedExits: number[] = []
96
+
97
+ constructor(options: BridgeOptions) {
98
+ this.options = options
99
+ }
100
+
101
+ /**
102
+ * 启动灵枢进程并完成握手(initialize → notifications/initialized)。
103
+ * 进程崩溃后自动指数退避重启,并重新握手。
104
+ */
105
+ start(): void {
106
+ if (this.started || this.disposed) return
107
+ this.started = true
108
+ this.spawnAndHandshake()
109
+ }
110
+
111
+ /** 拉取灵枢的全部工具清单(每次实时请求,不缓存)。 */
112
+ async listTools(): Promise<McpTool[]> {
113
+ const result = await this.request('tools/list', {})
114
+ const tools = result as { tools?: McpTool[] }
115
+ return tools.tools ?? []
116
+ }
117
+
118
+ /** 调用灵枢的一个工具,返回标准化 MCP 结果。 */
119
+ async callTool(name: string, args: Record<string, unknown>,
120
+ signal?: AbortSignal): Promise<McpCallResult> {
121
+ const result = await this.request('tools/call', { name, arguments: args }, signal)
122
+ return result as McpCallResult
123
+ }
124
+
125
+ /** 关闭进程并释放资源(写 stdin EOF 优雅退出,超时兜底 kill)。 */
126
+ dispose(): void {
127
+ this.disposed = true
128
+ if (this.retryTimer) clearTimeout(this.retryTimer)
129
+ const proc = this.proc
130
+ if (!proc || proc.exitCode !== null) return
131
+ try {
132
+ proc.stdin?.end()
133
+ } catch {
134
+ /* 已关闭则忽略 */
135
+ }
136
+ const killer = setTimeout(() => proc.kill(), 2000)
137
+ killer.unref()
138
+ }
139
+
140
+ /** 进程是否存活。 */
141
+ get alive(): boolean {
142
+ return this.proc !== null && this.proc.exitCode === null
143
+ }
144
+
145
+ /** 桥是否已握手就绪(工具注册用;防止轮询访问 private readyState)。 */
146
+ isReady(): boolean {
147
+ return this.readyState === 'ok'
148
+ }
149
+
150
+ /** 是否已达放弃终态(连续启动失败超上限,不再自动重启)。 */
151
+ get gaveUp(): boolean {
152
+ return this.retries >= MAX_RETRIES
153
+ }
154
+
155
+ /** 等待握手完成(用于 apply 阶段同步就绪)。 */
156
+ waitReady(): Promise<boolean> {
157
+ if (this.readyState === 'ok') return Promise.resolve(true)
158
+ if (this.readyState === 'failed') return Promise.resolve(false)
159
+ return new Promise((resolve) => this.bootQueue.push(resolve))
160
+ }
161
+
162
+ /**
163
+ * V23 修复(缺陷 :284):清理上一个仍存活的子进程。
164
+ * 握手超时/启动失败重试时直接 spawn 新进程并覆盖 this.proc(:182),
165
+ * 旧进程若仍存活(卡死但不退出的服务端)既不 kill 也不关 stdin——
166
+ * 引用被覆盖后永远无人清理 = 僵尸进程泄漏(最多 MAX_RETRIES 个并存,
167
+ * dispose 也只收尾最后一个)。换代前统一回收:stdin EOF 优雅退出 + kill 兜底。
168
+ * 幂等:已退出(exitCode/signalCode 非 null)的进程跳过。
169
+ */
170
+ private killStaleProc(): void {
171
+ const old = this.proc
172
+ this.proc = null // 先摘引用:旧进程随后的 exit 事件走 stale 早退分支
173
+ if (!old || old.exitCode !== null || old.signalCode !== null) return
174
+ try {
175
+ old.stdin?.end()
176
+ } catch {
177
+ /* 已关闭则忽略 */
178
+ }
179
+ try {
180
+ old.kill()
181
+ } catch {
182
+ /* 已退出则忽略 */
183
+ }
184
+ }
185
+
186
+ private spawnAndHandshake(): void {
187
+ if (this.disposed) return
188
+ // V23:重试路径先回收上一个仍存活的子进程(防换代泄漏僵尸进程)
189
+ this.killStaleProc()
190
+ this.procStartedAt = Date.now()
191
+ const { python, args, env, cwd } = this.options
192
+ const childEnv = { ...process.env, ...env }
193
+ // 确保 DB 目录存在(灵枢 server 也会防御性创建,这里提前为可读错误)
194
+ const dbPath = env['AEIS_DB']
195
+ if (dbPath && dbPath !== ':memory:') {
196
+ try {
197
+ mkdirSync(dirname(dbPath), { recursive: true })
198
+ } catch {
199
+ /* 目录创建失败由灵枢侧兜底 */
200
+ }
201
+ }
202
+ const proc = spawn(python, args, {
203
+ env: childEnv,
204
+ cwd,
205
+ stdio: ['pipe', 'pipe', 'pipe'],
206
+ windowsHide: true,
207
+ })
208
+ this.proc = proc
209
+ this.rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity })
210
+
211
+ proc.stderr?.on('data', (chunk: Buffer) => {
212
+ // stderr 透传日志(灵枢把日志写在 stderr,避免污染协议流)
213
+ const text = chunk.toString('utf8').trim()
214
+ if (text) console.error(`[lingshu-bridge] ${text}`)
215
+ })
216
+
217
+ this.rl.on('line', (line: string) => {
218
+ if (!line.trim()) return
219
+ let msg: Record<string, unknown>
220
+ try {
221
+ msg = JSON.parse(line)
222
+ } catch {
223
+ console.error(`[lingshu-bridge] 非 JSON 输出: ${line.slice(0, 200)}`)
224
+ return
225
+ }
226
+ if (typeof msg['id'] === 'number') {
227
+ this.settle(msg['id'] as number, msg)
228
+ }
229
+ })
230
+
231
+ proc.on('error', (err) => {
232
+ // 进程无法启动(python 不存在等)——响亮失败
233
+ if (!this.disposed) {
234
+ console.error(`[lingshu-bridge] 灵枢进程启动失败: ${err.message}`)
235
+ // issue #19:ENOENT 的第一因通常不是「环境损坏」,而是解释器名与平台不匹配
236
+ // (Linux/macOS 按 PEP 394 只有 python3)。把成因与修法写进同一条日志——
237
+ // 原始日志把该缺陷伪装成「调用超时」,用户按超时方向排查只会白耗一轮。
238
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
239
+ console.error(`[lingshu-bridge] ${explainMissingPython(python)}`)
240
+ }
241
+ probe(`spawn error: ${String(err)}`)
242
+ this.readyState = 'failed'
243
+ this.flushBootQueue(false)
244
+ this.scheduleRetry()
245
+ }
246
+ })
247
+
248
+ proc.on('exit', (code, signal) => {
249
+ // V23:被换代清理的旧进程退出(this.proc 已指向新一代或为 null)——
250
+ // 不动当前进程的 rl/pending/重试状态机,只留探针;否则旧 exit 会
251
+ // close 新进程的 readline、误拒新进程的挂起请求并多触发一轮重试。
252
+ if (proc !== this.proc) {
253
+ probe(`stale proc exit code=${code} signal=${signal}(换代已回收,不影响当前进程)`)
254
+ return
255
+ }
256
+ // 探针:写独立文件记录退出(绕过 DSH 日志系统,便于定位)
257
+ const uptimeS = this.procStartedAt
258
+ ? Math.round((Date.now() - this.procStartedAt) / 1000)
259
+ : -1
260
+ probe(`exit code=${code} signal=${signal} disposed=${this.disposed} ready=${this.readyState} started=${this.started} uptime=${uptimeS}s`)
261
+ console.error(`[lingshu-bridge] 灵枢进程退出 code=${code} signal=${signal}(存活 ${uptimeS}s)`)
262
+ this.rl?.close()
263
+ this.rl = null
264
+ this.rejectAll(new Error(`灵枢进程已退出(code=${code} signal=${signal ?? 'none'})`))
265
+ if (!this.disposed) {
266
+ // issue #7:长存后 code=0 退出 = stdin 被外部关闭(非崩溃)。
267
+ // 记录滑动窗口(10 分钟内 ≥3 次)→ 进入冷却,防「外部反复关闭 +
268
+ // 无限重启」刷屏;冷却结束自动恢复,不进入放弃终态。
269
+ this.unexpectedExits = this.unexpectedExits.filter(
270
+ (t) => Date.now() - t < 600_000,
271
+ )
272
+ this.unexpectedExits.push(Date.now())
273
+ if (uptimeS >= 0 && uptimeS < 5) {
274
+ console.error(
275
+ `[lingshu-bridge] 进程启动后 5 秒内即退出——请检查 ${python} 可执行文件与 md_cg 依赖。` +
276
+ `自检 \`${selfCheckCommand(python)}\` 须在插件包根目录运行` +
277
+ '(插件已自动锚定 cwd 与 PYTHONPATH,issue #12)。')
278
+ } else if (this.unexpectedExits.length >= 3) {
279
+ console.error(
280
+ `[lingshu-bridge] 10 分钟内已意外退出 ${this.unexpectedExits.length} 次,` +
281
+ '冷却 5 分钟后自动恢复。若持续出现,请检查是否有多个插件/脚本'
282
+ + '同时管理灵枢进程(重复 spawn 会互相关闭对方子进程的 stdin)。')
283
+ probe(`cooldown: ${this.unexpectedExits.length} unexpected exits in 10min`)
284
+ this.retryTimer = setTimeout(() => {
285
+ this.retryTimer = null
286
+ this.unexpectedExits = []
287
+ if (!this.disposed) this.spawnAndHandshake()
288
+ }, 300_000)
289
+ this.retryTimer.unref()
290
+ return
291
+ }
292
+ // 有挂起请求的失败是异常的;仅启动失败的等待者得到 false
293
+ this.readyState = 'failed'
294
+ this.flushBootQueue(false)
295
+ this.scheduleRetry()
296
+ }
297
+ })
298
+
299
+ void this.handshake()
300
+ }
301
+
302
+ private async handshake(): Promise<void> {
303
+ try {
304
+ await this.request('initialize', {
305
+ protocolVersion: '2024-11-05',
306
+ capabilities: {},
307
+ clientInfo: { name: 'dsh-memory', version: '0.1.0' },
308
+ })
309
+ // 初始化通知:无 id、无响应
310
+ this.writeRaw({ jsonrpc: '2.0', method: 'notifications/initialized' })
311
+ // 握手成功:重置退避与失败计数(issue #6)——运行期偶发崩溃重启
312
+ // 不累积启动失败;只有「从未握手成功的连续失败」才走向放弃终态
313
+ this.retryDelayMs = 1000
314
+ this.retries = 0
315
+ this.readyState = 'ok'
316
+ this.flushBootQueue(true)
317
+ } catch (err) {
318
+ if (!this.disposed) {
319
+ console.error(`[lingshu-bridge] 握手失败: ${(err as Error).message}`)
320
+ probe(`handshake failed: ${String(err)}`)
321
+ this.readyState = 'failed'
322
+ this.flushBootQueue(false)
323
+ this.scheduleRetry()
324
+ }
325
+ }
326
+ }
327
+
328
+ private scheduleRetry(): void {
329
+ if (this.disposed || this.retryTimer || this.gaveUp) return
330
+ this.retries += 1
331
+ if (this.gaveUp) {
332
+ // issue #6:放弃机制——明确终态,停止后台空转;请求方收到清晰错误而非超时
333
+ this.readyState = 'failed'
334
+ this.flushBootQueue(false)
335
+ console.error(
336
+ `[lingshu-bridge] 连续启动失败 ${this.retries} 次,已停止自动重启(不再后台空转)。` +
337
+ `请检查 ${this.options.python} 可执行文件与 md_cg 依赖;` +
338
+ `自检 \`${selfCheckCommand(this.options.python)}\` 须在插件包根目录运行` +
339
+ '(插件已自动锚定 cwd 与 PYTHONPATH,issue #12),修复后在 DSH 中重新启用 dsh-memory 插件。')
340
+ probe(`give up: ${this.retries} consecutive failures, entering failed terminal state`)
341
+ return
342
+ }
343
+ const delay = this.retryDelayMs
344
+ this.retryDelayMs = Math.min(this.retryDelayMs * 2, this.options.maxRetryDelayMs)
345
+ console.error(`[lingshu-bridge] 将进行第 ${this.retries}/${MAX_RETRIES} 次重试(${delay}ms 后)`)
346
+ probe(`schedule retry ${this.retries}/${MAX_RETRIES} in ${delay}ms`)
347
+ this.retryTimer = setTimeout(() => {
348
+ this.retryTimer = null
349
+ if (!this.disposed) this.spawnAndHandshake()
350
+ }, delay)
351
+ this.retryTimer.unref()
352
+ }
353
+
354
+ private writeRaw(msg: Record<string, unknown>): void {
355
+ const proc = this.proc
356
+ if (!proc || !proc.stdin?.writable) {
357
+ throw new Error('灵枢进程不可写')
358
+ }
359
+ proc.stdin.write(JSON.stringify(msg) + '\n')
360
+ }
361
+
362
+ private request(method: string, params: Record<string, unknown>,
363
+ signal?: AbortSignal): Promise<unknown> {
364
+ // issue #6:放弃终态下快速失败——之前进程不在且不再重启,请求只能白等超时
365
+ if (this.gaveUp) {
366
+ return Promise.reject(new Error(
367
+ '灵枢进程不可用:连续启动失败已达上限,已停止重试。' +
368
+ `请检查 ${this.options.python} 可执行文件与 md_cg 依赖` +
369
+ `(自检 \`${selfCheckCommand(this.options.python)}\` 须在插件包根目录运行),` +
370
+ '修复后重新启用 dsh-memory 插件。'))
371
+ }
372
+ const id = this.nextId++
373
+ const timeout = this.options.timeoutMs
374
+ // P0 修复(GPT 审查):定时器直接调 settle——settle 内部会 delete + clearTimeout
375
+ // 并 reject。此前先 pending.delete(id) 再 settle(),settle 找不到 entry 直接
376
+ // return,Promise 永不 resolve/reject(超时逻辑完全失效,子进程挂死时插件永久挂住)。
377
+ const timer = setTimeout(() => {
378
+ this.settle(id, {
379
+ error: { code: -32000, message: `灵枢调用超时(${timeout}ms):${method}` },
380
+ })
381
+ }, timeout)
382
+ if (signal?.aborted) {
383
+ clearTimeout(timer)
384
+ return Promise.reject(new Error(`已取消:${method}`))
385
+ }
386
+ const onAbort = (): void => {
387
+ this.settle(id, { error: { code: -32800, message: `已取消:${method}` } })
388
+ }
389
+ signal?.addEventListener('abort', onAbort, { once: true })
390
+ const promise = new Promise<unknown>((resolve, reject) => {
391
+ this.pending.set(id, { resolve, reject, timer })
392
+ })
393
+ // settle 后移除 abort 监听,避免内存积累
394
+ promise.finally(() => {
395
+ signal?.removeEventListener('abort', onAbort)
396
+ }).catch(() => { /* finally 链的 catch 防未处理拒绝 */ })
397
+ try {
398
+ this.writeRaw({ jsonrpc: '2.0', id, method, params })
399
+ } catch (err) {
400
+ clearTimeout(timer)
401
+ this.pending.delete(id)
402
+ signal?.removeEventListener('abort', onAbort)
403
+ return Promise.reject(err)
404
+ }
405
+ return promise
406
+ }
407
+
408
+ private settle(id: number, msg: Record<string, unknown>): void {
409
+ const entry = this.pending.get(id)
410
+ if (!entry) return
411
+ this.pending.delete(id)
412
+ clearTimeout(entry.timer)
413
+ if (msg['error']) {
414
+ const err = msg['error'] as { message?: string; code?: number }
415
+ entry.reject(new Error(`灵枢错误 ${err.code ?? ''}: ${err.message ?? 'unknown'}`))
416
+ } else {
417
+ entry.resolve(msg['result'])
418
+ }
419
+ }
420
+
421
+ private rejectAll(reason: Error): void {
422
+ for (const [, entry] of this.pending) {
423
+ clearTimeout(entry.timer)
424
+ entry.reject(reason)
425
+ }
426
+ this.pending.clear()
427
+ }
428
+
429
+ private flushBootQueue(ok: boolean): void {
430
+ const queue = this.bootQueue
431
+ this.bootQueue = []
432
+ for (const cb of queue) cb(ok)
433
+ }
434
+ }