openclaw-memory-atmem 2.2.7-beta.1 → 2.3.3

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 --upgrade atmem==2.2.7b1
8
+ python -m pip install --upgrade atmem==2.3.3
9
9
  atmem openclaw install
10
10
  ```
11
11
 
@@ -22,7 +22,7 @@ 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.7-beta.1` includes exact task-context delivery, the shared
25
+ Bridge `2.3.3` includes durable execution-event spooling in addition to exact task-context delivery and the shared
26
26
  calibrated retrieval decision, and delegated context correlation. When the host
27
27
  supplies `taskId` in hook context, the bridge requests only that governed task,
28
28
  checks its byte digest, contributes it separately from recalled memory, and
@@ -32,12 +32,16 @@ bridge does not claim it can block OpenClaw execution.
32
32
 
33
33
  In shadow mode the bridge observes native-memory changes without injecting AtMem context. In active mode it exposes compatible memory search/get tools, model-semantic capture, bounded recall and native-path protection. `atmem control restore` restores the saved OpenClaw configuration and native memory.
34
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
+ The bridge also supplies Agent Black Box hooks. With default full capture it sends
36
+ exact host-observed prompts, model boundaries, tool arguments/results and supported
37
+ multimodal parts into AtMem's encrypted evidence vault, alongside digest-bound
38
+ lifecycle metadata. Metadata-only and recorder-off modes remain explicit operator
39
+ choices. See the [Agent Black Box guide](../../docs/agent-blackbox.md) for the exact boundary.
36
40
 
37
41
  See the repository [OpenClaw setup](../../docs/openclaw-setup.md) and [control-plane guarantees](../../docs/control-plane.md).
38
42
 
39
43
 
40
- ### Delegated local CLI identity and tool evidence in 2.2.7b1
44
+ ### Delegated local CLI identity and tool evidence in 2.2.6
41
45
 
42
46
  The default delegated user mapping requires `senderIsOwner: true`. Setting
43
47
  `requireOwner: false` alone no longer bypasses identity checks. A dedicated local
@@ -55,7 +59,7 @@ completion hooks are absent. Only matched requests close, with content-free
55
59
  bounded caching and duplicate suppression. Fully observed
56
60
  terminal tool errors close evidence as `completed_with_tool_errors`, not success.
57
61
 
58
- Beta 2.2.7b1: automatic recall now requires calibrated direct
62
+ Local development follow-up: automatic recall now requires calibrated direct
59
63
  query support by default (`recall.requireDirectSupport: true`). Persona is an
60
64
  explicit opt-in (`persona.enabled: true`); setup defaults it off to avoid adding
61
65
  unrelated personal facts to every request. When enabled, persona records are
package/dist/index.js CHANGED
@@ -20,12 +20,12 @@
20
20
  import os from "node:os";
21
21
  import path from "node:path";
22
22
  import { createHash, randomUUID } from "node:crypto";
23
- import { createReadStream } from "node:fs";
24
23
  import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
25
24
  import { spawnSync } from "node:child_process";
26
25
  import { ToolObservations, observationContext } from "./src/tool-observations.js";
27
26
  import { progressCardComparison } from "./src/tool-result-comparison.js";
28
27
  import { toolErrorReason } from "./src/tool-errors.js";
28
+ import { ExecutionSpool } from "./src/execution-spool.js";
29
29
  import { delegatedIdentity, isolatedCliProcess } from "./src/delegated-identity.js";
30
30
  import { AtmemClient } from "./src/rpc-client.js";
31
31
  import { runSetup } from "./src/setup.js";
@@ -47,7 +47,10 @@ const PROMPT_CACHE_TTL_MS = 10 * 60 * 1000;
47
47
  const MAX_PENDING_PROMPTS = 256;
48
48
  function parseConfig(raw) {
49
49
  const cfg = (raw ?? {});
50
- const dbPath = expandHome(String(cfg.dbPath ?? "~/.atmem/memories.db"));
50
+ const selectedHome = process.env.ATMEM_HOME
51
+ ? expandHome(process.env.ATMEM_HOME)
52
+ : path.join(os.homedir(), ".atmem");
53
+ const dbPath = expandHome(String(cfg.dbPath ?? path.join(selectedHome, "memory", "memories.db")));
51
54
  const subject = String(cfg.subject ?? "default");
52
55
  const stringMap = (value) => value && typeof value === "object" && !Array.isArray(value)
53
56
  ? Object.fromEntries(Object.entries(value)
@@ -64,8 +67,8 @@ function parseConfig(raw) {
64
67
  ].filter(Boolean))];
65
68
  const controlPlane = {
66
69
  enabled: cfg.controlPlane?.enabled === true,
67
- statePath: expandHome(String(cfg.controlPlane?.statePath ?? "~/.atmem/control-plane.json")),
68
- blackboxEnabled: cfg.controlPlane?.enabled === true || cfg.controlPlane?.blackboxEnabled === true,
70
+ statePath: expandHome(String(cfg.controlPlane?.statePath ?? path.join(selectedHome, "config", "control-plane.json"))),
71
+ blackboxEnabled: Boolean(cfg.controlPlane) && cfg.controlPlane?.blackboxEnabled !== false,
69
72
  };
70
73
  const delegatedContext = {
71
74
  userId: String(cfg.delegatedContext?.userId ?? "").trim(),
@@ -229,7 +232,7 @@ async function writeAttachmentBinding(root, key, items) {
229
232
  const payload = {
230
233
  format: "atmem-openclaw-attachment-binding-v1",
231
234
  keySha256: createHash("sha256").update(key).digest("hex"),
232
- items,
235
+ items: items.map(({ dataBase64: _discarded, ...item }) => item),
233
236
  updatedAt: Date.now(),
234
237
  };
235
238
  await writeFile(temporary, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
@@ -259,17 +262,57 @@ async function readAttachmentBinding(root, key) {
259
262
  }
260
263
  function modalityFromMime(mimeType) {
261
264
  const mime = mimeType.toLowerCase();
262
- if (mime.startsWith("image/"))
265
+ if (mime === "image" || mime.startsWith("image/"))
263
266
  return "image";
264
- if (mime.startsWith("audio/"))
267
+ if (mime === "audio" || mime.startsWith("audio/"))
265
268
  return "audio";
266
- if (mime.startsWith("video/"))
269
+ if (mime === "video" || mime.startsWith("video/"))
267
270
  return "video";
268
- if (mime === "application/pdf" || mime.startsWith("text/") || mime.includes("document")) {
271
+ if (mime === "document" || mime === "application/pdf" || mime.startsWith("text/") || mime.includes("document")) {
269
272
  return "document";
270
273
  }
271
274
  return null;
272
275
  }
276
+ function inlineMediaParts(value) {
277
+ const parts = [];
278
+ const seen = new Set();
279
+ const visit = (item) => {
280
+ if (typeof item === "string") {
281
+ const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]+={0,2})$/.exec(item);
282
+ if (!match)
283
+ return;
284
+ const mimeType = match[1].toLowerCase();
285
+ const modality = modalityFromMime(mimeType);
286
+ if (!modality)
287
+ return;
288
+ const bytes = Buffer.from(match[2], "base64");
289
+ if (bytes.length > 100 * 1024 * 1024 || bytes.toString("base64") !== match[2])
290
+ return;
291
+ const digest = createHash("sha256").update(bytes).digest("hex");
292
+ if (seen.has(digest))
293
+ return;
294
+ seen.add(digest);
295
+ parts.push({
296
+ type: modality,
297
+ mime_type: mimeType,
298
+ data_base64: match[2],
299
+ representation: "model_input",
300
+ });
301
+ return;
302
+ }
303
+ if (Array.isArray(item)) {
304
+ for (const child of item)
305
+ visit(child);
306
+ return;
307
+ }
308
+ if (item && typeof item === "object") {
309
+ for (const child of Object.values(item))
310
+ visit(child);
311
+ }
312
+ };
313
+ visit(value);
314
+ return parts;
315
+ }
273
316
  function withinPath(candidate, root) {
274
317
  return candidate === root || candidate.startsWith(root + path.sep);
275
318
  }
@@ -282,12 +325,12 @@ async function hashInboundAttachment(filePath, mimeType) {
282
325
  throw new Error("OpenClaw attachment path is outside the managed media directory");
283
326
  }
284
327
  const before = await lstat(resolved);
285
- if (!before.isFile() || before.size > 512 * 1024 * 1024) {
328
+ // The 2.3 consumer-hardware profile is measured and bounded at 100 MiB per artifact.
329
+ if (!before.isFile() || before.size > 100 * 1024 * 1024) {
286
330
  throw new Error("OpenClaw attachment is not a bounded regular file");
287
331
  }
288
- const hash = createHash("sha256");
289
- for await (const chunk of createReadStream(resolved))
290
- hash.update(chunk);
332
+ const exactBytes = await readFile(resolved);
333
+ const hash = createHash("sha256").update(exactBytes);
291
334
  const after = await lstat(resolved);
292
335
  if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
293
336
  throw new Error("OpenClaw attachment changed while its digest was computed");
@@ -303,6 +346,53 @@ async function hashInboundAttachment(filePath, mimeType) {
303
346
  mimeType,
304
347
  bytes: before.size,
305
348
  capturedAt: Date.now(),
349
+ dataBase64: exactBytes.toString("base64"),
350
+ };
351
+ }
352
+ function managedMediaPath(reference) {
353
+ const match = /^media:\/\/inbound\/([A-Za-z0-9][A-Za-z0-9._-]{0,255})$/.exec(reference);
354
+ if (!match || match[1] === "." || match[1] === "..")
355
+ return null;
356
+ const root = process.env.ATMEM_OPENCLAW_MEDIA_ROOT
357
+ ? expandHome(process.env.ATMEM_OPENCLAW_MEDIA_ROOT)
358
+ : path.join(os.homedir(), ".openclaw", "media");
359
+ return path.join(root, "inbound", match[1]);
360
+ }
361
+ function attachmentFields(value) {
362
+ const selected = [];
363
+ const seen = new Set();
364
+ const add = (reference, mimeType) => {
365
+ if (typeof reference !== "string" || !reference)
366
+ return;
367
+ const resolved = reference.startsWith("media://") ? managedMediaPath(reference) : reference;
368
+ if (!resolved || seen.has(resolved))
369
+ return;
370
+ seen.add(resolved);
371
+ selected.push({
372
+ path: resolved,
373
+ type: typeof mimeType === "string" && mimeType
374
+ ? mimeType
375
+ : "application/octet-stream",
376
+ });
377
+ };
378
+ const visit = (item) => {
379
+ if (Array.isArray(item)) {
380
+ for (const child of item)
381
+ visit(child);
382
+ return;
383
+ }
384
+ if (!item || typeof item !== "object")
385
+ return;
386
+ const row = item;
387
+ add(row.path ?? row.url, row.contentType ?? row.mimeType ?? row.kind);
388
+ if (row.__openclaw && typeof row.__openclaw === "object") {
389
+ visit(row.__openclaw.media);
390
+ }
391
+ };
392
+ visit(value);
393
+ return {
394
+ paths: selected.map((item) => item.path),
395
+ types: selected.map((item) => item.type),
306
396
  };
307
397
  }
308
398
  function recordIdFromPath(value) {
@@ -314,7 +404,16 @@ function recordIdFromPath(value) {
314
404
  }
315
405
  function register(api) {
316
406
  const cfg = parseConfig(api.pluginConfig);
317
- const attachmentBindingRoot = path.join(path.dirname(cfg.controlPlane.enabled ? cfg.controlPlane.statePath : cfg.dbPath), "openclaw-attachment-bindings");
407
+ const stateDirectory = path.dirname(cfg.controlPlane.statePath);
408
+ const selectedHome = process.env.ATMEM_HOME
409
+ ? expandHome(process.env.ATMEM_HOME)
410
+ : path.basename(stateDirectory) === "config"
411
+ ? path.dirname(stateDirectory)
412
+ : stateDirectory;
413
+ const runtimeRoot = path.basename(stateDirectory) === "config" || process.env.ATMEM_HOME
414
+ ? path.join(selectedHome, "runtime")
415
+ : selectedHome;
416
+ const attachmentBindingRoot = path.join(runtimeRoot, "openclaw-attachment-bindings");
318
417
  const client = new AtmemClient({
319
418
  command: cfg.command,
320
419
  args: cfg.commandArgs,
@@ -329,6 +428,7 @@ function register(api) {
329
428
  log: (message) => api.logger.debug?.(`${TAG} ${message}`),
330
429
  logError: (message) => api.logger.warn(`${TAG} ${message}`),
331
430
  });
431
+ const executionSpool = new ExecutionSpool(path.join(runtimeRoot, "openclaw-execution-spool.json"));
332
432
  // Let long-lived hosts close the stdio child during lifecycle shutdown.
333
433
  // The client's bounded idle shutdown also covers one-shot local runners.
334
434
  api.registerService?.({
@@ -437,13 +537,27 @@ function register(api) {
437
537
  ? normalized.slice("openclaw".length)
438
538
  : normalized;
439
539
  };
540
+ const governedMemoryTools = new Set([
541
+ "memory_search",
542
+ "memory_get",
543
+ "memory_remember",
544
+ "atmem_observe",
545
+ "atmem_forget_artifact",
546
+ "atmem_trace",
547
+ "atmem_tasks",
548
+ "atmem_task_progress",
549
+ ]);
440
550
  const recordBlackbox = async (eventType, eventRunId, ctx, payload, toolCallId, correlation = {}) => {
441
551
  if (!cfg.controlPlane.blackboxEnabled)
442
552
  return;
443
553
  try {
444
- await blackboxClient.callTool("control_record_blackbox_event", {
554
+ await executionSpool.enqueue({
445
555
  event_type: eventType,
446
556
  run_id: flightRunId(eventRunId, ctx),
557
+ execution_id: ctx.executionId,
558
+ parent_execution_id: ctx.parentExecutionId,
559
+ attempt_id: ctx.attemptId,
560
+ retry_of_attempt_id: ctx.retryOfAttemptId,
447
561
  agent_id: agentIdFor(ctx),
448
562
  workspace_id: workspaceIdFor(ctx),
449
563
  subject_id: Object.keys(cfg.agentSubjects).length ? subjectFor(ctx) : undefined,
@@ -455,7 +569,8 @@ function register(api) {
455
569
  context_receipt_id: correlation.contextReceiptId,
456
570
  outcome_id: correlation.outcomeId,
457
571
  payload,
458
- }, cfg.recall.timeoutMs);
572
+ });
573
+ await executionSpool.flush((event) => blackboxClient.callTool("control_record_blackbox_event", event, cfg.recall.timeoutMs));
459
574
  }
460
575
  catch (error) {
461
576
  api.logger.warn(`${TAG} blackbox event ${eventType} was not recorded: ${error instanceof Error ? error.message : String(error)}`);
@@ -510,6 +625,9 @@ function register(api) {
510
625
  prompt_sha256: promptSha256,
511
626
  prompt_chars: prompt.length,
512
627
  images_count: imagesCount,
628
+ _atmem_evidence: {
629
+ parts: [{ type: "text", text: prompt }],
630
+ },
513
631
  });
514
632
  try {
515
633
  await stageInbound(prompt, ctx);
@@ -525,7 +643,7 @@ function register(api) {
525
643
  });
526
644
  await pending;
527
645
  };
528
- const bindInboundAttachments = async (keys, paths, types) => {
646
+ const bindInboundAttachments = async (keys, paths, types, ctx) => {
529
647
  const generation = ++nextAttachmentGeneration;
530
648
  for (const key of keys) {
531
649
  inboundAttachments.delete(key);
@@ -535,6 +653,20 @@ function register(api) {
535
653
  if (!paths.length)
536
654
  return;
537
655
  const items = await Promise.all(paths.map((filePath, index) => hashInboundAttachment(filePath, types[index] ?? types[0] ?? "application/octet-stream")));
656
+ for (const item of items) {
657
+ await recordBlackbox("turn.attachment", undefined, ctx, {
658
+ result_sha256: item.mediaSha256,
659
+ response_stream_bytes: item.bytes,
660
+ _atmem_evidence: {
661
+ parts: [{
662
+ type: item.modality,
663
+ mime_type: item.mimeType,
664
+ data_base64: item.dataBase64,
665
+ host_reference: item.hostReference,
666
+ }],
667
+ },
668
+ });
669
+ }
538
670
  for (const key of keys) {
539
671
  if (inboundAttachmentGeneration.get(key) === generation) {
540
672
  inboundAttachments.set(key, { items, ts: Date.now() });
@@ -560,7 +692,8 @@ function register(api) {
560
692
  : typeof rawType === "string"
561
693
  ? [rawType]
562
694
  : [];
563
- return { paths, types };
695
+ const current = attachmentFields(message);
696
+ return current.paths.length ? current : { paths, types };
564
697
  };
565
698
  const writtenMessageBindingKeys = (message, ctx) => {
566
699
  const idempotencyKey = typeof message.idempotencyKey === "string"
@@ -577,23 +710,57 @@ function register(api) {
577
710
  };
578
711
  api.on("message_received", async (event, ctx) => {
579
712
  const metadata = event.metadata ?? {};
580
- const paths = Array.isArray(metadata.mediaPaths)
713
+ // OpenClaw 2026.9 exposes stable media facts on the event. The metadata
714
+ // aliases are deprecated and can be absent even though the model receives
715
+ // the attachment, so use them only as a compatibility fallback.
716
+ const media = Array.isArray(event.media) ? event.media : [];
717
+ const currentFields = attachmentFields(media);
718
+ const current = currentFields.paths.map((path, index) => ({
719
+ path,
720
+ type: currentFields.types[index] ?? "application/octet-stream",
721
+ }));
722
+ const originalMedia = Array.isArray(event.originalMedia) ? event.originalMedia : [];
723
+ const originalFields = attachmentFields(originalMedia);
724
+ const original = originalFields.paths.map((path, index) => ({
725
+ path,
726
+ type: originalFields.types[index] ?? "application/octet-stream",
727
+ }));
728
+ const legacyPaths = Array.isArray(metadata.mediaPaths)
581
729
  ? metadata.mediaPaths.filter((value) => typeof value === "string")
582
- : typeof metadata.mediaPath === "string"
583
- ? [metadata.mediaPath]
584
- : [];
585
- const types = Array.isArray(metadata.mediaTypes)
730
+ : typeof metadata.mediaPath === "string" ? [metadata.mediaPath] : [];
731
+ const legacyTypes = Array.isArray(metadata.mediaTypes)
586
732
  ? metadata.mediaTypes.filter((value) => typeof value === "string")
587
- : typeof metadata.mediaType === "string"
588
- ? [metadata.mediaType]
589
- : [];
733
+ : typeof metadata.mediaType === "string" ? [metadata.mediaType] : [];
734
+ const legacyOriginalPaths = Array.isArray(metadata.originalMediaPaths)
735
+ ? metadata.originalMediaPaths.filter((value) => typeof value === "string")
736
+ : typeof metadata.originalMediaPath === "string" ? [metadata.originalMediaPath] : [];
737
+ const legacyOriginalTypes = Array.isArray(metadata.originalMediaTypes)
738
+ ? metadata.originalMediaTypes.filter((value) => typeof value === "string")
739
+ : typeof metadata.originalMediaType === "string" ? [metadata.originalMediaType] : [];
740
+ const selected = current.length
741
+ ? current
742
+ : original.length
743
+ ? original
744
+ : legacyPaths.map((path, index) => ({
745
+ path,
746
+ type: legacyTypes[index] ?? legacyTypes[0] ?? "application/octet-stream",
747
+ })).concat(legacyOriginalPaths.map((path, index) => ({
748
+ path,
749
+ type: legacyOriginalTypes[index] ?? legacyOriginalTypes[0] ?? "application/octet-stream",
750
+ })));
751
+ const paths = selected.map((fact) => fact.path);
752
+ const types = selected.map((fact) => fact.type);
590
753
  const keys = [...new Set([
591
754
  event.sessionKey,
592
755
  event.runId,
593
756
  ...contextIds(ctx),
594
757
  ].filter((value) => Boolean(value)))];
595
758
  try {
596
- await bindInboundAttachments(keys, paths, types);
759
+ // A staging-pending event has not supplied a safe local path yet. Do not
760
+ // erase an earlier binding for the same turn while OpenClaw is staging it.
761
+ if (!paths.length && (event.mediaStagingPending || metadata.mediaStagingPending === true))
762
+ return;
763
+ await bindInboundAttachments(keys, paths, types, ctx);
597
764
  }
598
765
  catch (error) {
599
766
  api.logger.warn(`${TAG} inbound attachment provenance unavailable: ${error instanceof Error ? error.message : String(error)}`);
@@ -636,12 +803,21 @@ function register(api) {
636
803
  context_location: deliveredLocation,
637
804
  success: delivered,
638
805
  reason: delivered ? undefined : `expected one exact delegated segment; observed ${occurrences}`,
806
+ _atmem_evidence: {
807
+ context: exact,
808
+ delivered_location: deliveredLocation,
809
+ },
639
810
  }, undefined, { contextReceiptId: pending.contextReceiptId });
640
811
  if (delivered && pending.exposureId) {
641
812
  await callFor(ctx, "control_exposure_shown", { exposure_id: pending.exposureId }, cfg.recall.timeoutMs);
642
813
  }
643
814
  cachePendingPrompt(sessionKey, { ...pending, delegatedContext: undefined });
644
815
  }
816
+ const modelMediaParts = inlineMediaParts([
817
+ event.historyMessages,
818
+ event.messages,
819
+ event.images,
820
+ ]);
645
821
  await recordBlackbox("model.input", event.runId, ctx, {
646
822
  provider: event.provider,
647
823
  model: event.model,
@@ -653,6 +829,13 @@ function register(api) {
653
829
  history_count: Array.isArray(event.historyMessages) ? event.historyMessages.length : 0,
654
830
  images_count: event.imagesCount ?? 0,
655
831
  tools_count: Array.isArray(event.tools) ? event.tools.length : 0,
832
+ _atmem_evidence: {
833
+ prompt: event.prompt ?? "",
834
+ system_prompt: event.systemPrompt ?? "",
835
+ history_messages: event.historyMessages ?? [],
836
+ tools: event.tools ?? [],
837
+ parts: modelMediaParts,
838
+ },
656
839
  });
657
840
  });
658
841
  api.on("llm_output", async (event, ctx) => {
@@ -683,6 +866,10 @@ function register(api) {
683
866
  usage: event.usage ?? {},
684
867
  reasoning_effort: event.reasoningEffort,
685
868
  fast_mode: event.fastMode,
869
+ _atmem_evidence: {
870
+ assistant_texts: responses,
871
+ usage: event.usage ?? {},
872
+ },
686
873
  });
687
874
  });
688
875
  api.registerCli?.(({ program }) => {
@@ -1082,6 +1269,10 @@ function register(api) {
1082
1269
  context_selection_profile: cfg.recall.requireDirectSupport ? "direct-support-v1" : "rank-threshold-v1",
1083
1270
  mode: "direct",
1084
1271
  context_location: contextLocation,
1272
+ _atmem_evidence: {
1273
+ context: memoryContext,
1274
+ envelope: result,
1275
+ },
1085
1276
  }, undefined, {
1086
1277
  retrievalId: current?.retrievalId,
1087
1278
  contextEventId: current?.contextEventId ?? personaContextEventId,
@@ -1119,7 +1310,16 @@ function register(api) {
1119
1310
  derived_path_sha256: Array.isArray(event.derivedPaths)
1120
1311
  ? event.derivedPaths.map((value) => digestText(String(value)))
1121
1312
  : [],
1313
+ _atmem_evidence: {
1314
+ params: event.params ?? {},
1315
+ derived_paths: event.derivedPaths ?? [],
1316
+ },
1122
1317
  }, event.toolCallId ?? ctx.toolCallId);
1318
+ // These virtual tools operate through AtMem. Their arguments legitimately
1319
+ // contain values such as `corpus: "memory"`; they are not filesystem
1320
+ // access to OpenClaw's frozen MEMORY.md or memory/* tree.
1321
+ if (governedMemoryTools.has(canonicalToolName(event.toolName)))
1322
+ return;
1123
1323
  if (!cfg.takeoverActive || !cfg.nativeWorkspaces.some((workspace) => touchesNativeMemory(event, workspace)))
1124
1324
  return;
1125
1325
  const reason = "AtMem takeover blocked access to OpenClaw's frozen native memory " +
@@ -1149,6 +1349,11 @@ function register(api) {
1149
1349
  error_sha256: event.error ? digestText(event.error) : undefined,
1150
1350
  result_present: event.result !== undefined,
1151
1351
  duration_ms: event.durationMs ?? 0,
1352
+ _atmem_evidence: {
1353
+ params: event.params ?? {},
1354
+ result: event.result ?? null,
1355
+ error: event.error ?? null,
1356
+ },
1152
1357
  }, event.toolCallId ?? ctx.toolCallId);
1153
1358
  });
1154
1359
  // ---- auto-capture: user turn through the pipeline, assistant as digest -
@@ -1165,6 +1370,18 @@ function register(api) {
1165
1370
  });
1166
1371
  const sessionKey = sessionKeyFor(ctx);
1167
1372
  const observedTurnInputKey = turnInputKey(ctx);
1373
+ // Current OpenClaw WebChat persists exact attachment provenance on the
1374
+ // user transcript as __openclaw.media[].url = media://inbound/<id>. Some
1375
+ // earlier hooks omit it, so agent_end is the durable late-capture boundary.
1376
+ const completedTurnMedia = attachmentFields(event.messages);
1377
+ if (completedTurnMedia.paths.length) {
1378
+ try {
1379
+ await bindInboundAttachments([...new Set([sessionKey, event.runId, ...contextIds(ctx)].filter((value) => Boolean(value)))], completedTurnMedia.paths, completedTurnMedia.types, ctx);
1380
+ }
1381
+ catch (error) {
1382
+ api.logger.warn(`${TAG} completed-turn attachment capture unavailable: ${error instanceof Error ? error.message : String(error)}`);
1383
+ }
1384
+ }
1168
1385
  const cached = pendingPrompts.get(sessionKey);
1169
1386
  deletePendingPrompt(sessionKey);
1170
1387
  const userText = cached?.text?.replace(INJECT_RE, "").trim();
@@ -1268,6 +1485,10 @@ function register(api) {
1268
1485
  turn_messages_sha256: turnMessagesSha256,
1269
1486
  digest_profile: "atmem-turn-messages-canonical-json-v1",
1270
1487
  messages_count: Array.isArray(event.messages) ? event.messages.length : 0,
1488
+ _atmem_evidence: {
1489
+ messages: event.messages ?? [],
1490
+ error: event.error ?? null,
1491
+ },
1271
1492
  }, undefined, {
1272
1493
  retrievalId: cached?.retrievalId,
1273
1494
  contextEventId: cached?.contextEventId,
@@ -1288,8 +1509,10 @@ function register(api) {
1288
1509
  // by scanning the media directory or scraping its name from user text.
1289
1510
  const attachmentFields = writtenMessageAttachmentFields(message);
1290
1511
  const attachmentKeys = writtenMessageBindingKeys(message, ctx);
1291
- if (attachmentKeys.length) {
1292
- void bindInboundAttachments(attachmentKeys, attachmentFields.paths, attachmentFields.types).catch((error) => {
1512
+ // A sparse transcript message is normal in current OpenClaw. It must not
1513
+ // erase the binding already established by message_received.
1514
+ if (attachmentKeys.length && attachmentFields.paths.length) {
1515
+ void bindInboundAttachments(attachmentKeys, attachmentFields.paths, attachmentFields.types, ctx).catch((error) => {
1293
1516
  api.logger.warn(`${TAG} persisted attachment provenance unavailable: ${error instanceof Error ? error.message : String(error)}`);
1294
1517
  });
1295
1518
  }
@@ -0,0 +1,189 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const SPOOL_AAD = Buffer.from("atmem-openclaw-encrypted-execution-spool-v2", "utf8");
5
+ /** A bounded, atomic local spool. Removal requires a durable terminal receipt. */
6
+ export class ExecutionSpool {
7
+ filePath;
8
+ maxPending;
9
+ maxAgeMs;
10
+ keyPath;
11
+ state = null;
12
+ serial = Promise.resolve();
13
+ constructor(filePath, maxPending = 2048, maxAgeMs = 7 * 24 * 60 * 60 * 1000, keyPath = path.resolve(path.dirname(filePath), "..", ".atmem-evidence-keys", "openclaw-spool.key")) {
14
+ this.filePath = filePath;
15
+ this.maxPending = maxPending;
16
+ this.maxAgeMs = maxAgeMs;
17
+ this.keyPath = keyPath;
18
+ if (maxPending < 2)
19
+ throw new Error("execution spool capacity must be at least 2");
20
+ }
21
+ enqueue(value) {
22
+ return this.exclusive(async () => {
23
+ const state = await this.load();
24
+ this.expirePending(state, Date.now() - this.maxAgeMs);
25
+ const envelope = {
26
+ ...value,
27
+ event_id: randomUUID(),
28
+ producer_instance_id: state.producerInstanceId,
29
+ producer_epoch: state.producerEpoch,
30
+ producer_sequence: state.nextSequence++,
31
+ event_time: new Date().toISOString(),
32
+ };
33
+ state.pending.push(envelope);
34
+ if (state.pending.length > this.maxPending) {
35
+ const dropped = state.pending.length - this.maxPending + 1;
36
+ const removed = state.pending.splice(0, dropped);
37
+ state.pending.unshift({
38
+ event_type: "capture.gap",
39
+ run_id: String(envelope.run_id ?? "capture-spool"),
40
+ payload: {
41
+ reason: "spool_capacity_exceeded",
42
+ dropped_count: removed.length,
43
+ first_sequence: removed[0]?.producer_sequence,
44
+ last_sequence: removed.at(-1)?.producer_sequence,
45
+ },
46
+ event_id: randomUUID(),
47
+ producer_instance_id: state.producerInstanceId,
48
+ producer_epoch: state.producerEpoch,
49
+ producer_sequence: state.nextSequence++,
50
+ event_time: new Date().toISOString(),
51
+ });
52
+ }
53
+ await this.persist(state);
54
+ return envelope;
55
+ });
56
+ }
57
+ flush(send) {
58
+ return this.exclusive(async () => {
59
+ const state = await this.load();
60
+ let acknowledged = 0;
61
+ while (state.pending.length) {
62
+ const result = await send(state.pending[0]);
63
+ const receipt = result;
64
+ const accepted = receipt?.durably_accepted === true;
65
+ const classifiedConflict = receipt?.decision === "conflict" &&
66
+ receipt?.durably_classified === true;
67
+ if (!accepted && !classifiedConflict) {
68
+ throw new Error("AtMem did not confirm a durable terminal execution-event decision");
69
+ }
70
+ state.pending.shift();
71
+ acknowledged += 1;
72
+ await this.persist(state);
73
+ }
74
+ return acknowledged;
75
+ });
76
+ }
77
+ async snapshot() {
78
+ return this.exclusive(async () => ({ ...(await this.load()), pending: [...(await this.load()).pending] }));
79
+ }
80
+ exclusive(action) {
81
+ const next = this.serial.then(action, action);
82
+ this.serial = next.then(() => undefined, () => undefined);
83
+ return next;
84
+ }
85
+ async load() {
86
+ if (this.state)
87
+ return this.state;
88
+ try {
89
+ const wrapper = JSON.parse(await readFile(this.filePath, "utf8"));
90
+ if (wrapper.format !== "atmem-openclaw-encrypted-execution-spool-v2") {
91
+ throw new Error("unsupported execution spool format");
92
+ }
93
+ const key = await this.loadOrCreateKey();
94
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(wrapper.nonce, "base64"));
95
+ decipher.setAAD(SPOOL_AAD);
96
+ decipher.setAuthTag(Buffer.from(wrapper.tag, "base64"));
97
+ const plain = Buffer.concat([
98
+ decipher.update(Buffer.from(wrapper.ciphertext, "base64")), decipher.final(),
99
+ ]).toString("utf8");
100
+ const value = JSON.parse(plain);
101
+ if (value.format !== "atmem-openclaw-execution-spool-v1" || !Array.isArray(value.pending)) {
102
+ throw new Error("unsupported decrypted execution spool state");
103
+ }
104
+ // Pending events retain their original epoch. New events get a fresh
105
+ // epoch and sequence after every plugin process start.
106
+ this.state = {
107
+ ...value,
108
+ producerEpoch: randomUUID(),
109
+ nextSequence: 1,
110
+ };
111
+ }
112
+ catch (error) {
113
+ const code = error.code;
114
+ if (code !== "ENOENT")
115
+ throw error;
116
+ this.state = {
117
+ format: "atmem-openclaw-execution-spool-v1",
118
+ producerInstanceId: randomUUID(),
119
+ producerEpoch: randomUUID(),
120
+ nextSequence: 1,
121
+ pending: [],
122
+ };
123
+ }
124
+ await this.persist(this.state);
125
+ return this.state;
126
+ }
127
+ async persist(state) {
128
+ await mkdir(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
129
+ const key = await this.loadOrCreateKey();
130
+ const nonce = randomBytes(12);
131
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
132
+ cipher.setAAD(SPOOL_AAD);
133
+ const ciphertext = Buffer.concat([
134
+ cipher.update(JSON.stringify(state), "utf8"), cipher.final(),
135
+ ]);
136
+ const wrapper = {
137
+ format: "atmem-openclaw-encrypted-execution-spool-v2",
138
+ suite: "AES-256-GCM",
139
+ nonce: nonce.toString("base64"),
140
+ ciphertext: ciphertext.toString("base64"),
141
+ tag: cipher.getAuthTag().toString("base64"),
142
+ };
143
+ const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
144
+ await writeFile(temporary, `${JSON.stringify(wrapper)}\n`, { encoding: "utf8", mode: 0o600 });
145
+ await rename(temporary, this.filePath);
146
+ await chmod(this.filePath, 0o600);
147
+ }
148
+ async loadOrCreateKey() {
149
+ await mkdir(path.dirname(this.keyPath), { recursive: true, mode: 0o700 });
150
+ try {
151
+ const encoded = (await readFile(this.keyPath, "utf8")).trim();
152
+ const key = Buffer.from(encoded, "base64");
153
+ if (key.length !== 32)
154
+ throw new Error("execution spool key must be 256 bits");
155
+ return key;
156
+ }
157
+ catch (error) {
158
+ if (error.code !== "ENOENT")
159
+ throw error;
160
+ const key = randomBytes(32);
161
+ await writeFile(this.keyPath, `${key.toString("base64")}\n`, {
162
+ encoding: "utf8", mode: 0o600, flag: "wx",
163
+ });
164
+ await chmod(this.keyPath, 0o600);
165
+ return key;
166
+ }
167
+ }
168
+ expirePending(state, cutoff) {
169
+ const expired = state.pending.filter(event => Date.parse(event.event_time) < cutoff);
170
+ if (!expired.length)
171
+ return;
172
+ state.pending = state.pending.filter(event => Date.parse(event.event_time) >= cutoff);
173
+ state.pending.unshift({
174
+ event_type: "capture.gap",
175
+ run_id: String(expired.at(-1)?.run_id ?? "capture-spool"),
176
+ payload: {
177
+ reason: "spool_retention_expired",
178
+ dropped_count: expired.length,
179
+ first_sequence: expired[0].producer_sequence,
180
+ last_sequence: expired.at(-1)?.producer_sequence,
181
+ },
182
+ event_id: randomUUID(),
183
+ producer_instance_id: state.producerInstanceId,
184
+ producer_epoch: state.producerEpoch,
185
+ producer_sequence: state.nextSequence++,
186
+ event_time: new Date().toISOString(),
187
+ });
188
+ }
189
+ }
@@ -128,7 +128,7 @@ export class AtmemClient {
128
128
  capabilities: {},
129
129
  clientInfo: {
130
130
  name: "openclaw-memory-atmem",
131
- version: "2.2.6",
131
+ version: "2.3.3",
132
132
  },
133
133
  });
134
134
  this.notify("notifications/initialized", {});
@@ -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.7-beta.1",
5
+ "version": "2.3.3",
6
6
  "commandAliases": ["memory-atmem"],
7
7
  "activation": {
8
8
  "onStartup": true,
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dbPath": {
39
39
  "type": "string",
40
- "default": "~/.atmem/memories.db",
40
+ "default": "~/.atmem/memory/memories.db",
41
41
  "description": "SQLite database path (also auditable from outside OpenClaw via `atmem verify`)"
42
42
  },
43
43
  "subject": {
@@ -129,13 +129,13 @@
129
129
  },
130
130
  "statePath": {
131
131
  "type": "string",
132
- "default": "~/.atmem/control-plane.json",
132
+ "default": "~/.atmem/config/control-plane.json",
133
133
  "description": "Digest-bound control file written by `atmem control`."
134
134
  },
135
135
  "blackboxEnabled": {
136
136
  "type": "boolean",
137
- "default": false,
138
- "description": "Record content-minimizing agent flights through the private control protocol. Managed automatically by the AtMem installer."
137
+ "default": true,
138
+ "description": "Record encrypted full-fidelity text, link/page, file, image, audio and video evidence plus metadata through the private control protocol. Set false to disable the recorder."
139
139
  }
140
140
  }
141
141
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-memory-atmem",
3
- "version": "2.2.7-beta.1",
3
+ "version": "2.3.3",
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,7 +40,7 @@
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/delegated-identity.mjs && node test/tool-observations.mjs && node test/tool-result-comparison.mjs && node test/blackbox-diagnostics.mjs && node test/task-tools.mjs && node test/delegated-context-contract.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/tool-result-comparison.mjs && node test/blackbox-diagnostics.mjs && node test/execution-spool.mjs && node test/task-tools.mjs && node test/delegated-context-contract.mjs",
44
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",