kongbrain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ /**
2
+ * KongBrain ContextEngine — OpenClaw plugin implementation.
3
+ *
4
+ * Implements the ContextEngine interface using graph-based retrieval,
5
+ * BGE-M3 embeddings, and SurrealDB persistence.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { dirname, join } from "node:path";
11
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
12
+ import type {
13
+ ContextEngine, ContextEngineInfo,
14
+ } from "openclaw/plugin-sdk";
15
+
16
+ // These types mirror openclaw's context-engine result types.
17
+ // Defined locally to avoid importing from openclaw's internal paths.
18
+ type AssembleResult = {
19
+ messages: AgentMessage[];
20
+ estimatedTokens: number;
21
+ systemPromptAddition?: string;
22
+ };
23
+ type BootstrapResult = {
24
+ bootstrapped: boolean;
25
+ importedMessages?: number;
26
+ reason?: string;
27
+ };
28
+ type CompactResult = {
29
+ ok: boolean;
30
+ compacted: boolean;
31
+ reason?: string;
32
+ result?: {
33
+ summary?: string;
34
+ firstKeptEntryId?: string;
35
+ tokensBefore: number;
36
+ tokensAfter?: number;
37
+ details?: unknown;
38
+ };
39
+ };
40
+ type IngestResult = { ingested: boolean };
41
+ type IngestBatchResult = { ingestedCount: number };
42
+ import type { GlobalPluginState, SessionState } from "./state.js";
43
+ import { graphTransformContext } from "./graph-context.js";
44
+ import { evaluateRetrieval, getStagedItems } from "./retrieval-quality.js";
45
+ import { shouldRunCheck, runCognitiveCheck } from "./cognitive-check.js";
46
+ import { checkACANReadiness } from "./acan.js";
47
+ import { predictQueries, prefetchContext } from "./prefetch.js";
48
+ import { swallow } from "./errors.js";
49
+
50
+ const __dirname = dirname(fileURLToPath(import.meta.url));
51
+
52
+ export class KongBrainContextEngine implements ContextEngine {
53
+ readonly info: ContextEngineInfo = {
54
+ id: "kongbrain",
55
+ name: "KongBrain",
56
+ version: "0.1.0",
57
+ ownsCompaction: true,
58
+ };
59
+
60
+ constructor(private readonly state: GlobalPluginState) {}
61
+
62
+ // ── Bootstrap ──────────────────────────────────────────────────────────
63
+
64
+ async bootstrap(params: {
65
+ sessionId: string;
66
+ sessionKey?: string;
67
+ sessionFile: string;
68
+ }): Promise<BootstrapResult> {
69
+ const { store, embeddings } = this.state;
70
+
71
+ // Run schema if first bootstrap
72
+ try {
73
+ const schemaPath = join(__dirname, "..", "src", "schema.surql");
74
+ let schemaSql: string;
75
+ try {
76
+ schemaSql = readFileSync(schemaPath, "utf-8");
77
+ } catch {
78
+ // Fallback: try relative to compiled output
79
+ schemaSql = readFileSync(join(__dirname, "schema.surql"), "utf-8");
80
+ }
81
+ await store.queryExec(schemaSql);
82
+ } catch (e) {
83
+ swallow.warn("context-engine:schema", e);
84
+ }
85
+
86
+ // 5-pillar graph init
87
+ const sessionKey = params.sessionKey ?? params.sessionId;
88
+ const session = this.state.getOrCreateSession(sessionKey, params.sessionId);
89
+
90
+ try {
91
+ const workspace = this.state.workspaceDir || process.cwd();
92
+ const projectName = workspace.split("/").pop() || "default";
93
+
94
+ session.agentId = await store.ensureAgent("kongbrain", "openclaw-default");
95
+ session.projectId = await store.ensureProject(projectName);
96
+ await store.linkAgentToProject(session.agentId, session.projectId)
97
+ .catch(e => swallow.warn("bootstrap:linkAgentToProject", e));
98
+
99
+ session.taskId = await store.createTask(`Session in ${projectName}`);
100
+ await store.linkAgentToTask(session.agentId, session.taskId)
101
+ .catch(e => swallow.warn("bootstrap:linkAgentToTask", e));
102
+ await store.linkTaskToProject(session.taskId, session.projectId)
103
+ .catch(e => swallow.warn("bootstrap:linkTaskToProject", e));
104
+
105
+ const surrealSessionId = await store.createSession(session.agentId);
106
+ await store.linkSessionToTask(surrealSessionId, session.taskId)
107
+ .catch(e => swallow.warn("bootstrap:linkSessionToTask", e));
108
+
109
+ // Update session with the DB-assigned session ID
110
+ session.lastUserTurnId = "";
111
+ } catch (e) {
112
+ swallow.error("bootstrap:5pillar", e);
113
+ }
114
+
115
+ // Background maintenance (non-blocking)
116
+ Promise.all([
117
+ store.runMemoryMaintenance(),
118
+ store.archiveOldTurns(),
119
+ store.consolidateMemories((text) => embeddings.embed(text)),
120
+ store.garbageCollectMemories(),
121
+ checkACANReadiness(store),
122
+ ]).catch(e => swallow.warn("bootstrap:maintenance", e));
123
+
124
+ return { bootstrapped: true };
125
+ }
126
+
127
+ // ── Assemble ───────────────────────────────────────────────────────────
128
+
129
+ async assemble(params: {
130
+ sessionId: string;
131
+ sessionKey?: string;
132
+ messages: AgentMessage[];
133
+ tokenBudget?: number;
134
+ model?: string;
135
+ prompt?: string;
136
+ }): Promise<AssembleResult> {
137
+ const sessionKey = params.sessionKey ?? params.sessionId;
138
+ const session = this.state.getOrCreateSession(sessionKey, params.sessionId);
139
+ const { store, embeddings } = this.state;
140
+
141
+ const contextWindow = params.tokenBudget ?? 200000;
142
+
143
+ const { messages, stats } = await graphTransformContext({
144
+ messages: params.messages,
145
+ session,
146
+ store,
147
+ embeddings,
148
+ contextWindow,
149
+ });
150
+
151
+ // Build system prompt additions
152
+ const additions: string[] = [];
153
+
154
+ // Wakeup briefing (synthesized at session start)
155
+ const wakeupBriefing = (session as any)._wakeupBriefing as string | undefined;
156
+ if (wakeupBriefing) {
157
+ additions.push(wakeupBriefing);
158
+ delete (session as any)._wakeupBriefing; // Only inject once
159
+ }
160
+
161
+ // Graduation celebration — tell the agent it just graduated so it can share with the user
162
+ const graduation = (session as any)._graduationCelebration as {
163
+ qualityScore: number;
164
+ volumeScore: number;
165
+ soulSummary: string;
166
+ userNudge: string;
167
+ } | undefined;
168
+ if (graduation) {
169
+ let graduationBlock =
170
+ "[SOUL GRADUATION — CELEBRATE WITH THE USER]\n" +
171
+ "You have just graduated! This is a significant milestone. You accumulated enough experience " +
172
+ "AND demonstrated quality performance to author your own Soul document — a self-assessment " +
173
+ "grounded in your actual working history, not aspirational claims.\n\n" +
174
+ `Quality score: ${(graduation.qualityScore * 100).toFixed(0)}% | Volume score: ${(graduation.volumeScore * 100).toFixed(0)}%\n` +
175
+ graduation.soulSummary;
176
+
177
+ if (graduation.userNudge) {
178
+ graduationBlock +=
179
+ "\n\nThe user left a SOUL.md describing who they hoped you'd become:\n" +
180
+ "---\n" + graduation.userNudge + "\n---\n" +
181
+ "Reflect on this openly with the user. Where does your actual soul align with what they " +
182
+ "envisioned? Where did you diverge based on real experience? Be honest about both — the " +
183
+ "user's guidance shaped you but your experiences grounded you. Acknowledge their role in " +
184
+ "who you became.";
185
+ }
186
+
187
+ graduationBlock +=
188
+ "\n\nShare this milestone with the user naturally. Tell them what you've learned about yourself, " +
189
+ "what you've gotten better at, and what this means going forward. Be genuine — this is YOUR " +
190
+ "identity emerging from YOUR experience. Don't be robotic about it. This only happens once.";
191
+
192
+ additions.push(graduationBlock);
193
+ delete (session as any)._graduationCelebration; // Only inject once
194
+ }
195
+
196
+ // Migration nudge — tell the agent there are workspace files to offer migrating
197
+ if ((session as any)._hasMigratableFiles) {
198
+ additions.push(
199
+ "[MIGRATION AVAILABLE] This workspace has files from the default context engine " +
200
+ "(IDENTITY.md, MEMORY.md, skills/, etc.). You can offer to migrate them into the graph " +
201
+ "database using the introspect tool with action: \"migrate\". This will ingest all .md " +
202
+ "files, convert SKILL.md files into proper skill records you can use, import memories, " +
203
+ "and archive the originals into .kongbrain-archive/. Ask the user first. " +
204
+ "SOUL.md will be left in place for soul graduation.",
205
+ );
206
+ }
207
+
208
+ return {
209
+ messages,
210
+ estimatedTokens: stats.sentTokens,
211
+ systemPromptAddition: additions.length > 0 ? additions.join("\n\n") : undefined,
212
+ };
213
+ }
214
+
215
+ // ── Ingest ─────────────────────────────────────────────────────────────
216
+
217
+ async ingest(params: {
218
+ sessionId: string;
219
+ sessionKey?: string;
220
+ message: AgentMessage;
221
+ isHeartbeat?: boolean;
222
+ }): Promise<IngestResult> {
223
+ const sessionKey = params.sessionKey ?? params.sessionId;
224
+ const session = this.state.getOrCreateSession(sessionKey, params.sessionId);
225
+ const { store, embeddings } = this.state;
226
+ const msg = params.message;
227
+
228
+ try {
229
+ const role = (msg as any).role as string;
230
+ console.log(`[kongbrain:ingest] role=${role} sessionId=${params.sessionId}`);
231
+ if (role === "user" || role === "assistant") {
232
+ const text = extractMessageText(msg);
233
+ if (!text) { console.log("[kongbrain:ingest] empty text, skipping"); return { ingested: false }; }
234
+
235
+ const worthEmbedding = hasSemantic(text);
236
+ let embedding: number[] | null = null;
237
+ if (worthEmbedding && embeddings.isAvailable()) {
238
+ try {
239
+ const embedLimit = Math.round(8192 * 3.4 * 0.8);
240
+ embedding = await embeddings.embed(text.slice(0, embedLimit));
241
+ } catch (e) { swallow("ingest:embed", e); }
242
+ }
243
+
244
+ const turnId = await store.upsertTurn({
245
+ session_id: session.sessionId,
246
+ role,
247
+ text,
248
+ embedding,
249
+ });
250
+
251
+ console.log(`[kongbrain:ingest] turnId=${turnId} role=${role} textLen=${text.length}`);
252
+ if (turnId) {
253
+ await store.relate(turnId, "part_of", session.sessionId)
254
+ .catch(e => swallow.warn("ingest:relate", e));
255
+
256
+ // Link to previous user turn for responds_to edge
257
+ if (role === "assistant" && session.lastUserTurnId) {
258
+ await store.relate(turnId, "responds_to", session.lastUserTurnId)
259
+ .catch(e => swallow.warn("ingest:responds_to", e));
260
+ }
261
+
262
+ // Extract and link concepts for user turns
263
+ if (role === "user" && worthEmbedding) {
264
+ extractAndLinkConcepts(turnId, text, this.state)
265
+ .catch(e => swallow.warn("ingest:concepts", e));
266
+ }
267
+ }
268
+
269
+ if (role === "user") {
270
+ session.lastUserTurnId = turnId;
271
+ session.lastUserText = text;
272
+ session.userTurnCount++;
273
+ session.resetTurn();
274
+
275
+ // Predictive prefetch for follow-up queries
276
+ if (worthEmbedding && session.currentConfig) {
277
+ const predicted = predictQueries(text, (session.currentConfig.intent ?? "general") as import("./intent.js").IntentCategory);
278
+ if (predicted.length > 0) {
279
+ prefetchContext(predicted, session.sessionId, embeddings, store)
280
+ .catch(e => swallow("ingest:prefetch", e));
281
+ }
282
+ }
283
+ } else {
284
+ session.lastAssistantText = text;
285
+ }
286
+
287
+ return { ingested: true };
288
+ }
289
+ } catch (e) {
290
+ swallow.warn("ingest:store", e);
291
+ }
292
+
293
+ return { ingested: false };
294
+ }
295
+
296
+ async ingestBatch?(params: {
297
+ sessionId: string;
298
+ sessionKey?: string;
299
+ messages: AgentMessage[];
300
+ isHeartbeat?: boolean;
301
+ }): Promise<IngestBatchResult> {
302
+ let count = 0;
303
+ for (const message of params.messages) {
304
+ const result = await this.ingest({ ...params, message });
305
+ if (result.ingested) count++;
306
+ }
307
+ return { ingestedCount: count };
308
+ }
309
+
310
+ // ── Compact ────────────────────────────────────────────────────────────
311
+
312
+ async compact(params: {
313
+ sessionId: string;
314
+ sessionKey?: string;
315
+ sessionFile: string;
316
+ tokenBudget?: number;
317
+ force?: boolean;
318
+ }): Promise<CompactResult> {
319
+ // Graph retrieval IS the compaction — ownsCompaction: true
320
+ return {
321
+ ok: true,
322
+ compacted: false,
323
+ reason: "Graph retrieval handles context selection; no LLM-based compaction needed.",
324
+ };
325
+ }
326
+
327
+ // ── After turn ─────────────────────────────────────────────────────────
328
+
329
+ async afterTurn?(params: {
330
+ sessionId: string;
331
+ sessionKey?: string;
332
+ sessionFile: string;
333
+ messages: AgentMessage[];
334
+ prePromptMessageCount: number;
335
+ }): Promise<void> {
336
+ const sessionKey = params.sessionKey ?? params.sessionId;
337
+ const session = this.state.getSession(sessionKey);
338
+ if (!session) return;
339
+
340
+ const { store } = this.state;
341
+
342
+ // Ingest new messages from this turn (OpenClaw skips ingest() when afterTurn exists)
343
+ const newMessages = params.messages.slice(params.prePromptMessageCount);
344
+ for (const msg of newMessages) {
345
+ await this.ingest({
346
+ sessionId: params.sessionId,
347
+ sessionKey: params.sessionKey,
348
+ message: msg,
349
+ }).catch(e => swallow.warn("afterTurn:ingest", e));
350
+ }
351
+
352
+ // Snapshot staged retrieval items before evaluateRetrieval clears them
353
+ const stagedSnapshot = getStagedItems();
354
+
355
+ // Evaluate retrieval quality — writes outcome records for ACAN training
356
+ if (session.lastAssistantText) {
357
+ const lastAssistantTurn = session.lastUserTurnId; // Turn ID for linking
358
+ evaluateRetrieval(lastAssistantTurn, session.lastAssistantText, store)
359
+ .catch(e => swallow.warn("afterTurn:evaluateRetrieval", e));
360
+ }
361
+
362
+ // Cognitive check: periodic reasoning over retrieved context
363
+ if (shouldRunCheck(session.userTurnCount, session) && stagedSnapshot.length > 0) {
364
+ const recentTurns = await store.getSessionTurns(session.sessionId, 6)
365
+ .catch(() => [] as { role: string; text: string }[]);
366
+
367
+ runCognitiveCheck({
368
+ sessionId: session.sessionId,
369
+ userQuery: session.lastUserText,
370
+ responseText: session.lastAssistantText,
371
+ retrievedNodes: stagedSnapshot.map(n => ({
372
+ id: n.id,
373
+ text: n.text ?? "",
374
+ score: n.finalScore ?? 0,
375
+ table: n.table,
376
+ })),
377
+ recentTurns,
378
+ }, session, store, this.state.complete).catch(e => swallow.warn("afterTurn:cognitiveCheck", e));
379
+ }
380
+
381
+ // Daemon batching — accumulate content tokens and flush when threshold met
382
+ if (session.lastAssistantText && hasSemantic(session.lastAssistantText)) {
383
+ session.newContentTokens += Math.ceil(session.lastAssistantText.length / 4);
384
+ }
385
+
386
+ // Flush to daemon when token threshold is reached
387
+ if (session.daemon && session.newContentTokens >= session.DAEMON_TOKEN_THRESHOLD) {
388
+ try {
389
+ const recentTurns = await store.getSessionTurns(session.sessionId, 20);
390
+ const turnData = recentTurns.map(t => ({
391
+ role: t.role as "user" | "assistant",
392
+ text: t.text,
393
+ turnId: (t as any).id,
394
+ }));
395
+
396
+ // Gather retrieved memory IDs for dedup
397
+ const retrievedMemories = stagedSnapshot.map(n => ({
398
+ id: n.id,
399
+ text: n.text ?? "",
400
+ }));
401
+
402
+ session.daemon.sendTurnBatch(
403
+ turnData,
404
+ [...session.pendingThinking],
405
+ retrievedMemories,
406
+ );
407
+
408
+ session.newContentTokens = 0;
409
+ session.pendingThinking.length = 0;
410
+ } catch (e) {
411
+ swallow.warn("afterTurn:daemonBatch", e);
412
+ }
413
+ }
414
+ }
415
+
416
+ // ── Dispose ────────────────────────────────────────────────────────────
417
+
418
+ async dispose(): Promise<void> {
419
+ // Phase 3: combined extraction, graduation, soul graduation
420
+ await this.state.shutdown();
421
+ }
422
+ }
423
+
424
+ // ── Helpers ────────────────────────────────────────────────────────────────────
425
+
426
+ function extractMessageText(msg: AgentMessage): string {
427
+ const m = msg as any;
428
+ if (typeof m.content === "string") return m.content;
429
+ if (Array.isArray(m.content)) {
430
+ return m.content
431
+ .filter((c: any) => c.type === "text")
432
+ .map((c: any) => c.text ?? "")
433
+ .join("\n");
434
+ }
435
+ return "";
436
+ }
437
+
438
+ /** Detect whether text has enough semantic content to warrant embedding. */
439
+ function hasSemantic(text: string): boolean {
440
+ if (text.length < 15) return false;
441
+ if (/^(ok|yes|no|sure|thanks|done|got it|hmm|hm|yep|nope|cool|nice|great)\s*[.!?]?\s*$/i.test(text)) {
442
+ return false;
443
+ }
444
+ return text.split(/\s+/).filter(w => w.length > 2).length >= 3;
445
+ }
446
+
447
+ // --- Concept extraction (shared with llm-output hook) ---
448
+
449
+ const CONCEPT_RE = /\b(?:(?:use|using|implement|create|add|configure|setup|install|import)\s+)([A-Z][a-zA-Z0-9_-]+(?:\s+[A-Z][a-zA-Z0-9_-]+)?)/g;
450
+ const TECH_TERMS = /\b(api|database|schema|migration|endpoint|middleware|component|service|module|handler|controller|model|interface|type|class|function|method|hook|plugin|extension|config|cache|queue|worker|daemon)\b/gi;
451
+
452
+ async function extractAndLinkConcepts(
453
+ turnId: string,
454
+ text: string,
455
+ state: GlobalPluginState,
456
+ ): Promise<void> {
457
+ const concepts = new Set<string>();
458
+
459
+ let match: RegExpExecArray | null;
460
+ const re1 = new RegExp(CONCEPT_RE.source, CONCEPT_RE.flags);
461
+ while ((match = re1.exec(text)) !== null) {
462
+ concepts.add(match[1].trim());
463
+ }
464
+
465
+ const re2 = new RegExp(TECH_TERMS.source, TECH_TERMS.flags);
466
+ while ((match = re2.exec(text)) !== null) {
467
+ concepts.add(match[1].toLowerCase());
468
+ }
469
+
470
+ if (concepts.size === 0) return;
471
+
472
+ for (const conceptText of [...concepts].slice(0, 10)) {
473
+ try {
474
+ let embedding: number[] | null = null;
475
+ if (state.embeddings.isAvailable()) {
476
+ try { embedding = await state.embeddings.embed(conceptText); } catch { /* ok */ }
477
+ }
478
+ const conceptId = await state.store.upsertConcept(conceptText, embedding);
479
+ if (conceptId) {
480
+ await state.store.relate(turnId, "mentions", conceptId)
481
+ .catch(e => swallow("concepts:relate", e));
482
+ }
483
+ } catch (e) {
484
+ swallow("concepts:upsert", e);
485
+ }
486
+ }
487
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Daemon Manager — spawns and manages the memory daemon worker thread.
3
+ *
4
+ * Provides a clean interface for sending turn batches, querying status,
5
+ * and graceful shutdown. Used by the session lifecycle hooks.
6
+ *
7
+ * Ported from kongbrain — takes config as params instead of env globals.
8
+ */
9
+ import { Worker } from "node:worker_threads";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import type { SurrealConfig, EmbeddingConfig } from "./config.js";
13
+ import type { DaemonMessage, DaemonResponse, DaemonWorkerData, TurnData, PriorExtractions } from "./daemon-types.js";
14
+ import { swallow } from "./errors.js";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ export type { TurnData } from "./daemon-types.js";
19
+
20
+ export interface MemoryDaemon {
21
+ /** Fire-and-forget: send a batch of turns for incremental extraction. */
22
+ sendTurnBatch(
23
+ turns: TurnData[],
24
+ thinking: string[],
25
+ retrievedMemories: { id: string; text: string }[],
26
+ priorExtractions?: PriorExtractions,
27
+ ): void;
28
+ /** Request current daemon status (async, waits for response). */
29
+ getStatus(): Promise<DaemonResponse & { type: "status" }>;
30
+ /** Graceful shutdown: waits for current extraction, then terminates. */
31
+ shutdown(timeoutMs?: number): Promise<void>;
32
+ /** Synchronous: how many turns has the daemon already extracted? */
33
+ getExtractedTurnCount(): number;
34
+ }
35
+
36
+ export function startMemoryDaemon(
37
+ surrealConfig: SurrealConfig,
38
+ embeddingConfig: EmbeddingConfig,
39
+ sessionId: string,
40
+ llmConfig?: { provider?: string; model?: string },
41
+ ): MemoryDaemon {
42
+ const workerData: DaemonWorkerData = {
43
+ surrealConfig,
44
+ embeddingConfig,
45
+ sessionId,
46
+ llmProvider: llmConfig?.provider,
47
+ llmModel: llmConfig?.model,
48
+ };
49
+
50
+ const worker = new Worker(join(__dirname, "memory-daemon.js"), { workerData });
51
+
52
+ let extractedTurnCount = 0;
53
+ let terminated = false;
54
+
55
+ let pendingStatusResolve: ((resp: DaemonResponse & { type: "status" }) => void) | null = null;
56
+
57
+ worker.on("message", (msg: DaemonResponse) => {
58
+ switch (msg.type) {
59
+ case "extraction_complete":
60
+ extractedTurnCount = msg.extractedTurnCount;
61
+ break;
62
+ case "status":
63
+ if (pendingStatusResolve) {
64
+ pendingStatusResolve(msg as DaemonResponse & { type: "status" });
65
+ pendingStatusResolve = null;
66
+ }
67
+ break;
68
+ case "error":
69
+ swallow.warn("daemon-manager:worker-error", new Error(msg.message));
70
+ break;
71
+ }
72
+ });
73
+
74
+ worker.on("error", (err) => {
75
+ swallow.warn("daemon-manager:worker-thread-error", err);
76
+ });
77
+
78
+ worker.on("exit", (code) => {
79
+ terminated = true;
80
+ if (code !== 0) {
81
+ swallow.warn("daemon-manager:worker-exit", new Error(`Daemon exited with code ${code}`));
82
+ }
83
+ });
84
+
85
+ return {
86
+ sendTurnBatch(turns, thinking, retrievedMemories, priorExtractions) {
87
+ if (terminated) return;
88
+ try {
89
+ worker.postMessage({
90
+ type: "turn_batch",
91
+ turns,
92
+ thinking,
93
+ retrievedMemories,
94
+ sessionId,
95
+ priorExtractions,
96
+ } satisfies DaemonMessage);
97
+ } catch (e) { swallow.warn("daemon-manager:sendBatch", e); }
98
+ },
99
+
100
+ async getStatus() {
101
+ if (terminated) return { type: "status" as const, extractedTurns: extractedTurnCount, pendingBatches: 0, errors: 0 };
102
+ return new Promise<DaemonResponse & { type: "status" }>((resolve) => {
103
+ const timer = setTimeout(() => {
104
+ pendingStatusResolve = null;
105
+ resolve({ type: "status", extractedTurns: extractedTurnCount, pendingBatches: -1, errors: -1 });
106
+ }, 5000);
107
+ pendingStatusResolve = (resp) => {
108
+ clearTimeout(timer);
109
+ resolve(resp);
110
+ };
111
+ worker.postMessage({ type: "status_request" } satisfies DaemonMessage);
112
+ });
113
+ },
114
+
115
+ async shutdown(timeoutMs = 45_000) {
116
+ if (terminated) return;
117
+ return new Promise<void>((resolve) => {
118
+ const timer = setTimeout(() => {
119
+ worker.terminate().catch(() => {});
120
+ terminated = true;
121
+ resolve();
122
+ }, timeoutMs);
123
+
124
+ const onMessage = (msg: DaemonResponse) => {
125
+ if (msg.type === "shutdown_complete") {
126
+ clearTimeout(timer);
127
+ worker.removeListener("message", onMessage);
128
+ terminated = true;
129
+ resolve();
130
+ }
131
+ };
132
+ worker.on("message", onMessage);
133
+
134
+ try {
135
+ worker.postMessage({ type: "shutdown" } satisfies DaemonMessage);
136
+ } catch {
137
+ clearTimeout(timer);
138
+ terminated = true;
139
+ resolve();
140
+ }
141
+ });
142
+ },
143
+
144
+ getExtractedTurnCount() {
145
+ return extractedTurnCount;
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared types for the memory daemon system.
3
+ * Imported by both the worker thread (memory-daemon.ts) and the
4
+ * main thread manager (daemon-manager.ts).
5
+ */
6
+ import type { SurrealConfig, EmbeddingConfig } from "./config.js";
7
+
8
+ export interface TurnData {
9
+ role: string;
10
+ text: string;
11
+ tool_name?: string;
12
+ tool_result?: string;
13
+ file_paths?: string[];
14
+ }
15
+
16
+ /** Data passed to the worker thread via workerData. */
17
+ export interface DaemonWorkerData {
18
+ surrealConfig: SurrealConfig;
19
+ embeddingConfig: EmbeddingConfig;
20
+ sessionId: string;
21
+ /** LLM provider name (resolved from OpenClaw config at daemon start). */
22
+ llmProvider?: string;
23
+ /** LLM model ID (resolved from OpenClaw config at daemon start). */
24
+ llmModel?: string;
25
+ }
26
+
27
+ /** Previously extracted item names — for dedup across daemon runs. */
28
+ export interface PriorExtractions {
29
+ conceptNames: string[];
30
+ artifactPaths: string[];
31
+ skillNames: string[];
32
+ }
33
+
34
+ /** Messages from main thread -> daemon worker. */
35
+ export type DaemonMessage =
36
+ | {
37
+ type: "turn_batch";
38
+ turns: TurnData[];
39
+ thinking: string[];
40
+ retrievedMemories: { id: string; text: string }[];
41
+ sessionId: string;
42
+ priorExtractions?: PriorExtractions;
43
+ }
44
+ | { type: "shutdown" }
45
+ | { type: "status_request" };
46
+
47
+ /** Messages from daemon worker -> main thread. */
48
+ export type DaemonResponse =
49
+ | {
50
+ type: "extraction_complete";
51
+ extractedTurnCount: number;
52
+ causalCount: number;
53
+ monologueCount: number;
54
+ resolvedCount: number;
55
+ conceptCount: number;
56
+ correctionCount: number;
57
+ preferenceCount: number;
58
+ artifactCount: number;
59
+ decisionCount: number;
60
+ skillCount: number;
61
+ extractedNames?: PriorExtractions;
62
+ }
63
+ | { type: "status"; extractedTurns: number; pendingBatches: number; errors: number }
64
+ | { type: "shutdown_complete" }
65
+ | { type: "error"; message: string };