@digitalforgestudios/openclaw-sulcus 6.6.6 → 7.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,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
- // --- Task 70: Set rebuild flag so next before_prompt_build does full context rebuild ---
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 sulcusMem.search_memory(recallQuery, effectiveMax, effectiveNamespace);
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 res = await sulcusMem.search_memory(params.query as string, (params.limit as number | undefined) ?? 5, searchNamespace);
3633
- const results = res?.results ?? [];
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
- const res = await sulcusMem.add_memory(content, mtype, storeHints);
3668
- const nodeId = res?.id ?? "unknown";
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
- const res = await sulcusMem.delete_memory(params.id as string, train);
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 ${params.id as string}${train ? " (trained SIVU to reject similar)" : ""}. Backend: ${backendMode}, namespace: ${namespace}` }],
3863
- details: { id: params.id as string, trained: train, result: res as Record<string, unknown>, backend: backendMode, namespace },
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
- const res = await sulcusMem.update_memory(memId, updates as any);
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(backendMode, namespace);
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
- logger.info(`sulcus: ready (backend: ${backendMode}, namespace: ${namespace}, autoRecall: ${autoRecall}, autoCapture: ${autoCapture}, captureFromAssistant: ${captureFromAssistant}, contextRebuild: ${contextRebuildEnabled})`);
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
- backendMode,
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.1";
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.1, 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) => {
@@ -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
  },