@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/README.md +0 -0
- package/index.js +5051 -0
- package/index.ts +971 -19
- package/openclaw.plugin.json +0 -0
- package/package.json +7 -6
- package/wasm/sulcus_wasm.js +0 -0
- package/wasm/sulcus_wasm_bg.wasm +0 -0
- package/bin/configure.mjs +0 -1056
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(
|
|
620
|
+
let hookProfileState = hookProfileStateMap.get(effectiveNamespace);
|
|
599
621
|
if (!hookProfileState) {
|
|
600
622
|
hookProfileState = { turnCount: 0, cache: null };
|
|
601
|
-
hookProfileStateMap.set(
|
|
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 =
|
|
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: ${
|
|
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,
|
|
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,
|
|
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,
|
|
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),
|
|
746
|
-
sulcusMem.search_memory("fact data knowledge", Math.min(hookEffectiveLimit, 5),
|
|
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="${
|
|
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, "&")
|
|
2386
|
+
.replace(/</g, "<")
|
|
2387
|
+
.replace(/>/g, ">")
|
|
2388
|
+
.replace(/"/g, """)
|
|
2389
|
+
.replace(/'/g, "'");
|
|
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
|
|
2730
|
-
//
|
|
2731
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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),
|
|
2943
|
-
const factRes = await sulcusMem.search_memory("fact data knowledge", Math.min(effectiveMax, 5),
|
|
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="${
|
|
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();
|