@digitalforgestudios/openclaw-sulcus 5.2.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
3
  import * as https from "node:https";
4
4
  import * as http from "node:http";
5
5
  import { URL } from "node:url";
@@ -91,7 +91,7 @@ const hookHandlers: Record<string, HookHandler> = {
91
91
  const { sulcusMem, namespace, logger } = ctx;
92
92
  if (!sulcusMem) return;
93
93
  const agentLabel = (event?.agentId as string) ?? "(unknown)";
94
- logger.info(`sulcus: before_agent_start hook triggered for agent ${agentLabel}`);
94
+ logger.info(`sulcus: auto_recall hook triggered for agent ${agentLabel}`);
95
95
  const prompt = typeof event?.prompt === "string" ? event.prompt : "";
96
96
  if (!prompt) return;
97
97
  try {
@@ -233,73 +233,64 @@ 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 { 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++;
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
+ }
256
269
  }
257
270
 
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
- }
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");
264
278
 
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++;
279
+ if (!shouldCapture(summary)) {
280
+ logger.debug?.("sulcus: pre_compaction_capture — dedup skip");
281
+ return;
270
282
  }
271
283
 
272
- if (shouldCapture(summary)) {
273
- stores.push(sulcusMem.add_memory(summary, "episodic").catch(() => {}));
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}`);
274
290
  }
275
-
276
- Promise.allSettled(stores);
277
-
278
- logger.info(`sulcus: compaction mining — ${dCount} decisions, ${pCount} preferences, ${fCount} facts, 1 summary`);
279
291
  },
280
292
  };
281
293
 
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
-
303
294
  // ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
304
295
 
305
296
  class SulcusCloudClient {
@@ -374,36 +365,9 @@ class SulcusCloudClient {
374
365
  return { results };
375
366
  }
376
367
 
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 }> {
368
+ async add_memory(content: string, memoryType?: string | null): Promise<{ id: string; [key: string]: unknown }> {
404
369
  const body: Record<string, unknown> = { label: content };
405
370
  if (memoryType) body.memory_type = memoryType;
406
- if (namespace) body.namespace = namespace;
407
371
  const res = await this.request("POST", "/api/v1/agent/nodes", body) as Record<string, unknown> | null;
408
372
  return (res ?? { id: "unknown" }) as { id: string; [key: string]: unknown };
409
373
  }
@@ -437,21 +401,6 @@ class SulcusCloudClient {
437
401
  return this.request("POST", "/api/v1/agent/import", { format: "markdown", content: text });
438
402
  }
439
403
 
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
-
455
404
  async evaluate_triggers(event: unknown, contextJson?: string): Promise<unknown> {
456
405
  const body: Record<string, unknown> = { event };
457
406
  if (contextJson) {
@@ -461,6 +410,27 @@ class SulcusCloudClient {
461
410
  return this.request("POST", "/api/v1/triggers/evaluate", body);
462
411
  }
463
412
 
413
+ async embed_text(text: string, namespace?: string): Promise<{ embedding: number[]; model: string; dimensions: number } | null> {
414
+ // NOTE: Requires Sulcus server >= v2.4 with /api/v1/agent/embed endpoint.
415
+ // Falls back to null if the endpoint is not available — caller handles gracefully.
416
+ try {
417
+ const body: Record<string, unknown> = { text };
418
+ if (namespace) body.namespace = namespace;
419
+ const res = await this.request("POST", "/api/v1/agent/embed", body) as Record<string, unknown> | null;
420
+ if (!res || !Array.isArray(res.embedding)) return null;
421
+ return {
422
+ embedding: res.embedding as number[],
423
+ model: (res.model as string) ?? "bge-small-en-v1.5",
424
+ dimensions: (res.dimensions as number) ?? (res.embedding as number[]).length,
425
+ };
426
+ } catch (e: unknown) {
427
+ // 404 = endpoint not deployed yet; warn once but don't break anything
428
+ const msg = e instanceof Error ? e.message : String(e);
429
+ if (msg.includes("404")) return null; // endpoint not available on this server version
430
+ throw e;
431
+ }
432
+ }
433
+
464
434
  async probe(): Promise<boolean> {
465
435
  try {
466
436
  await this.search_memory("probe", 1);
@@ -638,132 +608,6 @@ function shouldCapture(content: string): boolean {
638
608
  return true;
639
609
  }
640
610
 
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
-
767
611
  // ─── HOOKS CONFIG LOADER ─────────────────────────────────────────────────────
768
612
 
769
613
  function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
@@ -791,8 +635,6 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
791
635
  import_markdown: { enabled: false },
792
636
  evaluate_triggers: { enabled: false },
793
637
  __sulcus_workflow__: { enabled: true },
794
- memory_share: { enabled: false },
795
- memory_cross_recall: { enabled: false },
796
638
  },
797
639
  };
798
640
  }
@@ -810,8 +652,13 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
810
652
  mergedTools[name] = { ...(mergedTools[name] ?? { enabled: false }), ...override };
811
653
  }
812
654
 
813
- // Legacy compat: autoRecall flag → hooks.before_agent_start.enabled
655
+ // Legacy compat: autoRecall flag → hooks.before_prompt_build.enabled (v5.0.0+)
656
+ // Also keeps before_agent_start enabled for backward compat with older configs.
814
657
  if (apiConfig?.autoRecall === true) {
658
+ mergedHooks["before_prompt_build"] = {
659
+ ...(mergedHooks["before_prompt_build"] ?? { action: "auto_recall", enabled: false }),
660
+ enabled: true,
661
+ };
815
662
  mergedHooks["before_agent_start"] = {
816
663
  ...(mergedHooks["before_agent_start"] ?? { action: "auto_recall", enabled: false }),
817
664
  enabled: true,
@@ -843,28 +690,7 @@ function formatRelativeTime(isoTimestamp: string): string {
843
690
  }
844
691
  }
845
692
 
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
-
867
- // ─── SDK RECALL HANDLER (for before_agent_start with prependContext) ──────────
693
+ // ─── SDK RECALL HANDLER (for before_prompt_build with prependContext) ──────────
868
694
 
869
695
  interface ProfileCache {
870
696
  preferences: Record<string, unknown>[];
@@ -961,266 +787,6 @@ function buildSdkRecallHandler(
961
787
  };
962
788
  }
963
789
 
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
-
1224
790
  // ─── MEMORY RUNTIME BUILDER ───────────────────────────────────────────────────
1225
791
 
1226
792
  function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
@@ -1229,7 +795,7 @@ function buildMemoryRuntime(sulcusMem: SulcusCloudClient, backendMode: string) {
1229
795
  return {
1230
796
  backend: "builtin" as const,
1231
797
  provider: "sulcus",
1232
- model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus",
798
+ model: backendMode === "cloud" ? "sulcus-cloud" : "sulcus-local",
1233
799
  custom: { backendMode, transport: backendMode === "cloud" ? "remote" : "local" },
1234
800
  };
1235
801
  },
@@ -1296,7 +862,6 @@ interface ToolDeps {
1296
862
  logger: PluginLogger;
1297
863
  isAvailable: boolean;
1298
864
  siuRequest: ((method: string, path: string, body?: unknown) => Promise<unknown>) | null;
1299
- triggerProcessor?: TriggerResultProcessor;
1300
865
  }
1301
866
 
1302
867
  interface ToolDefinition {
@@ -1318,22 +883,12 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1318
883
  }),
1319
884
  },
1320
885
  options: { name: "memory_recall" },
1321
- makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger, triggerProcessor }) =>
886
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
1322
887
  async (_id, params) => {
1323
888
  if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
1324
889
  const searchNamespace = (params.namespace as string | undefined) ?? namespace;
1325
890
  const res = await sulcusMem.search_memory(params.query as string, (params.limit as number | undefined) ?? 5, searchNamespace);
1326
891
  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
-
1337
892
  return {
1338
893
  content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
1339
894
  details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace },
@@ -1356,7 +911,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1356
911
  }),
1357
912
  },
1358
913
  options: { name: "memory_store" },
1359
- makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger, triggerProcessor }) =>
914
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
1360
915
  async (_id, params) => {
1361
916
  const content = params.content as string;
1362
917
  if (isJunkMemory(content)) {
@@ -1367,15 +922,6 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1367
922
  const mtype = (params.memory_type as string | undefined) || "episodic";
1368
923
  const res = await sulcusMem.add_memory(content, mtype);
1369
924
  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
-
1379
925
  let trainResult: string | null = null;
1380
926
  if (params.train === true) {
1381
927
  try {
@@ -1639,96 +1185,6 @@ const toolDefinitions: Record<string, ToolDefinition> = {
1639
1185
  };
1640
1186
  },
1641
1187
  },
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
- },
1732
1188
  };
1733
1189
 
1734
1190
  // ─── FIRST-INSTALL HISTORY IMPORT ────────────────────────────────────────────
@@ -1815,6 +1271,17 @@ const sulcusPlugin = {
1815
1271
  ? resolve(pluginConfig.libDir as string)
1816
1272
  : resolve(process.env.HOME || "~", ".sulcus/lib");
1817
1273
 
1274
+ // Auto-create directories on first run (self-healing)
1275
+ const dataDir = resolve(process.env.HOME || "~", ".sulcus/data");
1276
+ for (const dir of [libDir, dataDir]) {
1277
+ if (!existsSync(dir)) {
1278
+ try {
1279
+ mkdirSync(dir, { recursive: true });
1280
+ logger.info(`sulcus: created directory ${dir}`);
1281
+ } catch { /* best effort — may be read-only in containers */ }
1282
+ }
1283
+ }
1284
+
1818
1285
  const storeLibPath = pluginConfig?.storeLibPath
1819
1286
  ? resolve(pluginConfig.storeLibPath as string)
1820
1287
  : resolve(libDir, process.platform === "darwin" ? "libsulcus_store.dylib" : "libsulcus_store.so");
@@ -1840,10 +1307,6 @@ const sulcusPlugin = {
1840
1307
  const autoCapture: boolean = (pluginConfig?.autoCapture as boolean | undefined) ?? false;
1841
1308
  const maxRecallResults: number = Math.min(20, Math.max(1, (pluginConfig?.maxRecallResults as number | undefined) ?? 5));
1842
1309
  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
- : [];
1847
1310
 
1848
1311
  // ── Load hooks config ──
1849
1312
  const hooksConfig = loadHooksConfig(pluginConfig);
@@ -1851,7 +1314,6 @@ const sulcusPlugin = {
1851
1314
  // ── Backend init ──
1852
1315
  let sulcusMem: SulcusCloudClient | null = null;
1853
1316
  let backendMode = "unavailable";
1854
- const nativeLoader = new NativeLibLoader(storeLibPath, vectorsLibPath);
1855
1317
 
1856
1318
  if (serverUrl && apiKey) {
1857
1319
  try {
@@ -1863,7 +1325,11 @@ const sulcusPlugin = {
1863
1325
  }
1864
1326
  }
1865
1327
 
1866
- if (sulcusMem === null) {
1328
+ // Only attempt native/WASM fallback if cloud mode was NOT configured or failed.
1329
+ // When serverUrl+apiKey are set, the user intends cloud mode — don't warn about
1330
+ // missing native libs that they never intended to use.
1331
+ const nativeLoader = new NativeLibLoader(storeLibPath, vectorsLibPath);
1332
+ if (sulcusMem === null && !(serverUrl && apiKey)) {
1867
1333
  nativeLoader.init(logger);
1868
1334
  if (nativeLoader.loaded) {
1869
1335
  const wasmJsPath = resolve(wasmDir, "sulcus_wasm.js");
@@ -1894,14 +1360,24 @@ const sulcusPlugin = {
1894
1360
 
1895
1361
  // ── Startup summary ──
1896
1362
  if (isAvailable) {
1897
- logger.info(`sulcus: registered (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture})`);
1363
+ logger.info(`sulcus: ready (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture})`);
1898
1364
  } else {
1365
+ // Give clear, actionable guidance instead of cryptic error chains
1899
1366
  const hints: string[] = [];
1900
- if (!serverUrl && !apiKey) hints.push("no serverUrl/apiKey for cloud mode");
1901
- else if (serverUrl && !apiKey) hints.push("serverUrl set but apiKey missing");
1902
- else if (!serverUrl && apiKey) hints.push("apiKey set but serverUrl missing");
1903
- if (nativeLoader.error) hints.push(`local: ${nativeLoader.error}`);
1904
- logger.warn(`sulcus: unavailable${hints.join("; ") || "unknown reason"}`);
1367
+ if (!serverUrl && !apiKey) {
1368
+ hints.push("To use Sulcus cloud: set serverUrl and apiKey in plugin config");
1369
+ hints.push("Get an API key at https://sulcus.ca/dashboard/settings");
1370
+ } else if (serverUrl && !apiKey) {
1371
+ hints.push("serverUrl is set but apiKey is missing add your API key to plugin config");
1372
+ } else if (!serverUrl && apiKey) {
1373
+ hints.push("apiKey is set but serverUrl is missing — add serverUrl (e.g. https://api.sulcus.ca)");
1374
+ } else {
1375
+ hints.push("Cloud connection failed — check serverUrl and apiKey are correct");
1376
+ }
1377
+ if (!serverUrl && !apiKey && nativeLoader.error) {
1378
+ hints.push(`Local mode: ${nativeLoader.error}`);
1379
+ }
1380
+ logger.warn(`sulcus: not ready — ${hints.join(". ")}`);
1905
1381
  }
1906
1382
 
1907
1383
  // ── SIU v2 request helper ──
@@ -1909,11 +1385,6 @@ const sulcusPlugin = {
1909
1385
  ? (method: string, path: string, body?: unknown) => (sulcusMem as SulcusCloudClient).request(method, path, body)
1910
1386
  : null;
1911
1387
 
1912
- // ── Trigger result processor (Phase 3) ──
1913
- const triggerProcessor = isCloudBackend && sulcusMem
1914
- ? new TriggerResultProcessor(sulcusMem, logger)
1915
- : undefined;
1916
-
1917
1388
  // ── Shared deps ──
1918
1389
  const toolDeps: ToolDeps = {
1919
1390
  sulcusMem,
@@ -1926,7 +1397,6 @@ const sulcusPlugin = {
1926
1397
  logger,
1927
1398
  isAvailable,
1928
1399
  siuRequest: siuRequestFn,
1929
- triggerProcessor,
1930
1400
  };
1931
1401
 
1932
1402
  const handlerCtx: HookHandlerCtx = {
@@ -2009,89 +1479,80 @@ const sulcusPlugin = {
2009
1479
  }
2010
1480
  }
2011
1481
 
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.
2015
- if (autoRecall && isCloudBackend && sulcusMem) {
2016
- const promptBuildHandler = buildPromptBuildHandler(
2017
- sulcusMem as SulcusCloudClient,
2018
- namespace,
2019
- maxRecallResults,
2020
- 500,
2021
- logger,
2022
- sharedNamespaces,
2023
- triggerProcessor
2024
- );
2025
- const apiOn = api.on as (event: string, handler: unknown) => void;
2026
- apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
2027
- try {
2028
- return await promptBuildHandler(event, ctx);
2029
- } catch (err) {
2030
- logger.warn("sulcus: before_prompt_build hook threw: " + err);
2031
- return undefined;
2032
- }
2033
- });
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) => {
1482
+ // 5. before_prompt_build recall + awareness (SDK path, v5.0.0+)
1483
+ // When autoRecall=true and cloud backend available: recall + inject awareness via prependContext.
1484
+ // When autoRecall=false but cloud backend available: inject awareness only (static context block).
1485
+ // Replaces legacy before_agent_start for new work; legacy hook loop handles fallback.
1486
+ if (isCloudBackend && sulcusMem) {
1487
+ if (autoRecall) {
1488
+ const sdkRecallHandler = buildSdkRecallHandler(
1489
+ sulcusMem as SulcusCloudClient,
1490
+ namespace,
1491
+ maxRecallResults,
1492
+ profileFrequency,
1493
+ logger
1494
+ );
1495
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1496
+ apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
2069
1497
  try {
2070
- logger.info("sulcus: session_end running final memory extraction");
2071
- return await hookHandlers.sivu_auto_capture(event, captureConfig, handlerCtx);
1498
+ // Recall returns prependContext with memories + awareness embedded
1499
+ const result = await sdkRecallHandler(event, ctx);
1500
+ // If recall returned nothing (no prompt), fall back to awareness-only
1501
+ if (!result) return { prependSystemContext: STATIC_AWARENESS };
1502
+ // Translate prependContext → prependSystemContext for hook shape compat
1503
+ const r = result as { prependContext?: string; prependSystemContext?: string };
1504
+ if (r.prependContext) return { prependSystemContext: r.prependContext };
1505
+ return result;
2072
1506
  } catch (err) {
2073
- logger.warn("sulcus: session_end hook threw: " + err);
2074
- return undefined;
1507
+ logger.warn("sulcus: before_prompt_build recall hook threw: " + err);
1508
+ return { prependSystemContext: STATIC_AWARENESS };
2075
1509
  }
2076
1510
  });
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
- }
1511
+ logger.info("sulcus: registered before_prompt_build (recall + awareness)");
1512
+ } else {
1513
+ // Awareness-only path — inject static context without recall
1514
+ const apiOn = api.on as (event: string, handler: unknown) => void;
1515
+ apiOn("before_prompt_build", async (_event: Record<string, unknown>, _ctx: unknown) => {
1516
+ return { prependSystemContext: STATIC_AWARENESS };
2086
1517
  });
1518
+ logger.info("sulcus: registered before_prompt_build (awareness-only)");
1519
+ }
1520
+ }
2087
1521
 
2088
- logger.info("sulcus: registered session_start, session_end, before_reset lifecycle hooks");
2089
- } else {
2090
- logger.info("sulcus: registered session_start lifecycle hook");
1522
+ // 6. registerMemoryEmbeddingProvider Sulcus embedding adapter
1523
+ if (typeof (api.registerMemoryEmbeddingProvider as unknown) === "function" && isCloudBackend && sulcusMem) {
1524
+ try {
1525
+ (api.registerMemoryEmbeddingProvider as (adapter: unknown) => void)({
1526
+ id: "sulcus",
1527
+ label: "Sulcus (BGE-small-en-v1.5)",
1528
+ transport: "remote",
1529
+ autoSelectPriority: 50,
1530
+ embed: async (texts: string[]) => {
1531
+ // Route through Sulcus cloud API for embeddings
1532
+ let warned = false;
1533
+ const results = await Promise.all(
1534
+ texts.map(async (text) => {
1535
+ const res = await (sulcusMem as SulcusCloudClient).embed_text(text, namespace);
1536
+ if (!res) {
1537
+ if (!warned) {
1538
+ warned = true;
1539
+ logger.warn("sulcus: embed_text returned null — /api/v1/agent/embed not available on this server version; embedding provider will return empty vectors");
1540
+ }
1541
+ return [];
1542
+ }
1543
+ return res.embedding;
1544
+ })
1545
+ );
1546
+ return { embeddings: results, model: "bge-small-en-v1.5", dimensions: 384 };
1547
+ },
1548
+ });
1549
+ logger.info("sulcus: registered memory embedding provider (BGE-small-en-v1.5)");
1550
+ } catch (e: unknown) {
1551
+ logger.warn(`sulcus: registerMemoryEmbeddingProvider failed: ${e instanceof Error ? e.message : e}`);
2091
1552
  }
2092
1553
  }
2093
1554
 
2094
- // 6. auto-capture on agent_end
1555
+ // 7. auto-capture on agent_end
2095
1556
  if (autoCapture) {
2096
1557
  const agentEndCaptureConfig: HookConfig = {
2097
1558
  action: "sivu_auto_capture",
@@ -2111,308 +1572,6 @@ const sulcusPlugin = {
2111
1572
  logger.info("sulcus: registered auto-capture (agent_end)");
2112
1573
  }
2113
1574
 
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
-
2416
1575
  // ─────────────────────────────────────────────────────────────────────────
2417
1576
  // LEGACY HOOK REGISTRATION (config-driven, backward compat)
2418
1577
  // ─────────────────────────────────────────────────────────────────────────
@@ -2420,10 +1579,10 @@ const sulcusPlugin = {
2420
1579
  for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
2421
1580
  if (!hookConfig.enabled) continue;
2422
1581
 
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;
1582
+ // Skip before_agent_start if we already registered the SDK path (v5: SDK uses before_prompt_build)
1583
+ if (hookName === "before_agent_start" && autoRecall && isCloudBackend) continue;
1584
+ // Skip before_prompt_build if we already registered the SDK handler above
1585
+ if (hookName === "before_prompt_build" && isCloudBackend && sulcusMem) continue;
2427
1586
  // Skip agent_end if autoCapture SDK path already registered
2428
1587
  if (hookName === "agent_end" && autoCapture && hookConfig.action === "sivu_auto_capture") continue;
2429
1588