@digitalforgestudios/openclaw-sulcus 1.5.3 → 3.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 +231 -378
- package/openclaw.plugin.json +23 -15
- package/package.json +4 -2
- package/wasm/sulcus_wasm.js +607 -0
- package/wasm/sulcus_wasm_bg.wasm +0 -0
package/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
-
import { createInterface } from "node:readline";
|
|
3
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
4
3
|
import { Type } from "@sinclair/typebox";
|
|
5
4
|
|
|
6
5
|
// ─── STATIC AWARENESS ───────────────────────────────────────────────────────
|
|
@@ -8,295 +7,171 @@ import { Type } from "@sinclair/typebox";
|
|
|
8
7
|
// This is the absolute minimum the LLM needs to know Sulcus exists.
|
|
9
8
|
// It fires even if build_context crashes, times out, or returns empty.
|
|
10
9
|
// Build static awareness with runtime backend info
|
|
11
|
-
function buildStaticAwareness(backendMode: string, namespace: string
|
|
10
|
+
function buildStaticAwareness(backendMode: string, namespace: string) {
|
|
12
11
|
return `## Persistent Memory (Sulcus)
|
|
13
12
|
You have Sulcus — a persistent, reactive, thermodynamic memory system with reactive triggers.
|
|
14
13
|
Memories survive across sessions. They have heat (0.0–1.0) that decays over time.
|
|
15
14
|
|
|
16
|
-
**Connection:** Backend: ${backendMode} | Namespace: ${namespace}
|
|
15
|
+
**Connection:** Backend: ${backendMode} | Namespace: ${namespace}
|
|
17
16
|
|
|
18
17
|
**Your memory tools:**
|
|
19
18
|
- \`memory_store\` — Save important information (preferences, facts, procedures, decisions, lessons)
|
|
20
|
-
Parameters: content, memory_type (episodic|semantic|preference|procedural|fact)
|
|
19
|
+
Parameters: content, memory_type (episodic|semantic|preference|procedural|fact)
|
|
21
20
|
- \`memory_recall\` — Search memories semantically. Use before answering about past work, decisions, or people.
|
|
22
21
|
Parameters: query, limit
|
|
23
22
|
|
|
24
23
|
**When to store:** User states a preference, important decision made, correction given, lesson learned, anything worth surviving this session.
|
|
25
24
|
**When to search:** Questions about prior work/decisions, context seems incomplete, user references past conversations.
|
|
26
25
|
|
|
27
|
-
**Memory types:** episodic (events, fast decay) · semantic (knowledge, slow) · preference (opinions, slower) · procedural (how-tos, slowest) · fact (data, slow)
|
|
28
|
-
**Decay classes:** volatile (hours) · normal (days) · stable (weeks) · permanent (never)
|
|
29
|
-
**Pinning:** is_pinned=true prevents decay. Use for critical knowledge.
|
|
30
|
-
**Triggers:** Reactive rules on memory events. Active triggers and recent fires appear in your context below.`;
|
|
26
|
+
**Memory types:** episodic (events, fast decay) · semantic (knowledge, slow) · preference (opinions, slower) · procedural (how-tos, slowest) · fact (data, slow)`;
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
// Legacy static string for backward compat (overwritten at register time)
|
|
34
|
-
let STATIC_AWARENESS = buildStaticAwareness("
|
|
30
|
+
let STATIC_AWARENESS = buildStaticAwareness("local", "default");
|
|
35
31
|
|
|
36
32
|
// Fallback context when build_context fails — includes the cheatsheet
|
|
37
33
|
// but warns that dynamic context is unavailable.
|
|
38
34
|
const FALLBACK_AWARENESS = `<sulcus_context token_budget="500">
|
|
39
35
|
<cheatsheet>
|
|
40
36
|
You have Sulcus — persistent memory with reactive triggers.
|
|
41
|
-
STORE: memory_store (content, memory_type
|
|
37
|
+
STORE: memory_store (content, memory_type)
|
|
42
38
|
FIND: memory_recall (query, limit)
|
|
43
|
-
MANAGE: memory_boost / memory_deprecate / memory_relate / memory_reclassify
|
|
44
|
-
PIN: Set is_pinned=true to make a memory permanent (immune to decay).
|
|
45
|
-
TRIGGERS: create_trigger to set reactive rules on your memory graph
|
|
46
39
|
TYPES: episodic (fast fade), semantic (slow), preference, procedural (slowest), fact
|
|
47
40
|
⚠️ Context build failed this turn — use memory_recall to search manually.
|
|
48
41
|
Below is your active context. Search for deeper recall. Unlimited storage.
|
|
49
42
|
</cheatsheet>
|
|
50
43
|
</sulcus_context>`;
|
|
51
44
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
private nextId = 1;
|
|
56
|
-
private pending = new Map<string | number, (res: any) => void>();
|
|
57
|
-
private configPath: string | undefined;
|
|
45
|
+
// ─── NATIVE LIB LOADER ──────────────────────────────────────────────────────
|
|
46
|
+
// Loads libsulcus_store.dylib (embedded PG) and libsulcus_embed.dylib (embeddings)
|
|
47
|
+
// via koffi FFI. Provides queryFn and embedFn callbacks for SulcusMem.create().
|
|
58
48
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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).
|
|
68
|
-
async start(configPath?: string) {
|
|
69
|
-
const cfgPath = configPath || this.configPath;
|
|
70
|
-
const args = cfgPath ? ["--config", cfgPath, "stdio"] : ["stdio"];
|
|
71
|
-
this.child = spawn(this.binaryPath, args, {
|
|
72
|
-
stdio: ["pipe", "pipe", "inherit"],
|
|
73
|
-
env: { ...process.env, RUST_LOG: "info" } // Only passes log-level config, not secrets
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
this.child.on("error", (err) => {
|
|
77
|
-
// Reject all pending calls if the process dies
|
|
78
|
-
for (const [id, resolve] of this.pending) {
|
|
79
|
-
resolve({ error: { code: -1, message: `Sulcus process error: ${err.message}` } });
|
|
80
|
-
}
|
|
81
|
-
this.pending.clear();
|
|
82
|
-
this.child = null;
|
|
83
|
-
});
|
|
49
|
+
class NativeLibLoader {
|
|
50
|
+
private koffi: any = null;
|
|
51
|
+
private storeLib: any = null;
|
|
52
|
+
private embedLib: any = null;
|
|
53
|
+
private embedHandle: any = null;
|
|
84
54
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
55
|
+
// koffi function handles
|
|
56
|
+
private fn_store_init: any = null;
|
|
57
|
+
private fn_store_query: any = null;
|
|
58
|
+
private fn_store_free: any = null;
|
|
59
|
+
private fn_embed_create: any = null;
|
|
60
|
+
private fn_embed_text: any = null;
|
|
61
|
+
private fn_embed_free: any = null;
|
|
92
62
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const msg = JSON.parse(line);
|
|
97
|
-
if (msg.id && this.pending.has(msg.id)) {
|
|
98
|
-
const resolve = this.pending.get(msg.id)!;
|
|
99
|
-
this.pending.delete(msg.id);
|
|
100
|
-
resolve(msg);
|
|
101
|
-
}
|
|
102
|
-
} catch (e) {}
|
|
103
|
-
});
|
|
104
|
-
}
|
|
63
|
+
public loaded = false;
|
|
64
|
+
public error: string | null = null;
|
|
105
65
|
|
|
106
|
-
async call(method: string, params: any = {}): Promise<any> {
|
|
107
|
-
if (!this.child) await this.start();
|
|
108
|
-
const id = this.nextId++;
|
|
109
|
-
const request = { jsonrpc: "2.0", id, method: "tools/call", params: { name: method, arguments: params } };
|
|
110
|
-
|
|
111
|
-
return new Promise((resolve, reject) => {
|
|
112
|
-
const timeout = setTimeout(() => reject(new Error(`Sulcus timeout: ${method}`)), 30000);
|
|
113
|
-
this.pending.set(id, (res) => {
|
|
114
|
-
clearTimeout(timeout);
|
|
115
|
-
if (res.error) reject(new Error(res.error.message));
|
|
116
|
-
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
|
-
}
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
this.child!.stdin!.write(JSON.stringify(request) + "\n");
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
stop() {
|
|
131
|
-
if (this.child) this.child.kill();
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// REST API client — fallback when sulcus-local binary isn't available
|
|
136
|
-
class SulcusRestClient {
|
|
137
66
|
constructor(
|
|
138
|
-
private
|
|
139
|
-
private
|
|
140
|
-
private namespace: string,
|
|
141
|
-
private logger: any
|
|
67
|
+
private storeLibPath: string,
|
|
68
|
+
private embedLibPath: string
|
|
142
69
|
) {}
|
|
143
70
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (!res.ok) {
|
|
153
|
-
const text = await res.text().catch(() => "");
|
|
154
|
-
throw new Error(`Sulcus API ${res.status}: ${text}`);
|
|
71
|
+
init(logger: any): void {
|
|
72
|
+
try {
|
|
73
|
+
// koffi is a pure-JS FFI library — no native compilation needed
|
|
74
|
+
this.koffi = require("koffi");
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
this.error = `koffi not available: ${e.message}`;
|
|
77
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
78
|
+
return;
|
|
155
79
|
}
|
|
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
80
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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;
|
|
81
|
+
// ── Load libsulcus_store.dylib ──
|
|
82
|
+
if (!existsSync(this.storeLibPath)) {
|
|
83
|
+
this.error = `libsulcus_store not found at ${this.storeLibPath}`;
|
|
84
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(this.embedLibPath)) {
|
|
88
|
+
this.error = `libsulcus_embed not found at ${this.embedLibPath}`;
|
|
89
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
229
92
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
93
|
+
try {
|
|
94
|
+
this.storeLib = this.koffi.load(this.storeLibPath);
|
|
95
|
+
this.fn_store_init = this.storeLib.func("sulcus_store_init", "int", ["str", "uint16"]);
|
|
96
|
+
this.fn_store_query = this.storeLib.func("sulcus_store_query", "char*", ["str"]);
|
|
97
|
+
this.fn_store_free = this.storeLib.func("sulcus_store_free_string", "void", ["char*"]);
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
this.error = `Failed to load libsulcus_store: ${e.message}`;
|
|
100
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
235
103
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 { }
|
|
104
|
+
try {
|
|
105
|
+
this.embedLib = this.koffi.load(this.embedLibPath);
|
|
106
|
+
this.fn_embed_create = this.embedLib.func("sulcus_embed_create", "void*", []);
|
|
107
|
+
this.fn_embed_text = this.embedLib.func("sulcus_embed_text", "char*", ["void*", "str"]);
|
|
108
|
+
this.fn_embed_free = this.embedLib.func("sulcus_embed_free_string", "void", ["char*"]);
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
this.error = `Failed to load libsulcus_embed: ${e.message}`;
|
|
111
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
112
|
+
return;
|
|
252
113
|
}
|
|
253
114
|
|
|
254
|
-
//
|
|
255
|
-
if (!this.serverUrl || !this.apiKey) return null;
|
|
115
|
+
// ── Initialise embedded PG store ──
|
|
256
116
|
try {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
writeFileSync(this.modelPath, JSON.stringify(data));
|
|
265
|
-
this.model = data;
|
|
266
|
-
return this.model;
|
|
117
|
+
const dataDir = resolve(process.env.HOME || "~", ".sulcus/data");
|
|
118
|
+
const port = 15432;
|
|
119
|
+
const rc = this.fn_store_init(dataDir, port);
|
|
120
|
+
if (rc !== 0) {
|
|
121
|
+
this.error = `sulcus_store_init returned ${rc}`;
|
|
122
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
123
|
+
return;
|
|
267
124
|
}
|
|
268
|
-
} catch {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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;
|
|
125
|
+
} catch (e: any) {
|
|
126
|
+
this.error = `sulcus_store_init failed: ${e.message}`;
|
|
127
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
277
130
|
|
|
278
|
-
//
|
|
279
|
-
|
|
131
|
+
// ── Create embed handle ──
|
|
132
|
+
try {
|
|
133
|
+
this.embedHandle = this.fn_embed_create();
|
|
134
|
+
} catch (e: any) {
|
|
135
|
+
this.error = `sulcus_embed_create failed: ${e.message}`;
|
|
136
|
+
logger.warn(`memory-sulcus: ${this.error}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
280
139
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
let bestScore = 0;
|
|
140
|
+
this.loaded = true;
|
|
141
|
+
logger.info(`memory-sulcus: native libs loaded (store: ${this.storeLibPath}, embed: ${this.embedLibPath})`);
|
|
142
|
+
}
|
|
285
143
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
144
|
+
// queryFn: async (sql: string, params: any[]) => any[]
|
|
145
|
+
// Calls sulcus_store_query which accepts plain SQL (params inlined by caller or
|
|
146
|
+
// passed as a JSON payload — the store lib handles parameterisation internally).
|
|
147
|
+
makeQueryFn(): (sql: string, params: any[]) => Promise<any[]> {
|
|
148
|
+
return async (sql: string, _params: any[]): Promise<any[]> => {
|
|
149
|
+
if (!this.loaded) throw new Error("Sulcus store not available");
|
|
150
|
+
// The store lib's query function takes a single JSON-encoded request
|
|
151
|
+
const payload = JSON.stringify({ sql, params: _params });
|
|
152
|
+
const raw: string = this.fn_store_query(payload);
|
|
153
|
+
if (!raw) return [];
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
return Array.isArray(parsed) ? parsed : (parsed?.rows ?? [parsed]);
|
|
157
|
+
} finally {
|
|
158
|
+
// NOTE: koffi manages string memory automatically for char* returns;
|
|
159
|
+
// no manual free needed with koffi's default charset handling.
|
|
160
|
+
// If the ABI requires explicit free, call fn_store_free here.
|
|
296
161
|
}
|
|
297
|
-
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
298
164
|
|
|
299
|
-
|
|
165
|
+
// embedFn: async (text: string) => Float32Array
|
|
166
|
+
// Calls sulcus_embed_text which returns a JSON float array string.
|
|
167
|
+
makeEmbedFn(): (text: string) => Promise<Float32Array> {
|
|
168
|
+
return async (text: string): Promise<Float32Array> => {
|
|
169
|
+
if (!this.loaded) throw new Error("Sulcus embed not available");
|
|
170
|
+
const raw: string = this.fn_embed_text(this.embedHandle, text);
|
|
171
|
+
if (!raw) throw new Error("sulcus_embed_text returned null");
|
|
172
|
+
const arr: number[] = JSON.parse(raw);
|
|
173
|
+
return new Float32Array(arr);
|
|
174
|
+
};
|
|
300
175
|
}
|
|
301
176
|
}
|
|
302
177
|
|
|
@@ -320,86 +195,83 @@ const JUNK_PATTERNS = [
|
|
|
320
195
|
|
|
321
196
|
function isJunkMemory(text: string): boolean {
|
|
322
197
|
if (!text || text.length < 10) return true;
|
|
323
|
-
if (text.length > 10000) return true;
|
|
198
|
+
if (text.length > 10000) return true;
|
|
324
199
|
for (const pattern of JUNK_PATTERNS) {
|
|
325
200
|
if (pattern.test(text.trim())) return true;
|
|
326
201
|
}
|
|
327
202
|
return false;
|
|
328
203
|
}
|
|
329
204
|
|
|
205
|
+
// ─── PLUGIN ──────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
330
207
|
const sulcusPlugin = {
|
|
331
208
|
id: "memory-sulcus",
|
|
332
209
|
name: "Sulcus vMMU",
|
|
333
|
-
description: "Sulcus-backed vMMU memory for OpenClaw — thermodynamic decay, reactive triggers, local-first
|
|
210
|
+
description: "Sulcus-backed vMMU memory for OpenClaw — thermodynamic decay, reactive triggers, local-first",
|
|
334
211
|
kind: "memory" as const,
|
|
335
212
|
|
|
336
213
|
register(api: any) {
|
|
337
|
-
|
|
338
|
-
const
|
|
214
|
+
// ── Configuration ──
|
|
215
|
+
const libDir = api.config?.libDir
|
|
216
|
+
? resolve(api.config.libDir)
|
|
217
|
+
: resolve(process.env.HOME || "~", ".sulcus/lib");
|
|
218
|
+
|
|
219
|
+
const storeLibPath = api.config?.storeLibPath
|
|
220
|
+
? resolve(api.config.storeLibPath)
|
|
221
|
+
: resolve(libDir, process.platform === "darwin" ? "libsulcus_store.dylib" : "libsulcus_store.so");
|
|
222
|
+
|
|
223
|
+
const embedLibPath = api.config?.embedLibPath
|
|
224
|
+
? resolve(api.config.embedLibPath)
|
|
225
|
+
: resolve(libDir, process.platform === "darwin" ? "libsulcus_embed.dylib" : "libsulcus_embed.so");
|
|
226
|
+
|
|
227
|
+
const wasmDir = api.config?.wasmDir
|
|
228
|
+
? resolve(api.config.wasmDir)
|
|
229
|
+
: resolve(__dirname, "wasm");
|
|
230
|
+
|
|
339
231
|
// Default namespace = agent name (prevents everything landing in "default")
|
|
340
|
-
// Priority: explicit namespace config > agentId config > pluginConfig.agentId > "default"
|
|
341
232
|
const agentId = api.config?.agentId || api.pluginConfig?.agentId;
|
|
342
233
|
const namespace = api.config?.namespace === "default" && agentId
|
|
343
234
|
? agentId
|
|
344
235
|
: (api.config?.namespace || agentId || "default");
|
|
345
|
-
const serverUrl = api.config?.serverUrl || "";
|
|
346
|
-
const apiKey = api.config?.apiKey || "";
|
|
347
|
-
const client = new SulcusClient(binaryPath, iniPath);
|
|
348
236
|
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
const { existsSync } = require("node:fs");
|
|
354
|
-
hasBinary = existsSync(binaryPath);
|
|
355
|
-
if (!hasBinary) {
|
|
356
|
-
backendMode = serverUrl ? "cloud" : "unavailable";
|
|
357
|
-
} else {
|
|
358
|
-
backendMode = serverUrl ? "hybrid" : "local";
|
|
359
|
-
}
|
|
360
|
-
} catch { }
|
|
237
|
+
// ── Load native dylibs ──
|
|
238
|
+
const nativeLoader = new NativeLibLoader(storeLibPath, embedLibPath);
|
|
239
|
+
nativeLoader.init(api.logger);
|
|
361
240
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
: null;
|
|
241
|
+
// ── Load WASM module ──
|
|
242
|
+
let sulcusMem: any = null;
|
|
243
|
+
let backendMode = "unavailable";
|
|
366
244
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
245
|
+
if (nativeLoader.loaded) {
|
|
246
|
+
const wasmJsPath = resolve(wasmDir, "sulcus_wasm.js");
|
|
247
|
+
if (existsSync(wasmJsPath)) {
|
|
370
248
|
try {
|
|
371
|
-
|
|
249
|
+
const { SulcusMem, on_init } = require(wasmJsPath);
|
|
250
|
+
// on_init sets up WASM internals (panic hooks etc.)
|
|
251
|
+
if (typeof on_init === "function") on_init();
|
|
252
|
+
|
|
253
|
+
const queryFn = nativeLoader.makeQueryFn();
|
|
254
|
+
const embedFn = nativeLoader.makeEmbedFn();
|
|
255
|
+
sulcusMem = SulcusMem.create(queryFn, embedFn);
|
|
256
|
+
backendMode = "wasm";
|
|
257
|
+
api.logger.info(`memory-sulcus: SulcusMem created via WASM (wasm: ${wasmJsPath})`);
|
|
372
258
|
} catch (e: any) {
|
|
373
|
-
api.logger.warn(`memory-sulcus:
|
|
374
|
-
|
|
259
|
+
api.logger.warn(`memory-sulcus: WASM load failed: ${e.message}`);
|
|
260
|
+
backendMode = "unavailable";
|
|
375
261
|
}
|
|
262
|
+
} else {
|
|
263
|
+
api.logger.warn(`memory-sulcus: WASM module not found at ${wasmJsPath}`);
|
|
376
264
|
}
|
|
377
|
-
|
|
378
|
-
|
|
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}`);
|
|
384
|
-
}
|
|
265
|
+
} else {
|
|
266
|
+
api.logger.warn(`memory-sulcus: native libs unavailable — ${nativeLoader.error}`);
|
|
385
267
|
}
|
|
386
268
|
|
|
269
|
+
const isAvailable = sulcusMem !== null;
|
|
270
|
+
|
|
387
271
|
// Update static awareness with runtime info
|
|
388
|
-
STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace
|
|
389
|
-
|
|
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
|
-
}
|
|
272
|
+
STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
|
|
401
273
|
|
|
402
|
-
api.logger.info(`memory-sulcus: registered (
|
|
274
|
+
api.logger.info(`memory-sulcus: registered (backend: ${backendMode}, namespace: ${namespace}, available: ${isAvailable})`);
|
|
403
275
|
|
|
404
276
|
// ── Core memory tools ──
|
|
405
277
|
|
|
@@ -412,12 +284,14 @@ const sulcusPlugin = {
|
|
|
412
284
|
limit: Type.Optional(Type.Number({ default: 5, description: "Maximum number of results to return (1-10)." }))
|
|
413
285
|
}),
|
|
414
286
|
async execute(_id: string, params: any) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
287
|
+
if (!isAvailable) {
|
|
288
|
+
throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
|
|
289
|
+
}
|
|
290
|
+
const res = await sulcusMem.search_memory(params.query, params.limit ?? 5);
|
|
291
|
+
const results = res?.results ?? res;
|
|
418
292
|
return {
|
|
419
293
|
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
420
|
-
details: {
|
|
294
|
+
details: { results, backend: backendMode, namespace }
|
|
421
295
|
};
|
|
422
296
|
}
|
|
423
297
|
}, { name: "memory_recall" });
|
|
@@ -425,10 +299,9 @@ const sulcusPlugin = {
|
|
|
425
299
|
api.registerTool({
|
|
426
300
|
name: "memory_store",
|
|
427
301
|
label: "Memory Store",
|
|
428
|
-
description: "Record information in Sulcus memory. Supports Markdown formatting. You control the memory type
|
|
302
|
+
description: "Record information in Sulcus memory. Supports Markdown formatting. You control the memory type at creation time.",
|
|
429
303
|
parameters: Type.Object({
|
|
430
304
|
content: Type.String({ description: "Memory content. Supports Markdown formatting for structured content." }),
|
|
431
|
-
fold_name: Type.Optional(Type.String({ description: `Memory namespace/fold. Defaults to "${namespace}" (agent namespace).` })),
|
|
432
305
|
memory_type: Type.Optional(Type.Union([
|
|
433
306
|
Type.Literal("episodic"),
|
|
434
307
|
Type.Literal("semantic"),
|
|
@@ -436,15 +309,6 @@ const sulcusPlugin = {
|
|
|
436
309
|
Type.Literal("procedural"),
|
|
437
310
|
Type.Literal("fact")
|
|
438
311
|
], { description: "Memory type. preference=user preferences, procedural=how-to/processes, fact=stable knowledge, semantic=concepts/relationships, episodic=events/experiences. Default: episodic" })),
|
|
439
|
-
decay_class: Type.Optional(Type.Union([
|
|
440
|
-
Type.Literal("volatile"),
|
|
441
|
-
Type.Literal("normal"),
|
|
442
|
-
Type.Literal("stable"),
|
|
443
|
-
Type.Literal("permanent")
|
|
444
|
-
], { description: "Decay rate. volatile=fast decay, normal=default, stable=slow decay, permanent=never decays" })),
|
|
445
|
-
is_pinned: Type.Optional(Type.Boolean({ description: "Pin memory to freeze heat at current value, preventing ALL decay. Pinned memories never lose heat." })),
|
|
446
|
-
min_heat: Type.Optional(Type.Number({ description: "Minimum heat floor (0.0-1.0). Memory will never decay below this value." })),
|
|
447
|
-
key_points: Type.Optional(Type.Array(Type.String(), { description: "Key points to index for search. Extracted highlights." }))
|
|
448
312
|
}),
|
|
449
313
|
async execute(_id: string, params: any) {
|
|
450
314
|
// Pre-send junk filter
|
|
@@ -456,25 +320,14 @@ const sulcusPlugin = {
|
|
|
456
320
|
};
|
|
457
321
|
}
|
|
458
322
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (res?.error === "storage_limit_reached") {
|
|
462
|
-
return {
|
|
463
|
-
content: [{ type: "text", text: `⚠️ Storage limit reached: ${res.message}` }],
|
|
464
|
-
details: res
|
|
465
|
-
};
|
|
323
|
+
if (!isAvailable) {
|
|
324
|
+
throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
|
|
466
325
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
namespace,
|
|
470
|
-
server: serverUrl,
|
|
471
|
-
sync_available: backendMode === "hybrid",
|
|
472
|
-
siu_classified: false,
|
|
473
|
-
};
|
|
474
|
-
const provenanceStr = `[${provenance.backend}] namespace: ${provenance.namespace || namespace}, server: ${provenance.server || serverUrl}`;
|
|
326
|
+
|
|
327
|
+
const res = await sulcusMem.add_memory(params.content, params.memory_type ?? null);
|
|
475
328
|
return {
|
|
476
|
-
content: [{ type: "text", text: `Stored [${params.memory_type || "episodic"}] memory: "${(params.content || "").substring(0, 80)}..." → ${
|
|
477
|
-
details: { ...res,
|
|
329
|
+
content: [{ type: "text", text: `Stored [${params.memory_type || "episodic"}] memory: "${(params.content || "").substring(0, 80)}..." → [${backendMode}] namespace: ${namespace}` }],
|
|
330
|
+
details: { ...res, backend: backendMode, namespace }
|
|
478
331
|
};
|
|
479
332
|
}
|
|
480
333
|
}, { name: "memory_store" });
|
|
@@ -482,31 +335,46 @@ const sulcusPlugin = {
|
|
|
482
335
|
api.registerTool({
|
|
483
336
|
name: "memory_status",
|
|
484
337
|
label: "Memory Status",
|
|
485
|
-
description: "Check Sulcus memory backend status: connection, namespace, capabilities, and
|
|
338
|
+
description: "Check Sulcus memory backend status: connection, namespace, capabilities, and hot nodes.",
|
|
486
339
|
parameters: Type.Object({}),
|
|
487
340
|
async execute(_id: string, _params: any) {
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
}
|
|
341
|
+
if (!isAvailable) {
|
|
342
|
+
return {
|
|
343
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
344
|
+
status: "unavailable",
|
|
345
|
+
backend: backendMode,
|
|
346
|
+
namespace,
|
|
347
|
+
error: nativeLoader.error || "WASM not loaded",
|
|
348
|
+
storeLib: storeLibPath,
|
|
349
|
+
embedLib: embedLibPath,
|
|
350
|
+
wasmDir,
|
|
351
|
+
}, null, 2) }],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const hotNodes = await sulcusMem.list_hot_nodes(20);
|
|
356
|
+
const nodeList = hotNodes?.nodes ?? hotNodes ?? [];
|
|
357
|
+
const count = Array.isArray(nodeList) ? nodeList.length : 0;
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
360
|
+
status: "ok",
|
|
361
|
+
backend: backendMode,
|
|
362
|
+
namespace,
|
|
363
|
+
hot_node_count: count,
|
|
364
|
+
hot_nodes: nodeList,
|
|
365
|
+
}, null, 2) }],
|
|
366
|
+
details: { status: "ok", backend: backendMode, namespace, count }
|
|
367
|
+
};
|
|
368
|
+
} catch (e: any) {
|
|
369
|
+
return {
|
|
370
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
371
|
+
status: "error",
|
|
372
|
+
backend: backendMode,
|
|
373
|
+
namespace,
|
|
374
|
+
error: e.message,
|
|
375
|
+
}, null, 2) }],
|
|
376
|
+
};
|
|
500
377
|
}
|
|
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
378
|
}
|
|
511
379
|
}, { name: "memory_status" });
|
|
512
380
|
|
|
@@ -529,32 +397,23 @@ const sulcusPlugin = {
|
|
|
529
397
|
api.logger.debug(`memory-sulcus: autoRecall is disabled, skipping context build`);
|
|
530
398
|
return;
|
|
531
399
|
}
|
|
400
|
+
if (!isAvailable) return;
|
|
532
401
|
api.logger.info(`memory-sulcus: before_agent_start hook triggered for agent ${event.agentId}`);
|
|
533
402
|
if (!event.prompt) return;
|
|
534
403
|
try {
|
|
535
|
-
api.logger.debug(`memory-sulcus:
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// - plain XML string (new format, post-4dca467)
|
|
541
|
-
// - { context: "...", token_estimate: N } (old format)
|
|
542
|
-
// The MCP client resolves to the parsed JSON if valid, or the raw MCP result object.
|
|
543
|
-
let context: string | undefined;
|
|
544
|
-
if (typeof res === "string") {
|
|
545
|
-
context = res;
|
|
546
|
-
} else if (res?.context) {
|
|
547
|
-
context = res.context;
|
|
548
|
-
} else if (res?.content?.[0]?.text) {
|
|
549
|
-
context = res.content[0].text;
|
|
550
|
-
}
|
|
551
|
-
if (context) {
|
|
552
|
-
api.logger.info(`memory-sulcus: context build successful, injecting ${context.length} chars`);
|
|
553
|
-
return { prependSystemContext: context };
|
|
404
|
+
api.logger.debug(`memory-sulcus: searching context for prompt: ${event.prompt.substring(0, 50)}...`);
|
|
405
|
+
const res = await sulcusMem.search_memory(event.prompt, 5);
|
|
406
|
+
const results = res?.results ?? [];
|
|
407
|
+
if (!results || results.length === 0) {
|
|
408
|
+
return { prependSystemContext: FALLBACK_AWARENESS };
|
|
554
409
|
}
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
410
|
+
// Format results as a concise XML context block
|
|
411
|
+
const items = results.map((r: any) =>
|
|
412
|
+
` <memory id="${r.id}" heat="${(r.current_heat ?? r.score ?? 0).toFixed(2)}" type="${r.memory_type ?? "unknown"}">${r.label ?? r.pointer_summary ?? ""}</memory>`
|
|
413
|
+
).join("\n");
|
|
414
|
+
const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${items}\n</sulcus_context>`;
|
|
415
|
+
api.logger.info(`memory-sulcus: injecting ${results.length} recalled memories (${context.length} chars)`);
|
|
416
|
+
return { prependSystemContext: context };
|
|
558
417
|
} catch (e) {
|
|
559
418
|
// build_context failed — inject fallback so the LLM isn't flying blind
|
|
560
419
|
api.logger.warn(`memory-sulcus: context build failed: ${e} — injecting fallback awareness`);
|
|
@@ -563,18 +422,12 @@ const sulcusPlugin = {
|
|
|
563
422
|
});
|
|
564
423
|
|
|
565
424
|
// agent_end: Do NOT auto-record raw conversation turns.
|
|
566
|
-
// The LLM has
|
|
567
|
-
// Auto-recording every turn flooded the store with 2000+ junk episodic nodes
|
|
568
|
-
// containing placeholder vectors and raw JSON conversation payloads.
|
|
425
|
+
// The LLM has memory_store as a tool — it decides what's worth remembering.
|
|
569
426
|
api.on("agent_end", async (event: any) => {
|
|
570
427
|
api.logger.debug(`memory-sulcus: agent_end hook triggered for agent ${event.agentId} (no auto-record)`);
|
|
571
428
|
});
|
|
572
429
|
|
|
573
|
-
|
|
574
|
-
id: "memory-sulcus",
|
|
575
|
-
start: () => client.start(),
|
|
576
|
-
stop: () => client.stop()
|
|
577
|
-
});
|
|
430
|
+
// No service registration needed — no background process to manage
|
|
578
431
|
}
|
|
579
432
|
};
|
|
580
433
|
|