@digitalforgestudios/openclaw-sulcus 3.11.1 → 4.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 CHANGED
@@ -6,11 +6,8 @@ import { URL } from "node:url";
6
6
  import { Type } from "@sinclair/typebox";
7
7
 
8
8
  // ─── STATIC AWARENESS ───────────────────────────────────────────────────────
9
- // Injected via before_prompt_build on EVERY turn, unconditionally.
10
- // This is the absolute minimum the LLM needs to know Sulcus exists.
11
- // It fires even if build_context crashes, times out, or returns empty.
12
- // Build static awareness with runtime backend info
13
- function buildStaticAwareness(backendMode: string, namespace: string) {
9
+
10
+ function buildStaticAwareness(backendMode: string, namespace: string): string {
14
11
  return `## Persistent Memory (Sulcus)
15
12
  You have Sulcus — a persistent, reactive, thermodynamic memory system with reactive triggers.
16
13
  Memories survive across sessions. They have heat (0.0–1.0) that decays over time.
@@ -29,19 +26,15 @@ Memories survive across sessions. They have heat (0.0–1.0) that decays over ti
29
26
  **Memory types:** episodic (events, fast decay) · semantic (knowledge, slow) · preference (opinions, slower) · procedural (how-tos, slowest) · fact (data, slow)`;
30
27
  }
31
28
 
32
- // Legacy static string for backward compat (overwritten at register time)
33
29
  let STATIC_AWARENESS = buildStaticAwareness("local", "default");
34
30
 
35
- // Fallback context when build_context fails — includes the cheatsheet
36
- // but warns that dynamic context is unavailable.
37
31
  const FALLBACK_AWARENESS = `<sulcus_context token_budget="500">
38
32
  <cheatsheet>
39
33
  You have Sulcus — persistent memory with reactive triggers.
40
34
  STORE: memory_store (content, memory_type)
41
35
  FIND: memory_recall (query, limit)
42
36
  TYPES: episodic (fast fade), semantic (slow), preference, procedural (slowest), fact
43
- ⚠️ Context build failed this turn — use memory_recall to search manually.
44
- Below is your active context. Search for deeper recall. Unlimited storage.
37
+ Context build failed this turn — use memory_recall to search manually.
45
38
  </cheatsheet>
46
39
  </sulcus_context>`;
47
40
 
@@ -52,12 +45,12 @@ interface HookConfig {
52
45
  enabled: boolean;
53
46
  limit?: number;
54
47
  minScore?: number;
55
- [key: string]: any;
48
+ [key: string]: unknown;
56
49
  }
57
50
 
58
51
  interface ToolConfig {
59
52
  enabled: boolean;
60
- [key: string]: any;
53
+ [key: string]: unknown;
61
54
  }
62
55
 
63
56
  interface HooksConfig {
@@ -68,162 +61,145 @@ interface HooksConfig {
68
61
  }
69
62
 
70
63
  interface HookHandlerCtx {
71
- sulcusMem: any;
64
+ sulcusMem: SulcusCloudClient | null;
72
65
  backendMode: string;
73
66
  namespace: string;
74
- logger: any;
67
+ logger: PluginLogger;
75
68
  nativeError?: string | null;
76
69
  storeLibPath?: string;
77
70
  vectorsLibPath?: string;
78
71
  wasmDir?: string;
79
72
  }
80
73
 
81
- type HookHandler = (event: any, config: HookConfig, ctx: HookHandlerCtx) => Promise<any>;
74
+ interface PluginLogger {
75
+ debug?: (msg: string) => void;
76
+ info: (msg: string) => void;
77
+ warn: (msg: string) => void;
78
+ error: (msg: string) => void;
79
+ }
80
+
81
+ type HookHandler = (event: Record<string, unknown>, config: HookConfig, ctx: HookHandlerCtx) => Promise<unknown>;
82
82
 
83
83
  // ─── HOOK HANDLERS ───────────────────────────────────────────────────────────
84
84
 
85
85
  const hookHandlers: Record<string, HookHandler> = {
86
- /**
87
- * inject_awareness — inject static Sulcus awareness into every prompt build.
88
- * No network call — just a static string describing available tools.
89
- */
90
- inject_awareness: async (_event: any, _config: HookConfig, _ctx: HookHandlerCtx) => {
86
+ inject_awareness: async (_event, _config, _ctx) => {
91
87
  return { appendSystemContext: STATIC_AWARENESS };
92
88
  },
93
89
 
94
- /**
95
- * auto_recall — search Sulcus memory for context relevant to the incoming prompt.
96
- * Only runs when enabled. Falls back to FALLBACK_AWARENESS on error.
97
- */
98
- auto_recall: async (event: any, config: HookConfig, ctx: HookHandlerCtx) => {
90
+ auto_recall: async (event, config, ctx) => {
99
91
  const { sulcusMem, namespace, logger } = ctx;
100
92
  if (!sulcusMem) return;
101
- const agentLabel = event?.agentId ?? "(unknown)";
93
+ const agentLabel = (event?.agentId as string) ?? "(unknown)";
102
94
  logger.info(`sulcus: before_agent_start hook triggered for agent ${agentLabel}`);
103
95
  const prompt = typeof event?.prompt === "string" ? event.prompt : "";
104
96
  if (!prompt) return;
105
97
  try {
106
- const limit = config.limit ?? 5;
107
- logger.debug(`sulcus: searching context for prompt: ${prompt.substring(0, 50)}... (namespace: ${namespace})`);
98
+ const limit = (config.limit as number) ?? 5;
99
+ logger.debug?.(`sulcus: searching context for prompt: ${prompt.substring(0, 50)}... (namespace: ${namespace})`);
108
100
  const res = await sulcusMem.search_memory(prompt, limit, namespace);
109
101
  const results = res?.results ?? [];
110
102
  if (!results || results.length === 0) {
111
103
  return { prependSystemContext: FALLBACK_AWARENESS };
112
104
  }
113
- // Format results as a concise XML context block
114
- const items = results.map((r: any) =>
115
- ` <memory id="${r.id}" heat="${(r.current_heat ?? r.score ?? 0).toFixed(2)}" type="${r.memory_type ?? "unknown"}">${r.label ?? r.pointer_summary ?? ""}</memory>`
116
- ).join("\n");
105
+ const items = results.map((r: Record<string, unknown>) => {
106
+ const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0).toFixed(2);
107
+ const mtype = (r.memory_type as string) ?? "unknown";
108
+ const label = (r.label as string) ?? (r.pointer_summary as string) ?? "";
109
+ return ` <memory id="${r.id}" heat="${heat}" type="${mtype}">${label}</memory>`;
110
+ }).join("\n");
117
111
  const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${items}\n</sulcus_context>`;
118
112
  logger.info(`sulcus: injecting ${results.length} recalled memories (${context.length} chars)`);
119
113
  return { prependSystemContext: context };
120
114
  } catch (e) {
121
- // build_context failed — inject fallback so the LLM isn't flying blind
122
115
  logger.warn(`sulcus: context build failed: ${e} — injecting fallback awareness`);
123
116
  return { prependSystemContext: FALLBACK_AWARENESS };
124
117
  }
125
118
  },
126
119
 
127
- /**
128
- * none no-op handler. Used for hooks that are enabled but should do nothing
129
- * (e.g., agent_end where we want to log but not auto-record).
130
- */
131
- none: async (event: any, _config: HookConfig, ctx: HookHandlerCtx) => {
132
- ctx.logger.debug(`sulcus: hook fired (action=none) for agent ${event.agentId ?? "(unknown)"} (no-op)`);
120
+ none: async (event, _config, ctx) => {
121
+ ctx.logger.debug?.(`sulcus: hook fired (action=none) for agent ${(event.agentId as string) ?? "(unknown)"} (no-op)`);
133
122
  },
134
123
 
135
- /**
136
- * sivu_auto_capture — SIU v2 quality-gated auto-capture on agent_end.
137
- *
138
- * When fired after each turn, extracts the user message from the event,
139
- * runs it through SIVU (store/reject gate) and SICU (type classifier),
140
- * and stores the memory only if SIVU approves. Falls back to basic
141
- * junk-filtering + episodic capture if SIU v2 endpoint is unavailable.
142
- *
143
- * Config options:
144
- * min_store_confidence: number (default 0.5) — minimum SIVU confidence to store
145
- * fallback_on_error: boolean (default true) — store as episodic if SIU unavailable
146
- */
147
- sivu_auto_capture: async (event: any, config: HookConfig, ctx: HookHandlerCtx) => {
124
+ sivu_auto_capture: async (event, config, ctx) => {
148
125
  const { sulcusMem, logger } = ctx;
149
126
  if (!sulcusMem) return;
150
127
 
151
- // Extract user message from the event
152
- const userMessage = event?.userMessage ?? event?.prompt ?? event?.text ?? "";
128
+ // Skip captures from system/automated event sources
129
+ const eventTrigger = (event?.trigger as string) ?? "";
130
+ const skippedTriggers = ["exec-event", "cron-event", "heartbeat"];
131
+ if (skippedTriggers.some((t) => eventTrigger === t)) {
132
+ logger.debug?.(`sulcus: sivu_auto_capture — skipping trigger="${eventTrigger}"`);
133
+ return;
134
+ }
135
+
136
+ const userMessage = (event?.userMessage ?? event?.prompt ?? event?.text ?? "") as string;
153
137
  if (!userMessage || typeof userMessage !== "string") {
154
- logger.debug("sulcus: sivu_auto_capture — no user message in event, skipping");
138
+ logger.debug?.("sulcus: sivu_auto_capture — no user message in event, skipping");
155
139
  return;
156
140
  }
157
141
 
158
- // Pre-filter obvious junk before hitting the API
159
142
  if (isJunkMemory(userMessage)) {
160
- logger.debug(`sulcus: sivu_auto_capture — pre-filtered junk: "${userMessage.substring(0, 50)}..."`);
143
+ logger.debug?.(`sulcus: sivu_auto_capture — pre-filtered junk: "${userMessage.substring(0, 50)}..."`);
161
144
  return;
162
145
  }
163
146
 
164
- const minConfidence = config.min_store_confidence ?? 0.5;
165
- const fallbackOnError = config.fallback_on_error !== false; // default true
147
+ const minConfidence = (config.min_store_confidence as number) ?? 0.5;
148
+ const fallbackOnError = config.fallback_on_error !== false;
166
149
 
167
- // Try SIU v2 endpoint for quality-gated classification
168
150
  if (sulcusMem instanceof SulcusCloudClient) {
169
151
  try {
170
- const siuResult = await (sulcusMem as SulcusCloudClient).request("POST", "/api/v2/siu/label", {
171
- text: userMessage,
172
- });
173
-
174
- const shouldStore = siuResult?.store === true && (siuResult?.store_confidence ?? 0) >= minConfidence;
175
- const memoryType = siuResult?.memory_type ?? "episodic";
176
- const modelVersion = siuResult?.model_version ?? "unknown";
152
+ const siuResult = await sulcusMem.request("POST", "/api/v2/siu/label", { text: userMessage }) as Record<string, unknown>;
153
+ const storeConf = (siuResult?.store_confidence as number) ?? 0;
154
+ const shouldStore = siuResult?.store === true && storeConf >= minConfidence;
155
+ const memoryType = (siuResult?.memory_type as string) ?? "episodic";
156
+ const modelVersion = (siuResult?.model_version as string) ?? "unknown";
177
157
 
178
158
  if (!shouldStore) {
179
- logger.info(`sulcus: sivu_auto_capture — SIVU rejected (confidence: ${siuResult?.store_confidence?.toFixed(3) ?? "?"}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
159
+ logger.info(`sulcus: sivu_auto_capture — SIVU rejected (confidence: ${storeConf.toFixed(3)}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
180
160
  return;
181
161
  }
182
162
 
183
- // SIVU approved — store with SICU-predicted type
184
163
  const res = await sulcusMem.add_memory(userMessage, memoryType);
185
- logger.info(`sulcus: sivu_auto_capture stored [${memoryType}] (id: ${res?.id ?? "?"}, sivu_conf: ${siuResult?.store_confidence?.toFixed(3)}, sicu_conf: ${siuResult?.type_confidence?.toFixed(3)}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
164
+ const typeConf = ((siuResult?.type_confidence as number) ?? 0).toFixed(3);
165
+ logger.info(`sulcus: sivu_auto_capture — stored [${memoryType}] (id: ${res?.id ?? "?"}, sivu_conf: ${storeConf.toFixed(3)}, sicu_conf: ${typeConf}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
186
166
  return;
187
- } catch (e: any) {
188
- logger.warn(`sulcus: sivu_auto_capture SIU v2 endpoint error: ${e.message}`);
167
+ } catch (e: unknown) {
168
+ const msg = e instanceof Error ? e.message : String(e);
169
+ logger.warn(`sulcus: sivu_auto_capture — SIU v2 endpoint error: ${msg}`);
189
170
  if (!fallbackOnError) return;
190
- // Fall through to basic capture
191
171
  }
192
172
  }
193
173
 
194
- // Fallback: store as episodic (no SIU gating available)
195
174
  try {
196
175
  const res = await sulcusMem.add_memory(userMessage, "episodic");
197
176
  logger.info(`sulcus: sivu_auto_capture — fallback stored [episodic] (id: ${res?.id ?? "?"}): "${userMessage.substring(0, 60)}..."`);
198
- } catch (e: any) {
199
- logger.warn(`sulcus: sivu_auto_capture fallback store failed: ${e.message}`);
177
+ } catch (e: unknown) {
178
+ const msg = e instanceof Error ? e.message : String(e);
179
+ logger.warn(`sulcus: sivu_auto_capture — fallback store failed: ${msg}`);
200
180
  }
201
181
  },
202
182
  };
203
183
 
204
184
  // ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
205
- // Lightweight fallback client for users without local dylibs/WASM.
206
- // Uses Node.js built-in https/http — ZERO external dependencies.
207
- // Activates only when serverUrl + apiKey are configured and local libs are absent.
208
185
 
209
186
  class SulcusCloudClient {
210
187
  private serverUrl: string;
211
188
  private apiKey: string;
212
189
 
213
190
  constructor(serverUrl: string, apiKey: string) {
214
- // Strip trailing slash for clean path concatenation
215
191
  this.serverUrl = serverUrl.replace(/\/+$/, "");
216
192
  this.apiKey = apiKey;
217
193
  }
218
194
 
219
- /** Low-level HTTP helper. Returns parsed JSON response body. */
220
- request(method: string, path: string, body?: any): Promise<any> {
221
- return new Promise((resolve, reject) => {
195
+ request(method: string, path: string, body?: unknown): Promise<unknown> {
196
+ return new Promise((resolveP, rejectP) => {
222
197
  let parsedUrl: URL;
223
198
  try {
224
199
  parsedUrl = new URL(this.serverUrl + path);
225
- } catch (e: any) {
226
- return reject(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${e.message}`));
200
+ } catch (e: unknown) {
201
+ const msg = e instanceof Error ? e.message : String(e);
202
+ return rejectP(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${msg}`));
227
203
  }
228
204
 
229
205
  const isHttps = parsedUrl.protocol === "https:";
@@ -253,129 +229,96 @@ class SulcusCloudClient {
253
229
  res.on("end", () => {
254
230
  const raw = Buffer.concat(chunks).toString("utf-8");
255
231
  if (!res.statusCode || res.statusCode >= 400) {
256
- return reject(new Error(`SulcusCloudClient: HTTP ${res.statusCode} for ${method} ${path}: ${raw.substring(0, 200)}`));
257
- }
258
- if (!raw || raw.trim() === "") {
259
- return resolve(null);
232
+ return rejectP(new Error(`SulcusCloudClient: HTTP ${res.statusCode} for ${method} ${path}: ${raw.substring(0, 200)}`));
260
233
  }
234
+ if (!raw || raw.trim() === "") return resolveP(null);
261
235
  try {
262
- resolve(JSON.parse(raw));
236
+ resolveP(JSON.parse(raw));
263
237
  } catch (_e) {
264
- // Some endpoints return plain text (e.g. markdown export)
265
- resolve(raw);
238
+ resolveP(raw);
266
239
  }
267
240
  });
268
241
  });
269
242
 
270
- req.on("error", (e: Error) => reject(new Error(`SulcusCloudClient: network error for ${method} ${path}: ${e.message}`)));
271
-
272
- if (bodyStr !== undefined) {
273
- req.write(bodyStr);
274
- }
243
+ req.on("error", (e: Error) => rejectP(new Error(`SulcusCloudClient: network error for ${method} ${path}: ${e.message}`)));
244
+ if (bodyStr !== undefined) req.write(bodyStr);
275
245
  req.end();
276
246
  });
277
247
  }
278
248
 
279
- /**
280
- * search_memory maps to POST /agent/search
281
- * Server returns { results: [...] }; we normalise to the results array.
282
- */
283
- async search_memory(query: string, limit?: number, namespace?: string): Promise<{ results: any[] }> {
284
- const body: any = { query };
249
+ async search_memory(query: string, limit?: number, namespace?: string): Promise<{ results: Record<string, unknown>[] }> {
250
+ const body: Record<string, unknown> = { query };
285
251
  if (limit !== undefined) body.limit = limit;
286
252
  if (namespace !== undefined) body.namespace = namespace;
287
- const res = await this.request("POST", "/api/v1/agent/search", body);
288
- const results = res?.results ?? res?.items ?? res?.nodes ?? (Array.isArray(res) ? res : []);
253
+ const res = await this.request("POST", "/api/v1/agent/search", body) as Record<string, unknown> | null;
254
+ const results = (res?.results ?? res?.items ?? res?.nodes ?? (Array.isArray(res) ? res : [])) as Record<string, unknown>[];
289
255
  return { results };
290
256
  }
291
257
 
292
- /**
293
- * add_memory maps to POST /agent/nodes
294
- * Server returns { id, ... }; pass through.
295
- */
296
- async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: any }> {
297
- const body: any = { label: content };
258
+ async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: unknown }> {
259
+ const body: Record<string, unknown> = { label: content };
298
260
  if (memoryType) body.memory_type = memoryType;
299
- const res = await this.request("POST", "/api/v1/agent/nodes", body);
300
- return res ?? { id: "unknown" };
261
+ const res = await this.request("POST", "/api/v1/agent/nodes", body) as Record<string, unknown> | null;
262
+ return (res ?? { id: "unknown" }) as { id: string; [key: string]: unknown };
301
263
  }
302
264
 
303
- /**
304
- * list_hot_nodes — maps to GET /agent/hot_nodes
305
- * Returns hot_nodes list; normalised for memory_status tool.
306
- * Note: was incorrectly calling /agent/memory/status which doesn't return hot_nodes.
307
- */
308
- async list_hot_nodes(limit?: number): Promise<{ nodes: any[] }> {
265
+ async list_hot_nodes(limit?: number): Promise<{ nodes: Record<string, unknown>[] }> {
309
266
  const q = limit ? `?limit=${limit}` : "";
310
- const res = await this.request("GET", `/api/v1/agent/hot_nodes${q}`);
311
- // Server returns an array directly from this endpoint
312
- const nodes = Array.isArray(res) ? res : (res?.hot_nodes ?? res?.nodes ?? []);
267
+ const res = await this.request("GET", `/api/v1/agent/hot_nodes${q}`) as Record<string, unknown> | unknown[] | null;
268
+ const nodes = (Array.isArray(res) ? res : ((res as Record<string, unknown>)?.hot_nodes ?? (res as Record<string, unknown>)?.nodes ?? [])) as Record<string, unknown>[];
313
269
  return { nodes };
314
270
  }
315
271
 
316
- /**
317
- * consolidate maps to POST /agent/consolidate
318
- */
319
- async consolidate(minHeat?: number): Promise<any> {
320
- const body: any = {};
272
+ async consolidate(minHeat?: number): Promise<unknown> {
273
+ const body: Record<string, unknown> = {};
321
274
  if (minHeat !== undefined) body.min_heat = minHeat;
322
275
  return this.request("POST", "/api/v1/agent/consolidate", body);
323
276
  }
324
277
 
325
- /**
326
- * delete_memory — maps to DELETE /agent/nodes/:id?train=true|false
327
- * If train=true, snapshots content before deletion and records a 'reject' training signal for SIVU.
328
- */
329
- async delete_memory(id: string, train?: boolean): Promise<any> {
278
+ async delete_memory(id: string, train?: boolean): Promise<unknown> {
330
279
  const trainParam = train ? "true" : "false";
331
280
  return this.request("DELETE", `/api/v1/agent/nodes/${encodeURIComponent(id)}?train=${trainParam}`);
332
281
  }
333
282
 
334
- /**
335
- * export_markdown — maps to GET /agent/export?format=markdown
336
- * Returns raw markdown string.
337
- */
338
283
  async export_markdown(): Promise<string> {
339
284
  const res = await this.request("GET", "/api/v1/agent/export?format=markdown");
340
- // Server may return { content: "..." } or raw string
341
285
  if (typeof res === "string") return res;
342
- return res?.content ?? res?.markdown ?? JSON.stringify(res, null, 2);
286
+ const r = res as Record<string, unknown>;
287
+ return (r?.content ?? r?.markdown ?? JSON.stringify(res, null, 2)) as string;
343
288
  }
344
289
 
345
- /**
346
- * import_markdown — maps to POST /agent/import
347
- */
348
- async import_markdown(text: string): Promise<any> {
290
+ async import_markdown(text: string): Promise<unknown> {
349
291
  return this.request("POST", "/api/v1/agent/import", { format: "markdown", content: text });
350
292
  }
351
293
 
352
- /**
353
- * evaluate_triggers maps to POST /triggers/evaluate
354
- */
355
- async evaluate_triggers(event: any, contextJson?: string): Promise<any> {
356
- const body: any = { event };
294
+ async evaluate_triggers(event: unknown, contextJson?: string): Promise<unknown> {
295
+ const body: Record<string, unknown> = { event };
357
296
  if (contextJson) {
358
- try {
359
- body.context = JSON.parse(contextJson);
360
- } catch (_e) {
361
- body.context = contextJson;
362
- }
297
+ try { body.context = JSON.parse(contextJson); }
298
+ catch (_e) { body.context = contextJson; }
363
299
  }
364
300
  return this.request("POST", "/api/v1/triggers/evaluate", body);
365
301
  }
302
+
303
+ async probe(): Promise<boolean> {
304
+ try {
305
+ await this.search_memory("probe", 1);
306
+ return true;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
366
311
  }
367
312
 
368
313
  // ─── NATIVE LIB LOADER ──────────────────────────────────────────────────────
369
- // Loads libsulcus_store.dylib (embedded PG) and libsulcus_vectors.dylib (embeddings)
370
- // via koffi FFI. Provides queryFn and embedFn callbacks for SulcusMem.create().
371
314
 
372
315
  class NativeLibLoader {
373
- private koffi: any = null;
374
- private storeLib: any = null;
375
- private vectorsLib: any = null;
376
- private vectorsHandle: any = null;
377
-
378
- // koffi function handles
316
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
317
+ private koffi: unknown = null;
318
+ private storeLib: unknown = null;
319
+ private vectorsLib: unknown = null;
320
+ private vectorsHandle: unknown = null;
321
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
322
  private fn_store_init: any = null;
380
323
  private fn_store_query: any = null;
381
324
  private fn_store_free: any = null;
@@ -386,22 +329,18 @@ class NativeLibLoader {
386
329
  public loaded = false;
387
330
  public error: string | null = null;
388
331
 
389
- constructor(
390
- private storeLibPath: string,
391
- private vectorsLibPath: string
392
- ) {}
332
+ constructor(private storeLibPath: string, private vectorsLibPath: string) {}
393
333
 
394
- init(logger: any): void {
334
+ init(logger: PluginLogger): void {
395
335
  try {
396
- // koffi is a pure-JS FFI library — no native compilation needed
336
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
397
337
  this.koffi = require("koffi");
398
- } catch (e: any) {
399
- this.error = `koffi not available: ${e.message}`;
338
+ } catch (e: unknown) {
339
+ this.error = `koffi not available: ${e instanceof Error ? e.message : e}`;
400
340
  logger.warn(`sulcus: ${this.error}`);
401
341
  return;
402
342
  }
403
343
 
404
- // ── Load libsulcus_store.dylib ──
405
344
  if (!existsSync(this.storeLibPath)) {
406
345
  this.error = `libsulcus_store not found at ${this.storeLibPath}`;
407
346
  logger.warn(`sulcus: ${this.error}`);
@@ -414,48 +353,47 @@ class NativeLibLoader {
414
353
  }
415
354
 
416
355
  try {
417
- this.storeLib = this.koffi.load(this.storeLibPath);
418
- this.fn_store_init = this.storeLib.func("sulcus_store_init", "int", ["str", "uint16"]);
419
- this.fn_store_query = this.storeLib.func("sulcus_store_query", "char*", ["str"]);
420
- this.fn_store_free = this.storeLib.func("sulcus_store_free_string", "void", ["char*"]);
421
- } catch (e: any) {
422
- this.error = `Failed to load libsulcus_store: ${e.message}`;
356
+ const k = this.koffi as any;
357
+ this.storeLib = k.load(this.storeLibPath);
358
+ this.fn_store_init = (this.storeLib as any).func("sulcus_store_init", "int", ["str", "uint16"]);
359
+ this.fn_store_query = (this.storeLib as any).func("sulcus_store_query", "char*", ["str"]);
360
+ this.fn_store_free = (this.storeLib as any).func("sulcus_store_free_string", "void", ["char*"]);
361
+ } catch (e: unknown) {
362
+ this.error = `Failed to load libsulcus_store: ${e instanceof Error ? e.message : e}`;
423
363
  logger.warn(`sulcus: ${this.error}`);
424
364
  return;
425
365
  }
426
366
 
427
367
  try {
428
- this.vectorsLib = this.koffi.load(this.vectorsLibPath);
429
- this.fn_vectors_create = this.vectorsLib.func("sulcus_vectors_create", "void*", []);
430
- this.fn_vectors_text = this.vectorsLib.func("sulcus_vectors_text", "char*", ["void*", "str"]);
431
- this.fn_vectors_free = this.vectorsLib.func("sulcus_vectors_free_string", "void", ["char*"]);
432
- } catch (e: any) {
433
- this.error = `Failed to load libsulcus_vectors: ${e.message}`;
368
+ const k = this.koffi as any;
369
+ this.vectorsLib = k.load(this.vectorsLibPath);
370
+ this.fn_vectors_create = (this.vectorsLib as any).func("sulcus_vectors_create", "void*", []);
371
+ this.fn_vectors_text = (this.vectorsLib as any).func("sulcus_vectors_text", "char*", ["void*", "str"]);
372
+ this.fn_vectors_free = (this.vectorsLib as any).func("sulcus_vectors_free_string", "void", ["char*"]);
373
+ } catch (e: unknown) {
374
+ this.error = `Failed to load libsulcus_vectors: ${e instanceof Error ? e.message : e}`;
434
375
  logger.warn(`sulcus: ${this.error}`);
435
376
  return;
436
377
  }
437
378
 
438
- // ── Initialise embedded PG store ──
439
379
  try {
440
380
  const dataDir = resolve(process.env.HOME || "~", ".sulcus/data");
441
- const port = 15432;
442
- const rc = this.fn_store_init(dataDir, port);
381
+ const rc = this.fn_store_init(dataDir, 15432);
443
382
  if (rc !== 0) {
444
383
  this.error = `sulcus_store_init returned ${rc}`;
445
384
  logger.warn(`sulcus: ${this.error}`);
446
385
  return;
447
386
  }
448
- } catch (e: any) {
449
- this.error = `sulcus_store_init failed: ${e.message}`;
387
+ } catch (e: unknown) {
388
+ this.error = `sulcus_store_init failed: ${e instanceof Error ? e.message : e}`;
450
389
  logger.warn(`sulcus: ${this.error}`);
451
390
  return;
452
391
  }
453
392
 
454
- // ── Create embed handle ──
455
393
  try {
456
394
  this.vectorsHandle = this.fn_vectors_create();
457
- } catch (e: any) {
458
- this.error = `sulcus_vectors_create failed: ${e.message}`;
395
+ } catch (e: unknown) {
396
+ this.error = `sulcus_vectors_create failed: ${e instanceof Error ? e.message : e}`;
459
397
  logger.warn(`sulcus: ${this.error}`);
460
398
  return;
461
399
  }
@@ -464,29 +402,17 @@ class NativeLibLoader {
464
402
  logger.info(`sulcus: native libs loaded (store: ${this.storeLibPath}, vectors: ${this.vectorsLibPath})`);
465
403
  }
466
404
 
467
- // queryFn: async (sql: string, params: any[]) => any[]
468
- // Calls sulcus_store_query which accepts plain SQL (params inlined by caller or
469
- // passed as a JSON payload — the store lib handles parameterisation internally).
470
- makeQueryFn(): (sql: string, params: any[]) => Promise<any[]> {
471
- return async (sql: string, _params: any[]): Promise<any[]> => {
405
+ makeQueryFn(): (sql: string, params: unknown[]) => Promise<unknown[]> {
406
+ return async (sql: string, params: unknown[]): Promise<unknown[]> => {
472
407
  if (!this.loaded) throw new Error("Sulcus store not available");
473
- // The store lib's query function takes a single JSON-encoded request
474
- const payload = JSON.stringify({ sql, params: _params });
475
- const raw: string = this.fn_store_query(payload);
408
+ const raw: string = this.fn_store_query(JSON.stringify({ sql, params }));
476
409
  if (!raw) return [];
477
- try {
478
- const parsed = JSON.parse(raw);
479
- return Array.isArray(parsed) ? parsed : (parsed?.rows ?? [parsed]);
480
- } finally {
481
- // NOTE: koffi manages string memory automatically for char* returns;
482
- // no manual free needed with koffi's default charset handling.
483
- // If the ABI requires explicit free, call fn_store_free here.
484
- }
410
+ const parsed = JSON.parse(raw);
411
+ const p = parsed as Record<string, unknown>;
412
+ return Array.isArray(parsed) ? (parsed as unknown[]) : ((Array.isArray(p?.rows) ? p.rows as unknown[] : [parsed as unknown]));
485
413
  };
486
414
  }
487
415
 
488
- // embedFn: async (text: string) => Float32Array
489
- // Calls sulcus_vectors_text which returns a JSON float array string.
490
416
  makeEmbedFn(): (text: string) => Promise<Float32Array> {
491
417
  return async (text: string): Promise<Float32Array> => {
492
418
  if (!this.loaded) throw new Error("Sulcus vectors not available");
@@ -499,9 +425,8 @@ class NativeLibLoader {
499
425
  }
500
426
 
501
427
  // ─── PRE-SEND FILTER ─────────────────────────────────────────────────────────
502
- // Rule-based junk filter. Catches obvious noise before it hits the API.
503
428
 
504
- const JUNK_PATTERNS = [
429
+ const JUNK_PATTERNS: RegExp[] = [
505
430
  /^(HEARTBEAT_OK|NO_REPLY|NOOP)$/i,
506
431
  /^\s*$/,
507
432
  /^system:\s/i,
@@ -514,18 +439,15 @@ const JUNK_PATTERNS = [
514
439
  /^<<<EXTERNAL_UNTRUSTED_CONTENT/i,
515
440
  /^Runtime:/i,
516
441
  /tool_call|function_call|<function_calls>/i,
517
- // Subagent completion events — internal runtime artifacts, not memories
518
442
  /\[Inter-session message\]\s*sourceSession=/i,
519
443
  /<<<BEGIN_UNTRUSTED_CHILD_RESULT>>>/,
520
444
  /<<<END_UNTRUSTED_CHILD_RESULT>>>/,
521
445
  /\[Internal task completion event\]/i,
522
446
  /^source:\s*subagent/im,
523
447
  /session_key:\s*agent:main:subagent:/i,
524
- // Cron task payloads — system prompts, not meaningful content
525
448
  /^Sulcus validation cycle\./i,
526
449
  /^Heartbeat prompt:/i,
527
450
  /OpenClaw runtime context \(internal\)/i,
528
- // Credential patterns — should never be stored
529
451
  /\b(sk-[a-f0-9]{40,}|Bearer\s+[A-Za-z0-9._~+/=-]{20,})\b/,
530
452
  /\b(api[_-]?key|secret|password|token)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}/i,
531
453
  ];
@@ -541,19 +463,13 @@ function isJunkMemory(text: string): boolean {
541
463
 
542
464
  // ─── HOOKS CONFIG LOADER ─────────────────────────────────────────────────────
543
465
 
544
- /**
545
- * Load and merge hooks config.
546
- * Precedence: user config (api.config.hooks/tools) > defaults from hooks.defaults.json
547
- * Legacy `autoRecall` flag maps to hooks.before_agent_start.enabled for backward compat.
548
- */
549
- function loadHooksConfig(apiConfig: any): HooksConfig {
550
- // Load defaults
466
+ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
551
467
  const defaultsPath = resolve(__dirname, "hooks.defaults.json");
552
468
  let defaults: HooksConfig;
553
469
  try {
470
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
554
471
  defaults = JSON.parse(require("fs").readFileSync(defaultsPath, "utf-8")) as HooksConfig;
555
472
  } catch (_e) {
556
- // Fallback inline defaults if file is missing (safety net)
557
473
  defaults = {
558
474
  version: 1,
559
475
  hooks: {
@@ -573,9 +489,8 @@ function loadHooksConfig(apiConfig: any): HooksConfig {
573
489
  };
574
490
  }
575
491
 
576
- // Deep-merge user hook overrides (per-hook object merge, not replace)
577
- const userHooks: Record<string, Partial<HookConfig>> = apiConfig?.hooks ?? {};
578
- const userTools: Record<string, Partial<ToolConfig>> = apiConfig?.tools ?? {};
492
+ const userHooks = (apiConfig?.hooks ?? {}) as Record<string, Partial<HookConfig>>;
493
+ const userTools = (apiConfig?.tools ?? {}) as Record<string, Partial<ToolConfig>>;
579
494
 
580
495
  const mergedHooks: Record<string, HookConfig> = { ...defaults.hooks };
581
496
  for (const [name, override] of Object.entries(userHooks)) {
@@ -587,7 +502,7 @@ function loadHooksConfig(apiConfig: any): HooksConfig {
587
502
  mergedTools[name] = { ...(mergedTools[name] ?? { enabled: false }), ...override };
588
503
  }
589
504
 
590
- // ── Legacy compat: autoRecall flag → hooks.before_agent_start.enabled ──
505
+ // Legacy compat: autoRecall flag → hooks.before_agent_start.enabled
591
506
  if (apiConfig?.autoRecall === true) {
592
507
  mergedHooks["before_agent_start"] = {
593
508
  ...(mergedHooks["before_agent_start"] ?? { action: "auto_recall", enabled: false }),
@@ -598,26 +513,206 @@ function loadHooksConfig(apiConfig: any): HooksConfig {
598
513
  return { version: defaults.version, hooks: mergedHooks, tools: mergedTools };
599
514
  }
600
515
 
601
- // ─── TOOL DEFINITIONS ────────────────────────────────────────────────────────
516
+ // ─── RELATIVE TIME FORMATTER ─────────────────────────────────────────────────
602
517
 
603
- interface ToolDefinition {
604
- schema: any;
605
- options: { name: string };
606
- makeExecute: (deps: ToolDeps) => (id: string, params: any) => Promise<any>;
518
+ function formatRelativeTime(isoTimestamp: string): string {
519
+ try {
520
+ const dt = new Date(isoTimestamp);
521
+ const now = new Date();
522
+ const seconds = (now.getTime() - dt.getTime()) / 1000;
523
+ const minutes = seconds / 60;
524
+ const hours = seconds / 3600;
525
+ const days = seconds / 86400;
526
+ if (minutes < 2) return "just now";
527
+ if (minutes < 60) return `${Math.floor(minutes)}m ago`;
528
+ if (hours < 24) return `${Math.floor(hours)}h ago`;
529
+ if (days < 7) return `${Math.floor(days)}d ago`;
530
+ const month = dt.toLocaleString("en", { month: "short" });
531
+ if (dt.getFullYear() === now.getFullYear()) return `${dt.getDate()} ${month}`;
532
+ return `${dt.getDate()} ${month}, ${dt.getFullYear()}`;
533
+ } catch {
534
+ return "";
535
+ }
607
536
  }
608
537
 
538
+ // ─── SDK RECALL HANDLER (for before_agent_start with prependContext) ──────────
539
+
540
+ interface ProfileCache {
541
+ preferences: Record<string, unknown>[];
542
+ facts: Record<string, unknown>[];
543
+ cachedAt: number;
544
+ }
545
+
546
+ function buildSdkRecallHandler(
547
+ sulcusMem: SulcusCloudClient,
548
+ namespace: string,
549
+ maxResults: number,
550
+ profileFrequency: number,
551
+ logger: PluginLogger
552
+ ) {
553
+ let turnCount = 0;
554
+ let profileCache: ProfileCache | null = null;
555
+
556
+ return async (event: Record<string, unknown>, _ctx: unknown): Promise<{ prependContext: string } | undefined> => {
557
+ const prompt = typeof event?.prompt === "string" ? event.prompt : "";
558
+ if (!prompt || prompt.length < 5) return undefined;
559
+
560
+ turnCount++;
561
+ const includeProfile = turnCount === 1 || turnCount % profileFrequency === 0;
562
+
563
+ try {
564
+ const searchRes = await sulcusMem.search_memory(prompt, maxResults, namespace);
565
+ const searchResults = searchRes?.results ?? [];
566
+
567
+ let preferences: Record<string, unknown>[] = [];
568
+ let facts: Record<string, unknown>[] = [];
569
+
570
+ if (includeProfile) {
571
+ try {
572
+ const prefRes = await sulcusMem.search_memory("user preference", Math.min(maxResults, 5), namespace);
573
+ const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(maxResults, 5), namespace);
574
+ preferences = (prefRes?.results ?? []).filter((r) => r.memory_type === "preference");
575
+ facts = (factRes?.results ?? []).filter((r) => r.memory_type === "fact");
576
+ profileCache = { preferences, facts, cachedAt: Date.now() };
577
+ } catch {
578
+ // profile fetch failed — continue without
579
+ }
580
+ } else if (profileCache) {
581
+ preferences = profileCache.preferences;
582
+ facts = profileCache.facts;
583
+ }
584
+
585
+ const profileIds = new Set([
586
+ ...preferences.map((r) => r.id as string),
587
+ ...facts.map((r) => r.id as string),
588
+ ]);
589
+ const dedupedSearch = searchResults.filter((r) => !profileIds.has(r.id as string));
590
+
591
+ const sections: string[] = [];
592
+
593
+ if (includeProfile && (preferences.length > 0 || facts.length > 0)) {
594
+ const profileLines: string[] = [];
595
+ for (const r of preferences) {
596
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
597
+ profileLines.push(`- [preference] ${label}`);
598
+ }
599
+ for (const r of facts) {
600
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
601
+ profileLines.push(`- [fact] ${label}`);
602
+ }
603
+ if (profileLines.length > 0) {
604
+ sections.push(`## User Profile (from preferences + facts)\n${profileLines.join("\n")}`);
605
+ }
606
+ }
607
+
608
+ if (dedupedSearch.length > 0) {
609
+ const memLines = dedupedSearch.slice(0, maxResults).map((r) => {
610
+ const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0);
611
+ const pct = `[${Math.round(heat * 100)}%]`;
612
+ const updatedAt = r.updated_at as string | undefined;
613
+ const timeStr = updatedAt ? `[${formatRelativeTime(updatedAt)}]` : "";
614
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
615
+ return `- ${pct} ${timeStr} ${label}`.trim();
616
+ });
617
+ sections.push(`## Relevant Memories (with relevance %)\n${memLines.join("\n")}`);
618
+ }
619
+
620
+ if (sections.length === 0) return undefined;
621
+
622
+ const intro =
623
+ "The following is background context from long-term memory. Use it silently to inform your understanding — only reference it when the conversation naturally calls for it.";
624
+ const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${intro}\n\n${sections.join("\n\n")}\n</sulcus_context>`;
625
+
626
+ logger.info(`sulcus: SDK recall injecting context (${context.length} chars, turn ${turnCount})`);
627
+ return { prependContext: context };
628
+ } catch (err) {
629
+ logger.warn(`sulcus: SDK recall failed: ${err}`);
630
+ return undefined;
631
+ }
632
+ };
633
+ }
634
+
635
+ // ─── MEMORY RUNTIME BUILDER ───────────────────────────────────────────────────
636
+
637
+ function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
638
+ const searchManager = {
639
+ status() {
640
+ return {
641
+ backend: "builtin" as const,
642
+ provider: "sulcus",
643
+ model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus-local",
644
+ custom: { backendMode, transport: backendMode === "cloud" ? "remote" : "local" },
645
+ };
646
+ },
647
+ async probeEmbeddingAvailability() {
648
+ try {
649
+ const ok = await sulcusMem.probe();
650
+ return { ok };
651
+ } catch (err) {
652
+ return { ok: false, error: err instanceof Error ? err.message : "sulcus unreachable" };
653
+ }
654
+ },
655
+ async probeVectorAvailability() { return true; },
656
+ async sync() { /* cloud sync is continuous */ },
657
+ async close() { /* no-op for HTTP client */ },
658
+ };
659
+
660
+ return {
661
+ async getMemorySearchManager() { return { manager: searchManager }; },
662
+ resolveMemoryBackendConfig() { return { backend: "builtin" as const }; },
663
+ async closeAllMemorySearchManagers() { /* no-op */ },
664
+ };
665
+ }
666
+
667
+ // ─── PROMPT SECTION BUILDER ───────────────────────────────────────────────────
668
+
669
+ function buildPromptSection(params: { availableTools: Set<string> }): string[] {
670
+ const hasRecall = params.availableTools.has("memory_recall");
671
+ const hasStore = params.availableTools.has("memory_store");
672
+ if (!hasRecall && !hasStore) return [];
673
+
674
+ const lines: string[] = [
675
+ "## Memory (Sulcus)",
676
+ "",
677
+ "You have persistent thermodynamic memory powered by Sulcus.",
678
+ "Relevant memories are automatically injected at the start of each conversation.",
679
+ "",
680
+ ];
681
+
682
+ if (hasRecall) lines.push("- Use `memory_recall` to search prior conversations, preferences, and facts.");
683
+ if (hasStore) lines.push("- Use `memory_store` to save information the user asks you to remember.");
684
+ if (params.availableTools.has("memory_delete")) lines.push("- Use `memory_delete` to remove incorrect or stale memories.");
685
+ if (params.availableTools.has("memory_status")) lines.push("- Use `memory_status` to check backend connection and hot nodes.");
686
+ if (params.availableTools.has("consolidate")) lines.push("- Use `consolidate` to prune cold memories below a heat threshold.");
687
+ if (params.availableTools.has("export_markdown")) lines.push("- Use `export_markdown` to export all memories as Markdown.");
688
+ if (params.availableTools.has("import_markdown")) lines.push("- Use `import_markdown` to import memories from a Markdown document.");
689
+ if (params.availableTools.has("evaluate_triggers")) lines.push("- Use `evaluate_triggers` to evaluate reactive memory triggers.");
690
+
691
+ lines.push("");
692
+ lines.push("Memory types: episodic (events, fast decay), semantic (knowledge, slow), preference (opinions, slower), procedural (how-tos, slowest), fact (data, slow)");
693
+
694
+ return lines;
695
+ }
696
+
697
+ // ─── TOOL DEFINITIONS ────────────────────────────────────────────────────────
698
+
609
699
  interface ToolDeps {
610
- sulcusMem: any;
700
+ sulcusMem: SulcusCloudClient | null;
611
701
  backendMode: string;
612
702
  namespace: string;
613
703
  nativeLoader: NativeLibLoader;
614
704
  storeLibPath: string;
615
705
  vectorsLibPath: string;
616
706
  wasmDir: string;
617
- logger: any;
707
+ logger: PluginLogger;
618
708
  isAvailable: boolean;
619
- /** HTTP request helper for SIU v2 endpoints null when cloud backend is not configured. */
620
- siuRequest: ((method: string, path: string, body?: any) => Promise<any>) | null;
709
+ siuRequest: ((method: string, path: string, body?: unknown) => Promise<unknown>) | null;
710
+ }
711
+
712
+ interface ToolDefinition {
713
+ schema: Record<string, unknown>;
714
+ options: { name: string };
715
+ makeExecute: (deps: ToolDeps) => (id: string, params: Record<string, unknown>) => Promise<{ content: { type: string; text: string }[]; details?: Record<string, unknown> }>;
621
716
  }
622
717
 
623
718
  const toolDefinitions: Record<string, ToolDefinition> = {
@@ -629,23 +724,19 @@ const toolDefinitions: Record<string, ToolDefinition> = {
629
724
  parameters: Type.Object({
630
725
  query: Type.String({ description: "Search query string." }),
631
726
  limit: Type.Optional(Type.Number({ default: 5, description: "Maximum number of results to return (1-10)." })),
632
- namespace: Type.Optional(Type.String({ description: "Namespace to search. Defaults to your own namespace. Specify another to cross-search (ACL enforced)." }))
727
+ namespace: Type.Optional(Type.String({ description: "Namespace to search. Defaults to your own namespace." })),
633
728
  }),
634
729
  },
635
730
  options: { name: "memory_recall" },
636
731
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
637
- async (_id: string, params: any) => {
638
- if (!isAvailable) {
639
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
640
- }
641
- // Default to agent's own namespace to prevent cross-namespace bleed.
642
- // Agent can explicitly pass namespace to search another (ACL enforced server-side).
643
- const searchNamespace = params.namespace ?? namespace;
644
- const res = await sulcusMem.search_memory(params.query, params.limit ?? 5, searchNamespace);
645
- const results = res?.results ?? res?.items ?? res?.nodes ?? res ?? [];
732
+ async (_id, params) => {
733
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
734
+ const searchNamespace = (params.namespace as string | undefined) ?? namespace;
735
+ const res = await sulcusMem.search_memory(params.query as string, (params.limit as number | undefined) ?? 5, searchNamespace);
736
+ const results = res?.results ?? [];
646
737
  return {
647
738
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
648
- details: { results, backend: backendMode, namespace: searchNamespace }
739
+ details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace },
649
740
  };
650
741
  },
651
742
  },
@@ -658,54 +749,41 @@ const toolDefinitions: Record<string, ToolDefinition> = {
658
749
  parameters: Type.Object({
659
750
  content: Type.String({ description: "Memory content. Supports Markdown formatting for structured content." }),
660
751
  memory_type: Type.Optional(Type.Union([
661
- Type.Literal("episodic"),
662
- Type.Literal("semantic"),
663
- Type.Literal("preference"),
664
- Type.Literal("procedural"),
665
- Type.Literal("fact")
666
- ], { description: "Memory type. preference=user preferences, procedural=how-to/processes, fact=stable knowledge, semantic=concepts/relationships, episodic=events/experiences. Default: episodic" })),
667
- train: Type.Optional(Type.Boolean({ description: "Signal the SIU to learn from this manual store. When true, this memory+type becomes a positive training example for both SIVU (store=yes) and SICU (type=<memory_type>). Default: false" })),
752
+ Type.Literal("episodic"), Type.Literal("semantic"), Type.Literal("preference"),
753
+ Type.Literal("procedural"), Type.Literal("fact"),
754
+ ], { description: "Memory type. Default: episodic" })),
755
+ train: Type.Optional(Type.Boolean({ description: "Signal the SIU to learn from this manual store. Default: false" })),
668
756
  }),
669
757
  },
670
758
  options: { name: "memory_store" },
671
759
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
672
- async (_id: string, params: any) => {
673
- // Pre-send junk filter
674
- if (isJunkMemory(params.content)) {
675
- logger.debug(`sulcus: filtered junk memory: "${(params.content || "").substring(0, 50)}..."`);
676
- return {
677
- content: [{ type: "text", text: `Filtered: content looks like system noise, not a meaningful memory.` }],
678
- details: { filtered: true, reason: "junk_pattern" }
679
- };
760
+ async (_id, params) => {
761
+ const content = params.content as string;
762
+ if (isJunkMemory(content)) {
763
+ logger.debug?.(`sulcus: filtered junk memory: "${content.substring(0, 50)}..."`);
764
+ return { content: [{ type: "text", text: "Filtered: content looks like system noise, not a meaningful memory." }], details: { filtered: true } };
680
765
  }
681
- if (!isAvailable) {
682
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
683
- }
684
- const res = await sulcusMem.add_memory(params.content, params.memory_type ?? null);
766
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
767
+ const mtype = (params.memory_type as string | undefined) || "episodic";
768
+ const res = await sulcusMem.add_memory(content, mtype);
685
769
  const nodeId = res?.id ?? "unknown";
686
- const mtype = params.memory_type || "episodic";
687
- // If train=true, submit a training signal to the SIU
688
770
  let trainResult: string | null = null;
689
- if (params.train && (sulcusMem as any).request) {
771
+ if (params.train === true) {
690
772
  try {
691
- await (sulcusMem as any).request("POST", "/api/v2/siu/signal", {
692
- memory_id: nodeId,
693
- signal_type: "accept",
694
- corrected_store: true,
695
- corrected_type: mtype,
696
- content_snapshot: params.content,
697
- source: "plugin",
773
+ await sulcusMem.request("POST", "/api/v2/siu/signal", {
774
+ memory_id: nodeId, signal_type: "accept", corrected_store: true,
775
+ corrected_type: mtype, content_snapshot: content, source: "plugin",
698
776
  });
699
777
  trainResult = "training signal submitted";
700
778
  logger.info(`sulcus: SIU training signal sent for memory ${nodeId} (store, ${mtype})`);
701
- } catch (e: any) {
702
- trainResult = `training signal failed: ${e.message}`;
703
- logger.warn(`sulcus: SIU training signal failed: ${e.message}`);
779
+ } catch (e: unknown) {
780
+ trainResult = `training signal failed: ${e instanceof Error ? e.message : e}`;
781
+ logger.warn(`sulcus: SIU training signal failed: ${trainResult}`);
704
782
  }
705
783
  }
706
784
  return {
707
785
  content: [{ type: "text", text: `Stored [${mtype}] memory (id: ${nodeId}) → backend: ${backendMode}, namespace: ${namespace}${trainResult ? ` | SIU: ${trainResult}` : ""}` }],
708
- details: { id: nodeId, memory_type: mtype, backend: backendMode, namespace, train: trainResult, ...res }
786
+ details: { ...res, id: nodeId, memory_type: mtype, backend: backendMode, namespace, train: trainResult as unknown as Record<string, unknown> },
709
787
  };
710
788
  },
711
789
  },
@@ -719,75 +797,40 @@ const toolDefinitions: Record<string, ToolDefinition> = {
719
797
  },
720
798
  options: { name: "memory_status" },
721
799
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, storeLibPath, vectorsLibPath, wasmDir, isAvailable }) =>
722
- async (_id: string, _params: any) => {
723
- if (!isAvailable) {
724
- return {
725
- content: [{ type: "text", text: JSON.stringify({
726
- status: "unavailable",
727
- backend: backendMode,
728
- namespace,
729
- error: nativeLoader.error || "WASM not loaded",
730
- storeLib: storeLibPath,
731
- vectorsLib: vectorsLibPath,
732
- wasmDir,
733
- }, null, 2) }],
734
- };
800
+ async (_id, _params) => {
801
+ if (!isAvailable || !sulcusMem) {
802
+ return { content: [{ type: "text", text: JSON.stringify({ status: "unavailable", backend: backendMode, namespace, error: nativeLoader.error || "not loaded", storeLib: storeLibPath, vectorsLib: vectorsLibPath, wasmDir }, null, 2) }] };
735
803
  }
736
804
  try {
737
- // Fetch both status info and hot nodes in parallel
738
805
  const [statusInfo, hotNodes] = await Promise.all([
739
806
  sulcusMem.request("GET", "/api/v1/agent/memory/status").catch(() => null),
740
807
  sulcusMem.list_hot_nodes(20),
741
808
  ]);
742
- const nodeList = hotNodes?.nodes ?? hotNodes ?? [];
743
- const count = Array.isArray(nodeList) ? nodeList.length : 0;
744
- return {
745
- content: [{ type: "text", text: JSON.stringify({
746
- status: "ok",
747
- backend: backendMode,
748
- namespace,
749
- ...(statusInfo?.capabilities ? { capabilities: statusInfo.capabilities } : {}),
750
- ...(statusInfo?.stats ? { stats: statusInfo.stats } : {}),
751
- hot_node_count: count,
752
- hot_nodes: nodeList,
753
- }, null, 2) }],
754
- details: { status: "ok", backend: backendMode, namespace, count, ...(statusInfo?.stats ?? {}) }
755
- };
756
- } catch (e: any) {
809
+ const nodeList = hotNodes?.nodes ?? [];
810
+ const si = statusInfo as Record<string, unknown> | null;
757
811
  return {
758
- content: [{ type: "text", text: JSON.stringify({
759
- status: "error",
760
- backend: backendMode,
761
- namespace,
762
- error: e.message,
763
- }, null, 2) }],
812
+ content: [{ type: "text", text: JSON.stringify({ status: "ok", backend: backendMode, namespace, ...(si?.capabilities ? { capabilities: si.capabilities } : {}), ...(si?.stats ? { stats: si.stats } : {}), hot_node_count: nodeList.length, hot_nodes: nodeList }, null, 2) }],
813
+ details: { status: "ok", backend: backendMode, namespace, count: nodeList.length },
764
814
  };
815
+ } catch (e: unknown) {
816
+ return { content: [{ type: "text", text: JSON.stringify({ status: "error", backend: backendMode, namespace, error: e instanceof Error ? e.message : String(e) }, null, 2) }] };
765
817
  }
766
818
  },
767
819
  },
768
820
 
769
- // ── New WASM-capability tools (disabled by default) ──
770
-
771
821
  consolidate: {
772
822
  schema: {
773
823
  name: "consolidate",
774
824
  label: "Memory Consolidate",
775
825
  description: "Consolidate cold memories: merges, prunes, or archives nodes below the given heat threshold.",
776
- parameters: Type.Object({
777
- min_heat: Type.Optional(Type.Number({ default: 0.1, description: "Nodes with heat below this value are candidates for consolidation (0.0–1.0)." }))
778
- }),
826
+ parameters: Type.Object({ min_heat: Type.Optional(Type.Number({ default: 0.1, description: "Heat threshold (0.0–1.0)." })) }),
779
827
  },
780
828
  options: { name: "consolidate" },
781
829
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
782
- async (_id: string, params: any) => {
783
- if (!isAvailable) {
784
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
785
- }
786
- const res = await sulcusMem.consolidate(params.min_heat ?? 0.1);
787
- return {
788
- content: [{ type: "text", text: JSON.stringify({ result: res, backend: backendMode, namespace }, null, 2) }],
789
- details: { result: res, backend: backendMode, namespace }
790
- };
830
+ async (_id, params) => {
831
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
832
+ const res = await sulcusMem.consolidate((params.min_heat as number | undefined) ?? 0.1);
833
+ return { content: [{ type: "text", text: JSON.stringify({ result: res, backend: backendMode, namespace }, null, 2) }], details: { result: res as Record<string, unknown>, backend: backendMode, namespace } };
791
834
  },
792
835
  },
793
836
 
@@ -800,15 +843,10 @@ const toolDefinitions: Record<string, ToolDefinition> = {
800
843
  },
801
844
  options: { name: "export_markdown" },
802
845
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
803
- async (_id: string, _params: any) => {
804
- if (!isAvailable) {
805
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
806
- }
807
- const markdown: string = await sulcusMem.export_markdown();
808
- return {
809
- content: [{ type: "text", text: markdown }],
810
- details: { backend: backendMode, namespace, length: markdown.length }
811
- };
846
+ async (_id, _params) => {
847
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
848
+ const markdown = await sulcusMem.export_markdown();
849
+ return { content: [{ type: "text", text: markdown }], details: { backend: backendMode, namespace, length: markdown.length } };
812
850
  },
813
851
  },
814
852
 
@@ -817,21 +855,14 @@ const toolDefinitions: Record<string, ToolDefinition> = {
817
855
  name: "import_markdown",
818
856
  label: "Import Memory (Markdown)",
819
857
  description: "Import memories from a Markdown document into the current namespace.",
820
- parameters: Type.Object({
821
- text: Type.String({ description: "Markdown content to import into Sulcus memory." })
822
- }),
858
+ parameters: Type.Object({ text: Type.String({ description: "Markdown content to import." }) }),
823
859
  },
824
860
  options: { name: "import_markdown" },
825
861
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
826
- async (_id: string, params: any) => {
827
- if (!isAvailable) {
828
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
829
- }
830
- const res = await sulcusMem.import_markdown(params.text);
831
- return {
832
- content: [{ type: "text", text: JSON.stringify({ result: res, backend: backendMode, namespace }, null, 2) }],
833
- details: { result: res, backend: backendMode, namespace }
834
- };
862
+ async (_id, params) => {
863
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
864
+ const res = await sulcusMem.import_markdown(params.text as string);
865
+ return { content: [{ type: "text", text: JSON.stringify({ result: res, backend: backendMode, namespace }, null, 2) }], details: { result: res as Record<string, unknown>, backend: backendMode, namespace } };
835
866
  },
836
867
  },
837
868
 
@@ -842,20 +873,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
842
873
  description: "Evaluate reactive memory triggers against an event and context.",
843
874
  parameters: Type.Object({
844
875
  event: Type.String({ description: "Event name to evaluate triggers against (e.g. 'agent_end', 'user_message')." }),
845
- context_json: Type.Optional(Type.String({ description: "JSON string of additional context to pass to trigger evaluation." }))
876
+ context_json: Type.Optional(Type.String({ description: "JSON string of additional context." })),
846
877
  }),
847
878
  },
848
879
  options: { name: "evaluate_triggers" },
849
880
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
850
- async (_id: string, params: any) => {
851
- if (!isAvailable) {
852
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
853
- }
854
- const res = await sulcusMem.evaluate_triggers(params.event, params.context_json ?? "{}");
855
- return {
856
- content: [{ type: "text", text: JSON.stringify({ result: res, backend: backendMode, namespace }, null, 2) }],
857
- details: { result: res, backend: backendMode, namespace }
858
- };
881
+ async (_id, params) => {
882
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
883
+ const res = await sulcusMem.evaluate_triggers(params.event, (params.context_json as string | undefined) ?? "{}");
884
+ return { content: [{ type: "text", text: JSON.stringify({ result: res, backend: backendMode, namespace }, null, 2) }], details: { result: res as Record<string, unknown>, backend: backendMode, namespace } };
859
885
  },
860
886
  },
861
887
 
@@ -863,65 +889,46 @@ const toolDefinitions: Record<string, ToolDefinition> = {
863
889
  schema: {
864
890
  name: "memory_delete",
865
891
  label: "Delete Memory",
866
- description: "Delete a memory node by ID. With train=true (default), the deleted content trains SIVU to reject similar content in the future. Use this to clean up junk, duplicates, or noise memories.",
892
+ description: "Delete a memory node by ID. With train=true (default), trains SIVU to reject similar content.",
867
893
  parameters: Type.Object({
868
894
  id: Type.String({ description: "Memory node ID to delete." }),
869
- train: Type.Optional(Type.Boolean({ default: true, description: "Train SIVU to reject similar content (default true). Set false to delete without training." })),
895
+ train: Type.Optional(Type.Boolean({ default: true, description: "Train SIVU to reject similar content (default true)." })),
870
896
  }),
871
897
  },
872
898
  options: { name: "memory_delete" },
873
899
  makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
874
- async (_id: string, params: any) => {
875
- if (!isAvailable) {
876
- throw new Error(`Sulcus unavailable: ${nativeLoader.error || "WASM not loaded"}`);
877
- }
878
- const train = params.train !== false; // default true
879
- const res = await (sulcusMem as SulcusCloudClient).delete_memory(params.id, train);
900
+ async (_id, params) => {
901
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
902
+ const train = params.train !== false;
903
+ const res = await sulcusMem.delete_memory(params.id as string, train);
880
904
  return {
881
- content: [{ type: "text", text: `Deleted memory ${params.id}${train ? " (trained SIVU to reject similar)" : ""}. Backend: ${backendMode}, namespace: ${namespace}` }],
882
- details: { id: params.id, trained: train, result: res, backend: backendMode, namespace }
905
+ content: [{ type: "text", text: `Deleted memory ${params.id as string}${train ? " (trained SIVU to reject similar)" : ""}. Backend: ${backendMode}, namespace: ${namespace}` }],
906
+ details: { id: params.id as string, trained: train, result: res as Record<string, unknown>, backend: backendMode, namespace },
883
907
  };
884
908
  },
885
909
  },
886
910
 
887
- // ── SIU v2 Tools ───────────────────────────────────────────────────────────
888
- // These tools call the SIU v2 server endpoints for text classification.
889
- // Requires cloud backend (serverUrl + apiKey). Uses /api/v2/siu/* endpoints.
890
-
891
911
  siu_label: {
892
912
  schema: {
893
913
  name: "siu_label",
894
914
  label: "SIU Label",
895
- description: "Classify text using SIU v2 — returns SIVU store/reject decision and SICU memory type classification with confidence scores.",
915
+ description: "Classify text using SIU v2 — returns SIVU store/reject decision and SICU memory type classification.",
896
916
  parameters: Type.Object({
897
917
  text: Type.String({ description: "Text to classify." }),
898
918
  classify_only: Type.Optional(Type.Boolean({ description: "Skip SIVU quality gate, only run SICU type classification." })),
899
919
  }),
900
920
  },
901
921
  options: { name: "siu_label" },
902
- makeExecute: ({ backendMode, siuRequest, logger }) =>
903
- async (_id: string, params: any) => {
904
- if (!siuRequest) {
905
- return {
906
- content: [{ type: "text", text: "SIU label requires cloud backend (serverUrl + apiKey)." }],
907
- details: { error: "cloud_required" },
908
- };
909
- }
922
+ makeExecute: ({ siuRequest, logger }) =>
923
+ async (_id, params) => {
924
+ if (!siuRequest) return { content: [{ type: "text", text: "SIU label requires cloud backend (serverUrl + apiKey)." }] };
910
925
  try {
911
- const res = await siuRequest("POST", "/api/v2/siu/label", {
912
- text: params.text,
913
- classify_only: params.classify_only ?? false,
914
- });
915
- return {
916
- content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
917
- details: res,
918
- };
919
- } catch (e: any) {
920
- logger.warn(`sulcus: siu_label failed: ${e.message}`);
921
- return {
922
- content: [{ type: "text", text: `SIU label failed: ${e.message}` }],
923
- details: { error: e.message },
924
- };
926
+ const res = await siuRequest("POST", "/api/v2/siu/label", { text: params.text as string, classify_only: params.classify_only ?? false });
927
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
928
+ } catch (e: unknown) {
929
+ const msg = e instanceof Error ? e.message : String(e);
930
+ logger.warn(`sulcus: siu_label failed: ${msg}`);
931
+ return { content: [{ type: "text", text: `SIU label failed: ${msg}` }] };
925
932
  }
926
933
  },
927
934
  },
@@ -935,25 +942,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
935
942
  },
936
943
  options: { name: "siu_status" },
937
944
  makeExecute: ({ siuRequest, logger }) =>
938
- async (_id: string, _params: any) => {
939
- if (!siuRequest) {
940
- return {
941
- content: [{ type: "text", text: "SIU status requires cloud backend (serverUrl + apiKey)." }],
942
- details: { error: "cloud_required" },
943
- };
944
- }
945
+ async (_id, _params) => {
946
+ if (!siuRequest) return { content: [{ type: "text", text: "SIU status requires cloud backend." }] };
945
947
  try {
946
948
  const res = await siuRequest("GET", "/api/v2/siu/status");
947
- return {
948
- content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
949
- details: res,
950
- };
951
- } catch (e: any) {
952
- logger.warn(`sulcus: siu_status failed: ${e.message}`);
953
- return {
954
- content: [{ type: "text", text: `SIU status failed: ${e.message}` }],
955
- details: { error: e.message },
956
- };
949
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
950
+ } catch (e: unknown) {
951
+ const msg = e instanceof Error ? e.message : String(e);
952
+ logger.warn(`sulcus: siu_status failed: ${msg}`);
953
+ return { content: [{ type: "text", text: `SIU status failed: ${msg}` }] };
957
954
  }
958
955
  },
959
956
  },
@@ -962,44 +959,31 @@ const toolDefinitions: Record<string, ToolDefinition> = {
962
959
  schema: {
963
960
  name: "siu_retrain",
964
961
  label: "SIU Retrain",
965
- description: "Trigger an async retrain of SIU v2 models using accumulated training signals. Returns job status.",
962
+ description: "Trigger an async retrain of SIU v2 models using accumulated training signals.",
966
963
  parameters: Type.Object({}),
967
964
  },
968
965
  options: { name: "siu_retrain" },
969
966
  makeExecute: ({ siuRequest, logger }) =>
970
- async (_id: string, _params: any) => {
971
- if (!siuRequest) {
972
- return {
973
- content: [{ type: "text", text: "SIU retrain requires cloud backend (serverUrl + apiKey)." }],
974
- details: { error: "cloud_required" },
975
- };
976
- }
967
+ async (_id, _params) => {
968
+ if (!siuRequest) return { content: [{ type: "text", text: "SIU retrain requires cloud backend." }] };
977
969
  try {
978
970
  const res = await siuRequest("POST", "/api/v2/siu/retrain");
979
- return {
980
- content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
981
- details: res,
982
- };
983
- } catch (e: any) {
984
- logger.warn(`sulcus: siu_retrain failed: ${e.message}`);
985
- return {
986
- content: [{ type: "text", text: `SIU retrain failed: ${e.message}` }],
987
- details: { error: e.message },
988
- };
971
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
972
+ } catch (e: unknown) {
973
+ const msg = e instanceof Error ? e.message : String(e);
974
+ logger.warn(`sulcus: siu_retrain failed: ${msg}`);
975
+ return { content: [{ type: "text", text: `SIU retrain failed: ${msg}` }] };
989
976
  }
990
977
  },
991
978
  },
979
+
992
980
  trigger_feedback: {
993
981
  schema: {
994
982
  name: "trigger_feedback",
995
983
  label: "Trigger Feedback",
996
- description:
997
- "Record feedback on a trigger fire (for SITU training). Use to report false positives (fired but shouldn't have), false negatives (should have fired but didn't), or confirm correct fires.",
984
+ description: "Record feedback on a trigger fire (for SITU training).",
998
985
  parameters: Type.Object({
999
- feedback_type: Type.String({
1000
- description:
1001
- 'One of: "false_positive" (fired wrongly), "false_negative" (missed fire), "correct" (good fire), "wrong_action" (fired but wrong action)',
1002
- }),
986
+ feedback_type: Type.String({ description: 'One of: "false_positive", "false_negative", "correct", "wrong_action"' }),
1003
987
  trigger_id: Type.Optional(Type.String({ description: "UUID of the trigger rule" })),
1004
988
  trigger_log_id: Type.Optional(Type.String({ description: "UUID of the trigger fire log entry" })),
1005
989
  event_type: Type.Optional(Type.String({ description: "Event type: memory_created, heat_threshold, recall, etc." })),
@@ -1010,25 +994,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1010
994
  },
1011
995
  options: { name: "trigger_feedback" },
1012
996
  makeExecute: ({ siuRequest, logger }) =>
1013
- async (_id: string, params: any) => {
1014
- if (!siuRequest) {
1015
- return {
1016
- content: [{ type: "text", text: "Trigger feedback requires cloud backend (serverUrl + apiKey)." }],
1017
- details: { error: "cloud_required" },
1018
- };
1019
- }
997
+ async (_id, params) => {
998
+ if (!siuRequest) return { content: [{ type: "text", text: "Trigger feedback requires cloud backend." }] };
1020
999
  try {
1021
1000
  const res = await siuRequest("POST", "/api/v1/triggers/feedback", params);
1022
- return {
1023
- content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
1024
- details: res,
1025
- };
1026
- } catch (e: any) {
1027
- logger.warn(`sulcus: trigger_feedback failed: ${e.message}`);
1028
- return {
1029
- content: [{ type: "text", text: `Trigger feedback failed: ${e.message}` }],
1030
- details: { error: e.message },
1031
- };
1001
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
1002
+ } catch (e: unknown) {
1003
+ const msg = e instanceof Error ? e.message : String(e);
1004
+ logger.warn(`sulcus: trigger_feedback failed: ${msg}`);
1005
+ return { content: [{ type: "text", text: `Trigger feedback failed: ${msg}` }] };
1032
1006
  }
1033
1007
  },
1034
1008
  },
@@ -1042,106 +1016,106 @@ const sulcusPlugin = {
1042
1016
  description: "Sulcus-backed vMMU memory for OpenClaw — thermodynamic decay, reactive triggers, local-first",
1043
1017
  kind: "memory" as const,
1044
1018
 
1045
- register(api: any) {
1019
+ register(api: Record<string, unknown>) {
1020
+ const logger = api.logger as PluginLogger;
1021
+ const pluginConfig = (api.pluginConfig ?? {}) as Record<string, unknown>;
1022
+
1046
1023
  // ── Configuration ──
1047
- const libDir = api.pluginConfig?.libDir
1048
- ? resolve(api.pluginConfig.libDir)
1024
+ const libDir = pluginConfig?.libDir
1025
+ ? resolve(pluginConfig.libDir as string)
1049
1026
  : resolve(process.env.HOME || "~", ".sulcus/lib");
1050
1027
 
1051
- const storeLibPath = api.pluginConfig?.storeLibPath
1052
- ? resolve(api.pluginConfig.storeLibPath)
1028
+ const storeLibPath = pluginConfig?.storeLibPath
1029
+ ? resolve(pluginConfig.storeLibPath as string)
1053
1030
  : resolve(libDir, process.platform === "darwin" ? "libsulcus_store.dylib" : "libsulcus_store.so");
1054
1031
 
1055
- const vectorsLibPath = api.pluginConfig?.vectorsLibPath
1056
- ? resolve(api.pluginConfig.vectorsLibPath)
1032
+ const vectorsLibPath = pluginConfig?.vectorsLibPath
1033
+ ? resolve(pluginConfig.vectorsLibPath as string)
1057
1034
  : resolve(libDir, process.platform === "darwin" ? "libsulcus_vectors.dylib" : "libsulcus_vectors.so");
1058
1035
 
1059
- const wasmDir = api.pluginConfig?.wasmDir
1060
- ? resolve(api.pluginConfig.wasmDir)
1036
+ const wasmDir = pluginConfig?.wasmDir
1037
+ ? resolve(pluginConfig.wasmDir as string)
1061
1038
  : resolve(__dirname, "wasm");
1062
1039
 
1063
- // Cloud fallback credentials (used when local libs unavailable)
1064
- const serverUrl: string | undefined = api.pluginConfig?.serverUrl;
1065
- const apiKey: string | undefined = api.pluginConfig?.apiKey;
1040
+ const serverUrl = pluginConfig?.serverUrl as string | undefined;
1041
+ const apiKey = pluginConfig?.apiKey as string | undefined;
1066
1042
 
1067
- // Default namespace = agent name (prevents everything landing in "default")
1068
- const agentId = api.pluginConfig?.agentId;
1069
- const namespace = api.pluginConfig?.namespace === "default" && agentId
1043
+ const agentId = pluginConfig?.agentId as string | undefined;
1044
+ const namespace = pluginConfig?.namespace === "default" && agentId
1070
1045
  ? agentId
1071
- : (api.pluginConfig?.namespace || agentId || "default");
1046
+ : ((pluginConfig?.namespace as string | undefined) || agentId || "default");
1072
1047
 
1073
- // ── Load hooks config (config-driven dispatch) ──
1074
- const hooksConfig = loadHooksConfig(api.pluginConfig);
1048
+ // New config options (v4.0.0)
1049
+ const autoRecall: boolean = (pluginConfig?.autoRecall as boolean | undefined) ?? false;
1050
+ const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
1051
+ const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
1052
+ const profileFrequency: number = Math.min(500, Math.max(1, (pluginConfig?.profileFrequency as number | undefined) ?? 10));
1075
1053
 
1076
- // ── Backend init: cloud-first, then local ──
1077
- let sulcusMem: any = null;
1054
+ // ── Load hooks config ──
1055
+ const hooksConfig = loadHooksConfig(pluginConfig);
1056
+
1057
+ // ── Backend init ──
1058
+ let sulcusMem: SulcusCloudClient | null = null;
1078
1059
  let backendMode = "unavailable";
1079
1060
  const nativeLoader = new NativeLibLoader(storeLibPath, vectorsLibPath);
1080
1061
 
1081
- // Priority 1: Cloud mode — if serverUrl + apiKey are configured, use HTTP.
1082
- // No local dylibs needed. This is the path for cloud-only users.
1083
1062
  if (serverUrl && apiKey) {
1084
1063
  try {
1085
- const cloudClient = new SulcusCloudClient(serverUrl, apiKey);
1086
- sulcusMem = cloudClient;
1064
+ sulcusMem = new SulcusCloudClient(serverUrl, apiKey);
1087
1065
  backendMode = "cloud";
1088
- api.logger.info(`sulcus: using cloud backend (server: ${serverUrl})`);
1089
- } catch (e: any) {
1090
- api.logger.warn(`sulcus: cloud client init failed: ${e.message}`);
1066
+ logger.info(`sulcus: using cloud backend (server: ${serverUrl})`);
1067
+ } catch (e: unknown) {
1068
+ logger.warn(`sulcus: cloud client init failed: ${e instanceof Error ? e.message : e}`);
1091
1069
  }
1092
1070
  }
1093
1071
 
1094
- // Priority 2: Local WASM+native — if cloud isn't configured or failed,
1095
- // try loading native dylibs + WASM module for fully local operation.
1096
1072
  if (sulcusMem === null) {
1097
- nativeLoader.init(api.logger);
1073
+ nativeLoader.init(logger);
1098
1074
  if (nativeLoader.loaded) {
1099
1075
  const wasmJsPath = resolve(wasmDir, "sulcus_wasm.js");
1100
1076
  if (existsSync(wasmJsPath)) {
1101
1077
  try {
1102
- const { SulcusMem, on_init } = require(wasmJsPath);
1078
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1079
+ const { SulcusMem, on_init } = require(wasmJsPath) as { SulcusMem: { create: (q: unknown, e: unknown) => SulcusCloudClient }; on_init?: () => void };
1103
1080
  if (typeof on_init === "function") on_init();
1104
-
1105
- const queryFn = nativeLoader.makeQueryFn();
1106
- const embedFn = nativeLoader.makeEmbedFn();
1107
- sulcusMem = SulcusMem.create(queryFn, embedFn);
1081
+ sulcusMem = SulcusMem.create(nativeLoader.makeQueryFn(), nativeLoader.makeEmbedFn());
1108
1082
  backendMode = "wasm";
1109
- api.logger.info(`sulcus: SulcusMem created via WASM (wasm: ${wasmJsPath})`);
1110
- } catch (e: any) {
1111
- api.logger.warn(`sulcus: WASM load failed: ${e.message}`);
1083
+ logger.info(`sulcus: SulcusMem created via WASM (wasm: ${wasmJsPath})`);
1084
+ } catch (e: unknown) {
1085
+ logger.warn(`sulcus: WASM load failed: ${e instanceof Error ? e.message : e}`);
1112
1086
  }
1113
1087
  } else {
1114
- api.logger.warn(`sulcus: WASM module not found at ${wasmJsPath}`);
1088
+ logger.warn(`sulcus: WASM module not found at ${wasmJsPath}`);
1115
1089
  }
1116
1090
  } else {
1117
- api.logger.info(`sulcus: local mode skipped — ${nativeLoader.error || "dylibs not found"}`);
1091
+ logger.info(`sulcus: local mode skipped — ${nativeLoader.error || "dylibs not found"}`);
1118
1092
  }
1119
1093
  }
1120
1094
 
1121
1095
  const isAvailable = sulcusMem !== null;
1096
+ const isCloudBackend = backendMode === "cloud" && sulcusMem instanceof SulcusCloudClient;
1122
1097
 
1123
1098
  // Update static awareness with runtime info
1124
1099
  STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
1125
1100
 
1126
1101
  // ── Startup summary ──
1127
1102
  if (isAvailable) {
1128
- api.logger.info(`sulcus: registered (backend: ${backendMode}, namespace: ${namespace})`);
1103
+ logger.info(`sulcus: registered (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture})`);
1129
1104
  } else {
1130
1105
  const hints: string[] = [];
1131
1106
  if (!serverUrl && !apiKey) hints.push("no serverUrl/apiKey for cloud mode");
1132
- if (serverUrl && !apiKey) hints.push("serverUrl set but apiKey missing");
1133
- if (!serverUrl && apiKey) hints.push("apiKey set but serverUrl missing");
1107
+ else if (serverUrl && !apiKey) hints.push("serverUrl set but apiKey missing");
1108
+ else if (!serverUrl && apiKey) hints.push("apiKey set but serverUrl missing");
1134
1109
  if (nativeLoader.error) hints.push(`local: ${nativeLoader.error}`);
1135
- api.logger.warn(`sulcus: unavailable — ${hints.join("; ") || "unknown reason"}. Configure serverUrl+apiKey for cloud, or install native dylibs for local.`);
1110
+ logger.warn(`sulcus: unavailable — ${hints.join("; ") || "unknown reason"}`);
1136
1111
  }
1137
1112
 
1138
- // ── SIU v2 request helper (bound to cloud client if available) ──
1139
- // SIU endpoints live on the same server as the Sulcus API.
1140
- const siuRequestFn = (backendMode === "cloud" && sulcusMem instanceof SulcusCloudClient)
1141
- ? (method: string, path: string, body?: any) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1113
+ // ── SIU v2 request helper ──
1114
+ const siuRequestFn = isCloudBackend && sulcusMem
1115
+ ? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1142
1116
  : null;
1143
1117
 
1144
- // ── Shared deps for tool executors ──
1118
+ // ── Shared deps ──
1145
1119
  const toolDeps: ToolDeps = {
1146
1120
  sulcusMem,
1147
1121
  backendMode,
@@ -1150,62 +1124,182 @@ const sulcusPlugin = {
1150
1124
  storeLibPath,
1151
1125
  vectorsLibPath,
1152
1126
  wasmDir,
1153
- logger: api.logger,
1127
+ logger,
1154
1128
  isAvailable,
1155
1129
  siuRequest: siuRequestFn,
1156
1130
  };
1157
1131
 
1158
- // ── Shared context for hook handlers ──
1159
1132
  const handlerCtx: HookHandlerCtx = {
1160
1133
  sulcusMem,
1161
1134
  backendMode,
1162
1135
  namespace,
1163
- logger: api.logger,
1136
+ logger,
1164
1137
  nativeError: nativeLoader.error,
1165
1138
  storeLibPath,
1166
1139
  vectorsLibPath,
1167
1140
  wasmDir,
1168
1141
  };
1169
1142
 
1170
- // ── Config-driven hook registration ──
1171
- // Each handler is wrapped in a defensive try-catch to prevent plugin errors
1172
- // from crashing the host agent's startup pipeline (OpenClaw bug workaround:
1173
- // normalizeResolvedModel() doesn't guard params.model being undefined).
1143
+ // ─────────────────────────────────────────────────────────────────────────
1144
+ // SDK INTEGRATIONS (v4.0.0)
1145
+ // ─────────────────────────────────────────────────────────────────────────
1146
+
1147
+ // 1. registerMemoryRuntime — Sulcus becomes the OpenClaw memory backend
1148
+ if (isCloudBackend && sulcusMem && typeof (api.registerMemoryRuntime as unknown) === "function") {
1149
+ try {
1150
+ (api.registerMemoryRuntime as (r: unknown) => void)(buildMemoryRuntime(sulcusMem as SulcusCloudClient, backendMode));
1151
+ logger.info("sulcus: registered as memory runtime (MemoryPluginRuntime)");
1152
+ } catch (e: unknown) {
1153
+ logger.warn(`sulcus: registerMemoryRuntime failed: ${e instanceof Error ? e.message : e}`);
1154
+ }
1155
+ }
1156
+
1157
+ // 2. registerMemoryPromptSection — dynamic system prompt guidance
1158
+ if (typeof (api.registerMemoryPromptSection as unknown) === "function") {
1159
+ try {
1160
+ (api.registerMemoryPromptSection as (b: unknown) => void)(buildPromptSection);
1161
+ logger.info("sulcus: registered memory prompt section");
1162
+ } catch (e: unknown) {
1163
+ logger.warn(`sulcus: registerMemoryPromptSection failed: ${e instanceof Error ? e.message : e}`);
1164
+ }
1165
+ }
1166
+
1167
+ // 3. registerMemoryFlushPlan — no custom compaction flush
1168
+ if (typeof (api.registerMemoryFlushPlan as unknown) === "function") {
1169
+ try {
1170
+ (api.registerMemoryFlushPlan as (r: unknown) => void)(() => null);
1171
+ logger.info("sulcus: registered memory flush plan (no-op)");
1172
+ } catch (e: unknown) {
1173
+ logger.warn(`sulcus: registerMemoryFlushPlan failed: ${e instanceof Error ? e.message : e}`);
1174
+ }
1175
+ }
1176
+
1177
+ // 4. registerService — lifecycle management
1178
+ if (typeof (api.registerService as unknown) === "function") {
1179
+ try {
1180
+ (api.registerService as (s: unknown) => void)({
1181
+ id: "openclaw-sulcus",
1182
+ start: async (ctx: Record<string, unknown>) => {
1183
+ const svcLogger = (ctx?.logger ?? logger) as PluginLogger;
1184
+ if (!isAvailable || !sulcusMem) {
1185
+ svcLogger.warn("sulcus: service start — backend unavailable, running in degraded mode");
1186
+ return;
1187
+ }
1188
+ if (isCloudBackend) {
1189
+ try {
1190
+ const ok = await (sulcusMem as SulcusCloudClient).probe();
1191
+ if (ok) svcLogger.info(`sulcus: service started — cloud backend connected (${serverUrl}, namespace: ${namespace})`);
1192
+ else svcLogger.warn(`sulcus: service started — cloud backend unreachable (${serverUrl})`);
1193
+ } catch (e
1194
+ : unknown) {
1195
+ svcLogger.warn("sulcus: service start probe failed");
1196
+ }
1197
+ } else {
1198
+ svcLogger.info("sulcus: service started (backend: " + backendMode + ", namespace: " + namespace + ")");
1199
+ }
1200
+ },
1201
+ stop: async (ctx: Record<string, unknown>) => {
1202
+ const svcLogger = (ctx?.logger ?? logger) as PluginLogger;
1203
+ svcLogger.info("sulcus: service stopped");
1204
+ },
1205
+ });
1206
+ logger.info("sulcus: registered service lifecycle");
1207
+ } catch (e: unknown) {
1208
+ logger.warn("sulcus: registerService failed: " + (e instanceof Error ? e.message : String(e)));
1209
+ }
1210
+ }
1211
+
1212
+ // 5. Enhanced before_agent_start with prependContext (SDK path)
1213
+ // When autoRecall=true and cloud backend available, use prependContext SDK pattern.
1214
+ // Falls back to legacy hook-based path when SDK is not available.
1215
+ if (autoRecall && isCloudBackend && sulcusMem) {
1216
+ const sdkRecallHandler = buildSdkRecallHandler(
1217
+ sulcusMem as SulcusCloudClient,
1218
+ namespace,
1219
+ maxRecallResults,
1220
+ profileFrequency,
1221
+ logger
1222
+ );
1223
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1224
+ apiOn("before_agent_start", async (event: Record<string, unknown>, ctx: unknown) => {
1225
+ try {
1226
+ return await sdkRecallHandler(event, ctx);
1227
+ } catch (err) {
1228
+ logger.warn("sulcus: SDK recall hook threw: " + err);
1229
+ return undefined;
1230
+ }
1231
+ });
1232
+ logger.info("sulcus: registered SDK auto-recall (prependContext path)");
1233
+ }
1234
+
1235
+ // 6. auto-capture on agent_end
1236
+ if (autoCapture) {
1237
+ const agentEndCaptureConfig: HookConfig = {
1238
+ action: "sivu_auto_capture",
1239
+ enabled: true,
1240
+ min_store_confidence: 0.5,
1241
+ fallback_on_error: true,
1242
+ };
1243
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1244
+ apiOn("agent_end", async (event: Record<string, unknown>, _ctx: unknown) => {
1245
+ try {
1246
+ return await hookHandlers.sivu_auto_capture(event, agentEndCaptureConfig, handlerCtx);
1247
+ } catch (err) {
1248
+ logger.warn("sulcus: auto-capture hook threw: " + err);
1249
+ return undefined;
1250
+ }
1251
+ });
1252
+ logger.info("sulcus: registered auto-capture (agent_end)");
1253
+ }
1254
+
1255
+ // ─────────────────────────────────────────────────────────────────────────
1256
+ // LEGACY HOOK REGISTRATION (config-driven, backward compat)
1257
+ // ─────────────────────────────────────────────────────────────────────────
1258
+
1174
1259
  for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
1175
1260
  if (!hookConfig.enabled) continue;
1261
+
1262
+ // Skip before_agent_start if we already registered the SDK path
1263
+ if (hookName === "before_agent_start" && autoRecall && isCloudBackend) continue;
1264
+ // Skip agent_end if autoCapture SDK path already registered
1265
+ if (hookName === "agent_end" && autoCapture && hookConfig.action === "sivu_auto_capture") continue;
1266
+
1176
1267
  const handler = hookHandlers[hookConfig.action];
1177
1268
  if (handler) {
1178
- api.on(hookName, async (event: any) => {
1269
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1270
+ apiOn(hookName, async (event: Record<string, unknown>) => {
1179
1271
  try {
1180
1272
  return await handler(event, hookConfig, handlerCtx);
1181
1273
  } catch (err) {
1182
- api.logger.warn(`sulcus: hook "${hookName}" (action=${hookConfig.action}) threw: ${err} returning empty result`);
1274
+ logger.warn("sulcus: hook " + hookName + " (action=" + hookConfig.action + ") threw: " + err);
1183
1275
  return undefined;
1184
1276
  }
1185
1277
  });
1186
1278
  } else {
1187
- api.logger.warn(`sulcus: unknown hook action "${hookConfig.action}" for hook "${hookName}"`);
1279
+ logger.warn("sulcus: unknown hook action " + hookConfig.action + " for hook " + hookName);
1188
1280
  }
1189
1281
  }
1190
1282
 
1191
- // ── Config-driven tool registration ──
1283
+ // ─────────────────────────────────────────────────────────────────────────
1284
+ // TOOL REGISTRATION
1285
+ // ─────────────────────────────────────────────────────────────────────────
1286
+
1192
1287
  for (const [toolName, toolConfig] of Object.entries(hooksConfig.tools)) {
1193
1288
  if (!toolConfig.enabled) continue;
1194
1289
  const toolDef = toolDefinitions[toolName];
1195
1290
  if (toolDef) {
1196
1291
  const schema = {
1197
1292
  ...toolDef.schema,
1198
- async execute(id: string, params: any) {
1293
+ async execute(id: string, params: Record<string, unknown>) {
1199
1294
  return toolDef.makeExecute(toolDeps)(id, params);
1200
1295
  },
1201
1296
  };
1202
- api.registerTool(schema, toolDef.options);
1297
+ const registerTool = api.registerTool as (schema: unknown, opts: unknown) => void;
1298
+ registerTool(schema, toolDef.options);
1203
1299
  } else {
1204
- api.logger.warn(`sulcus: unknown tool "${toolName}" in config — skipping`);
1300
+ logger.warn("sulcus: unknown tool " + toolName + " in config — skipping");
1205
1301
  }
1206
1302
  }
1207
-
1208
- // No service registration needed — no background process to manage
1209
1303
  }
1210
1304
  };
1211
1305