kongbrain 0.1.1 → 0.1.3
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 +6 -5
- package/package.json +1 -1
- package/src/context-engine.ts +16 -8
- package/src/daemon-manager.ts +183 -99
- package/src/daemon-types.ts +1 -48
- package/src/deferred-cleanup.ts +141 -0
- package/src/handoff-file.ts +55 -0
- package/src/hooks/llm-output.ts +12 -91
- package/src/index.ts +90 -18
- package/src/memory-daemon.ts +181 -376
- package/src/reflection.ts +1 -1
- package/src/schema.surql +2 -0
- package/src/state.ts +6 -1
- package/src/surreal.ts +24 -0
- package/src/wakeup.ts +13 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync handoff file — last-resort session continuity bridge.
|
|
3
|
+
*
|
|
4
|
+
* When the process dies (Ctrl+C×2), there's no async cleanup window.
|
|
5
|
+
* This module writes a minimal JSON snapshot synchronously on exit
|
|
6
|
+
* so the next session's wakeup has context even before deferred
|
|
7
|
+
* extraction runs.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const HANDOFF_FILENAME = ".kongbrain-handoff.json";
|
|
13
|
+
|
|
14
|
+
export interface HandoffFileData {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
userTurnCount: number;
|
|
18
|
+
lastUserText: string;
|
|
19
|
+
lastAssistantText: string;
|
|
20
|
+
unextractedTokens: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Synchronously write a handoff file. Safe to call from process.on("exit").
|
|
25
|
+
*/
|
|
26
|
+
export function writeHandoffFileSync(
|
|
27
|
+
data: HandoffFileData,
|
|
28
|
+
workspaceDir: string,
|
|
29
|
+
): void {
|
|
30
|
+
try {
|
|
31
|
+
const path = join(workspaceDir, HANDOFF_FILENAME);
|
|
32
|
+
writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
|
|
33
|
+
} catch {
|
|
34
|
+
// Best-effort — sync exit handler, can't log async
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read and delete the handoff file. Returns null if not found.
|
|
40
|
+
*/
|
|
41
|
+
export function readAndDeleteHandoffFile(
|
|
42
|
+
workspaceDir: string,
|
|
43
|
+
): HandoffFileData | null {
|
|
44
|
+
const path = join(workspaceDir, HANDOFF_FILENAME);
|
|
45
|
+
if (!existsSync(path)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(path, "utf-8");
|
|
48
|
+
unlinkSync(path);
|
|
49
|
+
return JSON.parse(raw) as HandoffFileData;
|
|
50
|
+
} catch {
|
|
51
|
+
// Corrupted or deleted between check and read
|
|
52
|
+
try { unlinkSync(path); } catch { /* ignore */ }
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/hooks/llm-output.ts
CHANGED
|
@@ -30,23 +30,25 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
|
|
|
30
30
|
const session = state.getSession(sessionKey);
|
|
31
31
|
if (!session) return;
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const outputTokens = event.usage.output ?? 0;
|
|
33
|
+
// Extract token counts (0 if provider didn't report usage)
|
|
34
|
+
const inputTokens = event.usage?.input ?? 0;
|
|
35
|
+
const outputTokens = event.usage?.output ?? 0;
|
|
37
36
|
|
|
38
|
-
|
|
37
|
+
// Always update session stats — turn_count must increment even without usage data
|
|
38
|
+
if (session.surrealSessionId) {
|
|
39
39
|
try {
|
|
40
40
|
await state.store.updateSessionStats(
|
|
41
|
-
session.
|
|
41
|
+
session.surrealSessionId,
|
|
42
42
|
inputTokens,
|
|
43
43
|
outputTokens,
|
|
44
44
|
);
|
|
45
45
|
} catch (e) {
|
|
46
46
|
swallow("hook:llmOutput:sessionStats", e);
|
|
47
47
|
}
|
|
48
|
+
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
// Accumulate for daemon batching (only when real tokens present)
|
|
51
|
+
if (inputTokens + outputTokens > 0) {
|
|
50
52
|
session.newContentTokens += inputTokens + outputTokens;
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -80,94 +82,13 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
|
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
//
|
|
85
|
+
// Track lastAssistantText for downstream use (afterTurn, daemon batching).
|
|
86
|
+
// Turn creation is handled by afterTurn() -> ingest() in context-engine.ts.
|
|
84
87
|
if (event.assistantTexts.length > 0) {
|
|
85
88
|
const text = event.assistantTexts.join("\n");
|
|
86
89
|
if (text.length > 0) {
|
|
87
|
-
|
|
88
|
-
const embedLimit = Math.round(8192 * 3.4 * 0.8);
|
|
89
|
-
let embedding: number[] | null = null;
|
|
90
|
-
if (hasSemantic(text) && state.embeddings.isAvailable()) {
|
|
91
|
-
try {
|
|
92
|
-
embedding = await state.embeddings.embed(text.slice(0, embedLimit));
|
|
93
|
-
} catch (e) { swallow("hook:llmOutput:embed", e); }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const turnId = await state.store.upsertTurn({
|
|
97
|
-
session_id: session.sessionId,
|
|
98
|
-
role: "assistant",
|
|
99
|
-
text,
|
|
100
|
-
embedding,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
if (turnId) {
|
|
104
|
-
await state.store.relate(turnId, "part_of", session.sessionId)
|
|
105
|
-
.catch(e => swallow("hook:llmOutput:relate", e));
|
|
106
|
-
|
|
107
|
-
// Extract and link concepts
|
|
108
|
-
if (hasSemantic(text)) {
|
|
109
|
-
extractAndLinkConcepts(turnId, text, state)
|
|
110
|
-
.catch(e => swallow.warn("hook:llmOutput:concepts", e));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
session.lastAssistantText = text;
|
|
115
|
-
} catch (e) {
|
|
116
|
-
swallow.warn("hook:llmOutput:storeTurn", e);
|
|
117
|
-
}
|
|
90
|
+
session.lastAssistantText = text;
|
|
118
91
|
}
|
|
119
92
|
}
|
|
120
93
|
};
|
|
121
94
|
}
|
|
122
|
-
|
|
123
|
-
function hasSemantic(text: string): boolean {
|
|
124
|
-
if (text.length < 15) return false;
|
|
125
|
-
if (/^(ok|yes|no|sure|thanks|done|got it|hmm|hm|yep|nope|cool|nice|great)\s*[.!?]?\s*$/i.test(text)) {
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
return text.split(/\s+/).filter(w => w.length > 2).length >= 3;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// --- Concept extraction ---
|
|
132
|
-
|
|
133
|
-
const CONCEPT_RE = /\b(?:(?:use|using|implement|create|add|configure|setup|install|import)\s+)([A-Z][a-zA-Z0-9_-]+(?:\s+[A-Z][a-zA-Z0-9_-]+)?)/g;
|
|
134
|
-
const TECH_TERMS = /\b(api|database|schema|migration|endpoint|middleware|component|service|module|handler|controller|model|interface|type|class|function|method|hook|plugin|extension|config|cache|queue|worker|daemon)\b/gi;
|
|
135
|
-
|
|
136
|
-
async function extractAndLinkConcepts(
|
|
137
|
-
turnId: string,
|
|
138
|
-
text: string,
|
|
139
|
-
state: GlobalPluginState,
|
|
140
|
-
): Promise<void> {
|
|
141
|
-
const concepts = new Set<string>();
|
|
142
|
-
|
|
143
|
-
// Named concepts (PascalCase after action verbs)
|
|
144
|
-
let match: RegExpExecArray | null;
|
|
145
|
-
const re1 = new RegExp(CONCEPT_RE.source, CONCEPT_RE.flags);
|
|
146
|
-
while ((match = re1.exec(text)) !== null) {
|
|
147
|
-
concepts.add(match[1].trim());
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Technical terms
|
|
151
|
-
const re2 = new RegExp(TECH_TERMS.source, TECH_TERMS.flags);
|
|
152
|
-
while ((match = re2.exec(text)) !== null) {
|
|
153
|
-
concepts.add(match[1].toLowerCase());
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (concepts.size === 0) return;
|
|
157
|
-
|
|
158
|
-
for (const conceptText of [...concepts].slice(0, 10)) {
|
|
159
|
-
try {
|
|
160
|
-
let embedding: number[] | null = null;
|
|
161
|
-
if (state.embeddings.isAvailable()) {
|
|
162
|
-
try { embedding = await state.embeddings.embed(conceptText); } catch { /* ok */ }
|
|
163
|
-
}
|
|
164
|
-
const conceptId = await state.store.upsertConcept(conceptText, embedding);
|
|
165
|
-
if (conceptId) {
|
|
166
|
-
await state.store.relate(turnId, "mentions", conceptId)
|
|
167
|
-
.catch(e => swallow("concepts:relate", e));
|
|
168
|
-
}
|
|
169
|
-
} catch (e) {
|
|
170
|
-
swallow("concepts:upsert", e);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -28,11 +28,14 @@ import { generateReflection, setReflectionContextWindow } from "./reflection.js"
|
|
|
28
28
|
import { graduateCausalToSkills } from "./skills.js";
|
|
29
29
|
import { attemptGraduation, evolveSoul, checkStageTransition } from "./soul.js";
|
|
30
30
|
import { hasMigratableFiles, migrateWorkspace } from "./workspace-migrate.js";
|
|
31
|
+
import { writeHandoffFileSync } from "./handoff-file.js";
|
|
32
|
+
import { runDeferredCleanup } from "./deferred-cleanup.js";
|
|
31
33
|
import { swallow } from "./errors.js";
|
|
32
34
|
|
|
33
35
|
let globalState: GlobalPluginState | null = null;
|
|
34
36
|
let shutdownPromise: Promise<void> | null = null;
|
|
35
37
|
let registeredExitHandler: (() => void) | null = null;
|
|
38
|
+
let registeredSyncExitHandler: (() => void) | null = null;
|
|
36
39
|
let registered = false;
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -92,7 +95,7 @@ async function runSessionCleanup(
|
|
|
92
95
|
.catch(e => { swallow.warn("cleanup:soulGraduation", e); return null; });
|
|
93
96
|
endOps.push(graduationPromise);
|
|
94
97
|
|
|
95
|
-
// The session-end
|
|
98
|
+
// The session-end LLM call is critical and needs the full 45s.
|
|
96
99
|
await Promise.race([
|
|
97
100
|
Promise.allSettled(endOps),
|
|
98
101
|
new Promise(resolve => setTimeout(resolve, 45_000)),
|
|
@@ -171,6 +174,33 @@ async function runSessionCleanup(
|
|
|
171
174
|
} catch (e) {
|
|
172
175
|
swallow.warn("cleanup:stageTransition", e);
|
|
173
176
|
}
|
|
177
|
+
|
|
178
|
+
// Generate handoff note for next session wakeup
|
|
179
|
+
try {
|
|
180
|
+
const recentTurns = await s.getSessionTurns(session.sessionId, 15)
|
|
181
|
+
.catch(() => [] as { role: string; text: string }[]);
|
|
182
|
+
if (recentTurns.length >= 2) {
|
|
183
|
+
const turnSummary = recentTurns
|
|
184
|
+
.map(t => `[${t.role}] ${t.text.slice(0, 200)}`)
|
|
185
|
+
.join("\n");
|
|
186
|
+
|
|
187
|
+
const handoffResponse = await complete({
|
|
188
|
+
system: "Summarize this session for handoff to your next self. What was worked on, what's unfinished, what to remember. 2-3 sentences. Write in first person.",
|
|
189
|
+
messages: [{ role: "user", content: turnSummary }],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const handoffText = handoffResponse.text.trim();
|
|
193
|
+
if (handoffText.length > 20) {
|
|
194
|
+
let embedding: number[] | null = null;
|
|
195
|
+
if (emb.isAvailable()) {
|
|
196
|
+
try { embedding = await emb.embed(handoffText); } catch { /* ok */ }
|
|
197
|
+
}
|
|
198
|
+
await s.createMemory(handoffText, embedding, 8, "handoff", session.sessionId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
swallow.warn("cleanup:handoff", e);
|
|
203
|
+
}
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
/**
|
|
@@ -264,16 +294,24 @@ export default definePluginEntry({
|
|
|
264
294
|
const config = parsePluginConfig(api.pluginConfig as Record<string, unknown> | undefined);
|
|
265
295
|
const logger = api.logger;
|
|
266
296
|
|
|
267
|
-
// Initialize shared resources
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
300
|
+
if (!globalState) {
|
|
301
|
+
const store = new SurrealStore(config.surreal);
|
|
302
|
+
const embeddings = new EmbeddingService(config.embedding);
|
|
303
|
+
globalState = new GlobalPluginState(config, store, embeddings, api.runtime.complete);
|
|
304
|
+
}
|
|
271
305
|
globalState.workspaceDir = api.resolvePath(".");
|
|
272
306
|
globalState.enqueueSystemEvent = (text, opts) =>
|
|
273
307
|
api.runtime.system.enqueueSystemEvent(text, opts);
|
|
274
308
|
|
|
309
|
+
const state = globalState;
|
|
310
|
+
|
|
275
311
|
// Register the context engine factory
|
|
276
312
|
api.registerContextEngine("kongbrain", async () => {
|
|
313
|
+
const { store, embeddings } = state;
|
|
314
|
+
|
|
277
315
|
// Connect to SurrealDB
|
|
278
316
|
try {
|
|
279
317
|
await store.initialize();
|
|
@@ -296,7 +334,7 @@ export default definePluginEntry({
|
|
|
296
334
|
.then(n => { if (n > 0) logger.info(`Seeded ${n} identity chunks`); })
|
|
297
335
|
.catch(e => swallow.warn("factory:seedIdentity", e));
|
|
298
336
|
|
|
299
|
-
return new KongBrainContextEngine(
|
|
337
|
+
return new KongBrainContextEngine(state);
|
|
300
338
|
});
|
|
301
339
|
|
|
302
340
|
// ── Hook handlers ──────────────────────────────────────────────────
|
|
@@ -363,7 +401,7 @@ export default definePluginEntry({
|
|
|
363
401
|
|
|
364
402
|
// Synthesize wakeup briefing (background, non-blocking)
|
|
365
403
|
// The briefing is stored and later injected via assemble()'s systemPromptAddition
|
|
366
|
-
synthesizeWakeup(store, globalState!.complete, session.sessionId)
|
|
404
|
+
synthesizeWakeup(store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
|
|
367
405
|
.then(briefing => {
|
|
368
406
|
if (briefing) (session as any)._wakeupBriefing = briefing;
|
|
369
407
|
})
|
|
@@ -375,6 +413,11 @@ export default definePluginEntry({
|
|
|
375
413
|
if (cognition) (session as any)._startupCognition = cognition;
|
|
376
414
|
})
|
|
377
415
|
.catch(e => swallow.warn("index:startupCognition", e));
|
|
416
|
+
|
|
417
|
+
// Deferred cleanup: extract knowledge from orphaned sessions (background)
|
|
418
|
+
runDeferredCleanup(store, embeddings, globalState!.complete)
|
|
419
|
+
.then(n => { if (n > 0) logger.info(`Deferred cleanup: processed ${n} orphaned session(s)`); })
|
|
420
|
+
.catch(e => swallow.warn("index:deferredCleanup", e));
|
|
378
421
|
});
|
|
379
422
|
|
|
380
423
|
api.on("session_end", async (event) => {
|
|
@@ -387,20 +430,49 @@ export default definePluginEntry({
|
|
|
387
430
|
await shutdownPromise;
|
|
388
431
|
shutdownPromise = null;
|
|
389
432
|
|
|
433
|
+
session.cleanedUp = true;
|
|
434
|
+
if (session.surrealSessionId) {
|
|
435
|
+
await store.markSessionEnded(session.surrealSessionId)
|
|
436
|
+
.catch(e => swallow.warn("session_end:markEnded", e));
|
|
437
|
+
}
|
|
438
|
+
|
|
390
439
|
globalState.removeSession(sessionKey);
|
|
391
440
|
});
|
|
392
441
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
442
|
+
// -- Exit handlers --
|
|
443
|
+
// OpenClaw TUI calls process.exit(0) on Ctrl+C×2 with no async window.
|
|
444
|
+
// We use two layers:
|
|
445
|
+
// 1. process.on("exit") — SYNC: writes handoff file to disk
|
|
446
|
+
// 2. SIGTERM — async cleanup for non-TUI modes (gateway, daemon)
|
|
447
|
+
// We do NOT register SIGINT — TUI owns that signal and always wins the race.
|
|
448
|
+
|
|
449
|
+
// Clean up previous listeners (register() can be called multiple times)
|
|
397
450
|
if (registeredExitHandler) {
|
|
398
|
-
process.removeListener("beforeExit", registeredExitHandler);
|
|
399
|
-
process.removeListener("SIGINT", registeredExitHandler);
|
|
400
451
|
process.removeListener("SIGTERM", registeredExitHandler);
|
|
401
452
|
}
|
|
453
|
+
if (registeredSyncExitHandler) {
|
|
454
|
+
process.removeListener("exit", registeredSyncExitHandler);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Sync exit handler: writes handoff file for all uncleaned sessions
|
|
458
|
+
const syncExitHandler = () => {
|
|
459
|
+
if (!globalState?.workspaceDir) return;
|
|
460
|
+
const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
|
|
461
|
+
for (const session of sessions) {
|
|
462
|
+
if (session.cleanedUp) continue;
|
|
463
|
+
writeHandoffFileSync({
|
|
464
|
+
sessionId: session.sessionId,
|
|
465
|
+
timestamp: new Date().toISOString(),
|
|
466
|
+
userTurnCount: session.userTurnCount,
|
|
467
|
+
lastUserText: session.lastUserText.slice(0, 500),
|
|
468
|
+
lastAssistantText: session.lastAssistantText.slice(0, 500),
|
|
469
|
+
unextractedTokens: session.newContentTokens,
|
|
470
|
+
}, globalState!.workspaceDir!);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
402
473
|
|
|
403
|
-
|
|
474
|
+
// Async exit handler: full cleanup for SIGTERM (gateway/daemon mode)
|
|
475
|
+
const asyncExitHandler = () => {
|
|
404
476
|
if (!globalState) return;
|
|
405
477
|
const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
|
|
406
478
|
if (sessions.length === 0 && !shutdownPromise) return;
|
|
@@ -415,10 +487,10 @@ export default definePluginEntry({
|
|
|
415
487
|
done.then(() => process.exit(0)).catch(() => process.exit(1));
|
|
416
488
|
};
|
|
417
489
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
process.
|
|
421
|
-
process.once("SIGTERM",
|
|
490
|
+
registeredSyncExitHandler = syncExitHandler;
|
|
491
|
+
registeredExitHandler = asyncExitHandler;
|
|
492
|
+
process.on("exit", syncExitHandler);
|
|
493
|
+
process.once("SIGTERM", asyncExitHandler);
|
|
422
494
|
|
|
423
495
|
if (!registered) {
|
|
424
496
|
logger.info("KongBrain plugin registered");
|