@digitalforgestudios/openclaw-sulcus 6.6.6 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -0
- package/hooks.defaults.json +0 -0
- package/index.js +1893 -162
- package/index.ts +403 -25
- package/openclaw.plugin.json +30 -0
- package/package.json +2 -2
- package/wasm/sulcus_wasm.js +0 -0
- package/wasm/sulcus_wasm_bg.wasm +0 -0
package/index.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
+
//
|
|
3
|
+
// openclaw-sulcus — Sulcus memory plugin for OpenClaw agents.
|
|
4
|
+
//
|
|
5
|
+
// BUILD & PUBLISH NOTES:
|
|
6
|
+
// - index.ts is the SOURCE OF TRUTH. index.js is the esbuild bundle.
|
|
7
|
+
// - `npm publish` triggers prepublishOnly → esbuild rebuilds index.js from index.ts.
|
|
8
|
+
// - DO NOT hand-edit index.js; it will be overwritten on next build/publish.
|
|
9
|
+
// - npm publish requires an **Automation** token (bypasses 2FA).
|
|
10
|
+
// "Publish" tokens trigger OTP prompts that block headless CI.
|
|
11
|
+
// Generate at: https://www.npmjs.com/settings/tokens
|
|
12
|
+
// - Package: @digitalforgestudios/openclaw-sulcus
|
|
13
|
+
// - Public repo: https://github.com/digitalforgeca/sulcus
|
|
14
|
+
//
|
|
2
15
|
import { resolve } from "node:path";
|
|
3
16
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
17
|
import * as https from "node:https";
|
|
5
18
|
import * as http from "node:http";
|
|
6
19
|
import { URL } from "node:url";
|
|
7
20
|
import { Type } from "@sinclair/typebox";
|
|
21
|
+
import { SulcusLocalClient } from "./src/local-client.js";
|
|
22
|
+
import { RetryQueue } from "./src/retry-queue.js";
|
|
23
|
+
import { SulcusContextEngine } from "./src/context-engine.js";
|
|
8
24
|
|
|
9
25
|
// --- SESSION SCOPE (Task 30) -------------------------------------------------------
|
|
10
26
|
// Each plugin instance gets a unique session ID at init time.
|
|
@@ -550,6 +566,11 @@ const recallQM: RecallQualityMetrics = {
|
|
|
550
566
|
// This is per-session (module scope); each gateway restart resets it correctly.
|
|
551
567
|
let wasJustCompacted = false;
|
|
552
568
|
|
|
569
|
+
// --- CONTEXT ENGINE ACTIVE FLAG (Phase 5) -----------------------------------
|
|
570
|
+
// Set to true when Sulcus context engine registers successfully. When active,
|
|
571
|
+
// the pre_compaction_capture hook skips (engine's compact() handles capture).
|
|
572
|
+
let contextEngineActive = false;
|
|
573
|
+
|
|
553
574
|
// Token budget for post-compaction context rebuild. Configured via
|
|
554
575
|
// contextRebuild.tokenBudget (default 10000, max 16000).
|
|
555
576
|
let REBUILD_TOKEN_BUDGET = 10000;
|
|
@@ -1141,8 +1162,14 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
1141
1162
|
const messages = Array.isArray(event?.messages) ? event.messages as Record<string, unknown>[] : [];
|
|
1142
1163
|
if (messages.length === 0) return;
|
|
1143
1164
|
|
|
1144
|
-
// ---
|
|
1165
|
+
// --- Phase 5: Skip when context engine owns compaction ---
|
|
1166
|
+
// The engine's compact() handles memory capture internally.
|
|
1167
|
+
// Still set the rebuild flag so post-compaction context rebuild fires.
|
|
1145
1168
|
wasJustCompacted = true;
|
|
1169
|
+
if (contextEngineActive) {
|
|
1170
|
+
logger.info("sulcus: pre_compaction_capture — skipped (context engine handles capture). Rebuild flag SET.");
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1146
1173
|
logger.info("sulcus: pre_compaction_capture — rebuild flag SET (next turn will inject full Sulcus context)");
|
|
1147
1174
|
|
|
1148
1175
|
const firstUser = messages.find((m) => m.role === "user" || m.type === "human");
|
|
@@ -2933,7 +2960,49 @@ function buildSdkRecallHandler(
|
|
|
2933
2960
|
contextRebuild: boolean = true,
|
|
2934
2961
|
/** Task 102: model context window size in tokens. Used for utilization-based throttling. */
|
|
2935
2962
|
contextWindowSize: number = 200000,
|
|
2963
|
+
/** Local sidecar client for local-first recall in autoRecall path. */
|
|
2964
|
+
localClient: SulcusLocalClient | null = null,
|
|
2936
2965
|
) {
|
|
2966
|
+
/**
|
|
2967
|
+
* Local-first search wrapper for autoRecall hot path.
|
|
2968
|
+
* Tries local sidecar first; if local returns enough results, uses them.
|
|
2969
|
+
* If partial, merges with cloud. If empty/down, falls through to cloud.
|
|
2970
|
+
*/
|
|
2971
|
+
async function localFirstSearch(query: string, limit: number, ns: string): Promise<{ results: Record<string, unknown>[]; source: string }> {
|
|
2972
|
+
if (!localClient || !localClient.isAvailable()) {
|
|
2973
|
+
const res = await sulcusMem.search_memory(query, limit, ns);
|
|
2974
|
+
return { results: res?.results ?? [], source: "cloud" };
|
|
2975
|
+
}
|
|
2976
|
+
try {
|
|
2977
|
+
const localRes = await localClient.search_memory(query, limit, ns);
|
|
2978
|
+
const localResults = localRes?.results ?? [];
|
|
2979
|
+
if (localResults.length >= Math.ceil(limit * 0.7)) {
|
|
2980
|
+
// Local has enough — use it, warm remote in background
|
|
2981
|
+
sulcusMem.search_memory(query, limit, ns).catch(() => {});
|
|
2982
|
+
return { results: localResults, source: "local" };
|
|
2983
|
+
}
|
|
2984
|
+
if (localResults.length > 0) {
|
|
2985
|
+
// Partial local — merge with remote
|
|
2986
|
+
try {
|
|
2987
|
+
const remoteRes = await sulcusMem.search_memory(query, limit, ns);
|
|
2988
|
+
const remoteResults = remoteRes?.results ?? [];
|
|
2989
|
+
const remoteById = new Map(remoteResults.map((r) => [r.id as string, r]));
|
|
2990
|
+
const mergedMap = new Map(remoteById);
|
|
2991
|
+
for (const [, node] of new Map(localResults.map((r) => [r.id as string, r]))) {
|
|
2992
|
+
if (!mergedMap.has(node.id as string)) mergedMap.set(node.id as string, node);
|
|
2993
|
+
}
|
|
2994
|
+
return { results: [...mergedMap.values()].slice(0, limit), source: "local+cloud" };
|
|
2995
|
+
} catch {
|
|
2996
|
+
return { results: localResults, source: "local" };
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
// Local empty — fall through to cloud
|
|
3000
|
+
} catch {
|
|
3001
|
+
logger.debug?.("sulcus: autoRecall local search failed, falling back to cloud");
|
|
3002
|
+
}
|
|
3003
|
+
const res = await sulcusMem.search_memory(query, limit, ns);
|
|
3004
|
+
return { results: res?.results ?? [], source: "cloud" };
|
|
3005
|
+
}
|
|
2937
3006
|
let turnCount = 0;
|
|
2938
3007
|
let profileCache: ProfileCache | null = null;
|
|
2939
3008
|
let recallCache: RecallCache | null = null;
|
|
@@ -3113,7 +3182,7 @@ function buildSdkRecallHandler(
|
|
|
3113
3182
|
try {
|
|
3114
3183
|
// Task 62: Use focused recallQuery instead of full accumulated prompt
|
|
3115
3184
|
// Task 101: Use adaptive limit instead of raw config maxResults
|
|
3116
|
-
const searchRes = await
|
|
3185
|
+
const searchRes = await localFirstSearch(recallQuery, effectiveMax, effectiveNamespace);
|
|
3117
3186
|
const vectorResults = searchRes?.results ?? [];
|
|
3118
3187
|
|
|
3119
3188
|
// -- Task 35: Query expansion for thin recall (SDK path) ---------------
|
|
@@ -3595,6 +3664,8 @@ function buildPromptSection(params: { availableTools: Set<string> }): string[] {
|
|
|
3595
3664
|
|
|
3596
3665
|
interface ToolDeps {
|
|
3597
3666
|
sulcusMem: SulcusCloudClient | null;
|
|
3667
|
+
localClient: SulcusLocalClient | null;
|
|
3668
|
+
retryQueue: RetryQueue;
|
|
3598
3669
|
backendMode: string;
|
|
3599
3670
|
namespace: string;
|
|
3600
3671
|
nativeLoader: NativeLibLoader;
|
|
@@ -3612,6 +3683,9 @@ interface ToolDefinition {
|
|
|
3612
3683
|
makeExecute: (deps: ToolDeps) => (id: string, params: Record<string, unknown>) => Promise<{ content: { type: string; text: string }[]; details?: Record<string, unknown> }>;
|
|
3613
3684
|
}
|
|
3614
3685
|
|
|
3686
|
+
/** Stale recall cache for the manual memory_recall tool — last-resort fallback when both local and cloud are down. */
|
|
3687
|
+
let toolRecallCache: { results: Record<string, unknown>[]; cachedAt: number } = { results: [], cachedAt: 0 };
|
|
3688
|
+
|
|
3615
3689
|
const toolDefinitions: Record<string, ToolDefinition> = {
|
|
3616
3690
|
memory_recall: {
|
|
3617
3691
|
schema: {
|
|
@@ -3625,15 +3699,85 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3625
3699
|
}),
|
|
3626
3700
|
},
|
|
3627
3701
|
options: { name: "memory_recall" },
|
|
3628
|
-
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
|
|
3702
|
+
makeExecute: ({ sulcusMem, localClient, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3629
3703
|
async (_id, params) => {
|
|
3630
3704
|
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3631
3705
|
const searchNamespace = (params.namespace as string | undefined) ?? namespace;
|
|
3632
|
-
const
|
|
3633
|
-
const
|
|
3706
|
+
const query = params.query as string;
|
|
3707
|
+
const limit = (params.limit as number | undefined) ?? 5;
|
|
3708
|
+
let results: Record<string, unknown>[] = [];
|
|
3709
|
+
let source = "cloud";
|
|
3710
|
+
|
|
3711
|
+
// Local-first recall: query local sidecar first, remote fallback if cold/empty
|
|
3712
|
+
if (localClient && localClient.isAvailable()) {
|
|
3713
|
+
try {
|
|
3714
|
+
const localRes = await localClient.search_memory(query, limit, searchNamespace);
|
|
3715
|
+
const localResults = localRes?.results ?? [];
|
|
3716
|
+
if (localResults.length >= Math.ceil(limit * 0.7)) {
|
|
3717
|
+
// Local has enough results — use them, warm remote in background
|
|
3718
|
+
results = localResults;
|
|
3719
|
+
source = "local";
|
|
3720
|
+
logger.debug?.(`sulcus: recall served from local (${localResults.length} results)`);
|
|
3721
|
+
// Background warm: fetch from remote and merge into local (fire-and-forget)
|
|
3722
|
+
sulcusMem.search_memory(query, limit, searchNamespace).catch(() => {});
|
|
3723
|
+
} else if (localResults.length > 0) {
|
|
3724
|
+
// Local has partial results — try remote for more, merge
|
|
3725
|
+
source = "local+cloud";
|
|
3726
|
+
try {
|
|
3727
|
+
const remoteRes = await sulcusMem.search_memory(query, limit, searchNamespace);
|
|
3728
|
+
const remoteResults = remoteRes?.results ?? [];
|
|
3729
|
+
// Merge: deduplicate by ID, prefer remote version for same ID (fresher heat)
|
|
3730
|
+
const remoteById = new Map(remoteResults.map((r) => [r.id as string, r]));
|
|
3731
|
+
const localById = new Map(localResults.map((r) => [r.id as string, r]));
|
|
3732
|
+
// Start with remote versions (fresher heat), then add local-only
|
|
3733
|
+
const mergedMap = new Map(remoteById);
|
|
3734
|
+
for (const [id, node] of localById) {
|
|
3735
|
+
if (!mergedMap.has(id)) mergedMap.set(id, node);
|
|
3736
|
+
}
|
|
3737
|
+
const merged = [...mergedMap.values()];
|
|
3738
|
+
results = merged.slice(0, limit);
|
|
3739
|
+
logger.debug?.(`sulcus: recall merged local(${localResults.length}) + cloud(${remoteResults.length}) → ${results.length}`);
|
|
3740
|
+
} catch {
|
|
3741
|
+
// Remote failed — return whatever local had
|
|
3742
|
+
results = localResults;
|
|
3743
|
+
source = "local";
|
|
3744
|
+
logger.debug?.(`sulcus: remote recall failed, using ${localResults.length} local results`);
|
|
3745
|
+
}
|
|
3746
|
+
} else {
|
|
3747
|
+
// Local returned empty — fall through to remote
|
|
3748
|
+
logger.debug?.("sulcus: local recall empty, falling back to cloud");
|
|
3749
|
+
}
|
|
3750
|
+
} catch (localErr) {
|
|
3751
|
+
logger.debug?.(`sulcus: local recall failed (${localErr}), falling back to cloud`);
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
// Cloud fallback (or primary when no local client)
|
|
3756
|
+
if (results.length === 0) {
|
|
3757
|
+
try {
|
|
3758
|
+
const res = await sulcusMem.search_memory(query, limit, searchNamespace);
|
|
3759
|
+
results = res?.results ?? [];
|
|
3760
|
+
source = "cloud";
|
|
3761
|
+
} catch (cloudErr) {
|
|
3762
|
+
// Both local and cloud failed — serve stale cache if available
|
|
3763
|
+
if (toolRecallCache.results.length > 0) {
|
|
3764
|
+
results = toolRecallCache.results;
|
|
3765
|
+
source = "stale-cache";
|
|
3766
|
+
logger.warn?.(`sulcus: both local and cloud recall failed, serving stale cache (${results.length} items)`);
|
|
3767
|
+
} else {
|
|
3768
|
+
throw cloudErr; // Nothing to fall back to
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
// Update stale cache on successful recall
|
|
3774
|
+
if (results.length > 0 && source !== "stale-cache") {
|
|
3775
|
+
toolRecallCache = { results, cachedAt: Date.now() };
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3634
3778
|
return {
|
|
3635
3779
|
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
3636
|
-
details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace },
|
|
3780
|
+
details: { results: results as unknown as Record<string, unknown>[], backend: backendMode, namespace: searchNamespace, source },
|
|
3637
3781
|
};
|
|
3638
3782
|
},
|
|
3639
3783
|
},
|
|
@@ -3653,7 +3797,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3653
3797
|
}),
|
|
3654
3798
|
},
|
|
3655
3799
|
options: { name: "memory_store" },
|
|
3656
|
-
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3800
|
+
makeExecute: ({ sulcusMem, localClient, retryQueue, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3657
3801
|
async (_id, params) => {
|
|
3658
3802
|
const content = params.content as string;
|
|
3659
3803
|
if (isJunkMemory(content)) {
|
|
@@ -3664,8 +3808,42 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3664
3808
|
const mtype = (params.memory_type as string | undefined) || "episodic";
|
|
3665
3809
|
// Phase 2: SILU prompt injection — derive hints from memory type + namespace for manual stores
|
|
3666
3810
|
const storeHints = buildExtractionHints(mtype, namespace, "user_capture", content.substring(0, 200));
|
|
3667
|
-
|
|
3668
|
-
|
|
3811
|
+
|
|
3812
|
+
let nodeId = "unknown";
|
|
3813
|
+
let res: Record<string, unknown> = {};
|
|
3814
|
+
let storeSource = "cloud";
|
|
3815
|
+
|
|
3816
|
+
// Dual-write: local first (fast path), remote fire-and-forget
|
|
3817
|
+
if (localClient && localClient.isAvailable()) {
|
|
3818
|
+
try {
|
|
3819
|
+
const localRes = await localClient.add_memory(content, mtype, storeHints);
|
|
3820
|
+
nodeId = localRes?.id ?? "unknown";
|
|
3821
|
+
res = localRes as Record<string, unknown>;
|
|
3822
|
+
storeSource = "local";
|
|
3823
|
+
logger.debug?.(`sulcus: stored to local sidecar (id: ${nodeId})`);
|
|
3824
|
+
|
|
3825
|
+
// Fire-and-forget to remote — enqueue retry on failure
|
|
3826
|
+
sulcusMem.add_memory(content, mtype, storeHints).then((remoteRes) => {
|
|
3827
|
+
logger.debug?.(`sulcus: remote store synced (local: ${nodeId}, remote: ${remoteRes?.id ?? "?"})`);
|
|
3828
|
+
}).catch((err) => {
|
|
3829
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3830
|
+
logger.warn(`sulcus: remote store failed for ${nodeId} (will retry): ${errMsg}`);
|
|
3831
|
+
retryQueue.enqueue(nodeId, "store", { content, memory_type: mtype, extraction_hints: storeHints });
|
|
3832
|
+
});
|
|
3833
|
+
} catch (localErr) {
|
|
3834
|
+
// Local failed — fall through to cloud-only store
|
|
3835
|
+
const errMsg = localErr instanceof Error ? localErr.message : String(localErr);
|
|
3836
|
+
logger.warn(`sulcus: local store failed (${errMsg}), falling back to cloud`);
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
// Cloud-only store (primary when no local, or fallback when local failed)
|
|
3841
|
+
if (storeSource === "cloud") {
|
|
3842
|
+
const cloudRes = await sulcusMem.add_memory(content, mtype, storeHints);
|
|
3843
|
+
nodeId = cloudRes?.id ?? "unknown";
|
|
3844
|
+
res = cloudRes as Record<string, unknown>;
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3669
3847
|
let trainResult: string | null = null;
|
|
3670
3848
|
if (params.train === true) {
|
|
3671
3849
|
try {
|
|
@@ -3681,8 +3859,8 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3681
3859
|
}
|
|
3682
3860
|
}
|
|
3683
3861
|
return {
|
|
3684
|
-
content: [{ type: "text", text: `Stored [${mtype}] memory (id: ${nodeId}) → backend: ${backendMode}, namespace: ${namespace}${trainResult ? ` | SIU: ${trainResult}` : ""}` }],
|
|
3685
|
-
details: { ...res, id: nodeId, memory_type: mtype, backend: backendMode, namespace, train: trainResult as unknown as Record<string, unknown> },
|
|
3862
|
+
content: [{ type: "text", text: `Stored [${mtype}] memory (id: ${nodeId}) → backend: ${backendMode}, namespace: ${namespace}, source: ${storeSource}${trainResult ? ` | SIU: ${trainResult}` : ""}` }],
|
|
3863
|
+
details: { ...res, id: nodeId, memory_type: mtype, backend: backendMode, namespace, source: storeSource, train: trainResult as unknown as Record<string, unknown> },
|
|
3686
3864
|
};
|
|
3687
3865
|
},
|
|
3688
3866
|
},
|
|
@@ -3695,10 +3873,17 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3695
3873
|
parameters: Type.Object({}),
|
|
3696
3874
|
},
|
|
3697
3875
|
options: { name: "memory_status" },
|
|
3698
|
-
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, storeLibPath, vectorsLibPath, wasmDir, isAvailable }) =>
|
|
3876
|
+
makeExecute: ({ sulcusMem, localClient, retryQueue, backendMode, namespace, nativeLoader, storeLibPath, vectorsLibPath, wasmDir, isAvailable }) =>
|
|
3699
3877
|
async (_id, _params) => {
|
|
3878
|
+
// Build local sidecar status block
|
|
3879
|
+
const localStatus: Record<string, unknown> | null = localClient ? {
|
|
3880
|
+
endpoint: (localClient as any).endpoint,
|
|
3881
|
+
...localClient.healthSummary(),
|
|
3882
|
+
retry_queue_size: retryQueue.size,
|
|
3883
|
+
} : null;
|
|
3884
|
+
|
|
3700
3885
|
if (!isAvailable || !sulcusMem) {
|
|
3701
|
-
return { content: [{ type: "text", text: JSON.stringify({ status: "unavailable", backend: backendMode, namespace, error: nativeLoader.error || "not loaded", storeLib: storeLibPath, vectorsLib: vectorsLibPath, wasmDir }, null, 2) }] };
|
|
3886
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "unavailable", backend: backendMode, namespace, error: nativeLoader.error || "not loaded", storeLib: storeLibPath, vectorsLib: vectorsLibPath, wasmDir, local: localStatus }, null, 2) }] };
|
|
3702
3887
|
}
|
|
3703
3888
|
try {
|
|
3704
3889
|
const [statusInfo, hotNodes] = await Promise.all([
|
|
@@ -3766,7 +3951,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3766
3951
|
}
|
|
3767
3952
|
|
|
3768
3953
|
return {
|
|
3769
|
-
content: [{ type: "text", text: JSON.stringify({ status: "ok", backend: backendMode, namespace, ...(si?.capabilities ? { capabilities: si.capabilities } : {}), ...(si?.stats ? { stats: si.stats } : {}), hot_node_count: nodeList.length, hot_nodes: nodeList, recall_quality: recallQuality, last_injection: lastInjection }, null, 2) }],
|
|
3954
|
+
content: [{ type: "text", text: JSON.stringify({ status: "ok", backend: backendMode, namespace, ...(si?.capabilities ? { capabilities: si.capabilities } : {}), ...(si?.stats ? { stats: si.stats } : {}), hot_node_count: nodeList.length, hot_nodes: nodeList, recall_quality: recallQuality, last_injection: lastInjection, ...(localStatus ? { local: localStatus } : {}) }, null, 2) }],
|
|
3770
3955
|
details: { status: "ok", backend: backendMode, namespace, count: nodeList.length },
|
|
3771
3956
|
};
|
|
3772
3957
|
} catch (e: unknown) {
|
|
@@ -3853,14 +4038,38 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3853
4038
|
}),
|
|
3854
4039
|
},
|
|
3855
4040
|
options: { name: "memory_delete" },
|
|
3856
|
-
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
|
|
4041
|
+
makeExecute: ({ sulcusMem, localClient, retryQueue, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3857
4042
|
async (_id, params) => {
|
|
3858
4043
|
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
4044
|
+
const memId = params.id as string;
|
|
3859
4045
|
const train = params.train !== false;
|
|
3860
|
-
|
|
4046
|
+
|
|
4047
|
+
// Dual-write: delete from local first, then remote
|
|
4048
|
+
if (localClient && localClient.isAvailable()) {
|
|
4049
|
+
try {
|
|
4050
|
+
await localClient.delete_memory(memId);
|
|
4051
|
+
logger.debug?.(`sulcus: deleted from local sidecar (${memId})`);
|
|
4052
|
+
} catch (localErr) {
|
|
4053
|
+
logger.debug?.(`sulcus: local delete failed (${localErr})`);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
let res: unknown;
|
|
4058
|
+
try {
|
|
4059
|
+
res = await sulcusMem.delete_memory(memId, train);
|
|
4060
|
+
} catch (remoteErr) {
|
|
4061
|
+
// Enqueue for retry
|
|
4062
|
+
retryQueue.enqueue(memId, "delete", { id: memId });
|
|
4063
|
+
logger.debug?.(`sulcus: remote delete failed, enqueued for retry (${memId})`);
|
|
4064
|
+
return {
|
|
4065
|
+
content: [{ type: "text", text: `Deleted memory ${memId} locally. Remote sync pending.` }],
|
|
4066
|
+
details: { id: memId, trained: train, backend: backendMode, namespace, source: "local" },
|
|
4067
|
+
};
|
|
4068
|
+
}
|
|
4069
|
+
|
|
3861
4070
|
return {
|
|
3862
|
-
content: [{ type: "text", text: `Deleted memory ${
|
|
3863
|
-
details: { id:
|
|
4071
|
+
content: [{ type: "text", text: `Deleted memory ${memId}${train ? " (trained SIVU to reject similar)" : ""}. Backend: ${backendMode}, namespace: ${namespace}` }],
|
|
4072
|
+
details: { id: memId, trained: train, result: res as Record<string, unknown>, backend: backendMode, namespace },
|
|
3864
4073
|
};
|
|
3865
4074
|
},
|
|
3866
4075
|
},
|
|
@@ -3875,16 +4084,36 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3875
4084
|
}),
|
|
3876
4085
|
},
|
|
3877
4086
|
options: { name: "memory_get" },
|
|
3878
|
-
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable }) =>
|
|
4087
|
+
makeExecute: ({ sulcusMem, localClient, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3879
4088
|
async (_id, params) => {
|
|
3880
4089
|
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3881
4090
|
if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_get requires cloud backend");
|
|
3882
4091
|
const memId = params.id as string;
|
|
4092
|
+
let source = "cloud";
|
|
4093
|
+
|
|
4094
|
+
// Local-first: try local sidecar first
|
|
4095
|
+
if (localClient && localClient.isAvailable()) {
|
|
4096
|
+
try {
|
|
4097
|
+
const localRes = await localClient.get_memory(memId);
|
|
4098
|
+
if (localRes) {
|
|
4099
|
+
source = "local";
|
|
4100
|
+
logger.debug?.(`sulcus: memory_get served from local (${memId})`);
|
|
4101
|
+
return {
|
|
4102
|
+
content: [{ type: "text", text: JSON.stringify(localRes, null, 2) }],
|
|
4103
|
+
details: { ...localRes, backend: backendMode, namespace, source },
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
4106
|
+
// Not found locally — fall through to remote
|
|
4107
|
+
} catch {
|
|
4108
|
+
logger.debug?.(`sulcus: local get failed for ${memId}, falling back to cloud`);
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
|
|
3883
4112
|
const res = await sulcusMem.get_memory(memId);
|
|
3884
4113
|
if (!res) return { content: [{ type: "text", text: `Memory ${memId} not found.` }], details: { found: false, id: memId } };
|
|
3885
4114
|
return {
|
|
3886
4115
|
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
3887
|
-
details: { ...res, backend: backendMode, namespace },
|
|
4116
|
+
details: { ...res, backend: backendMode, namespace, source },
|
|
3888
4117
|
};
|
|
3889
4118
|
},
|
|
3890
4119
|
},
|
|
@@ -3951,7 +4180,7 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3951
4180
|
}),
|
|
3952
4181
|
},
|
|
3953
4182
|
options: { name: "memory_update" },
|
|
3954
|
-
makeExecute: ({ sulcusMem, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
4183
|
+
makeExecute: ({ sulcusMem, localClient, retryQueue, backendMode, namespace, nativeLoader, isAvailable, logger }) =>
|
|
3955
4184
|
async (_id, params) => {
|
|
3956
4185
|
if (!isAvailable || !sulcusMem) throw new Error(`Sulcus unavailable: ${nativeLoader.error || "not loaded"}`);
|
|
3957
4186
|
if (!(sulcusMem instanceof SulcusCloudClient)) throw new Error("memory_update requires cloud backend");
|
|
@@ -3964,8 +4193,31 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
3964
4193
|
if (Object.keys(updates).length === 0) {
|
|
3965
4194
|
return { content: [{ type: "text", text: "No fields to update. Provide at least one of: content, memory_type, is_pinned, heat." }] };
|
|
3966
4195
|
}
|
|
3967
|
-
|
|
4196
|
+
|
|
4197
|
+
// Dual-write: update local first, then remote
|
|
4198
|
+
if (localClient && localClient.isAvailable()) {
|
|
4199
|
+
try {
|
|
4200
|
+
await localClient.update_memory(memId, updates as any);
|
|
4201
|
+
logger.debug?.(`sulcus: updated local sidecar (${memId})`);
|
|
4202
|
+
} catch (localErr) {
|
|
4203
|
+
logger.debug?.(`sulcus: local update failed (${localErr})`);
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
let res: unknown;
|
|
3968
4208
|
const fields = Object.keys(updates).join(", ");
|
|
4209
|
+
try {
|
|
4210
|
+
res = await sulcusMem.update_memory(memId, updates as any);
|
|
4211
|
+
} catch (remoteErr) {
|
|
4212
|
+
// Enqueue for retry
|
|
4213
|
+
retryQueue.enqueue(memId, "update", updates);
|
|
4214
|
+
logger.debug?.(`sulcus: remote update failed, enqueued for retry (${memId})`);
|
|
4215
|
+
return {
|
|
4216
|
+
content: [{ type: "text", text: `Updated memory ${memId} locally (fields: ${fields}). Remote sync pending.` }],
|
|
4217
|
+
details: { id: memId, updated_fields: Object.keys(updates), backend: backendMode, namespace, source: "local" },
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
4220
|
+
|
|
3969
4221
|
logger.info(`sulcus: memory_update — updated ${memId} (fields: ${fields})`);
|
|
3970
4222
|
return {
|
|
3971
4223
|
content: [{ type: "text", text: `Updated memory ${memId} (fields: ${fields}). Backend: ${backendMode}, namespace: ${namespace}` }],
|
|
@@ -5135,6 +5387,15 @@ const sulcusPlugin = {
|
|
|
5135
5387
|
const serverUrl = pluginConfig?.serverUrl as string | undefined;
|
|
5136
5388
|
const apiKey = pluginConfig?.apiKey as string | undefined;
|
|
5137
5389
|
|
|
5390
|
+
// -- Local sidecar config (Phase 1: dual-client) --
|
|
5391
|
+
// When local.endpoint is set, the plugin creates a SulcusLocalClient for
|
|
5392
|
+
// local-first reads/writes. Cloud becomes the async sync target.
|
|
5393
|
+
const localConfig = pluginConfig?.local as Record<string, unknown> | undefined;
|
|
5394
|
+
const localEndpoint = (localConfig?.endpoint as string | undefined) ?? (process.env.SULCUS_LOCAL_URL as string | undefined);
|
|
5395
|
+
const localApiKey = (localConfig?.apiKey as string | undefined) ?? (process.env.SULCUS_LOCAL_API_KEY as string | undefined);
|
|
5396
|
+
const localTimeoutMs = (localConfig?.timeoutMs as number | undefined) ?? 2000;
|
|
5397
|
+
const localEnabled = localEndpoint !== undefined && (localConfig?.enabled !== false);
|
|
5398
|
+
|
|
5138
5399
|
const agentId = pluginConfig?.agentId as string | undefined;
|
|
5139
5400
|
const namespace = pluginConfig?.namespace === "default" && agentId
|
|
5140
5401
|
? agentId
|
|
@@ -5208,8 +5469,34 @@ const sulcusPlugin = {
|
|
|
5208
5469
|
const isAvailable = sulcusMem !== null;
|
|
5209
5470
|
const isCloudBackend = backendMode === "cloud" && sulcusMem instanceof SulcusCloudClient;
|
|
5210
5471
|
|
|
5472
|
+
// -- Local sidecar client (Phase 1: dual-client) --
|
|
5473
|
+
let localClient: SulcusLocalClient | null = null;
|
|
5474
|
+
const retryQueue = new RetryQueue({ maxItems: 500, maxRetries: 5, logger });
|
|
5475
|
+
|
|
5476
|
+
if (localEnabled && localEndpoint) {
|
|
5477
|
+
localClient = new SulcusLocalClient({
|
|
5478
|
+
endpoint: localEndpoint,
|
|
5479
|
+
apiKey: localApiKey,
|
|
5480
|
+
timeoutMs: localTimeoutMs,
|
|
5481
|
+
logger,
|
|
5482
|
+
});
|
|
5483
|
+
logger.info(`sulcus: local sidecar client created (endpoint: ${localEndpoint}, timeout: ${localTimeoutMs}ms)`);
|
|
5484
|
+
// Probe sidecar availability at startup (non-blocking)
|
|
5485
|
+
localClient.probe().then((ok) => {
|
|
5486
|
+
if (ok) logger.info("sulcus: local sidecar probe OK ✅");
|
|
5487
|
+
else logger.warn("sulcus: local sidecar probe failed — will retry on first use");
|
|
5488
|
+
}).catch(() => {
|
|
5489
|
+
logger.warn("sulcus: local sidecar probe failed — will retry on first use");
|
|
5490
|
+
});
|
|
5491
|
+
} else if (localConfig?.enabled === true && !localEndpoint) {
|
|
5492
|
+
logger.warn("sulcus: local.enabled=true but no local.endpoint or SULCUS_LOCAL_URL set — local-first disabled");
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
const hasLocalClient = localClient !== null;
|
|
5496
|
+
const effectiveBackendMode = hasLocalClient && isCloudBackend ? "dual" : backendMode;
|
|
5497
|
+
|
|
5211
5498
|
// Update static awareness with runtime info
|
|
5212
|
-
STATIC_AWARENESS = buildStaticAwareness(
|
|
5499
|
+
STATIC_AWARENESS = buildStaticAwareness(effectiveBackendMode, namespace);
|
|
5213
5500
|
|
|
5214
5501
|
// Task 70: Wire contextRebuild budget to module-scope variable so rebuild
|
|
5215
5502
|
// handler (inside buildSdkRecallHandler closure) picks up configured value.
|
|
@@ -5217,7 +5504,9 @@ const sulcusPlugin = {
|
|
|
5217
5504
|
|
|
5218
5505
|
// -- Startup summary --
|
|
5219
5506
|
if (isAvailable) {
|
|
5220
|
-
|
|
5507
|
+
const localTag = hasLocalClient ? `, local: ${localEndpoint}` : "";
|
|
5508
|
+
const retryTag = hasLocalClient ? ", retryQueue: enabled" : "";
|
|
5509
|
+
logger.info(`sulcus: ready ✅ (backend: ${effectiveBackendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture}, captureFromAssistant: ${captureFromAssistant}, contextRebuild: ${contextRebuildEnabled}${localTag}${retryTag})`);
|
|
5221
5510
|
} else {
|
|
5222
5511
|
// Give clear, actionable guidance instead of cryptic error chains
|
|
5223
5512
|
const hints: string[] = [];
|
|
@@ -5245,7 +5534,9 @@ const sulcusPlugin = {
|
|
|
5245
5534
|
// -- Shared deps --
|
|
5246
5535
|
const toolDeps: ToolDeps = {
|
|
5247
5536
|
sulcusMem,
|
|
5248
|
-
|
|
5537
|
+
localClient,
|
|
5538
|
+
retryQueue,
|
|
5539
|
+
backendMode: effectiveBackendMode,
|
|
5249
5540
|
namespace,
|
|
5250
5541
|
nativeLoader,
|
|
5251
5542
|
storeLibPath,
|
|
@@ -5300,7 +5591,9 @@ const sulcusPlugin = {
|
|
|
5300
5591
|
try {
|
|
5301
5592
|
(api.registerMemoryFlushPlan as (r: unknown) => void)(() => {
|
|
5302
5593
|
if (!isAvailable || !sulcusMem) return null;
|
|
5594
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
5303
5595
|
return {
|
|
5596
|
+
relativePath: `memory/${today}.md`,
|
|
5304
5597
|
softThresholdTokens: 15000,
|
|
5305
5598
|
forceFlushTranscriptBytes: "2mb",
|
|
5306
5599
|
reserveTokensFloor: 30000,
|
|
@@ -5539,6 +5832,50 @@ const sulcusPlugin = {
|
|
|
5539
5832
|
}
|
|
5540
5833
|
}
|
|
5541
5834
|
|
|
5835
|
+
// 4b. Retry queue flush on each turn (local-first dual-write)
|
|
5836
|
+
// When a remote write fails, the operation is buffered in the retry queue.
|
|
5837
|
+
// We flush before each prompt build so pending syncs propagate to cloud.
|
|
5838
|
+
if (hasLocalClient && isCloudBackend && sulcusMem) {
|
|
5839
|
+
const retryApiOn = api.on as (event: string, handler: unknown) => void;
|
|
5840
|
+
const retryCloudClient = sulcusMem as SulcusCloudClient;
|
|
5841
|
+
retryApiOn("before_prompt_build", async () => {
|
|
5842
|
+
if (retryQueue.size === 0) return;
|
|
5843
|
+
try {
|
|
5844
|
+
await retryQueue.flush(async (item) => {
|
|
5845
|
+
switch (item.operation) {
|
|
5846
|
+
case "store": {
|
|
5847
|
+
const p = item.payload;
|
|
5848
|
+
await retryCloudClient.add_memory(
|
|
5849
|
+
p.content as string,
|
|
5850
|
+
p.memory_type as string | undefined,
|
|
5851
|
+
p.extraction_hints as Record<string, unknown> | undefined,
|
|
5852
|
+
);
|
|
5853
|
+
break;
|
|
5854
|
+
}
|
|
5855
|
+
case "update": {
|
|
5856
|
+
await retryCloudClient.update_memory(item.key, item.payload as { content?: string; label?: string; memory_type?: string; is_pinned?: boolean; current_heat?: number });
|
|
5857
|
+
break;
|
|
5858
|
+
}
|
|
5859
|
+
case "delete": {
|
|
5860
|
+
await retryCloudClient.delete_memory(item.key);
|
|
5861
|
+
break;
|
|
5862
|
+
}
|
|
5863
|
+
case "boost": {
|
|
5864
|
+
const boosts = item.payload.boosts as Array<{ id: string; heat: number }> | undefined;
|
|
5865
|
+
if (boosts) await retryCloudClient.boost_batch(boosts);
|
|
5866
|
+
break;
|
|
5867
|
+
}
|
|
5868
|
+
default:
|
|
5869
|
+
logger.warn(`sulcus-retry: unknown operation ${item.operation}`);
|
|
5870
|
+
}
|
|
5871
|
+
});
|
|
5872
|
+
} catch (flushErr) {
|
|
5873
|
+
logger.debug?.(`sulcus: retry queue flush error: ${flushErr}`);
|
|
5874
|
+
}
|
|
5875
|
+
});
|
|
5876
|
+
logger.info("sulcus: registered retry queue flush hook");
|
|
5877
|
+
}
|
|
5878
|
+
|
|
5542
5879
|
// 5. before_prompt_build — recall + awareness (SDK path, v5.0.0+)
|
|
5543
5880
|
// When autoRecall=true and cloud backend available: recall + inject awareness via prependContext.
|
|
5544
5881
|
// When autoRecall=false but cloud backend available: inject awareness only (static context block).
|
|
@@ -5555,6 +5892,7 @@ const sulcusPlugin = {
|
|
|
5555
5892
|
tokenBudget,
|
|
5556
5893
|
contextRebuildEnabled,
|
|
5557
5894
|
contextWindowSize,
|
|
5895
|
+
hasLocalClient ? localClient : null,
|
|
5558
5896
|
);
|
|
5559
5897
|
const apiOn = api.on as (event: string, handler: unknown) => void;
|
|
5560
5898
|
apiOn("before_prompt_build", async (event: Record<string, unknown>, ctx: unknown) => {
|
|
@@ -6769,6 +7107,46 @@ ${finalContent}`;
|
|
|
6769
7107
|
}
|
|
6770
7108
|
}
|
|
6771
7109
|
|
|
7110
|
+
// ── Context Engine (opt-in) ──
|
|
7111
|
+
// Phase 1: Safe delegate. Registers as context engine with ownsCompaction,
|
|
7112
|
+
// but delegates all behavior to the built-in runtime. Zero functional change.
|
|
7113
|
+
const contextEngineEnabled = pluginConfig?.contextEngine?.enabled === true;
|
|
7114
|
+
const assemblyMode = (pluginConfig?.contextEngine as any)?.assemblyMode ?? "passthrough";
|
|
7115
|
+
const compactMode = (pluginConfig?.contextEngine as any)?.compactMode ?? "smart";
|
|
7116
|
+
if (contextEngineEnabled && typeof (api as any).registerContextEngine === "function") {
|
|
7117
|
+
const ceVersion = "7.2.0";
|
|
7118
|
+
const ceThresholds = (pluginConfig?.contextEngine as any)?.thresholds ?? {};
|
|
7119
|
+
(api as any).registerContextEngine("openclaw-sulcus", async () => {
|
|
7120
|
+
// Lazy-load delegate at factory time
|
|
7121
|
+
let delegateCompaction: (params: any) => Promise<any>;
|
|
7122
|
+
try {
|
|
7123
|
+
const sdk = await import("openclaw/plugin-sdk");
|
|
7124
|
+
delegateCompaction = sdk.delegateCompactionToRuntime;
|
|
7125
|
+
} catch {
|
|
7126
|
+
const sdk = await import("@anthropic-ai/openclaw-plugin-sdk");
|
|
7127
|
+
delegateCompaction = sdk.delegateCompactionToRuntime;
|
|
7128
|
+
}
|
|
7129
|
+
|
|
7130
|
+
const engine = new SulcusContextEngine({
|
|
7131
|
+
version: ceVersion,
|
|
7132
|
+
assemblyMode: assemblyMode as "passthrough" | "memory-aware" | "constructive",
|
|
7133
|
+
compactMode: compactMode as "passthrough" | "smart",
|
|
7134
|
+
logger,
|
|
7135
|
+
delegateCompaction,
|
|
7136
|
+
memoryClient: (sulcusMem && sulcusMem instanceof SulcusCloudClient) ? sulcusMem : null,
|
|
7137
|
+
namespace,
|
|
7138
|
+
thresholds: ceThresholds,
|
|
7139
|
+
});
|
|
7140
|
+
return engine;
|
|
7141
|
+
});
|
|
7142
|
+
contextEngineActive = true;
|
|
7143
|
+
logger.info(`sulcus: context engine registered (v7.2.0, ownsCompaction: true, assembly: ${assemblyMode})`);
|
|
7144
|
+
} else if (contextEngineEnabled) {
|
|
7145
|
+
logger.warn("sulcus: contextEngine.enabled=true but api.registerContextEngine not available — skipping");
|
|
7146
|
+
} else {
|
|
7147
|
+
logger.info("sulcus: context engine disabled (set contextEngine.enabled: true to opt in)");
|
|
7148
|
+
}
|
|
7149
|
+
|
|
6772
7150
|
// Fire-and-forget first-install history import
|
|
6773
7151
|
if (isAvailable && sulcusMem instanceof SulcusCloudClient) {
|
|
6774
7152
|
importOpenClawHistory(sulcusMem, logger).catch((e: unknown) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -351,6 +351,36 @@
|
|
|
351
351
|
"maximum": 10000
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
|
+
},
|
|
355
|
+
"contextEngine": {
|
|
356
|
+
"type": "object",
|
|
357
|
+
"description": "Sulcus Context Engine settings. When enabled, registers Sulcus as the OpenClaw context engine (ownsCompaction) for full control over context assembly, compaction, and mid-loop management.",
|
|
358
|
+
"additionalProperties": false,
|
|
359
|
+
"properties": {
|
|
360
|
+
"enabled": {
|
|
361
|
+
"type": "boolean",
|
|
362
|
+
"description": "Register Sulcus as the OpenClaw context engine. When disabled, OpenClaw uses its built-in legacy engine. Default: false.",
|
|
363
|
+
"default": false
|
|
364
|
+
},
|
|
365
|
+
"assemblyMode": {
|
|
366
|
+
"type": "string",
|
|
367
|
+
"enum": ["passthrough", "memory-aware"],
|
|
368
|
+
"description": "How assemble() manages context. 'passthrough' returns messages unchanged. 'memory-aware' queries Sulcus for relevant memories, injects a memory index, and compresses stored content when over budget. Default: passthrough.",
|
|
369
|
+
"default": "passthrough"
|
|
370
|
+
},
|
|
371
|
+
"compactMode": {
|
|
372
|
+
"type": "string",
|
|
373
|
+
"enum": ["smart", "delegate"],
|
|
374
|
+
"description": "How compact() handles compaction. 'smart' queries Sulcus for stored memories and enriches the compaction LLM with context about what's already recallable (avoids re-summarizing durable content). 'delegate' passes through to built-in runtime unchanged. Both fall back to delegation on error. Default: smart.",
|
|
375
|
+
"default": "smart"
|
|
376
|
+
},
|
|
377
|
+
"compactFallback": {
|
|
378
|
+
"type": "string",
|
|
379
|
+
"enum": ["delegate", "disabled"],
|
|
380
|
+
"description": "Fallback when Sulcus compaction fails: 'delegate' uses built-in LLM pipeline, 'disabled' skips. Default: delegate.",
|
|
381
|
+
"default": "delegate"
|
|
382
|
+
}
|
|
383
|
+
}
|
|
354
384
|
}
|
|
355
385
|
}
|
|
356
386
|
},
|