@digitalforgestudios/openclaw-sulcus 3.3.0 → 3.5.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 (3) hide show
  1. package/bin/configure.mjs +246 -19
  2. package/index.ts +172 -0
  3. package/package.json +1 -1
package/bin/configure.mjs CHANGED
@@ -12,6 +12,8 @@ import readline from 'readline';
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import os from 'os';
15
+ import https from 'https';
16
+ import { execSync } from 'child_process';
15
17
 
16
18
  // ─── Colour support ───────────────────────────────────────────────────────────
17
19
 
@@ -153,6 +155,208 @@ function deepMerge(target, source) {
153
155
  return target;
154
156
  }
155
157
 
158
+ // ─── Prebuilt binary download ─────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Detect the current platform slug used in GitHub release asset names.
162
+ * Returns { platform, ext } or throws if unsupported.
163
+ */
164
+ function detectPlatform() {
165
+ const plat = process.platform;
166
+ const arch = process.arch;
167
+
168
+ const ext = plat === 'darwin' ? '.dylib' : '.so';
169
+
170
+ if (plat === 'darwin' && arch === 'arm64') return { platform: 'macos-arm64', ext };
171
+ if (plat === 'darwin' && arch === 'x64') return { platform: 'macos-x64', ext };
172
+ if (plat === 'linux' && arch === 'x64') return { platform: 'linux-x64', ext };
173
+ if (plat === 'linux' && arch === 'arm64') return { platform: 'linux-arm64', ext };
174
+
175
+ throw new Error(
176
+ `Prebuilt binaries are not available for your platform (${plat}/${arch}).\n` +
177
+ ` Supported: darwin/arm64, darwin/x64, linux/x64, linux/arm64`,
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Follow redirects and download `url` into `destFile`.
183
+ * Shows a simple percentage progress bar (or dots when content-length is unknown).
184
+ * Follows up to maxRedirects hops.
185
+ */
186
+ function downloadFile(url, destFile, maxRedirects = 5) {
187
+ return new Promise((resolve, reject) => {
188
+ let hops = 0;
189
+
190
+ function attempt(currentUrl) {
191
+ if (hops > maxRedirects) {
192
+ return reject(new Error('Too many redirects while downloading'));
193
+ }
194
+ hops++;
195
+
196
+ const parsed = new URL(currentUrl);
197
+ const opts = {
198
+ hostname: parsed.hostname,
199
+ path: parsed.pathname + parsed.search,
200
+ method: 'GET',
201
+ headers: { 'User-Agent': 'sulcus-configure/1.0' },
202
+ };
203
+
204
+ const req = https.request(opts, (res) => {
205
+ const { statusCode, headers: resHeaders } = res;
206
+
207
+ // Follow 301/302/307/308 redirects
208
+ if (
209
+ (statusCode === 301 || statusCode === 302 ||
210
+ statusCode === 307 || statusCode === 308) &&
211
+ resHeaders.location
212
+ ) {
213
+ res.resume(); // drain
214
+ return attempt(resHeaders.location);
215
+ }
216
+
217
+ if (statusCode !== 200) {
218
+ res.resume();
219
+ return reject(new Error(`HTTP ${statusCode} for ${currentUrl}`));
220
+ }
221
+
222
+ const total = parseInt(resHeaders['content-length'] || '0', 10);
223
+ let received = 0;
224
+ let lastPct = -1;
225
+
226
+ const out = fs.createWriteStream(destFile);
227
+
228
+ res.on('data', (chunk) => {
229
+ received += chunk.length;
230
+ out.write(chunk);
231
+
232
+ if (total > 0) {
233
+ const pct = Math.floor((received / total) * 100);
234
+ if (pct !== lastPct && pct % 5 === 0) {
235
+ lastPct = pct;
236
+ process.stdout.write(`\r Downloading... ${pct}% `);
237
+ }
238
+ } else {
239
+ // No content-length — show dots
240
+ if (received % (64 * 1024) === 0) process.stdout.write('.');
241
+ }
242
+ });
243
+
244
+ res.on('end', () => {
245
+ out.end(() => {
246
+ process.stdout.write(`\r Downloaded ${(received / 1024 / 1024).toFixed(1)} MB \n`);
247
+ resolve();
248
+ });
249
+ });
250
+
251
+ res.on('error', (err) => {
252
+ out.destroy();
253
+ reject(err);
254
+ });
255
+ });
256
+
257
+ req.on('error', reject);
258
+ req.end();
259
+ }
260
+
261
+ attempt(url);
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Download and install prebuilt dylibs for the current platform.
267
+ * Returns true on success, false if the user skips or something goes wrong.
268
+ *
269
+ * @param {string} resolvedLibDir Absolute path where dylibs should be placed
270
+ * @param {string[]} dylibNames Base names without extension, e.g. ['libsulcus_store', ...]
271
+ */
272
+ async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
273
+ let platformInfo;
274
+ try {
275
+ platformInfo = detectPlatform();
276
+ } catch (err) {
277
+ console.log(` ${yellow('⚠')} ${err.message}`);
278
+ return false;
279
+ }
280
+
281
+ const { platform, ext } = platformInfo;
282
+ const displayDir = resolvedLibDir.replace(os.homedir(), '~');
283
+ const tarUrl = `https://github.com/digitalforgeca/sulcus/releases/latest/download/sulcus-${platform}.tar.gz`;
284
+
285
+ console.log();
286
+ console.log(` ${yellow('⚠')} Native libraries not found at ${cyan(displayDir)}`);
287
+ console.log(` ${dim(`Download prebuilt binaries for ${bold(platform)}?`)}`);
288
+
289
+ const doDownload = await askYN(`Download prebuilt binaries for ${platform}?`, true);
290
+ if (!doDownload) {
291
+ console.log(` ${dim('Skipped. Install dylibs manually to use Sulcus.')}`);
292
+ return false;
293
+ }
294
+
295
+ // Create libDir if needed
296
+ try {
297
+ fs.mkdirSync(resolvedLibDir, { recursive: true });
298
+ } catch (err) {
299
+ console.log(` ${red('✗')} Cannot create ${cyan(resolvedLibDir)}: ${err.message}`);
300
+ console.log(` ${dim('Try running with appropriate permissions.')}`);
301
+ return false;
302
+ }
303
+
304
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sulcus-'));
305
+ const tarPath = path.join(tmpDir, `sulcus-${platform}.tar.gz`);
306
+
307
+ console.log(` ${dim(`→ ${tarUrl}`)}`);
308
+ process.stdout.write(` Downloading...`);
309
+
310
+ try {
311
+ await downloadFile(tarUrl, tarPath);
312
+ } catch (err) {
313
+ console.log(` ${red('✗')} Download failed: ${err.message}`);
314
+ console.log(` ${dim('Check your internet connection or download manually:')}`);
315
+ console.log(` ${cyan(tarUrl)}`);
316
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
317
+ return false;
318
+ }
319
+
320
+ // Extract
321
+ console.log(` Extracting...`);
322
+ try {
323
+ execSync(`tar xzf ${JSON.stringify(tarPath)} -C ${JSON.stringify(tmpDir)}`, { stdio: 'pipe' });
324
+ } catch (err) {
325
+ console.log(` ${red('✗')} Extraction failed: ${err.message}`);
326
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
327
+ return false;
328
+ }
329
+
330
+ // Move each dylib into libDir
331
+ let allInstalled = true;
332
+ for (const lib of dylibNames) {
333
+ const srcFile = path.join(tmpDir, lib + ext);
334
+ const destFile = path.join(resolvedLibDir, lib + ext);
335
+
336
+ if (!fs.existsSync(srcFile)) {
337
+ console.log(` ${yellow('⚠')} ${lib + ext} not found in tarball`);
338
+ allInstalled = false;
339
+ continue;
340
+ }
341
+
342
+ try {
343
+ fs.copyFileSync(srcFile, destFile);
344
+ console.log(` ${green('✓')} Installed: ${dim(destFile)}`);
345
+ } catch (err) {
346
+ console.log(` ${red('✗')} Failed to install ${lib + ext}: ${err.message}`);
347
+ if (err.code === 'EACCES') {
348
+ console.log(` ${dim('Try running with appropriate permissions (e.g. sudo).')}`);
349
+ }
350
+ allInstalled = false;
351
+ }
352
+ }
353
+
354
+ // Cleanup temp dir
355
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
356
+
357
+ return allInstalled;
358
+ }
359
+
156
360
  // ─── Main wizard ──────────────────────────────────────────────────────────────
157
361
 
158
362
  async function run() {
@@ -347,7 +551,7 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
347
551
  console.log();
348
552
  }
349
553
 
350
- // ── Step 4: Validate dylib path ───────────────────────────────────────────
554
+ // ── Step 4: Validate dylib path (+ auto-download if missing) ─────────────
351
555
 
352
556
  console.log(`${bold('Step 4 · Validate')}`);
353
557
 
@@ -357,30 +561,53 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
357
561
  : process.platform === 'win32' ? '.dll'
358
562
  : '.so';
359
563
 
360
- let dylibsOk = true;
564
+ /**
565
+ * Check which dylibs are present. Returns true when all are found.
566
+ */
567
+ function checkDylibs() {
568
+ if (!fs.existsSync(resolvedLibDir)) return false;
569
+ return dylibNames.every((lib) => fs.existsSync(path.join(resolvedLibDir, lib + ext)));
570
+ }
361
571
 
362
- if (!fs.existsSync(resolvedLibDir)) {
363
- dylibsOk = false;
364
- console.log(` ${yellow('⚠')} Dylib directory not found: ${cyan(resolvedLibDir)}`);
365
- } else {
572
+ let dylibsOk = checkDylibs();
573
+
574
+ if (dylibsOk) {
575
+ // All present — just print them
366
576
  for (const lib of dylibNames) {
367
- const full = path.join(resolvedLibDir, lib + ext);
368
- if (fs.existsSync(full)) {
369
- console.log(` ${green('✓')} Found: ${dim(full)}`);
370
- } else {
371
- dylibsOk = false;
372
- console.log(` ${yellow('⚠')} Missing: ${dim(full)}`);
577
+ console.log(` ${green('✓')} Found: ${dim(path.join(resolvedLibDir, lib + ext))}`);
578
+ }
579
+ } else {
580
+ // Some or all missing — try auto-download
581
+ const downloaded = await downloadAndInstallBinaries(resolvedLibDir, dylibNames);
582
+
583
+ if (downloaded) {
584
+ // Re-validate after successful download
585
+ dylibsOk = checkDylibs();
586
+ if (!dylibsOk) {
587
+ console.log(` ${yellow('⚠')} Some dylibs still missing after installation.`);
373
588
  }
589
+ } else if (!downloaded) {
590
+ // Download skipped or failed — show manual instructions
591
+ if (fs.existsSync(resolvedLibDir)) {
592
+ // Directory exists but files missing — list what we found / didn't find
593
+ for (const lib of dylibNames) {
594
+ const full = path.join(resolvedLibDir, lib + ext);
595
+ if (fs.existsSync(full)) {
596
+ console.log(` ${green('✓')} Found: ${dim(full)}`);
597
+ } else {
598
+ console.log(` ${yellow('⚠')} Missing: ${dim(full)}`);
599
+ }
600
+ }
601
+ }
602
+ console.log();
603
+ console.log(` ${yellow(bold('Native dylibs missing — Sulcus will not load.'))}`);
604
+ console.log(` Download manually from:`);
605
+ console.log(` ${cyan('https://github.com/digitalforgeca/sulcus/releases/latest')}`);
606
+ console.log(` Or visit: ${cyan('https://sulcus.ca/docs/install')}`);
374
607
  }
375
608
  }
376
609
 
377
- if (!dylibsOk) {
378
- console.log();
379
- console.log(` ${yellow(bold('Native dylibs missing — Sulcus will not load.'))}`);;
380
- console.log(` Run the setup script to download and install them:`);
381
- console.log(` ${cyan('bash ~/.sulcus/setup-local.sh')}`);
382
- console.log(` Or visit: ${cyan('https://sulcus.ca/docs/install')}`);
383
- } else {
610
+ if (dylibsOk) {
384
611
  console.log(` ${green('✓')} All dylibs present — Sulcus is ready to go.`);
385
612
  }
386
613
 
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,6 +802,20 @@ 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 && serverUrl && apiKey) {
809
+ try {
810
+ const cloudClient = new SulcusCloudClient(serverUrl, apiKey);
811
+ sulcusMem = cloudClient;
812
+ backendMode = "cloud";
813
+ api.logger.info(`sulcus: using cloud backend (server: ${serverUrl})`);
814
+ } catch (e: any) {
815
+ api.logger.warn(`sulcus: cloud client init failed: ${e.message}`);
816
+ }
817
+ }
818
+
647
819
  const isAvailable = sulcusMem !== null;
648
820
 
649
821
  // Update static awareness with runtime info
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalforgestudios/openclaw-sulcus",
3
- "version": "3.3.0",
3
+ "version": "3.5.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",