kongbrain 0.1.4 → 0.2.1
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/package.json +1 -1
- package/src/causal.ts +1 -1
- package/src/context-engine.ts +127 -39
- package/src/daemon-manager.ts +16 -61
- package/src/deferred-cleanup.ts +45 -22
- package/src/embeddings.ts +4 -1
- package/src/hooks/llm-output.ts +1 -0
- package/src/index.ts +124 -49
- package/src/memory-daemon.ts +3 -2
- package/src/reflection.ts +12 -11
- package/src/schema.surql +3 -12
- package/src/skills.ts +2 -11
- package/src/soul.ts +2 -7
- package/src/state.ts +28 -3
- package/src/surreal.ts +59 -18
- package/src/tools/recall.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kongbrain",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Graph-backed persistent memory engine for OpenClaw. Replaces the default context window with SurrealDB + vector embeddings that learn across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/causal.ts
CHANGED
|
@@ -86,7 +86,7 @@ export async function linkCausalEdges(
|
|
|
86
86
|
// Store chain metadata
|
|
87
87
|
await store.queryExec(`CREATE causal_chain CONTENT $data`, {
|
|
88
88
|
data: {
|
|
89
|
-
session_id: sessionId,
|
|
89
|
+
session_id: String(sessionId),
|
|
90
90
|
trigger_memory: triggerId,
|
|
91
91
|
outcome_memory: outcomeId,
|
|
92
92
|
description_memory: descriptionId,
|
package/src/context-engine.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
12
|
+
import { startMemoryDaemon } from "./daemon-manager.js";
|
|
12
13
|
import type {
|
|
13
14
|
ContextEngine, ContextEngineInfo,
|
|
14
15
|
} from "openclaw/plugin-sdk";
|
|
@@ -45,6 +46,10 @@ import { evaluateRetrieval, getStagedItems } from "./retrieval-quality.js";
|
|
|
45
46
|
import { shouldRunCheck, runCognitiveCheck } from "./cognitive-check.js";
|
|
46
47
|
import { checkACANReadiness } from "./acan.js";
|
|
47
48
|
import { predictQueries, prefetchContext } from "./prefetch.js";
|
|
49
|
+
import { runDeferredCleanup } from "./deferred-cleanup.js";
|
|
50
|
+
import { extractSkill } from "./skills.js";
|
|
51
|
+
import { generateReflection } from "./reflection.js";
|
|
52
|
+
import { graduateCausalToSkills } from "./skills.js";
|
|
48
53
|
import { swallow } from "./errors.js";
|
|
49
54
|
|
|
50
55
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -87,32 +92,41 @@ export class KongBrainContextEngine implements ContextEngine {
|
|
|
87
92
|
const sessionKey = params.sessionKey ?? params.sessionId;
|
|
88
93
|
const session = this.state.getOrCreateSession(sessionKey, params.sessionId);
|
|
89
94
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
95
|
+
// Only create graph nodes on first bootstrap for this session
|
|
96
|
+
if (!session.surrealSessionId) {
|
|
97
|
+
try {
|
|
98
|
+
const workspace = this.state.workspaceDir || process.cwd();
|
|
99
|
+
const projectName = workspace.split("/").pop() || "default";
|
|
100
|
+
|
|
101
|
+
session.agentId = await store.ensureAgent("kongbrain", "openclaw-default");
|
|
102
|
+
session.projectId = await store.ensureProject(projectName);
|
|
103
|
+
await store.linkAgentToProject(session.agentId, session.projectId)
|
|
104
|
+
.catch(e => swallow.warn("bootstrap:linkAgentToProject", e));
|
|
105
|
+
|
|
106
|
+
session.taskId = await store.createTask(`Session in ${projectName}`);
|
|
107
|
+
await store.linkAgentToTask(session.agentId, session.taskId)
|
|
108
|
+
.catch(e => swallow.warn("bootstrap:linkAgentToTask", e));
|
|
109
|
+
await store.linkTaskToProject(session.taskId, session.projectId)
|
|
110
|
+
.catch(e => swallow.warn("bootstrap:linkTaskToProject", e));
|
|
111
|
+
|
|
112
|
+
const surrealSessionId = await store.createSession(session.agentId);
|
|
113
|
+
await store.markSessionActive(surrealSessionId)
|
|
114
|
+
.catch(e => swallow.warn("bootstrap:markActive", e));
|
|
115
|
+
await store.linkSessionToTask(surrealSessionId, session.taskId)
|
|
116
|
+
.catch(e => swallow.warn("bootstrap:linkSessionToTask", e));
|
|
117
|
+
|
|
118
|
+
session.surrealSessionId = surrealSessionId;
|
|
119
|
+
session.lastUserTurnId = "";
|
|
120
|
+
|
|
121
|
+
// Start memory daemon for this session
|
|
122
|
+
if (!session.daemon) {
|
|
123
|
+
session.daemon = startMemoryDaemon(
|
|
124
|
+
store, embeddings, session.sessionId, this.state.complete,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
swallow.error("bootstrap:5pillar", e);
|
|
129
|
+
}
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
// Background maintenance (non-blocking)
|
|
@@ -122,6 +136,7 @@ export class KongBrainContextEngine implements ContextEngine {
|
|
|
122
136
|
store.consolidateMemories((text) => embeddings.embed(text)),
|
|
123
137
|
store.garbageCollectMemories(),
|
|
124
138
|
checkACANReadiness(store),
|
|
139
|
+
// Deferred cleanup is triggered on first afterTurn() when complete() is available
|
|
125
140
|
]).catch(e => swallow.warn("bootstrap:maintenance", e));
|
|
126
141
|
|
|
127
142
|
return { bootstrapped: true };
|
|
@@ -154,11 +169,12 @@ export class KongBrainContextEngine implements ContextEngine {
|
|
|
154
169
|
// Build system prompt additions
|
|
155
170
|
const additions: string[] = [];
|
|
156
171
|
|
|
157
|
-
// Wakeup briefing (synthesized at session start)
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
delete (session as any).
|
|
172
|
+
// Wakeup briefing (synthesized at session start, may still be in-flight)
|
|
173
|
+
const wakeupPromise = (session as any)._wakeupPromise as Promise<string | null> | undefined;
|
|
174
|
+
if (wakeupPromise) {
|
|
175
|
+
const wakeupBriefing = await wakeupPromise;
|
|
176
|
+
delete (session as any)._wakeupPromise; // Only inject once
|
|
177
|
+
if (wakeupBriefing) additions.push(wakeupBriefing);
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
// Graduation celebration — tell the agent it just graduated so it can share with the user
|
|
@@ -340,7 +356,13 @@ export class KongBrainContextEngine implements ContextEngine {
|
|
|
340
356
|
const session = this.state.getSession(sessionKey);
|
|
341
357
|
if (!session) return;
|
|
342
358
|
|
|
343
|
-
const { store } = this.state;
|
|
359
|
+
const { store, embeddings } = this.state;
|
|
360
|
+
|
|
361
|
+
// Deferred cleanup: run once on first turn when complete() is available
|
|
362
|
+
if (session.userTurnCount <= 1 && typeof this.state.complete === "function") {
|
|
363
|
+
runDeferredCleanup(store, embeddings, this.state.complete)
|
|
364
|
+
.catch(e => swallow.warn("afterTurn:deferredCleanup", e));
|
|
365
|
+
}
|
|
344
366
|
|
|
345
367
|
// Ingest new messages from this turn (OpenClaw skips ingest() when afterTurn exists)
|
|
346
368
|
const newMessages = params.messages.slice(params.prePromptMessageCount);
|
|
@@ -381,11 +403,6 @@ export class KongBrainContextEngine implements ContextEngine {
|
|
|
381
403
|
}, session, store, this.state.complete).catch(e => swallow.warn("afterTurn:cognitiveCheck", e));
|
|
382
404
|
}
|
|
383
405
|
|
|
384
|
-
// Daemon batching — accumulate content tokens and flush when threshold met
|
|
385
|
-
if (session.lastAssistantText && hasSemantic(session.lastAssistantText)) {
|
|
386
|
-
session.newContentTokens += Math.ceil(session.lastAssistantText.length / 4);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
406
|
// Flush to daemon when token threshold OR turn count threshold is reached
|
|
390
407
|
const tokenReady = session.newContentTokens >= session.DAEMON_TOKEN_THRESHOLD;
|
|
391
408
|
const turnReady = session.userTurnCount >= session.lastDaemonFlushTurnCount + 3;
|
|
@@ -417,13 +434,84 @@ export class KongBrainContextEngine implements ContextEngine {
|
|
|
417
434
|
swallow.warn("afterTurn:daemonBatch", e);
|
|
418
435
|
}
|
|
419
436
|
}
|
|
437
|
+
|
|
438
|
+
// Mid-session cleanup: simulate session_end after ~100k tokens.
|
|
439
|
+
// OpenClaw exits via Ctrl+C×2 (no async window), so session_end never fires.
|
|
440
|
+
// Run reflection, skill extraction, and causal graduation periodically.
|
|
441
|
+
const tokensSinceCleanup = session.cumulativeTokens - session.lastCleanupTokens;
|
|
442
|
+
if (tokensSinceCleanup >= session.MID_SESSION_CLEANUP_THRESHOLD && typeof this.state.complete === "function") {
|
|
443
|
+
session.lastCleanupTokens = session.cumulativeTokens;
|
|
444
|
+
|
|
445
|
+
// Fire-and-forget: these are non-critical background operations
|
|
446
|
+
const cleanupOps: Promise<unknown>[] = [];
|
|
447
|
+
|
|
448
|
+
// Final daemon flush with full transcript before cleanup
|
|
449
|
+
if (session.daemon) {
|
|
450
|
+
cleanupOps.push(
|
|
451
|
+
store.getSessionTurns(session.sessionId, 50)
|
|
452
|
+
.then(recentTurns => {
|
|
453
|
+
const turnData = recentTurns.map(t => ({
|
|
454
|
+
role: t.role as "user" | "assistant",
|
|
455
|
+
text: t.text,
|
|
456
|
+
turnId: (t as any).id,
|
|
457
|
+
}));
|
|
458
|
+
session.daemon!.sendTurnBatch(turnData, [...session.pendingThinking], []);
|
|
459
|
+
})
|
|
460
|
+
.catch(e => swallow.warn("midCleanup:daemonFlush", e)),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (session.taskId) {
|
|
465
|
+
cleanupOps.push(
|
|
466
|
+
extractSkill(session.sessionId, session.taskId, store, embeddings, this.state.complete)
|
|
467
|
+
.catch(e => swallow.warn("midCleanup:extractSkill", e)),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
cleanupOps.push(
|
|
472
|
+
generateReflection(session.sessionId, store, embeddings, this.state.complete)
|
|
473
|
+
.catch(e => swallow.warn("midCleanup:reflection", e)),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
cleanupOps.push(
|
|
477
|
+
graduateCausalToSkills(store, embeddings, this.state.complete)
|
|
478
|
+
.catch(e => swallow.warn("midCleanup:graduateCausal", e)),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// Handoff note — snapshot for wakeup even if session continues
|
|
482
|
+
cleanupOps.push(
|
|
483
|
+
(async () => {
|
|
484
|
+
const recentTurns = await store.getSessionTurns(session.sessionId, 15);
|
|
485
|
+
if (recentTurns.length < 2) return;
|
|
486
|
+
const turnSummary = recentTurns
|
|
487
|
+
.map(t => `[${t.role}] ${t.text.slice(0, 200)}`)
|
|
488
|
+
.join("\n");
|
|
489
|
+
const handoffResponse = await this.state.complete({
|
|
490
|
+
system: "Summarize this session for handoff to your next self. What was worked on, what's unfinished, what to remember. 2-3 sentences. Write in first person.",
|
|
491
|
+
messages: [{ role: "user", content: turnSummary }],
|
|
492
|
+
});
|
|
493
|
+
const handoffText = handoffResponse.text.trim();
|
|
494
|
+
if (handoffText.length > 20) {
|
|
495
|
+
let embedding: number[] | null = null;
|
|
496
|
+
if (embeddings.isAvailable()) {
|
|
497
|
+
try { embedding = await embeddings.embed(handoffText); } catch { /* ok */ }
|
|
498
|
+
}
|
|
499
|
+
await store.createMemory(handoffText, embedding, 8, "handoff", session.sessionId);
|
|
500
|
+
}
|
|
501
|
+
})().catch(e => swallow.warn("midCleanup:handoff", e)),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Don't await — let cleanup run in background
|
|
505
|
+
Promise.allSettled(cleanupOps).catch(() => {});
|
|
506
|
+
}
|
|
420
507
|
}
|
|
421
508
|
|
|
422
509
|
// ── Dispose ────────────────────────────────────────────────────────────
|
|
423
510
|
|
|
424
511
|
async dispose(): Promise<void> {
|
|
425
|
-
//
|
|
426
|
-
|
|
512
|
+
// No-op: global state (store, embeddings, sessions) is shared across
|
|
513
|
+
// context engine instances and must NOT be destroyed here. OpenClaw
|
|
514
|
+
// creates a new context engine per turn and disposes the old one.
|
|
427
515
|
}
|
|
428
516
|
}
|
|
429
517
|
|
package/src/daemon-manager.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* The extraction is I/O-bound (LLM calls + DB writes), not CPU-bound,
|
|
8
8
|
* so in-process execution is fine.
|
|
9
9
|
*/
|
|
10
|
-
import type { SurrealConfig, EmbeddingConfig } from "./config.js";
|
|
11
10
|
import type { TurnData, PriorExtractions } from "./daemon-types.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
11
|
+
import type { CompleteFn } from "./state.js";
|
|
12
|
+
import type { SurrealStore } from "./surreal.js";
|
|
13
|
+
import type { EmbeddingService } from "./embeddings.js";
|
|
14
14
|
import { swallow } from "./errors.js";
|
|
15
15
|
|
|
16
16
|
export type { TurnData } from "./daemon-types.js";
|
|
@@ -32,16 +32,14 @@ export interface MemoryDaemon {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function startMemoryDaemon(
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
sharedStore: SurrealStore,
|
|
36
|
+
sharedEmbeddings: EmbeddingService,
|
|
37
37
|
sessionId: string,
|
|
38
|
-
|
|
38
|
+
complete: CompleteFn,
|
|
39
39
|
): MemoryDaemon {
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
let initialized = false;
|
|
44
|
-
let initFailed = false;
|
|
40
|
+
// Use shared store/embeddings from global state (no duplicate connections)
|
|
41
|
+
const store = sharedStore;
|
|
42
|
+
const embeddings = sharedEmbeddings;
|
|
45
43
|
let processing = false;
|
|
46
44
|
let shuttingDown = false;
|
|
47
45
|
let extractedTurnCount = 0;
|
|
@@ -51,24 +49,6 @@ export function startMemoryDaemon(
|
|
|
51
49
|
conceptNames: [], artifactPaths: [], skillNames: [],
|
|
52
50
|
};
|
|
53
51
|
|
|
54
|
-
// Lazy init — connect on first batch, not at startup
|
|
55
|
-
async function ensureInit(): Promise<boolean> {
|
|
56
|
-
if (initialized) return true;
|
|
57
|
-
if (initFailed) return false;
|
|
58
|
-
try {
|
|
59
|
-
store = new SurrealStore(surrealConfig);
|
|
60
|
-
await store.initialize();
|
|
61
|
-
embeddings = new EmbeddingService(embeddingConfig);
|
|
62
|
-
await embeddings.initialize();
|
|
63
|
-
initialized = true;
|
|
64
|
-
return true;
|
|
65
|
-
} catch (e) {
|
|
66
|
-
swallow.warn("daemon:init", e);
|
|
67
|
-
initFailed = true;
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
52
|
// Import extraction logic lazily to avoid circular deps
|
|
73
53
|
async function runExtraction(
|
|
74
54
|
turns: TurnData[],
|
|
@@ -79,13 +59,6 @@ export function startMemoryDaemon(
|
|
|
79
59
|
if (!store || !embeddings) return;
|
|
80
60
|
if (turns.length < 2) return;
|
|
81
61
|
|
|
82
|
-
const provider = llmConfig?.provider;
|
|
83
|
-
const modelId = llmConfig?.model;
|
|
84
|
-
if (!provider || !modelId) {
|
|
85
|
-
swallow.warn("daemon:extraction", new Error("Missing llmProvider/llmModel"));
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
62
|
// Merge incoming prior state
|
|
90
63
|
if (incomingPrior) {
|
|
91
64
|
for (const name of incomingPrior.conceptNames) {
|
|
@@ -116,22 +89,12 @@ export function startMemoryDaemon(
|
|
|
116
89
|
|
|
117
90
|
const systemPrompt = buildSystemPrompt(thinking.length > 0, retrievedMemories.length > 0, priorState);
|
|
118
91
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const response = await completeSimple(model, {
|
|
123
|
-
systemPrompt,
|
|
124
|
-
messages: [{
|
|
125
|
-
role: "user",
|
|
126
|
-
timestamp: Date.now(),
|
|
127
|
-
content: sections.join("\n\n"),
|
|
128
|
-
}],
|
|
92
|
+
const response = await complete({
|
|
93
|
+
system: systemPrompt,
|
|
94
|
+
messages: [{ role: "user", content: sections.join("\n\n") }],
|
|
129
95
|
});
|
|
130
96
|
|
|
131
|
-
const responseText = response.
|
|
132
|
-
.filter((c: any) => c.type === "text")
|
|
133
|
-
.map((c: any) => c.text)
|
|
134
|
-
.join("");
|
|
97
|
+
const responseText = response.text;
|
|
135
98
|
|
|
136
99
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
|
137
100
|
if (!jsonMatch) return;
|
|
@@ -188,10 +151,8 @@ export function startMemoryDaemon(
|
|
|
188
151
|
sendTurnBatch(turns, thinking, retrievedMemories, priorExtractions) {
|
|
189
152
|
if (shuttingDown) return;
|
|
190
153
|
pendingBatch = { turns, thinking, retrievedMemories, priorExtractions };
|
|
191
|
-
// Fire-and-forget
|
|
192
|
-
|
|
193
|
-
.then(ok => { if (ok) return processPending(); })
|
|
194
|
-
.catch(e => swallow.warn("daemon:sendBatch", e));
|
|
154
|
+
// Fire-and-forget
|
|
155
|
+
processPending().catch(e => swallow.warn("daemon:sendBatch", e));
|
|
195
156
|
},
|
|
196
157
|
|
|
197
158
|
async getStatus() {
|
|
@@ -216,13 +177,7 @@ export function startMemoryDaemon(
|
|
|
216
177
|
new Promise<void>(resolve => setTimeout(resolve, timeoutMs)),
|
|
217
178
|
]);
|
|
218
179
|
}
|
|
219
|
-
//
|
|
220
|
-
await Promise.allSettled([
|
|
221
|
-
store?.dispose(),
|
|
222
|
-
embeddings?.dispose(),
|
|
223
|
-
]).catch(() => {});
|
|
224
|
-
store = null;
|
|
225
|
-
embeddings = null;
|
|
180
|
+
// Shared store/embeddings — don't dispose (owned by global state)
|
|
226
181
|
},
|
|
227
182
|
|
|
228
183
|
getExtractedTurnCount() {
|
package/src/deferred-cleanup.ts
CHANGED
|
@@ -15,20 +15,49 @@ import { buildSystemPrompt, buildTranscript, writeExtractionResults } from "./me
|
|
|
15
15
|
import type { PriorExtractions } from "./daemon-types.js";
|
|
16
16
|
import { swallow } from "./errors.js";
|
|
17
17
|
|
|
18
|
+
// Process-global flag — deferred cleanup runs AT MOST ONCE per process.
|
|
19
|
+
// Using Symbol.for so it survives Jiti re-importing this module.
|
|
20
|
+
const RAN_KEY = Symbol.for("kongbrain.deferredCleanup.ran");
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* Find and process orphaned sessions. Runs with a 30s total timeout.
|
|
20
24
|
* Fire-and-forget from session_start — does not block the new session.
|
|
25
|
+
* Only runs once per process lifetime.
|
|
21
26
|
*/
|
|
22
27
|
export async function runDeferredCleanup(
|
|
23
28
|
store: SurrealStore,
|
|
24
29
|
embeddings: EmbeddingService,
|
|
25
30
|
complete: CompleteFn,
|
|
31
|
+
): Promise<number> {
|
|
32
|
+
// Once per process — never re-run even if first run times out
|
|
33
|
+
if ((globalThis as any)[RAN_KEY]) return 0;
|
|
34
|
+
(globalThis as any)[RAN_KEY] = true;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
return await runDeferredCleanupInner(store, embeddings, complete);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
swallow.warn("deferredCleanup:outer", e);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runDeferredCleanupInner(
|
|
45
|
+
store: SurrealStore,
|
|
46
|
+
embeddings: EmbeddingService,
|
|
47
|
+
complete: CompleteFn,
|
|
26
48
|
): Promise<number> {
|
|
27
49
|
if (!store.isAvailable()) return 0;
|
|
28
50
|
|
|
29
|
-
const orphaned = await store.getOrphanedSessions(
|
|
51
|
+
const orphaned = await store.getOrphanedSessions(10).catch(() => []);
|
|
30
52
|
if (orphaned.length === 0) return 0;
|
|
31
53
|
|
|
54
|
+
// Immediately claim all orphaned sessions so no concurrent run can pick them up
|
|
55
|
+
await Promise.all(
|
|
56
|
+
orphaned.map(s =>
|
|
57
|
+
store.markSessionEnded(s.id).catch(e => swallow("deferred:claim", e))
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
|
|
32
61
|
let processed = 0;
|
|
33
62
|
|
|
34
63
|
const cleanup = async () => {
|
|
@@ -42,10 +71,10 @@ export async function runDeferredCleanup(
|
|
|
42
71
|
}
|
|
43
72
|
};
|
|
44
73
|
|
|
45
|
-
//
|
|
74
|
+
// 90s timeout — each session needs ~6s (2 LLM calls), 10 sessions ≈ 60s
|
|
46
75
|
await Promise.race([
|
|
47
76
|
cleanup(),
|
|
48
|
-
new Promise<void>(resolve => setTimeout(resolve,
|
|
77
|
+
new Promise<void>(resolve => setTimeout(resolve, 90_000)),
|
|
49
78
|
]);
|
|
50
79
|
|
|
51
80
|
return processed;
|
|
@@ -57,25 +86,15 @@ async function processOrphanedSession(
|
|
|
57
86
|
embeddings: EmbeddingService,
|
|
58
87
|
complete: CompleteFn,
|
|
59
88
|
): Promise<void> {
|
|
60
|
-
//
|
|
61
|
-
// (turns use the OpenClaw session_id, not the surreal record ID)
|
|
62
|
-
const sessionTurns = await store.queryFirst<{ session_id: string }>(
|
|
63
|
-
`SELECT session_id FROM turn WHERE session_id != NONE ORDER BY created_at DESC LIMIT 1`,
|
|
64
|
-
).catch(() => []);
|
|
65
|
-
|
|
66
|
-
// Load turns for extraction
|
|
67
|
-
// We need to find turns associated with this DB session via the part_of edge
|
|
89
|
+
// Load turns for extraction via part_of edges (turn->part_of->session)
|
|
68
90
|
const turns = await store.queryFirst<{ role: string; text: string; tool_name?: string }>(
|
|
69
|
-
`SELECT role, text, tool_name FROM turn
|
|
70
|
-
WHERE
|
|
71
|
-
OR session_id = $sid
|
|
91
|
+
`SELECT role, text, tool_name, created_at FROM turn
|
|
92
|
+
WHERE id IN (SELECT VALUE in FROM part_of WHERE out = $sid)
|
|
72
93
|
ORDER BY created_at ASC LIMIT 50`,
|
|
73
94
|
{ sid: surrealSessionId },
|
|
74
95
|
).catch(() => []);
|
|
75
96
|
|
|
76
97
|
if (turns.length < 2) {
|
|
77
|
-
// Nothing to extract, just mark complete
|
|
78
|
-
await store.markSessionEnded(surrealSessionId).catch(e => swallow("deferred:markEmpty", e));
|
|
79
98
|
return;
|
|
80
99
|
}
|
|
81
100
|
|
|
@@ -86,12 +105,14 @@ async function processOrphanedSession(
|
|
|
86
105
|
const systemPrompt = buildSystemPrompt(false, false, priorState);
|
|
87
106
|
|
|
88
107
|
try {
|
|
108
|
+
console.warn(`[deferred] extracting session ${surrealSessionId} (${turns.length} turns, transcript ${transcript.length} chars)`);
|
|
89
109
|
const response = await complete({
|
|
90
110
|
system: systemPrompt,
|
|
91
111
|
messages: [{ role: "user", content: `[TRANSCRIPT]\n${transcript.slice(0, 60000)}` }],
|
|
92
112
|
});
|
|
93
113
|
|
|
94
114
|
const responseText = response.text;
|
|
115
|
+
console.warn(`[deferred] extraction response: ${responseText.length} chars`);
|
|
95
116
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
|
96
117
|
if (jsonMatch) {
|
|
97
118
|
let result: Record<string, any>;
|
|
@@ -103,10 +124,14 @@ async function processOrphanedSession(
|
|
|
103
124
|
} catch { result = {}; }
|
|
104
125
|
}
|
|
105
126
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
const keys = Object.keys(result);
|
|
128
|
+
console.warn(`[deferred] parsed ${keys.length} keys: ${keys.join(", ")}`);
|
|
129
|
+
if (keys.length > 0) {
|
|
130
|
+
await writeExtractionResults(result, surrealSessionId, store, embeddings, priorState);
|
|
131
|
+
console.warn(`[deferred] wrote extraction results for ${surrealSessionId}`);
|
|
109
132
|
}
|
|
133
|
+
} else {
|
|
134
|
+
console.warn(`[deferred] no JSON found in response`);
|
|
110
135
|
}
|
|
111
136
|
} catch (e) {
|
|
112
137
|
swallow.warn("deferredCleanup:extraction", e);
|
|
@@ -125,6 +150,7 @@ async function processOrphanedSession(
|
|
|
125
150
|
});
|
|
126
151
|
|
|
127
152
|
const handoffText = handoffResponse.text.trim();
|
|
153
|
+
console.warn(`[deferred] handoff response: ${handoffText.length} chars`);
|
|
128
154
|
if (handoffText.length > 20) {
|
|
129
155
|
let emb: number[] | null = null;
|
|
130
156
|
if (embeddings.isAvailable()) {
|
|
@@ -135,7 +161,4 @@ async function processOrphanedSession(
|
|
|
135
161
|
} catch (e) {
|
|
136
162
|
swallow.warn("deferredCleanup:handoff", e);
|
|
137
163
|
}
|
|
138
|
-
|
|
139
|
-
// Mark session as cleaned up
|
|
140
|
-
await store.markSessionEnded(surrealSessionId).catch(e => swallow("deferred:markDone", e));
|
|
141
164
|
}
|
package/src/embeddings.ts
CHANGED
|
@@ -15,7 +15,9 @@ export class EmbeddingService {
|
|
|
15
15
|
|
|
16
16
|
constructor(private readonly config: EmbeddingConfig) {}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** Initialize the embedding model. Returns true if freshly loaded, false if already ready. */
|
|
19
|
+
async initialize(): Promise<boolean> {
|
|
20
|
+
if (this.ready) return false;
|
|
19
21
|
if (!existsSync(this.config.modelPath)) {
|
|
20
22
|
throw new Error(
|
|
21
23
|
`Embedding model not found at: ${this.config.modelPath}\n Download BGE-M3 GGUF or set EMBED_MODEL_PATH`,
|
|
@@ -33,6 +35,7 @@ export class EmbeddingService {
|
|
|
33
35
|
this.model = await llama.loadModel({ modelPath: this.config.modelPath });
|
|
34
36
|
this.ctx = await this.model.createEmbeddingContext();
|
|
35
37
|
this.ready = true;
|
|
38
|
+
return true;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
async embed(text: string): Promise<number[]> {
|
package/src/hooks/llm-output.ts
CHANGED
|
@@ -50,6 +50,7 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
|
|
|
50
50
|
// Accumulate for daemon batching (only when real tokens present)
|
|
51
51
|
if (inputTokens + outputTokens > 0) {
|
|
52
52
|
session.newContentTokens += inputTokens + outputTokens;
|
|
53
|
+
session.cumulativeTokens += inputTokens + outputTokens;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
// Track accumulated text output for planning gate
|
package/src/index.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
|
-
import {
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
10
11
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
11
12
|
import { parsePluginConfig } from "./config.js";
|
|
12
13
|
import { SurrealStore } from "./surreal.js";
|
|
13
14
|
import { EmbeddingService } from "./embeddings.js";
|
|
14
|
-
import { GlobalPluginState } from "./state.js";
|
|
15
|
+
import { GlobalPluginState, type CompleteFn } from "./state.js";
|
|
15
16
|
import { KongBrainContextEngine } from "./context-engine.js";
|
|
16
17
|
import { createRecallToolDef } from "./tools/recall.js";
|
|
17
18
|
import { createCoreMemoryToolDef } from "./tools/core-memory.js";
|
|
@@ -22,7 +23,7 @@ import { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
|
|
|
22
23
|
import { createLlmOutputHandler } from "./hooks/llm-output.js";
|
|
23
24
|
import { startMemoryDaemon } from "./daemon-manager.js";
|
|
24
25
|
import { seedIdentity } from "./identity.js";
|
|
25
|
-
import { synthesizeWakeup
|
|
26
|
+
import { synthesizeWakeup } from "./wakeup.js";
|
|
26
27
|
import { extractSkill } from "./skills.js";
|
|
27
28
|
import { generateReflection, setReflectionContextWindow } from "./reflection.js";
|
|
28
29
|
import { graduateCausalToSkills } from "./skills.js";
|
|
@@ -32,11 +33,28 @@ import { writeHandoffFileSync } from "./handoff-file.js";
|
|
|
32
33
|
import { runDeferredCleanup } from "./deferred-cleanup.js";
|
|
33
34
|
import { swallow } from "./errors.js";
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// Use process-global symbols so state survives Jiti re-importing the module.
|
|
37
|
+
// Jiti may load this file multiple times (fresh module scope each time),
|
|
38
|
+
// but process.env and Symbol.for() are process-wide singletons.
|
|
39
|
+
const GLOBAL_KEY = Symbol.for("kongbrain.globalState");
|
|
40
|
+
const REGISTERED_KEY = Symbol.for("kongbrain.registered");
|
|
41
|
+
|
|
42
|
+
function getGlobalState(): GlobalPluginState | null {
|
|
43
|
+
return (globalThis as any)[GLOBAL_KEY] ?? null;
|
|
44
|
+
}
|
|
45
|
+
function setGlobalState(state: GlobalPluginState): void {
|
|
46
|
+
(globalThis as any)[GLOBAL_KEY] = state;
|
|
47
|
+
}
|
|
48
|
+
function isRegistered(): boolean {
|
|
49
|
+
return (globalThis as any)[REGISTERED_KEY] === true;
|
|
50
|
+
}
|
|
51
|
+
function markRegistered(): void {
|
|
52
|
+
(globalThis as any)[REGISTERED_KEY] = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
let shutdownPromise: Promise<void> | null = null;
|
|
37
56
|
let registeredExitHandler: (() => void) | null = null;
|
|
38
57
|
let registeredSyncExitHandler: (() => void) | null = null;
|
|
39
|
-
let registered = false;
|
|
40
58
|
|
|
41
59
|
/**
|
|
42
60
|
* Run the critical session-end extraction for all active sessions.
|
|
@@ -294,13 +312,79 @@ export default definePluginEntry({
|
|
|
294
312
|
const config = parsePluginConfig(api.pluginConfig as Record<string, unknown> | undefined);
|
|
295
313
|
const logger = api.logger;
|
|
296
314
|
|
|
297
|
-
// Initialize shared resources — reuse existing
|
|
298
|
-
// multiple times (OpenClaw may invoke the factory more than once
|
|
299
|
-
//
|
|
315
|
+
// Initialize shared resources — reuse existing state if register() is called
|
|
316
|
+
// multiple times (OpenClaw may invoke the factory more than once, and Jiti may
|
|
317
|
+
// re-import the module creating fresh module scope). Process-global symbols
|
|
318
|
+
// ensure a single instance survives across module reloads.
|
|
319
|
+
let globalState = getGlobalState();
|
|
300
320
|
if (!globalState) {
|
|
301
321
|
const store = new SurrealStore(config.surreal);
|
|
302
322
|
const embeddings = new EmbeddingService(config.embedding);
|
|
303
|
-
|
|
323
|
+
// Build a CompleteFn using pi-ai directly since api.runtime.complete
|
|
324
|
+
// is not available in OpenClaw 2026.3.24 (unreleased feature).
|
|
325
|
+
const apiRef = api;
|
|
326
|
+
// Resolve pi-ai from openclaw's node_modules. pi-ai is ESM-only so
|
|
327
|
+
// require() can't load it. Walk up from process.argv[1] to find it,
|
|
328
|
+
// then lazy-load via import() on first use.
|
|
329
|
+
let piAi: { getModel: any; completeSimple: any } | null = null;
|
|
330
|
+
let piAiPath: string | null = null;
|
|
331
|
+
{
|
|
332
|
+
let dir = dirname(process.argv[1] || __filename);
|
|
333
|
+
for (let i = 0; i < 10; i++) {
|
|
334
|
+
const candidate = join(dir, "node_modules", "@mariozechner", "pi-ai", "dist", "index.js");
|
|
335
|
+
if (existsSync(candidate)) { piAiPath = candidate; break; }
|
|
336
|
+
const parent = dirname(dir);
|
|
337
|
+
if (parent === dir) break;
|
|
338
|
+
dir = parent;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const complete: CompleteFn = async (params) => {
|
|
343
|
+
// Try runtime.complete first (future-proof for when it ships)
|
|
344
|
+
if (typeof apiRef.runtime?.complete === "function") {
|
|
345
|
+
return apiRef.runtime.complete(params);
|
|
346
|
+
}
|
|
347
|
+
if (!piAi) {
|
|
348
|
+
if (!piAiPath) {
|
|
349
|
+
throw new Error("LLM completion not available: @mariozechner/pi-ai not found and runtime.complete missing");
|
|
350
|
+
}
|
|
351
|
+
piAi = await import(piAiPath);
|
|
352
|
+
}
|
|
353
|
+
// Fall back to calling pi-ai directly (runtime.complete not in OpenClaw 2026.3.24)
|
|
354
|
+
const provider = params.provider ?? apiRef.runtime.agent.defaults.provider;
|
|
355
|
+
const modelId = params.model ?? apiRef.runtime.agent.defaults.model;
|
|
356
|
+
const model = piAi!.getModel(provider, modelId);
|
|
357
|
+
if (!model) {
|
|
358
|
+
throw new Error(`Model "${modelId}" not found for provider "${provider}"`);
|
|
359
|
+
}
|
|
360
|
+
// Resolve auth via OpenClaw's runtime (handles profiles, env vars, etc.)
|
|
361
|
+
const cfg = apiRef.runtime.config.loadConfig();
|
|
362
|
+
const auth = await apiRef.runtime.modelAuth.getApiKeyForModel({ model, cfg });
|
|
363
|
+
// Build context
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
const messages: any[] = params.messages.map(m =>
|
|
366
|
+
m.role === "user"
|
|
367
|
+
? { role: "user", content: m.content, timestamp: now }
|
|
368
|
+
: { role: "assistant", content: [{ type: "text", text: m.content }],
|
|
369
|
+
api: model.api, provider: model.provider, model: model.id,
|
|
370
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
371
|
+
stopReason: "stop", timestamp: now }
|
|
372
|
+
);
|
|
373
|
+
const context = { systemPrompt: params.system, messages };
|
|
374
|
+
// Pass apiKey directly in options so the provider can use it
|
|
375
|
+
const response = await piAi!.completeSimple(model, context, {
|
|
376
|
+
apiKey: auth.apiKey,
|
|
377
|
+
});
|
|
378
|
+
let text = "";
|
|
379
|
+
let thinking: string | undefined;
|
|
380
|
+
for (const block of response.content) {
|
|
381
|
+
if (block.type === "text") text += block.text;
|
|
382
|
+
else if ((block as any).type === "thinking") thinking = (thinking ?? "") + (block as any).thinking;
|
|
383
|
+
}
|
|
384
|
+
return { text, thinking, usage: { input: response.usage.input, output: response.usage.output } };
|
|
385
|
+
};
|
|
386
|
+
globalState = new GlobalPluginState(config, store, embeddings, complete);
|
|
387
|
+
setGlobalState(globalState);
|
|
304
388
|
}
|
|
305
389
|
globalState.workspaceDir = api.resolvePath(".");
|
|
306
390
|
globalState.enqueueSystemEvent = (text, opts) =>
|
|
@@ -312,19 +396,19 @@ export default definePluginEntry({
|
|
|
312
396
|
api.registerContextEngine("kongbrain", async () => {
|
|
313
397
|
const { store, embeddings } = state;
|
|
314
398
|
|
|
315
|
-
// Connect to SurrealDB
|
|
399
|
+
// Connect to SurrealDB (no-op if already connected)
|
|
316
400
|
try {
|
|
317
|
-
await store.initialize();
|
|
318
|
-
logger.info(`SurrealDB connected: ${config.surreal.url}`);
|
|
401
|
+
const freshConnect = await store.initialize();
|
|
402
|
+
if (freshConnect) logger.info(`SurrealDB connected: ${config.surreal.url}`);
|
|
319
403
|
} catch (e) {
|
|
320
404
|
logger.error(`SurrealDB connection failed: ${e}`);
|
|
321
405
|
throw e;
|
|
322
406
|
}
|
|
323
407
|
|
|
324
|
-
// Initialize BGE-M3 embeddings
|
|
408
|
+
// Initialize BGE-M3 embeddings (no-op if already loaded)
|
|
325
409
|
try {
|
|
326
|
-
await embeddings.initialize();
|
|
327
|
-
logger.info(`BGE-M3 embeddings initialized: ${config.embedding.modelPath}`);
|
|
410
|
+
const freshEmbed = await embeddings.initialize();
|
|
411
|
+
if (freshEmbed) logger.info(`BGE-M3 embeddings initialized: ${config.embedding.modelPath}`);
|
|
328
412
|
} catch (e) {
|
|
329
413
|
logger.warn(`Embeddings init failed — running in degraded mode: ${e}`);
|
|
330
414
|
}
|
|
@@ -339,7 +423,7 @@ export default definePluginEntry({
|
|
|
339
423
|
|
|
340
424
|
// ── Hook handlers (register once — register() may be called multiple times) ──
|
|
341
425
|
|
|
342
|
-
if (!
|
|
426
|
+
if (!isRegistered()) {
|
|
343
427
|
api.on("before_prompt_build", createBeforePromptBuildHandler(globalState));
|
|
344
428
|
api.on("before_tool_call", createBeforeToolCallHandler(globalState));
|
|
345
429
|
api.on("after_tool_call", createAfterToolCallHandler(globalState));
|
|
@@ -348,7 +432,8 @@ export default definePluginEntry({
|
|
|
348
432
|
|
|
349
433
|
// ── Session lifecycle (also register once) ─────────────────────────
|
|
350
434
|
|
|
351
|
-
if (!
|
|
435
|
+
if (!isRegistered()) api.on("session_start", async (event) => {
|
|
436
|
+
const globalState = getGlobalState();
|
|
352
437
|
if (!globalState) return;
|
|
353
438
|
const sessionKey = event.sessionKey ?? event.sessionId;
|
|
354
439
|
const session = globalState.getOrCreateSession(sessionKey, event.sessionId);
|
|
@@ -374,10 +459,10 @@ export default definePluginEntry({
|
|
|
374
459
|
// Start memory daemon worker thread
|
|
375
460
|
try {
|
|
376
461
|
session.daemon = startMemoryDaemon(
|
|
377
|
-
|
|
378
|
-
|
|
462
|
+
globalState!.store,
|
|
463
|
+
globalState!.embeddings,
|
|
379
464
|
session.sessionId,
|
|
380
|
-
|
|
465
|
+
globalState!.complete,
|
|
381
466
|
);
|
|
382
467
|
} catch (e) {
|
|
383
468
|
swallow.warn("index:startDaemon", e);
|
|
@@ -398,31 +483,22 @@ export default definePluginEntry({
|
|
|
398
483
|
setReflectionContextWindow(200000);
|
|
399
484
|
|
|
400
485
|
// Check for recent graduation event (from a previous session)
|
|
401
|
-
detectGraduationEvent(store, session, globalState!)
|
|
486
|
+
detectGraduationEvent(globalState!.store, session, globalState!)
|
|
402
487
|
.catch(e => swallow("index:graduationDetect", e));
|
|
403
488
|
|
|
404
|
-
// Synthesize wakeup briefing (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (briefing) (session as any)._wakeupBriefing = briefing;
|
|
409
|
-
})
|
|
410
|
-
.catch(e => swallow.warn("index:wakeup", e));
|
|
411
|
-
|
|
412
|
-
// Startup cognition (background)
|
|
413
|
-
synthesizeStartupCognition(store, globalState!.complete)
|
|
414
|
-
.then(cognition => {
|
|
415
|
-
if (cognition) (session as any)._startupCognition = cognition;
|
|
416
|
-
})
|
|
417
|
-
.catch(e => swallow.warn("index:startupCognition", e));
|
|
489
|
+
// Synthesize wakeup briefing — store the promise so assemble() can await it
|
|
490
|
+
(session as any)._wakeupPromise = synthesizeWakeup(
|
|
491
|
+
globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir,
|
|
492
|
+
).catch(e => { swallow.warn("index:wakeup", e); return null; });
|
|
418
493
|
|
|
419
494
|
// Deferred cleanup: extract knowledge from orphaned sessions (background)
|
|
420
|
-
runDeferredCleanup(store, embeddings, globalState!.complete)
|
|
495
|
+
runDeferredCleanup(globalState!.store, globalState!.embeddings, globalState!.complete)
|
|
421
496
|
.then(n => { if (n > 0) logger.info(`Deferred cleanup: processed ${n} orphaned session(s)`); })
|
|
422
497
|
.catch(e => swallow.warn("index:deferredCleanup", e));
|
|
423
498
|
});
|
|
424
499
|
|
|
425
|
-
if (!
|
|
500
|
+
if (!isRegistered()) api.on("session_end", async (event) => {
|
|
501
|
+
const globalState = getGlobalState();
|
|
426
502
|
if (!globalState) return;
|
|
427
503
|
const sessionKey = event.sessionKey ?? event.sessionId;
|
|
428
504
|
const session = globalState.getSession(sessionKey);
|
|
@@ -434,7 +510,7 @@ export default definePluginEntry({
|
|
|
434
510
|
|
|
435
511
|
session.cleanedUp = true;
|
|
436
512
|
if (session.surrealSessionId) {
|
|
437
|
-
await store.markSessionEnded(session.surrealSessionId)
|
|
513
|
+
await globalState.store.markSessionEnded(session.surrealSessionId)
|
|
438
514
|
.catch(e => swallow.warn("session_end:markEnded", e));
|
|
439
515
|
}
|
|
440
516
|
|
|
@@ -458,8 +534,9 @@ export default definePluginEntry({
|
|
|
458
534
|
|
|
459
535
|
// Sync exit handler: writes handoff file for all uncleaned sessions
|
|
460
536
|
const syncExitHandler = () => {
|
|
461
|
-
|
|
462
|
-
|
|
537
|
+
const gs = getGlobalState();
|
|
538
|
+
if (!gs?.workspaceDir) return;
|
|
539
|
+
const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
|
|
463
540
|
for (const session of sessions) {
|
|
464
541
|
if (session.cleanedUp) continue;
|
|
465
542
|
writeHandoffFileSync({
|
|
@@ -469,21 +546,22 @@ export default definePluginEntry({
|
|
|
469
546
|
lastUserText: session.lastUserText.slice(0, 500),
|
|
470
547
|
lastAssistantText: session.lastAssistantText.slice(0, 500),
|
|
471
548
|
unextractedTokens: session.newContentTokens,
|
|
472
|
-
},
|
|
549
|
+
}, gs.workspaceDir!);
|
|
473
550
|
}
|
|
474
551
|
};
|
|
475
552
|
|
|
476
553
|
// Async exit handler: full cleanup for SIGTERM (gateway/daemon mode)
|
|
477
554
|
const asyncExitHandler = () => {
|
|
478
|
-
|
|
479
|
-
|
|
555
|
+
const gs = getGlobalState();
|
|
556
|
+
if (!gs) return;
|
|
557
|
+
const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
|
|
480
558
|
if (sessions.length === 0 && !shutdownPromise) return;
|
|
481
559
|
|
|
482
|
-
const cleanups = sessions.map(s => runSessionCleanup(s,
|
|
560
|
+
const cleanups = sessions.map(s => runSessionCleanup(s, gs));
|
|
483
561
|
if (shutdownPromise) cleanups.push(shutdownPromise);
|
|
484
562
|
|
|
485
563
|
const done = Promise.allSettled(cleanups).then(() => {
|
|
486
|
-
|
|
564
|
+
gs.shutdown().catch(() => {});
|
|
487
565
|
});
|
|
488
566
|
|
|
489
567
|
done.then(() => process.exit(0)).catch(() => process.exit(1));
|
|
@@ -494,9 +572,6 @@ export default definePluginEntry({
|
|
|
494
572
|
process.on("exit", syncExitHandler);
|
|
495
573
|
process.once("SIGTERM", asyncExitHandler);
|
|
496
574
|
|
|
497
|
-
|
|
498
|
-
logger.info("KongBrain plugin registered");
|
|
499
|
-
registered = true;
|
|
500
|
-
}
|
|
575
|
+
markRegistered();
|
|
501
576
|
},
|
|
502
577
|
});
|
package/src/memory-daemon.ts
CHANGED
|
@@ -37,11 +37,12 @@ ${dedup}
|
|
|
37
37
|
// Only when there's a clear trigger and outcome. Max 5.
|
|
38
38
|
{"triggerText": "what caused it (max 200 chars)", "outcomeText": "what happened as a result", "chainType": "debug|refactor|feature|fix", "success": true/false, "confidence": 0.0-1.0, "description": "1-sentence summary"}
|
|
39
39
|
],
|
|
40
|
-
|
|
40
|
+
"monologue": [
|
|
41
41
|
// Internal reasoning moments worth preserving: doubts, tradeoffs, insights, realizations.
|
|
42
|
+
// Infer from the conversation flow — approach changes, surprising discoveries, tradeoff decisions.
|
|
42
43
|
// Skip routine reasoning. Only novel/surprising thoughts. Max 5.
|
|
43
44
|
{"category": "doubt|tradeoff|alternative|insight|realization", "content": "1-2 sentence description"}
|
|
44
|
-
]
|
|
45
|
+
],
|
|
45
46
|
${hasRetrievedMemories ? ` "resolved": [
|
|
46
47
|
// IDs from [RETRIEVED MEMORIES] that have been FULLY addressed/fixed/completed in this conversation.
|
|
47
48
|
// Must be exact IDs like "memory:abc123". Empty [] if none resolved.
|
package/src/reflection.ts
CHANGED
|
@@ -140,30 +140,31 @@ export async function generateReflection(
|
|
|
140
140
|
embeddings: EmbeddingService,
|
|
141
141
|
complete: CompleteFn,
|
|
142
142
|
): Promise<void> {
|
|
143
|
-
|
|
144
|
-
if (!metrics) return;
|
|
143
|
+
if (!store.isAvailable()) return;
|
|
145
144
|
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
// Get session turns directly — no dependency on orchestrator_metrics
|
|
146
|
+
const turns = await store.getSessionTurns(sessionId, 30).catch(() => []);
|
|
147
|
+
if (turns.length < 3) return; // Too short for meaningful reflection
|
|
148
148
|
|
|
149
|
-
const
|
|
149
|
+
const transcript = turns
|
|
150
|
+
.map(t => `[${t.role}] ${(t.text ?? "").slice(0, 300)}`)
|
|
151
|
+
.join("\n");
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (metrics.steeringCandidates >= STEERING_THRESHOLD) category = "approach_strategy";
|
|
153
|
+
const severity = turns.length >= 15 ? "moderate" : "minor";
|
|
154
|
+
const category = "session_review";
|
|
154
155
|
|
|
155
156
|
try {
|
|
156
157
|
const response = await complete({
|
|
157
|
-
system: `Write 2-4 sentences:
|
|
158
|
+
system: `Reflect on this session. Write 2-4 sentences about: what went well, what could improve, any patterns worth noting. Be specific and actionable. If the session was too trivial for reflection, respond with just "skip".`,
|
|
158
159
|
messages: [{
|
|
159
160
|
role: "user",
|
|
160
|
-
content:
|
|
161
|
+
content: `Session with ${turns.length} turns:\n${transcript.slice(0, 15000)}`,
|
|
161
162
|
}],
|
|
162
163
|
});
|
|
163
164
|
|
|
164
165
|
const reflectionText = response.text.trim();
|
|
165
166
|
|
|
166
|
-
if (reflectionText.length < 20) return;
|
|
167
|
+
if (reflectionText.length < 20 || reflectionText.toLowerCase() === "skip") return;
|
|
167
168
|
|
|
168
169
|
let reflEmb: number[] | null = null;
|
|
169
170
|
if (embeddings.isAvailable()) {
|
package/src/schema.surql
CHANGED
|
@@ -375,15 +375,6 @@ DEFINE FIELD IF NOT EXISTS created_at ON graduation_event TYPE datetime DEFAULT
|
|
|
375
375
|
-- ============================================================
|
|
376
376
|
-- MIGRATIONS (must run after table definitions)
|
|
377
377
|
-- ============================================================
|
|
378
|
-
--
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
REMOVE INDEX IF EXISTS concept_vec_idx ON concept;
|
|
382
|
-
REMOVE INDEX IF EXISTS memory_vec_idx ON memory;
|
|
383
|
-
REMOVE INDEX IF EXISTS artifact_vec_idx ON artifact;
|
|
384
|
-
-- Clear stale 768d embeddings (incompatible with new 1024d model)
|
|
385
|
-
UPDATE turn SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
|
|
386
|
-
UPDATE identity_chunk SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
|
|
387
|
-
UPDATE concept SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
|
|
388
|
-
UPDATE memory SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
|
|
389
|
-
UPDATE artifact SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
|
|
378
|
+
-- 768d → 1024d migration completed; REMOVE INDEX / UPDATE stale
|
|
379
|
+
-- embeddings removed to avoid destroying live HNSW indexes on
|
|
380
|
+
-- every startup.
|
package/src/skills.ts
CHANGED
|
@@ -60,17 +60,8 @@ export async function extractSkill(
|
|
|
60
60
|
): Promise<string | null> {
|
|
61
61
|
if (!store.isAvailable()) return null;
|
|
62
62
|
|
|
63
|
-
// Check if session had enough tool activity
|
|
64
|
-
const metricsRows = await store.queryFirst<{ totalTools: number }>(
|
|
65
|
-
`SELECT math::sum(actual_tool_calls) AS totalTools
|
|
66
|
-
FROM orchestrator_metrics WHERE session_id = $sid GROUP ALL`,
|
|
67
|
-
{ sid: sessionId },
|
|
68
|
-
).catch(() => [] as { totalTools: number }[]);
|
|
69
|
-
const totalTools = Number(metricsRows[0]?.totalTools ?? 0);
|
|
70
|
-
if (totalTools < 3) return null;
|
|
71
|
-
|
|
72
63
|
const turns = await store.getSessionTurns(sessionId, 50);
|
|
73
|
-
if (turns.length < 4) return null;
|
|
64
|
+
if (turns.length < 4) return null; // Too short for skill extraction
|
|
74
65
|
|
|
75
66
|
const transcript = turns
|
|
76
67
|
.map((t) => `[${t.role}] ${(t.text ?? "").slice(0, 300)}`)
|
|
@@ -81,7 +72,7 @@ export async function extractSkill(
|
|
|
81
72
|
system: `Return JSON or null. Fields: {name, description, preconditions, steps: [{tool, description}] (max 8), postconditions}. Generic patterns only (no specific paths). null if no clear multi-step workflow.`,
|
|
82
73
|
messages: [{
|
|
83
74
|
role: "user",
|
|
84
|
-
content: `${
|
|
75
|
+
content: `${turns.length} turns:\n${transcript.slice(0, 20000)}`,
|
|
85
76
|
}],
|
|
86
77
|
});
|
|
87
78
|
|
package/src/soul.ts
CHANGED
|
@@ -36,7 +36,6 @@ export interface GraduationSignals {
|
|
|
36
36
|
reflections: number;
|
|
37
37
|
causalChains: number;
|
|
38
38
|
concepts: number;
|
|
39
|
-
memoryCompactions: number;
|
|
40
39
|
monologues: number;
|
|
41
40
|
spanDays: number;
|
|
42
41
|
}
|
|
@@ -91,7 +90,6 @@ const THRESHOLDS: GraduationSignals = {
|
|
|
91
90
|
reflections: 10,
|
|
92
91
|
causalChains: 5,
|
|
93
92
|
concepts: 30,
|
|
94
|
-
memoryCompactions: 5,
|
|
95
93
|
monologues: 5,
|
|
96
94
|
spanDays: 3,
|
|
97
95
|
};
|
|
@@ -104,17 +102,16 @@ const QUALITY_GATE = 0.6;
|
|
|
104
102
|
async function getGraduationSignals(store: SurrealStore): Promise<GraduationSignals> {
|
|
105
103
|
const defaults: GraduationSignals = {
|
|
106
104
|
sessions: 0, reflections: 0, causalChains: 0,
|
|
107
|
-
concepts: 0,
|
|
105
|
+
concepts: 0, monologues: 0, spanDays: 0,
|
|
108
106
|
};
|
|
109
107
|
if (!store.isAvailable()) return defaults;
|
|
110
108
|
|
|
111
109
|
try {
|
|
112
|
-
const [sessions, reflections, causal, concepts,
|
|
110
|
+
const [sessions, reflections, causal, concepts, monologues, span] = await Promise.all([
|
|
113
111
|
store.queryFirst<{ count: number }>(`SELECT count() AS count FROM session GROUP ALL`).catch(() => []),
|
|
114
112
|
store.queryFirst<{ count: number }>(`SELECT count() AS count FROM reflection GROUP ALL`).catch(() => []),
|
|
115
113
|
store.queryFirst<{ count: number }>(`SELECT count() AS count FROM causal_chain GROUP ALL`).catch(() => []),
|
|
116
114
|
store.queryFirst<{ count: number }>(`SELECT count() AS count FROM concept GROUP ALL`).catch(() => []),
|
|
117
|
-
store.queryFirst<{ count: number }>(`SELECT count() AS count FROM compaction_checkpoint WHERE status = "complete" GROUP ALL`).catch(() => []),
|
|
118
115
|
store.queryFirst<{ count: number }>(`SELECT count() AS count FROM monologue GROUP ALL`).catch(() => []),
|
|
119
116
|
store.queryFirst<{ earliest: string }>(`SELECT started_at AS earliest FROM session ORDER BY started_at ASC LIMIT 1`).catch(() => []),
|
|
120
117
|
]);
|
|
@@ -130,7 +127,6 @@ async function getGraduationSignals(store: SurrealStore): Promise<GraduationSign
|
|
|
130
127
|
reflections: (reflections as { count: number }[])[0]?.count ?? 0,
|
|
131
128
|
causalChains: (causal as { count: number }[])[0]?.count ?? 0,
|
|
132
129
|
concepts: (concepts as { count: number }[])[0]?.count ?? 0,
|
|
133
|
-
memoryCompactions: (compactions as { count: number }[])[0]?.count ?? 0,
|
|
134
130
|
monologues: (monologues as { count: number }[])[0]?.count ?? 0,
|
|
135
131
|
spanDays,
|
|
136
132
|
};
|
|
@@ -358,7 +354,6 @@ function getSuggestion(key: keyof GraduationSignals, current: number, threshold:
|
|
|
358
354
|
case "reflections": return `${remaining} more reflection(s) needed. These are generated automatically when sessions have performance issues.`;
|
|
359
355
|
case "causalChains": return `${remaining} more causal chain(s) needed. These form when the agent corrects mistakes during tool usage.`;
|
|
360
356
|
case "concepts": return `${remaining} more concept(s) needed. Concepts are extracted from conversation topics and domain vocabulary.`;
|
|
361
|
-
case "memoryCompactions": return `${remaining} more compaction(s) needed. These happen during longer sessions with substantial context.`;
|
|
362
357
|
case "monologues": return `${remaining} more monologue(s) needed. Inner monologue triggers during cognitive checks.`;
|
|
363
358
|
case "spanDays": return `${remaining} more day(s) of history needed. The agent needs time-spread experience, not just volume.`;
|
|
364
359
|
}
|
package/src/state.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
|
-
import type { PluginCompleteParams, PluginCompleteResult } from "openclaw/plugin-sdk";
|
|
2
1
|
import type { KongBrainConfig } from "./config.js";
|
|
3
2
|
import type { SurrealStore } from "./surreal.js";
|
|
4
3
|
import type { EmbeddingService } from "./embeddings.js";
|
|
5
4
|
import type { AdaptiveConfig } from "./orchestrator.js";
|
|
6
5
|
import type { MemoryDaemon } from "./daemon-manager.js";
|
|
7
6
|
|
|
7
|
+
/** Parameters for an LLM completion call. */
|
|
8
|
+
export type CompleteParams = {
|
|
9
|
+
system?: string;
|
|
10
|
+
messages: { role: "user" | "assistant"; content: string }[];
|
|
11
|
+
provider?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
temperature?: number;
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
reasoning?: "none" | "low" | "medium" | "high";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Result of an LLM completion call. */
|
|
19
|
+
export type CompleteResult = {
|
|
20
|
+
text: string;
|
|
21
|
+
thinking?: string;
|
|
22
|
+
usage?: { input: number; output: number };
|
|
23
|
+
provider?: string;
|
|
24
|
+
model?: string;
|
|
25
|
+
stopReason?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
8
28
|
/** Provider-agnostic LLM completion function. */
|
|
9
|
-
export type CompleteFn = (params:
|
|
29
|
+
export type CompleteFn = (params: CompleteParams) => Promise<CompleteResult>;
|
|
10
30
|
|
|
11
31
|
// --- Per-session mutable state ---
|
|
12
32
|
|
|
@@ -37,6 +57,11 @@ export class SessionState {
|
|
|
37
57
|
readonly DAEMON_TOKEN_THRESHOLD = 4000;
|
|
38
58
|
lastDaemonFlushTurnCount = 0;
|
|
39
59
|
|
|
60
|
+
// Cumulative session token tracking (for mid-session cleanup trigger)
|
|
61
|
+
cumulativeTokens = 0;
|
|
62
|
+
lastCleanupTokens = 0;
|
|
63
|
+
readonly MID_SESSION_CLEANUP_THRESHOLD = 100_000;
|
|
64
|
+
|
|
40
65
|
// Cleanup tracking
|
|
41
66
|
cleanedUp = false;
|
|
42
67
|
|
|
@@ -78,7 +103,7 @@ export class GlobalPluginState {
|
|
|
78
103
|
readonly config: KongBrainConfig;
|
|
79
104
|
readonly store: SurrealStore;
|
|
80
105
|
readonly embeddings: EmbeddingService;
|
|
81
|
-
|
|
106
|
+
complete: CompleteFn;
|
|
82
107
|
workspaceDir?: string;
|
|
83
108
|
enqueueSystemEvent?: EnqueueSystemEventFn;
|
|
84
109
|
private sessions = new Map<string, SessionState>();
|
package/src/surreal.ts
CHANGED
|
@@ -105,19 +105,28 @@ export class SurrealStore {
|
|
|
105
105
|
private config: SurrealConfig;
|
|
106
106
|
private reconnecting: Promise<void> | null = null;
|
|
107
107
|
private shutdownFlag = false;
|
|
108
|
+
private initialized = false;
|
|
108
109
|
|
|
109
110
|
constructor(config: SurrealConfig) {
|
|
110
111
|
this.config = config;
|
|
111
112
|
this.db = new Surreal();
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
/** Connect and run schema. Returns true if a new connection was made, false if already initialized. */
|
|
116
|
+
async initialize(): Promise<boolean> {
|
|
117
|
+
// Only connect once — subsequent calls are no-ops.
|
|
118
|
+
// This prevents register()/factory re-invocations from disrupting
|
|
119
|
+
// in-flight operations (deferred cleanup, daemon extraction).
|
|
120
|
+
// Don't check isConnected — ensureConnected() handles reconnection.
|
|
121
|
+
if (this.initialized) return false;
|
|
115
122
|
await this.db.connect(this.config.url, {
|
|
116
123
|
namespace: this.config.ns,
|
|
117
124
|
database: this.config.db,
|
|
118
125
|
authentication: { username: this.config.user, password: this.config.pass },
|
|
119
126
|
});
|
|
120
127
|
await this.runSchema();
|
|
128
|
+
this.initialized = true;
|
|
129
|
+
return true;
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
markShutdown(): void {
|
|
@@ -218,16 +227,43 @@ export class SurrealStore {
|
|
|
218
227
|
}
|
|
219
228
|
}
|
|
220
229
|
|
|
230
|
+
/** Returns true if an error is a connection-level failure worth retrying. */
|
|
231
|
+
private isConnectionError(e: unknown): boolean {
|
|
232
|
+
const msg = String((e as any)?.message ?? e);
|
|
233
|
+
return msg.includes("must be connected") || msg.includes("ConnectionUnavailable");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Run a query function with one retry on connection errors. */
|
|
237
|
+
private async withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
238
|
+
try {
|
|
239
|
+
return await fn();
|
|
240
|
+
} catch (e) {
|
|
241
|
+
if (!this.isConnectionError(e)) throw e;
|
|
242
|
+
// Connection died — force a fresh connection (close stale socket first)
|
|
243
|
+
this.initialized = false;
|
|
244
|
+
try { await this.db?.close(); } catch { /* ignore */ }
|
|
245
|
+
this.db = new Surreal();
|
|
246
|
+
await this.db.connect(this.config.url, {
|
|
247
|
+
namespace: this.config.ns,
|
|
248
|
+
database: this.config.db,
|
|
249
|
+
authentication: { username: this.config.user, password: this.config.pass },
|
|
250
|
+
});
|
|
251
|
+
return await fn();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
221
255
|
// ── Query helpers ──────────────────────────────────────────────────────
|
|
222
256
|
|
|
223
257
|
async queryFirst<T>(sql: string, bindings?: Record<string, unknown>): Promise<T[]> {
|
|
224
258
|
await this.ensureConnected();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
259
|
+
return this.withRetry(async () => {
|
|
260
|
+
const ns = this.config.ns;
|
|
261
|
+
const dbName = this.config.db;
|
|
262
|
+
const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
|
|
263
|
+
const result = await this.db.query<[T[]]>(fullSql, bindings);
|
|
264
|
+
const rows = Array.isArray(result) ? result[result.length - 1] : result;
|
|
265
|
+
return (Array.isArray(rows) ? rows : []).filter(Boolean);
|
|
266
|
+
});
|
|
231
267
|
}
|
|
232
268
|
|
|
233
269
|
async queryMulti<T = unknown>(
|
|
@@ -235,20 +271,24 @@ export class SurrealStore {
|
|
|
235
271
|
bindings?: Record<string, unknown>,
|
|
236
272
|
): Promise<T | undefined> {
|
|
237
273
|
await this.ensureConnected();
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
274
|
+
return this.withRetry(async () => {
|
|
275
|
+
const ns = this.config.ns;
|
|
276
|
+
const dbName = this.config.db;
|
|
277
|
+
const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
|
|
278
|
+
const raw = await this.db.query(fullSql, bindings);
|
|
279
|
+
const flat = (raw as unknown[]).flat();
|
|
280
|
+
return flat[flat.length - 1] as T | undefined;
|
|
281
|
+
});
|
|
244
282
|
}
|
|
245
283
|
|
|
246
284
|
async queryExec(sql: string, bindings?: Record<string, unknown>): Promise<void> {
|
|
247
285
|
await this.ensureConnected();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
286
|
+
return this.withRetry(async () => {
|
|
287
|
+
const ns = this.config.ns;
|
|
288
|
+
const dbName = this.config.db;
|
|
289
|
+
const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
|
|
290
|
+
await this.db.query(fullSql, bindings);
|
|
291
|
+
});
|
|
252
292
|
}
|
|
253
293
|
|
|
254
294
|
private async safeQuery(
|
|
@@ -884,7 +924,8 @@ export class SurrealStore {
|
|
|
884
924
|
timestamp: string;
|
|
885
925
|
}>(
|
|
886
926
|
`SELECT role, text, tool_name, timestamp FROM turn
|
|
887
|
-
WHERE
|
|
927
|
+
WHERE id IN (SELECT VALUE in FROM part_of WHERE out = $sid)
|
|
928
|
+
AND text != NONE AND text != ""
|
|
888
929
|
ORDER BY timestamp DESC LIMIT $lim`,
|
|
889
930
|
{ sid: prevSessionId, lim: limit },
|
|
890
931
|
);
|
package/src/tools/recall.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function createRecallToolDef(state: GlobalPluginState, session: SessionSt
|
|
|
41
41
|
const scope = params.scope ?? "all";
|
|
42
42
|
|
|
43
43
|
if (scope === "skills") {
|
|
44
|
-
const skills = await findRelevantSkills(queryVec, maxResults);
|
|
44
|
+
const skills = await findRelevantSkills(queryVec, maxResults, store);
|
|
45
45
|
if (skills.length === 0) {
|
|
46
46
|
return { content: [{ type: "text" as const, text: `No skills found matching "${params.query}".` }], details: null };
|
|
47
47
|
}
|