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.
@@ -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
+ }
@@ -30,23 +30,25 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
30
30
  const session = state.getSession(sessionKey);
31
31
  if (!session) return;
32
32
 
33
- // Track token usage
34
- if (event.usage) {
35
- const inputTokens = event.usage.input ?? 0;
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
- // Update session stats in SurrealDB
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.sessionId,
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
- // Accumulate for daemon batching
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
- // Store assistant turn with embedding
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
- try {
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 Opus call is critical and needs the full 45s.
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
- const store = new SurrealStore(config.surreal);
269
- const embeddings = new EmbeddingService(config.embedding);
270
- globalState = new GlobalPluginState(config, store, embeddings, api.runtime.complete);
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(globalState!);
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
- // OpenClaw's session_end is fire-and-forget and doesn't fire on CLI exit.
394
- // Register a process exit handler to ensure the critical Opus extraction
395
- // completes even when the user exits with Ctrl+D or /exit.
396
- // Clean up previous listeners first (register() can be called multiple times).
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
- const onProcessExit = () => {
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
- registeredExitHandler = onProcessExit;
419
- process.once("beforeExit", onProcessExit);
420
- process.once("SIGINT", onProcessExit);
421
- process.once("SIGTERM", onProcessExit);
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");