@digitalforgestudios/openclaw-sulcus 5.3.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/index.ts CHANGED
@@ -1,44 +1,205 @@
1
+ // @ts-nocheck
1
2
  import { resolve } from "node:path";
2
- import { existsSync } 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
- // ─── STATIC AWARENESS ───────────────────────────────────────────────────────
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 buildStaticAwareness(backendMode: string, namespace: string): string {
11
- return `## Persistent Memory (Sulcus)
12
- You have Sulcus — a persistent, reactive, thermodynamic memory system with reactive triggers.
13
- Memories survive across sessions. They have heat (0.0–1.0) that decays over time.
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
- **Connection:** Backend: ${backendMode} | Namespace: ${namespace}
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
- **Your memory tools:**
18
- - \`memory_store\` Save important information (preferences, facts, procedures, decisions, lessons)
19
- Parameters: content, memory_type (episodic|semantic|preference|procedural|fact)
20
- - \`memory_recall\` Search memories semantically. Use before answering about past work, decisions, or people.
21
- Parameters: query, limit
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
- **When to store:** User states a preference, important decision made, correction given, lesson learned, anything worth surviving this session.
24
- **When to search:** Questions about prior work/decisions, context seems incomplete, user references past conversations.
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
- **Memory types:** episodic (events, fast decay) · semantic (knowledge, slow) · preference (opinions, slower) · procedural (how-tos, slowest) · fact (data, slow)`;
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
- const FALLBACK_AWARENESS = `<sulcus_context token_budget="500">
32
- <cheatsheet>
33
- You have Sulcus — persistent memory with reactive triggers.
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
- // ─── HOOKS CONFIG TYPES ──────────────────────────────────────────────────────
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
- // ─── HOOK HANDLERS ───────────────────────────────────────────────────────────
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 prompt = typeof event?.prompt === "string" ? event.prompt : "";
96
- if (!prompt) return;
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
- logger.debug?.(`sulcus: searching context for prompt: ${prompt.substring(0, 50)}... (namespace: ${namespace})`);
100
- const res = await sulcusMem.search_memory(prompt, limit, namespace);
101
- const results = res?.results ?? [];
102
- if (!results || results.length === 0) {
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
- const items = results.map((r: Record<string, unknown>) => {
106
- const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0).toFixed(2);
107
- const mtype = (r.memory_type as string) ?? "unknown";
108
- const label = (r.label as string) ?? (r.pointer_summary as string) ?? "";
109
- return ` <memory id="${r.id}" heat="${heat}" type="${mtype}">${label}</memory>`;
110
- }).join("\n");
111
- const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${items}\n</sulcus_context>`;
112
- logger.info(`sulcus: injecting ${results.length} recalled memories (${context.length} chars)`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
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 res = await sulcusMem.add_memory(userMessage, memoryType);
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 res = await sulcusMem.add_memory(userMessage, "episodic");
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 res = await sulcusMem.add_memory(memoryContent, "episodic");
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
- try {
285
- const res = await sulcusMem.add_memory(summary, "episodic");
286
- logger.info(`sulcus: pre_compaction_capture stored session summary (id: ${res?.id ?? "?"})`);
287
- } catch (e: unknown) {
288
- const msg = e instanceof Error ? e.message : String(e);
289
- logger.debug?.(`sulcus: pre_compaction_capture — store failed: ${msg}`);
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
- // ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
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
- request(method: string, path: string, body?: unknown): Promise<unknown> {
306
- return new Promise((resolveP, rejectP) => {
307
- let parsedUrl: URL;
308
- try {
309
- parsedUrl = new URL(this.serverUrl + path);
310
- } catch (e: unknown) {
311
- const msg = e instanceof Error ? e.message : String(e);
312
- return rejectP(new Error(`SulcusCloudClient: invalid URL ${this.serverUrl}${path}: ${msg}`));
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
- return rejectP(new Error(`SulcusCloudClient: HTTP ${res.statusCode} for ${method} ${path}: ${raw.substring(0, 200)}`));
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 Error(`SulcusCloudClient: network error for ${method} ${path}: ${e.message}`)));
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
- init(logger: PluginLogger): void {
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
- // eslint-disable-next-line @typescript-eslint/no-require-imports
468
- this.koffi = require("koffi");
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
- this.error = `koffi not available: ${e instanceof Error ? e.message : e}`;
471
- logger.warn(`sulcus: ${this.error}`);
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
- // ─── PRE-SEND FILTER ─────────────────────────────────────────────────────────
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
- /tool_call|function_call|<function_calls>/i,
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
- // ─── CAPTURE DEDUP ───────────────────────────────────────────────────────────
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
- // ─── HOOKS CONFIG LOADER ─────────────────────────────────────────────────────
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
- // ─── RELATIVE TIME FORMATTER ─────────────────────────────────────────────────
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
- // ─── SDK RECALL HANDLER (for before_prompt_build with prependContext) ──────────
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 prompt = typeof event?.prompt === "string" ? event.prompt : "";
713
- if (!prompt || prompt.length < 5) return undefined;
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
- const searchRes = await sulcusMem.search_memory(prompt, maxResults, namespace);
720
- const searchResults = searchRes?.results ?? [];
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(maxResults, 5), namespace);
728
- const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(maxResults, 5), namespace);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
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
- if (includeProfile && (preferences.length > 0 || facts.length > 0)) {
749
- const profileLines: string[] = [];
750
- for (const r of preferences) {
751
- const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
752
- profileLines.push(`- [preference] ${label}`);
753
- }
754
- for (const r of facts) {
755
- const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
756
- profileLines.push(`- [fact] ${label}`);
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>`);
757
3062
  }
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 (dedupedSearch.length > 0) {
764
- const memLines = dedupedSearch.slice(0, maxResults).map((r) => {
765
- const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0);
766
- const pct = `[${Math.round(heat * 100)}%]`;
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 timeStr = updatedAt ? `[${formatRelativeTime(updatedAt)}]` : "";
769
- const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
770
- return `- ${pct} ${timeStr} ${label}`.trim();
771
- });
772
- sections.push(`## Relevant Memories (with relevance %)\n${memLines.join("\n")}`);
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 intro =
778
- "The following is background context from long-term memory. Use it silently to inform your understanding — only reference it when the conversation naturally calls for it.";
779
- const context = `<sulcus_context token_budget="500" namespace="${namespace}">\n${intro}\n\n${sections.join("\n\n")}\n</sulcus_context>`;
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
- // ─── MEMORY RUNTIME BUILDER ───────────────────────────────────────────────────
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
- // ─── PROMPT SECTION BUILDER ───────────────────────────────────────────────────
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
- // ─── TOOL DEFINITIONS ────────────────────────────────────────────────────────
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
- const res = await sulcusMem.add_memory(content, mtype);
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
- // ─── FIRST-INSTALL HISTORY IMPORT ────────────────────────────────────────────
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
- // ─── PLUGIN ──────────────────────────────────────────────────────────────────
4239
+ // --- PLUGIN ------------------------------------------------------------------
1258
4240
 
1259
4241
  const sulcusPlugin = {
1260
4242
  id: "openclaw-sulcus",
@@ -1264,13 +4246,32 @@ const sulcusPlugin = {
1264
4246
 
1265
4247
  register(api: Record<string, unknown>) {
1266
4248
  const logger = api.logger as PluginLogger;
1267
- const pluginConfig = (api.pluginConfig ?? {}) as Record<string, unknown>;
4249
+ const rawPluginConfig = (api.pluginConfig ?? {}) as Record<string, unknown>;
1268
4250
 
1269
- // ── Configuration ──
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);
4258
+
4259
+ // -- Configuration --
1270
4260
  const libDir = pluginConfig?.libDir
1271
4261
  ? resolve(pluginConfig.libDir as string)
1272
4262
  : resolve(process.env.HOME || "~", ".sulcus/lib");
1273
4263
 
4264
+ // Auto-create directories on first run (self-healing)
4265
+ const dataDir = resolve(process.env.HOME || "~", ".sulcus/data");
4266
+ for (const dir of [libDir, dataDir]) {
4267
+ if (!existsSync(dir)) {
4268
+ try {
4269
+ mkdirSync(dir, { recursive: true });
4270
+ logger.info(`sulcus: created directory ${dir}`);
4271
+ } catch { /* best effort — may be read-only in containers */ }
4272
+ }
4273
+ }
4274
+
1274
4275
  const storeLibPath = pluginConfig?.storeLibPath
1275
4276
  ? resolve(pluginConfig.storeLibPath as string)
1276
4277
  : resolve(libDir, process.platform === "darwin" ? "libsulcus_store.dylib" : "libsulcus_store.so");
@@ -1296,14 +4297,28 @@ const sulcusPlugin = {
1296
4297
  const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
1297
4298
  const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
1298
4299
  const profileFrequency: number = Math.min(500, Math.max(1, (pluginConfig?.profileFrequency as number | undefined) ?? 10));
1299
-
1300
- // ── Load hooks config ──
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 --
1301
4317
  const hooksConfig = loadHooksConfig(pluginConfig);
1302
4318
 
1303
- // ── Backend init ──
4319
+ // -- Backend init --
1304
4320
  let sulcusMem: SulcusCloudClient | null = null;
1305
4321
  let backendMode = "unavailable";
1306
- const nativeLoader = new NativeLibLoader(storeLibPath, vectorsLibPath);
1307
4322
 
1308
4323
  if (serverUrl && apiKey) {
1309
4324
  try {
@@ -1315,7 +4330,11 @@ const sulcusPlugin = {
1315
4330
  }
1316
4331
  }
1317
4332
 
1318
- if (sulcusMem === null) {
4333
+ // Only attempt native/WASM fallback if cloud mode was NOT configured or failed.
4334
+ // When serverUrl+apiKey are set, the user intends cloud mode — don't warn about
4335
+ // missing native libs that they never intended to use.
4336
+ const nativeLoader = new NativeLibLoader(storeLibPath, vectorsLibPath);
4337
+ if (sulcusMem === null && !(serverUrl && apiKey)) {
1319
4338
  nativeLoader.init(logger);
1320
4339
  if (nativeLoader.loaded) {
1321
4340
  const wasmJsPath = resolve(wasmDir, "sulcus_wasm.js");
@@ -1344,24 +4363,38 @@ const sulcusPlugin = {
1344
4363
  // Update static awareness with runtime info
1345
4364
  STATIC_AWARENESS = buildStaticAwareness(backendMode, namespace);
1346
4365
 
1347
- // ── Startup summary ──
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 --
1348
4371
  if (isAvailable) {
1349
- logger.info(`sulcus: registered (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})`);
1350
4373
  } else {
4374
+ // Give clear, actionable guidance instead of cryptic error chains
1351
4375
  const hints: string[] = [];
1352
- if (!serverUrl && !apiKey) hints.push("no serverUrl/apiKey for cloud mode");
1353
- else if (serverUrl && !apiKey) hints.push("serverUrl set but apiKey missing");
1354
- else if (!serverUrl && apiKey) hints.push("apiKey set but serverUrl missing");
1355
- if (nativeLoader.error) hints.push(`local: ${nativeLoader.error}`);
1356
- logger.warn(`sulcus: unavailable${hints.join("; ") || "unknown reason"}`);
4376
+ if (!serverUrl && !apiKey) {
4377
+ hints.push("To use Sulcus cloud: set serverUrl and apiKey in plugin config");
4378
+ hints.push("Get an API key at https://sulcus.ca/dashboard/settings");
4379
+ } else if (serverUrl && !apiKey) {
4380
+ hints.push("serverUrl is set but apiKey is missing add your API key to plugin config");
4381
+ } else if (!serverUrl && apiKey) {
4382
+ hints.push("apiKey is set but serverUrl is missing — add serverUrl (e.g. https://api.sulcus.ca)");
4383
+ } else {
4384
+ hints.push("Cloud connection failed — check serverUrl and apiKey are correct");
4385
+ }
4386
+ if (!serverUrl && !apiKey && nativeLoader.error) {
4387
+ hints.push(`Local mode: ${nativeLoader.error}`);
4388
+ }
4389
+ logger.warn(`sulcus: not ready — ${hints.join(". ")}`);
1357
4390
  }
1358
4391
 
1359
- // ── SIU v2 request helper ──
4392
+ // -- SIU v2 request helper --
1360
4393
  const siuRequestFn = isCloudBackend && sulcusMem
1361
4394
  ? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1362
4395
  : null;
1363
4396
 
1364
- // ── Shared deps ──
4397
+ // -- Shared deps --
1365
4398
  const toolDeps: ToolDeps = {
1366
4399
  sulcusMem,
1367
4400
  backendMode,
@@ -1384,11 +4417,15 @@ const sulcusPlugin = {
1384
4417
  storeLibPath,
1385
4418
  vectorsLibPath,
1386
4419
  wasmDir,
4420
+ boostOnRecall: boostOnRecallEnabled,
4421
+ profileFrequency,
4422
+ tokenBudget,
4423
+ contextWindowSize,
1387
4424
  };
1388
4425
 
1389
- // ─────────────────────────────────────────────────────────────────────────
4426
+ // -------------------------------------------------------------------------
1390
4427
  // SDK INTEGRATIONS (v4.0.0)
1391
- // ─────────────────────────────────────────────────────────────────────────
4428
+ // -------------------------------------------------------------------------
1392
4429
 
1393
4430
  // 1. registerMemoryRuntime — Sulcus becomes the OpenClaw memory backend
1394
4431
  if (isCloudBackend && sulcusMem && typeof (api.registerMemoryRuntime as unknown) === "function") {
@@ -1466,7 +4503,11 @@ const sulcusPlugin = {
1466
4503
  namespace,
1467
4504
  maxRecallResults,
1468
4505
  profileFrequency,
1469
- logger
4506
+ logger,
4507
+ boostOnRecallEnabled,
4508
+ tokenBudget,
4509
+ contextRebuildEnabled,
4510
+ contextWindowSize,
1470
4511
  );
1471
4512
  const apiOn = api.on as (event: string, handler: unknown) => void;
1472
4513
  apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
@@ -1533,7 +4574,10 @@ const sulcusPlugin = {
1533
4574
  const agentEndCaptureConfig: HookConfig = {
1534
4575
  action: "sivu_auto_capture",
1535
4576
  enabled: true,
1536
- min_store_confidence: 0.5,
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,
1537
4581
  fallback_on_error: true,
1538
4582
  };
1539
4583
  const apiOn = api.on as (event: string, handler: unknown) => void;
@@ -1548,9 +4592,635 @@ const sulcusPlugin = {
1548
4592
  logger.info("sulcus: registered auto-capture (agent_end)");
1549
4593
  }
1550
4594
 
1551
- // ─────────────────────────────────────────────────────────────────────────
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
+ // -------------------------------------------------------------------------
1552
5222
  // LEGACY HOOK REGISTRATION (config-driven, backward compat)
1553
- // ─────────────────────────────────────────────────────────────────────────
5223
+ // -------------------------------------------------------------------------
1554
5224
 
1555
5225
  for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
1556
5226
  if (!hookConfig.enabled) continue;
@@ -1578,9 +5248,9 @@ const sulcusPlugin = {
1578
5248
  }
1579
5249
  }
1580
5250
 
1581
- // ─────────────────────────────────────────────────────────────────────────
5251
+ // -------------------------------------------------------------------------
1582
5252
  // TOOL REGISTRATION
1583
- // ─────────────────────────────────────────────────────────────────────────
5253
+ // -------------------------------------------------------------------------
1584
5254
 
1585
5255
  for (const [toolName, toolConfig] of Object.entries(hooksConfig.tools)) {
1586
5256
  if (!toolConfig.enabled) continue;
@@ -1599,6 +5269,264 @@ const sulcusPlugin = {
1599
5269
  }
1600
5270
  }
1601
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
+
1602
5530
  // Fire-and-forget first-install history import
1603
5531
  if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
1604
5532
  importOpenClawHistory(sulcusMem, logger).catch((e: unknown) => {