@digitalforgestudios/openclaw-sulcus 4.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -144,6 +144,11 @@ const hookHandlers: Record<string, HookHandler> = {
144
144
  return;
145
145
  }
146
146
 
147
+ if (!shouldCapture(userMessage)) {
148
+ logger.debug?.("sulcus: sivu_auto_capture — dedup skip");
149
+ return;
150
+ }
151
+
147
152
  const minConfidence = (config.min_store_confidence as number) ?? 0.5;
148
153
  const fallbackOnError = config.fallback_on_error !== false;
149
154
 
@@ -179,8 +184,122 @@ const hookHandlers: Record<string, HookHandler> = {
179
184
  logger.warn(`sulcus: sivu_auto_capture — fallback store failed: ${msg}`);
180
185
  }
181
186
  },
187
+
188
+ /**
189
+ * auto_error_capture — stores tool errors as episodic memories with boosted heat.
190
+ *
191
+ * Fires on after_tool_call when a tool returns an error.
192
+ * Stores the error context so the agent learns from past failures.
193
+ * Skips errors from Sulcus's own tools to avoid self-referential loops.
194
+ */
195
+ auto_error_capture: async (event: any, _config: HookConfig, ctx: HookHandlerCtx) => {
196
+ const { sulcusMem, logger } = ctx;
197
+ const errorText = event?.error?.trim?.();
198
+ if (!errorText || !sulcusMem) return; // No error or no backend — nothing to capture
199
+
200
+ const toolName = event?.toolName ?? event?.tool_name ?? "unknown";
201
+
202
+ // Skip errors from our own tools to prevent capture loops
203
+ if (typeof toolName === "string" && (
204
+ toolName.startsWith("memory_") ||
205
+ toolName.startsWith("sulcus_") ||
206
+ toolName === "consolidate" ||
207
+ toolName === "evaluate_triggers" ||
208
+ toolName === "export_markdown" ||
209
+ toolName === "import_markdown" ||
210
+ toolName === "siu_label" ||
211
+ toolName === "siu_retrain"
212
+ )) {
213
+ return;
214
+ }
215
+
216
+ // Normalize + truncate error text
217
+ const normalized = errorText.replace(/\s+/g, " ").trim();
218
+ const truncated = normalized.length > 500 ? normalized.slice(0, 500) + " [truncated]" : normalized;
219
+ const memoryContent = `Tool '${toolName}' failed: ${truncated}`;
220
+
221
+ try {
222
+ const res = await sulcusMem.add_memory(memoryContent, "episodic");
223
+ // Boost heat so error memories persist longer — failures are high-value learnings
224
+ if (res?.id && sulcusMem instanceof SulcusCloudClient) {
225
+ await sulcusMem.request("PATCH", `/api/v1/agent/memory/${res.id}`, {
226
+ current_heat: 0.8,
227
+ }).catch(() => {}); // best-effort boost
228
+ }
229
+ logger.info(`sulcus: auto_error_capture — stored tool error [episodic] (id: ${res?.id ?? "?"}): "${memoryContent.substring(0, 80)}..."`);
230
+ } catch (e: unknown) {
231
+ const msg = e instanceof Error ? e.message : String(e);
232
+ logger.debug?.(`sulcus: auto_error_capture — failed to store: ${msg}`);
233
+ }
234
+ },
235
+
236
+ pre_compaction_capture: async (event: Record<string, unknown>, config: HookConfig, ctx: HookHandlerCtx) => {
237
+ const { sulcusMem, logger } = ctx;
238
+ if (!sulcusMem) return;
239
+
240
+ const messages = Array.isArray(event?.messages) ? event.messages as Record<string, unknown>[] : [];
241
+ if (messages.length === 0) return;
242
+
243
+ const { decisions, preferences, facts, summary } = extractConversationInsights(messages);
244
+
245
+ const maxPerType = (config.maxCapturePerTurn as number) ?? 3;
246
+ const maxTotal = Math.min(maxPerType * 3, 9);
247
+
248
+ const stores: Promise<unknown>[] = [];
249
+ let dCount = 0, pCount = 0, fCount = 0;
250
+
251
+ for (const decision of decisions.slice(0, Math.min(3, maxPerType))) {
252
+ if (stores.length >= maxTotal) break;
253
+ if (!shouldCapture(decision)) continue;
254
+ stores.push(sulcusMem.add_memory(decision, "procedural").catch(() => {}));
255
+ dCount++;
256
+ }
257
+
258
+ for (const pref of preferences.slice(0, Math.min(3, maxPerType))) {
259
+ if (stores.length >= maxTotal) break;
260
+ if (!shouldCapture(pref)) continue;
261
+ stores.push(sulcusMem.add_memory(pref, "preference").catch(() => {}));
262
+ pCount++;
263
+ }
264
+
265
+ for (const fact of facts.slice(0, Math.min(3, maxPerType))) {
266
+ if (stores.length >= maxTotal) break;
267
+ if (!shouldCapture(fact)) continue;
268
+ stores.push(sulcusMem.add_memory(fact, "fact").catch(() => {}));
269
+ fCount++;
270
+ }
271
+
272
+ if (shouldCapture(summary)) {
273
+ stores.push(sulcusMem.add_memory(summary, "episodic").catch(() => {}));
274
+ }
275
+
276
+ Promise.allSettled(stores);
277
+
278
+ logger.info(`sulcus: compaction mining — ${dCount} decisions, ${pCount} preferences, ${fCount} facts, 1 summary`);
279
+ },
182
280
  };
183
281
 
282
+ // ─── ENTITY CONTEXT TYPES ────────────────────────────────────────────────────
283
+
284
+ interface EntityContextMemory {
285
+ id: string;
286
+ pointer_summary: string;
287
+ memory_type: string;
288
+ current_heat: number;
289
+ }
290
+
291
+ interface EntityConnection {
292
+ name: string;
293
+ relationship: string;
294
+ }
295
+
296
+ interface EntityContextResult {
297
+ name: string;
298
+ type: string;
299
+ related_memories: EntityContextMemory[];
300
+ connections: EntityConnection[];
301
+ }
302
+
184
303
  // ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
185
304
 
186
305
  class SulcusCloudClient {
@@ -255,9 +374,36 @@ class SulcusCloudClient {
255
374
  return { results };
256
375
  }
257
376
 
258
- async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: unknown }> {
377
+ async hot_context(limit?: number, namespace?: string): Promise<{ results: Record<string, unknown>[] }> {
378
+ const body: Record<string, unknown> = {};
379
+ if (limit !== undefined) body.limit = limit;
380
+ if (namespace !== undefined) body.namespace = namespace;
381
+ const res = await this.request("POST", "/api/v1/agent/hot-context", body) as Record<string, unknown> | null;
382
+ const results = (res?.results ?? res?.items ?? res?.nodes ?? (Array.isArray(res) ? res : [])) as Record<string, unknown>[];
383
+ return { results };
384
+ }
385
+
386
+ async entity_context(entityNames: string[], namespace?: string, limit?: number): Promise<{ entities: EntityContextResult[] }> {
387
+ const body: Record<string, unknown> = { entity_names: entityNames };
388
+ if (namespace !== undefined) body.namespace = namespace;
389
+ if (limit !== undefined) body.limit = limit;
390
+ const res = await this.request("POST", "/api/v1/agent/entity-context", body) as Record<string, unknown> | null;
391
+ const entities = (res?.entities ?? (Array.isArray(res) ? res : [])) as EntityContextResult[];
392
+ return { entities };
393
+ }
394
+
395
+ async cross_namespace_search(query: string, targetNamespace: string, limit?: number): Promise<{ results: Record<string, unknown>[] }> {
396
+ const body: Record<string, unknown> = { query, namespace: targetNamespace };
397
+ if (limit !== undefined) body.limit = limit;
398
+ const res = await this.request("POST", "/api/v1/agent/search", body) as Record<string, unknown> | null;
399
+ const results = (res?.results ?? res?.items ?? res?.nodes ?? (Array.isArray(res) ? res : [])) as Record<string, unknown>[];
400
+ return { results };
401
+ }
402
+
403
+ async add_memory(content: string, memoryType?: string | null, namespace?: string): Promise<{ id: string; [key: string]: unknown }> {
259
404
  const body: Record<string, unknown> = { label: content };
260
405
  if (memoryType) body.memory_type = memoryType;
406
+ if (namespace) body.namespace = namespace;
261
407
  const res = await this.request("POST", "/api/v1/agent/nodes", body) as Record<string, unknown> | null;
262
408
  return (res ?? { id: "unknown" }) as { id: string; [key: string]: unknown };
263
409
  }
@@ -291,6 +437,21 @@ class SulcusCloudClient {
291
437
  return this.request("POST", "/api/v1/agent/import", { format: "markdown", content: text });
292
438
  }
293
439
 
440
+ async evaluate_output(
441
+ output: string,
442
+ context?: { prompt_summary?: string; agent_label?: string; model?: string },
443
+ ): Promise<{
444
+ alignment: { score: number; status: string; issues: unknown[]; corrections: unknown[] };
445
+ meta: { memories_checked: number; evaluation_ms: number; model: string };
446
+ }> {
447
+ const body: Record<string, unknown> = { output };
448
+ if (context) body.context = context;
449
+ return this.request("POST", "/api/v1/agent/evaluate-output", body) as Promise<{
450
+ alignment: { score: number; status: string; issues: unknown[]; corrections: unknown[] };
451
+ meta: { memories_checked: number; evaluation_ms: number; model: string };
452
+ }>;
453
+ }
454
+
294
455
  async evaluate_triggers(event: unknown, contextJson?: string): Promise<unknown> {
295
456
  const body: Record<string, unknown> = { event };
296
457
  if (contextJson) {
@@ -461,6 +622,148 @@ function isJunkMemory(text: string): boolean {
461
622
  return false;
462
623
  }
463
624
 
625
+ // ─── CAPTURE DEDUP ───────────────────────────────────────────────────────────
626
+
627
+ const captureDedup = new Map<string, number>();
628
+ const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
629
+
630
+ function shouldCapture(content: string): boolean {
631
+ const key = content.substring(0, 120) + "|" + content.length;
632
+ const now = Date.now();
633
+ for (const [k, ts] of captureDedup.entries()) {
634
+ if (now - ts > DEDUP_WINDOW_MS) captureDedup.delete(k);
635
+ }
636
+ if (captureDedup.has(key)) return false;
637
+ captureDedup.set(key, now);
638
+ return true;
639
+ }
640
+
641
+ // ─── CONVERSATION INSIGHTS EXTRACTOR ─────────────────────────────────────────
642
+
643
+ interface ConversationInsights {
644
+ decisions: string[];
645
+ preferences: string[];
646
+ facts: string[];
647
+ summary: string;
648
+ }
649
+
650
+ const DECISION_RE = /\b(decided to|going with|let'?s use|we'?ll go with|the plan is|agreed on)\b/i;
651
+ const PREFERENCE_RE = /\b(actually[,\s]|no,\s|I prefer|don'?t use|always use\b|never\b|that'?s wrong|I meant)\b/i;
652
+ const FACT_RE = /\bhttps?:\/\/\S+|\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|port\s+\d{2,5}|v\d+\.\d+(?:\.\d+)?\b|version\s+\d|\bendpoint[s]?[:\s]+\S+|\bservice[:\s]+\S+/i;
653
+ const CREDENTIAL_VALUE_RE = /\b(?:sk-[a-f0-9]{40,}|[A-Za-z0-9+/]{40,}={0,2})\b|(?:api[_-]?key|secret|password|token|auth)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}/i;
654
+
655
+ function extractMsgText(msg: Record<string, unknown>): string {
656
+ const content = msg.content ?? msg.text;
657
+ if (typeof content === "string") return content;
658
+ if (Array.isArray(content)) {
659
+ return (content as Record<string, unknown>[])
660
+ .map((c) => (typeof c.text === "string" ? c.text : ""))
661
+ .join(" ");
662
+ }
663
+ return "";
664
+ }
665
+
666
+ function extractConversationInsights(messages: Record<string, unknown>[]): ConversationInsights {
667
+ const decisions: string[] = [];
668
+ const preferences: string[] = [];
669
+ const facts: string[] = [];
670
+ const seenDecisions = new Set<string>();
671
+ const seenPreferences = new Set<string>();
672
+ const seenFacts = new Set<string>();
673
+
674
+ let firstTimestamp: string | undefined;
675
+ let lastTimestamp: string | undefined;
676
+ const topicWords: string[] = [];
677
+
678
+ const TOPIC_STOP = new Set(["would", "could", "should", "there", "their", "about", "which", "where", "doing", "using", "while", "before", "after", "these", "those", "being", "other", "every", "under", "until"]);
679
+
680
+ const trunc = (s: string): string => s.length > 300 ? s.slice(0, 297) + "..." : s;
681
+
682
+ for (const msg of messages) {
683
+ const role = (msg.role ?? msg.type) as string | undefined;
684
+ const isUser = role === "user" || role === "human";
685
+ const isAssistant = role === "assistant" || role === "ai";
686
+ if (!isUser && !isAssistant) continue;
687
+
688
+ const ts = (msg.created_at ?? msg.timestamp ?? msg.ts) as string | undefined;
689
+ if (ts && !firstTimestamp) firstTimestamp = ts;
690
+ if (ts) lastTimestamp = ts;
691
+
692
+ const text = extractMsgText(msg);
693
+ if (!text) continue;
694
+
695
+ for (const line of text.split(/\n/)) {
696
+ const trimmed = line.trim();
697
+ if (!trimmed || trimmed.length < 10) continue;
698
+ if (CREDENTIAL_VALUE_RE.test(trimmed)) continue;
699
+
700
+ if (decisions.length < 3 && DECISION_RE.test(trimmed)) {
701
+ const key = trimmed.slice(0, 60).toLowerCase();
702
+ if (!seenDecisions.has(key)) {
703
+ seenDecisions.add(key);
704
+ decisions.push(trunc(`Decision: ${trimmed}`));
705
+ }
706
+ }
707
+
708
+ if (preferences.length < 3 && isUser && PREFERENCE_RE.test(trimmed)) {
709
+ const key = trimmed.slice(0, 60).toLowerCase();
710
+ if (!seenPreferences.has(key)) {
711
+ seenPreferences.add(key);
712
+ preferences.push(trunc(`User preference: ${trimmed}`));
713
+ }
714
+ }
715
+
716
+ if (facts.length < 3 && FACT_RE.test(trimmed)) {
717
+ const key = trimmed.slice(0, 60).toLowerCase();
718
+ if (!seenFacts.has(key)) {
719
+ seenFacts.add(key);
720
+ facts.push(trunc(trimmed));
721
+ }
722
+ }
723
+ }
724
+
725
+ if (isUser && topicWords.length < 8) {
726
+ const words = text.match(/\b[a-zA-Z]{5,}\b/g) ?? [];
727
+ for (const w of words) {
728
+ const wl = w.toLowerCase();
729
+ if (!TOPIC_STOP.has(wl) && !topicWords.includes(wl)) {
730
+ topicWords.push(wl);
731
+ if (topicWords.length >= 8) break;
732
+ }
733
+ }
734
+ }
735
+ }
736
+
737
+ const firstUser = messages.find((m) => m.role === "user" || m.type === "human");
738
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant" || m.type === "ai");
739
+ const firstUserText = extractMsgText(firstUser ?? {}).substring(0, 200) || "(none)";
740
+ const lastAssistantText = extractMsgText(lastAssistant ?? {}).substring(0, 200) || "(none)";
741
+
742
+ const filesModified: string[] = [];
743
+ for (const msg of messages) {
744
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as Record<string, unknown>[] : [];
745
+ for (const tc of toolCalls) {
746
+ const name = (tc.name ?? tc.function) as string | undefined;
747
+ if (name === "Write" || name === "Edit" || name === "write" || name === "edit") {
748
+ const input = (tc.input ?? tc.arguments ?? {}) as Record<string, unknown>;
749
+ const fp = input?.file_path ?? input?.path;
750
+ if (fp && typeof fp === "string" && !filesModified.includes(fp)) filesModified.push(fp);
751
+ }
752
+ }
753
+ }
754
+
755
+ const summaryParts = [`Session compaction — ${messages.length} messages`];
756
+ if (firstTimestamp || lastTimestamp) {
757
+ summaryParts.push(`Time range: ${[firstTimestamp, lastTimestamp].filter(Boolean).join(" → ")}`);
758
+ }
759
+ if (topicWords.length > 0) summaryParts.push(`Topics: ${topicWords.join(", ")}`);
760
+ summaryParts.push(`First user message: ${firstUserText}`);
761
+ summaryParts.push(`Last assistant message: ${lastAssistantText}`);
762
+ if (filesModified.length > 0) summaryParts.push(`Files modified: ${filesModified.join(", ")}`);
763
+
764
+ return { decisions, preferences, facts, summary: summaryParts.join("\n") };
765
+ }
766
+
464
767
  // ─── HOOKS CONFIG LOADER ─────────────────────────────────────────────────────
465
768
 
466
769
  function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
@@ -476,6 +779,8 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
476
779
  before_prompt_build: { action: "inject_awareness", enabled: true },
477
780
  before_agent_start: { action: "auto_recall", enabled: false, limit: 5, minScore: 0.3 },
478
781
  agent_end: { action: "none", enabled: true },
782
+ after_tool_call: { action: "auto_error_capture", enabled: true },
783
+ before_compaction: { action: "pre_compaction_capture", enabled: true },
479
784
  },
480
785
  tools: {
481
786
  memory_recall: { enabled: true },
@@ -485,6 +790,9 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
485
790
  export_markdown: { enabled: false },
486
791
  import_markdown: { enabled: false },
487
792
  evaluate_triggers: { enabled: false },
793
+ __sulcus_workflow__: { enabled: true },
794
+ memory_share: { enabled: false },
795
+ memory_cross_recall: { enabled: false },
488
796
  },
489
797
  };
490
798
  }
@@ -535,6 +843,27 @@ function formatRelativeTime(isoTimestamp: string): string {
535
843
  }
536
844
  }
537
845
 
846
+ // ─── ENTITY EXTRACTION HELPER ────────────────────────────────────────────────
847
+
848
+ /// Extract candidate entity names from memory recall results.
849
+ /// Heuristic: capitalized words/tokens longer than 3 chars from label/pointer_summary.
850
+ /// Returns up to 5 unique names.
851
+ function extractEntityNames(results: Record<string, unknown>[]): string[] {
852
+ const seen = new Set<string>();
853
+ for (const r of results) {
854
+ const text = ((r.label ?? r.pointer_summary ?? "") as string).trim();
855
+ if (!text) continue;
856
+ for (const token of text.split(/\s+/)) {
857
+ const clean = token.replace(/[^a-zA-Z0-9]/g, "");
858
+ if (clean.length > 3 && /^[A-Z]/.test(clean) && !seen.has(clean)) {
859
+ seen.add(clean);
860
+ if (seen.size >= 5) return Array.from(seen);
861
+ }
862
+ }
863
+ }
864
+ return Array.from(seen);
865
+ }
866
+
538
867
  // ─── SDK RECALL HANDLER (for before_agent_start with prependContext) ──────────
539
868
 
540
869
  interface ProfileCache {
@@ -632,6 +961,266 @@ function buildSdkRecallHandler(
632
961
  };
633
962
  }
634
963
 
964
+ // ─── PROMPT BUILD HANDLER ─────────────────────────────────────────────────────
965
+
966
+ function buildPromptBuildHandler(
967
+ sulcusMem: SulcusCloudClient,
968
+ namespace: string,
969
+ maxResults: number,
970
+ tokenBudget: number,
971
+ logger: PluginLogger,
972
+ sharedNamespaces: string[] = [],
973
+ triggerProcessor?: TriggerResultProcessor
974
+ ) {
975
+ return async (event: Record<string, unknown>, _ctx: unknown): Promise<{ prependContext: string } | undefined> => {
976
+ // Extract the latest user message from event.messages or fall back to event.prompt
977
+ let query = "";
978
+ const messages = event?.messages;
979
+ if (Array.isArray(messages)) {
980
+ for (let i = messages.length - 1; i >= 0; i--) {
981
+ const msg = messages[i] as Record<string, unknown>;
982
+ if (msg?.role === "user") {
983
+ const content = msg?.content;
984
+ query = typeof content === "string" ? content : (Array.isArray(content) ? content.map((c) => (c as Record<string, unknown>)?.text ?? "").join(" ") : "");
985
+ break;
986
+ }
987
+ }
988
+ }
989
+ if (!query && typeof event?.prompt === "string") query = event.prompt;
990
+ if (!query || query.length < 5) return undefined;
991
+
992
+ const charBudget = tokenBudget * 4;
993
+ const allResults: Record<string, unknown>[] = [];
994
+ const seenIds = new Set<string>();
995
+
996
+ // L0/L1: hot-context first (always-on, highest priority)
997
+ try {
998
+ const hotRes = await sulcusMem.hot_context(5, namespace);
999
+ for (const r of hotRes.results) {
1000
+ const id = r.id as string;
1001
+ if (id && !seenIds.has(id)) { seenIds.add(id); allResults.push(r); }
1002
+ }
1003
+ } catch (err) {
1004
+ logger.warn(`sulcus: hot-context unavailable, falling back to search-only: ${err}`);
1005
+ }
1006
+
1007
+ // Semantic search against the user's message
1008
+ try {
1009
+ const searchRes = await sulcusMem.search_memory(query, maxResults, namespace);
1010
+ for (const r of searchRes.results) {
1011
+ const id = r.id as string;
1012
+ if (id && !seenIds.has(id)) { seenIds.add(id); allResults.push(r); }
1013
+ }
1014
+ } catch (err) {
1015
+ logger.warn(`sulcus: prompt-build search failed: ${err}`);
1016
+ }
1017
+
1018
+ if (allResults.length === 0) return undefined;
1019
+
1020
+ // Extract entity names from recall results for graph enrichment
1021
+ const entityNames = extractEntityNames(allResults);
1022
+
1023
+ // Graph enrichment: fetch entity context (best-effort, 2s timeout)
1024
+ let entityContextResults: EntityContextResult[] = [];
1025
+ if (entityNames.length > 0) {
1026
+ try {
1027
+ const entityRes = await Promise.race([
1028
+ sulcusMem.entity_context(entityNames, namespace, 3),
1029
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), 2000)),
1030
+ ]);
1031
+ entityContextResults = entityRes.entities;
1032
+ } catch (_err) {
1033
+ // best-effort — skip silently
1034
+ }
1035
+ }
1036
+
1037
+ // Format memories, respecting the token budget
1038
+ const memLines: string[] = [];
1039
+ let usedChars = 0;
1040
+ for (const r of allResults) {
1041
+ const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0);
1042
+ const pct = `[${Math.round(heat * 100)}%]`;
1043
+ const updatedAt = r.updated_at as string | undefined;
1044
+ const timeStr = updatedAt ? `[${formatRelativeTime(updatedAt)}]` : "";
1045
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
1046
+ const line = `- ${pct} ${timeStr} ${label}`.trim();
1047
+ if (usedChars + line.length > charBudget) break;
1048
+ memLines.push(line);
1049
+ usedChars += line.length + 1;
1050
+ }
1051
+
1052
+ if (memLines.length === 0) return undefined;
1053
+
1054
+ const intro =
1055
+ "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.";
1056
+
1057
+ const bodySections: string[] = [`## Relevant Memories\n${memLines.join("\n")}`];
1058
+
1059
+ // Entity Context: one line per entity showing connections (budget-capped)
1060
+ if (entityContextResults.length > 0) {
1061
+ const entityLines: string[] = [];
1062
+ for (const entity of entityContextResults) {
1063
+ const connNames = entity.connections.map((c) => c.name).filter(Boolean);
1064
+ const memCount = entity.related_memories.length;
1065
+ const parts: string[] = [];
1066
+ if (connNames.length > 0) parts.push(`connected to [${connNames.join(", ")}]`);
1067
+ if (memCount > 0) parts.push(`${memCount} related memories`);
1068
+ if (parts.length === 0) continue;
1069
+ const line = `- ${entity.name}: ${parts.join(", ")}`;
1070
+ if (usedChars + line.length > charBudget) break;
1071
+ entityLines.push(line);
1072
+ usedChars += line.length + 1;
1073
+ }
1074
+ if (entityLines.length > 0) {
1075
+ bodySections.push(`## Entity Context\n${entityLines.join("\n")}`);
1076
+ }
1077
+ }
1078
+
1079
+ // Drain up to 3 pending trigger notifications (oldest first)
1080
+ if (triggerProcessor) {
1081
+ const notifications = triggerProcessor.drainNotifications(3);
1082
+ if (notifications.length > 0) {
1083
+ const notifLines = notifications.map((n) => `- You should know: ${n}`).join("\n");
1084
+ bodySections.push(`## Trigger Notifications\n${notifLines}`);
1085
+ }
1086
+ }
1087
+
1088
+ // Shared context from cross-namespace namespaces (opt-in via sharedNamespaces config)
1089
+ if (sharedNamespaces.length > 0) {
1090
+ const sharedLines: string[] = [];
1091
+ for (const sharedNs of sharedNamespaces) {
1092
+ try {
1093
+ const sharedRes = await sulcusMem.cross_namespace_search(query, sharedNs, Math.min(maxResults, 3));
1094
+ for (const r of sharedRes.results) {
1095
+ if (usedChars >= charBudget) break;
1096
+ const heat = ((r.current_heat as number) ?? (r.score as number) ?? 0);
1097
+ const pct = `[${Math.round(heat * 100)}%]`;
1098
+ const label = (r.label ?? r.pointer_summary ?? r.id ?? "") as string;
1099
+ const line = `- [${sharedNs}] ${pct} ${label}`.trim();
1100
+ if (usedChars + line.length > charBudget) break;
1101
+ sharedLines.push(line);
1102
+ usedChars += line.length + 1;
1103
+ }
1104
+ } catch (_sharedErr) {
1105
+ // best-effort cross-namespace fetch — skip silently
1106
+ }
1107
+ }
1108
+ if (sharedLines.length > 0) {
1109
+ bodySections.push(`## Shared Context\n${sharedLines.join("\n")}`);
1110
+ }
1111
+ }
1112
+
1113
+ const body = bodySections.join("\n\n");
1114
+ const context = `<sulcus_context token_budget="${tokenBudget}" namespace="${namespace}">\n${intro}\n\n${body}\n</sulcus_context>`;
1115
+
1116
+ logger.info(`sulcus: before_prompt_build injecting context (${context.length} chars, ${memLines.length} memories)`);
1117
+ return { prependContext: context };
1118
+ };
1119
+ }
1120
+
1121
+ // ─── TRIGGER RESULT PROCESSOR ────────────────────────────────────────────────
1122
+
1123
+ interface TriggerResult {
1124
+ trigger_id: string;
1125
+ trigger_name: string;
1126
+ action: string;
1127
+ success: boolean;
1128
+ message?: string;
1129
+ data?: Record<string, unknown>;
1130
+ }
1131
+
1132
+ class TriggerResultProcessor {
1133
+ private notificationBuffer: string[] = [];
1134
+ private readonly MAX_BUFFER = 10;
1135
+
1136
+ // Session telemetry
1137
+ totalTriggerFires = 0;
1138
+ triggerFiresByAction: Record<string, number> = {};
1139
+
1140
+ constructor(
1141
+ private sulcusMem: SulcusCloudClient | null,
1142
+ private logger: PluginLogger
1143
+ ) {}
1144
+
1145
+ process(results: unknown): void {
1146
+ let items: TriggerResult[];
1147
+ try {
1148
+ if (Array.isArray(results)) {
1149
+ items = results as TriggerResult[];
1150
+ } else if (results && typeof results === "object") {
1151
+ const r = results as Record<string, unknown>;
1152
+ items = Array.isArray(r.results) ? (r.results as TriggerResult[]) : [(results as TriggerResult)];
1153
+ } else {
1154
+ return;
1155
+ }
1156
+ } catch {
1157
+ return;
1158
+ }
1159
+
1160
+ for (const result of items) {
1161
+ try {
1162
+ this._processOne(result);
1163
+ } catch (e) {
1164
+ this.logger.warn(`sulcus: trigger processor error on '${result?.trigger_name ?? "?"}': ${e}`);
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ private _processOne(result: TriggerResult): void {
1170
+ if (!result.success) {
1171
+ this.logger.warn(`sulcus: trigger '${result.trigger_name}' failed: ${result.message ?? "(no message)"}`);
1172
+ return;
1173
+ }
1174
+
1175
+ this.totalTriggerFires++;
1176
+ this.triggerFiresByAction[result.action] = (this.triggerFiresByAction[result.action] ?? 0) + 1;
1177
+
1178
+ const action = result.action;
1179
+
1180
+ if (action === "notify") {
1181
+ const msg =
1182
+ result.message ??
1183
+ (result.data?.message as string | undefined) ??
1184
+ `Trigger '${result.trigger_name}' fired`;
1185
+ this.logger.info(`sulcus: trigger notify — ${msg}`);
1186
+
1187
+ // Store as episodic memory (fire-and-forget) so the notification survives
1188
+ if (this.sulcusMem) {
1189
+ this.sulcusMem.add_memory(`[trigger_notification] ${msg}`, "episodic").catch((e: unknown) => {
1190
+ this.logger.warn(`sulcus: trigger notification store failed: ${e instanceof Error ? e.message : String(e)}`);
1191
+ });
1192
+ }
1193
+
1194
+ // Append to circular buffer (oldest evicted when full)
1195
+ this.notificationBuffer.push(msg);
1196
+ if (this.notificationBuffer.length > this.MAX_BUFFER) {
1197
+ this.notificationBuffer.shift();
1198
+ }
1199
+ } else if (action === "boost" || action === "pin" || action === "tag" || action === "deprecate") {
1200
+ this.logger.info(`sulcus: trigger server-side action '${action}' completed for trigger '${result.trigger_name}'`);
1201
+ } else if (action === "webhook") {
1202
+ this.logger.info(`sulcus: trigger webhook fired for '${result.trigger_name}'`);
1203
+ } else if (action === "chain") {
1204
+ this.logger.info(`sulcus: trigger chain fired for '${result.trigger_name}'`);
1205
+ } else {
1206
+ this.logger.info(`sulcus: trigger '${result.trigger_name}' action='${action}' completed`);
1207
+ }
1208
+ }
1209
+
1210
+ /** Drain up to `max` notifications from the front of the buffer (oldest first). */
1211
+ drainNotifications(max: number): string[] {
1212
+ return this.notificationBuffer.splice(0, max);
1213
+ }
1214
+
1215
+ logSessionSummary(): void {
1216
+ if (this.totalTriggerFires === 0) return;
1217
+ const byAction = Object.entries(this.triggerFiresByAction)
1218
+ .map(([a, n]) => `${a}: ${n}`)
1219
+ .join(", ");
1220
+ this.logger.info(`sulcus: session trigger summary — total=${this.totalTriggerFires} (${byAction})`);
1221
+ }
1222
+ }
1223
+
635
1224
  // ─── MEMORY RUNTIME BUILDER ───────────────────────────────────────────────────
636
1225
 
637
1226
  function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
@@ -640,7 +1229,7 @@ function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
640
1229
  return {
641
1230
  backend: "builtin" as const,
642
1231
  provider: "sulcus",
643
- model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus-local",
1232
+ model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus",
644
1233
  custom: { backendMode, transport: backendMode === "cloud" ? "remote" : "local" },
645
1234
  };
646
1235
  },
@@ -707,6 +1296,7 @@ interface ToolDeps {
707
1296
  logger: PluginLogger;
708
1297
  isAvailable: boolean;
709
1298
  siuRequest: ((method: string, path: string, body?: unknown) => Promise<unknown>) | null;
1299
+ triggerProcessor?: TriggerResultProcessor;
710
1300
  }
711
1301
 
712
1302
  interface ToolDefinition {
@@ -728,12 +1318,22 @@ const toolDefinitions: Record<string, ToolDefinition> = {
728
1318
  }),
729
1319
  },
730
1320
  options: { name: "memory_recall" },
731
- makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
1321
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger, triggerProcessor }) =>
732
1322
  async (_id, params) => {
733
1323
  if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
734
1324
  const searchNamespace = (params.namespace as string | undefined) ?? namespace;
735
1325
  const res = await sulcusMem.search_memory(params.query as string, (params.limit as number | undefined) ?? 5, searchNamespace);
736
1326
  const results = res?.results ?? [];
1327
+
1328
+ // Fire-and-forget: evaluate on_recall triggers concurrently (non-blocking)
1329
+ if (triggerProcessor) {
1330
+ const topIds = results.slice(0, 3).map((r) => r.id as string).filter(Boolean);
1331
+ const triggerCtx = JSON.stringify({ query: params.query as string, result_ids: topIds });
1332
+ sulcusMem.evaluate_triggers("on_recall", triggerCtx)
1333
+ .then((triggerResults) => triggerProcessor.process(triggerResults))
1334
+ .catch((e: unknown) => logger.warn(`sulcus: on_recall trigger evaluation failed: ${e instanceof Error ? e.message : String(e)}`));
1335
+ }
1336
+
737
1337
  return {
738
1338
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
739
1339
  details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace },
@@ -756,7 +1356,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
756
1356
  }),
757
1357
  },
758
1358
  options: { name: "memory_store" },
759
- makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
1359
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger, triggerProcessor }) =>
760
1360
  async (_id, params) => {
761
1361
  const content = params.content as string;
762
1362
  if (isJunkMemory(content)) {
@@ -767,6 +1367,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
767
1367
  const mtype = (params.memory_type as string | undefined) || "episodic";
768
1368
  const res = await sulcusMem.add_memory(content, mtype);
769
1369
  const nodeId = res?.id ?? "unknown";
1370
+
1371
+ // Fire-and-forget: evaluate on_store triggers (non-blocking)
1372
+ if (triggerProcessor) {
1373
+ const triggerCtx = JSON.stringify({ content, memory_type: mtype, id: nodeId });
1374
+ sulcusMem.evaluate_triggers("on_store", triggerCtx)
1375
+ .then((triggerResults) => triggerProcessor.process(triggerResults))
1376
+ .catch((e: unknown) => logger.warn(`sulcus: on_store trigger evaluation failed: ${e instanceof Error ? e.message : String(e)}`));
1377
+ }
1378
+
770
1379
  let trainResult: string | null = null;
771
1380
  if (params.train === true) {
772
1381
  try {
@@ -1006,8 +1615,189 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1006
1615
  }
1007
1616
  },
1008
1617
  },
1618
+
1619
+ __sulcus_workflow__: {
1620
+ schema: {
1621
+ name: "__sulcus_workflow__",
1622
+ label: "Sulcus Workflow",
1623
+ description: "Call this when you are unsure what to do next with Sulcus memory tools. Returns a step-by-step workflow checklist so you always know the right action.",
1624
+ parameters: Type.Object({}),
1625
+ },
1626
+ options: { name: "__sulcus_workflow__" },
1627
+ makeExecute: (_deps: ToolDeps) =>
1628
+ async (_id: string, _params: Record<string, unknown>) => {
1629
+ const workflow = [
1630
+ { step: 1, action: "search first", tool: "memory_recall", description: "Before starting work, search memory for relevant context from prior sessions." },
1631
+ { step: 2, action: "store decisions/patterns/learnings", tool: "memory_store", description: "After significant work, store important decisions, patterns, corrections, or learnings." },
1632
+ { step: 3, action: "boost important memories", tool: "PATCH /api/v1/agent/memory/:id", description: "Use PATCH to set current_heat=0.9 on memories that should persist longer (memory_boost not yet exposed as a tool)." },
1633
+ { step: 4, action: "check triggers", tool: "evaluate_triggers", description: "Evaluate reactive rules to see if any triggers should fire based on current context." },
1634
+ { step: 5, action: "export if needed", tool: "export_markdown", description: "Export all memories as Markdown for backup or review." },
1635
+ ];
1636
+ return {
1637
+ content: [{ type: "text", text: JSON.stringify(workflow, null, 2) }],
1638
+ details: { workflow: workflow as unknown as Record<string, unknown> },
1639
+ };
1640
+ },
1641
+ },
1642
+
1643
+ memory_share: {
1644
+ schema: {
1645
+ name: "memory_share",
1646
+ label: "Memory Share",
1647
+ description: "Share a memory with another agent by storing it in their namespace. Use for cross-agent context sharing.",
1648
+ parameters: Type.Object({
1649
+ content: Type.String({ description: "The memory to share." }),
1650
+ target_namespace: Type.String({ description: "The agent namespace to share with." }),
1651
+ memory_type: Type.Optional(Type.String({ description: "Memory type. Defaults to 'semantic'." })),
1652
+ source_note: Type.Optional(Type.String({ description: "Optional note about where this came from." })),
1653
+ }),
1654
+ },
1655
+ options: { name: "memory_share" },
1656
+ makeExecute: ({ sulcusMem, namespace, nativeLoader, isAvailable, logger }) =>
1657
+ async (_id, params) => {
1658
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
1659
+ const content = params.content as string;
1660
+ const targetNamespace = params.target_namespace as string;
1661
+ const memoryType = (params.memory_type as string | undefined) ?? "semantic";
1662
+ const sourceNote = params.source_note as string | undefined;
1663
+
1664
+ const sharedContent = `[Shared by ${namespace}] ${content}${sourceNote ? ` (source: ${sourceNote})` : ""}`;
1665
+ const refContent = `[Shared to ${targetNamespace}] ${content.substring(0, 100)}${content.length > 100 ? "..." : ""}`;
1666
+
1667
+ let sharedId = "unknown";
1668
+ let refId = "unknown";
1669
+
1670
+ try {
1671
+ const sharedRes = await (sulcusMem as SulcusCloudClient).add_memory(sharedContent, memoryType, targetNamespace);
1672
+ sharedId = sharedRes?.id ?? "unknown";
1673
+ } catch (e: unknown) {
1674
+ const msg = e instanceof Error ? e.message : String(e);
1675
+ logger.warn(`sulcus: memory_share — failed to store in target namespace: ${msg}`);
1676
+ return { content: [{ type: "text", text: `Failed to share memory to namespace '${targetNamespace}': ${msg}` }] };
1677
+ }
1678
+
1679
+ try {
1680
+ const refRes = await (sulcusMem as SulcusCloudClient).add_memory(refContent, "semantic");
1681
+ refId = refRes?.id ?? "unknown";
1682
+ } catch (_e) {
1683
+ // best-effort reference store
1684
+ }
1685
+
1686
+ logger.info(`sulcus: memory_share — shared to '${targetNamespace}' (shared_id: ${sharedId}, ref_id: ${refId})`);
1687
+ return {
1688
+ content: [{ type: "text", text: `Shared memory to namespace '${targetNamespace}' (id: ${sharedId}). Reference stored locally (id: ${refId}).` }],
1689
+ details: { shared_id: sharedId, ref_id: refId, target_namespace: targetNamespace, memory_type: memoryType },
1690
+ };
1691
+ },
1692
+ },
1693
+
1694
+ memory_cross_recall: {
1695
+ schema: {
1696
+ name: "memory_cross_recall",
1697
+ label: "Memory Cross-Recall",
1698
+ description: "Search another agent's memories. Useful for checking what another agent knows about a topic. Requires cross-namespace access.",
1699
+ parameters: Type.Object({
1700
+ query: Type.String({ description: "Search query string." }),
1701
+ target_namespace: Type.String({ description: "The agent namespace to search in." }),
1702
+ limit: Type.Optional(Type.Number({ default: 5, description: "Maximum number of results to return (1-10)." })),
1703
+ }),
1704
+ },
1705
+ options: { name: "memory_cross_recall" },
1706
+ makeExecute: ({ sulcusMem, nativeLoader, isAvailable, logger }) =>
1707
+ async (_id, params) => {
1708
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
1709
+ const targetNamespace = params.target_namespace as string;
1710
+ const query = params.query as string;
1711
+ const limit = (params.limit as number | undefined) ?? 5;
1712
+
1713
+ try {
1714
+ const res = await (sulcusMem as SulcusCloudClient).cross_namespace_search(query, targetNamespace, limit);
1715
+ const results = res?.results ?? [];
1716
+ const prefixed = results.map((r) => ({ ...r, _source: `[from ${targetNamespace}]` }));
1717
+ logger.info(`sulcus: memory_cross_recall — ${results.length} results from '${targetNamespace}'`);
1718
+ return {
1719
+ content: [{ type: "text", text: JSON.stringify(prefixed, null, 2) }],
1720
+ details: { results: prefixed as unknown as Record<string, unknown>[], target_namespace: targetNamespace },
1721
+ };
1722
+ } catch (e: unknown) {
1723
+ const msg = e instanceof Error ? e.message : String(e);
1724
+ if (msg.includes("HTTP 403") || msg.includes("403")) {
1725
+ return { content: [{ type: "text", text: `Access denied to namespace '${targetNamespace}': insufficient permissions (403).` }] };
1726
+ }
1727
+ logger.warn(`sulcus: memory_cross_recall — failed: ${msg}`);
1728
+ return { content: [{ type: "text", text: `Cross-namespace recall failed for '${targetNamespace}': ${msg}` }] };
1729
+ }
1730
+ },
1731
+ },
1009
1732
  };
1010
1733
 
1734
+ // ─── FIRST-INSTALL HISTORY IMPORT ────────────────────────────────────────────
1735
+
1736
+ async function importOpenClawHistory(sulcusMem: SulcusCloudClient, logger: PluginLogger): Promise<void> {
1737
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1738
+ const fs = require("fs") as {
1739
+ existsSync: (p: string) => boolean;
1740
+ readFileSync: (p: string, enc: string) => string;
1741
+ readdirSync: (p: string) => string[];
1742
+ statSync: (p: string) => { mtimeMs: number };
1743
+ writeFileSync: (p: string, d: string, enc: string) => void;
1744
+ };
1745
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1746
+ const path = require("path") as { join: (...args: string[]) => string };
1747
+
1748
+ const workspaceDir = process.env.OPENCLAW_WORKSPACE
1749
+ ? resolve(process.env.OPENCLAW_WORKSPACE)
1750
+ : resolve(process.env.HOME || "~", ".openclaw/workspace");
1751
+ const markerPath = path.join(workspaceDir, ".sulcus-imported");
1752
+
1753
+ if (fs.existsSync(markerPath)) return;
1754
+
1755
+ logger.info("sulcus: first-install history import starting...");
1756
+
1757
+ const memories: string[] = [];
1758
+
1759
+ const memoryMdPath = path.join(workspaceDir, "MEMORY.md");
1760
+ if (fs.existsSync(memoryMdPath)) {
1761
+ try {
1762
+ const text = fs.readFileSync(memoryMdPath, "utf-8");
1763
+ const entries = text.split(/\n(?:---+|\s*\n\s*\n)/g).map((s) => s.trim()).filter((s) => s.length > 20);
1764
+ memories.push(...entries);
1765
+ } catch (_e) { /* best-effort */ }
1766
+ }
1767
+
1768
+ const memDir = path.join(workspaceDir, "memory");
1769
+ if (fs.existsSync(memDir)) {
1770
+ try {
1771
+ const files = fs.readdirSync(memDir);
1772
+ const now = Date.now();
1773
+ const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
1774
+ for (const file of files) {
1775
+ if (!/^\d{4}-\d{2}-\d{2}\.md$/.test(file)) continue;
1776
+ try {
1777
+ const stat = fs.statSync(path.join(memDir, file));
1778
+ if (now - stat.mtimeMs > thirtyDaysMs) continue;
1779
+ const text = fs.readFileSync(path.join(memDir, file), "utf-8");
1780
+ const entries = text.split(/\n---\n/g).map((s) => s.trim()).filter((s) => s.length > 20);
1781
+ memories.push(...entries);
1782
+ } catch (_e) { /* best-effort */ }
1783
+ }
1784
+ } catch (_e) { /* best-effort */ }
1785
+ }
1786
+
1787
+ let stored = 0;
1788
+ for (const mem of memories) {
1789
+ try {
1790
+ await sulcusMem.add_memory(mem, "episodic");
1791
+ stored++;
1792
+ } catch (_e) { /* best-effort */ }
1793
+ }
1794
+
1795
+ try {
1796
+ fs.writeFileSync(markerPath, new Date().toISOString(), "utf-8");
1797
+ logger.info(`sulcus: history import complete — stored ${stored} memories from OpenClaw workspace`);
1798
+ } catch (_e) { /* best-effort */ }
1799
+ }
1800
+
1011
1801
  // ─── PLUGIN ──────────────────────────────────────────────────────────────────
1012
1802
 
1013
1803
  const sulcusPlugin = {
@@ -1050,6 +1840,10 @@ const sulcusPlugin = {
1050
1840
  const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
1051
1841
  const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
1052
1842
  const profileFrequency: number = Math.min(500, Math.max(1, (pluginConfig?.profileFrequency as number | undefined) ?? 10));
1843
+ // Phase 6: cross-agent config (v4.7.0)
1844
+ const sharedNamespaces: string[] = Array.isArray(pluginConfig?.sharedNamespaces)
1845
+ ? (pluginConfig.sharedNamespaces as string[]).filter((s) => typeof s === "string" && s.length > 0)
1846
+ : [];
1053
1847
 
1054
1848
  // ── Load hooks config ──
1055
1849
  const hooksConfig = loadHooksConfig(pluginConfig);
@@ -1115,6 +1909,11 @@ const sulcusPlugin = {
1115
1909
  ? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1116
1910
  : null;
1117
1911
 
1912
+ // ── Trigger result processor (Phase 3) ──
1913
+ const triggerProcessor = isCloudBackend && sulcusMem
1914
+ ? new TriggerResultProcessor(sulcusMem, logger)
1915
+ : undefined;
1916
+
1118
1917
  // ── Shared deps ──
1119
1918
  const toolDeps: ToolDeps = {
1120
1919
  sulcusMem,
@@ -1127,6 +1926,7 @@ const sulcusPlugin = {
1127
1926
  logger,
1128
1927
  isAvailable,
1129
1928
  siuRequest: siuRequestFn,
1929
+ triggerProcessor,
1130
1930
  };
1131
1931
 
1132
1932
  const handlerCtx: HookHandlerCtx = {
@@ -1209,27 +2009,86 @@ const sulcusPlugin = {
1209
2009
  }
1210
2010
  }
1211
2011
 
1212
- // 5. Enhanced before_agent_start with prependContext (SDK path)
1213
- // When autoRecall=true and cloud backend available, use prependContext SDK pattern.
1214
- // Falls back to legacy hook-based path when SDK is not available.
2012
+ // 5. before_prompt_build: hot-context + semantic recall injection (SDK path)
2013
+ // Fires every turn, has access to event.messages and event.prompt.
2014
+ // Uses hot-context endpoint first (L0/L1), then falls back to search.
1215
2015
  if (autoRecall && isCloudBackend && sulcusMem) {
1216
- const sdkRecallHandler = buildSdkRecallHandler(
2016
+ const promptBuildHandler = buildPromptBuildHandler(
1217
2017
  sulcusMem as SulcusCloudClient,
1218
2018
  namespace,
1219
2019
  maxRecallResults,
1220
- profileFrequency,
1221
- logger
2020
+ 500,
2021
+ logger,
2022
+ sharedNamespaces,
2023
+ triggerProcessor
1222
2024
  );
1223
2025
  const apiOn = api.on as (event: string, handler: unknown) => void;
1224
- apiOn("before_agent_start", async (event: Record<string, unknown>, ctx: unknown) => {
2026
+ apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
1225
2027
  try {
1226
- return await sdkRecallHandler(event, ctx);
2028
+ return await promptBuildHandler(event, ctx);
1227
2029
  } catch (err) {
1228
- logger.warn("sulcus: SDK recall hook threw: " + err);
2030
+ logger.warn("sulcus: before_prompt_build hook threw: " + err);
1229
2031
  return undefined;
1230
2032
  }
1231
2033
  });
1232
- logger.info("sulcus: registered SDK auto-recall (prependContext path)");
2034
+ logger.info("sulcus: registered before_prompt_build recall (hot-context + search)");
2035
+ }
2036
+
2037
+ // 5b. Lifecycle hooks: session_start, session_end, before_reset
2038
+ {
2039
+ const apiOn = api.on as (event: string, handler: unknown) => void;
2040
+
2041
+ apiOn("session_start", async (_event: Record<string, unknown>, _ctx: unknown) => {
2042
+ try {
2043
+ logger.info("sulcus: session_start — session opened");
2044
+ } catch (err) {
2045
+ logger.warn("sulcus: session_start hook threw: " + err);
2046
+ }
2047
+ return undefined;
2048
+ });
2049
+
2050
+ // session_end trigger telemetry (always registered)
2051
+ apiOn("session_end", async (_event: Record<string, unknown>, _ctx: unknown) => {
2052
+ try {
2053
+ triggerProcessor?.logSessionSummary();
2054
+ } catch (err) {
2055
+ logger.warn("sulcus: session_end trigger telemetry threw: " + err);
2056
+ }
2057
+ return undefined;
2058
+ });
2059
+
2060
+ if (autoCapture && sulcusMem) {
2061
+ const captureConfig: HookConfig = {
2062
+ action: "sivu_auto_capture",
2063
+ enabled: true,
2064
+ min_store_confidence: 0.5,
2065
+ fallback_on_error: true,
2066
+ };
2067
+
2068
+ apiOn("session_end", async (event: Record<string, unknown>, _ctx: unknown) => {
2069
+ try {
2070
+ logger.info("sulcus: session_end — running final memory extraction");
2071
+ return await hookHandlers.sivu_auto_capture(event, captureConfig, handlerCtx);
2072
+ } catch (err) {
2073
+ logger.warn("sulcus: session_end hook threw: " + err);
2074
+ return undefined;
2075
+ }
2076
+ });
2077
+
2078
+ apiOn("before_reset", async (event: Record<string, unknown>, _ctx: unknown) => {
2079
+ try {
2080
+ logger.info("sulcus: before_reset — extracting memories before context wipe");
2081
+ return await hookHandlers.sivu_auto_capture(event, captureConfig, handlerCtx);
2082
+ } catch (err) {
2083
+ logger.warn("sulcus: before_reset hook threw: " + err);
2084
+ return undefined;
2085
+ }
2086
+ });
2087
+
2088
+ logger.info("sulcus: registered session_start, session_end, before_reset lifecycle hooks");
2089
+ } else {
2090
+ logger.info("sulcus: registered session_start lifecycle hook");
2091
+ }
1233
2092
  }
1234
2093
 
1235
2094
  // 6. auto-capture on agent_end
@@ -1252,6 +2111,308 @@ const sulcusPlugin = {
1252
2111
  logger.info("sulcus: registered auto-capture (agent_end)");
1253
2112
  }
1254
2113
 
2114
+ // ─────────────────────────────────────────────────────────────────────────
2115
+ // PHASE 2: OBSERVATION LAYER HOOKS
2116
+ // ─────────────────────────────────────────────────────────────────────────
2117
+ {
2118
+ const apiOn = api.on as (event: string, handler: unknown) => void;
2119
+
2120
+ // llm_input — observe the final payload sent to the LLM
2121
+ if (isCloudBackend) {
2122
+ apiOn("llm_input", async (event: Record<string, unknown>, _ctx: unknown) => {
2123
+ try {
2124
+ const systemPrompt = (event.systemPrompt as string) ?? "";
2125
+ const prompt = (event.prompt as string) ?? "";
2126
+ const model = (event.model as string) ?? "unknown";
2127
+ const historyMessages = (event.historyMessages as unknown[]) ?? [];
2128
+
2129
+ // Count memories that made it into the prompt via sulcus_context block
2130
+ const sulcusContextMatch = (systemPrompt + prompt).match(/<sulcus_context>([\s\S]*?)<\/sulcus_context>/);
2131
+ const memoryLines = sulcusContextMatch
2132
+ ? sulcusContextMatch[1].split("\n").filter((l) => l.trim().length > 0).length
2133
+ : 0;
2134
+
2135
+ // Approximate token count (rough: chars / 4)
2136
+ const totalChars =
2137
+ systemPrompt.length +
2138
+ prompt.length +
2139
+ historyMessages.reduce((sum: number, m: unknown) => {
2140
+ const msg = m as Record<string, unknown>;
2141
+ return sum + String(msg.content ?? "").length;
2142
+ }, 0);
2143
+ const approxTokens = Math.round(totalChars / 4);
2144
+
2145
+ logger.info(
2146
+ `sulcus: llm_input — ${memoryLines} memories in context, ~${approxTokens} tokens, model=${model}`
2147
+ );
2148
+ } catch (err) {
2149
+ logger.warn("sulcus: llm_input hook threw: " + err);
2150
+ }
2151
+ return undefined;
2152
+ });
2153
+ }
2154
+
2155
+ // llm_output — observe the raw response from the LLM
2156
+ if (isCloudBackend) {
2157
+ const failurePattern = /\b(error|failed|doesn['']t work|cannot|unable to)\b/i;
2158
+ apiOn("llm_output", async (event: Record<string, unknown>, _ctx: unknown) => {
2159
+ try {
2160
+ const assistantTexts = (event.assistantTexts as string[]) ?? [];
2161
+ const usage = (event.usage as Record<string, number>) ?? {};
2162
+ const inputTokens = usage.inputTokens ?? 0;
2163
+ const outputTokens = usage.outputTokens ?? 0;
2164
+ const cacheReadTokens = usage.cacheReadTokens ?? 0;
2165
+
2166
+ logger.info(
2167
+ `sulcus: llm_output — input=${inputTokens}, output=${outputTokens}, cache_read=${cacheReadTokens}`
2168
+ );
2169
+
2170
+ if (autoCapture && sulcusMem) {
2171
+ const fullText = assistantTexts.join(" ");
2172
+ if (failurePattern.test(fullText)) {
2173
+ const snippet = fullText.slice(0, 200);
2174
+ try {
2175
+ await (sulcusMem as SulcusCloudClient).add_memory(
2176
+ `[Agent reported failure] ${snippet}`,
2177
+ "episodic"
2178
+ );
2179
+ } catch (storeErr) {
2180
+ logger.warn("sulcus: llm_output failure memory store threw: " + storeErr);
2181
+ }
2182
+ }
2183
+ }
2184
+
2185
+ // --- SILU Output Evaluation (recursive LM supervisor) ---
2186
+ const evalHook = hooksConfig.hooks["llm_output_evaluation"];
2187
+ if (evalHook?.enabled && sulcusMem && isCloudBackend) {
2188
+ const fullText = assistantTexts.join(" ");
2189
+ if (fullText.length > 20) {
2190
+ // Fire-and-forget: don't block the response pipeline
2191
+ const promptSummary = String(event.promptSummary ?? event.prompt_summary ?? "");
2192
+ const model = String(event.model ?? "");
2193
+ (sulcusMem as SulcusCloudClient).evaluate_output(
2194
+ fullText,
2195
+ { prompt_summary: promptSummary || undefined, model: model || undefined }
2196
+ ).then((result) => {
2197
+ if (result?.alignment?.status !== "aligned") {
2198
+ logger.info(
2199
+ `sulcus: SILU evaluation — score=${result.alignment.score}, ` +
2200
+ `status=${result.alignment.status}, issues=${result.alignment.issues.length}, ` +
2201
+ `corrections=${result.alignment.corrections.length}`
2202
+ );
2203
+ // Store misalignment as episodic memory for learning
2204
+ if (result.alignment.issues.length > 0) {
2205
+ const issuesSummary = result.alignment.issues
2206
+ .map((i: unknown) => {
2207
+ const issue = i as Record<string, string>;
2208
+ return `[${issue.type}] ${issue.description ?? issue.detail ?? ""}`;
2209
+ })
2210
+ .join("; ");
2211
+ (sulcusMem as SulcusCloudClient).add_memory(
2212
+ `[SILU evaluation — ${result.alignment.status}] score=${result.alignment.score}: ${issuesSummary}`,
2213
+ "episodic"
2214
+ ).catch((e: unknown) => logger.warn(`sulcus: SILU eval memory store failed: ${e}`));
2215
+ }
2216
+ } else {
2217
+ logger.debug?.(`sulcus: SILU evaluation — aligned (score=${result.alignment.score})`);
2218
+ }
2219
+ }).catch((e: unknown) => {
2220
+ logger.warn(`sulcus: SILU output evaluation failed: ${e}`);
2221
+ });
2222
+ }
2223
+ }
2224
+ } catch (err) {
2225
+ logger.warn("sulcus: llm_output hook threw: " + err);
2226
+ }
2227
+ return undefined;
2228
+ });
2229
+ }
2230
+
2231
+ // message_received — classify inbound message for telemetry (observation only)
2232
+ apiOn("message_received", async (event: Record<string, unknown>, _ctx: unknown) => {
2233
+ try {
2234
+ const message = String(event.message ?? event.content ?? "");
2235
+ if (/\b(actually|no,|that'?s wrong|correction:|I meant)\b/i.test(message)) {
2236
+ logger.info("sulcus: message_received — classified as correction");
2237
+ } else if (/\b(I prefer|I like|I don'?t like|always use|never use)\b/i.test(message)) {
2238
+ logger.info("sulcus: message_received — classified as preference statement");
2239
+ } else if (/\b(remember when|did we|what was|last time)\b/i.test(message)) {
2240
+ logger.info("sulcus: message_received — classified as question about past");
2241
+ }
2242
+ } catch (err) {
2243
+ logger.warn("sulcus: message_received hook threw: " + err);
2244
+ }
2245
+ return undefined;
2246
+ });
2247
+
2248
+ // before_dispatch — lightweight telemetry before the agent loop
2249
+ apiOn("before_dispatch", async (event: Record<string, unknown>, _ctx: unknown) => {
2250
+ try {
2251
+ const message = String(event.message ?? event.content ?? "");
2252
+ logger.info(`sulcus: before_dispatch — message length=${message.length}`);
2253
+ } catch (err) {
2254
+ logger.warn("sulcus: before_dispatch hook threw: " + err);
2255
+ }
2256
+ return undefined;
2257
+ });
2258
+
2259
+ logger.info("sulcus: registered observation layer hooks (llm_input, llm_output, message_received, before_dispatch)");
2260
+ }
2261
+
2262
+ // ─────────────────────────────────────────────────────────────────────────
2263
+ // PHASE 3: FULL LIFECYCLE COVERAGE
2264
+ // ─────────────────────────────────────────────────────────────────────────
2265
+ {
2266
+ const apiOn = api.on as (event: string, handler: unknown) => void;
2267
+
2268
+ // after_compaction — store a summary of the compacted context as an episodic memory
2269
+ apiOn("after_compaction", async (event: Record<string, unknown>, _ctx: unknown) => {
2270
+ try {
2271
+ const messageCount = (event.messageCount as number) ?? (event.compactedCount as number) ?? 0;
2272
+ const summary = (event.summary as string) ?? (event.compactionSummary as string) ?? "";
2273
+ logger.info(`sulcus: after_compaction — ${messageCount} messages compacted`);
2274
+ if (sulcusMem && summary) {
2275
+ const memContent = `[after_compaction] ${messageCount} messages compacted. ${summary.slice(0, 400)}`;
2276
+ await (sulcusMem as SulcusCloudClient).add_memory(memContent, "episodic").catch((e: unknown) => {
2277
+ logger.warn(`sulcus: after_compaction memory store failed: ${e instanceof Error ? e.message : String(e)}`);
2278
+ });
2279
+ }
2280
+ } catch (err) {
2281
+ logger.warn("sulcus: after_compaction hook threw: " + err);
2282
+ }
2283
+ return undefined;
2284
+ });
2285
+
2286
+ // message_sending — check for memory-related corrections before outbound send
2287
+ apiOn("message_sending", async (event: Record<string, unknown>, _ctx: unknown) => {
2288
+ try {
2289
+ const content = String(event.content ?? event.message ?? event.text ?? "");
2290
+ if (/\bI was wrong about\b|\bcorrection:\b/i.test(content)) {
2291
+ logger.info("sulcus: message_sending — correction detected in outbound message");
2292
+ }
2293
+ } catch (err) {
2294
+ logger.warn("sulcus: message_sending hook threw: " + err);
2295
+ }
2296
+ return undefined;
2297
+ });
2298
+
2299
+ // message_sent — telemetry; optionally capture long messages as episodic memory
2300
+ apiOn("message_sent", async (event: Record<string, unknown>, _ctx: unknown) => {
2301
+ try {
2302
+ const content = String(event.content ?? event.message ?? event.text ?? "");
2303
+ logger.info(`sulcus: message_sent — length=${content.length}`);
2304
+ if (autoCapture && sulcusMem && content.length > 500) {
2305
+ const snippet = content.slice(0, 300);
2306
+ await (sulcusMem as SulcusCloudClient).add_memory(
2307
+ `[message_sent summary] ${snippet}`,
2308
+ "episodic"
2309
+ ).catch((e: unknown) => {
2310
+ logger.warn(`sulcus: message_sent memory store failed: ${e instanceof Error ? e.message : String(e)}`);
2311
+ });
2312
+ }
2313
+ } catch (err) {
2314
+ logger.warn("sulcus: message_sent hook threw: " + err);
2315
+ }
2316
+ return undefined;
2317
+ });
2318
+
2319
+ // before_tool_call — log memory/sulcus tool invocations
2320
+ apiOn("before_tool_call", async (event: Record<string, unknown>, _ctx: unknown) => {
2321
+ try {
2322
+ const toolName = String(event.toolName ?? event.tool_name ?? event.name ?? "");
2323
+ if (toolName && (toolName.toLowerCase().includes("memory") || toolName.toLowerCase().includes("sulcus"))) {
2324
+ logger.info(`sulcus: before_tool_call — memory/sulcus tool invoked: ${toolName}`);
2325
+ }
2326
+ } catch (err) {
2327
+ logger.warn("sulcus: before_tool_call hook threw: " + err);
2328
+ }
2329
+ return undefined;
2330
+ });
2331
+
2332
+ // before_message_write — observation only, log message type
2333
+ apiOn("before_message_write", async (event: Record<string, unknown>, _ctx: unknown) => {
2334
+ try {
2335
+ const role = String(event.role ?? event.type ?? "unknown");
2336
+ logger.info(`sulcus: before_message_write — role=${role}`);
2337
+ } catch (err) {
2338
+ logger.warn("sulcus: before_message_write hook threw: " + err);
2339
+ }
2340
+ return undefined;
2341
+ });
2342
+
2343
+ // subagent_spawning — log namespace inheritance if shared namespaces are configured
2344
+ apiOn("subagent_spawning", async (event: Record<string, unknown>, _ctx: unknown) => {
2345
+ try {
2346
+ const agentId = String(event.agentId ?? event.agent_id ?? "(unknown)");
2347
+ if (sharedNamespaces.length > 0) {
2348
+ logger.info(`sulcus: subagent_spawning — agent=${agentId}, inheriting namespaces: [${sharedNamespaces.join(", ")}]`);
2349
+ } else {
2350
+ logger.info(`sulcus: subagent_spawning — agent=${agentId}`);
2351
+ }
2352
+ } catch (err) {
2353
+ logger.warn("sulcus: subagent_spawning hook threw: " + err);
2354
+ }
2355
+ return undefined;
2356
+ });
2357
+
2358
+ // subagent_spawned — log after subagent spawn
2359
+ apiOn("subagent_spawned", async (event: Record<string, unknown>, _ctx: unknown) => {
2360
+ try {
2361
+ const agentId = String(event.agentId ?? event.agent_id ?? "(unknown)");
2362
+ logger.info(`sulcus: subagent_spawned — agent=${agentId}`);
2363
+ } catch (err) {
2364
+ logger.warn("sulcus: subagent_spawned hook threw: " + err);
2365
+ }
2366
+ return undefined;
2367
+ });
2368
+
2369
+ // subagent_ended — optionally capture what the subagent did as an episodic memory
2370
+ apiOn("subagent_ended", async (event: Record<string, unknown>, _ctx: unknown) => {
2371
+ try {
2372
+ const agentId = String(event.agentId ?? event.agent_id ?? "(unknown)");
2373
+ const result = String(event.result ?? event.output ?? event.summary ?? "");
2374
+ logger.info(`sulcus: subagent_ended — agent=${agentId}`);
2375
+ if (autoCapture && sulcusMem && result) {
2376
+ const snippet = result.slice(0, 300);
2377
+ await (sulcusMem as SulcusCloudClient).add_memory(
2378
+ `[subagent_ended] agent=${agentId}: ${snippet}`,
2379
+ "episodic"
2380
+ ).catch((e: unknown) => {
2381
+ logger.warn(`sulcus: subagent_ended memory store failed: ${e instanceof Error ? e.message : String(e)}`);
2382
+ });
2383
+ }
2384
+ } catch (err) {
2385
+ logger.warn("sulcus: subagent_ended hook threw: " + err);
2386
+ }
2387
+ return undefined;
2388
+ });
2389
+
2390
+ // gateway_start — log plugin version and config summary
2391
+ apiOn("gateway_start", async (_event: Record<string, unknown>, _ctx: unknown) => {
2392
+ try {
2393
+ logger.info(
2394
+ `sulcus: gateway_start — openclaw-sulcus v5.0.0 | backend=${backendMode} | namespace=${namespace} | autoRecall=${autoRecall} | autoCapture=${autoCapture} | sharedNamespaces=[${sharedNamespaces.join(", ")}]`
2395
+ );
2396
+ } catch (err) {
2397
+ logger.warn("sulcus: gateway_start hook threw: " + err);
2398
+ }
2399
+ return undefined;
2400
+ });
2401
+
2402
+ // gateway_stop — log session stats
2403
+ apiOn("gateway_stop", async (_event: Record<string, unknown>, _ctx: unknown) => {
2404
+ try {
2405
+ logger.info("sulcus: gateway_stop — session ending");
2406
+ triggerProcessor?.logSessionSummary();
2407
+ } catch (err) {
2408
+ logger.warn("sulcus: gateway_stop hook threw: " + err);
2409
+ }
2410
+ return undefined;
2411
+ });
2412
+
2413
+ logger.info("sulcus: registered Phase 3 lifecycle hooks (after_compaction, message_sending, message_sent, before_tool_call, before_message_write, subagent_spawning, subagent_spawned, subagent_ended, gateway_start, gateway_stop)");
2414
+ }
2415
+
1255
2416
  // ─────────────────────────────────────────────────────────────────────────
1256
2417
  // LEGACY HOOK REGISTRATION (config-driven, backward compat)
1257
2418
  // ─────────────────────────────────────────────────────────────────────────
@@ -1259,8 +2420,10 @@ const sulcusPlugin = {
1259
2420
  for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
1260
2421
  if (!hookConfig.enabled) continue;
1261
2422
 
1262
- // Skip before_agent_start if we already registered the SDK path
1263
- if (hookName === "before_agent_start" && autoRecall && isCloudBackend) continue;
2423
+ // Skip before_agent_start replaced by before_prompt_build SDK path
2424
+ if (hookName === "before_agent_start") continue;
2425
+ // Skip before_prompt_build if we already registered the SDK path
2426
+ if (hookName === "before_prompt_build" && autoRecall && isCloudBackend) continue;
1264
2427
  // Skip agent_end if autoCapture SDK path already registered
1265
2428
  if (hookName === "agent_end" && autoCapture && hookConfig.action === "sivu_auto_capture") continue;
1266
2429
 
@@ -1300,6 +2463,13 @@ const sulcusPlugin = {
1300
2463
  logger.warn("sulcus: unknown tool " + toolName + " in config — skipping");
1301
2464
  }
1302
2465
  }
2466
+
2467
+ // Fire-and-forget first-install history import
2468
+ if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
2469
+ importOpenClawHistory(sulcusMem, logger).catch((e: unknown) => {
2470
+ logger.warn(`sulcus: history import failed: ${e instanceof Error ? e.message : String(e)}`);
2471
+ });
2472
+ }
1303
2473
  }
1304
2474
  };
1305
2475