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.
- package/LICENSE +201 -201
- package/README.md +218 -265
- package/bin/ppx-channels.js +2 -2
- package/bin/ppx-serve.js +5 -5
- package/bin/ppx-setup.js +124 -0
- package/bin/ppx-web.js +140 -0
- package/bin/ppx.js +2 -2
- package/config/identity.md +6 -6
- package/config/ishiki.md +16 -16
- package/config/ppx.json +15 -151
- package/config/ppx.json.example +143 -0
- package/package.json +17 -10
- package/skills/.usage.json +6 -0
- package/skills/agent-professional-training/SKILL.md +94 -0
- package/skills/brainstorm/SKILL.md +24 -0
- package/skills/cupid-lover-comms/SKILL.md +37 -0
- package/skills/debug/SKILL.md +26 -0
- package/skills/plan/SKILL.md +25 -0
- package/skills/ponytail/SKILL.md +25 -0
- package/skills/ppx-memory/SKILL.md +91 -0
- package/skills/ppx-memory/scripts/cli.js +192 -0
- package/skills/ppx-memory/scripts/experience.js +133 -0
- package/skills/ppx-memory/scripts/fact-store.js +842 -0
- package/skills/ppx-memory/scripts/l0.js +52 -0
- package/skills/ppx-memory/scripts/l2.js +146 -0
- package/skills/ppx-memory/scripts/l3.js +112 -0
- package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
- package/skills/ppx-memory/scripts/pii.js +42 -0
- package/skills/ppx-memory/scripts/schema.js +80 -0
- package/skills/ppx-memory/scripts/session.js +398 -0
- package/skills/ppx-memory/scripts/similarity.js +43 -0
- package/skills/ppx-memory/scripts/store.js +116 -0
- package/skills/ppx-memory/scripts/wal.js +38 -0
- package/skills/ppx-selfheal/SKILL.md +24 -0
- package/skills/ppx-selfheal/scripts/cli.js +80 -0
- package/skills/ppx-selfheal/scripts/healer.js +184 -0
- package/skills/ppx-selfheal/scripts/logger.js +17 -0
- package/skills/ppx-selfheal/scripts/store.js +116 -0
- package/skills/prompt-depth-kit/SKILL.md +28 -0
- package/skills/session-naming/SKILL.md +36 -0
- package/skills/verify/SKILL.md +25 -0
- package/src/agent/index.js +1340 -717
- package/src/agent/prompts.js +47 -3
- package/src/aml-server.js +197 -151
- package/src/ans/eviction.js +123 -143
- package/src/ans/guard.js +159 -120
- package/src/ans/lifecycle.js +96 -93
- package/src/ans/proactive.js +112 -129
- package/src/ans/reward.js +95 -111
- package/src/ans/values.js +15 -15
- package/src/audit/audit-chain.js +43 -7
- package/src/audit/verifier.js +157 -120
- package/src/bus/circuit-breaker.js +9 -1
- package/src/bus/runtime-bus.js +107 -93
- package/src/channels/base.js +57 -34
- package/src/channels/feishu.js +118 -126
- package/src/channels/http.js +1090 -592
- package/src/channels/index.js +111 -110
- package/src/channels/log.js +29 -29
- package/src/channels/wechat-crypto.js +73 -74
- package/src/channels/wechat.js +191 -197
- package/src/channels/workspace.js +94 -0
- package/src/channels-cli.js +126 -124
- package/src/cli.js +129 -120
- package/src/commands/index.js +142 -0
- package/src/config/channels.js +137 -170
- package/src/config/index.js +277 -224
- package/src/config/placeholder.js +31 -0
- package/src/config/providers.js +148 -188
- package/src/config/settings.js +156 -182
- package/src/core/policy.js +69 -18
- package/src/core/trace.js +8 -6
- package/src/edit/editblock.js +266 -0
- package/src/edit/snapshot.js +67 -0
- package/src/evidence/index.js +153 -0
- package/src/evolve/playbook.js +9 -10
- package/src/hooks/index.js +113 -0
- package/src/llm/client.js +188 -446
- package/src/llm/dsml.js +74 -74
- package/src/llm/embedder.js +41 -35
- package/src/llm/fence.js +52 -105
- package/src/llm/index.js +4 -4
- package/src/llm/local-embedder.js +94 -0
- package/src/llm/presets.js +113 -0
- package/src/llm/pricing.js +93 -0
- package/src/llm/retry.js +73 -73
- package/src/llm/router.js +92 -97
- package/src/mcp/admin.js +326 -0
- package/src/mcp/client.js +487 -375
- package/src/mcp/http.js +203 -0
- package/src/mcp/index.js +116 -116
- package/src/mcp/server.js +392 -0
- package/src/mcp/tasks.js +133 -0
- package/src/memory/asset-hub.js +6 -11
- package/src/memory/canvas.js +2 -5
- package/src/memory/compaction.js +28 -28
- package/src/memory/experience.js +133 -122
- package/src/memory/fact-store.js +914 -698
- package/src/memory/failure-episode.js +20 -11
- package/src/memory/fork.js +17 -8
- package/src/memory/index.js +8 -6
- package/src/memory/l0.js +53 -52
- package/src/memory/l2.js +145 -130
- package/src/memory/l3.js +111 -111
- package/src/memory/legion-board.js +71 -0
- package/src/memory/memory-ticker.js +239 -240
- package/src/memory/session.js +398 -397
- package/src/memory/sqlite-store.js +581 -0
- package/src/mode/blackboard.js +49 -49
- package/src/mode/graph.js +42 -41
- package/src/mode/index.js +64 -64
- package/src/mode/legion.js +54 -51
- package/src/mode/plan-exec.js +50 -50
- package/src/mode/router.js +28 -40
- package/src/orchestrator/agent-worker.js +69 -69
- package/src/orchestrator/dag.js +90 -83
- package/src/orchestrator/experts.js +76 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/legion.js +179 -187
- package/src/orchestrator/supervisor.js +6 -8
- package/src/permissions/index.js +378 -0
- package/src/persona/index.js +28 -29
- package/src/plugin/builtin.js +313 -212
- package/src/plugin/context.js +80 -79
- package/src/plugin/index.js +62 -62
- package/src/plugin/v3.js +73 -0
- package/src/protocol/index.js +148 -0
- package/src/repomap/index.js +309 -0
- package/src/review/index.js +393 -0
- package/src/seam/registry.js +3 -0
- package/src/seam/shell.js +55 -55
- package/src/security/injection.js +79 -0
- package/src/selfheal/evolve.js +67 -67
- package/src/selfheal/healer.js +184 -167
- package/src/selfheal/run.js +9 -9
- package/src/server.js +63 -60
- package/src/services/diagnose.js +180 -0
- package/src/services/learning-service.js +9 -0
- package/src/services/memory-health.js +34 -6
- package/src/services/memory-service.js +42 -9
- package/src/services/triage.js +138 -0
- package/src/session/parts.js +76 -0
- package/src/session/projection.js +73 -0
- package/src/session/rollout.js +54 -0
- package/src/session/turn.js +137 -0
- package/src/skills/lint.js +72 -0
- package/src/skills/loader.js +231 -150
- package/src/skills/search.js +58 -0
- package/src/skills/verify.js +95 -100
- package/src/tools/advanced.js +388 -352
- package/src/tools/builtin.js +384 -297
- package/src/tools/catalog.js +283 -159
- package/src/tools/command-guard.js +112 -112
- package/src/tools/custom.js +47 -47
- package/src/tools/delegate.js +383 -297
- package/src/tools/document.js +254 -253
- package/src/tools/git.js +151 -0
- package/src/tools/governance.js +47 -20
- package/src/tools/index.js +16 -11
- package/src/tools/methods.js +178 -178
- package/src/tools/ocr.js +59 -59
- package/src/tools/sandbox-worker.js +40 -0
- package/src/tools/sandbox.js +92 -0
- package/src/tools/seam.js +162 -125
- package/src/tools/selfmod.js +196 -176
- package/src/tools/v3.js +225 -0
- package/src/tools/vad.js +176 -0
- package/src/tools/voice.js +238 -0
- package/src/utils/async.js +14 -0
- package/src/utils/config-file.js +53 -0
- package/src/utils/crashguard.js +88 -0
- package/src/utils/http.js +53 -0
- package/src/utils/id.js +8 -0
- package/src/utils/json-state.js +33 -0
- package/src/utils/logger.js +17 -17
- package/src/utils/ndjson.js +25 -0
- package/src/utils/pii.js +42 -42
- package/src/utils/rate-limit.js +50 -0
- package/src/utils/schema.js +80 -0
- package/src/utils/similarity.js +43 -0
- package/src/utils/store.js +170 -108
- package/src/utils/text.js +15 -15
- package/src/utils/trace.js +153 -153
- package/src/utils/wal.js +39 -0
- package/src/utils/winutf8.js +16 -15
- package/src/wiki/index.js +170 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
// src/memory/sqlite-store.js - 内嵌记忆数据库 (SQLite, Node 内置 node:sqlite, 零运行时依赖)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它: JSON 版 (fact-store.js) 每次 add/hit 都要「全量读盘 → 重建倒排索引 → 全量原子写」,
|
|
4
|
+
// 且靠文件锁 + 忙等保证并发 —— 高频写场景磁盘写放大严重, 多进程只能串行。
|
|
5
|
+
// SQLite 版把这三件事一次性解决: 增量写 (O(1))、FTS5 索引 (C 实现)、事务级并发安全 (WAL 模式)。
|
|
6
|
+
//
|
|
7
|
+
// 接口与 FactStore **完全对齐** (19 个公开方法), 因此可在 config.memory.backend 上一键切换。
|
|
8
|
+
//
|
|
9
|
+
// 中文检索说明: FTS5 内置 unicode61 把连续汉字当一个 token, trigram 又要求查询 ≥3 字符 ——
|
|
10
|
+
// 都不适合中文。故采用「应用层 bigram 切分 + FTS5 存切分文本」: 写入与查询走同一套切分,
|
|
11
|
+
// 中文子串检索正常工作 (与 JSON 版的倒排口径一致, 便于横向对比)。
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { ensureDir, nowISO } from "../utils/store.js";
|
|
16
|
+
import { setJaccard, setOverlap } from "../utils/similarity.js";
|
|
17
|
+
|
|
18
|
+
// node:sqlite 用**惰性 require** 加载, 不做静态 import ——
|
|
19
|
+
// 静态 import 会在任何 import 了本模块的进程里立刻加载 node:sqlite,
|
|
20
|
+
// 从而让"完全没用到 SQLite 后端"的场景也弹出 ExperimentalWarning。
|
|
21
|
+
// 改为首次真正打开数据库时才解析, 不使用 SQLite 的进程零感知。
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
export const LAYER_L1 = 1;
|
|
25
|
+
export const LAYER_L4 = 4;
|
|
26
|
+
export const SCHEMA_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
const SNAKE_TO_CAMEL = {
|
|
29
|
+
decay_per_day: "decayPerDay",
|
|
30
|
+
hit_bonus: "hitBonus",
|
|
31
|
+
base_importance: "baseImportance",
|
|
32
|
+
forget_speed: "forgetSpeed",
|
|
33
|
+
max_facts: "maxFacts",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// 检索切分: CJK 取 bigram (单字成词则保留单字), 英数取小写整词。
|
|
37
|
+
// 与写入端使用同一函数 —— 这是中文能被 FTS5 命中的前提。
|
|
38
|
+
export function tokenize(text) {
|
|
39
|
+
const s = String(text || "").toLowerCase();
|
|
40
|
+
const out = [];
|
|
41
|
+
const re = /[a-z0-9_]+|[\u4e00-\u9fff]+/g;
|
|
42
|
+
for (const m of s.matchAll(re)) {
|
|
43
|
+
const t = m[0];
|
|
44
|
+
if (/^[a-z0-9_]+$/.test(t)) {
|
|
45
|
+
out.push(t);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (t.length === 1) {
|
|
49
|
+
out.push(t);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (let i = 0; i + 1 < t.length; i++) out.push(t.slice(i, i + 2));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FTS_QUERY_ESCAPE = (toks) => toks.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
|
|
58
|
+
|
|
59
|
+
function cryptoRandomId() {
|
|
60
|
+
return "f_" + globalThis.crypto.randomUUID();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class SqliteFactStore {
|
|
64
|
+
constructor(dataDir, opts = {}) {
|
|
65
|
+
this.dir = path.join(dataDir, "memory");
|
|
66
|
+
ensureDir(this.dir);
|
|
67
|
+
this.file = path.join(this.dir, "facts.db");
|
|
68
|
+
const normOpts = {};
|
|
69
|
+
for (const [k, v] of Object.entries(opts || {})) normOpts[SNAKE_TO_CAMEL[k] || k] = v;
|
|
70
|
+
this.opts = {
|
|
71
|
+
decayPerDay: 0.02,
|
|
72
|
+
hitBonus: 5,
|
|
73
|
+
baseImportance: 10,
|
|
74
|
+
forgetSpeed: 1.0,
|
|
75
|
+
maxFacts: 1000,
|
|
76
|
+
...normOpts,
|
|
77
|
+
};
|
|
78
|
+
this.embedder = null;
|
|
79
|
+
this._embedCache = new Map();
|
|
80
|
+
this._open();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_open() {
|
|
84
|
+
const { DatabaseSync } = require("node:sqlite"); // 惰性加载: 只有真正用 SQLite 后端时才引入
|
|
85
|
+
this.db = new DatabaseSync(this.file);
|
|
86
|
+
// WAL: 读写不互斥, 崩溃可恢复 (多进程安全的底座)
|
|
87
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
88
|
+
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
89
|
+
this.db.exec(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS facts (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
content TEXT NOT NULL,
|
|
93
|
+
norm_key TEXT,
|
|
94
|
+
toks TEXT,
|
|
95
|
+
type TEXT DEFAULT 'general',
|
|
96
|
+
source TEXT DEFAULT 'manual',
|
|
97
|
+
importance REAL DEFAULT 10,
|
|
98
|
+
score REAL DEFAULT 10,
|
|
99
|
+
created INTEGER,
|
|
100
|
+
last_access INTEGER,
|
|
101
|
+
hits INTEGER DEFAULT 0,
|
|
102
|
+
scope TEXT,
|
|
103
|
+
layer INTEGER DEFAULT 1,
|
|
104
|
+
status TEXT DEFAULT 'active',
|
|
105
|
+
prev_id TEXT,
|
|
106
|
+
superseded_by TEXT,
|
|
107
|
+
deleted_reason TEXT,
|
|
108
|
+
deleted_at INTEGER,
|
|
109
|
+
ttl_days INTEGER,
|
|
110
|
+
meta TEXT
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_facts_status ON facts(status);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_facts_scope ON facts(scope);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_facts_layer ON facts(layer);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_facts_norm ON facts(norm_key);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS idx_facts_access ON facts(last_access);
|
|
117
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
|
118
|
+
`);
|
|
119
|
+
// FTS5 索引表: 存"切分后的 token 文本", 检索时同口径切分。
|
|
120
|
+
// 用标准 (非 contentless) 表 —— content='' 的表不支持普通 DELETE, 增量更新会失效。
|
|
121
|
+
try {
|
|
122
|
+
this.db.exec(`
|
|
123
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
|
|
124
|
+
toks,
|
|
125
|
+
tokenize='unicode61'
|
|
126
|
+
);
|
|
127
|
+
`);
|
|
128
|
+
this.ftsReady = true;
|
|
129
|
+
} catch {
|
|
130
|
+
this.ftsReady = false; // 极端环境无 FTS5 时回落 LIKE 检索
|
|
131
|
+
}
|
|
132
|
+
this.db.exec(`INSERT OR REPLACE INTO meta(k,v) VALUES('schema', '${SCHEMA_VERSION}')`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- 内部工具 ----
|
|
136
|
+
_norm(s) {
|
|
137
|
+
return String(s || "").trim().replace(/\s+/g, " ");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_normKey(s) {
|
|
141
|
+
return this._norm(s)
|
|
142
|
+
.replace(/^(请记住|记一下|记得|别忘了|提醒我|记住)[::,,\s]*/, "")
|
|
143
|
+
.replace(/[。!?!?.,,、;;::\s]+$/, "")
|
|
144
|
+
.toLowerCase();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_row2fact(r) {
|
|
148
|
+
if (!r) return null;
|
|
149
|
+
return {
|
|
150
|
+
id: r.id,
|
|
151
|
+
content: r.content,
|
|
152
|
+
type: r.type,
|
|
153
|
+
source: r.source,
|
|
154
|
+
importance: r.importance,
|
|
155
|
+
score: r.score,
|
|
156
|
+
created: new Date(r.created).toISOString(),
|
|
157
|
+
lastAccess: new Date(r.last_access).toISOString(),
|
|
158
|
+
hits: r.hits,
|
|
159
|
+
scope: r.scope,
|
|
160
|
+
layer: r.layer,
|
|
161
|
+
status: r.status,
|
|
162
|
+
prevId: r.prev_id,
|
|
163
|
+
supersededBy: r.superseded_by,
|
|
164
|
+
deletedReason: r.deleted_reason,
|
|
165
|
+
meta: r.meta ? JSON.parse(r.meta) : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_lambdaOf(f) {
|
|
170
|
+
const base = Number(f.layer) === LAYER_L4 ? 0.005 : this.opts.decayPerDay;
|
|
171
|
+
return base * this.opts.forgetSpeed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_decay(score, days, layer = LAYER_L1) {
|
|
175
|
+
const lambda = (Number(layer) === LAYER_L4 ? 0.005 : this.opts.decayPerDay) * this.opts.forgetSpeed;
|
|
176
|
+
const d = Math.max(0, Number(days) || 0);
|
|
177
|
+
return score * Math.exp(-lambda * d * d);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_ftsUpsert(fact) {
|
|
181
|
+
if (!this.ftsReady) return;
|
|
182
|
+
const toks = tokenize(fact.content).join(" ");
|
|
183
|
+
try {
|
|
184
|
+
const row = this.db.prepare("SELECT rowid FROM facts WHERE id = ?").get(fact.id);
|
|
185
|
+
if (row) {
|
|
186
|
+
this.db.prepare("DELETE FROM facts_fts WHERE rowid = ?").run(row.rowid);
|
|
187
|
+
this.db.prepare("INSERT INTO facts_fts(rowid, toks) VALUES(?, ?)").run(row.rowid, toks);
|
|
188
|
+
}
|
|
189
|
+
} catch { /* 索引失败不影响主数据 */ }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_ftsDelete(id) {
|
|
193
|
+
if (!this.ftsReady) return;
|
|
194
|
+
try {
|
|
195
|
+
const row = this.db.prepare("SELECT rowid FROM facts WHERE id = ?").get(id);
|
|
196
|
+
if (row) this.db.prepare("DELETE FROM facts_fts WHERE rowid = ?").run(row.rowid);
|
|
197
|
+
} catch { /* 忽略 */ }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- 写入 ----
|
|
201
|
+
// 与 FactStore.add 语义一致: 归一化去重 (命中则 hits+1 并加分) / 容量裁剪 / 层级
|
|
202
|
+
add(content, {
|
|
203
|
+
importance = this.opts.baseImportance, type = "general", source = "manual",
|
|
204
|
+
dedupe = true, scope = null, meta = null, similarThreshold = 0,
|
|
205
|
+
layer = LAYER_L1, ttlDays = null,
|
|
206
|
+
} = {}) {
|
|
207
|
+
const clean = this._norm(content);
|
|
208
|
+
if (!clean) return null;
|
|
209
|
+
const key = this._normKey(clean);
|
|
210
|
+
|
|
211
|
+
if (dedupe && key) {
|
|
212
|
+
const hit = this.db.prepare(
|
|
213
|
+
"SELECT * FROM facts WHERE norm_key = ? AND status = 'active' AND (scope IS ? OR scope = ?) LIMIT 1",
|
|
214
|
+
).get(key, scope, scope);
|
|
215
|
+
if (hit) {
|
|
216
|
+
this.db.prepare(
|
|
217
|
+
"UPDATE facts SET hits = hits + 1, score = score + ?, last_access = ? WHERE id = ?",
|
|
218
|
+
).run(this.opts.hitBonus, Date.now(), hit.id);
|
|
219
|
+
this._ftsUpsert({ id: hit.id, content: hit.content });
|
|
220
|
+
return this._row2fact(this.db.prepare("SELECT * FROM facts WHERE id = ?").get(hit.id));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (similarThreshold > 0) {
|
|
225
|
+
const sim = this.findSimilar(clean, { threshold: similarThreshold, scope });
|
|
226
|
+
if (sim) {
|
|
227
|
+
this.db.prepare("UPDATE facts SET hits = hits + 1, score = score + ?, last_access = ? WHERE id = ?")
|
|
228
|
+
.run(this.opts.hitBonus, Date.now(), sim.id);
|
|
229
|
+
return sim;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const f = {
|
|
234
|
+
id: cryptoRandomId(),
|
|
235
|
+
content: clean,
|
|
236
|
+
normKey: key,
|
|
237
|
+
// 切分结果随行落库: 查询精排时直接读, 避免对每条候选重复切分 (实测这是查询慢 50x 的主因)
|
|
238
|
+
toks: tokenize(clean).join(" "),
|
|
239
|
+
type, source,
|
|
240
|
+
importance: Number(importance) || this.opts.baseImportance,
|
|
241
|
+
score: Number(importance) || this.opts.baseImportance,
|
|
242
|
+
created: Date.now(),
|
|
243
|
+
lastAccess: Date.now(),
|
|
244
|
+
hits: 0,
|
|
245
|
+
scope,
|
|
246
|
+
layer: Number(layer) || LAYER_L1,
|
|
247
|
+
status: "active",
|
|
248
|
+
ttlDays: ttlDays == null ? null : Number(ttlDays),
|
|
249
|
+
meta: meta ? JSON.stringify(meta) : null,
|
|
250
|
+
};
|
|
251
|
+
this.db.prepare(`
|
|
252
|
+
INSERT INTO facts(id,content,norm_key,toks,type,source,importance,score,created,last_access,hits,scope,layer,status,ttl_days,meta)
|
|
253
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
254
|
+
`).run(f.id, f.content, f.normKey, f.toks, f.type, f.source, f.importance, f.score, f.created, f.lastAccess, f.hits, f.scope, f.layer, f.status, f.ttlDays, f.meta);
|
|
255
|
+
|
|
256
|
+
this._ftsUpsert({ id: f.id, content: f.content });
|
|
257
|
+
this._prune();
|
|
258
|
+
return this._row2fact(this.db.prepare("SELECT * FROM facts WHERE id = ?").get(f.id));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
addMemory(message) {
|
|
262
|
+
return this.add(message, { source: "message", type: "message" });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 容量裁剪: 超 maxFacts 时按「衰减分 × 重要性」淘汰最弱 (与 JSON 版同口径)
|
|
266
|
+
_prune() {
|
|
267
|
+
const max = Number(this.opts.maxFacts);
|
|
268
|
+
if (!max || max <= 0) return 0;
|
|
269
|
+
const { n } = this.db.prepare("SELECT COUNT(*) AS n FROM facts WHERE status = 'active'").get();
|
|
270
|
+
if (n <= max) return 0;
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const rows = this.db.prepare("SELECT * FROM facts WHERE status='active'").all();
|
|
273
|
+
const ranked = rows.map((r) => {
|
|
274
|
+
const days = Math.max(0, (now - r.last_access) / 86400000);
|
|
275
|
+
const eff = this._decay(r.score, days, r.layer) * (0.5 + 0.5 * Math.min(r.importance || 0, 20) / 20);
|
|
276
|
+
return { id: r.id, eff };
|
|
277
|
+
}).sort((a, b) => a.eff - b.eff);
|
|
278
|
+
const drop = ranked.slice(0, n - max);
|
|
279
|
+
const del = this.db.prepare("DELETE FROM facts WHERE id = ?");
|
|
280
|
+
for (const d of drop) {
|
|
281
|
+
this._ftsDelete(d.id);
|
|
282
|
+
del.run(d.id);
|
|
283
|
+
}
|
|
284
|
+
return drop.length;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- 检索 ----
|
|
288
|
+
// 粗召回走 FTS5 (C 实现, 远快于内存全扫), 精排复用 JSON 版公式, 保证结果口径一致
|
|
289
|
+
query(q, { limit = 5, minScore = 1, scope = null } = {}) {
|
|
290
|
+
const ql = String(q || "").toLowerCase();
|
|
291
|
+
const toks = tokenize(ql);
|
|
292
|
+
let rows = [];
|
|
293
|
+
|
|
294
|
+
if (toks.length && this.ftsReady) {
|
|
295
|
+
try {
|
|
296
|
+
// ⚠ 关键: FTS 的 `ORDER BY bm25()` 必须隔离在**子查询**里。
|
|
297
|
+
// 实测把 bm25 排序与外层 JOIN 写在一起会让 SQLite 走错执行计划 —— 同样的数据量
|
|
298
|
+
// 从 0.16ms/次 劣化到 16ms/次 (100 倍)。子查询形式让"先按 bm25 取 rowid"和
|
|
299
|
+
// "按 rowid 取列"两件事各自走最优路径。
|
|
300
|
+
rows = this.db.prepare(`
|
|
301
|
+
SELECT f.id, f.content, f.toks, f.type, f.source, f.importance, f.score,
|
|
302
|
+
f.created, f.last_access, f.hits, f.scope, f.layer, f.status,
|
|
303
|
+
f.prev_id, f.superseded_by, f.deleted_reason, f.meta
|
|
304
|
+
FROM facts f
|
|
305
|
+
WHERE f.status = 'active' AND (? IS NULL OR f.scope = ?)
|
|
306
|
+
AND f.rowid IN (
|
|
307
|
+
SELECT rowid FROM facts_fts WHERE facts_fts MATCH ? ORDER BY bm25(facts_fts) LIMIT 200
|
|
308
|
+
)
|
|
309
|
+
`).all(scope, scope, FTS_QUERY_ESCAPE(toks));
|
|
310
|
+
} catch { rows = []; }
|
|
311
|
+
}
|
|
312
|
+
if (!rows.length) {
|
|
313
|
+
// 回落: LIKE 粗筛 (无 FTS5 或查询词过短)
|
|
314
|
+
const like = `%${ql.slice(0, 60)}%`;
|
|
315
|
+
rows = this.db.prepare(
|
|
316
|
+
"SELECT * FROM facts WHERE status='active' AND (? IS NULL OR scope = ?) AND lower(content) LIKE ? LIMIT 300",
|
|
317
|
+
).all(scope, scope, like);
|
|
318
|
+
}
|
|
319
|
+
if (!rows.length) return [];
|
|
320
|
+
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
const decayL1 = this.opts.decayPerDay * this.opts.forgetSpeed;
|
|
323
|
+
const decayL4 = 0.005 * this.opts.forgetSpeed;
|
|
324
|
+
const scored = [];
|
|
325
|
+
// 精排阶段只碰原始 row: 不构造 Date/ISO 字符串 (对 300 个候选做两次 new Date().toISOString()
|
|
326
|
+
// 是查询慢的主因 —— 实测 11ms/次 → 优化后 <1ms)。序列化留到最终 top-N。
|
|
327
|
+
for (const r of rows) {
|
|
328
|
+
const days = Math.max(0, (now - r.last_access) / 86400000);
|
|
329
|
+
const lambda = Number(r.layer) === LAYER_L4 ? decayL4 : decayL1;
|
|
330
|
+
const recency = Math.exp(-lambda * days * days);
|
|
331
|
+
const docSet = new Set(String(r.toks || "").split(" ").filter(Boolean));
|
|
332
|
+
let covered = 0;
|
|
333
|
+
if (toks.length) {
|
|
334
|
+
for (const t of toks) if (docSet.has(t)) covered++;
|
|
335
|
+
covered /= toks.length;
|
|
336
|
+
}
|
|
337
|
+
const subHit = ql && String(r.content).toLowerCase().includes(ql) ? 5 : 0;
|
|
338
|
+
let s = 10 * (0.4 + 0.6 * recency) * (0.3 + 0.7 * covered);
|
|
339
|
+
s += subHit;
|
|
340
|
+
s += r.hits > 0 ? Math.min(r.hits, 5) : 0;
|
|
341
|
+
s += Math.min(r.importance || 0, 20) / 20 * 3;
|
|
342
|
+
if (s >= minScore) scored.push({ r, s });
|
|
343
|
+
}
|
|
344
|
+
scored.sort((a, b) => b.s - a.s);
|
|
345
|
+
return scored.slice(0, limit).map((x) => ({ ...this._row2fact(x.r), effectiveScore: x.s }));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
queryMulti(queries, { limit = 5, scope = null, minScore = 1 } = {}) {
|
|
349
|
+
const lists = (queries || []).filter(Boolean).map((q) => this.query(q, { limit: Math.max(limit * 2, 10), scope, minScore }));
|
|
350
|
+
const K = 60;
|
|
351
|
+
const acc = new Map();
|
|
352
|
+
for (const list of lists) {
|
|
353
|
+
list.forEach((f, rank) => {
|
|
354
|
+
const cur = acc.get(f.id) || { fact: f, rrf: 0 };
|
|
355
|
+
cur.rrf += 1 / (K + rank + 1);
|
|
356
|
+
acc.set(f.id, cur);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return [...acc.values()].sort((a, b) => b.rrf - a.rrf).slice(0, limit).map((x) => ({ ...x.fact, effectiveScore: x.rrf }));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
findSimilar(content, { threshold = 0.6, scope = null } = {}) {
|
|
363
|
+
const a = new Set(tokenize(content));
|
|
364
|
+
if (!a.size) return null;
|
|
365
|
+
const rows = this.db.prepare("SELECT * FROM facts WHERE status='active' AND (? IS NULL OR scope = ?) LIMIT 500").all(scope, scope);
|
|
366
|
+
for (const r of rows) {
|
|
367
|
+
const b = new Set(tokenize(r.content));
|
|
368
|
+
if (setJaccard(a, b) >= threshold || setOverlap(a, b) >= threshold) return this._row2fact(r);
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
setEmbedder(fn) {
|
|
374
|
+
this.embedder = typeof fn === "function" ? fn : null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async _embed(text) {
|
|
378
|
+
if (!this.embedder) return null;
|
|
379
|
+
if (this._embedCache.has(text)) return this._embedCache.get(text);
|
|
380
|
+
try {
|
|
381
|
+
const v = await this.embedder(text);
|
|
382
|
+
if (Array.isArray(v) && v.length) {
|
|
383
|
+
if (this._embedCache.size > 1000) this._embedCache.clear();
|
|
384
|
+
this._embedCache.set(text, v);
|
|
385
|
+
return v;
|
|
386
|
+
}
|
|
387
|
+
} catch { /* 向量化失败回落词法检索 */ }
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async querySemantic(q, { limit = 5, scope = null } = {}) {
|
|
392
|
+
const lexical = this.query(q, { limit: limit * 2, scope });
|
|
393
|
+
if (!this.embedder) return lexical.slice(0, limit);
|
|
394
|
+
const qv = await this._embed(q);
|
|
395
|
+
if (!qv) return lexical.slice(0, limit);
|
|
396
|
+
const rows = this.db.prepare("SELECT * FROM facts WHERE status='active' AND (? IS NULL OR scope = ?) LIMIT 500").all(scope, scope);
|
|
397
|
+
const dense = [];
|
|
398
|
+
for (const r of rows) {
|
|
399
|
+
const v = await this._embed(r.content);
|
|
400
|
+
if (!v) continue;
|
|
401
|
+
dense.push({ ...this._row2fact(r), effectiveScore: cosine(qv, v) });
|
|
402
|
+
}
|
|
403
|
+
dense.sort((a, b) => b.effectiveScore - a.effectiveScore);
|
|
404
|
+
const K = 60;
|
|
405
|
+
const acc = new Map();
|
|
406
|
+
lexical.forEach((f, i) => acc.set(f.id, { f, s: 1 / (K + i + 1) }));
|
|
407
|
+
dense.slice(0, limit * 2).forEach((f, i) => {
|
|
408
|
+
const cur = acc.get(f.id);
|
|
409
|
+
if (cur) cur.s += 1 / (K + i + 1);
|
|
410
|
+
else acc.set(f.id, { f, s: 1 / (K + i + 1) });
|
|
411
|
+
});
|
|
412
|
+
return [...acc.values()].sort((a, b) => b.s - a.s).slice(0, limit).map((x) => x.f);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
hit(id) {
|
|
416
|
+
const r = this.db.prepare("SELECT * FROM facts WHERE id = ?").get(id);
|
|
417
|
+
if (!r) return null;
|
|
418
|
+
this.db.prepare("UPDATE facts SET hits = hits + 1, last_access = ?, score = score + ? WHERE id = ?")
|
|
419
|
+
.run(Date.now(), this.opts.hitBonus, id);
|
|
420
|
+
return this._row2fact(this.db.prepare("SELECT * FROM facts WHERE id = ?").get(id));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---- 治理 ----
|
|
424
|
+
forget(idOrContent, { reason = null } = {}) {
|
|
425
|
+
const key = String(idOrContent || "");
|
|
426
|
+
let row = this.db.prepare("SELECT * FROM facts WHERE id = ?").get(key);
|
|
427
|
+
if (!row) row = this.db.prepare("SELECT * FROM facts WHERE norm_key = ? AND status='active' LIMIT 1").get(this._normKey(key));
|
|
428
|
+
if (!row) return null;
|
|
429
|
+
// 已软删则不覆盖原 reason (幂等)
|
|
430
|
+
if (row.status !== "deleted") {
|
|
431
|
+
this.db.prepare("UPDATE facts SET status='deleted', deleted_reason=?, deleted_at=? WHERE id=?")
|
|
432
|
+
.run(reason || null, Date.now(), row.id);
|
|
433
|
+
}
|
|
434
|
+
return this._row2fact(this.db.prepare("SELECT * FROM facts WHERE id = ?").get(row.id));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
restore(id) {
|
|
438
|
+
const row = this.db.prepare("SELECT * FROM facts WHERE id = ?").get(String(id || ""));
|
|
439
|
+
if (!row || row.status !== "deleted") return null;
|
|
440
|
+
// 恢复即视为一次访问, 避免刚恢复就被衰减清空
|
|
441
|
+
this.db.prepare("UPDATE facts SET status='active', deleted_reason=NULL, deleted_at=NULL, last_access=?, hits=hits+1 WHERE id=?")
|
|
442
|
+
.run(Date.now(), row.id);
|
|
443
|
+
return this._row2fact(this.db.prepare("SELECT * FROM facts WHERE id = ?").get(row.id));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
deletedList() {
|
|
447
|
+
return this.db.prepare("SELECT * FROM facts WHERE status='deleted' ORDER BY deleted_at DESC").all().map((r) => this._row2fact(r));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
update(id, patch = {}) {
|
|
451
|
+
const row = this.db.prepare("SELECT * FROM facts WHERE id = ?").get(String(id || ""));
|
|
452
|
+
if (!row) return null;
|
|
453
|
+
const content = patch.content != null ? this._norm(patch.content) : row.content;
|
|
454
|
+
if (!content) return null;
|
|
455
|
+
// 版本链: 旧条归档 + 新条指回 (只保留一层历史, 与 JSON 版一致)
|
|
456
|
+
const newId = cryptoRandomId();
|
|
457
|
+
this.db.prepare("UPDATE facts SET status='archived', superseded_by=? WHERE id=?").run(newId, row.id);
|
|
458
|
+
this.db.prepare(`
|
|
459
|
+
INSERT INTO facts(id,content,norm_key,toks,type,source,importance,score,created,last_access,hits,scope,layer,status,prev_id,meta)
|
|
460
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?, 'active', ?, ?)
|
|
461
|
+
`).run(
|
|
462
|
+
newId, content, this._normKey(content), tokenize(content).join(" "),
|
|
463
|
+
patch.type ?? row.type, patch.source ?? row.source,
|
|
464
|
+
patch.importance ?? row.importance, patch.importance ?? row.score,
|
|
465
|
+
Date.now(), Date.now(), row.scope, row.layer, row.id, row.meta,
|
|
466
|
+
);
|
|
467
|
+
this._ftsUpsert({ id: newId, content });
|
|
468
|
+
return this._row2fact(this.db.prepare("SELECT * FROM facts WHERE id = ?").get(newId));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
sweepExpired({ ttlDays = 90, layer = null, dryRun = false } = {}) {
|
|
472
|
+
const cutoff = Date.now() - Number(ttlDays) * 86400000;
|
|
473
|
+
const rows = this.db.prepare(
|
|
474
|
+
"SELECT * FROM facts WHERE status='active' AND last_access < ? AND (? IS NULL OR layer = ?)",
|
|
475
|
+
).all(cutoff, layer, layer);
|
|
476
|
+
if (!dryRun) {
|
|
477
|
+
const st = this.db.prepare("UPDATE facts SET status='deleted', deleted_reason=?, deleted_at=? WHERE id=?");
|
|
478
|
+
for (const r of rows) st.run(`ttl 超期 (${ttlDays}天未访问)`, Date.now(), r.id);
|
|
479
|
+
}
|
|
480
|
+
return { count: rows.length, ids: rows.map((r) => r.id) };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
clearLayer(layer = LAYER_L1, { hard = false } = {}) {
|
|
484
|
+
if (hard) {
|
|
485
|
+
const ids = this.db.prepare("SELECT id FROM facts WHERE layer = ?").all(layer).map((r) => r.id);
|
|
486
|
+
for (const id of ids) this._ftsDelete(id);
|
|
487
|
+
const n = this.db.prepare("DELETE FROM facts WHERE layer = ?").run(layer).changes;
|
|
488
|
+
return { cleared: n, hard: true };
|
|
489
|
+
}
|
|
490
|
+
const n = this.db.prepare("UPDATE facts SET status='deleted', deleted_reason=?, deleted_at=? WHERE layer=? AND status='active'")
|
|
491
|
+
.run(`按层清理 L${layer}`, Date.now(), layer).changes;
|
|
492
|
+
return { cleared: n, hard: false };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
exportAll({ includeDeleted = true } = {}) {
|
|
496
|
+
const rows = includeDeleted
|
|
497
|
+
? this.db.prepare("SELECT * FROM facts ORDER BY created").all()
|
|
498
|
+
: this.db.prepare("SELECT * FROM facts WHERE status='active' ORDER BY created").all();
|
|
499
|
+
return { version: SCHEMA_VERSION, exportedAt: nowISO(), count: rows.length, facts: rows.map((r) => this._row2fact(r)) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
importAll(data, { mode = "merge" } = {}) {
|
|
503
|
+
const list = Array.isArray(data) ? data : (data && Array.isArray(data.facts) ? data.facts : []);
|
|
504
|
+
if (mode === "replace") {
|
|
505
|
+
const ids = this.db.prepare("SELECT id FROM facts").all().map((r) => r.id);
|
|
506
|
+
for (const id of ids) this._ftsDelete(id);
|
|
507
|
+
this.db.exec("DELETE FROM facts");
|
|
508
|
+
}
|
|
509
|
+
let added = 0;
|
|
510
|
+
for (const f of list) {
|
|
511
|
+
if (!f || !f.content) continue;
|
|
512
|
+
const exists = this.db.prepare("SELECT id FROM facts WHERE norm_key = ? LIMIT 1").get(this._normKey(f.content));
|
|
513
|
+
if (exists && mode === "merge") continue;
|
|
514
|
+
this.add(f.content, {
|
|
515
|
+
importance: f.importance, type: f.type, source: f.source,
|
|
516
|
+
scope: f.scope, layer: f.layer, dedupe: false, meta: f.meta,
|
|
517
|
+
});
|
|
518
|
+
added++;
|
|
519
|
+
}
|
|
520
|
+
return { mode, added, total: list.length };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
list({ limit = 50, status = "active" } = {}) {
|
|
524
|
+
return this.db.prepare("SELECT * FROM facts WHERE status = ? ORDER BY last_access DESC LIMIT ?")
|
|
525
|
+
.all(status, limit).map((r) => this._row2fact(r));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
count() {
|
|
529
|
+
return this.db.prepare("SELECT COUNT(*) AS n FROM facts").get().n;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
countLive() {
|
|
533
|
+
return this.db.prepare("SELECT COUNT(*) AS n FROM facts WHERE status='active'").get().n;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
stats() {
|
|
537
|
+
const total = this.count();
|
|
538
|
+
const active = this.countLive();
|
|
539
|
+
const deleted = this.db.prepare("SELECT COUNT(*) AS n FROM facts WHERE status='deleted'").get().n;
|
|
540
|
+
const archived = this.db.prepare("SELECT COUNT(*) AS n FROM facts WHERE status='archived'").get().n;
|
|
541
|
+
const byLayer = {};
|
|
542
|
+
for (const r of this.db.prepare("SELECT layer, COUNT(*) AS n FROM facts WHERE status='active' GROUP BY layer").all()) byLayer[String(r.layer)] = r.n;
|
|
543
|
+
const bySource = {};
|
|
544
|
+
for (const r of this.db.prepare("SELECT source, COUNT(*) AS n FROM facts WHERE status='active' GROUP BY source").all()) bySource[r.source || "unknown"] = r.n;
|
|
545
|
+
const size = fs.existsSync(this.file) ? fs.statSync(this.file).size : 0;
|
|
546
|
+
return {
|
|
547
|
+
total, active, deleted, archived,
|
|
548
|
+
max_facts: this.opts.maxFacts,
|
|
549
|
+
by_layer: byLayer,
|
|
550
|
+
by_source: bySource,
|
|
551
|
+
file: this.file,
|
|
552
|
+
bytes: size,
|
|
553
|
+
backend: "sqlite",
|
|
554
|
+
fts: this.ftsReady,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// SQLite 版即时落盘, 这两个方法保留只为接口对齐
|
|
559
|
+
save() { return this; }
|
|
560
|
+
flush() { return this; }
|
|
561
|
+
rebuildIndex() { return 0; }
|
|
562
|
+
|
|
563
|
+
close() {
|
|
564
|
+
try { this.db.close(); } catch { /* 已关闭 */ }
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 余弦相似度 (与 fact-store 同口径)
|
|
569
|
+
function cosine(a, b) {
|
|
570
|
+
let dot = 0, na = 0, nb = 0;
|
|
571
|
+
const n = Math.min(a.length, b.length);
|
|
572
|
+
for (let i = 0; i < n; i++) {
|
|
573
|
+
dot += a[i] * b[i];
|
|
574
|
+
na += a[i] * a[i];
|
|
575
|
+
nb += b[i] * b[i];
|
|
576
|
+
}
|
|
577
|
+
if (!na || !nb) return 0;
|
|
578
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export default SqliteFactStore;
|
package/src/mode/blackboard.js
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
// src/mode/blackboard.js - Blackboard 黑板模式
|
|
2
|
-
// 共享黑板: 多个专家轮流读黑板 → 贡献 → 写黑板, 最后汇总。
|
|
3
|
-
// 适合: 需要共享上下文的协作、增量求解、无固定顺序的问题。
|
|
4
|
-
// 解耦: 专家间不直接通信, 只通过黑板读写。
|
|
5
|
-
|
|
6
|
-
// 黑板: 共享状态容器 (内存版, 单进程内共享)
|
|
7
|
-
export class Board {
|
|
8
|
-
constructor() { this._data = new Map(); }
|
|
9
|
-
set(key, value) { this._data.set(key, String(value)); return value; }
|
|
10
|
-
get(key) { return this._data.get(key); }
|
|
11
|
-
keys() { return [...this._data.keys()]; }
|
|
12
|
-
snapshot() { return Object.fromEntries(this._data); }
|
|
13
|
-
toString() { return JSON.stringify(this.snapshot(), null, 2); }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// 默认专家团队: 分析 → 执行 → 审查
|
|
17
|
-
const DEFAULT_EXPERTS = [
|
|
18
|
-
{ name: "分析师", system: "你是分析师。分析任务, 输出关键要点、约束和风险。" },
|
|
19
|
-
{ name: "执行者", system: "你是执行者。基于黑板上的分析结果, 给出具体可执行的方案步骤。" },
|
|
20
|
-
{ name: "审查者", system: "你是审查者。审查执行方案, 指出问题并给出最终建议。" },
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
export async function blackboardExecutor(agent, userMsg, { sessionKey = "default", experts = null } = {}) {
|
|
24
|
-
if (!agent.llm) {
|
|
25
|
-
return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
|
|
26
|
-
}
|
|
27
|
-
const board = new Board();
|
|
28
|
-
board.set("task", String(userMsg));
|
|
29
|
-
|
|
30
|
-
// v1.0.8: 空数组回退默认专家 (原 [] 时 last 为 undefined 静默返回空)
|
|
31
|
-
const team = (Array.isArray(experts) && experts.length) ? experts : DEFAULT_EXPERTS;
|
|
32
|
-
for (const ex of team) {
|
|
33
|
-
let r;
|
|
34
|
-
try {
|
|
35
|
-
const resp = await agent.llm.chat([
|
|
36
|
-
{ role: "system", content: `${ex.system}\n\n[黑板当前状态]\n${board.toString()}` },
|
|
37
|
-
{ role: "user", content: "请基于黑板状态完成你的职责, 输出结果。" },
|
|
38
|
-
]);
|
|
39
|
-
r = resp.content;
|
|
40
|
-
} catch (e) {
|
|
41
|
-
r = `(${ex.name} 失败: ${e.message})`;
|
|
42
|
-
}
|
|
43
|
-
board.set(ex.name, r);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// 汇总: 最后一个专家(审查者)的输出作为最终答案
|
|
47
|
-
const last = team[team.length - 1];
|
|
48
|
-
return board.get(last.name) || "(黑板无结果)";
|
|
49
|
-
}
|
|
1
|
+
// src/mode/blackboard.js - Blackboard 黑板模式
|
|
2
|
+
// 共享黑板: 多个专家轮流读黑板 → 贡献 → 写黑板, 最后汇总。
|
|
3
|
+
// 适合: 需要共享上下文的协作、增量求解、无固定顺序的问题。
|
|
4
|
+
// 解耦: 专家间不直接通信, 只通过黑板读写。
|
|
5
|
+
|
|
6
|
+
// 黑板: 共享状态容器 (内存版, 单进程内共享)
|
|
7
|
+
export class Board {
|
|
8
|
+
constructor() { this._data = new Map(); }
|
|
9
|
+
set(key, value) { this._data.set(key, String(value)); return value; }
|
|
10
|
+
get(key) { return this._data.get(key); }
|
|
11
|
+
keys() { return [...this._data.keys()]; }
|
|
12
|
+
snapshot() { return Object.fromEntries(this._data); }
|
|
13
|
+
toString() { return JSON.stringify(this.snapshot(), null, 2); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 默认专家团队: 分析 → 执行 → 审查
|
|
17
|
+
const DEFAULT_EXPERTS = [
|
|
18
|
+
{ name: "分析师", system: "你是分析师。分析任务, 输出关键要点、约束和风险。" },
|
|
19
|
+
{ name: "执行者", system: "你是执行者。基于黑板上的分析结果, 给出具体可执行的方案步骤。" },
|
|
20
|
+
{ name: "审查者", system: "你是审查者。审查执行方案, 指出问题并给出最终建议。" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export async function blackboardExecutor(agent, userMsg, { sessionKey = "default", experts = null } = {}) {
|
|
24
|
+
if (!agent.llm) {
|
|
25
|
+
return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
|
|
26
|
+
}
|
|
27
|
+
const board = new Board();
|
|
28
|
+
board.set("task", String(userMsg));
|
|
29
|
+
|
|
30
|
+
// v1.0.8: 空数组回退默认专家 (原 [] 时 last 为 undefined 静默返回空)
|
|
31
|
+
const team = (Array.isArray(experts) && experts.length) ? experts : DEFAULT_EXPERTS;
|
|
32
|
+
for (const ex of team) {
|
|
33
|
+
let r;
|
|
34
|
+
try {
|
|
35
|
+
const resp = await agent.llm.chat([
|
|
36
|
+
{ role: "system", content: `${ex.system}\n\n[黑板当前状态]\n${board.toString()}` },
|
|
37
|
+
{ role: "user", content: "请基于黑板状态完成你的职责, 输出结果。" },
|
|
38
|
+
]);
|
|
39
|
+
r = resp.content;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
r = `(${ex.name} 失败: ${e.message})`;
|
|
42
|
+
}
|
|
43
|
+
board.set(ex.name, r);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 汇总: 最后一个专家(审查者)的输出作为最终答案
|
|
47
|
+
const last = team[team.length - 1];
|
|
48
|
+
return board.get(last.name) || "(黑板无结果)";
|
|
49
|
+
}
|