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 +1 -1
- package/src/causal.ts +1 -1
- package/src/context-engine.ts +9 -1
- package/src/daemon-manager.ts +6 -22
- package/src/deferred-cleanup.ts +45 -22
- package/src/embeddings.ts +4 -1
- package/src/index.ts +119 -34
- package/src/state.ts +23 -3
- package/src/surreal.ts +57 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kongbrain",
|
|
3
|
-
"version": "0.
|
|
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,
|
package/src/context-engine.ts
CHANGED
|
@@ -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);
|
package/src/daemon-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const response = await completeSimple(model, {
|
|
123
|
-
systemPrompt,
|
|
124
|
-
messages: [{
|
|
125
|
-
role: "user",
|
|
126
|
-
timestamp: Date.now(),
|
|
127
|
-
content: sections.join("\n\n"),
|
|
128
|
-
}],
|
|
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.
|
|
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;
|
package/src/deferred-cleanup.ts
CHANGED
|
@@ -15,20 +15,49 @@ import { buildSystemPrompt, buildTranscript, writeExtractionResults } from "./me
|
|
|
15
15
|
import type { PriorExtractions } from "./daemon-types.js";
|
|
16
16
|
import { swallow } from "./errors.js";
|
|
17
17
|
|
|
18
|
+
// Process-global flag — deferred cleanup runs AT MOST ONCE per process.
|
|
19
|
+
// Using Symbol.for so it survives Jiti re-importing this module.
|
|
20
|
+
const RAN_KEY = Symbol.for("kongbrain.deferredCleanup.ran");
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* Find and process orphaned sessions. Runs with a 30s total timeout.
|
|
20
24
|
* Fire-and-forget from session_start — does not block the new session.
|
|
25
|
+
* Only runs once per process lifetime.
|
|
21
26
|
*/
|
|
22
27
|
export async function runDeferredCleanup(
|
|
23
28
|
store: SurrealStore,
|
|
24
29
|
embeddings: EmbeddingService,
|
|
25
30
|
complete: CompleteFn,
|
|
31
|
+
): Promise<number> {
|
|
32
|
+
// Once per process — never re-run even if first run times out
|
|
33
|
+
if ((globalThis as any)[RAN_KEY]) return 0;
|
|
34
|
+
(globalThis as any)[RAN_KEY] = true;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
return await runDeferredCleanupInner(store, embeddings, complete);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
swallow.warn("deferredCleanup:outer", e);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runDeferredCleanupInner(
|
|
45
|
+
store: SurrealStore,
|
|
46
|
+
embeddings: EmbeddingService,
|
|
47
|
+
complete: CompleteFn,
|
|
26
48
|
): Promise<number> {
|
|
27
49
|
if (!store.isAvailable()) return 0;
|
|
28
50
|
|
|
29
|
-
const orphaned = await store.getOrphanedSessions(
|
|
51
|
+
const orphaned = await store.getOrphanedSessions(10).catch(() => []);
|
|
30
52
|
if (orphaned.length === 0) return 0;
|
|
31
53
|
|
|
54
|
+
// Immediately claim all orphaned sessions so no concurrent run can pick them up
|
|
55
|
+
await Promise.all(
|
|
56
|
+
orphaned.map(s =>
|
|
57
|
+
store.markSessionEnded(s.id).catch(e => swallow("deferred:claim", e))
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
|
|
32
61
|
let processed = 0;
|
|
33
62
|
|
|
34
63
|
const cleanup = async () => {
|
|
@@ -42,10 +71,10 @@ export async function runDeferredCleanup(
|
|
|
42
71
|
}
|
|
43
72
|
};
|
|
44
73
|
|
|
45
|
-
//
|
|
74
|
+
// 90s timeout — each session needs ~6s (2 LLM calls), 10 sessions ≈ 60s
|
|
46
75
|
await Promise.race([
|
|
47
76
|
cleanup(),
|
|
48
|
-
new Promise<void>(resolve => setTimeout(resolve,
|
|
77
|
+
new Promise<void>(resolve => setTimeout(resolve, 90_000)),
|
|
49
78
|
]);
|
|
50
79
|
|
|
51
80
|
return processed;
|
|
@@ -57,25 +86,15 @@ async function processOrphanedSession(
|
|
|
57
86
|
embeddings: EmbeddingService,
|
|
58
87
|
complete: CompleteFn,
|
|
59
88
|
): Promise<void> {
|
|
60
|
-
//
|
|
61
|
-
// (turns use the OpenClaw session_id, not the surreal record ID)
|
|
62
|
-
const sessionTurns = await store.queryFirst<{ session_id: string }>(
|
|
63
|
-
`SELECT session_id FROM turn WHERE session_id != NONE ORDER BY created_at DESC LIMIT 1`,
|
|
64
|
-
).catch(() => []);
|
|
65
|
-
|
|
66
|
-
// Load turns for extraction
|
|
67
|
-
// We need to find turns associated with this DB session via the part_of edge
|
|
89
|
+
// Load turns for extraction via part_of edges (turn->part_of->session)
|
|
68
90
|
const turns = await store.queryFirst<{ role: string; text: string; tool_name?: string }>(
|
|
69
|
-
`SELECT role, text, tool_name FROM turn
|
|
70
|
-
WHERE
|
|
71
|
-
OR session_id = $sid
|
|
91
|
+
`SELECT role, text, tool_name, created_at FROM turn
|
|
92
|
+
WHERE id IN (SELECT VALUE in FROM part_of WHERE out = $sid)
|
|
72
93
|
ORDER BY created_at ASC LIMIT 50`,
|
|
73
94
|
{ sid: surrealSessionId },
|
|
74
95
|
).catch(() => []);
|
|
75
96
|
|
|
76
97
|
if (turns.length < 2) {
|
|
77
|
-
// Nothing to extract, just mark complete
|
|
78
|
-
await store.markSessionEnded(surrealSessionId).catch(e => swallow("deferred:markEmpty", e));
|
|
79
98
|
return;
|
|
80
99
|
}
|
|
81
100
|
|
|
@@ -86,12 +105,14 @@ async function processOrphanedSession(
|
|
|
86
105
|
const systemPrompt = buildSystemPrompt(false, false, priorState);
|
|
87
106
|
|
|
88
107
|
try {
|
|
108
|
+
console.warn(`[deferred] extracting session ${surrealSessionId} (${turns.length} turns, transcript ${transcript.length} chars)`);
|
|
89
109
|
const response = await complete({
|
|
90
110
|
system: systemPrompt,
|
|
91
111
|
messages: [{ role: "user", content: `[TRANSCRIPT]\n${transcript.slice(0, 60000)}` }],
|
|
92
112
|
});
|
|
93
113
|
|
|
94
114
|
const responseText = response.text;
|
|
115
|
+
console.warn(`[deferred] extraction response: ${responseText.length} chars`);
|
|
95
116
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
|
96
117
|
if (jsonMatch) {
|
|
97
118
|
let result: Record<string, any>;
|
|
@@ -103,10 +124,14 @@ async function processOrphanedSession(
|
|
|
103
124
|
} catch { result = {}; }
|
|
104
125
|
}
|
|
105
126
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
const keys = Object.keys(result);
|
|
128
|
+
console.warn(`[deferred] parsed ${keys.length} keys: ${keys.join(", ")}`);
|
|
129
|
+
if (keys.length > 0) {
|
|
130
|
+
await writeExtractionResults(result, surrealSessionId, store, embeddings, priorState);
|
|
131
|
+
console.warn(`[deferred] wrote extraction results for ${surrealSessionId}`);
|
|
109
132
|
}
|
|
133
|
+
} else {
|
|
134
|
+
console.warn(`[deferred] no JSON found in response`);
|
|
110
135
|
}
|
|
111
136
|
} catch (e) {
|
|
112
137
|
swallow.warn("deferredCleanup:extraction", e);
|
|
@@ -125,6 +150,7 @@ async function processOrphanedSession(
|
|
|
125
150
|
});
|
|
126
151
|
|
|
127
152
|
const handoffText = handoffResponse.text.trim();
|
|
153
|
+
console.warn(`[deferred] handoff response: ${handoffText.length} chars`);
|
|
128
154
|
if (handoffText.length > 20) {
|
|
129
155
|
let emb: number[] | null = null;
|
|
130
156
|
if (embeddings.isAvailable()) {
|
|
@@ -135,7 +161,4 @@ async function processOrphanedSession(
|
|
|
135
161
|
} catch (e) {
|
|
136
162
|
swallow.warn("deferredCleanup:handoff", e);
|
|
137
163
|
}
|
|
138
|
-
|
|
139
|
-
// Mark session as cleaned up
|
|
140
|
-
await store.markSessionEnded(surrealSessionId).catch(e => swallow("deferred:markDone", e));
|
|
141
164
|
}
|
package/src/embeddings.ts
CHANGED
|
@@ -15,7 +15,9 @@ export class EmbeddingService {
|
|
|
15
15
|
|
|
16
16
|
constructor(private readonly config: EmbeddingConfig) {}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** Initialize the embedding model. Returns true if freshly loaded, false if already ready. */
|
|
19
|
+
async initialize(): Promise<boolean> {
|
|
20
|
+
if (this.ready) return false;
|
|
19
21
|
if (!existsSync(this.config.modelPath)) {
|
|
20
22
|
throw new Error(
|
|
21
23
|
`Embedding model not found at: ${this.config.modelPath}\n Download BGE-M3 GGUF or set EMBED_MODEL_PATH`,
|
|
@@ -33,6 +35,7 @@ export class EmbeddingService {
|
|
|
33
35
|
this.model = await llama.loadModel({ modelPath: this.config.modelPath });
|
|
34
36
|
this.ctx = await this.model.createEmbeddingContext();
|
|
35
37
|
this.ready = true;
|
|
38
|
+
return true;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
async embed(text: string): Promise<number[]> {
|
package/src/index.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
|
-
import {
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
10
11
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
11
12
|
import { parsePluginConfig } from "./config.js";
|
|
12
13
|
import { SurrealStore } from "./surreal.js";
|
|
13
14
|
import { EmbeddingService } from "./embeddings.js";
|
|
14
|
-
import { GlobalPluginState } from "./state.js";
|
|
15
|
+
import { GlobalPluginState, type CompleteFn } from "./state.js";
|
|
15
16
|
import { KongBrainContextEngine } from "./context-engine.js";
|
|
16
17
|
import { createRecallToolDef } from "./tools/recall.js";
|
|
17
18
|
import { createCoreMemoryToolDef } from "./tools/core-memory.js";
|
|
@@ -32,11 +33,28 @@ import { writeHandoffFileSync } from "./handoff-file.js";
|
|
|
32
33
|
import { runDeferredCleanup } from "./deferred-cleanup.js";
|
|
33
34
|
import { swallow } from "./errors.js";
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// Use process-global symbols so state survives Jiti re-importing the module.
|
|
37
|
+
// Jiti may load this file multiple times (fresh module scope each time),
|
|
38
|
+
// but process.env and Symbol.for() are process-wide singletons.
|
|
39
|
+
const GLOBAL_KEY = Symbol.for("kongbrain.globalState");
|
|
40
|
+
const REGISTERED_KEY = Symbol.for("kongbrain.registered");
|
|
41
|
+
|
|
42
|
+
function getGlobalState(): GlobalPluginState | null {
|
|
43
|
+
return (globalThis as any)[GLOBAL_KEY] ?? null;
|
|
44
|
+
}
|
|
45
|
+
function setGlobalState(state: GlobalPluginState): void {
|
|
46
|
+
(globalThis as any)[GLOBAL_KEY] = state;
|
|
47
|
+
}
|
|
48
|
+
function isRegistered(): boolean {
|
|
49
|
+
return (globalThis as any)[REGISTERED_KEY] === true;
|
|
50
|
+
}
|
|
51
|
+
function markRegistered(): void {
|
|
52
|
+
(globalThis as any)[REGISTERED_KEY] = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
let shutdownPromise: Promise<void> | null = null;
|
|
37
56
|
let registeredExitHandler: (() => void) | null = null;
|
|
38
57
|
let registeredSyncExitHandler: (() => void) | null = null;
|
|
39
|
-
let registered = false;
|
|
40
58
|
|
|
41
59
|
/**
|
|
42
60
|
* Run the critical session-end extraction for all active sessions.
|
|
@@ -294,13 +312,79 @@ export default definePluginEntry({
|
|
|
294
312
|
const config = parsePluginConfig(api.pluginConfig as Record<string, unknown> | undefined);
|
|
295
313
|
const logger = api.logger;
|
|
296
314
|
|
|
297
|
-
// Initialize shared resources — reuse existing
|
|
298
|
-
// multiple times (OpenClaw may invoke the factory more than once
|
|
299
|
-
//
|
|
315
|
+
// Initialize shared resources — reuse existing state if register() is called
|
|
316
|
+
// multiple times (OpenClaw may invoke the factory more than once, and Jiti may
|
|
317
|
+
// re-import the module creating fresh module scope). Process-global symbols
|
|
318
|
+
// ensure a single instance survives across module reloads.
|
|
319
|
+
let globalState = getGlobalState();
|
|
300
320
|
if (!globalState) {
|
|
301
321
|
const store = new SurrealStore(config.surreal);
|
|
302
322
|
const embeddings = new EmbeddingService(config.embedding);
|
|
303
|
-
|
|
323
|
+
// Build a CompleteFn using pi-ai directly since api.runtime.complete
|
|
324
|
+
// is not available in OpenClaw 2026.3.24 (unreleased feature).
|
|
325
|
+
const apiRef = api;
|
|
326
|
+
// Resolve pi-ai from openclaw's node_modules. pi-ai is ESM-only so
|
|
327
|
+
// require() can't load it. Walk up from process.argv[1] to find it,
|
|
328
|
+
// then lazy-load via import() on first use.
|
|
329
|
+
let piAi: { getModel: any; completeSimple: any } | null = null;
|
|
330
|
+
let piAiPath: string | null = null;
|
|
331
|
+
{
|
|
332
|
+
let dir = dirname(process.argv[1] || __filename);
|
|
333
|
+
for (let i = 0; i < 10; i++) {
|
|
334
|
+
const candidate = join(dir, "node_modules", "@mariozechner", "pi-ai", "dist", "index.js");
|
|
335
|
+
if (existsSync(candidate)) { piAiPath = candidate; break; }
|
|
336
|
+
const parent = dirname(dir);
|
|
337
|
+
if (parent === dir) break;
|
|
338
|
+
dir = parent;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const complete: CompleteFn = async (params) => {
|
|
343
|
+
// Try runtime.complete first (future-proof for when it ships)
|
|
344
|
+
if (typeof apiRef.runtime?.complete === "function") {
|
|
345
|
+
return apiRef.runtime.complete(params);
|
|
346
|
+
}
|
|
347
|
+
if (!piAi) {
|
|
348
|
+
if (!piAiPath) {
|
|
349
|
+
throw new Error("LLM completion not available: @mariozechner/pi-ai not found and runtime.complete missing");
|
|
350
|
+
}
|
|
351
|
+
piAi = await import(piAiPath);
|
|
352
|
+
}
|
|
353
|
+
// Fall back to calling pi-ai directly (runtime.complete not in OpenClaw 2026.3.24)
|
|
354
|
+
const provider = params.provider ?? apiRef.runtime.agent.defaults.provider;
|
|
355
|
+
const modelId = params.model ?? apiRef.runtime.agent.defaults.model;
|
|
356
|
+
const model = piAi!.getModel(provider, modelId);
|
|
357
|
+
if (!model) {
|
|
358
|
+
throw new Error(`Model "${modelId}" not found for provider "${provider}"`);
|
|
359
|
+
}
|
|
360
|
+
// Resolve auth via OpenClaw's runtime (handles profiles, env vars, etc.)
|
|
361
|
+
const cfg = apiRef.runtime.config.loadConfig();
|
|
362
|
+
const auth = await apiRef.runtime.modelAuth.getApiKeyForModel({ model, cfg });
|
|
363
|
+
// Build context
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
const messages: any[] = params.messages.map(m =>
|
|
366
|
+
m.role === "user"
|
|
367
|
+
? { role: "user", content: m.content, timestamp: now }
|
|
368
|
+
: { role: "assistant", content: [{ type: "text", text: m.content }],
|
|
369
|
+
api: model.api, provider: model.provider, model: model.id,
|
|
370
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
371
|
+
stopReason: "stop", timestamp: now }
|
|
372
|
+
);
|
|
373
|
+
const context = { systemPrompt: params.system, messages };
|
|
374
|
+
// Pass apiKey directly in options so the provider can use it
|
|
375
|
+
const response = await piAi!.completeSimple(model, context, {
|
|
376
|
+
apiKey: auth.apiKey,
|
|
377
|
+
});
|
|
378
|
+
let text = "";
|
|
379
|
+
let thinking: string | undefined;
|
|
380
|
+
for (const block of response.content) {
|
|
381
|
+
if (block.type === "text") text += block.text;
|
|
382
|
+
else if ((block as any).type === "thinking") thinking = (thinking ?? "") + (block as any).thinking;
|
|
383
|
+
}
|
|
384
|
+
return { text, thinking, usage: { input: response.usage.input, output: response.usage.output } };
|
|
385
|
+
};
|
|
386
|
+
globalState = new GlobalPluginState(config, store, embeddings, complete);
|
|
387
|
+
setGlobalState(globalState);
|
|
304
388
|
}
|
|
305
389
|
globalState.workspaceDir = api.resolvePath(".");
|
|
306
390
|
globalState.enqueueSystemEvent = (text, opts) =>
|
|
@@ -312,19 +396,19 @@ export default definePluginEntry({
|
|
|
312
396
|
api.registerContextEngine("kongbrain", async () => {
|
|
313
397
|
const { store, embeddings } = state;
|
|
314
398
|
|
|
315
|
-
// Connect to SurrealDB
|
|
399
|
+
// Connect to SurrealDB (no-op if already connected)
|
|
316
400
|
try {
|
|
317
|
-
await store.initialize();
|
|
318
|
-
logger.info(`SurrealDB connected: ${config.surreal.url}`);
|
|
401
|
+
const freshConnect = await store.initialize();
|
|
402
|
+
if (freshConnect) logger.info(`SurrealDB connected: ${config.surreal.url}`);
|
|
319
403
|
} catch (e) {
|
|
320
404
|
logger.error(`SurrealDB connection failed: ${e}`);
|
|
321
405
|
throw e;
|
|
322
406
|
}
|
|
323
407
|
|
|
324
|
-
// Initialize BGE-M3 embeddings
|
|
408
|
+
// Initialize BGE-M3 embeddings (no-op if already loaded)
|
|
325
409
|
try {
|
|
326
|
-
await embeddings.initialize();
|
|
327
|
-
logger.info(`BGE-M3 embeddings initialized: ${config.embedding.modelPath}`);
|
|
410
|
+
const freshEmbed = await embeddings.initialize();
|
|
411
|
+
if (freshEmbed) logger.info(`BGE-M3 embeddings initialized: ${config.embedding.modelPath}`);
|
|
328
412
|
} catch (e) {
|
|
329
413
|
logger.warn(`Embeddings init failed — running in degraded mode: ${e}`);
|
|
330
414
|
}
|
|
@@ -339,7 +423,7 @@ export default definePluginEntry({
|
|
|
339
423
|
|
|
340
424
|
// ── Hook handlers (register once — register() may be called multiple times) ──
|
|
341
425
|
|
|
342
|
-
if (!
|
|
426
|
+
if (!isRegistered()) {
|
|
343
427
|
api.on("before_prompt_build", createBeforePromptBuildHandler(globalState));
|
|
344
428
|
api.on("before_tool_call", createBeforeToolCallHandler(globalState));
|
|
345
429
|
api.on("after_tool_call", createAfterToolCallHandler(globalState));
|
|
@@ -348,7 +432,8 @@ export default definePluginEntry({
|
|
|
348
432
|
|
|
349
433
|
// ── Session lifecycle (also register once) ─────────────────────────
|
|
350
434
|
|
|
351
|
-
if (!
|
|
435
|
+
if (!isRegistered()) api.on("session_start", async (event) => {
|
|
436
|
+
const globalState = getGlobalState();
|
|
352
437
|
if (!globalState) return;
|
|
353
438
|
const sessionKey = event.sessionKey ?? event.sessionId;
|
|
354
439
|
const session = globalState.getOrCreateSession(sessionKey, event.sessionId);
|
|
@@ -377,7 +462,7 @@ export default definePluginEntry({
|
|
|
377
462
|
config.surreal,
|
|
378
463
|
config.embedding,
|
|
379
464
|
session.sessionId,
|
|
380
|
-
|
|
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 (!
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
479
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
/** Connect and run schema. Returns true if a new connection was made, false if already initialized. */
|
|
116
|
+
async initialize(): Promise<boolean> {
|
|
117
|
+
// Only connect once — subsequent calls are no-ops.
|
|
118
|
+
// This prevents register()/factory re-invocations from disrupting
|
|
119
|
+
// in-flight operations (deferred cleanup, daemon extraction).
|
|
120
|
+
// Don't check isConnected — ensureConnected() handles reconnection.
|
|
121
|
+
if (this.initialized) return false;
|
|
115
122
|
await this.db.connect(this.config.url, {
|
|
116
123
|
namespace: this.config.ns,
|
|
117
124
|
database: this.config.db,
|
|
118
125
|
authentication: { username: this.config.user, password: this.config.pass },
|
|
119
126
|
});
|
|
120
127
|
await this.runSchema();
|
|
128
|
+
this.initialized = true;
|
|
129
|
+
return true;
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
markShutdown(): void {
|
|
@@ -218,16 +227,43 @@ export class SurrealStore {
|
|
|
218
227
|
}
|
|
219
228
|
}
|
|
220
229
|
|
|
230
|
+
/** Returns true if an error is a connection-level failure worth retrying. */
|
|
231
|
+
private isConnectionError(e: unknown): boolean {
|
|
232
|
+
const msg = String((e as any)?.message ?? e);
|
|
233
|
+
return msg.includes("must be connected") || msg.includes("ConnectionUnavailable");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Run a query function with one retry on connection errors. */
|
|
237
|
+
private async withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
238
|
+
try {
|
|
239
|
+
return await fn();
|
|
240
|
+
} catch (e) {
|
|
241
|
+
if (!this.isConnectionError(e)) throw e;
|
|
242
|
+
// Connection died — force a fresh connection (close stale socket first)
|
|
243
|
+
this.initialized = false;
|
|
244
|
+
try { await this.db?.close(); } catch { /* ignore */ }
|
|
245
|
+
this.db = new Surreal();
|
|
246
|
+
await this.db.connect(this.config.url, {
|
|
247
|
+
namespace: this.config.ns,
|
|
248
|
+
database: this.config.db,
|
|
249
|
+
authentication: { username: this.config.user, password: this.config.pass },
|
|
250
|
+
});
|
|
251
|
+
return await fn();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
221
255
|
// ── Query helpers ──────────────────────────────────────────────────────
|
|
222
256
|
|
|
223
257
|
async queryFirst<T>(sql: string, bindings?: Record<string, unknown>): Promise<T[]> {
|
|
224
258
|
await this.ensureConnected();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
259
|
+
return this.withRetry(async () => {
|
|
260
|
+
const ns = this.config.ns;
|
|
261
|
+
const dbName = this.config.db;
|
|
262
|
+
const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
|
|
263
|
+
const result = await this.db.query<[T[]]>(fullSql, bindings);
|
|
264
|
+
const rows = Array.isArray(result) ? result[result.length - 1] : result;
|
|
265
|
+
return (Array.isArray(rows) ? rows : []).filter(Boolean);
|
|
266
|
+
});
|
|
231
267
|
}
|
|
232
268
|
|
|
233
269
|
async queryMulti<T = unknown>(
|
|
@@ -235,20 +271,24 @@ export class SurrealStore {
|
|
|
235
271
|
bindings?: Record<string, unknown>,
|
|
236
272
|
): Promise<T | undefined> {
|
|
237
273
|
await this.ensureConnected();
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
274
|
+
return this.withRetry(async () => {
|
|
275
|
+
const ns = this.config.ns;
|
|
276
|
+
const dbName = this.config.db;
|
|
277
|
+
const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
|
|
278
|
+
const raw = await this.db.query(fullSql, bindings);
|
|
279
|
+
const flat = (raw as unknown[]).flat();
|
|
280
|
+
return flat[flat.length - 1] as T | undefined;
|
|
281
|
+
});
|
|
244
282
|
}
|
|
245
283
|
|
|
246
284
|
async queryExec(sql: string, bindings?: Record<string, unknown>): Promise<void> {
|
|
247
285
|
await this.ensureConnected();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
286
|
+
return this.withRetry(async () => {
|
|
287
|
+
const ns = this.config.ns;
|
|
288
|
+
const dbName = this.config.db;
|
|
289
|
+
const fullSql = `USE NS ${ns} DB ${dbName}; ${patchOrderByFields(sql)}`;
|
|
290
|
+
await this.db.query(fullSql, bindings);
|
|
291
|
+
});
|
|
252
292
|
}
|
|
253
293
|
|
|
254
294
|
private async safeQuery(
|