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,110 @@
|
|
|
1
|
+
// src/channels/index.js - 通道管理器 (注册表, config 驱动, 可更换)
|
|
2
|
+
// 每个通道 = Channel 子类 (connect/send/disconnect); 新增通道类型 → 注册进 channelTypes 即可, 不改 manager
|
|
3
|
+
// 使用方: new ChannelManager(agent, config.channels) → await start()
|
|
4
|
+
import { HttpChannel } from "./http.js";
|
|
5
|
+
import { FeishuChannel } from "./feishu.js";
|
|
6
|
+
import { WechatWebhookChannel } from "./wechat.js";
|
|
7
|
+
import { LogChannel } from "./log.js";
|
|
8
|
+
|
|
9
|
+
// 内置通道类型注册表: name -> 通道类 (构造函数签名: (agent, configObj))
|
|
10
|
+
export const BUILTIN_CHANNEL_TYPES = {
|
|
11
|
+
http: HttpChannel,
|
|
12
|
+
feishu: FeishuChannel,
|
|
13
|
+
wechat: WechatWebhookChannel,
|
|
14
|
+
log: LogChannel,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 启停策略: http 默认开, 其余默认关 (config.channels.<name>.enabled)
|
|
18
|
+
function isEnabled(name, cfg) {
|
|
19
|
+
if (name === "http") return cfg.enabled !== false;
|
|
20
|
+
return !!cfg.enabled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ChannelManager {
|
|
24
|
+
constructor(agent, config = {}, channelTypes = null) {
|
|
25
|
+
this.agent = agent;
|
|
26
|
+
this.config = config || {}; // config.channels
|
|
27
|
+
this.channelTypes = channelTypes || BUILTIN_CHANNEL_TYPES; // 可注入自定义类型 (更换通道实现)
|
|
28
|
+
this.channels = [];
|
|
29
|
+
this.httpServer = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 按注册表顺序启动所有启用的通道 (单个失败不影响其他)
|
|
33
|
+
// 流程: 全部 connect → 若 HTTP 通道在跑, 给 webhook 型通道挂载路由
|
|
34
|
+
async start() {
|
|
35
|
+
const started = [];
|
|
36
|
+
for (const [name, Ctor] of Object.entries(this.channelTypes)) {
|
|
37
|
+
const cfg = { ...(this.config[name] || {}) };
|
|
38
|
+
if (!isEnabled(name, cfg)) continue;
|
|
39
|
+
try {
|
|
40
|
+
const ch = new Ctor(this.agent, cfg);
|
|
41
|
+
await ch.connect();
|
|
42
|
+
this.channels.push(ch);
|
|
43
|
+
if (ch.server) this.httpServer = ch.server;
|
|
44
|
+
started.push(name);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn(`[channels] ${name} 通道启动失败: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// webhook 型通道挂到 HTTP server (若已启动); 传 http 通道实例供 registerWebhook 路由分发
|
|
50
|
+
if (this.httpServer) {
|
|
51
|
+
const httpCh = this.get("http") || null;
|
|
52
|
+
for (const ch of this.channels) {
|
|
53
|
+
if (ch.name !== "http" && typeof ch.mount === "function") {
|
|
54
|
+
try { ch.mount(this.httpServer, httpCh); } catch (e) { console.warn(`[channels] ${ch.name} webhook 挂载失败: ${e.message}`); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return started;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 按 name 取已启动通道 (无则 null)
|
|
62
|
+
get(name) {
|
|
63
|
+
return this.channels.find((c) => c.name === name) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 列出所有可配置通道的状态 (含未启用的), 供 CLI/UI 展示
|
|
67
|
+
list() {
|
|
68
|
+
return Object.entries(this.channelTypes).map(([name, Ctor]) => {
|
|
69
|
+
const cfg = this.config[name] || {};
|
|
70
|
+
return { name, enabled: isEnabled(name, cfg), connected: !!this.get(name) };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 连通性测试: 用独立实例验证某通道配置 (不干扰已启动的通道)
|
|
75
|
+
async test(name) {
|
|
76
|
+
const Ctor = this.channelTypes[name];
|
|
77
|
+
if (!Ctor) return { ok: false, detail: `未知通道类型: ${name}` };
|
|
78
|
+
const cfg = { ...(this.config[name] || {}) };
|
|
79
|
+
try {
|
|
80
|
+
const ch = new Ctor(this.agent, cfg);
|
|
81
|
+
const r = typeof ch.test === "function" ? await ch.test() : await ch.connect();
|
|
82
|
+
return typeof r === "object" && r !== null ? r : { ok: true, detail: String(r) };
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return { ok: false, detail: e.message };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 广播到所有已启动通道 (主动提醒/系统消息投递入口)
|
|
89
|
+
async broadcast(text, { to = "*" } = {}) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const c of this.channels) {
|
|
92
|
+
try { out.push(await c.send(to, text)); } catch (e) { console.warn(`[channels] ${c.name} 发送失败: ${e.message}`); }
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async stop() {
|
|
98
|
+
for (const c of this.channels) {
|
|
99
|
+
try { await c.disconnect?.(); } catch {}
|
|
100
|
+
}
|
|
101
|
+
this.channels = [];
|
|
102
|
+
this.httpServer = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { Channel } from "./base.js";
|
|
107
|
+
export { HttpChannel } from "./http.js";
|
|
108
|
+
export { FeishuChannel } from "./feishu.js";
|
|
109
|
+
export { WechatWebhookChannel } from "./wechat.js";
|
|
110
|
+
export { LogChannel } from "./log.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/channels/log.js - 日志通道 (dummy): 无网络依赖, 把消息打印到 stdout
|
|
2
|
+
// 用途: 主动提醒/消息先落到本地日志验证契约, 再接真实通道 (webhook/IM) 时只需"换一个 adapter"
|
|
3
|
+
import { Channel } from "./base.js";
|
|
4
|
+
|
|
5
|
+
export class LogChannel extends Channel {
|
|
6
|
+
constructor(agent, { target = "console" } = {}) {
|
|
7
|
+
super("log", agent);
|
|
8
|
+
this.target = target; // 预留: "console" | 未来文件/远程日志
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async connect() {
|
|
12
|
+
this.connected = true;
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async send(to, text) {
|
|
17
|
+
const prefix = to && to !== "*" ? `[log:${to}]` : "[log]";
|
|
18
|
+
console.log(`${prefix} ${String(text)}`);
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async test() {
|
|
23
|
+
return { ok: true, detail: "日志通道无需网络, 总是可用" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async disconnect() {
|
|
27
|
+
this.connected = false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// src/channels/wechat-crypto.js - 微信消息加解密 (企业微信/公众号) 零依赖
|
|
2
|
+
// 参考官方 WXBizMsgCrypt: AES-256-CBC + PKCS7 填充 + SHA1 签名校验
|
|
3
|
+
// 用 Node 原生 crypto, 无任何 npm 依赖。真实联调需企微/公众号凭据 (token + encodingAESKey)。
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
// 43 字符 encodingAESKey → 32 字节 AES key (base64, 补 '=' 凑满 32 字节)
|
|
7
|
+
function keyOf(encodingAESKey) {
|
|
8
|
+
return Buffer.from(String(encodingAESKey) + "=", "base64");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// SHA1 签名校验: sort([token, timestamp, nonce, encrypt]).join("") 的 sha1 与 signature 比对
|
|
12
|
+
export function verifySignature(token, timestamp, nonce, encrypt, signature) {
|
|
13
|
+
const arr = [String(token), String(timestamp), String(nonce), String(encrypt)].sort();
|
|
14
|
+
const sha1 = crypto.createHash("sha1").update(arr.join(""), "utf8").digest("hex");
|
|
15
|
+
return sha1 === String(signature);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 生成签名 (加密回包时用, 与 verifySignature 同一算法)
|
|
19
|
+
export function generateSignature(token, timestamp, nonce, encrypt) {
|
|
20
|
+
const arr = [String(token), String(timestamp), String(nonce), String(encrypt)].sort();
|
|
21
|
+
return crypto.createHash("sha1").update(arr.join(""), "utf8").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 解密 <Encrypt> → { msg, receiveId } (receiveId 通常为 corpId)
|
|
25
|
+
export function decryptMsg(encodingAESKey, encryptedXml) {
|
|
26
|
+
const m = String(encryptedXml).match(/<Encrypt><!\[CDATA\[([\s\S]*?)\]\]><\/Encrypt>/);
|
|
27
|
+
const enc = m ? m[1] : "";
|
|
28
|
+
if (!enc) throw new Error("消息体无 <Encrypt>");
|
|
29
|
+
const key = keyOf(encodingAESKey);
|
|
30
|
+
const iv = key.slice(0, 16);
|
|
31
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
32
|
+
decipher.setAutoPadding(false);
|
|
33
|
+
const dec = Buffer.concat([decipher.update(enc, "base64"), decipher.final()]);
|
|
34
|
+
// PKCS7 去填充
|
|
35
|
+
const pad = dec[dec.length - 1];
|
|
36
|
+
const plain = dec.slice(0, dec.length - pad);
|
|
37
|
+
// 明文格式: random(16) + msgLen(4, 大端) + msg + receiveId
|
|
38
|
+
const msgLen = plain.readUInt32BE(16);
|
|
39
|
+
const msg = plain.slice(20, 20 + msgLen).toString("utf8");
|
|
40
|
+
const receiveId = plain.slice(20 + msgLen).toString("utf8");
|
|
41
|
+
return { msg, receiveId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 加密回复 → 返回 <Encrypt> 里的 base64 密文 (供拼回复 XML)
|
|
45
|
+
export function encryptMsg(encodingAESKey, msg, receiveId = "") {
|
|
46
|
+
const key = keyOf(encodingAESKey);
|
|
47
|
+
const iv = key.slice(0, 16);
|
|
48
|
+
const msgBuf = Buffer.from(String(msg), "utf8");
|
|
49
|
+
const idBuf = Buffer.from(String(receiveId), "utf8");
|
|
50
|
+
const lenBuf = Buffer.alloc(4);
|
|
51
|
+
lenBuf.writeUInt32BE(msgBuf.length, 0);
|
|
52
|
+
const plain = Buffer.concat([crypto.randomBytes(16), lenBuf, msgBuf, idBuf]);
|
|
53
|
+
// PKCS7 填充到 32 字节倍数
|
|
54
|
+
const padLen = 32 - (plain.length % 32);
|
|
55
|
+
const padded = Buffer.concat([plain, Buffer.alloc(padLen, padLen)]);
|
|
56
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
57
|
+
cipher.setAutoPadding(false);
|
|
58
|
+
return Buffer.concat([cipher.update(padded), cipher.final()]).toString("base64");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 加密被动回复: 生成企业微信加密模式回包 XML (含 MsgSignature/TimeStamp/Nonce)
|
|
62
|
+
// replyXml = 明文回复 XML, receiveId = corpId (解密时拿到的)
|
|
63
|
+
export function encryptReplyXml({ encodingAESKey, token, replyXml, receiveId = "", timestamp = null, nonce = null }) {
|
|
64
|
+
const enc = encryptMsg(encodingAESKey, replyXml, receiveId);
|
|
65
|
+
const ts = String(timestamp ?? Math.floor(Date.now() / 1000));
|
|
66
|
+
const nc = nonce ?? Math.random().toString(36).slice(2, 12);
|
|
67
|
+
const sig = generateSignature(token, ts, nc, enc);
|
|
68
|
+
return `<xml>
|
|
69
|
+
<Encrypt><![CDATA[${enc}]]></Encrypt>
|
|
70
|
+
<MsgSignature><![CDATA[${sig}]]></MsgSignature>
|
|
71
|
+
<TimeStamp>${ts}</TimeStamp>
|
|
72
|
+
<Nonce><![CDATA[${nc}]]></Nonce>
|
|
73
|
+
</xml>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/channels/wechat.js - 微信通道适配器 (企业微信/公众号 webhook)
|
|
2
|
+
// 已实现:
|
|
3
|
+
// - 加密消息解密 (AES-256-CBC) + 验签 (wechat-crypto.js)
|
|
4
|
+
// - 明文/加密模式被动回复 (加密模式回包 encryptReplyXml)
|
|
5
|
+
// - 主动推送 send(): 企业微信应用消息 API (corp_id + corp_secret + agent_id)
|
|
6
|
+
// 凭据: 接收回调用 token + encodingAESKey; 主动推送用 corp_id + corp_secret + agent_id
|
|
7
|
+
// (config.channels.wechat 或环境变量 WECHAT_*)
|
|
8
|
+
import { Channel } from "./base.js";
|
|
9
|
+
import { decryptMsg, verifySignature, encryptReplyXml } from "./wechat-crypto.js";
|
|
10
|
+
|
|
11
|
+
export class WechatWebhookChannel extends Channel {
|
|
12
|
+
constructor(agent, {
|
|
13
|
+
path = "/wechat/webhook",
|
|
14
|
+
token = "",
|
|
15
|
+
encodingAESKey = "",
|
|
16
|
+
corpId = "",
|
|
17
|
+
corpSecret = "",
|
|
18
|
+
agentId = "",
|
|
19
|
+
} = {}) {
|
|
20
|
+
super("wechat", agent);
|
|
21
|
+
this.path = path;
|
|
22
|
+
this.token = token || process.env.WECHAT_WEBHOOK_TOKEN || "";
|
|
23
|
+
this.encodingAESKey = encodingAESKey || process.env.WECHAT_ENCODING_AES_KEY || "";
|
|
24
|
+
// 主动推送凭据 (企业微信应用消息)
|
|
25
|
+
this.corpId = corpId || process.env.WECHAT_CORP_ID || "";
|
|
26
|
+
this.corpSecret = corpSecret || process.env.WECHAT_CORP_SECRET || "";
|
|
27
|
+
this.agentId = agentId || process.env.WECHAT_AGENT_ID || "";
|
|
28
|
+
this.accessToken = null;
|
|
29
|
+
this.tokenExpire = 0;
|
|
30
|
+
this.lastUser = null; // 最近收到消息的用户 ID, 供主动提醒(广播)回发
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async connect() {
|
|
34
|
+
this.connected = true;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 连通性测试: 配置了主动推送凭据则验证 access_token; 仅回调模式返回提示
|
|
39
|
+
async test() {
|
|
40
|
+
if (!this.corpId || !this.corpSecret) {
|
|
41
|
+
return { ok: true, detail: "回调模式配置完整 (回调无法离线探测, 需公网/平台侧验证); 未配置主动推送凭据 (corp_id/corp_secret/agent_id)" };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const token = await this._getAccessToken();
|
|
45
|
+
return { ok: true, detail: `企业微信推送通道通 (access_token 获取成功, ${String(token).slice(0, 6)}...)` };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { ok: false, detail: e.message };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 把 /wechat/webhook 挂到 HTTP server (webhook 型通道, 支持明文/加密回包 + GET echostr URL 验证)
|
|
52
|
+
// v1.0.8: 注册到 HttpChannel 的 webhook 路由 (单一 request handler 分发); 支持 GET echostr
|
|
53
|
+
mount(server, httpChannel = null) {
|
|
54
|
+
const register = httpChannel && typeof httpChannel.registerWebhook === "function"
|
|
55
|
+
? (path, fn) => httpChannel.registerWebhook(path, fn)
|
|
56
|
+
: (path, fn) => { server && server.on?.("request", fn); };
|
|
57
|
+
register(this.path, async (req, res) => {
|
|
58
|
+
const u = new URL(req.url || "/", "http://localhost");
|
|
59
|
+
let body = "";
|
|
60
|
+
if (req.method === "POST") {
|
|
61
|
+
for await (const c of req) body += c;
|
|
62
|
+
}
|
|
63
|
+
const query = {
|
|
64
|
+
msg_signature: u.searchParams.get("msg_signature") || "",
|
|
65
|
+
timestamp: u.searchParams.get("timestamp") || "",
|
|
66
|
+
nonce: u.searchParams.get("nonce") || "",
|
|
67
|
+
echostr: u.searchParams.get("echostr") || "",
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const out = await this.handleWebhook(body, query);
|
|
71
|
+
if (out.xml) { res.writeHead(200, { "Content-Type": "text/xml" }); res.end(out.xml); }
|
|
72
|
+
else if (typeof out === "string") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end(out); } // echostr 明文回显 (企业微信 URL 验证要求)
|
|
73
|
+
else { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(out)); }
|
|
74
|
+
} catch (e) {
|
|
75
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
76
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 企业微信回调: URL query 带 msg_signature/timestamp/nonce; body 为 XML
|
|
82
|
+
// 加密模式: 外层 XML 含 <Encrypt>, 解密得内层明文消息
|
|
83
|
+
// v1.0.8 安全加固: 配置了 token 时所有模式都必须验签 (明文/加密/echostr), 防伪造消息驱动 agent
|
|
84
|
+
async handleWebhook(body, query = {}) {
|
|
85
|
+
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
|
86
|
+
const { msg_signature, timestamp, nonce } = query;
|
|
87
|
+
|
|
88
|
+
// URL 验证 (GET echostr, 明文模式验签后直接回显)
|
|
89
|
+
if (query.echostr) {
|
|
90
|
+
if (this.token) {
|
|
91
|
+
if (!msg_signature) return { error: "缺少 msg_signature" };
|
|
92
|
+
const ok = verifySignature(this.token, timestamp || "", nonce || "", query.echostr, msg_signature);
|
|
93
|
+
if (!ok) return { error: "签名校验失败" };
|
|
94
|
+
}
|
|
95
|
+
return query.echostr;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 加密消息: 验签 + 解密 + 加密回包
|
|
99
|
+
if (/<Encrypt>/.test(raw)) {
|
|
100
|
+
const enc = (raw.match(/<Encrypt><!\[CDATA\[([\s\S]*?)\]\]><\/Encrypt>/) || [])[1] || "";
|
|
101
|
+
// 配置了 token 就必须验签 (防无 token 时跳过)
|
|
102
|
+
if (this.token) {
|
|
103
|
+
if (!msg_signature) return { error: "缺少 msg_signature" };
|
|
104
|
+
const ok = verifySignature(this.token, timestamp || "", nonce || "", enc, msg_signature);
|
|
105
|
+
if (!ok) return { error: "签名校验失败" };
|
|
106
|
+
}
|
|
107
|
+
if (!this.encodingAESKey) return { error: "未配置 encodingAESKey, 无法解密" };
|
|
108
|
+
const { msg, receiveId } = decryptMsg(this.encodingAESKey, raw);
|
|
109
|
+
const reply = await this._replyFromXml(msg);
|
|
110
|
+
// 加密模式: 把明文回复 XML 加密成回包 (含签名/时间戳/随机数)
|
|
111
|
+
if (reply.xml) {
|
|
112
|
+
return { xml: encryptReplyXml({
|
|
113
|
+
encodingAESKey: this.encodingAESKey,
|
|
114
|
+
token: this.token,
|
|
115
|
+
replyXml: reply.xml,
|
|
116
|
+
receiveId,
|
|
117
|
+
timestamp: timestamp || null,
|
|
118
|
+
nonce: nonce || null,
|
|
119
|
+
}) };
|
|
120
|
+
}
|
|
121
|
+
return reply;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 明文 XML 模式: 配置了 token 就必须验签 (防伪造消息)
|
|
125
|
+
if (/<Content>/.test(raw)) {
|
|
126
|
+
if (this.token) {
|
|
127
|
+
if (!msg_signature) return { error: "缺少 msg_signature" };
|
|
128
|
+
const ok = verifySignature(this.token, timestamp || "", nonce || "", raw, msg_signature);
|
|
129
|
+
if (!ok) return { error: "签名校验失败" };
|
|
130
|
+
}
|
|
131
|
+
return this._replyFromXml(raw);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { error: "未识别的微信消息" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 从明文 XML 提取文本并生成被动回复
|
|
138
|
+
async _replyFromXml(xml) {
|
|
139
|
+
const g = (tag) => ((String(xml).match(new RegExp(`<${tag}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`)) || [])[1] || "").trim();
|
|
140
|
+
const text = g("Content");
|
|
141
|
+
if (!text) return { error: "无文本内容" };
|
|
142
|
+
const reply = await this.agent.chat(text);
|
|
143
|
+
const toUser = g("FromUserName");
|
|
144
|
+
if (toUser) this.lastUser = toUser; // 记录最近联系人, 供主动提醒回发
|
|
145
|
+
const fromUser = g("ToUserName");
|
|
146
|
+
return {
|
|
147
|
+
xml: `<xml>
|
|
148
|
+
<ToUserName><![CDATA[${toUser}]]></ToUserName>
|
|
149
|
+
<FromUserName><![CDATA[${fromUser}]]></FromUserName>
|
|
150
|
+
<CreateTime>${Math.floor(Date.now() / 1000)}</CreateTime>
|
|
151
|
+
<MsgType><![CDATA[text]]></MsgType>
|
|
152
|
+
<Content><![CDATA[${reply}]]></Content>
|
|
153
|
+
</xml>`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 获取企业微信 access_token (主动推送用), 带缓存
|
|
158
|
+
async _getAccessToken() {
|
|
159
|
+
if (this.accessToken && Date.now() < this.tokenExpire) return this.accessToken;
|
|
160
|
+
if (!this.corpId || !this.corpSecret) {
|
|
161
|
+
throw new Error("微信主动推送未配置: 需 corp_id + corp_secret (config.channels.wechat 或环境变量 WECHAT_CORP_ID/WECHAT_CORP_SECRET)");
|
|
162
|
+
}
|
|
163
|
+
const r = await fetch(
|
|
164
|
+
`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(this.corpId)}&corpsecret=${encodeURIComponent(this.corpSecret)}`,
|
|
165
|
+
{ signal: AbortSignal.timeout(15000) },
|
|
166
|
+
);
|
|
167
|
+
const data = await r.json();
|
|
168
|
+
if (data.errcode !== 0) throw new Error(`微信 access_token 失败: ${data.errmsg} (errcode=${data.errcode})`);
|
|
169
|
+
this.accessToken = data.access_token;
|
|
170
|
+
this.tokenExpire = Date.now() + (data.expires_in - 60) * 1000;
|
|
171
|
+
return this.accessToken;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 主动推送 (企业微信应用消息): 需 corp_id + corp_secret + agent_id; to="*" 时回最近联系人
|
|
175
|
+
async send(to, text) {
|
|
176
|
+
const token = await this._getAccessToken();
|
|
177
|
+
if (!this.agentId) {
|
|
178
|
+
throw new Error("微信主动推送未配置 agent_id (config.channels.wechat.agent_id 或环境变量 WECHAT_AGENT_ID)");
|
|
179
|
+
}
|
|
180
|
+
const target = !to || to === "*" ? this.lastUser : to;
|
|
181
|
+
if (!target) throw new Error("微信主动推送缺接收人: 先收到过一条用户消息记录用户 ID, 或用 send(userId, ...)");
|
|
182
|
+
const r = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "Content-Type": "application/json" },
|
|
185
|
+
body: JSON.stringify({
|
|
186
|
+
touser: String(target),
|
|
187
|
+
msgtype: "text",
|
|
188
|
+
agentid: Number(this.agentId),
|
|
189
|
+
text: { content: String(text) },
|
|
190
|
+
}),
|
|
191
|
+
signal: AbortSignal.timeout(15000),
|
|
192
|
+
});
|
|
193
|
+
const data = await r.json();
|
|
194
|
+
if (data.errcode !== 0) throw new Error(`微信发送失败: ${data.errmsg} (errcode=${data.errcode})`);
|
|
195
|
+
return data;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/channels-cli.js - `ppx channels` 通道管理 CLI (借鉴 openclaw `channels add`)
|
|
3
|
+
// 让用户自己连通道: 交互式配置 → 写 config/ppx.json → 测试连通性 → 重启服务生效
|
|
4
|
+
//
|
|
5
|
+
// 用法:
|
|
6
|
+
// ppx-channels list 列出全部通道 + 启用状态
|
|
7
|
+
// ppx-channels add <name> 交互式配置通道 (默认写盘并启用)
|
|
8
|
+
// ppx-channels test <name> 测试连通性 (网络探测)
|
|
9
|
+
// ppx-channels enable|disable <name> 启用/禁用
|
|
10
|
+
// ppx-channels remove <name> 移除配置 (恢复默认)
|
|
11
|
+
// ppx-channels --root <dir> ... 指定项目根目录 (默认当前目录)
|
|
12
|
+
import { ensureUTF8Console } from "./utils/winutf8.js";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import readline from "node:readline/promises";
|
|
15
|
+
import {
|
|
16
|
+
ensureUTF8Console();
|
|
17
|
+
CHANNEL_SCHEMAS, readChannels, listChannels,
|
|
18
|
+
updateChannel, setChannelEnabled, removeChannel,
|
|
19
|
+
} from "./config/channels.js";
|
|
20
|
+
import { ChannelManager } from "./channels/index.js";
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
let root = process.cwd();
|
|
24
|
+
if (args[0] === "--root") { root = path.resolve(args[1]); args.splice(0, 2); }
|
|
25
|
+
const [cmd, name] = args;
|
|
26
|
+
|
|
27
|
+
const HELP = `ppx-channels - 皮皮虾通道管理 (让用户自己连)
|
|
28
|
+
用法:
|
|
29
|
+
ppx-channels list 列出全部通道 + 启用状态
|
|
30
|
+
ppx-channels add <name> 交互式配置通道
|
|
31
|
+
ppx-channels test <name> 测试连通性
|
|
32
|
+
ppx-channels enable <name> 启用通道
|
|
33
|
+
ppx-channels disable <name> 禁用通道
|
|
34
|
+
ppx-channels remove <name> 移除配置 (恢复默认)
|
|
35
|
+
ppx-channels --root <dir> <cmd> 指定项目根目录
|
|
36
|
+
可用通道: ${Object.keys(CHANNEL_SCHEMAS).join(" / ")}`;
|
|
37
|
+
|
|
38
|
+
// ---- list ----
|
|
39
|
+
function doList() {
|
|
40
|
+
const rows = listChannels(root);
|
|
41
|
+
for (const r of rows) {
|
|
42
|
+
const marks = Object.entries(r.fields)
|
|
43
|
+
.map(([k, v]) => (k.endsWith("_set") ? `${k}=✓` : `${k}=${v}`))
|
|
44
|
+
.join(" ");
|
|
45
|
+
console.log(`${r.enabled ? "✓ 启用" : "· 禁用"} ${r.name}${marks ? " " + marks : ""}`);
|
|
46
|
+
}
|
|
47
|
+
const enabled = rows.filter((r) => r.enabled).map((r) => r.name);
|
|
48
|
+
console.log(`\n${enabled.length ? "已启用: " + enabled.join(", ") : "全部未启用 — 用 add <name> 配置一个"}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- add (交互式引导, 借鉴 openclaw channels add) ----
|
|
52
|
+
async function doAdd(name) {
|
|
53
|
+
const schema = CHANNEL_SCHEMAS[name];
|
|
54
|
+
if (!schema) {
|
|
55
|
+
console.error(`未知通道: ${name} (可用: ${Object.keys(CHANNEL_SCHEMAS).join(", ")})`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
59
|
+
const existing = readChannels(root).channels[name] || {};
|
|
60
|
+
const patch = {};
|
|
61
|
+
console.log(`\n配置 ${name} 通道 (回车用默认值, 需要密码的字段直接粘贴):`);
|
|
62
|
+
for (const key of Object.keys(schema).filter((k) => k !== "enabled")) {
|
|
63
|
+
const f = schema[key];
|
|
64
|
+
const def = existing[key] ?? f.def ?? "";
|
|
65
|
+
const defHint = def !== "" ? ` [${def}]` : "";
|
|
66
|
+
const prompt = f.prompt ? ` ${f.label} — ${f.prompt}${defHint}: ` : ` ${f.label}${defHint}: `;
|
|
67
|
+
const ans = (await rl.question(prompt)).trim();
|
|
68
|
+
const val = ans === "" ? def : ans;
|
|
69
|
+
if (val !== "") patch[key] = f.type === "number" ? Number(val) : val;
|
|
70
|
+
}
|
|
71
|
+
rl.close();
|
|
72
|
+
try {
|
|
73
|
+
updateChannel(root, name, { ...patch, enabled: true });
|
|
74
|
+
console.log(`\n✓ ${name} 已配置并启用 (config/ppx.json 已原子写盘, 原文件已备份).`);
|
|
75
|
+
console.log(` 重启服务生效: ppx-serve`);
|
|
76
|
+
console.log(` 验证连通性: ppx-channels test ${name}`);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error(`✗ 保存失败: ${e.message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- test (连通性) ----
|
|
84
|
+
async function doTest(name) {
|
|
85
|
+
if (!(name in CHANNEL_SCHEMAS)) {
|
|
86
|
+
console.error(`未知通道: ${name} (可用: ${Object.keys(CHANNEL_SCHEMAS).join(", ")})`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const { channels } = readChannels(root);
|
|
90
|
+
// 轻量 agent stub: 通道 test() 只用 root 构造, 不触发完整 agent 启动
|
|
91
|
+
const mgr = new ChannelManager({ root, config: { agent: {} } }, channels);
|
|
92
|
+
const r = await mgr.test(name);
|
|
93
|
+
console.log(`${r.ok ? "✓" : "✗"} ${name}: ${r.detail}`);
|
|
94
|
+
process.exit(r.ok ? 0 : 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- enable/disable/remove ----
|
|
98
|
+
function doToggle(name, enabled) {
|
|
99
|
+
if (!(name in CHANNEL_SCHEMAS)) { console.error(`未知通道: ${name}`); process.exit(1); }
|
|
100
|
+
try {
|
|
101
|
+
const out = setChannelEnabled(root, name, enabled);
|
|
102
|
+
console.log(`✓ ${name} 已${enabled ? "启用" : "禁用"}${Object.keys(out).length ? ` (${Object.keys(out).join(", ")})` : ""}`);
|
|
103
|
+
} catch (e) { console.error(`✗ 操作失败: ${e.message}`); process.exit(1); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function doRemove(name) {
|
|
107
|
+
if (!(name in CHANNEL_SCHEMAS)) { console.error(`未知通道: ${name}`); process.exit(1); }
|
|
108
|
+
const ok = removeChannel(root, name);
|
|
109
|
+
console.log(ok ? `✓ ${name} 配置已移除 (恢复默认, 默认禁用)` : `${name} 本来就没有自定义配置`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- main ----
|
|
113
|
+
(async () => {
|
|
114
|
+
if (!cmd || cmd === "-h" || cmd === "--help") { console.log(HELP); return; }
|
|
115
|
+
switch (cmd) {
|
|
116
|
+
case "list": doList(); break;
|
|
117
|
+
case "add": await doAdd(name); break;
|
|
118
|
+
case "test": await doTest(name); break;
|
|
119
|
+
case "enable": doToggle(name, true); break;
|
|
120
|
+
case "disable": doToggle(name, false); break;
|
|
121
|
+
case "remove": doRemove(name); break;
|
|
122
|
+
default: console.error(`未知命令: ${cmd}\n${HELP}`); process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
})();
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/cli.js - 皮皮虾 CLI 交互入口 (readline 历史 + interrupt 中断)
|
|
3
|
+
import { ensureUTF8Console } from "./utils/winutf8.js";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { PPXAgent } from "./agent/index.js";
|
|
8
|
+
import { suggestProactive } from "./ans/proactive.js";
|
|
9
|
+
|
|
10
|
+
ensureUTF8Console();
|
|
11
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const agent = new PPXAgent({ root: ROOT });
|
|
13
|
+
|
|
14
|
+
console.log("======================================");
|
|
15
|
+
console.log(" 皮皮虾 (PPX) - 自我修复·自我学习 Agent");
|
|
16
|
+
console.log(` 记忆:${agent.facts.count()}条 | 经验:${agent.experience.lessons.length}条`);
|
|
17
|
+
console.log(` 模型: ${agent.llm ? "已配置" : "未配置(离线记忆模式)"}`);
|
|
18
|
+
console.log(" 命令: quit/exit 退出 | /stop 中断当前任务 | /reset 清空会话");
|
|
19
|
+
console.log(" /proactive 主动提醒(扫描记忆待办) | /proactive-done <id> 标记待办完成 | ↑↓ 浏览历史 | Ctrl+C 中断(再按一次退出)");
|
|
20
|
+
console.log("======================================");
|
|
21
|
+
|
|
22
|
+
const rl = readline.createInterface({
|
|
23
|
+
input: process.stdin,
|
|
24
|
+
output: process.stdout,
|
|
25
|
+
prompt: "皮皮虾> ",
|
|
26
|
+
terminal: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let busy = false; // 防止任务执行中重复输入
|
|
30
|
+
|
|
31
|
+
// 主动任务生成定时器 (ANS 自主性): config.agent.proactive.enabled 时启动
|
|
32
|
+
// 有待办信号才推送 (suggestProactive 无信号返回 null 不打扰), 输出到 stdout
|
|
33
|
+
if (agent.config.agent?.proactive?.enabled) {
|
|
34
|
+
agent.startProactiveTicker((payload) => {
|
|
35
|
+
console.log("\n[主动提醒] " + payload.text);
|
|
36
|
+
rl.prompt();
|
|
37
|
+
});
|
|
38
|
+
console.log(` (主动提醒已开启: 每 ${Math.round(agent.config.agent.proactive.interval_ms / 60000)} 分钟扫描记忆待办)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
rl.on("line", async (line) => {
|
|
42
|
+
const text = line.trim();
|
|
43
|
+
if (!text) return rl.prompt();
|
|
44
|
+
if (busy) return rl.prompt(); // 上一轮任务未结束, 忽略输入 (可用 /stop 或 Ctrl+C 打断)
|
|
45
|
+
|
|
46
|
+
// 退出
|
|
47
|
+
if (["quit", "exit", "q"].includes(text.toLowerCase())) {
|
|
48
|
+
agent.shutdown();
|
|
49
|
+
console.log("皮皮虾 收工, 已保存记忆。");
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
// 中断当前任务 (Human-in-the-loop)
|
|
53
|
+
if (text === "/stop") {
|
|
54
|
+
agent.interrupt();
|
|
55
|
+
console.log("(已发送中断信号, 当前任务将尽快停下)");
|
|
56
|
+
return rl.prompt();
|
|
57
|
+
}
|
|
58
|
+
// 清空会话
|
|
59
|
+
if (text === "/reset") {
|
|
60
|
+
agent.resetSession("default");
|
|
61
|
+
console.log("(会话已清空)");
|
|
62
|
+
return rl.prompt();
|
|
63
|
+
}
|
|
64
|
+
// 主动任务生成: 扫描记忆里的待办/偏好, 给出主动提醒 (ANS 自主性)
|
|
65
|
+
// 输出含 id, 可用 /proactive-done <id> 标记完成 (窗口去重, 24h 内不重复提醒)
|
|
66
|
+
if (text === "/proactive") {
|
|
67
|
+
busy = true;
|
|
68
|
+
try {
|
|
69
|
+
const out = await suggestProactive(agent);
|
|
70
|
+
if (out) {
|
|
71
|
+
console.log("\n" + out.text + "\n");
|
|
72
|
+
for (const it of out.items) console.log(` [${it.id}] ${it.content}`);
|
|
73
|
+
console.log("\n(用 /proactive-done <id> 标记完成, 之后不再提醒)\n");
|
|
74
|
+
} else {
|
|
75
|
+
console.log("\n(暂时没有需要提醒的事项)\n");
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.log("\n[错误] " + e.message + "\n");
|
|
79
|
+
} finally {
|
|
80
|
+
busy = false;
|
|
81
|
+
}
|
|
82
|
+
return rl.prompt();
|
|
83
|
+
}
|
|
84
|
+
// 标记待办完成: /proactive-done <factId>
|
|
85
|
+
if (text.startsWith("/proactive-done")) {
|
|
86
|
+
const id = text.replace("/proactive-done", "").trim();
|
|
87
|
+
const ok = agent.proactiveMarkDone(id);
|
|
88
|
+
console.log(ok ? "(已标记完成, 之后不再提醒)" : "(待办不存在: " + id + ")");
|
|
89
|
+
return rl.prompt();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
busy = true;
|
|
93
|
+
try {
|
|
94
|
+
const r = await agent.chat(text);
|
|
95
|
+
console.log("\n" + r + "\n");
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.log("\n[错误] " + e.message + "\n");
|
|
98
|
+
} finally {
|
|
99
|
+
busy = false;
|
|
100
|
+
}
|
|
101
|
+
rl.prompt();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Ctrl+C: 第一次中断任务, 第二次退出
|
|
105
|
+
let ctrlC = 0;
|
|
106
|
+
rl.on("SIGINT", () => {
|
|
107
|
+
ctrlC += 1;
|
|
108
|
+
if (ctrlC >= 2) {
|
|
109
|
+
agent.shutdown();
|
|
110
|
+
console.log("\n皮皮虾 收工。");
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
agent.interrupt();
|
|
114
|
+
console.log("\n(已中断, 再按一次 Ctrl+C 退出)");
|
|
115
|
+
rl.prompt();
|
|
116
|
+
// 重置计数 (若任务继续则允许再次单次中断)
|
|
117
|
+
setTimeout(() => { ctrlC = 0; }, 1000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
rl.prompt();
|