@digitalforgestudios/openclaw-sulcus 3.12.0 → 4.2.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,255 @@ 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
+ if (!shouldCapture(userMessage)) {
148
+ logger.debug?.("sulcus: sivu_auto_capture dedup skip");
149
+ return;
150
+ }
151
+
152
+ const minConfidence = (config.min_store_confidence as number) ?? 0.5;
153
+ const fallbackOnError = config.fallback_on_error !== false;
166
154
 
167
- // Try SIU v2 endpoint for quality-gated classification
168
155
  if (sulcusMem instanceof SulcusCloudClient) {
169
156
  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";
157
+ const siuResult = await sulcusMem.request("POST", "/api/v2/siu/label", { text: userMessage }) as Record<string, unknown>;
158
+ const storeConf = (siuResult?.store_confidence as number) ?? 0;
159
+ const shouldStore = siuResult?.store === true && storeConf >= minConfidence;
160
+ const memoryType = (siuResult?.memory_type as string) ?? "episodic";
161
+ const modelVersion = (siuResult?.model_version as string) ?? "unknown";
177
162
 
178
163
  if (!shouldStore) {
179
- logger.info(`sulcus: sivu_auto_capture — SIVU rejected (confidence: ${siuResult?.store_confidence?.toFixed(3) ?? "?"}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
164
+ logger.info(`sulcus: sivu_auto_capture — SIVU rejected (confidence: ${storeConf.toFixed(3)}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
180
165
  return;
181
166
  }
182
167
 
183
- // SIVU approved — store with SICU-predicted type
184
168
  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)}..."`);
169
+ const typeConf = ((siuResult?.type_confidence as number) ?? 0).toFixed(3);
170
+ 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
171
  return;
187
- } catch (e: any) {
188
- logger.warn(`sulcus: sivu_auto_capture SIU v2 endpoint error: ${e.message}`);
172
+ } catch (e: unknown) {
173
+ const msg = e instanceof Error ? e.message : String(e);
174
+ logger.warn(`sulcus: sivu_auto_capture — SIU v2 endpoint error: ${msg}`);
189
175
  if (!fallbackOnError) return;
190
- // Fall through to basic capture
191
176
  }
192
177
  }
193
178
 
194
- // Fallback: store as episodic (no SIU gating available)
195
179
  try {
196
180
  const res = await sulcusMem.add_memory(userMessage, "episodic");
197
181
  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}`);
182
+ } catch (e: unknown) {
183
+ const msg = e instanceof Error ? e.message : String(e);
184
+ logger.warn(`sulcus: sivu_auto_capture — fallback store failed: ${msg}`);
185
+ }
186
+ },
187
+
188
+ /**
189
+ * auto_error_capture — stores tool errors as episodic memories with boosted heat.
190
+ *
191
+ * Fires on after_tool_call when a tool returns an error.
192
+ * Stores the error context so the agent learns from past failures.
193
+ * Skips errors from Sulcus's own tools to avoid self-referential loops.
194
+ */
195
+ auto_error_capture: async (event: any, _config: HookConfig, ctx: HookHandlerCtx) => {
196
+ const { sulcusMem, logger } = ctx;
197
+ const errorText = event?.error?.trim?.();
198
+ if (!errorText || !sulcusMem) return; // No error or no backend — nothing to capture
199
+
200
+ const toolName = event?.toolName ?? event?.tool_name ?? "unknown";
201
+
202
+ // Skip errors from our own tools to prevent capture loops
203
+ if (typeof toolName === "string" && (
204
+ toolName.startsWith("memory_") ||
205
+ toolName.startsWith("sulcus_") ||
206
+ toolName === "consolidate" ||
207
+ toolName === "evaluate_triggers" ||
208
+ toolName === "export_markdown" ||
209
+ toolName === "import_markdown" ||
210
+ toolName === "siu_label" ||
211
+ toolName === "siu_retrain"
212
+ )) {
213
+ return;
214
+ }
215
+
216
+ // Normalize + truncate error text
217
+ const normalized = errorText.replace(/\s+/g, " ").trim();
218
+ const truncated = normalized.length > 500 ? normalized.slice(0, 500) + " [truncated]" : normalized;
219
+ const memoryContent = `Tool '${toolName}' failed: ${truncated}`;
220
+
221
+ try {
222
+ const res = await sulcusMem.add_memory(memoryContent, "episodic");
223
+ // Boost heat so error memories persist longer — failures are high-value learnings
224
+ if (res?.id && sulcusMem instanceof SulcusCloudClient) {
225
+ await sulcusMem.request("PATCH", `/api/v1/agent/memory/${res.id}`, {
226
+ current_heat: 0.8,
227
+ }).catch(() => {}); // best-effort boost
228
+ }
229
+ logger.info(`sulcus: auto_error_capture — stored tool error [episodic] (id: ${res?.id ?? "?"}): "${memoryContent.substring(0, 80)}..."`);
230
+ } catch (e: unknown) {
231
+ const msg = e instanceof Error ? e.message : String(e);
232
+ logger.debug?.(`sulcus: auto_error_capture — failed to store: ${msg}`);
233
+ }
234
+ },
235
+
236
+ pre_compaction_capture: async (event: Record<string, unknown>, _config: HookConfig, ctx: HookHandlerCtx) => {
237
+ const { sulcusMem, logger } = ctx;
238
+ if (!sulcusMem) return;
239
+
240
+ const messages = Array.isArray(event?.messages) ? event.messages as Record<string, unknown>[] : [];
241
+ if (messages.length === 0) return;
242
+
243
+ const firstUser = messages.find((m) => m.role === "user" || m.type === "human");
244
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant" || m.type === "ai");
245
+
246
+ const firstUserText = typeof firstUser?.content === "string"
247
+ ? firstUser.content.substring(0, 200)
248
+ : typeof firstUser?.text === "string"
249
+ ? (firstUser.text as string).substring(0, 200)
250
+ : "(none)";
251
+
252
+ const lastAssistantText = typeof lastAssistant?.content === "string"
253
+ ? lastAssistant.content.substring(0, 200)
254
+ : typeof lastAssistant?.text === "string"
255
+ ? (lastAssistant.text as string).substring(0, 200)
256
+ : "(none)";
257
+
258
+ const filesModified: string[] = [];
259
+ for (const msg of messages) {
260
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as Record<string, unknown>[] : [];
261
+ for (const tc of toolCalls) {
262
+ const name = (tc.name ?? tc.function) as string | undefined;
263
+ if (name === "Write" || name === "Edit" || name === "write" || name === "edit") {
264
+ const input = (tc.input ?? tc.arguments ?? {}) as Record<string, unknown>;
265
+ const fp = input?.file_path ?? input?.path;
266
+ if (fp && typeof fp === "string" && !filesModified.includes(fp)) filesModified.push(fp);
267
+ }
268
+ }
269
+ }
270
+
271
+ const summaryParts = [
272
+ `Session compaction — ${messages.length} messages`,
273
+ `First user message: ${firstUserText}`,
274
+ `Last assistant message: ${lastAssistantText}`,
275
+ ];
276
+ if (filesModified.length > 0) summaryParts.push(`Files modified: ${filesModified.join(", ")}`);
277
+ const summary = summaryParts.join("\n");
278
+
279
+ if (!shouldCapture(summary)) {
280
+ logger.debug?.("sulcus: pre_compaction_capture — dedup skip");
281
+ return;
282
+ }
283
+
284
+ try {
285
+ const res = await sulcusMem.add_memory(summary, "episodic");
286
+ logger.info(`sulcus: pre_compaction_capture — stored session summary (id: ${res?.id ?? "?"})`);
287
+ } catch (e: unknown) {
288
+ const msg = e instanceof Error ? e.message : String(e);
289
+ logger.debug?.(`sulcus: pre_compaction_capture — store failed: ${msg}`);
200
290
  }
201
291
  },
202
292
  };
203
293
 
204
294
  // ─── 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
295
 
209
296
  class SulcusCloudClient {
210
297
  private serverUrl: string;
211
298
  private apiKey: string;
212
299
 
213
300
  constructor(serverUrl: string, apiKey: string) {
214
- // Strip trailing slash for clean path concatenation
215
301
  this.serverUrl = serverUrl.replace(/\/+$/, "");
216
302
  this.apiKey = apiKey;
217
303
  }
218
304
 
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) => {
305
+ request(method: string, path: string, body?: unknown): Promise<unknown> {
306
+ return new Promise((resolveP, rejectP) => {
222
307
  let parsedUrl: URL;
223
308
  try {
224
309
  parsedUrl = new URL(this.serverUrl + path);
225
- } catch (e: any) {
226
- return reject(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${e.message}`));
310
+ } catch (e: unknown) {
311
+ const msg = e instanceof Error ? e.message : String(e);
312
+ return rejectP(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${msg}`));
227
313
  }
228
314
 
229
315
  const isHttps = parsedUrl.protocol === "https:";
@@ -253,129 +339,96 @@ class SulcusCloudClient {
253
339
  res.on("end", () => {
254
340
  const raw = Buffer.concat(chunks).toString("utf-8");
255
341
  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);
342
+ return rejectP(new Error(`SulcusCloudClient: HTTP ${res.statusCode} for ${method} ${path}: ${raw.substring(0, 200)}`));
260
343
  }
344
+ if (!raw || raw.trim() === "") return resolveP(null);
261
345
  try {
262
- resolve(JSON.parse(raw));
346
+ resolveP(JSON.parse(raw));
263
347
  } catch (_e) {
264
- // Some endpoints return plain text (e.g. markdown export)
265
- resolve(raw);
348
+ resolveP(raw);
266
349
  }
267
350
  });
268
351
  });
269
352
 
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
- }
353
+ req.on("error", (e: Error) => rejectP(new Error(`SulcusCloudClient: network error for ${method} ${path}: ${e.message}`)));
354
+ if (bodyStr !== undefined) req.write(bodyStr);
275
355
  req.end();
276
356
  });
277
357
  }
278
358
 
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 };
359
+ async search_memory(query: string, limit?: number, namespace?: string): Promise<{ results: Record<string, unknown>[] }> {
360
+ const body: Record<string, unknown> = { query };
285
361
  if (limit !== undefined) body.limit = limit;
286
362
  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 : []);
363
+ const res = await this.request("POST", "/api/v1/agent/search", body) as Record<string, unknown> | null;
364
+ const results = (res?.results ?? res?.items ?? res?.nodes ?? (Array.isArray(res) ? res : [])) as Record<string, unknown>[];
289
365
  return { results };
290
366
  }
291
367
 
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 };
368
+ async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: unknown }> {
369
+ const body: Record<string, unknown> = { label: content };
298
370
  if (memoryType) body.memory_type = memoryType;
299
- const res = await this.request("POST", "/api/v1/agent/nodes", body);
300
- return res ?? { id: "unknown" };
371
+ const res = await this.request("POST", "/api/v1/agent/nodes", body) as Record<string, unknown> | null;
372
+ return (res ?? { id: "unknown" }) as { id: string; [key: string]: unknown };
301
373
  }
302
374
 
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[] }> {
375
+ async list_hot_nodes(limit?: number): Promise<{ nodes: Record<string, unknown>[] }> {
309
376
  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 ?? []);
377
+ const res = await this.request("GET", `/api/v1/agent/hot_nodes${q}`) as Record<string, unknown> | unknown[] | null;
378
+ const nodes = (Array.isArray(res) ? res : ((res as Record<string, unknown>)?.hot_nodes ?? (res as Record<string, unknown>)?.nodes ?? [])) as Record<string, unknown>[];
313
379
  return { nodes };
314
380
  }
315
381
 
316
- /**
317
- * consolidate maps to POST /agent/consolidate
318
- */
319
- async consolidate(minHeat?: number): Promise<any> {
320
- const body: any = {};
382
+ async consolidate(minHeat?: number): Promise<unknown> {
383
+ const body: Record<string, unknown> = {};
321
384
  if (minHeat !== undefined) body.min_heat = minHeat;
322
385
  return this.request("POST", "/api/v1/agent/consolidate", body);
323
386
  }
324
387
 
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> {
388
+ async delete_memory(id: string, train?: boolean): Promise<unknown> {
330
389
  const trainParam = train ? "true" : "false";
331
390
  return this.request("DELETE", `/api/v1/agent/nodes/${encodeURIComponent(id)}?train=${trainParam}`);
332
391
  }
333
392
 
334
- /**
335
- * export_markdown — maps to GET /agent/export?format=markdown
336
- * Returns raw markdown string.
337
- */
338
393
  async export_markdown(): Promise<string> {
339
394
  const res = await this.request("GET", "/api/v1/agent/export?format=markdown");
340
- // Server may return { content: "..." } or raw string
341
395
  if (typeof res === "string") return res;
342
- return res?.content ?? res?.markdown ?? JSON.stringify(res, null, 2);
396
+ const r = res as Record<string, unknown>;
397
+ return (r?.content ?? r?.markdown ?? JSON.stringify(res, null, 2)) as string;
343
398
  }
344
399
 
345
- /**
346
- * import_markdown — maps to POST /agent/import
347
- */
348
- async import_markdown(text: string): Promise<any> {
400
+ async import_markdown(text: string): Promise<unknown> {
349
401
  return this.request("POST", "/api/v1/agent/import", { format: "markdown", content: text });
350
402
  }
351
403
 
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 };
404
+ async evaluate_triggers(event: unknown, contextJson?: string): Promise<unknown> {
405
+ const body: Record<string, unknown> = { event };
357
406
  if (contextJson) {
358
- try {
359
- body.context = JSON.parse(contextJson);
360
- } catch (_e) {
361
- body.context = contextJson;
362
- }
407
+ try { body.context = JSON.parse(contextJson); }
408
+ catch (_e) { body.context = contextJson; }
363
409
  }
364
410
  return this.request("POST", "/api/v1/triggers/evaluate", body);
365
411
  }
412
+
413
+ async probe(): Promise<boolean> {
414
+ try {
415
+ await this.search_memory("probe", 1);
416
+ return true;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
366
421
  }
367
422
 
368
423
  // ─── 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
424
 
372
425
  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
426
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
+ private koffi: unknown = null;
428
+ private storeLib: unknown = null;
429
+ private vectorsLib: unknown = null;
430
+ private vectorsHandle: unknown = null;
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
432
  private fn_store_init: any = null;
380
433
  private fn_store_query: any = null;
381
434
  private fn_store_free: any = null;
@@ -386,22 +439,18 @@ class NativeLibLoader {
386
439
  public loaded = false;
387
440
  public error: string | null = null;
388
441
 
389
- constructor(
390
- private storeLibPath: string,
391
- private vectorsLibPath: string
392
- ) {}
442
+ constructor(private storeLibPath: string, private vectorsLibPath: string) {}
393
443
 
394
- init(logger: any): void {
444
+ init(logger: PluginLogger): void {
395
445
  try {
396
- // koffi is a pure-JS FFI library — no native compilation needed
446
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
397
447
  this.koffi = require("koffi");
398
- } catch (e: any) {
399
- this.error = `koffi not available: ${e.message}`;
448
+ } catch (e: unknown) {
449
+ this.error = `koffi not available: ${e instanceof Error ? e.message : e}`;
400
450
  logger.warn(`sulcus: ${this.error}`);
401
451
  return;
402
452
  }
403
453
 
404
- // ── Load libsulcus_store.dylib ──
405
454
  if (!existsSync(this.storeLibPath)) {
406
455
  this.error = `libsulcus_store not found at ${this.storeLibPath}`;
407
456
  logger.warn(`sulcus: ${this.error}`);
@@ -414,48 +463,47 @@ class NativeLibLoader {
414
463
  }
415
464
 
416
465
  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}`;
466
+ const k = this.koffi as any;
467
+ this.storeLib = k.load(this.storeLibPath);
468
+ this.fn_store_init = (this.storeLib as any).func("sulcus_store_init", "int", ["str", "uint16"]);
469
+ this.fn_store_query = (this.storeLib as any).func("sulcus_store_query", "char*", ["str"]);
470
+ this.fn_store_free = (this.storeLib as any).func("sulcus_store_free_string", "void", ["char*"]);
471
+ } catch (e: unknown) {
472
+ this.error = `Failed to load libsulcus_store: ${e instanceof Error ? e.message : e}`;
423
473
  logger.warn(`sulcus: ${this.error}`);
424
474
  return;
425
475
  }
426
476
 
427
477
  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}`;
478
+ const k = this.koffi as any;
479
+ this.vectorsLib = k.load(this.vectorsLibPath);
480
+ this.fn_vectors_create = (this.vectorsLib as any).func("sulcus_vectors_create", "void*", []);
481
+ this.fn_vectors_text = (this.vectorsLib as any).func("sulcus_vectors_text", "char*", ["void*", "str"]);
482
+ this.fn_vectors_free = (this.vectorsLib as any).func("sulcus_vectors_free_string", "void", ["char*"]);
483
+ } catch (e: unknown) {
484
+ this.error = `Failed to load libsulcus_vectors: ${e instanceof Error ? e.message : e}`;
434
485
  logger.warn(`sulcus: ${this.error}`);
435
486
  return;
436
487
  }
437
488
 
438
- // ── Initialise embedded PG store ──
439
489
  try {
440
490
  const dataDir = resolve(process.env.HOME || "~", ".sulcus/data");
441
- const port = 15432;
442
- const rc = this.fn_store_init(dataDir, port);
491
+ const rc = this.fn_store_init(dataDir, 15432);
443
492
  if (rc !== 0) {
444
493
  this.error = `sulcus_store_init returned ${rc}`;
445
494
  logger.warn(`sulcus: ${this.error}`);
446
495
  return;
447
496
  }
448
- } catch (e: any) {
449
- this.error = `sulcus_store_init failed: ${e.message}`;
497
+ } catch (e: unknown) {
498
+ this.error = `sulcus_store_init failed: ${e instanceof Error ? e.message : e}`;
450
499
  logger.warn(`sulcus: ${this.error}`);
451
500
  return;
452
501
  }
453
502
 
454
- // ── Create embed handle ──
455
503
  try {
456
504
  this.vectorsHandle = this.fn_vectors_create();
457
- } catch (e: any) {
458
- this.error = `sulcus_vectors_create failed: ${e.message}`;
505
+ } catch (e: unknown) {
506
+ this.error = `sulcus_vectors_create failed: ${e instanceof Error ? e.message : e}`;
459
507
  logger.warn(`sulcus: ${this.error}`);
460
508
  return;
461
509
  }
@@ -464,29 +512,17 @@ class NativeLibLoader {
464
512
  logger.info(`sulcus: native libs loaded (store: ${this.storeLibPath}, vectors: ${this.vectorsLibPath})`);
465
513
  }
466
514
 
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[]> => {
515
+ makeQueryFn(): (sql: string, params: unknown[]) => Promise<unknown[]> {
516
+ return async (sql: string, params: unknown[]): Promise<unknown[]> => {
472
517
  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);
518
+ const raw: string = this.fn_store_query(JSON.stringify({ sql, params }));
476
519
  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
- }
520
+ const parsed = JSON.parse(raw);
521
+ const p = parsed as Record<string, unknown>;
522
+ return Array.isArray(parsed) ? (parsed as unknown[]) : ((Array.isArray(p?.rows) ? p.rows as unknown[] : [parsed as unknown]));
485
523
  };
486
524
  }
487
525
 
488
- // embedFn: async (text: string) => Float32Array
489
- // Calls sulcus_vectors_text which returns a JSON float array string.
490
526
  makeEmbedFn(): (text: string) => Promise<Float32Array> {
491
527
  return async (text: string): Promise<Float32Array> => {
492
528
  if (!this.loaded) throw new Error("Sulcus vectors not available");
@@ -499,9 +535,8 @@ class NativeLibLoader {
499
535
  }
500
536
 
501
537
  // ─── PRE-SEND FILTER ─────────────────────────────────────────────────────────
502
- // Rule-based junk filter. Catches obvious noise before it hits the API.
503
538
 
504
- const JUNK_PATTERNS = [
539
+ const JUNK_PATTERNS: RegExp[] = [
505
540
  /^(HEARTBEAT_OK|NO_REPLY|NOOP)$/i,
506
541
  /^\s*$/,
507
542
  /^system:\s/i,
@@ -514,18 +549,15 @@ const JUNK_PATTERNS = [
514
549
  /^<<<EXTERNAL_UNTRUSTED_CONTENT/i,
515
550
  /^Runtime:/i,
516
551
  /tool_call|function_call|<function_calls>/i,
517
- // Subagent completion events — internal runtime artifacts, not memories
518
552
  /\[Inter-session message\]\s*sourceSession=/i,
519
553
  /<<<BEGIN_UNTRUSTED_CHILD_RESULT>>>/,
520
554
  /<<<END_UNTRUSTED_CHILD_RESULT>>>/,
521
555
  /\[Internal task completion event\]/i,
522
556
  /^source:\s*subagent/im,
523
557
  /session_key:\s*agent:main:subagent:/i,
524
- // Cron task payloads — system prompts, not meaningful content
525
558
  /^Sulcus validation cycle\./i,
526
559
  /^Heartbeat prompt:/i,
527
560
  /OpenClaw runtime context \(internal\)/i,
528
- // Credential patterns — should never be stored
529
561
  /\b(sk-[a-f0-9]{40,}|Bearer\s+[A-Za-z0-9._~+/=-]{20,})\b/,
530
562
  /\b(api[_-]?key|secret|password|token)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}/i,
531
563
  ];
@@ -539,27 +571,39 @@ function isJunkMemory(text: string): boolean {
539
571
  return false;
540
572
  }
541
573
 
574
+ // ─── CAPTURE DEDUP ───────────────────────────────────────────────────────────
575
+
576
+ const captureDedup = new Map<string, number>();
577
+ const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
578
+
579
+ function shouldCapture(content: string): boolean {
580
+ const key = content.substring(0, 120) + "|" + content.length;
581
+ const now = Date.now();
582
+ for (const [k, ts] of captureDedup.entries()) {
583
+ if (now - ts > DEDUP_WINDOW_MS) captureDedup.delete(k);
584
+ }
585
+ if (captureDedup.has(key)) return false;
586
+ captureDedup.set(key, now);
587
+ return true;
588
+ }
589
+
542
590
  // ─── HOOKS CONFIG LOADER ─────────────────────────────────────────────────────
543
591
 
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
592
+ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
551
593
  const defaultsPath = resolve(__dirname, "hooks.defaults.json");
552
594
  let defaults: HooksConfig;
553
595
  try {
596
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
554
597
  defaults = JSON.parse(require("fs").readFileSync(defaultsPath, "utf-8")) as HooksConfig;
555
598
  } catch (_e) {
556
- // Fallback inline defaults if file is missing (safety net)
557
599
  defaults = {
558
600
  version: 1,
559
601
  hooks: {
560
602
  before_prompt_build: { action: "inject_awareness", enabled: true },
561
603
  before_agent_start: { action: "auto_recall", enabled: false, limit: 5, minScore: 0.3 },
562
604
  agent_end: { action: "none", enabled: true },
605
+ after_tool_call: { action: "auto_error_capture", enabled: true },
606
+ before_compaction: { action: "pre_compaction_capture", enabled: true },
563
607
  },
564
608
  tools: {
565
609
  memory_recall: { enabled: true },
@@ -569,13 +613,13 @@ function loadHooksConfig(apiConfig: any): HooksConfig {
569
613
  export_markdown: { enabled: false },
570
614
  import_markdown: { enabled: false },
571
615
  evaluate_triggers: { enabled: false },
616
+ __sulcus_workflow__: { enabled: true },
572
617
  },
573
618
  };
574
619
  }
575
620
 
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 ?? {};
621
+ const userHooks = (apiConfig?.hooks ?? {}) as Record<string, Partial<HookConfig>>;
622
+ const userTools = (apiConfig?.tools ?? {}) as Record<string, Partial<ToolConfig>>;
579
623
 
580
624
  const mergedHooks: Record<string, HookConfig> = { ...defaults.hooks };
581
625
  for (const [name, override] of Object.entries(userHooks)) {
@@ -587,7 +631,7 @@ function loadHooksConfig(apiConfig: any): HooksConfig {
587
631
  mergedTools[name] = { ...(mergedTools[name] ?? { enabled: false }), ...override };
588
632
  }
589
633
 
590
- // ── Legacy compat: autoRecall flag → hooks.before_agent_start.enabled ──
634
+ // Legacy compat: autoRecall flag → hooks.before_agent_start.enabled
591
635
  if (apiConfig?.autoRecall === true) {
592
636
  mergedHooks["before_agent_start"] = {
593
637
  ...(mergedHooks["before_agent_start"] ?? { action: "auto_recall", enabled: false }),
@@ -598,26 +642,206 @@ function loadHooksConfig(apiConfig: any): HooksConfig {
598
642
  return { version: defaults.version, hooks: mergedHooks, tools: mergedTools };
599
643
  }
600
644
 
601
- // ─── TOOL DEFINITIONS ────────────────────────────────────────────────────────
645
+ // ─── RELATIVE TIME FORMATTER ─────────────────────────────────────────────────
602
646
 
603
- interface ToolDefinition {
604
- schema: any;
605
- options: { name: string };
606
- makeExecute: (deps: ToolDeps) => (id: string, params: any) => Promise<any>;
647
+ function formatRelativeTime(isoTimestamp: string): string {
648
+ try {
649
+ const dt = new Date(isoTimestamp);
650
+ const now = new Date();
651
+ const seconds = (now.getTime() - dt.getTime()) / 1000;
652
+ const minutes = seconds / 60;
653
+ const hours = seconds / 3600;
654
+ const days = seconds / 86400;
655
+ if (minutes < 2) return "just now";
656
+ if (minutes < 60) return `${Math.floor(minutes)}m ago`;
657
+ if (hours < 24) return `${Math.floor(hours)}h ago`;
658
+ if (days < 7) return `${Math.floor(days)}d ago`;
659
+ const month = dt.toLocaleString("en", { month: "short" });
660
+ if (dt.getFullYear() === now.getFullYear()) return `${dt.getDate()} ${month}`;
661
+ return `${dt.getDate()} ${month}, ${dt.getFullYear()}`;
662
+ } catch {
663
+ return "";
664
+ }
665
+ }
666
+
667
+ // ─── SDK RECALL HANDLER (for before_agent_start with prependContext) ──────────
668
+
669
+ interface ProfileCache {
670
+ preferences: Record<string, unknown>[];
671
+ facts: Record<string, unknown>[];
672
+ cachedAt: number;
673
+ }
674
+
675
+ function buildSdkRecallHandler(
676
+ sulcusMem: SulcusCloudClient,
677
+ namespace: string,
678
+ maxResults: number,
679
+ profileFrequency: number,
680
+ logger: PluginLogger
681
+ ) {
682
+ let turnCount = 0;
683
+ let profileCache: ProfileCache | null = null;
684
+
685
+ return async (event: Record<string, unknown>, _ctx: unknown): Promise<{ prependContext: string } | undefined> => {
686
+ const prompt = typeof event?.prompt === "string" ? event.prompt : "";
687
+ if (!prompt || prompt.length < 5) return undefined;
688
+
689
+ turnCount++;
690
+ const includeProfile = turnCount === 1 || turnCount % profileFrequency === 0;
691
+
692
+ try {
693
+ const searchRes = await sulcusMem.search_memory(prompt, maxResults, namespace);
694
+ const searchResults = searchRes?.results ?? [];
695
+
696
+ let preferences: Record<string, unknown>[] = [];
697
+ let facts: Record<string, unknown>[] = [];
698
+
699
+ if (includeProfile) {
700
+ try {
701
+ const prefRes = await sulcusMem.search_memory("user preference", Math.min(maxResults, 5), namespace);
702
+ const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(maxResults, 5), namespace);
703
+ preferences = (prefRes?.results ?? []).filter((r) => r.memory_type === "preference");
704
+ facts = (factRes?.results ?? []).filter((r) => r.memory_type === "fact");
705
+ profileCache = { preferences, facts, cachedAt: Date.now() };
706
+ } catch {
707
+ // profile fetch failed — continue without
708
+ }
709
+ } else if (profileCache) {
710
+ preferences = profileCache.preferences;
711
+ facts = profileCache.facts;
712
+ }
713
+
714
+ const profileIds = new Set([
715
+ ...preferences.map((r) => r.id as string),
716
+ ...facts.map((r) => r.id as string),
717
+ ]);
718
+ const dedupedSearch = searchResults.filter((r) => !profileIds.has(r.id as string));
719
+
720
+ const sections: string[] = [];
721
+
722
+ if (includeProfile && (preferences.length > 0 || facts.length > 0)) {
723
+ const profileLines: string[] = [];
724
+ for (const r of preferences) {
725
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
726
+ profileLines.push(`- [preference] ${label}`);
727
+ }
728
+ for (const r of facts) {
729
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
730
+ profileLines.push(`- [fact] ${label}`);
731
+ }
732
+ if (profileLines.length > 0) {
733
+ sections.push(`## User Profile (from preferences + facts)\n${profileLines.join("\n")}`);
734
+ }
735
+ }
736
+
737
+ if (dedupedSearch.length > 0) {
738
+ const memLines = dedupedSearch.slice(0, maxResults).map((r) => {
739
+ const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0);
740
+ const pct = `[${Math.round(heat * 100)}%]`;
741
+ const updatedAt = r.updated_at as string | undefined;
742
+ const timeStr = updatedAt ? `[${formatRelativeTime(updatedAt)}]` : "";
743
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
744
+ return `- ${pct} ${timeStr} ${label}`.trim();
745
+ });
746
+ sections.push(`## Relevant Memories (with relevance %)\n${memLines.join("\n")}`);
747
+ }
748
+
749
+ if (sections.length === 0) return undefined;
750
+
751
+ const intro =
752
+ "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.";
753
+ const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${intro}\n\n${sections.join("\n\n")}\n</sulcus_context>`;
754
+
755
+ logger.info(`sulcus: SDK recall injecting context (${context.length} chars, turn ${turnCount})`);
756
+ return { prependContext: context };
757
+ } catch (err) {
758
+ logger.warn(`sulcus: SDK recall failed: ${err}`);
759
+ return undefined;
760
+ }
761
+ };
762
+ }
763
+
764
+ // ─── MEMORY RUNTIME BUILDER ───────────────────────────────────────────────────
765
+
766
+ function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
767
+ const searchManager = {
768
+ status() {
769
+ return {
770
+ backend: "builtin" as const,
771
+ provider: "sulcus",
772
+ model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus-local",
773
+ custom: { backendMode, transport: backendMode === "cloud" ? "remote" : "local" },
774
+ };
775
+ },
776
+ async probeEmbeddingAvailability() {
777
+ try {
778
+ const ok = await sulcusMem.probe();
779
+ return { ok };
780
+ } catch (err) {
781
+ return { ok: false, error: err instanceof Error ? err.message : "sulcus unreachable" };
782
+ }
783
+ },
784
+ async probeVectorAvailability() { return true; },
785
+ async sync() { /* cloud sync is continuous */ },
786
+ async close() { /* no-op for HTTP client */ },
787
+ };
788
+
789
+ return {
790
+ async getMemorySearchManager() { return { manager: searchManager }; },
791
+ resolveMemoryBackendConfig() { return { backend: "builtin" as const }; },
792
+ async closeAllMemorySearchManagers() { /* no-op */ },
793
+ };
794
+ }
795
+
796
+ // ─── PROMPT SECTION BUILDER ───────────────────────────────────────────────────
797
+
798
+ function buildPromptSection(params: { availableTools: Set<string> }): string[] {
799
+ const hasRecall = params.availableTools.has("memory_recall");
800
+ const hasStore = params.availableTools.has("memory_store");
801
+ if (!hasRecall && !hasStore) return [];
802
+
803
+ const lines: string[] = [
804
+ "## Memory (Sulcus)",
805
+ "",
806
+ "You have persistent thermodynamic memory powered by Sulcus.",
807
+ "Relevant memories are automatically injected at the start of each conversation.",
808
+ "",
809
+ ];
810
+
811
+ if (hasRecall) lines.push("- Use `memory_recall` to search prior conversations, preferences, and facts.");
812
+ if (hasStore) lines.push("- Use `memory_store` to save information the user asks you to remember.");
813
+ if (params.availableTools.has("memory_delete")) lines.push("- Use `memory_delete` to remove incorrect or stale memories.");
814
+ if (params.availableTools.has("memory_status")) lines.push("- Use `memory_status` to check backend connection and hot nodes.");
815
+ if (params.availableTools.has("consolidate")) lines.push("- Use `consolidate` to prune cold memories below a heat threshold.");
816
+ if (params.availableTools.has("export_markdown")) lines.push("- Use `export_markdown` to export all memories as Markdown.");
817
+ if (params.availableTools.has("import_markdown")) lines.push("- Use `import_markdown` to import memories from a Markdown document.");
818
+ if (params.availableTools.has("evaluate_triggers")) lines.push("- Use `evaluate_triggers` to evaluate reactive memory triggers.");
819
+
820
+ lines.push("");
821
+ lines.push("Memory types: episodic (events, fast decay), semantic (knowledge, slow), preference (opinions, slower), procedural (how-tos, slowest), fact (data, slow)");
822
+
823
+ return lines;
607
824
  }
608
825
 
826
+ // ─── TOOL DEFINITIONS ────────────────────────────────────────────────────────
827
+
609
828
  interface ToolDeps {
610
- sulcusMem: any;
829
+ sulcusMem: SulcusCloudClient | null;
611
830
  backendMode: string;
612
831
  namespace: string;
613
832
  nativeLoader: NativeLibLoader;
614
833
  storeLibPath: string;
615
834
  vectorsLibPath: string;
616
835
  wasmDir: string;
617
- logger: any;
836
+ logger: PluginLogger;
618
837
  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;
838
+ siuRequest: ((method: string, path: string, body?: unknown) => Promise<unknown>) | null;
839
+ }
840
+
841
+ interface ToolDefinition {
842
+ schema: Record<string, unknown>;
843
+ options: { name: string };
844
+ makeExecute: (deps: ToolDeps) => (id: string, params: Record<string, unknown>) => Promise<{ content: { type: string; text: string }[]; details?: Record<string, unknown> }>;
621
845
  }
622
846
 
623
847
  const toolDefinitions: Record<string, ToolDefinition> = {
@@ -629,23 +853,19 @@ const toolDefinitions: Record<string, ToolDefinition> = {
629
853
  parameters: Type.Object({
630
854
  query: Type.String({ description: "Search query string." }),
631
855
  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)." }))
856
+ namespace: Type.Optional(Type.String({ description: "Namespace to search. Defaults to your own namespace." })),
633
857
  }),
634
858
  },
635
859
  options: { name: "memory_recall" },
636
860
  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 ?? [];
861
+ async (_id, params) => {
862
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
863
+ const searchNamespace = (params.namespace as string | undefined) ?? namespace;
864
+ const res = await sulcusMem.search_memory(params.query as string, (params.limit as number | undefined) ?? 5, searchNamespace);
865
+ const results = res?.results ?? [];
646
866
  return {
647
867
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
648
- details: { results, backend: backendMode, namespace: searchNamespace }
868
+ details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace },
649
869
  };
650
870
  },
651
871
  },
@@ -658,54 +878,41 @@ const toolDefinitions: Record<string, ToolDefinition> = {
658
878
  parameters: Type.Object({
659
879
  content: Type.String({ description: "Memory content. Supports Markdown formatting for structured content." }),
660
880
  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" })),
881
+ Type.Literal("episodic"), Type.Literal("semantic"), Type.Literal("preference"),
882
+ Type.Literal("procedural"), Type.Literal("fact"),
883
+ ], { description: "Memory type. Default: episodic" })),
884
+ train: Type.Optional(Type.Boolean({ description: "Signal the SIU to learn from this manual store. Default: false" })),
668
885
  }),
669
886
  },
670
887
  options: { name: "memory_store" },
671
888
  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
- };
889
+ async (_id, params) => {
890
+ const content = params.content as string;
891
+ if (isJunkMemory(content)) {
892
+ logger.debug?.(`sulcus: filtered junk memory: "${content.substring(0, 50)}..."`);
893
+ return { content: [{ type: "text", text: "Filtered: content looks like system noise, not a meaningful memory." }], details: { filtered: true } };
680
894
  }
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);
895
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
896
+ const mtype = (params.memory_type as string | undefined) || "episodic";
897
+ const res = await sulcusMem.add_memory(content, mtype);
685
898
  const nodeId = res?.id ?? "unknown";
686
- const mtype = params.memory_type || "episodic";
687
- // If train=true, submit a training signal to the SIU
688
899
  let trainResult: string | null = null;
689
- if (params.train && (sulcusMem as any).request) {
900
+ if (params.train === true) {
690
901
  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",
902
+ await sulcusMem.request("POST", "/api/v2/siu/signal", {
903
+ memory_id: nodeId, signal_type: "accept", corrected_store: true,
904
+ corrected_type: mtype, content_snapshot: content, source: "plugin",
698
905
  });
699
906
  trainResult = "training signal submitted";
700
907
  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}`);
908
+ } catch (e: unknown) {
909
+ trainResult = `training signal failed: ${e instanceof Error ? e.message : e}`;
910
+ logger.warn(`sulcus: SIU training signal failed: ${trainResult}`);
704
911
  }
705
912
  }
706
913
  return {
707
914
  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 }
915
+ details: { ...res, id: nodeId, memory_type: mtype, backend: backendMode, namespace, train: trainResult as unknown as Record<string, unknown> },
709
916
  };
710
917
  },
711
918
  },
@@ -719,75 +926,40 @@ const toolDefinitions: Record<string, ToolDefinition> = {
719
926
  },
720
927
  options: { name: "memory_status" },
721
928
  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
- };
929
+ async (_id, _params) => {
930
+ if (!isAvailable || !sulcusMem) {
931
+ 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
932
  }
736
933
  try {
737
- // Fetch both status info and hot nodes in parallel
738
934
  const [statusInfo, hotNodes] = await Promise.all([
739
935
  sulcusMem.request("GET", "/api/v1/agent/memory/status").catch(() => null),
740
936
  sulcusMem.list_hot_nodes(20),
741
937
  ]);
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) {
938
+ const nodeList = hotNodes?.nodes ?? [];
939
+ const si = statusInfo as Record<string, unknown> | null;
757
940
  return {
758
- content: [{ type: "text", text: JSON.stringify({
759
- status: "error",
760
- backend: backendMode,
761
- namespace,
762
- error: e.message,
763
- }, null, 2) }],
941
+ 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) }],
942
+ details: { status: "ok", backend: backendMode, namespace, count: nodeList.length },
764
943
  };
944
+ } catch (e: unknown) {
945
+ return { content: [{ type: "text", text: JSON.stringify({ status: "error", backend: backendMode, namespace, error: e instanceof Error ? e.message : String(e) }, null, 2) }] };
765
946
  }
766
947
  },
767
948
  },
768
949
 
769
- // ── New WASM-capability tools (disabled by default) ──
770
-
771
950
  consolidate: {
772
951
  schema: {
773
952
  name: "consolidate",
774
953
  label: "Memory Consolidate",
775
954
  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
- }),
955
+ parameters: Type.Object({ min_heat: Type.Optional(Type.Number({ default: 0.1, description: "Heat threshold (0.0–1.0)." })) }),
779
956
  },
780
957
  options: { name: "consolidate" },
781
958
  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
- };
959
+ async (_id, params) => {
960
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
961
+ const res = await sulcusMem.consolidate((params.min_heat as number | undefined) ?? 0.1);
962
+ 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
963
  },
792
964
  },
793
965
 
@@ -800,15 +972,10 @@ const toolDefinitions: Record<string, ToolDefinition> = {
800
972
  },
801
973
  options: { name: "export_markdown" },
802
974
  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
- };
975
+ async (_id, _params) => {
976
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
977
+ const markdown = await sulcusMem.export_markdown();
978
+ return { content: [{ type: "text", text: markdown }], details: { backend: backendMode, namespace, length: markdown.length } };
812
979
  },
813
980
  },
814
981
 
@@ -817,21 +984,14 @@ const toolDefinitions: Record<string, ToolDefinition> = {
817
984
  name: "import_markdown",
818
985
  label: "Import Memory (Markdown)",
819
986
  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
- }),
987
+ parameters: Type.Object({ text: Type.String({ description: "Markdown content to import." }) }),
823
988
  },
824
989
  options: { name: "import_markdown" },
825
990
  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
- };
991
+ async (_id, params) => {
992
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
993
+ const res = await sulcusMem.import_markdown(params.text as string);
994
+ 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
995
  },
836
996
  },
837
997
 
@@ -842,20 +1002,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
842
1002
  description: "Evaluate reactive memory triggers against an event and context.",
843
1003
  parameters: Type.Object({
844
1004
  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." }))
1005
+ context_json: Type.Optional(Type.String({ description: "JSON string of additional context." })),
846
1006
  }),
847
1007
  },
848
1008
  options: { name: "evaluate_triggers" },
849
1009
  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
- };
1010
+ async (_id, params) => {
1011
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
1012
+ const res = await sulcusMem.evaluate_triggers(params.event, (params.context_json as string | undefined) ?? "{}");
1013
+ 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
1014
  },
860
1015
  },
861
1016
 
@@ -863,65 +1018,46 @@ const toolDefinitions: Record<string, ToolDefinition> = {
863
1018
  schema: {
864
1019
  name: "memory_delete",
865
1020
  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.",
1021
+ description: "Delete a memory node by ID. With train=true (default), trains SIVU to reject similar content.",
867
1022
  parameters: Type.Object({
868
1023
  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." })),
1024
+ train: Type.Optional(Type.Boolean({ default: true, description: "Train SIVU to reject similar content (default true)." })),
870
1025
  }),
871
1026
  },
872
1027
  options: { name: "memory_delete" },
873
1028
  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);
1029
+ async (_id, params) => {
1030
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
1031
+ const train = params.train !== false;
1032
+ const res = await sulcusMem.delete_memory(params.id as string, train);
880
1033
  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 }
1034
+ content: [{ type: "text", text: `Deleted memory ${params.id as string}${train ? " (trained SIVU to reject similar)" : ""}. Backend: ${backendMode}, namespace: ${namespace}` }],
1035
+ details: { id: params.id as string, trained: train, result: res as Record<string, unknown>, backend: backendMode, namespace },
883
1036
  };
884
1037
  },
885
1038
  },
886
1039
 
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
1040
  siu_label: {
892
1041
  schema: {
893
1042
  name: "siu_label",
894
1043
  label: "SIU Label",
895
- description: "Classify text using SIU v2 — returns SIVU store/reject decision and SICU memory type classification with confidence scores.",
1044
+ description: "Classify text using SIU v2 — returns SIVU store/reject decision and SICU memory type classification.",
896
1045
  parameters: Type.Object({
897
1046
  text: Type.String({ description: "Text to classify." }),
898
1047
  classify_only: Type.Optional(Type.Boolean({ description: "Skip SIVU quality gate, only run SICU type classification." })),
899
1048
  }),
900
1049
  },
901
1050
  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
- }
1051
+ makeExecute: ({ siuRequest, logger }) =>
1052
+ async (_id, params) => {
1053
+ if (!siuRequest) return { content: [{ type: "text", text: "SIU label requires cloud backend (serverUrl + apiKey)." }] };
910
1054
  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
- };
1055
+ const res = await siuRequest("POST", "/api/v2/siu/label", { text: params.text as string, classify_only: params.classify_only ?? false });
1056
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
1057
+ } catch (e: unknown) {
1058
+ const msg = e instanceof Error ? e.message : String(e);
1059
+ logger.warn(`sulcus: siu_label failed: ${msg}`);
1060
+ return { content: [{ type: "text", text: `SIU label failed: ${msg}` }] };
925
1061
  }
926
1062
  },
927
1063
  },
@@ -935,25 +1071,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
935
1071
  },
936
1072
  options: { name: "siu_status" },
937
1073
  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
- }
1074
+ async (_id, _params) => {
1075
+ if (!siuRequest) return { content: [{ type: "text", text: "SIU status requires cloud backend." }] };
945
1076
  try {
946
1077
  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
- };
1078
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
1079
+ } catch (e: unknown) {
1080
+ const msg = e instanceof Error ? e.message : String(e);
1081
+ logger.warn(`sulcus: siu_status failed: ${msg}`);
1082
+ return { content: [{ type: "text", text: `SIU status failed: ${msg}` }] };
957
1083
  }
958
1084
  },
959
1085
  },
@@ -962,44 +1088,31 @@ const toolDefinitions: Record<string, ToolDefinition> = {
962
1088
  schema: {
963
1089
  name: "siu_retrain",
964
1090
  label: "SIU Retrain",
965
- description: "Trigger an async retrain of SIU v2 models using accumulated training signals. Returns job status.",
1091
+ description: "Trigger an async retrain of SIU v2 models using accumulated training signals.",
966
1092
  parameters: Type.Object({}),
967
1093
  },
968
1094
  options: { name: "siu_retrain" },
969
1095
  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
- }
1096
+ async (_id, _params) => {
1097
+ if (!siuRequest) return { content: [{ type: "text", text: "SIU retrain requires cloud backend." }] };
977
1098
  try {
978
1099
  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
- };
1100
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
1101
+ } catch (e: unknown) {
1102
+ const msg = e instanceof Error ? e.message : String(e);
1103
+ logger.warn(`sulcus: siu_retrain failed: ${msg}`);
1104
+ return { content: [{ type: "text", text: `SIU retrain failed: ${msg}` }] };
989
1105
  }
990
1106
  },
991
1107
  },
1108
+
992
1109
  trigger_feedback: {
993
1110
  schema: {
994
1111
  name: "trigger_feedback",
995
1112
  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.",
1113
+ description: "Record feedback on a trigger fire (for SITU training).",
998
1114
  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
- }),
1115
+ feedback_type: Type.String({ description: 'One of: "false_positive", "false_negative", "correct", "wrong_action"' }),
1003
1116
  trigger_id: Type.Optional(Type.String({ description: "UUID of the trigger rule" })),
1004
1117
  trigger_log_id: Type.Optional(Type.String({ description: "UUID of the trigger fire log entry" })),
1005
1118
  event_type: Type.Optional(Type.String({ description: "Event type: memory_created, heat_threshold, recall, etc." })),
@@ -1010,30 +1123,111 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1010
1123
  },
1011
1124
  options: { name: "trigger_feedback" },
1012
1125
  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
- }
1126
+ async (_id, params) => {
1127
+ if (!siuRequest) return { content: [{ type: "text", text: "Trigger feedback requires cloud backend." }] };
1020
1128
  try {
1021
1129
  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
- };
1130
+ return { content: [{ type: "text", text: JSON.stringify(res, null, 2) }], details: res as Record<string, unknown> };
1131
+ } catch (e: unknown) {
1132
+ const msg = e instanceof Error ? e.message : String(e);
1133
+ logger.warn(`sulcus: trigger_feedback failed: ${msg}`);
1134
+ return { content: [{ type: "text", text: `Trigger feedback failed: ${msg}` }] };
1032
1135
  }
1033
1136
  },
1034
1137
  },
1138
+
1139
+ __sulcus_workflow__: {
1140
+ schema: {
1141
+ name: "__sulcus_workflow__",
1142
+ label: "Sulcus Workflow",
1143
+ description: "Call this when you are unsure what to do next with Sulcus memory tools. Returns a step-by-step workflow checklist so you always know the right action.",
1144
+ parameters: Type.Object({}),
1145
+ },
1146
+ options: { name: "__sulcus_workflow__" },
1147
+ makeExecute: (_deps: ToolDeps) =>
1148
+ async (_id: string, _params: Record<string, unknown>) => {
1149
+ const workflow = [
1150
+ { step: 1, action: "search first", tool: "memory_recall", description: "Before starting work, search memory for relevant context from prior sessions." },
1151
+ { step: 2, action: "store decisions/patterns/learnings", tool: "memory_store", description: "After significant work, store important decisions, patterns, corrections, or learnings." },
1152
+ { step: 3, action: "boost important memories", tool: "PATCH /api/v1/agent/memory/:id", description: "Use PATCH to set current_heat=0.9 on memories that should persist longer (memory_boost not yet exposed as a tool)." },
1153
+ { step: 4, action: "check triggers", tool: "evaluate_triggers", description: "Evaluate reactive rules to see if any triggers should fire based on current context." },
1154
+ { step: 5, action: "export if needed", tool: "export_markdown", description: "Export all memories as Markdown for backup or review." },
1155
+ ];
1156
+ return {
1157
+ content: [{ type: "text", text: JSON.stringify(workflow, null, 2) }],
1158
+ details: { workflow: workflow as unknown as Record<string, unknown> },
1159
+ };
1160
+ },
1161
+ },
1035
1162
  };
1036
1163
 
1164
+ // ─── FIRST-INSTALL HISTORY IMPORT ────────────────────────────────────────────
1165
+
1166
+ async function importOpenClawHistory(sulcusMem: SulcusCloudClient, logger: PluginLogger): Promise<void> {
1167
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1168
+ const fs = require("fs") as {
1169
+ existsSync: (p: string) => boolean;
1170
+ readFileSync: (p: string, enc: string) => string;
1171
+ readdirSync: (p: string) => string[];
1172
+ statSync: (p: string) => { mtimeMs: number };
1173
+ writeFileSync: (p: string, d: string, enc: string) => void;
1174
+ };
1175
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1176
+ const path = require("path") as { join: (...args: string[]) => string };
1177
+
1178
+ const workspaceDir = process.env.OPENCLAW_WORKSPACE
1179
+ ? resolve(process.env.OPENCLAW_WORKSPACE)
1180
+ : resolve(process.env.HOME || "~", ".openclaw/workspace");
1181
+ const markerPath = path.join(workspaceDir, ".sulcus-imported");
1182
+
1183
+ if (fs.existsSync(markerPath)) return;
1184
+
1185
+ logger.info("sulcus: first-install history import starting...");
1186
+
1187
+ const memories: string[] = [];
1188
+
1189
+ const memoryMdPath = path.join(workspaceDir, "MEMORY.md");
1190
+ if (fs.existsSync(memoryMdPath)) {
1191
+ try {
1192
+ const text = fs.readFileSync(memoryMdPath, "utf-8");
1193
+ const entries = text.split(/\n(?:---+|\s*\n\s*\n)/g).map((s) => s.trim()).filter((s) => s.length > 20);
1194
+ memories.push(...entries);
1195
+ } catch (_e) { /* best-effort */ }
1196
+ }
1197
+
1198
+ const memDir = path.join(workspaceDir, "memory");
1199
+ if (fs.existsSync(memDir)) {
1200
+ try {
1201
+ const files = fs.readdirSync(memDir);
1202
+ const now = Date.now();
1203
+ const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
1204
+ for (const file of files) {
1205
+ if (!/^\d{4}-\d{2}-\d{2}\.md$/.test(file)) continue;
1206
+ try {
1207
+ const stat = fs.statSync(path.join(memDir, file));
1208
+ if (now - stat.mtimeMs > thirtyDaysMs) continue;
1209
+ const text = fs.readFileSync(path.join(memDir, file), "utf-8");
1210
+ const entries = text.split(/\n---\n/g).map((s) => s.trim()).filter((s) => s.length > 20);
1211
+ memories.push(...entries);
1212
+ } catch (_e) { /* best-effort */ }
1213
+ }
1214
+ } catch (_e) { /* best-effort */ }
1215
+ }
1216
+
1217
+ let stored = 0;
1218
+ for (const mem of memories) {
1219
+ try {
1220
+ await sulcusMem.add_memory(mem, "episodic");
1221
+ stored++;
1222
+ } catch (_e) { /* best-effort */ }
1223
+ }
1224
+
1225
+ try {
1226
+ fs.writeFileSync(markerPath, new Date().toISOString(), "utf-8");
1227
+ logger.info(`sulcus: history import complete — stored ${stored} memories from OpenClaw workspace`);
1228
+ } catch (_e) { /* best-effort */ }
1229
+ }
1230
+
1037
1231
  // ─── PLUGIN ──────────────────────────────────────────────────────────────────
1038
1232
 
1039
1233
  const sulcusPlugin = {
@@ -1042,106 +1236,106 @@ const sulcusPlugin = {
1042
1236
  description: "Sulcus-backed vMMU memory for OpenClaw — thermodynamic decay, reactive triggers, local-first",
1043
1237
  kind: "memory" as const,
1044
1238
 
1045
- register(api: any) {
1239
+ register(api: Record<string, unknown>) {
1240
+ const logger = api.logger as PluginLogger;
1241
+ const pluginConfig = (api.pluginConfig ?? {}) as Record<string, unknown>;
1242
+
1046
1243
  // ── Configuration ──
1047
- const libDir = api.pluginConfig?.libDir
1048
- ? resolve(api.pluginConfig.libDir)
1244
+ const libDir = pluginConfig?.libDir
1245
+ ? resolve(pluginConfig.libDir as string)
1049
1246
  : resolve(process.env.HOME || "~", ".sulcus/lib");
1050
1247
 
1051
- const storeLibPath = api.pluginConfig?.storeLibPath
1052
- ? resolve(api.pluginConfig.storeLibPath)
1248
+ const storeLibPath = pluginConfig?.storeLibPath
1249
+ ? resolve(pluginConfig.storeLibPath as string)
1053
1250
  : resolve(libDir, process.platform === "darwin" ? "libsulcus_store.dylib" : "libsulcus_store.so");
1054
1251
 
1055
- const vectorsLibPath = api.pluginConfig?.vectorsLibPath
1056
- ? resolve(api.pluginConfig.vectorsLibPath)
1252
+ const vectorsLibPath = pluginConfig?.vectorsLibPath
1253
+ ? resolve(pluginConfig.vectorsLibPath as string)
1057
1254
  : resolve(libDir, process.platform === "darwin" ? "libsulcus_vectors.dylib" : "libsulcus_vectors.so");
1058
1255
 
1059
- const wasmDir = api.pluginConfig?.wasmDir
1060
- ? resolve(api.pluginConfig.wasmDir)
1256
+ const wasmDir = pluginConfig?.wasmDir
1257
+ ? resolve(pluginConfig.wasmDir as string)
1061
1258
  : resolve(__dirname, "wasm");
1062
1259
 
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;
1260
+ const serverUrl = pluginConfig?.serverUrl as string | undefined;
1261
+ const apiKey = pluginConfig?.apiKey as string | undefined;
1066
1262
 
1067
- // Default namespace = agent name (prevents everything landing in "default")
1068
- const agentId = api.pluginConfig?.agentId;
1069
- const namespace = api.pluginConfig?.namespace === "default" && agentId
1263
+ const agentId = pluginConfig?.agentId as string | undefined;
1264
+ const namespace = pluginConfig?.namespace === "default" && agentId
1070
1265
  ? agentId
1071
- : (api.pluginConfig?.namespace || agentId || "default");
1266
+ : ((pluginConfig?.namespace as string | undefined) || agentId || "default");
1267
+
1268
+ // New config options (v4.0.0)
1269
+ const autoRecall: boolean = (pluginConfig?.autoRecall as boolean | undefined) ?? false;
1270
+ const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
1271
+ const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
1272
+ const profileFrequency: number = Math.min(500, Math.max(1, (pluginConfig?.profileFrequency as number | undefined) ?? 10));
1072
1273
 
1073
- // ── Load hooks config (config-driven dispatch) ──
1074
- const hooksConfig = loadHooksConfig(api.pluginConfig);
1274
+ // ── Load hooks config ──
1275
+ const hooksConfig = loadHooksConfig(pluginConfig);
1075
1276
 
1076
- // ── Backend init: cloud-first, then local ──
1077
- let sulcusMem: any = null;
1277
+ // ── Backend init ──
1278
+ let sulcusMem: SulcusCloudClient | null = null;
1078
1279
  let backendMode = "unavailable";
1079
1280
  const nativeLoader = new NativeLibLoader(storeLibPath, vectorsLibPath);
1080
1281
 
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
1282
  if (serverUrl && apiKey) {
1084
1283
  try {
1085
- const cloudClient = new SulcusCloudClient(serverUrl, apiKey);
1086
- sulcusMem = cloudClient;
1284
+ sulcusMem = new SulcusCloudClient(serverUrl, apiKey);
1087
1285
  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}`);
1286
+ logger.info(`sulcus: using cloud backend (server: ${serverUrl})`);
1287
+ } catch (e: unknown) {
1288
+ logger.warn(`sulcus: cloud client init failed: ${e instanceof Error ? e.message : e}`);
1091
1289
  }
1092
1290
  }
1093
1291
 
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
1292
  if (sulcusMem === null) {
1097
- nativeLoader.init(api.logger);
1293
+ nativeLoader.init(logger);
1098
1294
  if (nativeLoader.loaded) {
1099
1295
  const wasmJsPath = resolve(wasmDir, "sulcus_wasm.js");
1100
1296
  if (existsSync(wasmJsPath)) {
1101
1297
  try {
1102
- const { SulcusMem, on_init } = require(wasmJsPath);
1298
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1299
+ const { SulcusMem, on_init } = require(wasmJsPath) as { SulcusMem: { create: (q: unknown, e: unknown) => SulcusCloudClient }; on_init?: () => void };
1103
1300
  if (typeof on_init === "function") on_init();
1104
-
1105
- const queryFn = nativeLoader.makeQueryFn();
1106
- const embedFn = nativeLoader.makeEmbedFn();
1107
- sulcusMem = SulcusMem.create(queryFn, embedFn);
1301
+ sulcusMem = SulcusMem.create(nativeLoader.makeQueryFn(), nativeLoader.makeEmbedFn());
1108
1302
  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}`);
1303
+ logger.info(`sulcus: SulcusMem created via WASM (wasm: ${wasmJsPath})`);
1304
+ } catch (e: unknown) {
1305
+ logger.warn(`sulcus: WASM load failed: ${e instanceof Error ? e.message : e}`);
1112
1306
  }
1113
1307
  } else {
1114
- api.logger.warn(`sulcus: WASM module not found at ${wasmJsPath}`);
1308
+ logger.warn(`sulcus: WASM module not found at ${wasmJsPath}`);
1115
1309
  }
1116
1310
  } else {
1117
- api.logger.info(`sulcus: local mode skipped — ${nativeLoader.error || "dylibs not found"}`);
1311
+ logger.info(`sulcus: local mode skipped — ${nativeLoader.error || "dylibs not found"}`);
1118
1312
  }
1119
1313
  }
1120
1314
 
1121
1315
  const isAvailable = sulcusMem !== null;
1316
+ const isCloudBackend = backendMode === "cloud" && sulcusMem instanceof SulcusCloudClient;
1122
1317
 
1123
1318
  // Update static awareness with runtime info
1124
1319
  STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
1125
1320
 
1126
1321
  // ── Startup summary ──
1127
1322
  if (isAvailable) {
1128
- api.logger.info(`sulcus: registered (backend: ${backendMode}, namespace: ${namespace})`);
1323
+ logger.info(`sulcus: registered (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture})`);
1129
1324
  } else {
1130
1325
  const hints: string[] = [];
1131
1326
  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");
1327
+ else if (serverUrl && !apiKey) hints.push("serverUrl set but apiKey missing");
1328
+ else if (!serverUrl && apiKey) hints.push("apiKey set but serverUrl missing");
1134
1329
  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.`);
1330
+ logger.warn(`sulcus: unavailable — ${hints.join("; ") || "unknown reason"}`);
1136
1331
  }
1137
1332
 
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)
1333
+ // ── SIU v2 request helper ──
1334
+ const siuRequestFn = isCloudBackend && sulcusMem
1335
+ ? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1142
1336
  : null;
1143
1337
 
1144
- // ── Shared deps for tool executors ──
1338
+ // ── Shared deps ──
1145
1339
  const toolDeps: ToolDeps = {
1146
1340
  sulcusMem,
1147
1341
  backendMode,
@@ -1150,62 +1344,189 @@ const sulcusPlugin = {
1150
1344
  storeLibPath,
1151
1345
  vectorsLibPath,
1152
1346
  wasmDir,
1153
- logger: api.logger,
1347
+ logger,
1154
1348
  isAvailable,
1155
1349
  siuRequest: siuRequestFn,
1156
1350
  };
1157
1351
 
1158
- // ── Shared context for hook handlers ──
1159
1352
  const handlerCtx: HookHandlerCtx = {
1160
1353
  sulcusMem,
1161
1354
  backendMode,
1162
1355
  namespace,
1163
- logger: api.logger,
1356
+ logger,
1164
1357
  nativeError: nativeLoader.error,
1165
1358
  storeLibPath,
1166
1359
  vectorsLibPath,
1167
1360
  wasmDir,
1168
1361
  };
1169
1362
 
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).
1363
+ // ─────────────────────────────────────────────────────────────────────────
1364
+ // SDK INTEGRATIONS (v4.0.0)
1365
+ // ─────────────────────────────────────────────────────────────────────────
1366
+
1367
+ // 1. registerMemoryRuntime — Sulcus becomes the OpenClaw memory backend
1368
+ if (isCloudBackend && sulcusMem && typeof (api.registerMemoryRuntime as unknown) === "function") {
1369
+ try {
1370
+ (api.registerMemoryRuntime as (r: unknown) => void)(buildMemoryRuntime(sulcusMem as SulcusCloudClient, backendMode));
1371
+ logger.info("sulcus: registered as memory runtime (MemoryPluginRuntime)");
1372
+ } catch (e: unknown) {
1373
+ logger.warn(`sulcus: registerMemoryRuntime failed: ${e instanceof Error ? e.message : e}`);
1374
+ }
1375
+ }
1376
+
1377
+ // 2. registerMemoryPromptSection — dynamic system prompt guidance
1378
+ if (typeof (api.registerMemoryPromptSection as unknown) === "function") {
1379
+ try {
1380
+ (api.registerMemoryPromptSection as (b: unknown) => void)(buildPromptSection);
1381
+ logger.info("sulcus: registered memory prompt section");
1382
+ } catch (e: unknown) {
1383
+ logger.warn(`sulcus: registerMemoryPromptSection failed: ${e instanceof Error ? e.message : e}`);
1384
+ }
1385
+ }
1386
+
1387
+ // 3. registerMemoryFlushPlan — no custom compaction flush
1388
+ if (typeof (api.registerMemoryFlushPlan as unknown) === "function") {
1389
+ try {
1390
+ (api.registerMemoryFlushPlan as (r: unknown) => void)(() => null);
1391
+ logger.info("sulcus: registered memory flush plan (no-op)");
1392
+ } catch (e: unknown) {
1393
+ logger.warn(`sulcus: registerMemoryFlushPlan failed: ${e instanceof Error ? e.message : e}`);
1394
+ }
1395
+ }
1396
+
1397
+ // 4. registerService — lifecycle management
1398
+ if (typeof (api.registerService as unknown) === "function") {
1399
+ try {
1400
+ (api.registerService as (s: unknown) => void)({
1401
+ id: "openclaw-sulcus",
1402
+ start: async (ctx: Record<string, unknown>) => {
1403
+ const svcLogger = (ctx?.logger ?? logger) as PluginLogger;
1404
+ if (!isAvailable || !sulcusMem) {
1405
+ svcLogger.warn("sulcus: service start — backend unavailable, running in degraded mode");
1406
+ return;
1407
+ }
1408
+ if (isCloudBackend) {
1409
+ try {
1410
+ const ok = await (sulcusMem as SulcusCloudClient).probe();
1411
+ if (ok) svcLogger.info(`sulcus: service started — cloud backend connected (${serverUrl}, namespace: ${namespace})`);
1412
+ else svcLogger.warn(`sulcus: service started — cloud backend unreachable (${serverUrl})`);
1413
+ } catch (e
1414
+ : unknown) {
1415
+ svcLogger.warn("sulcus: service start probe failed");
1416
+ }
1417
+ } else {
1418
+ svcLogger.info("sulcus: service started (backend: " + backendMode + ", namespace: " + namespace + ")");
1419
+ }
1420
+ },
1421
+ stop: async (ctx: Record<string, unknown>) => {
1422
+ const svcLogger = (ctx?.logger ?? logger) as PluginLogger;
1423
+ svcLogger.info("sulcus: service stopped");
1424
+ },
1425
+ });
1426
+ logger.info("sulcus: registered service lifecycle");
1427
+ } catch (e: unknown) {
1428
+ logger.warn("sulcus: registerService failed: " + (e instanceof Error ? e.message : String(e)));
1429
+ }
1430
+ }
1431
+
1432
+ // 5. Enhanced before_agent_start with prependContext (SDK path)
1433
+ // When autoRecall=true and cloud backend available, use prependContext SDK pattern.
1434
+ // Falls back to legacy hook-based path when SDK is not available.
1435
+ if (autoRecall && isCloudBackend && sulcusMem) {
1436
+ const sdkRecallHandler = buildSdkRecallHandler(
1437
+ sulcusMem as SulcusCloudClient,
1438
+ namespace,
1439
+ maxRecallResults,
1440
+ profileFrequency,
1441
+ logger
1442
+ );
1443
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1444
+ apiOn("before_agent_start", async (event: Record<string, unknown>, ctx: unknown) => {
1445
+ try {
1446
+ return await sdkRecallHandler(event, ctx);
1447
+ } catch (err) {
1448
+ logger.warn("sulcus: SDK recall hook threw: " + err);
1449
+ return undefined;
1450
+ }
1451
+ });
1452
+ logger.info("sulcus: registered SDK auto-recall (prependContext path)");
1453
+ }
1454
+
1455
+ // 6. auto-capture on agent_end
1456
+ if (autoCapture) {
1457
+ const agentEndCaptureConfig: HookConfig = {
1458
+ action: "sivu_auto_capture",
1459
+ enabled: true,
1460
+ min_store_confidence: 0.5,
1461
+ fallback_on_error: true,
1462
+ };
1463
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1464
+ apiOn("agent_end", async (event: Record<string, unknown>, _ctx: unknown) => {
1465
+ try {
1466
+ return await hookHandlers.sivu_auto_capture(event, agentEndCaptureConfig, handlerCtx);
1467
+ } catch (err) {
1468
+ logger.warn("sulcus: auto-capture hook threw: " + err);
1469
+ return undefined;
1470
+ }
1471
+ });
1472
+ logger.info("sulcus: registered auto-capture (agent_end)");
1473
+ }
1474
+
1475
+ // ─────────────────────────────────────────────────────────────────────────
1476
+ // LEGACY HOOK REGISTRATION (config-driven, backward compat)
1477
+ // ─────────────────────────────────────────────────────────────────────────
1478
+
1174
1479
  for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
1175
1480
  if (!hookConfig.enabled) continue;
1481
+
1482
+ // Skip before_agent_start if we already registered the SDK path
1483
+ if (hookName === "before_agent_start" && autoRecall && isCloudBackend) continue;
1484
+ // Skip agent_end if autoCapture SDK path already registered
1485
+ if (hookName === "agent_end" && autoCapture && hookConfig.action === "sivu_auto_capture") continue;
1486
+
1176
1487
  const handler = hookHandlers[hookConfig.action];
1177
1488
  if (handler) {
1178
- api.on(hookName, async (event: any) => {
1489
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1490
+ apiOn(hookName, async (event: Record<string, unknown>) => {
1179
1491
  try {
1180
1492
  return await handler(event, hookConfig, handlerCtx);
1181
1493
  } catch (err) {
1182
- api.logger.warn(`sulcus: hook "${hookName}" (action=${hookConfig.action}) threw: ${err} returning empty result`);
1494
+ logger.warn("sulcus: hook " + hookName + " (action=" + hookConfig.action + ") threw: " + err);
1183
1495
  return undefined;
1184
1496
  }
1185
1497
  });
1186
1498
  } else {
1187
- api.logger.warn(`sulcus: unknown hook action "${hookConfig.action}" for hook "${hookName}"`);
1499
+ logger.warn("sulcus: unknown hook action " + hookConfig.action + " for hook " + hookName);
1188
1500
  }
1189
1501
  }
1190
1502
 
1191
- // ── Config-driven tool registration ──
1503
+ // ─────────────────────────────────────────────────────────────────────────
1504
+ // TOOL REGISTRATION
1505
+ // ─────────────────────────────────────────────────────────────────────────
1506
+
1192
1507
  for (const [toolName, toolConfig] of Object.entries(hooksConfig.tools)) {
1193
1508
  if (!toolConfig.enabled) continue;
1194
1509
  const toolDef = toolDefinitions[toolName];
1195
1510
  if (toolDef) {
1196
1511
  const schema = {
1197
1512
  ...toolDef.schema,
1198
- async execute(id: string, params: any) {
1513
+ async execute(id: string, params: Record<string, unknown>) {
1199
1514
  return toolDef.makeExecute(toolDeps)(id, params);
1200
1515
  },
1201
1516
  };
1202
- api.registerTool(schema, toolDef.options);
1517
+ const registerTool = api.registerTool as (schema: unknown, opts: unknown) => void;
1518
+ registerTool(schema, toolDef.options);
1203
1519
  } else {
1204
- api.logger.warn(`sulcus: unknown tool "${toolName}" in config — skipping`);
1520
+ logger.warn("sulcus: unknown tool " + toolName + " in config — skipping");
1205
1521
  }
1206
1522
  }
1207
1523
 
1208
- // No service registration needed — no background process to manage
1524
+ // Fire-and-forget first-install history import
1525
+ if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
1526
+ importOpenClawHistory(sulcusMem, logger).catch((e: unknown) => {
1527
+ logger.warn(`sulcus: history import failed: ${e instanceof Error ? e.message : String(e)}`);
1528
+ });
1529
+ }
1209
1530
  }
1210
1531
  };
1211
1532