kongbrain 0.2.1 → 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.1",
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
  }
@@ -73,19 +73,22 @@ export class KongBrainContextEngine implements ContextEngine {
73
73
  }): Promise<BootstrapResult> {
74
74
  const { store, embeddings } = this.state;
75
75
 
76
- // Run schema if first bootstrap
77
- try {
78
- const schemaPath = join(__dirname, "..", "src", "schema.surql");
79
- let schemaSql: string;
76
+ // Run schema once per process (idempotent but expensive on every bootstrap)
77
+ if (!this.state.schemaApplied) {
80
78
  try {
81
- schemaSql = readFileSync(schemaPath, "utf-8");
82
- } catch {
83
- // Fallback: try relative to compiled output
84
- 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);
85
91
  }
86
- await store.queryExec(schemaSql);
87
- } catch (e) {
88
- swallow.warn("context-engine:schema", e);
89
92
  }
90
93
 
91
94
  // 5-pillar graph init
@@ -122,6 +125,7 @@ export class KongBrainContextEngine implements ContextEngine {
122
125
  if (!session.daemon) {
123
126
  session.daemon = startMemoryDaemon(
124
127
  store, embeddings, session.sessionId, this.state.complete,
128
+ this.state.config.thresholds.extractionTimeoutMs,
125
129
  );
126
130
  }
127
131
  } catch (e) {
@@ -135,7 +139,7 @@ export class KongBrainContextEngine implements ContextEngine {
135
139
  store.archiveOldTurns(),
136
140
  store.consolidateMemories((text) => embeddings.embed(text)),
137
141
  store.garbageCollectMemories(),
138
- checkACANReadiness(store),
142
+ checkACANReadiness(store, this.state.config.thresholds.acanTrainingThreshold),
139
143
  // Deferred cleanup is triggered on first afterTurn() when complete() is available
140
144
  ]).catch(e => swallow.warn("bootstrap:maintenance", e));
141
145
 
@@ -404,7 +408,7 @@ export class KongBrainContextEngine implements ContextEngine {
404
408
  }
405
409
 
406
410
  // Flush to daemon when token threshold OR turn count threshold is reached
407
- const tokenReady = session.newContentTokens >= session.DAEMON_TOKEN_THRESHOLD;
411
+ const tokenReady = session.newContentTokens >= session.daemonTokenThreshold;
408
412
  const turnReady = session.userTurnCount >= session.lastDaemonFlushTurnCount + 3;
409
413
  if (session.daemon && (tokenReady || turnReady)) {
410
414
  try {
@@ -439,7 +443,7 @@ export class KongBrainContextEngine implements ContextEngine {
439
443
  // OpenClaw exits via Ctrl+C×2 (no async window), so session_end never fires.
440
444
  // Run reflection, skill extraction, and causal graduation periodically.
441
445
  const tokensSinceCleanup = session.cumulativeTokens - session.lastCleanupTokens;
442
- if (tokensSinceCleanup >= session.MID_SESSION_CLEANUP_THRESHOLD && typeof this.state.complete === "function") {
446
+ if (tokensSinceCleanup >= session.midSessionCleanupThreshold && typeof this.state.complete === "function") {
443
447
  session.lastCleanupTokens = session.cumulativeTokens;
444
448
 
445
449
  // Fire-and-forget: these are non-critical background operations
@@ -478,6 +482,12 @@ export class KongBrainContextEngine implements ContextEngine {
478
482
  .catch(e => swallow.warn("midCleanup:graduateCausal", e)),
479
483
  );
480
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
+
481
491
  // Handoff note — snapshot for wakeup even if session continues
482
492
  cleanupOps.push(
483
493
  (async () => {
@@ -36,6 +36,7 @@ export function startMemoryDaemon(
36
36
  sharedEmbeddings: EmbeddingService,
37
37
  sessionId: string,
38
38
  complete: CompleteFn,
39
+ extractionTimeoutMs = 60_000,
39
40
  ): MemoryDaemon {
40
41
  // Use shared store/embeddings from global state (no duplicate connections)
41
42
  const store = sharedStore;
@@ -137,7 +138,12 @@ export function startMemoryDaemon(
137
138
  const batch = pendingBatch;
138
139
  pendingBatch = null;
139
140
  try {
140
- 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
+ ]);
141
147
  } catch (e) {
142
148
  errorCount++;
143
149
  swallow.warn("daemon:extraction", e);
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,14 +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
- session.cumulativeTokens += inputTokens + outputTokens;
54
- }
57
+ // Accumulate for daemon batching and mid-session cleanup
58
+ session.newContentTokens += inputTokens + outputTokens;
59
+ session.cumulativeTokens += inputTokens + outputTokens;
55
60
 
56
61
  // Track accumulated text output for planning gate
57
- const textLen = event.assistantTexts.reduce((s, t) => s + t.length, 0);
58
62
  session.turnTextLength += textLen;
59
63
 
60
64
  if (textLen > 50) {
@@ -78,6 +82,11 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
78
82
  const thinking = block.thinking ?? block.text ?? "";
79
83
  if (thinking.length > 50) {
80
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
+ }
81
90
  }
82
91
  }
83
92
  }
package/src/index.ts CHANGED
@@ -463,6 +463,7 @@ export default definePluginEntry({
463
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
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;
package/src/skills.ts CHANGED
@@ -154,7 +154,7 @@ export async function supersedeOldSkills(
154
154
  );
155
155
  }
156
156
  }
157
- } catch (e) { swallow("skills:supersedeOld", e); }
157
+ } catch (e) { swallow.warn("skills:supersedeOld", e); }
158
158
  }
159
159
 
160
160
  // --- Skill Retrieval ---
package/src/state.ts CHANGED
@@ -54,13 +54,13 @@ 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
60
  // Cumulative session token tracking (for mid-session cleanup trigger)
61
61
  cumulativeTokens = 0;
62
62
  lastCleanupTokens = 0;
63
- readonly MID_SESSION_CLEANUP_THRESHOLD = 100_000;
63
+ midSessionCleanupThreshold = 100_000;
64
64
 
65
65
  // Cleanup tracking
66
66
  cleanedUp = false;
@@ -106,6 +106,7 @@ export class GlobalPluginState {
106
106
  complete: CompleteFn;
107
107
  workspaceDir?: string;
108
108
  enqueueSystemEvent?: EnqueueSystemEventFn;
109
+ schemaApplied = false;
109
110
  private sessions = new Map<string, SessionState>();
110
111
 
111
112
  constructor(
@@ -125,6 +126,8 @@ export class GlobalPluginState {
125
126
  let session = this.sessions.get(sessionKey);
126
127
  if (!session) {
127
128
  session = new SessionState(sessionId, sessionKey);
129
+ session.daemonTokenThreshold = this.config.thresholds.daemonTokenThreshold;
130
+ session.midSessionCleanupThreshold = this.config.thresholds.midSessionCleanupThreshold;
128
131
  this.sessions.set(sessionKey, session);
129
132
  }
130
133
  return session;
package/src/surreal.ts CHANGED
@@ -1062,16 +1062,16 @@ export class SurrealStore {
1062
1062
 
1063
1063
  async runMemoryMaintenance(): Promise<void> {
1064
1064
  try {
1065
- await this.queryExec(
1066
- `UPDATE memory SET importance = math::max([importance * 0.95, 2.0]) WHERE importance > 2.0`,
1067
- );
1068
- await this.queryExec(
1069
- `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 + ((
1070
1069
  SELECT VALUE avg_utilization FROM memory_utility_cache WHERE memory_id = string::concat(meta::tb(id), ":", meta::id(id)) LIMIT 1
1071
- )[0] ?? 0) * 4]) WHERE importance < 7`,
1072
- );
1070
+ )[0] ?? 0) * 4]) WHERE importance < 7;
1071
+ `);
1073
1072
  } catch (e) {
1074
- swallow.warn("surreal:runMemoryMaintenance", e);
1073
+ // Transaction conflicts expected when daemon writes concurrently — silent
1074
+ swallow("surreal:runMemoryMaintenance", e);
1075
1075
  }
1076
1076
  }
1077
1077