openclaw-memory-atmem 2.2.6-beta.2 → 2.2.6-beta.4

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.6b2
8
+ python -m pip install --pre --upgrade atmem==2.2.6b5
9
9
  atmem openclaw install
10
10
  ```
11
11
 
@@ -22,6 +22,13 @@ 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.3` adds Spec 007 exact task-context delivery. When the host
26
+ supplies `taskId` in hook context, the bridge requests only that governed task,
27
+ checks its byte digest, contributes it separately from recalled memory, and
28
+ confirms exposure. With no `taskId`, task delivery stays off and existing
29
+ memory-only behavior is unchanged. Guard detection is available, but this
30
+ bridge does not claim it can block OpenClaw execution.
31
+
25
32
  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.
26
33
 
27
34
  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.
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "nod
25
25
  import { spawnSync } from "node:child_process";
26
26
  import { AtmemClient } from "./src/rpc-client.js";
27
27
  import { runSetup } from "./src/setup.js";
28
+ import { NOT_OWNER_MESSAGE, NO_IDENTITY_MESSAGE, describeDecision, isConversationOwner, ok, refusal, sessionIdentityForTool, } from "./src/task-tools.js";
28
29
  const TAG = "[memory-atmem]";
29
30
  const TAKEOVER_GUIDANCE = "<atmem_memory_provider>\n" +
30
31
  "AtMem is the active durable-memory provider. " +
@@ -352,6 +353,26 @@ function register(api) {
352
353
  }
353
354
  return cfg.subject;
354
355
  };
356
+ /**
357
+ * The identity AtMem resolves a governed task through.
358
+ *
359
+ * `sessionId` is the generation: OpenClaw changes it when a conversation is
360
+ * reset, which is what stops a recycled `sessionKey` from inheriting an
361
+ * earlier task binding. Both fields are optional upstream, so returning
362
+ * `undefined` is an ordinary outcome and callers must withhold rather than
363
+ * send a partial identity.
364
+ */
365
+ const sessionIdentityFor = (ctx) => {
366
+ const sessionKey = ctx.sessionKey ?? ctx.sessionId;
367
+ const sessionEpoch = ctx.sessionId;
368
+ if (!sessionKey || !sessionEpoch)
369
+ return undefined;
370
+ return {
371
+ host_type: "openclaw",
372
+ session_key: sessionKey,
373
+ session_epoch: sessionEpoch,
374
+ };
375
+ };
355
376
  const workspaceIdFor = (ctx) => {
356
377
  const workspace = cfg.agentWorkspaces[agentIdFor(ctx)];
357
378
  return workspace ? `ws_${digestText(workspace).slice(0, 16)}` : undefined;
@@ -747,6 +768,27 @@ function register(api) {
747
768
  user_id: delegatedUserIdFor(ctx),
748
769
  workspace_id: workspaceIdFor(ctx),
749
770
  }, cfg.recall.timeoutMs));
771
+ // Task identity resolves through the manager, not from ctx.taskId
772
+ // alone: OpenClaw supplies no task identity of its own, so without a
773
+ // registered binding this branch could never run. Presenting the
774
+ // session identity on every lookup keeps binding and resolution from
775
+ // disagreeing about which conversation they mean.
776
+ const taskIdentity = sessionIdentityFor(ctx);
777
+ const taskPrepared = (ctx.taskId || taskIdentity)
778
+ ? (await callFor(ctx, "control_prepare_task_context", {
779
+ ...(ctx.taskId ? { task_id: ctx.taskId } : {}),
780
+ ...(taskIdentity ?? {}),
781
+ session_id: sessionKey,
782
+ host_run_id: ctx.runId,
783
+ agent_id: agentIdFor(ctx),
784
+ workspace_id: workspaceIdFor(ctx),
785
+ }, cfg.recall.timeoutMs))
786
+ : undefined;
787
+ if (taskPrepared?.disposition === "injected" &&
788
+ (!taskPrepared.context || !taskPrepared.context_sha256 ||
789
+ `sha256:${digestText(taskPrepared.context)}` !== taskPrepared.context_sha256)) {
790
+ throw new Error("governed task context failed exact handoff digest validation");
791
+ }
750
792
  pendingPrompts.set(sessionKey, {
751
793
  text: userText,
752
794
  ts: Date.now(),
@@ -758,7 +800,19 @@ function register(api) {
758
800
  delegatedContextSha256: prepared.authority === "delegated" ? prepared.context_sha256 : undefined,
759
801
  delegatedAuthority: prepared.authority,
760
802
  delegatedResultSha256: prepared.result_sha256,
803
+ taskDeliveryId: taskPrepared?.delivery_id,
804
+ taskContextSha256: taskPrepared?.context_sha256,
805
+ taskId: taskPrepared?.task_id ?? ctx.taskId,
761
806
  });
807
+ if (taskPrepared) {
808
+ await recordBlackbox("task.context.prepared", undefined, ctx, {
809
+ task_id: taskPrepared.task_id ?? ctx.taskId,
810
+ task_disposition: taskPrepared?.disposition ?? "withheld",
811
+ task_revision: taskPrepared?.revision,
812
+ task_context_sha256: taskPrepared?.context_sha256?.replace(/^sha256:/, ""),
813
+ task_reason_codes: taskPrepared?.reason_codes ?? [],
814
+ });
815
+ }
762
816
  if (prepared.authority === "delegated" &&
763
817
  prepared.inject &&
764
818
  (!prepared.context ||
@@ -805,8 +859,21 @@ function register(api) {
805
859
  if (prepared.inject && prepared.context) {
806
860
  api.logger.info(`${TAG} memory control plane ${prepared.mode ?? "active"} context exposed`);
807
861
  return prepared.authority === "delegated"
808
- ? { prependContext: prepared.context }
809
- : { appendContext: prepared.context };
862
+ ? {
863
+ prependContext: prepared.context,
864
+ appendContext: taskPrepared?.disposition === "injected"
865
+ ? taskPrepared.context
866
+ : undefined,
867
+ }
868
+ : {
869
+ appendContext: [
870
+ prepared.context,
871
+ taskPrepared?.disposition === "injected" ? taskPrepared.context : "",
872
+ ].filter(Boolean).join("\n\n"),
873
+ };
874
+ }
875
+ if (taskPrepared?.disposition === "injected" && taskPrepared.context) {
876
+ return { appendContext: taskPrepared.context };
810
877
  }
811
878
  return;
812
879
  }
@@ -1007,6 +1074,14 @@ function register(api) {
1007
1074
  if (cached?.exposureId) {
1008
1075
  await callFor(ctx, "control_exposure_shown", { exposure_id: cached.exposureId }, cfg.recall.timeoutMs);
1009
1076
  }
1077
+ if (cached?.taskDeliveryId) {
1078
+ await callFor(ctx, "control_task_exposure_shown", { delivery_id: cached.taskDeliveryId }, cfg.recall.timeoutMs);
1079
+ await recordBlackbox("task.context.exposed", event.runId, ctx, {
1080
+ task_id: cached.taskId ?? ctx.taskId,
1081
+ task_disposition: "injected",
1082
+ task_context_sha256: cached.taskContextSha256?.replace(/^sha256:/, ""),
1083
+ });
1084
+ }
1010
1085
  if (event.success !== false) {
1011
1086
  await callFor(ctx, "control_sync_openclaw_memory", {}, cfg.recall.timeoutMs);
1012
1087
  }
@@ -1630,6 +1705,121 @@ function register(api) {
1630
1705
  };
1631
1706
  },
1632
1707
  }), { name: "atmem_forget_artifact" });
1708
+ // --- governed task tools (Amendment A) -------------------------------
1709
+ //
1710
+ // A control-plane operation is invisible to a model. Without these
1711
+ // registrations an agent receives a task checklist it has no way to tick,
1712
+ // which is worse than receiving nothing: it looks like progress is being
1713
+ // tracked when nothing is being recorded.
1714
+ //
1715
+ // Every one resolves through this conversation's own binding, so a model
1716
+ // can only touch the task its conversation is bound to.
1717
+ const taskScope = (toolCtx) => ({
1718
+ agent_id: agentIdFor(toolCtx),
1719
+ workspace_id: workspaceIdFor(toolCtx),
1720
+ });
1721
+ api.registerTool((toolCtx) => ({
1722
+ name: "task_report_progress",
1723
+ label: "Report Task Progress (atmem)",
1724
+ description: "Report progress on the governed task this conversation is bound to. " +
1725
+ "State the item and its new status. AtMem validates the change and " +
1726
+ "decides; you are proposing, not writing. If no task is bound this " +
1727
+ "does nothing.",
1728
+ parameters: {
1729
+ type: "object",
1730
+ properties: {
1731
+ item_id: { type: "string", description: "Which task item changed" },
1732
+ status: {
1733
+ type: "string",
1734
+ enum: ["ready", "running", "blocked", "completed", "skipped", "failed"],
1735
+ description: "The item's new status",
1736
+ },
1737
+ base_revision: {
1738
+ type: "integer",
1739
+ description: "The task revision you read. If the task has moved since, this " +
1740
+ "returns a conflict instead of overwriting someone else's work.",
1741
+ },
1742
+ reason: { type: "string", description: "Why, in one line" },
1743
+ },
1744
+ required: ["item_id", "status", "base_revision"],
1745
+ },
1746
+ async execute(toolCallId, params) {
1747
+ const identity = sessionIdentityForTool(toolCtx);
1748
+ if (!identity)
1749
+ return refusal(NO_IDENTITY_MESSAGE);
1750
+ const result = (await callFor(toolCtx, "control_propose_task_delta", {
1751
+ ...identity,
1752
+ ...taskScope(toolCtx),
1753
+ task_id: String(params.task_id ?? ""),
1754
+ base_revision: Number(params.base_revision ?? 0),
1755
+ // Derived from stable host identifiers, never from payload content
1756
+ // or a clock, so a retried tool call collapses to one decision.
1757
+ idempotency_key: `${toolCtx.runId ?? "run"}:${toolCallId}`,
1758
+ operations: [
1759
+ {
1760
+ kind: "set_item_status",
1761
+ item_id: String(params.item_id ?? ""),
1762
+ status: String(params.status ?? ""),
1763
+ reason: params.reason ? String(params.reason) : undefined,
1764
+ },
1765
+ ],
1766
+ adapter: "openclaw",
1767
+ // The tool call is the evidence. Completing an item requires some,
1768
+ // and a host reporting its own tool outcome is asserting rather
1769
+ // than independently verifying -- AtMem records it at exactly that
1770
+ // assurance and never upgrades it.
1771
+ evidence: [
1772
+ { kind: "tool_call", reference_id: `${toolCtx.runId ?? "run"}-${toolCallId}` },
1773
+ ],
1774
+ reason: params.reason ? String(params.reason) : "",
1775
+ }));
1776
+ return {
1777
+ content: [{ type: "text", text: describeDecision(result) }],
1778
+ details: { outcome: result.outcome ?? result.reason_code ?? null },
1779
+ };
1780
+ },
1781
+ }), { name: "task_report_progress" });
1782
+ api.registerTool((toolCtx) => ({
1783
+ name: "task_binding_status",
1784
+ label: "Governed Task Binding (atmem)",
1785
+ description: "Show which governed task, if any, this conversation is bound to, and " +
1786
+ "the exact command to bind it. Owner only.",
1787
+ parameters: { type: "object", properties: {} },
1788
+ async execute() {
1789
+ if (!isConversationOwner(toolCtx))
1790
+ return refusal(NOT_OWNER_MESSAGE);
1791
+ const identity = sessionIdentityForTool(toolCtx);
1792
+ if (!identity)
1793
+ return refusal(NO_IDENTITY_MESSAGE);
1794
+ const prepared = (await callFor(toolCtx, "control_prepare_task_context", {
1795
+ ...identity,
1796
+ ...taskScope(toolCtx),
1797
+ }));
1798
+ // Binding stays an authenticated operator action at the terminal, so
1799
+ // the owner needs their own conversation's identity to run it. They
1800
+ // cannot see it otherwise -- it is an internal host value -- and
1801
+ // without it the whole feature is unreachable from inside OpenClaw.
1802
+ // Handing the owner a ready-to-run command is the same "one useful
1803
+ // next command" the CLI gives everywhere else. This discloses nothing
1804
+ // a non-owner can obtain: the gate above already refused them.
1805
+ const bindCommand = `atmem task bind DB_PATH TASK_ID --subject SUBJECT ` +
1806
+ `--agent ${agentIdFor(toolCtx)} --workspace ${workspaceIdFor(toolCtx) ?? "WORKSPACE"} ` +
1807
+ `--actor YOU --reason WHY --host-type ${identity.host_type} ` +
1808
+ `--session-key ${identity.session_key} --session-epoch ${identity.session_epoch} --yes`;
1809
+ if (prepared.disposition !== "injected") {
1810
+ return ok({
1811
+ bound: false,
1812
+ reason: (prepared.reason_codes ?? []).join(", ") || "not bound",
1813
+ bind_with: bindCommand,
1814
+ });
1815
+ }
1816
+ return ok({
1817
+ bound: true,
1818
+ task_id: prepared.task_id ?? null,
1819
+ rebind_with: bindCommand,
1820
+ });
1821
+ },
1822
+ }), { name: "task_binding_status" });
1633
1823
  }
1634
1824
  api.logger.info(`${TAG} registered (db=${cfg.dbPath}, subject=${cfg.subject}, ` +
1635
1825
  `recall=${cfg.recall.enabled}, capture=${cfg.capture.enabled}, ` +
@@ -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.2",
131
+ version: "2.2.6-beta.4",
132
132
  },
133
133
  });
134
134
  this.notify("notifications/initialized", {});
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Model- and owner-facing governed task tools.
3
+ *
4
+ * Spec 007 Amendment A, FR-049 path (b) and FR-050.
5
+ *
6
+ * A manager method and an MCP operation are invisible to a model. For an agent
7
+ * to report progress at all, a tool has to be registered with the host through
8
+ * the same mechanism that publishes `memory_search`. That is what this file is.
9
+ *
10
+ * Two audiences, deliberately separated:
11
+ *
12
+ * - `task_*` tools the model calls. They resolve through the conversation's
13
+ * own binding, so a model can only affect the task its conversation is bound
14
+ * to, and every outcome is reported back in words the model can act on -- a
15
+ * rejection it cannot interpret is a rejection it will retry blindly.
16
+ * - `task_bind` / `task_unbind` / `task_binding_status`, which an operator
17
+ * runs from inside the conversation. These are gated on the host's own owner
18
+ * signal. That signal is optional upstream, so anything other than an
19
+ * explicit `true` is treated as *not* the owner: absence is never permission.
20
+ */
21
+ /** The one place identity is derived, so no caller can assemble a partial one. */
22
+ export function sessionIdentityForTool(ctx) {
23
+ const sessionKey = ctx.sessionKey ?? ctx.sessionId;
24
+ const sessionEpoch = ctx.sessionId;
25
+ if (!sessionKey || !sessionEpoch)
26
+ return undefined;
27
+ return {
28
+ host_type: "openclaw",
29
+ session_key: sessionKey,
30
+ session_epoch: sessionEpoch,
31
+ };
32
+ }
33
+ /** Absence is never permission. Only an explicit affirmative is the owner. */
34
+ export function isConversationOwner(ctx) {
35
+ return ctx.senderIsOwner === true;
36
+ }
37
+ /**
38
+ * Turn one AtMem decision into something a model can act on.
39
+ *
40
+ * The outcome vocabulary is small and each value implies a different next
41
+ * move, so saying which one it is matters more than saying it failed.
42
+ */
43
+ export function describeDecision(result) {
44
+ const outcome = String(result.outcome ?? "");
45
+ const reasons = Array.isArray(result.reason_codes)
46
+ ? result.reason_codes.join(", ")
47
+ : String(result.reason_code ?? "");
48
+ switch (outcome) {
49
+ case "accepted":
50
+ return `Recorded. The task is now at revision ${result.resulting_revision}.`;
51
+ case "no_change":
52
+ return `No change: the task already reflects this (${reasons}).`;
53
+ case "conflict":
54
+ return (`Conflict: the task moved while you were working (${reasons}). ` +
55
+ "Read it again with task_state and submit against the revision you get back.");
56
+ case "rejected":
57
+ return `Rejected (${reasons}). Do not retry this unchanged; the reason explains what AtMem will accept.`;
58
+ default:
59
+ break;
60
+ }
61
+ if (result.reason_code) {
62
+ return `Not applied (${result.reason_code}): ${String(result.message ?? "")}`;
63
+ }
64
+ return String(result.message ?? "No decision was returned.");
65
+ }
66
+ /** A refusal shaped like every other tool result, so a model reads one thing. */
67
+ export function refusal(message) {
68
+ return {
69
+ content: [{ type: "text", text: JSON.stringify({ ok: false, message }) }],
70
+ };
71
+ }
72
+ export function ok(payload) {
73
+ return {
74
+ content: [{ type: "text", text: JSON.stringify({ ok: true, ...payload }) }],
75
+ };
76
+ }
77
+ export const NOT_OWNER_MESSAGE =
78
+ // Identical whether a binding exists or not: a non-owner must not be able to
79
+ // learn what this conversation is bound to by asking.
80
+ "Only the owner of this conversation can manage its governed task binding.";
81
+ export const NO_IDENTITY_MESSAGE = "This conversation has no usable session identity, so AtMem cannot tell " +
82
+ "which conversation it is. No task binding can be resolved or created.";
@@ -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.2",
5
+ "version": "2.2.6-beta.3",
6
6
  "commandAliases": ["memory-atmem"],
7
7
  "activation": {
8
8
  "onStartup": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-memory-atmem",
3
- "version": "2.2.6-beta.2",
3
+ "version": "2.2.6-beta.4",
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,9 +40,11 @@
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "node test/setup.mjs && node test/hooks.mjs && node test/delegated-context-contract.mjs",
44
- "smoke": "node test/smoke.mjs",
45
- "prepack": "npm run build && npm run typecheck && npm test && npm run smoke"
43
+ "test": "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",
45
+ "prepack": "npm run build && npm run typecheck && npm test && npm run smoke",
46
+ "hook-context": "node test/hook-context-compat.mjs",
47
+ "record-hook-context": "node test/lib/record-hook-context.mjs"
46
48
  },
47
49
  "devDependencies": {
48
50
  "@types/node": "^22.20.1",