@digitalforgestudios/openclaw-sulcus 3.4.0 → 3.5.1

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 +187 -1
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { resolve } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
+ import * as https from "node:https";
4
+ import * as http from "node:http";
5
+ import { URL } from "node:url";
3
6
  import { Type } from "@sinclair/typebox";
4
7
 
5
8
  // ─── STATIC AWARENESS ───────────────────────────────────────────────────────
@@ -128,6 +131,157 @@ const hookHandlers: Record<string, HookHandler> = {
128
131
  },
129
132
  };
130
133
 
134
+ // ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
135
+ // Lightweight fallback client for users without local dylibs/WASM.
136
+ // Uses Node.js built-in https/http — ZERO external dependencies.
137
+ // Activates only when serverUrl + apiKey are configured and local libs are absent.
138
+
139
+ class SulcusCloudClient {
140
+ private serverUrl: string;
141
+ private apiKey: string;
142
+
143
+ constructor(serverUrl: string, apiKey: string) {
144
+ // Strip trailing slash for clean path concatenation
145
+ this.serverUrl = serverUrl.replace(/\/+$/, "");
146
+ this.apiKey = apiKey;
147
+ }
148
+
149
+ /** Low-level HTTP helper. Returns parsed JSON response body. */
150
+ private request(method: string, path: string, body?: any): Promise<any> {
151
+ return new Promise((resolve, reject) => {
152
+ let parsedUrl: URL;
153
+ try {
154
+ parsedUrl = new URL(this.serverUrl + path);
155
+ } catch (e: any) {
156
+ return reject(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${e.message}`));
157
+ }
158
+
159
+ const isHttps = parsedUrl.protocol === "https:";
160
+ const transport = isHttps ? https : http;
161
+
162
+ const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
163
+ const headers: Record<string, string> = {
164
+ "Authorization": `Bearer ${this.apiKey}`,
165
+ "Accept": "application/json",
166
+ };
167
+ if (bodyStr !== undefined) {
168
+ headers["Content-Type"] = "application/json";
169
+ headers["Content-Length"] = String(Buffer.byteLength(bodyStr));
170
+ }
171
+
172
+ const options = {
173
+ hostname: parsedUrl.hostname,
174
+ port: parsedUrl.port ? parseInt(parsedUrl.port, 10) : (isHttps ? 443 : 80),
175
+ path: parsedUrl.pathname + parsedUrl.search,
176
+ method,
177
+ headers,
178
+ };
179
+
180
+ const req = transport.request(options, (res) => {
181
+ const chunks: Buffer[] = [];
182
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
183
+ res.on("end", () => {
184
+ const raw = Buffer.concat(chunks).toString("utf-8");
185
+ if (!res.statusCode || res.statusCode >= 400) {
186
+ return reject(new Error(`SulcusCloudClient: HTTP ${res.statusCode} for ${method} ${path}: ${raw.substring(0, 200)}`));
187
+ }
188
+ if (!raw || raw.trim() === "") {
189
+ return resolve(null);
190
+ }
191
+ try {
192
+ resolve(JSON.parse(raw));
193
+ } catch (_e) {
194
+ // Some endpoints return plain text (e.g. markdown export)
195
+ resolve(raw);
196
+ }
197
+ });
198
+ });
199
+
200
+ req.on("error", (e: Error) => reject(new Error(`SulcusCloudClient: network error for ${method} ${path}: ${e.message}`)));
201
+
202
+ if (bodyStr !== undefined) {
203
+ req.write(bodyStr);
204
+ }
205
+ req.end();
206
+ });
207
+ }
208
+
209
+ /**
210
+ * search_memory — maps to POST /agent/search
211
+ * Server returns { results: [...] }; we normalise to the results array.
212
+ */
213
+ async search_memory(query: string, limit?: number): Promise<{ results: any[] }> {
214
+ const body: any = { query };
215
+ if (limit !== undefined) body.limit = limit;
216
+ const res = await this.request("POST", "/agent/search", body);
217
+ const results = res?.results ?? res?.items ?? res?.nodes ?? (Array.isArray(res) ? res : []);
218
+ return { results };
219
+ }
220
+
221
+ /**
222
+ * add_memory — maps to POST /agent/nodes
223
+ * Server returns { id, ... }; pass through.
224
+ */
225
+ async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: any }> {
226
+ const body: any = { text: content };
227
+ if (memoryType) body.memory_type = memoryType;
228
+ const res = await this.request("POST", "/agent/nodes", body);
229
+ return res ?? { id: "unknown" };
230
+ }
231
+
232
+ /**
233
+ * list_hot_nodes — maps to GET /agent/memory/status
234
+ * Returns hot_nodes list; normalised for memory_status tool.
235
+ */
236
+ async list_hot_nodes(_limit?: number): Promise<{ nodes: any[] }> {
237
+ const res = await this.request("GET", "/agent/memory/status");
238
+ const nodes = res?.hot_nodes ?? res?.nodes ?? [];
239
+ return { nodes };
240
+ }
241
+
242
+ /**
243
+ * consolidate — maps to POST /agent/consolidate
244
+ */
245
+ async consolidate(minHeat?: number): Promise<any> {
246
+ const body: any = {};
247
+ if (minHeat !== undefined) body.min_heat = minHeat;
248
+ return this.request("POST", "/agent/consolidate", body);
249
+ }
250
+
251
+ /**
252
+ * export_markdown — maps to GET /agent/export?format=markdown
253
+ * Returns raw markdown string.
254
+ */
255
+ async export_markdown(): Promise<string> {
256
+ const res = await this.request("GET", "/agent/export?format=markdown");
257
+ // Server may return { content: "..." } or raw string
258
+ if (typeof res === "string") return res;
259
+ return res?.content ?? res?.markdown ?? JSON.stringify(res, null, 2);
260
+ }
261
+
262
+ /**
263
+ * import_markdown — maps to POST /agent/import
264
+ */
265
+ async import_markdown(text: string): Promise<any> {
266
+ return this.request("POST", "/agent/import", { format: "markdown", content: text });
267
+ }
268
+
269
+ /**
270
+ * evaluate_triggers — maps to POST /agent/triggers/evaluate
271
+ */
272
+ async evaluate_triggers(event: any, contextJson?: string): Promise<any> {
273
+ const body: any = { event };
274
+ if (contextJson) {
275
+ try {
276
+ body.context = JSON.parse(contextJson);
277
+ } catch (_e) {
278
+ body.context = contextJson;
279
+ }
280
+ }
281
+ return this.request("POST", "/agent/triggers/evaluate", body);
282
+ }
283
+ }
284
+
131
285
  // ─── NATIVE LIB LOADER ──────────────────────────────────────────────────────
132
286
  // Loads libsulcus_store.dylib (embedded PG) and libsulcus_vectors.dylib (embeddings)
133
287
  // via koffi FFI. Provides queryFn and embedFn callbacks for SulcusMem.create().
@@ -603,6 +757,10 @@ const sulcusPlugin = {
603
757
  ? resolve(api.config.wasmDir)
604
758
  : resolve(__dirname, "wasm");
605
759
 
760
+ // Cloud fallback credentials (used when local libs unavailable)
761
+ const serverUrl: string | undefined = api.config?.serverUrl;
762
+ const apiKey: string | undefined = api.config?.apiKey;
763
+
606
764
  // Default namespace = agent name (prevents everything landing in "default")
607
765
  const agentId = api.config?.agentId || api.pluginConfig?.agentId;
608
766
  const namespace = api.config?.namespace === "default" && agentId
@@ -644,12 +802,40 @@ const sulcusPlugin = {
644
802
  api.logger.warn(`sulcus: native libs unavailable — ${nativeLoader.error}`);
645
803
  }
646
804
 
805
+ // ── Cloud HTTP fallback ──
806
+ // Activates only when local WASM/native libs are unavailable AND
807
+ // serverUrl + apiKey are configured. Zero external dependencies.
808
+ if (sulcusMem === null) {
809
+ if (serverUrl && apiKey) {
810
+ try {
811
+ const cloudClient = new SulcusCloudClient(serverUrl, apiKey);
812
+ sulcusMem = cloudClient;
813
+ backendMode = "cloud";
814
+ api.logger.info(`sulcus: using cloud backend (server: ${serverUrl})`);
815
+ } catch (e: any) {
816
+ api.logger.warn(`sulcus: cloud client init failed: ${e.message}`);
817
+ }
818
+ } else {
819
+ api.logger.info(`sulcus: no cloud fallback — serverUrl: ${serverUrl ? "set" : "missing"}, apiKey: ${apiKey ? "set" : "missing"}`);
820
+ }
821
+ }
822
+
647
823
  const isAvailable = sulcusMem !== null;
648
824
 
649
825
  // Update static awareness with runtime info
650
826
  STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
651
827
 
652
- api.logger.info(`sulcus: registered (backend: ${backendMode}, namespace: ${namespace}, available: ${isAvailable})`);
828
+ // ── Startup summary ──
829
+ if (isAvailable) {
830
+ api.logger.info(`sulcus: ✓ registered (backend: ${backendMode}, namespace: ${namespace})`);
831
+ } else {
832
+ const hints: string[] = [];
833
+ if (!serverUrl && !apiKey) hints.push("no serverUrl/apiKey for cloud mode");
834
+ if (serverUrl && !apiKey) hints.push("serverUrl set but apiKey missing");
835
+ if (!serverUrl && apiKey) hints.push("apiKey set but serverUrl missing");
836
+ if (nativeLoader.error) hints.push(`local: ${nativeLoader.error}`);
837
+ api.logger.warn(`sulcus: ✗ unavailable — ${hints.join("; ") || "unknown reason"}. Configure serverUrl+apiKey for cloud, or install native dylibs for local.`);
838
+ }
653
839
 
654
840
  // ── Shared deps for tool executors ──
655
841
  const toolDeps: ToolDeps = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalforgestudios/openclaw-sulcus",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
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",