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 +1 -1
- 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 +126 -39
- package/src/state.ts +23 -3
- package/src/surreal.ts +57 -17
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.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
477
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|