kongbrain 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kongbrain",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
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,
@@ -45,6 +45,7 @@ import { evaluateRetrieval, getStagedItems } from "./retrieval-quality.js";
45
45
  import { shouldRunCheck, runCognitiveCheck } from "./cognitive-check.js";
46
46
  import { checkACANReadiness } from "./acan.js";
47
47
  import { predictQueries, prefetchContext } from "./prefetch.js";
48
+ import { runDeferredCleanup } from "./deferred-cleanup.js";
48
49
  import { swallow } from "./errors.js";
49
50
 
50
51
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -122,6 +123,7 @@ export class KongBrainContextEngine implements ContextEngine {
122
123
  store.consolidateMemories((text) => embeddings.embed(text)),
123
124
  store.garbageCollectMemories(),
124
125
  checkACANReadiness(store),
126
+ // Deferred cleanup is triggered on first afterTurn() when complete() is available
125
127
  ]).catch(e => swallow.warn("bootstrap:maintenance", e));
126
128
 
127
129
  return { bootstrapped: true };
@@ -340,7 +342,13 @@ export class KongBrainContextEngine implements ContextEngine {
340
342
  const session = this.state.getSession(sessionKey);
341
343
  if (!session) return;
342
344
 
343
- const { store } = this.state;
345
+ const { store, embeddings } = this.state;
346
+
347
+ // Deferred cleanup: run once on first turn when complete() is available
348
+ if (session.userTurnCount <= 1 && typeof this.state.complete === "function") {
349
+ runDeferredCleanup(store, embeddings, this.state.complete)
350
+ .catch(e => swallow.warn("afterTurn:deferredCleanup", e));
351
+ }
344
352
 
345
353
  // Ingest new messages from this turn (OpenClaw skips ingest() when afterTurn exists)
346
354
  const newMessages = params.messages.slice(params.prePromptMessageCount);
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { SurrealConfig, EmbeddingConfig } from "./config.js";
11
11
  import type { TurnData, PriorExtractions } from "./daemon-types.js";
12
+ import type { CompleteFn } from "./state.js";
12
13
  import { SurrealStore } from "./surreal.js";
13
14
  import { EmbeddingService } from "./embeddings.js";
14
15
  import { swallow } from "./errors.js";
@@ -35,7 +36,7 @@ export function startMemoryDaemon(
35
36
  surrealConfig: SurrealConfig,
36
37
  embeddingConfig: EmbeddingConfig,
37
38
  sessionId: string,
38
- llmConfig?: { provider?: string; model?: string },
39
+ complete: CompleteFn,
39
40
  ): MemoryDaemon {
40
41
  // Daemon-local DB and embedding instances (separate connections)
41
42
  let store: SurrealStore | null = null;
@@ -79,13 +80,6 @@ export function startMemoryDaemon(
79
80
  if (!store || !embeddings) return;
80
81
  if (turns.length < 2) return;
81
82
 
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
83
  // Merge incoming prior state
90
84
  if (incomingPrior) {
91
85
  for (const name of incomingPrior.conceptNames) {
@@ -116,22 +110,12 @@ export function startMemoryDaemon(
116
110
 
117
111
  const systemPrompt = buildSystemPrompt(thinking.length > 0, retrievedMemories.length > 0, priorState);
118
112
 
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
- }],
113
+ const response = await complete({
114
+ system: systemPrompt,
115
+ messages: [{ role: "user", content: sections.join("\n\n") }],
129
116
  });
130
117
 
131
- const responseText = response.content
132
- .filter((c: any) => c.type === "text")
133
- .map((c: any) => c.text)
134
- .join("");
118
+ const responseText = response.text;
135
119
 
136
120
  const jsonMatch = responseText.match(/\{[\s\S]*\}/);
137
121
  if (!jsonMatch) return;
@@ -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[]> {
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";
@@ -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);
@@ -377,7 +462,7 @@ export default definePluginEntry({
377
462
  config.surreal,
378
463
  config.embedding,
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,32 @@ 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
489
  // Synthesize wakeup briefing (background, non-blocking)
405
490
  // The briefing is stored and later injected via assemble()'s systemPromptAddition
406
- synthesizeWakeup(store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
491
+ synthesizeWakeup(globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
407
492
  .then(briefing => {
408
493
  if (briefing) (session as any)._wakeupBriefing = briefing;
409
494
  })
410
495
  .catch(e => swallow.warn("index:wakeup", e));
411
496
 
412
497
  // Startup cognition (background)
413
- synthesizeStartupCognition(store, globalState!.complete)
498
+ synthesizeStartupCognition(globalState!.store, globalState!.complete)
414
499
  .then(cognition => {
415
500
  if (cognition) (session as any)._startupCognition = cognition;
416
501
  })
417
502
  .catch(e => swallow.warn("index:startupCognition", e));
418
503
 
419
504
  // Deferred cleanup: extract knowledge from orphaned sessions (background)
420
- runDeferredCleanup(store, embeddings, globalState!.complete)
505
+ runDeferredCleanup(globalState!.store, globalState!.embeddings, globalState!.complete)
421
506
  .then(n => { if (n > 0) logger.info(`Deferred cleanup: processed ${n} orphaned session(s)`); })
422
507
  .catch(e => swallow.warn("index:deferredCleanup", e));
423
508
  });
424
509
 
425
- if (!registered) api.on("session_end", async (event) => {
510
+ if (!isRegistered()) api.on("session_end", async (event) => {
511
+ const globalState = getGlobalState();
426
512
  if (!globalState) return;
427
513
  const sessionKey = event.sessionKey ?? event.sessionId;
428
514
  const session = globalState.getSession(sessionKey);
@@ -434,7 +520,7 @@ export default definePluginEntry({
434
520
 
435
521
  session.cleanedUp = true;
436
522
  if (session.surrealSessionId) {
437
- await store.markSessionEnded(session.surrealSessionId)
523
+ await globalState.store.markSessionEnded(session.surrealSessionId)
438
524
  .catch(e => swallow.warn("session_end:markEnded", e));
439
525
  }
440
526
 
@@ -458,8 +544,9 @@ export default definePluginEntry({
458
544
 
459
545
  // Sync exit handler: writes handoff file for all uncleaned sessions
460
546
  const syncExitHandler = () => {
461
- if (!globalState?.workspaceDir) return;
462
- const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
547
+ const gs = getGlobalState();
548
+ if (!gs?.workspaceDir) return;
549
+ const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
463
550
  for (const session of sessions) {
464
551
  if (session.cleanedUp) continue;
465
552
  writeHandoffFileSync({
@@ -469,21 +556,22 @@ export default definePluginEntry({
469
556
  lastUserText: session.lastUserText.slice(0, 500),
470
557
  lastAssistantText: session.lastAssistantText.slice(0, 500),
471
558
  unextractedTokens: session.newContentTokens,
472
- }, globalState!.workspaceDir!);
559
+ }, gs.workspaceDir!);
473
560
  }
474
561
  };
475
562
 
476
563
  // Async exit handler: full cleanup for SIGTERM (gateway/daemon mode)
477
564
  const asyncExitHandler = () => {
478
- if (!globalState) return;
479
- const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
565
+ const gs = getGlobalState();
566
+ if (!gs) return;
567
+ const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
480
568
  if (sessions.length === 0 && !shutdownPromise) return;
481
569
 
482
- const cleanups = sessions.map(s => runSessionCleanup(s, globalState!));
570
+ const cleanups = sessions.map(s => runSessionCleanup(s, gs));
483
571
  if (shutdownPromise) cleanups.push(shutdownPromise);
484
572
 
485
573
  const done = Promise.allSettled(cleanups).then(() => {
486
- globalState?.shutdown().catch(() => {});
574
+ gs.shutdown().catch(() => {});
487
575
  });
488
576
 
489
577
  done.then(() => process.exit(0)).catch(() => process.exit(1));
@@ -494,9 +582,6 @@ export default definePluginEntry({
494
582
  process.on("exit", syncExitHandler);
495
583
  process.once("SIGTERM", asyncExitHandler);
496
584
 
497
- if (!registered) {
498
- logger.info("KongBrain plugin registered");
499
- registered = true;
500
- }
585
+ markRegistered();
501
586
  },
502
587
  });
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
 
@@ -78,7 +98,7 @@ export class GlobalPluginState {
78
98
  readonly config: KongBrainConfig;
79
99
  readonly store: SurrealStore;
80
100
  readonly embeddings: EmbeddingService;
81
- readonly complete: CompleteFn;
101
+ complete: CompleteFn;
82
102
  workspaceDir?: string;
83
103
  enqueueSystemEvent?: EnqueueSystemEventFn;
84
104
  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(