ppxans-harness 2.4.0 → 3.2.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 (186) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +218 -265
  3. package/bin/ppx-channels.js +2 -2
  4. package/bin/ppx-serve.js +5 -5
  5. package/bin/ppx-setup.js +124 -0
  6. package/bin/ppx-web.js +140 -0
  7. package/bin/ppx.js +2 -2
  8. package/config/identity.md +6 -6
  9. package/config/ishiki.md +16 -16
  10. package/config/ppx.json +15 -151
  11. package/config/ppx.json.example +143 -0
  12. package/package.json +17 -10
  13. package/skills/.usage.json +6 -0
  14. package/skills/agent-professional-training/SKILL.md +94 -0
  15. package/skills/brainstorm/SKILL.md +24 -0
  16. package/skills/cupid-lover-comms/SKILL.md +37 -0
  17. package/skills/debug/SKILL.md +26 -0
  18. package/skills/plan/SKILL.md +25 -0
  19. package/skills/ponytail/SKILL.md +25 -0
  20. package/skills/ppx-memory/SKILL.md +91 -0
  21. package/skills/ppx-memory/scripts/cli.js +192 -0
  22. package/skills/ppx-memory/scripts/experience.js +133 -0
  23. package/skills/ppx-memory/scripts/fact-store.js +842 -0
  24. package/skills/ppx-memory/scripts/l0.js +52 -0
  25. package/skills/ppx-memory/scripts/l2.js +146 -0
  26. package/skills/ppx-memory/scripts/l3.js +112 -0
  27. package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
  28. package/skills/ppx-memory/scripts/pii.js +42 -0
  29. package/skills/ppx-memory/scripts/schema.js +80 -0
  30. package/skills/ppx-memory/scripts/session.js +398 -0
  31. package/skills/ppx-memory/scripts/similarity.js +43 -0
  32. package/skills/ppx-memory/scripts/store.js +116 -0
  33. package/skills/ppx-memory/scripts/wal.js +38 -0
  34. package/skills/ppx-selfheal/SKILL.md +24 -0
  35. package/skills/ppx-selfheal/scripts/cli.js +80 -0
  36. package/skills/ppx-selfheal/scripts/healer.js +184 -0
  37. package/skills/ppx-selfheal/scripts/logger.js +17 -0
  38. package/skills/ppx-selfheal/scripts/store.js +116 -0
  39. package/skills/prompt-depth-kit/SKILL.md +28 -0
  40. package/skills/session-naming/SKILL.md +36 -0
  41. package/skills/verify/SKILL.md +25 -0
  42. package/src/agent/index.js +1340 -717
  43. package/src/agent/prompts.js +47 -3
  44. package/src/aml-server.js +197 -151
  45. package/src/ans/eviction.js +123 -143
  46. package/src/ans/guard.js +159 -120
  47. package/src/ans/lifecycle.js +96 -93
  48. package/src/ans/proactive.js +112 -129
  49. package/src/ans/reward.js +95 -111
  50. package/src/ans/values.js +15 -15
  51. package/src/audit/audit-chain.js +43 -7
  52. package/src/audit/verifier.js +157 -120
  53. package/src/bus/circuit-breaker.js +9 -1
  54. package/src/bus/runtime-bus.js +107 -93
  55. package/src/channels/base.js +57 -34
  56. package/src/channels/feishu.js +118 -126
  57. package/src/channels/http.js +1090 -592
  58. package/src/channels/index.js +111 -110
  59. package/src/channels/log.js +29 -29
  60. package/src/channels/wechat-crypto.js +73 -74
  61. package/src/channels/wechat.js +191 -197
  62. package/src/channels/workspace.js +94 -0
  63. package/src/channels-cli.js +126 -124
  64. package/src/cli.js +129 -120
  65. package/src/commands/index.js +142 -0
  66. package/src/config/channels.js +137 -170
  67. package/src/config/index.js +277 -224
  68. package/src/config/placeholder.js +31 -0
  69. package/src/config/providers.js +148 -188
  70. package/src/config/settings.js +156 -182
  71. package/src/core/policy.js +69 -18
  72. package/src/core/trace.js +8 -6
  73. package/src/edit/editblock.js +266 -0
  74. package/src/edit/snapshot.js +67 -0
  75. package/src/evidence/index.js +153 -0
  76. package/src/evolve/playbook.js +9 -10
  77. package/src/hooks/index.js +113 -0
  78. package/src/llm/client.js +188 -446
  79. package/src/llm/dsml.js +74 -74
  80. package/src/llm/embedder.js +41 -35
  81. package/src/llm/fence.js +52 -105
  82. package/src/llm/index.js +4 -4
  83. package/src/llm/local-embedder.js +94 -0
  84. package/src/llm/presets.js +113 -0
  85. package/src/llm/pricing.js +93 -0
  86. package/src/llm/retry.js +73 -73
  87. package/src/llm/router.js +92 -97
  88. package/src/mcp/admin.js +326 -0
  89. package/src/mcp/client.js +487 -375
  90. package/src/mcp/http.js +203 -0
  91. package/src/mcp/index.js +116 -116
  92. package/src/mcp/server.js +392 -0
  93. package/src/mcp/tasks.js +133 -0
  94. package/src/memory/asset-hub.js +6 -11
  95. package/src/memory/canvas.js +2 -5
  96. package/src/memory/compaction.js +28 -28
  97. package/src/memory/experience.js +133 -122
  98. package/src/memory/fact-store.js +914 -698
  99. package/src/memory/failure-episode.js +20 -11
  100. package/src/memory/fork.js +17 -8
  101. package/src/memory/index.js +8 -6
  102. package/src/memory/l0.js +53 -52
  103. package/src/memory/l2.js +145 -130
  104. package/src/memory/l3.js +111 -111
  105. package/src/memory/legion-board.js +71 -0
  106. package/src/memory/memory-ticker.js +239 -240
  107. package/src/memory/session.js +398 -397
  108. package/src/memory/sqlite-store.js +581 -0
  109. package/src/mode/blackboard.js +49 -49
  110. package/src/mode/graph.js +42 -41
  111. package/src/mode/index.js +64 -64
  112. package/src/mode/legion.js +54 -51
  113. package/src/mode/plan-exec.js +50 -50
  114. package/src/mode/router.js +28 -40
  115. package/src/orchestrator/agent-worker.js +69 -69
  116. package/src/orchestrator/dag.js +90 -83
  117. package/src/orchestrator/experts.js +76 -0
  118. package/src/orchestrator/index.js +1 -1
  119. package/src/orchestrator/legion.js +179 -187
  120. package/src/orchestrator/supervisor.js +6 -8
  121. package/src/permissions/index.js +378 -0
  122. package/src/persona/index.js +28 -29
  123. package/src/plugin/builtin.js +313 -212
  124. package/src/plugin/context.js +80 -79
  125. package/src/plugin/index.js +62 -62
  126. package/src/plugin/v3.js +73 -0
  127. package/src/protocol/index.js +148 -0
  128. package/src/repomap/index.js +309 -0
  129. package/src/review/index.js +393 -0
  130. package/src/seam/registry.js +3 -0
  131. package/src/seam/shell.js +55 -55
  132. package/src/security/injection.js +79 -0
  133. package/src/selfheal/evolve.js +67 -67
  134. package/src/selfheal/healer.js +184 -167
  135. package/src/selfheal/run.js +9 -9
  136. package/src/server.js +63 -60
  137. package/src/services/diagnose.js +180 -0
  138. package/src/services/learning-service.js +9 -0
  139. package/src/services/memory-health.js +34 -6
  140. package/src/services/memory-service.js +42 -9
  141. package/src/services/triage.js +138 -0
  142. package/src/session/parts.js +76 -0
  143. package/src/session/projection.js +73 -0
  144. package/src/session/rollout.js +54 -0
  145. package/src/session/turn.js +137 -0
  146. package/src/skills/lint.js +72 -0
  147. package/src/skills/loader.js +231 -150
  148. package/src/skills/search.js +58 -0
  149. package/src/skills/verify.js +95 -100
  150. package/src/tools/advanced.js +388 -352
  151. package/src/tools/builtin.js +384 -297
  152. package/src/tools/catalog.js +283 -159
  153. package/src/tools/command-guard.js +112 -112
  154. package/src/tools/custom.js +47 -47
  155. package/src/tools/delegate.js +383 -297
  156. package/src/tools/document.js +254 -253
  157. package/src/tools/git.js +151 -0
  158. package/src/tools/governance.js +47 -20
  159. package/src/tools/index.js +16 -11
  160. package/src/tools/methods.js +178 -178
  161. package/src/tools/ocr.js +59 -59
  162. package/src/tools/sandbox-worker.js +40 -0
  163. package/src/tools/sandbox.js +92 -0
  164. package/src/tools/seam.js +162 -125
  165. package/src/tools/selfmod.js +196 -176
  166. package/src/tools/v3.js +225 -0
  167. package/src/tools/vad.js +176 -0
  168. package/src/tools/voice.js +238 -0
  169. package/src/utils/async.js +14 -0
  170. package/src/utils/config-file.js +53 -0
  171. package/src/utils/crashguard.js +88 -0
  172. package/src/utils/http.js +53 -0
  173. package/src/utils/id.js +8 -0
  174. package/src/utils/json-state.js +33 -0
  175. package/src/utils/logger.js +17 -17
  176. package/src/utils/ndjson.js +25 -0
  177. package/src/utils/pii.js +42 -42
  178. package/src/utils/rate-limit.js +50 -0
  179. package/src/utils/schema.js +80 -0
  180. package/src/utils/similarity.js +43 -0
  181. package/src/utils/store.js +170 -108
  182. package/src/utils/text.js +15 -15
  183. package/src/utils/trace.js +153 -153
  184. package/src/utils/wal.js +39 -0
  185. package/src/utils/winutf8.js +16 -15
  186. package/src/wiki/index.js +170 -0
@@ -5,10 +5,11 @@
5
5
  import { buildDsmlPrompt } from "../llm/dsml.js";
6
6
  import { valuesPrompt } from "../ans/values.js";
7
7
  import { context as rewardContext } from "../ans/reward.js";
8
+ import { renderBullets } from "../evolve/playbook.js";
8
9
  import { imageFileToDataUrl } from "../tools/builtin.js";
9
10
 
10
11
  // 多模态: 提取 user 消息中的图片路径并同步读图, 注入 OpenAI 视觉格式的 content 数组。
11
- // 仅当当前 LLM 是 http 后端且 provider 标记 vision=true 时生效 (openclaw/dsh 走文本围栏不传图)。
12
+ // 仅当当前 LLM 是 http 后端且 provider 标记 vision=true 时生效 (自研底座唯一后端)。
12
13
  // 返回 string (无图/不支持) 或 [{type:text}, {type:image_url}...]
13
14
  export function visionUserContent(llm, root, userMsg) {
14
15
  const text = String(userMsg);
@@ -39,8 +40,40 @@ export const promptMethods = {
39
40
  if (!this.skills) return "";
40
41
  const list = this.skills.list().filter((s) => s && s.description);
41
42
  if (!list.length) return "";
43
+ // 蓝皮书 2026 优化 (2026-10-01): description 是触发路由的唯一依据, 但目录常驻上下文,
44
+ // 全量全文描述会随技能数线性膨胀。改为: 常用优先 (usage 降序) + 单条描述截断 + top-K 上限,
45
+ // 其余技能由 skill_search 按需发现 — 三层渐进加载的第 1 层保持轻量。
46
+ const usage = typeof this.skills.usageAll === "function" ? this.skills.usageAll() : {};
47
+ const sorted = [...list].sort((a, b) => ((usage[b.id] || {}).uses || 0) - ((usage[a.id] || {}).uses || 0) || a.id.localeCompare(b.id));
48
+ const DESC_CAP = 120, MAX_SHOWN = 16;
49
+ const lines = sorted.slice(0, MAX_SHOWN).map((s) => {
50
+ let d = String(s.description).split("\n")[0];
51
+ if (d.length > DESC_CAP) d = d.slice(0, DESC_CAP) + "…";
52
+ return `- ${s.id}: ${d}`;
53
+ });
54
+ const more = sorted.length - Math.min(sorted.length, MAX_SHOWN);
42
55
  return "【可用技能】面对对应任务时用 load_skill 读取全文再执行:\n"
43
- + list.map((s) => `- ${s.id}: ${s.description}`).join("\n");
56
+ + lines.join("\n")
57
+ + (more > 0 ? `\n(另有 ${more} 个技能未列出, 可用 skill_search 按关键词检索)` : "");
58
+ } catch { return ""; }
59
+ },
60
+
61
+ // 按需工具清单 (2026-10-03, 上下文工程): 未披露给 LLM 的工具**只列名字、不列参数 schema**。
62
+ // 目的: 让 agent 知道"还有什么能力可取", 同时不为 41 个工具的 JSON schema 付 token。
63
+ // 实测 59 工具全量 schema ≈ 6725 tok/请求, 而这张清单 ≈ 300 tok —— 省掉 95% 的开销。
64
+ // 与 _skillsPrompt 同一思路: 渐进披露的中间层 (列名 → 按需拉全文)。
65
+ _toolsPrompt() {
66
+ try {
67
+ if (!this.tools || typeof this.tools.hiddenFromLLM !== "function") return "";
68
+ const hidden = this.tools.hiddenFromLLM();
69
+ if (!hidden.length) return "";
70
+ const MAX_SHOWN = 40;
71
+ const shown = hidden.slice(0, MAX_SHOWN);
72
+ const more = hidden.length - shown.length;
73
+ return "【按需工具】以下能力已注册, 但完整参数说明未加载。需要时先 enable_capability 启用, 下一轮即可调用:\n"
74
+ + shown.join(", ")
75
+ + (more > 0 ? ` …(另有 ${more} 个)` : "")
76
+ + "\n(不确定用哪个时, 先 list_capabilities 查看全部能力及其用途)";
44
77
  } catch { return ""; }
45
78
  },
46
79
 
@@ -55,10 +88,21 @@ export const promptMethods = {
55
88
  const active = this.scenes.activeContext(userMsg || "");
56
89
  const baseCtx = active ? base + "\n\n" + active : base;
57
90
  const skills = this._skillsPrompt();
91
+ const toolsHint = this._toolsPrompt();
58
92
  // DSML 原生文本模型 opt-in (provider.dsml=true): 注入工具协议, 让模型能稳定输出 DSML 结构做工具调用
59
93
  const dsml = this._dsmlPrompt();
60
94
  const rewardCtx = rewardContext(this); // ⑦ 低可靠性工具提醒 (Reward 闭环注入)
61
- return [values, baseCtx, skills, citation, perspective, extra, dsml, rewardCtx].filter(Boolean).join("\n\n");
95
+ const playbookCtx = this._playbookPrompt(); // 2026-10-03 接线: 语境 Playbook bullets 注入
96
+ return [values, baseCtx, skills, toolsHint, citation, perspective, extra, dsml, rewardCtx, playbookCtx].filter(Boolean).join("\n\n");
97
+ },
98
+
99
+ // 语境 Playbook 注入 (2026-10-03 接线): 修复 playbook 引擎"就绪、无消费方"的缺口。
100
+ // bullets 为空时 renderBullets 返回空串 → 零 token 成本, 不影响现有会话。
101
+ _playbookPrompt() {
102
+ try {
103
+ if (!this.playbook) return "";
104
+ return renderBullets(this.playbook.playbook, { maxBullets: 12 });
105
+ } catch { return ""; }
62
106
  },
63
107
 
64
108
  // DSML 工具调用协议注入 (v1.1.1 接线): 修复 buildDsmlPrompt 过去从未注入的缺口。
package/src/aml-server.js CHANGED
@@ -1,151 +1,197 @@
1
- // src/aml-server.js - AML (Agent Memory Leaderboard) 适配服务
2
- // 提供 Add + Search 契约, 映射进 FactStore (BM25 + bigram 检索, scope 隔离)
3
- // 端点:
4
- // POST /v1/memories/add 同步存储 (消息完整落盘且可检索后才返回), 回显 request_id
5
- // POST /v1/memories/search query + scope + top_k 检索
6
- // GET /health
7
- // 鉴权: PPX_AML_AUTH = token|bearer|x-api-key|none (默认 none)
8
- // PPX_AML_AUTH_VALUE = 对应密钥
9
- // 启动: node src/aml-server.js (监听 PPX_AML_PORT, 默认 8900)
10
- import http from "node:http";
11
- import path from "node:path";
12
- import { fileURLToPath } from "node:url";
13
- import { FactStore } from "./memory/fact-store.js";
14
-
15
- const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
16
- const PORT = Number(process.env.PPX_AML_PORT || 8900);
17
- const DATA = process.env.PPX_AML_DATA || path.join(ROOT, "data", "aml");
18
- const AUTH_SCHEME = (process.env.PPX_AML_AUTH || "none").toLowerCase();
19
- const AUTH_VALUE = process.env.PPX_AML_AUTH_VALUE || "";
20
- const MAX_BODY = 1024 * 1024; // 1MB 请求体上限, 防滥用
21
- const RATE_PER_MIN = 60; // 每 IP 每分钟最大请求数 (令牌桶, 对齐 http.js)
22
- const RATE_WINDOW_MS = 60_000;
23
-
24
- const store = new FactStore(DATA, {});
25
- const _buckets = new Map(); // ip -> {tokens, last}
26
-
27
- // 简单令牌桶限流: 每 IP 60 req/min (对齐 channels/http.js) [P1#10]
28
- function rateLimit(req, res) {
29
- const ip = req.socket?.remoteAddress || "unknown";
30
- const now = Date.now();
31
- let b = _buckets.get(ip);
32
- if (!b) {
33
- b = { tokens: RATE_PER_MIN, last: now };
34
- _buckets.set(ip, b);
35
- }
36
- const refill = Math.floor((now - b.last) / RATE_WINDOW_MS);
37
- if (refill > 0) {
38
- b.tokens = Math.min(RATE_PER_MIN, b.tokens + refill * RATE_PER_MIN);
39
- b.last = now;
40
- }
41
- if (b.tokens <= 0) {
42
- res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
43
- res.end(JSON.stringify({ error: "rate limited" }));
44
- return false;
45
- }
46
- b.tokens -= 1;
47
- return true;
48
- }
49
-
50
- function authOk(req) {
51
- if (AUTH_SCHEME === "none") return true;
52
- if (AUTH_SCHEME === "token") return req.headers.authorization === "Token " + AUTH_VALUE;
53
- if (AUTH_SCHEME === "bearer") return req.headers.authorization === "Bearer " + AUTH_VALUE;
54
- if (AUTH_SCHEME === "x-api-key") return req.headers["x-api-key"] === AUTH_VALUE;
55
- return false;
56
- }
57
-
58
- // 读取请求体, 带大小上限 (超限/非法 JSON 返回 null)
59
- function readBody(req, maxBytes = MAX_BODY) {
60
- return new Promise((resolve) => {
61
- let d = "";
62
- let tooBig = false;
63
- req.on("data", (c) => {
64
- if (tooBig) return; // 已超限, 丢弃后续数据但不断连 (让 handler 回 413)
65
- d += c;
66
- if (Buffer.byteLength(d) > maxBytes) { tooBig = true; d = ""; }
67
- });
68
- req.on("end", () => {
69
- if (tooBig) return resolve(null); // handler 据此回 413
70
- try { resolve(JSON.parse(d)); } catch { resolve(null); }
71
- });
72
- req.on("error", () => resolve(null));
73
- });
74
- }
75
-
76
- function send(res, code, obj) {
77
- const body = JSON.stringify(obj);
78
- res.writeHead(code, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
79
- res.end(body);
80
- }
81
-
82
- // Add: messages[] -> 每条存为带 scope + 结构化元数据(role/seq/timestamp) 的 fact
83
- // 保留 role/顺序/时间, 支撑 AML 多跳/时间/关系维度
84
- // 同步语义 = 全部落盘且可检索后才返回
85
- async function handleAdd(req, res) {
86
- const body = await readBody(req);
87
- if (!body) return send(res, 413, { error: "request body too large or invalid" });
88
- if (!body || !Array.isArray(body.messages)) return send(res, 422, { error: "messages[] required" });
89
- const { request_id, messages, scope, conversation_id, async_mode } = body;
90
- if (scope == null) return send(res, 422, { error: "scope required" });
91
- let stored = 0;
92
- messages.forEach((m, i) => {
93
- const content = String(m?.content || "").trim();
94
- if (!content) return;
95
- const meta = {
96
- seq: i, // 保留对话内顺序
97
- };
98
- if (m.role != null) meta.role = String(m.role); // 保留 speaker
99
- if (m.timestamp != null) meta.timestamp = m.timestamp; // 保留原始时间戳
100
- if (conversation_id != null) meta.conversation_id = String(conversation_id);
101
- store.add(content, { type: "message", source: "aml", dedupe: false, scope: String(scope), meta });
102
- stored += 1;
103
- });
104
- // request_id 原样回显; 同步模式已完成存储
105
- return send(res, 200, {
106
- request_id: request_id ?? null,
107
- status: "ok",
108
- stored,
109
- scope: String(scope),
110
- conversation_id: conversation_id ?? null,
111
- async_mode: async_mode || false,
112
- });
113
- }
114
-
115
- async function handleSearch(req, res) {
116
- const body = await readBody(req);
117
- if (!body) return send(res, 413, { error: "request body too large or invalid" });
118
- const query = String(body?.query || "").trim();
119
- const scope = body?.scope ?? null;
120
- const top_k = Math.min(Number(body?.top_k || 10), 100);
121
- if (!query) return send(res, 422, { error: "query required" });
122
- const hits = store.query(query, { limit: top_k || 10, scope: scope == null ? null : String(scope) });
123
- return send(res, 200, {
124
- query,
125
- scope: scope ?? null,
126
- count: hits.length,
127
- results: hits.map((h) => ({ id: h.id, content: h.content, score: Math.round(h.effectiveScore * 100) / 100 })),
128
- });
129
- }
130
-
131
- export function createAmlServer() {
132
- return http.createServer(async (req, res) => {
133
- const url = new URL(req.url, "http://x");
134
- if (!authOk(req)) return send(res, 401, { error: "unauthorized" });
135
- if (!rateLimit(req, res)) return;
136
- const pathname = url.pathname;
137
- if (req.method === "POST" && pathname === "/v1/memories/add") return handleAdd(req, res);
138
- if (req.method === "POST" && pathname === "/v1/memories/search") return handleSearch(req, res);
139
- if (req.method === "GET" && pathname === "/health") return send(res, 200, { status: "ok" });
140
- return send(res, 404, { error: "not found" });
141
- });
142
- }
143
-
144
- // CLI 入口: 直接运行本文件时启动服务器
145
- if (import.meta.url === `file:///${process.argv[1]?.replace(/\\\\/g, "/")}`) {
146
- const server = createAmlServer();
147
- server.listen(PORT, () => {
148
- console.log(`[aml-server] listening on :${PORT} | auth=${AUTH_SCHEME} | data=${DATA}`);
149
- console.log(` POST /v1/memories/add POST /v1/memories/search GET /health`);
150
- });
151
- }
1
+ // src/aml-server.js - AML (Agent Memory Leaderboard) 适配服务
2
+ // 提供 Add + Search 契约, 映射进 FactStore (BM25 + bigram 检索, scope 隔离)
3
+ // 端点:
4
+ // POST /v1/memories/add 同步存储 (消息完整落盘且可检索后才返回), 回显 request_id
5
+ // POST /v1/memories/search query + scope + top_k 检索
6
+ // GET /health
7
+ // 鉴权: PPX_AML_AUTH = token|bearer|x-api-key|none (默认 none)
8
+ // PPX_AML_AUTH_VALUE = 对应密钥
9
+ // 启动: node src/aml-server.js (监听 PPX_AML_PORT, 默认 8900)
10
+ import http from "node:http";
11
+ import path from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import crypto from "node:crypto";
14
+ import { FactStore } from "./memory/fact-store.js";
15
+
16
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const PORT = Number(process.env.PPX_AML_PORT || 8900);
18
+ const AUTH_SCHEME = (process.env.PPX_AML_AUTH || "none").toLowerCase();
19
+ const AUTH_VALUE = process.env.PPX_AML_AUTH_VALUE || "";
20
+ const MAX_BODY = 1024 * 1024; // 1MB 请求体上限, 防滥用
21
+ const RATE_PER_MIN = 60; // 每 IP 每分钟最大请求数 (令牌桶, 对齐 http.js)
22
+ const RATE_WINDOW_MS = 60_000;
23
+
24
+ // 数据目录: 每次调用现读环境变量 (不在模块加载期固化)
25
+ function amlDataDir() {
26
+ return process.env.PPX_AML_DATA || path.join(ROOT, "data", "aml");
27
+ }
28
+
29
+ // 记忆库懒初始化 (2026-10-03 修复)
30
+ // 原先写的是模块顶层 `const store = new FactStore(DATA, {})` —— ESM 静态 import 会在任何
31
+ // 调用方代码之前执行, 于是测试里"先设 process.env.PPX_AML_DATA 再 createAmlServer()"完全无效:
32
+ // store 早已指向真实 <root>/data/aml。后果是**每跑一轮测试就往生产数据目录写记录**
33
+ // (实测 data/aml/memory/facts.json 每轮 +2 条, 内容与测试用例一致)。
34
+ // 改为懒加载: 首次真正用到时才读环境变量建库, import 本身零副作用。
35
+ let _store = null;
36
+ function getStore() {
37
+ if (!_store) _store = new FactStore(amlDataDir(), {});
38
+ return _store;
39
+ }
40
+ const _buckets = new Map(); // ip -> {tokens, last}
41
+
42
+ // 桶回收 (2026-10-03 修复): 变源 IP 长期运行时 _buckets 无界增长 → 超过阈值时
43
+ // 摊还回收 "2 倍窗口未活跃" 的过期桶 (对齐 utils/rate-limit.js 的 sweep 策略)
44
+ function sweepBuckets(now) {
45
+ if (_buckets.size < 512) return;
46
+ for (const [k, b] of _buckets) {
47
+ if (now - b.last > RATE_WINDOW_MS * 2) _buckets.delete(k);
48
+ }
49
+ }
50
+
51
+ // 简单令牌桶限流: 每 IP 60 req/min (对齐 channels/http.js) [P1#10]
52
+ function rateLimit(req, res) {
53
+ const ip = req.socket?.remoteAddress || "unknown";
54
+ const now = Date.now();
55
+ sweepBuckets(now);
56
+ let b = _buckets.get(ip);
57
+ if (!b) {
58
+ b = { tokens: RATE_PER_MIN, last: now };
59
+ _buckets.set(ip, b);
60
+ }
61
+ const refill = Math.floor((now - b.last) / RATE_WINDOW_MS);
62
+ if (refill > 0) {
63
+ b.tokens = Math.min(RATE_PER_MIN, b.tokens + refill * RATE_PER_MIN);
64
+ b.last = now;
65
+ }
66
+ if (b.tokens <= 0) {
67
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" });
68
+ res.end(JSON.stringify({ error: "rate limited" }));
69
+ return false;
70
+ }
71
+ b.tokens -= 1;
72
+ return true;
73
+ }
74
+
75
+ // 恒定时间比较 (2026-10-03 修复): 原 === 直接比较存在计时侧信道;
76
+ // 先 sha256 摘要拉平长度再 timingSafeEqual (对齐 channels/http.js 的 safeEqual)
77
+ function safeEqual(a, b) {
78
+ const sha = (s) => crypto.createHash("sha256").update(String(s)).digest();
79
+ try { return crypto.timingSafeEqual(sha(a), sha(b)); } catch { return false; }
80
+ }
81
+
82
+ function authOk(req) {
83
+ if (AUTH_SCHEME === "none") return true;
84
+ if (AUTH_SCHEME === "token") return safeEqual(req.headers.authorization, "Token " + AUTH_VALUE);
85
+ if (AUTH_SCHEME === "bearer") return safeEqual(req.headers.authorization, "Bearer " + AUTH_VALUE);
86
+ if (AUTH_SCHEME === "x-api-key") return safeEqual(req.headers["x-api-key"], AUTH_VALUE);
87
+ return false;
88
+ }
89
+
90
+ // 读取请求体, 带大小上限。
91
+ // 2026-10-03 修复: 原实现用字符串累加 `d += c`, 多字节 UTF-8 字符跨 TCP 分块边界
92
+ // 会被切成 U+FFFD, 中文消息静默损坏 (utils/http.js 修过一模一样的 bug)。
93
+ // 改为先 Buffer.concat 再整体 toString("utf8")。
94
+ // 返回: { ok: true, body } | { ok: false, tooBig } (tooBig → 413, 非法 JSON → 400)
95
+ function readBody(req, maxBytes = MAX_BODY) {
96
+ return new Promise((resolve) => {
97
+ const chunks = [];
98
+ let size = 0;
99
+ let tooBig = false;
100
+ req.on("data", (c) => {
101
+ if (tooBig) return; // 已超限, 丢弃后续数据但不断连 (让 handler 回 413)
102
+ size += c.length;
103
+ if (size > maxBytes) { tooBig = true; chunks.length = 0; return; }
104
+ chunks.push(c);
105
+ });
106
+ req.on("end", () => {
107
+ if (tooBig) return resolve({ ok: false, tooBig: true });
108
+ try { resolve({ ok: true, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) }); }
109
+ catch { resolve({ ok: false, tooBig: false }); }
110
+ });
111
+ req.on("error", () => resolve({ ok: false, tooBig: false }));
112
+ });
113
+ }
114
+
115
+ function send(res, code, obj) {
116
+ const body = JSON.stringify(obj);
117
+ res.writeHead(code, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
118
+ res.end(body);
119
+ }
120
+
121
+ // Add: messages[] -> 每条存为带 scope + 结构化元数据(role/seq/timestamp) 的 fact
122
+ // 保留 role/顺序/时间, 支撑 AML 多跳/时间/关系维度
123
+ // 同步语义 = 全部落盘且可检索后才返回
124
+ async function handleAdd(req, res) {
125
+ const parsed = await readBody(req);
126
+ if (!parsed.ok) return send(res, parsed.tooBig ? 413 : 400, { error: parsed.tooBig ? "request body too large" : "invalid JSON body" });
127
+ const body = parsed.body;
128
+ if (!body || !Array.isArray(body.messages)) return send(res, 422, { error: "messages[] required" });
129
+ const { request_id, messages, scope, conversation_id, async_mode } = body;
130
+ if (scope == null) return send(res, 422, { error: "scope required" });
131
+ let stored = 0;
132
+ messages.forEach((m, i) => {
133
+ const content = String(m?.content || "").trim();
134
+ if (!content) return;
135
+ const meta = {
136
+ seq: i, // 保留对话内顺序
137
+ };
138
+ if (m.role != null) meta.role = String(m.role); // 保留 speaker
139
+ if (m.timestamp != null) meta.timestamp = m.timestamp; // 保留原始时间戳
140
+ if (conversation_id != null) meta.conversation_id = String(conversation_id);
141
+ getStore().add(content, { type: "message", source: "aml", dedupe: false, scope: String(scope), meta });
142
+ stored += 1;
143
+ });
144
+ // request_id 原样回显; 同步模式已完成存储
145
+ return send(res, 200, {
146
+ request_id: request_id ?? null,
147
+ status: "ok",
148
+ stored,
149
+ scope: String(scope),
150
+ conversation_id: conversation_id ?? null,
151
+ async_mode: async_mode || false,
152
+ });
153
+ }
154
+
155
+ async function handleSearch(req, res) {
156
+ const parsed = await readBody(req);
157
+ if (!parsed.ok) return send(res, parsed.tooBig ? 413 : 400, { error: parsed.tooBig ? "request body too large" : "invalid JSON body" });
158
+ const body = parsed.body;
159
+ const query = String(body?.query || "").trim();
160
+ const scope = body?.scope ?? null;
161
+ const top_k = Math.min(Number(body?.top_k || 10), 100);
162
+ if (!query) return send(res, 422, { error: "query required" });
163
+ const hits = getStore().query(query, { limit: top_k || 10, scope: scope == null ? null : String(scope) });
164
+ return send(res, 200, {
165
+ query,
166
+ scope: scope ?? null,
167
+ count: hits.length,
168
+ results: hits.map((h) => ({ id: h.id, content: h.content, score: Math.round(h.effectiveScore * 100) / 100 })),
169
+ });
170
+ }
171
+
172
+ export function createAmlServer() {
173
+ return http.createServer(async (req, res) => {
174
+ const url = new URL(req.url, "http://x");
175
+ if (!rateLimit(req, res)) return;
176
+ // /health 免鉴权 (2026-10-03 修复): 监控系统探活惯例不带业务 token。
177
+ // 注意仍受限流约束 (在 rateLimit 之后), 与测试 "限流 429 (60/min 令牌桶)" 的口径一致。
178
+ if (req.method === "GET" && url.pathname === "/health") return send(res, 200, { status: "ok" });
179
+ if (!authOk(req)) return send(res, 401, { error: "unauthorized" });
180
+ const pathname = url.pathname;
181
+ if (req.method === "POST" && pathname === "/v1/memories/add") return handleAdd(req, res);
182
+ if (req.method === "POST" && pathname === "/v1/memories/search") return handleSearch(req, res);
183
+ return send(res, 404, { error: "not found" });
184
+ });
185
+ }
186
+
187
+ // CLI 入口: 直接运行本文件时启动服务器
188
+ // 2026-10-03 修复 (P0): 原判断 `import.meta.url === \`file:///${argv1.replace(/\\\\/g, "/")}\``
189
+ // 正则写成了 /\\\\/g (匹配两个连续反斜杠), Windows argv1 是单反斜杠 → 替换不生效 →
190
+ // 条件恒 false → `node src/aml-server.js` 静默退出。改用 pathToFileURL 规范比较。
191
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
192
+ const server = createAmlServer();
193
+ server.listen(PORT, () => {
194
+ console.log(`[aml-server] listening on :${PORT} | auth=${AUTH_SCHEME} | data=${amlDataDir()}`);
195
+ console.log(` POST /v1/memories/add POST /v1/memories/search GET /health`);
196
+ });
197
+ }