@jonathangu/openclawbrain 0.3.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 +21 -0
- package/README.md +412 -0
- package/bin/openclawbrain.js +15 -0
- package/docs/END_STATE.md +244 -0
- package/docs/EVIDENCE.md +128 -0
- package/docs/RELEASE_CONTRACT.md +91 -0
- package/docs/agent-tools.md +106 -0
- package/docs/architecture.md +224 -0
- package/docs/configuration.md +178 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/status.json +87 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/summary.md +16 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/trace.json +273 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/validation-report.json +652 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/channels-status.txt +31 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/config-snapshot.json +66 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/doctor.json +14 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-probe.txt +34 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-status.txt +41 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/logs.txt +428 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status-all.txt +60 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status.json +223 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/summary.md +13 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/trace.json +4 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/validation-report.json +334 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/channels-status.txt +25 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/config-snapshot.json +91 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/doctor.json +14 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-probe.txt +36 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-status.txt +44 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/logs.txt +428 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-doctor.json +10 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-sdk-probe.json +11 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-setup-only.json +12 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/summary.md +30 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/validation-report.json +72 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status-all.txt +63 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status.json +200 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/summary.md +13 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/trace.json +4 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/validation-report.json +311 -0
- package/docs/evidence/README.md +16 -0
- package/docs/fts5.md +161 -0
- package/docs/tui.md +506 -0
- package/index.ts +1372 -0
- package/openclaw.plugin.json +136 -0
- package/package.json +66 -0
- package/src/assembler.ts +804 -0
- package/src/brain-cli.ts +316 -0
- package/src/brain-core/decay.ts +35 -0
- package/src/brain-core/episode.ts +82 -0
- package/src/brain-core/graph.ts +321 -0
- package/src/brain-core/health.ts +116 -0
- package/src/brain-core/mutator.ts +281 -0
- package/src/brain-core/pack.ts +117 -0
- package/src/brain-core/policy.ts +153 -0
- package/src/brain-core/replay.ts +1 -0
- package/src/brain-core/teacher.ts +105 -0
- package/src/brain-core/trace.ts +40 -0
- package/src/brain-core/traverse.ts +230 -0
- package/src/brain-core/types.ts +405 -0
- package/src/brain-core/update.ts +123 -0
- package/src/brain-harvest/human.ts +46 -0
- package/src/brain-harvest/scanner.ts +98 -0
- package/src/brain-harvest/self.ts +147 -0
- package/src/brain-runtime/assembler-extension.ts +230 -0
- package/src/brain-runtime/evidence-detectors.ts +68 -0
- package/src/brain-runtime/graph-io.ts +72 -0
- package/src/brain-runtime/harvester-extension.ts +98 -0
- package/src/brain-runtime/service.ts +659 -0
- package/src/brain-runtime/tools.ts +109 -0
- package/src/brain-runtime/worker-state.ts +106 -0
- package/src/brain-runtime/worker-supervisor.ts +169 -0
- package/src/brain-store/embedding.ts +179 -0
- package/src/brain-store/init.ts +347 -0
- package/src/brain-store/migrations.ts +188 -0
- package/src/brain-store/store.ts +816 -0
- package/src/brain-worker/child-runner.ts +321 -0
- package/src/brain-worker/jobs.ts +12 -0
- package/src/brain-worker/mutation-job.ts +5 -0
- package/src/brain-worker/promotion-job.ts +5 -0
- package/src/brain-worker/protocol.ts +79 -0
- package/src/brain-worker/teacher-job.ts +5 -0
- package/src/brain-worker/update-job.ts +5 -0
- package/src/brain-worker/worker.ts +422 -0
- package/src/compaction.ts +1332 -0
- package/src/db/config.ts +265 -0
- package/src/db/connection.ts +72 -0
- package/src/db/features.ts +42 -0
- package/src/db/migration.ts +561 -0
- package/src/engine.ts +1995 -0
- package/src/expansion-auth.ts +351 -0
- package/src/expansion-policy.ts +303 -0
- package/src/expansion.ts +383 -0
- package/src/integrity.ts +600 -0
- package/src/large-files.ts +527 -0
- package/src/openclaw-bridge.ts +22 -0
- package/src/retrieval.ts +357 -0
- package/src/store/conversation-store.ts +748 -0
- package/src/store/fts5-sanitize.ts +29 -0
- package/src/store/full-text-fallback.ts +74 -0
- package/src/store/index.ts +29 -0
- package/src/store/summary-store.ts +918 -0
- package/src/summarize.ts +847 -0
- package/src/tools/common.ts +53 -0
- package/src/tools/lcm-conversation-scope.ts +76 -0
- package/src/tools/lcm-describe-tool.ts +234 -0
- package/src/tools/lcm-expand-query-tool.ts +594 -0
- package/src/tools/lcm-expand-tool.delegation.ts +556 -0
- package/src/tools/lcm-expand-tool.ts +448 -0
- package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
- package/src/tools/lcm-grep-tool.ts +200 -0
- package/src/transcript-repair.ts +301 -0
- package/src/types.ts +149 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory knowledge graph for the brain's learned retrieval layer.
|
|
3
|
+
*
|
|
4
|
+
* Loaded from SQLite, provides adjacency queries, seed selection by
|
|
5
|
+
* embedding similarity, action set computation, and inhibitory veto checks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BrainNode,
|
|
10
|
+
BrainEdge,
|
|
11
|
+
EdgeKind,
|
|
12
|
+
NodeKind,
|
|
13
|
+
TraversalAction,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cosine similarity between two Float32Arrays.
|
|
18
|
+
*/
|
|
19
|
+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
20
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
21
|
+
let dot = 0;
|
|
22
|
+
let normA = 0;
|
|
23
|
+
let normB = 0;
|
|
24
|
+
for (let i = 0; i < a.length; i++) {
|
|
25
|
+
dot += a[i] * b[i];
|
|
26
|
+
normA += a[i] * a[i];
|
|
27
|
+
normB += b[i] * b[i];
|
|
28
|
+
}
|
|
29
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
30
|
+
return denom === 0 ? 0 : dot / denom;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class BrainGraph {
|
|
34
|
+
private nodes: Map<string, BrainNode> = new Map();
|
|
35
|
+
private outEdges: Map<string, BrainEdge[]> = new Map();
|
|
36
|
+
private inEdges: Map<string, BrainEdge[]> = new Map();
|
|
37
|
+
private seedWeights: Map<string, number> = new Map();
|
|
38
|
+
|
|
39
|
+
addNode(node: BrainNode): void {
|
|
40
|
+
this.nodes.set(node.id, node);
|
|
41
|
+
if (!this.outEdges.has(node.id)) this.outEdges.set(node.id, []);
|
|
42
|
+
if (!this.inEdges.has(node.id)) this.inEdges.set(node.id, []);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
removeNode(nodeId: string): void {
|
|
46
|
+
this.nodes.delete(nodeId);
|
|
47
|
+
this.seedWeights.delete(nodeId);
|
|
48
|
+
// Remove all edges involving this node
|
|
49
|
+
const out = this.outEdges.get(nodeId) ?? [];
|
|
50
|
+
for (const edge of out) {
|
|
51
|
+
const targetIn = this.inEdges.get(edge.target);
|
|
52
|
+
if (targetIn) {
|
|
53
|
+
const idx = targetIn.indexOf(edge);
|
|
54
|
+
if (idx >= 0) targetIn.splice(idx, 1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.outEdges.delete(nodeId);
|
|
58
|
+
|
|
59
|
+
const inc = this.inEdges.get(nodeId) ?? [];
|
|
60
|
+
for (const edge of inc) {
|
|
61
|
+
const sourceOut = this.outEdges.get(edge.source);
|
|
62
|
+
if (sourceOut) {
|
|
63
|
+
const idx = sourceOut.indexOf(edge);
|
|
64
|
+
if (idx >= 0) sourceOut.splice(idx, 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.inEdges.delete(nodeId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getNode(nodeId: string): BrainNode | undefined {
|
|
71
|
+
return this.nodes.get(nodeId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getAllNodes(): BrainNode[] {
|
|
75
|
+
return [...this.nodes.values()];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getAllEdges(): BrainEdge[] {
|
|
79
|
+
return [...this.outEdges.values()].flatMap((edges) => edges);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getNodesByKind(kind: NodeKind): BrainNode[] {
|
|
83
|
+
return [...this.nodes.values()].filter((n) => n.kind === kind);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
addEdge(edge: BrainEdge): void {
|
|
87
|
+
const out = this.outEdges.get(edge.source);
|
|
88
|
+
if (out) {
|
|
89
|
+
// Replace if same source/target/kind exists
|
|
90
|
+
const idx = out.findIndex(
|
|
91
|
+
(e) => e.target === edge.target && e.kind === edge.kind,
|
|
92
|
+
);
|
|
93
|
+
if (idx >= 0) out[idx] = edge;
|
|
94
|
+
else out.push(edge);
|
|
95
|
+
} else {
|
|
96
|
+
this.outEdges.set(edge.source, [edge]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const inc = this.inEdges.get(edge.target);
|
|
100
|
+
if (inc) {
|
|
101
|
+
const idx = inc.findIndex(
|
|
102
|
+
(e) => e.source === edge.source && e.kind === edge.kind,
|
|
103
|
+
);
|
|
104
|
+
if (idx >= 0) inc[idx] = edge;
|
|
105
|
+
else inc.push(edge);
|
|
106
|
+
} else {
|
|
107
|
+
this.inEdges.set(edge.target, [edge]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
removeEdge(source: string, target: string, kind: EdgeKind): void {
|
|
112
|
+
const out = this.outEdges.get(source);
|
|
113
|
+
if (out) {
|
|
114
|
+
const idx = out.findIndex((e) => e.target === target && e.kind === kind);
|
|
115
|
+
if (idx >= 0) out.splice(idx, 1);
|
|
116
|
+
}
|
|
117
|
+
const inc = this.inEdges.get(target);
|
|
118
|
+
if (inc) {
|
|
119
|
+
const idx = inc.findIndex((e) => e.source === source && e.kind === kind);
|
|
120
|
+
if (idx >= 0) inc.splice(idx, 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getEdge(source: string, target: string): BrainEdge | undefined {
|
|
125
|
+
const out = this.outEdges.get(source);
|
|
126
|
+
if (!out) return undefined;
|
|
127
|
+
return out.find((e) => e.target === target);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getOutgoingEdges(nodeId: string): BrainEdge[] {
|
|
131
|
+
return this.outEdges.get(nodeId) ?? [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getIncomingEdges(nodeId: string): BrainEdge[] {
|
|
135
|
+
return this.inEdges.get(nodeId) ?? [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getNeighbors(nodeId: string): string[] {
|
|
139
|
+
const edges = this.outEdges.get(nodeId) ?? [];
|
|
140
|
+
return [...new Set(edges.map((e) => e.target))];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getSeedWeight(nodeId: string): number {
|
|
144
|
+
return this.seedWeights.get(nodeId) ?? 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getSeedWeights(nodeIds: string[]): Record<string, number> {
|
|
148
|
+
const weights: Record<string, number> = {};
|
|
149
|
+
for (const nodeId of nodeIds) {
|
|
150
|
+
weights[nodeId] = this.getSeedWeight(nodeId);
|
|
151
|
+
}
|
|
152
|
+
return weights;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setSeedWeight(nodeId: string, weight: number): void {
|
|
156
|
+
if (!this.nodes.has(nodeId)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.seedWeights.set(nodeId, weight);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
getAllSeedWeights(): Array<{ nodeId: string; weight: number }> {
|
|
163
|
+
return [...this.seedWeights.entries()].map(([nodeId, weight]) => ({ nodeId, weight }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
hasSeedWeights(): boolean {
|
|
167
|
+
return this.seedWeights.size > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
clone(): BrainGraph {
|
|
171
|
+
const clone = new BrainGraph();
|
|
172
|
+
for (const node of this.nodes.values()) {
|
|
173
|
+
clone.addNode({
|
|
174
|
+
...node,
|
|
175
|
+
embedding: node.embedding ? new Float32Array(node.embedding) : null,
|
|
176
|
+
tags: [...node.tags],
|
|
177
|
+
metadata: { ...node.metadata },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const edge of this.getAllEdges()) {
|
|
181
|
+
clone.addEdge({
|
|
182
|
+
...edge,
|
|
183
|
+
metadata: { ...edge.metadata },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
for (const { nodeId, weight } of this.getAllSeedWeights()) {
|
|
187
|
+
clone.setSeedWeight(nodeId, weight);
|
|
188
|
+
}
|
|
189
|
+
return clone;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Seed selection: top-k nodes by cosine similarity to query embedding.
|
|
194
|
+
* Linear scan — fine for <10K nodes.
|
|
195
|
+
*/
|
|
196
|
+
seedByEmbedding(
|
|
197
|
+
queryEmbedding: Float32Array,
|
|
198
|
+
topK: number,
|
|
199
|
+
threshold: number,
|
|
200
|
+
): Array<{ nodeId: string; score: number }> {
|
|
201
|
+
const scored: Array<{ nodeId: string; score: number }> = [];
|
|
202
|
+
|
|
203
|
+
for (const node of this.nodes.values()) {
|
|
204
|
+
if (!node.embedding) continue;
|
|
205
|
+
const score = cosineSimilarity(queryEmbedding, node.embedding);
|
|
206
|
+
if (score >= threshold) {
|
|
207
|
+
scored.push({ nodeId: node.id, score });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
scored.sort((a, b) => b.score - a.score);
|
|
212
|
+
return scored.slice(0, topK);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Compute action set at current state.
|
|
217
|
+
* A(s) = { traverse(neighbor) for neighbor not in visited } ∪ { STOP }
|
|
218
|
+
*
|
|
219
|
+
* At seed phase (currentNodeId === null), uses provided seeds.
|
|
220
|
+
*/
|
|
221
|
+
getActionSet(
|
|
222
|
+
currentNodeId: string | null,
|
|
223
|
+
visited: Set<string>,
|
|
224
|
+
seeds?: Array<{ nodeId: string; score?: number }>,
|
|
225
|
+
): TraversalAction[] {
|
|
226
|
+
const actions: TraversalAction[] = [];
|
|
227
|
+
|
|
228
|
+
if (currentNodeId === null) {
|
|
229
|
+
// Seed phase: actions are the seed candidates
|
|
230
|
+
if (seeds) {
|
|
231
|
+
for (const seed of seeds) {
|
|
232
|
+
if (!visited.has(seed.nodeId)) {
|
|
233
|
+
actions.push({ type: "traverse", targetNodeId: seed.nodeId, seedScore: seed.score });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
// Normal phase: neighbors via outgoing edges
|
|
239
|
+
const neighbors = this.getNeighbors(currentNodeId);
|
|
240
|
+
for (const neighborId of neighbors) {
|
|
241
|
+
if (!visited.has(neighborId)) {
|
|
242
|
+
actions.push({ type: "traverse", targetNodeId: neighborId });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// STOP is always available
|
|
248
|
+
actions.push({ type: "stop" });
|
|
249
|
+
return actions;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Check if traversal from source to target is suppressed by an inhibitory edge.
|
|
254
|
+
*/
|
|
255
|
+
isVetoed(sourceNodeId: string, targetNodeId: string): boolean {
|
|
256
|
+
const edges = this.outEdges.get(sourceNodeId) ?? [];
|
|
257
|
+
return edges.some(
|
|
258
|
+
(e) => e.target === targetNodeId && (e.kind === "inhibitory" || e.weight < -0.5),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getVetoReason(sourceNodeId: string, targetNodeId: string): string | null {
|
|
263
|
+
const edges = this.outEdges.get(sourceNodeId) ?? [];
|
|
264
|
+
const vetoEdge = edges.find(
|
|
265
|
+
(e) => e.target === targetNodeId && (e.kind === "inhibitory" || e.weight < -0.5),
|
|
266
|
+
);
|
|
267
|
+
if (!vetoEdge) return null;
|
|
268
|
+
if (vetoEdge.kind === "inhibitory") return "inhibitory edge";
|
|
269
|
+
return `negative weight (${vetoEdge.weight.toFixed(3)})`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Nodes with no edges at all.
|
|
274
|
+
*/
|
|
275
|
+
getOrphanNodes(): string[] {
|
|
276
|
+
const orphans: string[] = [];
|
|
277
|
+
for (const nodeId of this.nodes.keys()) {
|
|
278
|
+
const outCount = (this.outEdges.get(nodeId) ?? []).length;
|
|
279
|
+
const inCount = (this.inEdges.get(nodeId) ?? []).length;
|
|
280
|
+
if (outCount === 0 && inCount === 0) {
|
|
281
|
+
orphans.push(nodeId);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return orphans;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Nodes not present in any recent episode's fired list.
|
|
289
|
+
*/
|
|
290
|
+
getDormantNodes(recentFiredNodeIds: Set<string>): string[] {
|
|
291
|
+
const dormant: string[] = [];
|
|
292
|
+
for (const nodeId of this.nodes.keys()) {
|
|
293
|
+
if (!recentFiredNodeIds.has(nodeId)) {
|
|
294
|
+
dormant.push(nodeId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return dormant;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
nodeCount(): number {
|
|
301
|
+
return this.nodes.size;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
edgeCount(): number {
|
|
305
|
+
let count = 0;
|
|
306
|
+
for (const edges of this.outEdges.values()) {
|
|
307
|
+
count += edges.length;
|
|
308
|
+
}
|
|
309
|
+
return count;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Clear all nodes and edges.
|
|
314
|
+
*/
|
|
315
|
+
clear(): void {
|
|
316
|
+
this.nodes.clear();
|
|
317
|
+
this.outEdges.clear();
|
|
318
|
+
this.inEdges.clear();
|
|
319
|
+
this.seedWeights.clear();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph health metrics for promotion gates and operator visibility.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { HealthMetrics, NodeKind, EdgeKind, Episode } from "./types.js";
|
|
6
|
+
import type { BrainGraph } from "./graph.js";
|
|
7
|
+
|
|
8
|
+
const NODE_KINDS: NodeKind[] = ["chunk", "workflow", "correction", "toolcard", "episode_anchor", "summary_bridge"];
|
|
9
|
+
const EDGE_KINDS: EdgeKind[] = ["sibling", "semantic", "learned", "seed", "inhibitory", "bridge"];
|
|
10
|
+
|
|
11
|
+
export function computeFiredPerQuery(episodes: Episode[]): number {
|
|
12
|
+
if (episodes.length === 0) return 0;
|
|
13
|
+
const totalFired = episodes.reduce((sum, ep) => sum + ep.firedNodes.length, 0);
|
|
14
|
+
return totalFired / episodes.length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function computeDormantPercent(graph: BrainGraph, episodes: Episode[]): number {
|
|
18
|
+
const totalNodes = graph.nodeCount();
|
|
19
|
+
if (totalNodes === 0) return 0;
|
|
20
|
+
|
|
21
|
+
const firedSet = new Set<string>();
|
|
22
|
+
for (const ep of episodes) {
|
|
23
|
+
for (const id of ep.firedNodes) firedSet.add(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dormant = graph.getDormantNodes(firedSet);
|
|
27
|
+
return dormant.length / totalNodes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function computeInhibitoryPercent(graph: BrainGraph): number {
|
|
31
|
+
const totalEdges = graph.edgeCount();
|
|
32
|
+
if (totalEdges === 0) return 0;
|
|
33
|
+
|
|
34
|
+
let inhibCount = 0;
|
|
35
|
+
for (const edge of graph.getAllEdges()) {
|
|
36
|
+
if (edge.kind === "inhibitory" || edge.weight < 0) inhibCount++;
|
|
37
|
+
}
|
|
38
|
+
return inhibCount / totalEdges;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeAvgPathLength(episodes: Episode[]): number {
|
|
42
|
+
if (episodes.length === 0) return 0;
|
|
43
|
+
const totalHops = episodes.reduce((sum, ep) => sum + ep.trajectory.length, 0);
|
|
44
|
+
return totalHops / episodes.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function computeAvgReward(episodes: Episode[]): number {
|
|
48
|
+
const rewarded = episodes.filter((ep) => ep.reward !== null);
|
|
49
|
+
if (rewarded.length === 0) return 0;
|
|
50
|
+
const totalReward = rewarded.reduce((sum, ep) => sum + (ep.reward ?? 0), 0);
|
|
51
|
+
return totalReward / rewarded.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function computeChurn(
|
|
55
|
+
previous: HealthMetrics | null,
|
|
56
|
+
current: HealthMetrics,
|
|
57
|
+
): number {
|
|
58
|
+
if (!previous) return 0;
|
|
59
|
+
return Math.abs(current.avgReward - previous.avgReward)
|
|
60
|
+
+ Math.abs(current.firedPerQuery - previous.firedPerQuery) * 0.1
|
|
61
|
+
+ Math.abs(current.dormantPercent - previous.dormantPercent) * 0.1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function computeHealth(
|
|
65
|
+
graph: BrainGraph,
|
|
66
|
+
recentEpisodes: Episode[],
|
|
67
|
+
packVersion: number,
|
|
68
|
+
previousHealth?: HealthMetrics | null,
|
|
69
|
+
): HealthMetrics {
|
|
70
|
+
const nodesByKind = {} as Record<NodeKind, number>;
|
|
71
|
+
for (const kind of NODE_KINDS) nodesByKind[kind] = 0;
|
|
72
|
+
for (const node of graph.getAllNodes()) nodesByKind[node.kind]++;
|
|
73
|
+
|
|
74
|
+
const edgesByKind = {} as Record<EdgeKind, number>;
|
|
75
|
+
for (const kind of EDGE_KINDS) edgesByKind[kind] = 0;
|
|
76
|
+
for (const edge of graph.getAllEdges()) {
|
|
77
|
+
edgesByKind[edge.kind]++;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Cross-file edge percent: edges connecting nodes from different sourceUris
|
|
81
|
+
let crossFileCount = 0;
|
|
82
|
+
let totalEdgeCount = 0;
|
|
83
|
+
for (const edge of graph.getAllEdges()) {
|
|
84
|
+
totalEdgeCount++;
|
|
85
|
+
const sourceNode = graph.getNode(edge.source);
|
|
86
|
+
const targetNode = graph.getNode(edge.target);
|
|
87
|
+
if (sourceNode && targetNode && sourceNode.sourceUri !== targetNode.sourceUri) {
|
|
88
|
+
crossFileCount++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const health: HealthMetrics = {
|
|
93
|
+
nodeCount: graph.nodeCount(),
|
|
94
|
+
edgeCount: graph.edgeCount(),
|
|
95
|
+
nodesByKind,
|
|
96
|
+
edgesByKind,
|
|
97
|
+
firedPerQuery: computeFiredPerQuery(recentEpisodes),
|
|
98
|
+
dormantPercent: computeDormantPercent(graph, recentEpisodes),
|
|
99
|
+
inhibitoryPercent: computeInhibitoryPercent(graph),
|
|
100
|
+
orphanCount: graph.getOrphanNodes().length,
|
|
101
|
+
avgPathLength: computeAvgPathLength(recentEpisodes),
|
|
102
|
+
avgReward: computeAvgReward(recentEpisodes),
|
|
103
|
+
crossFileEdgePercent: totalEdgeCount > 0 ? crossFileCount / totalEdgeCount : 0,
|
|
104
|
+
churn: computeChurn(previousHealth ?? null, {
|
|
105
|
+
// Temporary partial health for churn computation
|
|
106
|
+
avgReward: computeAvgReward(recentEpisodes),
|
|
107
|
+
firedPerQuery: computeFiredPerQuery(recentEpisodes),
|
|
108
|
+
dormantPercent: computeDormantPercent(graph, recentEpisodes),
|
|
109
|
+
} as HealthMetrics),
|
|
110
|
+
packVersion,
|
|
111
|
+
lastUpdateAt: Date.now(),
|
|
112
|
+
totalEpisodes: recentEpisodes.length,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return health;
|
|
116
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural graph mutations: split, merge, prune, connect, inject.
|
|
3
|
+
*
|
|
4
|
+
* All mutations are proposals validated via replay gate before promotion.
|
|
5
|
+
* Mutations happen in the learned retrieval graph ONLY, never the LCM summary DAG.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import type { Episode, MutationProposal, BrainNode } from "./types.js";
|
|
10
|
+
import type { BrainGraph } from "./graph.js";
|
|
11
|
+
import { cosineSimilarity } from "./graph.js";
|
|
12
|
+
|
|
13
|
+
export interface BrainMutationPersistence {
|
|
14
|
+
insertNode(node: BrainNode): void;
|
|
15
|
+
insertEdge(edge: {
|
|
16
|
+
source: string;
|
|
17
|
+
target: string;
|
|
18
|
+
kind: "learned";
|
|
19
|
+
weight: number;
|
|
20
|
+
prior: number;
|
|
21
|
+
metadata: Record<string, unknown>;
|
|
22
|
+
decayedAt: number;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
}): void;
|
|
25
|
+
deleteNode(id: string): void;
|
|
26
|
+
deleteEdge(source: string, target: string, kind: string): void;
|
|
27
|
+
resolveMutation(id: string, status: "promoted" | "rejected"): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class BrainMutator {
|
|
31
|
+
constructor(
|
|
32
|
+
private persistence: BrainMutationPersistence,
|
|
33
|
+
private graph: BrainGraph,
|
|
34
|
+
private log: { info: (msg: string) => void },
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Propose mutations based on episode patterns.
|
|
39
|
+
* Called periodically by the trainer.
|
|
40
|
+
*/
|
|
41
|
+
proposeMutations(recentEpisodes: Episode[]): MutationProposal[] {
|
|
42
|
+
const proposals: MutationProposal[] = [];
|
|
43
|
+
|
|
44
|
+
proposals.push(...this.proposePrunes());
|
|
45
|
+
proposals.push(...this.proposeConnections(recentEpisodes));
|
|
46
|
+
proposals.push(...this.proposeInjects(recentEpisodes));
|
|
47
|
+
|
|
48
|
+
return proposals;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Prune: edges dormant across many episodes.
|
|
53
|
+
* Signal: weight has decayed near zero and no recent traversal.
|
|
54
|
+
*/
|
|
55
|
+
private proposePrunes(): MutationProposal[] {
|
|
56
|
+
const proposals: MutationProposal[] = [];
|
|
57
|
+
|
|
58
|
+
for (const node of this.graph.getAllNodes()) {
|
|
59
|
+
for (const edge of this.graph.getOutgoingEdges(node.id)) {
|
|
60
|
+
if (edge.kind === "sibling" || edge.kind === "bridge") continue;
|
|
61
|
+
if (Math.abs(edge.weight) < 0.05 && Math.abs(edge.weight - edge.prior) < 0.05) {
|
|
62
|
+
proposals.push({
|
|
63
|
+
id: `bm_${randomUUID().slice(0, 8)}`,
|
|
64
|
+
kind: "prune",
|
|
65
|
+
proposal: { source: edge.source, target: edge.target, edgeKind: edge.kind, weight: edge.weight },
|
|
66
|
+
evidence: null,
|
|
67
|
+
expectedGain: 0.01,
|
|
68
|
+
status: "pending",
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
resolvedAt: null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return proposals.slice(0, 5); // Limit per tick
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Connect: successful episodes repeatedly bridge two regions.
|
|
81
|
+
* Signal: nodes co-fire frequently but have no direct edge.
|
|
82
|
+
*/
|
|
83
|
+
private proposeConnections(episodes: Episode[]): MutationProposal[] {
|
|
84
|
+
const coFiring = new Map<string, number>();
|
|
85
|
+
|
|
86
|
+
for (const ep of episodes) {
|
|
87
|
+
if (ep.reward === null || ep.reward < 0.3) continue;
|
|
88
|
+
for (let i = 0; i < ep.firedNodes.length; i++) {
|
|
89
|
+
for (let j = i + 1; j < ep.firedNodes.length; j++) {
|
|
90
|
+
const key = [ep.firedNodes[i], ep.firedNodes[j]].sort().join("↔");
|
|
91
|
+
coFiring.set(key, (coFiring.get(key) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const proposals: MutationProposal[] = [];
|
|
97
|
+
for (const [key, count] of coFiring) {
|
|
98
|
+
if (count < 3) continue;
|
|
99
|
+
const [a, b] = key.split("↔");
|
|
100
|
+
if (this.graph.getEdge(a, b) || this.graph.getEdge(b, a)) continue;
|
|
101
|
+
|
|
102
|
+
proposals.push({
|
|
103
|
+
id: `bm_${randomUUID().slice(0, 8)}`,
|
|
104
|
+
kind: "connect",
|
|
105
|
+
proposal: { nodeA: a, nodeB: b, coFireCount: count },
|
|
106
|
+
evidence: { episodeCount: count },
|
|
107
|
+
expectedGain: count * 0.05,
|
|
108
|
+
status: "pending",
|
|
109
|
+
createdAt: Date.now(),
|
|
110
|
+
resolvedAt: null,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return proposals.slice(0, 3);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private proposeInjects(episodes: Episode[]): MutationProposal[] {
|
|
118
|
+
const proposals: MutationProposal[] = [];
|
|
119
|
+
const seenQueries = new Set<string>();
|
|
120
|
+
|
|
121
|
+
for (const episode of episodes) {
|
|
122
|
+
if (episode.reward === null || episode.reward < 0.6) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (seenQueries.has(episode.queryText)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
seenQueries.add(episode.queryText);
|
|
129
|
+
if (episode.firedNodes.length < 2) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
proposals.push({
|
|
134
|
+
id: `bm_${randomUUID().slice(0, 8)}`,
|
|
135
|
+
kind: "inject",
|
|
136
|
+
proposal: {
|
|
137
|
+
nodeKind: "episode_anchor",
|
|
138
|
+
content: episode.queryText,
|
|
139
|
+
firedNodes: episode.firedNodes,
|
|
140
|
+
},
|
|
141
|
+
evidence: { episodeId: episode.id, reward: episode.reward },
|
|
142
|
+
expectedGain: 0.05,
|
|
143
|
+
status: "pending",
|
|
144
|
+
createdAt: Date.now(),
|
|
145
|
+
resolvedAt: null,
|
|
146
|
+
});
|
|
147
|
+
if (proposals.length >= 3) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return proposals;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Apply a validated mutation to the graph and store.
|
|
157
|
+
*/
|
|
158
|
+
private applyMutationToGraph(targetGraph: BrainGraph, proposal: MutationProposal): {
|
|
159
|
+
insertedEdges?: Array<{
|
|
160
|
+
source: string;
|
|
161
|
+
target: string;
|
|
162
|
+
kind: "learned";
|
|
163
|
+
weight: number;
|
|
164
|
+
prior: number;
|
|
165
|
+
metadata: Record<string, unknown>;
|
|
166
|
+
decayedAt: number;
|
|
167
|
+
createdAt: number;
|
|
168
|
+
}>;
|
|
169
|
+
deletedEdges?: Array<{ source: string; target: string; kind: string }>;
|
|
170
|
+
deletedNodes?: string[];
|
|
171
|
+
insertedNodes?: BrainNode[];
|
|
172
|
+
} {
|
|
173
|
+
const p = proposal.proposal as Record<string, unknown>;
|
|
174
|
+
const result: {
|
|
175
|
+
insertedEdges?: Array<{
|
|
176
|
+
source: string;
|
|
177
|
+
target: string;
|
|
178
|
+
kind: "learned";
|
|
179
|
+
weight: number;
|
|
180
|
+
prior: number;
|
|
181
|
+
metadata: Record<string, unknown>;
|
|
182
|
+
decayedAt: number;
|
|
183
|
+
createdAt: number;
|
|
184
|
+
}>;
|
|
185
|
+
deletedEdges?: Array<{ source: string; target: string; kind: string }>;
|
|
186
|
+
deletedNodes?: string[];
|
|
187
|
+
insertedNodes?: BrainNode[];
|
|
188
|
+
} = {};
|
|
189
|
+
|
|
190
|
+
switch (proposal.kind) {
|
|
191
|
+
case "prune": {
|
|
192
|
+
targetGraph.removeEdge(p.source as string, p.target as string, p.edgeKind as any);
|
|
193
|
+
result.deletedEdges = [{
|
|
194
|
+
source: p.source as string,
|
|
195
|
+
target: p.target as string,
|
|
196
|
+
kind: p.edgeKind as string,
|
|
197
|
+
}];
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "connect": {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const edge = {
|
|
203
|
+
source: p.nodeA as string,
|
|
204
|
+
target: p.nodeB as string,
|
|
205
|
+
kind: "learned" as const,
|
|
206
|
+
weight: 0.5,
|
|
207
|
+
prior: 0.5,
|
|
208
|
+
metadata: { mutationId: proposal.id },
|
|
209
|
+
decayedAt: now,
|
|
210
|
+
createdAt: now,
|
|
211
|
+
};
|
|
212
|
+
targetGraph.addEdge(edge);
|
|
213
|
+
result.insertedEdges = [edge];
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "inject": {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const node: BrainNode = {
|
|
219
|
+
id: `bn_${randomUUID().slice(0, 12)}`,
|
|
220
|
+
kind: "episode_anchor",
|
|
221
|
+
content: String(p.content ?? ""),
|
|
222
|
+
embedding: null,
|
|
223
|
+
sourceUri: null,
|
|
224
|
+
trust: "scanner",
|
|
225
|
+
tags: ["episode-anchor"],
|
|
226
|
+
tokenCount: Math.ceil(String(p.content ?? "").length / 4),
|
|
227
|
+
metadata: {
|
|
228
|
+
mutationId: proposal.id,
|
|
229
|
+
firedNodes: Array.isArray(p.firedNodes) ? p.firedNodes : [],
|
|
230
|
+
},
|
|
231
|
+
createdAt: now,
|
|
232
|
+
updatedAt: now,
|
|
233
|
+
};
|
|
234
|
+
targetGraph.addNode(node);
|
|
235
|
+
result.insertedNodes = [node];
|
|
236
|
+
|
|
237
|
+
const firedNodes = Array.isArray(p.firedNodes) ? p.firedNodes.filter((value): value is string => typeof value === "string") : [];
|
|
238
|
+
result.insertedEdges = firedNodes.map((firedNodeId) => {
|
|
239
|
+
const edge = {
|
|
240
|
+
source: node.id,
|
|
241
|
+
target: firedNodeId,
|
|
242
|
+
kind: "learned" as const,
|
|
243
|
+
weight: 0.4,
|
|
244
|
+
prior: 0.4,
|
|
245
|
+
metadata: { mutationId: proposal.id },
|
|
246
|
+
decayedAt: now,
|
|
247
|
+
createdAt: now,
|
|
248
|
+
};
|
|
249
|
+
targetGraph.addEdge(edge);
|
|
250
|
+
return edge;
|
|
251
|
+
});
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
applyToCandidateGraph(targetGraph: BrainGraph, proposal: MutationProposal): void {
|
|
260
|
+
this.applyMutationToGraph(targetGraph, proposal);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
applyMutation(proposal: MutationProposal): void {
|
|
264
|
+
const result = this.applyMutationToGraph(this.graph, proposal);
|
|
265
|
+
for (const edge of result.insertedEdges ?? []) {
|
|
266
|
+
this.persistence.insertEdge(edge);
|
|
267
|
+
}
|
|
268
|
+
for (const node of result.insertedNodes ?? []) {
|
|
269
|
+
this.persistence.insertNode(node);
|
|
270
|
+
}
|
|
271
|
+
for (const edge of result.deletedEdges ?? []) {
|
|
272
|
+
this.persistence.deleteEdge(edge.source, edge.target, edge.kind);
|
|
273
|
+
}
|
|
274
|
+
for (const nodeId of result.deletedNodes ?? []) {
|
|
275
|
+
this.persistence.deleteNode(nodeId);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.persistence.resolveMutation(proposal.id, "promoted");
|
|
279
|
+
this.log.info(`[brain] Applied ${proposal.kind}: ${proposal.id}`);
|
|
280
|
+
}
|
|
281
|
+
}
|