@digitalforgestudios/openclaw-sulcus 6.1.0 → 6.6.2

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
@@ -554,6 +554,26 @@ let wasJustCompacted = false;
554
554
  // contextRebuild.tokenBudget (default 4000, max 10000).
555
555
  let REBUILD_TOKEN_BUDGET = 4000;
556
556
 
557
+ // --- CORE MEMORY CACHE (Phase 3) -----------------------------------------------
558
+ // Core memory is fetched once on first turn and cached for the session.
559
+ // It's refreshed when the agent explicitly updates it via core_memory_update.
560
+ // Max size enforced at ~4000 chars (~1000 tokens).
561
+ const CORE_MEMORY_MAX_CHARS = 4000;
562
+ let coreMemoryCache: Record<string, unknown> | null | undefined = undefined; // undefined = not fetched yet
563
+
564
+ // --- MULTI-USER NAMESPACE SCOPING (Phase 6) -----------------------------------
565
+ // Runtime namespace override — set by memory_namespace tool, cleared on session end.
566
+ // When set, overrides the config-level namespace for all memory operations.
567
+ let activeNamespaceOverride: string | null = null;
568
+
569
+ /**
570
+ * Phase 6: Return the effective namespace, preferring any runtime override
571
+ * set by the `memory_namespace` tool over the config-level default.
572
+ */
573
+ function getEffectiveNamespace(configNamespace: string): string {
574
+ return activeNamespaceOverride ?? configNamespace;
575
+ }
576
+
557
577
  // --- HOOK PROFILE STATE (Task 31) --------------------------------------------
558
578
  // Per-namespace profile cache for the auto_recall hook.
559
579
  // Mirrors the SDK recall handler: inject full profile on turn 1 + every N turns,
@@ -578,6 +598,8 @@ const hookHandlers: Record<string, HookHandler> = {
578
598
  // Task 31: Profile injection frequency — full profile on turn 1 + every N turns.
579
599
  const { sulcusMem, namespace, logger } = ctx;
580
600
  if (!sulcusMem) return;
601
+ // Phase 6: Multi-user namespace scoping — runtime override takes precedence
602
+ const effectiveNamespace = getEffectiveNamespace(namespace);
581
603
  const agentLabel = (event?.agentId as string) ?? "(unknown)";
582
604
  logger.info(`sulcus: auto_recall hook triggered for agent ${agentLabel}`);
583
605
  const rawPrompt = typeof event?.prompt === "string" ? event.prompt : "";
@@ -595,10 +617,10 @@ const hookHandlers: Record<string, HookHandler> = {
595
617
  // (prefs + facts) on turn 1 and every profileFrequency turns; serve
596
618
  // cache on stable turns to avoid redundant API calls.
597
619
  const profileFreq = ctx.profileFrequency ?? 10;
598
- let hookProfileState = hookProfileStateMap.get(namespace);
620
+ let hookProfileState = hookProfileStateMap.get(effectiveNamespace);
599
621
  if (!hookProfileState) {
600
622
  hookProfileState = { turnCount: 0, cache: null };
601
- hookProfileStateMap.set(namespace, hookProfileState);
623
+ hookProfileStateMap.set(effectiveNamespace, hookProfileState);
602
624
  }
603
625
  hookProfileState.turnCount++;
604
626
  const hookTurn = hookProfileState.turnCount;
@@ -620,7 +642,7 @@ const hookHandlers: Record<string, HookHandler> = {
620
642
  // -- end Task 31 + 102 --------------------------------------------------
621
643
 
622
644
  // -- Topic-shift detection (Task 14 parity) ----------------------------
623
- const cacheKey = namespace;
645
+ const cacheKey = effectiveNamespace;
624
646
  const currentTokens = extractTopicTokens(prompt);
625
647
  const existingCache = hookRecallCacheMap.get(cacheKey);
626
648
  const cacheExpired = existingCache !== undefined && (Date.now() - existingCache.cachedAt) > HOOK_CACHE_TTL_MS;
@@ -636,10 +658,10 @@ const hookHandlers: Record<string, HookHandler> = {
636
658
  if (existingCache !== undefined) {
637
659
  logger.info(`sulcus: auto_recall hook — TOPIC SHIFT detected (overlap=${overlap.toFixed(2)}), fresh recall`);
638
660
  }
639
- logger.debug?.(`sulcus: searching context for prompt (focused: ${recallQuery.substring(0, 50)}...) (namespace: ${namespace})`);
661
+ logger.debug?.(`sulcus: searching context for prompt (focused: ${recallQuery.substring(0, 50)}...) (namespace: ${effectiveNamespace})`);
640
662
  // Task 62: Use focused last-user-turn query for better relevance
641
663
  // Task 101: Use adaptive limit instead of raw config limit
642
- const res = await sulcusMem.search_memory(recallQuery, hookEffectiveLimit, namespace);
664
+ const res = await sulcusMem.search_memory(recallQuery, hookEffectiveLimit, effectiveNamespace);
643
665
  vectorResults = res?.results ?? [];
644
666
  recallQM.freshRecalls++; // Task 32: module-scope QM
645
667
  // Update cache with fresh results
@@ -660,7 +682,7 @@ const hookHandlers: Record<string, HookHandler> = {
660
682
  try {
661
683
  // Task 62: use focused recallQuery for entity expansion too
662
684
  const { extraMemories, expandedQuery } = await expandQueryWithEntities(
663
- sulcusMem, recallQuery, namespace, logger
685
+ sulcusMem, recallQuery, effectiveNamespace, logger
664
686
  );
665
687
  // Merge extra memories from entity graph (dedup by ID)
666
688
  const seenExpandIds = new Set(vectorResults.map((r) => r.id as string));
@@ -672,7 +694,7 @@ const hookHandlers: Record<string, HookHandler> = {
672
694
  // If still thin, do second vector search with expanded query
673
695
  if (hookExpanded.length < THIN_RECALL_THRESHOLD && expandedQuery !== recallQuery) {
674
696
  try {
675
- const expandedRes = await sulcusMem.search_memory(expandedQuery, hookEffectiveLimit, namespace);
697
+ const expandedRes = await sulcusMem.search_memory(expandedQuery, hookEffectiveLimit, effectiveNamespace);
676
698
  const expandedVec = expandedRes?.results ?? [];
677
699
  const expandedSeenIds = new Set(hookExpanded.map((r) => r.id as string));
678
700
  const newVecExtras = expandedVec.filter((r) => !expandedSeenIds.has(r.id as string));
@@ -742,8 +764,8 @@ const hookHandlers: Record<string, HookHandler> = {
742
764
  if (includeProfile) {
743
765
  try {
744
766
  const [prefRes, factRes] = await Promise.all([
745
- sulcusMem.search_memory("user preference", Math.min(hookEffectiveLimit, 5), namespace),
746
- sulcusMem.search_memory("fact data knowledge", Math.min(hookEffectiveLimit, 5), namespace),
767
+ sulcusMem.search_memory("user preference", Math.min(hookEffectiveLimit, 5), effectiveNamespace),
768
+ sulcusMem.search_memory("fact data knowledge", Math.min(hookEffectiveLimit, 5), effectiveNamespace),
747
769
  ]);
748
770
  profilePreferences = (prefRes?.results ?? []).filter((r) => r.memory_type === "preference");
749
771
  profileFacts = (factRes?.results ?? []).filter((r) => r.memory_type === "fact");
@@ -827,8 +849,49 @@ const hookHandlers: Record<string, HookHandler> = {
827
849
  recallElements.push(` <memory type="${mtype}" heat="${heatStr}" age="${ageStr}"${staleAttr}${supersededAttr}>${r.label}</memory>`);
828
850
  }
829
851
 
852
+ // -- Phase 3: Core Memory Block — always injected, never scaled ---------
853
+ // Core memory is identity/relationship/preference context that persists.
854
+ // It's tiny (~1000 tokens max) and critical for personality continuity.
855
+ let coreMemoryXml = "";
856
+ if (sulcusMem instanceof SulcusCloudClient) {
857
+ if (coreMemoryCache === undefined) {
858
+ // First fetch — load from server
859
+ try {
860
+ coreMemoryCache = await sulcusMem.get_core_memory();
861
+ if (coreMemoryCache) {
862
+ logger.info(`sulcus: core memory loaded (${JSON.stringify(coreMemoryCache).length} chars)`);
863
+ }
864
+ } catch {
865
+ coreMemoryCache = null; // failed — don't retry this session
866
+ }
867
+ }
868
+ if (coreMemoryCache && Object.keys(coreMemoryCache).length > 0) {
869
+ const coreLines: string[] = [];
870
+ for (const [key, value] of Object.entries(coreMemoryCache)) {
871
+ if (key === "namespace" || key === "updated_at" || key === "created_at") continue;
872
+ if (typeof value === "string" && value.trim()) {
873
+ coreLines.push(` <${key}>${escapeXml(value)}</${key}>`);
874
+ } else if (Array.isArray(value) && value.length > 0) {
875
+ const items = value.map((v: unknown) => ` <item>${escapeXml(String(v))}</item>`).join("\n");
876
+ coreLines.push(` <${key}>\n${items}\n </${key}>`);
877
+ } else if (typeof value === "object" && value !== null) {
878
+ const entries = Object.entries(value as Record<string, unknown>)
879
+ .filter(([, v]) => v !== null && v !== undefined && String(v).trim())
880
+ .map(([k, v]) => ` <${k}>${escapeXml(String(v))}</${k}>`).join("\n");
881
+ if (entries) coreLines.push(` <${key}>\n${entries}\n </${key}>`);
882
+ }
883
+ }
884
+ if (coreLines.length > 0) {
885
+ const raw = `<core_memory>\n${coreLines.join("\n")}\n</core_memory>`;
886
+ coreMemoryXml = raw.length > CORE_MEMORY_MAX_CHARS ? raw.substring(0, CORE_MEMORY_MAX_CHARS) + "\n</core_memory>" : raw;
887
+ }
888
+ }
889
+ }
890
+
830
891
  // -- Assemble context XML ------------------------------------------------
831
892
  const sections: string[] = [];
893
+ // Phase 3: Core memory is the FIRST section — always present, never scaled
894
+ if (coreMemoryXml) sections.push(coreMemoryXml);
832
895
  // Profile section (Task 31) — inject before recall so agent sees identity context first
833
896
  if (budgetedProfile.length > 0) {
834
897
  const profileElements: string[] = [];
@@ -851,7 +914,7 @@ const hookHandlers: Record<string, HookHandler> = {
851
914
  `<guidance>${guidance}</guidance>`,
852
915
  ...sections,
853
916
  ];
854
- const context = `<sulcus_context token_budget="${TOKEN_BUDGET}" namespace="${namespace}" turn="${hookTurn}">\n${contextParts.join("\n")}\n</sulcus_context>`;
917
+ const context = `<sulcus_context token_budget="${TOKEN_BUDGET}" namespace="${effectiveNamespace}" turn="${hookTurn}">\n${contextParts.join("\n")}\n</sulcus_context>`;
855
918
  const estimatedTokens = estimateTokens(context);
856
919
  // Task 32: track items served + avg relevance score in module-scope QM
857
920
  recallQM.totalItemsServed += budgeted.length;
@@ -1164,6 +1227,33 @@ const hookHandlers: Record<string, HookHandler> = {
1164
1227
  }
1165
1228
  }
1166
1229
 
1230
+ // -- Phase 4: Build structured episode object --------------------------------
1231
+ const episode: Record<string, unknown> = {
1232
+ topic: firstUserText,
1233
+ decisions: decisions.slice(0, 5),
1234
+ files_modified: filesModified.slice(0, 10),
1235
+ commands_run: commandsRun.slice(0, 5),
1236
+ errors: errors.slice(0, 3),
1237
+ outcome: "compacted",
1238
+ duration_turns: messages.length,
1239
+ timestamp: new Date().toISOString(),
1240
+ };
1241
+
1242
+ // Detect mood from message patterns
1243
+ const allText = messages.map(m => {
1244
+ const content = typeof m.content === "string" ? m.content : typeof m.text === "string" ? m.text as string : "";
1245
+ return content.toLowerCase();
1246
+ }).join(" ");
1247
+ if (allText.includes("error") || allText.includes("failed") || allText.includes("broken")) {
1248
+ episode.mood = "debugging";
1249
+ } else if (allText.includes("looks good") || allText.includes("working") || allText.includes("done")) {
1250
+ episode.mood = "productive";
1251
+ } else if (allText.includes("?") && allText.split("?").length > 3) {
1252
+ episode.mood = "exploratory";
1253
+ } else {
1254
+ episode.mood = "neutral";
1255
+ }
1256
+
1167
1257
  // --- Build primary summary memory ---
1168
1258
  const summaryParts = [
1169
1259
  `Session compaction — ${messages.length} messages`,
@@ -1216,6 +1306,15 @@ const hookHandlers: Record<string, HookHandler> = {
1216
1306
  }
1217
1307
  }
1218
1308
 
1309
+ // -- Phase 4: Store structured episode
1310
+ if (sulcusMem instanceof SulcusCloudClient) {
1311
+ storePromises.push(
1312
+ sulcusMem.store_episode(episode)
1313
+ .then((res) => logger.info(`sulcus: pre_compaction_capture — stored structured episode (id: ${res?.id ?? "?"})`)
1314
+ ).catch((e: unknown) => logger.debug?.(`sulcus: pre_compaction_capture — episode store failed: ${e instanceof Error ? e.message : String(e)}`))
1315
+ );
1316
+ }
1317
+
1219
1318
  // Fire all stores in parallel (non-blocking from OpenClaw's perspective)
1220
1319
  await Promise.allSettled(storePromises);
1221
1320
  logger.info(`sulcus: pre_compaction_capture — stored ${storePromises.length} memory/memories from ${messages.length}-message session`);
@@ -1651,6 +1750,118 @@ class SulcusCloudClient {
1651
1750
  throw e;
1652
1751
  }
1653
1752
  }
1753
+
1754
+ async graph_status(): Promise<Record<string, unknown>> {
1755
+ return this.request("GET", "/api/v1/agent/graph/status") as Promise<Record<string, unknown>>;
1756
+ }
1757
+
1758
+ async graph_temporal(query: string, timeFrom?: string, timeTo?: string, limit?: number): Promise<Record<string, unknown>[]> {
1759
+ const body: Record<string, unknown> = { query };
1760
+ if (timeFrom) body.time_from = timeFrom;
1761
+ if (timeTo) body.time_to = timeTo;
1762
+ if (limit) body.limit = limit;
1763
+ const res = await this.request("POST", "/api/v1/agent/graph/temporal", body);
1764
+ return (Array.isArray(res) ? res : []) as Record<string, unknown>[];
1765
+ }
1766
+
1767
+ async list_conflicts(namespace?: string, limit?: number): Promise<Record<string, unknown>[]> {
1768
+ const params = new URLSearchParams();
1769
+ if (namespace) params.set("namespace", namespace);
1770
+ if (limit) params.set("limit", String(limit));
1771
+ const qs = params.toString();
1772
+ const res = await this.request("GET", `/api/v1/agent/conflicts${qs ? "?" + qs : ""}`);
1773
+ return (Array.isArray(res) ? res : ((res as Record<string, unknown>)?.conflicts ?? [])) as Record<string, unknown>[];
1774
+ }
1775
+
1776
+ async resolve_conflict(id: string, resolution: string): Promise<Record<string, unknown>> {
1777
+ return this.request("PATCH", `/api/v1/agent/conflicts/${encodeURIComponent(id)}`, { resolution }) as Promise<Record<string, unknown>>;
1778
+ }
1779
+
1780
+ async list_archived(namespace?: string, limit?: number, offset?: number): Promise<Record<string, unknown>> {
1781
+ const params = new URLSearchParams();
1782
+ if (namespace) params.set("namespace", namespace);
1783
+ if (limit) params.set("limit", String(limit));
1784
+ if (offset) params.set("offset", String(offset));
1785
+ const qs = params.toString();
1786
+ return this.request("GET", `/api/v1/agent/archive${qs ? "?" + qs : ""}`) as Promise<Record<string, unknown>>;
1787
+ }
1788
+
1789
+ async restore_memories(ids: string[], namespace?: string): Promise<Record<string, unknown>> {
1790
+ return this.request("POST", "/api/v1/agent/restore", { ids, namespace }) as Promise<Record<string, unknown>>;
1791
+ }
1792
+
1793
+ async fold_memories(ids: string[], namespace?: string, label?: string): Promise<Record<string, unknown>> {
1794
+ return this.request("POST", "/api/v1/agent/fold", { ids, label, namespace }) as Promise<Record<string, unknown>>;
1795
+ }
1796
+
1797
+ async dashboard_stats(): Promise<Record<string, unknown>> {
1798
+ return this.request("GET", "/api/v1/admin/dashboard") as Promise<Record<string, unknown>>;
1799
+ }
1800
+
1801
+ async storage_status(): Promise<Record<string, unknown>> {
1802
+ return this.request("GET", "/api/v1/agent/storage") as Promise<Record<string, unknown>>;
1803
+ }
1804
+
1805
+ async get_core_memory(): Promise<Record<string, unknown> | null> {
1806
+ try {
1807
+ const params = new URLSearchParams();
1808
+ if (this.namespace) params.set("namespace", this.namespace);
1809
+ const qs = params.toString();
1810
+ return await this.request("GET", `/api/v1/agent/core-memory${qs ? "?" + qs : ""}`) as Record<string, unknown>;
1811
+ } catch {
1812
+ return null;
1813
+ }
1814
+ }
1815
+
1816
+ async update_core_memory(updates: Record<string, unknown>): Promise<Record<string, unknown>> {
1817
+ const body: Record<string, unknown> = { ...updates };
1818
+ if (this.namespace) body.namespace = this.namespace;
1819
+ return this.request("PATCH", "/api/v1/agent/core-memory", body) as Promise<Record<string, unknown>>;
1820
+ }
1821
+
1822
+ // -- Phase 4: Episodic Session Layer ----------------------------------------
1823
+ async store_episode(episode: Record<string, unknown>): Promise<{ id: string; [key: string]: unknown }> {
1824
+ // Store as episodic memory with structured metadata hints
1825
+ const content = this.formatEpisodeSummary(episode);
1826
+ const hints: Record<string, unknown> = {
1827
+ memory_type: "episodic",
1828
+ context_note: "Structured session episode — contains topic, decisions, files, and outcome.",
1829
+ episode_metadata: episode,
1830
+ };
1831
+ return this.request("POST", "/api/v1/agent/store", {
1832
+ content,
1833
+ memory_type: "episodic",
1834
+ namespace: this.namespace,
1835
+ hints,
1836
+ metadata: episode,
1837
+ }) as Promise<{ id: string; [key: string]: unknown }>;
1838
+ }
1839
+
1840
+ private formatEpisodeSummary(episode: Record<string, unknown>): string {
1841
+ const parts: string[] = [];
1842
+ if (episode.topic) parts.push(`Topic: ${episode.topic}`);
1843
+ if (Array.isArray(episode.decisions) && episode.decisions.length > 0) {
1844
+ parts.push(`Decisions: ${(episode.decisions as string[]).join("; ")}`);
1845
+ }
1846
+ if (Array.isArray(episode.files_modified) && episode.files_modified.length > 0) {
1847
+ parts.push(`Files: ${(episode.files_modified as string[]).join(", ")}`);
1848
+ }
1849
+ if (Array.isArray(episode.errors) && episode.errors.length > 0) {
1850
+ parts.push(`Errors: ${(episode.errors as string[]).join("; ")}`);
1851
+ }
1852
+ if (episode.outcome) parts.push(`Outcome: ${episode.outcome}`);
1853
+ if (episode.mood) parts.push(`Mood: ${episode.mood}`);
1854
+ if (episode.duration_turns) parts.push(`Duration: ${episode.duration_turns} turns`);
1855
+ return `Session episode: ${parts.join(" | ")}`;
1856
+ }
1857
+
1858
+ // -- Phase 6: Multi-user namespace listing ----------------------------------------
1859
+ async list_namespaces(): Promise<Record<string, unknown>[]> {
1860
+ const res = await this.request("GET", "/api/v1/agent/namespaces") as Record<string, unknown> | unknown[];
1861
+ if (Array.isArray(res)) return res as Record<string, unknown>[];
1862
+ const data = res as Record<string, unknown>;
1863
+ return (data?.namespaces ?? data?.results ?? []) as Record<string, unknown>[];
1864
+ }
1654
1865
  }
1655
1866
 
1656
1867
  // --- NATIVE LIB LOADER ------------------------------------------------------
@@ -1856,6 +2067,24 @@ function loadHooksConfig(apiConfig: Record<string, unknown>): HooksConfig {
1856
2067
  __sulcus_workflow__: { enabled: true },
1857
2068
  session_store: { enabled: true },
1858
2069
  session_recall: { enabled: true },
2070
+ memory_get: { enabled: true },
2071
+ memory_list: { enabled: true },
2072
+ memory_update: { enabled: true },
2073
+ siu_label: { enabled: false },
2074
+ siu_status: { enabled: false },
2075
+ siu_retrain: { enabled: false },
2076
+ trigger_feedback: { enabled: false },
2077
+ graph_explore: { enabled: true },
2078
+ memory_conflicts: { enabled: true },
2079
+ core_memory_read: { enabled: true },
2080
+ core_memory_update: { enabled: true },
2081
+ memory_archive: { enabled: true },
2082
+ memory_fold: { enabled: true },
2083
+ memory_dashboard: { enabled: true },
2084
+ episode_recall: { enabled: true },
2085
+ memory_namespace: { enabled: true },
2086
+ namespace_list: { enabled: true },
2087
+ sulcus_setup: { enabled: true },
1859
2088
  },
1860
2089
  };
1861
2090
  }
@@ -2148,6 +2377,18 @@ function estimateTokens(text: string): number {
2148
2377
  return Math.ceil(text.length / 4);
2149
2378
  }
2150
2379
 
2380
+ /**
2381
+ * XML-escape a string for safe injection into XML context blocks.
2382
+ */
2383
+ function escapeXml(str: string): string {
2384
+ return str
2385
+ .replace(/&/g, "&amp;")
2386
+ .replace(/</g, "&lt;")
2387
+ .replace(/>/g, "&gt;")
2388
+ .replace(/"/g, "&quot;")
2389
+ .replace(/'/g, "&apos;");
2390
+ }
2391
+
2151
2392
  /**
2152
2393
  * Truncate a memory label to fit within a character budget.
2153
2394
  * Appends ellipsis if truncated. Prefers word-boundary cuts.
@@ -2709,6 +2950,9 @@ function buildSdkRecallHandler(
2709
2950
  const rawPrompt = typeof event?.prompt === "string" ? event.prompt : "";
2710
2951
  if (!rawPrompt || rawPrompt.length < 5) return undefined;
2711
2952
 
2953
+ // Phase 6: Multi-user namespace scoping — runtime override takes precedence
2954
+ const effectiveNamespace = getEffectiveNamespace(namespace);
2955
+
2712
2956
  // Strip OpenClaw metadata noise before using as search query
2713
2957
  const prompt = sanitizeRecallQuery(rawPrompt);
2714
2958
  if (!prompt || prompt.length < 3) return undefined;
@@ -2726,9 +2970,39 @@ function buildSdkRecallHandler(
2726
2970
  // fill the window fast regardless of turn count.
2727
2971
  const throttled = applyContextWindowThrottle(rawPrompt.length, contextWindowSize, sdkScale, logger);
2728
2972
  if (throttled.selfMuted) {
2729
- // Context window is critically full. Don't inject anything — let the model breathe.
2730
- // Return minimal awareness only (no recall, no profile).
2731
- return { prependContext: `<!-- sulcus: self-muted, context ${((rawPrompt.length / 4 / contextWindowSize) * 100).toFixed(0)}% full -->` };
2973
+ // Context window is critically full. Don't inject recall/profile — let the model breathe.
2974
+ // Phase 3: Core memory is tiny (~1000 tokens) always inject it even when self-muted.
2975
+ let selfMutedCore = "";
2976
+ if (coreMemoryCache === undefined) {
2977
+ try {
2978
+ coreMemoryCache = await sulcusMem.get_core_memory();
2979
+ } catch {
2980
+ coreMemoryCache = null;
2981
+ }
2982
+ }
2983
+ if (coreMemoryCache && Object.keys(coreMemoryCache).length > 0) {
2984
+ const coreLines: string[] = [];
2985
+ for (const [key, value] of Object.entries(coreMemoryCache)) {
2986
+ if (key === "namespace" || key === "updated_at" || key === "created_at") continue;
2987
+ if (typeof value === "string" && value.trim()) {
2988
+ coreLines.push(` <${key}>${escapeXml(value)}</${key}>`);
2989
+ } else if (Array.isArray(value) && value.length > 0) {
2990
+ const items = value.map((v: unknown) => ` <item>${escapeXml(String(v))}</item>`).join("\n");
2991
+ coreLines.push(` <${key}>\n${items}\n </${key}>`);
2992
+ } else if (typeof value === "object" && value !== null) {
2993
+ const entries = Object.entries(value as Record<string, unknown>)
2994
+ .filter(([, v]) => v !== null && v !== undefined && String(v).trim())
2995
+ .map(([k, v]) => ` <${k}>${escapeXml(String(v))}</${k}>`).join("\n");
2996
+ if (entries) coreLines.push(` <${key}>\n${entries}\n </${key}>`);
2997
+ }
2998
+ }
2999
+ if (coreLines.length > 0) {
3000
+ const raw = `<core_memory>\n${coreLines.join("\n")}\n</core_memory>`;
3001
+ selfMutedCore = raw.length > CORE_MEMORY_MAX_CHARS ? raw.substring(0, CORE_MEMORY_MAX_CHARS) + "\n</core_memory>" : raw;
3002
+ }
3003
+ }
3004
+ const mutedComment = `<!-- sulcus: self-muted, context ${((rawPrompt.length / 4 / contextWindowSize) * 100).toFixed(0)}% full -->`;
3005
+ return { prependContext: selfMutedCore ? `${selfMutedCore}\n${mutedComment}` : mutedComment };
2732
3006
  }
2733
3007
  const effectiveMax = throttled.effectiveMax;
2734
3008
  const effectiveTokenBudget = throttled.effectiveTokenBudget;
@@ -2839,7 +3113,7 @@ function buildSdkRecallHandler(
2839
3113
  try {
2840
3114
  // Task 62: Use focused recallQuery instead of full accumulated prompt
2841
3115
  // Task 101: Use adaptive limit instead of raw config maxResults
2842
- const searchRes = await sulcusMem.search_memory(recallQuery, effectiveMax, namespace);
3116
+ const searchRes = await sulcusMem.search_memory(recallQuery, effectiveMax, effectiveNamespace);
2843
3117
  const vectorResults = searchRes?.results ?? [];
2844
3118
 
2845
3119
  // -- Task 35: Query expansion for thin recall (SDK path) ---------------
@@ -2848,7 +3122,7 @@ function buildSdkRecallHandler(
2848
3122
  try {
2849
3123
  // Task 62: use focused recallQuery for entity expansion
2850
3124
  const { extraMemories: sdkExtraMem, expandedQuery: sdkExpandedQ } = await expandQueryWithEntities(
2851
- sulcusMem, recallQuery, namespace, logger
3125
+ sulcusMem, recallQuery, effectiveNamespace, logger
2852
3126
  );
2853
3127
  const sdkSeenIds = new Set(vectorResults.map((r) => r.id as string));
2854
3128
  const sdkNewExtras = sdkExtraMem.filter((m) => !sdkSeenIds.has(m.id as string));
@@ -2858,7 +3132,7 @@ function buildSdkRecallHandler(
2858
3132
  }
2859
3133
  if (sdkExpanded.length < THIN_RECALL_THRESHOLD && sdkExpandedQ !== recallQuery) {
2860
3134
  try {
2861
- const sdkExpandedRes = await sulcusMem.search_memory(sdkExpandedQ, effectiveMax, namespace);
3135
+ const sdkExpandedRes = await sulcusMem.search_memory(sdkExpandedQ, effectiveMax, effectiveNamespace);
2862
3136
  const sdkExpandedVec = sdkExpandedRes?.results ?? [];
2863
3137
  const sdkExpandedSeen = new Set(sdkExpanded.map((r) => r.id as string));
2864
3138
  const sdkExpandedNew = sdkExpandedVec.filter((r) => !sdkExpandedSeen.has(r.id as string));
@@ -2939,8 +3213,8 @@ function buildSdkRecallHandler(
2939
3213
 
2940
3214
  if (includeProfile) {
2941
3215
  try {
2942
- const prefRes = await sulcusMem.search_memory("user preference", Math.min(effectiveMax, 5), namespace);
2943
- const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(effectiveMax, 5), namespace);
3216
+ const prefRes = await sulcusMem.search_memory("user preference", Math.min(effectiveMax, 5), effectiveNamespace);
3217
+ const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(effectiveMax, 5), effectiveNamespace);
2944
3218
  preferences = (prefRes?.results ?? []).filter((r) => r.memory_type === "preference");
2945
3219
  facts = (factRes?.results ?? []).filter((r) => r.memory_type === "fact");
2946
3220
  profileCache = { preferences, facts, cachedAt: Date.now() };
@@ -3050,7 +3324,43 @@ function buildSdkRecallHandler(
3050
3324
  }
3051
3325
  // -- end Task 79 (SDK) -------------------------------------------------------
3052
3326
 
3327
+ // -- Phase 3: Core Memory Block (SDK path) — always injected, never scaled ---
3328
+ let sdkCoreMemoryXml = "";
3329
+ if (coreMemoryCache === undefined) {
3330
+ try {
3331
+ coreMemoryCache = await sulcusMem.get_core_memory();
3332
+ if (coreMemoryCache) {
3333
+ logger.info(`sulcus: core memory loaded (${JSON.stringify(coreMemoryCache).length} chars)`);
3334
+ }
3335
+ } catch {
3336
+ coreMemoryCache = null;
3337
+ }
3338
+ }
3339
+ if (coreMemoryCache && Object.keys(coreMemoryCache).length > 0) {
3340
+ const sdkCoreLines: string[] = [];
3341
+ for (const [key, value] of Object.entries(coreMemoryCache)) {
3342
+ if (key === "namespace" || key === "updated_at" || key === "created_at") continue;
3343
+ if (typeof value === "string" && value.trim()) {
3344
+ sdkCoreLines.push(` <${key}>${escapeXml(value)}</${key}>`);
3345
+ } else if (Array.isArray(value) && value.length > 0) {
3346
+ const items = value.map((v: unknown) => ` <item>${escapeXml(String(v))}</item>`).join("\n");
3347
+ sdkCoreLines.push(` <${key}>\n${items}\n </${key}>`);
3348
+ } else if (typeof value === "object" && value !== null) {
3349
+ const entries = Object.entries(value as Record<string, unknown>)
3350
+ .filter(([, v]) => v !== null && v !== undefined && String(v).trim())
3351
+ .map(([k, v]) => ` <${k}>${escapeXml(String(v))}</${k}>`).join("\n");
3352
+ if (entries) sdkCoreLines.push(` <${key}>\n${entries}\n </${key}>`);
3353
+ }
3354
+ }
3355
+ if (sdkCoreLines.length > 0) {
3356
+ const raw = `<core_memory>\n${sdkCoreLines.join("\n")}\n</core_memory>`;
3357
+ sdkCoreMemoryXml = raw.length > CORE_MEMORY_MAX_CHARS ? raw.substring(0, CORE_MEMORY_MAX_CHARS) + "\n</core_memory>" : raw;
3358
+ }
3359
+ }
3360
+
3053
3361
  const sections: string[] = [];
3362
+ // Phase 3: Core memory is the FIRST section — always present, never scaled
3363
+ if (sdkCoreMemoryXml) sections.push(sdkCoreMemoryXml);
3054
3364
 
3055
3365
  // Task 18: use budgetedProfile (heat-sorted, budget-trimmed, labels already normalized)
3056
3366
  if (includeProfile && budgetedProfile.length > 0) {
@@ -3109,7 +3419,7 @@ function buildSdkRecallHandler(
3109
3419
  `<guidance>${guidance}</guidance>`,
3110
3420
  ];
3111
3421
  contextParts.push(...sections);
3112
- const context = `<sulcus_context token_budget="${TOKEN_BUDGET}" namespace="${namespace}">\n${contextParts.join("\n")}\n</sulcus_context>`;
3422
+ const context = `<sulcus_context token_budget="${TOKEN_BUDGET}" namespace="${effectiveNamespace}">\n${contextParts.join("\n")}\n</sulcus_context>`;
3113
3423
 
3114
3424
  // Task 18: log budget utilisation
3115
3425
  const estimatedTokens = estimateTokens(context);
@@ -4167,6 +4477,544 @@ const toolDefinitions: Record<string, ToolDefinition> = {
4167
4477
  };
4168
4478
  },
4169
4479
  },
4480
+
4481
+ graph_explore: {
4482
+ schema: {
4483
+ name: "graph_explore",
4484
+ label: "Graph Explore",
4485
+ description: "Explore the knowledge graph around a memory (neighbors mode) or query temporal connections (temporal mode).",
4486
+ parameters: Type.Object({
4487
+ mode: Type.Union([Type.Literal("neighbors"), Type.Literal("temporal")], { description: "Exploration mode: 'neighbors' for graph edges around a memory, 'temporal' for time-based connections." }),
4488
+ memory_id: Type.Optional(Type.String({ description: "Memory node UUID. Required for neighbors mode." })),
4489
+ query: Type.Optional(Type.String({ description: "Search query for temporal mode." })),
4490
+ time_from: Type.Optional(Type.String({ description: "ISO 8601 start time for temporal range filter." })),
4491
+ time_to: Type.Optional(Type.String({ description: "ISO 8601 end time for temporal range filter." })),
4492
+ limit: Type.Optional(Type.Number({ default: 10, description: "Max results to return (default 10).", minimum: 1, maximum: 50 })),
4493
+ }),
4494
+ },
4495
+ options: { name: "graph_explore" },
4496
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4497
+ async (_id, params) => {
4498
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4499
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("graph_explore requires cloud backend");
4500
+ const mode = params.mode as string;
4501
+ const limit = (params.limit as number | undefined) ?? 10;
4502
+ if (mode === "neighbors") {
4503
+ const memoryId = params.memory_id as string | undefined;
4504
+ if (!memoryId) return { content: [{ type: "text", text: "memory_id is required for neighbors mode." }] };
4505
+ const neighbors = await sulcusMem.graph_neighbors(memoryId, limit);
4506
+ return {
4507
+ content: [{ type: "text", text: JSON.stringify(neighbors, null, 2) }],
4508
+ details: { mode, memory_id: memoryId, count: neighbors.length, backend: backendMode, namespace },
4509
+ };
4510
+ } else {
4511
+ const query = params.query as string | undefined;
4512
+ if (!query) return { content: [{ type: "text", text: "query is required for temporal mode." }] };
4513
+ const results = await sulcusMem.graph_temporal(
4514
+ query,
4515
+ params.time_from as string | undefined,
4516
+ params.time_to as string | undefined,
4517
+ limit,
4518
+ );
4519
+ return {
4520
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
4521
+ details: { mode, query, count: results.length, backend: backendMode, namespace },
4522
+ };
4523
+ }
4524
+ },
4525
+ },
4526
+
4527
+ memory_conflicts: {
4528
+ schema: {
4529
+ name: "memory_conflicts",
4530
+ label: "Memory Conflicts",
4531
+ description: "List detected memory conflicts or resolve a specific conflict.",
4532
+ parameters: Type.Object({
4533
+ action: Type.Union([Type.Literal("list"), Type.Literal("resolve")], { description: "Action: 'list' to see conflicts, 'resolve' to resolve one." }),
4534
+ id: Type.Optional(Type.String({ description: "Conflict UUID. Required for resolve action." })),
4535
+ resolution: Type.Optional(Type.Union([
4536
+ Type.Literal("keep_newer"),
4537
+ Type.Literal("keep_older"),
4538
+ Type.Literal("merge"),
4539
+ Type.Literal("dismiss"),
4540
+ ], { description: "Resolution strategy. Required for resolve action." })),
4541
+ limit: Type.Optional(Type.Number({ default: 10, description: "Max conflicts to return for list action (default 10).", minimum: 1, maximum: 100 })),
4542
+ }),
4543
+ },
4544
+ options: { name: "memory_conflicts" },
4545
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4546
+ async (_id, params) => {
4547
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4548
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_conflicts requires cloud backend");
4549
+ const action = params.action as string;
4550
+ if (action === "list") {
4551
+ const limit = (params.limit as number | undefined) ?? 10;
4552
+ const conflicts = await sulcusMem.list_conflicts(namespace, limit);
4553
+ const summary = conflicts.length === 0 ? "No conflicts found." : `${conflicts.length} conflict(s) found.`;
4554
+ return {
4555
+ content: [{ type: "text", text: summary + "\n" + JSON.stringify(conflicts, null, 2) }],
4556
+ details: { action, count: conflicts.length, backend: backendMode, namespace },
4557
+ };
4558
+ } else {
4559
+ const conflictId = params.id as string | undefined;
4560
+ const resolution = params.resolution as string | undefined;
4561
+ if (!conflictId) return { content: [{ type: "text", text: "id is required for resolve action." }] };
4562
+ if (!resolution) return { content: [{ type: "text", text: "resolution is required for resolve action." }] };
4563
+ const res = await sulcusMem.resolve_conflict(conflictId, resolution);
4564
+ return {
4565
+ content: [{ type: "text", text: `Conflict ${conflictId} resolved with strategy: ${resolution}` }],
4566
+ details: { action, id: conflictId, resolution, result: res, backend: backendMode, namespace },
4567
+ };
4568
+ }
4569
+ },
4570
+ },
4571
+
4572
+ core_memory_read: {
4573
+ schema: {
4574
+ name: "core_memory_read",
4575
+ label: "Core Memory Read",
4576
+ description: "Read the current core memory block — the persistent structured identity context always injected into agent sessions. Contains identity, relationships, preferences, current_focus, and custom fields.",
4577
+ parameters: Type.Object({}),
4578
+ },
4579
+ options: { name: "core_memory_read" },
4580
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4581
+ async (_id, _params) => {
4582
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4583
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("core_memory_read requires cloud backend");
4584
+ const core = await sulcusMem.get_core_memory();
4585
+ if (!core || Object.keys(core).length === 0) {
4586
+ return {
4587
+ content: [{ type: "text", text: "No core memory set. Use core_memory_update to create your identity block." }],
4588
+ details: { backend: backendMode, namespace },
4589
+ };
4590
+ }
4591
+ return {
4592
+ content: [{ type: "text", text: JSON.stringify(core, null, 2) }],
4593
+ details: { backend: backendMode, namespace, fields: Object.keys(core) },
4594
+ };
4595
+ },
4596
+ },
4597
+
4598
+ core_memory_update: {
4599
+ schema: {
4600
+ name: "core_memory_update",
4601
+ label: "Core Memory Update",
4602
+ description: "Update fields in the core memory block. Core memory is a persistent structured identity context always injected into agent sessions. Only provide the fields you want to update.",
4603
+ parameters: Type.Object({
4604
+ identity: Type.Optional(Type.String({ description: "Who the agent is: name, role, and description." })),
4605
+ relationships: Type.Optional(Type.String({ description: "Key people and entities the agent works with." })),
4606
+ preferences: Type.Optional(Type.String({ description: "Agent preferences and communication style." })),
4607
+ current_focus: Type.Optional(Type.String({ description: "What the agent is currently working on (mutable)." })),
4608
+ custom: Type.Optional(Type.String({ description: "JSON string of additional freeform key-value pairs." })),
4609
+ }),
4610
+ },
4611
+ options: { name: "core_memory_update" },
4612
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4613
+ async (_id, params) => {
4614
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4615
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("core_memory_update requires cloud backend");
4616
+ const updates: Record<string, unknown> = {};
4617
+ if (typeof params.identity === "string" && params.identity.trim()) updates.identity = params.identity.trim();
4618
+ if (typeof params.relationships === "string" && params.relationships.trim()) updates.relationships = params.relationships.trim();
4619
+ if (typeof params.preferences === "string" && params.preferences.trim()) updates.preferences = params.preferences.trim();
4620
+ if (typeof params.current_focus === "string" && params.current_focus.trim()) updates.current_focus = params.current_focus.trim();
4621
+ if (typeof params.custom === "string" && params.custom.trim()) {
4622
+ try {
4623
+ updates.custom = JSON.parse(params.custom.trim());
4624
+ } catch {
4625
+ return { content: [{ type: "text", text: "Invalid JSON in custom field." }] };
4626
+ }
4627
+ }
4628
+ if (Object.keys(updates).length === 0) {
4629
+ return { content: [{ type: "text", text: "No fields provided to update." }] };
4630
+ }
4631
+ const res = await sulcusMem.update_core_memory(updates);
4632
+ // Invalidate the module-scope cache so next turn fetches fresh core memory
4633
+ coreMemoryCache = undefined;
4634
+ return {
4635
+ content: [{ type: "text", text: `Core memory updated. Fields changed: ${Object.keys(updates).join(", ")}` }],
4636
+ details: { updated: Object.keys(updates), result: res, backend: backendMode, namespace },
4637
+ };
4638
+ },
4639
+ },
4640
+
4641
+ memory_archive: {
4642
+ schema: {
4643
+ name: "memory_archive",
4644
+ label: "Memory Archive",
4645
+ description: "List archived memories or restore specific ones from the archive.",
4646
+ parameters: Type.Object({
4647
+ action: Type.Union([Type.Literal("list"), Type.Literal("restore")], { description: "Action: 'list' to browse archived memories, 'restore' to un-archive specific ones." }),
4648
+ ids: Type.Optional(Type.Array(Type.String(), { description: "Memory UUIDs to restore. Required for restore action." })),
4649
+ limit: Type.Optional(Type.Number({ default: 20, description: "Max archived memories to return for list action (default 20).", minimum: 1, maximum: 100 })),
4650
+ offset: Type.Optional(Type.Number({ default: 0, description: "Pagination offset for list action (default 0).", minimum: 0 })),
4651
+ }),
4652
+ },
4653
+ options: { name: "memory_archive" },
4654
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4655
+ async (_id, params) => {
4656
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4657
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_archive requires cloud backend");
4658
+ const action = params.action as string;
4659
+ if (action === "list") {
4660
+ const limit = (params.limit as number | undefined) ?? 20;
4661
+ const offset = (params.offset as number | undefined) ?? 0;
4662
+ const res = await sulcusMem.list_archived(namespace, limit, offset);
4663
+ return {
4664
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
4665
+ details: { action, limit, offset, backend: backendMode, namespace },
4666
+ };
4667
+ } else {
4668
+ const ids = params.ids as string[] | undefined;
4669
+ if (!ids || ids.length === 0) return { content: [{ type: "text", text: "ids array is required for restore action." }] };
4670
+ const res = await sulcusMem.restore_memories(ids, namespace);
4671
+ return {
4672
+ content: [{ type: "text", text: `Restored ${ids.length} memory(ies) from archive.` }],
4673
+ details: { action, ids, result: res, backend: backendMode, namespace },
4674
+ };
4675
+ }
4676
+ },
4677
+ },
4678
+
4679
+ memory_fold: {
4680
+ schema: {
4681
+ name: "memory_fold",
4682
+ label: "Memory Fold",
4683
+ description: "Merge multiple related memories into a single consolidated node. Collapses redundant or tightly related memories into one.",
4684
+ parameters: Type.Object({
4685
+ ids: Type.Array(Type.String(), { description: "Array of memory UUIDs to merge (minimum 2).", minItems: 2 }),
4686
+ label: Type.Optional(Type.String({ description: "Optional summary label for the merged memory node." })),
4687
+ }),
4688
+ },
4689
+ options: { name: "memory_fold" },
4690
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4691
+ async (_id, params) => {
4692
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4693
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_fold requires cloud backend");
4694
+ const ids = params.ids as string[];
4695
+ if (!ids || ids.length < 2) return { content: [{ type: "text", text: "At least 2 memory IDs are required to fold." }] };
4696
+ const label = params.label as string | undefined;
4697
+ const res = await sulcusMem.fold_memories(ids, namespace, label);
4698
+ return {
4699
+ content: [{ type: "text", text: `Folded ${ids.length} memories into one node.${label ? ` Label: "${label}"` : ""}` }],
4700
+ details: { ids, label, result: res, backend: backendMode, namespace },
4701
+ };
4702
+ },
4703
+ },
4704
+
4705
+ memory_dashboard: {
4706
+ schema: {
4707
+ name: "memory_dashboard",
4708
+ label: "Memory Dashboard",
4709
+ description: "Get a high-level dashboard of memory health, usage statistics, and storage information.",
4710
+ parameters: Type.Object({}),
4711
+ },
4712
+ options: { name: "memory_dashboard" },
4713
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4714
+ async (_id, _params) => {
4715
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4716
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_dashboard requires cloud backend");
4717
+ const [dashResult, storageResult] = await Promise.allSettled([
4718
+ sulcusMem.dashboard_stats(),
4719
+ sulcusMem.storage_status(),
4720
+ ]);
4721
+ const dashboard = dashResult.status === "fulfilled" ? dashResult.value : {};
4722
+ const storage = storageResult.status === "fulfilled" ? storageResult.value : {};
4723
+ const merged = { ...dashboard, storage, backend: backendMode, namespace };
4724
+ return {
4725
+ content: [{ type: "text", text: JSON.stringify(merged, null, 2) }],
4726
+ details: merged as Record<string, unknown>,
4727
+ };
4728
+ },
4729
+ },
4730
+
4731
+ // -- Phase 4: Episodic Session Recall ---------------------------------------
4732
+ episode_recall: {
4733
+ schema: {
4734
+ name: "episode_recall",
4735
+ label: "Episode Recall",
4736
+ description: "Search past conversation episodes — structured session summaries including topic, decisions, files modified, and outcome. Use for questions like 'what did we discuss last time?' or 'when did we work on X?'",
4737
+ parameters: Type.Object({
4738
+ query: Type.String({ description: "Search query — topic, keyword, date, or question about past sessions." }),
4739
+ limit: Type.Optional(Type.Number({ default: 5, minimum: 1, maximum: 20, description: "Max episodes to return." })),
4740
+ }),
4741
+ },
4742
+ options: { name: "episode_recall" },
4743
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4744
+ async (_id, params) => {
4745
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4746
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("episode_recall requires cloud backend");
4747
+ const query = params.query as string;
4748
+ const limit = Math.min(20, Math.max(1, (params.limit as number | undefined) ?? 5));
4749
+ // Search with a hint toward episodic/session content
4750
+ const res = await sulcusMem.search_memory(`session episode: ${query}`, limit * 2, namespace);
4751
+ const episodes = (res?.results ?? [])
4752
+ .filter((r) => {
4753
+ const mtype = r.memory_type as string | undefined;
4754
+ const content = ((r.label ?? r.pointer_summary ?? "") as string).toLowerCase();
4755
+ return mtype === "episodic" || content.includes("session episode") || content.includes("session compaction");
4756
+ })
4757
+ .slice(0, limit);
4758
+ if (episodes.length === 0) {
4759
+ return { content: [{ type: "text", text: `No episodes found matching "${query}".` }], details: { query, count: 0 } };
4760
+ }
4761
+ const formatted = episodes.map((e, i) => {
4762
+ const content = (e.label ?? e.pointer_summary ?? e.content ?? "") as string;
4763
+ const heat = typeof e.current_heat === "number" ? e.current_heat.toFixed(2) : "?";
4764
+ const date = (e.updated_at ?? e.created_at ?? "unknown") as string;
4765
+ const meta = e.metadata as Record<string, unknown> | undefined;
4766
+ let metaStr = "";
4767
+ if (meta) {
4768
+ const parts: string[] = [];
4769
+ if (meta.mood) parts.push(`Mood: ${meta.mood}`);
4770
+ if (meta.outcome) parts.push(`Outcome: ${meta.outcome}`);
4771
+ if (meta.duration_turns) parts.push(`Turns: ${meta.duration_turns}`);
4772
+ if (parts.length > 0) metaStr = ` [${parts.join(", ")}]`;
4773
+ }
4774
+ return `${i + 1}. [${date}] (heat: ${heat})${metaStr}\n ${content}`;
4775
+ }).join("\n\n");
4776
+ return {
4777
+ content: [{ type: "text", text: `Found ${episodes.length} episode(s) for "${query}":\n\n${formatted}` }],
4778
+ details: { query, count: episodes.length, backend: backendMode, namespace },
4779
+ };
4780
+ },
4781
+ },
4782
+
4783
+ memory_namespace: {
4784
+ schema: {
4785
+ name: "memory_namespace",
4786
+ label: "Memory Namespace",
4787
+ description: "Switch the active memory namespace at runtime. Useful for reading from project-specific or shared namespaces, or when serving multiple users. Affects all subsequent memory operations until switched again or the session ends.",
4788
+ parameters: Type.Object({
4789
+ namespace: Type.String({ description: "Target namespace to switch to. Use the base namespace name (e.g. 'ariadne', 'project-alpha')." }),
4790
+ reason: Type.Optional(Type.String({ description: "Why you're switching namespace — logged for audit trail." })),
4791
+ }),
4792
+ },
4793
+ options: { name: "memory_namespace" },
4794
+ makeExecute: ({ backendMode, namespace: currentNs, logger }) =>
4795
+ async (_id, params) => {
4796
+ const target = (params.namespace as string).trim();
4797
+ if (!target) return { content: [{ type: "text", text: "Namespace cannot be empty." }] };
4798
+ const reason = (params.reason as string | undefined) ?? "manual switch";
4799
+ // Phase 6: Update the module-scope runtime namespace override
4800
+ activeNamespaceOverride = target;
4801
+ logger.info(`sulcus: namespace switched ${currentNs} \u2192 ${target} (reason: ${reason})`);
4802
+ return {
4803
+ content: [{ type: "text", text: `Namespace switched: **${currentNs}** \u2192 **${target}**\nReason: ${reason}\n\nAll subsequent memory operations will use namespace \`${target}\` until switched again or session ends.` }],
4804
+ details: { previous: currentNs, current: target, reason, backend: backendMode },
4805
+ };
4806
+ },
4807
+ },
4808
+
4809
+ namespace_list: {
4810
+ schema: {
4811
+ name: "namespace_list",
4812
+ label: "Namespace List",
4813
+ description: "List available memory namespaces and their stats (node count, last activity). Useful for multi-user setups or project-scoped memory. Cloud-only.",
4814
+ parameters: Type.Object({}),
4815
+ },
4816
+ options: { name: "namespace_list" },
4817
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
4818
+ async (_id, _params) => {
4819
+ if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
4820
+ if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("namespace_list requires cloud backend");
4821
+ try {
4822
+ const res = await (sulcusMem as SulcusCloudClient).list_namespaces();
4823
+ if (!res || !Array.isArray(res) || res.length === 0) {
4824
+ return { content: [{ type: "text", text: "No namespaces found or endpoint not available." }] };
4825
+ }
4826
+ const current = activeNamespaceOverride ?? namespace;
4827
+ const formatted = res.map((ns: Record<string, unknown>) => {
4828
+ const name = (ns.namespace ?? ns.name ?? "unknown") as string;
4829
+ const count = (ns.node_count ?? ns.count ?? "?") as string | number;
4830
+ const active = name === current ? " \u2190 active" : "";
4831
+ return `- **${name}** (${count} nodes)${active}`;
4832
+ }).join("\n");
4833
+ return {
4834
+ content: [{ type: "text", text: `## Namespaces\nActive: \`${current}\`\n\n${formatted}` }],
4835
+ details: { namespaces: res, active: current, backend: backendMode },
4836
+ };
4837
+ } catch {
4838
+ return { content: [{ type: "text", text: "Namespace listing not available \u2014 server may need update." }] };
4839
+ }
4840
+ },
4841
+ },
4842
+
4843
+ sulcus_setup: {
4844
+ schema: {
4845
+ name: "sulcus_setup",
4846
+ label: "Sulcus Setup",
4847
+ description: "Run the Sulcus setup diagnostic — checks backend connectivity, configuration status, core memory state, and generates recommended cron job configurations for memory maintenance. Call this once when first setting up Sulcus.",
4848
+ parameters: Type.Object({
4849
+ init_core_memory: Type.Optional(Type.Boolean({ description: "If true and core memory is empty, initialize it with defaults based on the agent's namespace." })),
4850
+ }),
4851
+ },
4852
+ options: { name: "sulcus_setup" },
4853
+ makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
4854
+ async (_id, params) => {
4855
+ const report: string[] = [];
4856
+ report.push("# 🧵 Sulcus Setup Report\n");
4857
+
4858
+ // -- Section 1: Backend Status --
4859
+ report.push("## Backend");
4860
+ if (!isAvailable || !sulcusMem) {
4861
+ report.push(`❌ **Not available** — ${nativeLoader.error || "not loaded"}`);
4862
+ report.push("Fix: Check your sulcus config (apiKey, server URL). Run `memory_status` for details.\n");
4863
+ return { content: [{ type: "text", text: report.join("\n") }], details: { status: "unavailable" } };
4864
+ }
4865
+ report.push(`✅ **Backend:** ${backendMode}`);
4866
+ report.push(`✅ **Namespace:** ${namespace || "(default)"}`);
4867
+
4868
+ // Try to get server info
4869
+ const isCloud = sulcusMem instanceof SulcusCloudClient;
4870
+ if (isCloud) {
4871
+ try {
4872
+ const info = await (sulcusMem as any).request("GET", "/api/v1/agent/info");
4873
+ if (info) {
4874
+ report.push(`✅ **Server:** connected`);
4875
+ if ((info as any).capabilities) report.push(` Capabilities: ${JSON.stringify((info as any).capabilities)}`);
4876
+ }
4877
+ } catch {
4878
+ report.push("⚠️ **Server info:** could not fetch (non-critical)");
4879
+ }
4880
+ }
4881
+ report.push("");
4882
+
4883
+ // -- Section 2: Configuration Audit --
4884
+ report.push("## Configuration");
4885
+ const checks: [string, boolean, string][] = [
4886
+ ["Auto-recall (before_prompt_build)", true, "Memories are injected into context automatically"],
4887
+ ["Auto-capture (agent_end)", true, "Conversations are captured when sessions end"],
4888
+ ["Context-window awareness", true, "Plugin self-throttles to prevent context overflow"],
4889
+ ["Cloud backend", isCloud, "Required for advanced tools (graph, conflicts, archive, fold, dashboard, core memory, episodes)"],
4890
+ ];
4891
+ for (const [name, ok, desc] of checks) {
4892
+ report.push(`${ok ? "✅" : "⚠️"} **${name}** — ${desc}`);
4893
+ }
4894
+ report.push("");
4895
+
4896
+ // -- Section 3: Core Memory --
4897
+ report.push("## Core Memory");
4898
+ if (isCloud) {
4899
+ try {
4900
+ const core = await (sulcusMem as SulcusCloudClient).get_core_memory();
4901
+ if (core && Object.keys(core).filter(k => !["namespace", "created_at", "updated_at"].includes(k)).length > 0) {
4902
+ const fields = Object.keys(core).filter(k => !["namespace", "created_at", "updated_at"].includes(k));
4903
+ report.push(`✅ **Core memory set** — fields: ${fields.join(", ")}`);
4904
+ } else {
4905
+ report.push("⚠️ **Core memory is empty** — no persistent identity block");
4906
+ if (params.init_core_memory) {
4907
+ try {
4908
+ await (sulcusMem as SulcusCloudClient).update_core_memory({
4909
+ identity: `Agent in namespace '${namespace || "default"}'`,
4910
+ current_focus: "Initial setup",
4911
+ });
4912
+ report.push("✅ **Initialized** with default identity. Use `core_memory_update` to customize.");
4913
+ // Invalidate cache so next turn picks it up
4914
+ coreMemoryCache = undefined;
4915
+ } catch (e: unknown) {
4916
+ report.push(`❌ **Init failed:** ${e instanceof Error ? e.message : String(e)}`);
4917
+ }
4918
+ } else {
4919
+ report.push(" → Call `sulcus_setup(init_core_memory: true)` to initialize, or use `core_memory_update` directly.");
4920
+ }
4921
+ }
4922
+ } catch {
4923
+ report.push("⚠️ **Core memory endpoint not available** — server may need update");
4924
+ }
4925
+ } else {
4926
+ report.push("⚠️ Core memory requires cloud backend");
4927
+ }
4928
+ report.push("");
4929
+
4930
+ // -- Section 4: Tool Availability --
4931
+ report.push("## Available Tools");
4932
+ const allTools = [
4933
+ ["memory_store", "Store memories"],
4934
+ ["memory_recall", "Search memories"],
4935
+ ["memory_get", "Fetch by ID (cloud)"],
4936
+ ["memory_list", "Browse memories (cloud)"],
4937
+ ["memory_update", "Update in-place (cloud)"],
4938
+ ["memory_delete", "Delete memories"],
4939
+ ["memory_status", "Backend status"],
4940
+ ["memory_profile", "Health snapshot"],
4941
+ ["memory_namespace", "Switch namespace"],
4942
+ ["core_memory_read", "Read identity block (cloud)"],
4943
+ ["core_memory_update", "Update identity block (cloud)"],
4944
+ ["graph_explore", "Knowledge graph traversal (cloud)"],
4945
+ ["memory_conflicts", "Conflict detection (cloud)"],
4946
+ ["memory_archive", "Archive management (cloud)"],
4947
+ ["memory_fold", "Memory consolidation (cloud)"],
4948
+ ["memory_dashboard", "Health dashboard (cloud)"],
4949
+ ["episode_recall", "Past session search (cloud)"],
4950
+ ["namespace_list", "List namespaces (cloud)"],
4951
+ ["session_store", "Session-scoped storage"],
4952
+ ["session_recall", "Session-scoped search"],
4953
+ ["consolidate", "Trigger consolidation"],
4954
+ ["guardrail_status", "Safety guardrails"],
4955
+ ["sulcus_setup", "This tool"],
4956
+ ];
4957
+ const cloudOnly = ["memory_get", "memory_list", "memory_update", "core_memory_read", "core_memory_update", "graph_explore", "memory_conflicts", "memory_archive", "memory_fold", "memory_dashboard", "episode_recall", "namespace_list"];
4958
+ let available = 0;
4959
+ for (const [name, desc] of allTools) {
4960
+ const ok = cloudOnly.includes(name) ? isCloud : true;
4961
+ if (ok) available++;
4962
+ report.push(`${ok ? "✅" : "⚠️"} \`${name}\` — ${desc}`);
4963
+ }
4964
+ report.push(`\n**${available}/${allTools.length}** tools available.\n`);
4965
+
4966
+ // -- Section 5: Recommended Crons --
4967
+ report.push("## Recommended Maintenance Crons");
4968
+ report.push("");
4969
+ report.push("Set these up in your host platform (OpenClaw cron, system crontab, etc.):");
4970
+ report.push("");
4971
+ report.push("### 1. Daily Consolidation");
4972
+ report.push("Merge related memories, reduce noise, strengthen connections.");
4973
+ report.push("```");
4974
+ report.push("Schedule: daily at 03:00 UTC");
4975
+ report.push("Action: Call consolidate(min_heat: 0.1)");
4976
+ report.push("OpenClaw cron example:");
4977
+ report.push(' schedule: { kind: "cron", expr: "0 3 * * *", tz: "UTC" }');
4978
+ report.push(' payload: { kind: "agentTurn", message: "Run consolidate(min_heat: 0.1) and report results." }');
4979
+ report.push("```");
4980
+ report.push("");
4981
+ report.push("### 2. Weekly Quality Audit");
4982
+ report.push("Check memory health, identify duplicates, review type distribution.");
4983
+ report.push("```");
4984
+ report.push("Schedule: weekly on Sunday at 04:00 UTC");
4985
+ report.push("Action: Call memory_dashboard() and memory_profile()");
4986
+ report.push("OpenClaw cron example:");
4987
+ report.push(' schedule: { kind: "cron", expr: "0 4 * * 0", tz: "UTC" }');
4988
+ report.push(' payload: { kind: "agentTurn", message: "Run memory_dashboard() and memory_profile(). Summarize health and flag issues." }');
4989
+ report.push("```");
4990
+ report.push("");
4991
+ report.push("### 3. Daily Dashboard Snapshot");
4992
+ report.push("Quick health check — storage, node counts, hot memories.");
4993
+ report.push("```");
4994
+ report.push("Schedule: daily at 08:00 local");
4995
+ report.push("Action: Call memory_status()");
4996
+ report.push("OpenClaw cron example:");
4997
+ report.push(' schedule: { kind: "cron", expr: "0 8 * * *", tz: "America/Vancouver" }');
4998
+ report.push(' payload: { kind: "agentTurn", message: "Run memory_status() and report any anomalies." }');
4999
+ report.push("```");
5000
+ report.push("");
5001
+ report.push("---");
5002
+ report.push("*Setup complete. Run this tool again anytime to re-check status.*");
5003
+
5004
+ return {
5005
+ content: [{ type: "text", text: report.join("\n") }],
5006
+ details: {
5007
+ status: "ok",
5008
+ backend: backendMode,
5009
+ namespace,
5010
+ isCloud,
5011
+ toolsAvailable: available,
5012
+ toolsTotal: allTools.length,
5013
+ coreMemorySet: true,
5014
+ },
5015
+ };
5016
+ },
5017
+ },
4170
5018
  };
4171
5019
 
4172
5020
  // --- FIRST-INSTALL HISTORY IMPORT --------------------------------------------
@@ -4590,6 +5438,108 @@ const sulcusPlugin = {
4590
5438
  }
4591
5439
  });
4592
5440
  logger.info("sulcus: registered auto-capture (agent_end)");
5441
+
5442
+ // -- Phase 4: Register agent_end episode capture -----------------------
5443
+ if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
5444
+ const episodeApiOn = api.on as (event: string, handler: unknown) => void;
5445
+ episodeApiOn("agent_end", async (event: Record<string, unknown>) => {
5446
+ try {
5447
+ const messages = Array.isArray(event?.messages) ? event.messages as Record<string, unknown>[] : [];
5448
+ if (messages.length === 0) return;
5449
+
5450
+ const firstUser = messages.find((m) => m.role === "user" || m.type === "human");
5451
+ const firstUserText = typeof firstUser?.content === "string"
5452
+ ? firstUser.content.substring(0, 200)
5453
+ : typeof firstUser?.text === "string"
5454
+ ? (firstUser.text as string).substring(0, 200)
5455
+ : "(none)";
5456
+
5457
+ // Extract artifacts
5458
+ const filesModified: string[] = [];
5459
+ const commandsRun: string[] = [];
5460
+ const decisions: string[] = [];
5461
+ const errors: string[] = [];
5462
+ const DECISION_MARKERS = ["decided", "will use", "going to", "plan is", "the fix", "conclusion", "recommend", "approach"];
5463
+ const ERROR_MARKERS = ["error:", "failed:", "exception", "traceback", "panicked", "stack trace"];
5464
+
5465
+ for (const msg of messages) {
5466
+ const role = (msg.role ?? msg.type) as string | undefined;
5467
+ const rawContent = typeof msg.content === "string" ? msg.content
5468
+ : typeof msg.text === "string" ? msg.text as string : "";
5469
+
5470
+ if ((role === "assistant" || role === "ai") && rawContent.length > 20) {
5471
+ const lc = rawContent.toLowerCase();
5472
+ if (DECISION_MARKERS.some((m) => lc.includes(m))) {
5473
+ const sentences = rawContent.split(/[.!?\n]/).filter((s) => s.trim().length > 10);
5474
+ for (const s of sentences) {
5475
+ if (DECISION_MARKERS.some((m) => s.toLowerCase().includes(m)) && !decisions.includes(s.trim())) {
5476
+ decisions.push(s.trim().substring(0, 200));
5477
+ if (decisions.length >= 5) break;
5478
+ }
5479
+ }
5480
+ }
5481
+ const lcContent = rawContent.toLowerCase();
5482
+ if (ERROR_MARKERS.some((m) => lcContent.includes(m))) {
5483
+ const errorLine = rawContent.split("\n").find((l) => ERROR_MARKERS.some((m) => l.toLowerCase().includes(m)));
5484
+ if (errorLine && !errors.includes(errorLine.trim())) {
5485
+ errors.push(errorLine.trim().substring(0, 150));
5486
+ }
5487
+ }
5488
+ }
5489
+
5490
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as Record<string, unknown>[] : [];
5491
+ for (const tc of toolCalls) {
5492
+ const name = (tc.name ?? tc.function) as string | undefined;
5493
+ if (name === "Write" || name === "Edit" || name === "write" || name === "edit") {
5494
+ const input = (tc.input ?? tc.arguments ?? {}) as Record<string, unknown>;
5495
+ const fp = input?.file_path ?? input?.path;
5496
+ if (fp && typeof fp === "string" && !filesModified.includes(fp)) filesModified.push(fp);
5497
+ }
5498
+ if (name === "Bash" || name === "bash" || name === "exec" || name === "shell") {
5499
+ const input = (tc.input ?? tc.arguments ?? {}) as Record<string, unknown>;
5500
+ const cmd = input?.command ?? input?.cmd;
5501
+ if (cmd && typeof cmd === "string" && commandsRun.length < 5) {
5502
+ commandsRun.push((cmd as string).substring(0, 100));
5503
+ }
5504
+ }
5505
+ }
5506
+ }
5507
+
5508
+ const allText = messages.map(m => {
5509
+ const content = typeof m.content === "string" ? m.content : typeof m.text === "string" ? m.text as string : "";
5510
+ return content.toLowerCase();
5511
+ }).join(" ");
5512
+
5513
+ const episode: Record<string, unknown> = {
5514
+ topic: firstUserText,
5515
+ decisions: decisions.slice(0, 5),
5516
+ files_modified: filesModified.slice(0, 10),
5517
+ commands_run: commandsRun.slice(0, 5),
5518
+ errors: errors.slice(0, 3),
5519
+ outcome: "completed",
5520
+ duration_turns: messages.length,
5521
+ timestamp: new Date().toISOString(),
5522
+ };
5523
+
5524
+ if (allText.includes("error") || allText.includes("failed") || allText.includes("broken")) {
5525
+ episode.mood = "debugging";
5526
+ } else if (allText.includes("looks good") || allText.includes("working") || allText.includes("done")) {
5527
+ episode.mood = "productive";
5528
+ } else if (allText.includes("?") && allText.split("?").length > 3) {
5529
+ episode.mood = "exploratory";
5530
+ } else {
5531
+ episode.mood = "neutral";
5532
+ }
5533
+
5534
+ await (sulcusMem as SulcusCloudClient).store_episode(episode)
5535
+ .then((res) => logger.info(`sulcus: agent_end — stored structured episode (id: ${res?.id ?? "?"})`)
5536
+ ).catch((e: unknown) => logger.debug?.(`sulcus: agent_end — episode store failed: ${e instanceof Error ? e.message : String(e)}`));
5537
+ } catch (err) {
5538
+ logger.debug?.("sulcus: agent_end episode capture threw: " + err);
5539
+ }
5540
+ });
5541
+ logger.info("sulcus: registered agent_end episode capture (Phase 4)");
5542
+ }
4593
5543
  }
4594
5544
 
4595
5545
  // -------------------------------------------------------------------------
@@ -4600,6 +5550,8 @@ const sulcusPlugin = {
4600
5550
  if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
4601
5551
  const sessionPurgeApiOn = api.on as (event: string, handler: unknown) => void;
4602
5552
  sessionPurgeApiOn("agent_end", async () => {
5553
+ // Phase 6: Reset namespace override on session end
5554
+ activeNamespaceOverride = null;
4603
5555
  if (sessionMemoryIds.size === 0) return;
4604
5556
  const ids = Array.from(sessionMemoryIds);
4605
5557
  sessionMemoryIds.clear();