kongbrain 0.1.3 → 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/README.md CHANGED
@@ -88,7 +88,7 @@ Add to your OpenClaw config (`~/.openclaw/openclaw.json`):
88
88
  ### 5. Talk to your ape
89
89
 
90
90
  ```bash
91
- openclaw
91
+ openclaw tui
92
92
  ```
93
93
 
94
94
  That's it. KongBrain uses whatever LLM provider and model you already have configured in OpenClaw (Anthropic, OpenAI, Google, Ollama, whatever). No separate API keys needed for the brain itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kongbrain",
3
- "version": "0.1.3",
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
  }
@@ -337,16 +421,19 @@ export default definePluginEntry({
337
421
  return new KongBrainContextEngine(state);
338
422
  });
339
423
 
340
- // ── Hook handlers ──────────────────────────────────────────────────
424
+ // ── Hook handlers (register once — register() may be called multiple times) ──
341
425
 
342
- api.on("before_prompt_build", createBeforePromptBuildHandler(globalState));
343
- api.on("before_tool_call", createBeforeToolCallHandler(globalState));
344
- api.on("after_tool_call", createAfterToolCallHandler(globalState));
345
- api.on("llm_output", createLlmOutputHandler(globalState));
426
+ if (!isRegistered()) {
427
+ api.on("before_prompt_build", createBeforePromptBuildHandler(globalState));
428
+ api.on("before_tool_call", createBeforeToolCallHandler(globalState));
429
+ api.on("after_tool_call", createAfterToolCallHandler(globalState));
430
+ api.on("llm_output", createLlmOutputHandler(globalState));
431
+ }
346
432
 
347
- // ── Session lifecycle ──────────────────────────────────────────────
433
+ // ── Session lifecycle (also register once) ─────────────────────────
348
434
 
349
- api.on("session_start", async (event) => {
435
+ if (!isRegistered()) api.on("session_start", async (event) => {
436
+ const globalState = getGlobalState();
350
437
  if (!globalState) return;
351
438
  const sessionKey = event.sessionKey ?? event.sessionId;
352
439
  const session = globalState.getOrCreateSession(sessionKey, event.sessionId);
@@ -375,7 +462,7 @@ export default definePluginEntry({
375
462
  config.surreal,
376
463
  config.embedding,
377
464
  session.sessionId,
378
- { provider: api.runtime.agent.defaults.provider, model: api.runtime.agent.defaults.model },
465
+ globalState!.complete,
379
466
  );
380
467
  } catch (e) {
381
468
  swallow.warn("index:startDaemon", e);
@@ -396,31 +483,32 @@ export default definePluginEntry({
396
483
  setReflectionContextWindow(200000);
397
484
 
398
485
  // Check for recent graduation event (from a previous session)
399
- detectGraduationEvent(store, session, globalState!)
486
+ detectGraduationEvent(globalState!.store, session, globalState!)
400
487
  .catch(e => swallow("index:graduationDetect", e));
401
488
 
402
489
  // Synthesize wakeup briefing (background, non-blocking)
403
490
  // The briefing is stored and later injected via assemble()'s systemPromptAddition
404
- synthesizeWakeup(store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
491
+ synthesizeWakeup(globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
405
492
  .then(briefing => {
406
493
  if (briefing) (session as any)._wakeupBriefing = briefing;
407
494
  })
408
495
  .catch(e => swallow.warn("index:wakeup", e));
409
496
 
410
497
  // Startup cognition (background)
411
- synthesizeStartupCognition(store, globalState!.complete)
498
+ synthesizeStartupCognition(globalState!.store, globalState!.complete)
412
499
  .then(cognition => {
413
500
  if (cognition) (session as any)._startupCognition = cognition;
414
501
  })
415
502
  .catch(e => swallow.warn("index:startupCognition", e));
416
503
 
417
504
  // Deferred cleanup: extract knowledge from orphaned sessions (background)
418
- runDeferredCleanup(store, embeddings, globalState!.complete)
505
+ runDeferredCleanup(globalState!.store, globalState!.embeddings, globalState!.complete)
419
506
  .then(n => { if (n > 0) logger.info(`Deferred cleanup: processed ${n} orphaned session(s)`); })
420
507
  .catch(e => swallow.warn("index:deferredCleanup", e));
421
508
  });
422
509
 
423
- api.on("session_end", async (event) => {
510
+ if (!isRegistered()) api.on("session_end", async (event) => {
511
+ const globalState = getGlobalState();
424
512
  if (!globalState) return;
425
513
  const sessionKey = event.sessionKey ?? event.sessionId;
426
514
  const session = globalState.getSession(sessionKey);
@@ -432,7 +520,7 @@ export default definePluginEntry({
432
520
 
433
521
  session.cleanedUp = true;
434
522
  if (session.surrealSessionId) {
435
- await store.markSessionEnded(session.surrealSessionId)
523
+ await globalState.store.markSessionEnded(session.surrealSessionId)
436
524
  .catch(e => swallow.warn("session_end:markEnded", e));
437
525
  }
438
526
 
@@ -456,8 +544,9 @@ export default definePluginEntry({
456
544
 
457
545
  // Sync exit handler: writes handoff file for all uncleaned sessions
458
546
  const syncExitHandler = () => {
459
- if (!globalState?.workspaceDir) return;
460
- 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[];
461
550
  for (const session of sessions) {
462
551
  if (session.cleanedUp) continue;
463
552
  writeHandoffFileSync({
@@ -467,21 +556,22 @@ export default definePluginEntry({
467
556
  lastUserText: session.lastUserText.slice(0, 500),
468
557
  lastAssistantText: session.lastAssistantText.slice(0, 500),
469
558
  unextractedTokens: session.newContentTokens,
470
- }, globalState!.workspaceDir!);
559
+ }, gs.workspaceDir!);
471
560
  }
472
561
  };
473
562
 
474
563
  // Async exit handler: full cleanup for SIGTERM (gateway/daemon mode)
475
564
  const asyncExitHandler = () => {
476
- if (!globalState) return;
477
- 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[];
478
568
  if (sessions.length === 0 && !shutdownPromise) return;
479
569
 
480
- const cleanups = sessions.map(s => runSessionCleanup(s, globalState!));
570
+ const cleanups = sessions.map(s => runSessionCleanup(s, gs));
481
571
  if (shutdownPromise) cleanups.push(shutdownPromise);
482
572
 
483
573
  const done = Promise.allSettled(cleanups).then(() => {
484
- globalState?.shutdown().catch(() => {});
574
+ gs.shutdown().catch(() => {});
485
575
  });
486
576
 
487
577
  done.then(() => process.exit(0)).catch(() => process.exit(1));
@@ -492,9 +582,6 @@ export default definePluginEntry({
492
582
  process.on("exit", syncExitHandler);
493
583
  process.once("SIGTERM", asyncExitHandler);
494
584
 
495
- if (!registered) {
496
- logger.info("KongBrain plugin registered");
497
- registered = true;
498
- }
585
+ markRegistered();
499
586
  },
500
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(