@digitalforgestudios/openclaw-sulcus 4.2.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.
@@ -23,6 +23,11 @@
23
23
  "before_compaction": {
24
24
  "action": "pre_compaction_capture",
25
25
  "enabled": true
26
+ },
27
+ "llm_output_evaluation": {
28
+ "action": "evaluate_output",
29
+ "enabled": false,
30
+ "description": "Send LLM output to SILU for semantic alignment evaluation against memory. Requires silu_output_evaluation=true on the server per-agent config."
26
31
  }
27
32
  },
28
33
  "tools": {
@@ -37,6 +42,8 @@
37
42
  "siu_status": { "enabled": false },
38
43
  "siu_retrain": { "enabled": false },
39
44
  "trigger_feedback": { "enabled": false },
40
- "__sulcus_workflow__": { "enabled": true }
45
+ "__sulcus_workflow__": { "enabled": true },
46
+ "memory_share": { "enabled": false },
47
+ "memory_cross_recall": { "enabled": false }
41
48
  }
42
49
  }
package/index.ts CHANGED
@@ -233,64 +233,73 @@ const hookHandlers: Record<string, HookHandler> = {
233
233
  }
234
234
  },
235
235
 
236
- pre_compaction_capture: async (event: Record<string, unknown>, _config: HookConfig, ctx: HookHandlerCtx) => {
236
+ pre_compaction_capture: async (event: Record<string, unknown>, config: HookConfig, ctx: HookHandlerCtx) => {
237
237
  const { sulcusMem, logger } = ctx;
238
238
  if (!sulcusMem) return;
239
239
 
240
240
  const messages = Array.isArray(event?.messages) ? event.messages as Record<string, unknown>[] : [];
241
241
  if (messages.length === 0) return;
242
242
 
243
- const firstUser = messages.find((m) => m.role === "user" || m.type === "human");
244
- const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant" || m.type === "ai");
245
-
246
- const firstUserText = typeof firstUser?.content === "string"
247
- ? firstUser.content.substring(0, 200)
248
- : typeof firstUser?.text === "string"
249
- ? (firstUser.text as string).substring(0, 200)
250
- : "(none)";
251
-
252
- const lastAssistantText = typeof lastAssistant?.content === "string"
253
- ? lastAssistant.content.substring(0, 200)
254
- : typeof lastAssistant?.text === "string"
255
- ? (lastAssistant.text as string).substring(0, 200)
256
- : "(none)";
257
-
258
- const filesModified: string[] = [];
259
- for (const msg of messages) {
260
- const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as Record<string, unknown>[] : [];
261
- for (const tc of toolCalls) {
262
- const name = (tc.name ?? tc.function) as string | undefined;
263
- if (name === "Write" || name === "Edit" || name === "write" || name === "edit") {
264
- const input = (tc.input ?? tc.arguments ?? {}) as Record<string, unknown>;
265
- const fp = input?.file_path ?? input?.path;
266
- if (fp && typeof fp === "string" && !filesModified.includes(fp)) filesModified.push(fp);
267
- }
268
- }
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++;
269
256
  }
270
257
 
271
- const summaryParts = [
272
- `Session compaction — ${messages.length} messages`,
273
- `First user message: ${firstUserText}`,
274
- `Last assistant message: ${lastAssistantText}`,
275
- ];
276
- if (filesModified.length > 0) summaryParts.push(`Files modified: ${filesModified.join(", ")}`);
277
- const summary = summaryParts.join("\n");
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
+ }
278
264
 
279
- if (!shouldCapture(summary)) {
280
- logger.debug?.("sulcus: pre_compaction_capture dedup skip");
281
- return;
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++;
282
270
  }
283
271
 
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}`);
272
+ if (shouldCapture(summary)) {
273
+ stores.push(sulcusMem.add_memory(summary, "episodic").catch(() => {}));
290
274
  }
275
+
276
+ Promise.allSettled(stores);
277
+
278
+ logger.info(`sulcus: compaction mining — ${dCount} decisions, ${pCount} preferences, ${fCount} facts, 1 summary`);
291
279
  },
292
280
  };
293
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
+
294
303
  // ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
295
304
 
296
305
  class SulcusCloudClient {
@@ -365,9 +374,36 @@ class SulcusCloudClient {
365
374
  return { results };
366
375
  }
367
376
 
368
- 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 }> {
369
404
  const body: Record<string, unknown> = { label: content };
370
405
  if (memoryType) body.memory_type = memoryType;
406
+ if (namespace) body.namespace = namespace;
371
407
  const res = await this.request("POST", "/api/v1/agent/nodes", body) as Record<string, unknown> | null;
372
408
  return (res ?? { id: "unknown" }) as { id: string; [key: string]: unknown };
373
409
  }
@@ -401,6 +437,21 @@ class SulcusCloudClient {
401
437
  return this.request("POST", "/api/v1/agent/import", { format: "markdown", content: text });
402
438
  }
403
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
+
404
455
  async evaluate_triggers(event: unknown, contextJson?: string): Promise<unknown> {
405
456
  const body: Record<string, unknown> = { event };
406
457
  if (contextJson) {
@@ -587,6 +638,132 @@ function shouldCapture(content: string): boolean {
587
638
  return true;
588
639
  }
589
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
+
590
767
  // ─── HOOKS CONFIG LOADER ─────────────────────────────────────────────────────
591
768
 
592
769
  function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
@@ -614,6 +791,8 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
614
791
  import_markdown: { enabled: false },
615
792
  evaluate_triggers: { enabled: false },
616
793
  __sulcus_workflow__: { enabled: true },
794
+ memory_share: { enabled: false },
795
+ memory_cross_recall: { enabled: false },
617
796
  },
618
797
  };
619
798
  }
@@ -664,6 +843,27 @@ function formatRelativeTime(isoTimestamp: string): string {
664
843
  }
665
844
  }
666
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
+
667
867
  // ─── SDK RECALL HANDLER (for before_agent_start with prependContext) ──────────
668
868
 
669
869
  interface ProfileCache {
@@ -761,6 +961,266 @@ function buildSdkRecallHandler(
761
961
  };
762
962
  }
763
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
+
764
1224
  // ─── MEMORY RUNTIME BUILDER ───────────────────────────────────────────────────
765
1225
 
766
1226
  function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
@@ -769,7 +1229,7 @@ function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
769
1229
  return {
770
1230
  backend: "builtin" as const,
771
1231
  provider: "sulcus",
772
- model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus-local",
1232
+ model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus",
773
1233
  custom: { backendMode, transport: backendMode === "cloud" ? "remote" : "local" },
774
1234
  };
775
1235
  },
@@ -836,6 +1296,7 @@ interface ToolDeps {
836
1296
  logger: PluginLogger;
837
1297
  isAvailable: boolean;
838
1298
  siuRequest: ((method: string, path: string, body?: unknown) => Promise<unknown>) | null;
1299
+ triggerProcessor?: TriggerResultProcessor;
839
1300
  }
840
1301
 
841
1302
  interface ToolDefinition {
@@ -857,12 +1318,22 @@ const toolDefinitions: Record<string, ToolDefinition> = {
857
1318
  }),
858
1319
  },
859
1320
  options: { name: "memory_recall" },
860
- makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
1321
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger, triggerProcessor }) =>
861
1322
  async (_id, params) => {
862
1323
  if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
863
1324
  const searchNamespace = (params.namespace as string | undefined) ?? namespace;
864
1325
  const res = await sulcusMem.search_memory(params.query as string, (params.limit as number | undefined) ?? 5, searchNamespace);
865
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
+
866
1337
  return {
867
1338
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
868
1339
  details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace },
@@ -885,7 +1356,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
885
1356
  }),
886
1357
  },
887
1358
  options: { name: "memory_store" },
888
- makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
1359
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger, triggerProcessor }) =>
889
1360
  async (_id, params) => {
890
1361
  const content = params.content as string;
891
1362
  if (isJunkMemory(content)) {
@@ -896,6 +1367,15 @@ const toolDefinitions: Record<string, ToolDefinition> = {
896
1367
  const mtype = (params.memory_type as string | undefined) || "episodic";
897
1368
  const res = await sulcusMem.add_memory(content, mtype);
898
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
+
899
1379
  let trainResult: string | null = null;
900
1380
  if (params.train === true) {
901
1381
  try {
@@ -1159,6 +1639,96 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1159
1639
  };
1160
1640
  },
1161
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
+ },
1162
1732
  };
1163
1733
 
1164
1734
  // ─── FIRST-INSTALL HISTORY IMPORT ────────────────────────────────────────────
@@ -1270,6 +1840,10 @@ const sulcusPlugin = {
1270
1840
  const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
1271
1841
  const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
1272
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
+ : [];
1273
1847
 
1274
1848
  // ── Load hooks config ──
1275
1849
  const hooksConfig = loadHooksConfig(pluginConfig);
@@ -1335,6 +1909,11 @@ const sulcusPlugin = {
1335
1909
  ? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1336
1910
  : null;
1337
1911
 
1912
+ // ── Trigger result processor (Phase 3) ──
1913
+ const triggerProcessor = isCloudBackend && sulcusMem
1914
+ ? new TriggerResultProcessor(sulcusMem, logger)
1915
+ : undefined;
1916
+
1338
1917
  // ── Shared deps ──
1339
1918
  const toolDeps: ToolDeps = {
1340
1919
  sulcusMem,
@@ -1347,6 +1926,7 @@ const sulcusPlugin = {
1347
1926
  logger,
1348
1927
  isAvailable,
1349
1928
  siuRequest: siuRequestFn,
1929
+ triggerProcessor,
1350
1930
  };
1351
1931
 
1352
1932
  const handlerCtx: HookHandlerCtx = {
@@ -1429,27 +2009,86 @@ const sulcusPlugin = {
1429
2009
  }
1430
2010
  }
1431
2011
 
1432
- // 5. Enhanced before_agent_start with prependContext (SDK path)
1433
- // When autoRecall=true and cloud backend available, use prependContext SDK pattern.
1434
- // Falls back to legacy hook-based path when SDK is not available.
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.
1435
2015
  if (autoRecall && isCloudBackend && sulcusMem) {
1436
- const sdkRecallHandler = buildSdkRecallHandler(
2016
+ const promptBuildHandler = buildPromptBuildHandler(
1437
2017
  sulcusMem as SulcusCloudClient,
1438
2018
  namespace,
1439
2019
  maxRecallResults,
1440
- profileFrequency,
1441
- logger
2020
+ 500,
2021
+ logger,
2022
+ sharedNamespaces,
2023
+ triggerProcessor
1442
2024
  );
1443
2025
  const apiOn = api.on as (event: string, handler: unknown) => void;
1444
- apiOn("before_agent_start", async (event: Record<string, unknown>, ctx: unknown) => {
2026
+ apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
1445
2027
  try {
1446
- return await sdkRecallHandler(event, ctx);
2028
+ return await promptBuildHandler(event, ctx);
1447
2029
  } catch (err) {
1448
- logger.warn("sulcus: SDK recall hook threw: " + err);
2030
+ logger.warn("sulcus: before_prompt_build hook threw: " + err);
1449
2031
  return undefined;
1450
2032
  }
1451
2033
  });
1452
- 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
+ }
1453
2092
  }
1454
2093
 
1455
2094
  // 6. auto-capture on agent_end
@@ -1472,6 +2111,308 @@ const sulcusPlugin = {
1472
2111
  logger.info("sulcus: registered auto-capture (agent_end)");
1473
2112
  }
1474
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
+
1475
2416
  // ─────────────────────────────────────────────────────────────────────────
1476
2417
  // LEGACY HOOK REGISTRATION (config-driven, backward compat)
1477
2418
  // ─────────────────────────────────────────────────────────────────────────
@@ -1479,8 +2420,10 @@ const sulcusPlugin = {
1479
2420
  for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
1480
2421
  if (!hookConfig.enabled) continue;
1481
2422
 
1482
- // Skip before_agent_start if we already registered the SDK path
1483
- 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;
1484
2427
  // Skip agent_end if autoCapture SDK path already registered
1485
2428
  if (hookName === "agent_end" && autoCapture && hookConfig.action === "sivu_auto_capture") continue;
1486
2429
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digitalforgestudios/openclaw-sulcus",
3
- "version": "4.2.0",
3
+ "version": "5.2.0",
4
4
  "description": "Sulcus — thermodynamic memory + Apache AGE knowledge graph for OpenClaw agents. v4: registerMemoryRuntime, prependContext recall, registerMemoryPromptSection, registerService lifecycle, uiHints, provider-filtered auto-capture. SIU v2 pipeline auto-classifies and scores memories. Interaction-based decay (3 modes). Curator sleep-cycle. Relevance-weighted recall. Cross-agent sync.",
5
5
  "keywords": [
6
6
  "openclaw",