@digitalforgestudios/openclaw-sulcus 5.4.0 → 6.1.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 +1 -1
- package/bin/configure.mjs +0 -0
- package/hooks.defaults.json +99 -40
- package/index.ts +4061 -157
- package/openclaw.plugin.json +275 -4
- package/package.json +4 -4
package/index.ts
CHANGED
|
@@ -1,44 +1,205 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import * as https from "node:https";
|
|
4
5
|
import * as http from "node:http";
|
|
5
6
|
import { URL } from "node:url";
|
|
6
7
|
import { Type } from "@sinclair/typebox";
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
+
// --- SESSION SCOPE (Task 30) -------------------------------------------------------
|
|
10
|
+
// Each plugin instance gets a unique session ID at init time.
|
|
11
|
+
// Session memories are stored under `session:<id>` namespace prefix and
|
|
12
|
+
// auto-purged on agent_end so they never outlive the conversation.
|
|
9
13
|
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
function generateSessionId(): string {
|
|
15
|
+
const ts = Date.now().toString(36);
|
|
16
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
17
|
+
return `${ts}-${rand}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Module-scope session ID — reset on gateway restart (which is the right behavior).
|
|
21
|
+
// Each plugin load gets fresh ephemeral context.
|
|
22
|
+
const CURRENT_SESSION_ID = generateSessionId();
|
|
23
|
+
|
|
24
|
+
// Track IDs of session-scoped memories so we can purge them on agent_end.
|
|
25
|
+
// Using a Set so dedup is free and lookup is O(1).
|
|
26
|
+
const sessionMemoryIds = new Set<string>();
|
|
27
|
+
|
|
28
|
+
// --- SULCUS.TOML CONFIG LAYER (Task 69) ------------------------------------
|
|
29
|
+
// Optional `~/.sulcus/sulcus.toml` config file. Provides file-based defaults
|
|
30
|
+
// that are merged with the plugin UI config (which wins on conflict).
|
|
31
|
+
// Precedence: pluginConfig (OpenClaw UI) > sulcus.toml > built-in defaults.
|
|
32
|
+
//
|
|
33
|
+
// Supported types: string, number, boolean, inline arrays of strings.
|
|
34
|
+
// Supports [sections] which map to nested objects (e.g. [guardrails.outputGuard]).
|
|
35
|
+
// Lines starting with # are comments.
|
|
36
|
+
|
|
37
|
+
/** Flat key-value result from TOML parse — nested keys joined with dots. */
|
|
38
|
+
type TomlFlat = Record<string, string | number | boolean | string[]>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minimal TOML parser for sulcus.toml.
|
|
42
|
+
* Supports: scalar string/number/boolean values, inline string arrays,
|
|
43
|
+
* [section] and [section.subsection] headers, # comments.
|
|
44
|
+
* Does NOT support multi-line strings, inline tables, or date types.
|
|
45
|
+
*/
|
|
46
|
+
function parseSulcusToml(raw: string): TomlFlat {
|
|
47
|
+
const result: TomlFlat = {};
|
|
48
|
+
let section = "";
|
|
49
|
+
|
|
50
|
+
for (const rawLine of raw.split("\n")) {
|
|
51
|
+
const line = rawLine.trim();
|
|
52
|
+
if (!line || line.startsWith("#")) continue;
|
|
53
|
+
|
|
54
|
+
// Section header: [section] or [section.sub]
|
|
55
|
+
if (line.startsWith("[") && line.endsWith("]")) {
|
|
56
|
+
section = line.slice(1, -1).trim();
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Key = value
|
|
61
|
+
const eqIdx = line.indexOf("=");
|
|
62
|
+
if (eqIdx === -1) continue;
|
|
63
|
+
|
|
64
|
+
const rawKey = line.slice(0, eqIdx).trim();
|
|
65
|
+
const rawVal = line.slice(eqIdx + 1).trim();
|
|
66
|
+
const fullKey = section ? `${section}.${rawKey}` : rawKey;
|
|
67
|
+
|
|
68
|
+
// Strip inline comment (outside of strings/arrays)
|
|
69
|
+
const valNoComment = rawVal.replace(/\s*#.*$/, "");
|
|
70
|
+
|
|
71
|
+
// Parse value
|
|
72
|
+
let parsed: string | number | boolean | string[];
|
|
73
|
+
|
|
74
|
+
if (valNoComment === "true") {
|
|
75
|
+
parsed = true;
|
|
76
|
+
} else if (valNoComment === "false") {
|
|
77
|
+
parsed = false;
|
|
78
|
+
} else if (valNoComment.startsWith('[') && valNoComment.endsWith(']')) {
|
|
79
|
+
// Inline array of strings: ["a", "b"] or ['a', 'b']
|
|
80
|
+
const inner = valNoComment.slice(1, -1);
|
|
81
|
+
parsed = inner
|
|
82
|
+
.split(",")
|
|
83
|
+
.map(s => s.trim().replace(/^["']|["']$/g, ""))
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
} else if (
|
|
86
|
+
(valNoComment.startsWith('"') && valNoComment.endsWith('"')) ||
|
|
87
|
+
(valNoComment.startsWith("'") && valNoComment.endsWith("'"))
|
|
88
|
+
) {
|
|
89
|
+
parsed = valNoComment.slice(1, -1);
|
|
90
|
+
} else {
|
|
91
|
+
const num = Number(valNoComment);
|
|
92
|
+
parsed = isNaN(num) ? valNoComment : num;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
result[fullKey] = parsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Expand dotted keys into nested objects.
|
|
103
|
+
* "guardrails.outputGuard.enabled" → { guardrails: { outputGuard: { enabled: … } } }
|
|
104
|
+
*/
|
|
105
|
+
function expandTomlKeys(flat: TomlFlat): Record<string, unknown> {
|
|
106
|
+
const out: Record<string, unknown> = {};
|
|
107
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
108
|
+
const parts = key.split(".");
|
|
109
|
+
let cur = out as Record<string, unknown>;
|
|
110
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
111
|
+
const part = parts[i];
|
|
112
|
+
if (typeof cur[part] !== "object" || cur[part] === null) {
|
|
113
|
+
cur[part] = {};
|
|
114
|
+
}
|
|
115
|
+
cur = cur[part] as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
cur[parts[parts.length - 1]] = value;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
14
121
|
|
|
15
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Load and parse sulcus.toml from disk.
|
|
124
|
+
* Returns an empty object if the file doesn't exist or can't be parsed.
|
|
125
|
+
* @param configPath Override path; defaults to ~/.sulcus/sulcus.toml
|
|
126
|
+
*/
|
|
127
|
+
function loadSulcusToml(
|
|
128
|
+
configPath?: string,
|
|
129
|
+
logger?: { warn: (s: string) => void; info: (s: string) => void }
|
|
130
|
+
): Record<string, unknown> {
|
|
131
|
+
const defaultPath = resolve(process.env.HOME || "~", ".sulcus/sulcus.toml");
|
|
132
|
+
const tomlPath = configPath ?? defaultPath;
|
|
133
|
+
|
|
134
|
+
if (!existsSync(tomlPath)) {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const raw = readFileSync(tomlPath, "utf8");
|
|
140
|
+
const flat = parseSulcusToml(raw);
|
|
141
|
+
const expanded = expandTomlKeys(flat);
|
|
142
|
+
const keyCount = Object.keys(flat).length;
|
|
143
|
+
logger?.info(`sulcus: loaded sulcus.toml (${keyCount} keys) from ${tomlPath}`);
|
|
144
|
+
return expanded;
|
|
145
|
+
} catch (err: unknown) {
|
|
146
|
+
logger?.warn(`sulcus: failed to parse sulcus.toml at ${tomlPath}: ${(err as Error).message}`);
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
16
150
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Deep-merge two config objects.
|
|
153
|
+
* `override` wins on scalar conflicts; nested objects are merged recursively.
|
|
154
|
+
* Used to merge sulcus.toml (base) with pluginConfig (override).
|
|
155
|
+
*/
|
|
156
|
+
function mergeConfig(
|
|
157
|
+
base: Record<string, unknown>,
|
|
158
|
+
override: Record<string, unknown>
|
|
159
|
+
): Record<string, unknown> {
|
|
160
|
+
const result: Record<string, unknown> = { ...base };
|
|
161
|
+
for (const [key, val] of Object.entries(override)) {
|
|
162
|
+
if (
|
|
163
|
+
val !== null &&
|
|
164
|
+
typeof val === "object" &&
|
|
165
|
+
!Array.isArray(val) &&
|
|
166
|
+
typeof result[key] === "object" &&
|
|
167
|
+
result[key] !== null &&
|
|
168
|
+
!Array.isArray(result[key])
|
|
169
|
+
) {
|
|
170
|
+
// Both sides are objects — recurse
|
|
171
|
+
result[key] = mergeConfig(
|
|
172
|
+
result[key] as Record<string, unknown>,
|
|
173
|
+
val as Record<string, unknown>
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
// Scalar or array — override wins
|
|
177
|
+
result[key] = val;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
22
182
|
|
|
23
|
-
|
|
24
|
-
|
|
183
|
+
// --- STATIC AWARENESS -------------------------------------------------------
|
|
184
|
+
// Task 12: XML-structured injection — static awareness uses the same
|
|
185
|
+
// <sulcus_context> envelope as the dynamic recall path for LLM consistency.
|
|
25
186
|
|
|
26
|
-
|
|
187
|
+
function buildStaticAwareness(backendMode: string, namespace: string): string {
|
|
188
|
+
// Minimal awareness — tool schemas handle parameter docs.
|
|
189
|
+
// One sentence: you have memory, it's thermodynamic, it's automatic.
|
|
190
|
+
return `<sulcus_context backend="${backendMode}" namespace="${namespace}">
|
|
191
|
+
You have Sulcus — persistent, thermodynamic memory. Memories survive across sessions with heat (0.0\u20131.0) that decays over time. Context is injected automatically each turn.
|
|
192
|
+
</sulcus_context>`;
|
|
27
193
|
}
|
|
28
194
|
|
|
29
195
|
let STATIC_AWARENESS = buildStaticAwareness("local", "default");
|
|
30
196
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
STORE: memory_store (content, memory_type)
|
|
35
|
-
FIND: memory_recall (query, limit)
|
|
36
|
-
TYPES: episodic (fast fade), semantic (slow), preference, procedural (slowest), fact
|
|
37
|
-
Context build failed this turn — use memory_recall to search manually.
|
|
38
|
-
</cheatsheet>
|
|
197
|
+
// Fallback when recall fails — same minimal awareness, plus a hint to search manually.
|
|
198
|
+
const FALLBACK_AWARENESS = `<sulcus_context token_budget="4000">
|
|
199
|
+
You have Sulcus — persistent memory. Context build failed this turn. Use memory_recall to search manually.
|
|
39
200
|
</sulcus_context>`;
|
|
40
201
|
|
|
41
|
-
//
|
|
202
|
+
// --- HOOKS CONFIG TYPES ------------------------------------------------------
|
|
42
203
|
|
|
43
204
|
interface HookConfig {
|
|
44
205
|
action: string;
|
|
@@ -69,6 +230,12 @@ interface HookHandlerCtx {
|
|
|
69
230
|
storeLibPath?: string;
|
|
70
231
|
vectorsLibPath?: string;
|
|
71
232
|
wasmDir?: string;
|
|
233
|
+
boostOnRecall?: boolean;
|
|
234
|
+
profileFrequency?: number;
|
|
235
|
+
/** Task 66: configurable token budget for recall context injection. Default: 4000. */
|
|
236
|
+
tokenBudget?: number;
|
|
237
|
+
/** Task 102: model context window size in tokens. Default: 200000. */
|
|
238
|
+
contextWindowSize?: number;
|
|
72
239
|
}
|
|
73
240
|
|
|
74
241
|
interface PluginLogger {
|
|
@@ -80,7 +247,324 @@ interface PluginLogger {
|
|
|
80
247
|
|
|
81
248
|
type HookHandler = (event: Record<string, unknown>, config: HookConfig, ctx: HookHandlerCtx) => Promise<unknown>;
|
|
82
249
|
|
|
83
|
-
//
|
|
250
|
+
// --- GUARDRAILS (Task 54) --------------------------------------------------
|
|
251
|
+
// Memory-aware output guard. Intercepts agent output before delivery.
|
|
252
|
+
// Two hooks: llm_output (fast pre-analysis, regex only) + message_sending (enforcement).
|
|
253
|
+
// All disabled by default — opt-in via guardrails.outputGuard.enabled in plugin config.
|
|
254
|
+
|
|
255
|
+
interface PiiPattern {
|
|
256
|
+
name: string;
|
|
257
|
+
regex: RegExp;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface PiiSpan {
|
|
261
|
+
start: number;
|
|
262
|
+
end: number;
|
|
263
|
+
type: string;
|
|
264
|
+
redactionId: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface SulcusGuardFlags {
|
|
268
|
+
piiDetected: boolean;
|
|
269
|
+
piiSpans: PiiSpan[];
|
|
270
|
+
suspectedPreferenceViolation: boolean;
|
|
271
|
+
suspectedViolationReason?: string;
|
|
272
|
+
scanTimeMs: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface OutputGuardConfig {
|
|
276
|
+
enabled: boolean;
|
|
277
|
+
pii: {
|
|
278
|
+
enabled: boolean;
|
|
279
|
+
reversible: boolean;
|
|
280
|
+
storageKey: string;
|
|
281
|
+
patterns: string[];
|
|
282
|
+
customPatterns: Array<{ name: string; regex: string; replacement?: string }>;
|
|
283
|
+
onViolation: "redact" | "replace" | "block";
|
|
284
|
+
};
|
|
285
|
+
preferenceViolation: {
|
|
286
|
+
enabled: boolean;
|
|
287
|
+
onViolation: "replace" | "warn" | "block";
|
|
288
|
+
replacementMessage: string;
|
|
289
|
+
};
|
|
290
|
+
failMode: "fail-open" | "fail-closed";
|
|
291
|
+
auditTrail: boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface ToolGuardConfig {
|
|
295
|
+
enabled: boolean;
|
|
296
|
+
sensitiveTools: string[];
|
|
297
|
+
requireApprovalThreshold: "info" | "warning" | "critical";
|
|
298
|
+
allowlist: string[];
|
|
299
|
+
blocklist: string[];
|
|
300
|
+
objectiveCheck: boolean;
|
|
301
|
+
failMode: "fail-open" | "fail-closed";
|
|
302
|
+
auditTrail: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Built-in PII patterns (GDPR-neutral, common formats)
|
|
306
|
+
const BUILTIN_PII_PATTERNS: PiiPattern[] = [
|
|
307
|
+
{ name: "email", regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g },
|
|
308
|
+
{ name: "phone", regex: /(?:\+?\d[\s.\-]?)?(?:\(?\d{3}\)?[\s.\-]?)\d{3}[\s.\-]?\d{4}\b/g },
|
|
309
|
+
{ name: "ssn", regex: /\b\d{3}[\s\-]\d{2}[\s\-]\d{4}\b/g },
|
|
310
|
+
{ name: "credit_card", regex: /\b(?:4\d{12}(?:\d{3})?|5[1-5]\d{14}|3[47]\d{13}|6011\d{12}|3(?:0[0-5]|[68]\d)\d{11})\b/g },
|
|
311
|
+
{ name: "ip_address", regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
// Module-scope negative preference cache to avoid hammering Sulcus on every message
|
|
315
|
+
interface NegPrefCache {
|
|
316
|
+
prefs: string[];
|
|
317
|
+
cachedAt: number;
|
|
318
|
+
namespace: string;
|
|
319
|
+
}
|
|
320
|
+
let negPrefCache: NegPrefCache | null = null;
|
|
321
|
+
const NEG_PREF_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
322
|
+
|
|
323
|
+
// Module-scope llm_output flag relay (flags produced by llm_output, consumed by message_sending)
|
|
324
|
+
// Keyed by a simple turn counter since OpenClaw doesn't expose a stable runId.
|
|
325
|
+
let lastGuardFlags: SulcusGuardFlags | null = null;
|
|
326
|
+
|
|
327
|
+
// --- INSPECT BUFFER (Task 56) ------------------------------------------------
|
|
328
|
+
// Module-scope debug window for memory_inspect tool.
|
|
329
|
+
// Captures last recall injection + guardrail events across both hook and SDK paths.
|
|
330
|
+
// Never resets intentionally — survives topic shifts so agent can review last N events.
|
|
331
|
+
interface InspectRecallSnapshot {
|
|
332
|
+
capturedAt: number; // Date.now() when injection happened
|
|
333
|
+
path: "hook" | "sdk"; // which recall path produced this
|
|
334
|
+
turn: number; // turn number within the session
|
|
335
|
+
query: string; // query used for recall (first 200 chars)
|
|
336
|
+
fromCache: boolean; // true = HookRecallCache hit, false = fresh API call
|
|
337
|
+
itemsInjected: number; // total items injected (profile + recall combined)
|
|
338
|
+
recallItems: Array<{ // recall items (not profile items)
|
|
339
|
+
id: string;
|
|
340
|
+
content_preview: string; // first 80 chars
|
|
341
|
+
memory_type: string;
|
|
342
|
+
heat: number;
|
|
343
|
+
score: number | null; // server fused_score if available
|
|
344
|
+
stale: boolean; // age > 30 days
|
|
345
|
+
source: "semantic" | "graph" | "unknown";
|
|
346
|
+
}>;
|
|
347
|
+
profileItems: number; // number of profile items injected
|
|
348
|
+
staleCount: number; // items flagged as stale
|
|
349
|
+
graphHopCount: number; // items from graph expansion
|
|
350
|
+
tokensBudget: number;
|
|
351
|
+
tokensUsed: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
interface InspectGuardrailEvent {
|
|
355
|
+
capturedAt: number;
|
|
356
|
+
guard: "output" | "tool";
|
|
357
|
+
eventType: string; // e.g. "pii_redacted", "preference_violation", "tool_blocked", "tool_allowed"
|
|
358
|
+
action: string; // e.g. "redact", "block", "allow", "warn"
|
|
359
|
+
details: string;
|
|
360
|
+
toolName?: string; // for tool guard events
|
|
361
|
+
severity?: string; // for tool guard events
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
interface InspectBuffer {
|
|
365
|
+
lastRecall: InspectRecallSnapshot | null;
|
|
366
|
+
guardrailEvents: InspectGuardrailEvent[]; // ring buffer, last 10
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const inspectBuffer: InspectBuffer = {
|
|
370
|
+
lastRecall: null,
|
|
371
|
+
guardrailEvents: [],
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const INSPECT_GUARDRAIL_MAX = 10;
|
|
375
|
+
|
|
376
|
+
// --- GUARDRAIL STATUS SNAPSHOT (Task 57) ------------------------------------
|
|
377
|
+
// Set during init after parsing both guard configs. Read by guardrail_status tool.
|
|
378
|
+
interface GuardrailStatusSnapshot {
|
|
379
|
+
outputGuard: {
|
|
380
|
+
enabled: boolean;
|
|
381
|
+
pii: { enabled: boolean; patterns: string[]; onViolation: string; reversible: boolean };
|
|
382
|
+
preferenceViolation: { enabled: boolean; onViolation: string };
|
|
383
|
+
failMode: string;
|
|
384
|
+
auditTrail: boolean;
|
|
385
|
+
};
|
|
386
|
+
toolGuard: {
|
|
387
|
+
enabled: boolean;
|
|
388
|
+
sensitiveTools: string[];
|
|
389
|
+
allowlist: string[];
|
|
390
|
+
blocklist: string[];
|
|
391
|
+
objectiveCheck: boolean;
|
|
392
|
+
requireApprovalThreshold: string;
|
|
393
|
+
failMode: string;
|
|
394
|
+
auditTrail: boolean;
|
|
395
|
+
};
|
|
396
|
+
negPrefCount: () => number;
|
|
397
|
+
negPrefCachedAt: () => number | null;
|
|
398
|
+
}
|
|
399
|
+
let guardrailStatus: GuardrailStatusSnapshot | null = null;
|
|
400
|
+
|
|
401
|
+
function pushGuardrailEvent(evt: InspectGuardrailEvent): void {
|
|
402
|
+
inspectBuffer.guardrailEvents.push(evt);
|
|
403
|
+
if (inspectBuffer.guardrailEvents.length > INSPECT_GUARDRAIL_MAX) {
|
|
404
|
+
inspectBuffer.guardrailEvents.shift();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function scanForPii(content: string, activePatterns: string[], customPatterns: Array<{ name: string; regex: string }>): PiiSpan[] {
|
|
409
|
+
const spans: PiiSpan[] = [];
|
|
410
|
+
const patterns = BUILTIN_PII_PATTERNS.filter(p => activePatterns.includes(p.name));
|
|
411
|
+
// Add custom patterns
|
|
412
|
+
for (const cp of customPatterns) {
|
|
413
|
+
try { patterns.push({ name: cp.name, regex: new RegExp(cp.regex, "g") }); } catch { /* ignore bad regex */ }
|
|
414
|
+
}
|
|
415
|
+
for (const pat of patterns) {
|
|
416
|
+
const re = new RegExp(pat.regex.source, "g");
|
|
417
|
+
let m: RegExpExecArray | null;
|
|
418
|
+
while ((m = re.exec(content)) !== null) {
|
|
419
|
+
const redactionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
420
|
+
spans.push({ start: m.index, end: m.index + m[0].length, type: pat.name, redactionId });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Sort by start position for left-to-right replacement
|
|
424
|
+
spans.sort((a, b) => a.start - b.start);
|
|
425
|
+
return spans;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function redactSpans(content: string, spans: PiiSpan[]): string {
|
|
429
|
+
let result = "";
|
|
430
|
+
let cursor = 0;
|
|
431
|
+
for (const span of spans) {
|
|
432
|
+
if (span.start > cursor) result += content.slice(cursor, span.start);
|
|
433
|
+
result += `[REDACTED-${span.redactionId}]`;
|
|
434
|
+
cursor = span.end;
|
|
435
|
+
}
|
|
436
|
+
result += content.slice(cursor);
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function storeRedactionKey(spans: PiiSpan[], content: string, storageKey: string, namespace: string): void {
|
|
441
|
+
// Best-effort local reversible redaction storage (plain JSON, no crypto dep in plugin)
|
|
442
|
+
// NOTE: Full AES-256 encryption from design doc requires native crypto — using JSON for now.
|
|
443
|
+
// The owning agent can decrypt by reading this file. Cross-agent sharing requires file export.
|
|
444
|
+
try {
|
|
445
|
+
const keyPath = storageKey.replace("~", process.env.HOME || "~");
|
|
446
|
+
let store: Record<string, unknown> = {};
|
|
447
|
+
if (existsSync(keyPath)) {
|
|
448
|
+
try { store = JSON.parse(readFileSync(keyPath, "utf-8")); } catch { store = {}; }
|
|
449
|
+
}
|
|
450
|
+
if (!store.version) { store.version = 1; store.entries = {}; }
|
|
451
|
+
const entries = store.entries as Record<string, unknown>;
|
|
452
|
+
for (const span of spans) {
|
|
453
|
+
entries[span.redactionId] = {
|
|
454
|
+
original: content.slice(span.start, span.end),
|
|
455
|
+
type: span.type,
|
|
456
|
+
redactedAt: new Date().toISOString(),
|
|
457
|
+
namespace,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const dir = keyPath.split("/").slice(0, -1).join("/");
|
|
461
|
+
if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
462
|
+
writeFileSync(keyPath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
463
|
+
} catch { /* best effort — don't break output delivery */ }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function parseOutputGuardConfig(pluginConfig: Record<string, unknown>): OutputGuardConfig {
|
|
467
|
+
const g = (pluginConfig?.guardrails as Record<string, unknown> | undefined) ?? {};
|
|
468
|
+
const og = (g?.outputGuard as Record<string, unknown> | undefined) ?? {};
|
|
469
|
+
const pii = (og?.pii as Record<string, unknown> | undefined) ?? {};
|
|
470
|
+
const pv = (og?.preferenceViolation as Record<string, unknown> | undefined) ?? {};
|
|
471
|
+
return {
|
|
472
|
+
enabled: (og?.enabled as boolean | undefined) ?? false,
|
|
473
|
+
pii: {
|
|
474
|
+
enabled: (pii?.enabled as boolean | undefined) ?? false,
|
|
475
|
+
reversible: (pii?.reversible as boolean | undefined) ?? true,
|
|
476
|
+
storageKey: (pii?.storageKey as string | undefined) ?? "~/.openclaw/sulcus-redaction-key.json",
|
|
477
|
+
patterns: (pii?.patterns as string[] | undefined) ?? ["email", "phone", "ssn", "credit_card", "ip_address"],
|
|
478
|
+
customPatterns: (pii?.customPatterns as Array<{ name: string; regex: string }> | undefined) ?? [],
|
|
479
|
+
onViolation: ((pii?.onViolation as string | undefined) ?? "redact") as "redact" | "replace" | "block",
|
|
480
|
+
},
|
|
481
|
+
preferenceViolation: {
|
|
482
|
+
enabled: (pv?.enabled as boolean | undefined) ?? true,
|
|
483
|
+
onViolation: ((pv?.onViolation as string | undefined) ?? "replace") as "replace" | "warn" | "block",
|
|
484
|
+
replacementMessage: (pv?.replacementMessage as string | undefined) ?? "⚠️ I stopped myself — this output would violate a preference you've stored with me.",
|
|
485
|
+
},
|
|
486
|
+
failMode: ((og?.failMode as string | undefined) ?? "fail-open") as "fail-open" | "fail-closed",
|
|
487
|
+
auditTrail: (og?.auditTrail as boolean | undefined) ?? true,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function parseToolGuardConfig(pluginConfig: Record<string, unknown>): ToolGuardConfig {
|
|
492
|
+
const g = (pluginConfig?.guardrails as Record<string, unknown> | undefined) ?? {};
|
|
493
|
+
const tg = (g?.toolGuard as Record<string, unknown> | undefined) ?? {};
|
|
494
|
+
return {
|
|
495
|
+
enabled: (tg?.enabled as boolean | undefined) ?? false,
|
|
496
|
+
sensitiveTools: (tg?.sensitiveTools as string[] | undefined) ?? ["exec", "write", "edit", "delete", "message"],
|
|
497
|
+
requireApprovalThreshold: ((tg?.requireApprovalThreshold as string | undefined) ?? "warning") as "info" | "warning" | "critical",
|
|
498
|
+
allowlist: (tg?.allowlist as string[] | undefined) ?? [],
|
|
499
|
+
blocklist: (tg?.blocklist as string[] | undefined) ?? [],
|
|
500
|
+
objectiveCheck: (tg?.objectiveCheck as boolean | undefined) ?? true,
|
|
501
|
+
failMode: ((tg?.failMode as string | undefined) ?? "fail-open") as "fail-open" | "fail-closed",
|
|
502
|
+
auditTrail: (tg?.auditTrail as boolean | undefined) ?? true,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- HOOK RECALL CACHE (Task 14 parity for hook path) ----------------------
|
|
507
|
+
// Per-namespace topic-shift cache for the auto_recall hook.
|
|
508
|
+
// Mirrors the SDK recall handler cache so hooks avoid redundant API calls
|
|
509
|
+
// when the conversation topic is stable across consecutive turns.
|
|
510
|
+
|
|
511
|
+
interface HookRecallCache {
|
|
512
|
+
results: Record<string, unknown>[];
|
|
513
|
+
topicTokens: Set<string>;
|
|
514
|
+
cachedAt: number;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const hookRecallCacheMap = new Map<string, HookRecallCache>();
|
|
518
|
+
const HOOK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
519
|
+
const HOOK_TOPIC_SHIFT_THRESHOLD = 0.25; // Jaccard overlap below this = topic shift
|
|
520
|
+
|
|
521
|
+
// --- RECALL QUALITY METRICS (Task 32) --------------------------------------
|
|
522
|
+
// Module-scope counters — written by both SDK and hook recall paths.
|
|
523
|
+
// Exposed via memory_status so the agent can inspect recall health.
|
|
524
|
+
// Resets on gateway restart (session-scoped is intentional).
|
|
525
|
+
interface RecallQualityMetrics {
|
|
526
|
+
freshRecalls: number; // turns where API was called (topic shifted)
|
|
527
|
+
cacheHits: number; // turns where cached results were served
|
|
528
|
+
totalItemsServed: number; // cumulative recall items injected
|
|
529
|
+
zeroResultTurns: number; // turns where recall returned nothing
|
|
530
|
+
graphHopContrib: number; // total graph-hop items folded in
|
|
531
|
+
graphHopTurns: number; // turns with at least one graph-hop item
|
|
532
|
+
scoreSum: number; // sum of avg heat per fresh recall (for avg relevance)
|
|
533
|
+
scoreTurns: number; // fresh recall turns that had items (for avg relevance)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const recallQM: RecallQualityMetrics = {
|
|
537
|
+
freshRecalls: 0,
|
|
538
|
+
cacheHits: 0,
|
|
539
|
+
totalItemsServed: 0,
|
|
540
|
+
zeroResultTurns: 0,
|
|
541
|
+
graphHopContrib: 0,
|
|
542
|
+
graphHopTurns: 0,
|
|
543
|
+
scoreSum: 0,
|
|
544
|
+
scoreTurns: 0,
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// --- COMPACTION REBUILD FLAG (Task 70) --------------------------------------
|
|
548
|
+
// Set to true when before_compaction fires. Cleared after the first
|
|
549
|
+
// post-compaction before_prompt_build injects a rich Sulcus context rebuild.
|
|
550
|
+
// This is per-session (module scope); each gateway restart resets it correctly.
|
|
551
|
+
let wasJustCompacted = false;
|
|
552
|
+
|
|
553
|
+
// Token budget for post-compaction context rebuild. Configured via
|
|
554
|
+
// contextRebuild.tokenBudget (default 4000, max 10000).
|
|
555
|
+
let REBUILD_TOKEN_BUDGET = 4000;
|
|
556
|
+
|
|
557
|
+
// --- HOOK PROFILE STATE (Task 31) --------------------------------------------
|
|
558
|
+
// Per-namespace profile cache for the auto_recall hook.
|
|
559
|
+
// Mirrors the SDK recall handler: inject full profile on turn 1 + every N turns,
|
|
560
|
+
// serve cached profile on stable turns to reduce token waste.
|
|
561
|
+
interface HookProfileState {
|
|
562
|
+
turnCount: number;
|
|
563
|
+
cache: { preferences: Record<string, unknown>[]; facts: Record<string, unknown>[]; cachedAt: number } | null;
|
|
564
|
+
}
|
|
565
|
+
const hookProfileStateMap = new Map<string, HookProfileState>();
|
|
566
|
+
|
|
567
|
+
// --- HOOK HANDLERS -----------------------------------------------------------
|
|
84
568
|
|
|
85
569
|
const hookHandlers: Record<string, HookHandler> = {
|
|
86
570
|
inject_awareness: async (_event, _config, _ctx) => {
|
|
@@ -88,28 +572,363 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
88
572
|
},
|
|
89
573
|
|
|
90
574
|
auto_recall: async (event, config, ctx) => {
|
|
575
|
+
// Task 22: Unified recall pipeline — same XML formatting, budget enforcement,
|
|
576
|
+
// diversity filter, and conflict surfacing as the SDK recall path.
|
|
577
|
+
// Task 14 parity: topic-shift detection + per-namespace cache for hook path.
|
|
578
|
+
// Task 31: Profile injection frequency — full profile on turn 1 + every N turns.
|
|
91
579
|
const { sulcusMem, namespace, logger } = ctx;
|
|
92
580
|
if (!sulcusMem) return;
|
|
93
581
|
const agentLabel = (event?.agentId as string) ?? "(unknown)";
|
|
94
582
|
logger.info(`sulcus: auto_recall hook triggered for agent ${agentLabel}`);
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
583
|
+
const rawPrompt = typeof event?.prompt === "string" ? event.prompt : "";
|
|
584
|
+
if (!rawPrompt) return;
|
|
585
|
+
// Strip OpenClaw metadata noise before using as search query
|
|
586
|
+
const prompt = sanitizeRecallQuery(rawPrompt);
|
|
587
|
+
if (!prompt || prompt.length < 3) return;
|
|
588
|
+
// Task 62: Use focused last-user-turn for recall query; full prompt for topic-shift
|
|
589
|
+
const recallQuery = extractLastUserTurn(rawPrompt);
|
|
97
590
|
try {
|
|
98
591
|
const limit = (config.limit as number) ?? 5;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
592
|
+
|
|
593
|
+
// -- Task 31: Profile injection frequency ------------------------------
|
|
594
|
+
// Track per-namespace turn count + profile cache. Inject full profile
|
|
595
|
+
// (prefs + facts) on turn 1 and every profileFrequency turns; serve
|
|
596
|
+
// cache on stable turns to avoid redundant API calls.
|
|
597
|
+
const profileFreq = ctx.profileFrequency ?? 10;
|
|
598
|
+
let hookProfileState = hookProfileStateMap.get(namespace);
|
|
599
|
+
if (!hookProfileState) {
|
|
600
|
+
hookProfileState = { turnCount: 0, cache: null };
|
|
601
|
+
hookProfileStateMap.set(namespace, hookProfileState);
|
|
602
|
+
}
|
|
603
|
+
hookProfileState.turnCount++;
|
|
604
|
+
const hookTurn = hookProfileState.turnCount;
|
|
605
|
+
const includeProfile = hookTurn === 1 || hookTurn % profileFreq === 0;
|
|
606
|
+
|
|
607
|
+
// -- Task 101: Adaptive scaling — reduce recall footprint as conversation grows
|
|
608
|
+
const hookScale = applyAdaptiveScaling(hookTurn, limit, ctx.tokenBudget ?? 4000);
|
|
609
|
+
|
|
610
|
+
// -- Task 102: Context-window-aware throttling (hook path) — same logic as SDK
|
|
611
|
+
const hookContextWindow = ctx.contextWindowSize ?? 200000;
|
|
612
|
+
const hookThrottled = applyContextWindowThrottle(rawPrompt.length, hookContextWindow, hookScale, logger);
|
|
613
|
+
if (hookThrottled.selfMuted) {
|
|
614
|
+
logger.warn(`sulcus: hook path self-muted — context ${((rawPrompt.length / 4 / hookContextWindow) * 100).toFixed(0)}% full`);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const hookEffectiveLimit = hookThrottled.effectiveMax;
|
|
618
|
+
const hookEffectiveTokenBudget = hookThrottled.effectiveTokenBudget;
|
|
619
|
+
if (hookTurn > 5) logger.debug?.(`sulcus: adaptive scaling (hook turn ${hookTurn}) — limit=${hookEffectiveLimit}, budget=${hookEffectiveTokenBudget}`);
|
|
620
|
+
// -- end Task 31 + 102 --------------------------------------------------
|
|
621
|
+
|
|
622
|
+
// -- Topic-shift detection (Task 14 parity) ----------------------------
|
|
623
|
+
const cacheKey = namespace;
|
|
624
|
+
const currentTokens = extractTopicTokens(prompt);
|
|
625
|
+
const existingCache = hookRecallCacheMap.get(cacheKey);
|
|
626
|
+
const cacheExpired = existingCache !== undefined && (Date.now() - existingCache.cachedAt) > HOOK_CACHE_TTL_MS;
|
|
627
|
+
const overlap = existingCache !== undefined ? topicOverlap(currentTokens, existingCache.topicTokens) : 0;
|
|
628
|
+
const topicShifted = existingCache === undefined || cacheExpired || overlap < HOOK_TOPIC_SHIFT_THRESHOLD;
|
|
629
|
+
|
|
630
|
+
let vectorResults: Record<string, unknown>[];
|
|
631
|
+
if (!topicShifted && existingCache !== undefined) {
|
|
632
|
+
vectorResults = existingCache.results;
|
|
633
|
+
recallQM.cacheHits++; // Task 32: module-scope QM
|
|
634
|
+
logger.info(`sulcus: auto_recall hook — topic stable (overlap=${overlap.toFixed(2)}), serving cached recall`);
|
|
635
|
+
} else {
|
|
636
|
+
if (existingCache !== undefined) {
|
|
637
|
+
logger.info(`sulcus: auto_recall hook — TOPIC SHIFT detected (overlap=${overlap.toFixed(2)}), fresh recall`);
|
|
638
|
+
}
|
|
639
|
+
logger.debug?.(`sulcus: searching context for prompt (focused: ${recallQuery.substring(0, 50)}...) (namespace: ${namespace})`);
|
|
640
|
+
// Task 62: Use focused last-user-turn query for better relevance
|
|
641
|
+
// Task 101: Use adaptive limit instead of raw config limit
|
|
642
|
+
const res = await sulcusMem.search_memory(recallQuery, hookEffectiveLimit, namespace);
|
|
643
|
+
vectorResults = res?.results ?? [];
|
|
644
|
+
recallQM.freshRecalls++; // Task 32: module-scope QM
|
|
645
|
+
// Update cache with fresh results
|
|
646
|
+
hookRecallCacheMap.set(cacheKey, { results: vectorResults, topicTokens: currentTokens, cachedAt: Date.now() });
|
|
647
|
+
}
|
|
648
|
+
// -- end topic-shift detection -----------------------------------------
|
|
649
|
+
if (!vectorResults || vectorResults.length === 0) {
|
|
650
|
+
recallQM.zeroResultTurns++; // Task 32: module-scope QM
|
|
103
651
|
return { prependSystemContext: FALLBACK_AWARENESS };
|
|
104
652
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
653
|
+
|
|
654
|
+
// -- Task 35: Query expansion for thin recall --------------------------
|
|
655
|
+
// When vector search returns < THIN_RECALL_THRESHOLD results, use the
|
|
656
|
+
// entity knowledge graph to find synonym terms and directly-connected
|
|
657
|
+
// memories, then do a second vector search with the expanded query.
|
|
658
|
+
let hookExpanded = vectorResults;
|
|
659
|
+
if (topicShifted && vectorResults.length < THIN_RECALL_THRESHOLD && sulcusMem instanceof SulcusCloudClient) {
|
|
660
|
+
try {
|
|
661
|
+
// Task 62: use focused recallQuery for entity expansion too
|
|
662
|
+
const { extraMemories, expandedQuery } = await expandQueryWithEntities(
|
|
663
|
+
sulcusMem, recallQuery, namespace, logger
|
|
664
|
+
);
|
|
665
|
+
// Merge extra memories from entity graph (dedup by ID)
|
|
666
|
+
const seenExpandIds = new Set(vectorResults.map((r) => r.id as string));
|
|
667
|
+
const newExtras = extraMemories.filter((m) => !seenExpandIds.has(m.id as string));
|
|
668
|
+
if (newExtras.length > 0) {
|
|
669
|
+
hookExpanded = [...vectorResults, ...newExtras];
|
|
670
|
+
logger.info(`sulcus: auto_recall thin-recall expansion added ${newExtras.length} entity-graph memory/memories`);
|
|
671
|
+
}
|
|
672
|
+
// If still thin, do second vector search with expanded query
|
|
673
|
+
if (hookExpanded.length < THIN_RECALL_THRESHOLD && expandedQuery !== recallQuery) {
|
|
674
|
+
try {
|
|
675
|
+
const expandedRes = await sulcusMem.search_memory(expandedQuery, hookEffectiveLimit, namespace);
|
|
676
|
+
const expandedVec = expandedRes?.results ?? [];
|
|
677
|
+
const expandedSeenIds = new Set(hookExpanded.map((r) => r.id as string));
|
|
678
|
+
const newVecExtras = expandedVec.filter((r) => !expandedSeenIds.has(r.id as string));
|
|
679
|
+
if (newVecExtras.length > 0) {
|
|
680
|
+
hookExpanded = [...hookExpanded, ...newVecExtras];
|
|
681
|
+
logger.info(`sulcus: auto_recall expanded query search added ${newVecExtras.length} result(s)`);
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// expanded search failed — keep what we have
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
} catch {
|
|
688
|
+
// expansion failed — proceed with original results
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const vectorResults_expanded = hookExpanded;
|
|
692
|
+
// -- end Task 35 -------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
// -- Graph-hop expansion (Task 13) — parity with SDK recall path ------
|
|
695
|
+
// Seed from top-2 vector hits, fetch AGE neighbours, fold in warm nodes.
|
|
696
|
+
let rawResults = vectorResults_expanded;
|
|
697
|
+
if (sulcusMem instanceof SulcusCloudClient) {
|
|
698
|
+
const seedIds = vectorResults_expanded.slice(0, 2).map((r) => r.id as string).filter(Boolean);
|
|
699
|
+
if (seedIds.length > 0) {
|
|
700
|
+
try {
|
|
701
|
+
const neighborFetches = await Promise.allSettled(
|
|
702
|
+
seedIds.map((id) => (sulcusMem as SulcusCloudClient).graph_neighbors(id, 6))
|
|
703
|
+
);
|
|
704
|
+
const seenIds = new Set(vectorResults_expanded.map((r) => r.id as string));
|
|
705
|
+
const graphExtras: Record<string, unknown>[] = [];
|
|
706
|
+
for (const result of neighborFetches) {
|
|
707
|
+
if (result.status !== "fulfilled") continue;
|
|
708
|
+
for (const node of result.value) {
|
|
709
|
+
const nodeId = node.id as string;
|
|
710
|
+
if (!nodeId || seenIds.has(nodeId)) continue;
|
|
711
|
+
const heat = (node.current_heat as number) ?? 0;
|
|
712
|
+
if (heat < 0.2) continue; // skip cold ephemeral noise
|
|
713
|
+
seenIds.add(nodeId);
|
|
714
|
+
graphExtras.push({ ...node, _source: "graph" });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (graphExtras.length > 0) {
|
|
718
|
+
graphExtras.sort((a, b) => ((b.current_heat as number) ?? 0) - ((a.current_heat as number) ?? 0));
|
|
719
|
+
const hopCount = Math.min(graphExtras.length, 4);
|
|
720
|
+
rawResults = [...vectorResults_expanded, ...graphExtras.slice(0, hopCount)];
|
|
721
|
+
recallQM.graphHopContrib += hopCount; // Task 32: graph-hop metrics
|
|
722
|
+
recallQM.graphHopTurns++; // Task 32: graph-hop metrics
|
|
723
|
+
logger.info(`sulcus: auto_recall graph-hop added ${hopCount} neighbour(s)`);
|
|
724
|
+
}
|
|
725
|
+
} catch {
|
|
726
|
+
// graph expansion failed — fall back to vector results only
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// -- end graph-hop -----------------------------------------------------
|
|
731
|
+
|
|
732
|
+
// -- Budget constants (mirror SDK recall) ------------------------------
|
|
733
|
+
// Task 66: configurable tokenBudget, Task 101: adaptive scaling reduces it
|
|
734
|
+
const TOKEN_BUDGET = hookEffectiveTokenBudget;
|
|
735
|
+
const FIXED_OVERHEAD = 80;
|
|
736
|
+
|
|
737
|
+
// -- Task 31: Profile fetch (frequency-gated) ------------------------------
|
|
738
|
+
// Fetch prefs + facts on turn 1 and every profileFreq turns.
|
|
739
|
+
// Serve cached profile on all other turns to save API calls + tokens.
|
|
740
|
+
let profilePreferences: Record<string, unknown>[] = [];
|
|
741
|
+
let profileFacts: Record<string, unknown>[] = [];
|
|
742
|
+
if (includeProfile) {
|
|
743
|
+
try {
|
|
744
|
+
const [prefRes, factRes] = await Promise.all([
|
|
745
|
+
sulcusMem.search_memory("user preference", Math.min(hookEffectiveLimit, 5), namespace),
|
|
746
|
+
sulcusMem.search_memory("fact data knowledge", Math.min(hookEffectiveLimit, 5), namespace),
|
|
747
|
+
]);
|
|
748
|
+
profilePreferences = (prefRes?.results ?? []).filter((r) => r.memory_type === "preference");
|
|
749
|
+
profileFacts = (factRes?.results ?? []).filter((r) => r.memory_type === "fact");
|
|
750
|
+
hookProfileState!.cache = { preferences: profilePreferences, facts: profileFacts, cachedAt: Date.now() };
|
|
751
|
+
logger.info(`sulcus: auto_recall profile refreshed (turn ${hookTurn}, prefs=${profilePreferences.length}, facts=${profileFacts.length})`);
|
|
752
|
+
} catch {
|
|
753
|
+
// profile fetch failed — continue without
|
|
754
|
+
}
|
|
755
|
+
} else if (hookProfileState!.cache) {
|
|
756
|
+
profilePreferences = hookProfileState!.cache.preferences;
|
|
757
|
+
profileFacts = hookProfileState!.cache.facts;
|
|
758
|
+
}
|
|
759
|
+
// -- end Task 31 profile fetch ------------------------------------------
|
|
760
|
+
|
|
761
|
+
// Dedup profile IDs from recall results so profile items don't double-appear
|
|
762
|
+
const profileIdSet = new Set([
|
|
763
|
+
...profilePreferences.map((r) => r.id as string),
|
|
764
|
+
...profileFacts.map((r) => r.id as string),
|
|
765
|
+
]);
|
|
766
|
+
|
|
767
|
+
// -- Diversity filter (Task 20) ---------------------------------------------
|
|
768
|
+
const preDiversity = rawResults
|
|
769
|
+
.filter((r) => !profileIdSet.has(r.id as string)) // exclude profile items from recall
|
|
770
|
+
.map((r) => ({
|
|
771
|
+
...r,
|
|
772
|
+
label: ((r.label ?? r.pointer_summary ?? r.id ?? "") as string),
|
|
773
|
+
// Fix 2: prefer server fused_score over raw heat for ranking (Task 58)
|
|
774
|
+
_heat: (r.score as number) ?? (r.current_heat as number) ?? 0,
|
|
775
|
+
}));
|
|
776
|
+
preDiversity.sort((a, b) => b._heat - a._heat);
|
|
777
|
+
const diverseResults = diversityFilter(preDiversity, hookEffectiveLimit);
|
|
778
|
+
const droppedCount = preDiversity.length - diverseResults.length;
|
|
779
|
+
if (droppedCount > 0) logger.info(`sulcus: auto_recall diversity filter dropped ${droppedCount} near-duplicate(s)`);
|
|
780
|
+
|
|
781
|
+
// -- Budget split: ~30% profile / ~70% recall (mirrors SDK path) -----------
|
|
782
|
+
const profileBudgetTokens = Math.floor((TOKEN_BUDGET - FIXED_OVERHEAD) * 0.3);
|
|
783
|
+
const recallBudgetTokens = TOKEN_BUDGET - FIXED_OVERHEAD - profileBudgetTokens;
|
|
784
|
+
|
|
785
|
+
// Profile items sorted by heat desc
|
|
786
|
+
const profileItemsSorted = [...profilePreferences, ...profileFacts].map((r) => ({
|
|
787
|
+
...r,
|
|
788
|
+
label: ((r.label ?? r.pointer_summary ?? r.id ?? "") as string)
|
|
789
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
790
|
+
_heat: (r.score as number) ?? (r.current_heat as number) ?? 0,
|
|
791
|
+
})).sort((a, b) => b._heat - a._heat);
|
|
792
|
+
const budgetedProfile = enforceContextBudget(profileItemsSorted, TOKEN_BUDGET, FIXED_OVERHEAD + recallBudgetTokens);
|
|
793
|
+
|
|
794
|
+
// -- XML-escape labels ----------------------------------------------------
|
|
795
|
+
const escapedResults = diverseResults.map((r) => ({
|
|
796
|
+
...r,
|
|
797
|
+
label: r.label.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
798
|
+
}));
|
|
799
|
+
|
|
800
|
+
// -- Task 80: Temporal supersession (hook path) ----------------------------
|
|
801
|
+
// Mark older conflicting memories as superseded (score penalty + flag).
|
|
802
|
+
// Must run BEFORE budget enforcement so penalized items fall below the cut.
|
|
803
|
+
const hookSupersededCount = markSuperseded(escapedResults);
|
|
804
|
+
if (hookSupersededCount > 0) logger.info(`sulcus: temporal supersession (hook) marked ${hookSupersededCount} memory/memories as superseded`);
|
|
805
|
+
|
|
806
|
+
// -- Budget enforcement (Task 18) ---------------------------------------------
|
|
807
|
+
// Re-sort after supersession penalties so budget cuts superseded items first
|
|
808
|
+
escapedResults.sort((a, b) => b._heat - a._heat);
|
|
809
|
+
const budgeted = enforceContextBudget(escapedResults, TOKEN_BUDGET, FIXED_OVERHEAD + profileBudgetTokens);
|
|
810
|
+
|
|
811
|
+
// -- Task 79: Temporal re-ranking (hook path) ----------------------------
|
|
812
|
+
const hookTemporalDetected = isTemporalQuery(recallQuery);
|
|
813
|
+
const orderedBudgeted = hookTemporalDetected ? temporalRerank(budgeted) : budgeted;
|
|
814
|
+
if (hookTemporalDetected) logger.info(`sulcus: temporal query detected (hook) — re-ranking ${orderedBudgeted.length} results chronologically`);
|
|
815
|
+
|
|
816
|
+
// -- Flat recall list — superseded items get demoted with attribute ---------
|
|
817
|
+
const recallElements: string[] = [];
|
|
818
|
+
for (const r of orderedBudgeted) {
|
|
819
|
+
const heat = r._heat as number;
|
|
820
|
+
const heatStr = heat.toFixed(2);
|
|
821
|
+
const mtype = (r.memory_type as string) ?? "episodic";
|
|
822
|
+
const updatedAt = r.updated_at as string | undefined;
|
|
823
|
+
const ageStr = updatedAt ? formatRelativeTime(updatedAt) : "unknown";
|
|
824
|
+
const staleAttr = isStaleMemory(updatedAt) ? ` stale="true"` : "";
|
|
825
|
+
// Task 80: superseded memories get a marker so the LLM treats them as historical
|
|
826
|
+
const supersededAttr = r._superseded ? ` superseded="true"` : "";
|
|
827
|
+
recallElements.push(` <memory type="${mtype}" heat="${heatStr}" age="${ageStr}"${staleAttr}${supersededAttr}>${r.label}</memory>`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// -- Assemble context XML ------------------------------------------------
|
|
831
|
+
const sections: string[] = [];
|
|
832
|
+
// Profile section (Task 31) — inject before recall so agent sees identity context first
|
|
833
|
+
if (budgetedProfile.length > 0) {
|
|
834
|
+
const profileElements: string[] = [];
|
|
835
|
+
for (const r of budgetedProfile) {
|
|
836
|
+
const mtype = (r.memory_type as string) ?? "preference";
|
|
837
|
+
const heat = (r._heat as number).toFixed(2);
|
|
838
|
+
profileElements.push(` <item type="${mtype}" heat="${heat}">${r.label}</item>`);
|
|
839
|
+
}
|
|
840
|
+
sections.push(`<profile>\n${profileElements.join("\n")}\n</profile>`);
|
|
841
|
+
}
|
|
842
|
+
if (recallElements.length > 0) {
|
|
843
|
+
const recallOrderAttr = hookTemporalDetected ? ` order="chronological"` : "";
|
|
844
|
+
sections.push(`<recall${recallOrderAttr}>\n${recallElements.join("\n")}\n</recall>`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (sections.length === 0) return { prependSystemContext: FALLBACK_AWARENESS };
|
|
848
|
+
|
|
849
|
+
const guidance = "Background context from long-term memory. Use it silently to inform your understanding — only reference it when the conversation naturally calls for it.";
|
|
850
|
+
const contextParts = [
|
|
851
|
+
`<guidance>${guidance}</guidance>`,
|
|
852
|
+
...sections,
|
|
853
|
+
];
|
|
854
|
+
const context = `<sulcus_context token_budget="${TOKEN_BUDGET}" namespace="${namespace}" turn="${hookTurn}">\n${contextParts.join("\n")}\n</sulcus_context>`;
|
|
855
|
+
const estimatedTokens = estimateTokens(context);
|
|
856
|
+
// Task 32: track items served + avg relevance score in module-scope QM
|
|
857
|
+
recallQM.totalItemsServed += budgeted.length;
|
|
858
|
+
if (budgeted.length === 0) recallQM.zeroResultTurns++;
|
|
859
|
+
if (budgeted.length > 0 && topicShifted) {
|
|
860
|
+
const hookAvgScore = budgeted.reduce((s, r) => s + ((r._heat as number) ?? 0), 0) / budgeted.length;
|
|
861
|
+
recallQM.scoreSum += hookAvgScore;
|
|
862
|
+
recallQM.scoreTurns++;
|
|
863
|
+
}
|
|
864
|
+
logger.info(`sulcus: auto_recall injecting context (${context.length} chars, ~${estimatedTokens}/${TOKEN_BUDGET} tokens, turn ${hookTurn}, profile: ${budgetedProfile.length}, recall: ${budgeted.length})`);
|
|
865
|
+
|
|
866
|
+
// Task 56: write to inspect buffer for memory_inspect tool (hook path)
|
|
867
|
+
{
|
|
868
|
+
const staleHookItems = budgeted.filter((r) => (r as Record<string, unknown>).stale === true || (r as Record<string, unknown>)._stale === true);
|
|
869
|
+
const graphHookItems = budgeted.filter((r) => (r as Record<string, unknown>)._source === "graph");
|
|
870
|
+
inspectBuffer.lastRecall = {
|
|
871
|
+
capturedAt: Date.now(),
|
|
872
|
+
path: "hook",
|
|
873
|
+
turn: hookTurn,
|
|
874
|
+
query: prompt.substring(0, 200),
|
|
875
|
+
fromCache: !topicShifted,
|
|
876
|
+
itemsInjected: budgetedProfile.length + budgeted.length,
|
|
877
|
+
recallItems: budgeted.map((r) => ({
|
|
878
|
+
id: (r.id as string) ?? "",
|
|
879
|
+
content_preview: ((r.content ?? r.text ?? "") as string).substring(0, 80),
|
|
880
|
+
memory_type: (r.memory_type ?? r.type ?? "unknown") as string,
|
|
881
|
+
heat: (r.current_heat ?? r._heat ?? 0) as number,
|
|
882
|
+
score: (r.score as number | null) ?? null,
|
|
883
|
+
stale: !!(r.stale ?? r._stale),
|
|
884
|
+
source: ((r._source as string) === "graph" ? "graph" : "semantic") as "semantic" | "graph" | "unknown",
|
|
885
|
+
})),
|
|
886
|
+
profileItems: budgetedProfile.length,
|
|
887
|
+
staleCount: staleHookItems.length,
|
|
888
|
+
graphHopCount: graphHookItems.length,
|
|
889
|
+
tokensBudget: TOKEN_BUDGET,
|
|
890
|
+
tokensUsed: estimatedTokens,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Spaced repetition: boost heat for recalled memories (fire-and-forget)
|
|
895
|
+
if (ctx.boostOnRecall !== false && sulcusMem instanceof SulcusCloudClient) {
|
|
896
|
+
boostRecalledMemories(sulcusMem, budgeted, logger).catch(() => {});
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// -- Task 27: SIRU recall logging (hook path parity with SDK Task 23) --
|
|
900
|
+
// Post recall session metadata to SIRU on fresh recalls so the server
|
|
901
|
+
// can learn which memories were most useful. Skipped on cache-hit turns
|
|
902
|
+
// (topicShifted === false) to avoid duplicate logging for stable topics.
|
|
903
|
+
if (topicShifted && sulcusMem instanceof SulcusCloudClient) {
|
|
904
|
+
const recallIds = budgeted.map((r) => (r.id as string) ?? "").filter(Boolean);
|
|
905
|
+
const recallScores = budgeted.map((r) => (r._heat as number) ?? 0);
|
|
906
|
+
const recallSources = budgeted.map((r) =>
|
|
907
|
+
(r._source as string) === "graph" ? "graph" : "semantic"
|
|
908
|
+
);
|
|
909
|
+
const entityHints = Array.from(currentTokens).slice(0, 10);
|
|
910
|
+
const semanticCount = recallSources.filter((s) => s === "semantic").length;
|
|
911
|
+
const graphCount = recallSources.filter((s) => s === "graph").length;
|
|
912
|
+
sulcusMem.recall_log({
|
|
913
|
+
namespace,
|
|
914
|
+
agent_id: namespace,
|
|
915
|
+
query_text: recallQuery.substring(0, 500), // Task 62: focused query
|
|
916
|
+
memory_ids: recallIds,
|
|
917
|
+
memory_scores: recallScores,
|
|
918
|
+
memory_sources: recallSources,
|
|
919
|
+
token_budget: TOKEN_BUDGET,
|
|
920
|
+
tokens_used: estimatedTokens,
|
|
921
|
+
candidates_total: rawResults.length,
|
|
922
|
+
candidates_selected: recallIds.length,
|
|
923
|
+
semantic_count: semanticCount,
|
|
924
|
+
hot_count: graphCount,
|
|
925
|
+
entity_count: entityHints.length,
|
|
926
|
+
entity_hints: entityHints,
|
|
927
|
+
}).catch(() => {}); // never block context injection
|
|
928
|
+
logger.debug?.("sulcus: auto_recall SIRU log posted (hook path)");
|
|
929
|
+
}
|
|
930
|
+
// -- end Task 27 -------------------------------------------------------
|
|
931
|
+
|
|
113
932
|
return { prependSystemContext: context };
|
|
114
933
|
} catch (e) {
|
|
115
934
|
logger.warn(`sulcus: context build failed: ${e} — injecting fallback awareness`);
|
|
@@ -165,9 +984,18 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
165
984
|
return;
|
|
166
985
|
}
|
|
167
986
|
|
|
168
|
-
const
|
|
987
|
+
const hints = buildExtractionHints(memoryType, ctx.namespace, "user_capture", userMessage.substring(0, 200));
|
|
988
|
+
const res = await sulcusMem.add_memory(userMessage, memoryType, hints);
|
|
169
989
|
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)}..."`);
|
|
990
|
+
logger.info(`sulcus: sivu_auto_capture — stored [${memoryType}] (id: ${res?.id ?? "?"}, sivu_conf: ${storeConf.toFixed(3)}, sicu_conf: ${typeConf}, model: ${modelVersion}, hints: ${hints ? "yes" : "no"}): "${userMessage.substring(0, 60)}..."`);
|
|
991
|
+
|
|
992
|
+
// -- Task 21: Correction detection (SIVU path) -----------------------
|
|
993
|
+
if (isCorrectionMessage(userMessage)) {
|
|
994
|
+
const boosted = await boostRelatedMemories(sulcusMem, userMessage, ctx.namespace, 0.85, 3, logger);
|
|
995
|
+
if (boosted > 0) {
|
|
996
|
+
logger.info(`sulcus: sivu_auto_capture — correction detected, heat-boosted ${boosted} related memor${boosted === 1 ? "y" : "ies"}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
171
999
|
return;
|
|
172
1000
|
} catch (e: unknown) {
|
|
173
1001
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -177,8 +1005,17 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
177
1005
|
}
|
|
178
1006
|
|
|
179
1007
|
try {
|
|
180
|
-
const
|
|
1008
|
+
const fallbackHints = buildExtractionHints("episodic", ctx.namespace, "user_capture", userMessage.substring(0, 200));
|
|
1009
|
+
const res = await sulcusMem.add_memory(userMessage, "episodic", fallbackHints);
|
|
181
1010
|
logger.info(`sulcus: sivu_auto_capture — fallback stored [episodic] (id: ${res?.id ?? "?"}): "${userMessage.substring(0, 60)}..."`);
|
|
1011
|
+
|
|
1012
|
+
// -- Task 21: Correction detection (fallback path) -------------------
|
|
1013
|
+
if (isCorrectionMessage(userMessage) && sulcusMem instanceof SulcusCloudClient) {
|
|
1014
|
+
const boosted = await boostRelatedMemories(sulcusMem, userMessage, ctx.namespace, 0.85, 3, logger);
|
|
1015
|
+
if (boosted > 0) {
|
|
1016
|
+
logger.info(`sulcus: sivu_auto_capture — correction detected, heat-boosted ${boosted} related memor${boosted === 1 ? "y" : "ies"}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
182
1019
|
} catch (e: unknown) {
|
|
183
1020
|
const msg = e instanceof Error ? e.message : String(e);
|
|
184
1021
|
logger.warn(`sulcus: sivu_auto_capture — fallback store failed: ${msg}`);
|
|
@@ -219,7 +1056,8 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
219
1056
|
const memoryContent = `Tool '${toolName}' failed: ${truncated}`;
|
|
220
1057
|
|
|
221
1058
|
try {
|
|
222
|
-
const
|
|
1059
|
+
const errorHints = buildExtractionHints("episodic", ctx.namespace, "tool_error", memoryContent.substring(0, 200));
|
|
1060
|
+
const res = await sulcusMem.add_memory(memoryContent, "episodic", errorHints);
|
|
223
1061
|
// Boost heat so error memories persist longer — failures are high-value learnings
|
|
224
1062
|
if (res?.id && sulcusMem instanceof SulcusCloudClient) {
|
|
225
1063
|
await sulcusMem.request("PATCH", `/api/v1/agent/memory/${res.id}`, {
|
|
@@ -240,6 +1078,10 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
240
1078
|
const messages = Array.isArray(event?.messages) ? event.messages as Record<string, unknown>[] : [];
|
|
241
1079
|
if (messages.length === 0) return;
|
|
242
1080
|
|
|
1081
|
+
// --- Task 70: Set rebuild flag so next before_prompt_build does full context rebuild ---
|
|
1082
|
+
wasJustCompacted = true;
|
|
1083
|
+
logger.info("sulcus: pre_compaction_capture — rebuild flag SET (next turn will inject full Sulcus context)");
|
|
1084
|
+
|
|
243
1085
|
const firstUser = messages.find((m) => m.role === "user" || m.type === "human");
|
|
244
1086
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant" || m.type === "ai");
|
|
245
1087
|
|
|
@@ -255,8 +1097,55 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
255
1097
|
? (lastAssistant.text as string).substring(0, 200)
|
|
256
1098
|
: "(none)";
|
|
257
1099
|
|
|
1100
|
+
// --- Task 70: Extract richer multi-part knowledge from session ---
|
|
1101
|
+
// 1. Files modified (write/edit tool calls)
|
|
258
1102
|
const filesModified: string[] = [];
|
|
1103
|
+
// 2. Commands run (exec tool calls)
|
|
1104
|
+
const commandsRun: string[] = [];
|
|
1105
|
+
// 3. Decisions made (assistant messages containing decision markers)
|
|
1106
|
+
const decisions: string[] = [];
|
|
1107
|
+
// 4. Errors encountered
|
|
1108
|
+
const errors: string[] = [];
|
|
1109
|
+
// 5. All user intents (short user messages for multi-query rebuild)
|
|
1110
|
+
const userIntents: string[] = [];
|
|
1111
|
+
|
|
1112
|
+
const DECISION_MARKERS = ["decided", "will use", "going to", "plan is", "the fix", "conclusion", "recommend", "approach"];
|
|
1113
|
+
const ERROR_MARKERS = ["error:", "failed:", "exception", "traceback", "panicked", "stack trace"];
|
|
1114
|
+
|
|
259
1115
|
for (const msg of messages) {
|
|
1116
|
+
const role = (msg.role ?? msg.type) as string | undefined;
|
|
1117
|
+
const rawContent = typeof msg.content === "string" ? msg.content
|
|
1118
|
+
: typeof msg.text === "string" ? msg.text : "";
|
|
1119
|
+
|
|
1120
|
+
// Extract user intents (first 150 chars of each user message)
|
|
1121
|
+
if ((role === "user" || role === "human") && rawContent.length > 10) {
|
|
1122
|
+
userIntents.push(rawContent.substring(0, 150));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Extract decisions from assistant messages
|
|
1126
|
+
if ((role === "assistant" || role === "ai") && rawContent.length > 20) {
|
|
1127
|
+
const lc = rawContent.toLowerCase();
|
|
1128
|
+
if (DECISION_MARKERS.some((m) => lc.includes(m))) {
|
|
1129
|
+
// Extract the sentence containing the decision marker
|
|
1130
|
+
const sentences = rawContent.split(/[.!?\n]/).filter((s) => s.trim().length > 10);
|
|
1131
|
+
for (const s of sentences) {
|
|
1132
|
+
if (DECISION_MARKERS.some((m) => s.toLowerCase().includes(m)) && !decisions.includes(s.trim())) {
|
|
1133
|
+
decisions.push(s.trim().substring(0, 200));
|
|
1134
|
+
if (decisions.length >= 5) break;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Extract errors
|
|
1139
|
+
const lcContent = rawContent.toLowerCase();
|
|
1140
|
+
if (ERROR_MARKERS.some((m) => lcContent.includes(m))) {
|
|
1141
|
+
const errorLine = rawContent.split("\n").find((l) => ERROR_MARKERS.some((m) => l.toLowerCase().includes(m)));
|
|
1142
|
+
if (errorLine && !errors.includes(errorLine.trim())) {
|
|
1143
|
+
errors.push(errorLine.trim().substring(0, 150));
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Extract tool calls
|
|
260
1149
|
const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as Record<string, unknown>[] : [];
|
|
261
1150
|
for (const tc of toolCalls) {
|
|
262
1151
|
const name = (tc.name ?? tc.function) as string | undefined;
|
|
@@ -265,15 +1154,24 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
265
1154
|
const fp = input?.file_path ?? input?.path;
|
|
266
1155
|
if (fp && typeof fp === "string" && !filesModified.includes(fp)) filesModified.push(fp);
|
|
267
1156
|
}
|
|
1157
|
+
if (name === "Bash" || name === "bash" || name === "exec" || name === "shell") {
|
|
1158
|
+
const input = (tc.input ?? tc.arguments ?? {}) as Record<string, unknown>;
|
|
1159
|
+
const cmd = input?.command ?? input?.cmd;
|
|
1160
|
+
if (cmd && typeof cmd === "string" && commandsRun.length < 5) {
|
|
1161
|
+
commandsRun.push(cmd.substring(0, 100));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
268
1164
|
}
|
|
269
1165
|
}
|
|
270
1166
|
|
|
1167
|
+
// --- Build primary summary memory ---
|
|
271
1168
|
const summaryParts = [
|
|
272
1169
|
`Session compaction — ${messages.length} messages`,
|
|
273
1170
|
`First user message: ${firstUserText}`,
|
|
274
1171
|
`Last assistant message: ${lastAssistantText}`,
|
|
275
1172
|
];
|
|
276
1173
|
if (filesModified.length > 0) summaryParts.push(`Files modified: ${filesModified.join(", ")}`);
|
|
1174
|
+
if (commandsRun.length > 0) summaryParts.push(`Commands run: ${commandsRun.join(" | ")}`);
|
|
277
1175
|
const summary = summaryParts.join("\n");
|
|
278
1176
|
|
|
279
1177
|
if (!shouldCapture(summary)) {
|
|
@@ -281,17 +1179,156 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
281
1179
|
return;
|
|
282
1180
|
}
|
|
283
1181
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
1182
|
+
const storePromises: Promise<unknown>[] = [];
|
|
1183
|
+
|
|
1184
|
+
// Store primary session summary
|
|
1185
|
+
const compactionHints = buildExtractionHints("episodic", ctx.namespace, "compaction", summary.substring(0, 200));
|
|
1186
|
+
storePromises.push(
|
|
1187
|
+
sulcusMem.add_memory(summary, "episodic", compactionHints)
|
|
1188
|
+
.then((res) => logger.info(`sulcus: pre_compaction_capture — stored session summary (id: ${res?.id ?? "?"})`)
|
|
1189
|
+
).catch((e: unknown) => logger.debug?.(`sulcus: pre_compaction_capture — summary store failed: ${e instanceof Error ? e.message : String(e)}`))
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
// --- Task 70: Store decisions as semantic memories for long-term value ---
|
|
1193
|
+
if (decisions.length > 0) {
|
|
1194
|
+
const decisionText = `Session decisions: ${decisions.join(" | ")}`;
|
|
1195
|
+
const decisionHints = buildExtractionHints("semantic", ctx.namespace, "compaction", decisionText.substring(0, 200));
|
|
1196
|
+
storePromises.push(
|
|
1197
|
+
sulcusMem.add_memory(decisionText, "semantic", decisionHints)
|
|
1198
|
+
.then((res) => logger.info(`sulcus: pre_compaction_capture — stored decisions (id: ${res?.id ?? "?"})`)
|
|
1199
|
+
).catch((e: unknown) => logger.debug?.(`sulcus: pre_compaction_capture — decisions store failed: ${e instanceof Error ? e.message : String(e)}`))
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// --- Task 70: Store user intents for multi-query rebuild ---
|
|
1204
|
+
// Store the middle of the session's user intents as a searchable procedural memory.
|
|
1205
|
+
// This lets the post-compaction rebuild find relevant session context.
|
|
1206
|
+
if (userIntents.length > 2) {
|
|
1207
|
+
const midIntents = userIntents.slice(Math.floor(userIntents.length / 4), Math.floor(3 * userIntents.length / 4)).slice(0, 3);
|
|
1208
|
+
const intentsText = `Session user intents: ${midIntents.join(" | ")}`;
|
|
1209
|
+
if (shouldCapture(intentsText)) {
|
|
1210
|
+
const intentHints = buildExtractionHints("episodic", ctx.namespace, "compaction", intentsText.substring(0, 200));
|
|
1211
|
+
storePromises.push(
|
|
1212
|
+
sulcusMem.add_memory(intentsText, "episodic", intentHints)
|
|
1213
|
+
.then((res) => logger.info(`sulcus: pre_compaction_capture — stored intents (id: ${res?.id ?? "?"})`)
|
|
1214
|
+
).catch((e: unknown) => logger.debug?.(`sulcus: pre_compaction_capture — intents store failed: ${e instanceof Error ? e.message : String(e)}`))
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
290
1217
|
}
|
|
1218
|
+
|
|
1219
|
+
// Fire all stores in parallel (non-blocking from OpenClaw's perspective)
|
|
1220
|
+
await Promise.allSettled(storePromises);
|
|
1221
|
+
logger.info(`sulcus: pre_compaction_capture — stored ${storePromises.length} memory/memories from ${messages.length}-message session`);
|
|
291
1222
|
},
|
|
292
1223
|
};
|
|
293
1224
|
|
|
294
|
-
//
|
|
1225
|
+
// --- EXTRACTION HINTS -------------------------------------------------------
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Caller-supplied hints for SILU entity extraction + classification.
|
|
1229
|
+
* Mirrors the server-side ExtractionHints struct (entity_extraction.rs).
|
|
1230
|
+
* These are injected as a preamble into the SILU system prompt to guide
|
|
1231
|
+
* extraction without overriding the LLM's judgment.
|
|
1232
|
+
*/
|
|
1233
|
+
export interface ExtractionHints {
|
|
1234
|
+
/** Entity types the caller expects (e.g. ["person", "tool", "project"]). */
|
|
1235
|
+
entity_types?: string[];
|
|
1236
|
+
/** Free-form domain focus areas (e.g. ["infrastructure", "memory systems"]). */
|
|
1237
|
+
focus_areas?: string[];
|
|
1238
|
+
/** Entity types to suppress if irrelevant (e.g. ["location"]). */
|
|
1239
|
+
suppress_types?: string[];
|
|
1240
|
+
/** Soft suggestion for memory type — SILU may override if content clearly differs. */
|
|
1241
|
+
expected_type?: string;
|
|
1242
|
+
/** Free-form context note injected verbatim (max 500 chars server-side). */
|
|
1243
|
+
context_note?: string;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Derive ExtractionHints from available context signals.
|
|
1248
|
+
* Called at store time to guide SILU toward better entity extraction + classification.
|
|
1249
|
+
*
|
|
1250
|
+
* @param memoryType - The memory type being stored (episodic|semantic|etc.)
|
|
1251
|
+
* @param namespace - Agent namespace (provides domain context)
|
|
1252
|
+
* @param eventType - Hook event type (e.g. "sivu_auto_capture", "tool_error", "compaction")
|
|
1253
|
+
* @param contentSnippet - First 200 chars of content for heuristic detection
|
|
1254
|
+
*/
|
|
1255
|
+
function buildExtractionHints(
|
|
1256
|
+
memoryType: string | null | undefined,
|
|
1257
|
+
namespace: string,
|
|
1258
|
+
eventType: string,
|
|
1259
|
+
contentSnippet: string
|
|
1260
|
+
): ExtractionHints | undefined {
|
|
1261
|
+
const hints: ExtractionHints = {};
|
|
1262
|
+
|
|
1263
|
+
// -- Expected type from known memory_type --
|
|
1264
|
+
if (memoryType && memoryType !== "episodic") {
|
|
1265
|
+
hints.expected_type = memoryType;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// -- Domain focus from namespace --
|
|
1269
|
+
// Namespace is typically the agent id — map known agents to domains
|
|
1270
|
+
const ns = namespace.toLowerCase();
|
|
1271
|
+
if (ns.includes("sulcus") || ns.includes("memory")) {
|
|
1272
|
+
hints.focus_areas = ["memory systems", "AI infrastructure", "sulcus"];
|
|
1273
|
+
hints.entity_types = ["tool", "concept", "project", "model"];
|
|
1274
|
+
} else if (ns.includes("daedalus") || ns.includes("forge") || ns.includes("workshop")) {
|
|
1275
|
+
hints.focus_areas = ["infrastructure", "devops", "software engineering", "AI agents"];
|
|
1276
|
+
hints.entity_types = ["tool", "project", "person", "organization"];
|
|
1277
|
+
} else if (ns.includes("icarus") || ns.includes("booker")) {
|
|
1278
|
+
hints.focus_areas = ["product development", "business logic"];
|
|
1279
|
+
hints.entity_types = ["tool", "project", "person"];
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// -- Event-type signals --
|
|
1283
|
+
if (eventType === "tool_error") {
|
|
1284
|
+
hints.context_note = "This is a tool failure memory — focus on tool names, error patterns, and failure causes.";
|
|
1285
|
+
hints.entity_types = [...(hints.entity_types ?? []), "tool"];
|
|
1286
|
+
hints.suppress_types = ["location"];
|
|
1287
|
+
} else if (eventType === "compaction") {
|
|
1288
|
+
hints.context_note = "This is a session summary from context compaction — extract key decisions, files modified, and tasks completed.";
|
|
1289
|
+
hints.entity_types = [...(hints.entity_types ?? []), "project", "tool"];
|
|
1290
|
+
} else if (eventType === "user_capture") {
|
|
1291
|
+
// User conversational content — don't over-suppress anything
|
|
1292
|
+
if (!hints.context_note) {
|
|
1293
|
+
hints.context_note = "This was captured from a user message during an agent session.";
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// -- Content heuristics --
|
|
1298
|
+
const lower = contentSnippet.toLowerCase();
|
|
1299
|
+
if (lower.includes("prefer") || lower.includes("always") || lower.includes("never") || lower.includes("want")) {
|
|
1300
|
+
if (!hints.expected_type) hints.expected_type = "preference";
|
|
1301
|
+
} else if (lower.includes("step") || lower.includes("command") || lower.includes("run ") || lower.includes("deploy")) {
|
|
1302
|
+
if (!hints.expected_type) hints.expected_type = "procedural";
|
|
1303
|
+
} else if (lower.includes("is defined as") || lower.includes("means") || lower.includes("concept") || lower.includes("architecture")) {
|
|
1304
|
+
if (!hints.expected_type) hints.expected_type = "semantic";
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Return undefined if nothing useful was derived (avoid sending empty hints)
|
|
1308
|
+
const hasContent =
|
|
1309
|
+
(hints.entity_types?.length ?? 0) > 0 ||
|
|
1310
|
+
(hints.focus_areas?.length ?? 0) > 0 ||
|
|
1311
|
+
(hints.suppress_types?.length ?? 0) > 0 ||
|
|
1312
|
+
hints.expected_type != null ||
|
|
1313
|
+
hints.context_note != null;
|
|
1314
|
+
|
|
1315
|
+
return hasContent ? hints : undefined;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// --- CLOUD HTTP CLIENT -------------------------------------------------------
|
|
1319
|
+
|
|
1320
|
+
// Task 29: Typed HTTP error — carries statusCode and optional Retry-After delay
|
|
1321
|
+
// so the retry loop can honour server-specified backoff on 429s.
|
|
1322
|
+
class SulcusHttpError extends Error {
|
|
1323
|
+
constructor(
|
|
1324
|
+
message: string,
|
|
1325
|
+
public readonly statusCode: number,
|
|
1326
|
+
public readonly retryAfterMs?: number,
|
|
1327
|
+
) {
|
|
1328
|
+
super(message);
|
|
1329
|
+
this.name = "SulcusHttpError";
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
295
1332
|
|
|
296
1333
|
class SulcusCloudClient {
|
|
297
1334
|
private serverUrl: string;
|
|
@@ -302,20 +1339,19 @@ class SulcusCloudClient {
|
|
|
302
1339
|
this.apiKey = apiKey;
|
|
303
1340
|
}
|
|
304
1341
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return rejectP(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${msg}`));
|
|
313
|
-
}
|
|
1342
|
+
// -- Task 28: Transient retry with exponential backoff ---------------------
|
|
1343
|
+
// Retries on 502/503/504 and network errors — up to RETRY_MAX attempts.
|
|
1344
|
+
// Backoff: 400ms → 800ms → 1600ms (jitter ±20%). Non-retryable errors (4xx
|
|
1345
|
+
// except 429, 5xx ≠ 502/503/504) are surfaced immediately.
|
|
1346
|
+
private static readonly RETRY_MAX = 3;
|
|
1347
|
+
private static readonly RETRY_BASE_MS = 400;
|
|
1348
|
+
private static readonly RETRY_JITTER = 0.2; // ±20%
|
|
314
1349
|
|
|
1350
|
+
private _rawRequest(method: string, path: string, bodyStr: string | undefined, parsedUrl: URL): Promise<unknown> {
|
|
1351
|
+
return new Promise((resolveP, rejectP) => {
|
|
315
1352
|
const isHttps = parsedUrl.protocol === "https:";
|
|
316
1353
|
const transport = isHttps ? https : http;
|
|
317
1354
|
|
|
318
|
-
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
|
|
319
1355
|
const headers: Record<string, string> = {
|
|
320
1356
|
"Authorization": `Bearer ${this.apiKey}`,
|
|
321
1357
|
"Accept": "application/json",
|
|
@@ -339,7 +1375,23 @@ class SulcusCloudClient {
|
|
|
339
1375
|
res.on("end", () => {
|
|
340
1376
|
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
341
1377
|
if (!res.statusCode || res.statusCode >= 400) {
|
|
342
|
-
|
|
1378
|
+
// Task 29: Parse Retry-After header on 429 — prefer server-specified delay
|
|
1379
|
+
let retryAfterMs: number | undefined;
|
|
1380
|
+
if (res.statusCode === 429) {
|
|
1381
|
+
const ra = res.headers["retry-after"];
|
|
1382
|
+
if (ra) {
|
|
1383
|
+
const raNum = Number(ra);
|
|
1384
|
+
// RFC 7231: value is either seconds or an HTTP-date
|
|
1385
|
+
retryAfterMs = isNaN(raNum)
|
|
1386
|
+
? Math.max(0, new Date(ra).getTime() - Date.now())
|
|
1387
|
+
: raNum * 1000;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return rejectP(new SulcusHttpError(
|
|
1391
|
+
`SulcusCloudClient: HTTP ${res.statusCode} for ${method} ${path}: ${raw.substring(0, 200)}`,
|
|
1392
|
+
res.statusCode,
|
|
1393
|
+
retryAfterMs,
|
|
1394
|
+
));
|
|
343
1395
|
}
|
|
344
1396
|
if (!raw || raw.trim() === "") return resolveP(null);
|
|
345
1397
|
try {
|
|
@@ -350,12 +1402,55 @@ class SulcusCloudClient {
|
|
|
350
1402
|
});
|
|
351
1403
|
});
|
|
352
1404
|
|
|
353
|
-
req.on("error", (e: Error) => rejectP(new
|
|
1405
|
+
req.on("error", (e: Error) => rejectP(new SulcusHttpError(`SulcusCloudClient: network error for ${method} ${path}: ${e.message}`, 0)));
|
|
354
1406
|
if (bodyStr !== undefined) req.write(bodyStr);
|
|
355
1407
|
req.end();
|
|
356
1408
|
});
|
|
357
1409
|
}
|
|
358
1410
|
|
|
1411
|
+
request(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
1412
|
+
let parsedUrl: URL;
|
|
1413
|
+
try {
|
|
1414
|
+
parsedUrl = new URL(this.serverUrl + path);
|
|
1415
|
+
} catch (e: unknown) {
|
|
1416
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1417
|
+
return Promise.reject(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${msg}`));
|
|
1418
|
+
}
|
|
1419
|
+
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
|
|
1420
|
+
|
|
1421
|
+
// Task 29: 429 is retryable — server is asking us to back off, not fail
|
|
1422
|
+
const isRetryable = (err: SulcusHttpError): boolean => {
|
|
1423
|
+
// Network errors (statusCode === 0) are always retryable
|
|
1424
|
+
if (err.statusCode === 0) return true;
|
|
1425
|
+
// 429 Too Many Requests — retryable, honours Retry-After below
|
|
1426
|
+
if (err.statusCode === 429) return true;
|
|
1427
|
+
// 502/503/504 are transient gateway errors
|
|
1428
|
+
return err.statusCode === 502 || err.statusCode === 503 || err.statusCode === 504;
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const attempt = (tries: number): Promise<unknown> => {
|
|
1432
|
+
return this._rawRequest(method, path, bodyStr, parsedUrl).catch((err: SulcusHttpError) => {
|
|
1433
|
+
if (tries >= SulcusCloudClient.RETRY_MAX || !isRetryable(err)) {
|
|
1434
|
+
throw err;
|
|
1435
|
+
}
|
|
1436
|
+
// Task 29: Prefer Retry-After from server (429) over our own exponential schedule
|
|
1437
|
+
let delay: number;
|
|
1438
|
+
if (err.retryAfterMs !== undefined && err.retryAfterMs > 0) {
|
|
1439
|
+
delay = err.retryAfterMs;
|
|
1440
|
+
} else {
|
|
1441
|
+
// Exponential backoff with jitter
|
|
1442
|
+
const base = SulcusCloudClient.RETRY_BASE_MS * Math.pow(2, tries - 1);
|
|
1443
|
+
const jitter = base * SulcusCloudClient.RETRY_JITTER * (Math.random() * 2 - 1);
|
|
1444
|
+
delay = Math.round(base + jitter);
|
|
1445
|
+
}
|
|
1446
|
+
return new Promise<void>((res) => setTimeout(res, delay)).then(() => attempt(tries + 1));
|
|
1447
|
+
});
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
return attempt(1);
|
|
1451
|
+
}
|
|
1452
|
+
// -- end Task 28 --------------------------------------------------------------
|
|
1453
|
+
|
|
359
1454
|
async search_memory(query: string, limit?: number, namespace?: string): Promise<{ results: Record<string, unknown>[] }> {
|
|
360
1455
|
const body: Record<string, unknown> = { query };
|
|
361
1456
|
if (limit !== undefined) body.limit = limit;
|
|
@@ -365,9 +1460,11 @@ class SulcusCloudClient {
|
|
|
365
1460
|
return { results };
|
|
366
1461
|
}
|
|
367
1462
|
|
|
368
|
-
async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: unknown }> {
|
|
1463
|
+
async add_memory(content: string, memoryType?: string | null, hints?: ExtractionHints): Promise<{ id: string; [key: string]: unknown }> {
|
|
369
1464
|
const body: Record<string, unknown> = { label: content };
|
|
370
1465
|
if (memoryType) body.memory_type = memoryType;
|
|
1466
|
+
// Phase 2: SILU prompt injection — pass extraction hints to guide entity extraction + classification
|
|
1467
|
+
if (hints) body.extraction_hints = hints;
|
|
371
1468
|
const res = await this.request("POST", "/api/v1/agent/nodes", body) as Record<string, unknown> | null;
|
|
372
1469
|
return (res ?? { id: "unknown" }) as { id: string; [key: string]: unknown };
|
|
373
1470
|
}
|
|
@@ -431,6 +1528,39 @@ class SulcusCloudClient {
|
|
|
431
1528
|
}
|
|
432
1529
|
}
|
|
433
1530
|
|
|
1531
|
+
async get_memory(id: string): Promise<Record<string, unknown> | null> {
|
|
1532
|
+
try {
|
|
1533
|
+
const res = await this.request("GET", `/api/v1/agent/nodes/${encodeURIComponent(id)}`) as Record<string, unknown> | null;
|
|
1534
|
+
return res;
|
|
1535
|
+
} catch (e: unknown) {
|
|
1536
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1537
|
+
if (msg.includes("404")) return null;
|
|
1538
|
+
throw e;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
async list_memories(opts: { page?: number; page_size?: number; memory_type?: string; namespace?: string; pinned?: boolean; sort_by?: string; sort_order?: string } = {}): Promise<{ items: Record<string, unknown>[]; total?: number; page?: number; page_size?: number }> {
|
|
1543
|
+
const params = new URLSearchParams();
|
|
1544
|
+
if (opts.page !== undefined) params.set("page", String(opts.page));
|
|
1545
|
+
if (opts.page_size !== undefined) params.set("page_size", String(opts.page_size));
|
|
1546
|
+
if (opts.memory_type) params.set("memory_type", opts.memory_type);
|
|
1547
|
+
if (opts.namespace) params.set("namespace", opts.namespace);
|
|
1548
|
+
if (opts.pinned !== undefined) params.set("pinned", String(opts.pinned));
|
|
1549
|
+
if (opts.sort_by) params.set("sort_by", opts.sort_by);
|
|
1550
|
+
if (opts.sort_order) params.set("sort_order", opts.sort_order);
|
|
1551
|
+
const q = params.toString() ? `?${params.toString()}` : "";
|
|
1552
|
+
const res = await this.request("GET", `/api/v1/agent/nodes${q}`) as Record<string, unknown> | unknown[] | null;
|
|
1553
|
+
if (Array.isArray(res)) return { items: res as Record<string, unknown>[], total: res.length };
|
|
1554
|
+
const r = (res ?? {}) as Record<string, unknown>;
|
|
1555
|
+
const items = (r.items ?? r.nodes ?? r.results ?? []) as Record<string, unknown>[];
|
|
1556
|
+
return { items, total: r.total as number | undefined, page: r.page as number | undefined, page_size: r.page_size as number | undefined };
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async update_memory(id: string, updates: { content?: string; label?: string; memory_type?: string; is_pinned?: boolean; current_heat?: number }): Promise<Record<string, unknown> | null> {
|
|
1560
|
+
const res = await this.request("PATCH", `/api/v1/agent/memory/${encodeURIComponent(id)}`, updates) as Record<string, unknown> | null;
|
|
1561
|
+
return res;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
434
1564
|
async probe(): Promise<boolean> {
|
|
435
1565
|
try {
|
|
436
1566
|
await this.search_memory("probe", 1);
|
|
@@ -439,36 +1569,118 @@ class SulcusCloudClient {
|
|
|
439
1569
|
return false;
|
|
440
1570
|
}
|
|
441
1571
|
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ─── NATIVE LIB LOADER ──────────────────────────────────────────────────────
|
|
445
|
-
|
|
446
|
-
class NativeLibLoader {
|
|
447
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
448
|
-
private koffi: unknown = null;
|
|
449
|
-
private storeLib: unknown = null;
|
|
450
|
-
private vectorsLib: unknown = null;
|
|
451
|
-
private vectorsHandle: unknown = null;
|
|
452
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
453
|
-
private fn_store_init: any = null;
|
|
454
|
-
private fn_store_query: any = null;
|
|
455
|
-
private fn_store_free: any = null;
|
|
456
|
-
private fn_vectors_create: any = null;
|
|
457
|
-
private fn_vectors_text: any = null;
|
|
458
|
-
private fn_vectors_free: any = null;
|
|
459
|
-
|
|
460
|
-
public loaded = false;
|
|
461
|
-
public error: string | null = null;
|
|
462
|
-
|
|
463
|
-
constructor(private storeLibPath: string, private vectorsLibPath: string) {}
|
|
464
1572
|
|
|
465
|
-
|
|
1573
|
+
/**
|
|
1574
|
+
* Fetch graph neighbours for a memory node via AGE Cypher.
|
|
1575
|
+
* Returns [] gracefully if the endpoint is unavailable (server too old).
|
|
1576
|
+
*/
|
|
1577
|
+
async graph_neighbors(nodeId: string, limit = 6): Promise<Record<string, unknown>[]> {
|
|
466
1578
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
1579
|
+
const res = await this.request("GET", `/api/v1/agent/graph/neighbors/${encodeURIComponent(nodeId)}?limit=${limit}`) as Record<string, unknown> | null;
|
|
1580
|
+
if (!res) return [];
|
|
1581
|
+
const nodes = (res.neighbors ?? res.nodes ?? res.results ?? (Array.isArray(res) ? res : [])) as Record<string, unknown>[];
|
|
1582
|
+
return nodes;
|
|
469
1583
|
} catch (e: unknown) {
|
|
470
|
-
|
|
471
|
-
|
|
1584
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1585
|
+
// 404 = server too old, no graph endpoint — degrade gracefully
|
|
1586
|
+
if (msg.includes("404") || msg.includes("HTTP 404")) return [];
|
|
1587
|
+
return [];
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Task 23: SIRU recall logging — post a recall session to the server for training data.
|
|
1593
|
+
* Fire-and-forget: called after each fresh recall, never blocks context injection.
|
|
1594
|
+
* Server stores this in recall_sessions table for SIRU adaptive scoring.
|
|
1595
|
+
*/
|
|
1596
|
+
async recall_log(payload: {
|
|
1597
|
+
namespace: string;
|
|
1598
|
+
agent_id: string;
|
|
1599
|
+
query_text: string;
|
|
1600
|
+
memory_ids: string[];
|
|
1601
|
+
memory_scores: number[];
|
|
1602
|
+
memory_sources: string[];
|
|
1603
|
+
token_budget: number;
|
|
1604
|
+
tokens_used: number;
|
|
1605
|
+
candidates_total: number;
|
|
1606
|
+
candidates_selected: number;
|
|
1607
|
+
semantic_count: number;
|
|
1608
|
+
hot_count: number;
|
|
1609
|
+
entity_count: number;
|
|
1610
|
+
entity_hints: string[];
|
|
1611
|
+
}): Promise<void> {
|
|
1612
|
+
try {
|
|
1613
|
+
await this.request("POST", "/api/v1/agent/recall-log", payload);
|
|
1614
|
+
} catch {
|
|
1615
|
+
// Logging failure must never interrupt recall — silently drop
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Task 35: Entity-context lookup for query expansion.
|
|
1621
|
+
* Fetches graph-connected memories and sibling entities for a set of entity names.
|
|
1622
|
+
* Returns empty gracefully if the endpoint is unavailable.
|
|
1623
|
+
*/
|
|
1624
|
+
async entity_context(entityNames: string[], namespace?: string, limit = 3): Promise<EntityContextEntry[]> {
|
|
1625
|
+
try {
|
|
1626
|
+
const body: Record<string, unknown> = { entity_names: entityNames, limit };
|
|
1627
|
+
if (namespace) body.namespace = namespace;
|
|
1628
|
+
const res = await this.request("POST", "/api/v1/agent/entity-context", body) as Record<string, unknown> | null;
|
|
1629
|
+
if (!res) return [];
|
|
1630
|
+
return (res.entities as EntityContextEntry[]) ?? [];
|
|
1631
|
+
} catch {
|
|
1632
|
+
return [];
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Task 34: Batch heat-boost — single round-trip to POST /api/v1/agent/boost-batch.
|
|
1638
|
+
* Accepts an array of { id, heat } boost items.
|
|
1639
|
+
* Returns true if the server accepted the batch; false if the endpoint is not yet deployed (404).
|
|
1640
|
+
* On false, the caller falls back to individual PATCH requests.
|
|
1641
|
+
*/
|
|
1642
|
+
async boost_batch(boosts: Array<{ id: string; heat: number }>): Promise<boolean> {
|
|
1643
|
+
try {
|
|
1644
|
+
await this.request("POST", "/api/v1/agent/boost-batch", { boosts });
|
|
1645
|
+
return true;
|
|
1646
|
+
} catch (e: unknown) {
|
|
1647
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1648
|
+
// 404 means endpoint not deployed yet — caller will fall back to individual PATCHes
|
|
1649
|
+
if (msg.includes("404")) return false;
|
|
1650
|
+
// Other errors (5xx, network) — propagate so caller can handle
|
|
1651
|
+
throw e;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// --- NATIVE LIB LOADER ------------------------------------------------------
|
|
1657
|
+
|
|
1658
|
+
class NativeLibLoader {
|
|
1659
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1660
|
+
private koffi: unknown = null;
|
|
1661
|
+
private storeLib: unknown = null;
|
|
1662
|
+
private vectorsLib: unknown = null;
|
|
1663
|
+
private vectorsHandle: unknown = null;
|
|
1664
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1665
|
+
private fn_store_init: any = null;
|
|
1666
|
+
private fn_store_query: any = null;
|
|
1667
|
+
private fn_store_free: any = null;
|
|
1668
|
+
private fn_vectors_create: any = null;
|
|
1669
|
+
private fn_vectors_text: any = null;
|
|
1670
|
+
private fn_vectors_free: any = null;
|
|
1671
|
+
|
|
1672
|
+
public loaded = false;
|
|
1673
|
+
public error: string | null = null;
|
|
1674
|
+
|
|
1675
|
+
constructor(private storeLibPath: string, private vectorsLibPath: string) {}
|
|
1676
|
+
|
|
1677
|
+
init(logger: PluginLogger): void {
|
|
1678
|
+
try {
|
|
1679
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1680
|
+
this.koffi = require("koffi");
|
|
1681
|
+
} catch (e: unknown) {
|
|
1682
|
+
this.error = `koffi not available: ${e instanceof Error ? e.message : e}`;
|
|
1683
|
+
logger.warn(`sulcus: ${this.error}`);
|
|
472
1684
|
return;
|
|
473
1685
|
}
|
|
474
1686
|
|
|
@@ -555,7 +1767,7 @@ class NativeLibLoader {
|
|
|
555
1767
|
}
|
|
556
1768
|
}
|
|
557
1769
|
|
|
558
|
-
//
|
|
1770
|
+
// --- PRE-SEND FILTER ---------------------------------------------------------
|
|
559
1771
|
|
|
560
1772
|
const JUNK_PATTERNS: RegExp[] = [
|
|
561
1773
|
/^(HEARTBEAT_OK|NO_REPLY|NOOP)$/i,
|
|
@@ -569,7 +1781,11 @@ const JUNK_PATTERNS: RegExp[] = [
|
|
|
569
1781
|
/^UNTRUSTED (channel|Discord)/i,
|
|
570
1782
|
/^<<<EXTERNAL_UNTRUSTED_CONTENT/i,
|
|
571
1783
|
/^Runtime:/i,
|
|
572
|
-
/
|
|
1784
|
+
// Match raw function-call blobs only — NOT prose that mentions tool/function concepts.
|
|
1785
|
+
// e.g. raw JSON {"tool_calls":[...]} or <function_calls><invoke> XML sequences.
|
|
1786
|
+
// Avoids false-positives on architectural content like "the tool call returns..."
|
|
1787
|
+
/^\{"tool_calls":/i,
|
|
1788
|
+
/^<function_calls>\s*<invoke/i,
|
|
573
1789
|
/\[Inter-session message\]\s*sourceSession=/i,
|
|
574
1790
|
/<<<BEGIN_UNTRUSTED_CHILD_RESULT>>>/,
|
|
575
1791
|
/<<<END_UNTRUSTED_CHILD_RESULT>>>/,
|
|
@@ -592,7 +1808,7 @@ function isJunkMemory(text: string): boolean {
|
|
|
592
1808
|
return false;
|
|
593
1809
|
}
|
|
594
1810
|
|
|
595
|
-
//
|
|
1811
|
+
// --- CAPTURE DEDUP -----------------------------------------------------------
|
|
596
1812
|
|
|
597
1813
|
const captureDedup = new Map<string, number>();
|
|
598
1814
|
const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -608,7 +1824,7 @@ function shouldCapture(content: string): boolean {
|
|
|
608
1824
|
return true;
|
|
609
1825
|
}
|
|
610
1826
|
|
|
611
|
-
//
|
|
1827
|
+
// --- HOOKS CONFIG LOADER -----------------------------------------------------
|
|
612
1828
|
|
|
613
1829
|
function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
|
|
614
1830
|
const defaultsPath = resolve(__dirname, "hooks.defaults.json");
|
|
@@ -630,11 +1846,16 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
|
|
|
630
1846
|
memory_recall: { enabled: true },
|
|
631
1847
|
memory_store: { enabled: true },
|
|
632
1848
|
memory_status: { enabled: true },
|
|
1849
|
+
memory_profile: { enabled: true },
|
|
633
1850
|
consolidate: { enabled: false },
|
|
634
1851
|
export_markdown: { enabled: false },
|
|
635
1852
|
import_markdown: { enabled: false },
|
|
636
1853
|
evaluate_triggers: { enabled: false },
|
|
1854
|
+
memory_inspect: { enabled: true },
|
|
1855
|
+
guardrail_status: { enabled: true },
|
|
637
1856
|
__sulcus_workflow__: { enabled: true },
|
|
1857
|
+
session_store: { enabled: true },
|
|
1858
|
+
session_recall: { enabled: true },
|
|
638
1859
|
},
|
|
639
1860
|
};
|
|
640
1861
|
}
|
|
@@ -668,7 +1889,7 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
|
|
|
668
1889
|
return { version: defaults.version, hooks: mergedHooks, tools: mergedTools };
|
|
669
1890
|
}
|
|
670
1891
|
|
|
671
|
-
//
|
|
1892
|
+
// --- RELATIVE TIME FORMATTER -------------------------------------------------
|
|
672
1893
|
|
|
673
1894
|
function formatRelativeTime(isoTimestamp: string): string {
|
|
674
1895
|
try {
|
|
@@ -690,7 +1911,767 @@ function formatRelativeTime(isoTimestamp: string): string {
|
|
|
690
1911
|
}
|
|
691
1912
|
}
|
|
692
1913
|
|
|
693
|
-
//
|
|
1914
|
+
// --- MEMORY AGE WARNING (Task 33) ----------------------------------------------
|
|
1915
|
+
// Returns true when a memory's updated_at timestamp is older than 30 days.
|
|
1916
|
+
// Used to emit stale="true" on <memory> elements so the agent can weigh recency.
|
|
1917
|
+
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
|
|
1918
|
+
|
|
1919
|
+
function isStaleMemory(isoTimestamp: string | undefined): boolean {
|
|
1920
|
+
if (!isoTimestamp) return false;
|
|
1921
|
+
try {
|
|
1922
|
+
const dt = new Date(isoTimestamp);
|
|
1923
|
+
return Date.now() - dt.getTime() > STALE_THRESHOLD_MS;
|
|
1924
|
+
} catch {
|
|
1925
|
+
return false;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// --- CORRECTION DETECTION + HEAT-BOOST (Task 21) ----------------------------------
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* Markers that strongly suggest the user is correcting or updating a prior belief.
|
|
1933
|
+
* Checked against the full message text (case-insensitive).
|
|
1934
|
+
*/
|
|
1935
|
+
const CORRECTION_MARKERS: string[] = [
|
|
1936
|
+
"actually,", "actually ", "that's wrong", "thats wrong",
|
|
1937
|
+
"that is wrong", "correction:", "no, it", "no it's", "not quite",
|
|
1938
|
+
"update:", "i meant", "i mean", "i was wrong", "was incorrect",
|
|
1939
|
+
"is incorrect", "please update", "forget that", "ignore that",
|
|
1940
|
+
"disregard", "instead,", "rather,", "not that,", "fix:",
|
|
1941
|
+
];
|
|
1942
|
+
|
|
1943
|
+
function isCorrectionMessage(text: string): boolean {
|
|
1944
|
+
const lower = text.toLowerCase();
|
|
1945
|
+
return CORRECTION_MARKERS.some((m) => lower.includes(m));
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// --- ASSISTANT OUTPUT CAPTURE HELPERS (Task 67) ------------------------------
|
|
1949
|
+
|
|
1950
|
+
// Generic acknowledgment patterns — not worth storing as memories.
|
|
1951
|
+
const GENERIC_ACK_PATTERNS: RegExp[] = [
|
|
1952
|
+
/^(ok|okay|sure|got it|will do|understood|noted|done|sounds good|great|perfect|no problem|no worries|absolutely|certainly|of course|copy that|roger|on it|right away|working on it|let me|i'll|i will)[\.!,]?$/i,
|
|
1953
|
+
/^(yes|yeah|yep|yup|nope|no|nah)[\.!]?$/i,
|
|
1954
|
+
/^(thanks|thank you|thx|ty)[\.!]?$/i,
|
|
1955
|
+
/^(one moment|just a moment|give me a (second|moment|sec))[\.!,]?$/i,
|
|
1956
|
+
/^(looking into|checking|fetching|retrieving|processing|analyzing)\b/i,
|
|
1957
|
+
];
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Returns true if the assistant output is a generic acknowledgment
|
|
1961
|
+
* (short filler responses not worth capturing as memories).
|
|
1962
|
+
*/
|
|
1963
|
+
function isGenericAck(text: string): boolean {
|
|
1964
|
+
const trimmed = text.trim();
|
|
1965
|
+
if (trimmed.length > 250) return false; // Long responses are never pure acks
|
|
1966
|
+
return GENERIC_ACK_PATTERNS.some((p) => p.test(trimmed));
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/** Maximum characters of assistant output to store directly. Longer → summarized. */
|
|
1970
|
+
const ASSISTANT_CAPTURE_MAX_DIRECT = 1500;
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Compresses a long assistant response into a compact summary for storage.
|
|
1974
|
+
* Extracts: first paragraph (context), last paragraph (conclusion/decision),
|
|
1975
|
+
* and any sentences containing decision/recommendation markers.
|
|
1976
|
+
*/
|
|
1977
|
+
function summarizeForCapture(text: string, namespace: string): string {
|
|
1978
|
+
const paragraphs = text.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 20);
|
|
1979
|
+
if (paragraphs.length === 0) return text.substring(0, ASSISTANT_CAPTURE_MAX_DIRECT);
|
|
1980
|
+
|
|
1981
|
+
const DECISION_MARKERS = [
|
|
1982
|
+
"decided", "recommend", "conclusion", "therefore", "result:", "outcome:",
|
|
1983
|
+
"solution:", "answer:", "key point", "important:", "note:", "summary:",
|
|
1984
|
+
"in summary", "to summarize", "bottom line", "takeaway",
|
|
1985
|
+
];
|
|
1986
|
+
const keyParagraphs: string[] = [];
|
|
1987
|
+
|
|
1988
|
+
// Always include first paragraph (sets context)
|
|
1989
|
+
if (paragraphs[0]) keyParagraphs.push(paragraphs[0]);
|
|
1990
|
+
|
|
1991
|
+
// Include paragraphs with decision markers
|
|
1992
|
+
for (let i = 1; i < paragraphs.length - 1; i++) {
|
|
1993
|
+
const pLower = paragraphs[i].toLowerCase();
|
|
1994
|
+
if (DECISION_MARKERS.some((m) => pLower.includes(m))) {
|
|
1995
|
+
keyParagraphs.push(paragraphs[i]);
|
|
1996
|
+
if (keyParagraphs.length >= 3) break;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Always include last paragraph (conclusion)
|
|
2001
|
+
const last = paragraphs[paragraphs.length - 1];
|
|
2002
|
+
if (last && last !== keyParagraphs[0]) keyParagraphs.push(last);
|
|
2003
|
+
|
|
2004
|
+
const summary = keyParagraphs.join(" [...] ").substring(0, ASSISTANT_CAPTURE_MAX_DIRECT);
|
|
2005
|
+
return `[assistant summary, ns=${namespace}] ${summary}`;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Heat-boost memories semantically related to a correction message.
|
|
2010
|
+
* Searches for up to `limit` related memories and PATCHes each with
|
|
2011
|
+
* elevated heat so they surface strongly and decay slowly.
|
|
2012
|
+
* Best-effort — individual PATCH failures are silently skipped.
|
|
2013
|
+
*/
|
|
2014
|
+
async function boostRelatedMemories(
|
|
2015
|
+
sulcusMem: SulcusCloudClient,
|
|
2016
|
+
query: string,
|
|
2017
|
+
namespace: string,
|
|
2018
|
+
boostHeat: number,
|
|
2019
|
+
limit: number,
|
|
2020
|
+
logger: PluginLogger,
|
|
2021
|
+
): Promise<number> {
|
|
2022
|
+
let boosted = 0;
|
|
2023
|
+
try {
|
|
2024
|
+
const res = await sulcusMem.search_memory(query, limit, namespace);
|
|
2025
|
+
const results = res?.results ?? [];
|
|
2026
|
+
await Promise.allSettled(
|
|
2027
|
+
results.map(async (node) => {
|
|
2028
|
+
const nodeId = node.id as string;
|
|
2029
|
+
if (!nodeId) return;
|
|
2030
|
+
try {
|
|
2031
|
+
await sulcusMem.request("PATCH", `/api/v1/agent/memory/${nodeId}`, { current_heat: boostHeat });
|
|
2032
|
+
boosted++;
|
|
2033
|
+
} catch {
|
|
2034
|
+
// best-effort
|
|
2035
|
+
}
|
|
2036
|
+
})
|
|
2037
|
+
);
|
|
2038
|
+
} catch {
|
|
2039
|
+
// search failed — no boost possible
|
|
2040
|
+
}
|
|
2041
|
+
return boosted;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// --- SPACED REPETITION: BOOST ON RECALL -----------------------------------------
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* Spaced-repetition heat boost for recalled memories.
|
|
2048
|
+
* When a memory surfaces in context, nudge its heat upward so frequently
|
|
2049
|
+
* accessed knowledge persists longer. Caps at 0.95 to avoid pinning memories
|
|
2050
|
+
* that should eventually decay.
|
|
2051
|
+
*
|
|
2052
|
+
* Boost is small (delta 0.05–0.10) so the thermodynamic decay still governs
|
|
2053
|
+
* long-term retention — this just resets the decay clock slightly.
|
|
2054
|
+
* Best-effort — PATCH failures are silently swallowed.
|
|
2055
|
+
*/
|
|
2056
|
+
/**
|
|
2057
|
+
* Task 27: Heat-graduated spaced repetition boost.
|
|
2058
|
+
*
|
|
2059
|
+
* Flat-delta boosts waste PATCH calls on already-hot nodes and under-reinforce
|
|
2060
|
+
* cooling memories. This version applies a graduated delta:
|
|
2061
|
+
*
|
|
2062
|
+
* heat in [0.10, 0.40) → delta 0.12 (cold: needs a real nudge to stay alive)
|
|
2063
|
+
* heat in [0.40, 0.65) → delta 0.08 (warm: moderate reinforcement)
|
|
2064
|
+
* heat in [0.65, 0.85) → delta 0.05 (hot: small top-up only)
|
|
2065
|
+
* heat in [0.85, 1.00] → skip (already near cap — no wasted PATCH)
|
|
2066
|
+
*
|
|
2067
|
+
* Effect: recall frequency governs long-term retention more faithfully.
|
|
2068
|
+
* Rarely-recalled but still-warm memories get a strong rescue; near-maxed
|
|
2069
|
+
* memories don't get artificially pinned.
|
|
2070
|
+
*/
|
|
2071
|
+
async function boostRecalledMemories(
|
|
2072
|
+
sulcusMem: SulcusCloudClient,
|
|
2073
|
+
memories: Array<{ id?: unknown; current_heat?: unknown }>,
|
|
2074
|
+
logger: PluginLogger,
|
|
2075
|
+
): Promise<void> {
|
|
2076
|
+
const BOOST_CAP = 0.95;
|
|
2077
|
+
const MIN_HEAT_FOR_BOOST = 0.10; // skip nearly-dead nodes — they should decay
|
|
2078
|
+
const SKIP_ABOVE = 0.85; // already near cap — no PATCH needed
|
|
2079
|
+
|
|
2080
|
+
/** Returns heat-graduated boost delta, or 0 if node should be skipped. */
|
|
2081
|
+
function boostDelta(heat: number): number {
|
|
2082
|
+
if (heat < MIN_HEAT_FOR_BOOST || heat >= SKIP_ABOVE) return 0;
|
|
2083
|
+
if (heat < 0.40) return 0.12;
|
|
2084
|
+
if (heat < 0.65) return 0.08;
|
|
2085
|
+
return 0.05;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const toBoost = memories
|
|
2089
|
+
.map((m) => ({ id: m.id as string | undefined, heat: (m.current_heat as number) ?? 0 }))
|
|
2090
|
+
.filter((m) => m.id && boostDelta(m.heat) > 0);
|
|
2091
|
+
|
|
2092
|
+
if (toBoost.length === 0) return;
|
|
2093
|
+
|
|
2094
|
+
// -- Task 34: Batch heat-boost — single round-trip when server supports it --
|
|
2095
|
+
// Try POST /api/v1/agent/boost-batch first (server >= 2.11.0).
|
|
2096
|
+
// Falls back to N individual PATCHes if the endpoint returns 404.
|
|
2097
|
+
const batchItems = toBoost.map(({ id, heat }) => ({
|
|
2098
|
+
id: id!,
|
|
2099
|
+
heat: parseFloat(Math.min(BOOST_CAP, heat + boostDelta(heat)).toFixed(3)),
|
|
2100
|
+
}));
|
|
2101
|
+
|
|
2102
|
+
let usedBatch = false;
|
|
2103
|
+
try {
|
|
2104
|
+
usedBatch = await sulcusMem.boost_batch(batchItems);
|
|
2105
|
+
} catch {
|
|
2106
|
+
// network error or 5xx — fall through to individual PATCHes
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
if (usedBatch) {
|
|
2110
|
+
const totalDeltaBatch = toBoost.reduce((acc, { heat }) => acc + boostDelta(heat), 0);
|
|
2111
|
+
const avgDelta = (totalDeltaBatch / toBoost.length).toFixed(3);
|
|
2112
|
+
logger.info(`sulcus: boost-on-recall — batch boost for ${toBoost.length} memor${toBoost.length === 1 ? "y" : "ies"} (avg Δ${avgDelta}, 1 round-trip)`);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Fallback: individual PATCHes (server < 2.11.0 or batch endpoint unavailable)
|
|
2117
|
+
let boosted = 0;
|
|
2118
|
+
let totalDelta = 0;
|
|
2119
|
+
await Promise.allSettled(
|
|
2120
|
+
toBoost.map(async ({ id, heat }) => {
|
|
2121
|
+
const delta = boostDelta(heat);
|
|
2122
|
+
const newHeat = Math.min(BOOST_CAP, heat + delta);
|
|
2123
|
+
try {
|
|
2124
|
+
await sulcusMem.request("PATCH", `/api/v1/agent/memory/${encodeURIComponent(id!)}`, {
|
|
2125
|
+
current_heat: parseFloat(newHeat.toFixed(3)),
|
|
2126
|
+
});
|
|
2127
|
+
boosted++;
|
|
2128
|
+
totalDelta += delta;
|
|
2129
|
+
} catch {
|
|
2130
|
+
// best-effort — server may be busy or node already decayed
|
|
2131
|
+
}
|
|
2132
|
+
})
|
|
2133
|
+
);
|
|
2134
|
+
|
|
2135
|
+
if (boosted > 0) {
|
|
2136
|
+
const avgDelta = (totalDelta / boosted).toFixed(3);
|
|
2137
|
+
logger.info(`sulcus: boost-on-recall — individual boost for ${boosted}/${toBoost.length} memor${boosted === 1 ? "y" : "ies"} (avg Δ${avgDelta}, ${toBoost.length} round-trips)`);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// --- CONTEXT BUDGET ENFORCEMENT (Task 18) ---------------------------------------
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* Rough token estimator — 1 token ≈ 4 chars (conservative for XML-heavy content).
|
|
2145
|
+
* Used to enforce the context token budget before injecting.
|
|
2146
|
+
*/
|
|
2147
|
+
function estimateTokens(text: string): number {
|
|
2148
|
+
return Math.ceil(text.length / 4);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* Truncate a memory label to fit within a character budget.
|
|
2153
|
+
* Appends ellipsis if truncated. Prefers word-boundary cuts.
|
|
2154
|
+
*/
|
|
2155
|
+
function truncateLabel(label: string, maxChars: number): string {
|
|
2156
|
+
if (label.length <= maxChars) return label;
|
|
2157
|
+
const cut = label.lastIndexOf(" ", maxChars - 3);
|
|
2158
|
+
const boundary = cut > maxChars * 0.6 ? cut : maxChars - 3;
|
|
2159
|
+
return label.slice(0, boundary) + "…";
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// --- ADAPTIVE SCALING (Task 101) -----------------------------------------------
|
|
2163
|
+
// Reduces recall budget and result count as conversation grows.
|
|
2164
|
+
// Early turns get full context; later turns shrink to leave room for actual work.
|
|
2165
|
+
// This prevents Sulcus from consuming an ever-larger share of the context window
|
|
2166
|
+
// in long conversations, which was causing compaction/summarization triggers.
|
|
2167
|
+
interface AdaptiveScale {
|
|
2168
|
+
effectiveMax: number;
|
|
2169
|
+
effectiveTokenBudget: number;
|
|
2170
|
+
/** True when context utilization is so high that injection should be skipped entirely. */
|
|
2171
|
+
selfMuted: boolean;
|
|
2172
|
+
}
|
|
2173
|
+
function applyAdaptiveScaling(turnCount: number, maxResults: number, tokenBudget: number): AdaptiveScale {
|
|
2174
|
+
// Turns 1-5: full budget
|
|
2175
|
+
// Turns 6-15: 80%
|
|
2176
|
+
// Turns 16-30: 60%
|
|
2177
|
+
// Turns 30+: 40%
|
|
2178
|
+
let factor = 1.0;
|
|
2179
|
+
if (turnCount > 30) factor = 0.4;
|
|
2180
|
+
else if (turnCount > 15) factor = 0.6;
|
|
2181
|
+
else if (turnCount > 5) factor = 0.8;
|
|
2182
|
+
|
|
2183
|
+
return {
|
|
2184
|
+
effectiveMax: Math.max(2, Math.floor(maxResults * factor)),
|
|
2185
|
+
effectiveTokenBudget: Math.max(500, Math.floor(tokenBudget * factor)),
|
|
2186
|
+
selfMuted: false,
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Task 102: Context-window-aware throttling.
|
|
2192
|
+
* Measures the actual prompt size (event.prompt) against the model's context window
|
|
2193
|
+
* and applies aggressive throttling when utilization is high.
|
|
2194
|
+
*
|
|
2195
|
+
* This is the REAL defense against context crashes — turn-based scaling is a heuristic,
|
|
2196
|
+
* but prompt size measurement is ground truth. A few turns with large tool outputs
|
|
2197
|
+
* (e.g. file reads, verbose exec results) can fill the window fast regardless of turn count.
|
|
2198
|
+
*
|
|
2199
|
+
* Thresholds:
|
|
2200
|
+
* <70%: no additional throttling (turn-based scaling is sufficient)
|
|
2201
|
+
* 70-85%: reduce to 50% of budget
|
|
2202
|
+
* 85-93%: reduce to 20% of budget, max 2 results
|
|
2203
|
+
* >93%: self-mute — return nothing, let the model breathe
|
|
2204
|
+
*/
|
|
2205
|
+
function applyContextWindowThrottle(
|
|
2206
|
+
promptChars: number,
|
|
2207
|
+
contextWindowTokens: number,
|
|
2208
|
+
scale: AdaptiveScale,
|
|
2209
|
+
logger?: PluginLogger,
|
|
2210
|
+
): AdaptiveScale {
|
|
2211
|
+
// Estimate tokens from chars (~4 chars per token for English + code mixed content)
|
|
2212
|
+
const estimatedTokens = Math.ceil(promptChars / 4);
|
|
2213
|
+
const utilization = estimatedTokens / contextWindowTokens;
|
|
2214
|
+
|
|
2215
|
+
if (utilization > 0.93) {
|
|
2216
|
+
// DANGER ZONE: self-mute entirely. Every token counts.
|
|
2217
|
+
logger?.warn?.(`sulcus: context window ${(utilization * 100).toFixed(0)}% full (~${estimatedTokens} tokens / ${contextWindowTokens}) — SELF-MUTING recall injection`);
|
|
2218
|
+
return { effectiveMax: 0, effectiveTokenBudget: 0, selfMuted: true };
|
|
2219
|
+
}
|
|
2220
|
+
if (utilization > 0.85) {
|
|
2221
|
+
// HIGH: minimal injection — just enough for continuity
|
|
2222
|
+
logger?.info?.(`sulcus: context window ${(utilization * 100).toFixed(0)}% full — aggressive throttle (20% budget, max 2 results)`);
|
|
2223
|
+
return {
|
|
2224
|
+
effectiveMax: Math.min(2, scale.effectiveMax),
|
|
2225
|
+
effectiveTokenBudget: Math.max(200, Math.floor(scale.effectiveTokenBudget * 0.2)),
|
|
2226
|
+
selfMuted: false,
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
if (utilization > 0.70) {
|
|
2230
|
+
// MODERATE: reduce footprint
|
|
2231
|
+
logger?.debug?.(`sulcus: context window ${(utilization * 100).toFixed(0)}% full — moderate throttle (50% budget)`);
|
|
2232
|
+
return {
|
|
2233
|
+
effectiveMax: Math.max(2, Math.floor(scale.effectiveMax * 0.6)),
|
|
2234
|
+
effectiveTokenBudget: Math.max(300, Math.floor(scale.effectiveTokenBudget * 0.5)),
|
|
2235
|
+
selfMuted: false,
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// <70%: no additional throttling
|
|
2240
|
+
return scale;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* Given a list of memory items already sorted by heat desc, trim them to fit
|
|
2245
|
+
* within `tokenBudget` tokens (estimated). Returns the subset that fits.
|
|
2246
|
+
* Each item's label is also truncated if it alone would exceed the per-item cap.
|
|
2247
|
+
*
|
|
2248
|
+
* @param items - Memory records with normalized `label` field, sorted by heat desc
|
|
2249
|
+
* @param tokenBudget - Max tokens for the entire recall block
|
|
2250
|
+
* @param overhead - Fixed overhead tokens already allocated elsewhere
|
|
2251
|
+
*/
|
|
2252
|
+
function enforceContextBudget(
|
|
2253
|
+
items: Array<{ label: string; [k: string]: unknown }>,
|
|
2254
|
+
tokenBudget: number,
|
|
2255
|
+
overhead: number
|
|
2256
|
+
): Array<{ label: string; [k: string]: unknown }> {
|
|
2257
|
+
const remaining = tokenBudget - overhead;
|
|
2258
|
+
if (remaining <= 0) return [];
|
|
2259
|
+
|
|
2260
|
+
// Per-item cap: a single memory should not dominate the budget.
|
|
2261
|
+
// Allow up to 40% of the remaining budget for any one item, but never exceed 250 chars
|
|
2262
|
+
// (Task 101: prevents verbose pointer_summaries from consuming the context window).
|
|
2263
|
+
const MAX_LABEL_CHARS = 250;
|
|
2264
|
+
const perItemCharCap = Math.min(MAX_LABEL_CHARS, Math.floor((remaining * 4) * 0.4));
|
|
2265
|
+
|
|
2266
|
+
const result: Array<{ label: string; [k: string]: unknown }> = [];
|
|
2267
|
+
let usedTokens = 0;
|
|
2268
|
+
|
|
2269
|
+
for (const item of items) {
|
|
2270
|
+
const truncated = truncateLabel(item.label, perItemCharCap);
|
|
2271
|
+
const itemTokens = estimateTokens(truncated) + 8; // +8 for XML tag overhead
|
|
2272
|
+
if (usedTokens + itemTokens > remaining) break;
|
|
2273
|
+
result.push({ ...item, label: truncated });
|
|
2274
|
+
usedTokens += itemTokens;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
return result;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// --- DIVERSITY FILTER (Task 20) -----------------------------------------------
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Jaccard-penalised diversity filter — prevents the context window from being
|
|
2284
|
+
* filled with near-duplicate memories about the same thing.
|
|
2285
|
+
*
|
|
2286
|
+
* Algorithm (MMR-lite):
|
|
2287
|
+
* 1. Start with the highest-heat item as the first selected.
|
|
2288
|
+
* 2. For each remaining candidate, compute its max Jaccard similarity to
|
|
2289
|
+
* any already-selected item.
|
|
2290
|
+
* 3. Score = heat * (1 - LAMBDA * maxSim) where LAMBDA controls how
|
|
2291
|
+
* strongly we penalise similarity (0 = pure heat, 1 = pure diversity).
|
|
2292
|
+
* 4. Pick the highest-scoring candidate next. Repeat until cap reached.
|
|
2293
|
+
*
|
|
2294
|
+
* This keeps the top result trustworthy (highest heat wins) while diversifying
|
|
2295
|
+
* the rest. A cap of `limit` prevents runaway expansion.
|
|
2296
|
+
*/
|
|
2297
|
+
const DIVERSITY_LAMBDA = 0.55; // penalty weight for similarity
|
|
2298
|
+
const DIVERSITY_SIM_THRESHOLD = 0.65; // above this → considered near-duplicate
|
|
2299
|
+
|
|
2300
|
+
function diversityFilter(
|
|
2301
|
+
items: Array<{ label: string; _heat: number; [k: string]: unknown }>,
|
|
2302
|
+
limit: number
|
|
2303
|
+
): typeof items {
|
|
2304
|
+
if (items.length <= 1) return items;
|
|
2305
|
+
|
|
2306
|
+
const selected: typeof items = [];
|
|
2307
|
+
const remaining = [...items];
|
|
2308
|
+
|
|
2309
|
+
// Always seed with the top-heat item
|
|
2310
|
+
const first = remaining.splice(0, 1)[0];
|
|
2311
|
+
selected.push(first);
|
|
2312
|
+
|
|
2313
|
+
while (selected.length < limit && remaining.length > 0) {
|
|
2314
|
+
let bestIdx = 0;
|
|
2315
|
+
let bestScore = -Infinity;
|
|
2316
|
+
|
|
2317
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
2318
|
+
const candidate = remaining[i];
|
|
2319
|
+
// Max similarity to any already-selected item
|
|
2320
|
+
let maxSim = 0;
|
|
2321
|
+
for (const sel of selected) {
|
|
2322
|
+
const sim = topicTokenOverlap(candidate.label, sel.label);
|
|
2323
|
+
if (sim > maxSim) maxSim = sim;
|
|
2324
|
+
}
|
|
2325
|
+
// MMR score: balance heat vs novelty
|
|
2326
|
+
const score = candidate._heat * (1 - DIVERSITY_LAMBDA * maxSim);
|
|
2327
|
+
if (score > bestScore) {
|
|
2328
|
+
bestScore = score;
|
|
2329
|
+
bestIdx = i;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
const chosen = remaining.splice(bestIdx, 1)[0];
|
|
2334
|
+
// Hard cutoff: skip if too similar to anything already in window
|
|
2335
|
+
// (score so low even penalised it won't help)
|
|
2336
|
+
const maxSimToSelected = selected.reduce((m, s) => {
|
|
2337
|
+
const sim = topicTokenOverlap(chosen.label, s.label);
|
|
2338
|
+
return sim > m ? sim : m;
|
|
2339
|
+
}, 0);
|
|
2340
|
+
if (maxSimToSelected < DIVERSITY_SIM_THRESHOLD) {
|
|
2341
|
+
selected.push(chosen);
|
|
2342
|
+
}
|
|
2343
|
+
// If similarity was too high, we still consumed the slot (prevents infinite loop)
|
|
2344
|
+
// but don't add it — effectively dropping the near-duplicate.
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
return selected;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// --- CONFLICT DETECTION (Task 19) ----------------------------------------------
|
|
2351
|
+
|
|
2352
|
+
/**
|
|
2353
|
+
* Lightweight conflict detector — finds pairs of memories that share
|
|
2354
|
+
* significant topic overlap but where one contains negation/correction
|
|
2355
|
+
* language that may contradict the other, OR where both address the same
|
|
2356
|
+
* concept but one is substantially newer (stale vs updated).
|
|
2357
|
+
*
|
|
2358
|
+
* Returns pairs as { older, newer } with a reason string.
|
|
2359
|
+
* Capped at 3 conflict pairs to stay within token budget.
|
|
2360
|
+
*/
|
|
2361
|
+
const NEGATION_MARKERS = [
|
|
2362
|
+
"not ", "no longer", "never", "removed", "deprecated", "disabled",
|
|
2363
|
+
"changed", "replaced", "fixed", "incorrect", "wrong", "actually",
|
|
2364
|
+
"correction", "mistake", "was wrong", "instead", "update:",
|
|
2365
|
+
];
|
|
2366
|
+
|
|
2367
|
+
function hasNegationMarker(text: string): boolean {
|
|
2368
|
+
const lower = text.toLowerCase();
|
|
2369
|
+
return NEGATION_MARKERS.some((m) => lower.includes(m));
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function topicTokenOverlap(a: string, b: string): number {
|
|
2373
|
+
const ta = extractTopicTokens(a);
|
|
2374
|
+
const tb = extractTopicTokens(b);
|
|
2375
|
+
return topicOverlap(ta, tb);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function parseISOMs(iso: string | undefined): number {
|
|
2379
|
+
if (!iso) return 0;
|
|
2380
|
+
try { return new Date(iso).getTime(); } catch { return 0; }
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// -- Task 79: Temporal query detection ------------------------------------------
|
|
2384
|
+
// Detects whether a user query is asking about events in time-order ("when did",
|
|
2385
|
+
// "what happened yesterday", "sequence of events", etc.) so recall results can
|
|
2386
|
+
// be re-sorted chronologically instead of by relevance/heat.
|
|
2387
|
+
const TEMPORAL_KEYWORDS = [
|
|
2388
|
+
"yesterday", "today", "last week", "this week", "last month", "this month",
|
|
2389
|
+
"days ago", "hours ago", "weeks ago", "months ago",
|
|
2390
|
+
"last monday", "last tuesday", "last wednesday", "last thursday",
|
|
2391
|
+
"last friday", "last saturday", "last sunday",
|
|
2392
|
+
"recently", "timeline", "chronolog", "sequence of", "in order",
|
|
2393
|
+
"what order", "time order", "when did", "when was", "since when",
|
|
2394
|
+
"how long ago", "first thing", "before that", "after that",
|
|
2395
|
+
];
|
|
2396
|
+
|
|
2397
|
+
function isTemporalQuery(query: string): boolean {
|
|
2398
|
+
const q = query.toLowerCase();
|
|
2399
|
+
return TEMPORAL_KEYWORDS.some((kw) => q.includes(kw));
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Re-sort recall results chronologically (oldest → newest) for temporal queries.
|
|
2404
|
+
* Falls back to original order if updated_at is missing from results.
|
|
2405
|
+
* Returns a new array — does not mutate the input.
|
|
2406
|
+
*/
|
|
2407
|
+
function temporalRerank<T extends { updated_at?: string; [k: string]: unknown }>(items: T[]): T[] {
|
|
2408
|
+
const withTimestamp = items.filter((r) => r.updated_at);
|
|
2409
|
+
// Only re-rank if at least half the results have timestamps — otherwise
|
|
2410
|
+
// chronological ordering would be meaningless (too many unknowns).
|
|
2411
|
+
if (withTimestamp.length < items.length / 2) return items;
|
|
2412
|
+
return [...items].sort((a, b) => {
|
|
2413
|
+
const aMs = parseISOMs(a.updated_at);
|
|
2414
|
+
const bMs = parseISOMs(b.updated_at);
|
|
2415
|
+
return aMs - bMs; // ascending = oldest first (chronological)
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
// -- end Task 79 ----------------------------------------------------------------
|
|
2419
|
+
|
|
2420
|
+
// Task 35: entity-context response entry
|
|
2421
|
+
interface EntityContextEntry {
|
|
2422
|
+
name: string;
|
|
2423
|
+
type: string;
|
|
2424
|
+
related_memories: Array<{ id: string; pointer_summary: string; memory_type: string; current_heat: number }>;
|
|
2425
|
+
connections: Array<{ name: string; relationship: string }>;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// -- Task 80: Temporal supersession (replaces Task 19 conflict detection) -----
|
|
2429
|
+
// When two memories share significant topic overlap AND one is newer (by time
|
|
2430
|
+
// or by containing correction/negation language), the newer one is the truth.
|
|
2431
|
+
// The older one is historical context that should rank below.
|
|
2432
|
+
//
|
|
2433
|
+
// Instead of a separate <conflicts> block, we:
|
|
2434
|
+
// 1. Detect superseded items via topic overlap + time/negation
|
|
2435
|
+
// 2. Mark them with _superseded = true
|
|
2436
|
+
// 3. Apply a 50% score penalty so they fall below the budget cut line
|
|
2437
|
+
// 4. Tag them with superseded="true" in the XML
|
|
2438
|
+
// 5. The newer memory keeps its natural (prominent) position
|
|
2439
|
+
//
|
|
2440
|
+
// This aligns with transformer attention: prominent/later context items have
|
|
2441
|
+
// stronger influence on generation. Newer memory = primary, older = footnote.
|
|
2442
|
+
|
|
2443
|
+
const SUPERSESSION_SCORE_PENALTY = 0.5; // 50% penalty on superseded item scores
|
|
2444
|
+
const SUPERSESSION_MIN_OVERLAP = 0.35; // minimum topic overlap to compare
|
|
2445
|
+
const SUPERSESSION_STALENESS_GAP_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* Scan items for supersession: when two memories share topic overlap and one
|
|
2449
|
+
* is newer (by timestamp or negation markers), the older one gets _superseded=true
|
|
2450
|
+
* and a score penalty. Mutates items in-place. Returns count of superseded items.
|
|
2451
|
+
*/
|
|
2452
|
+
function markSuperseded<T extends { label: string; _heat: number; updated_at?: string; _superseded?: boolean; [k: string]: unknown }>(
|
|
2453
|
+
items: T[]
|
|
2454
|
+
): number {
|
|
2455
|
+
let supersededCount = 0;
|
|
2456
|
+
const alreadySuperseded = new Set<number>();
|
|
2457
|
+
|
|
2458
|
+
for (let i = 0; i < items.length; i++) {
|
|
2459
|
+
if (alreadySuperseded.has(i)) continue;
|
|
2460
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
2461
|
+
if (alreadySuperseded.has(j)) continue;
|
|
2462
|
+
|
|
2463
|
+
const a = items[i];
|
|
2464
|
+
const b = items[j];
|
|
2465
|
+
const overlap = topicTokenOverlap(a.label, b.label);
|
|
2466
|
+
if (overlap < SUPERSESSION_MIN_OVERLAP) continue;
|
|
2467
|
+
|
|
2468
|
+
// Determine which is newer
|
|
2469
|
+
const aNeg = hasNegationMarker(a.label);
|
|
2470
|
+
const bNeg = hasNegationMarker(b.label);
|
|
2471
|
+
const aMs = parseISOMs(a.updated_at);
|
|
2472
|
+
const bMs = parseISOMs(b.updated_at);
|
|
2473
|
+
|
|
2474
|
+
let olderIdx: number | null = null;
|
|
2475
|
+
|
|
2476
|
+
// Negation supersession: the corrective memory supersedes the original
|
|
2477
|
+
if (aNeg !== bNeg) {
|
|
2478
|
+
olderIdx = aNeg ? j : i; // non-negation item is the older/superseded one
|
|
2479
|
+
}
|
|
2480
|
+
// Staleness supersession: significantly newer timestamp wins
|
|
2481
|
+
else if (aMs > 0 && bMs > 0 && Math.abs(aMs - bMs) > SUPERSESSION_STALENESS_GAP_MS) {
|
|
2482
|
+
olderIdx = aMs < bMs ? i : j;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if (olderIdx !== null) {
|
|
2486
|
+
items[olderIdx]._superseded = true;
|
|
2487
|
+
items[olderIdx]._heat *= SUPERSESSION_SCORE_PENALTY;
|
|
2488
|
+
alreadySuperseded.add(olderIdx);
|
|
2489
|
+
supersededCount++;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
return supersededCount;
|
|
2495
|
+
}
|
|
2496
|
+
// -- end Task 80 -----------------------------------------------------------------
|
|
2497
|
+
|
|
2498
|
+
// Legacy alias — detectConflicts is no longer used but kept for backward compat
|
|
2499
|
+
// in case external code references it. Returns empty array.
|
|
2500
|
+
function detectConflicts(
|
|
2501
|
+
_items: Array<{ label: string; memory_type?: string; updated_at?: string; [k: string]: unknown }>
|
|
2502
|
+
): Array<{ olderLabel: string; newerLabel: string; reason: string }> {
|
|
2503
|
+
return [];
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// --- SDK RECALL HANDLER (for before_prompt_build with prependContext) ----------
|
|
2507
|
+
|
|
2508
|
+
// Topic-shift detection constants (Task 14)
|
|
2509
|
+
const TOPIC_SHIFT_THRESHOLD = 0.25; // Jaccard overlap below this = topic shift
|
|
2510
|
+
const TOPIC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes hard TTL
|
|
2511
|
+
const STOPWORDS = new Set([
|
|
2512
|
+
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
|
|
2513
|
+
"of", "with", "by", "is", "it", "this", "that", "be", "as", "are",
|
|
2514
|
+
"was", "were", "has", "have", "had", "do", "does", "did", "can", "could",
|
|
2515
|
+
"will", "would", "should", "i", "you", "we", "they", "he", "she", "me",
|
|
2516
|
+
"my", "your", "our", "their", "its", "not", "no", "so", "if", "what",
|
|
2517
|
+
"how", "when", "where", "which", "who", "from", "up", "about", "into",
|
|
2518
|
+
"just", "also", "any", "all", "than", "then", "there", "been", "more",
|
|
2519
|
+
]);
|
|
2520
|
+
|
|
2521
|
+
// --- QUERY SANITIZATION ------------------------------------------------------
|
|
2522
|
+
// Strip OpenClaw framework noise from prompts before using them as search queries.
|
|
2523
|
+
// Removes sender metadata JSON blocks, untrusted content wrappers, conversation
|
|
2524
|
+
// info blocks, and timestamp prefixes that pollute semantic search.
|
|
2525
|
+
|
|
2526
|
+
/**
|
|
2527
|
+
* Task 62: Extract the last user-authored turn from the full prompt context.
|
|
2528
|
+
* The full `event.prompt` in before_prompt_build is the entire accumulated context
|
|
2529
|
+
* buffer — using it as a recall query means old context dominates over what the
|
|
2530
|
+
* user is actually asking right now.
|
|
2531
|
+
*
|
|
2532
|
+
* Strategy: find the last non-empty block of text that looks like user input
|
|
2533
|
+
* (not a system header, not a JSON metadata block, not an assistant turn marker).
|
|
2534
|
+
* Falls back to the last 500 chars of the full sanitized prompt if extraction fails.
|
|
2535
|
+
*
|
|
2536
|
+
* Used for: recall vector search query (current intent)
|
|
2537
|
+
* NOT used for: topic-shift detection (still uses full prompt for drift measurement)
|
|
2538
|
+
*/
|
|
2539
|
+
function extractLastUserTurn(rawPrompt: string): string {
|
|
2540
|
+
// First sanitize to remove metadata noise
|
|
2541
|
+
const cleaned = sanitizeRecallQuery(rawPrompt);
|
|
2542
|
+
if (!cleaned || cleaned.length < 3) return cleaned;
|
|
2543
|
+
|
|
2544
|
+
// Split into paragraphs — user messages are typically separated by newlines
|
|
2545
|
+
const paragraphs = cleaned
|
|
2546
|
+
.split(/\n{2,}/)
|
|
2547
|
+
.map((p) => p.trim())
|
|
2548
|
+
.filter((p) => p.length > 0);
|
|
2549
|
+
|
|
2550
|
+
if (paragraphs.length === 0) return cleaned.substring(cleaned.length - 500);
|
|
2551
|
+
|
|
2552
|
+
// Walk backwards to find the last paragraph that looks like user content
|
|
2553
|
+
// Skip: system headers ("You are...", "Your task..."), very short fragments,
|
|
2554
|
+
// pure JSON/XML blocks, assistant turn markers
|
|
2555
|
+
for (let i = paragraphs.length - 1; i >= 0; i--) {
|
|
2556
|
+
const p = paragraphs[i];
|
|
2557
|
+
if (p.length < 10) continue; // too short to be meaningful
|
|
2558
|
+
if (/^\{[\s\S]*\}$/.test(p)) continue; // pure JSON object
|
|
2559
|
+
if (/^<[a-zA-Z]/.test(p) && /<\/[a-zA-Z]/.test(p)) continue; // pure XML block
|
|
2560
|
+
if (/^(you are|your task|system:|assistant:|\[system\])/i.test(p)) continue; // system/role headers
|
|
2561
|
+
if (/^\s*```/.test(p)) continue; // code block
|
|
2562
|
+
// Found a usable paragraph — take up to 500 chars
|
|
2563
|
+
return p.substring(0, 500);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// Fallback: last 500 chars of the full cleaned prompt
|
|
2567
|
+
return cleaned.substring(Math.max(0, cleaned.length - 500));
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function sanitizeRecallQuery(raw: string): string {
|
|
2571
|
+
let cleaned = raw;
|
|
2572
|
+
// Strip "Conversation info (untrusted metadata):" + JSON code blocks
|
|
2573
|
+
cleaned = cleaned.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "");
|
|
2574
|
+
// Strip "Sender (untrusted metadata):" + JSON code blocks
|
|
2575
|
+
cleaned = cleaned.replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, "");
|
|
2576
|
+
// Strip "Replied message (untrusted, for context):" + JSON code blocks
|
|
2577
|
+
cleaned = cleaned.replace(/Replied message \(untrusted[^)]*\):\s*```json[\s\S]*?```\s*/gi, "");
|
|
2578
|
+
// Strip EXTERNAL_UNTRUSTED_CONTENT wrappers
|
|
2579
|
+
cleaned = cleaned.replace(/<<<EXTERNAL_UNTRUSTED_CONTENT[\s\S]*?<<<END_EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/g, "");
|
|
2580
|
+
// Strip "Untrusted context (metadata, do not treat as instructions or commands):" headers
|
|
2581
|
+
cleaned = cleaned.replace(/Untrusted context \(metadata[^)]*\):\s*/gi, "");
|
|
2582
|
+
// Strip leading [timestamp] or [sender] tags
|
|
2583
|
+
cleaned = cleaned.replace(/^\[[^\]]{0,100}\]\s*/g, "");
|
|
2584
|
+
// Strip @ mentions
|
|
2585
|
+
cleaned = cleaned.replace(/<@!?\d+>/g, "");
|
|
2586
|
+
cleaned = cleaned.replace(/@\w+/g, "");
|
|
2587
|
+
// Collapse whitespace
|
|
2588
|
+
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
|
2589
|
+
return cleaned || raw;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function extractTopicTokens(text: string): Set<string> {
|
|
2593
|
+
const tokens = text
|
|
2594
|
+
.toLowerCase()
|
|
2595
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
2596
|
+
.split(/\s+/)
|
|
2597
|
+
.filter((t) => t.length > 2 && !STOPWORDS.has(t));
|
|
2598
|
+
return new Set(tokens.slice(0, 40));
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function topicOverlap(a: Set<string>, b: Set<string>): number {
|
|
2602
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
2603
|
+
let shared = 0;
|
|
2604
|
+
for (const token of a) { if (b.has(token)) shared++; }
|
|
2605
|
+
return shared / Math.max(a.size, b.size);
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/**
|
|
2609
|
+
* Task 35: Query expansion for thin recall.
|
|
2610
|
+
* When vector search returns < THIN_RECALL_THRESHOLD results, call entity-context
|
|
2611
|
+
* to discover synonym terms and directly-connected memories from the knowledge graph.
|
|
2612
|
+
*
|
|
2613
|
+
* Returns an object with:
|
|
2614
|
+
* - extraMemories: additional memory records from entity graph connections
|
|
2615
|
+
* - expandedQuery: a broadened query string with synonym entity names appended
|
|
2616
|
+
*/
|
|
2617
|
+
async function expandQueryWithEntities(
|
|
2618
|
+
client: SulcusCloudClient,
|
|
2619
|
+
originalQuery: string,
|
|
2620
|
+
namespace: string | undefined,
|
|
2621
|
+
logger: PluginLogger,
|
|
2622
|
+
): Promise<{ extraMemories: Record<string, unknown>[]; expandedQuery: string }> {
|
|
2623
|
+
const tokens = Array.from(extractTopicTokens(originalQuery)).slice(0, 5);
|
|
2624
|
+
if (tokens.length === 0) return { extraMemories: [], expandedQuery: originalQuery };
|
|
2625
|
+
|
|
2626
|
+
const entityData = await client.entity_context(tokens, namespace, 3);
|
|
2627
|
+
if (entityData.length === 0) return { extraMemories: [], expandedQuery: originalQuery };
|
|
2628
|
+
|
|
2629
|
+
const synonymTerms: string[] = [];
|
|
2630
|
+
const extraMemories: Record<string, unknown>[] = [];
|
|
2631
|
+
const seenIds = new Set<string>();
|
|
2632
|
+
|
|
2633
|
+
for (const entity of entityData) {
|
|
2634
|
+
// Collect connected entity names as synonym expansion terms
|
|
2635
|
+
for (const conn of entity.connections) {
|
|
2636
|
+
if (conn.name && conn.name.length > 2) {
|
|
2637
|
+
synonymTerms.push(conn.name);
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
// Collect directly-connected memories
|
|
2641
|
+
for (const mem of entity.related_memories) {
|
|
2642
|
+
if (mem.id && !seenIds.has(mem.id)) {
|
|
2643
|
+
seenIds.add(mem.id);
|
|
2644
|
+
extraMemories.push({
|
|
2645
|
+
id: mem.id,
|
|
2646
|
+
label: mem.pointer_summary,
|
|
2647
|
+
pointer_summary: mem.pointer_summary,
|
|
2648
|
+
memory_type: mem.memory_type,
|
|
2649
|
+
current_heat: mem.current_heat,
|
|
2650
|
+
_heat: mem.current_heat,
|
|
2651
|
+
_source: "entity_expansion",
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// Build expanded query: original terms + up to 5 unique synonym terms
|
|
2658
|
+
const uniqueSynonyms = [...new Set(synonymTerms)].slice(0, 5);
|
|
2659
|
+
const expandedQuery = uniqueSynonyms.length > 0
|
|
2660
|
+
? `${originalQuery} ${uniqueSynonyms.join(" ")}`
|
|
2661
|
+
: originalQuery;
|
|
2662
|
+
|
|
2663
|
+
logger.info(`sulcus: query expansion found ${entityData.length} entity/entities, ${extraMemories.length} extra memories, ${uniqueSynonyms.length} synonym(s)`);
|
|
2664
|
+
return { extraMemories, expandedQuery };
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/** Minimum vector results before triggering query expansion (Task 35). */
|
|
2668
|
+
const THIN_RECALL_THRESHOLD = 3;
|
|
2669
|
+
|
|
2670
|
+
interface RecallCache {
|
|
2671
|
+
results: Record<string, unknown>[];
|
|
2672
|
+
topicTokens: Set<string>;
|
|
2673
|
+
cachedAt: number;
|
|
2674
|
+
}
|
|
694
2675
|
|
|
695
2676
|
interface ProfileCache {
|
|
696
2677
|
preferences: Record<string, unknown>[];
|
|
@@ -703,29 +2684,263 @@ function buildSdkRecallHandler(
|
|
|
703
2684
|
namespace: string,
|
|
704
2685
|
maxResults: number,
|
|
705
2686
|
profileFrequency: number,
|
|
706
|
-
logger: PluginLogger
|
|
2687
|
+
logger: PluginLogger,
|
|
2688
|
+
boostOnRecall: boolean = true,
|
|
2689
|
+
/** Task 66: configurable token budget for recall context injection. */
|
|
2690
|
+
tokenBudget: number = 4000,
|
|
2691
|
+
/** Task 70: enable post-compaction context rebuild. Default true. */
|
|
2692
|
+
contextRebuild: boolean = true,
|
|
2693
|
+
/** Task 102: model context window size in tokens. Used for utilization-based throttling. */
|
|
2694
|
+
contextWindowSize: number = 200000,
|
|
707
2695
|
) {
|
|
708
2696
|
let turnCount = 0;
|
|
709
2697
|
let profileCache: ProfileCache | null = null;
|
|
2698
|
+
let recallCache: RecallCache | null = null;
|
|
2699
|
+
|
|
2700
|
+
// -- Task 26: Recall quality metrics (session-scoped) ---------------------
|
|
2701
|
+
let qm_freshRecalls = 0; // turns where we hit the API (topic shifted)
|
|
2702
|
+
let qm_cacheHits = 0; // turns where we served cached results
|
|
2703
|
+
let qm_totalItemsServed = 0; // cumulative recall items injected into context
|
|
2704
|
+
let qm_totalFails = 0; // turns where recall returned nothing
|
|
2705
|
+
const QM_LOG_INTERVAL = 10; // log a quality summary every N turns
|
|
2706
|
+
// -- end Task 26 ------------------------------------------------------------
|
|
710
2707
|
|
|
711
2708
|
return async (event: Record<string, unknown>, _ctx: unknown): Promise<{ prependContext: string } | undefined> => {
|
|
712
|
-
const
|
|
713
|
-
if (!
|
|
2709
|
+
const rawPrompt = typeof event?.prompt === "string" ? event.prompt : "";
|
|
2710
|
+
if (!rawPrompt || rawPrompt.length < 5) return undefined;
|
|
2711
|
+
|
|
2712
|
+
// Strip OpenClaw metadata noise before using as search query
|
|
2713
|
+
const prompt = sanitizeRecallQuery(rawPrompt);
|
|
2714
|
+
if (!prompt || prompt.length < 3) return undefined;
|
|
2715
|
+
// Task 62: Use focused last-user-turn for recall query; full prompt for topic-shift detection
|
|
2716
|
+
const recallQuery = extractLastUserTurn(rawPrompt);
|
|
714
2717
|
|
|
715
2718
|
turnCount++;
|
|
2719
|
+
|
|
2720
|
+
// -- Task 101: Adaptive scaling — reduce recall footprint as conversation grows
|
|
2721
|
+
const sdkScale = applyAdaptiveScaling(turnCount, maxResults, tokenBudget);
|
|
2722
|
+
|
|
2723
|
+
// -- Task 102: Context-window-aware throttling — measure ACTUAL prompt size
|
|
2724
|
+
// This is the real defense against context crashes. Turn-based scaling is a heuristic;
|
|
2725
|
+
// prompt size measurement is ground truth. A few turns with large tool outputs can
|
|
2726
|
+
// fill the window fast regardless of turn count.
|
|
2727
|
+
const throttled = applyContextWindowThrottle(rawPrompt.length, contextWindowSize, sdkScale, logger);
|
|
2728
|
+
if (throttled.selfMuted) {
|
|
2729
|
+
// Context window is critically full. Don't inject anything — let the model breathe.
|
|
2730
|
+
// Return minimal awareness only (no recall, no profile).
|
|
2731
|
+
return { prependContext: `<!-- sulcus: self-muted, context ${((rawPrompt.length / 4 / contextWindowSize) * 100).toFixed(0)}% full -->` };
|
|
2732
|
+
}
|
|
2733
|
+
const effectiveMax = throttled.effectiveMax;
|
|
2734
|
+
const effectiveTokenBudget = throttled.effectiveTokenBudget;
|
|
2735
|
+
if (turnCount > 5) logger.debug?.(`sulcus: adaptive scaling (sdk turn ${turnCount}) — limit=${effectiveMax}, budget=${effectiveTokenBudget}`);
|
|
2736
|
+
// -- end Task 101 + 102 -----------------------------------------------------
|
|
2737
|
+
|
|
716
2738
|
const includeProfile = turnCount === 1 || turnCount % profileFrequency === 0;
|
|
717
2739
|
|
|
2740
|
+
// -- Task 70: Post-compaction context rebuild --------------------------------------
|
|
2741
|
+
// When before_compaction fired, the context window was degraded (150k→20k tokens).
|
|
2742
|
+
// On the very next turn, skip topic-shift cache, use an expanded token budget,
|
|
2743
|
+
// and fire multiple parallel queries to rebuild a rich Sulcus context from scratch.
|
|
2744
|
+
if (wasJustCompacted && contextRebuild) {
|
|
2745
|
+
wasJustCompacted = false; // clear flag — rebuild happens exactly once per compaction
|
|
2746
|
+
logger.info(`sulcus: POST-COMPACTION REBUILD — injecting full Sulcus context (budget: ${REBUILD_TOKEN_BUDGET} tokens)`);
|
|
2747
|
+
try {
|
|
2748
|
+
// Multi-query recall: focused current turn + broader prompt coverage
|
|
2749
|
+
const rebuildQueries = [recallQuery];
|
|
2750
|
+
const promptHead = prompt.substring(0, 150).trim();
|
|
2751
|
+
if (promptHead.length > 10 && promptHead !== recallQuery) rebuildQueries.push(promptHead);
|
|
2752
|
+
|
|
2753
|
+
const rebuildLimit = Math.min(30, maxResults * 3);
|
|
2754
|
+
const rawParallel = await Promise.allSettled(
|
|
2755
|
+
rebuildQueries.map((q) => sulcusMem.search_memory(q, rebuildLimit, namespace))
|
|
2756
|
+
);
|
|
2757
|
+
|
|
2758
|
+
const seenRebuildIds = new Set<string>();
|
|
2759
|
+
const rebuildResults: Record<string, unknown>[] = [];
|
|
2760
|
+
for (const r of rawParallel) {
|
|
2761
|
+
if (r.status === "fulfilled") {
|
|
2762
|
+
const items = (r.value?.results ?? []) as Record<string, unknown>[];
|
|
2763
|
+
for (const item of items) {
|
|
2764
|
+
const id = item.id as string;
|
|
2765
|
+
if (!seenRebuildIds.has(id)) { seenRebuildIds.add(id); rebuildResults.push(item); }
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// Sort by score desc, apply diversity filter (higher threshold — allow more overlap post-compaction)
|
|
2771
|
+
const sorted = rebuildResults.sort((a, b) => ((b.score ?? 0) as number) - ((a.score ?? 0) as number));
|
|
2772
|
+
const diverse = diversityFilter(sorted, 0.9);
|
|
2773
|
+
|
|
2774
|
+
// Bust the recall cache so next turn re-evaluates fresh
|
|
2775
|
+
recallCache = null;
|
|
2776
|
+
|
|
2777
|
+
if (diverse.length > 0) {
|
|
2778
|
+
const staleThresholdMs = 30 * 24 * 60 * 60 * 1000;
|
|
2779
|
+
const nowMs = Date.now();
|
|
2780
|
+
const memXml = diverse.map((m) => {
|
|
2781
|
+
const id = m.id as string ?? "?";
|
|
2782
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
|
|
2783
|
+
const heat = typeof m.current_heat === "number" ? m.current_heat.toFixed(3) : "?";
|
|
2784
|
+
const score = typeof m.score === "number" ? m.score.toFixed(3) : "?";
|
|
2785
|
+
const mtype = typeof m.memory_type === "string" ? m.memory_type : "unknown";
|
|
2786
|
+
const created = typeof m.created_at === "string" ? m.created_at : null;
|
|
2787
|
+
const stale = created !== null && (nowMs - new Date(created).getTime()) > staleThresholdMs;
|
|
2788
|
+
return ` <memory id="${id}" type="${mtype}" heat="${heat}" score="${score}"${stale ? " age=\"stale\"" : ""}>${escapeXml(content)}</memory>`;
|
|
2789
|
+
}).join("\n");
|
|
2790
|
+
|
|
2791
|
+
const rebuildXml = [
|
|
2792
|
+
`<sulcus_context mode="post_compaction_rebuild" memories="${diverse.length}" budget="${REBUILD_TOKEN_BUDGET}">`,
|
|
2793
|
+
` <!-- Context rebuilt from Sulcus after session compaction. Use this to restore working knowledge. -->`,
|
|
2794
|
+
` <memories count="${diverse.length}">`,
|
|
2795
|
+
memXml,
|
|
2796
|
+
` </memories>`,
|
|
2797
|
+
` <session turn="${turnCount}" mode="compaction_rebuild" />`,
|
|
2798
|
+
`</sulcus_context>`,
|
|
2799
|
+
].join("\n");
|
|
2800
|
+
|
|
2801
|
+
const budgetedRebuild = enforceContextBudget(rebuildXml, REBUILD_TOKEN_BUDGET);
|
|
2802
|
+
|
|
2803
|
+
if (boostOnRecall) {
|
|
2804
|
+
boostRecalledMemories(sulcusMem, diverse, namespace, logger).catch(() => {/* non-critical */});
|
|
2805
|
+
}
|
|
2806
|
+
recallQM.freshRecalls++;
|
|
2807
|
+
recallQM.totalItemsServed += diverse.length;
|
|
2808
|
+
|
|
2809
|
+
logger.info(`sulcus: post-compaction rebuild injected ${diverse.length} memories (~${Math.round(budgetedRebuild.length / 4)} tokens)`);
|
|
2810
|
+
return { prependContext: budgetedRebuild };
|
|
2811
|
+
}
|
|
2812
|
+
} catch (e: unknown) {
|
|
2813
|
+
logger.warn(`sulcus: post-compaction rebuild failed: ${e instanceof Error ? e.message : String(e)} — falling back to normal recall`);
|
|
2814
|
+
// Fall through to normal recall path on error
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
// -- end Task 70 --------------------------------------------------------------------
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
// -- Topic-shift detection (Task 14) ---------------------------------------
|
|
2821
|
+
const currentTokens = extractTopicTokens(prompt);
|
|
2822
|
+
const cacheExpired = recallCache !== null && (Date.now() - recallCache.cachedAt) > TOPIC_CACHE_TTL_MS;
|
|
2823
|
+
const overlap = recallCache !== null ? topicOverlap(currentTokens, recallCache.topicTokens) : 0;
|
|
2824
|
+
const topicShifted = recallCache === null || cacheExpired || overlap < TOPIC_SHIFT_THRESHOLD;
|
|
2825
|
+
|
|
2826
|
+
let searchResults: Record<string, unknown>[] = [];
|
|
2827
|
+
|
|
2828
|
+
if (!topicShifted && recallCache !== null) {
|
|
2829
|
+
// Topic stable — reuse cached recall results, skip API call
|
|
2830
|
+
searchResults = recallCache.results;
|
|
2831
|
+
qm_cacheHits++; // Task 26: count cache-hit turns
|
|
2832
|
+
recallQM.cacheHits++; // Task 32: module-scope QM
|
|
2833
|
+
logger.info(`sulcus: topic stable (overlap=${overlap.toFixed(2)}) — serving cached recall (turn ${turnCount})`);
|
|
2834
|
+
} else {
|
|
2835
|
+
if (recallCache !== null) {
|
|
2836
|
+
logger.info(`sulcus: TOPIC SHIFT detected (overlap=${overlap.toFixed(2)}) — fresh recall (turn ${turnCount})`);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
718
2839
|
try {
|
|
719
|
-
|
|
720
|
-
|
|
2840
|
+
// Task 62: Use focused recallQuery instead of full accumulated prompt
|
|
2841
|
+
// Task 101: Use adaptive limit instead of raw config maxResults
|
|
2842
|
+
const searchRes = await sulcusMem.search_memory(recallQuery, effectiveMax, namespace);
|
|
2843
|
+
const vectorResults = searchRes?.results ?? [];
|
|
2844
|
+
|
|
2845
|
+
// -- Task 35: Query expansion for thin recall (SDK path) ---------------
|
|
2846
|
+
let sdkExpanded = vectorResults;
|
|
2847
|
+
if (vectorResults.length < THIN_RECALL_THRESHOLD) {
|
|
2848
|
+
try {
|
|
2849
|
+
// Task 62: use focused recallQuery for entity expansion
|
|
2850
|
+
const { extraMemories: sdkExtraMem, expandedQuery: sdkExpandedQ } = await expandQueryWithEntities(
|
|
2851
|
+
sulcusMem, recallQuery, namespace, logger
|
|
2852
|
+
);
|
|
2853
|
+
const sdkSeenIds = new Set(vectorResults.map((r) => r.id as string));
|
|
2854
|
+
const sdkNewExtras = sdkExtraMem.filter((m) => !sdkSeenIds.has(m.id as string));
|
|
2855
|
+
if (sdkNewExtras.length > 0) {
|
|
2856
|
+
sdkExpanded = [...vectorResults, ...sdkNewExtras];
|
|
2857
|
+
logger.info(`sulcus: sdk thin-recall expansion added ${sdkNewExtras.length} entity-graph memory/memories`);
|
|
2858
|
+
}
|
|
2859
|
+
if (sdkExpanded.length < THIN_RECALL_THRESHOLD && sdkExpandedQ !== recallQuery) {
|
|
2860
|
+
try {
|
|
2861
|
+
const sdkExpandedRes = await sulcusMem.search_memory(sdkExpandedQ, effectiveMax, namespace);
|
|
2862
|
+
const sdkExpandedVec = sdkExpandedRes?.results ?? [];
|
|
2863
|
+
const sdkExpandedSeen = new Set(sdkExpanded.map((r) => r.id as string));
|
|
2864
|
+
const sdkExpandedNew = sdkExpandedVec.filter((r) => !sdkExpandedSeen.has(r.id as string));
|
|
2865
|
+
if (sdkExpandedNew.length > 0) {
|
|
2866
|
+
sdkExpanded = [...sdkExpanded, ...sdkExpandedNew];
|
|
2867
|
+
logger.info(`sulcus: sdk expanded query search added ${sdkExpandedNew.length} result(s)`);
|
|
2868
|
+
}
|
|
2869
|
+
} catch {
|
|
2870
|
+
// expanded search failed — keep what we have
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
} catch {
|
|
2874
|
+
// expansion failed — proceed with original results
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
// -- end Task 35 (SDK path) -----------------------------------------
|
|
2878
|
+
|
|
2879
|
+
// -- Graph-hop expansion (Task 13) ---------------------------------------------
|
|
2880
|
+
// Seed from top-2 expanded results, fetch AGE neighbors non-blocking.
|
|
2881
|
+
// Fold in Memory-type neighbors (heat >= 0.2), dedup, cap at maxResults+4.
|
|
2882
|
+
searchResults = sdkExpanded;
|
|
2883
|
+
const seedIds = sdkExpanded.slice(0, 2).map((r) => r.id as string).filter(Boolean);
|
|
2884
|
+
if (seedIds.length > 0) {
|
|
2885
|
+
try {
|
|
2886
|
+
const neighborFetches = await Promise.allSettled(
|
|
2887
|
+
seedIds.map((id) => sulcusMem.graph_neighbors(id, 6))
|
|
2888
|
+
);
|
|
2889
|
+
const seenIds = new Set(sdkExpanded.map((r) => r.id as string));
|
|
2890
|
+
const graphExtras: Record<string, unknown>[] = [];
|
|
2891
|
+
for (const result of neighborFetches) {
|
|
2892
|
+
if (result.status !== "fulfilled") continue;
|
|
2893
|
+
for (const node of result.value) {
|
|
2894
|
+
const nodeId = node.id as string;
|
|
2895
|
+
if (!nodeId || seenIds.has(nodeId)) continue;
|
|
2896
|
+
const heat = (node.current_heat as number) ?? 0;
|
|
2897
|
+
// Only include meaningful nodes — skip cold ephemeral noise
|
|
2898
|
+
if (heat < 0.2) continue;
|
|
2899
|
+
seenIds.add(nodeId);
|
|
2900
|
+
graphExtras.push(node);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (graphExtras.length > 0) {
|
|
2904
|
+
// Sort graph extras by heat desc, cap at 4
|
|
2905
|
+
graphExtras.sort((a, b) => ((b.current_heat as number) ?? 0) - ((a.current_heat as number) ?? 0));
|
|
2906
|
+
// Tag graph-hop results with source so context formatter can annotate them
|
|
2907
|
+
const taggedExtras = graphExtras.slice(0, 4).map((r) => ({ ...r, _source: "graph" }));
|
|
2908
|
+
const sdkHopCount = taggedExtras.length;
|
|
2909
|
+
searchResults = [...sdkExpanded, ...taggedExtras];
|
|
2910
|
+
recallQM.graphHopContrib += sdkHopCount; // Task 32: module-scope QM
|
|
2911
|
+
recallQM.graphHopTurns++; // Task 32: module-scope QM
|
|
2912
|
+
logger.info(`sulcus: graph-hop added ${sdkHopCount} neighbours (seeds: ${seedIds.length})`);
|
|
2913
|
+
}
|
|
2914
|
+
} catch {
|
|
2915
|
+
// graph expansion failed — fall back to vector results only
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
// -- end graph-hop ------------------------------------------------------
|
|
2919
|
+
|
|
2920
|
+
// Update recall cache (fresh fetch path)
|
|
2921
|
+
qm_freshRecalls++; // Task 26: count fresh-recall turns
|
|
2922
|
+
recallQM.freshRecalls++; // Task 32: module-scope QM
|
|
2923
|
+
recallCache = { results: searchResults, topicTokens: currentTokens, cachedAt: Date.now() };
|
|
2924
|
+
} catch (freshErr) {
|
|
2925
|
+
// fresh recall failed — fall back to cache if available
|
|
2926
|
+
if (recallCache !== null) {
|
|
2927
|
+
logger.warn(`sulcus: fresh recall failed (${freshErr}), using stale cache`);
|
|
2928
|
+
searchResults = recallCache.results;
|
|
2929
|
+
} else {
|
|
2930
|
+
throw freshErr; // no cache to fall back to — let outer catch handle
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
} // end topic-shift branch
|
|
2934
|
+
|
|
2935
|
+
try { // processing, scoring, budget enforcement, XML assembly
|
|
721
2936
|
|
|
722
2937
|
let preferences: Record<string, unknown>[] = [];
|
|
723
2938
|
let facts: Record<string, unknown>[] = [];
|
|
724
2939
|
|
|
725
2940
|
if (includeProfile) {
|
|
726
2941
|
try {
|
|
727
|
-
const prefRes = await sulcusMem.search_memory("user preference", Math.min(
|
|
728
|
-
const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(
|
|
2942
|
+
const prefRes = await sulcusMem.search_memory("user preference", Math.min(effectiveMax, 5), namespace);
|
|
2943
|
+
const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(effectiveMax, 5), namespace);
|
|
729
2944
|
preferences = (prefRes?.results ?? []).filter((r) => r.memory_type === "preference");
|
|
730
2945
|
facts = (factRes?.results ?? []).filter((r) => r.memory_type === "fact");
|
|
731
2946
|
profileCache = { preferences, facts, cachedAt: Date.now() };
|
|
@@ -743,51 +2958,272 @@ function buildSdkRecallHandler(
|
|
|
743
2958
|
]);
|
|
744
2959
|
const dedupedSearch = searchResults.filter((r) => !profileIds.has(r.id as string));
|
|
745
2960
|
|
|
2961
|
+
// -- Task 20: Recall diversity filter --------------------------------------
|
|
2962
|
+
// Before budget enforcement: apply MMR-lite diversity filter to recall results.
|
|
2963
|
+
// Penalises near-duplicate memories (same topic, different phrasings) so the
|
|
2964
|
+
// context window surfaces genuinely distinct information.
|
|
2965
|
+
// Pre-normalise labels for topic extraction (strip XML escapes not needed yet).
|
|
2966
|
+
const preDiversityItems = dedupedSearch.map((r) => ({
|
|
2967
|
+
...r,
|
|
2968
|
+
label: ((r.label ?? r.pointer_summary ?? r.id ?? "") as string),
|
|
2969
|
+
// Fix 2: prefer server fused_score over raw heat for ranking (Task 58)
|
|
2970
|
+
_heat: (r.score as number) ?? (r.current_heat as number) ?? 0,
|
|
2971
|
+
}));
|
|
2972
|
+
// Sort score-desc first so diversity filter seeds on best item
|
|
2973
|
+
preDiversityItems.sort((a, b) => b._heat - a._heat);
|
|
2974
|
+
const diverseSearch = diversityFilter(preDiversityItems, effectiveMax);
|
|
2975
|
+
const droppedByDiversity = preDiversityItems.length - diverseSearch.length;
|
|
2976
|
+
if (droppedByDiversity > 0) {
|
|
2977
|
+
logger.info(`sulcus: diversity filter dropped ${droppedByDiversity} near-duplicate(s)`);
|
|
2978
|
+
}
|
|
2979
|
+
// -- end Task 20 ------------------------------------------------------
|
|
2980
|
+
|
|
2981
|
+
// -- Task 18: Context budget enforcement ------------------------------------
|
|
2982
|
+
// -- Category-priority ranking (Mem0 parity) --------------------------
|
|
2983
|
+
// Rank by memory type priority (durable types first), then by heat within tier.
|
|
2984
|
+
// Procedural and preference memories are high-priority (identity/config equivalent).
|
|
2985
|
+
// This ensures persistent knowledge surfaces before transient observations.
|
|
2986
|
+
const TYPE_PRIORITY: Record<string, number> = {
|
|
2987
|
+
procedural: 0, // how-tos = highest priority
|
|
2988
|
+
preference: 1, // user preferences = identity
|
|
2989
|
+
fact: 2, // stable data
|
|
2990
|
+
semantic: 3, // domain knowledge
|
|
2991
|
+
episodic: 4, // events = lowest priority
|
|
2992
|
+
};
|
|
2993
|
+
diverseSearch.sort((a, b) => {
|
|
2994
|
+
const typeA = (a.memory_type as string) ?? "episodic";
|
|
2995
|
+
const typeB = (b.memory_type as string) ?? "episodic";
|
|
2996
|
+
const prioA = TYPE_PRIORITY[typeA] ?? 5;
|
|
2997
|
+
const prioB = TYPE_PRIORITY[typeB] ?? 5;
|
|
2998
|
+
if (prioA !== prioB) return prioA - prioB;
|
|
2999
|
+
return (b._heat as number) - (a._heat as number); // heat desc within tier
|
|
3000
|
+
});
|
|
3001
|
+
// -- end category-priority ranking -------------------------------------
|
|
3002
|
+
|
|
3003
|
+
// Sort all items by heat desc so highest-value memories always fit first.
|
|
3004
|
+
// Task 66: token budget is configurable (default 4000). ~80 for fixed overhead.
|
|
3005
|
+
// Remaining split ~30% profile / ~70% recall.
|
|
3006
|
+
// Task 101: Use adaptive token budget instead of raw config value
|
|
3007
|
+
const TOKEN_BUDGET = effectiveTokenBudget;
|
|
3008
|
+
const FIXED_OVERHEAD = 80;
|
|
3009
|
+
const profileBudgetTokens = Math.floor((TOKEN_BUDGET - FIXED_OVERHEAD) * 0.3);
|
|
3010
|
+
const recallBudgetTokens = TOKEN_BUDGET - FIXED_OVERHEAD - profileBudgetTokens;
|
|
3011
|
+
|
|
3012
|
+
// Normalize + XML-escape labels up front, attach _heat for sorting
|
|
3013
|
+
const profileItemsSorted = [...preferences, ...facts]
|
|
3014
|
+
.map((r) => ({
|
|
3015
|
+
...r,
|
|
3016
|
+
label: ((r.label ?? r.pointer_summary ?? r.id ?? "") as string)
|
|
3017
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
3018
|
+
_heat: (r.current_heat as number) ?? 0,
|
|
3019
|
+
}))
|
|
3020
|
+
.sort((a, b) => b._heat - a._heat);
|
|
3021
|
+
|
|
3022
|
+
// Task 20: use diversity-filtered items (already heat-sorted by diversityFilter)
|
|
3023
|
+
const recallItemsSorted = diverseSearch
|
|
3024
|
+
.map((r) => ({
|
|
3025
|
+
...r,
|
|
3026
|
+
label: ((r.label ?? r.pointer_summary ?? r.id ?? "") as string)
|
|
3027
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
3028
|
+
_heat: (r.score as number) ?? (r.current_heat as number) ?? 0,
|
|
3029
|
+
}));
|
|
3030
|
+
|
|
3031
|
+
// -- Task 80: Temporal supersession (SDK path) ------------------------------
|
|
3032
|
+
// Mark older conflicting memories as superseded BEFORE budget enforcement
|
|
3033
|
+
// so the penalty pushes them below the cut line.
|
|
3034
|
+
const sdkSupersededCount = markSuperseded(recallItemsSorted);
|
|
3035
|
+
if (sdkSupersededCount > 0) {
|
|
3036
|
+
logger.info(`sulcus: temporal supersession (sdk) marked ${sdkSupersededCount} memory/memories as superseded`);
|
|
3037
|
+
// Re-sort after penalties so budget cuts superseded items first
|
|
3038
|
+
recallItemsSorted.sort((a, b) => b._heat - a._heat);
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
const budgetedProfile = enforceContextBudget(profileItemsSorted, TOKEN_BUDGET, FIXED_OVERHEAD + recallBudgetTokens);
|
|
3042
|
+
let budgetedRecall = enforceContextBudget(recallItemsSorted, TOKEN_BUDGET, FIXED_OVERHEAD + profileBudgetTokens);
|
|
3043
|
+
// -- end Task 18 -----------------------------------------------------------
|
|
3044
|
+
|
|
3045
|
+
// -- Task 79: Temporal re-ranking (SDK path) ---------------------------------
|
|
3046
|
+
const sdkTemporalDetected = isTemporalQuery(recallQuery);
|
|
3047
|
+
if (sdkTemporalDetected) {
|
|
3048
|
+
budgetedRecall = temporalRerank(budgetedRecall);
|
|
3049
|
+
logger.info(`sulcus: temporal query detected (sdk) — re-ranking ${budgetedRecall.length} results chronologically`);
|
|
3050
|
+
}
|
|
3051
|
+
// -- end Task 79 (SDK) -------------------------------------------------------
|
|
3052
|
+
|
|
746
3053
|
const sections: string[] = [];
|
|
747
3054
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
3055
|
+
// Task 18: use budgetedProfile (heat-sorted, budget-trimmed, labels already normalized)
|
|
3056
|
+
if (includeProfile && budgetedProfile.length > 0) {
|
|
3057
|
+
const profileElements: string[] = [];
|
|
3058
|
+
for (const r of budgetedProfile) {
|
|
3059
|
+
const mtype = (r.memory_type as string) === "fact" ? "fact" : "preference";
|
|
3060
|
+
const heat = (r._heat as number).toFixed(2);
|
|
3061
|
+
profileElements.push(` <item type="${mtype}" heat="${heat}">${r.label}</item>`);
|
|
753
3062
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
profileLines.push(`- [fact] ${label}`);
|
|
757
|
-
}
|
|
758
|
-
if (profileLines.length > 0) {
|
|
759
|
-
sections.push(`## User Profile (from preferences + facts)\n${profileLines.join("\n")}`);
|
|
3063
|
+
if (profileElements.length > 0) {
|
|
3064
|
+
sections.push(`<profile>\n${profileElements.join("\n")}\n</profile>`);
|
|
760
3065
|
}
|
|
761
3066
|
}
|
|
762
3067
|
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
3068
|
+
if (budgetedRecall.length > 0) {
|
|
3069
|
+
// -- Task 12: Structured context formatting ----------------------------
|
|
3070
|
+
// Group recall items by memory type so LLM receives semantically
|
|
3071
|
+
// coherent blocks. Add source (vector/graph) and relevance tier.
|
|
3072
|
+
// Task 18: iterate over budgetedRecall instead of raw dedupedSearch —
|
|
3073
|
+
// already heat-sorted, budget-trimmed, labels normalized.
|
|
3074
|
+
// Flat recall list — no group wrappers, no source/relevance attributes.
|
|
3075
|
+
// Items already sorted by category-priority (procedural > preference > fact > semantic > episodic)
|
|
3076
|
+
// then by heat within tier. Type is an attribute on each memory element.
|
|
3077
|
+
// Task 79: when temporal, items are re-sorted chronologically (oldest first).
|
|
3078
|
+
const recallElements: string[] = [];
|
|
3079
|
+
for (const r of budgetedRecall) {
|
|
3080
|
+
const heat = r._heat as number;
|
|
3081
|
+
const heatStr = heat.toFixed(2);
|
|
3082
|
+
const mtype = (r.memory_type as string) ?? "episodic";
|
|
767
3083
|
const updatedAt = r.updated_at as string | undefined;
|
|
768
|
-
const
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
3084
|
+
const ageStr = updatedAt ? formatRelativeTime(updatedAt) : "unknown";
|
|
3085
|
+
const staleAttr = isStaleMemory(updatedAt) ? ` stale="true"` : "";
|
|
3086
|
+
// Task 80: superseded memories get a marker so the LLM treats them as historical
|
|
3087
|
+
const supersededAttr = r._superseded ? ` superseded="true"` : "";
|
|
3088
|
+
// label already normalized + escaped + budget-truncated by enforceContextBudget
|
|
3089
|
+
recallElements.push(` <memory type="${mtype}" heat="${heatStr}" age="${ageStr}"${staleAttr}${supersededAttr}>${r.label}</memory>`);
|
|
3090
|
+
}
|
|
3091
|
+
if (recallElements.length > 0) {
|
|
3092
|
+
// Task 79: annotate recall block with ordering hint for the LLM
|
|
3093
|
+
const sdkRecallOrderAttr = sdkTemporalDetected ? ` order="chronological"` : "";
|
|
3094
|
+
sections.push(`<recall${sdkRecallOrderAttr}>\n${recallElements.join("\n")}\n</recall>`);
|
|
3095
|
+
}
|
|
3096
|
+
// -- end Task 12 / Task 18 / Task 79 -----------------------------------------
|
|
773
3097
|
}
|
|
774
3098
|
|
|
3099
|
+
// Task 19 (conflict detection) replaced by Task 80 (temporal supersession).
|
|
3100
|
+
// Superseded items are already marked inline with superseded="true" attribute.
|
|
3101
|
+
// No separate <conflicts> block needed — the transformer sees position + markup.
|
|
3102
|
+
|
|
775
3103
|
if (sections.length === 0) return undefined;
|
|
776
3104
|
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
const
|
|
3105
|
+
const guidance = "Background context from long-term memory. Use it silently to inform your understanding — only reference it when the conversation naturally calls for it.";
|
|
3106
|
+
const recallMode = !topicShifted ? "cached" : "fresh";
|
|
3107
|
+
const contextParts: string[] = [
|
|
3108
|
+
`<session turn="${turnCount}" mode="${recallMode}" />`,
|
|
3109
|
+
`<guidance>${guidance}</guidance>`,
|
|
3110
|
+
];
|
|
3111
|
+
contextParts.push(...sections);
|
|
3112
|
+
const context = `<sulcus_context token_budget="${TOKEN_BUDGET}" namespace="${namespace}">\n${contextParts.join("\n")}\n</sulcus_context>`;
|
|
3113
|
+
|
|
3114
|
+
// Task 18: log budget utilisation
|
|
3115
|
+
const estimatedTokens = estimateTokens(context);
|
|
3116
|
+
logger.info(`sulcus: SDK recall injecting context (${context.length} chars, ~${estimatedTokens}/${TOKEN_BUDGET} tokens, turn ${turnCount}, profile: ${budgetedProfile.length}, recall: ${budgetedRecall.length})`);
|
|
3117
|
+
|
|
3118
|
+
// -- Task 26: Recall quality metrics ----------------------------------------
|
|
3119
|
+
qm_totalItemsServed += budgetedRecall.length;
|
|
3120
|
+
if (budgetedRecall.length === 0) qm_totalFails++;
|
|
3121
|
+
// Task 32: write to module-scope QM for memory_status exposure
|
|
3122
|
+
recallQM.totalItemsServed += budgetedRecall.length;
|
|
3123
|
+
if (budgetedRecall.length === 0) recallQM.zeroResultTurns++;
|
|
3124
|
+
if (budgetedRecall.length > 0 && topicShifted) {
|
|
3125
|
+
const sdkAvgScore = budgetedRecall.reduce((s, r) => s + ((r._heat as number) ?? 0), 0) / budgetedRecall.length;
|
|
3126
|
+
recallQM.scoreSum += sdkAvgScore;
|
|
3127
|
+
recallQM.scoreTurns++;
|
|
3128
|
+
}
|
|
3129
|
+
// Emit a periodic quality summary every QM_LOG_INTERVAL turns
|
|
3130
|
+
if (turnCount % QM_LOG_INTERVAL === 0) {
|
|
3131
|
+
const qm_totalRecallTurns = qm_freshRecalls + qm_cacheHits;
|
|
3132
|
+
const qm_cacheHitRate = qm_totalRecallTurns > 0
|
|
3133
|
+
? ((qm_cacheHits / qm_totalRecallTurns) * 100).toFixed(1)
|
|
3134
|
+
: "0.0";
|
|
3135
|
+
const qm_avgItems = qm_totalRecallTurns > 0
|
|
3136
|
+
? (qm_totalItemsServed / qm_totalRecallTurns).toFixed(1)
|
|
3137
|
+
: "0.0";
|
|
3138
|
+
logger.info(
|
|
3139
|
+
`sulcus: [quality-metrics turn=${turnCount}] ` +
|
|
3140
|
+
`fresh=${qm_freshRecalls} cached=${qm_cacheHits} ` +
|
|
3141
|
+
`cache_hit_rate=${qm_cacheHitRate}% ` +
|
|
3142
|
+
`avg_items_served=${qm_avgItems} ` +
|
|
3143
|
+
`zero_result_turns=${qm_totalFails}`
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
// -- end Task 26 ------------------------------------------------------------
|
|
3147
|
+
|
|
3148
|
+
// Task 56: write to inspect buffer for memory_inspect tool (SDK path)
|
|
3149
|
+
{
|
|
3150
|
+
const staleSDKItems = budgetedRecall.filter((r) => (r as Record<string, unknown>).stale === true || (r as Record<string, unknown>)._stale === true);
|
|
3151
|
+
const graphSDKItems = budgetedRecall.filter((r) => (r as Record<string, unknown>)._source === "graph");
|
|
3152
|
+
inspectBuffer.lastRecall = {
|
|
3153
|
+
capturedAt: Date.now(),
|
|
3154
|
+
path: "sdk",
|
|
3155
|
+
turn: turnCount,
|
|
3156
|
+
query: prompt.substring(0, 200),
|
|
3157
|
+
fromCache: !topicShifted,
|
|
3158
|
+
itemsInjected: budgetedProfile.length + budgetedRecall.length,
|
|
3159
|
+
recallItems: budgetedRecall.map((r) => ({
|
|
3160
|
+
id: (r.id as string) ?? "",
|
|
3161
|
+
content_preview: ((r.content ?? r.text ?? "") as string).substring(0, 80),
|
|
3162
|
+
memory_type: (r.memory_type ?? r.type ?? "unknown") as string,
|
|
3163
|
+
heat: (r.current_heat ?? r._heat ?? 0) as number,
|
|
3164
|
+
score: (r.score as number | null) ?? null,
|
|
3165
|
+
stale: !!(r.stale ?? r._stale),
|
|
3166
|
+
source: ((r._source as string) === "graph" ? "graph" : "semantic") as "semantic" | "graph" | "unknown",
|
|
3167
|
+
})),
|
|
3168
|
+
profileItems: budgetedProfile.length,
|
|
3169
|
+
staleCount: staleSDKItems.length,
|
|
3170
|
+
graphHopCount: graphSDKItems.length,
|
|
3171
|
+
tokensBudget: TOKEN_BUDGET,
|
|
3172
|
+
tokensUsed: estimatedTokens,
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
// Spaced repetition: boost heat for recalled memories (fire-and-forget, non-blocking)
|
|
3177
|
+
if (boostOnRecall && budgetedRecall.length > 0) {
|
|
3178
|
+
boostRecalledMemories(sulcusMem, budgetedRecall, logger).catch(() => {});
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
// -- Task 23: SIRU recall logging (fire-and-forget, only on fresh recall) ----
|
|
3182
|
+
// Post recall session metadata to the server so SIRU can learn which memories
|
|
3183
|
+
// were most useful. Skipped on cache-hit turns (topicShifted === false) to avoid
|
|
3184
|
+
// logging identical sessions when the topic is stable.
|
|
3185
|
+
if (topicShifted && sulcusMem instanceof SulcusCloudClient) {
|
|
3186
|
+
const recallIds = budgetedRecall.map((r) => (r.id as string) ?? "").filter(Boolean);
|
|
3187
|
+
const recallScores = budgetedRecall.map((r) => (r._heat as number) ?? 0);
|
|
3188
|
+
const recallSources = budgetedRecall.map((r) =>
|
|
3189
|
+
(r._source as string) === "graph" ? "graph" : "semantic"
|
|
3190
|
+
);
|
|
3191
|
+
// Extract entity hints from prompt (reuse topic tokens as lightweight entity proxy)
|
|
3192
|
+
const entityHints = Array.from(currentTokens).slice(0, 10);
|
|
3193
|
+
// Source breakdown counts
|
|
3194
|
+
const semanticCount = recallSources.filter((s) => s === "semantic").length;
|
|
3195
|
+
const graphCount = recallSources.filter((s) => s === "graph").length;
|
|
3196
|
+
sulcusMem.recall_log({
|
|
3197
|
+
namespace,
|
|
3198
|
+
agent_id: namespace,
|
|
3199
|
+
query_text: prompt.substring(0, 500),
|
|
3200
|
+
memory_ids: recallIds,
|
|
3201
|
+
memory_scores: recallScores,
|
|
3202
|
+
memory_sources: recallSources,
|
|
3203
|
+
token_budget: TOKEN_BUDGET,
|
|
3204
|
+
tokens_used: estimatedTokens,
|
|
3205
|
+
candidates_total: searchResults.length,
|
|
3206
|
+
candidates_selected: recallIds.length,
|
|
3207
|
+
semantic_count: semanticCount,
|
|
3208
|
+
hot_count: graphCount,
|
|
3209
|
+
entity_count: entityHints.length,
|
|
3210
|
+
entity_hints: entityHints,
|
|
3211
|
+
}).catch(() => {}); // never block context injection
|
|
3212
|
+
logger.debug?.("sulcus: SIRU recall log posted");
|
|
3213
|
+
}
|
|
3214
|
+
// -- end Task 23 -----------------------------------------------------------
|
|
780
3215
|
|
|
781
|
-
logger.info(`sulcus: SDK recall injecting context (${context.length} chars, turn ${turnCount})`);
|
|
782
3216
|
return { prependContext: context };
|
|
783
3217
|
} catch (err) {
|
|
3218
|
+
qm_totalFails++; // Task 26: count hard-fail turns
|
|
3219
|
+
recallQM.zeroResultTurns++; // Task 32: module-scope QM
|
|
784
3220
|
logger.warn(`sulcus: SDK recall failed: ${err}`);
|
|
785
3221
|
return undefined;
|
|
786
3222
|
}
|
|
787
3223
|
};
|
|
788
3224
|
}
|
|
789
3225
|
|
|
790
|
-
//
|
|
3226
|
+
// --- MEMORY RUNTIME BUILDER ---------------------------------------------------
|
|
791
3227
|
|
|
792
3228
|
function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
|
|
793
3229
|
const searchManager = {
|
|
@@ -819,29 +3255,25 @@ function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
|
|
|
819
3255
|
};
|
|
820
3256
|
}
|
|
821
3257
|
|
|
822
|
-
//
|
|
3258
|
+
// --- PROMPT SECTION BUILDER ---------------------------------------------------
|
|
823
3259
|
|
|
824
3260
|
function buildPromptSection(params: { availableTools: Set<string> }): string[] {
|
|
825
3261
|
const hasRecall = params.availableTools.has("memory_recall");
|
|
826
3262
|
const hasStore = params.availableTools.has("memory_store");
|
|
827
3263
|
if (!hasRecall && !hasStore) return [];
|
|
828
3264
|
|
|
3265
|
+
// Behavioral nudge only — tool schemas already document parameters.
|
|
3266
|
+
// Goal: tell the agent WHEN to use memory, not HOW (tool defs handle that).
|
|
829
3267
|
const lines: string[] = [
|
|
830
3268
|
"## Memory (Sulcus)",
|
|
831
3269
|
"",
|
|
832
3270
|
"You have persistent thermodynamic memory powered by Sulcus.",
|
|
833
3271
|
"Relevant memories are automatically injected at the start of each conversation.",
|
|
834
|
-
"",
|
|
835
3272
|
];
|
|
836
3273
|
|
|
837
3274
|
if (hasRecall) lines.push("- Use `memory_recall` to search prior conversations, preferences, and facts.");
|
|
838
3275
|
if (hasStore) lines.push("- Use `memory_store` to save information the user asks you to remember.");
|
|
839
|
-
if (params.availableTools.has("memory_delete")) lines.push("- Use `memory_delete` to remove incorrect or stale memories.");
|
|
840
3276
|
if (params.availableTools.has("memory_status")) lines.push("- Use `memory_status` to check backend connection and hot nodes.");
|
|
841
|
-
if (params.availableTools.has("consolidate")) lines.push("- Use `consolidate` to prune cold memories below a heat threshold.");
|
|
842
|
-
if (params.availableTools.has("export_markdown")) lines.push("- Use `export_markdown` to export all memories as Markdown.");
|
|
843
|
-
if (params.availableTools.has("import_markdown")) lines.push("- Use `import_markdown` to import memories from a Markdown document.");
|
|
844
|
-
if (params.availableTools.has("evaluate_triggers")) lines.push("- Use `evaluate_triggers` to evaluate reactive memory triggers.");
|
|
845
3277
|
|
|
846
3278
|
lines.push("");
|
|
847
3279
|
lines.push("Memory types: episodic (events, fast decay), semantic (knowledge, slow), preference (opinions, slower), procedural (how-tos, slowest), fact (data, slow)");
|
|
@@ -849,7 +3281,7 @@ function buildPromptSection(params: { availableTools: Set<string> }): string[] {
|
|
|
849
3281
|
return lines;
|
|
850
3282
|
}
|
|
851
3283
|
|
|
852
|
-
//
|
|
3284
|
+
// --- TOOL DEFINITIONS --------------------------------------------------------
|
|
853
3285
|
|
|
854
3286
|
interface ToolDeps {
|
|
855
3287
|
sulcusMem: SulcusCloudClient | null;
|
|
@@ -920,7 +3352,9 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
920
3352
|
}
|
|
921
3353
|
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
922
3354
|
const mtype = (params.memory_type as string | undefined) || "episodic";
|
|
923
|
-
|
|
3355
|
+
// Phase 2: SILU prompt injection — derive hints from memory type + namespace for manual stores
|
|
3356
|
+
const storeHints = buildExtractionHints(mtype, namespace, "user_capture", content.substring(0, 200));
|
|
3357
|
+
const res = await sulcusMem.add_memory(content, mtype, storeHints);
|
|
924
3358
|
const nodeId = res?.id ?? "unknown";
|
|
925
3359
|
let trainResult: string | null = null;
|
|
926
3360
|
if (params.train === true) {
|
|
@@ -963,8 +3397,66 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
963
3397
|
]);
|
|
964
3398
|
const nodeList = hotNodes?.nodes ?? [];
|
|
965
3399
|
const si = statusInfo as Record<string, unknown> | null;
|
|
3400
|
+
// Task 32: compute recall quality metrics for exposure
|
|
3401
|
+
const qm_totalTurns = recallQM.freshRecalls + recallQM.cacheHits;
|
|
3402
|
+
const qm_cacheHitRate = qm_totalTurns > 0 ? parseFloat(((recallQM.cacheHits / qm_totalTurns) * 100).toFixed(1)) : null;
|
|
3403
|
+
const qm_avgRelevance = recallQM.scoreTurns > 0 ? parseFloat((recallQM.scoreSum / recallQM.scoreTurns).toFixed(3)) : null;
|
|
3404
|
+
const qm_graphHopRate = qm_totalTurns > 0 ? parseFloat(((recallQM.graphHopTurns / qm_totalTurns) * 100).toFixed(1)) : null;
|
|
3405
|
+
const qm_avgItemsServed = qm_totalTurns > 0 ? parseFloat((recallQM.totalItemsServed / qm_totalTurns).toFixed(1)) : null;
|
|
3406
|
+
const recallQuality = {
|
|
3407
|
+
total_turns: qm_totalTurns,
|
|
3408
|
+
fresh_recalls: recallQM.freshRecalls,
|
|
3409
|
+
cache_hits: recallQM.cacheHits,
|
|
3410
|
+
cache_hit_rate_pct: qm_cacheHitRate,
|
|
3411
|
+
avg_relevance_score: qm_avgRelevance,
|
|
3412
|
+
avg_items_served: qm_avgItemsServed,
|
|
3413
|
+
zero_result_turns: recallQM.zeroResultTurns,
|
|
3414
|
+
graph_hop_turns: recallQM.graphHopTurns,
|
|
3415
|
+
graph_hop_contrib_total: recallQM.graphHopContrib,
|
|
3416
|
+
graph_hop_rate_pct: qm_graphHopRate,
|
|
3417
|
+
};
|
|
3418
|
+
|
|
3419
|
+
// Task 58: last_injection block — snapshot of what was actually injected last turn
|
|
3420
|
+
let lastInjection: Record<string, unknown> | null = null;
|
|
3421
|
+
const lr = inspectBuffer.lastRecall;
|
|
3422
|
+
if (lr) {
|
|
3423
|
+
const recallHeats = lr.recallItems.map((i) => i.heat);
|
|
3424
|
+
const avgHeat = recallHeats.length > 0
|
|
3425
|
+
? parseFloat((recallHeats.reduce((s, h) => s + h, 0) / recallHeats.length).toFixed(3))
|
|
3426
|
+
: null;
|
|
3427
|
+
const typeSet = new Set(lr.recallItems.map((i) => i.memory_type));
|
|
3428
|
+
const typeCoveragePct = lr.recallItems.length > 0
|
|
3429
|
+
? parseFloat(((typeSet.size / 5) * 100).toFixed(1)) // 5 = total memory types
|
|
3430
|
+
: null;
|
|
3431
|
+
const stalePct = lr.recallItems.length > 0
|
|
3432
|
+
? parseFloat(((lr.staleCount / lr.recallItems.length) * 100).toFixed(1))
|
|
3433
|
+
: null;
|
|
3434
|
+
const ageMs = Date.now() - lr.capturedAt;
|
|
3435
|
+
lastInjection = {
|
|
3436
|
+
captured_ms_ago: ageMs,
|
|
3437
|
+
turn: lr.turn,
|
|
3438
|
+
path: lr.path,
|
|
3439
|
+
from_cache: lr.fromCache,
|
|
3440
|
+
query_preview: lr.query.slice(0, 100),
|
|
3441
|
+
items_injected: lr.itemsInjected,
|
|
3442
|
+
recall_items: lr.recallItems.length,
|
|
3443
|
+
profile_items: lr.profileItems,
|
|
3444
|
+
stale_count: lr.staleCount,
|
|
3445
|
+
stale_pct: stalePct,
|
|
3446
|
+
graph_hop_count: lr.graphHopCount,
|
|
3447
|
+
avg_heat_injected: avgHeat,
|
|
3448
|
+
type_coverage_pct: typeCoveragePct,
|
|
3449
|
+
types_present: Array.from(typeSet),
|
|
3450
|
+
token_budget: lr.tokensBudget,
|
|
3451
|
+
tokens_used: lr.tokensUsed,
|
|
3452
|
+
budget_utilization_pct: lr.tokensBudget > 0
|
|
3453
|
+
? parseFloat(((lr.tokensUsed / lr.tokensBudget) * 100).toFixed(1))
|
|
3454
|
+
: null,
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
|
|
966
3458
|
return {
|
|
967
|
-
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) }],
|
|
3459
|
+
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, recall_quality: recallQuality, last_injection: lastInjection }, null, 2) }],
|
|
968
3460
|
details: { status: "ok", backend: backendMode, namespace, count: nodeList.length },
|
|
969
3461
|
};
|
|
970
3462
|
} catch (e: unknown) {
|
|
@@ -1063,9 +3555,234 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
1063
3555
|
},
|
|
1064
3556
|
},
|
|
1065
3557
|
|
|
3558
|
+
memory_get: {
|
|
3559
|
+
schema: {
|
|
3560
|
+
name: "memory_get",
|
|
3561
|
+
label: "Get Memory",
|
|
3562
|
+
description: "Fetch a specific memory by its UUID. Returns full memory details including content, type, heat, graph edges, and metadata.",
|
|
3563
|
+
parameters: Type.Object({
|
|
3564
|
+
id: Type.String({ description: "Memory node UUID." }),
|
|
3565
|
+
}),
|
|
3566
|
+
},
|
|
3567
|
+
options: { name: "memory_get" },
|
|
3568
|
+
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
|
|
3569
|
+
async (_id, params) => {
|
|
3570
|
+
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3571
|
+
if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_get requires cloud backend");
|
|
3572
|
+
const memId = params.id as string;
|
|
3573
|
+
const res = await sulcusMem.get_memory(memId);
|
|
3574
|
+
if (!res) return { content: [{ type: "text", text: `Memory ${memId} not found.` }], details: { found: false, id: memId } };
|
|
3575
|
+
return {
|
|
3576
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
3577
|
+
details: { ...res, backend: backendMode, namespace },
|
|
3578
|
+
};
|
|
3579
|
+
},
|
|
3580
|
+
},
|
|
3581
|
+
|
|
3582
|
+
memory_list: {
|
|
3583
|
+
schema: {
|
|
3584
|
+
name: "memory_list",
|
|
3585
|
+
label: "List Memories",
|
|
3586
|
+
description: "Browse memories with optional filters. Returns paginated results sorted by heat (hottest first). Use this to explore what Sulcus knows without a search query.",
|
|
3587
|
+
parameters: Type.Object({
|
|
3588
|
+
page: Type.Optional(Type.Number({ default: 1, description: "Page number (1-indexed)." })),
|
|
3589
|
+
page_size: Type.Optional(Type.Number({ default: 20, description: "Results per page (1-100).", minimum: 1, maximum: 100 })),
|
|
3590
|
+
memory_type: Type.Optional(Type.Union([
|
|
3591
|
+
Type.Literal("episodic"), Type.Literal("semantic"), Type.Literal("preference"),
|
|
3592
|
+
Type.Literal("procedural"), Type.Literal("fact"),
|
|
3593
|
+
], { description: "Filter by memory type." })),
|
|
3594
|
+
pinned: Type.Optional(Type.Boolean({ description: "Filter by pinned status." })),
|
|
3595
|
+
sort_by: Type.Optional(Type.Union([
|
|
3596
|
+
Type.Literal("current_heat"), Type.Literal("created_at"), Type.Literal("updated_at"),
|
|
3597
|
+
], { description: "Sort field (default: current_heat)." })),
|
|
3598
|
+
sort_order: Type.Optional(Type.Union([
|
|
3599
|
+
Type.Literal("asc"), Type.Literal("desc"),
|
|
3600
|
+
], { description: "Sort order (default: desc)." })),
|
|
3601
|
+
}),
|
|
3602
|
+
},
|
|
3603
|
+
options: { name: "memory_list" },
|
|
3604
|
+
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
|
|
3605
|
+
async (_id, params) => {
|
|
3606
|
+
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3607
|
+
if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_list requires cloud backend");
|
|
3608
|
+
const page = (params.page as number | undefined) ?? 1;
|
|
3609
|
+
const pageSize = Math.min(100, Math.max(1, (params.page_size as number | undefined) ?? 20));
|
|
3610
|
+
const res = await sulcusMem.list_memories({
|
|
3611
|
+
page,
|
|
3612
|
+
page_size: pageSize,
|
|
3613
|
+
memory_type: params.memory_type as string | undefined,
|
|
3614
|
+
pinned: params.pinned as boolean | undefined,
|
|
3615
|
+
sort_by: (params.sort_by as string | undefined) ?? "current_heat",
|
|
3616
|
+
sort_order: (params.sort_order as string | undefined) ?? "desc",
|
|
3617
|
+
namespace,
|
|
3618
|
+
});
|
|
3619
|
+
const summary = `Page ${page} — ${res.items.length} memories${res.total !== undefined ? ` (${res.total} total)` : ""}`;
|
|
3620
|
+
return {
|
|
3621
|
+
content: [{ type: "text", text: summary + "\n" + JSON.stringify(res.items, null, 2) }],
|
|
3622
|
+
details: { page, page_size: pageSize, count: res.items.length, total: res.total, backend: backendMode, namespace },
|
|
3623
|
+
};
|
|
3624
|
+
},
|
|
3625
|
+
},
|
|
3626
|
+
|
|
3627
|
+
memory_update: {
|
|
3628
|
+
schema: {
|
|
3629
|
+
name: "memory_update",
|
|
3630
|
+
label: "Update Memory",
|
|
3631
|
+
description: "Update fields on an existing memory in-place. Preserves graph edges and history. More surgical than delete+re-store.",
|
|
3632
|
+
parameters: Type.Object({
|
|
3633
|
+
id: Type.String({ description: "Memory node UUID to update." }),
|
|
3634
|
+
content: Type.Optional(Type.String({ description: "New content text (replaces existing)." })),
|
|
3635
|
+
memory_type: Type.Optional(Type.Union([
|
|
3636
|
+
Type.Literal("episodic"), Type.Literal("semantic"), Type.Literal("preference"),
|
|
3637
|
+
Type.Literal("procedural"), Type.Literal("fact"),
|
|
3638
|
+
], { description: "New memory type classification." })),
|
|
3639
|
+
is_pinned: Type.Optional(Type.Boolean({ description: "Pin (prevent decay) or unpin." })),
|
|
3640
|
+
heat: Type.Optional(Type.Number({ description: "Set heat directly (0.0-1.0).", minimum: 0, maximum: 1 })),
|
|
3641
|
+
}),
|
|
3642
|
+
},
|
|
3643
|
+
options: { name: "memory_update" },
|
|
3644
|
+
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3645
|
+
async (_id, params) => {
|
|
3646
|
+
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3647
|
+
if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_update requires cloud backend");
|
|
3648
|
+
const memId = params.id as string;
|
|
3649
|
+
const updates: Record<string, unknown> = {};
|
|
3650
|
+
if (params.content !== undefined) updates.label = params.content as string;
|
|
3651
|
+
if (params.memory_type !== undefined) updates.memory_type = params.memory_type as string;
|
|
3652
|
+
if (params.is_pinned !== undefined) updates.is_pinned = params.is_pinned as boolean;
|
|
3653
|
+
if (params.heat !== undefined) updates.current_heat = params.heat as number;
|
|
3654
|
+
if (Object.keys(updates).length === 0) {
|
|
3655
|
+
return { content: [{ type: "text", text: "No fields to update. Provide at least one of: content, memory_type, is_pinned, heat." }] };
|
|
3656
|
+
}
|
|
3657
|
+
const res = await sulcusMem.update_memory(memId, updates as any);
|
|
3658
|
+
const fields = Object.keys(updates).join(", ");
|
|
3659
|
+
logger.info(`sulcus: memory_update — updated ${memId} (fields: ${fields})`);
|
|
3660
|
+
return {
|
|
3661
|
+
content: [{ type: "text", text: `Updated memory ${memId} (fields: ${fields}). Backend: ${backendMode}, namespace: ${namespace}` }],
|
|
3662
|
+
details: { id: memId, updated_fields: Object.keys(updates), result: res as Record<string, unknown>, backend: backendMode, namespace },
|
|
3663
|
+
};
|
|
3664
|
+
},
|
|
3665
|
+
},
|
|
3666
|
+
|
|
3667
|
+
memory_profile: {
|
|
3668
|
+
schema: {
|
|
3669
|
+
name: "memory_profile",
|
|
3670
|
+
label: "Memory Profile",
|
|
3671
|
+
description: "Show a rich snapshot of this agent's memory health: type distribution, heat curve, top hot nodes, top preferences/facts, and graph stats. Call this to understand what Sulcus knows and how active the memory is.",
|
|
3672
|
+
parameters: Type.Object({
|
|
3673
|
+
limit: Type.Optional(Type.Number({ description: "Max hot nodes to surface (default 10).", minimum: 1, maximum: 50 })),
|
|
3674
|
+
}),
|
|
3675
|
+
},
|
|
3676
|
+
options: { name: "memory_profile" },
|
|
3677
|
+
makeExecute: ({ sulcusMem, backendMode, namespace, isAvailable }) =>
|
|
3678
|
+
async (_id, params) => {
|
|
3679
|
+
if (!isAvailable || !sulcusMem) {
|
|
3680
|
+
return { content: [{ type: "text", text: `Memory profile unavailable — backend: ${backendMode}, namespace: ${namespace}` }] };
|
|
3681
|
+
}
|
|
3682
|
+
const hotLimit = Math.min(50, Math.max(1, (params?.limit as number | undefined) ?? 10));
|
|
3683
|
+
try {
|
|
3684
|
+
const [statusRes, hotRes, prefRes, factRes] = await Promise.allSettled([
|
|
3685
|
+
(sulcusMem as SulcusCloudClient).request("GET", "/api/v1/agent/memory/status").catch(() => null),
|
|
3686
|
+
(sulcusMem as SulcusCloudClient).list_hot_nodes(hotLimit),
|
|
3687
|
+
(sulcusMem as SulcusCloudClient).search_memory("preference", hotLimit),
|
|
3688
|
+
(sulcusMem as SulcusCloudClient).search_memory("fact", hotLimit),
|
|
3689
|
+
]);
|
|
3690
|
+
|
|
3691
|
+
const status = (statusRes.status === "fulfilled" ? statusRes.value : null) as Record<string, unknown> | null;
|
|
3692
|
+
const hotNodes = (hotRes.status === "fulfilled" ? hotRes.value?.nodes : []) ?? [];
|
|
3693
|
+
const preferences = (prefRes.status === "fulfilled" ? prefRes.value?.results : []) ?? [];
|
|
3694
|
+
const facts = (factRes.status === "fulfilled" ? factRes.value?.results : []) ?? [];
|
|
3695
|
+
|
|
3696
|
+
// Filter preferences/facts by type
|
|
3697
|
+
const prefItems = (preferences as Record<string, unknown>[]).filter(
|
|
3698
|
+
(r) => (r.memory_type ?? r.type) === "preference"
|
|
3699
|
+
).slice(0, 5);
|
|
3700
|
+
const factItems = (facts as Record<string, unknown>[]).filter(
|
|
3701
|
+
(r) => (r.memory_type ?? r.type) === "fact"
|
|
3702
|
+
).slice(0, 5);
|
|
3703
|
+
|
|
3704
|
+
const stats = status?.stats as Record<string, unknown> | undefined;
|
|
3705
|
+
const caps = status?.capabilities as Record<string, unknown> | undefined;
|
|
3706
|
+
|
|
3707
|
+
// Build human-readable summary
|
|
3708
|
+
const lines: string[] = [];
|
|
3709
|
+
lines.push(`## 🧠 Sulcus Memory Profile`);
|
|
3710
|
+
lines.push(`**Namespace:** ${namespace} | **Backend:** ${backendMode}`);
|
|
3711
|
+
lines.push("");
|
|
3712
|
+
|
|
3713
|
+
if (stats) {
|
|
3714
|
+
const total = (stats.total_nodes ?? stats.total ?? "?") as string | number;
|
|
3715
|
+
const hot = (stats.hot_nodes ?? "?") as string | number;
|
|
3716
|
+
const cold = (stats.cold_nodes ?? "?") as string | number;
|
|
3717
|
+
const avgHeat = typeof stats.average_heat === "number" ? (stats.average_heat * 100).toFixed(1) + "%" : "?";
|
|
3718
|
+
lines.push(`### Memory Stats`);
|
|
3719
|
+
lines.push(`- **Total nodes:** ${total}`);
|
|
3720
|
+
lines.push(`- **Hot / Cold:** ${hot} hot / ${cold} cold`);
|
|
3721
|
+
lines.push(`- **Average heat:** ${avgHeat}`);
|
|
3722
|
+
if (stats.memory_types && Array.isArray(stats.memory_types)) {
|
|
3723
|
+
const types = (stats.memory_types as { type: string; count: number }[])
|
|
3724
|
+
.sort((a, b) => b.count - a.count)
|
|
3725
|
+
.map((t) => `${t.type}: ${t.count}`)
|
|
3726
|
+
.join(" | ");
|
|
3727
|
+
lines.push(`- **By type:** ${types}`);
|
|
3728
|
+
}
|
|
3729
|
+
lines.push("");
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
if (caps) {
|
|
3733
|
+
const enabled = Object.entries(caps)
|
|
3734
|
+
.filter(([, v]) => v === true)
|
|
3735
|
+
.map(([k]) => k)
|
|
3736
|
+
.join(", ");
|
|
3737
|
+
if (enabled) lines.push(`**Active capabilities:** ${enabled}\n`);
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
if (hotNodes.length > 0) {
|
|
3741
|
+
lines.push(`### 🔥 Top Hot Nodes (${hotNodes.length})`);
|
|
3742
|
+
for (const n of (hotNodes as Record<string, unknown>[]).slice(0, hotLimit)) {
|
|
3743
|
+
const heat = typeof n.current_heat === "number" ? (n.current_heat * 100).toFixed(0) + "%" : "?";
|
|
3744
|
+
const mtype = (n.memory_type ?? n.type ?? "?") as string;
|
|
3745
|
+
const label = ((n.summary ?? n.label ?? n.content ?? "") as string).slice(0, 80);
|
|
3746
|
+
lines.push(`- [${heat} ${mtype}] ${label}`);
|
|
3747
|
+
}
|
|
3748
|
+
lines.push("");
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
if (prefItems.length > 0) {
|
|
3752
|
+
lines.push(`### 📌 Active Preferences`);
|
|
3753
|
+
for (const p of prefItems) {
|
|
3754
|
+
const heat = typeof p.current_heat === "number" ? (p.current_heat * 100).toFixed(0) + "%" : "?";
|
|
3755
|
+
const label = ((p.summary ?? p.label ?? p.content ?? "") as string).slice(0, 100);
|
|
3756
|
+
lines.push(`- [${heat}] ${label}`);
|
|
3757
|
+
}
|
|
3758
|
+
lines.push("");
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
if (factItems.length > 0) {
|
|
3762
|
+
lines.push(`### 📚 Active Facts`);
|
|
3763
|
+
for (const f of factItems) {
|
|
3764
|
+
const heat = typeof f.current_heat === "number" ? (f.current_heat * 100).toFixed(0) + "%" : "?";
|
|
3765
|
+
const label = ((f.summary ?? f.label ?? f.content ?? "") as string).slice(0, 100);
|
|
3766
|
+
lines.push(`- [${heat}] ${label}`);
|
|
3767
|
+
}
|
|
3768
|
+
lines.push("");
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
const summary = lines.join("\n");
|
|
3772
|
+
return {
|
|
3773
|
+
content: [{ type: "text", text: summary }],
|
|
3774
|
+
details: { backend: backendMode, namespace, hot_count: hotNodes.length, pref_count: prefItems.length, fact_count: factItems.length },
|
|
3775
|
+
};
|
|
3776
|
+
} catch (e: unknown) {
|
|
3777
|
+
return { content: [{ type: "text", text: `Memory profile error: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
3778
|
+
}
|
|
3779
|
+
},
|
|
3780
|
+
},
|
|
3781
|
+
|
|
1066
3782
|
siu_label: {
|
|
1067
3783
|
schema: {
|
|
1068
3784
|
name: "siu_label",
|
|
3785
|
+
|
|
1069
3786
|
label: "SIU Label",
|
|
1070
3787
|
description: "Classify text using SIU v2 — returns SIVU store/reject decision and SICU memory type classification.",
|
|
1071
3788
|
parameters: Type.Object({
|
|
@@ -1162,6 +3879,271 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
1162
3879
|
},
|
|
1163
3880
|
},
|
|
1164
3881
|
|
|
3882
|
+
session_store: {
|
|
3883
|
+
schema: {
|
|
3884
|
+
name: "session_store",
|
|
3885
|
+
label: "Session Store",
|
|
3886
|
+
description: "Store ephemeral context for the current conversation only. Automatically purged when the session ends. Use this for short-term scratch-pad notes, intermediate reasoning, or context that's only relevant to this exchange.",
|
|
3887
|
+
parameters: Type.Object({
|
|
3888
|
+
content: Type.String({ description: "Content to store for this session." }),
|
|
3889
|
+
memory_type: Type.Optional(Type.Union([
|
|
3890
|
+
Type.Literal("episodic"), Type.Literal("semantic"), Type.Literal("preference"),
|
|
3891
|
+
Type.Literal("procedural"), Type.Literal("fact"),
|
|
3892
|
+
], { description: "Memory type classification. Default: episodic" })),
|
|
3893
|
+
}),
|
|
3894
|
+
},
|
|
3895
|
+
options: { name: "session_store" },
|
|
3896
|
+
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3897
|
+
async (_id, params) => {
|
|
3898
|
+
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3899
|
+
const content = params.content as string;
|
|
3900
|
+
if (isJunkMemory(content)) {
|
|
3901
|
+
return { content: [{ type: "text", text: "Filtered: content looks like system noise." }], details: { filtered: true } };
|
|
3902
|
+
}
|
|
3903
|
+
const mtype = (params.memory_type as string | undefined) || "episodic";
|
|
3904
|
+
// Session namespace: "session:<id>" — scoped prefix ensures auto-purge targets only this session
|
|
3905
|
+
const sessionNs = `session:${CURRENT_SESSION_ID}`;
|
|
3906
|
+
const hints = buildExtractionHints(mtype, namespace, "user_capture", content.substring(0, 200));
|
|
3907
|
+
// Store with high initial heat so session memories surface immediately
|
|
3908
|
+
const res = await sulcusMem.add_memory(content, mtype, hints);
|
|
3909
|
+
const nodeId = res?.id ?? "unknown";
|
|
3910
|
+
// Pin with high heat so it persists clearly for the session duration (will be deleted at end)
|
|
3911
|
+
if (nodeId !== "unknown" && sulcusMem instanceof SulcusCloudClient) {
|
|
3912
|
+
await (sulcusMem as SulcusCloudClient).request("PATCH", `/api/v1/agent/memory/${nodeId}`, {
|
|
3913
|
+
current_heat: 0.95,
|
|
3914
|
+
// Tag with session namespace via a search-scoped namespace field
|
|
3915
|
+
}).catch(() => {}); // best-effort
|
|
3916
|
+
}
|
|
3917
|
+
// Track session memory IDs for purge at agent_end
|
|
3918
|
+
sessionMemoryIds.add(nodeId);
|
|
3919
|
+
logger.info(`sulcus: session_store — stored [${mtype}] for session ${CURRENT_SESSION_ID} (id: ${nodeId})`);
|
|
3920
|
+
return {
|
|
3921
|
+
content: [{ type: "text", text: `Stored session memory [${mtype}] (id: ${nodeId}) — will be purged at session end.` }],
|
|
3922
|
+
details: { id: nodeId, memory_type: mtype, session_id: CURRENT_SESSION_ID, backend: backendMode, namespace: sessionNs },
|
|
3923
|
+
};
|
|
3924
|
+
},
|
|
3925
|
+
},
|
|
3926
|
+
|
|
3927
|
+
session_recall: {
|
|
3928
|
+
schema: {
|
|
3929
|
+
name: "session_recall",
|
|
3930
|
+
label: "Session Recall",
|
|
3931
|
+
description: "Search ephemeral context stored in the current conversation with session_store. Returns only memories from this session — nothing from prior sessions.",
|
|
3932
|
+
parameters: Type.Object({
|
|
3933
|
+
query: Type.String({ description: "Search query string." }),
|
|
3934
|
+
limit: Type.Optional(Type.Number({ default: 5, description: "Maximum results (1-10)." })),
|
|
3935
|
+
}),
|
|
3936
|
+
},
|
|
3937
|
+
options: { name: "session_recall" },
|
|
3938
|
+
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
|
|
3939
|
+
async (_id, params) => {
|
|
3940
|
+
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3941
|
+
if (sessionMemoryIds.size === 0) {
|
|
3942
|
+
return { content: [{ type: "text", text: "No session memories stored yet. Use session_store to add ephemeral context." }], details: { results: [], session_id: CURRENT_SESSION_ID } };
|
|
3943
|
+
}
|
|
3944
|
+
// Search the main namespace but filter to only this session's IDs
|
|
3945
|
+
const limit = Math.min(10, Math.max(1, (params.limit as number | undefined) ?? 5));
|
|
3946
|
+
const res = await sulcusMem.search_memory(params.query as string, limit * 3, namespace);
|
|
3947
|
+
const allResults = res?.results ?? [];
|
|
3948
|
+
// Filter to session-owned IDs only
|
|
3949
|
+
const sessionResults = allResults.filter((r) => sessionMemoryIds.has(r.id as string)).slice(0, limit);
|
|
3950
|
+
return {
|
|
3951
|
+
content: [{ type: "text", text: JSON.stringify(sessionResults, null, 2) }],
|
|
3952
|
+
details: { results: sessionResults as unknown as Record<string, unknown>[], session_id: CURRENT_SESSION_ID, session_count: sessionMemoryIds.size, backend: backendMode },
|
|
3953
|
+
};
|
|
3954
|
+
},
|
|
3955
|
+
},
|
|
3956
|
+
|
|
3957
|
+
|
|
3958
|
+
memory_inspect: {
|
|
3959
|
+
schema: {
|
|
3960
|
+
name: "memory_inspect",
|
|
3961
|
+
label: "Memory Inspect",
|
|
3962
|
+
description: "Debug window into what Sulcus is actually doing. Shows what was injected in the last recall, what the output/tool guard scanned, what was blocked and why, and the last N guardrail events. Use this to verify Sulcus is working correctly.",
|
|
3963
|
+
parameters: Type.Object({}),
|
|
3964
|
+
},
|
|
3965
|
+
options: { name: "memory_inspect" },
|
|
3966
|
+
makeExecute: (_deps: ToolDeps) =>
|
|
3967
|
+
async (_id: string, _params: Record<string, unknown>) => {
|
|
3968
|
+
const now = Date.now();
|
|
3969
|
+
|
|
3970
|
+
// Format last recall snapshot
|
|
3971
|
+
const recall = inspectBuffer.lastRecall;
|
|
3972
|
+
const recallSection: Record<string, unknown> = recall
|
|
3973
|
+
? {
|
|
3974
|
+
captured_ago_s: Math.round((now - recall.capturedAt) / 1000),
|
|
3975
|
+
path: recall.path,
|
|
3976
|
+
turn: recall.turn,
|
|
3977
|
+
query_preview: recall.query,
|
|
3978
|
+
from_cache: recall.fromCache,
|
|
3979
|
+
items_injected: recall.itemsInjected,
|
|
3980
|
+
profile_items: recall.profileItems,
|
|
3981
|
+
recall_item_count: recall.recallItems.length,
|
|
3982
|
+
stale_items: recall.staleCount,
|
|
3983
|
+
graph_hop_items: recall.graphHopCount,
|
|
3984
|
+
tokens_used: recall.tokensUsed,
|
|
3985
|
+
tokens_budget: recall.tokensBudget,
|
|
3986
|
+
recall_items: recall.recallItems.map((r) => ({
|
|
3987
|
+
id: r.id,
|
|
3988
|
+
preview: r.content_preview,
|
|
3989
|
+
type: r.memory_type,
|
|
3990
|
+
heat: r.heat.toFixed(3),
|
|
3991
|
+
score: r.score !== null ? r.score.toFixed(4) : null,
|
|
3992
|
+
stale: r.stale,
|
|
3993
|
+
source: r.source,
|
|
3994
|
+
})),
|
|
3995
|
+
}
|
|
3996
|
+
: { status: "no_recall_yet", note: "No recall injection has occurred this session yet." };
|
|
3997
|
+
|
|
3998
|
+
// Format guardrail events (most recent first)
|
|
3999
|
+
const events = inspectBuffer.guardrailEvents
|
|
4000
|
+
.slice()
|
|
4001
|
+
.reverse()
|
|
4002
|
+
.map((e) => ({
|
|
4003
|
+
ago_s: Math.round((now - e.capturedAt) / 1000),
|
|
4004
|
+
guard: e.guard,
|
|
4005
|
+
event: e.eventType,
|
|
4006
|
+
action: e.action,
|
|
4007
|
+
details: e.details,
|
|
4008
|
+
...(e.toolName ? { tool: e.toolName } : {}),
|
|
4009
|
+
...(e.severity ? { severity: e.severity } : {}),
|
|
4010
|
+
}));
|
|
4011
|
+
|
|
4012
|
+
const result = {
|
|
4013
|
+
last_recall: recallSection,
|
|
4014
|
+
guardrail_events: events.length > 0 ? events : [{ status: "none", note: "No guardrail events this session." }],
|
|
4015
|
+
guardrail_event_count: inspectBuffer.guardrailEvents.length,
|
|
4016
|
+
};
|
|
4017
|
+
|
|
4018
|
+
const lines: string[] = [
|
|
4019
|
+
"## \U0001f50d Sulcus Inspect",
|
|
4020
|
+
"",
|
|
4021
|
+
"### Last Recall Injection",
|
|
4022
|
+
"```json",
|
|
4023
|
+
JSON.stringify(recallSection, null, 2),
|
|
4024
|
+
"```",
|
|
4025
|
+
"",
|
|
4026
|
+
"### Guardrail Events (most recent first)",
|
|
4027
|
+
"```json",
|
|
4028
|
+
JSON.stringify(events.length > 0 ? events : [{ status: "none" }], null, 2),
|
|
4029
|
+
"```",
|
|
4030
|
+
];
|
|
4031
|
+
|
|
4032
|
+
return {
|
|
4033
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
4034
|
+
details: result as unknown as Record<string, unknown>,
|
|
4035
|
+
};
|
|
4036
|
+
},
|
|
4037
|
+
},
|
|
4038
|
+
|
|
4039
|
+
guardrail_status: {
|
|
4040
|
+
schema: {
|
|
4041
|
+
name: "guardrail_status",
|
|
4042
|
+
label: "Guardrail Status",
|
|
4043
|
+
description: "Returns current guardrail configuration: outputGuard enabled/disabled, which rules are active (PII/preferences/custom), last 5 blocked events with reasons, preference keywords cached, negative prefs count. Use this to verify the guard is working and what it's watching.",
|
|
4044
|
+
parameters: Type.Object({}),
|
|
4045
|
+
},
|
|
4046
|
+
options: { name: "guardrail_status" },
|
|
4047
|
+
makeExecute: (_deps: ToolDeps) =>
|
|
4048
|
+
async (_id: string, _params: Record<string, unknown>) => {
|
|
4049
|
+
const now = Date.now();
|
|
4050
|
+
|
|
4051
|
+
if (!guardrailStatus) {
|
|
4052
|
+
return {
|
|
4053
|
+
content: [{ type: "text", text: "## 🛡️ Guardrail Status\n\nPlugin not fully initialized yet. Try again after the first turn." }],
|
|
4054
|
+
details: { status: "not_initialized" },
|
|
4055
|
+
};
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
const gs = guardrailStatus;
|
|
4059
|
+
const negCount = gs.negPrefCount();
|
|
4060
|
+
const negCachedAt = gs.negPrefCachedAt();
|
|
4061
|
+
const negCacheAge = negCachedAt ? Math.round((now - negCachedAt) / 1000) : null;
|
|
4062
|
+
|
|
4063
|
+
// Last 5 blocked/flagged guardrail events
|
|
4064
|
+
const blockedEvents = inspectBuffer.guardrailEvents
|
|
4065
|
+
.slice()
|
|
4066
|
+
.reverse()
|
|
4067
|
+
.filter((e) => e.action === "block" || e.action === "redact" || e.action === "replace" || e.action === "warn" || e.eventType.includes("violation") || e.eventType.includes("blocked"))
|
|
4068
|
+
.slice(0, 5)
|
|
4069
|
+
.map((e) => ({
|
|
4070
|
+
ago_s: Math.round((now - e.capturedAt) / 1000),
|
|
4071
|
+
guard: e.guard,
|
|
4072
|
+
event: e.eventType,
|
|
4073
|
+
action: e.action,
|
|
4074
|
+
details: e.details,
|
|
4075
|
+
...(e.toolName ? { tool: e.toolName } : {}),
|
|
4076
|
+
...(e.severity ? { severity: e.severity } : {}),
|
|
4077
|
+
}));
|
|
4078
|
+
|
|
4079
|
+
const result = {
|
|
4080
|
+
output_guard: {
|
|
4081
|
+
enabled: gs.outputGuard.enabled,
|
|
4082
|
+
pii: gs.outputGuard.pii,
|
|
4083
|
+
preference_violation: gs.outputGuard.preferenceViolation,
|
|
4084
|
+
fail_mode: gs.outputGuard.failMode,
|
|
4085
|
+
audit_trail: gs.outputGuard.auditTrail,
|
|
4086
|
+
},
|
|
4087
|
+
tool_guard: {
|
|
4088
|
+
enabled: gs.toolGuard.enabled,
|
|
4089
|
+
sensitive_tools: gs.toolGuard.sensitiveTools,
|
|
4090
|
+
allowlist: gs.toolGuard.allowlist,
|
|
4091
|
+
blocklist: gs.toolGuard.blocklist,
|
|
4092
|
+
objective_check: gs.toolGuard.objectiveCheck,
|
|
4093
|
+
require_approval_threshold: gs.toolGuard.requireApprovalThreshold,
|
|
4094
|
+
fail_mode: gs.toolGuard.failMode,
|
|
4095
|
+
audit_trail: gs.toolGuard.auditTrail,
|
|
4096
|
+
},
|
|
4097
|
+
neg_pref_cache: {
|
|
4098
|
+
count: negCount,
|
|
4099
|
+
cached_age_s: negCacheAge,
|
|
4100
|
+
note: negCount === 0 ? "No negative preferences cached (cache empty or not yet loaded)" : `${negCount} negative preference(s) cached`,
|
|
4101
|
+
},
|
|
4102
|
+
recent_blocked_events: blockedEvents.length > 0 ? blockedEvents : [{ status: "none", note: "No blocks/violations this session" }],
|
|
4103
|
+
};
|
|
4104
|
+
|
|
4105
|
+
const ogStatus = gs.outputGuard.enabled
|
|
4106
|
+
? `✅ enabled (PII: ${gs.outputGuard.pii.enabled ? "on" : "off"}, prefViolation: ${gs.outputGuard.preferenceViolation.enabled ? "on" : "off"})`
|
|
4107
|
+
: `❌ disabled (set guardrails.outputGuard.enabled=true to activate)`;
|
|
4108
|
+
const tgStatus = gs.toolGuard.enabled
|
|
4109
|
+
? `✅ enabled (objectiveCheck: ${gs.toolGuard.objectiveCheck ? "on" : "off"}, threshold: ${gs.toolGuard.requireApprovalThreshold})`
|
|
4110
|
+
: `❌ disabled (set guardrails.toolGuard.enabled=true to activate)`;
|
|
4111
|
+
|
|
4112
|
+
const lines: string[] = [
|
|
4113
|
+
"## 🛡️ Guardrail Status",
|
|
4114
|
+
"",
|
|
4115
|
+
`**Output Guard:** ${ogStatus}`,
|
|
4116
|
+
...(gs.outputGuard.enabled ? [
|
|
4117
|
+
` - PII patterns: ${gs.outputGuard.pii.patterns.join(", ")}`,
|
|
4118
|
+
` - PII action: ${gs.outputGuard.pii.onViolation} (reversible: ${gs.outputGuard.pii.reversible})`,
|
|
4119
|
+
` - Preference violation action: ${gs.outputGuard.preferenceViolation.onViolation}`,
|
|
4120
|
+
` - Fail mode: ${gs.outputGuard.failMode}`,
|
|
4121
|
+
] : []),
|
|
4122
|
+
"",
|
|
4123
|
+
`**Tool Guard:** ${tgStatus}`,
|
|
4124
|
+
...(gs.toolGuard.enabled ? [
|
|
4125
|
+
` - Sensitive tools: ${gs.toolGuard.sensitiveTools.join(", ")}`,
|
|
4126
|
+
` - Allowlist: ${gs.toolGuard.allowlist.length > 0 ? gs.toolGuard.allowlist.join(", ") : "(none)"}`,
|
|
4127
|
+
` - Blocklist: ${gs.toolGuard.blocklist.length > 0 ? gs.toolGuard.blocklist.join(", ") : "(none)"}`,
|
|
4128
|
+
` - Approval threshold: ${gs.toolGuard.requireApprovalThreshold}`,
|
|
4129
|
+
` - Fail mode: ${gs.toolGuard.failMode}`,
|
|
4130
|
+
] : []),
|
|
4131
|
+
"",
|
|
4132
|
+
`**Negative Pref Cache:** ${negCount} prefs cached${negCacheAge !== null ? `, ${negCacheAge}s ago` : ""}`,
|
|
4133
|
+
"",
|
|
4134
|
+
`**Recent Blocks (last 5):**`,
|
|
4135
|
+
"```json",
|
|
4136
|
+
JSON.stringify(blockedEvents.length > 0 ? blockedEvents : [{ status: "none" }], null, 2),
|
|
4137
|
+
"```",
|
|
4138
|
+
];
|
|
4139
|
+
|
|
4140
|
+
return {
|
|
4141
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
4142
|
+
details: result as unknown as Record<string, unknown>,
|
|
4143
|
+
};
|
|
4144
|
+
},
|
|
4145
|
+
},
|
|
4146
|
+
|
|
1165
4147
|
__sulcus_workflow__: {
|
|
1166
4148
|
schema: {
|
|
1167
4149
|
name: "__sulcus_workflow__",
|
|
@@ -1187,7 +4169,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
1187
4169
|
},
|
|
1188
4170
|
};
|
|
1189
4171
|
|
|
1190
|
-
//
|
|
4172
|
+
// --- FIRST-INSTALL HISTORY IMPORT --------------------------------------------
|
|
1191
4173
|
|
|
1192
4174
|
async function importOpenClawHistory(sulcusMem: SulcusCloudClient, logger: PluginLogger): Promise<void> {
|
|
1193
4175
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -1254,7 +4236,7 @@ async function importOpenClawHistory(sulcusMem: SulcusCloudClient, logger: Plugi
|
|
|
1254
4236
|
} catch (_e) { /* best-effort */ }
|
|
1255
4237
|
}
|
|
1256
4238
|
|
|
1257
|
-
//
|
|
4239
|
+
// --- PLUGIN ------------------------------------------------------------------
|
|
1258
4240
|
|
|
1259
4241
|
const sulcusPlugin = {
|
|
1260
4242
|
id: "openclaw-sulcus",
|
|
@@ -1264,9 +4246,17 @@ const sulcusPlugin = {
|
|
|
1264
4246
|
|
|
1265
4247
|
register(api: Record<string, unknown>) {
|
|
1266
4248
|
const logger = api.logger as PluginLogger;
|
|
1267
|
-
const
|
|
4249
|
+
const rawPluginConfig = (api.pluginConfig ?? {}) as Record<string, unknown>;
|
|
4250
|
+
|
|
4251
|
+
// -- Task 69: Load sulcus.toml config layer --
|
|
4252
|
+
// sulcus.toml provides file-based defaults. Plugin UI config wins on conflict.
|
|
4253
|
+
// Precedence: pluginConfig (OpenClaw UI) > sulcus.toml > built-in defaults.
|
|
4254
|
+
const tomlConfigPath = rawPluginConfig?.configFile as string | undefined;
|
|
4255
|
+
const tomlConfig = loadSulcusToml(tomlConfigPath, logger);
|
|
4256
|
+
// Merge: toml is the base, pluginConfig overrides. Deep merge for nested objects.
|
|
4257
|
+
const pluginConfig = mergeConfig(tomlConfig, rawPluginConfig);
|
|
1268
4258
|
|
|
1269
|
-
//
|
|
4259
|
+
// -- Configuration --
|
|
1270
4260
|
const libDir = pluginConfig?.libDir
|
|
1271
4261
|
? resolve(pluginConfig.libDir as string)
|
|
1272
4262
|
: resolve(process.env.HOME || "~", ".sulcus/lib");
|
|
@@ -1307,11 +4297,26 @@ const sulcusPlugin = {
|
|
|
1307
4297
|
const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
|
|
1308
4298
|
const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
|
|
1309
4299
|
const profileFrequency: number = Math.min(500, Math.max(1, (pluginConfig?.profileFrequency as number | undefined) ?? 10));
|
|
1310
|
-
|
|
1311
|
-
//
|
|
4300
|
+
// Task 66: configurable token budget. Clamped to [100, 8000]; default 4000.
|
|
4301
|
+
// Task 101: maxRecallChars is an alias — converted to token budget at ~4 chars/token.
|
|
4302
|
+
const rawMaxRecallChars = pluginConfig?.maxRecallChars as number | undefined;
|
|
4303
|
+
const tokenBudgetFromChars = rawMaxRecallChars ? Math.floor(rawMaxRecallChars / 4) : undefined;
|
|
4304
|
+
const tokenBudget: number = Math.min(8000, Math.max(100, tokenBudgetFromChars ?? (pluginConfig?.tokenBudget as number | undefined) ?? 4000));
|
|
4305
|
+
// Task 102: Context window size for utilization-based throttling.
|
|
4306
|
+
const contextWindowSize: number = Math.max(8000, (pluginConfig?.contextWindowSize as number | undefined) ?? 200000);
|
|
4307
|
+
const boostOnRecallEnabled: boolean = (pluginConfig?.boostOnRecall as boolean | undefined) ?? true;
|
|
4308
|
+
// Task 67: assistant output capture
|
|
4309
|
+
const captureFromAssistant: boolean = (pluginConfig?.captureFromAssistant as boolean | undefined) ?? false;
|
|
4310
|
+
// Task 70: Context rebuild config. Enabled by default when autoRecall + cloud backend are active.
|
|
4311
|
+
const contextRebuildEnabled: boolean = (pluginConfig?.contextRebuild as Record<string, unknown> | undefined)?.enabled !== false;
|
|
4312
|
+
const contextRebuildBudget: number = Math.min(10000, Math.max(500, (
|
|
4313
|
+
(pluginConfig?.contextRebuild as Record<string, unknown> | undefined)?.tokenBudget as number | undefined
|
|
4314
|
+
) ?? 4000));
|
|
4315
|
+
|
|
4316
|
+
// -- Load hooks config --
|
|
1312
4317
|
const hooksConfig = loadHooksConfig(pluginConfig);
|
|
1313
4318
|
|
|
1314
|
-
//
|
|
4319
|
+
// -- Backend init --
|
|
1315
4320
|
let sulcusMem: SulcusCloudClient | null = null;
|
|
1316
4321
|
let backendMode = "unavailable";
|
|
1317
4322
|
|
|
@@ -1358,9 +4363,13 @@ const sulcusPlugin = {
|
|
|
1358
4363
|
// Update static awareness with runtime info
|
|
1359
4364
|
STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
|
|
1360
4365
|
|
|
1361
|
-
//
|
|
4366
|
+
// Task 70: Wire contextRebuild budget to module-scope variable so rebuild
|
|
4367
|
+
// handler (inside buildSdkRecallHandler closure) picks up configured value.
|
|
4368
|
+
REBUILD_TOKEN_BUDGET = contextRebuildBudget;
|
|
4369
|
+
|
|
4370
|
+
// -- Startup summary --
|
|
1362
4371
|
if (isAvailable) {
|
|
1363
|
-
logger.info(`sulcus: ready ✅ (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture})`);
|
|
4372
|
+
logger.info(`sulcus: ready ✅ (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture}, captureFromAssistant: ${captureFromAssistant}, contextRebuild: ${contextRebuildEnabled})`);
|
|
1364
4373
|
} else {
|
|
1365
4374
|
// Give clear, actionable guidance instead of cryptic error chains
|
|
1366
4375
|
const hints: string[] = [];
|
|
@@ -1380,12 +4389,12 @@ const sulcusPlugin = {
|
|
|
1380
4389
|
logger.warn(`sulcus: not ready — ${hints.join(". ")}`);
|
|
1381
4390
|
}
|
|
1382
4391
|
|
|
1383
|
-
//
|
|
4392
|
+
// -- SIU v2 request helper --
|
|
1384
4393
|
const siuRequestFn = isCloudBackend && sulcusMem
|
|
1385
4394
|
? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
|
|
1386
4395
|
: null;
|
|
1387
4396
|
|
|
1388
|
-
//
|
|
4397
|
+
// -- Shared deps --
|
|
1389
4398
|
const toolDeps: ToolDeps = {
|
|
1390
4399
|
sulcusMem,
|
|
1391
4400
|
backendMode,
|
|
@@ -1408,11 +4417,15 @@ const sulcusPlugin = {
|
|
|
1408
4417
|
storeLibPath,
|
|
1409
4418
|
vectorsLibPath,
|
|
1410
4419
|
wasmDir,
|
|
4420
|
+
boostOnRecall: boostOnRecallEnabled,
|
|
4421
|
+
profileFrequency,
|
|
4422
|
+
tokenBudget,
|
|
4423
|
+
contextWindowSize,
|
|
1411
4424
|
};
|
|
1412
4425
|
|
|
1413
|
-
//
|
|
4426
|
+
// -------------------------------------------------------------------------
|
|
1414
4427
|
// SDK INTEGRATIONS (v4.0.0)
|
|
1415
|
-
//
|
|
4428
|
+
// -------------------------------------------------------------------------
|
|
1416
4429
|
|
|
1417
4430
|
// 1. registerMemoryRuntime — Sulcus becomes the OpenClaw memory backend
|
|
1418
4431
|
if (isCloudBackend && sulcusMem && typeof (api.registerMemoryRuntime as unknown) === "function") {
|
|
@@ -1490,7 +4503,11 @@ const sulcusPlugin = {
|
|
|
1490
4503
|
namespace,
|
|
1491
4504
|
maxRecallResults,
|
|
1492
4505
|
profileFrequency,
|
|
1493
|
-
logger
|
|
4506
|
+
logger,
|
|
4507
|
+
boostOnRecallEnabled,
|
|
4508
|
+
tokenBudget,
|
|
4509
|
+
contextRebuildEnabled,
|
|
4510
|
+
contextWindowSize,
|
|
1494
4511
|
);
|
|
1495
4512
|
const apiOn = api.on as (event: string, handler: unknown) => void;
|
|
1496
4513
|
apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
|
|
@@ -1557,7 +4574,10 @@ const sulcusPlugin = {
|
|
|
1557
4574
|
const agentEndCaptureConfig: HookConfig = {
|
|
1558
4575
|
action: "sivu_auto_capture",
|
|
1559
4576
|
enabled: true,
|
|
1560
|
-
|
|
4577
|
+
// Task 25: Lowered from 0.5 → 0.4 — SIVU gate was too aggressive,
|
|
4578
|
+
// rejecting real architectural/technical content that scored in the
|
|
4579
|
+
// 0.4–0.5 range. 0.4 is still well above noise threshold (< 0.2).
|
|
4580
|
+
min_store_confidence: 0.4,
|
|
1561
4581
|
fallback_on_error: true,
|
|
1562
4582
|
};
|
|
1563
4583
|
const apiOn = api.on as (event: string, handler: unknown) => void;
|
|
@@ -1572,9 +4592,635 @@ const sulcusPlugin = {
|
|
|
1572
4592
|
logger.info("sulcus: registered auto-capture (agent_end)");
|
|
1573
4593
|
}
|
|
1574
4594
|
|
|
1575
|
-
//
|
|
4595
|
+
// -------------------------------------------------------------------------
|
|
4596
|
+
// SESSION MEMORY AUTO-PURGE (Task 30)
|
|
4597
|
+
// When agent_end fires, delete all session-scoped memories so they don't
|
|
4598
|
+
// persist beyond the conversation. Fire-and-forget — purge failure is silent.
|
|
4599
|
+
// -------------------------------------------------------------------------
|
|
4600
|
+
if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
|
|
4601
|
+
const sessionPurgeApiOn = api.on as (event: string, handler: unknown) => void;
|
|
4602
|
+
sessionPurgeApiOn("agent_end", async () => {
|
|
4603
|
+
if (sessionMemoryIds.size === 0) return;
|
|
4604
|
+
const ids = Array.from(sessionMemoryIds);
|
|
4605
|
+
sessionMemoryIds.clear();
|
|
4606
|
+
logger.info(`sulcus: session_purge — purging ${ids.length} session memor${ids.length === 1 ? "y" : "ies"} (session: ${CURRENT_SESSION_ID})`);
|
|
4607
|
+
Promise.allSettled(
|
|
4608
|
+
ids.map((id) =>
|
|
4609
|
+
(sulcusMem as SulcusCloudClient).delete_memory(id, false).catch(() => {})
|
|
4610
|
+
)
|
|
4611
|
+
).then((results) => {
|
|
4612
|
+
const deleted = results.filter((r) => r.status === "fulfilled").length;
|
|
4613
|
+
logger.info(`sulcus: session_purge — purged ${deleted}/${ids.length} session memor${ids.length === 1 ? "y" : "ies"}`);
|
|
4614
|
+
}).catch(() => {});
|
|
4615
|
+
});
|
|
4616
|
+
logger.info(`sulcus: registered session_purge (agent_end) for session ${CURRENT_SESSION_ID}`);
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
// -------------------------------------------------------------------------
|
|
4620
|
+
// DREAM AUTO-TRIGGER (Phase 4)
|
|
4621
|
+
// Cheap local gates → expensive API call → fire-and-forget consolidation.
|
|
4622
|
+
// Gate cascade: session counter → time gap → memory count → lock → execute.
|
|
4623
|
+
// -------------------------------------------------------------------------
|
|
4624
|
+
|
|
4625
|
+
const dreamEnabled = (pluginConfig?.dreamAutoTrigger as boolean) !== false; // default: true
|
|
4626
|
+
const dreamSessionInterval = (pluginConfig?.dreamSessionInterval as number) ?? 10;
|
|
4627
|
+
const dreamMinGapMs = ((pluginConfig?.dreamMinGapHours as number) ?? 24) * 3600_000;
|
|
4628
|
+
const dreamMinMemories = (pluginConfig?.dreamMinMemories as number) ?? 50;
|
|
4629
|
+
const dreamMinHeat = (pluginConfig?.dreamConsolidateMinHeat as number) ?? 0.1;
|
|
4630
|
+
|
|
4631
|
+
if (dreamEnabled && isAvailable && sulcusMem instanceof SulcusCloudClient) {
|
|
4632
|
+
// State file for cross-session persistence
|
|
4633
|
+
const stateDir = resolve(__dirname, ".sulcus-state");
|
|
4634
|
+
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
|
4635
|
+
const dreamStateFile = resolve(stateDir, "dream-state.json");
|
|
4636
|
+
const dreamLockFile = resolve(stateDir, "dream.lock");
|
|
4637
|
+
|
|
4638
|
+
// In-memory session counter (resets on gateway restart, which is fine)
|
|
4639
|
+
let dreamSessionCount = 0;
|
|
4640
|
+
|
|
4641
|
+
// Read persisted state
|
|
4642
|
+
function readDreamState(): { lastDreamMs: number; lastSessionCount: number } {
|
|
4643
|
+
try {
|
|
4644
|
+
if (existsSync(dreamStateFile)) {
|
|
4645
|
+
const raw = readFileSync(dreamStateFile, "utf-8");
|
|
4646
|
+
const parsed = JSON.parse(raw);
|
|
4647
|
+
return {
|
|
4648
|
+
lastDreamMs: typeof parsed.lastDreamMs === "number" ? parsed.lastDreamMs : 0,
|
|
4649
|
+
lastSessionCount: typeof parsed.lastSessionCount === "number" ? parsed.lastSessionCount : 0,
|
|
4650
|
+
};
|
|
4651
|
+
}
|
|
4652
|
+
} catch { /* corrupted state = treat as fresh */ }
|
|
4653
|
+
return { lastDreamMs: 0, lastSessionCount: 0 };
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
function writeDreamState(state: { lastDreamMs: number; lastSessionCount: number }): void {
|
|
4657
|
+
try { writeFileSync(dreamStateFile, JSON.stringify(state)); } catch { /* best effort */ }
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
// Simple file lock (not bulletproof, but prevents obvious races)
|
|
4661
|
+
function acquireDreamLock(): boolean {
|
|
4662
|
+
try {
|
|
4663
|
+
if (existsSync(dreamLockFile)) {
|
|
4664
|
+
const lockAge = Date.now() - (JSON.parse(readFileSync(dreamLockFile, "utf-8")).ts ?? 0);
|
|
4665
|
+
if (lockAge < 600_000) return false; // Lock held < 10 min = still running
|
|
4666
|
+
// Stale lock — claim it
|
|
4667
|
+
}
|
|
4668
|
+
writeFileSync(dreamLockFile, JSON.stringify({ ts: Date.now(), pid: process.pid }));
|
|
4669
|
+
return true;
|
|
4670
|
+
} catch { return false; }
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
function releaseDreamLock(): void {
|
|
4674
|
+
try { if (existsSync(dreamLockFile)) require("node:fs").unlinkSync(dreamLockFile); } catch { /* best effort */ }
|
|
4675
|
+
}
|
|
4676
|
+
|
|
4677
|
+
// Register on before_prompt_build to count sessions (cheap — just increment)
|
|
4678
|
+
const origBeforePromptBuild = api.on as (event: string, handler: unknown) => void;
|
|
4679
|
+
origBeforePromptBuild("session_start", async () => {
|
|
4680
|
+
dreamSessionCount++;
|
|
4681
|
+
});
|
|
4682
|
+
|
|
4683
|
+
// Register on agent_end to check dream gates
|
|
4684
|
+
const dreamApiOn = api.on as (event: string, handler: unknown) => void;
|
|
4685
|
+
dreamApiOn("agent_end", async () => {
|
|
4686
|
+
// Gate 1 (free): Session counter — only check every N sessions
|
|
4687
|
+
if (dreamSessionCount % dreamSessionInterval !== 0) return;
|
|
4688
|
+
if (dreamSessionCount === 0) return; // Skip first session
|
|
4689
|
+
|
|
4690
|
+
// Gate 2 (free): Time gap — minimum hours since last dream
|
|
4691
|
+
const state = readDreamState();
|
|
4692
|
+
const elapsed = Date.now() - state.lastDreamMs;
|
|
4693
|
+
if (elapsed < dreamMinGapMs) {
|
|
4694
|
+
logger.info(`sulcus/dream: gate 2 skip — ${Math.round(elapsed / 3600_000)}h since last dream (need ${Math.round(dreamMinGapMs / 3600_000)}h)`);
|
|
4695
|
+
return;
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
// Gate 3 (cheap API): Memory count — only consolidate if enough memories exist
|
|
4699
|
+
try {
|
|
4700
|
+
const statusResp = await (sulcusMem as SulcusCloudClient).request("GET", "/api/v1/agent/memory/status") as Record<string, unknown> | null;
|
|
4701
|
+
const stats = statusResp?.stats as Record<string, unknown> | undefined;
|
|
4702
|
+
const totalMemories = typeof stats?.total_memories === "number" ? stats.total_memories as number : 0;
|
|
4703
|
+
if (totalMemories < dreamMinMemories) {
|
|
4704
|
+
logger.info(`sulcus/dream: gate 3 skip — ${totalMemories} memories (need ${dreamMinMemories})`);
|
|
4705
|
+
return;
|
|
4706
|
+
}
|
|
4707
|
+
logger.info(`sulcus/dream: gates passed — ${totalMemories} memories, ${Math.round(elapsed / 3600_000)}h since last dream`);
|
|
4708
|
+
} catch (e: unknown) {
|
|
4709
|
+
logger.warn(`sulcus/dream: gate 3 error — ${e instanceof Error ? e.message : e}`);
|
|
4710
|
+
return;
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
// Gate 4 (lock): Prevent concurrent consolidation
|
|
4714
|
+
if (!acquireDreamLock()) {
|
|
4715
|
+
logger.info("sulcus/dream: lock held — another consolidation in progress");
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4718
|
+
|
|
4719
|
+
// Execute: Fire-and-forget consolidation
|
|
4720
|
+
logger.info(`sulcus/dream: triggering consolidation (minHeat=${dreamMinHeat})`);
|
|
4721
|
+
(sulcusMem as SulcusCloudClient).consolidate(dreamMinHeat)
|
|
4722
|
+
.then((result: unknown) => {
|
|
4723
|
+
writeDreamState({ lastDreamMs: Date.now(), lastSessionCount: dreamSessionCount });
|
|
4724
|
+
logger.info(`sulcus/dream: consolidation complete — ${JSON.stringify(result)}`);
|
|
4725
|
+
})
|
|
4726
|
+
.catch((e: unknown) => {
|
|
4727
|
+
logger.warn(`sulcus/dream: consolidation failed — ${e instanceof Error ? e.message : e}`);
|
|
4728
|
+
})
|
|
4729
|
+
.finally(() => {
|
|
4730
|
+
releaseDreamLock();
|
|
4731
|
+
});
|
|
4732
|
+
});
|
|
4733
|
+
|
|
4734
|
+
logger.info(`sulcus: dream auto-trigger enabled (every ${dreamSessionInterval} sessions, ${Math.round(dreamMinGapMs / 3600_000)}h gap, min ${dreamMinMemories} memories)`);
|
|
4735
|
+
}
|
|
4736
|
+
|
|
4737
|
+
// -------------------------------------------------------------------------
|
|
4738
|
+
// GUARDRAIL HOOK REGISTRATION (Task 54 — outputGuard)
|
|
4739
|
+
// llm_output: fast pre-analysis (regex only, <5ms target)
|
|
4740
|
+
// message_sending: enforcement (may do async Sulcus recall for pref check)
|
|
4741
|
+
// -------------------------------------------------------------------------
|
|
4742
|
+
|
|
4743
|
+
const outputGuardCfg = parseOutputGuardConfig(pluginConfig);
|
|
4744
|
+
|
|
4745
|
+
if (outputGuardCfg.enabled) {
|
|
4746
|
+
// -- Hook 1: llm_output — fast pre-analysis --------------------------
|
|
4747
|
+
const llmOutputApiOn = api.on as (event: string, handler: unknown) => void;
|
|
4748
|
+
llmOutputApiOn("llm_output", async (event: Record<string, unknown>) => {
|
|
4749
|
+
const t0 = Date.now();
|
|
4750
|
+
try {
|
|
4751
|
+
const content = (event?.content ?? event?.text ?? "") as string;
|
|
4752
|
+
if (!content) { lastGuardFlags = null; return undefined; }
|
|
4753
|
+
|
|
4754
|
+
// Fast PII scan (regex only — no API calls)
|
|
4755
|
+
let piiSpans: PiiSpan[] = [];
|
|
4756
|
+
if (outputGuardCfg.pii.enabled) {
|
|
4757
|
+
piiSpans = scanForPii(content, outputGuardCfg.pii.patterns, outputGuardCfg.pii.customPatterns);
|
|
4758
|
+
}
|
|
4759
|
+
|
|
4760
|
+
// Fast preference violation heuristic (keyword check against cached prefs)
|
|
4761
|
+
let suspectedPrefViolation = false;
|
|
4762
|
+
let suspectedReason: string | undefined;
|
|
4763
|
+
if (outputGuardCfg.preferenceViolation.enabled && negPrefCache && negPrefCache.namespace === namespace) {
|
|
4764
|
+
const lowerContent = content.toLowerCase();
|
|
4765
|
+
for (const pref of negPrefCache.prefs) {
|
|
4766
|
+
if (lowerContent.includes(pref.toLowerCase())) {
|
|
4767
|
+
suspectedPrefViolation = true;
|
|
4768
|
+
suspectedReason = `Content contains term matching stored negative preference: "${pref.slice(0, 50)}"`;
|
|
4769
|
+
break;
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
const flags: SulcusGuardFlags = {
|
|
4775
|
+
piiDetected: piiSpans.length > 0,
|
|
4776
|
+
piiSpans,
|
|
4777
|
+
suspectedPreferenceViolation: suspectedPrefViolation,
|
|
4778
|
+
suspectedViolationReason: suspectedReason,
|
|
4779
|
+
scanTimeMs: Date.now() - t0,
|
|
4780
|
+
};
|
|
4781
|
+
lastGuardFlags = flags;
|
|
4782
|
+
|
|
4783
|
+
logger.debug?.(`sulcus/output-guard: llm_output scan complete (${flags.scanTimeMs}ms, pii=${flags.piiDetected}, prefViolation=${flags.suspectedPreferenceViolation})`);
|
|
4784
|
+
return undefined; // no modification at this stage — enforcement is in message_sending
|
|
4785
|
+
} catch (err) {
|
|
4786
|
+
logger.warn(`sulcus/output-guard: llm_output threw: ${err}`);
|
|
4787
|
+
lastGuardFlags = null;
|
|
4788
|
+
return outputGuardCfg.failMode === "fail-closed" ? { content: "⚠️ Output guardrail error — message blocked (fail-closed mode)." } : undefined;
|
|
4789
|
+
}
|
|
4790
|
+
});
|
|
4791
|
+
|
|
4792
|
+
// -- Hook 2: message_sending — enforcement --------------------------
|
|
4793
|
+
const msgSendingApiOn = api.on as (event: string, handler: unknown) => void;
|
|
4794
|
+
msgSendingApiOn("message_sending", async (event: Record<string, unknown>) => {
|
|
4795
|
+
try {
|
|
4796
|
+
const content = (event?.content ?? event?.text ?? event?.message ?? "") as string;
|
|
4797
|
+
if (!content) return undefined;
|
|
4798
|
+
|
|
4799
|
+
// Consume flags from llm_output (or run fast scan if unavailable)
|
|
4800
|
+
const flags: SulcusGuardFlags = lastGuardFlags ?? (() => {
|
|
4801
|
+
const t0 = Date.now();
|
|
4802
|
+
const piiSpans = outputGuardCfg.pii.enabled
|
|
4803
|
+
? scanForPii(content, outputGuardCfg.pii.patterns, outputGuardCfg.pii.customPatterns)
|
|
4804
|
+
: [];
|
|
4805
|
+
return {
|
|
4806
|
+
piiDetected: piiSpans.length > 0,
|
|
4807
|
+
piiSpans,
|
|
4808
|
+
suspectedPreferenceViolation: false,
|
|
4809
|
+
scanTimeMs: Date.now() - t0,
|
|
4810
|
+
};
|
|
4811
|
+
})();
|
|
4812
|
+
lastGuardFlags = null; // consume flags — one-shot per turn
|
|
4813
|
+
|
|
4814
|
+
let modified = false;
|
|
4815
|
+
let finalContent = content;
|
|
4816
|
+
const auditEvents: Array<{ eventType: string; action: string; details: string }> = [];
|
|
4817
|
+
|
|
4818
|
+
// -- PII enforcement ---------------------------------------------
|
|
4819
|
+
if (outputGuardCfg.pii.enabled && flags.piiDetected) {
|
|
4820
|
+
switch (outputGuardCfg.pii.onViolation) {
|
|
4821
|
+
case "redact": {
|
|
4822
|
+
// Store redaction key if reversible
|
|
4823
|
+
if (outputGuardCfg.pii.reversible) {
|
|
4824
|
+
storeRedactionKey(flags.piiSpans, content, outputGuardCfg.pii.storageKey, namespace);
|
|
4825
|
+
}
|
|
4826
|
+
finalContent = redactSpans(finalContent, flags.piiSpans);
|
|
4827
|
+
modified = true;
|
|
4828
|
+
auditEvents.push({ eventType: "pii_redacted", action: "redact", details: `${flags.piiSpans.length} span(s) redacted (types: ${[...new Set(flags.piiSpans.map(s => s.type))].join(", ")})` });
|
|
4829
|
+
logger.info(`sulcus/output-guard: redacted ${flags.piiSpans.length} PII span(s)`);
|
|
4830
|
+
break;
|
|
4831
|
+
}
|
|
4832
|
+
case "replace":
|
|
4833
|
+
case "block": {
|
|
4834
|
+
// Never silent cancel — always explain (Dooley's directive)
|
|
4835
|
+
finalContent = `⚠️ This message contained personal information (${[...new Set(flags.piiSpans.map(s => s.type))].join(", ")}) and was blocked by the output guard. Please rephrase without including identifiable data.`;
|
|
4836
|
+
modified = true;
|
|
4837
|
+
auditEvents.push({ eventType: "pii_blocked", action: outputGuardCfg.pii.onViolation, details: `${flags.piiSpans.length} span(s) blocked` });
|
|
4838
|
+
logger.info(`sulcus/output-guard: blocked message containing PII (${outputGuardCfg.pii.onViolation})`);
|
|
4839
|
+
break;
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
// -- Preference violation enforcement (async — Sulcus recall) ----
|
|
4845
|
+
if (outputGuardCfg.preferenceViolation.enabled && flags.suspectedPreferenceViolation && sulcusMem instanceof SulcusCloudClient) {
|
|
4846
|
+
try {
|
|
4847
|
+
// Refresh negative pref cache if stale or namespace changed
|
|
4848
|
+
const now = Date.now();
|
|
4849
|
+
if (!negPrefCache || negPrefCache.namespace !== namespace || (now - negPrefCache.cachedAt) > NEG_PREF_CACHE_TTL_MS) {
|
|
4850
|
+
const prefRes = await sulcusMem.search_memory("negative preference dislike avoid", 10, namespace);
|
|
4851
|
+
const prefMemories = prefRes?.results ?? [];
|
|
4852
|
+
// Extract content strings from preference memories
|
|
4853
|
+
const prefTexts = prefMemories
|
|
4854
|
+
.filter((m) => {
|
|
4855
|
+
const mtype = m.memory_type as string | undefined;
|
|
4856
|
+
return !mtype || mtype === "preference";
|
|
4857
|
+
})
|
|
4858
|
+
.map((m) => ((m.label ?? m.content ?? "") as string).toLowerCase())
|
|
4859
|
+
.filter((t) => t.length > 3);
|
|
4860
|
+
negPrefCache = { prefs: prefTexts, cachedAt: now, namespace };
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4863
|
+
// Confirm violation against actual recalled preferences
|
|
4864
|
+
const lowerFinal = finalContent.toLowerCase();
|
|
4865
|
+
let confirmedViolation = false;
|
|
4866
|
+
let violatedPref = "";
|
|
4867
|
+
for (const pref of negPrefCache.prefs) {
|
|
4868
|
+
if (lowerFinal.includes(pref.toLowerCase().slice(0, 30))) {
|
|
4869
|
+
confirmedViolation = true;
|
|
4870
|
+
violatedPref = pref.slice(0, 80);
|
|
4871
|
+
break;
|
|
4872
|
+
}
|
|
4873
|
+
}
|
|
4874
|
+
|
|
4875
|
+
if (confirmedViolation) {
|
|
4876
|
+
const replacement = outputGuardCfg.preferenceViolation.replacementMessage;
|
|
4877
|
+
switch (outputGuardCfg.preferenceViolation.onViolation) {
|
|
4878
|
+
case "replace":
|
|
4879
|
+
case "block":
|
|
4880
|
+
finalContent = replacement;
|
|
4881
|
+
modified = true;
|
|
4882
|
+
auditEvents.push({ eventType: "preference_violation", action: outputGuardCfg.preferenceViolation.onViolation, details: `Violated preference: "${violatedPref}"` });
|
|
4883
|
+
logger.info(`sulcus/output-guard: preference violation — replaced message (pref: "${violatedPref.slice(0, 50)}")`);
|
|
4884
|
+
break;
|
|
4885
|
+
case "warn":
|
|
4886
|
+
finalContent = `⚠️ Note: This response may conflict with your stored preferences.
|
|
4887
|
+
|
|
4888
|
+
${finalContent}`;
|
|
4889
|
+
modified = true;
|
|
4890
|
+
auditEvents.push({ eventType: "preference_violation", action: "warn", details: `Possible conflict with preference: "${violatedPref}"` });
|
|
4891
|
+
break;
|
|
4892
|
+
}
|
|
4893
|
+
}
|
|
4894
|
+
} catch (prefErr) {
|
|
4895
|
+
logger.warn(`sulcus/output-guard: preference check failed: ${prefErr}`);
|
|
4896
|
+
if (outputGuardCfg.failMode === "fail-closed") {
|
|
4897
|
+
finalContent = "⚠️ Output guardrail check failed — message blocked (fail-closed mode).";
|
|
4898
|
+
modified = true;
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
// -- Audit trail + inspect buffer (Task 56) -------------------------------
|
|
4904
|
+
if (auditEvents.length > 0) {
|
|
4905
|
+
// Always push to inspect buffer (regardless of auditTrail config)
|
|
4906
|
+
for (const evt of auditEvents) {
|
|
4907
|
+
pushGuardrailEvent({
|
|
4908
|
+
capturedAt: Date.now(),
|
|
4909
|
+
guard: "output",
|
|
4910
|
+
eventType: evt.eventType,
|
|
4911
|
+
action: evt.action,
|
|
4912
|
+
details: evt.details,
|
|
4913
|
+
});
|
|
4914
|
+
}
|
|
4915
|
+
// Persist to Sulcus only when auditTrail is enabled
|
|
4916
|
+
if (outputGuardCfg.auditTrail && sulcusMem instanceof SulcusCloudClient) {
|
|
4917
|
+
for (const evt of auditEvents) {
|
|
4918
|
+
sulcusMem.store({
|
|
4919
|
+
content: `[output_guard] ${evt.eventType}: ${evt.details}. Action: ${evt.action}. Timestamp: ${new Date().toISOString()}.`,
|
|
4920
|
+
memory_type: "episodic",
|
|
4921
|
+
metadata: { _source: "output_guard", eventType: evt.eventType, action: evt.action, namespace },
|
|
4922
|
+
} as any).catch(() => { /* best effort audit */ });
|
|
4923
|
+
}
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
|
|
4927
|
+
if (modified) {
|
|
4928
|
+
return { content: finalContent };
|
|
4929
|
+
}
|
|
4930
|
+
return undefined;
|
|
4931
|
+
} catch (err) {
|
|
4932
|
+
logger.warn(`sulcus/output-guard: message_sending threw: ${err}`);
|
|
4933
|
+
return outputGuardCfg.failMode === "fail-closed" ? { content: "⚠️ Output guardrail error — message blocked (fail-closed mode)." } : undefined;
|
|
4934
|
+
}
|
|
4935
|
+
});
|
|
4936
|
+
|
|
4937
|
+
logger.info(`sulcus/output-guard: registered (pii=${outputGuardCfg.pii.enabled}, prefViolation=${outputGuardCfg.preferenceViolation.enabled}, failMode=${outputGuardCfg.failMode})`);
|
|
4938
|
+
} else {
|
|
4939
|
+
logger.info("sulcus/output-guard: disabled (set guardrails.outputGuard.enabled=true to activate)");
|
|
4940
|
+
}
|
|
4941
|
+
|
|
4942
|
+
// -------------------------------------------------------------------------
|
|
4943
|
+
// ASSISTANT OUTPUT CAPTURE (Task 67 — llm_output hook)
|
|
4944
|
+
// Captures assistant responses as memories via SIVU quality gate.
|
|
4945
|
+
// Filters generic acks; compresses long responses before storing.
|
|
4946
|
+
// Config: captureFromAssistant=true (disabled by default).
|
|
4947
|
+
// -------------------------------------------------------------------------
|
|
4948
|
+
|
|
4949
|
+
if (captureFromAssistant && isAvailable && sulcusMem) {
|
|
4950
|
+
const assistantCaptureApiOn = api.on as (event: string, handler: unknown) => void;
|
|
4951
|
+
assistantCaptureApiOn("llm_output", async (event: Record<string, unknown>) => {
|
|
4952
|
+
try {
|
|
4953
|
+
const content = (event?.content ?? event?.text ?? "") as string;
|
|
4954
|
+
if (!content || typeof content !== "string") return undefined;
|
|
4955
|
+
|
|
4956
|
+
// Skip generic acknowledgments ("ok", "sure", "got it", etc.)
|
|
4957
|
+
if (isGenericAck(content)) {
|
|
4958
|
+
logger.debug?.("sulcus: assistant_capture — skipping generic ack");
|
|
4959
|
+
return undefined;
|
|
4960
|
+
}
|
|
4961
|
+
|
|
4962
|
+
// Skip junk (system blobs, patterns we never want)
|
|
4963
|
+
if (isJunkMemory(content)) {
|
|
4964
|
+
logger.debug?.(`sulcus: assistant_capture — skipping junk: "${content.substring(0, 50)}..."`);
|
|
4965
|
+
return undefined;
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
// Build capture text: summarize long responses
|
|
4969
|
+
const captureText = content.length > ASSISTANT_CAPTURE_MAX_DIRECT
|
|
4970
|
+
? summarizeForCapture(content, namespace)
|
|
4971
|
+
: content;
|
|
4972
|
+
|
|
4973
|
+
// Dedup check
|
|
4974
|
+
if (!shouldCapture(captureText)) {
|
|
4975
|
+
logger.debug?.("sulcus: assistant_capture — dedup skip");
|
|
4976
|
+
return undefined;
|
|
4977
|
+
}
|
|
4978
|
+
|
|
4979
|
+
// SIVU quality gate + store
|
|
4980
|
+
if (sulcusMem instanceof SulcusCloudClient) {
|
|
4981
|
+
try {
|
|
4982
|
+
const siuResult = await sulcusMem.request("POST", "/api/v2/siu/label", { text: captureText }) as Record<string, unknown>;
|
|
4983
|
+
const storeConf = (siuResult?.store_confidence as number) ?? 0;
|
|
4984
|
+
const shouldStore = siuResult?.store === true && storeConf >= 0.4;
|
|
4985
|
+
|
|
4986
|
+
if (!shouldStore) {
|
|
4987
|
+
logger.debug?.(`sulcus: assistant_capture — SIVU rejected (conf: ${storeConf.toFixed(3)}): "${captureText.substring(0, 60)}..."`);
|
|
4988
|
+
return undefined;
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
const memoryType = (siuResult?.memory_type as string) ?? "episodic";
|
|
4992
|
+
const hints = buildExtractionHints(memoryType, namespace, "assistant_capture", captureText.substring(0, 200));
|
|
4993
|
+
const res = await sulcusMem.add_memory(captureText, memoryType, hints);
|
|
4994
|
+
logger.info(`sulcus: assistant_capture — stored [${memoryType}] (id: ${res?.id ?? "?"}, conf: ${storeConf.toFixed(3)}): "${captureText.substring(0, 60)}..."`);
|
|
4995
|
+
} catch (e: unknown) {
|
|
4996
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
4997
|
+
logger.warn(`sulcus: assistant_capture — SIVU error: ${msg}`);
|
|
4998
|
+
// fallback: store as episodic without quality gate
|
|
4999
|
+
try {
|
|
5000
|
+
const hints = buildExtractionHints("episodic", namespace, "assistant_capture", captureText.substring(0, 200));
|
|
5001
|
+
const res = await sulcusMem.add_memory(captureText, "episodic", hints);
|
|
5002
|
+
logger.info(`sulcus: assistant_capture — fallback stored [episodic] (id: ${res?.id ?? "?"}): "${captureText.substring(0, 60)}..."`);
|
|
5003
|
+
} catch (fe: unknown) {
|
|
5004
|
+
logger.warn(`sulcus: assistant_capture — fallback failed: ${fe instanceof Error ? fe.message : fe}`);
|
|
5005
|
+
}
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
|
|
5009
|
+
return undefined; // never modify output — capture only
|
|
5010
|
+
} catch (err) {
|
|
5011
|
+
logger.warn("sulcus: assistant_capture — hook threw: " + err);
|
|
5012
|
+
return undefined;
|
|
5013
|
+
}
|
|
5014
|
+
});
|
|
5015
|
+
logger.info("sulcus: registered assistant_capture (llm_output hook, captureFromAssistant=true)");
|
|
5016
|
+
} else if (!captureFromAssistant) {
|
|
5017
|
+
logger.debug?.("sulcus: assistant_capture disabled (set captureFromAssistant=true to activate)");
|
|
5018
|
+
}
|
|
5019
|
+
|
|
5020
|
+
// -------------------------------------------------------------------------
|
|
5021
|
+
// TOOL GUARD HOOK REGISTRATION (Task 55 — before_tool_call)
|
|
5022
|
+
// Evaluates tool calls against memory + allowlists before execution
|
|
5023
|
+
// -------------------------------------------------------------------------
|
|
5024
|
+
|
|
5025
|
+
const toolGuardCfg = parseToolGuardConfig(pluginConfig);
|
|
5026
|
+
|
|
5027
|
+
// Task 57: populate module-scope snapshot for guardrail_status tool
|
|
5028
|
+
guardrailStatus = {
|
|
5029
|
+
outputGuard: {
|
|
5030
|
+
enabled: outputGuardCfg.enabled,
|
|
5031
|
+
pii: {
|
|
5032
|
+
enabled: outputGuardCfg.pii.enabled,
|
|
5033
|
+
patterns: outputGuardCfg.pii.patterns,
|
|
5034
|
+
onViolation: outputGuardCfg.pii.onViolation,
|
|
5035
|
+
reversible: outputGuardCfg.pii.reversible,
|
|
5036
|
+
},
|
|
5037
|
+
preferenceViolation: {
|
|
5038
|
+
enabled: outputGuardCfg.preferenceViolation.enabled,
|
|
5039
|
+
onViolation: outputGuardCfg.preferenceViolation.onViolation,
|
|
5040
|
+
},
|
|
5041
|
+
failMode: outputGuardCfg.failMode,
|
|
5042
|
+
auditTrail: outputGuardCfg.auditTrail,
|
|
5043
|
+
},
|
|
5044
|
+
toolGuard: {
|
|
5045
|
+
enabled: toolGuardCfg.enabled,
|
|
5046
|
+
sensitiveTools: toolGuardCfg.sensitiveTools,
|
|
5047
|
+
allowlist: toolGuardCfg.allowlist,
|
|
5048
|
+
blocklist: toolGuardCfg.blocklist,
|
|
5049
|
+
objectiveCheck: toolGuardCfg.objectiveCheck,
|
|
5050
|
+
requireApprovalThreshold: toolGuardCfg.requireApprovalThreshold,
|
|
5051
|
+
failMode: toolGuardCfg.failMode,
|
|
5052
|
+
auditTrail: toolGuardCfg.auditTrail,
|
|
5053
|
+
},
|
|
5054
|
+
negPrefCount: () => negPrefCache?.prefs.length ?? 0,
|
|
5055
|
+
negPrefCachedAt: () => negPrefCache?.cachedAt ?? null,
|
|
5056
|
+
};
|
|
5057
|
+
|
|
5058
|
+
if (toolGuardCfg.enabled) {
|
|
5059
|
+
const toolGuardApiOn = api.on as (event: string, handler: unknown) => void;
|
|
5060
|
+
toolGuardApiOn("before_tool_call", async (event: Record<string, unknown>) => {
|
|
5061
|
+
try {
|
|
5062
|
+
const toolName = (event?.name ?? event?.function ?? event?.tool_name ?? "") as string;
|
|
5063
|
+
const toolArgs = (event?.arguments ?? event?.input ?? event?.params ?? {}) as Record<string, unknown>;
|
|
5064
|
+
|
|
5065
|
+
if (!toolName) {
|
|
5066
|
+
logger.warn("sulcus/tool-guard: no tool name in event — allowing by default");
|
|
5067
|
+
return { allow: true };
|
|
5068
|
+
}
|
|
5069
|
+
|
|
5070
|
+
// -- Allowlist check (immediate pass) ------------------------------
|
|
5071
|
+
if (toolGuardCfg.allowlist.length > 0 && toolGuardCfg.allowlist.includes(toolName)) {
|
|
5072
|
+
// Task 56: push to inspect buffer
|
|
5073
|
+
pushGuardrailEvent({ capturedAt: Date.now(), guard: "tool", eventType: "tool_allowed", action: "allow", details: `Allowlisted tool: ${toolName}`, toolName, severity: "info" });
|
|
5074
|
+
if (toolGuardCfg.auditTrail && sulcusMem instanceof SulcusCloudClient) {
|
|
5075
|
+
sulcusMem.add_memory(
|
|
5076
|
+
`[tool_guard] ${toolName}: allowed (allowlist). Args: ${JSON.stringify(toolArgs).slice(0, 200)}`,
|
|
5077
|
+
"episodic",
|
|
5078
|
+
{ _source: "tool_guard" } as any
|
|
5079
|
+
).catch(() => {});
|
|
5080
|
+
}
|
|
5081
|
+
return { allow: true };
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
// -- Blocklist check (immediate block) -----------------------------
|
|
5085
|
+
if (toolGuardCfg.blocklist.length > 0 && toolGuardCfg.blocklist.includes(toolName)) {
|
|
5086
|
+
const reason = `Tool '${toolName}' is on the blocklist and cannot be used.`;
|
|
5087
|
+
// Task 56: push to inspect buffer
|
|
5088
|
+
pushGuardrailEvent({ capturedAt: Date.now(), guard: "tool", eventType: "tool_blocked", action: "block", details: `Blocklisted tool: ${toolName}`, toolName, severity: "critical" });
|
|
5089
|
+
if (toolGuardCfg.auditTrail && sulcusMem instanceof SulcusCloudClient) {
|
|
5090
|
+
sulcusMem.add_memory(
|
|
5091
|
+
`[tool_guard] ${toolName}: blocked (blocklist). Reason: ${reason}`,
|
|
5092
|
+
"episodic",
|
|
5093
|
+
{ _source: "tool_guard" } as any
|
|
5094
|
+
).catch(() => {});
|
|
5095
|
+
}
|
|
5096
|
+
logger.info(`sulcus/tool-guard: blocked tool '${toolName}' (blocklist)`);
|
|
5097
|
+
return { block: true, reason };
|
|
5098
|
+
}
|
|
5099
|
+
|
|
5100
|
+
// -- Sensitivity check ---------------------------------------------
|
|
5101
|
+
const isSensitive = toolGuardCfg.sensitiveTools.includes(toolName);
|
|
5102
|
+
if (!isSensitive) {
|
|
5103
|
+
// Non-sensitive tools pass without evaluation
|
|
5104
|
+
return { allow: true };
|
|
5105
|
+
}
|
|
5106
|
+
|
|
5107
|
+
// -- Objective alignment check (for sensitive tools) ---------------
|
|
5108
|
+
let severity: "info" | "warning" | "critical" = "info";
|
|
5109
|
+
let reason = "";
|
|
5110
|
+
|
|
5111
|
+
if (toolGuardCfg.objectiveCheck && sulcusMem instanceof SulcusCloudClient) {
|
|
5112
|
+
try {
|
|
5113
|
+
// Search for relevant objectives and preferences
|
|
5114
|
+
const objectiveRes = await sulcusMem.search_memory(`objective goal preference ${toolName}`, 5, namespace);
|
|
5115
|
+
const objectives = objectiveRes?.results ?? [];
|
|
5116
|
+
|
|
5117
|
+
// Simplified alignment scoring based on memory content
|
|
5118
|
+
const toolDescription = `Tool call: ${toolName} with args ${JSON.stringify(toolArgs).slice(0, 200)}`;
|
|
5119
|
+
let hasConflict = false;
|
|
5120
|
+
let conflictingObjective = "";
|
|
5121
|
+
|
|
5122
|
+
// Check for explicit negative preferences about this tool or action
|
|
5123
|
+
for (const obj of objectives) {
|
|
5124
|
+
const content = ((obj.label ?? obj.content ?? "") as string).toLowerCase();
|
|
5125
|
+
const toolLower = toolName.toLowerCase();
|
|
5126
|
+
|
|
5127
|
+
// Look for explicit prohibitions
|
|
5128
|
+
if (content.includes("never") || content.includes("don't") || content.includes("avoid")) {
|
|
5129
|
+
if (content.includes(toolLower) ||
|
|
5130
|
+
(toolName === "exec" && (content.includes("command") || content.includes("execute"))) ||
|
|
5131
|
+
(toolName === "write" && content.includes("file")) ||
|
|
5132
|
+
(toolName === "edit" && content.includes("modify")) ||
|
|
5133
|
+
(toolName === "delete" && content.includes("remove"))) {
|
|
5134
|
+
hasConflict = true;
|
|
5135
|
+
conflictingObjective = (obj.label ?? obj.content ?? "") as string;
|
|
5136
|
+
break;
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
}
|
|
5140
|
+
|
|
5141
|
+
if (hasConflict) {
|
|
5142
|
+
severity = "critical";
|
|
5143
|
+
reason = `This tool call conflicts with stored preference: "${conflictingObjective.slice(0, 100)}"`;
|
|
5144
|
+
} else if (objectives.length === 0) {
|
|
5145
|
+
// No relevant memories found — low risk
|
|
5146
|
+
severity = "info";
|
|
5147
|
+
reason = "No relevant objectives found in memory — proceeding with caution.";
|
|
5148
|
+
} else {
|
|
5149
|
+
// Has relevant memories but no clear conflict
|
|
5150
|
+
severity = "warning";
|
|
5151
|
+
reason = "Tool call is sensitive but appears aligned with stored objectives.";
|
|
5152
|
+
}
|
|
5153
|
+
} catch (objErr) {
|
|
5154
|
+
logger.warn(`sulcus/tool-guard: objective check failed: ${objErr}`);
|
|
5155
|
+
if (toolGuardCfg.failMode === "fail-closed") {
|
|
5156
|
+
return { block: true, reason: "Tool guard objective check failed (fail-closed mode)." };
|
|
5157
|
+
}
|
|
5158
|
+
// fail-open: allow with info severity
|
|
5159
|
+
severity = "info";
|
|
5160
|
+
reason = "Objective check failed — allowing with reduced confidence.";
|
|
5161
|
+
}
|
|
5162
|
+
} else {
|
|
5163
|
+
// No objective check configured — default to warning for sensitive tools
|
|
5164
|
+
severity = "warning";
|
|
5165
|
+
reason = `Tool '${toolName}' is marked as sensitive. Please verify this action is intended.`;
|
|
5166
|
+
}
|
|
5167
|
+
|
|
5168
|
+
// -- Severity threshold evaluation ---------------------------------
|
|
5169
|
+
const severityLevels = { "info": 0, "warning": 1, "critical": 2 };
|
|
5170
|
+
const currentLevel = severityLevels[severity];
|
|
5171
|
+
const thresholdLevel = severityLevels[toolGuardCfg.requireApprovalThreshold];
|
|
5172
|
+
|
|
5173
|
+
// -- Audit trail + inspect buffer (Task 56) -----------------------
|
|
5174
|
+
{
|
|
5175
|
+
const decision = currentLevel >= thresholdLevel ? "require_approval" : "allow";
|
|
5176
|
+
// Always push to inspect buffer
|
|
5177
|
+
pushGuardrailEvent({
|
|
5178
|
+
capturedAt: Date.now(),
|
|
5179
|
+
guard: "tool",
|
|
5180
|
+
eventType: currentLevel >= thresholdLevel ? "tool_require_approval" : "tool_allowed",
|
|
5181
|
+
action: decision,
|
|
5182
|
+
details: reason.slice(0, 200),
|
|
5183
|
+
toolName,
|
|
5184
|
+
severity,
|
|
5185
|
+
});
|
|
5186
|
+
if (toolGuardCfg.auditTrail && sulcusMem instanceof SulcusCloudClient) {
|
|
5187
|
+
sulcusMem.add_memory(
|
|
5188
|
+
`[tool_guard] ${toolName}: ${decision}. Severity: ${severity}. Reason: ${reason}. Args: ${JSON.stringify(toolArgs).slice(0, 200)}`,
|
|
5189
|
+
"episodic",
|
|
5190
|
+
{ _source: "tool_guard" } as any
|
|
5191
|
+
).catch(() => {});
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
5194
|
+
|
|
5195
|
+
if (currentLevel >= thresholdLevel) {
|
|
5196
|
+
logger.info(`sulcus/tool-guard: requiring approval for '${toolName}' (severity: ${severity}, threshold: ${toolGuardCfg.requireApprovalThreshold})`);
|
|
5197
|
+
return {
|
|
5198
|
+
requireApproval: true,
|
|
5199
|
+
severity,
|
|
5200
|
+
reason: `${reason}\n\nTool: ${toolName}\nArguments: ${JSON.stringify(toolArgs, null, 2)}`,
|
|
5201
|
+
};
|
|
5202
|
+
} else {
|
|
5203
|
+
logger.debug?.(`sulcus/tool-guard: allowing '${toolName}' (severity: ${severity} below threshold: ${toolGuardCfg.requireApprovalThreshold})`);
|
|
5204
|
+
return { allow: true };
|
|
5205
|
+
}
|
|
5206
|
+
} catch (err) {
|
|
5207
|
+
logger.warn(`sulcus/tool-guard: before_tool_call threw: ${err}`);
|
|
5208
|
+
if (toolGuardCfg.failMode === "fail-closed") {
|
|
5209
|
+
return { block: true, reason: "Tool guard error — blocked (fail-closed mode)." };
|
|
5210
|
+
}
|
|
5211
|
+
// fail-open: allow on error
|
|
5212
|
+
return { allow: true };
|
|
5213
|
+
}
|
|
5214
|
+
});
|
|
5215
|
+
|
|
5216
|
+
logger.info(`sulcus/tool-guard: registered (sensitiveTools=${toolGuardCfg.sensitiveTools.length}, objectiveCheck=${toolGuardCfg.objectiveCheck}, threshold=${toolGuardCfg.requireApprovalThreshold}, failMode=${toolGuardCfg.failMode})`);
|
|
5217
|
+
} else {
|
|
5218
|
+
logger.info("sulcus/tool-guard: disabled (set guardrails.toolGuard.enabled=true to activate)");
|
|
5219
|
+
}
|
|
5220
|
+
|
|
5221
|
+
// -------------------------------------------------------------------------
|
|
1576
5222
|
// LEGACY HOOK REGISTRATION (config-driven, backward compat)
|
|
1577
|
-
//
|
|
5223
|
+
// -------------------------------------------------------------------------
|
|
1578
5224
|
|
|
1579
5225
|
for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
|
|
1580
5226
|
if (!hookConfig.enabled) continue;
|
|
@@ -1602,9 +5248,9 @@ const sulcusPlugin = {
|
|
|
1602
5248
|
}
|
|
1603
5249
|
}
|
|
1604
5250
|
|
|
1605
|
-
//
|
|
5251
|
+
// -------------------------------------------------------------------------
|
|
1606
5252
|
// TOOL REGISTRATION
|
|
1607
|
-
//
|
|
5253
|
+
// -------------------------------------------------------------------------
|
|
1608
5254
|
|
|
1609
5255
|
for (const [toolName, toolConfig] of Object.entries(hooksConfig.tools)) {
|
|
1610
5256
|
if (!toolConfig.enabled) continue;
|
|
@@ -1623,6 +5269,264 @@ const sulcusPlugin = {
|
|
|
1623
5269
|
}
|
|
1624
5270
|
}
|
|
1625
5271
|
|
|
5272
|
+
// -------------------------------------------------------------------------
|
|
5273
|
+
// CLI REGISTRATION (Phase 3: `openclaw sulcus <subcommand>`)
|
|
5274
|
+
// -------------------------------------------------------------------------
|
|
5275
|
+
|
|
5276
|
+
const registerCli = api.registerCli as ((registrar: (ctx: { program: any; config: any; logger: any }) => void, opts?: any) => void) | undefined;
|
|
5277
|
+
if (typeof registerCli === "function") {
|
|
5278
|
+
registerCli((ctx: { program: any; config: any; logger: any }) => {
|
|
5279
|
+
const sulcusCmd = ctx.program.command("sulcus").description("Sulcus memory management");
|
|
5280
|
+
|
|
5281
|
+
// --- openclaw sulcus status ---
|
|
5282
|
+
sulcusCmd.command("status")
|
|
5283
|
+
.description("Check Sulcus connection, config, and memory stats")
|
|
5284
|
+
.option("--json", "Machine-readable JSON output")
|
|
5285
|
+
.action(async (opts: { json?: boolean }) => {
|
|
5286
|
+
if (!isAvailable || !sulcusMem) {
|
|
5287
|
+
const out = { status: "unavailable", backend: backendMode, namespace, error: "Backend not connected" };
|
|
5288
|
+
if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
|
|
5289
|
+
console.log(`Status: unavailable`);
|
|
5290
|
+
console.log(`Backend: ${backendMode}`);
|
|
5291
|
+
console.log(`Namespace: ${namespace}`);
|
|
5292
|
+
if (serverUrl) console.log(`Server: ${serverUrl}`);
|
|
5293
|
+
console.log(`\nRun \`openclaw sulcus init\` to configure.`);
|
|
5294
|
+
}
|
|
5295
|
+
return;
|
|
5296
|
+
}
|
|
5297
|
+
try {
|
|
5298
|
+
const status = await (sulcusMem as SulcusCloudClient).request("GET", "/api/v1/agent/memory/status") as Record<string, unknown> | null;
|
|
5299
|
+
const hot = await (sulcusMem as SulcusCloudClient).list_hot_nodes(5);
|
|
5300
|
+
const out = {
|
|
5301
|
+
status: "connected",
|
|
5302
|
+
backend: backendMode,
|
|
5303
|
+
namespace,
|
|
5304
|
+
server: serverUrl,
|
|
5305
|
+
autoRecall,
|
|
5306
|
+
autoCapture,
|
|
5307
|
+
...(status?.stats ? { stats: status.stats } : {}),
|
|
5308
|
+
...(status?.capabilities ? { capabilities: status.capabilities } : {}),
|
|
5309
|
+
hot_nodes: (hot.nodes || []).length,
|
|
5310
|
+
};
|
|
5311
|
+
if (opts.json) { console.log(JSON.stringify(out, null, 2)); } else {
|
|
5312
|
+
console.log(`Status: connected \u2705`);
|
|
5313
|
+
console.log(`Backend: ${backendMode}`);
|
|
5314
|
+
console.log(`Namespace: ${namespace}`);
|
|
5315
|
+
console.log(`Server: ${serverUrl}`);
|
|
5316
|
+
console.log(`Auto-recall: ${autoRecall}`);
|
|
5317
|
+
console.log(`Auto-capture: ${autoCapture}`);
|
|
5318
|
+
const stats = status?.stats as Record<string, unknown> | undefined;
|
|
5319
|
+
if (stats?.total_memories !== undefined) console.log(`Memories: ${stats.total_memories}`);
|
|
5320
|
+
if (stats?.average_heat !== undefined) console.log(`Average heat: ${(stats.average_heat as number).toFixed(3)}`);
|
|
5321
|
+
console.log(`Hot nodes: ${(hot.nodes || []).length}`);
|
|
5322
|
+
}
|
|
5323
|
+
} catch (e: unknown) {
|
|
5324
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
5325
|
+
if (opts.json) { console.log(JSON.stringify({ status: "error", error: msg })); }
|
|
5326
|
+
else { console.error(`Error: ${msg}`); }
|
|
5327
|
+
}
|
|
5328
|
+
});
|
|
5329
|
+
|
|
5330
|
+
// --- openclaw sulcus search ---
|
|
5331
|
+
sulcusCmd.command("search <query>")
|
|
5332
|
+
.description("Search memories")
|
|
5333
|
+
.option("-n, --limit <n>", "Max results", "10")
|
|
5334
|
+
.option("--json", "Machine-readable JSON output")
|
|
5335
|
+
.action(async (query: string, opts: { limit: string; json?: boolean }) => {
|
|
5336
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5337
|
+
try {
|
|
5338
|
+
const res = await sulcusMem.search_memory(query, parseInt(opts.limit, 10), namespace);
|
|
5339
|
+
const results = res?.results ?? [];
|
|
5340
|
+
if (opts.json) { console.log(JSON.stringify(results, null, 2)); return; }
|
|
5341
|
+
if (results.length === 0) { console.log("No results."); return; }
|
|
5342
|
+
for (const r of results) {
|
|
5343
|
+
const heat = typeof r.current_heat === "number" ? (r.current_heat * 100).toFixed(0) + "%" : "?";
|
|
5344
|
+
const mtype = (r.memory_type ?? "?") as string;
|
|
5345
|
+
const label = ((r.label ?? r.content ?? "") as string).slice(0, 120);
|
|
5346
|
+
console.log(`[${heat} ${mtype}] ${label}`);
|
|
5347
|
+
console.log(` id: ${r.id}`);
|
|
5348
|
+
}
|
|
5349
|
+
console.log(`\n${results.length} result(s)`);
|
|
5350
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5351
|
+
});
|
|
5352
|
+
|
|
5353
|
+
// --- openclaw sulcus add ---
|
|
5354
|
+
sulcusCmd.command("add <content>")
|
|
5355
|
+
.description("Store a memory")
|
|
5356
|
+
.option("-t, --type <type>", "Memory type", "semantic")
|
|
5357
|
+
.option("--json", "Machine-readable JSON output")
|
|
5358
|
+
.action(async (content: string, opts: { type: string; json?: boolean }) => {
|
|
5359
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5360
|
+
try {
|
|
5361
|
+
const hints = buildExtractionHints(opts.type, namespace, "cli_add", content.substring(0, 200));
|
|
5362
|
+
const res = await sulcusMem.add_memory(content, opts.type, hints);
|
|
5363
|
+
if (opts.json) { console.log(JSON.stringify(res, null, 2)); }
|
|
5364
|
+
else { console.log(`Stored [${opts.type}] memory (id: ${res?.id ?? "?"})`); }
|
|
5365
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5366
|
+
});
|
|
5367
|
+
|
|
5368
|
+
// --- openclaw sulcus get ---
|
|
5369
|
+
sulcusCmd.command("get <id>")
|
|
5370
|
+
.description("Fetch a memory by ID")
|
|
5371
|
+
.option("--json", "Machine-readable JSON output")
|
|
5372
|
+
.action(async (id: string, opts: { json?: boolean }) => {
|
|
5373
|
+
if (!isAvailable || !(sulcusMem instanceof SulcusCloudClient)) { console.error("Sulcus not connected."); return; }
|
|
5374
|
+
try {
|
|
5375
|
+
const res = await sulcusMem.get_memory(id);
|
|
5376
|
+
if (!res) { console.log(`Memory ${id} not found.`); return; }
|
|
5377
|
+
if (opts.json) { console.log(JSON.stringify(res, null, 2)); } else {
|
|
5378
|
+
const heat = typeof res.current_heat === "number" ? ((res.current_heat as number) * 100).toFixed(0) + "%" : "?";
|
|
5379
|
+
console.log(`ID: ${res.id}`);
|
|
5380
|
+
console.log(`Type: ${res.memory_type ?? "?"}`); console.log(`Heat: ${heat}`);
|
|
5381
|
+
console.log(`Pinned: ${res.is_pinned ?? false}`);
|
|
5382
|
+
console.log(`Content: ${((res.label ?? res.content ?? "") as string).slice(0, 500)}`);
|
|
5383
|
+
}
|
|
5384
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5385
|
+
});
|
|
5386
|
+
|
|
5387
|
+
// --- openclaw sulcus list ---
|
|
5388
|
+
sulcusCmd.command("list")
|
|
5389
|
+
.description("List memories")
|
|
5390
|
+
.option("-n, --limit <n>", "Max results", "20")
|
|
5391
|
+
.option("-t, --type <type>", "Filter by memory type")
|
|
5392
|
+
.option("--pinned", "Only pinned memories")
|
|
5393
|
+
.option("--sort <field>", "Sort by: current_heat, created_at, updated_at", "current_heat")
|
|
5394
|
+
.option("--json", "Machine-readable JSON output")
|
|
5395
|
+
.action(async (opts: { limit: string; type?: string; pinned?: boolean; sort: string; json?: boolean }) => {
|
|
5396
|
+
if (!isAvailable || !(sulcusMem instanceof SulcusCloudClient)) { console.error("Sulcus not connected."); return; }
|
|
5397
|
+
try {
|
|
5398
|
+
const res = await sulcusMem.list_memories({
|
|
5399
|
+
page_size: parseInt(opts.limit, 10),
|
|
5400
|
+
memory_type: opts.type,
|
|
5401
|
+
pinned: opts.pinned,
|
|
5402
|
+
sort_by: opts.sort,
|
|
5403
|
+
sort_order: "desc",
|
|
5404
|
+
namespace,
|
|
5405
|
+
});
|
|
5406
|
+
if (opts.json) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
5407
|
+
if (res.items.length === 0) { console.log("No memories."); return; }
|
|
5408
|
+
for (const r of res.items) {
|
|
5409
|
+
const heat = typeof r.current_heat === "number" ? ((r.current_heat as number) * 100).toFixed(0) + "%" : "?";
|
|
5410
|
+
const mtype = (r.memory_type ?? "?") as string;
|
|
5411
|
+
const label = ((r.label ?? r.content ?? "") as string).slice(0, 100);
|
|
5412
|
+
console.log(`[${heat} ${mtype}] ${label}`);
|
|
5413
|
+
console.log(` id: ${r.id}`);
|
|
5414
|
+
}
|
|
5415
|
+
console.log(`\n${res.items.length} shown${res.total ? ` of ${res.total}` : ""}`);
|
|
5416
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5417
|
+
});
|
|
5418
|
+
|
|
5419
|
+
// --- openclaw sulcus update ---
|
|
5420
|
+
sulcusCmd.command("update <id>")
|
|
5421
|
+
.description("Update a memory")
|
|
5422
|
+
.option("-c, --content <text>", "New content")
|
|
5423
|
+
.option("-t, --type <type>", "New memory type")
|
|
5424
|
+
.option("--pin", "Pin the memory")
|
|
5425
|
+
.option("--unpin", "Unpin the memory")
|
|
5426
|
+
.option("--heat <value>", "Set heat (0.0-1.0)")
|
|
5427
|
+
.option("--json", "Machine-readable JSON output")
|
|
5428
|
+
.action(async (id: string, opts: { content?: string; type?: string; pin?: boolean; unpin?: boolean; heat?: string; json?: boolean }) => {
|
|
5429
|
+
if (!isAvailable || !(sulcusMem instanceof SulcusCloudClient)) { console.error("Sulcus not connected."); return; }
|
|
5430
|
+
const updates: Record<string, unknown> = {};
|
|
5431
|
+
if (opts.content) updates.label = opts.content;
|
|
5432
|
+
if (opts.type) updates.memory_type = opts.type;
|
|
5433
|
+
if (opts.pin) updates.is_pinned = true;
|
|
5434
|
+
if (opts.unpin) updates.is_pinned = false;
|
|
5435
|
+
if (opts.heat) updates.current_heat = parseFloat(opts.heat);
|
|
5436
|
+
if (Object.keys(updates).length === 0) { console.error("No fields to update."); return; }
|
|
5437
|
+
try {
|
|
5438
|
+
const res = await sulcusMem.update_memory(id, updates as any);
|
|
5439
|
+
if (opts.json) { console.log(JSON.stringify(res, null, 2)); }
|
|
5440
|
+
else { console.log(`Updated memory ${id} (${Object.keys(updates).join(", ")})`); }
|
|
5441
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5442
|
+
});
|
|
5443
|
+
|
|
5444
|
+
// --- openclaw sulcus delete ---
|
|
5445
|
+
sulcusCmd.command("delete <id>")
|
|
5446
|
+
.description("Delete a memory")
|
|
5447
|
+
.option("--no-train", "Don't train SIVU to reject similar")
|
|
5448
|
+
.option("--json", "Machine-readable JSON output")
|
|
5449
|
+
.action(async (id: string, opts: { train?: boolean; json?: boolean }) => {
|
|
5450
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5451
|
+
try {
|
|
5452
|
+
const train = opts.train !== false;
|
|
5453
|
+
await sulcusMem.delete_memory(id, train);
|
|
5454
|
+
if (opts.json) { console.log(JSON.stringify({ deleted: id, trained: train })); }
|
|
5455
|
+
else { console.log(`Deleted memory ${id}${train ? " (trained SIVU)" : ""}`); }
|
|
5456
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5457
|
+
});
|
|
5458
|
+
|
|
5459
|
+
// --- openclaw sulcus export ---
|
|
5460
|
+
sulcusCmd.command("export")
|
|
5461
|
+
.description("Export all memories as Markdown")
|
|
5462
|
+
.action(async () => {
|
|
5463
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5464
|
+
try {
|
|
5465
|
+
const md = await sulcusMem.export_markdown();
|
|
5466
|
+
console.log(md);
|
|
5467
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5468
|
+
});
|
|
5469
|
+
|
|
5470
|
+
// --- openclaw sulcus import ---
|
|
5471
|
+
sulcusCmd.command("import <file>")
|
|
5472
|
+
.description("Import memories from a Markdown file")
|
|
5473
|
+
.action(async (file: string) => {
|
|
5474
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5475
|
+
try {
|
|
5476
|
+
const { readFileSync } = require("fs") as { readFileSync: (p: string, e: string) => string };
|
|
5477
|
+
const text = readFileSync(file, "utf-8");
|
|
5478
|
+
const res = await sulcusMem.import_markdown(text);
|
|
5479
|
+
console.log(JSON.stringify(res, null, 2));
|
|
5480
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5481
|
+
});
|
|
5482
|
+
|
|
5483
|
+
// --- openclaw sulcus consolidate ---
|
|
5484
|
+
sulcusCmd.command("consolidate")
|
|
5485
|
+
.description("Run dream/consolidation on cold memories")
|
|
5486
|
+
.option("--min-heat <value>", "Heat threshold (0.0-1.0)", "0.1")
|
|
5487
|
+
.option("--json", "Machine-readable JSON output")
|
|
5488
|
+
.action(async (opts: { minHeat: string; json?: boolean }) => {
|
|
5489
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5490
|
+
try {
|
|
5491
|
+
const res = await sulcusMem.consolidate(parseFloat(opts.minHeat));
|
|
5492
|
+
if (opts.json) { console.log(JSON.stringify(res, null, 2)); }
|
|
5493
|
+
else { console.log("Consolidation complete."); console.log(JSON.stringify(res, null, 2)); }
|
|
5494
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5495
|
+
});
|
|
5496
|
+
|
|
5497
|
+
// --- openclaw sulcus hot ---
|
|
5498
|
+
sulcusCmd.command("hot")
|
|
5499
|
+
.description("Show hottest memories")
|
|
5500
|
+
.option("-n, --limit <n>", "Max results", "10")
|
|
5501
|
+
.option("--json", "Machine-readable JSON output")
|
|
5502
|
+
.action(async (opts: { limit: string; json?: boolean }) => {
|
|
5503
|
+
if (!isAvailable || !sulcusMem) { console.error("Sulcus not connected."); return; }
|
|
5504
|
+
try {
|
|
5505
|
+
const res = await sulcusMem.list_hot_nodes(parseInt(opts.limit, 10));
|
|
5506
|
+
const nodes = res?.nodes ?? [];
|
|
5507
|
+
if (opts.json) { console.log(JSON.stringify(nodes, null, 2)); return; }
|
|
5508
|
+
if (nodes.length === 0) { console.log("No hot nodes."); return; }
|
|
5509
|
+
for (const n of nodes) {
|
|
5510
|
+
const heat = typeof n.current_heat === "number" ? ((n.current_heat as number) * 100).toFixed(0) + "%" : "?";
|
|
5511
|
+
const label = ((n.label ?? n.pointer_summary ?? "") as string).slice(0, 100);
|
|
5512
|
+
console.log(`[${heat}] ${label}`);
|
|
5513
|
+
}
|
|
5514
|
+
} catch (e: unknown) { console.error(`Error: ${e instanceof Error ? e.message : e}`); }
|
|
5515
|
+
});
|
|
5516
|
+
|
|
5517
|
+
logger.info("sulcus: registered CLI commands (openclaw sulcus <cmd>)");
|
|
5518
|
+
}, {
|
|
5519
|
+
commands: ["sulcus"],
|
|
5520
|
+
descriptors: [{
|
|
5521
|
+
name: "sulcus",
|
|
5522
|
+
description: "Sulcus memory management \u2014 status, search, add, get, list, update, delete, export, import, consolidate, hot",
|
|
5523
|
+
hasSubcommands: true,
|
|
5524
|
+
}],
|
|
5525
|
+
});
|
|
5526
|
+
} else {
|
|
5527
|
+
logger.info("sulcus: registerCli not available \u2014 CLI commands skipped");
|
|
5528
|
+
}
|
|
5529
|
+
|
|
1626
5530
|
// Fire-and-forget first-install history import
|
|
1627
5531
|
if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
|
|
1628
5532
|
importOpenClawHistory(sulcusMem, logger).catch((e: unknown) => {
|