@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.
@@ -0,0 +1,123 @@
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
+ import type { Context } from '@deepseek-ai/cordis';
13
+ import type { LingshuBridge } from './bridge.js';
14
+ export interface LlmStreamChunk {
15
+ type: 'block-start' | 'text-delta' | 'reasoning-delta' | 'tool-call-delta' | 'block-end' | 'usage' | 'finish';
16
+ index?: number;
17
+ blockType?: string;
18
+ text?: string;
19
+ id?: string;
20
+ name?: string;
21
+ argumentsDelta?: string;
22
+ block?: {
23
+ type: string;
24
+ text?: string;
25
+ [k: string]: unknown;
26
+ };
27
+ usage?: {
28
+ inputTokens: number;
29
+ outputTokens: number;
30
+ cacheReadTokens?: number;
31
+ cacheWriteTokens?: number;
32
+ };
33
+ reason?: string;
34
+ replayState?: unknown;
35
+ }
36
+ export interface LlmGenerateOptions {
37
+ provider: string;
38
+ model: string;
39
+ reasoningEffort?: string;
40
+ messages: Array<{
41
+ role: string;
42
+ content?: Array<{
43
+ type?: string;
44
+ text?: string;
45
+ arguments?: string;
46
+ [k: string]: unknown;
47
+ }>;
48
+ }>;
49
+ system?: string;
50
+ tools?: Array<{
51
+ name: string;
52
+ description: string;
53
+ parameters: Record<string, unknown>;
54
+ }>;
55
+ temperature?: number;
56
+ maxTokens?: number;
57
+ stop?: string[];
58
+ signal?: AbortSignal;
59
+ sessionId?: string;
60
+ purpose?: 'compaction' | 'session-title';
61
+ }
62
+ export declare function estimateTokens(text: string | undefined | null): number;
63
+ /** 从 GenerateOptions 汇总输入 token(system + messages 全部文本)。 */
64
+ export declare function countInputTokens(options: LlmGenerateOptions): number;
65
+ /** 把 DSH 会话消息折叠成白箱可读的对话文本。 */
66
+ export declare function serializeMessages(options: LlmGenerateOptions): string;
67
+ /** 把一段完整文本包装成协议流(确定性整段生成 → 单次流)。 */
68
+ export declare function wrapTextStream(text: string, inputTokens: number, outputTokens: number, cacheReadTokens: number): AsyncIterable<LlmStreamChunk>;
69
+ /**
70
+ * 白箱知识卡片格式检测:REVERSE_DAILY 直答是知识库内部格式——
71
+ * 「X是什么…,是『A』vs『B』的矛盾——X(是…(…(…)…)…真相:①…」
72
+ * 特征(任一强信号命中即判定):
73
+ * ① 卡片开头句式:内容含「,是『X』vs『Y』的矛盾——」(矛盾标记+破折号)
74
+ * ② 「真相:」结构化标记(卡片分段标题)+ 括号密集(>8)
75
+ * ③ 长文本(>300)+ 高括号密度(>20)+ 无自然语言标点分隔(顿号密集)
76
+ */
77
+ export declare function isCardFormat(text: string): boolean;
78
+ /** 白箱 adapter 依赖注入。 */
79
+ export interface WhiteboxAdapterDeps {
80
+ /** 灵枢桥(MCP stdio)——白箱引擎通道。 */
81
+ bridge?: LingshuBridge;
82
+ /**
83
+ * 降级 LLM:白箱未命中(route=llm / 桥未就绪)时转发。由 installWhiteboxLlm
84
+ * 注入 DSH 的 llm 服务(走 fallbackProvider 路由),实现「白箱优先 + LLM 降级」。
85
+ */
86
+ fallback?: {
87
+ stream(o: LlmGenerateOptions): AsyncIterable<LlmStreamChunk>;
88
+ };
89
+ }
90
+ /** 白箱 LLM adapter —— 注册为 DSH 的 provider。 */
91
+ export declare class WhiteboxLlmAdapter {
92
+ private readonly bridge?;
93
+ private readonly fallback?;
94
+ constructor(deps?: WhiteboxAdapterDeps);
95
+ providerInfo(provider: string): {
96
+ id: string;
97
+ name: string;
98
+ };
99
+ providerRetryPolicy(): undefined;
100
+ listModels(): Promise<Array<{
101
+ provider: string;
102
+ id: string;
103
+ name: string;
104
+ }>>;
105
+ resolveModel(provider: string, model: string): Promise<{
106
+ provider: string;
107
+ id: string;
108
+ name: string;
109
+ }>;
110
+ /**
111
+ * 核心:一次模型调用。白箱优先,无把握降级。
112
+ * @param options 完全装配的请求(必须 honor options.signal)
113
+ */
114
+ stream(options: LlmGenerateOptions): AsyncIterable<LlmStreamChunk>;
115
+ }
116
+ /** provider 路由名(设置页「模型」下拉可见)。 */
117
+ export declare const WHITEBOX_PROVIDER = "lingshu-whitebox";
118
+ /**
119
+ * 注册白箱 provider 到 DSH llm 服务(动态检测,不强依赖 llm 服务——
120
+ * 最小 host 无 llm 时跳过,插件其余功能不受影响)。
121
+ * @returns 注销函数;未注册返回 noop。
122
+ */
123
+ export declare function installWhiteboxLlm(ctx: Context, bridge: LingshuBridge | undefined): () => void;
@@ -0,0 +1,243 @@
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
+ // ---- token 估算(确定性启发式:CJK 0.6/字符,其余 /4)----
13
+ export function estimateTokens(text) {
14
+ if (!text)
15
+ return 0;
16
+ let cjk = 0;
17
+ let other = 0;
18
+ for (const ch of text) {
19
+ const code = ch.codePointAt(0);
20
+ if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3040 && code <= 0x30ff))
21
+ cjk++;
22
+ else
23
+ other++;
24
+ }
25
+ return Math.ceil(cjk * 0.6 + other / 4);
26
+ }
27
+ /** 从 GenerateOptions 汇总输入 token(system + messages 全部文本)。 */
28
+ export function countInputTokens(options) {
29
+ let total = 0;
30
+ if (options.system)
31
+ total += estimateTokens(options.system);
32
+ for (const msg of options.messages ?? []) {
33
+ for (const block of msg.content ?? []) {
34
+ if (typeof block?.text === 'string')
35
+ total += estimateTokens(block.text);
36
+ if (typeof block?.arguments === 'string')
37
+ total += estimateTokens(block.arguments);
38
+ }
39
+ }
40
+ return total;
41
+ }
42
+ /** 把 DSH 会话消息折叠成白箱可读的对话文本。 */
43
+ export function serializeMessages(options) {
44
+ const parts = [];
45
+ if (options.system)
46
+ parts.push(`[系统] ${options.system}`);
47
+ for (const msg of options.messages ?? []) {
48
+ const text = (msg.content ?? [])
49
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
50
+ .map((b) => b.text)
51
+ .join('');
52
+ if (!text)
53
+ continue;
54
+ const role = msg.role === 'assistant' ? '灵枢' : msg.role === 'user' ? '用户' : msg.role;
55
+ parts.push(`[${role}] ${text}`);
56
+ }
57
+ return parts.join('\n');
58
+ }
59
+ /** 把一段完整文本包装成协议流(确定性整段生成 → 单次流)。 */
60
+ export async function* wrapTextStream(text, inputTokens, outputTokens, cacheReadTokens) {
61
+ yield { type: 'block-start', index: 0, blockType: 'text' };
62
+ yield { type: 'text-delta', index: 0, text };
63
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } };
64
+ yield {
65
+ type: 'usage',
66
+ usage: {
67
+ inputTokens,
68
+ outputTokens,
69
+ ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
70
+ },
71
+ };
72
+ yield { type: 'finish', reason: 'stop' };
73
+ }
74
+ /**
75
+ * 白箱知识卡片格式检测:REVERSE_DAILY 直答是知识库内部格式——
76
+ * 「X是什么…,是『A』vs『B』的矛盾——X(是…(…(…)…)…真相:①…」
77
+ * 特征(任一强信号命中即判定):
78
+ * ① 卡片开头句式:内容含「,是『X』vs『Y』的矛盾——」(矛盾标记+破折号)
79
+ * ② 「真相:」结构化标记(卡片分段标题)+ 括号密集(>8)
80
+ * ③ 长文本(>300)+ 高括号密度(>20)+ 无自然语言标点分隔(顿号密集)
81
+ */
82
+ export function isCardFormat(text) {
83
+ if (!text)
84
+ return false;
85
+ // ① 强信号:矛盾标记句式(「,是『A』vs『B』的矛盾——」)——卡片开头的
86
+ // 标志性结构,不受长度门槛限制(短卡片也判定)
87
+ if (/,是『[^』]{1,12}』vs『[^』]{1,12}』的矛盾[——\-]/.test(text))
88
+ return true;
89
+ // 短文本(<150)无矛盾标记 → 正常文本
90
+ if (text.length < 150)
91
+ return false;
92
+ // ② 「真相:」+ 括号密集(卡片的分段结构)
93
+ if (text.includes('真相:')) {
94
+ const parens = (text.match(/(/g) ?? []).length;
95
+ if (parens >= 8)
96
+ return true;
97
+ }
98
+ // ③ 超长 + 极高括号密度(卡片展开体)
99
+ const parens2 = (text.match(/(/g) ?? []).length;
100
+ if (text.length > 300 && parens2 > 20)
101
+ return true;
102
+ return false;
103
+ }
104
+ /** 白箱 LLM adapter —— 注册为 DSH 的 provider。 */
105
+ export class WhiteboxLlmAdapter {
106
+ bridge;
107
+ fallback;
108
+ constructor(deps = {}) {
109
+ this.bridge = deps.bridge;
110
+ this.fallback = deps.fallback;
111
+ }
112
+ providerInfo(provider) {
113
+ return { id: provider, name: '白箱灵枢 (lingshu-whitebox)' };
114
+ }
115
+ providerRetryPolicy() {
116
+ return undefined;
117
+ }
118
+ async listModels() {
119
+ return [
120
+ { provider: 'lingshu-whitebox', id: 'whitebox-v1', name: '白箱引擎 v1 (CCG 条件路由)' },
121
+ { provider: 'lingshu-whitebox', id: 'whitebox-wisdom', name: '白箱智慧之书' },
122
+ ];
123
+ }
124
+ async resolveModel(provider, model) {
125
+ return { provider, id: model, name: model };
126
+ }
127
+ /**
128
+ * 核心:一次模型调用。白箱优先,无把握降级。
129
+ * @param options 完全装配的请求(必须 honor options.signal)
130
+ */
131
+ async *stream(options) {
132
+ const inputTokens = countInputTokens(options);
133
+ const conversation = serializeMessages(options);
134
+ // 取最后一条用户文本作为白箱输入(完整对话太长时截尾)
135
+ const lastUserText = [...(options.messages ?? [])]
136
+ .reverse()
137
+ .flatMap((m) => m.content ?? [])
138
+ .filter((b) => b?.type === 'text')
139
+ .map((b) => b.text)
140
+ .join('')
141
+ .slice(-2000);
142
+ // ---- 白箱主路径:调灵枢 wisdom_chat ----
143
+ let whitebox = null;
144
+ if (this.bridge?.isReady?.()) {
145
+ try {
146
+ const r = await this.bridge.callTool('wisdom_chat', {
147
+ message: lastUserText || conversation.slice(-4000),
148
+ session_id: options.sessionId ?? 'default',
149
+ }, options.signal);
150
+ const raw = (r?.content ?? []).map((c) => c.text ?? '').join('\n');
151
+ // 白箱返回结构:{"reply": "...", "route": "self|llm", "hits": [...], "honest": bool}
152
+ let text = raw;
153
+ let route = 'self';
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ if (parsed.route)
157
+ route = parsed.route;
158
+ if (typeof parsed.reply === 'string')
159
+ text = parsed.reply;
160
+ else if (parsed.reply !== undefined)
161
+ text = JSON.stringify(parsed.reply);
162
+ }
163
+ catch { /* 非 JSON = 纯文本回复 */ }
164
+ // 质量门槛(v0.4.1):REVERSE_DAILY 卡片直答是知识库内部格式——
165
+ // 「X是什么…是『A』vs『B』的矛盾——X(是…(…)」重复+括号嵌套,
166
+ // 直接当 LLM 输出喂给用户=「重复回答」。检测到卡片特征 → 视为
167
+ // 未命中,走降级 LLM(白箱不输出内部格式垃圾)。
168
+ if (route !== 'llm' && text && text.trim() && !isCardFormat(text)) {
169
+ whitebox = { text: text.trim(), route };
170
+ }
171
+ else if (text && isCardFormat(text)) {
172
+ console.warn('[whitebox-llm] 白箱返回知识卡片格式,降级 LLM(内部格式不直出)');
173
+ }
174
+ }
175
+ catch (err) {
176
+ // 白箱异常不阻塞——记日志后走降级
177
+ console.warn('[whitebox-llm] 白箱调用失败,降级:', String(err));
178
+ }
179
+ }
180
+ if (whitebox) {
181
+ const outputTokens = estimateTokens(whitebox.text);
182
+ // 白箱直答 = 全缓存命中(条件路由固化命中,零推理成本)
183
+ yield* wrapTextStream(whitebox.text, inputTokens, outputTokens, inputTokens);
184
+ return;
185
+ }
186
+ // ---- 降级路径:白箱无把握 / 桥未就绪 ----
187
+ if (this.fallback) {
188
+ console.warn('[whitebox-llm] 白箱未命中,转降级 LLM');
189
+ yield* this.fallback.stream(options);
190
+ return;
191
+ }
192
+ // 无降级可用:诚实声明(盲区 28:未来承诺不可检验——不假装回答)
193
+ const honest = '(白箱引擎未命中且降级 LLM 未配置——按白箱纪律,我诚实声明不知道,不编造答案。)';
194
+ yield* wrapTextStream(honest, inputTokens, estimateTokens(honest), 0);
195
+ }
196
+ }
197
+ /** provider 路由名(设置页「模型」下拉可见)。 */
198
+ export const WHITEBOX_PROVIDER = 'lingshu-whitebox';
199
+ /**
200
+ * 注册白箱 provider 到 DSH llm 服务(动态检测,不强依赖 llm 服务——
201
+ * 最小 host 无 llm 时跳过,插件其余功能不受影响)。
202
+ * @returns 注销函数;未注册返回 noop。
203
+ */
204
+ export function installWhiteboxLlm(ctx, bridge) {
205
+ // 动态检测 llm 服务(P1 模式:timer/webServer 同理)。
206
+ // 注意:不能直接读 ctx.llm——Cordis 未声明 inject 的属性访问会抛
207
+ // "cannot get property without inject";ctx.get('llm') 安全返回 undefined。
208
+ const llm = ctx.get('llm');
209
+ if (!llm?.registerAdapter) {
210
+ ctx.logger?.info?.('dsh-memory: llm 服务不可用,跳过白箱 provider 注册(最小 host)');
211
+ return () => { };
212
+ }
213
+ // 降级 LLM:白箱未命中时转发给 fallbackProvider 路由(默认 deepseek-official,
214
+ // 可在 settings.yaml 的 llm-whitebox.fallbackProvider 覆盖)。通过 llm.stream
215
+ // 重路由——保持 DSH 的全链路(重试/瀑布流/用量计量)。
216
+ const fallbackProvider = 'deepseek-official';
217
+ const fallback = llm.stream
218
+ ? {
219
+ stream(options) {
220
+ return llm.stream({ ...options, provider: fallbackProvider });
221
+ },
222
+ }
223
+ : undefined;
224
+ const adapter = new WhiteboxLlmAdapter({ bridge, fallback });
225
+ try {
226
+ llm.registerConfigurableProviders?.([{
227
+ provider: WHITEBOX_PROVIDER,
228
+ displayName: '白箱灵枢 (Whitebox)',
229
+ settingsNs: 'llm-whitebox',
230
+ settingsPath: [],
231
+ }]);
232
+ const registration = llm.registerAdapter([WHITEBOX_PROVIDER], adapter);
233
+ ctx.logger?.info?.('dsh-memory: 白箱 LLM provider 已注册: %s(降级→%s)', WHITEBOX_PROVIDER, fallbackProvider);
234
+ return () => { try {
235
+ registration?.replace?.([]);
236
+ }
237
+ catch { /* 忽略注销异常 */ } };
238
+ }
239
+ catch (err) {
240
+ ctx.logger?.warn?.('dsh-memory: 白箱 provider 注册失败: %s', String(err));
241
+ return () => { };
242
+ }
243
+ }
package/lib/mutual.d.ts CHANGED
@@ -26,7 +26,9 @@ export declare function judgeStamp(stamp: {
26
26
  ageMs: number;
27
27
  task_running: boolean;
28
28
  } | null, opts?: MutualOptions): 'alive' | 'alive_working' | 'warning' | 'dead' | 'no_stamp';
29
- /** 检测 harness 进程是否存在(wmic) */
29
+ /** 检测 harness 进程是否存在
30
+ * P1 完善(GPT 审查·wmic 兼容):wmic 在 Win11+ 已被移除——失败时
31
+ * fallback PowerShell Get-CimInstance(Win);非 Windows 用 ps 查询。 */
30
32
  export declare function harnessRunning(): Promise<boolean>;
31
33
  /** detached 拉起 A 侧 harness.guardian(幂等:先确认不存在) */
32
34
  export declare function ensureHarness(python?: string, opts?: MutualOptions): Promise<'started' | 'already' | 'failed'>;
@@ -78,6 +80,7 @@ export declare function llmReview(claim: string, whitebox: VerifyResult['whitebo
78
80
  }>): Promise<VerifyResult['llm_review']>;
79
81
  /** 综合 verdict(白箱优先) */
80
82
  export declare function combineVerdict(w: VerifyResult['whitebox'], l: VerifyResult['llm_review']): VerifyResult['verdict'];
83
+ export declare function safeTaskId(id: string): boolean;
81
84
  /** 处理单个任务:双通道验证 → 写回 result */
82
85
  export declare function processTask(task: VerifyTask, verifyFn: (c: string) => Promise<{
83
86
  judgment: string;
@@ -87,14 +90,13 @@ export declare function processTask(task: VerifyTask, verifyFn: (c: string) => P
87
90
  }>, reviewFn: (c: string, w: VerifyResult['whitebox']) => Promise<{
88
91
  conclusion: string;
89
92
  reason: string;
90
- }>, opts?: MutualOptions): Promise<VerifyResult>;
93
+ }>, opts?: MutualOptions): Promise<VerifyResult | null>;
91
94
  export declare function log(opts: MutualOptions, msg: string): void;
92
95
  export declare function writeLastContact(opts?: MutualOptions): void;
93
96
  export declare function installMutualMaintenance(ctx: {
94
97
  logger: {
95
98
  info(m: string): void;
96
99
  };
97
- interval(ms: number, fn: () => void): () => void;
98
100
  effect(fn: () => () => void, name?: string): void;
99
101
  }, config?: Partial<MutualOptions>, hooks?: {
100
102
  verify?: (c: string) => Promise<{
package/lib/mutual.js CHANGED
@@ -75,30 +75,79 @@ function execP(cmd) {
75
75
  });
76
76
  });
77
77
  }
78
- /** 检测 harness 进程是否存在(wmic) */
78
+ /** 检测 harness 进程是否存在
79
+ * P1 完善(GPT 审查·wmic 兼容):wmic 在 Win11+ 已被移除——失败时
80
+ * fallback PowerShell Get-CimInstance(Win);非 Windows 用 ps 查询。 */
79
81
  export async function harnessRunning() {
80
- const out = await execP(`wmic process where "name='python.exe'" get commandline 2>nul`);
81
- return out.includes('harness.guardian') || out.includes('harness.main');
82
+ if (process.platform === 'win32') {
83
+ // ① wmic(旧 Win10 可用)
84
+ const out = await execP(`wmic process where "name='python.exe'" get commandline 2>nul`);
85
+ if (out.includes('harness.guardian') || out.includes('harness.main'))
86
+ return true;
87
+ // ② wmic 不可用(Win11 移除)→ PowerShell
88
+ const psOut = await execP(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='python.exe'\\" | Select-Object -ExpandProperty CommandLine"`);
89
+ return psOut.includes('harness.guardian') || psOut.includes('harness.main');
90
+ }
91
+ // 非 Windows(Linux/macOS):ps 查询
92
+ const psOut = await execP(`ps -eo command | grep -E 'harness\\.(guardian|main)' | grep -v grep`);
93
+ return !!psOut;
82
94
  }
83
95
  /** detached 拉起 A 侧 harness.guardian(幂等:先确认不存在) */
84
96
  export async function ensureHarness(python = 'python', opts = DEFAULTS) {
85
97
  const running = await harnessRunning();
86
98
  if (running)
87
99
  return 'already';
88
- try {
89
- const child = spawn(python, ['-m', 'harness.guardian'], {
90
- windowsHide: true,
91
- detached: true,
92
- stdio: 'ignore',
100
+ // P1 修复(GPT 审查):spawn 找不到可执行文件是异步 error 事件,无监听
101
+ // 会直接崩 Node(Unhandled 'error' event)。加监听 + 超时判定拉起失败。
102
+ return new Promise((resolve) => {
103
+ let child = null;
104
+ let failTimer = null;
105
+ let okTimer = null;
106
+ let settled = false;
107
+ const finish = (r) => {
108
+ if (settled)
109
+ return;
110
+ settled = true;
111
+ if (failTimer)
112
+ clearTimeout(failTimer);
113
+ if (okTimer)
114
+ clearTimeout(okTimer);
115
+ resolve(r);
116
+ };
117
+ try {
118
+ child = spawn(python, ['-m', 'harness.guardian'], {
119
+ windowsHide: true,
120
+ detached: true,
121
+ stdio: 'ignore',
122
+ });
123
+ }
124
+ catch (e) {
125
+ log(opts, `守护失败:${String(e)}`);
126
+ finish('failed');
127
+ return;
128
+ }
129
+ // 拉起失败(python 不存在等)→ 记录 + failed,不崩 Node。
130
+ // spawn 的 error 事件是异步的:不能立即判 started——等 50ms error 窗口,
131
+ // 无 error 才算拉起成功(GPT 审查:此前立即返回 started,错误路径也报成功)。
132
+ child.on('error', (err) => {
133
+ log(opts, `守护拉起错误:${err.message}`);
134
+ finish('failed');
93
135
  });
136
+ // 拉起成功但立即退出(harness.guardian 不存在)→ failed
137
+ child.on('exit', (code) => {
138
+ if (code !== null && code !== 0) {
139
+ log(opts, `守护进程退出 code=${code}(guardian 可能不可用)`);
140
+ finish('failed');
141
+ }
142
+ });
143
+ okTimer = setTimeout(() => finish('started'), 50);
144
+ failTimer = setTimeout(() => {
145
+ log(opts, `守护拉起超时(10s 无健康确认)`);
146
+ finish('failed');
147
+ }, 10_000);
94
148
  child.unref();
95
149
  log(opts, `守护动作:拉起 harness.guardian(pid=${child.pid ?? '?'})`);
96
- return 'started';
97
- }
98
- catch (e) {
99
- log(opts, `守护失败:${String(e)}`);
100
- return 'failed';
101
- }
150
+ });
102
151
  }
103
152
  /** 扫描互维目录里 A→B 的 pending 任务 */
104
153
  export function scanTasks(opts = DEFAULTS) {
@@ -139,6 +188,11 @@ export async function llmReview(claim, whitebox, reviewFn) {
139
188
  /** 综合 verdict(白箱优先) */
140
189
  export function combineVerdict(w, l) {
141
190
  const c = l.conclusion || '';
191
+ // P1 修复(GPT 审查):白箱通道异常 → fail-closed(不因复核「同意」而 pass)
192
+ if (w.judgment.startsWith('whitebox_error') || w.judgment.startsWith('llm_error')
193
+ || w.d_norm < 0) {
194
+ return 'needs_revision';
195
+ }
142
196
  if (w.judgment.startsWith('采纳')) {
143
197
  // 白箱采纳:复核质疑/不同意→needs_revision(白箱优先,记录分歧);否则 pass
144
198
  return c.includes('质疑') || c.includes('不同意') || c.includes('不通过')
@@ -149,43 +203,73 @@ export function combineVerdict(w, l) {
149
203
  // 证据不足/无法判断 → 白箱不确定,交给复核决定(「不同意」须排除「同意」子串)
150
204
  if (c.includes('不同意') || c.includes('不通过'))
151
205
  return 'needs_revision';
152
- if (c.includes('通过') || c.includes('同意') || c.includes('质疑'))
206
+ if (c.includes('通过') || c.includes('同意'))
153
207
  return 'pass';
208
+ // P1 修复(GPT 审查):「质疑」是分歧信号——白箱不确定 + 复核质疑 → 需要修订,
209
+ // 此前把「质疑」当 pass 与白箱采纳分支语义矛盾。
210
+ if (c.includes('质疑'))
211
+ return 'needs_revision';
154
212
  return 'needs_revision';
155
213
  }
214
+ /** 任务 ID 安全校验(P1 修复:防路径穿越——task.id 可含 ../../ 写目录外) */
215
+ const TASK_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
216
+ export function safeTaskId(id) {
217
+ return TASK_ID_RE.test(id) && !id.includes('..');
218
+ }
156
219
  /** 处理单个任务:双通道验证 → 写回 result */
157
220
  export async function processTask(task, verifyFn, reviewFn, opts = DEFAULTS) {
158
221
  const dir = path.join(netDir(opts), 'tasks');
222
+ // P1 修复(GPT 审查):task.id 路径穿越——非法 id 拒绝处理,不写任何文件
223
+ if (!safeTaskId(task.id)) {
224
+ log(opts, `任务 ${JSON.stringify(task.id)} 非法(路径穿越风险),拒绝处理`);
225
+ throw new Error(`非法任务 id: ${task.id}`);
226
+ }
159
227
  const taskPath = path.join(dir, `task-${task.id}.json`);
160
- // 标记 processing
228
+ // P1 完善(GPT 审查·任务竞态):原子 claim——rename task→processing。
229
+ // 多实例同时 scanTasks 读到同一 pending 时,rename 只有一方成功;
230
+ // 失败(文件已被 rename/不存在)= 其他实例正在处理 → 跳过(返回 null)。
231
+ // 此前无锁:多实例都写 processing/result/done,结果互相覆盖。
232
+ const claimPath = path.join(dir, `processing-${task.id}.json`);
161
233
  try {
162
- fs.writeFileSync(taskPath, JSON.stringify({ ...task, status: 'processing' }, null, 2), 'utf-8');
234
+ fs.renameSync(taskPath, claimPath);
235
+ }
236
+ catch {
237
+ return null; // 已被其他实例 claim,或文件不存在
163
238
  }
164
- catch { /* 非阻塞 */ }
165
- const w = await whiteboxVerify(task.payload.claim, verifyFn);
166
- const l = await llmReview(task.payload.claim, w, reviewFn);
167
- const verdict = combineVerdict(w, l);
168
- const result = {
169
- task_id: task.id,
170
- verdict,
171
- whitebox: w,
172
- llm_review: l,
173
- reasons: [
174
- `白箱判定:${w.judgment}(best=${w.best},d_norm=${w.d_norm})`,
175
- `复核结论:${l.conclusion}`,
176
- ],
177
- evidence: [task.payload.source_ref || ''].filter(Boolean),
178
- verifier: 'B',
179
- at: Date.now() / 1000,
180
- };
181
- fs.writeFileSync(path.join(dir, `result-${task.id}.json`), JSON.stringify(result, null, 2), 'utf-8');
182
- // 标记 done
183
239
  try {
184
- fs.writeFileSync(taskPath, JSON.stringify({ ...task, status: 'done' }, null, 2), 'utf-8');
240
+ const w = await whiteboxVerify(task.payload.claim, verifyFn);
241
+ const l = await llmReview(task.payload.claim, w, reviewFn);
242
+ const verdict = combineVerdict(w, l);
243
+ const result = {
244
+ task_id: task.id,
245
+ verdict,
246
+ whitebox: w,
247
+ llm_review: l,
248
+ reasons: [
249
+ `白箱判定:${w.judgment}(best=${w.best},d_norm=${w.d_norm})`,
250
+ `复核结论:${l.conclusion}`,
251
+ ],
252
+ evidence: [task.payload.source_ref || ''].filter(Boolean),
253
+ verifier: 'B',
254
+ at: Date.now() / 1000,
255
+ };
256
+ // 结果先写 tmp 再 rename(原子,防半写文件)
257
+ const resultTmp = path.join(dir, `result-${task.id}.json.tmp`);
258
+ fs.writeFileSync(resultTmp, JSON.stringify(result, null, 2), 'utf-8');
259
+ fs.renameSync(resultTmp, path.join(dir, `result-${task.id}.json`));
260
+ // 完成:清理 claim 文件(rename 回 done 标记供审计)
261
+ fs.renameSync(claimPath, path.join(dir, `done-${task.id}.json`));
262
+ log(opts, `任务 ${task.id} 验证完成:${verdict}`);
263
+ return result;
264
+ }
265
+ catch (e) {
266
+ // 处理失败:释放 claim(rename 回 task 供重试)
267
+ try {
268
+ fs.renameSync(claimPath, taskPath);
269
+ }
270
+ catch { /* 文件可能已不在 */ }
271
+ throw e;
185
272
  }
186
- catch { /* 非阻塞 */ }
187
- log(opts, `任务 ${task.id} 验证完成:${verdict}`);
188
- return result;
189
273
  }
190
274
  // ---------------------------------------------------------------------------
191
275
  // 日志 / last_contact
@@ -209,7 +293,13 @@ export function writeLastContact(opts = DEFAULTS) {
209
293
  export function installMutualMaintenance(ctx, config = {}, hooks = {}) {
210
294
  const opts = { ...DEFAULTS, ...config };
211
295
  // 1. 心跳写戳 + 守护 A(10min 周期)
212
- const heartbeatDispose = ctx.interval(opts.heartbeatMs, () => {
296
+ // P1 修复(GPT 审查):cordis-plugin-timer 签名是 interval(callback, delay)
297
+ // ——此前 (ms, fn) 写反:数字被当 callback、函数被当 delay,回调永不执行,
298
+ // 互维心跳/任务扫描实际不工作。
299
+ // P2 修复:不能用 ctx.interval——那是 cordis-plugin-timer 注入的快捷属性,
300
+ // 未声明 `timer` inject 时访问抛 "cannot get property without inject"。
301
+ // 改用 Node 原生 setInterval(互维不需要 Cordis effect 生命周期管理)。
302
+ const heartbeatTimer = setInterval(() => {
213
303
  try {
214
304
  writeHeartbeat(opts);
215
305
  // 守护 A:harness 不在 → 拉起
@@ -227,9 +317,10 @@ export function installMutualMaintenance(ctx, config = {}, hooks = {}) {
227
317
  writeLastContact(opts);
228
318
  }
229
319
  catch (e) { /* 单次失败不中断 */ }
230
- });
320
+ }, opts.heartbeatMs);
321
+ heartbeatTimer.unref?.();
231
322
  // 2. 任务轮询(30s 周期,检查 A→B 任务)
232
- const taskDispose = ctx.interval(30_000, () => {
323
+ const taskTimer = setInterval(() => {
233
324
  try {
234
325
  const tasks = scanTasks(opts);
235
326
  for (const t of tasks) {
@@ -241,13 +332,14 @@ export function installMutualMaintenance(ctx, config = {}, hooks = {}) {
241
332
  }
242
333
  }
243
334
  catch (e) { /* 单次失败不中断 */ }
244
- });
335
+ }, 30_000);
336
+ taskTimer.unref?.();
245
337
  // 3. effect 作用域清理
246
338
  ctx.effect(() => {
247
339
  return () => {
248
340
  try {
249
- heartbeatDispose();
250
- taskDispose();
341
+ clearInterval(heartbeatTimer);
342
+ clearInterval(taskTimer);
251
343
  }
252
344
  catch { /* 清理失败不阻塞 */ }
253
345
  };