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,99 @@
|
|
|
1
|
+
// src/memory/failure-episode.js - 故障记忆 (P1⑥)
|
|
2
|
+
// 吸收 ReLoop / Vial / Aegis 的"故障即知识"设计:
|
|
3
|
+
// 每次失败存结构化 episode (错误类型/根因/修复/置信度), 下次相似故障检索历史辅助诊断, 自愈不再从零推理。
|
|
4
|
+
// 与经验库 (Experience, L4) 互补: 经验库是"学到的教训", 本模块是"故障的结构化病历" (可检索、可回放)。
|
|
5
|
+
// 纯代码可测, 检索用词法相似 (零依赖), 可升级 embedding。
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { ensureDir, writeText } from "../utils/store.js";
|
|
9
|
+
import { lexicalSimilarity } from "../evolve/playbook.js";
|
|
10
|
+
|
|
11
|
+
export const FAILURE_CATEGORY = ["throttle", "network", "validation", "auth", "unknown"];
|
|
12
|
+
|
|
13
|
+
export class FailureEpisodeStore {
|
|
14
|
+
constructor(dataDir, { maxEpisodes = 500 } = {}) {
|
|
15
|
+
this.dir = path.join(dataDir, "memory", "failures");
|
|
16
|
+
ensureDir(this.dir);
|
|
17
|
+
this.file = path.join(this.dir, "episodes.json");
|
|
18
|
+
this.maxEpisodes = maxEpisodes;
|
|
19
|
+
this._episodes = this._load();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_load() {
|
|
23
|
+
try {
|
|
24
|
+
if (fs.existsSync(this.file)) {
|
|
25
|
+
const d = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
26
|
+
if (Array.isArray(d)) return d;
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_save() {
|
|
33
|
+
writeText(this.file, JSON.stringify(this._episodes, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 记录一次失败 episode
|
|
37
|
+
// { tool, error, category, rootCause, fix, confidence, traceRef }
|
|
38
|
+
record(ep) {
|
|
39
|
+
const e = {
|
|
40
|
+
id: "fe" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
|
|
41
|
+
tool: ep.tool || "unknown",
|
|
42
|
+
error: String(ep.error || "").slice(0, 500),
|
|
43
|
+
category: FAILURE_CATEGORY.includes(ep.category) ? ep.category : "unknown",
|
|
44
|
+
rootCause: String(ep.rootCause || "").slice(0, 500) || null,
|
|
45
|
+
fix: String(ep.fix || "").slice(0, 500) || null,
|
|
46
|
+
confidence: Number(ep.confidence) || 0,
|
|
47
|
+
traceRef: ep.traceRef || null, // 事件日志引用 (trace.js 行号/seq)
|
|
48
|
+
hit: 0, // 被检索命中次数 (元学习信号)
|
|
49
|
+
ts: Date.now(),
|
|
50
|
+
};
|
|
51
|
+
this._episodes.unshift(e);
|
|
52
|
+
if (this._episodes.length > this.maxEpisodes) this._episodes = this._episodes.slice(0, this.maxEpisodes);
|
|
53
|
+
this._save();
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 相似故障检索: 按 错误文本 + 工具名 词法相似度排序, 返回 top N
|
|
58
|
+
search({ tool = null, error = "", limit = 3, minScore = 0.25 } = {}) {
|
|
59
|
+
const q = String(error || "");
|
|
60
|
+
const scored = this._episodes
|
|
61
|
+
.map((e) => {
|
|
62
|
+
let score = 0;
|
|
63
|
+
if (q) score = Math.max(score, lexicalSimilarity(e.error, q));
|
|
64
|
+
if (tool && e.tool === tool) score = Math.max(score, 0.5); // 同工具强信号
|
|
65
|
+
if (e.rootCause && q) score = Math.max(score, lexicalSimilarity(e.rootCause, q) * 0.8);
|
|
66
|
+
return { ...e, score };
|
|
67
|
+
})
|
|
68
|
+
.filter((e) => e.score >= minScore)
|
|
69
|
+
.sort((a, b) => b.score - a.score)
|
|
70
|
+
.slice(0, limit);
|
|
71
|
+
// 命中计数 (元学习): 写回原 episode, 不只是副本
|
|
72
|
+
for (const s of scored) {
|
|
73
|
+
const orig = this._episodes.find((x) => x.id === s.id);
|
|
74
|
+
if (orig) { orig.hit++; s.hit = orig.hit; }
|
|
75
|
+
}
|
|
76
|
+
if (scored.length) this._save();
|
|
77
|
+
return scored;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 统计: 按类别分布 / 命中率
|
|
81
|
+
stats() {
|
|
82
|
+
const byCat = {};
|
|
83
|
+
for (const e of this._episodes) byCat[e.category] = (byCat[e.category] || 0) + 1;
|
|
84
|
+
const withFix = this._episodes.filter((e) => e.fix).length;
|
|
85
|
+
const totalHit = this._episodes.reduce((a, e) => a + e.hit, 0);
|
|
86
|
+
return {
|
|
87
|
+
total: this._episodes.length,
|
|
88
|
+
byCategory: byCat,
|
|
89
|
+
withFix: withFix,
|
|
90
|
+
fixRate: this._episodes.length ? (withFix / this._episodes.length * 100).toFixed(1) + "%" : "0%",
|
|
91
|
+
totalHits: totalHit,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
list(limit = 20) { return this._episodes.slice(0, limit); }
|
|
96
|
+
clear() { this._episodes = []; this._save(); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default FailureEpisodeStore;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/memory/fork.js - 会话 fork 基线 (P2⑦)
|
|
2
|
+
// 吸收 HanaAgent 的 fork baseline 设计思想:
|
|
3
|
+
// 子代理 spawn 时携带记忆基线快照 (L1 facts + L3 persona + 经验精选), 子任务结束按结果 merge 或 discard。
|
|
4
|
+
// 皮皮虾自研实现: 主 agent 记忆导出为子 dataDir 可读的快照文件, 子 agent 独立演进,
|
|
5
|
+
// 结束后可选 merge 回主记忆 (去重) 或丢弃 (隔离)。纯代码, 无 LLM 参与 merge 决策。
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { ensureDir, writeText, readText } from "../utils/store.js";
|
|
9
|
+
import { lexicalSimilarity } from "../evolve/playbook.js";
|
|
10
|
+
|
|
11
|
+
// 从主 agent 导出记忆快照到子 dataDir
|
|
12
|
+
// 快照内容: L1 facts (top N) + L3 persona (若存在) + 全局经验精选 (top M)
|
|
13
|
+
// 返回 { wrote: {facts, persona, experience}, path }
|
|
14
|
+
export function exportMemorySnapshot({ agent, toDataDir, factsLimit = 50, experienceLimit = 10 } = {}) {
|
|
15
|
+
if (!agent) return { wrote: {}, path: null };
|
|
16
|
+
const snapDir = path.join(toDataDir, "memory", "snapshot");
|
|
17
|
+
ensureDir(snapDir);
|
|
18
|
+
const wrote = {};
|
|
19
|
+
|
|
20
|
+
// L1 facts: 取衰减分 top N (事实快照)
|
|
21
|
+
try {
|
|
22
|
+
if (agent.facts && typeof agent.facts.query === "function") {
|
|
23
|
+
const top = agent.facts.query("", { limit: factsLimit });
|
|
24
|
+
const lines = top.map((f) => `- [${Math.round(f.score * 100)}] ${f.content}`).join("\n") || "(无)";
|
|
25
|
+
writeText(path.join(snapDir, "facts.md"), `# L1 事实快照 (fork 基线)\n${lines}\n`);
|
|
26
|
+
wrote.facts = top.length;
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
// L3 persona: 主 agent 画像
|
|
31
|
+
try {
|
|
32
|
+
if (agent.personaStore && typeof agent.personaStore.read === "function") {
|
|
33
|
+
const p = agent.personaStore.read();
|
|
34
|
+
if (p) {
|
|
35
|
+
writeText(path.join(snapDir, "persona.md"), String(p));
|
|
36
|
+
wrote.persona = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
|
|
41
|
+
// 全局经验精选
|
|
42
|
+
try {
|
|
43
|
+
if (agent.experience && typeof agent.experience.list === "function") {
|
|
44
|
+
const list = agent.experience.list({ limit: experienceLimit });
|
|
45
|
+
if (list && list.length) {
|
|
46
|
+
const lines = list.map((e) => `- ${e.lesson || e.content || ""}`).join("\n");
|
|
47
|
+
writeText(path.join(snapDir, "experience.md"), `# 经验快照 (fork 基线)\n${lines}\n`);
|
|
48
|
+
wrote.experience = list.length;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
|
|
53
|
+
return { wrote, path: snapDir };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 子任务完成后 merge 回主记忆 (按内容去重, 冲突保留主记忆)
|
|
57
|
+
// 返回 { merged: n, skipped: n }
|
|
58
|
+
export function mergeSnapshotBack({ agent, fromDataDir, dryRun = false } = {}) {
|
|
59
|
+
const snapDir = path.join(fromDataDir, "memory", "snapshot");
|
|
60
|
+
const factsFile = path.join(snapDir, "facts.md");
|
|
61
|
+
let merged = 0;
|
|
62
|
+
let skipped = 0;
|
|
63
|
+
if (!agent || !agent.facts || !fs.existsSync(factsFile)) return { merged: 0, skipped: 0 };
|
|
64
|
+
const text = readText(factsFile) || "";
|
|
65
|
+
const lines = text.split("\n").filter((l) => /^- \[/.test(l));
|
|
66
|
+
for (const l of lines) {
|
|
67
|
+
const content = l.replace(/^- \[\d+\]\s*/, "").trim();
|
|
68
|
+
if (!content) continue;
|
|
69
|
+
// 精确/高度词法相似才判定重复 (BM25 分数不可靠: 短查询常命中不相关事实)
|
|
70
|
+
const dup = (agent.facts.query(content, { limit: 3 }) || []).find((f) => lexicalSimilarity(f.content, content) > 0.8);
|
|
71
|
+
if (dup) { skipped++; continue; }
|
|
72
|
+
if (!dryRun) agent.facts.add(content, { source: "fork-merge", similarThreshold: 0.8 });
|
|
73
|
+
merged++;
|
|
74
|
+
}
|
|
75
|
+
return { merged, skipped };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 便捷: 判断某 dataDir 是否带快照
|
|
79
|
+
export function hasSnapshot(dataDir) {
|
|
80
|
+
return fs.existsSync(path.join(dataDir, "memory", "snapshot", "facts.md"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default { exportMemorySnapshot, mergeSnapshotBack, hasSnapshot };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// src/memory/index.js - 记忆系统统一出口 (v0.3 四层架构)
|
|
2
|
+
export { FactStore } from "./fact-store.js";
|
|
3
|
+
export { MemoryTicker } from "./memory-ticker.js";
|
|
4
|
+
export { Experience } from "./experience.js";
|
|
5
|
+
export { L0Recorder } from "./l0.js";
|
|
6
|
+
export { SceneStore } from "./l2.js";
|
|
7
|
+
export { PersonaStore } from "./l3.js";
|
package/src/memory/l0.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/memory/l0.js - L0 原始对话记录 (腾讯风格)
|
|
2
|
+
// 架构: 会话事件日志 (SessionStore) 为唯一事实源, L0 是其只读派生视图
|
|
3
|
+
// 不再独立维护 l0/*.jsonl -- 对话原文由 session 事件日志全量保存, 消除 today/l0/facts 三处重复
|
|
4
|
+
// 过滤噪音: 短消息/命令/注入标签 (保留, 用于判断"是否值得记录")
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { ensureDir, logicalDay } from "../utils/store.js";
|
|
7
|
+
import { SessionStore, EVENTS } from "./session.js";
|
|
8
|
+
|
|
9
|
+
// 短消息/命令/噪音过滤
|
|
10
|
+
function shouldCapture(content) {
|
|
11
|
+
const c = String(content || "").trim();
|
|
12
|
+
if (!c) return false;
|
|
13
|
+
if (c.length < 2) return false; // 太短
|
|
14
|
+
if (/^(\/|!|\.)/.test(c)) return false; // 命令
|
|
15
|
+
if (c.length < 3) return false; // 太短(含2字寒暄)
|
|
16
|
+
if (["你好","您好","在吗","哈喽","拜拜","再见","晚安","早上好","下午好","晚上好"].includes(c)) return false; // 纯寒暄
|
|
17
|
+
if (/\b(hi|hello|ok|thanks)\b/i.test(c) && c.length < 8) return false;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class L0Recorder {
|
|
22
|
+
// 兼容两种构造: (sessionStore[, dataDir]) 或 (dataDir) -- 后者内部新建 SessionStore
|
|
23
|
+
constructor(sessionStoreOrDir, dataDir) {
|
|
24
|
+
if (sessionStoreOrDir && typeof sessionStoreOrDir.eventsByDay === "function") {
|
|
25
|
+
this.sessionStore = sessionStoreOrDir;
|
|
26
|
+
this.dir = dataDir ? path.join(dataDir, "memory", "l0") : null;
|
|
27
|
+
} else {
|
|
28
|
+
const dir = sessionStoreOrDir;
|
|
29
|
+
this.sessionStore = new SessionStore(dir);
|
|
30
|
+
this.dir = path.join(dir, "memory", "l0");
|
|
31
|
+
}
|
|
32
|
+
if (this.dir) { try { ensureDir(this.dir); } catch {} }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 记录: 代理到 session 事件日志 (session 是唯一事实源, 不再独立写 JSONL)
|
|
36
|
+
record({ role, content, sessionKey = "default" } = {}) {
|
|
37
|
+
if (!shouldCapture(content)) return null;
|
|
38
|
+
const type = role === "assistant" ? EVENTS.ASSISTANT : EVENTS.USER;
|
|
39
|
+
return this.sessionStore.append(sessionKey, type, { content: String(content).slice(0, 4000) });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 读取某天的对话 (从 session 事件按天派生, 最近 N 条)
|
|
43
|
+
read(day = logicalDay(), limit = 50) {
|
|
44
|
+
return this.sessionStore.eventsByDay(day)
|
|
45
|
+
.map((r) => ({ role: r.role, content: r.content, sessionKey: r.sessionKey, timestamp: r.timestamp }))
|
|
46
|
+
.slice(-limit);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
count() {
|
|
50
|
+
return this.sessionStore.eventsByDay(logicalDay()).length;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/memory/l2.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// src/memory/l2.js - L2 场景记忆 (腾讯风格 scene extraction)
|
|
2
|
+
// 把相关记忆归档成场景: { name, keywords, facts[], lastUpdated }
|
|
3
|
+
// 零依赖: 用关键词聚类 + 时间窗聚合
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensureDir, readJson, writeJson, logicalDay, withFileLock } from "../utils/store.js";
|
|
6
|
+
|
|
7
|
+
// 中文简单分词: 提取有意义的词 (2字以上连续片段 + 已知高频概念)
|
|
8
|
+
const STOP = new Set(["这个","那个","我们","你们","他们","什么","怎么","可以","一个","就是","知道","没有","如果","因为","所以","但是","然后","现在","今天","昨天","明天","已经","还有","所有","这样","那样","自己","的时候","一下","一点","一些","这些","那些","东西","事情","问题","觉得","应该","需要","开始","继续","大家","真的","只是","可能","不是","都是","一直","非常","其实","最后","主要","联系","关系"]);
|
|
9
|
+
|
|
10
|
+
function tokenize(text) {
|
|
11
|
+
const clean = String(text || "").replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, " ");
|
|
12
|
+
const words = clean.split(/\s+/).filter(Boolean);
|
|
13
|
+
const cjk = clean.match(/[\u4e00-\u9fa5]{2,4}/g) || [];
|
|
14
|
+
return [...new Set([...words, ...cjk.map((w) => w.toLowerCase())].filter((w) => !STOP.has(w) && w.length >= 2))];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SceneStore {
|
|
18
|
+
constructor(dataDir) {
|
|
19
|
+
this.dir = path.join(dataDir, "memory", "l2");
|
|
20
|
+
ensureDir(this.dir);
|
|
21
|
+
this.file = path.join(this.dir, "scenes.json");
|
|
22
|
+
this.scenes = readJson(this.file, []);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 把一条事实归入最匹配的场景 (或新建)
|
|
26
|
+
assign(fact) {
|
|
27
|
+
const tokens = tokenize(fact.content);
|
|
28
|
+
if (!tokens.length) return null;
|
|
29
|
+
|
|
30
|
+
// 找最匹配的场景
|
|
31
|
+
let best = null, bestScore = 0;
|
|
32
|
+
for (const s of this.scenes) {
|
|
33
|
+
let score = 0;
|
|
34
|
+
for (const t of tokens) if ((s.keywords || []).includes(t)) score++;
|
|
35
|
+
if (score > bestScore) { bestScore = score; best = s; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (best && bestScore >= 1) {
|
|
39
|
+
best.facts.push({ id: fact.id, content: fact.content, ts: fact.created });
|
|
40
|
+
if (best.facts.length > 50) best.facts = best.facts.slice(-50);
|
|
41
|
+
best.lastUpdated = logicalDay();
|
|
42
|
+
// 合并新关键词
|
|
43
|
+
for (const t of tokens) if (!best.keywords.includes(t)) best.keywords.push(t);
|
|
44
|
+
if (best.keywords.length > 30) best.keywords = best.keywords.slice(-30);
|
|
45
|
+
} else {
|
|
46
|
+
best = {
|
|
47
|
+
id: "s_" + Math.random().toString(36).slice(2, 8),
|
|
48
|
+
name: tokens.slice(0, 3).join("·"),
|
|
49
|
+
keywords: tokens.slice(0, 10),
|
|
50
|
+
facts: [{ id: fact.id, content: fact.content, ts: fact.created }],
|
|
51
|
+
mode: "auto",
|
|
52
|
+
description: tokens.slice(1, 4).join("、") || "自动场景",
|
|
53
|
+
canHelp: "基于该话题的对话与记忆提供帮助",
|
|
54
|
+
created: logicalDay(),
|
|
55
|
+
lastUpdated: logicalDay(),
|
|
56
|
+
};
|
|
57
|
+
this.scenes.push(best);
|
|
58
|
+
}
|
|
59
|
+
// v1.0.9: 写盘加文件锁 (防军团多进程共享 dataDir 时写交错)
|
|
60
|
+
return withFileLock(this.file, () => {
|
|
61
|
+
writeJson(this.file, this.scenes);
|
|
62
|
+
return best;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 手动创建场景 (用户设定人设/能力, 类似灵魂文件)
|
|
67
|
+
create({ name, description, canHelp, keywords = [] }) {
|
|
68
|
+
const scene = {
|
|
69
|
+
id: "s_" + Math.random().toString(36).slice(2, 8),
|
|
70
|
+
name: String(name || "").slice(0, 50),
|
|
71
|
+
keywords: keywords.slice(0, 15),
|
|
72
|
+
facts: [],
|
|
73
|
+
mode: "manual",
|
|
74
|
+
description: String(description || "").slice(0, 300),
|
|
75
|
+
canHelp: String(canHelp || "").slice(0, 300),
|
|
76
|
+
created: logicalDay(),
|
|
77
|
+
lastUpdated: logicalDay(),
|
|
78
|
+
};
|
|
79
|
+
this.scenes.push(scene);
|
|
80
|
+
this._save();
|
|
81
|
+
return scene;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 列出所有场景 (含介绍)
|
|
85
|
+
listWithDesc() {
|
|
86
|
+
return this.scenes.map((s) => ({
|
|
87
|
+
id: s.id, name: s.name, mode: s.mode || "auto",
|
|
88
|
+
description: s.description || "", canHelp: s.canHelp || "",
|
|
89
|
+
facts: (s.facts || []).length, lastUpdated: s.lastUpdated,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 按文本匹配激活场景 (关键词命中)
|
|
94
|
+
findMatch(text) {
|
|
95
|
+
const tokens = tokenize(text);
|
|
96
|
+
if (!tokens.length) return null;
|
|
97
|
+
let best = null, bestScore = 0;
|
|
98
|
+
for (const s of this.scenes) {
|
|
99
|
+
let score = 0;
|
|
100
|
+
for (const t of tokens) if ((s.keywords || []).includes(t)) score++;
|
|
101
|
+
if (score > bestScore) { bestScore = score; best = s; }
|
|
102
|
+
}
|
|
103
|
+
return bestScore >= 1 ? best : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 激活场景的上下文块 (人设 + 能力)
|
|
107
|
+
activeContext(text) {
|
|
108
|
+
const s = this.findMatch(text);
|
|
109
|
+
if (!s) return "";
|
|
110
|
+
return [
|
|
111
|
+
`【当前场景:${s.name}】`,
|
|
112
|
+
s.description ? `场景介绍: ${s.description}` : "",
|
|
113
|
+
s.canHelp ? `你可以帮用户: ${s.canHelp}` : "",
|
|
114
|
+
s.mode === "manual" ? "(用户手动设定, 请遵循此场景行为)" : "",
|
|
115
|
+
].filter(Boolean).join("\n");
|
|
116
|
+
} // 按记忆 id 找回场景
|
|
117
|
+
findByFactId(id) {
|
|
118
|
+
return this.scenes.find((s) => s.facts.some((f) => f.id === id));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
context(limit = 5) {
|
|
122
|
+
return this.scenes.slice(-limit).map((s) =>
|
|
123
|
+
`【场景:${s.name}】\n${s.facts.slice(-5).map((f) => ` - ${f.content}`).join("\n")}`
|
|
124
|
+
).join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
count() { return this.scenes.length; }
|
|
128
|
+
|
|
129
|
+
// v1.0.9: _save 加锁 (create/scene_describe 等写盘路径)
|
|
130
|
+
_save() { withFileLock(this.file, () => writeJson(this.file, this.scenes)); }
|
|
131
|
+
}
|
package/src/memory/l3.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/memory/l3.js - L3 核心画像 (腾讯风格 persona generation)
|
|
2
|
+
// 从记忆提炼: 用户画像 (user.persona.md) + agent 人格 (agent.persona.md)
|
|
3
|
+
// 零依赖: 高频词统计 + 主题聚合, 输出结构化画像
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { ensureDir, readText, writeText, logicalDay } from "../utils/store.js";
|
|
7
|
+
|
|
8
|
+
const STOP = new Set(["这个","那个","我们","你们","他们","什么","怎么","可以","一个","就是","知道","没有","如果","因为","所以","但是","然后","现在","今天","昨天","明天","已经","还有","所有","这样","那样","自己","的东西","的事情","一下","一点","一些","这些","那些","东西","事情","问题","觉得","应该","需要","开始","继续","大家","真的","只是","可能","不是","都是","一直","非常","其实","最后","主要","联系","关系","喜欢","讨厌","不要","想要","认为"]);
|
|
9
|
+
|
|
10
|
+
export class PersonaStore {
|
|
11
|
+
constructor(dataDir, { userName = "兄弟" } = {}) {
|
|
12
|
+
this.dir = path.join(dataDir, "memory", "l3");
|
|
13
|
+
ensureDir(this.dir);
|
|
14
|
+
this.userFile = path.join(this.dir, "user.persona.md");
|
|
15
|
+
this.agentFile = path.join(this.dir, "agent.persona.md");
|
|
16
|
+
this.userName = userName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 从一批事实提炼用户画像
|
|
20
|
+
buildUserPersona(facts, { force = false } = {}) {
|
|
21
|
+
if (!force && this._exists(this.userFile)) return this._read(this.userFile);
|
|
22
|
+
// 聚焦用户相关的记忆 (来源: 对话 / LLM提炼 / 主动记 / 手动 / 用户分享)
|
|
23
|
+
// 注: 事实的来源标记在 source 字段 (type 恒为 general), 之前按 type 过滤永远为空 -> 修正为按 source
|
|
24
|
+
const USER_SOURCES = ["conversation", "extract", "agent-self", "manual", "user-shared"];
|
|
25
|
+
const userFacts = facts.filter((f) => USER_SOURCES.includes(f.source) || USER_SOURCES.includes(f.type));
|
|
26
|
+
const interests = this._topTopics(userFacts);
|
|
27
|
+
// 记忆概要: 内容去重后取最近 10 条 (防 LLM 提炼变体/重复记忆稀释画像)
|
|
28
|
+
const uniq = this._uniqByContent(userFacts).slice(-10);
|
|
29
|
+
const md = `# ${this.userName} 的用户画像
|
|
30
|
+
|
|
31
|
+
> 由皮皮虾 L3 画像引擎生成 | 更新: ${logicalDay()}
|
|
32
|
+
|
|
33
|
+
## 关注主题
|
|
34
|
+
${interests.length ? interests.map(([w, n]) => `- ${w} (出现${n}次)`).join("\n") : "- 暂无足够数据"}
|
|
35
|
+
|
|
36
|
+
## 记忆概要
|
|
37
|
+
${uniq.map((f) => `- ${f.content}`).join("\n") || "- 暂无"}
|
|
38
|
+
|
|
39
|
+
## 画像版本
|
|
40
|
+
- 生成时间: ${logicalDay()}
|
|
41
|
+
- 数据来源: 对话记忆 + 用户主动分享
|
|
42
|
+
`;
|
|
43
|
+
writeText(this.userFile, md);
|
|
44
|
+
return md;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 提炼 agent 自身人格 (从经验/工具使用学习)
|
|
48
|
+
buildAgentPersona(lessons, { force = false } = {}) {
|
|
49
|
+
if (!force && this._exists(this.agentFile)) return this._read(this.agentFile);
|
|
50
|
+
// 学到的经验: 内容去重后取最近 10 条 (防重复经验污染自我画像)
|
|
51
|
+
const uniq = this._uniqByContent(lessons, (l) => l.lesson).slice(-10);
|
|
52
|
+
const md = `# 皮皮虾 自我画像
|
|
53
|
+
|
|
54
|
+
> 从经验库自动学习 | 更新: ${logicalDay()}
|
|
55
|
+
|
|
56
|
+
## 学到的经验
|
|
57
|
+
${uniq.map((l) => `- ${l.lesson}`).join("\n") || "- 暂无"}
|
|
58
|
+
|
|
59
|
+
## 能力画像
|
|
60
|
+
- 工具: 文件操作 / 命令执行 / 搜索 / HTTP / 定时任务
|
|
61
|
+
- 记忆: 四层架构 (L0对话→L1原子→L2场景→L3画像)
|
|
62
|
+
- 自愈: 崩溃恢复 / 数据修复
|
|
63
|
+
- 军团: 多进程并行协作
|
|
64
|
+
`;
|
|
65
|
+
writeText(this.agentFile, md);
|
|
66
|
+
return md;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 内容去重: 按归一化内容 (去空白折叠) 过滤, 保留首次出现的条目
|
|
70
|
+
_uniqByContent(items, pick = (x) => x.content) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const it of items || []) {
|
|
74
|
+
const key = String(pick(it) || "").trim().replace(/\s+/g, " ");
|
|
75
|
+
if (!key || seen.has(key)) continue;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
out.push(it);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 读取已生成的画像 (供 agent._context 注入; 未生成返回 "")
|
|
83
|
+
userPersona() { return this._read(this.userFile); }
|
|
84
|
+
agentPersona() { return this._read(this.agentFile); }
|
|
85
|
+
|
|
86
|
+
// 可观测: L3 画像更新时间 (无文件返回 null), 供 agent.stats() 聚合
|
|
87
|
+
stats() {
|
|
88
|
+
return {
|
|
89
|
+
user_updated: this._mtime(this.userFile),
|
|
90
|
+
agent_updated: this._mtime(this.agentFile),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_mtime(f) {
|
|
95
|
+
try { return new Date(fs.statSync(f).mtime).toISOString().slice(0, 10); } catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_topTopics(facts) {
|
|
99
|
+
const freq = new Map();
|
|
100
|
+
for (const f of facts) {
|
|
101
|
+
const words = String(f.content).match(/[\u4e00-\u9fa5]{2,4}/g) || [];
|
|
102
|
+
for (const w of words) {
|
|
103
|
+
if (STOP.has(w)) continue;
|
|
104
|
+
freq.set(w, (freq.get(w) || 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_exists(f) { try { return fs.existsSync(f); } catch { return false; } }
|
|
111
|
+
_read(f) { return readText(f, ""); }
|
|
112
|
+
}
|