@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.
- package/index.ts +90 -238
- 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("
|
|
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
|
-
//
|
|
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(
|
|
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: {
|
|
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 [
|
|
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 [
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
//
|
|
340
|
-
|
|
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
|
-
|
|
353
|
-
const restClient = serverUrl && apiKey
|
|
354
|
-
? new SulcusRestClient(serverUrl, apiKey, namespace, api.logger)
|
|
355
|
-
: null;
|
|
222
|
+
const backendMode = hasBinary ? "local" : "unavailable";
|
|
356
223
|
|
|
357
|
-
//
|
|
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
|
-
|
|
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:
|
|
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 ||
|
|
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 (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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": "
|
|
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",
|