@digitalforgestudios/openclaw-sulcus 1.5.2 → 2.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.
Files changed (2) hide show
  1. package/index.ts +90 -238
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -31,7 +31,7 @@ Memories survive across sessions. They have heat (0.0–1.0) that decays over ti
31
31
  }
32
32
 
33
33
  // Legacy static string for backward compat (overwritten at register time)
34
- let STATIC_AWARENESS = buildStaticAwareness("unknown", "default", "unknown");
34
+ let STATIC_AWARENESS = buildStaticAwareness("local", "default", "local");
35
35
 
36
36
  // Fallback context when build_context fails — includes the cheatsheet
37
37
  // but warns that dynamic context is unavailable.
@@ -49,28 +49,46 @@ const FALLBACK_AWARENESS = `<sulcus_context token_budget="500">
49
49
  </cheatsheet>
50
50
  </sulcus_context>`;
51
51
 
52
- // Simple MCP Client for sulcus-local
52
+ // MCP-over-stdio client for sulcus-local
53
+ // This is the ONLY communication path — no REST/network calls in this plugin.
54
+ // If serverUrl/apiKey are configured, they are passed as env vars to the spawn()
55
+ // call below so that sulcus-sync (a dylib loaded by sulcus-local) can pick them
56
+ // up for cloud replication. The plugin itself never makes any HTTP calls.
53
57
  class SulcusClient {
54
58
  private child: ChildProcess | null = null;
55
59
  private nextId = 1;
56
60
  private pending = new Map<string | number, (res: any) => void>();
57
61
  private configPath: string | undefined;
62
+ private spawnEnv: Record<string, string>;
58
63
 
59
- constructor(private binaryPath: string, configPath?: string) {
64
+ constructor(
65
+ private binaryPath: string,
66
+ configPath?: string,
67
+ spawnEnv: Record<string, string> = {}
68
+ ) {
60
69
  this.configPath = configPath;
70
+ this.spawnEnv = spawnEnv;
61
71
  }
62
72
 
73
+ // Launches sulcus-local binary as an MCP stdio sidecar.
74
+ // serverUrl/apiKey (if any) are passed via environment variables so that
75
+ // sulcus-sync (dylib) can perform cloud replication without this plugin
76
+ // making any network calls itself.
63
77
  async start(configPath?: string) {
64
78
  const cfgPath = configPath || this.configPath;
65
79
  const args = cfgPath ? ["--config", cfgPath, "stdio"] : ["stdio"];
66
80
  this.child = spawn(this.binaryPath, args, {
67
81
  stdio: ["pipe", "pipe", "inherit"],
68
- env: { ...process.env, RUST_LOG: "info" }
82
+ env: {
83
+ ...process.env,
84
+ RUST_LOG: "info",
85
+ ...this.spawnEnv, // SULCUS_SERVER_URL and SULCUS_API_KEY forwarded here
86
+ }
69
87
  });
70
88
 
71
89
  this.child.on("error", (err) => {
72
90
  // Reject all pending calls if the process dies
73
- for (const [id, resolve] of this.pending) {
91
+ for (const [_id, resolve] of this.pending) {
74
92
  resolve({ error: { code: -1, message: `Sulcus process error: ${err.message}` } });
75
93
  }
76
94
  this.pending.clear();
@@ -78,7 +96,7 @@ class SulcusClient {
78
96
  });
79
97
 
80
98
  this.child.on("exit", (code) => {
81
- for (const [id, resolve] of this.pending) {
99
+ for (const [_id, resolve] of this.pending) {
82
100
  resolve({ error: { code: -1, message: `Sulcus process exited with code ${code}` } });
83
101
  }
84
102
  this.pending.clear();
@@ -109,13 +127,13 @@ class SulcusClient {
109
127
  clearTimeout(timeout);
110
128
  if (res.error) reject(new Error(res.error.message));
111
129
  else {
112
- // MCP result format
113
- try {
114
- const content = JSON.parse(res.result.content[0].text);
115
- resolve(content);
116
- } catch(e) {
117
- resolve(res.result);
118
- }
130
+ // MCP result format
131
+ try {
132
+ const content = JSON.parse(res.result.content[0].text);
133
+ resolve(content);
134
+ } catch (e) {
135
+ resolve(res.result);
136
+ }
119
137
  }
120
138
  });
121
139
  this.child!.stdin!.write(JSON.stringify(request) + "\n");
@@ -127,168 +145,16 @@ class SulcusClient {
127
145
  }
128
146
  }
129
147
 
130
- // REST API clientfallback when sulcus-local binary isn't available
131
- class SulcusRestClient {
132
- constructor(
133
- private serverUrl: string,
134
- private apiKey: string,
135
- private namespace: string,
136
- private logger: any
137
- ) {}
138
-
139
- private async fetch(path: string, options: RequestInit = {}): Promise<any> {
140
- const url = `${this.serverUrl}${path}`;
141
- const headers: Record<string, string> = {
142
- "Authorization": `Bearer ${this.apiKey}`,
143
- "Content-Type": "application/json",
144
- ...(options.headers as Record<string, string> || {}),
145
- };
146
- const res = await globalThis.fetch(url, { ...options, headers });
147
- if (!res.ok) {
148
- const text = await res.text().catch(() => "");
149
- throw new Error(`Sulcus API ${res.status}: ${text}`);
150
- }
151
- return res.json();
152
- }
153
-
154
- async searchMemory(query: string, limit: number = 5): Promise<any> {
155
- const raw = await this.fetch("/api/v1/agent/search", {
156
- method: "POST",
157
- body: JSON.stringify({ query, namespace: this.namespace, limit }),
158
- });
159
- // Normalize: server returns {results, provenance} — ensure .results is always accessible
160
- // Also handle flat array (backward compat) and .items/.nodes variants
161
- const results = raw?.results ?? raw?.items ?? raw?.nodes ?? (Array.isArray(raw) ? raw : []);
162
- return { results, provenance: raw?.provenance || { backend: "cloud", namespace: this.namespace } };
163
- }
164
-
165
- async recordMemory(params: any): Promise<any> {
166
- const raw = await this.fetch("/api/v1/agent/nodes", {
167
- method: "POST",
168
- body: JSON.stringify({
169
- label: params.content,
170
- memory_type: params.memory_type || "episodic",
171
- namespace: params.fold_name || this.namespace,
172
- heat: 0.8,
173
- }),
174
- });
175
- // Normalize: ensure node_id is accessible (server returns "id")
176
- return { ...raw, node_id: raw?.id || raw?.node_id };
177
- }
178
-
179
- async getMemory(id: string): Promise<any> {
180
- return this.fetch(`/api/v1/agent/nodes/${id}`);
181
- }
182
-
183
- async deleteMemory(id: string): Promise<any> {
184
- return this.fetch(`/api/v1/agent/nodes/${id}`, { method: "DELETE" });
185
- }
186
-
187
- async buildContext(prompt: string, tokenBudget: number = 2000): Promise<any> {
188
- // REST doesn't have a build_context endpoint — search for relevant memories instead
189
- const res = await this.searchMemory(prompt, 10);
190
- const results = res?.results || res || [];
191
- if (!results.length) return null;
192
-
193
- // Build a simple context string from results
194
- const items = results.slice(0, 5).map((r: any) =>
195
- `[${r.memory_type || "memory"}] ${r.pointer_summary || r.label || ""}`
196
- ).join("\n");
197
- return `<sulcus_context source="cloud" namespace="${this.namespace}">\n${items}\n</sulcus_context>`;
198
- }
199
-
200
- async getStatus(): Promise<any> {
201
- return this.fetch("/api/v1/agent/memory/status");
202
- }
203
- }
204
-
205
- // ─── CLIENT-SIDE SIU (Semantic Inference Unit) ───────────────────────────────
206
- // JSON weights classifier: scale → dot → sigmoid. Zero native deps.
207
- // Downloads model from server on first use, caches locally.
208
-
209
- interface SiuModel {
210
- classes: string[];
211
- scaler_mean: number[];
212
- scaler_scale: number[];
213
- coefficients: number[][];
214
- intercepts: number[];
215
- n_features: number;
216
- default_threshold: number;
217
- }
218
-
219
- class ClientSiu {
220
- private model: SiuModel | null = null;
221
- private modelPath: string;
222
- private serverUrl: string;
223
- private apiKey: string;
224
-
225
- constructor(cacheDir: string, serverUrl: string, apiKey: string) {
226
- this.modelPath = resolve(cacheDir, "memory_classifier_multilabel.json");
227
- this.serverUrl = serverUrl;
228
- this.apiKey = apiKey;
229
- }
230
-
231
- async ensureModel(): Promise<SiuModel | null> {
232
- if (this.model) return this.model;
233
- const { existsSync, readFileSync, writeFileSync, mkdirSync } = require("node:fs");
234
- const { dirname } = require("node:path");
235
-
236
- // Try loading cached model
237
- if (existsSync(this.modelPath)) {
238
- try {
239
- this.model = JSON.parse(readFileSync(this.modelPath, "utf8"));
240
- return this.model;
241
- } catch { }
242
- }
243
-
244
- // Download from server
245
- if (!this.serverUrl || !this.apiKey) return null;
246
- try {
247
- const res = await globalThis.fetch(
248
- `${this.serverUrl}/api/v1/agent/siu-model`,
249
- { headers: { "Authorization": `Bearer ${this.apiKey}` } }
250
- );
251
- if (res.ok) {
252
- const data = await res.json();
253
- mkdirSync(dirname(this.modelPath), { recursive: true });
254
- writeFileSync(this.modelPath, JSON.stringify(data));
255
- this.model = data;
256
- return this.model;
257
- }
258
- } catch { }
259
- return null;
260
- }
261
-
262
- // Classify using pre-computed embedding (384-dim)
263
- classifyEmbedding(embedding: number[]): { type: string; confidence: number; all: Record<string, number> } | null {
264
- if (!this.model) return null;
265
- const m = this.model;
266
- if (embedding.length !== m.n_features) return null;
267
-
268
- // Scale: (x - mean) / scale
269
- const scaled = embedding.map((x, i) => (x - m.scaler_mean[i]) / m.scaler_scale[i]);
148
+ // NOTE: SulcusRestClient was removed the plugin no longer makes any HTTP/REST
149
+ // calls. All communication with sulcus-local goes through MCP over stdio via
150
+ // SulcusClient above. If serverUrl/apiKey are provided in config, they are
151
+ // forwarded as SULCUS_SERVER_URL / SULCUS_API_KEY environment variables to the
152
+ // sulcus-local spawn so that sulcus-sync (dylib) can replicate to the cloud
153
+ // without this plugin touching the network.
270
154
 
271
- // Dot product + sigmoid for each class
272
- const scores: Record<string, number> = {};
273
- let bestType = "episodic";
274
- let bestScore = 0;
275
-
276
- for (let c = 0; c < m.classes.length; c++) {
277
- let dot = m.intercepts[c];
278
- for (let f = 0; f < m.n_features; f++) {
279
- dot += scaled[f] * m.coefficients[c][f];
280
- }
281
- const sigmoid = 1 / (1 + Math.exp(-dot));
282
- scores[m.classes[c]] = sigmoid;
283
- if (sigmoid > bestScore) {
284
- bestScore = sigmoid;
285
- bestType = m.classes[c];
286
- }
287
- }
288
-
289
- return { type: bestType, confidence: bestScore, all: scores };
290
- }
291
- }
155
+ // NOTE: SiuClassifier / ClientSiu / SiuModel were removed — sulcus-local
156
+ // already has fastembed + ONNX for embeddings and handles classification
157
+ // internally. A future PR may expose classification via an MCP tool if needed.
292
158
 
293
159
  // ─── PRE-SEND FILTER ─────────────────────────────────────────────────────────
294
160
  // Rule-based junk filter. Catches obvious noise before it hits the API.
@@ -332,63 +198,42 @@ const sulcusPlugin = {
332
198
  const namespace = api.config?.namespace === "default" && agentId
333
199
  ? agentId
334
200
  : (api.config?.namespace || agentId || "default");
201
+
202
+ // serverUrl/apiKey are NOT used by this plugin for HTTP calls.
203
+ // They are forwarded as env vars to sulcus-local so sulcus-sync (dylib)
204
+ // can replicate memories to the cloud without any network calls here.
335
205
  const serverUrl = api.config?.serverUrl || "";
336
206
  const apiKey = api.config?.apiKey || "";
337
- const client = new SulcusClient(binaryPath, iniPath);
338
207
 
339
- // Detect backend mode: if serverUrl is set and binaryPath doesn't exist, it's cloud-only
340
- let backendMode = "local"; // default assumption
208
+ // Build spawn env: pass cloud config to sulcus-local via environment
209
+ const spawnEnv: Record<string, string> = {};
210
+ if (serverUrl) spawnEnv["SULCUS_SERVER_URL"] = serverUrl;
211
+ if (apiKey) spawnEnv["SULCUS_API_KEY"] = apiKey;
212
+
213
+ const client = new SulcusClient(binaryPath, iniPath, spawnEnv);
214
+
215
+ // Check binary availability
341
216
  let hasBinary = false;
342
217
  try {
343
218
  const { existsSync } = require("node:fs");
344
219
  hasBinary = existsSync(binaryPath);
345
- if (!hasBinary) {
346
- backendMode = serverUrl ? "cloud" : "unavailable";
347
- } else {
348
- backendMode = serverUrl ? "hybrid" : "local";
349
- }
350
220
  } catch { }
351
221
 
352
- // REST client for cloud fallback (or cloud-only mode)
353
- const restClient = serverUrl && apiKey
354
- ? new SulcusRestClient(serverUrl, apiKey, namespace, api.logger)
355
- : null;
222
+ const backendMode = hasBinary ? "local" : "unavailable";
356
223
 
357
- // Unified call helper: try local binary first, fall back to REST
224
+ // All memory calls go exclusively through MCP no REST fallback.
225
+ // If the MCP client isn't connected (binary missing or crashed), we return
226
+ // an error rather than falling back to HTTP.
358
227
  async function memoryCall(method: string, params: any): Promise<any> {
359
- if (hasBinary) {
360
- try {
361
- return await client.call(method, params);
362
- } catch (e: any) {
363
- api.logger.warn(`memory-sulcus: local binary failed (${method}): ${e.message}, falling back to REST`);
364
- if (!restClient) throw e;
365
- }
366
- }
367
- if (!restClient) throw new Error("Sulcus unavailable: no binary and no server configured");
368
- // Route to appropriate REST method
369
- switch (method) {
370
- case "search_memory": return restClient.searchMemory(params.query, params.limit);
371
- case "record_memory": return restClient.recordMemory(params);
372
- case "build_context": return restClient.buildContext(params.prompt, params.token_budget);
373
- default: throw new Error(`Unknown method: ${method}`);
228
+ if (!hasBinary) {
229
+ throw new Error(`Sulcus unavailable: binary not found at ${binaryPath}`);
374
230
  }
231
+ return client.call(method, params);
375
232
  }
376
233
 
377
234
  // Update static awareness with runtime info
378
235
  STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace, serverUrl || "local");
379
236
 
380
- // Initialize client-side SIU
381
- const siuCacheDir = resolve(process.env.HOME || "~", ".cache/sulcus/model");
382
- const clientSiu = serverUrl && apiKey ? new ClientSiu(siuCacheDir, serverUrl, apiKey) : null;
383
-
384
- // Pre-load SIU model (non-blocking)
385
- if (clientSiu) {
386
- clientSiu.ensureModel().then(m => {
387
- if (m) api.logger.info(`memory-sulcus: client SIU loaded (${m.classes.length} classes, ${m.n_features} features)`);
388
- else api.logger.warn("memory-sulcus: client SIU model not available");
389
- }).catch(() => {});
390
- }
391
-
392
237
  api.logger.info(`memory-sulcus: registered (binary: ${binaryPath}, hasBinary: ${hasBinary}, namespace: ${namespace}, backend: ${backendMode})`);
393
238
 
394
239
  // ── Core memory tools ──
@@ -457,11 +302,11 @@ const sulcusPlugin = {
457
302
  const provenance = res?.provenance || {
458
303
  backend: backendMode,
459
304
  namespace,
460
- server: serverUrl,
461
- sync_available: backendMode === "hybrid",
305
+ server: serverUrl || "local",
306
+ sync_available: !!serverUrl,
462
307
  siu_classified: false,
463
308
  };
464
- const provenanceStr = `[${provenance.backend}] namespace: ${provenance.namespace || namespace}, server: ${provenance.server || serverUrl}`;
309
+ const provenanceStr = `[${provenance.backend}] namespace: ${provenance.namespace || namespace}, server: ${provenance.server || "local"}`;
465
310
  return {
466
311
  content: [{ type: "text", text: `Stored [${params.memory_type || "episodic"}] memory: "${(params.content || "").substring(0, 80)}..." → ${provenanceStr}` }],
467
312
  details: { ...res, provenance }
@@ -475,28 +320,35 @@ const sulcusPlugin = {
475
320
  description: "Check Sulcus memory backend status: connection, namespace, capabilities, and memory count.",
476
321
  parameters: Type.Object({}),
477
322
  async execute(_id: string, _params: any) {
478
- if (restClient) {
479
- try {
480
- const status = await restClient.getStatus();
481
- return {
482
- content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
483
- details: status
484
- };
485
- } catch (e: any) {
486
- return {
487
- content: [{ type: "text", text: `Memory status unavailable: ${e.message}` }],
488
- };
489
- }
323
+ if (!hasBinary) {
324
+ return {
325
+ content: [{ type: "text", text: JSON.stringify({
326
+ status: "unavailable",
327
+ backend: backendMode,
328
+ namespace,
329
+ binary: binaryPath,
330
+ server: serverUrl || "none (env forwarding only)",
331
+ }, null, 2) }],
332
+ };
333
+ }
334
+ try {
335
+ // Ask sulcus-local for status via MCP
336
+ const status = await memoryCall("memory_status", {});
337
+ return {
338
+ content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
339
+ details: status
340
+ };
341
+ } catch (e: any) {
342
+ return {
343
+ content: [{ type: "text", text: JSON.stringify({
344
+ status: "error",
345
+ backend: backendMode,
346
+ namespace,
347
+ server: serverUrl || "none (env forwarding only)",
348
+ error: e.message,
349
+ }, null, 2) }],
350
+ };
490
351
  }
491
- // Fallback: return local config info
492
- return {
493
- content: [{ type: "text", text: JSON.stringify({
494
- status: hasBinary ? "local" : "unavailable",
495
- backend: backendMode,
496
- namespace,
497
- server: serverUrl || "none",
498
- }, null, 2) }],
499
- };
500
352
  }
501
353
  }, { name: "memory_status" });
502
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalforgestudios/openclaw-sulcus",
3
- "version": "1.5.2",
3
+ "version": "2.0.0",
4
4
  "description": "Sulcus — reactive, thermodynamic memory plugin for OpenClaw. Opt-in persistent memory with heat-based decay, semantic search, and cross-agent sync. Auto-recall and auto-capture disabled by default.",
5
5
  "keywords": [
6
6
  "openclaw",