openclaw-memory-atmem 2.2.6-beta.6 → 2.2.6

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 CHANGED
@@ -5,7 +5,7 @@ This npm package is the host bridge for AtMem. It is not a standalone memory eng
5
5
  Use the Python-owned installer:
6
6
 
7
7
  ```bash
8
- python -m pip install --pre --upgrade atmem==2.2.6b7
8
+ python -m pip install --upgrade atmem==2.2.6
9
9
  atmem openclaw install
10
10
  ```
11
11
 
@@ -22,7 +22,8 @@ Existing AtMem 2.1 users run `atmem openclaw upgrade` after upgrading the Python
22
22
  package. This preserves the current memory mode and migration, verifies the new
23
23
  bridge with a self-test flight, and rolls back the bridge on failure.
24
24
 
25
- Bridge `2.2.6-beta.6` adds Spec 007 exact task-context delivery. When the host
25
+ Bridge `2.2.6` includes exact task-context delivery, the shared
26
+ calibrated retrieval decision, and delegated context correlation. When the host
26
27
  supplies `taskId` in hook context, the bridge requests only that governed task,
27
28
  checks its byte digest, contributes it separately from recalled memory, and
28
29
  confirms exposure. With no `taskId`, task delivery stays off and existing
@@ -34,3 +35,22 @@ In shadow mode the bridge observes native-memory changes without injecting AtMem
34
35
  The bridge also supplies Agent Black Box hooks. It records model/tool lifecycle digests and bounded metadata—not raw prompts, responses, parameters or results—so `atmem blackbox verify RUN_ID` can check timeline integrity and observed tool-hook closure. See the [Agent Black Box guide](../../docs/agent-blackbox.md) for the exact boundary.
35
36
 
36
37
  See the repository [OpenClaw setup](../../docs/openclaw-setup.md) and [control-plane guarantees](../../docs/control-plane.md).
38
+
39
+
40
+ ### Delegated local CLI identity and tool evidence in 2.2.6
41
+
42
+ The default delegated user mapping requires `senderIsOwner: true`. Setting
43
+ `requireOwner: false` alone no longer bypasses identity checks. A dedicated local
44
+ CLI process can use the explicit [isolated mapping configuration](../../docs/delegated-context-provider.md#isolated-local-cli-identity-226)
45
+ with matching workspace, agent and exact session generation. If CLI origin is
46
+ absent, an explicitly configured private state directory and an unrouted
47
+ `agent --local` process with no configured shared channels establish the local
48
+ process boundary. Shared-channel and explicit non-owner turns are refused.
49
+
50
+ Tool completion hooks use the event's invocation ID or, when absent, the host
51
+ context's ID. The verifier requires matching tool, turn and scope and a request
52
+ preceding completion. Missing completion observations remain `incomplete_evidence`;
53
+ actual terminal host tool-event results supply fallback observations when typed
54
+ completion hooks are absent. Only matched requests close, with content-free
55
+ bounded caching and duplicate suppression. Fully observed
56
+ terminal tool errors close evidence as `completed_with_tool_errors`, not success.
package/dist/index.js CHANGED
@@ -23,6 +23,8 @@ import { createHash, randomUUID } from "node:crypto";
23
23
  import { createReadStream } from "node:fs";
24
24
  import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
25
25
  import { spawnSync } from "node:child_process";
26
+ import { ToolObservations, observationContext } from "./src/tool-observations.js";
27
+ import { delegatedIdentity, isolatedCliProcess } from "./src/delegated-identity.js";
26
28
  import { AtmemClient } from "./src/rpc-client.js";
27
29
  import { runSetup } from "./src/setup.js";
28
30
  import { NOT_OWNER_MESSAGE, NO_IDENTITY_MESSAGE, describeDecision, isConversationOwner, ok, resolveBoundTaskForTool, refusal, sessionIdentityForTool, } from "./src/task-tools.js";
@@ -40,6 +42,7 @@ const TAKEOVER_GUIDANCE = "<atmem_memory_provider>\n" +
40
42
  "</atmem_memory_provider>";
41
43
  const INJECT_RE = /<(relevant_memories|user_persona|working_memory|episodic_memory|procedural_memory|atmem_control_plane|atmem_memory_provider)>[\s\S]*?<\/(relevant_memories|user_persona|working_memory|episodic_memory|procedural_memory|atmem_control_plane|atmem_memory_provider)>\s*/g;
42
44
  const PROMPT_CACHE_TTL_MS = 10 * 60 * 1000;
45
+ const MAX_PENDING_PROMPTS = 256;
43
46
  function parseConfig(raw) {
44
47
  const cfg = (raw ?? {});
45
48
  const dbPath = expandHome(String(cfg.dbPath ?? "~/.atmem/memories.db"));
@@ -65,6 +68,14 @@ function parseConfig(raw) {
65
68
  const delegatedContext = {
66
69
  userId: String(cfg.delegatedContext?.userId ?? "").trim(),
67
70
  requireOwner: cfg.delegatedContext?.requireOwner !== false,
71
+ localOperator: cfg.delegatedContext?.localOperator ? {
72
+ isolated: cfg.delegatedContext.localOperator.isolated === true,
73
+ stateDir: expandHome(String(cfg.delegatedContext.localOperator.stateDir ?? "")),
74
+ agentId: String(cfg.delegatedContext.localOperator.agentId ?? "").trim(),
75
+ workspaceDir: expandHome(String(cfg.delegatedContext.localOperator.workspaceDir ?? "")),
76
+ sessionKey: String(cfg.delegatedContext.localOperator.sessionKey ?? "").trim(),
77
+ sessionId: String(cfg.delegatedContext.localOperator.sessionId ?? "").trim(),
78
+ } : undefined,
68
79
  };
69
80
  return {
70
81
  command: String(cfg.command ?? "atmem"),
@@ -329,9 +340,8 @@ function register(api) {
329
340
  stop: () => blackboxClient.close(),
330
341
  });
331
342
  }
332
- // Per-turn recall state. Semantic admission uses a short-lived SQLite handoff
333
- // because OpenClaw may run prompt hooks and agent tools in separate runtimes.
334
343
  const pendingPrompts = new Map();
344
+ const pendingPromptTimers = new Map();
335
345
  const observedTurnInputs = new Map();
336
346
  const inboundAttachments = new Map();
337
347
  const inboundAttachmentGeneration = new Map();
@@ -377,14 +387,20 @@ function register(api) {
377
387
  const workspace = cfg.agentWorkspaces[agentIdFor(ctx)];
378
388
  return workspace ? `ws_${digestText(workspace).slice(0, 16)}` : undefined;
379
389
  };
390
+ const verifiedIsolatedProcess = isolatedCliProcess(cfg.delegatedContext, api.config);
380
391
  const delegatedUserIdFor = (ctx) => {
381
- if (!cfg.delegatedContext.userId)
382
- return undefined;
383
- if (cfg.delegatedContext.requireOwner && ctx.senderIsOwner !== true)
384
- return undefined;
385
- return cfg.delegatedContext.userId;
392
+ const identity = delegatedIdentity(cfg.delegatedContext, ctx, cfg.agentWorkspaces[agentIdFor(ctx)], verifiedIsolatedProcess);
393
+ if (!identity.userId && cfg.delegatedContext.userId) {
394
+ api.logger.warn(`${TAG} delegated identity withheld: ${identity.reason}. ` +
395
+ "Use authenticated owner metadata or an explicitly isolated, scope-bound CLI mapping; " +
396
+ "provider HMAC authentication does not establish sender identity.");
397
+ }
398
+ return identity.userId;
386
399
  };
387
400
  const scopedKey = (value, ctx) => `${agentIdFor(ctx)}:${value}`;
401
+ // Lifecycle hooks must resolve one key. An llm_input-only event.sessionId
402
+ // must not redirect confirmation away from before_prompt_build's entry.
403
+ const sessionKeyFor = (ctx) => scopedKey(ctx.sessionKey ?? ctx.sessionId ?? ctx.runId ?? "default-session", ctx);
388
404
  const contextIds = (ctx) => [...new Set([ctx.runId, ctx.sessionKey, ctx.sessionId].filter((value) => Boolean(value)).map((value) => scopedKey(value, ctx)))];
389
405
  const callFor = (ctx, name, args, timeoutMs = cfg.recall.timeoutMs) => {
390
406
  const scoped = cfg.controlPlane.enabled && !Object.keys(cfg.agentSubjects).length
@@ -586,8 +602,14 @@ function register(api) {
586
602
  await observeTurnInput(event.prompt, ctx, "before_model_resolve", Array.isArray(event.attachments) ? event.attachments.length : 0);
587
603
  });
588
604
  api.on("llm_input", async (event, ctx) => {
589
- const sessionKey = scopedKey(ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? "default-session", ctx);
605
+ const sessionKey = sessionKeyFor(ctx);
590
606
  const pending = pendingPrompts.get(sessionKey);
607
+ if (!pending &&
608
+ cfg.controlPlane.enabled &&
609
+ Boolean(cfg.delegatedContext.userId) &&
610
+ event.prompt.trim().length > 0) {
611
+ api.logger.warn(`${TAG} no pending prompt state for llm_input; exact delegated delivery confirmation is unavailable`);
612
+ }
591
613
  if (pending?.delegatedContext !== undefined) {
592
614
  const exact = pending.delegatedContext;
593
615
  const promptOccurrences = exact ? event.prompt.split(exact).length - 1 : 0;
@@ -615,7 +637,7 @@ function register(api) {
615
637
  if (delivered && pending.exposureId) {
616
638
  await callFor(ctx, "control_exposure_shown", { exposure_id: pending.exposureId }, cfg.recall.timeoutMs);
617
639
  }
618
- pendingPrompts.set(sessionKey, { ...pending, delegatedContext: undefined });
640
+ cachePendingPrompt(sessionKey, { ...pending, delegatedContext: undefined });
619
641
  }
620
642
  await recordBlackbox("model.input", event.runId, ctx, {
621
643
  provider: event.provider,
@@ -635,10 +657,10 @@ function register(api) {
635
657
  const visibleText = responses.map(String).join("");
636
658
  const assistantVisibleTextSha256 = digestText(visibleText);
637
659
  const modelOutputBundleSha256 = digestJson(responses);
638
- const sessionKey = scopedKey(ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? "default-session", ctx);
660
+ const sessionKey = sessionKeyFor(ctx);
639
661
  const pending = pendingPrompts.get(sessionKey);
640
662
  if (pending) {
641
- pendingPrompts.set(sessionKey, {
663
+ cachePendingPrompt(sessionKey, {
642
664
  ...pending,
643
665
  assistantVisibleTextSha256,
644
666
  modelOutputBundleSha256,
@@ -711,7 +733,7 @@ function register(api) {
711
733
  const now = Date.now();
712
734
  for (const [key, value] of pendingPrompts) {
713
735
  if (now - value.ts > PROMPT_CACHE_TTL_MS)
714
- pendingPrompts.delete(key);
736
+ deletePendingPrompt(key);
715
737
  }
716
738
  for (const [key, value] of observedTurnInputs) {
717
739
  if (now - value.observedAt > PROMPT_CACHE_TTL_MS)
@@ -722,6 +744,30 @@ function register(api) {
722
744
  inboundAttachments.delete(key);
723
745
  }
724
746
  };
747
+ function deletePendingPrompt(key) {
748
+ const timer = pendingPromptTimers.get(key);
749
+ if (timer)
750
+ clearTimeout(timer);
751
+ pendingPromptTimers.delete(key);
752
+ pendingPrompts.delete(key);
753
+ }
754
+ function cachePendingPrompt(key, value) {
755
+ deletePendingPrompt(key);
756
+ pendingPrompts.set(key, value);
757
+ const timer = setTimeout(() => {
758
+ const current = pendingPrompts.get(key);
759
+ if (current?.ts === value.ts)
760
+ deletePendingPrompt(key);
761
+ }, PROMPT_CACHE_TTL_MS);
762
+ timer.unref?.();
763
+ pendingPromptTimers.set(key, timer);
764
+ while (pendingPrompts.size > MAX_PENDING_PROMPTS) {
765
+ const oldest = pendingPrompts.keys().next().value;
766
+ if (oldest === undefined)
767
+ break;
768
+ deletePendingPrompt(oldest);
769
+ }
770
+ }
725
771
  async function personaBlock(sessionKey, ctx) {
726
772
  if (!cfg.persona.enabled)
727
773
  return { block: "", recordIds: [] };
@@ -753,10 +799,10 @@ function register(api) {
753
799
  if (!userText)
754
800
  return;
755
801
  await observeTurnInput(userText, ctx, "before_prompt_build");
756
- const sessionKey = scopedKey(ctx.sessionKey ?? ctx.sessionId ?? "default-session", ctx);
802
+ const sessionKey = sessionKeyFor(ctx);
757
803
  const takeoverGuidance = cfg.takeoverActive ? TAKEOVER_GUIDANCE : "";
758
- pendingPrompts.set(sessionKey, { text: userText, ts: Date.now() });
759
804
  sweep();
805
+ cachePendingPrompt(sessionKey, { text: userText, ts: Date.now() });
760
806
  if (cfg.controlPlane.enabled) {
761
807
  try {
762
808
  const prepared = (await callFor(ctx, "control_prepare", {
@@ -789,7 +835,14 @@ function register(api) {
789
835
  `sha256:${digestText(taskPrepared.context)}` !== taskPrepared.context_sha256)) {
790
836
  throw new Error("governed task context failed exact handoff digest validation");
791
837
  }
792
- pendingPrompts.set(sessionKey, {
838
+ if (prepared.authority === "delegated" &&
839
+ prepared.inject &&
840
+ (!prepared.context ||
841
+ !prepared.context_sha256 ||
842
+ digestText(prepared.context) !== prepared.context_sha256)) {
843
+ throw new Error("delegated context failed exact handoff digest validation");
844
+ }
845
+ cachePendingPrompt(sessionKey, {
793
846
  text: userText,
794
847
  ts: Date.now(),
795
848
  exposureId: prepared.exposure_id,
@@ -815,13 +868,6 @@ function register(api) {
815
868
  task_reason_codes: taskPrepared?.reason_codes ?? [],
816
869
  });
817
870
  }
818
- if (prepared.authority === "delegated" &&
819
- prepared.inject &&
820
- (!prepared.context ||
821
- !prepared.context_sha256 ||
822
- digestText(prepared.context) !== prepared.context_sha256)) {
823
- throw new Error("delegated context failed exact handoff digest validation");
824
- }
825
871
  if (prepared.authority === "delegated" || prepared.authority === "atmem_fallback") {
826
872
  await recordBlackbox("context.provider_authorization", undefined, ctx, {
827
873
  disposition: prepared.decision ?? "provider_failure",
@@ -835,20 +881,43 @@ function register(api) {
835
881
  success: prepared.decision === "inject" || prepared.decision === "withhold",
836
882
  }, undefined, { contextReceiptId: prepared.context_receipt_id });
837
883
  }
884
+ const taskContext = taskPrepared?.disposition === "injected"
885
+ ? taskPrepared.context ?? ""
886
+ : "";
887
+ let contextEnvelope = {};
888
+ if (prepared.inject && prepared.context) {
889
+ contextEnvelope = prepared.authority === "delegated"
890
+ ? {
891
+ prependContext: prepared.context,
892
+ ...(taskContext ? { appendContext: taskContext } : {}),
893
+ }
894
+ : {
895
+ appendContext: [prepared.context, taskContext].filter(Boolean).join("\n\n"),
896
+ };
897
+ }
898
+ else if (taskContext) {
899
+ contextEnvelope = { appendContext: taskContext };
900
+ }
901
+ const contextLocations = [
902
+ contextEnvelope.prependContext ? "prependContext" : "",
903
+ contextEnvelope.appendContext ? "appendContext" : "",
904
+ ].filter(Boolean);
905
+ const deliveredContext = [
906
+ contextEnvelope.prependContext,
907
+ contextEnvelope.appendContext,
908
+ ].filter((value) => Boolean(value)).join("\n\n");
838
909
  await recordBlackbox("context.disposition", undefined, ctx, {
839
- disposition: prepared.inject && prepared.context
910
+ disposition: deliveredContext
840
911
  ? "injected"
841
912
  : prepared.mode === "shadow" && (prepared.preview_context ?? "")
842
913
  ? "withheld_by_policy"
843
914
  : "no_relevant_memory",
844
- context_sha256: digestText(prepared.context ?? ""),
845
- context_block_sha256: digestText(prepared.context ?? ""),
846
- context_envelope_sha256: digestJson(prepared.authority === "delegated"
847
- ? { prependContext: prepared.context ?? "" }
848
- : { appendContext: prepared.context ?? "" }),
915
+ context_sha256: digestText(deliveredContext),
916
+ context_block_sha256: digestText(deliveredContext),
917
+ context_envelope_sha256: digestJson(contextEnvelope),
849
918
  context_receipt_sha256: prepared.manifest_sha256,
850
919
  digest_profile: "atmem-context-envelope-canonical-json-v1",
851
- context_chars: (prepared.context ?? "").length,
920
+ context_chars: deliveredContext.length,
852
921
  candidate_ids: prepared.candidate_ids ?? [],
853
922
  candidates_considered: prepared.retrieval?.eligible_candidate_count ?? 0,
854
923
  retrieval_support_class: prepared.retrieval?.decision?.support_class ?? "not_recorded",
@@ -856,34 +925,20 @@ function register(api) {
856
925
  retrieval_calibration_version: prepared.retrieval?.decision?.calibration_version,
857
926
  exposure_id: prepared.exposure_id,
858
927
  mode: prepared.mode,
859
- context_location: prepared.inject
860
- ? prepared.authority === "delegated" ? "prependContext" : "appendContext"
861
- : "none",
928
+ context_location: contextLocations.length ? contextLocations.join("+") : "none",
862
929
  }, undefined, {
863
930
  contextReceiptId: prepared.context_receipt_id,
864
931
  });
865
- if (prepared.inject && prepared.context) {
932
+ if (Object.keys(contextEnvelope).length) {
866
933
  api.logger.info(`${TAG} memory control plane ${prepared.mode ?? "active"} context exposed`);
867
- return prepared.authority === "delegated"
868
- ? {
869
- prependContext: prepared.context,
870
- appendContext: taskPrepared?.disposition === "injected"
871
- ? taskPrepared.context
872
- : undefined,
873
- }
874
- : {
875
- appendContext: [
876
- prepared.context,
877
- taskPrepared?.disposition === "injected" ? taskPrepared.context : "",
878
- ].filter(Boolean).join("\n\n"),
879
- };
880
- }
881
- if (taskPrepared?.disposition === "injected" && taskPrepared.context) {
882
- return { appendContext: taskPrepared.context };
934
+ return contextEnvelope;
883
935
  }
884
936
  return;
885
937
  }
886
938
  catch (error) {
939
+ // Any exact delegated bytes retained while constructing evidence are
940
+ // discarded immediately on the fail-closed path.
941
+ cachePendingPrompt(sessionKey, { text: userText, ts: Date.now() });
887
942
  await recordBlackbox("context.disposition", undefined, ctx, {
888
943
  disposition: "recall_failed",
889
944
  context_block_sha256: digestText(""),
@@ -949,7 +1004,7 @@ function register(api) {
949
1004
  recall = result.block;
950
1005
  const current = pendingPrompts.get(sessionKey);
951
1006
  if (current) {
952
- pendingPrompts.set(sessionKey, {
1007
+ cachePendingPrompt(sessionKey, {
953
1008
  ...current,
954
1009
  injectedRecordIds: result.record_ids ?? [],
955
1010
  retrievalId: result.retrieval_id,
@@ -1002,7 +1057,7 @@ function register(api) {
1002
1057
  ? `ctxr_${digestJson({ componentEventIds, contextEnvelopeSha256 })}`
1003
1058
  : undefined;
1004
1059
  if (current) {
1005
- pendingPrompts.set(sessionKey, { ...current, contextReceiptId });
1060
+ cachePendingPrompt(sessionKey, { ...current, contextReceiptId });
1006
1061
  }
1007
1062
  await recordBlackbox("context.disposition", undefined, ctx, {
1008
1063
  disposition: memoryContext
@@ -1027,8 +1082,26 @@ function register(api) {
1027
1082
  if (Object.keys(result).length)
1028
1083
  return result;
1029
1084
  });
1085
+ // Latest CLI harnesses emit terminal results on the agent-event stream.
1086
+ // Feature-detect both API generations; never infer completion from a request.
1087
+ const runContext = api.runContext ?? (api.setRunContext && api.getRunContext ? {
1088
+ setRunContext: api.setRunContext.bind(api), getRunContext: api.getRunContext.bind(api),
1089
+ } : undefined);
1090
+ const subscribe = api.agent?.events?.registerAgentEventSubscription?.bind(api.agent.events)
1091
+ ?? api.registerAgentEventSubscription?.bind(api);
1092
+ const toolObservations = cfg.controlPlane.blackboxEnabled && subscribe
1093
+ ? new ToolObservations(observationContext(runContext, digestJson({
1094
+ command: cfg.command, args: cfg.commandArgs, state: cfg.controlPlane.statePath,
1095
+ subjects: cfg.agentSubjects, workspaces: cfg.agentWorkspaces,
1096
+ })), digestJson) : undefined;
1097
+ if (toolObservations && subscribe)
1098
+ subscribe({
1099
+ id: "atmem-terminal-tool-observations", streams: ["tool"],
1100
+ handle: event => toolObservations.observe(event),
1101
+ });
1030
1102
  // ---- flight recorder + takeover enforcement --------------------------
1031
1103
  api.on("before_tool_call", async (event, ctx) => {
1104
+ toolObservations?.request(event.toolName, event.toolCallId ?? ctx.toolCallId, { ...ctx, runId: event.runId ?? ctx.runId, agentId: agentIdFor(ctx) });
1032
1105
  await recordBlackbox("tool.requested", event.runId, ctx, {
1033
1106
  tool_name: event.toolName,
1034
1107
  tool_canonical_name: canonicalToolName(event.toolName),
@@ -1038,7 +1111,7 @@ function register(api) {
1038
1111
  derived_path_sha256: Array.isArray(event.derivedPaths)
1039
1112
  ? event.derivedPaths.map((value) => digestText(String(value)))
1040
1113
  : [],
1041
- }, event.toolCallId);
1114
+ }, event.toolCallId ?? ctx.toolCallId);
1042
1115
  if (!cfg.takeoverActive || !cfg.nativeWorkspaces.some((workspace) => touchesNativeMemory(event, workspace)))
1043
1116
  return;
1044
1117
  const reason = "AtMem takeover blocked access to OpenClaw's frozen native memory " +
@@ -1051,11 +1124,12 @@ function register(api) {
1051
1124
  error_category: "blocked_by_memory_boundary",
1052
1125
  result_sha256: digestText(reason),
1053
1126
  duration_ms: 0,
1054
- }, event.toolCallId);
1127
+ }, event.toolCallId ?? ctx.toolCallId);
1055
1128
  api.logger.warn(`${TAG} ${reason} Tool: ${event.toolName}`);
1056
1129
  return { block: true, blockReason: reason };
1057
1130
  });
1058
1131
  api.on("after_tool_call", async (event, ctx) => {
1132
+ toolObservations?.completed(event.toolCallId ?? ctx.toolCallId, { ...ctx, runId: event.runId ?? ctx.runId, agentId: agentIdFor(ctx) });
1059
1133
  await recordBlackbox("tool.completed", event.runId, ctx, {
1060
1134
  tool_name: event.toolName,
1061
1135
  tool_canonical_name: canonicalToolName(event.toolName),
@@ -1063,14 +1137,22 @@ function register(api) {
1063
1137
  outcome: event.error ? "error" : "completed",
1064
1138
  error_category: event.error ? "tool_error" : undefined,
1065
1139
  duration_ms: event.durationMs ?? 0,
1066
- }, event.toolCallId);
1140
+ }, event.toolCallId ?? ctx.toolCallId);
1067
1141
  });
1068
1142
  // ---- auto-capture: user turn through the pipeline, assistant as digest -
1069
1143
  api.on("agent_end", async (event, ctx) => {
1070
- const sessionKey = scopedKey(ctx.sessionKey ?? ctx.sessionId ?? "default-session", ctx);
1144
+ await toolObservations?.flush({ ...ctx, runId: event.runId ?? ctx.runId, agentId: agentIdFor(ctx) }, async (saved) => {
1145
+ await recordBlackbox("tool.completed", saved.ctx.runId, saved.ctx, {
1146
+ tool_name: saved.name, tool_canonical_name: canonicalToolName(saved.name),
1147
+ result_sha256: saved.result.digest, outcome: saved.result.error ? "error" : "completed",
1148
+ error_category: saved.result.error ? "tool_error" : undefined,
1149
+ reason: "observed_terminal_host_tool_event",
1150
+ }, saved.callId);
1151
+ });
1152
+ const sessionKey = sessionKeyFor(ctx);
1071
1153
  const observedTurnInputKey = turnInputKey(ctx);
1072
1154
  const cached = pendingPrompts.get(sessionKey);
1073
- pendingPrompts.delete(sessionKey);
1155
+ deletePendingPrompt(sessionKey);
1074
1156
  const userText = cached?.text?.replace(INJECT_RE, "").trim();
1075
1157
  try {
1076
1158
  if (cfg.takeoverActive) {
@@ -0,0 +1,62 @@
1
+ import path from "node:path";
2
+ import { lstatSync, realpathSync } from "node:fs";
3
+ /** Verify the dedicated CLI process when the host omits origin metadata.
4
+ * This is an explicit local operator trust boundary, never shared-channel auth.
5
+ */
6
+ export function isolatedCliProcess(config, hostConfig, runtime = { argv: process.argv.slice(2), stateDir: process.env.OPENCLAW_STATE_DIR, uid: process.getuid?.() }) {
7
+ const local = config.localOperator;
8
+ if (!local?.isolated || !local.stateDir || !runtime.stateDir || !hostConfig)
9
+ return false;
10
+ if (!runtime.argv.includes("agent") || !runtime.argv.includes("--local"))
11
+ return false;
12
+ const routed = ["--deliver", "--channel", "--to", "--reply-channel", "--reply-to", "--reply-account"];
13
+ if (runtime.argv.some(arg => routed.some(flag => arg === flag || arg.startsWith(flag + "="))))
14
+ return false;
15
+ const channels = hostConfig.channels;
16
+ if (channels !== undefined && (!channels || typeof channels !== "object" || Array.isArray(channels) || Object.keys(channels).length))
17
+ return false;
18
+ try {
19
+ const directory = lstatSync(local.stateDir);
20
+ return path.isAbsolute(local.stateDir) && directory.isDirectory() && !directory.isSymbolicLink()
21
+ && (directory.mode & 0o077) === 0 && directory.uid === runtime.uid
22
+ && realpathSync(local.stateDir) === realpathSync(runtime.stateDir);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Host metadata and operator configuration only; never prompt/model input. */
29
+ export function delegatedIdentity(config, ctx, configuredWorkspace, verifiedIsolatedProcess = false) {
30
+ if (!config.userId)
31
+ return { reason: "delegated_user_unconfigured" };
32
+ if (ctx.senderIsOwner === false)
33
+ return { reason: "delegated_sender_not_owner" };
34
+ if (ctx.senderIsOwner !== undefined && ctx.senderIsOwner !== true) {
35
+ return { reason: "delegated_owner_metadata_invalid" };
36
+ }
37
+ if (config.requireOwner) {
38
+ return ctx.senderIsOwner === true
39
+ ? { userId: config.userId }
40
+ : { reason: "delegated_owner_metadata_missing" };
41
+ }
42
+ // Legacy requireOwner:false is no longer an unrestricted identity bypass.
43
+ const local = config.localOperator;
44
+ if (!local || local.isolated !== true) {
45
+ return { reason: "delegated_local_isolation_required" };
46
+ }
47
+ if ((ctx.messageProvider !== "cli" && !(ctx.messageProvider === undefined && verifiedIsolatedProcess)) || ctx.channel || ctx.channelId ||
48
+ ctx.accountId || ctx.chatId || ctx.senderId) {
49
+ return { reason: "delegated_local_origin_unverified" };
50
+ }
51
+ if (!local.agentId || !local.sessionKey || !local.sessionId || !local.workspaceDir ||
52
+ !ctx.runId || !ctx.workspaceDir || !configuredWorkspace ||
53
+ !path.isAbsolute(local.workspaceDir) || !path.isAbsolute(ctx.workspaceDir) ||
54
+ !path.isAbsolute(configuredWorkspace) ||
55
+ ctx.agentId !== local.agentId || ctx.sessionKey !== local.sessionKey ||
56
+ ctx.sessionId !== local.sessionId ||
57
+ path.resolve(ctx.workspaceDir) !== path.resolve(local.workspaceDir) ||
58
+ path.resolve(configuredWorkspace) !== path.resolve(local.workspaceDir)) {
59
+ return { reason: "delegated_local_scope_mismatch" };
60
+ }
61
+ return { userId: config.userId };
62
+ }
@@ -128,7 +128,7 @@ export class AtmemClient {
128
128
  capabilities: {},
129
129
  clientInfo: {
130
130
  name: "openclaw-memory-atmem",
131
- version: "2.2.6-beta.6",
131
+ version: "2.2.6",
132
132
  },
133
133
  });
134
134
  this.notify("notifications/initialized", {});
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ const CACHE_KEY = Symbol.for("atmem.tool-observation-cache.v1");
3
+ const sharedCache = globalThis;
4
+ const cache = sharedCache[CACHE_KEY] ??= new Map();
5
+ /** Some hosts deny run-context writes from tool hooks after registry reload.
6
+ * Keep a bounded content-free process fallback, isolated by installation config.
7
+ */
8
+ export function observationContext(host, scope) {
9
+ const keyFor = (runId, namespace) => JSON.stringify([scope, runId, namespace]);
10
+ const sweep = () => {
11
+ for (const [key, entry] of cache)
12
+ if (entry.expires <= Date.now())
13
+ cache.delete(key);
14
+ while (cache.size >= 2048)
15
+ cache.delete(cache.keys().next().value);
16
+ };
17
+ return {
18
+ setRunContext(patch) {
19
+ sweep();
20
+ cache.set(keyFor(patch.runId, patch.namespace), { value: structuredClone(patch.value), expires: Date.now() + 600_000 });
21
+ try {
22
+ host?.setRunContext(patch);
23
+ }
24
+ catch { /* Process fallback remains bounded. */ }
25
+ return true;
26
+ },
27
+ getRunContext(params) {
28
+ sweep();
29
+ const entry = cache.get(keyFor(params.runId, params.namespace));
30
+ if (entry)
31
+ return structuredClone(entry.value);
32
+ try {
33
+ return host?.getRunContext(params);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ },
39
+ };
40
+ }
41
+ const INDEX = "atmem-tool-observations-v1";
42
+ const namespace = (id) => INDEX + "-" + createHash("sha256").update(id).digest("hex");
43
+ /** The CLI exposes its native names; the request hook exposes OpenClaw names. */
44
+ function canonical(name) {
45
+ const unwrapped = name.replace(/^mcp__openclaw__/, "");
46
+ return { Read: "read", Bash: "exec" }[unwrapped] ?? unwrapped;
47
+ }
48
+ /** Stores only scope, names and digests in bounded ephemeral observation context. */
49
+ export class ToolObservations {
50
+ access;
51
+ digest;
52
+ constructor(access, digest) {
53
+ this.access = access;
54
+ this.digest = digest;
55
+ }
56
+ request(name, id, ctx) {
57
+ if (!id || !ctx.runId || !ctx.agentId || !ctx.sessionId || !ctx.sessionKey)
58
+ return;
59
+ const runId = ctx.runId;
60
+ const ids = this.access.getRunContext({ runId, namespace: INDEX }) ?? [];
61
+ if (ids.includes(id) || ids.length >= 256)
62
+ return;
63
+ // Never store the full hook context: it may contain host config/credentials.
64
+ const scope = { runId, agentId: ctx.agentId, sessionId: ctx.sessionId, sessionKey: ctx.sessionKey };
65
+ if (this.access.setRunContext({ runId, namespace: namespace(id), value: { ctx: scope, name, callId: id } })) {
66
+ this.access.setRunContext({ runId, namespace: INDEX, value: [...ids, id] });
67
+ }
68
+ }
69
+ completed(id, ctx) {
70
+ if (!id || !ctx.runId)
71
+ return;
72
+ const key = { runId: ctx.runId, namespace: namespace(id) };
73
+ const saved = this.access.getRunContext(key);
74
+ if (saved && saved.ctx.sessionId === ctx.sessionId && saved.ctx.sessionKey === ctx.sessionKey && saved.ctx.agentId === ctx.agentId) {
75
+ this.access.setRunContext({ ...key, value: { ...saved, hookCompleted: true } });
76
+ }
77
+ }
78
+ observe(event) {
79
+ const d = event.data;
80
+ if (event.stream !== "tool" || d.phase !== "result" || typeof d.toolCallId !== "string" ||
81
+ typeof d.name !== "string" || typeof d.isError !== "boolean" ||
82
+ !Object.hasOwn(d, "result") || d.incomplete === true || d.synthetic === true)
83
+ return;
84
+ const key = { runId: event.runId, namespace: namespace(d.toolCallId) };
85
+ const saved = this.access.getRunContext(key);
86
+ if (!saved || event.sessionKey !== saved.ctx.sessionKey ||
87
+ (event.sessionId !== undefined && event.sessionId !== saved.ctx.sessionId) ||
88
+ (event.agentId !== undefined && event.agentId !== saved.ctx.agentId) ||
89
+ canonical(d.name) !== canonical(saved.name))
90
+ return;
91
+ const result = { digest: this.digest(d.result), error: d.isError };
92
+ const conflict = saved.conflict || (saved.result !== undefined &&
93
+ (saved.result.digest !== result.digest || saved.result.error !== result.error));
94
+ this.access.setRunContext({ ...key, value: { ...saved, result, conflict } });
95
+ }
96
+ async flush(ctx, record) {
97
+ if (!ctx.runId)
98
+ return;
99
+ const runId = ctx.runId;
100
+ const ids = this.access.getRunContext({ runId, namespace: INDEX }) ?? [];
101
+ for (const id of ids) {
102
+ const key = { runId, namespace: namespace(id) };
103
+ const saved = this.access.getRunContext(key);
104
+ if (saved && !saved.hookCompleted && !saved.conflict && saved.result &&
105
+ saved.ctx.sessionKey === ctx.sessionKey && saved.ctx.sessionId === ctx.sessionId && saved.ctx.agentId === ctx.agentId) {
106
+ await record(saved);
107
+ this.access.setRunContext({ ...key, value: { ...saved, hookCompleted: true } });
108
+ }
109
+ }
110
+ }
111
+ }
package/dist/src/types.js CHANGED
@@ -1,7 +1 @@
1
- /**
2
- * Minimal structural types for the OpenClaw plugin API surface this plugin
3
- * uses. Kept local (instead of importing "openclaw/plugin-sdk/core") so the
4
- * plugin builds without the SDK on the compile path; the shapes match the
5
- * hook/tool contracts observed in OpenClaw's plugin SDK.
6
- */
7
1
  export {};
@@ -2,7 +2,7 @@
2
2
  "id": "memory-atmem",
3
3
  "name": "Memory (atmem)",
4
4
  "description": "OpenClaw bridge installed and managed by the AtMem memory control plane.",
5
- "version": "2.2.6-beta.6",
5
+ "version": "2.2.6",
6
6
  "commandAliases": ["memory-atmem"],
7
7
  "activation": {
8
8
  "onStartup": true,
@@ -150,7 +150,21 @@
150
150
  "requireOwner": {
151
151
  "type": "boolean",
152
152
  "default": true,
153
- "description": "Send the mapped user ID only when OpenClaw marks the sender as owner."
153
+ "description": "Require authenticated owner metadata (default). False requires the explicit isolated localOperator mapping; it is not an ownership bypass."
154
+ },
155
+ "localOperator": {
156
+ "type": "object",
157
+ "additionalProperties": false,
158
+ "required": ["isolated", "agentId", "workspaceDir", "sessionKey", "sessionId"],
159
+ "description": "Only for a dedicated local CLI process with channels disabled by the operator. Requires host messageProvider=cli, workspace and exact session generation; channel-originated turns are refused.",
160
+ "properties": {
161
+ "isolated": { "type": "boolean", "const": true, "description": "Operator assertion that this process is isolated from shared channels and other users." },
162
+ "stateDir": { "type": "string", "minLength": 1, "description": "Optional dedicated owner-only OPENCLAW_STATE_DIR. Required when a direct agent --local process omits CLI origin metadata; channels and delivery flags must be absent." },
163
+ "agentId": { "type": "string", "minLength": 1 },
164
+ "workspaceDir": { "type": "string", "minLength": 1 },
165
+ "sessionKey": { "type": "string", "minLength": 1 },
166
+ "sessionId": { "type": "string", "minLength": 1 }
167
+ }
154
168
  }
155
169
  }
156
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-memory-atmem",
3
- "version": "2.2.6-beta.6",
3
+ "version": "2.2.6",
4
4
  "description": "OpenClaw Agent Black Box and memory control-plane bridge for AtMem",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -40,8 +40,8 @@
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "node test/manifest.mjs && node test/hook-context-compat.mjs && node test/setup.mjs && node test/hooks.mjs && node test/task-tools.mjs && node test/delegated-context-contract.mjs",
44
- "smoke": "node test/smoke.mjs && node test/task-journey.mjs",
43
+ "test": "node test/manifest.mjs && node test/hook-context-compat.mjs && node test/setup.mjs && node test/hooks.mjs && node test/delegated-identity.mjs && node test/tool-observations.mjs && node test/task-tools.mjs && node test/delegated-context-contract.mjs",
44
+ "smoke": "node test/smoke.mjs && node test/task-journey.mjs && node test/delegated-journey.mjs",
45
45
  "prepack": "npm run build && npm run typecheck && npm test && npm run smoke",
46
46
  "hook-context": "node test/hook-context-compat.mjs",
47
47
  "record-hook-context": "node test/lib/record-hook-context.mjs"