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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kongbrain",
3
- "version": "0.1.4",
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,
@@ -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
- try {
91
- const workspace = this.state.workspaceDir || process.cwd();
92
- const projectName = workspace.split("/").pop() || "default";
93
-
94
- session.agentId = await store.ensureAgent("kongbrain", "openclaw-default");
95
- session.projectId = await store.ensureProject(projectName);
96
- await store.linkAgentToProject(session.agentId, session.projectId)
97
- .catch(e => swallow.warn("bootstrap:linkAgentToProject", e));
98
-
99
- session.taskId = await store.createTask(`Session in ${projectName}`);
100
- await store.linkAgentToTask(session.agentId, session.taskId)
101
- .catch(e => swallow.warn("bootstrap:linkAgentToTask", e));
102
- await store.linkTaskToProject(session.taskId, session.projectId)
103
- .catch(e => swallow.warn("bootstrap:linkTaskToProject", e));
104
-
105
- const surrealSessionId = await store.createSession(session.agentId);
106
- await store.markSessionActive(surrealSessionId)
107
- .catch(e => swallow.warn("bootstrap:markActive", e));
108
- await store.linkSessionToTask(surrealSessionId, session.taskId)
109
- .catch(e => swallow.warn("bootstrap:linkSessionToTask", e));
110
-
111
- // Store the DB session ID for cleanup tracking
112
- session.surrealSessionId = surrealSessionId;
113
- session.lastUserTurnId = "";
114
- } catch (e) {
115
- swallow.error("bootstrap:5pillar", e);
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 wakeupBriefing = (session as any)._wakeupBriefing as string | undefined;
159
- if (wakeupBriefing) {
160
- additions.push(wakeupBriefing);
161
- delete (session as any)._wakeupBriefing; // Only inject once
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
- // Phase 3: combined extraction, graduation, soul graduation
426
- await this.state.shutdown();
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
 
@@ -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 { SurrealStore } from "./surreal.js";
13
- import { EmbeddingService } from "./embeddings.js";
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
- surrealConfig: SurrealConfig,
36
- embeddingConfig: EmbeddingConfig,
35
+ sharedStore: SurrealStore,
36
+ sharedEmbeddings: EmbeddingService,
37
37
  sessionId: string,
38
- llmConfig?: { provider?: string; model?: string },
38
+ complete: CompleteFn,
39
39
  ): MemoryDaemon {
40
- // Daemon-local DB and embedding instances (separate connections)
41
- let store: SurrealStore | null = null;
42
- let embeddings: EmbeddingService | null = null;
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 { completeSimple, getModel } = await import("@mariozechner/pi-ai");
120
- const model = (getModel as any)(provider, modelId);
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.content
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: init if needed, then process
192
- ensureInit()
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
- // Clean up daemon-local connections
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() {
@@ -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(3).catch(() => []);
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
- // 30s timeout — don't hold up the new session forever
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, 30_000)),
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
- // Find the OpenClaw session ID from turns stored in this session
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 session_id IN (SELECT VALUE out FROM part_of WHERE in = $sid)
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
- if (Object.keys(result).length > 0) {
107
- const sessionId = surrealSessionId; // Use DB ID as session reference
108
- await writeExtractionResults(result, sessionId, store, embeddings, priorState);
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
- async initialize(): Promise<void> {
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[]> {
@@ -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 { join } from "node:path";
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, synthesizeStartupCognition } from "./wakeup.js";
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
- let globalState: GlobalPluginState | null = null;
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 globalState if register() is called
298
- // multiple times (OpenClaw may invoke the factory more than once). Hooks from the
299
- // first register() hold a closure over globalState, so replacing it would orphan them.
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
- globalState = new GlobalPluginState(config, store, embeddings, api.runtime.complete);
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 (!registered) {
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 (!registered) api.on("session_start", async (event) => {
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
- config.surreal,
378
- config.embedding,
462
+ globalState!.store,
463
+ globalState!.embeddings,
379
464
  session.sessionId,
380
- { provider: api.runtime.agent.defaults.provider, model: api.runtime.agent.defaults.model },
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 (background, non-blocking)
405
- // The briefing is stored and later injected via assemble()'s systemPromptAddition
406
- synthesizeWakeup(store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
407
- .then(briefing => {
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 (!registered) api.on("session_end", async (event) => {
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
- if (!globalState?.workspaceDir) return;
462
- const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
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
- }, globalState!.workspaceDir!);
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
- if (!globalState) return;
479
- const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
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, globalState!));
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
- globalState?.shutdown().catch(() => {});
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
- if (!registered) {
498
- logger.info("KongBrain plugin registered");
499
- registered = true;
500
- }
575
+ markRegistered();
501
576
  },
502
577
  });
@@ -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
- ${hasThinking ? ` "monologue": [
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
- ],` : ' "monologue": [],'}
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
- const metrics = await gatherSessionMetrics(sessionId, store);
144
- if (!metrics) return;
143
+ if (!store.isAvailable()) return;
145
144
 
146
- const { reflect, reasons } = shouldReflect(metrics);
147
- if (!reflect) return;
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 severity = reasons.length >= 3 ? "critical" : reasons.length >= 2 ? "moderate" : "minor";
149
+ const transcript = turns
150
+ .map(t => `[${t.role}] ${(t.text ?? "").slice(0, 300)}`)
151
+ .join("\n");
150
152
 
151
- let category = "efficiency";
152
- if (metrics.toolFailureRate > TOOL_FAILURE_THRESHOLD) category = "failure_pattern";
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: root cause, error pattern, what to do differently. Be specific. Example: "Spent 8 tool calls reading source before checking error log. For timeout bugs, check logs first."`,
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: `${metrics.totalTurns} turns, ${metrics.totalToolCalls} tools, ${(metrics.avgUtilization * 100).toFixed(0)}% util, ${(metrics.toolFailureRate * 100).toFixed(0)}% fail, ~${metrics.wastedTokens} wasted tokens\nIssues: ${reasons.join("; ")}`,
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
- -- Drop old 768d HNSW indexes (now 1024d with BGE-M3)
379
- REMOVE INDEX IF EXISTS turn_vec_idx ON turn;
380
- REMOVE INDEX IF EXISTS identity_vec_idx ON identity_chunk;
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: `${totalTools} tool calls:\n${transcript.slice(0, 20000)}`,
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, memoryCompactions: 0, monologues: 0, spanDays: 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, compactions, monologues, span] = await Promise.all([
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: PluginCompleteParams) => Promise<PluginCompleteResult>;
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
- readonly complete: CompleteFn;
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
- async initialize(): Promise<void> {
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
- const ns = this.config.ns;
226
- const dbName = this.config.db;
227
- const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
228
- const result = await this.db.query<[T[]]>(fullSql, bindings);
229
- const rows = Array.isArray(result) ? result[result.length - 1] : result;
230
- return (Array.isArray(rows) ? rows : []).filter(Boolean);
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
- const ns = this.config.ns;
239
- const dbName = this.config.db;
240
- const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
241
- const raw = await this.db.query(fullSql, bindings);
242
- const flat = (raw as unknown[]).flat();
243
- return flat[flat.length - 1] as T | undefined;
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
- const ns = this.config.ns;
249
- const dbName = this.config.db;
250
- const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
251
- await this.db.query(fullSql, bindings);
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 session_id = $sid AND text != NONE AND text != ""
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
  );
@@ -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
  }