kongbrain 0.2.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kongbrain",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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/acan.ts CHANGED
@@ -282,8 +282,9 @@ function trainInBackground(
282
282
  const STALENESS_GROWTH_FACTOR = 0.5;
283
283
  const STALENESS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
284
284
 
285
- export async function checkACANReadiness(store?: SurrealStore): Promise<void> {
285
+ export async function checkACANReadiness(store?: SurrealStore, trainingThreshold?: number): Promise<void> {
286
286
  if (!store) return;
287
+ const threshold = trainingThreshold ?? TRAINING_THRESHOLD;
287
288
  const weightsPath = join(getKongDir(), WEIGHTS_FILENAME);
288
289
  const hasWeights = initACAN();
289
290
  const count = await getTrainingDataCount(store);
@@ -295,13 +296,13 @@ export async function checkACANReadiness(store?: SurrealStore): Promise<void> {
295
296
  const ageMs = Date.now() - trainedAt;
296
297
  const isStale = growthRatio >= STALENESS_GROWTH_FACTOR || ageMs >= STALENESS_MAX_AGE_MS;
297
298
  if (!isStale) return;
298
- } else if (count < TRAINING_THRESHOLD) {
299
+ } else if (count < threshold) {
299
300
  return;
300
301
  }
301
302
 
302
303
  try {
303
304
  const samples = await fetchTrainingData(store);
304
- if (samples.length < TRAINING_THRESHOLD) return;
305
+ if (samples.length < threshold) return;
305
306
  trainInBackground(samples, weightsPath, hasWeights ? _weights ?? undefined : undefined);
306
307
  } catch {
307
308
  // training is best-effort
package/src/config.ts CHANGED
@@ -15,9 +15,23 @@ export interface EmbeddingConfig {
15
15
  dimensions: number;
16
16
  }
17
17
 
18
+ export interface ThresholdConfig {
19
+ /** Tokens accumulated before daemon flushes extraction (default: 4000) */
20
+ daemonTokenThreshold: number;
21
+ /** Cumulative tokens before mid-session cleanup fires (default: 100000) */
22
+ midSessionCleanupThreshold: number;
23
+ /** Per-extraction timeout in ms (default: 60000) */
24
+ extractionTimeoutMs: number;
25
+ /** Max pending thinking blocks kept in memory (default: 20) */
26
+ maxPendingThinking: number;
27
+ /** Retrieval outcome samples needed before ACAN training (default: 5000) */
28
+ acanTrainingThreshold: number;
29
+ }
30
+
18
31
  export interface KongBrainConfig {
19
32
  surreal: SurrealConfig;
20
33
  embedding: EmbeddingConfig;
34
+ thresholds: ThresholdConfig;
21
35
  }
22
36
 
23
37
  /**
@@ -27,6 +41,7 @@ export interface KongBrainConfig {
27
41
  export function parsePluginConfig(raw?: Record<string, unknown>): KongBrainConfig {
28
42
  const surreal = (raw?.surreal ?? {}) as Record<string, unknown>;
29
43
  const embedding = (raw?.embedding ?? {}) as Record<string, unknown>;
44
+ const thresholds = (raw?.thresholds ?? {}) as Record<string, unknown>;
30
45
 
31
46
  // Priority: plugin config > env vars > defaults
32
47
  const url =
@@ -60,5 +75,17 @@ export function parsePluginConfig(raw?: Record<string, unknown>): KongBrainConfi
60
75
  dimensions:
61
76
  typeof embedding.dimensions === "number" ? embedding.dimensions : 1024,
62
77
  },
78
+ thresholds: {
79
+ daemonTokenThreshold:
80
+ typeof thresholds.daemonTokenThreshold === "number" ? thresholds.daemonTokenThreshold : 4000,
81
+ midSessionCleanupThreshold:
82
+ typeof thresholds.midSessionCleanupThreshold === "number" ? thresholds.midSessionCleanupThreshold : 100_000,
83
+ extractionTimeoutMs:
84
+ typeof thresholds.extractionTimeoutMs === "number" ? thresholds.extractionTimeoutMs : 60_000,
85
+ maxPendingThinking:
86
+ typeof thresholds.maxPendingThinking === "number" ? thresholds.maxPendingThinking : 20,
87
+ acanTrainingThreshold:
88
+ typeof thresholds.acanTrainingThreshold === "number" ? thresholds.acanTrainingThreshold : 5000,
89
+ },
63
90
  };
64
91
  }
@@ -9,6 +9,7 @@ import { readFileSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { dirname, join } from "node:path";
11
11
  import type { AgentMessage } from "@mariozechner/pi-agent-core";
12
+ import { startMemoryDaemon } from "./daemon-manager.js";
12
13
  import type {
13
14
  ContextEngine, ContextEngineInfo,
14
15
  } from "openclaw/plugin-sdk";
@@ -46,6 +47,9 @@ import { shouldRunCheck, runCognitiveCheck } from "./cognitive-check.js";
46
47
  import { checkACANReadiness } from "./acan.js";
47
48
  import { predictQueries, prefetchContext } from "./prefetch.js";
48
49
  import { runDeferredCleanup } from "./deferred-cleanup.js";
50
+ import { extractSkill } from "./skills.js";
51
+ import { generateReflection } from "./reflection.js";
52
+ import { graduateCausalToSkills } from "./skills.js";
49
53
  import { swallow } from "./errors.js";
50
54
 
51
55
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -69,51 +73,64 @@ export class KongBrainContextEngine implements ContextEngine {
69
73
  }): Promise<BootstrapResult> {
70
74
  const { store, embeddings } = this.state;
71
75
 
72
- // Run schema if first bootstrap
73
- try {
74
- const schemaPath = join(__dirname, "..", "src", "schema.surql");
75
- let schemaSql: string;
76
+ // Run schema once per process (idempotent but expensive on every bootstrap)
77
+ if (!this.state.schemaApplied) {
76
78
  try {
77
- schemaSql = readFileSync(schemaPath, "utf-8");
78
- } catch {
79
- // Fallback: try relative to compiled output
80
- schemaSql = readFileSync(join(__dirname, "schema.surql"), "utf-8");
79
+ const schemaPath = join(__dirname, "..", "src", "schema.surql");
80
+ let schemaSql: string;
81
+ try {
82
+ schemaSql = readFileSync(schemaPath, "utf-8");
83
+ } catch {
84
+ // Fallback: try relative to compiled output
85
+ schemaSql = readFileSync(join(__dirname, "schema.surql"), "utf-8");
86
+ }
87
+ await store.queryExec(schemaSql);
88
+ this.state.schemaApplied = true;
89
+ } catch (e) {
90
+ swallow.warn("context-engine:schema", e);
81
91
  }
82
- await store.queryExec(schemaSql);
83
- } catch (e) {
84
- swallow.warn("context-engine:schema", e);
85
92
  }
86
93
 
87
94
  // 5-pillar graph init
88
95
  const sessionKey = params.sessionKey ?? params.sessionId;
89
96
  const session = this.state.getOrCreateSession(sessionKey, params.sessionId);
90
97
 
91
- try {
92
- const workspace = this.state.workspaceDir || process.cwd();
93
- const projectName = workspace.split("/").pop() || "default";
94
-
95
- session.agentId = await store.ensureAgent("kongbrain", "openclaw-default");
96
- session.projectId = await store.ensureProject(projectName);
97
- await store.linkAgentToProject(session.agentId, session.projectId)
98
- .catch(e => swallow.warn("bootstrap:linkAgentToProject", e));
99
-
100
- session.taskId = await store.createTask(`Session in ${projectName}`);
101
- await store.linkAgentToTask(session.agentId, session.taskId)
102
- .catch(e => swallow.warn("bootstrap:linkAgentToTask", e));
103
- await store.linkTaskToProject(session.taskId, session.projectId)
104
- .catch(e => swallow.warn("bootstrap:linkTaskToProject", e));
105
-
106
- const surrealSessionId = await store.createSession(session.agentId);
107
- await store.markSessionActive(surrealSessionId)
108
- .catch(e => swallow.warn("bootstrap:markActive", e));
109
- await store.linkSessionToTask(surrealSessionId, session.taskId)
110
- .catch(e => swallow.warn("bootstrap:linkSessionToTask", e));
111
-
112
- // Store the DB session ID for cleanup tracking
113
- session.surrealSessionId = surrealSessionId;
114
- session.lastUserTurnId = "";
115
- } catch (e) {
116
- swallow.error("bootstrap:5pillar", e);
98
+ // Only create graph nodes on first bootstrap for this session
99
+ if (!session.surrealSessionId) {
100
+ try {
101
+ const workspace = this.state.workspaceDir || process.cwd();
102
+ const projectName = workspace.split("/").pop() || "default";
103
+
104
+ session.agentId = await store.ensureAgent("kongbrain", "openclaw-default");
105
+ session.projectId = await store.ensureProject(projectName);
106
+ await store.linkAgentToProject(session.agentId, session.projectId)
107
+ .catch(e => swallow.warn("bootstrap:linkAgentToProject", e));
108
+
109
+ session.taskId = await store.createTask(`Session in ${projectName}`);
110
+ await store.linkAgentToTask(session.agentId, session.taskId)
111
+ .catch(e => swallow.warn("bootstrap:linkAgentToTask", e));
112
+ await store.linkTaskToProject(session.taskId, session.projectId)
113
+ .catch(e => swallow.warn("bootstrap:linkTaskToProject", e));
114
+
115
+ const surrealSessionId = await store.createSession(session.agentId);
116
+ await store.markSessionActive(surrealSessionId)
117
+ .catch(e => swallow.warn("bootstrap:markActive", e));
118
+ await store.linkSessionToTask(surrealSessionId, session.taskId)
119
+ .catch(e => swallow.warn("bootstrap:linkSessionToTask", e));
120
+
121
+ session.surrealSessionId = surrealSessionId;
122
+ session.lastUserTurnId = "";
123
+
124
+ // Start memory daemon for this session
125
+ if (!session.daemon) {
126
+ session.daemon = startMemoryDaemon(
127
+ store, embeddings, session.sessionId, this.state.complete,
128
+ this.state.config.thresholds.extractionTimeoutMs,
129
+ );
130
+ }
131
+ } catch (e) {
132
+ swallow.error("bootstrap:5pillar", e);
133
+ }
117
134
  }
118
135
 
119
136
  // Background maintenance (non-blocking)
@@ -122,7 +139,7 @@ export class KongBrainContextEngine implements ContextEngine {
122
139
  store.archiveOldTurns(),
123
140
  store.consolidateMemories((text) => embeddings.embed(text)),
124
141
  store.garbageCollectMemories(),
125
- checkACANReadiness(store),
142
+ checkACANReadiness(store, this.state.config.thresholds.acanTrainingThreshold),
126
143
  // Deferred cleanup is triggered on first afterTurn() when complete() is available
127
144
  ]).catch(e => swallow.warn("bootstrap:maintenance", e));
128
145
 
@@ -156,11 +173,12 @@ export class KongBrainContextEngine implements ContextEngine {
156
173
  // Build system prompt additions
157
174
  const additions: string[] = [];
158
175
 
159
- // Wakeup briefing (synthesized at session start)
160
- const wakeupBriefing = (session as any)._wakeupBriefing as string | undefined;
161
- if (wakeupBriefing) {
162
- additions.push(wakeupBriefing);
163
- delete (session as any)._wakeupBriefing; // Only inject once
176
+ // Wakeup briefing (synthesized at session start, may still be in-flight)
177
+ const wakeupPromise = (session as any)._wakeupPromise as Promise<string | null> | undefined;
178
+ if (wakeupPromise) {
179
+ const wakeupBriefing = await wakeupPromise;
180
+ delete (session as any)._wakeupPromise; // Only inject once
181
+ if (wakeupBriefing) additions.push(wakeupBriefing);
164
182
  }
165
183
 
166
184
  // Graduation celebration — tell the agent it just graduated so it can share with the user
@@ -389,13 +407,8 @@ export class KongBrainContextEngine implements ContextEngine {
389
407
  }, session, store, this.state.complete).catch(e => swallow.warn("afterTurn:cognitiveCheck", e));
390
408
  }
391
409
 
392
- // Daemon batching — accumulate content tokens and flush when threshold met
393
- if (session.lastAssistantText && hasSemantic(session.lastAssistantText)) {
394
- session.newContentTokens += Math.ceil(session.lastAssistantText.length / 4);
395
- }
396
-
397
410
  // Flush to daemon when token threshold OR turn count threshold is reached
398
- const tokenReady = session.newContentTokens >= session.DAEMON_TOKEN_THRESHOLD;
411
+ const tokenReady = session.newContentTokens >= session.daemonTokenThreshold;
399
412
  const turnReady = session.userTurnCount >= session.lastDaemonFlushTurnCount + 3;
400
413
  if (session.daemon && (tokenReady || turnReady)) {
401
414
  try {
@@ -425,13 +438,90 @@ export class KongBrainContextEngine implements ContextEngine {
425
438
  swallow.warn("afterTurn:daemonBatch", e);
426
439
  }
427
440
  }
441
+
442
+ // Mid-session cleanup: simulate session_end after ~100k tokens.
443
+ // OpenClaw exits via Ctrl+C×2 (no async window), so session_end never fires.
444
+ // Run reflection, skill extraction, and causal graduation periodically.
445
+ const tokensSinceCleanup = session.cumulativeTokens - session.lastCleanupTokens;
446
+ if (tokensSinceCleanup >= session.midSessionCleanupThreshold && typeof this.state.complete === "function") {
447
+ session.lastCleanupTokens = session.cumulativeTokens;
448
+
449
+ // Fire-and-forget: these are non-critical background operations
450
+ const cleanupOps: Promise<unknown>[] = [];
451
+
452
+ // Final daemon flush with full transcript before cleanup
453
+ if (session.daemon) {
454
+ cleanupOps.push(
455
+ store.getSessionTurns(session.sessionId, 50)
456
+ .then(recentTurns => {
457
+ const turnData = recentTurns.map(t => ({
458
+ role: t.role as "user" | "assistant",
459
+ text: t.text,
460
+ turnId: (t as any).id,
461
+ }));
462
+ session.daemon!.sendTurnBatch(turnData, [...session.pendingThinking], []);
463
+ })
464
+ .catch(e => swallow.warn("midCleanup:daemonFlush", e)),
465
+ );
466
+ }
467
+
468
+ if (session.taskId) {
469
+ cleanupOps.push(
470
+ extractSkill(session.sessionId, session.taskId, store, embeddings, this.state.complete)
471
+ .catch(e => swallow.warn("midCleanup:extractSkill", e)),
472
+ );
473
+ }
474
+
475
+ cleanupOps.push(
476
+ generateReflection(session.sessionId, store, embeddings, this.state.complete)
477
+ .catch(e => swallow.warn("midCleanup:reflection", e)),
478
+ );
479
+
480
+ cleanupOps.push(
481
+ graduateCausalToSkills(store, embeddings, this.state.complete)
482
+ .catch(e => swallow.warn("midCleanup:graduateCausal", e)),
483
+ );
484
+
485
+ // ACAN: check if new retrieval outcomes warrant retraining
486
+ cleanupOps.push(
487
+ checkACANReadiness(store, this.state.config.thresholds.acanTrainingThreshold)
488
+ .catch(e => swallow("midCleanup:acan", e)),
489
+ );
490
+
491
+ // Handoff note — snapshot for wakeup even if session continues
492
+ cleanupOps.push(
493
+ (async () => {
494
+ const recentTurns = await store.getSessionTurns(session.sessionId, 15);
495
+ if (recentTurns.length < 2) return;
496
+ const turnSummary = recentTurns
497
+ .map(t => `[${t.role}] ${t.text.slice(0, 200)}`)
498
+ .join("\n");
499
+ const handoffResponse = await this.state.complete({
500
+ 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.",
501
+ messages: [{ role: "user", content: turnSummary }],
502
+ });
503
+ const handoffText = handoffResponse.text.trim();
504
+ if (handoffText.length > 20) {
505
+ let embedding: number[] | null = null;
506
+ if (embeddings.isAvailable()) {
507
+ try { embedding = await embeddings.embed(handoffText); } catch { /* ok */ }
508
+ }
509
+ await store.createMemory(handoffText, embedding, 8, "handoff", session.sessionId);
510
+ }
511
+ })().catch(e => swallow.warn("midCleanup:handoff", e)),
512
+ );
513
+
514
+ // Don't await — let cleanup run in background
515
+ Promise.allSettled(cleanupOps).catch(() => {});
516
+ }
428
517
  }
429
518
 
430
519
  // ── Dispose ────────────────────────────────────────────────────────────
431
520
 
432
521
  async dispose(): Promise<void> {
433
- // Phase 3: combined extraction, graduation, soul graduation
434
- await this.state.shutdown();
522
+ // No-op: global state (store, embeddings, sessions) is shared across
523
+ // context engine instances and must NOT be destroyed here. OpenClaw
524
+ // creates a new context engine per turn and disposes the old one.
435
525
  }
436
526
  }
437
527
 
@@ -7,11 +7,10 @@
7
7
  * The extraction is I/O-bound (LLM calls + DB writes), not CPU-bound,
8
8
  * so in-process execution is fine.
9
9
  */
10
- import type { SurrealConfig, EmbeddingConfig } from "./config.js";
11
10
  import type { TurnData, PriorExtractions } from "./daemon-types.js";
12
11
  import type { CompleteFn } from "./state.js";
13
- import { SurrealStore } from "./surreal.js";
14
- import { EmbeddingService } from "./embeddings.js";
12
+ import type { SurrealStore } from "./surreal.js";
13
+ import type { EmbeddingService } from "./embeddings.js";
15
14
  import { swallow } from "./errors.js";
16
15
 
17
16
  export type { TurnData } from "./daemon-types.js";
@@ -33,16 +32,15 @@ export interface MemoryDaemon {
33
32
  }
34
33
 
35
34
  export function startMemoryDaemon(
36
- surrealConfig: SurrealConfig,
37
- embeddingConfig: EmbeddingConfig,
35
+ sharedStore: SurrealStore,
36
+ sharedEmbeddings: EmbeddingService,
38
37
  sessionId: string,
39
38
  complete: CompleteFn,
39
+ extractionTimeoutMs = 60_000,
40
40
  ): MemoryDaemon {
41
- // Daemon-local DB and embedding instances (separate connections)
42
- let store: SurrealStore | null = null;
43
- let embeddings: EmbeddingService | null = null;
44
- let initialized = false;
45
- let initFailed = false;
41
+ // Use shared store/embeddings from global state (no duplicate connections)
42
+ const store = sharedStore;
43
+ const embeddings = sharedEmbeddings;
46
44
  let processing = false;
47
45
  let shuttingDown = false;
48
46
  let extractedTurnCount = 0;
@@ -52,24 +50,6 @@ export function startMemoryDaemon(
52
50
  conceptNames: [], artifactPaths: [], skillNames: [],
53
51
  };
54
52
 
55
- // Lazy init — connect on first batch, not at startup
56
- async function ensureInit(): Promise<boolean> {
57
- if (initialized) return true;
58
- if (initFailed) return false;
59
- try {
60
- store = new SurrealStore(surrealConfig);
61
- await store.initialize();
62
- embeddings = new EmbeddingService(embeddingConfig);
63
- await embeddings.initialize();
64
- initialized = true;
65
- return true;
66
- } catch (e) {
67
- swallow.warn("daemon:init", e);
68
- initFailed = true;
69
- return false;
70
- }
71
- }
72
-
73
53
  // Import extraction logic lazily to avoid circular deps
74
54
  async function runExtraction(
75
55
  turns: TurnData[],
@@ -158,7 +138,12 @@ export function startMemoryDaemon(
158
138
  const batch = pendingBatch;
159
139
  pendingBatch = null;
160
140
  try {
161
- await runExtraction(batch.turns, batch.thinking, batch.retrievedMemories, batch.priorExtractions);
141
+ await Promise.race([
142
+ runExtraction(batch.turns, batch.thinking, batch.retrievedMemories, batch.priorExtractions),
143
+ new Promise<void>((_, reject) =>
144
+ setTimeout(() => reject(new Error(`Extraction timed out after ${extractionTimeoutMs}ms`)), extractionTimeoutMs),
145
+ ),
146
+ ]);
162
147
  } catch (e) {
163
148
  errorCount++;
164
149
  swallow.warn("daemon:extraction", e);
@@ -172,10 +157,8 @@ export function startMemoryDaemon(
172
157
  sendTurnBatch(turns, thinking, retrievedMemories, priorExtractions) {
173
158
  if (shuttingDown) return;
174
159
  pendingBatch = { turns, thinking, retrievedMemories, priorExtractions };
175
- // Fire-and-forget: init if needed, then process
176
- ensureInit()
177
- .then(ok => { if (ok) return processPending(); })
178
- .catch(e => swallow.warn("daemon:sendBatch", e));
160
+ // Fire-and-forget
161
+ processPending().catch(e => swallow.warn("daemon:sendBatch", e));
179
162
  },
180
163
 
181
164
  async getStatus() {
@@ -200,13 +183,7 @@ export function startMemoryDaemon(
200
183
  new Promise<void>(resolve => setTimeout(resolve, timeoutMs)),
201
184
  ]);
202
185
  }
203
- // Clean up daemon-local connections
204
- await Promise.allSettled([
205
- store?.dispose(),
206
- embeddings?.dispose(),
207
- ]).catch(() => {});
208
- store = null;
209
- embeddings = null;
186
+ // Shared store/embeddings don't dispose (owned by global state)
210
187
  },
211
188
 
212
189
  getExtractedTurnCount() {
package/src/embeddings.ts CHANGED
@@ -11,7 +11,6 @@ export class EmbeddingService {
11
11
  private model: LlamaModel | null = null;
12
12
  private ctx: LlamaEmbeddingContext | null = null;
13
13
  private ready = false;
14
- private embedCallCount = 0;
15
14
 
16
15
  constructor(private readonly config: EmbeddingConfig) {}
17
16
 
@@ -40,7 +39,6 @@ export class EmbeddingService {
40
39
 
41
40
  async embed(text: string): Promise<number[]> {
42
41
  if (!this.ready || !this.ctx) throw new Error("Embeddings not initialized");
43
- this.embedCallCount++;
44
42
  const result = await this.ctx.getEmbeddingFor(text);
45
43
  return Array.from(result.vector);
46
44
  }
@@ -58,16 +56,6 @@ export class EmbeddingService {
58
56
  return this.ready;
59
57
  }
60
58
 
61
- drainEmbedCallCount(): number {
62
- const count = this.embedCallCount;
63
- this.embedCallCount = 0;
64
- return count;
65
- }
66
-
67
- getEmbedCallCount(): number {
68
- return this.embedCallCount;
69
- }
70
-
71
59
  async dispose(): Promise<void> {
72
60
  try {
73
61
  await this.ctx?.dispose();
@@ -634,9 +634,9 @@ async function formatContextMessage(
634
634
 
635
635
  function truncateToolResult(msg: AgentMessage, maxChars: number): AgentMessage {
636
636
  if (!isToolResult(msg)) return msg;
637
- const totalLen = msg.content.reduce((s, c) => s + ((c as TextContent).text?.length ?? 0), 0);
637
+ const totalLen = msg.content.reduce((s: number, c: any) => s + ((c as TextContent).text?.length ?? 0), 0);
638
638
  if (totalLen <= maxChars) return msg;
639
- const content = msg.content.map((c) => {
639
+ const content = msg.content.map((c: any) => {
640
640
  if (c.type !== "text") return c;
641
641
  const tc = c as TextContent;
642
642
  const allowed = Math.max(200, Math.floor((tc.text.length / totalLen) * maxChars));
@@ -654,8 +654,8 @@ function getRecentTurns(messages: AgentMessage[], maxTokens: number, contextWind
654
654
  const clean = messages.map((m) => {
655
655
  if (isAssistant(m) && m.stopReason === "error") {
656
656
  const errorText = m.content
657
- .filter((c): c is TextContent => c.type === "text")
658
- .map((c) => c.text)
657
+ .filter((c: any): c is TextContent => c.type === "text")
658
+ .map((c: any) => c.text)
659
659
  .join("")
660
660
  .slice(0, 150);
661
661
  return {
@@ -672,7 +672,7 @@ function getRecentTurns(messages: AgentMessage[], maxTokens: number, contextWind
672
672
  let i = 0;
673
673
  while (i < clean.length) {
674
674
  const msg = clean[i];
675
- if (isAssistant(msg) && msg.content.some((c) => c.type === "toolCall")) {
675
+ if (isAssistant(msg) && msg.content.some((c: any) => c.type === "toolCall")) {
676
676
  const group: AgentMessage[] = [clean[i]];
677
677
  let j = i + 1;
678
678
  while (j < clean.length && isToolResult(clean[j])) {
@@ -837,9 +837,19 @@ async function graphTransformInner(
837
837
  const config = session.currentConfig;
838
838
  const skipRetrieval = config?.skipRetrieval ?? false;
839
839
  const currentIntent = config?.intent ?? "unknown";
840
- const vectorSearchLimits = config?.vectorSearchLimits ?? {
840
+ const baseLimits = config?.vectorSearchLimits ?? {
841
841
  turn: 25, identity: 10, concept: 20, memory: 20, artifact: 10,
842
842
  };
843
+ // Scale search limits with context window — larger windows can use more results
844
+ const cwScale = Math.max(0.5, Math.min(2.0, contextWindow / 200_000));
845
+ const vectorSearchLimits = {
846
+ turn: Math.round((baseLimits.turn ?? 25) * cwScale),
847
+ identity: baseLimits.identity, // always load full identity
848
+ concept: Math.round((baseLimits.concept ?? 20) * cwScale),
849
+ memory: Math.round((baseLimits.memory ?? 20) * cwScale),
850
+ artifact: Math.round((baseLimits.artifact ?? 10) * cwScale),
851
+ monologue: Math.round(8 * cwScale),
852
+ };
843
853
  let tokenBudget = Math.min(config?.tokenBudget ?? 6000, budgets.retrieval);
844
854
 
845
855
  // Pressure-based adaptive scaling
@@ -30,9 +30,16 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
30
30
  const session = state.getSession(sessionKey);
31
31
  if (!session) return;
32
32
 
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;
33
+ // Measure assistant text output (used for token estimation and planning gate)
34
+ const textLen = event.assistantTexts.reduce((s, t) => s + t.length, 0);
35
+
36
+ // Extract token counts — fall back to text-length estimate when provider
37
+ // doesn't report usage (OpenClaw often passes 0 or undefined)
38
+ let inputTokens = event.usage?.input ?? 0;
39
+ let outputTokens = event.usage?.output ?? 0;
40
+ if (inputTokens + outputTokens === 0 && textLen > 0) {
41
+ outputTokens = Math.ceil(textLen / 4); // ~4 chars per token
42
+ }
36
43
 
37
44
  // Always update session stats — turn_count must increment even without usage data
38
45
  if (session.surrealSessionId) {
@@ -47,13 +54,11 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
47
54
  }
48
55
  }
49
56
 
50
- // Accumulate for daemon batching (only when real tokens present)
51
- if (inputTokens + outputTokens > 0) {
52
- session.newContentTokens += inputTokens + outputTokens;
53
- }
57
+ // Accumulate for daemon batching and mid-session cleanup
58
+ session.newContentTokens += inputTokens + outputTokens;
59
+ session.cumulativeTokens += inputTokens + outputTokens;
54
60
 
55
61
  // Track accumulated text output for planning gate
56
- const textLen = event.assistantTexts.reduce((s, t) => s + t.length, 0);
57
62
  session.turnTextLength += textLen;
58
63
 
59
64
  if (textLen > 50) {
@@ -77,6 +82,11 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
77
82
  const thinking = block.thinking ?? block.text ?? "";
78
83
  if (thinking.length > 50) {
79
84
  session.pendingThinking.push(thinking);
85
+ // Cap to prevent unbounded growth in long sessions
86
+ const max = state.config.thresholds.maxPendingThinking;
87
+ if (session.pendingThinking.length > max) {
88
+ session.pendingThinking.splice(0, session.pendingThinking.length - max);
89
+ }
80
90
  }
81
91
  }
82
92
  }
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
23
23
  import { createLlmOutputHandler } from "./hooks/llm-output.js";
24
24
  import { startMemoryDaemon } from "./daemon-manager.js";
25
25
  import { seedIdentity } from "./identity.js";
26
- import { synthesizeWakeup, synthesizeStartupCognition } from "./wakeup.js";
26
+ import { synthesizeWakeup } from "./wakeup.js";
27
27
  import { extractSkill } from "./skills.js";
28
28
  import { generateReflection, setReflectionContextWindow } from "./reflection.js";
29
29
  import { graduateCausalToSkills } from "./skills.js";
@@ -459,10 +459,11 @@ export default definePluginEntry({
459
459
  // Start memory daemon worker thread
460
460
  try {
461
461
  session.daemon = startMemoryDaemon(
462
- config.surreal,
463
- config.embedding,
462
+ globalState!.store,
463
+ globalState!.embeddings,
464
464
  session.sessionId,
465
465
  globalState!.complete,
466
+ globalState!.config.thresholds.extractionTimeoutMs,
466
467
  );
467
468
  } catch (e) {
468
469
  swallow.warn("index:startDaemon", e);
@@ -476,7 +477,7 @@ export default definePluginEntry({
476
477
  (session as any)._hasMigratableFiles = true;
477
478
  }
478
479
  })
479
- .catch(e => swallow("index:migrationCheck", e));
480
+ .catch(e => swallow.warn("index:migrationCheck", e));
480
481
  }
481
482
 
482
483
  // Set reflection context window from config
@@ -486,20 +487,10 @@ export default definePluginEntry({
486
487
  detectGraduationEvent(globalState!.store, session, globalState!)
487
488
  .catch(e => swallow("index:graduationDetect", e));
488
489
 
489
- // Synthesize wakeup briefing (background, non-blocking)
490
- // The briefing is stored and later injected via assemble()'s systemPromptAddition
491
- synthesizeWakeup(globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir)
492
- .then(briefing => {
493
- if (briefing) (session as any)._wakeupBriefing = briefing;
494
- })
495
- .catch(e => swallow.warn("index:wakeup", e));
496
-
497
- // Startup cognition (background)
498
- synthesizeStartupCognition(globalState!.store, globalState!.complete)
499
- .then(cognition => {
500
- if (cognition) (session as any)._startupCognition = cognition;
501
- })
502
- .catch(e => swallow.warn("index:startupCognition", e));
490
+ // Synthesize wakeup briefing — store the promise so assemble() can await it
491
+ (session as any)._wakeupPromise = synthesizeWakeup(
492
+ globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir,
493
+ ).catch(e => { swallow.warn("index:wakeup", e); return null; });
503
494
 
504
495
  // Deferred cleanup: extract knowledge from orphaned sessions (background)
505
496
  runDeferredCleanup(globalState!.store, globalState!.embeddings, globalState!.complete)
@@ -37,11 +37,12 @@ ${dedup}
37
37
  // Only when there's a clear trigger and outcome. Max 5.
38
38
  {"triggerText": "what caused it (max 200 chars)", "outcomeText": "what happened as a result", "chainType": "debug|refactor|feature|fix", "success": true/false, "confidence": 0.0-1.0, "description": "1-sentence summary"}
39
39
  ],
40
- ${hasThinking ? ` "monologue": [
40
+ "monologue": [
41
41
  // Internal reasoning moments worth preserving: doubts, tradeoffs, insights, realizations.
42
+ // Infer from the conversation flow — approach changes, surprising discoveries, tradeoff decisions.
42
43
  // Skip routine reasoning. Only novel/surprising thoughts. Max 5.
43
44
  {"category": "doubt|tradeoff|alternative|insight|realization", "content": "1-2 sentence description"}
44
- ],` : ' "monologue": [],'}
45
+ ],
45
46
  ${hasRetrievedMemories ? ` "resolved": [
46
47
  // IDs from [RETRIEVED MEMORIES] that have been FULLY addressed/fixed/completed in this conversation.
47
48
  // Must be exact IDs like "memory:abc123". Empty [] if none resolved.
package/src/reflection.ts CHANGED
@@ -140,30 +140,31 @@ export async function generateReflection(
140
140
  embeddings: EmbeddingService,
141
141
  complete: CompleteFn,
142
142
  ): Promise<void> {
143
- const metrics = await gatherSessionMetrics(sessionId, store);
144
- if (!metrics) return;
143
+ if (!store.isAvailable()) return;
145
144
 
146
- const { reflect, reasons } = shouldReflect(metrics);
147
- if (!reflect) return;
145
+ // Get session turns directly no dependency on orchestrator_metrics
146
+ const turns = await store.getSessionTurns(sessionId, 30).catch(() => []);
147
+ if (turns.length < 3) return; // Too short for meaningful reflection
148
148
 
149
- const severity = reasons.length >= 3 ? "critical" : reasons.length >= 2 ? "moderate" : "minor";
149
+ const transcript = turns
150
+ .map(t => `[${t.role}] ${(t.text ?? "").slice(0, 300)}`)
151
+ .join("\n");
150
152
 
151
- let category = "efficiency";
152
- if (metrics.toolFailureRate > TOOL_FAILURE_THRESHOLD) category = "failure_pattern";
153
- if (metrics.steeringCandidates >= STEERING_THRESHOLD) category = "approach_strategy";
153
+ const severity = turns.length >= 15 ? "moderate" : "minor";
154
+ const category = "session_review";
154
155
 
155
156
  try {
156
157
  const response = await complete({
157
- system: `Write 2-4 sentences: root cause, error pattern, what to do differently. Be specific. Example: "Spent 8 tool calls reading source before checking error log. For timeout bugs, check logs first."`,
158
+ system: `Reflect on this session. Write 2-4 sentences about: what went well, what could improve, any patterns worth noting. Be specific and actionable. If the session was too trivial for reflection, respond with just "skip".`,
158
159
  messages: [{
159
160
  role: "user",
160
- content: `${metrics.totalTurns} turns, ${metrics.totalToolCalls} tools, ${(metrics.avgUtilization * 100).toFixed(0)}% util, ${(metrics.toolFailureRate * 100).toFixed(0)}% fail, ~${metrics.wastedTokens} wasted tokens\nIssues: ${reasons.join("; ")}`,
161
+ content: `Session with ${turns.length} turns:\n${transcript.slice(0, 15000)}`,
161
162
  }],
162
163
  });
163
164
 
164
165
  const reflectionText = response.text.trim();
165
166
 
166
- if (reflectionText.length < 20) return;
167
+ if (reflectionText.length < 20 || reflectionText.toLowerCase() === "skip") return;
167
168
 
168
169
  let reflEmb: number[] | null = null;
169
170
  if (embeddings.isAvailable()) {
package/src/schema.surql CHANGED
@@ -76,8 +76,6 @@ DEFINE FIELD IF NOT EXISTS model ON turn TYPE option<string>;
76
76
  DEFINE FIELD IF NOT EXISTS usage ON turn TYPE option<object>;
77
77
  DEFINE INDEX IF NOT EXISTS turn_vec_idx ON turn FIELDS embedding HNSW DIMENSION 1024 DIST COSINE;
78
78
  DEFINE INDEX IF NOT EXISTS turn_session_idx ON turn FIELDS session_id;
79
- -- Migration: backfill created_at from timestamp for existing turns
80
- UPDATE turn SET created_at = timestamp WHERE created_at IS NONE AND timestamp IS NOT NONE;
81
79
 
82
80
  -- Identity chunks (agent persona / identity)
83
81
  DEFINE TABLE IF NOT EXISTS identity_chunk SCHEMALESS;
@@ -112,7 +110,6 @@ DEFINE FIELD IF NOT EXISTS source ON memory TYPE option<string>;
112
110
  DEFINE FIELD IF NOT EXISTS created_at ON memory TYPE datetime DEFAULT time::now();
113
111
  DEFINE FIELD IF NOT EXISTS last_accessed ON memory TYPE option<datetime>;
114
112
  DEFINE FIELD IF NOT EXISTS status ON memory TYPE option<string> DEFAULT "active";
115
- UPDATE memory SET status = "active" WHERE status IS NONE;
116
113
  DEFINE FIELD IF NOT EXISTS resolved_at ON memory TYPE option<datetime>;
117
114
  DEFINE FIELD IF NOT EXISTS resolved_by ON memory TYPE option<string>;
118
115
  DEFINE INDEX IF NOT EXISTS memory_vec_idx ON memory FIELDS embedding HNSW DIMENSION 1024 DIST COSINE;
@@ -375,15 +372,6 @@ DEFINE FIELD IF NOT EXISTS created_at ON graduation_event TYPE datetime DEFAULT
375
372
  -- ============================================================
376
373
  -- MIGRATIONS (must run after table definitions)
377
374
  -- ============================================================
378
- -- Drop old 768d HNSW indexes (now 1024d with BGE-M3)
379
- REMOVE INDEX IF EXISTS turn_vec_idx ON turn;
380
- REMOVE INDEX IF EXISTS identity_vec_idx ON identity_chunk;
381
- REMOVE INDEX IF EXISTS concept_vec_idx ON concept;
382
- REMOVE INDEX IF EXISTS memory_vec_idx ON memory;
383
- REMOVE INDEX IF EXISTS artifact_vec_idx ON artifact;
384
- -- Clear stale 768d embeddings (incompatible with new 1024d model)
385
- UPDATE turn SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
386
- UPDATE identity_chunk SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
387
- UPDATE concept SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
388
- UPDATE memory SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
389
- UPDATE artifact SET embedding = NONE WHERE embedding != NONE AND array::len(embedding) = 768;
375
+ -- 768d 1024d migration completed; REMOVE INDEX / UPDATE stale
376
+ -- embeddings removed to avoid destroying live HNSW indexes on
377
+ -- every startup.
package/src/skills.ts CHANGED
@@ -60,17 +60,8 @@ export async function extractSkill(
60
60
  ): Promise<string | null> {
61
61
  if (!store.isAvailable()) return null;
62
62
 
63
- // Check if session had enough tool activity
64
- const metricsRows = await store.queryFirst<{ totalTools: number }>(
65
- `SELECT math::sum(actual_tool_calls) AS totalTools
66
- FROM orchestrator_metrics WHERE session_id = $sid GROUP ALL`,
67
- { sid: sessionId },
68
- ).catch(() => [] as { totalTools: number }[]);
69
- const totalTools = Number(metricsRows[0]?.totalTools ?? 0);
70
- if (totalTools < 3) return null;
71
-
72
63
  const turns = await store.getSessionTurns(sessionId, 50);
73
- if (turns.length < 4) return null;
64
+ if (turns.length < 4) return null; // Too short for skill extraction
74
65
 
75
66
  const transcript = turns
76
67
  .map((t) => `[${t.role}] ${(t.text ?? "").slice(0, 300)}`)
@@ -81,7 +72,7 @@ export async function extractSkill(
81
72
  system: `Return JSON or null. Fields: {name, description, preconditions, steps: [{tool, description}] (max 8), postconditions}. Generic patterns only (no specific paths). null if no clear multi-step workflow.`,
82
73
  messages: [{
83
74
  role: "user",
84
- content: `${totalTools} tool calls:\n${transcript.slice(0, 20000)}`,
75
+ content: `${turns.length} turns:\n${transcript.slice(0, 20000)}`,
85
76
  }],
86
77
  });
87
78
 
@@ -163,7 +154,7 @@ export async function supersedeOldSkills(
163
154
  );
164
155
  }
165
156
  }
166
- } catch (e) { swallow("skills:supersedeOld", e); }
157
+ } catch (e) { swallow.warn("skills:supersedeOld", e); }
167
158
  }
168
159
 
169
160
  // --- Skill Retrieval ---
package/src/soul.ts CHANGED
@@ -36,7 +36,6 @@ export interface GraduationSignals {
36
36
  reflections: number;
37
37
  causalChains: number;
38
38
  concepts: number;
39
- memoryCompactions: number;
40
39
  monologues: number;
41
40
  spanDays: number;
42
41
  }
@@ -91,7 +90,6 @@ const THRESHOLDS: GraduationSignals = {
91
90
  reflections: 10,
92
91
  causalChains: 5,
93
92
  concepts: 30,
94
- memoryCompactions: 5,
95
93
  monologues: 5,
96
94
  spanDays: 3,
97
95
  };
@@ -104,17 +102,16 @@ const QUALITY_GATE = 0.6;
104
102
  async function getGraduationSignals(store: SurrealStore): Promise<GraduationSignals> {
105
103
  const defaults: GraduationSignals = {
106
104
  sessions: 0, reflections: 0, causalChains: 0,
107
- concepts: 0, memoryCompactions: 0, monologues: 0, spanDays: 0,
105
+ concepts: 0, monologues: 0, spanDays: 0,
108
106
  };
109
107
  if (!store.isAvailable()) return defaults;
110
108
 
111
109
  try {
112
- const [sessions, reflections, causal, concepts, compactions, monologues, span] = await Promise.all([
110
+ const [sessions, reflections, causal, concepts, monologues, span] = await Promise.all([
113
111
  store.queryFirst<{ count: number }>(`SELECT count() AS count FROM session GROUP ALL`).catch(() => []),
114
112
  store.queryFirst<{ count: number }>(`SELECT count() AS count FROM reflection GROUP ALL`).catch(() => []),
115
113
  store.queryFirst<{ count: number }>(`SELECT count() AS count FROM causal_chain GROUP ALL`).catch(() => []),
116
114
  store.queryFirst<{ count: number }>(`SELECT count() AS count FROM concept GROUP ALL`).catch(() => []),
117
- store.queryFirst<{ count: number }>(`SELECT count() AS count FROM compaction_checkpoint WHERE status = "complete" GROUP ALL`).catch(() => []),
118
115
  store.queryFirst<{ count: number }>(`SELECT count() AS count FROM monologue GROUP ALL`).catch(() => []),
119
116
  store.queryFirst<{ earliest: string }>(`SELECT started_at AS earliest FROM session ORDER BY started_at ASC LIMIT 1`).catch(() => []),
120
117
  ]);
@@ -130,7 +127,6 @@ async function getGraduationSignals(store: SurrealStore): Promise<GraduationSign
130
127
  reflections: (reflections as { count: number }[])[0]?.count ?? 0,
131
128
  causalChains: (causal as { count: number }[])[0]?.count ?? 0,
132
129
  concepts: (concepts as { count: number }[])[0]?.count ?? 0,
133
- memoryCompactions: (compactions as { count: number }[])[0]?.count ?? 0,
134
130
  monologues: (monologues as { count: number }[])[0]?.count ?? 0,
135
131
  spanDays,
136
132
  };
@@ -358,7 +354,6 @@ function getSuggestion(key: keyof GraduationSignals, current: number, threshold:
358
354
  case "reflections": return `${remaining} more reflection(s) needed. These are generated automatically when sessions have performance issues.`;
359
355
  case "causalChains": return `${remaining} more causal chain(s) needed. These form when the agent corrects mistakes during tool usage.`;
360
356
  case "concepts": return `${remaining} more concept(s) needed. Concepts are extracted from conversation topics and domain vocabulary.`;
361
- case "memoryCompactions": return `${remaining} more compaction(s) needed. These happen during longer sessions with substantial context.`;
362
357
  case "monologues": return `${remaining} more monologue(s) needed. Inner monologue triggers during cognitive checks.`;
363
358
  case "spanDays": return `${remaining} more day(s) of history needed. The agent needs time-spread experience, not just volume.`;
364
359
  }
package/src/state.ts CHANGED
@@ -54,9 +54,14 @@ export class SessionState {
54
54
  // Memory daemon
55
55
  daemon: MemoryDaemon | null = null;
56
56
  newContentTokens = 0;
57
- readonly DAEMON_TOKEN_THRESHOLD = 4000;
57
+ daemonTokenThreshold = 4000;
58
58
  lastDaemonFlushTurnCount = 0;
59
59
 
60
+ // Cumulative session token tracking (for mid-session cleanup trigger)
61
+ cumulativeTokens = 0;
62
+ lastCleanupTokens = 0;
63
+ midSessionCleanupThreshold = 100_000;
64
+
60
65
  // Cleanup tracking
61
66
  cleanedUp = false;
62
67
 
@@ -101,6 +106,7 @@ export class GlobalPluginState {
101
106
  complete: CompleteFn;
102
107
  workspaceDir?: string;
103
108
  enqueueSystemEvent?: EnqueueSystemEventFn;
109
+ schemaApplied = false;
104
110
  private sessions = new Map<string, SessionState>();
105
111
 
106
112
  constructor(
@@ -120,6 +126,8 @@ export class GlobalPluginState {
120
126
  let session = this.sessions.get(sessionKey);
121
127
  if (!session) {
122
128
  session = new SessionState(sessionId, sessionKey);
129
+ session.daemonTokenThreshold = this.config.thresholds.daemonTokenThreshold;
130
+ session.midSessionCleanupThreshold = this.config.thresholds.midSessionCleanupThreshold;
123
131
  this.sessions.set(sessionKey, session);
124
132
  }
125
133
  return session;
package/src/surreal.ts CHANGED
@@ -924,7 +924,8 @@ export class SurrealStore {
924
924
  timestamp: string;
925
925
  }>(
926
926
  `SELECT role, text, tool_name, timestamp FROM turn
927
- WHERE session_id = $sid AND text != NONE AND text != ""
927
+ WHERE id IN (SELECT VALUE in FROM part_of WHERE out = $sid)
928
+ AND text != NONE AND text != ""
928
929
  ORDER BY timestamp DESC LIMIT $lim`,
929
930
  { sid: prevSessionId, lim: limit },
930
931
  );
@@ -1061,16 +1062,16 @@ export class SurrealStore {
1061
1062
 
1062
1063
  async runMemoryMaintenance(): Promise<void> {
1063
1064
  try {
1064
- await this.queryExec(
1065
- `UPDATE memory SET importance = math::max([importance * 0.95, 2.0]) WHERE importance > 2.0`,
1066
- );
1067
- await this.queryExec(
1068
- `UPDATE memory SET importance = math::max([importance, 3 + ((
1065
+ // Single round-trip to reduce transaction conflict window
1066
+ await this.queryExec(`
1067
+ UPDATE memory SET importance = math::max([importance * 0.95, 2.0]) WHERE importance > 2.0;
1068
+ UPDATE memory SET importance = math::max([importance, 3 + ((
1069
1069
  SELECT VALUE avg_utilization FROM memory_utility_cache WHERE memory_id = string::concat(meta::tb(id), ":", meta::id(id)) LIMIT 1
1070
- )[0] ?? 0) * 4]) WHERE importance < 7`,
1071
- );
1070
+ )[0] ?? 0) * 4]) WHERE importance < 7;
1071
+ `);
1072
1072
  } catch (e) {
1073
- swallow.warn("surreal:runMemoryMaintenance", e);
1073
+ // Transaction conflicts expected when daemon writes concurrently — silent
1074
+ swallow("surreal:runMemoryMaintenance", e);
1074
1075
  }
1075
1076
  }
1076
1077
 
@@ -41,7 +41,7 @@ export function createRecallToolDef(state: GlobalPluginState, session: SessionSt
41
41
  const scope = params.scope ?? "all";
42
42
 
43
43
  if (scope === "skills") {
44
- const skills = await findRelevantSkills(queryVec, maxResults);
44
+ const skills = await findRelevantSkills(queryVec, maxResults, store);
45
45
  if (skills.length === 0) {
46
46
  return { content: [{ type: "text" as const, text: `No skills found matching "${params.query}".` }], details: null };
47
47
  }