openclaw-memory-atmem 2.2.5 → 2.2.6-beta.10
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 +16 -2
- package/dist/index.js +295 -4
- package/dist/src/rpc-client.js +1 -1
- package/dist/src/task-tools.js +105 -0
- package/openclaw.plugin.json +20 -2
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -5,16 +5,30 @@ 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.
|
|
8
|
+
python -m pip install --pre --upgrade atmem==2.2.6b10
|
|
9
9
|
atmem openclaw install
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
The installer pins
|
|
12
|
+
The installer pins the matching OpenClaw bridge, binds the exact `atmem` executable, copies existing OpenClaw memory, configures shadow mode, restarts the gateway and verifies the loaded plugin. Direct npm installation cannot perform or prove those steps.
|
|
13
|
+
|
|
14
|
+
The bridge also supports AtMem's optional delegated context-provider mode. It
|
|
15
|
+
supplies authenticated owner/workspace bindings, contributes a verified result
|
|
16
|
+
as exactly one `prependContext` segment, confirms that segment at `llm_input`,
|
|
17
|
+
and suppresses native AtMem recall for the turn. Delegation is enabled only by
|
|
18
|
+
an AtMem registration; the bridge's `delegatedContext.userId` is an identity
|
|
19
|
+
mapping, not an activation switch.
|
|
13
20
|
|
|
14
21
|
Existing AtMem 2.1 users run `atmem openclaw upgrade` after upgrading the Python
|
|
15
22
|
package. This preserves the current memory mode and migration, verifies the new
|
|
16
23
|
bridge with a self-test flight, and rolls back the bridge on failure.
|
|
17
24
|
|
|
25
|
+
Bridge `2.2.6-beta.10` 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
|
+
|
|
18
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.
|
|
19
33
|
|
|
20
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, resolveBoundTaskForTool, 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. " +
|
|
@@ -61,6 +62,10 @@ function parseConfig(raw) {
|
|
|
61
62
|
statePath: expandHome(String(cfg.controlPlane?.statePath ?? "~/.atmem/control-plane.json")),
|
|
62
63
|
blackboxEnabled: cfg.controlPlane?.enabled === true || cfg.controlPlane?.blackboxEnabled === true,
|
|
63
64
|
};
|
|
65
|
+
const delegatedContext = {
|
|
66
|
+
userId: String(cfg.delegatedContext?.userId ?? "").trim(),
|
|
67
|
+
requireOwner: cfg.delegatedContext?.requireOwner !== false,
|
|
68
|
+
};
|
|
64
69
|
return {
|
|
65
70
|
command: String(cfg.command ?? "atmem"),
|
|
66
71
|
commandArgs: controlPlane.enabled
|
|
@@ -98,6 +103,7 @@ function parseConfig(raw) {
|
|
|
98
103
|
},
|
|
99
104
|
tools: { enabled: cfg.tools?.enabled !== false },
|
|
100
105
|
controlPlane,
|
|
106
|
+
delegatedContext,
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
const FILE_TOOL_HINTS = [
|
|
@@ -347,6 +353,37 @@ function register(api) {
|
|
|
347
353
|
}
|
|
348
354
|
return cfg.subject;
|
|
349
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
|
+
};
|
|
376
|
+
const workspaceIdFor = (ctx) => {
|
|
377
|
+
const workspace = cfg.agentWorkspaces[agentIdFor(ctx)];
|
|
378
|
+
return workspace ? `ws_${digestText(workspace).slice(0, 16)}` : undefined;
|
|
379
|
+
};
|
|
380
|
+
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;
|
|
386
|
+
};
|
|
350
387
|
const scopedKey = (value, ctx) => `${agentIdFor(ctx)}:${value}`;
|
|
351
388
|
const contextIds = (ctx) => [...new Set([ctx.runId, ctx.sessionKey, ctx.sessionId].filter((value) => Boolean(value)).map((value) => scopedKey(value, ctx)))];
|
|
352
389
|
const callFor = (ctx, name, args, timeoutMs = cfg.recall.timeoutMs) => {
|
|
@@ -389,6 +426,7 @@ function register(api) {
|
|
|
389
426
|
event_type: eventType,
|
|
390
427
|
run_id: flightRunId(eventRunId, ctx),
|
|
391
428
|
agent_id: agentIdFor(ctx),
|
|
429
|
+
workspace_id: workspaceIdFor(ctx),
|
|
392
430
|
subject_id: Object.keys(cfg.agentSubjects).length ? subjectFor(ctx) : undefined,
|
|
393
431
|
session_id: ctx.sessionId ?? ctx.sessionKey,
|
|
394
432
|
tool_call_id: toolCallId,
|
|
@@ -548,6 +586,37 @@ function register(api) {
|
|
|
548
586
|
await observeTurnInput(event.prompt, ctx, "before_model_resolve", Array.isArray(event.attachments) ? event.attachments.length : 0);
|
|
549
587
|
});
|
|
550
588
|
api.on("llm_input", async (event, ctx) => {
|
|
589
|
+
const sessionKey = scopedKey(ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? "default-session", ctx);
|
|
590
|
+
const pending = pendingPrompts.get(sessionKey);
|
|
591
|
+
if (pending?.delegatedContext !== undefined) {
|
|
592
|
+
const exact = pending.delegatedContext;
|
|
593
|
+
const promptOccurrences = exact ? event.prompt.split(exact).length - 1 : 0;
|
|
594
|
+
const systemOccurrences = exact
|
|
595
|
+
? (event.systemPrompt ?? "").split(exact).length - 1
|
|
596
|
+
: 0;
|
|
597
|
+
const occurrences = promptOccurrences + systemOccurrences;
|
|
598
|
+
const deliveredLocation = promptOccurrences === 1
|
|
599
|
+
? "prompt"
|
|
600
|
+
: systemOccurrences === 1
|
|
601
|
+
? "systemPrompt"
|
|
602
|
+
: "none";
|
|
603
|
+
const delivered = occurrences === 1 && digestText(exact) === pending.delegatedContextSha256;
|
|
604
|
+
await recordBlackbox("context.injected", event.runId, ctx, {
|
|
605
|
+
disposition: delivered ? "injected" : "recall_failed",
|
|
606
|
+
provider: pending.delegatedAuthority,
|
|
607
|
+
result_sha256: pending.delegatedResultSha256,
|
|
608
|
+
context_sha256: pending.delegatedContextSha256,
|
|
609
|
+
context_chars: exact.length,
|
|
610
|
+
context_byte_length: Buffer.byteLength(exact, "utf8"),
|
|
611
|
+
context_location: deliveredLocation,
|
|
612
|
+
success: delivered,
|
|
613
|
+
reason: delivered ? undefined : `expected one exact delegated segment; observed ${occurrences}`,
|
|
614
|
+
}, undefined, { contextReceiptId: pending.contextReceiptId });
|
|
615
|
+
if (delivered && pending.exposureId) {
|
|
616
|
+
await callFor(ctx, "control_exposure_shown", { exposure_id: pending.exposureId }, cfg.recall.timeoutMs);
|
|
617
|
+
}
|
|
618
|
+
pendingPrompts.set(sessionKey, { ...pending, delegatedContext: undefined });
|
|
619
|
+
}
|
|
551
620
|
await recordBlackbox("model.input", event.runId, ctx, {
|
|
552
621
|
provider: event.provider,
|
|
553
622
|
model: event.model,
|
|
@@ -690,13 +759,82 @@ function register(api) {
|
|
|
690
759
|
sweep();
|
|
691
760
|
if (cfg.controlPlane.enabled) {
|
|
692
761
|
try {
|
|
693
|
-
const prepared = (await callFor(ctx, "control_prepare", {
|
|
762
|
+
const prepared = (await callFor(ctx, "control_prepare", {
|
|
763
|
+
query: userText,
|
|
764
|
+
session_id: sessionKey,
|
|
765
|
+
host_run_id: ctx.runId,
|
|
766
|
+
turn_id: ctx.runId,
|
|
767
|
+
agent_id: agentIdFor(ctx),
|
|
768
|
+
user_id: delegatedUserIdFor(ctx),
|
|
769
|
+
workspace_id: workspaceIdFor(ctx),
|
|
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
|
+
}
|
|
694
792
|
pendingPrompts.set(sessionKey, {
|
|
695
793
|
text: userText,
|
|
696
794
|
ts: Date.now(),
|
|
697
795
|
exposureId: prepared.exposure_id,
|
|
698
796
|
contextReceiptId: prepared.context_receipt_id,
|
|
797
|
+
delegatedContext: prepared.authority === "delegated" && prepared.inject
|
|
798
|
+
? prepared.context ?? ""
|
|
799
|
+
: undefined,
|
|
800
|
+
delegatedContextSha256: prepared.authority === "delegated" ? prepared.context_sha256 : undefined,
|
|
801
|
+
delegatedAuthority: prepared.authority,
|
|
802
|
+
delegatedResultSha256: prepared.result_sha256,
|
|
803
|
+
taskDeliveryId: taskPrepared?.delivery_id,
|
|
804
|
+
taskContextSha256: taskPrepared?.context_sha256,
|
|
805
|
+
taskId: taskPrepared?.task_id ?? ctx.taskId,
|
|
699
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
|
+
...(taskPrepared?.context_sha256
|
|
813
|
+
? { task_context_sha256: taskPrepared.context_sha256.replace(/^sha256:/, "") }
|
|
814
|
+
: {}),
|
|
815
|
+
task_reason_codes: taskPrepared?.reason_codes ?? [],
|
|
816
|
+
});
|
|
817
|
+
}
|
|
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
|
+
if (prepared.authority === "delegated" || prepared.authority === "atmem_fallback") {
|
|
826
|
+
await recordBlackbox("context.provider_authorization", undefined, ctx, {
|
|
827
|
+
disposition: prepared.decision ?? "provider_failure",
|
|
828
|
+
provider: prepared.provider?.id,
|
|
829
|
+
mode: prepared.authority,
|
|
830
|
+
result_sha256: prepared.result_sha256,
|
|
831
|
+
context_sha256: prepared.context_sha256,
|
|
832
|
+
context_byte_length: Buffer.byteLength(prepared.context ?? "", "utf8"),
|
|
833
|
+
context_receipt_sha256: prepared.receipt?.sha256,
|
|
834
|
+
context_chars: (prepared.context ?? "").length,
|
|
835
|
+
success: prepared.decision === "inject" || prepared.decision === "withhold",
|
|
836
|
+
}, undefined, { contextReceiptId: prepared.context_receipt_id });
|
|
837
|
+
}
|
|
700
838
|
await recordBlackbox("context.disposition", undefined, ctx, {
|
|
701
839
|
disposition: prepared.inject && prepared.context
|
|
702
840
|
? "injected"
|
|
@@ -705,20 +843,43 @@ function register(api) {
|
|
|
705
843
|
: "no_relevant_memory",
|
|
706
844
|
context_sha256: digestText(prepared.context ?? ""),
|
|
707
845
|
context_block_sha256: digestText(prepared.context ?? ""),
|
|
708
|
-
context_envelope_sha256: digestJson(
|
|
846
|
+
context_envelope_sha256: digestJson(prepared.authority === "delegated"
|
|
847
|
+
? { prependContext: prepared.context ?? "" }
|
|
848
|
+
: { appendContext: prepared.context ?? "" }),
|
|
709
849
|
context_receipt_sha256: prepared.manifest_sha256,
|
|
710
850
|
digest_profile: "atmem-context-envelope-canonical-json-v1",
|
|
711
851
|
context_chars: (prepared.context ?? "").length,
|
|
712
852
|
candidate_ids: prepared.candidate_ids ?? [],
|
|
853
|
+
candidates_considered: prepared.retrieval?.eligible_candidate_count ?? 0,
|
|
854
|
+
retrieval_support_class: prepared.retrieval?.decision?.support_class ?? "not_recorded",
|
|
855
|
+
retrieval_reason_codes: prepared.retrieval?.decision?.reason_codes ?? [],
|
|
856
|
+
retrieval_calibration_version: prepared.retrieval?.decision?.calibration_version,
|
|
713
857
|
exposure_id: prepared.exposure_id,
|
|
714
858
|
mode: prepared.mode,
|
|
715
|
-
context_location: prepared.inject
|
|
859
|
+
context_location: prepared.inject
|
|
860
|
+
? prepared.authority === "delegated" ? "prependContext" : "appendContext"
|
|
861
|
+
: "none",
|
|
716
862
|
}, undefined, {
|
|
717
863
|
contextReceiptId: prepared.context_receipt_id,
|
|
718
864
|
});
|
|
719
865
|
if (prepared.inject && prepared.context) {
|
|
720
866
|
api.logger.info(`${TAG} memory control plane ${prepared.mode ?? "active"} context exposed`);
|
|
721
|
-
return
|
|
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 };
|
|
722
883
|
}
|
|
723
884
|
return;
|
|
724
885
|
}
|
|
@@ -919,6 +1080,14 @@ function register(api) {
|
|
|
919
1080
|
if (cached?.exposureId) {
|
|
920
1081
|
await callFor(ctx, "control_exposure_shown", { exposure_id: cached.exposureId }, cfg.recall.timeoutMs);
|
|
921
1082
|
}
|
|
1083
|
+
if (cached?.taskDeliveryId) {
|
|
1084
|
+
await callFor(ctx, "control_task_exposure_shown", { delivery_id: cached.taskDeliveryId }, cfg.recall.timeoutMs);
|
|
1085
|
+
await recordBlackbox("task.context.exposed", event.runId, ctx, {
|
|
1086
|
+
task_id: cached.taskId ?? ctx.taskId,
|
|
1087
|
+
task_disposition: "injected",
|
|
1088
|
+
task_context_sha256: cached.taskContextSha256?.replace(/^sha256:/, ""),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
922
1091
|
if (event.success !== false) {
|
|
923
1092
|
await callFor(ctx, "control_sync_openclaw_memory", {}, cfg.recall.timeoutMs);
|
|
924
1093
|
}
|
|
@@ -1542,6 +1711,128 @@ function register(api) {
|
|
|
1542
1711
|
};
|
|
1543
1712
|
},
|
|
1544
1713
|
}), { name: "atmem_forget_artifact" });
|
|
1714
|
+
// --- governed task tools (Amendment A) -------------------------------
|
|
1715
|
+
//
|
|
1716
|
+
// A control-plane operation is invisible to a model. Without these
|
|
1717
|
+
// registrations an agent receives a task checklist it has no way to tick,
|
|
1718
|
+
// which is worse than receiving nothing: it looks like progress is being
|
|
1719
|
+
// tracked when nothing is being recorded.
|
|
1720
|
+
//
|
|
1721
|
+
// Every one resolves through this conversation's own binding, so a model
|
|
1722
|
+
// can only touch the task its conversation is bound to.
|
|
1723
|
+
const taskScope = (toolCtx) => ({
|
|
1724
|
+
agent_id: agentIdFor(toolCtx),
|
|
1725
|
+
workspace_id: workspaceIdFor(toolCtx),
|
|
1726
|
+
});
|
|
1727
|
+
api.registerTool((toolCtx) => ({
|
|
1728
|
+
name: "task_report_progress",
|
|
1729
|
+
label: "Report Task Progress (atmem)",
|
|
1730
|
+
description: "Report progress on the governed task this conversation is bound to. " +
|
|
1731
|
+
"State the item and its new status. AtMem validates the change and " +
|
|
1732
|
+
"decides; you are proposing, not writing. If no task is bound this " +
|
|
1733
|
+
"does nothing.",
|
|
1734
|
+
parameters: {
|
|
1735
|
+
type: "object",
|
|
1736
|
+
properties: {
|
|
1737
|
+
item_id: { type: "string", description: "Which task item changed" },
|
|
1738
|
+
status: {
|
|
1739
|
+
type: "string",
|
|
1740
|
+
enum: ["ready", "running", "blocked", "completed", "skipped", "failed"],
|
|
1741
|
+
description: "The item's new status",
|
|
1742
|
+
},
|
|
1743
|
+
base_revision: {
|
|
1744
|
+
type: "integer",
|
|
1745
|
+
description: "The task revision you read. If the task has moved since, this " +
|
|
1746
|
+
"returns a conflict instead of overwriting someone else's work.",
|
|
1747
|
+
},
|
|
1748
|
+
reason: { type: "string", description: "Why, in one line" },
|
|
1749
|
+
},
|
|
1750
|
+
required: ["item_id", "status", "base_revision"],
|
|
1751
|
+
},
|
|
1752
|
+
async execute(toolCallId, params) {
|
|
1753
|
+
const resolution = await resolveBoundTaskForTool(toolCtx, (identity) => callFor(toolCtx, "control_prepare_task_context", {
|
|
1754
|
+
...identity,
|
|
1755
|
+
...taskScope(toolCtx),
|
|
1756
|
+
host_run_id: toolCtx.runId,
|
|
1757
|
+
}));
|
|
1758
|
+
if (!resolution.ok) {
|
|
1759
|
+
return refusal(`${resolution.message} (${resolution.reasonCodes.join(", ")})`);
|
|
1760
|
+
}
|
|
1761
|
+
const result = (await callFor(toolCtx, "control_propose_task_delta", {
|
|
1762
|
+
...resolution.identity,
|
|
1763
|
+
...taskScope(toolCtx),
|
|
1764
|
+
// Redundant checked assertion. Authority came from the current
|
|
1765
|
+
// conversation focus above, never from the model's parameters.
|
|
1766
|
+
task_id: resolution.taskId,
|
|
1767
|
+
base_revision: Number(params.base_revision ?? 0),
|
|
1768
|
+
// Derived from stable host identifiers, never from payload content
|
|
1769
|
+
// or a clock, so a retried tool call collapses to one decision.
|
|
1770
|
+
idempotency_key: `${toolCtx.runId ?? "run"}:${toolCallId}`,
|
|
1771
|
+
operations: [
|
|
1772
|
+
{
|
|
1773
|
+
kind: "set_item_status",
|
|
1774
|
+
item_id: String(params.item_id ?? ""),
|
|
1775
|
+
status: String(params.status ?? ""),
|
|
1776
|
+
reason: params.reason ? String(params.reason) : undefined,
|
|
1777
|
+
},
|
|
1778
|
+
],
|
|
1779
|
+
adapter: "openclaw",
|
|
1780
|
+
// The tool call is the evidence. Completing an item requires some,
|
|
1781
|
+
// and a host reporting its own tool outcome is asserting rather
|
|
1782
|
+
// than independently verifying -- AtMem records it at exactly that
|
|
1783
|
+
// assurance and never upgrades it.
|
|
1784
|
+
evidence: [
|
|
1785
|
+
{ kind: "tool_call", reference_id: `${toolCtx.runId ?? "run"}-${toolCallId}` },
|
|
1786
|
+
],
|
|
1787
|
+
reason: params.reason ? String(params.reason) : "",
|
|
1788
|
+
}));
|
|
1789
|
+
return {
|
|
1790
|
+
content: [{ type: "text", text: describeDecision(result) }],
|
|
1791
|
+
details: { outcome: result.outcome ?? result.reason_code ?? null },
|
|
1792
|
+
};
|
|
1793
|
+
},
|
|
1794
|
+
}), { name: "task_report_progress" });
|
|
1795
|
+
api.registerTool((toolCtx) => ({
|
|
1796
|
+
name: "task_binding_status",
|
|
1797
|
+
label: "Governed Task Binding (atmem)",
|
|
1798
|
+
description: "Show which governed task, if any, this conversation is bound to, and " +
|
|
1799
|
+
"the exact command to bind it. Owner only.",
|
|
1800
|
+
parameters: { type: "object", properties: {} },
|
|
1801
|
+
async execute() {
|
|
1802
|
+
if (!isConversationOwner(toolCtx))
|
|
1803
|
+
return refusal(NOT_OWNER_MESSAGE);
|
|
1804
|
+
const identity = sessionIdentityForTool(toolCtx);
|
|
1805
|
+
if (!identity)
|
|
1806
|
+
return refusal(NO_IDENTITY_MESSAGE);
|
|
1807
|
+
const prepared = (await callFor(toolCtx, "control_prepare_task_context", {
|
|
1808
|
+
...identity,
|
|
1809
|
+
...taskScope(toolCtx),
|
|
1810
|
+
}));
|
|
1811
|
+
// Binding stays an authenticated operator action at the terminal, so
|
|
1812
|
+
// the owner needs their own conversation's identity to run it. They
|
|
1813
|
+
// cannot see it otherwise -- it is an internal host value -- and
|
|
1814
|
+
// without it the whole feature is unreachable from inside OpenClaw.
|
|
1815
|
+
// Handing the owner a ready-to-run command is the same "one useful
|
|
1816
|
+
// next command" the CLI gives everywhere else. This discloses nothing
|
|
1817
|
+
// a non-owner can obtain: the gate above already refused them.
|
|
1818
|
+
const bindCommand = `atmem task bind DB_PATH TASK_ID --subject SUBJECT ` +
|
|
1819
|
+
`--agent ${agentIdFor(toolCtx)} --workspace ${workspaceIdFor(toolCtx) ?? "WORKSPACE"} ` +
|
|
1820
|
+
`--actor YOU --reason WHY --host-type ${identity.host_type} ` +
|
|
1821
|
+
`--session-key ${identity.session_key} --session-epoch ${identity.session_epoch} --yes`;
|
|
1822
|
+
if (prepared.disposition !== "injected") {
|
|
1823
|
+
return ok({
|
|
1824
|
+
bound: false,
|
|
1825
|
+
reason: (prepared.reason_codes ?? []).join(", ") || "not bound",
|
|
1826
|
+
bind_with: bindCommand,
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
return ok({
|
|
1830
|
+
bound: true,
|
|
1831
|
+
task_id: prepared.task_id ?? null,
|
|
1832
|
+
rebind_with: bindCommand,
|
|
1833
|
+
});
|
|
1834
|
+
},
|
|
1835
|
+
}), { name: "task_binding_status" });
|
|
1545
1836
|
}
|
|
1546
1837
|
api.logger.info(`${TAG} registered (db=${cfg.dbPath}, subject=${cfg.subject}, ` +
|
|
1547
1838
|
`recall=${cfg.recall.enabled}, capture=${cfg.capture.enabled}, ` +
|
package/dist/src/rpc-client.js
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
/** Resolve authority from the authenticated conversation, never model input. */
|
|
34
|
+
export async function resolveBoundTaskForTool(ctx, prepare) {
|
|
35
|
+
const identity = sessionIdentityForTool(ctx);
|
|
36
|
+
if (!identity) {
|
|
37
|
+
return { ok: false, message: NO_IDENTITY_MESSAGE, reasonCodes: ["host_identity_missing"] };
|
|
38
|
+
}
|
|
39
|
+
const prepared = (await prepare(identity));
|
|
40
|
+
if (prepared.disposition !== "injected" || !prepared.task_id) {
|
|
41
|
+
const reasons = (prepared.reason_codes ?? []).map(String);
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
message: "This conversation has no active governed task focus. " +
|
|
45
|
+
"No task progress was recorded.",
|
|
46
|
+
reasonCodes: reasons.length ? reasons : ["task_context_selection_required"],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
identity,
|
|
52
|
+
taskId: String(prepared.task_id),
|
|
53
|
+
revision: prepared.revision,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Absence is never permission. Only an explicit affirmative is the owner. */
|
|
57
|
+
export function isConversationOwner(ctx) {
|
|
58
|
+
return ctx.senderIsOwner === true;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Turn one AtMem decision into something a model can act on.
|
|
62
|
+
*
|
|
63
|
+
* The outcome vocabulary is small and each value implies a different next
|
|
64
|
+
* move, so saying which one it is matters more than saying it failed.
|
|
65
|
+
*/
|
|
66
|
+
export function describeDecision(result) {
|
|
67
|
+
const outcome = String(result.outcome ?? "");
|
|
68
|
+
const reasons = Array.isArray(result.reason_codes)
|
|
69
|
+
? result.reason_codes.join(", ")
|
|
70
|
+
: String(result.reason_code ?? "");
|
|
71
|
+
switch (outcome) {
|
|
72
|
+
case "accepted":
|
|
73
|
+
return `Recorded. The task is now at revision ${result.resulting_revision}.`;
|
|
74
|
+
case "no_change":
|
|
75
|
+
return `No change: the task already reflects this (${reasons}).`;
|
|
76
|
+
case "conflict":
|
|
77
|
+
return (`Conflict: the task moved while you were working (${reasons}). ` +
|
|
78
|
+
"Read it again with task_state and submit against the revision you get back.");
|
|
79
|
+
case "rejected":
|
|
80
|
+
return `Rejected (${reasons}). Do not retry this unchanged; the reason explains what AtMem will accept.`;
|
|
81
|
+
default:
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (result.reason_code) {
|
|
85
|
+
return `Not applied (${result.reason_code}): ${String(result.message ?? "")}`;
|
|
86
|
+
}
|
|
87
|
+
return String(result.message ?? "No decision was returned.");
|
|
88
|
+
}
|
|
89
|
+
/** A refusal shaped like every other tool result, so a model reads one thing. */
|
|
90
|
+
export function refusal(message) {
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, message }) }],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function ok(payload) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, ...payload }) }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export const NOT_OWNER_MESSAGE =
|
|
101
|
+
// Identical whether a binding exists or not: a non-owner must not be able to
|
|
102
|
+
// learn what this conversation is bound to by asking.
|
|
103
|
+
"Only the owner of this conversation can manage its governed task binding.";
|
|
104
|
+
export const NO_IDENTITY_MESSAGE = "This conversation has no usable session identity, so AtMem cannot tell " +
|
|
105
|
+
"which conversation it is. No task binding can be resolved or created.";
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "2.2.6-beta.10",
|
|
6
6
|
"commandAliases": ["memory-atmem"],
|
|
7
7
|
"activation": {
|
|
8
8
|
"onStartup": true,
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"atmem_search",
|
|
17
17
|
"atmem_forget",
|
|
18
18
|
"atmem_observe",
|
|
19
|
-
"atmem_forget_artifact"
|
|
19
|
+
"atmem_forget_artifact",
|
|
20
|
+
"task_report_progress",
|
|
21
|
+
"task_binding_status"
|
|
20
22
|
]
|
|
21
23
|
},
|
|
22
24
|
"configSchema": {
|
|
@@ -135,6 +137,22 @@
|
|
|
135
137
|
"description": "Record content-minimizing agent flights through the private control protocol. Managed automatically by the AtMem installer."
|
|
136
138
|
}
|
|
137
139
|
}
|
|
140
|
+
},
|
|
141
|
+
"delegatedContext": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"description": "Identity mapping used only when AtMem has an explicitly enabled delegated context-provider registration. This does not enable delegation.",
|
|
144
|
+
"properties": {
|
|
145
|
+
"userId": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"default": "",
|
|
148
|
+
"description": "Opaque authenticated user ID registered with AtMem. Leave empty unless delegated authority is configured."
|
|
149
|
+
},
|
|
150
|
+
"requireOwner": {
|
|
151
|
+
"type": "boolean",
|
|
152
|
+
"default": true,
|
|
153
|
+
"description": "Send the mapped user ID only when OpenClaw marks the sender as owner."
|
|
154
|
+
}
|
|
155
|
+
}
|
|
138
156
|
}
|
|
139
157
|
}
|
|
140
158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-memory-atmem",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.6-beta.10",
|
|
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",
|
|
44
|
-
"smoke": "node test/smoke.mjs",
|
|
45
|
-
"prepack": "npm run build && npm run typecheck && npm test && npm run smoke"
|
|
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 && node test/delegated-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",
|