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.
@@ -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
  /**
@@ -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,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(globalState!);
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
- api.on("before_prompt_build", createBeforePromptBuildHandler(globalState));
305
- api.on("before_tool_call", createBeforeToolCallHandler(globalState));
306
- api.on("after_tool_call", createAfterToolCallHandler(globalState));
307
- api.on("llm_output", createLlmOutputHandler(globalState));
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
- // 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 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).
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
- const onProcessExit = () => {
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
- registeredExitHandler = onProcessExit;
419
- process.once("beforeExit", onProcessExit);
420
- process.once("SIGINT", onProcessExit);
421
- process.once("SIGTERM", onProcessExit);
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");