kongbrain 0.1.2 → 0.1.4
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/context-engine.ts +15 -7
- 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 +99 -25
- package/src/memory-daemon.ts +181 -379
- package/src/schema.surql +2 -0
- package/src/state.ts +6 -1
- package/src/surreal.ts +24 -0
- package/src/wakeup.ts +13 -1
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
|
/**
|
|
@@ -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,19 +334,21 @@ 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
|
-
// ── Hook handlers
|
|
340
|
+
// ── Hook handlers (register once — register() may be called multiple times) ──
|
|
303
341
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
342
|
+
if (!registered) {
|
|
343
|
+
api.on("before_prompt_build", createBeforePromptBuildHandler(globalState));
|
|
344
|
+
api.on("before_tool_call", createBeforeToolCallHandler(globalState));
|
|
345
|
+
api.on("after_tool_call", createAfterToolCallHandler(globalState));
|
|
346
|
+
api.on("llm_output", createLlmOutputHandler(globalState));
|
|
347
|
+
}
|
|
308
348
|
|
|
309
|
-
// ── Session lifecycle
|
|
349
|
+
// ── Session lifecycle (also register once) ─────────────────────────
|
|
310
350
|
|
|
311
|
-
api.on("session_start", async (event) => {
|
|
351
|
+
if (!registered) api.on("session_start", async (event) => {
|
|
312
352
|
if (!globalState) return;
|
|
313
353
|
const sessionKey = event.sessionKey ?? event.sessionId;
|
|
314
354
|
const session = globalState.getOrCreateSession(sessionKey, event.sessionId);
|
|
@@ -363,7 +403,7 @@ export default definePluginEntry({
|
|
|
363
403
|
|
|
364
404
|
// Synthesize wakeup briefing (background, non-blocking)
|
|
365
405
|
// The briefing is stored and later injected via assemble()'s systemPromptAddition
|
|
366
|
-
synthesizeWakeup(store, globalState!.complete, session.sessionId)
|
|
406
|
+
synthesizeWakeup(store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
|
|
367
407
|
.then(briefing => {
|
|
368
408
|
if (briefing) (session as any)._wakeupBriefing = briefing;
|
|
369
409
|
})
|
|
@@ -375,9 +415,14 @@ export default definePluginEntry({
|
|
|
375
415
|
if (cognition) (session as any)._startupCognition = cognition;
|
|
376
416
|
})
|
|
377
417
|
.catch(e => swallow.warn("index:startupCognition", e));
|
|
418
|
+
|
|
419
|
+
// Deferred cleanup: extract knowledge from orphaned sessions (background)
|
|
420
|
+
runDeferredCleanup(store, embeddings, globalState!.complete)
|
|
421
|
+
.then(n => { if (n > 0) logger.info(`Deferred cleanup: processed ${n} orphaned session(s)`); })
|
|
422
|
+
.catch(e => swallow.warn("index:deferredCleanup", e));
|
|
378
423
|
});
|
|
379
424
|
|
|
380
|
-
api.on("session_end", async (event) => {
|
|
425
|
+
if (!registered) api.on("session_end", async (event) => {
|
|
381
426
|
if (!globalState) return;
|
|
382
427
|
const sessionKey = event.sessionKey ?? event.sessionId;
|
|
383
428
|
const session = globalState.getSession(sessionKey);
|
|
@@ -387,20 +432,49 @@ export default definePluginEntry({
|
|
|
387
432
|
await shutdownPromise;
|
|
388
433
|
shutdownPromise = null;
|
|
389
434
|
|
|
435
|
+
session.cleanedUp = true;
|
|
436
|
+
if (session.surrealSessionId) {
|
|
437
|
+
await store.markSessionEnded(session.surrealSessionId)
|
|
438
|
+
.catch(e => swallow.warn("session_end:markEnded", e));
|
|
439
|
+
}
|
|
440
|
+
|
|
390
441
|
globalState.removeSession(sessionKey);
|
|
391
442
|
});
|
|
392
443
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
444
|
+
// -- Exit handlers --
|
|
445
|
+
// OpenClaw TUI calls process.exit(0) on Ctrl+C×2 with no async window.
|
|
446
|
+
// We use two layers:
|
|
447
|
+
// 1. process.on("exit") — SYNC: writes handoff file to disk
|
|
448
|
+
// 2. SIGTERM — async cleanup for non-TUI modes (gateway, daemon)
|
|
449
|
+
// We do NOT register SIGINT — TUI owns that signal and always wins the race.
|
|
450
|
+
|
|
451
|
+
// Clean up previous listeners (register() can be called multiple times)
|
|
397
452
|
if (registeredExitHandler) {
|
|
398
|
-
process.removeListener("beforeExit", registeredExitHandler);
|
|
399
|
-
process.removeListener("SIGINT", registeredExitHandler);
|
|
400
453
|
process.removeListener("SIGTERM", registeredExitHandler);
|
|
401
454
|
}
|
|
455
|
+
if (registeredSyncExitHandler) {
|
|
456
|
+
process.removeListener("exit", registeredSyncExitHandler);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Sync exit handler: writes handoff file for all uncleaned sessions
|
|
460
|
+
const syncExitHandler = () => {
|
|
461
|
+
if (!globalState?.workspaceDir) return;
|
|
462
|
+
const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
|
|
463
|
+
for (const session of sessions) {
|
|
464
|
+
if (session.cleanedUp) continue;
|
|
465
|
+
writeHandoffFileSync({
|
|
466
|
+
sessionId: session.sessionId,
|
|
467
|
+
timestamp: new Date().toISOString(),
|
|
468
|
+
userTurnCount: session.userTurnCount,
|
|
469
|
+
lastUserText: session.lastUserText.slice(0, 500),
|
|
470
|
+
lastAssistantText: session.lastAssistantText.slice(0, 500),
|
|
471
|
+
unextractedTokens: session.newContentTokens,
|
|
472
|
+
}, globalState!.workspaceDir!);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
402
475
|
|
|
403
|
-
|
|
476
|
+
// Async exit handler: full cleanup for SIGTERM (gateway/daemon mode)
|
|
477
|
+
const asyncExitHandler = () => {
|
|
404
478
|
if (!globalState) return;
|
|
405
479
|
const sessions = [...(globalState as any).sessions.values()] as import("./state.js").SessionState[];
|
|
406
480
|
if (sessions.length === 0 && !shutdownPromise) return;
|
|
@@ -415,10 +489,10 @@ export default definePluginEntry({
|
|
|
415
489
|
done.then(() => process.exit(0)).catch(() => process.exit(1));
|
|
416
490
|
};
|
|
417
491
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
process.
|
|
421
|
-
process.once("SIGTERM",
|
|
492
|
+
registeredSyncExitHandler = syncExitHandler;
|
|
493
|
+
registeredExitHandler = asyncExitHandler;
|
|
494
|
+
process.on("exit", syncExitHandler);
|
|
495
|
+
process.once("SIGTERM", asyncExitHandler);
|
|
422
496
|
|
|
423
497
|
if (!registered) {
|
|
424
498
|
logger.info("KongBrain plugin registered");
|