@digitalforgestudios/openclaw-sulcus 1.5.3 → 3.0.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.
package/index.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { spawn, ChildProcess } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
1
  import { resolve } from "node:path";
2
+ import { existsSync } from "node:fs";
4
3
  import { Type } from "@sinclair/typebox";
5
4
 
6
5
  // ─── STATIC AWARENESS ───────────────────────────────────────────────────────
@@ -8,295 +7,171 @@ import { Type } from "@sinclair/typebox";
8
7
  // This is the absolute minimum the LLM needs to know Sulcus exists.
9
8
  // It fires even if build_context crashes, times out, or returns empty.
10
9
  // Build static awareness with runtime backend info
11
- function buildStaticAwareness(backendMode: string, namespace: string, serverUrl: string) {
10
+ function buildStaticAwareness(backendMode: string, namespace: string) {
12
11
  return `## Persistent Memory (Sulcus)
13
12
  You have Sulcus — a persistent, reactive, thermodynamic memory system with reactive triggers.
14
13
  Memories survive across sessions. They have heat (0.0–1.0) that decays over time.
15
14
 
16
- **Connection:** Backend: ${backendMode} | Namespace: ${namespace} | Server: ${serverUrl}
15
+ **Connection:** Backend: ${backendMode} | Namespace: ${namespace}
17
16
 
18
17
  **Your memory tools:**
19
18
  - \`memory_store\` — Save important information (preferences, facts, procedures, decisions, lessons)
20
- Parameters: content, memory_type (episodic|semantic|preference|procedural|fact), decay_class (volatile|normal|stable|permanent), is_pinned, min_heat, key_points
19
+ Parameters: content, memory_type (episodic|semantic|preference|procedural|fact)
21
20
  - \`memory_recall\` — Search memories semantically. Use before answering about past work, decisions, or people.
22
21
  Parameters: query, limit
23
22
 
24
23
  **When to store:** User states a preference, important decision made, correction given, lesson learned, anything worth surviving this session.
25
24
  **When to search:** Questions about prior work/decisions, context seems incomplete, user references past conversations.
26
25
 
27
- **Memory types:** episodic (events, fast decay) · semantic (knowledge, slow) · preference (opinions, slower) · procedural (how-tos, slowest) · fact (data, slow)
28
- **Decay classes:** volatile (hours) · normal (days) · stable (weeks) · permanent (never)
29
- **Pinning:** is_pinned=true prevents decay. Use for critical knowledge.
30
- **Triggers:** Reactive rules on memory events. Active triggers and recent fires appear in your context below.`;
26
+ **Memory types:** episodic (events, fast decay) · semantic (knowledge, slow) · preference (opinions, slower) · procedural (how-tos, slowest) · fact (data, slow)`;
31
27
  }
32
28
 
33
29
  // Legacy static string for backward compat (overwritten at register time)
34
- let STATIC_AWARENESS = buildStaticAwareness("unknown", "default", "unknown");
30
+ let STATIC_AWARENESS = buildStaticAwareness("local", "default");
35
31
 
36
32
  // Fallback context when build_context fails — includes the cheatsheet
37
33
  // but warns that dynamic context is unavailable.
38
34
  const FALLBACK_AWARENESS = `<sulcus_context token_budget="500">
39
35
  <cheatsheet>
40
36
  You have Sulcus — persistent memory with reactive triggers.
41
- STORE: memory_store (content, memory_type, decay_class, is_pinned, key_points)
37
+ STORE: memory_store (content, memory_type)
42
38
  FIND: memory_recall (query, limit)
43
- MANAGE: memory_boost / memory_deprecate / memory_relate / memory_reclassify
44
- PIN: Set is_pinned=true to make a memory permanent (immune to decay).
45
- TRIGGERS: create_trigger to set reactive rules on your memory graph
46
39
  TYPES: episodic (fast fade), semantic (slow), preference, procedural (slowest), fact
47
40
  ⚠️ Context build failed this turn — use memory_recall to search manually.
48
41
  Below is your active context. Search for deeper recall. Unlimited storage.
49
42
  </cheatsheet>
50
43
  </sulcus_context>`;
51
44
 
52
- // Simple MCP Client for sulcus-local
53
- class SulcusClient {
54
- private child: ChildProcess | null = null;
55
- private nextId = 1;
56
- private pending = new Map<string | number, (res: any) => void>();
57
- private configPath: string | undefined;
45
+ // ─── NATIVE LIB LOADER ──────────────────────────────────────────────────────
46
+ // Loads libsulcus_store.dylib (embedded PG) and libsulcus_embed.dylib (embeddings)
47
+ // via koffi FFI. Provides queryFn and embedFn callbacks for SulcusMem.create().
58
48
 
59
- constructor(private binaryPath: string, configPath?: string) {
60
- this.configPath = configPath;
61
- }
62
-
63
- // SECURITY NOTE: spawn() launches the sulcus-local binary (a Rust MCP server)
64
- // as a child process for local-only operation. No user data is passed via argv
65
- // or env vars — only RUST_LOG for log verbosity. This is the standard MCP sidecar
66
- // pattern used by Claude Desktop, Cursor, etc. Only used when serverUrl is empty
67
- // (local mode). When serverUrl is set, REST API is used instead (no spawn).
68
- async start(configPath?: string) {
69
- const cfgPath = configPath || this.configPath;
70
- const args = cfgPath ? ["--config", cfgPath, "stdio"] : ["stdio"];
71
- this.child = spawn(this.binaryPath, args, {
72
- stdio: ["pipe", "pipe", "inherit"],
73
- env: { ...process.env, RUST_LOG: "info" } // Only passes log-level config, not secrets
74
- });
75
-
76
- this.child.on("error", (err) => {
77
- // Reject all pending calls if the process dies
78
- for (const [id, resolve] of this.pending) {
79
- resolve({ error: { code: -1, message: `Sulcus process error: ${err.message}` } });
80
- }
81
- this.pending.clear();
82
- this.child = null;
83
- });
49
+ class NativeLibLoader {
50
+ private koffi: any = null;
51
+ private storeLib: any = null;
52
+ private embedLib: any = null;
53
+ private embedHandle: any = null;
84
54
 
85
- this.child.on("exit", (code) => {
86
- for (const [id, resolve] of this.pending) {
87
- resolve({ error: { code: -1, message: `Sulcus process exited with code ${code}` } });
88
- }
89
- this.pending.clear();
90
- this.child = null;
91
- });
55
+ // koffi function handles
56
+ private fn_store_init: any = null;
57
+ private fn_store_query: any = null;
58
+ private fn_store_free: any = null;
59
+ private fn_embed_create: any = null;
60
+ private fn_embed_text: any = null;
61
+ private fn_embed_free: any = null;
92
62
 
93
- const rl = createInterface({ input: this.child.stdout! });
94
- rl.on("line", (line) => {
95
- try {
96
- const msg = JSON.parse(line);
97
- if (msg.id && this.pending.has(msg.id)) {
98
- const resolve = this.pending.get(msg.id)!;
99
- this.pending.delete(msg.id);
100
- resolve(msg);
101
- }
102
- } catch (e) {}
103
- });
104
- }
63
+ public loaded = false;
64
+ public error: string | null = null;
105
65
 
106
- async call(method: string, params: any = {}): Promise<any> {
107
- if (!this.child) await this.start();
108
- const id = this.nextId++;
109
- const request = { jsonrpc: "2.0", id, method: "tools/call", params: { name: method, arguments: params } };
110
-
111
- return new Promise((resolve, reject) => {
112
- const timeout = setTimeout(() => reject(new Error(`Sulcus timeout: ${method}`)), 30000);
113
- this.pending.set(id, (res) => {
114
- clearTimeout(timeout);
115
- if (res.error) reject(new Error(res.error.message));
116
- else {
117
- // MCP result format
118
- try {
119
- const content = JSON.parse(res.result.content[0].text);
120
- resolve(content);
121
- } catch(e) {
122
- resolve(res.result);
123
- }
124
- }
125
- });
126
- this.child!.stdin!.write(JSON.stringify(request) + "\n");
127
- });
128
- }
129
-
130
- stop() {
131
- if (this.child) this.child.kill();
132
- }
133
- }
134
-
135
- // REST API client — fallback when sulcus-local binary isn't available
136
- class SulcusRestClient {
137
66
  constructor(
138
- private serverUrl: string,
139
- private apiKey: string,
140
- private namespace: string,
141
- private logger: any
67
+ private storeLibPath: string,
68
+ private embedLibPath: string
142
69
  ) {}
143
70
 
144
- private async fetch(path: string, options: RequestInit = {}): Promise<any> {
145
- const url = `${this.serverUrl}${path}`;
146
- const headers: Record<string, string> = {
147
- "Authorization": `Bearer ${this.apiKey}`,
148
- "Content-Type": "application/json",
149
- ...(options.headers as Record<string, string> || {}),
150
- };
151
- const res = await globalThis.fetch(url, { ...options, headers });
152
- if (!res.ok) {
153
- const text = await res.text().catch(() => "");
154
- throw new Error(`Sulcus API ${res.status}: ${text}`);
71
+ init(logger: any): void {
72
+ try {
73
+ // koffi is a pure-JS FFI library — no native compilation needed
74
+ this.koffi = require("koffi");
75
+ } catch (e: any) {
76
+ this.error = `koffi not available: ${e.message}`;
77
+ logger.warn(`memory-sulcus: ${this.error}`);
78
+ return;
155
79
  }
156
- return res.json();
157
- }
158
-
159
- async searchMemory(query: string, limit: number = 5): Promise<any> {
160
- const raw = await this.fetch("/api/v1/agent/search", {
161
- method: "POST",
162
- body: JSON.stringify({ query, namespace: this.namespace, limit }),
163
- });
164
- // Normalize: server returns {results, provenance} — ensure .results is always accessible
165
- // Also handle flat array (backward compat) and .items/.nodes variants
166
- const results = raw?.results ?? raw?.items ?? raw?.nodes ?? (Array.isArray(raw) ? raw : []);
167
- return { results, provenance: raw?.provenance || { backend: "cloud", namespace: this.namespace } };
168
- }
169
-
170
- async recordMemory(params: any): Promise<any> {
171
- const raw = await this.fetch("/api/v1/agent/nodes", {
172
- method: "POST",
173
- body: JSON.stringify({
174
- label: params.content,
175
- memory_type: params.memory_type || "episodic",
176
- namespace: params.fold_name || this.namespace,
177
- heat: 0.8,
178
- }),
179
- });
180
- // Normalize: ensure node_id is accessible (server returns "id")
181
- return { ...raw, node_id: raw?.id || raw?.node_id };
182
- }
183
-
184
- async getMemory(id: string): Promise<any> {
185
- return this.fetch(`/api/v1/agent/nodes/${id}`);
186
- }
187
-
188
- async deleteMemory(id: string): Promise<any> {
189
- return this.fetch(`/api/v1/agent/nodes/${id}`, { method: "DELETE" });
190
- }
191
-
192
- async buildContext(prompt: string, tokenBudget: number = 2000): Promise<any> {
193
- // REST doesn't have a build_context endpoint — search for relevant memories instead
194
- const res = await this.searchMemory(prompt, 10);
195
- const results = res?.results || res || [];
196
- if (!results.length) return null;
197
-
198
- // Build a simple context string from results
199
- const items = results.slice(0, 5).map((r: any) =>
200
- `[${r.memory_type || "memory"}] ${r.pointer_summary || r.label || ""}`
201
- ).join("\n");
202
- return `<sulcus_context source="cloud" namespace="${this.namespace}">\n${items}\n</sulcus_context>`;
203
- }
204
80
 
205
- async getStatus(): Promise<any> {
206
- return this.fetch("/api/v1/agent/memory/status");
207
- }
208
- }
209
-
210
- // ─── CLIENT-SIDE SIU (Semantic Inference Unit) ───────────────────────────────
211
- // JSON weights classifier: scale → dot → sigmoid. Zero native deps.
212
- // Downloads model from server on first use, caches locally.
213
-
214
- interface SiuModel {
215
- classes: string[];
216
- scaler_mean: number[];
217
- scaler_scale: number[];
218
- coefficients: number[][];
219
- intercepts: number[];
220
- n_features: number;
221
- default_threshold: number;
222
- }
223
-
224
- class ClientSiu {
225
- private model: SiuModel | null = null;
226
- private modelPath: string;
227
- private serverUrl: string;
228
- private apiKey: string;
81
+ // ── Load libsulcus_store.dylib ──
82
+ if (!existsSync(this.storeLibPath)) {
83
+ this.error = `libsulcus_store not found at ${this.storeLibPath}`;
84
+ logger.warn(`memory-sulcus: ${this.error}`);
85
+ return;
86
+ }
87
+ if (!existsSync(this.embedLibPath)) {
88
+ this.error = `libsulcus_embed not found at ${this.embedLibPath}`;
89
+ logger.warn(`memory-sulcus: ${this.error}`);
90
+ return;
91
+ }
229
92
 
230
- constructor(cacheDir: string, serverUrl: string, apiKey: string) {
231
- this.modelPath = resolve(cacheDir, "memory_classifier_multilabel.json");
232
- this.serverUrl = serverUrl;
233
- this.apiKey = apiKey;
234
- }
93
+ try {
94
+ this.storeLib = this.koffi.load(this.storeLibPath);
95
+ this.fn_store_init = this.storeLib.func("sulcus_store_init", "int", ["str", "uint16"]);
96
+ this.fn_store_query = this.storeLib.func("sulcus_store_query", "char*", ["str"]);
97
+ this.fn_store_free = this.storeLib.func("sulcus_store_free_string", "void", ["char*"]);
98
+ } catch (e: any) {
99
+ this.error = `Failed to load libsulcus_store: ${e.message}`;
100
+ logger.warn(`memory-sulcus: ${this.error}`);
101
+ return;
102
+ }
235
103
 
236
- // SECURITY NOTE: SIU (Semantic Intelligence Unit) model is a JSON classifier
237
- // for memory type detection. Downloaded once from the configured Sulcus server,
238
- // then cached locally at ~/.sulcus/cache/. File read is local cache check only —
239
- // no user data is sent. The download sends only the API key for auth, not file
240
- // contents. This is a standard model-caching pattern (like downloading an ONNX model).
241
- async ensureModel(): Promise<SiuModel | null> {
242
- if (this.model) return this.model;
243
- const { existsSync, readFileSync, writeFileSync, mkdirSync } = require("node:fs");
244
- const { dirname } = require("node:path");
245
-
246
- // Try loading cached model — local file read, no network
247
- if (existsSync(this.modelPath)) {
248
- try {
249
- this.model = JSON.parse(readFileSync(this.modelPath, "utf8"));
250
- return this.model;
251
- } catch { }
104
+ try {
105
+ this.embedLib = this.koffi.load(this.embedLibPath);
106
+ this.fn_embed_create = this.embedLib.func("sulcus_embed_create", "void*", []);
107
+ this.fn_embed_text = this.embedLib.func("sulcus_embed_text", "char*", ["void*", "str"]);
108
+ this.fn_embed_free = this.embedLib.func("sulcus_embed_free_string", "void", ["char*"]);
109
+ } catch (e: any) {
110
+ this.error = `Failed to load libsulcus_embed: ${e.message}`;
111
+ logger.warn(`memory-sulcus: ${this.error}`);
112
+ return;
252
113
  }
253
114
 
254
- // Download from server
255
- if (!this.serverUrl || !this.apiKey) return null;
115
+ // ── Initialise embedded PG store ──
256
116
  try {
257
- const res = await globalThis.fetch(
258
- `${this.serverUrl}/api/v1/agent/siu-model`,
259
- { headers: { "Authorization": `Bearer ${this.apiKey}` } }
260
- );
261
- if (res.ok) {
262
- const data = await res.json();
263
- mkdirSync(dirname(this.modelPath), { recursive: true });
264
- writeFileSync(this.modelPath, JSON.stringify(data));
265
- this.model = data;
266
- return this.model;
117
+ const dataDir = resolve(process.env.HOME || "~", ".sulcus/data");
118
+ const port = 15432;
119
+ const rc = this.fn_store_init(dataDir, port);
120
+ if (rc !== 0) {
121
+ this.error = `sulcus_store_init returned ${rc}`;
122
+ logger.warn(`memory-sulcus: ${this.error}`);
123
+ return;
267
124
  }
268
- } catch { }
269
- return null;
270
- }
271
-
272
- // Classify using pre-computed embedding (384-dim)
273
- classifyEmbedding(embedding: number[]): { type: string; confidence: number; all: Record<string, number> } | null {
274
- if (!this.model) return null;
275
- const m = this.model;
276
- if (embedding.length !== m.n_features) return null;
125
+ } catch (e: any) {
126
+ this.error = `sulcus_store_init failed: ${e.message}`;
127
+ logger.warn(`memory-sulcus: ${this.error}`);
128
+ return;
129
+ }
277
130
 
278
- // Scale: (x - mean) / scale
279
- const scaled = embedding.map((x, i) => (x - m.scaler_mean[i]) / m.scaler_scale[i]);
131
+ // ── Create embed handle ──
132
+ try {
133
+ this.embedHandle = this.fn_embed_create();
134
+ } catch (e: any) {
135
+ this.error = `sulcus_embed_create failed: ${e.message}`;
136
+ logger.warn(`memory-sulcus: ${this.error}`);
137
+ return;
138
+ }
280
139
 
281
- // Dot product + sigmoid for each class
282
- const scores: Record<string, number> = {};
283
- let bestType = "episodic";
284
- let bestScore = 0;
140
+ this.loaded = true;
141
+ logger.info(`memory-sulcus: native libs loaded (store: ${this.storeLibPath}, embed: ${this.embedLibPath})`);
142
+ }
285
143
 
286
- for (let c = 0; c < m.classes.length; c++) {
287
- let dot = m.intercepts[c];
288
- for (let f = 0; f < m.n_features; f++) {
289
- dot += scaled[f] * m.coefficients[c][f];
290
- }
291
- const sigmoid = 1 / (1 + Math.exp(-dot));
292
- scores[m.classes[c]] = sigmoid;
293
- if (sigmoid > bestScore) {
294
- bestScore = sigmoid;
295
- bestType = m.classes[c];
144
+ // queryFn: async (sql: string, params: any[]) => any[]
145
+ // Calls sulcus_store_query which accepts plain SQL (params inlined by caller or
146
+ // passed as a JSON payload the store lib handles parameterisation internally).
147
+ makeQueryFn(): (sql: string, params: any[]) => Promise<any[]> {
148
+ return async (sql: string, _params: any[]): Promise<any[]> => {
149
+ if (!this.loaded) throw new Error("Sulcus store not available");
150
+ // The store lib's query function takes a single JSON-encoded request
151
+ const payload = JSON.stringify({ sql, params: _params });
152
+ const raw: string = this.fn_store_query(payload);
153
+ if (!raw) return [];
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ return Array.isArray(parsed) ? parsed : (parsed?.rows ?? [parsed]);
157
+ } finally {
158
+ // NOTE: koffi manages string memory automatically for char* returns;
159
+ // no manual free needed with koffi's default charset handling.
160
+ // If the ABI requires explicit free, call fn_store_free here.
296
161
  }
297
- }
162
+ };
163
+ }
298
164
 
299
- return { type: bestType, confidence: bestScore, all: scores };
165
+ // embedFn: async (text: string) => Float32Array
166
+ // Calls sulcus_embed_text which returns a JSON float array string.
167
+ makeEmbedFn(): (text: string) => Promise<Float32Array> {
168
+ return async (text: string): Promise<Float32Array> => {
169
+ if (!this.loaded) throw new Error("Sulcus embed not available");
170
+ const raw: string = this.fn_embed_text(this.embedHandle, text);
171
+ if (!raw) throw new Error("sulcus_embed_text returned null");
172
+ const arr: number[] = JSON.parse(raw);
173
+ return new Float32Array(arr);
174
+ };
300
175
  }
301
176
  }
302
177
 
@@ -320,86 +195,83 @@ const JUNK_PATTERNS = [
320
195
 
321
196
  function isJunkMemory(text: string): boolean {
322
197
  if (!text || text.length < 10) return true;
323
- if (text.length > 10000) return true; // probably a raw dump
198
+ if (text.length > 10000) return true;
324
199
  for (const pattern of JUNK_PATTERNS) {
325
200
  if (pattern.test(text.trim())) return true;
326
201
  }
327
202
  return false;
328
203
  }
329
204
 
205
+ // ─── PLUGIN ──────────────────────────────────────────────────────────────────
206
+
330
207
  const sulcusPlugin = {
331
208
  id: "memory-sulcus",
332
209
  name: "Sulcus vMMU",
333
- description: "Sulcus-backed vMMU memory for OpenClaw — thermodynamic decay, reactive triggers, local-first with cloud sync",
210
+ description: "Sulcus-backed vMMU memory for OpenClaw — thermodynamic decay, reactive triggers, local-first",
334
211
  kind: "memory" as const,
335
212
 
336
213
  register(api: any) {
337
- const binaryPath = api.config?.binaryPath || "/Users/dv00003-00/dev/sulcus/target/release/sulcus-local";
338
- const iniPath = api.config?.iniPath || resolve(process.env.HOME || "~", ".config/sulcus/sulcus.ini");
214
+ // ── Configuration ──
215
+ const libDir = api.config?.libDir
216
+ ? resolve(api.config.libDir)
217
+ : resolve(process.env.HOME || "~", ".sulcus/lib");
218
+
219
+ const storeLibPath = api.config?.storeLibPath
220
+ ? resolve(api.config.storeLibPath)
221
+ : resolve(libDir, process.platform === "darwin" ? "libsulcus_store.dylib" : "libsulcus_store.so");
222
+
223
+ const embedLibPath = api.config?.embedLibPath
224
+ ? resolve(api.config.embedLibPath)
225
+ : resolve(libDir, process.platform === "darwin" ? "libsulcus_embed.dylib" : "libsulcus_embed.so");
226
+
227
+ const wasmDir = api.config?.wasmDir
228
+ ? resolve(api.config.wasmDir)
229
+ : resolve(__dirname, "wasm");
230
+
339
231
  // Default namespace = agent name (prevents everything landing in "default")
340
- // Priority: explicit namespace config > agentId config > pluginConfig.agentId > "default"
341
232
  const agentId = api.config?.agentId || api.pluginConfig?.agentId;
342
233
  const namespace = api.config?.namespace === "default" && agentId
343
234
  ? agentId
344
235
  : (api.config?.namespace || agentId || "default");
345
- const serverUrl = api.config?.serverUrl || "";
346
- const apiKey = api.config?.apiKey || "";
347
- const client = new SulcusClient(binaryPath, iniPath);
348
236
 
349
- // Detect backend mode: if serverUrl is set and binaryPath doesn't exist, it's cloud-only
350
- let backendMode = "local"; // default assumption
351
- let hasBinary = false;
352
- try {
353
- const { existsSync } = require("node:fs");
354
- hasBinary = existsSync(binaryPath);
355
- if (!hasBinary) {
356
- backendMode = serverUrl ? "cloud" : "unavailable";
357
- } else {
358
- backendMode = serverUrl ? "hybrid" : "local";
359
- }
360
- } catch { }
237
+ // ── Load native dylibs ──
238
+ const nativeLoader = new NativeLibLoader(storeLibPath, embedLibPath);
239
+ nativeLoader.init(api.logger);
361
240
 
362
- // REST client for cloud fallback (or cloud-only mode)
363
- const restClient = serverUrl && apiKey
364
- ? new SulcusRestClient(serverUrl, apiKey, namespace, api.logger)
365
- : null;
241
+ // ── Load WASM module ──
242
+ let sulcusMem: any = null;
243
+ let backendMode = "unavailable";
366
244
 
367
- // Unified call helper: try local binary first, fall back to REST
368
- async function memoryCall(method: string, params: any): Promise<any> {
369
- if (hasBinary) {
245
+ if (nativeLoader.loaded) {
246
+ const wasmJsPath = resolve(wasmDir, "sulcus_wasm.js");
247
+ if (existsSync(wasmJsPath)) {
370
248
  try {
371
- return await client.call(method, params);
249
+ const { SulcusMem, on_init } = require(wasmJsPath);
250
+ // on_init sets up WASM internals (panic hooks etc.)
251
+ if (typeof on_init === "function") on_init();
252
+
253
+ const queryFn = nativeLoader.makeQueryFn();
254
+ const embedFn = nativeLoader.makeEmbedFn();
255
+ sulcusMem = SulcusMem.create(queryFn, embedFn);
256
+ backendMode = "wasm";
257
+ api.logger.info(`memory-sulcus: SulcusMem created via WASM (wasm: ${wasmJsPath})`);
372
258
  } catch (e: any) {
373
- api.logger.warn(`memory-sulcus: local binary failed (${method}): ${e.message}, falling back to REST`);
374
- if (!restClient) throw e;
259
+ api.logger.warn(`memory-sulcus: WASM load failed: ${e.message}`);
260
+ backendMode = "unavailable";
375
261
  }
262
+ } else {
263
+ api.logger.warn(`memory-sulcus: WASM module not found at ${wasmJsPath}`);
376
264
  }
377
- if (!restClient) throw new Error("Sulcus unavailable: no binary and no server configured");
378
- // Route to appropriate REST method
379
- switch (method) {
380
- case "search_memory": return restClient.searchMemory(params.query, params.limit);
381
- case "record_memory": return restClient.recordMemory(params);
382
- case "build_context": return restClient.buildContext(params.prompt, params.token_budget);
383
- default: throw new Error(`Unknown method: ${method}`);
384
- }
265
+ } else {
266
+ api.logger.warn(`memory-sulcus: native libs unavailable ${nativeLoader.error}`);
385
267
  }
386
268
 
269
+ const isAvailable = sulcusMem !== null;
270
+
387
271
  // Update static awareness with runtime info
388
- STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace, serverUrl || "local");
389
-
390
- // Initialize client-side SIU
391
- const siuCacheDir = resolve(process.env.HOME || "~", ".cache/sulcus/model");
392
- const clientSiu = serverUrl && apiKey ? new ClientSiu(siuCacheDir, serverUrl, apiKey) : null;
393
-
394
- // Pre-load SIU model (non-blocking)
395
- if (clientSiu) {
396
- clientSiu.ensureModel().then(m => {
397
- if (m) api.logger.info(`memory-sulcus: client SIU loaded (${m.classes.length} classes, ${m.n_features} features)`);
398
- else api.logger.warn("memory-sulcus: client SIU model not available");
399
- }).catch(() => {});
400
- }
272
+ STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
401
273
 
402
- api.logger.info(`memory-sulcus: registered (binary: ${binaryPath}, hasBinary: ${hasBinary}, namespace: ${namespace}, backend: ${backendMode})`);
274
+ api.logger.info(`memory-sulcus: registered (backend: ${backendMode}, namespace: ${namespace}, available: ${isAvailable})`);
403
275
 
404
276
  // ── Core memory tools ──
405
277
 
@@ -412,12 +284,14 @@ const sulcusPlugin = {
412
284
  limit: Type.Optional(Type.Number({ default: 5, description: "Maximum number of results to return (1-10)." }))
413
285
  }),
414
286
  async execute(_id: string, params: any) {
415
- const res = await memoryCall("search_memory", { query: params.query, limit: params.limit });
416
- const results = res?.results || res;
417
- const provenance = res?.provenance || { backend: backendMode, namespace };
287
+ if (!isAvailable) {
288
+ throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
289
+ }
290
+ const res = await sulcusMem.search_memory(params.query, params.limit ?? 5);
291
+ const results = res?.results ?? res;
418
292
  return {
419
293
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
420
- details: { ...res, provenance }
294
+ details: { results, backend: backendMode, namespace }
421
295
  };
422
296
  }
423
297
  }, { name: "memory_recall" });
@@ -425,10 +299,9 @@ const sulcusPlugin = {
425
299
  api.registerTool({
426
300
  name: "memory_store",
427
301
  label: "Memory Store",
428
- description: "Record information in Sulcus memory. Supports Markdown formatting. You control the memory type, decay rate, importance, and key details at creation time.",
302
+ description: "Record information in Sulcus memory. Supports Markdown formatting. You control the memory type at creation time.",
429
303
  parameters: Type.Object({
430
304
  content: Type.String({ description: "Memory content. Supports Markdown formatting for structured content." }),
431
- fold_name: Type.Optional(Type.String({ description: `Memory namespace/fold. Defaults to "${namespace}" (agent namespace).` })),
432
305
  memory_type: Type.Optional(Type.Union([
433
306
  Type.Literal("episodic"),
434
307
  Type.Literal("semantic"),
@@ -436,15 +309,6 @@ const sulcusPlugin = {
436
309
  Type.Literal("procedural"),
437
310
  Type.Literal("fact")
438
311
  ], { description: "Memory type. preference=user preferences, procedural=how-to/processes, fact=stable knowledge, semantic=concepts/relationships, episodic=events/experiences. Default: episodic" })),
439
- decay_class: Type.Optional(Type.Union([
440
- Type.Literal("volatile"),
441
- Type.Literal("normal"),
442
- Type.Literal("stable"),
443
- Type.Literal("permanent")
444
- ], { description: "Decay rate. volatile=fast decay, normal=default, stable=slow decay, permanent=never decays" })),
445
- is_pinned: Type.Optional(Type.Boolean({ description: "Pin memory to freeze heat at current value, preventing ALL decay. Pinned memories never lose heat." })),
446
- min_heat: Type.Optional(Type.Number({ description: "Minimum heat floor (0.0-1.0). Memory will never decay below this value." })),
447
- key_points: Type.Optional(Type.Array(Type.String(), { description: "Key points to index for search. Extracted highlights." }))
448
312
  }),
449
313
  async execute(_id: string, params: any) {
450
314
  // Pre-send junk filter
@@ -456,25 +320,14 @@ const sulcusPlugin = {
456
320
  };
457
321
  }
458
322
 
459
- const res = await memoryCall("record_memory", { ...params, namespace, fold_name: params.fold_name || namespace });
460
- // Check for storage limit error
461
- if (res?.error === "storage_limit_reached") {
462
- return {
463
- content: [{ type: "text", text: `⚠️ Storage limit reached: ${res.message}` }],
464
- details: res
465
- };
323
+ if (!isAvailable) {
324
+ throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
466
325
  }
467
- const provenance = res?.provenance || {
468
- backend: backendMode,
469
- namespace,
470
- server: serverUrl,
471
- sync_available: backendMode === "hybrid",
472
- siu_classified: false,
473
- };
474
- const provenanceStr = `[${provenance.backend}] namespace: ${provenance.namespace || namespace}, server: ${provenance.server || serverUrl}`;
326
+
327
+ const res = await sulcusMem.add_memory(params.content, params.memory_type ?? null);
475
328
  return {
476
- content: [{ type: "text", text: `Stored [${params.memory_type || "episodic"}] memory: "${(params.content || "").substring(0, 80)}..." → ${provenanceStr}` }],
477
- details: { ...res, provenance }
329
+ content: [{ type: "text", text: `Stored [${params.memory_type || "episodic"}] memory: "${(params.content || "").substring(0, 80)}..." → [${backendMode}] namespace: ${namespace}` }],
330
+ details: { ...res, backend: backendMode, namespace }
478
331
  };
479
332
  }
480
333
  }, { name: "memory_store" });
@@ -482,31 +335,46 @@ const sulcusPlugin = {
482
335
  api.registerTool({
483
336
  name: "memory_status",
484
337
  label: "Memory Status",
485
- description: "Check Sulcus memory backend status: connection, namespace, capabilities, and memory count.",
338
+ description: "Check Sulcus memory backend status: connection, namespace, capabilities, and hot nodes.",
486
339
  parameters: Type.Object({}),
487
340
  async execute(_id: string, _params: any) {
488
- if (restClient) {
489
- try {
490
- const status = await restClient.getStatus();
491
- return {
492
- content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
493
- details: status
494
- };
495
- } catch (e: any) {
496
- return {
497
- content: [{ type: "text", text: `Memory status unavailable: ${e.message}` }],
498
- };
499
- }
341
+ if (!isAvailable) {
342
+ return {
343
+ content: [{ type: "text", text: JSON.stringify({
344
+ status: "unavailable",
345
+ backend: backendMode,
346
+ namespace,
347
+ error: nativeLoader.error || "WASM not loaded",
348
+ storeLib: storeLibPath,
349
+ embedLib: embedLibPath,
350
+ wasmDir,
351
+ }, null, 2) }],
352
+ };
353
+ }
354
+ try {
355
+ const hotNodes = await sulcusMem.list_hot_nodes(20);
356
+ const nodeList = hotNodes?.nodes ?? hotNodes ?? [];
357
+ const count = Array.isArray(nodeList) ? nodeList.length : 0;
358
+ return {
359
+ content: [{ type: "text", text: JSON.stringify({
360
+ status: "ok",
361
+ backend: backendMode,
362
+ namespace,
363
+ hot_node_count: count,
364
+ hot_nodes: nodeList,
365
+ }, null, 2) }],
366
+ details: { status: "ok", backend: backendMode, namespace, count }
367
+ };
368
+ } catch (e: any) {
369
+ return {
370
+ content: [{ type: "text", text: JSON.stringify({
371
+ status: "error",
372
+ backend: backendMode,
373
+ namespace,
374
+ error: e.message,
375
+ }, null, 2) }],
376
+ };
500
377
  }
501
- // Fallback: return local config info
502
- return {
503
- content: [{ type: "text", text: JSON.stringify({
504
- status: hasBinary ? "local" : "unavailable",
505
- backend: backendMode,
506
- namespace,
507
- server: serverUrl || "none",
508
- }, null, 2) }],
509
- };
510
378
  }
511
379
  }, { name: "memory_status" });
512
380
 
@@ -529,32 +397,23 @@ const sulcusPlugin = {
529
397
  api.logger.debug(`memory-sulcus: autoRecall is disabled, skipping context build`);
530
398
  return;
531
399
  }
400
+ if (!isAvailable) return;
532
401
  api.logger.info(`memory-sulcus: before_agent_start hook triggered for agent ${event.agentId}`);
533
402
  if (!event.prompt) return;
534
403
  try {
535
- api.logger.debug(`memory-sulcus: building context for prompt: ${event.prompt.substring(0, 50)}...`);
536
- // include_recent: false OpenClaw already has conversation context.
537
- // Only inject curated preferences, facts, and procedures from Sulcus.
538
- const res = await memoryCall("build_context", { prompt: event.prompt, token_budget: 2000, include_recent: false });
539
- // build_context returns either:
540
- // - plain XML string (new format, post-4dca467)
541
- // - { context: "...", token_estimate: N } (old format)
542
- // The MCP client resolves to the parsed JSON if valid, or the raw MCP result object.
543
- let context: string | undefined;
544
- if (typeof res === "string") {
545
- context = res;
546
- } else if (res?.context) {
547
- context = res.context;
548
- } else if (res?.content?.[0]?.text) {
549
- context = res.content[0].text;
550
- }
551
- if (context) {
552
- api.logger.info(`memory-sulcus: context build successful, injecting ${context.length} chars`);
553
- return { prependSystemContext: context };
404
+ api.logger.debug(`memory-sulcus: searching context for prompt: ${event.prompt.substring(0, 50)}...`);
405
+ const res = await sulcusMem.search_memory(event.prompt, 5);
406
+ const results = res?.results ?? [];
407
+ if (!results || results.length === 0) {
408
+ return { prependSystemContext: FALLBACK_AWARENESS };
554
409
  }
555
- // Context was empty inject fallback so LLM still knows about Sulcus
556
- api.logger.warn(`memory-sulcus: build_context returned empty, injecting fallback awareness`);
557
- return { prependSystemContext: FALLBACK_AWARENESS };
410
+ // Format results as a concise XML context block
411
+ const items = results.map((r: any) =>
412
+ ` <memory id="${r.id}" heat="${(r.current_heat ?? r.score ?? 0).toFixed(2)}" type="${r.memory_type ?? "unknown"}">${r.label ?? r.pointer_summary ?? ""}</memory>`
413
+ ).join("\n");
414
+ const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${items}\n</sulcus_context>`;
415
+ api.logger.info(`memory-sulcus: injecting ${results.length} recalled memories (${context.length} chars)`);
416
+ return { prependSystemContext: context };
558
417
  } catch (e) {
559
418
  // build_context failed — inject fallback so the LLM isn't flying blind
560
419
  api.logger.warn(`memory-sulcus: context build failed: ${e} — injecting fallback awareness`);
@@ -563,18 +422,12 @@ const sulcusPlugin = {
563
422
  });
564
423
 
565
424
  // agent_end: Do NOT auto-record raw conversation turns.
566
- // The LLM has record_memory as an MCP tool — it decides what's worth remembering.
567
- // Auto-recording every turn flooded the store with 2000+ junk episodic nodes
568
- // containing placeholder vectors and raw JSON conversation payloads.
425
+ // The LLM has memory_store as a tool — it decides what's worth remembering.
569
426
  api.on("agent_end", async (event: any) => {
570
427
  api.logger.debug(`memory-sulcus: agent_end hook triggered for agent ${event.agentId} (no auto-record)`);
571
428
  });
572
429
 
573
- api.registerService({
574
- id: "memory-sulcus",
575
- start: () => client.start(),
576
- stop: () => client.stop()
577
- });
430
+ // No service registration needed — no background process to manage
578
431
  }
579
432
  };
580
433