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