ppxans-harness 2.4.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 (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +265 -0
  3. package/bin/ppx-channels.js +3 -0
  4. package/bin/ppx-serve.js +6 -0
  5. package/bin/ppx.js +3 -0
  6. package/config/identity.md +6 -0
  7. package/config/ishiki.md +16 -0
  8. package/config/ppx.json +151 -0
  9. package/package.json +69 -0
  10. package/src/agent/context.js +179 -0
  11. package/src/agent/index.js +717 -0
  12. package/src/agent/prompts.js +107 -0
  13. package/src/aml-server.js +151 -0
  14. package/src/ans/eviction.js +144 -0
  15. package/src/ans/guard.js +120 -0
  16. package/src/ans/lifecycle.js +93 -0
  17. package/src/ans/proactive.js +129 -0
  18. package/src/ans/reward.js +112 -0
  19. package/src/ans/values.js +15 -0
  20. package/src/audit/audit-chain.js +167 -0
  21. package/src/audit/verifier.js +120 -0
  22. package/src/bus/circuit-breaker.js +115 -0
  23. package/src/bus/runtime-bus.js +94 -0
  24. package/src/channels/base.js +35 -0
  25. package/src/channels/feishu.js +127 -0
  26. package/src/channels/http.js +592 -0
  27. package/src/channels/index.js +110 -0
  28. package/src/channels/log.js +29 -0
  29. package/src/channels/wechat-crypto.js +74 -0
  30. package/src/channels/wechat.js +197 -0
  31. package/src/channels-cli.js +124 -0
  32. package/src/cli.js +120 -0
  33. package/src/config/channels.js +170 -0
  34. package/src/config/index.js +224 -0
  35. package/src/config/providers.js +189 -0
  36. package/src/config/settings.js +182 -0
  37. package/src/core/policy.js +272 -0
  38. package/src/core/trace.js +89 -0
  39. package/src/evolve/playbook.js +194 -0
  40. package/src/llm/client.js +446 -0
  41. package/src/llm/dsml.js +74 -0
  42. package/src/llm/embedder.js +35 -0
  43. package/src/llm/fence.js +105 -0
  44. package/src/llm/index.js +4 -0
  45. package/src/llm/retry.js +73 -0
  46. package/src/llm/router.js +98 -0
  47. package/src/mcp/client.js +375 -0
  48. package/src/mcp/index.js +116 -0
  49. package/src/memory/asset-hub.js +131 -0
  50. package/src/memory/canvas.js +131 -0
  51. package/src/memory/compaction.js +28 -0
  52. package/src/memory/experience.js +122 -0
  53. package/src/memory/fact-store.js +699 -0
  54. package/src/memory/failure-episode.js +99 -0
  55. package/src/memory/fork.js +83 -0
  56. package/src/memory/index.js +7 -0
  57. package/src/memory/l0.js +52 -0
  58. package/src/memory/l2.js +131 -0
  59. package/src/memory/l3.js +112 -0
  60. package/src/memory/memory-ticker.js +240 -0
  61. package/src/memory/session.js +398 -0
  62. package/src/mode/blackboard.js +49 -0
  63. package/src/mode/graph.js +41 -0
  64. package/src/mode/index.js +64 -0
  65. package/src/mode/legion.js +51 -0
  66. package/src/mode/plan-exec.js +50 -0
  67. package/src/mode/router.js +40 -0
  68. package/src/orchestrator/agent-worker.js +70 -0
  69. package/src/orchestrator/dag.js +83 -0
  70. package/src/orchestrator/index.js +2 -0
  71. package/src/orchestrator/legion.js +188 -0
  72. package/src/orchestrator/supervisor.js +177 -0
  73. package/src/persona/index.js +29 -0
  74. package/src/plugin/builtin.js +212 -0
  75. package/src/plugin/context.js +79 -0
  76. package/src/plugin/index.js +62 -0
  77. package/src/seam/registry.js +98 -0
  78. package/src/seam/shell.js +55 -0
  79. package/src/selfheal/evolve.js +68 -0
  80. package/src/selfheal/healer.js +167 -0
  81. package/src/selfheal/run.js +9 -0
  82. package/src/server.js +60 -0
  83. package/src/services/learning-service.js +177 -0
  84. package/src/services/memory-health.js +99 -0
  85. package/src/services/memory-service.js +160 -0
  86. package/src/skills/loader.js +150 -0
  87. package/src/skills/verify.js +100 -0
  88. package/src/tools/advanced.js +353 -0
  89. package/src/tools/builtin.js +298 -0
  90. package/src/tools/catalog.js +159 -0
  91. package/src/tools/command-guard.js +112 -0
  92. package/src/tools/custom.js +47 -0
  93. package/src/tools/delegate.js +297 -0
  94. package/src/tools/document.js +253 -0
  95. package/src/tools/governance.js +260 -0
  96. package/src/tools/index.js +11 -0
  97. package/src/tools/methods.js +178 -0
  98. package/src/tools/ocr.js +59 -0
  99. package/src/tools/seam.js +125 -0
  100. package/src/tools/selfmod.js +176 -0
  101. package/src/utils/logger.js +17 -0
  102. package/src/utils/pii.js +42 -0
  103. package/src/utils/store.js +108 -0
  104. package/src/utils/text.js +16 -0
  105. package/src/utils/trace.js +153 -0
  106. package/src/utils/winutf8.js +15 -0
@@ -0,0 +1,194 @@
1
+ // src/evolve/playbook.js - 语境 Playbook 引擎 v1 (P1④)
2
+ // 吸收 ACE (ICLR 2026, agentic-context-engineering) 的设计思想:
3
+ // - 语境即 Playbook: 系统提示词 = 静态基底 + 动态 bullets 注入
4
+ // - 增量 delta 合并 (ADD/UPDATE/REMOVE), 非 LLM 确定性应用, 防"整体重写→语境塌缩"
5
+ // - grow-and-refine: 语义去重 + harmful 裁剪
6
+ // - 门禁回滚: 任何进化必须过回归基准才 commit, 否则自动 rollback
7
+ // 仅借鉴思想, 源码自研。核心合并/裁剪/门禁纯代码 (可测), LLM 提炼教训走可选 adapter (无 LLM 时跳过)。
8
+ // 与 LearningService.refine (失败→经验库) 互补: refine 沉淀经验, playbook 反哺系统提示词 (零 token 成本时为空)。
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { ensureDir, readText, writeText } from "../utils/store.js";
12
+
13
+ // ---- 词法相似度 (grow-and-refine 用, 零依赖) ----
14
+ // 英文/空格分隔 → 词级 Jaccard; 中文等 CJK 连续串 → 字符 bigram Dice (单 token 时退化)。
15
+ // 这样"失败后查看审计链" 与 "失败后查看审计链重试" 也能算出高相似度。
16
+ export function lexicalSimilarity(a, b) {
17
+ const ta = String(a || "");
18
+ const tb = String(b || "");
19
+ if (!ta || !tb) return 0;
20
+ const tokensA = tokenize(ta);
21
+ const tokensB = tokenize(tb);
22
+ return diceSet(tokensA, tokensB);
23
+ }
24
+
25
+ function tokenize(s) {
26
+ const words = s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
27
+ // 有词分隔: 用词级
28
+ if (words.length > 1) return words;
29
+ // 无分隔 (中文连续串): 用字符 bigram (含单字符兜底)
30
+ const chars = s.replace(/\s+/g, "").toLowerCase();
31
+ const bigrams = [];
32
+ for (let i = 0; i < chars.length - 1; i++) bigrams.push(chars.slice(i, i + 2));
33
+ if (bigrams.length) return bigrams;
34
+ return chars.split("");
35
+ }
36
+
37
+ function diceSet(A, B) {
38
+ const setA = new Set(A);
39
+ const setB = new Set(B);
40
+ if (!setA.size || !setB.size) return 0;
41
+ let inter = 0;
42
+ for (const w of setA) if (setB.has(w)) inter++;
43
+ return (2 * inter) / (setA.size + setB.size);
44
+ }
45
+
46
+ // ---- delta 操作 (确定性合并, 非 LLM) ----
47
+ // ops: [{ op:'ADD', kind, content, evidence_ref? } | { op:'UPDATE', id, content, counters? } | { op:'REMOVE', id }]
48
+ // 返回 { playbook, applied, rejected: [{op, reason}] }
49
+ export function applyDelta(playbook, ops = [], { maxBullets = 200 } = {}) {
50
+ const pb = clonePlaybook(playbook);
51
+ const applied = [];
52
+ const rejected = [];
53
+ for (const op of ops) {
54
+ if (!op || !op.op) { rejected.push({ op, reason: "缺 op 字段" }); continue; }
55
+ if (op.op === "ADD") {
56
+ const content = String(op.content || "").trim();
57
+ if (!content) { rejected.push({ op, reason: "ADD 缺内容" }); continue; }
58
+ // 新增前查重: 与已有 bullet 词法相似 > 0.6 视为重复, 拒收 (grow-and-refine)
59
+ const dup = pb.bullets.find((b) => lexicalSimilarity(b.content, content) > 0.6);
60
+ if (dup) { rejected.push({ op, reason: "重复: " + dup.id }); continue; }
61
+ const id = op.id || ("b" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5));
62
+ const kind = ["strategy", "pitfall", "domain"].includes(op.kind) ? op.kind : "strategy";
63
+ pb.bullets.push({ id, kind, content, counters: { helpful: 0, harmful: 0 }, evidence_ref: op.evidence_ref || null, createdAt: Date.now() });
64
+ applied.push({ op: "ADD", id });
65
+ } else if (op.op === "UPDATE") {
66
+ const b = pb.bullets.find((x) => x.id === op.id);
67
+ if (!b) { rejected.push({ op, reason: "UPDATE 未知 id: " + op.id }); continue; }
68
+ if (op.content && String(op.content).trim() !== b.content) {
69
+ b.content = String(op.content).trim();
70
+ b.updatedAt = Date.now();
71
+ }
72
+ if (op.evidence_ref) b.evidence_ref = op.evidence_ref;
73
+ if (op.counters) {
74
+ b.counters.helpful += Number(op.counters.helpful) || 0;
75
+ b.counters.harmful += Number(op.counters.harmful) || 0;
76
+ }
77
+ applied.push({ op: "UPDATE", id: op.id });
78
+ } else if (op.op === "REMOVE") {
79
+ const i = pb.bullets.findIndex((x) => x.id === op.id);
80
+ if (i < 0) { rejected.push({ op, reason: "REMOVE 未知 id: " + op.id }); continue; }
81
+ pb.bullets.splice(i, 1);
82
+ applied.push({ op: "REMOVE", id: op.id });
83
+ } else {
84
+ rejected.push({ op, reason: "未知 op: " + op.op });
85
+ }
86
+ }
87
+ // 容量保护: 超上限裁剪最弱 (harmful 最高) bullets —— 保留质量高 (harmful-净额小) 的在前
88
+ if (pb.bullets.length > maxBullets) {
89
+ pb.bullets.sort((a, b) => (a.counters.harmful - a.counters.helpful) - (b.counters.harmful - b.counters.helpful));
90
+ pb.bullets = pb.bullets.slice(0, maxBullets);
91
+ }
92
+ return { playbook: pb, applied, rejected };
93
+ }
94
+
95
+ // ---- grow-and-refine: 去重 + 裁剪 ----
96
+ // harmful - helpful >= pruneAt 的 bullet 移除 (持续有害的策略自动淘汰)
97
+ export function growAndRefine(playbook, { pruneAt = 3 } = {}) {
98
+ const pb = clonePlaybook(playbook);
99
+ const pruned = [];
100
+ const before = pb.bullets.length;
101
+ pb.bullets = pb.bullets.filter((b) => {
102
+ const net = b.counters.harmful - b.counters.helpful;
103
+ if (net >= pruneAt) { pruned.push({ id: b.id, net }); return false; }
104
+ return true;
105
+ });
106
+ return { playbook: pb, pruned, removed: before - pb.bullets.length };
107
+ }
108
+
109
+ // ---- 门禁: 进化必须过回归基准才 commit, 否则 rollback ----
110
+ // gate(check: (playbook)=>boolean|Promise<boolean>) — 基准通过返回 true 才落盘
111
+ export function createGate(regressionCheck, { onReject = null } = {}) {
112
+ return {
113
+ async commit(store, playbook, ops, meta = {}) {
114
+ const pass = typeof regressionCheck === "function" ? await regressionCheck(playbook) : true;
115
+ if (!pass) {
116
+ // 回滚: 不落盘, 保留原 playbook
117
+ onReject?.(playbook, meta);
118
+ return { committed: false, reason: "回归基准未通过", applied: [] };
119
+ }
120
+ store.save(playbook);
121
+ return { committed: true, applied: ops };
122
+ },
123
+ };
124
+ }
125
+
126
+ // ---- 渲染: bullets -> 注入串 (空时返回空串, 零 token 成本) ----
127
+ export function renderBullets(playbook, { maxBullets = 30 } = {}) {
128
+ const bullets = (playbook?.bullets || []).slice(0, maxBullets);
129
+ if (!bullets.length) return "";
130
+ const lines = bullets.map((b, i) => `${i + 1}. [${b.kind}] ${b.content}`);
131
+ return `\n# 经验策略 (Playbook, 基于过往实践)\n${lines.join("\n")}\n`;
132
+ }
133
+
134
+ // ---- 存储 ----
135
+ export class PlaybookStore {
136
+ constructor(dataDir) {
137
+ this.dir = path.join(dataDir, "evolve");
138
+ ensureDir(this.dir);
139
+ this.file = path.join(this.dir, "playbook.json");
140
+ this._playbook = this._load();
141
+ }
142
+
143
+ _load() {
144
+ try {
145
+ if (fs.existsSync(this.file)) {
146
+ const d = JSON.parse(fs.readFileSync(this.file, "utf8"));
147
+ if (d && Array.isArray(d.bullets)) return d;
148
+ }
149
+ } catch {}
150
+ return { base: "", bullets: [], version: 1 };
151
+ }
152
+
153
+ get playbook() { return this._playbook; }
154
+
155
+ setBase(text) {
156
+ this._playbook.base = String(text || "");
157
+ return this;
158
+ }
159
+
160
+ save(pb = this._playbook) {
161
+ this._playbook = pb;
162
+ writeText(this.file, JSON.stringify(pb, null, 2));
163
+ return pb;
164
+ }
165
+
166
+ // 便捷: 应用 delta + 门禁
167
+ async apply(ops, { gate = null, maxBullets } = {}) {
168
+ const { playbook: next, applied, rejected } = applyDelta(this._playbook, ops, { maxBullets });
169
+ if (gate) {
170
+ const r = await gate.commit(this, next, ops);
171
+ return { ...r, rejected };
172
+ }
173
+ this.save(next);
174
+ return { committed: true, applied, rejected };
175
+ }
176
+ }
177
+
178
+ function clonePlaybook(pb) {
179
+ return {
180
+ base: pb?.base || "",
181
+ version: pb?.version || 1,
182
+ bullets: (pb?.bullets || []).map((b) => ({
183
+ id: b.id,
184
+ kind: b.kind,
185
+ content: b.content,
186
+ counters: { helpful: b.counters?.helpful || 0, harmful: b.counters?.harmful || 0 },
187
+ evidence_ref: b.evidence_ref || null,
188
+ createdAt: b.createdAt,
189
+ updatedAt: b.updatedAt,
190
+ })),
191
+ };
192
+ }
193
+
194
+ export default PlaybookStore;
@@ -0,0 +1,446 @@
1
+ // src/llm/client.js - LLM 客户端
2
+ // 后端两种模式:
3
+ // backend="openclaw" : 通过 `openclaw agent` CLI 驱动 OpenClaw 引擎 (底座=OpenClaw)
4
+ // backend="http" : 直接 OpenAI 兼容 HTTP API (零依赖, 用 fetch) [默认]
5
+ import { spawn } from "node:child_process";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import fs from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { info, warn } from "../utils/logger.js";
11
+ import { estimateTokens, truncateByTokens } from "../utils/text.js";
12
+ import { buildFencePrompt, proxyToolLoop, parseToolCalls } from "./fence.js";
13
+ import { withRetry } from "./retry.js";
14
+
15
+ const PPX_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+ // DeepSeek Harness 已内嵌到 ppx-agent/.deps/deepseek-harness;仍可用 PPX_DSH_ROOT 覆盖。
17
+ const DEFAULT_DSH_ROOT = process.env.PPX_DSH_ROOT || path.join(PPX_ROOT, ".deps", "deepseek-harness");
18
+ const DEFAULT_MJS = process.env.PPX_OPENCLAW_MJS || "";
19
+
20
+ // 纯函数: 校验 Node 版本是否满足 OpenClaw 引擎要求 (>=22.22.3 / >=24.15 / >=25.9, 且 23 与 24.0-24.14 不支持)
21
+ export function nodeVersionOk(version) {
22
+ const v = String(version).split(".").map(Number);
23
+ const [maj, min, pat] = v;
24
+ return (maj === 22 && (min > 22 || (min === 22 && pat >= 3))) ||
25
+ (maj === 24 && min >= 15) ||
26
+ (maj === 25 && min >= 9) || maj > 25;
27
+ }
28
+ export class LLMClient {
29
+ constructor(provider) {
30
+ this.providerId = provider.id || "openclaw";
31
+ // 后端选择: 显式 backend=openclaw, 或 id=openclaw
32
+ this.backend = provider.backend === "openclaw" || provider.id === "openclaw" ? "openclaw"
33
+ : provider.backend === "deepseek" || provider.backend === "dsh" || provider.id === "dsh" ? "deepseek"
34
+ : "http";
35
+ this.baseUrl = (provider.base_url || "").replace(/\/$/, "");
36
+ this.apiKey = provider.api_key || process.env[provider.api_key_env] || "";
37
+ this.apiKeyEnvName = provider.api_key_env || ""; // 供缺失 key 报错时提示应设置的环境变量名
38
+ this.model = provider.model || provider.models?.chat || "gpt-4o-mini";
39
+ this.vision = !!provider.vision; // 是否支持多模态 (视觉) — 标记后才会注入图片到 user 消息
40
+ // 上下文窗口 (token): 供 agent 据此收紧会话历史预算, 防止本地小模型溢出。
41
+ // 可选字段, provider 未配置时用保守默认 8192 (绝不因未知窗口放大历史)。
42
+ this.context_window = Number(provider.context_window) || Number(provider.models?.context_window) || 8192;
43
+ this.timeoutMs = provider.timeout_ms || 120000;
44
+ this.retryMax = provider.retry_max ?? 3; // 单次调用内瞬态错误重试次数 (429/5xx/timeout)
45
+ // openclaw 后端专用
46
+ this.mjs = provider.mjs || DEFAULT_MJS;
47
+ this.sessionKey = provider.session_key || "ppx:main";
48
+ const dshRoot = process.env.PPX_DSH_ROOT || provider.dsh_root || DEFAULT_DSH_ROOT;
49
+ this.dshRoot = path.isAbsolute(dshRoot) ? dshRoot : path.resolve(PPX_ROOT, dshRoot);
50
+ this._tmpCounter = 0;
51
+ if (this.backend === "openclaw") info(`LLMClient[${this.providerId}] backend=openclaw mjs=${this.mjs} session=${this.sessionKey}`);
52
+ }
53
+
54
+ // ===== OpenClaw 后端异步版 (async spawn, 不阻塞事件循环) ====
55
+ async _openclawChatAsync(messages) {
56
+ this._openclawReadyOrThrow();
57
+ const lastUser = [...messages].reverse().find(m => m && (m.role === "user"));
58
+ const text = lastUser?.content || "";
59
+ const tmp = path.join(os.tmpdir(), `ppx_msg_${Date.now()}_${this._tmpCounter++}.txt`);
60
+ fs.writeFileSync(tmp, String(text), "utf8");
61
+ try {
62
+ const args = [
63
+ this.mjs, "agent",
64
+ "--session-key", this.sessionKey,
65
+ "--message-file", tmp,
66
+ "--json",
67
+ "--timeout", String(Math.floor(this.timeoutMs / 1000)),
68
+ ];
69
+ const stdout = await new Promise((resolve, reject) => {
70
+ const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
71
+ let out = "", err = "";
72
+ child.stdout.on("data", d => out += d);
73
+ child.stderr.on("data", d => err += d);
74
+ child.on("error", reject);
75
+ child.on("close", code => {
76
+ if (code !== 0) reject(this._translateOpenclawError(new Error(`openclaw 退出 code=${code}: ${err.slice(-500)}`)));
77
+ else resolve(out);
78
+ });
79
+ });
80
+ const j = JSON.parse(stdout);
81
+ if (j.status && j.status !== "ok") throw new Error(`[皮皮虾] OpenClaw 执行状态异常 (status=${j.status})`);
82
+ const payloads = j?.result?.payloads || [];
83
+ const content = payloads.map(p => p?.text || "").filter(Boolean).join("\n");
84
+ if (!content) {
85
+ const alt = j?.result?.meta?.finalAssistantVisibleText;
86
+ if (alt) return { content: alt, usage: null };
87
+ throw new Error("OpenClaw 返回空内容 (runId=" + (j.runId ?? "?") + ")");
88
+ }
89
+ return { content, usage: null, meta: { engine: "openclaw", runId: j.runId } };
90
+ } finally {
91
+ try { fs.rmSync(tmp, { force: true }); } catch {}
92
+ }
93
+ }
94
+
95
+ // ===== DeepSeek Harness 后端 (dsh headless 一次性运行器) ====
96
+ // 两种形态:
97
+ // built: dsh npm 包已安装 (dshRoot/lib/bin.js) — 零构建, 直接跑, 优先
98
+ // src: dsh 源码树 (dshRoot/apps/cli/src/bin.ts + node_modules/tsx) — 内嵌 .deps 形态
99
+ _dshResolveBin() {
100
+ const built = path.join(this.dshRoot, "lib", "bin.js");
101
+ if (fs.existsSync(built)) return { kind: "built", bin: built };
102
+ const src = path.join(this.dshRoot, "apps", "cli", "src", "bin.ts");
103
+ if (fs.existsSync(src)) return { kind: "src", bin: src };
104
+ return null;
105
+ }
106
+
107
+ _dshReadyOrThrow() {
108
+ const r = this._dshResolveBin();
109
+ if (!r) {
110
+ throw new Error("[皮皮虾] dsh 未就绪: " + this.dshRoot + " 下找不到 lib/bin.js (已安装 dsh npm 包) 或 apps/cli/src/bin.ts (dsh 源码)。\n请设置 PPX_DSH_ROOT 指向 dsh 安装目录,或 clone deepseek-harness 到 .deps/deepseek-harness 后运行 npm run dsh:install。");
111
+ }
112
+ if (r.kind === "src" && !fs.existsSync(path.join(this.dshRoot, "node_modules", "tsx"))) {
113
+ throw new Error("[皮皮虾] dsh 源码形态依赖未安装: " + this.dshRoot + "/node_modules/tsx 不存在。\n请先运行 npm run dsh:install(等价于 cd .deps/deepseek-harness && pnpm install),或将 PPX_DSH_ROOT 指向已安装的 dsh npm 包目录。");
114
+ }
115
+ }
116
+
117
+ // 驱动 `node <dsh bin> --profile headless "<task>"`,
118
+ // stdout = 最终助手文本, exit 0 = turn 完成, 1 = 出错(stderr 带错误)
119
+ async _dshChatAsync(messages) {
120
+ this._dshReadyOrThrow();
121
+ const lastUser = [...messages].reverse().find(m => m && m.role === "user");
122
+ const text = lastUser?.content || "";
123
+ const r = this._dshResolveBin();
124
+ const args = r.kind === "built"
125
+ ? [r.bin, "--profile", "headless", text]
126
+ : ["--import", "tsx/esm", "apps/cli/src/bin.ts", "--profile", "headless", text];
127
+ const stdout = await new Promise((resolve, reject) => {
128
+ const child = spawn(process.execPath, args, { cwd: this.dshRoot, stdio: ["ignore", "pipe", "pipe"] });
129
+ let out = "", err = "";
130
+ child.stdout.on("data", d => out += d);
131
+ child.stderr.on("data", d => err += d);
132
+ child.on("error", reject);
133
+ child.on("close", code => {
134
+ if (code !== 0) reject(new Error(`dsh 退出 code=${code}: ${err.slice(-500)}`));
135
+ else resolve(out);
136
+ });
137
+ });
138
+ const content = stdout.trim();
139
+ if (!content) throw new Error("dsh 返回空内容");
140
+ return { content, usage: null, meta: { engine: "deepseek-harness" } };
141
+ }
142
+
143
+ // 原生 chat (无工具)
144
+ // timeoutMs/retryMax: 可选覆盖 provider 默认 (辅助调用传短超时+禁重试快速失败, 见 AUX_TIMEOUT_MS)
145
+ async chat(messages, { temperature = 0.7, maxTokens = 2048, timeoutMs, retryMax } = {}) {
146
+ if (this.backend === "openclaw") {
147
+ const r = await this._openclawChatAsync(messages);
148
+ return { content: r.content, usage: r.usage };
149
+ }
150
+ if (this.backend === "deepseek") {
151
+ const r = await this._dshChatAsync(messages);
152
+ return { content: r.content, usage: r.usage };
153
+ }
154
+ const data = await this._request("/chat/completions", { model: this.model, messages, temperature, max_tokens: maxTokens }, { timeoutMs, retryMax });
155
+ const m1 = data?.choices?.[0]?.message;
156
+ let content = m1?.content;
157
+ // 本地推理模型兜底: thinking 吃满 token 时 content 为空, 用 reasoning_content 降级, 避免误判"断线/失败"并写入污染记忆
158
+ if (!content && m1?.reasoning_content) content = "[思考] " + m1.reasoning_content;
159
+ if (!content) throw new Error("LLM 返回空内容");
160
+ return { content, usage: data?.usage };
161
+ }
162
+
163
+ // API chat (支持工具调用), 返回完整 message (含 tool_calls)
164
+ async apiChat(messages, { tools = [], temperature = 0.7, maxTokens = 4096, toolRunner = null, timeoutMs, retryMax } = {}) {
165
+ if (this.backend === "openclaw") {
166
+ // OpenClaw 引擎是外部进程, 无法直接调用 PPX 内部工具。
167
+ // 若提供 toolRunner, 走围栏代理循环: 引擎以纯 LLM 输出工具意图, PPX 解析执行。
168
+ // 否则退化纯 LLM (引擎自带工具循环, 只回最终文本)。
169
+ if (toolRunner && tools.length) return this._proxyChat(messages, { tools, toolRunner, engine: "openclaw" });
170
+ const r = await this._openclawChatAsync(messages);
171
+ return { message: { role: "assistant", content: r.content, tool_calls: null }, usage: r.usage };
172
+ }
173
+ if (this.backend === "deepseek") {
174
+ if (toolRunner && tools.length) return this._proxyChat(messages, { tools, toolRunner, engine: "deepseek" });
175
+ const r = await this._dshChatAsync(messages);
176
+ return { message: { role: "assistant", content: r.content, tool_calls: null }, usage: r.usage };
177
+ }
178
+ const body = { model: this.model, messages, temperature, max_tokens: maxTokens };
179
+ if (tools.length) body.tools = tools;
180
+ const data = await this._request("/chat/completions", body, { timeoutMs, retryMax });
181
+ const message = data?.choices?.[0]?.message;
182
+ if (!message) throw new Error("LLM 返回空 message");
183
+ let toolCalls = message.tool_calls || null;
184
+ let content = message.content || null;
185
+ // 本地推理模型兜底: 无正文且无工具调用时, 用 reasoning_content 降级(避免误判断线/失败并写入污染记忆)
186
+ if (!content && !toolCalls?.length && message.reasoning_content) {
187
+ content = "[思考] " + message.reasoning_content;
188
+ toolCalls = null;
189
+ }
190
+ // 纯文本工具调用修复 (吸收 OpenClaw tool-call-repair):
191
+ // 部分模型(本地/DSML)返回文本工具意图而非原生 tool_calls, 从文本恢复
192
+ if (tools.length && (!toolCalls || !toolCalls.length) && typeof content === "string" && content) {
193
+ const parsed = parseToolCalls(content);
194
+ if (parsed.calls.length) {
195
+ toolCalls = parsed.calls.map((c) => ({
196
+ id: c.id,
197
+ type: "function",
198
+ function: { name: c.function.name, arguments: c.function.arguments },
199
+ }));
200
+ content = parsed.clean || null;
201
+ }
202
+ }
203
+ return {
204
+ message: {
205
+ role: message.role || "assistant",
206
+ content,
207
+ tool_calls: toolCalls,
208
+ },
209
+ usage: data?.usage,
210
+ };
211
+ }
212
+
213
+ // 围栏代理循环: 把工具清单注入, 引擎以纯 LLM 输出工具意图, PPX 执行 [P0#1]
214
+ // 围栏上下文 token 预算 (v0.6.6 优化: 替代固定 800/400 字符截断)
215
+ // 按 token 预算动态分配: persona 优先 40%, 历史按"最新优先 + 信息量"分配剩余 60%
216
+ static get FENCE_CTX_TOKEN_BUDGET() { return 2400; }
217
+ static get FENCE_PERSONA_RATIO() { return 0.4; }
218
+ static get FENCE_HIST_BUDGET() { return 2400 * 0.6; }
219
+ // 信息量启发: 含指令/数字/路径/结论的轮次更该保留
220
+ static _histPriority(m) {
221
+ const s = String(m?.content || "");
222
+ let p = 0;
223
+ if (/[查|算|写|建|改|创建|删除|修复|总结|分析|配置|执行|运行|启动|停止|提交|部署|安装|生成|编译|测试]/.test(s)) p += 2;
224
+ if (/[0-9]{2,}/.test(s)) p += 1;
225
+ if (/\.(js|py|md|json|txt|ts|go)\b|[:\\\/][A-Za-z]/.test(s)) p += 2;
226
+ if (/失败|错误|报错|异常|成功|完成|结果|结论|决定|方案/.test(s)) p += 2;
227
+ if (/^(你好|hi|hello|在吗|谢谢|好的|嗯|是的|对|收到|再见)/i.test(s.trim())) p -= 3;
228
+ return p;
229
+ }
230
+ async _proxyChat(messages, { tools, toolRunner, engine }) {
231
+ const fencePrompt = buildFencePrompt(tools);
232
+ // 保留 system/persona 设定 + 最近历史, 避免外部引擎只见"当前问题" [复审 P2]
233
+ const systemMsg = messages.find((m) => m && m.role === "system");
234
+ const nonSys = messages.filter((m) => m && m.role !== "system");
235
+ // person 预算: 固定 40%, 截断到预算内
236
+ const persona = systemMsg?.content
237
+ ? "角色设定:\n" + truncateByTokens(systemMsg.content, LLMClient.FENCE_CTX_TOKEN_BUDGET * LLMClient.FENCE_PERSONA_RATIO)
238
+ : null;
239
+ // 历史预算: 剩余 60% 按"最新优先 + 信息量"分配
240
+ const histBudget = LLMClient.FENCE_HIST_BUDGET;
241
+ let histLines = [], used = 0;
242
+ // 1) 最新 6 条按时间倒序, 优先保留(信息量高或最新)
243
+ const recent = nonSys.slice(-6);
244
+ for (let i = recent.length - 1; i >= 0; i--) {
245
+ const m = recent[i];
246
+ const line = (m.role === "user" ? "用户" : "助手") + ": " + String(m.content || "");
247
+ const t = estimateTokens(line);
248
+ if (used + t > histBudget) continue; // 超预算跳过(不截断硬塞)
249
+ histLines.push(line); used += t;
250
+ }
251
+ // 2) 若预算还有余量, 补更早的高信息量轮次
252
+ const older = nonSys.slice(0, Math.max(0, nonSys.length - 6));
253
+ for (let i = older.length - 1; i >= 0; i--) {
254
+ const m = older[i];
255
+ if (LLMClient._histPriority(m) < 2) continue; // 只补高信息量
256
+ const line = (m.role === "user" ? "用户" : "助手") + ": " + String(m.content || "");
257
+ const t = estimateTokens(line);
258
+ if (used + t > histBudget) break;
259
+ histLines.push(line); used += t;
260
+ }
261
+ histLines.reverse(); // 恢复时间正序
262
+ const ctx = [persona, histLines.length ? histLines.join("\n") : null].filter(Boolean).join("\n\n");
263
+ const combined = fencePrompt + "\n\n[上下文]\n" + ctx;
264
+ const finalText = await proxyToolLoop(
265
+ async (context) => {
266
+ // 每条消息: 围栏协议 + 任务 + 累积工具结果
267
+ const msg = context ? combined + "\n\n" + context : combined;
268
+ if (engine === "openclaw") {
269
+ const r = await this._openclawChatAsync([{ role: "user", content: msg }]);
270
+ return r.content;
271
+ }
272
+ const r = await this._dshChatAsync([{ role: "user", content: msg }]);
273
+ return r.content;
274
+ },
275
+ toolRunner,
276
+ { maxRounds: 8 }
277
+ );
278
+ return { message: { role: "assistant", content: finalText, tool_calls: null }, usage: null };
279
+ }
280
+
281
+ // 辅助 LLM 调用短超时 (毫秒): 压缩/提炼/扩展/经验 等非主对话调用, 快速失败降级, 避免 120s 卡死
282
+ // 使用场景: 模型未运行/网络不通时, 主对话靠 localIntent 或回退, 辅助调用不应阻塞主流程
283
+ static get AUX_TIMEOUT_MS() { return 10000; }
284
+
285
+ async _request(path, jsonBody, { timeoutMs, retryMax } = {}) {
286
+ if (!this.apiKey) throw new Error(`[皮皮虾] LLM 缺少 API key (env=${this.apiKeyEnvName || "?"})`);
287
+ const url = `${this.baseUrl}${path}`;
288
+ const ms = timeoutMs || this.timeoutMs;
289
+ // 辅助调用 (压缩/提炼/扩展等) 传 retryMax:0 禁重试: 短超时 + 不重试 = 快速失败降级, 不阻塞主流程
290
+ const maxRetries = retryMax === undefined ? this.retryMax : retryMax;
291
+ const doFetch = async () => {
292
+ const ctrl = new AbortController();
293
+ const timer = setTimeout(() => ctrl.abort(), ms);
294
+ try {
295
+ const resp = await fetch(url, {
296
+ method: "POST",
297
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.apiKey}` },
298
+ body: JSON.stringify(jsonBody),
299
+ signal: ctrl.signal,
300
+ });
301
+ if (!resp.ok) {
302
+ const text = await resp.text().catch(() => "");
303
+ const e = new Error(`LLM HTTP ${resp.status}: ${text.slice(0, 300)}`);
304
+ e.status = resp.status; // 结构化状态码, 供 retry.isTransientError 分类
305
+ throw e;
306
+ }
307
+ return await resp.json();
308
+ } finally {
309
+ clearTimeout(timer);
310
+ }
311
+ };
312
+ // 瞬态错误(429/5xx/timeout)单次调用内重试, 非瞬态(400/401/403)立即抛给上层 provider 回退
313
+ return withRetry(doFetch, { maxRetries });
314
+ }
315
+
316
+ // 是否支持逐字流式: 仅直连 HTTP API 后端 (openclaw/deepseek 为外部进程, 一次性返回) [复审 P2]
317
+ get supportsStream() { return this.backend !== "openclaw" && this.backend !== "deepseek"; }
318
+
319
+ // 是否支持原生 tool_calls: 仅 http 后端 (OpenAI 兼容 API)。
320
+ // openclaw 是完整 agent 运行时, 拒绝 PPX 的围栏协议(视为伪协议); dsh 走文本围栏。
321
+ // 工具类任务应优先路由到支持原生 tool_calls 的后端 (实测 LM Studio 原生 tool_calls 全链路通过)。
322
+ get supportsNativeToolCalls() { return this.backend === "http"; }
323
+
324
+ // openclaw 后端: 启动前校验 Node 版本, 不满足则抛中文引导错误 (而非原始报错)
325
+ _openclawReadyOrThrow() {
326
+ if (!nodeVersionOk(process.versions.node)) {
327
+ throw new Error("[皮皮虾] 当前 Node v" + process.versions.node + " 不满足 OpenClaw 引擎要求。\n请升级 Node 至 >=22.22.3 (推荐 26.x);注意 Node 23 与 24.0-24.14 不支持。\n或改用 http 后端配置 API key 直连。");
328
+ }
329
+ if (!this.mjs || !fs.existsSync(this.mjs)) {
330
+ throw new Error("[皮皮虾] OpenClaw 引擎未就绪: mjs 路径不存在 (" + this.mjs + ")。\n请设置环境变量 PPX_OPENCLAW_MJS 指向 openclaw.mjs,或在 config/ppx.json 的 openclaw provider 里填 mjs 字段。");
331
+ }
332
+ }
333
+
334
+ // 翻译 openclaw CLI 的版本类报错为中文引导
335
+ _translateOpenclawError(e) {
336
+ const msg = String((e && e.message) || e);
337
+ if (/Node\.js >= \d+\.\d+\.\d+/.test(msg) || /engines|不满足引擎要求/.test(msg)) {
338
+ return new Error("[皮皮虾] OpenClaw 引擎要求更高的 Node 版本,请升级 Node 至 >=22.22.3 (推荐 26.x) 后重试。原始信息: " + msg.slice(0, 200));
339
+ }
340
+ return e;
341
+ }
342
+ // ===== Provider 健康探测 (Harness 化: 并发探测可用性, 支持快速失败) =====
343
+ // openclaw 后端: 校验本地 Node 版本是否满足引擎要求 (>=22.22.3 / >=24.15 / >=25.9)
344
+ // http 后端: 快速探测 /models (3s 超时), 不发完整请求
345
+ async health() {
346
+ if (this.backend === "openclaw") {
347
+ const okNode = nodeVersionOk(process.versions.node);
348
+ const okMjs = !!this.mjs && fs.existsSync(this.mjs);
349
+ const ok = okNode && okMjs;
350
+ if (!okNode) info("[health] openclaw 不可用: Node v" + process.versions.node + " 不满足引擎要求");
351
+ if (okNode && !okMjs) info("[health] openclaw 不可用: mjs 路径不存在 (" + this.mjs + "),请设置 PPX_OPENCLAW_MJS");
352
+ return ok;
353
+ }
354
+ if (this.backend === "deepseek") {
355
+ const r = this._dshResolveBin();
356
+ if (!r) {
357
+ info("[health] deepseek 不可用: dsh 未就绪 " + this.dshRoot + " (缺 lib/bin.js 或 apps/cli/src/bin.ts)");
358
+ return false;
359
+ }
360
+ if (r.kind === "src" && !fs.existsSync(path.join(this.dshRoot, "node_modules", "tsx"))) {
361
+ info("[health] deepseek 不可用: dsh 源码形态依赖未安装 (请运行 npm run dsh:install 或设 PPX_DSH_ROOT)");
362
+ return false;
363
+ }
364
+ return nodeVersionOk(process.versions.node);
365
+ }
366
+ if (!this.apiKey) return false;
367
+ try {
368
+ const ctrl = new AbortController();
369
+ const t = setTimeout(() => ctrl.abort(), 3000);
370
+ const r = await fetch(this.baseUrl + "/models", {
371
+ headers: { "Authorization": "Bearer " + this.apiKey },
372
+ signal: ctrl.signal,
373
+ });
374
+ clearTimeout(t);
375
+ return r.ok;
376
+ } catch (e) {
377
+ warn("[health] " + this.providerId + " 探测失败:", e.message);
378
+ return false;
379
+ }
380
+ }
381
+
382
+
383
+ // 流式 chat: 逐块回调 (SSE), 返回累积文本
384
+ // onDelta(content) 每次增量, onDone(full) 结束
385
+ async streamChat(messages, { temperature = 0.7, maxTokens = 4096, onDelta, signal } = {}) {
386
+ if (this.backend === "openclaw") {
387
+ // OpenClaw CLI 非流式: 一次性返回全文 (先可用, 后续可切 SSE)
388
+ const r = await this._openclawChatAsync(messages);
389
+ if (onDelta) onDelta(r.content);
390
+ return r.content;
391
+ }
392
+ if (this.backend === "deepseek") {
393
+ // dsh headless 非流式: 一次性返回全文
394
+ const r = await this._dshChatAsync(messages);
395
+ if (onDelta) onDelta(r.content);
396
+ return r.content;
397
+ }
398
+ if (!this.apiKey) throw new Error(`[皮皮虾] LLM 缺少 API key`);
399
+ const url = `${this.baseUrl}/chat/completions`;
400
+ const ctrl = new AbortController();
401
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
402
+ const extSig = signal || null;
403
+ if (extSig) extSig.addEventListener("abort", () => ctrl.abort());
404
+ let full = "";
405
+ try {
406
+ const resp = await fetch(url, {
407
+ method: "POST",
408
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.apiKey}` },
409
+ body: JSON.stringify({ model: this.model, messages, temperature, max_tokens: maxTokens, stream: true }),
410
+ signal: ctrl.signal,
411
+ });
412
+ if (!resp.ok) {
413
+ const txt = await resp.text().catch(() => "");
414
+ throw new Error(`LLM HTTP ${resp.status}: ${txt.slice(0, 300)}`);
415
+ }
416
+ if (!resp.body) { throw new Error("响应无 body, 不支持流式"); }
417
+ const reader = resp.body.getReader();
418
+ const decoder = new TextDecoder();
419
+ let buf = "";
420
+ let streamDone = false; // 独立结束信号, 不污染 reader.read() 的 done
421
+ while (!streamDone) {
422
+ const { done, value } = await reader.read();
423
+ if (done) break;
424
+ buf += decoder.decode(value, { stream: true });
425
+ // 按行解析 SSE
426
+ let idx;
427
+ while (!streamDone && (idx = buf.indexOf("\n")) !== -1) {
428
+ const line = buf.slice(0, idx).trim().replace(/\r$/, "");
429
+ buf = buf.slice(idx + 1);
430
+ if (!line.startsWith("data:")) continue;
431
+ const data = line.slice(5).trim();
432
+ // 官方结束信号: 仅匹配空格式的 "data: [DONE]", 不做 buf 全文搜防误截
433
+ if (data === "[DONE]") { streamDone = true; break; }
434
+ try {
435
+ const j = JSON.parse(data);
436
+ const delta = j.choices?.[0]?.delta?.content;
437
+ if (delta) { full += delta; onDelta && onDelta(delta); }
438
+ } catch {}
439
+ }
440
+ }
441
+ return full;
442
+ } finally {
443
+ clearTimeout(timer);
444
+ }
445
+ }
446
+ }