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 +1 -1
- package/src/acan.ts +4 -3
- package/src/config.ts +27 -0
- package/src/context-engine.ts +141 -51
- package/src/daemon-manager.ts +17 -40
- package/src/embeddings.ts +0 -12
- package/src/graph-context.ts +16 -6
- package/src/hooks/llm-output.ts +18 -8
- package/src/index.ts +9 -18
- package/src/memory-daemon.ts +3 -2
- package/src/reflection.ts +12 -11
- package/src/schema.surql +3 -15
- package/src/skills.ts +3 -12
- package/src/soul.ts +2 -7
- package/src/state.ts +9 -1
- package/src/surreal.ts +10 -9
- package/src/tools/recall.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kongbrain",
|
|
3
|
-
"version": "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 <
|
|
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 <
|
|
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
|
}
|
package/src/context-engine.ts
CHANGED
|
@@ -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
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
delete (session as any).
|
|
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.
|
|
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
|
-
//
|
|
434
|
-
|
|
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
|
|
package/src/daemon-manager.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
35
|
+
sharedStore: SurrealStore,
|
|
36
|
+
sharedEmbeddings: EmbeddingService,
|
|
38
37
|
sessionId: string,
|
|
39
38
|
complete: CompleteFn,
|
|
39
|
+
extractionTimeoutMs = 60_000,
|
|
40
40
|
): MemoryDaemon {
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
176
|
-
|
|
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
|
-
//
|
|
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();
|
package/src/graph-context.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/hooks/llm-output.ts
CHANGED
|
@@ -30,9 +30,16 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
|
|
|
30
30
|
const session = state.getSession(sessionKey);
|
|
31
31
|
if (!session) return;
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
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 (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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)
|
package/src/memory-daemon.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
]
|
|
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
|
-
|
|
144
|
-
if (!metrics) return;
|
|
143
|
+
if (!store.isAvailable()) return;
|
|
145
144
|
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
149
|
+
const transcript = turns
|
|
150
|
+
.map(t => `[${t.role}] ${(t.text ?? "").slice(0, 300)}`)
|
|
151
|
+
.join("\n");
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
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:
|
|
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:
|
|
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
|
-
--
|
|
379
|
-
|
|
380
|
-
|
|
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: `${
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
-
|
|
1073
|
+
// Transaction conflicts expected when daemon writes concurrently — silent
|
|
1074
|
+
swallow("surreal:runMemoryMaintenance", e);
|
|
1074
1075
|
}
|
|
1075
1076
|
}
|
|
1076
1077
|
|
package/src/tools/recall.ts
CHANGED
|
@@ -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
|
}
|