@digitalforgestudios/openclaw-sulcus 1.5.3 → 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 -248
  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,33 +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
 
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).
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.
68
77
  async start(configPath?: string) {
69
78
  const cfgPath = configPath || this.configPath;
70
79
  const args = cfgPath ? ["--config", cfgPath, "stdio"] : ["stdio"];
71
80
  this.child = spawn(this.binaryPath, args, {
72
81
  stdio: ["pipe", "pipe", "inherit"],
73
- env: { ...process.env, RUST_LOG: "info" } // Only passes log-level config, not secrets
82
+ env: {
83
+ ...process.env,
84
+ RUST_LOG: "info",
85
+ ...this.spawnEnv, // SULCUS_SERVER_URL and SULCUS_API_KEY forwarded here
86
+ }
74
87
  });
75
88
 
76
89
  this.child.on("error", (err) => {
77
90
  // Reject all pending calls if the process dies
78
- for (const [id, resolve] of this.pending) {
91
+ for (const [_id, resolve] of this.pending) {
79
92
  resolve({ error: { code: -1, message: `Sulcus process error: ${err.message}` } });
80
93
  }
81
94
  this.pending.clear();
@@ -83,7 +96,7 @@ class SulcusClient {
83
96
  });
84
97
 
85
98
  this.child.on("exit", (code) => {
86
- for (const [id, resolve] of this.pending) {
99
+ for (const [_id, resolve] of this.pending) {
87
100
  resolve({ error: { code: -1, message: `Sulcus process exited with code ${code}` } });
88
101
  }
89
102
  this.pending.clear();
@@ -114,13 +127,13 @@ class SulcusClient {
114
127
  clearTimeout(timeout);
115
128
  if (res.error) reject(new Error(res.error.message));
116
129
  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
- }
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
+ }
124
137
  }
125
138
  });
126
139
  this.child!.stdin!.write(JSON.stringify(request) + "\n");
@@ -132,173 +145,16 @@ class SulcusClient {
132
145
  }
133
146
  }
134
147
 
135
- // REST API clientfallback when sulcus-local binary isn't available
136
- class SulcusRestClient {
137
- constructor(
138
- private serverUrl: string,
139
- private apiKey: string,
140
- private namespace: string,
141
- private logger: any
142
- ) {}
143
-
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}`);
155
- }
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
-
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;
229
-
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
- }
235
-
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 { }
252
- }
253
-
254
- // Download from server
255
- if (!this.serverUrl || !this.apiKey) return null;
256
- 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;
267
- }
268
- } catch { }
269
- return null;
270
- }
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.
271
154
 
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;
277
-
278
- // Scale: (x - mean) / scale
279
- const scaled = embedding.map((x, i) => (x - m.scaler_mean[i]) / m.scaler_scale[i]);
280
-
281
- // Dot product + sigmoid for each class
282
- const scores: Record<string, number> = {};
283
- let bestType = "episodic";
284
- let bestScore = 0;
285
-
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];
296
- }
297
- }
298
-
299
- return { type: bestType, confidence: bestScore, all: scores };
300
- }
301
- }
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.
302
158
 
303
159
  // ─── PRE-SEND FILTER ─────────────────────────────────────────────────────────
304
160
  // Rule-based junk filter. Catches obvious noise before it hits the API.
@@ -342,63 +198,42 @@ const sulcusPlugin = {
342
198
  const namespace = api.config?.namespace === "default" && agentId
343
199
  ? agentId
344
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.
345
205
  const serverUrl = api.config?.serverUrl || "";
346
206
  const apiKey = api.config?.apiKey || "";
347
- const client = new SulcusClient(binaryPath, iniPath);
348
207
 
349
- // Detect backend mode: if serverUrl is set and binaryPath doesn't exist, it's cloud-only
350
- 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
351
216
  let hasBinary = false;
352
217
  try {
353
218
  const { existsSync } = require("node:fs");
354
219
  hasBinary = existsSync(binaryPath);
355
- if (!hasBinary) {
356
- backendMode = serverUrl ? "cloud" : "unavailable";
357
- } else {
358
- backendMode = serverUrl ? "hybrid" : "local";
359
- }
360
220
  } catch { }
361
221
 
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;
222
+ const backendMode = hasBinary ? "local" : "unavailable";
366
223
 
367
- // 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.
368
227
  async function memoryCall(method: string, params: any): Promise<any> {
369
- if (hasBinary) {
370
- try {
371
- return await client.call(method, params);
372
- } catch (e: any) {
373
- api.logger.warn(`memory-sulcus: local binary failed (${method}): ${e.message}, falling back to REST`);
374
- if (!restClient) throw e;
375
- }
376
- }
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}`);
228
+ if (!hasBinary) {
229
+ throw new Error(`Sulcus unavailable: binary not found at ${binaryPath}`);
384
230
  }
231
+ return client.call(method, params);
385
232
  }
386
233
 
387
234
  // Update static awareness with runtime info
388
235
  STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace, serverUrl || "local");
389
236
 
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
- }
401
-
402
237
  api.logger.info(`memory-sulcus: registered (binary: ${binaryPath}, hasBinary: ${hasBinary}, namespace: ${namespace}, backend: ${backendMode})`);
403
238
 
404
239
  // ── Core memory tools ──
@@ -467,11 +302,11 @@ const sulcusPlugin = {
467
302
  const provenance = res?.provenance || {
468
303
  backend: backendMode,
469
304
  namespace,
470
- server: serverUrl,
471
- sync_available: backendMode === "hybrid",
305
+ server: serverUrl || "local",
306
+ sync_available: !!serverUrl,
472
307
  siu_classified: false,
473
308
  };
474
- 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"}`;
475
310
  return {
476
311
  content: [{ type: "text", text: `Stored [${params.memory_type || "episodic"}] memory: "${(params.content || "").substring(0, 80)}..." → ${provenanceStr}` }],
477
312
  details: { ...res, provenance }
@@ -485,28 +320,35 @@ const sulcusPlugin = {
485
320
  description: "Check Sulcus memory backend status: connection, namespace, capabilities, and memory count.",
486
321
  parameters: Type.Object({}),
487
322
  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
- }
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
+ };
500
351
  }
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
352
  }
511
353
  }, { name: "memory_status" });
512
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalforgestudios/openclaw-sulcus",
3
- "version": "1.5.3",
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",