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.
- package/LICENSE +201 -0
- package/README.md +265 -0
- package/bin/ppx-channels.js +3 -0
- package/bin/ppx-serve.js +6 -0
- package/bin/ppx.js +3 -0
- package/config/identity.md +6 -0
- package/config/ishiki.md +16 -0
- package/config/ppx.json +151 -0
- package/package.json +69 -0
- package/src/agent/context.js +179 -0
- package/src/agent/index.js +717 -0
- package/src/agent/prompts.js +107 -0
- package/src/aml-server.js +151 -0
- package/src/ans/eviction.js +144 -0
- package/src/ans/guard.js +120 -0
- package/src/ans/lifecycle.js +93 -0
- package/src/ans/proactive.js +129 -0
- package/src/ans/reward.js +112 -0
- package/src/ans/values.js +15 -0
- package/src/audit/audit-chain.js +167 -0
- package/src/audit/verifier.js +120 -0
- package/src/bus/circuit-breaker.js +115 -0
- package/src/bus/runtime-bus.js +94 -0
- package/src/channels/base.js +35 -0
- package/src/channels/feishu.js +127 -0
- package/src/channels/http.js +592 -0
- package/src/channels/index.js +110 -0
- package/src/channels/log.js +29 -0
- package/src/channels/wechat-crypto.js +74 -0
- package/src/channels/wechat.js +197 -0
- package/src/channels-cli.js +124 -0
- package/src/cli.js +120 -0
- package/src/config/channels.js +170 -0
- package/src/config/index.js +224 -0
- package/src/config/providers.js +189 -0
- package/src/config/settings.js +182 -0
- package/src/core/policy.js +272 -0
- package/src/core/trace.js +89 -0
- package/src/evolve/playbook.js +194 -0
- package/src/llm/client.js +446 -0
- package/src/llm/dsml.js +74 -0
- package/src/llm/embedder.js +35 -0
- package/src/llm/fence.js +105 -0
- package/src/llm/index.js +4 -0
- package/src/llm/retry.js +73 -0
- package/src/llm/router.js +98 -0
- package/src/mcp/client.js +375 -0
- package/src/mcp/index.js +116 -0
- package/src/memory/asset-hub.js +131 -0
- package/src/memory/canvas.js +131 -0
- package/src/memory/compaction.js +28 -0
- package/src/memory/experience.js +122 -0
- package/src/memory/fact-store.js +699 -0
- package/src/memory/failure-episode.js +99 -0
- package/src/memory/fork.js +83 -0
- package/src/memory/index.js +7 -0
- package/src/memory/l0.js +52 -0
- package/src/memory/l2.js +131 -0
- package/src/memory/l3.js +112 -0
- package/src/memory/memory-ticker.js +240 -0
- package/src/memory/session.js +398 -0
- package/src/mode/blackboard.js +49 -0
- package/src/mode/graph.js +41 -0
- package/src/mode/index.js +64 -0
- package/src/mode/legion.js +51 -0
- package/src/mode/plan-exec.js +50 -0
- package/src/mode/router.js +40 -0
- package/src/orchestrator/agent-worker.js +70 -0
- package/src/orchestrator/dag.js +83 -0
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/legion.js +188 -0
- package/src/orchestrator/supervisor.js +177 -0
- package/src/persona/index.js +29 -0
- package/src/plugin/builtin.js +212 -0
- package/src/plugin/context.js +79 -0
- package/src/plugin/index.js +62 -0
- package/src/seam/registry.js +98 -0
- package/src/seam/shell.js +55 -0
- package/src/selfheal/evolve.js +68 -0
- package/src/selfheal/healer.js +167 -0
- package/src/selfheal/run.js +9 -0
- package/src/server.js +60 -0
- package/src/services/learning-service.js +177 -0
- package/src/services/memory-health.js +99 -0
- package/src/services/memory-service.js +160 -0
- package/src/skills/loader.js +150 -0
- package/src/skills/verify.js +100 -0
- package/src/tools/advanced.js +353 -0
- package/src/tools/builtin.js +298 -0
- package/src/tools/catalog.js +159 -0
- package/src/tools/command-guard.js +112 -0
- package/src/tools/custom.js +47 -0
- package/src/tools/delegate.js +297 -0
- package/src/tools/document.js +253 -0
- package/src/tools/governance.js +260 -0
- package/src/tools/index.js +11 -0
- package/src/tools/methods.js +178 -0
- package/src/tools/ocr.js +59 -0
- package/src/tools/seam.js +125 -0
- package/src/tools/selfmod.js +176 -0
- package/src/utils/logger.js +17 -0
- package/src/utils/pii.js +42 -0
- package/src/utils/store.js +108 -0
- package/src/utils/text.js +16 -0
- package/src/utils/trace.js +153 -0
- package/src/utils/winutf8.js +15 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/agent/prompts.js - Agent 提示词/上下文构建 (从 index.js 拆分, mixin 挂回 prototype)
|
|
2
|
+
// 重构 (2026-09-15): 提示词组装 (技能清单/核心价值/DSML/画像/多模态) 从 PPXAgent 类中抽出,
|
|
3
|
+
// 方法以 mixin 方式挂回 prototype, 实例行为与调用方完全不变。
|
|
4
|
+
|
|
5
|
+
import { buildDsmlPrompt } from "../llm/dsml.js";
|
|
6
|
+
import { valuesPrompt } from "../ans/values.js";
|
|
7
|
+
import { context as rewardContext } from "../ans/reward.js";
|
|
8
|
+
import { imageFileToDataUrl } from "../tools/builtin.js";
|
|
9
|
+
|
|
10
|
+
// 多模态: 提取 user 消息中的图片路径并同步读图, 注入 OpenAI 视觉格式的 content 数组。
|
|
11
|
+
// 仅当当前 LLM 是 http 后端且 provider 标记 vision=true 时生效 (openclaw/dsh 走文本围栏不传图)。
|
|
12
|
+
// 返回 string (无图/不支持) 或 [{type:text}, {type:image_url}...]
|
|
13
|
+
export function visionUserContent(llm, root, userMsg) {
|
|
14
|
+
const text = String(userMsg);
|
|
15
|
+
if (!llm || llm.backend !== "http" || !llm.vision) return text;
|
|
16
|
+
const paths = [];
|
|
17
|
+
for (const m of text.matchAll(/[^\s"'`,。;;::,,()()]+\.(?:png|jpe?g|gif|webp|bmp)/gi)) {
|
|
18
|
+
paths.push(m[0]);
|
|
19
|
+
}
|
|
20
|
+
if (!paths.length) return text;
|
|
21
|
+
const content = [{ type: "text", text }];
|
|
22
|
+
for (const p of [...new Set(paths)].slice(0, 4)) {
|
|
23
|
+
try {
|
|
24
|
+
const dataUrl = imageFileToDataUrl(root, p);
|
|
25
|
+
content.push({ type: "image_url", image_url: { url: dataUrl } });
|
|
26
|
+
} catch { /* 读图失败静默跳过, 保留纯文本 */ }
|
|
27
|
+
}
|
|
28
|
+
return content.length > 1 ? content : text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const promptMethods = {
|
|
32
|
+
// 组装记忆上下文
|
|
33
|
+
// 差异化视角 (_perspective): 多 agent 场景下由委派方注入子 agent 的专属视角,
|
|
34
|
+
// 对抗同质失败 (Anthropic: 同模型+同上下文 → 一个错全错), 生命周期由调用方控制
|
|
35
|
+
// 方法技能清单注入: 让 LLM 知道有哪些方法论技能可用, 面对任务时主动 load_skill
|
|
36
|
+
// (Superpowers 吸收: 技能不自动生效, 需要触发才读取全文, 省 token)
|
|
37
|
+
_skillsPrompt() {
|
|
38
|
+
try {
|
|
39
|
+
if (!this.skills) return "";
|
|
40
|
+
const list = this.skills.list().filter((s) => s && s.description);
|
|
41
|
+
if (!list.length) return "";
|
|
42
|
+
return "【可用技能】面对对应任务时用 load_skill 读取全文再执行:\n"
|
|
43
|
+
+ list.map((s) => `- ${s.id}: ${s.description}`).join("\n");
|
|
44
|
+
} catch { return ""; }
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
_context(userMsg) {
|
|
48
|
+
const base = this.persona.systemPrompt(this.userName) + "\n\n" + this.memory.context(userMsg) + "\n\n" + this.experience.context() + this._l3Context();
|
|
49
|
+
// 核心价值 (ANS 价值对齐): 注入最前, 独立于 prompt, 不可被后续指令违背
|
|
50
|
+
const values = this._valuesPrompt();
|
|
51
|
+
// 引用规则 + 额外 system 内容均可配置 (agent.citation_rule / agent.system_extra)
|
|
52
|
+
const citation = this.config.agent?.citation_rule || "";
|
|
53
|
+
const extra = this.config.agent?.system_extra || "";
|
|
54
|
+
const perspective = this._perspective ? `【任务视角】${this._perspective}` : "";
|
|
55
|
+
const active = this.scenes.activeContext(userMsg || "");
|
|
56
|
+
const baseCtx = active ? base + "\n\n" + active : base;
|
|
57
|
+
const skills = this._skillsPrompt();
|
|
58
|
+
// DSML 原生文本模型 opt-in (provider.dsml=true): 注入工具协议, 让模型能稳定输出 DSML 结构做工具调用
|
|
59
|
+
const dsml = this._dsmlPrompt();
|
|
60
|
+
const rewardCtx = rewardContext(this); // ⑦ 低可靠性工具提醒 (Reward 闭环注入)
|
|
61
|
+
return [values, baseCtx, skills, citation, perspective, extra, dsml, rewardCtx].filter(Boolean).join("\n\n");
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// DSML 工具调用协议注入 (v1.1.1 接线): 修复 buildDsmlPrompt 过去从未注入的缺口。
|
|
65
|
+
// 仅当激活的 http provider 显式 dsml=true 且工具启用时注入; 默认所有 provider 不注入(零回归)。
|
|
66
|
+
_dsmlPrompt() {
|
|
67
|
+
try {
|
|
68
|
+
if (!(this.llm && this.llm.dsml === true)) return "";
|
|
69
|
+
if (!this.toolsEnabled || !this.tools) return "";
|
|
70
|
+
const tools = this.tools.toOpenAI();
|
|
71
|
+
return buildDsmlPrompt(tools);
|
|
72
|
+
} catch { return ""; }
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// 核心价值文本 (委托 ans/values 模块): 数组 → 固定格式注入 (无值时不注入, 向后兼容)
|
|
76
|
+
_valuesPrompt() {
|
|
77
|
+
return valuesPrompt(this.config.agent?.values);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// L3 画像注入: 已生成的用户画像 + agent 自我画像 (未生成返回 "")
|
|
81
|
+
_l3Context() {
|
|
82
|
+
try {
|
|
83
|
+
const parts = [];
|
|
84
|
+
const u = this.personaStore.userPersona();
|
|
85
|
+
const a = this.personaStore.agentPersona();
|
|
86
|
+
if (u) parts.push(u);
|
|
87
|
+
if (a) parts.push(a);
|
|
88
|
+
return parts.length ? "\n\n" + parts.join("\n\n") : "";
|
|
89
|
+
} catch { return ""; }
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
// L3 画像刷新: 跨天触发 (实现已迁 memory-service, 此处分发; 日期标记在 service 内部)
|
|
93
|
+
_maybeRefreshPersona() {
|
|
94
|
+
return this.memorySvc.refreshPersona();
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// 找视觉 provider: 当前 LLM 若是 vision 直接用, 否则从 allProviders 找第一个 vision
|
|
98
|
+
_visionLLM() {
|
|
99
|
+
if (this.llm && this.llm.vision) return this.llm;
|
|
100
|
+
return (this.allProviders || []).find((p) => p.vision) || null;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// 多模态 user 消息内容: 有图且存在 vision provider 时返回 content 数组, 否则纯文本
|
|
104
|
+
_userContent(userMsg) {
|
|
105
|
+
return visionUserContent(this._visionLLM(), this.root, userMsg);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
@@ -0,0 +1,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 } 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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// src/ans/eviction.js - 排泄系 (⑤ 遗忘-归档) 自治治理 (ANS)
|
|
2
|
+
//
|
|
3
|
+
// 职责 (RC1 §1 ⑤): 记忆压缩、冷热层、历史会话归档、冗余识别、过期治理、遗忘与删除候选生成。
|
|
4
|
+
// 边界仅限跨会话长期记忆 (L1 FactStore + 会话日志); 功能性缓存/日志不归它管。
|
|
5
|
+
//
|
|
6
|
+
// 本期聚焦三大可落地的自治能力:
|
|
7
|
+
// 1. 冗余识别 - 利用 FactStore.findSimilar 检测高度重复记忆, 标记为"冗余候选"(合并/降权/删除候选)
|
|
8
|
+
// 2. 冷热分层 - 按 低访问天数×低衰减分 识别"冷记忆", 归档为只读, 不占热上下文
|
|
9
|
+
// 3. 归档扫描 - 产出结构化治理报告 (preview 不直接改), 挂 Scheduler 每日自动跑
|
|
10
|
+
//
|
|
11
|
+
// 删除策略 (务实): findSimilar 检测到的高相似对 → 保留高保真(importance/hits 更高)那条, 低的那条标"冗余候选"。
|
|
12
|
+
// 默认只"报告 + 标记", 硬删除交给 FactStore 既有 _prune (超 maxFacts 时)。本模块产出治理信号, 不越权改数据。
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { ensureDir } from "../utils/store.js";
|
|
16
|
+
import { info } from "../utils/logger.js";
|
|
17
|
+
|
|
18
|
+
// 冗余判定阈值 (bigram overlap 相似度, 0~1; 中文同义变体通常 >0.6)
|
|
19
|
+
const SIM_THRESHOLD = 0.6;
|
|
20
|
+
// 中文 bigram 集: 连续两个字符为一组 (字符级 bigram, 对中文简繁/词序变化有容错)
|
|
21
|
+
function _bigramSet(s) {
|
|
22
|
+
const set = new Set();
|
|
23
|
+
const t = String(s || "").replace(/\s+/g, "");
|
|
24
|
+
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
|
|
25
|
+
return set;
|
|
26
|
+
}
|
|
27
|
+
// bigram overlap: 交集 / 较短集合 (0~1) — 对「共享核心词但措辞宽松」更敏感
|
|
28
|
+
function _overlap(a, b) {
|
|
29
|
+
const A = _bigramSet(a), B = _bigramSet(b);
|
|
30
|
+
if (!A.size || !B.size) return 0;
|
|
31
|
+
let inter = 0;
|
|
32
|
+
for (const x of A) if (B.has(x)) inter++;
|
|
33
|
+
return inter / Math.min(A.size, B.size);
|
|
34
|
+
}
|
|
35
|
+
// 冷记忆判定: 连续 N 天未被访问 + 分数低于分位 → 冷
|
|
36
|
+
const COLD_DAYS = 14;
|
|
37
|
+
// 治理报告保留条数
|
|
38
|
+
const REPORT_LIMIT = 50;
|
|
39
|
+
|
|
40
|
+
function _file(agent) { return path.join(agent.dataDir, "memory", "eviction.json"); }
|
|
41
|
+
|
|
42
|
+
// 读取治理状态 (缺失/损坏 → 空)
|
|
43
|
+
export function loadState(agent) {
|
|
44
|
+
try {
|
|
45
|
+
const f = _file(agent);
|
|
46
|
+
if (fs.existsSync(f)) {
|
|
47
|
+
const s = JSON.parse(fs.readFileSync(f, "utf8"));
|
|
48
|
+
return (s && typeof s === "object") ? s : {};
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function saveState(agent, state) {
|
|
55
|
+
try {
|
|
56
|
+
const f = _file(agent);
|
|
57
|
+
ensureDir(path.dirname(f));
|
|
58
|
+
fs.writeFileSync(f, JSON.stringify(state, null, 2), "utf8");
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 幂等记录一次扫描结果 (去重: 同 id 已存在则更新, 不无限追加)
|
|
63
|
+
function _recordRun(agent, run) {
|
|
64
|
+
const st = loadState(agent);
|
|
65
|
+
st.lastRun = run;
|
|
66
|
+
saveState(agent, st);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 扫描一次 L1 记忆: 返回 { redundant, cold, total, scanned } 治理报告 (只读, 不改数据)
|
|
70
|
+
export function scan(agent) {
|
|
71
|
+
const facts = agent.facts;
|
|
72
|
+
if (!facts || typeof facts.list !== "function" || !facts.findSimilar) {
|
|
73
|
+
return { total: 0, redundant: [], cold: [], lastRunAt: Date.now() };
|
|
74
|
+
}
|
|
75
|
+
let all = [];
|
|
76
|
+
try { all = facts.list() || []; } catch { return { total: 0, redundant: [], cold: [], error: "list-failed" }; }
|
|
77
|
+
if (!all.length) return { total: 0, redundant: [], cold: [], lastRunAt: Date.now() };
|
|
78
|
+
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const DAY = 86400000;
|
|
81
|
+
|
|
82
|
+
// 1) 冗余识别: 两两 findSimilar 检测高相似对, 标低保真那条为冗余候选
|
|
83
|
+
const redundant = [];
|
|
84
|
+
const visited = new Set();
|
|
85
|
+
// 两两比较 (自实现 overlap): findSimilar 对自身返回1.0无法用, 逐对算 bigram 相似度
|
|
86
|
+
for (let i = 0; i < all.length; i++) {
|
|
87
|
+
const f = all[i];
|
|
88
|
+
if (visited.has(f.id)) continue;
|
|
89
|
+
let best = null, bestSim = 0;
|
|
90
|
+
for (let j = 0; j < all.length; j++) {
|
|
91
|
+
const g = all[j];
|
|
92
|
+
if (g.id === f.id || visited.has(g.id)) continue;
|
|
93
|
+
try {
|
|
94
|
+
const sim = _overlap(f.content, g.content);
|
|
95
|
+
if (sim > bestSim) { bestSim = sim; best = g; }
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
if (best && bestSim >= SIM_THRESHOLD) {
|
|
99
|
+
const aKeep = (f.importance || 0) + (f.hits || 0);
|
|
100
|
+
const bKeep = (best.importance || 0) + (best.hits || 0);
|
|
101
|
+
const loser = aKeep >= bKeep ? best : f;
|
|
102
|
+
const winner = aKeep >= bKeep ? f : best;
|
|
103
|
+
redundant.push({
|
|
104
|
+
id: loser.id,
|
|
105
|
+
content: String(loser.content || "").slice(0, 120),
|
|
106
|
+
keepId: winner.id,
|
|
107
|
+
keep: String(winner.content || "").slice(0, 120),
|
|
108
|
+
importance: loser.importance || 0,
|
|
109
|
+
hits: loser.hits || 0,
|
|
110
|
+
sim: Math.round(bestSim * 100) / 100,
|
|
111
|
+
});
|
|
112
|
+
visited.add(loser.id);
|
|
113
|
+
visited.add(f.id);
|
|
114
|
+
} else {
|
|
115
|
+
visited.add(f.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2) 冷热分层: N 天未访问 → 冷 (归档候选, 不参与热上下文)
|
|
120
|
+
const cold = all
|
|
121
|
+
.filter((f) => {
|
|
122
|
+
const last = f.lastAccess ? new Date(f.lastAccess).getTime() : (f.created ? new Date(f.created).getTime() : now);
|
|
123
|
+
return (now - last) > COLD_DAYS * DAY;
|
|
124
|
+
})
|
|
125
|
+
.map((f) => ({ id: f.id, content: String(f.content || "").slice(0, 120), lastAccess: f.lastAccess, importance: f.importance || 0, hits: f.hits || 0 }))
|
|
126
|
+
.slice(0, REPORT_LIMIT);
|
|
127
|
+
|
|
128
|
+
const run = {
|
|
129
|
+
total: all.length,
|
|
130
|
+
redundant: redundant.slice(0, REPORT_LIMIT),
|
|
131
|
+
redundantCount: redundant.length,
|
|
132
|
+
coldCount: cold.length,
|
|
133
|
+
cold: cold.slice(0, REPORT_LIMIT),
|
|
134
|
+
lastRunAt: Date.now(),
|
|
135
|
+
};
|
|
136
|
+
_recordRun(agent, run);
|
|
137
|
+
info(`[eviction] 扫描 ${all.length} 条: 冗余 ${redundant.length}, 冷记忆 ${cold.length}`);
|
|
138
|
+
return run;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 人类可读状态 (可观测)
|
|
142
|
+
export function status(agent) {
|
|
143
|
+
return loadState(agent);
|
|
144
|
+
}
|
package/src/ans/guard.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/ans/guard.js - 免疫系 (⑧安全治理) 全局闸门 (ANS)
|
|
2
|
+
// 在 P0 总线 intercept() 之上搭全局免疫: 任何经总线命令通道的动作执行前先过闸门。
|
|
3
|
+
// 能力 (务实, 不硬造):
|
|
4
|
+
// 1. 全局审计 - 所有放行/拒绝的命令记入 Auditor 账本 (可离线核实)
|
|
5
|
+
// 2. 危险 verb 阻断 - 白名单制: 未显式放行的"危险操作"(删除/清空/覆盖写) 默认拒绝
|
|
6
|
+
// 3. 单次审批授权 - 对越出白名单的关键操作可单次放行 (双模式授权)
|
|
7
|
+
// 说明: 总线 command 通道 (P0 建) + 免疫闸门 (本模块) 形成"执行前统一安全校验"。
|
|
8
|
+
// 现有工具走 catalog/seam (已有局部 runWithPolicy 权限);本闸门覆盖"走总线命令"的敏感动作 (记忆写/删除等未来接线)。
|
|
9
|
+
// P0 (2026-09-15): 新增 installGuardOnCatalog —— 把同一闸门状态挂到 ToolCatalog 策略链,
|
|
10
|
+
// 解决 MERGE-REPORT 遗留 P2 (guard 空转: 工具走 catalog 不走总线)。危险判定作用于工具名。
|
|
11
|
+
import { hasPII } from "../utils/pii.js";
|
|
12
|
+
|
|
13
|
+
// 危险 verb 前缀: 命中且未显式白名单放行 → 需单次审批或拒绝
|
|
14
|
+
const DANGEROUS_RE = /^(delete|remove|clear|wipe|drop|purge|truncate|overwrite)/i;
|
|
15
|
+
// 默认记审计账本的最大条目
|
|
16
|
+
const AUDIT_LIMIT = 200;
|
|
17
|
+
|
|
18
|
+
// 危险判定: verb 命中危险前缀且未白名单放行
|
|
19
|
+
export function isDangerous(verb, allowList = []) {
|
|
20
|
+
return DANGEROUS_RE.test(verb) && !allowList.includes(verb);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 安装免疫闸门: 挂到 agent.bus.intercept(), 返回可观测状态
|
|
24
|
+
// opts.intercept: 是否实际拦截 (true=默认审计+危险阻断; false=仅审计不阻断, 用于灰度)
|
|
25
|
+
export function installGuard(agent, opts = {}) {
|
|
26
|
+
if (!agent.bus || typeof agent.bus.intercept !== "function") {
|
|
27
|
+
return { enabled: false, reason: "no-bus" };
|
|
28
|
+
}
|
|
29
|
+
const { intercept = true, allowList = [], ledgerLimit = AUDIT_LIMIT } = opts;
|
|
30
|
+
const state = {
|
|
31
|
+
enabled: true,
|
|
32
|
+
intercept,
|
|
33
|
+
allowList: [...allowList],
|
|
34
|
+
checks: 0,
|
|
35
|
+
blocked: 0,
|
|
36
|
+
allowed: 0,
|
|
37
|
+
audited: [],
|
|
38
|
+
lastVerdict: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const record = (verdict, cmd, note) => {
|
|
42
|
+
state.checks++;
|
|
43
|
+
if (verdict === "block") state.blocked++;
|
|
44
|
+
else if (verdict === "allow" || verdict === "approve") state.allowed++;
|
|
45
|
+
state.audited.push({ verdict, verb: cmd.verb, id: cmd.id, ts: Date.now(), note, payloadHasPII: hasPII(JSON.stringify(cmd.payload || {})) });
|
|
46
|
+
if (state.audited.length > ledgerLimit) state.audited = state.audited.slice(-ledgerLimit);
|
|
47
|
+
// 写 Auditor 账本 (若装了)
|
|
48
|
+
try {
|
|
49
|
+
if (agent.auditor && typeof agent.auditor.record === "function") {
|
|
50
|
+
agent.auditor.record("guard", { verdict, verb: cmd.verb, note });
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
state.lastVerdict = { verdict, verb: cmd.verb, note };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// 卸载钩子
|
|
57
|
+
const off = agent.bus.intercept(async (cmd, next) => {
|
|
58
|
+
const verb = String(cmd.verb || "");
|
|
59
|
+
const dangerous = DANGEROUS_RE.test(verb);
|
|
60
|
+
const allowed = state.allowList.includes(verb);
|
|
61
|
+
if (!dangerous || allowed) {
|
|
62
|
+
record("allow", cmd, dangerous ? "dangerous-whitelisted" : "normal");
|
|
63
|
+
} else if (intercept) {
|
|
64
|
+
record("block", cmd, "dangerous-not-whitelisted");
|
|
65
|
+
throw new Error("免疫闸门: 危险命令未授信而阻断: " + verb);
|
|
66
|
+
} else {
|
|
67
|
+
record("allow", cmd, "dangerous-observed-not-blocked");
|
|
68
|
+
}
|
|
69
|
+
await next();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 单次审批授权: 放行一次危险 verb (一次用完自动失效)
|
|
73
|
+
const approveOnce = (verb) => { state.allowList.push(verb); return () => removeAllow(verb); };
|
|
74
|
+
const removeAllow = (verb) => { const i = state.allowList.indexOf(verb); if (i >= 0) state.allowList.splice(i, 1); };
|
|
75
|
+
const status = () => ({ enabled: state.enabled, intercept: state.intercept, checks: state.checks, blocked: state.blocked, allowed: state.allowed, allowList: [...state.allowList], recent: state.audited.slice(-10) });
|
|
76
|
+
|
|
77
|
+
return { off, approveOnce, status, _state: state };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- P0: 免疫闸门接入工具执行收口 (ToolCatalog 策略链) ----
|
|
81
|
+
// 与 installGuard (总线版) 共享同一 state: allowList / 计数 / 审计记录全部复用,
|
|
82
|
+
// 所以 agent.approveGuard("delete/x") 一次授权同时作用于总线命令和工具调用。
|
|
83
|
+
// catalog.addPolicySubscriber 已保证: 策略订阅者异常不拖垮工具 (fail-open + 日志)。
|
|
84
|
+
export function installGuardOnCatalog(catalog, guardHandle) {
|
|
85
|
+
if (!catalog || typeof catalog.addPolicySubscriber !== "function") {
|
|
86
|
+
return { enabled: false, reason: "no-catalog" };
|
|
87
|
+
}
|
|
88
|
+
const state = guardHandle?._state;
|
|
89
|
+
if (!state) return { enabled: false, reason: "no-guard-state" };
|
|
90
|
+
const off = catalog.addPolicySubscriber(async (name, args, ctx) => {
|
|
91
|
+
const verb = String(name || "");
|
|
92
|
+
const dangerous = DANGEROUS_RE.test(verb);
|
|
93
|
+
const allowed = state.allowList.includes(verb);
|
|
94
|
+
if (!dangerous || allowed) {
|
|
95
|
+
state.checks++;
|
|
96
|
+
state.allowed++;
|
|
97
|
+
state.audited.push({ verdict: "allow", verb, ts: Date.now(), note: dangerous ? "dangerous-whitelisted" : "normal", payloadHasPII: hasPII(JSON.stringify(args || {})) });
|
|
98
|
+
if (state.audited.length > AUDIT_LIMIT) state.audited = state.audited.slice(-AUDIT_LIMIT);
|
|
99
|
+
return null; // 弃权 = allow (放行)
|
|
100
|
+
}
|
|
101
|
+
state.checks++;
|
|
102
|
+
state.blocked++;
|
|
103
|
+
state.audited.push({ verdict: "block", verb, ts: Date.now(), note: "dangerous-not-whitelisted", payloadHasPII: hasPII(JSON.stringify(args || {})) });
|
|
104
|
+
if (state.audited.length > AUDIT_LIMIT) state.audited = state.audited.slice(-AUDIT_LIMIT);
|
|
105
|
+
if (typeof guardHandle.status === "function") {
|
|
106
|
+
try {
|
|
107
|
+
// 同步状态到 guard 的 lastVerdict (可观测)
|
|
108
|
+
const st = guardHandle._state;
|
|
109
|
+
st.lastVerdict = { verdict: "block", verb, note: "dangerous-not-whitelisted" };
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
return { decision: "deny", reason: "免疫闸门: 危险工具未授信: " + verb, priority: 100 };
|
|
113
|
+
}, { name: "immune-guard", priority: 100 });
|
|
114
|
+
return { enabled: true, off };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 可观测摘要 (读已安装实例)
|
|
118
|
+
export function guardStatus(agent) {
|
|
119
|
+
return agent.__guard ? agent.__guard.status() : { enabled: false };
|
|
120
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/ans/lifecycle.js - 生命周期状态机 (ANS)
|
|
2
|
+
// 独立可更换模块: agent 只持有 Lifecycle 实例, 需要自定义阶段逻辑时替换此模块即可
|
|
3
|
+
// 阶段: born → growing(首次对话) → mature(10 次对话); evolving(进化)/ reproducing(繁衍) 为累计计数
|
|
4
|
+
// v1.0.7 持久化: 传入 file 时状态落盘 JSON, 跨进程/重启不归零 (P1)
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { ensureDir } from "../utils/store.js";
|
|
8
|
+
import { info } from "../utils/logger.js";
|
|
9
|
+
|
|
10
|
+
const MATURE_CHATS = 10; // 对话达到该次数 → mature
|
|
11
|
+
const LOG_LIMIT = 50; // 阶段日志上限 (环形)
|
|
12
|
+
|
|
13
|
+
export class Lifecycle {
|
|
14
|
+
constructor({ file = null } = {}) {
|
|
15
|
+
this.file = file || null;
|
|
16
|
+
this.stage = "born";
|
|
17
|
+
this.bornAt = Date.now();
|
|
18
|
+
this.chats = 0;
|
|
19
|
+
this.evolved = 0;
|
|
20
|
+
this.reproduced = 0;
|
|
21
|
+
this.log = [];
|
|
22
|
+
this._load();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 从磁盘恢复状态 (跨进程/重启), 文件缺失或损坏时静默用初始值
|
|
26
|
+
_load() {
|
|
27
|
+
if (!this.file) return;
|
|
28
|
+
try {
|
|
29
|
+
if (fs.existsSync(this.file)) {
|
|
30
|
+
const s = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
31
|
+
if (s && typeof s.stage === "string" && s.stage) {
|
|
32
|
+
this.stage = s.stage;
|
|
33
|
+
this.bornAt = s.bornAt || Date.now();
|
|
34
|
+
this.chats = Number(s.chats) || 0;
|
|
35
|
+
this.evolved = Number(s.evolved) || 0;
|
|
36
|
+
this.reproduced = Number(s.reproduced) || 0;
|
|
37
|
+
this.log = Array.isArray(s.log) ? s.log.slice(-LOG_LIMIT) : [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch { /* 损坏文件静默忽略, 用初始状态 */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_save() {
|
|
44
|
+
if (!this.file) return;
|
|
45
|
+
try {
|
|
46
|
+
ensureDir(path.dirname(this.file));
|
|
47
|
+
fs.writeFileSync(this.file, JSON.stringify({
|
|
48
|
+
stage: this.stage,
|
|
49
|
+
bornAt: this.bornAt,
|
|
50
|
+
chats: this.chats,
|
|
51
|
+
evolved: this.evolved,
|
|
52
|
+
reproduced: this.reproduced,
|
|
53
|
+
log: this.log.slice(-LOG_LIMIT),
|
|
54
|
+
}, null, 2), "utf8");
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 每次对话推进: born → growing → mature (计数由调用方保证每次对话调一次)
|
|
59
|
+
tick() {
|
|
60
|
+
this.chats += 1;
|
|
61
|
+
if (this.stage === "born") this.to("growing", "首次对话");
|
|
62
|
+
else if (this.stage === "growing" && this.chats >= MATURE_CHATS) this.to("mature", `完成 ${this.chats} 次对话`);
|
|
63
|
+
this._save();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 阶段转换 (含日志), 供进化/繁衍等外部事件调用
|
|
67
|
+
to(stage, note) {
|
|
68
|
+
this.stage = stage;
|
|
69
|
+
this.log.push({ stage, note, ts: Date.now() });
|
|
70
|
+
if (this.log.length > LOG_LIMIT) this.log.shift();
|
|
71
|
+
info(`[lifecycle] ${stage}: ${note}`);
|
|
72
|
+
this._save();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 进化计数 (refine/分享经验), 内部落盘
|
|
76
|
+
evolve(n = 1) { this.evolved += n; this._save(); }
|
|
77
|
+
|
|
78
|
+
// 繁衍计数 (spawn_agent), 内部落盘
|
|
79
|
+
reproduce(n = 1) { this.reproduced += n; this._save(); }
|
|
80
|
+
|
|
81
|
+
// 人类可读摘要 (可观测)
|
|
82
|
+
status() {
|
|
83
|
+
return {
|
|
84
|
+
stage: this.stage,
|
|
85
|
+
bornAt: new Date(this.bornAt).toISOString(),
|
|
86
|
+
ageMs: Date.now() - this.bornAt,
|
|
87
|
+
chats: this.chats,
|
|
88
|
+
evolved: this.evolved,
|
|
89
|
+
reproduced: this.reproduced,
|
|
90
|
+
recent: this.log.slice(-5),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|