openclaw-memory-atmem 2.2.6 → 2.3.2
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 -3
- package/dist/index.js +267 -30
- package/dist/src/execution-spool.js +189 -0
- package/dist/src/rpc-client.js +1 -1
- package/dist/src/setup.js +1 -1
- package/dist/src/tool-errors.js +31 -0
- package/dist/src/tool-observations.js +8 -4
- package/dist/src/tool-result-comparison.js +45 -0
- package/openclaw.plugin.json +8 -7
- package/package.json +2 -2
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
|
|
8
|
+
python -m pip install --upgrade atmem==2.3.2
|
|
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
|
|
25
|
+
Bridge `2.3.2` 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,7 +32,11 @@ 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.
|
|
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
|
|
|
@@ -54,3 +58,12 @@ actual terminal host tool-event results supply fallback observations when typed
|
|
|
54
58
|
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.
|
|
61
|
+
|
|
62
|
+
Local development follow-up: automatic recall now requires calibrated direct
|
|
63
|
+
query support by default (`recall.requireDirectSupport: true`). Persona is an
|
|
64
|
+
explicit opt-in (`persona.enabled: true`); setup defaults it off to avoid adding
|
|
65
|
+
unrelated personal facts to every request. When enabled, persona records are
|
|
66
|
+
excluded from recall so each appears once. Existing explicit persona settings
|
|
67
|
+
are respected; set `persona.enabled: false` to adopt selective recall. Explicit
|
|
68
|
+
memory search remains available. The legacy rank-only mode is available with
|
|
69
|
+
`recall.requireDirectSupport: false`; it can admit weak matches.
|
package/dist/index.js
CHANGED
|
@@ -20,10 +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";
|
|
26
|
+
import { progressCardComparison } from "./src/tool-result-comparison.js";
|
|
27
|
+
import { toolErrorReason } from "./src/tool-errors.js";
|
|
28
|
+
import { ExecutionSpool } from "./src/execution-spool.js";
|
|
27
29
|
import { delegatedIdentity, isolatedCliProcess } from "./src/delegated-identity.js";
|
|
28
30
|
import { AtmemClient } from "./src/rpc-client.js";
|
|
29
31
|
import { runSetup } from "./src/setup.js";
|
|
@@ -45,7 +47,10 @@ const PROMPT_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
45
47
|
const MAX_PENDING_PROMPTS = 256;
|
|
46
48
|
function parseConfig(raw) {
|
|
47
49
|
const cfg = (raw ?? {});
|
|
48
|
-
const
|
|
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")));
|
|
49
54
|
const subject = String(cfg.subject ?? "default");
|
|
50
55
|
const stringMap = (value) => value && typeof value === "object" && !Array.isArray(value)
|
|
51
56
|
? Object.fromEntries(Object.entries(value)
|
|
@@ -62,8 +67,8 @@ function parseConfig(raw) {
|
|
|
62
67
|
].filter(Boolean))];
|
|
63
68
|
const controlPlane = {
|
|
64
69
|
enabled: cfg.controlPlane?.enabled === true,
|
|
65
|
-
statePath: expandHome(String(cfg.controlPlane?.statePath ?? "
|
|
66
|
-
blackboxEnabled: cfg.controlPlane
|
|
70
|
+
statePath: expandHome(String(cfg.controlPlane?.statePath ?? path.join(selectedHome, "config", "control-plane.json"))),
|
|
71
|
+
blackboxEnabled: Boolean(cfg.controlPlane) && cfg.controlPlane?.blackboxEnabled !== false,
|
|
67
72
|
};
|
|
68
73
|
const delegatedContext = {
|
|
69
74
|
userId: String(cfg.delegatedContext?.userId ?? "").trim(),
|
|
@@ -97,10 +102,11 @@ function parseConfig(raw) {
|
|
|
97
102
|
maxRecords: Number(cfg.recall?.maxRecords ?? 3),
|
|
98
103
|
maxChars: Number(cfg.recall?.maxChars ?? 1200),
|
|
99
104
|
minScore: Number(cfg.recall?.minScore ?? 0.3),
|
|
105
|
+
requireDirectSupport: cfg.recall?.requireDirectSupport !== false,
|
|
100
106
|
timeoutMs: Number(cfg.recall?.timeoutMs ?? 4000),
|
|
101
107
|
},
|
|
102
108
|
persona: {
|
|
103
|
-
enabled: cfg.persona?.enabled
|
|
109
|
+
enabled: cfg.persona?.enabled === true,
|
|
104
110
|
maxChars: Number(cfg.persona?.maxChars ?? 600),
|
|
105
111
|
ttlSeconds: Number(cfg.persona?.ttlSeconds ?? 300),
|
|
106
112
|
},
|
|
@@ -226,7 +232,7 @@ async function writeAttachmentBinding(root, key, items) {
|
|
|
226
232
|
const payload = {
|
|
227
233
|
format: "atmem-openclaw-attachment-binding-v1",
|
|
228
234
|
keySha256: createHash("sha256").update(key).digest("hex"),
|
|
229
|
-
items,
|
|
235
|
+
items: items.map(({ dataBase64: _discarded, ...item }) => item),
|
|
230
236
|
updatedAt: Date.now(),
|
|
231
237
|
};
|
|
232
238
|
await writeFile(temporary, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
|
|
@@ -256,17 +262,57 @@ async function readAttachmentBinding(root, key) {
|
|
|
256
262
|
}
|
|
257
263
|
function modalityFromMime(mimeType) {
|
|
258
264
|
const mime = mimeType.toLowerCase();
|
|
259
|
-
if (mime.startsWith("image/"))
|
|
265
|
+
if (mime === "image" || mime.startsWith("image/"))
|
|
260
266
|
return "image";
|
|
261
|
-
if (mime.startsWith("audio/"))
|
|
267
|
+
if (mime === "audio" || mime.startsWith("audio/"))
|
|
262
268
|
return "audio";
|
|
263
|
-
if (mime.startsWith("video/"))
|
|
269
|
+
if (mime === "video" || mime.startsWith("video/"))
|
|
264
270
|
return "video";
|
|
265
|
-
if (mime === "application/pdf" || mime.startsWith("text/") || mime.includes("document")) {
|
|
271
|
+
if (mime === "document" || mime === "application/pdf" || mime.startsWith("text/") || mime.includes("document")) {
|
|
266
272
|
return "document";
|
|
267
273
|
}
|
|
268
274
|
return null;
|
|
269
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
|
+
}
|
|
270
316
|
function withinPath(candidate, root) {
|
|
271
317
|
return candidate === root || candidate.startsWith(root + path.sep);
|
|
272
318
|
}
|
|
@@ -279,12 +325,12 @@ async function hashInboundAttachment(filePath, mimeType) {
|
|
|
279
325
|
throw new Error("OpenClaw attachment path is outside the managed media directory");
|
|
280
326
|
}
|
|
281
327
|
const before = await lstat(resolved);
|
|
282
|
-
|
|
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) {
|
|
283
330
|
throw new Error("OpenClaw attachment is not a bounded regular file");
|
|
284
331
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
hash.update(chunk);
|
|
332
|
+
const exactBytes = await readFile(resolved);
|
|
333
|
+
const hash = createHash("sha256").update(exactBytes);
|
|
288
334
|
const after = await lstat(resolved);
|
|
289
335
|
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
290
336
|
throw new Error("OpenClaw attachment changed while its digest was computed");
|
|
@@ -300,6 +346,53 @@ async function hashInboundAttachment(filePath, mimeType) {
|
|
|
300
346
|
mimeType,
|
|
301
347
|
bytes: before.size,
|
|
302
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),
|
|
303
396
|
};
|
|
304
397
|
}
|
|
305
398
|
function recordIdFromPath(value) {
|
|
@@ -311,7 +404,16 @@ function recordIdFromPath(value) {
|
|
|
311
404
|
}
|
|
312
405
|
function register(api) {
|
|
313
406
|
const cfg = parseConfig(api.pluginConfig);
|
|
314
|
-
const
|
|
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");
|
|
315
417
|
const client = new AtmemClient({
|
|
316
418
|
command: cfg.command,
|
|
317
419
|
args: cfg.commandArgs,
|
|
@@ -326,6 +428,7 @@ function register(api) {
|
|
|
326
428
|
log: (message) => api.logger.debug?.(`${TAG} ${message}`),
|
|
327
429
|
logError: (message) => api.logger.warn(`${TAG} ${message}`),
|
|
328
430
|
});
|
|
431
|
+
const executionSpool = new ExecutionSpool(path.join(runtimeRoot, "openclaw-execution-spool.json"));
|
|
329
432
|
// Let long-lived hosts close the stdio child during lifecycle shutdown.
|
|
330
433
|
// The client's bounded idle shutdown also covers one-shot local runners.
|
|
331
434
|
api.registerService?.({
|
|
@@ -434,13 +537,27 @@ function register(api) {
|
|
|
434
537
|
? normalized.slice("openclaw".length)
|
|
435
538
|
: normalized;
|
|
436
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
|
+
]);
|
|
437
550
|
const recordBlackbox = async (eventType, eventRunId, ctx, payload, toolCallId, correlation = {}) => {
|
|
438
551
|
if (!cfg.controlPlane.blackboxEnabled)
|
|
439
552
|
return;
|
|
440
553
|
try {
|
|
441
|
-
await
|
|
554
|
+
await executionSpool.enqueue({
|
|
442
555
|
event_type: eventType,
|
|
443
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,
|
|
444
561
|
agent_id: agentIdFor(ctx),
|
|
445
562
|
workspace_id: workspaceIdFor(ctx),
|
|
446
563
|
subject_id: Object.keys(cfg.agentSubjects).length ? subjectFor(ctx) : undefined,
|
|
@@ -452,7 +569,8 @@ function register(api) {
|
|
|
452
569
|
context_receipt_id: correlation.contextReceiptId,
|
|
453
570
|
outcome_id: correlation.outcomeId,
|
|
454
571
|
payload,
|
|
455
|
-
}
|
|
572
|
+
});
|
|
573
|
+
await executionSpool.flush((event) => blackboxClient.callTool("control_record_blackbox_event", event, cfg.recall.timeoutMs));
|
|
456
574
|
}
|
|
457
575
|
catch (error) {
|
|
458
576
|
api.logger.warn(`${TAG} blackbox event ${eventType} was not recorded: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -507,6 +625,9 @@ function register(api) {
|
|
|
507
625
|
prompt_sha256: promptSha256,
|
|
508
626
|
prompt_chars: prompt.length,
|
|
509
627
|
images_count: imagesCount,
|
|
628
|
+
_atmem_evidence: {
|
|
629
|
+
parts: [{ type: "text", text: prompt }],
|
|
630
|
+
},
|
|
510
631
|
});
|
|
511
632
|
try {
|
|
512
633
|
await stageInbound(prompt, ctx);
|
|
@@ -522,7 +643,7 @@ function register(api) {
|
|
|
522
643
|
});
|
|
523
644
|
await pending;
|
|
524
645
|
};
|
|
525
|
-
const bindInboundAttachments = async (keys, paths, types) => {
|
|
646
|
+
const bindInboundAttachments = async (keys, paths, types, ctx) => {
|
|
526
647
|
const generation = ++nextAttachmentGeneration;
|
|
527
648
|
for (const key of keys) {
|
|
528
649
|
inboundAttachments.delete(key);
|
|
@@ -532,6 +653,20 @@ function register(api) {
|
|
|
532
653
|
if (!paths.length)
|
|
533
654
|
return;
|
|
534
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
|
+
}
|
|
535
670
|
for (const key of keys) {
|
|
536
671
|
if (inboundAttachmentGeneration.get(key) === generation) {
|
|
537
672
|
inboundAttachments.set(key, { items, ts: Date.now() });
|
|
@@ -557,7 +692,8 @@ function register(api) {
|
|
|
557
692
|
: typeof rawType === "string"
|
|
558
693
|
? [rawType]
|
|
559
694
|
: [];
|
|
560
|
-
|
|
695
|
+
const current = attachmentFields(message);
|
|
696
|
+
return current.paths.length ? current : { paths, types };
|
|
561
697
|
};
|
|
562
698
|
const writtenMessageBindingKeys = (message, ctx) => {
|
|
563
699
|
const idempotencyKey = typeof message.idempotencyKey === "string"
|
|
@@ -574,23 +710,57 @@ function register(api) {
|
|
|
574
710
|
};
|
|
575
711
|
api.on("message_received", async (event, ctx) => {
|
|
576
712
|
const metadata = event.metadata ?? {};
|
|
577
|
-
|
|
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)
|
|
578
729
|
? metadata.mediaPaths.filter((value) => typeof value === "string")
|
|
579
|
-
: typeof metadata.mediaPath === "string"
|
|
580
|
-
|
|
581
|
-
: [];
|
|
582
|
-
const types = Array.isArray(metadata.mediaTypes)
|
|
730
|
+
: typeof metadata.mediaPath === "string" ? [metadata.mediaPath] : [];
|
|
731
|
+
const legacyTypes = Array.isArray(metadata.mediaTypes)
|
|
583
732
|
? metadata.mediaTypes.filter((value) => typeof value === "string")
|
|
584
|
-
: typeof metadata.mediaType === "string"
|
|
585
|
-
|
|
586
|
-
|
|
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);
|
|
587
753
|
const keys = [...new Set([
|
|
588
754
|
event.sessionKey,
|
|
589
755
|
event.runId,
|
|
590
756
|
...contextIds(ctx),
|
|
591
757
|
].filter((value) => Boolean(value)))];
|
|
592
758
|
try {
|
|
593
|
-
|
|
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);
|
|
594
764
|
}
|
|
595
765
|
catch (error) {
|
|
596
766
|
api.logger.warn(`${TAG} inbound attachment provenance unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -633,12 +803,21 @@ function register(api) {
|
|
|
633
803
|
context_location: deliveredLocation,
|
|
634
804
|
success: delivered,
|
|
635
805
|
reason: delivered ? undefined : `expected one exact delegated segment; observed ${occurrences}`,
|
|
806
|
+
_atmem_evidence: {
|
|
807
|
+
context: exact,
|
|
808
|
+
delivered_location: deliveredLocation,
|
|
809
|
+
},
|
|
636
810
|
}, undefined, { contextReceiptId: pending.contextReceiptId });
|
|
637
811
|
if (delivered && pending.exposureId) {
|
|
638
812
|
await callFor(ctx, "control_exposure_shown", { exposure_id: pending.exposureId }, cfg.recall.timeoutMs);
|
|
639
813
|
}
|
|
640
814
|
cachePendingPrompt(sessionKey, { ...pending, delegatedContext: undefined });
|
|
641
815
|
}
|
|
816
|
+
const modelMediaParts = inlineMediaParts([
|
|
817
|
+
event.historyMessages,
|
|
818
|
+
event.messages,
|
|
819
|
+
event.images,
|
|
820
|
+
]);
|
|
642
821
|
await recordBlackbox("model.input", event.runId, ctx, {
|
|
643
822
|
provider: event.provider,
|
|
644
823
|
model: event.model,
|
|
@@ -650,6 +829,13 @@ function register(api) {
|
|
|
650
829
|
history_count: Array.isArray(event.historyMessages) ? event.historyMessages.length : 0,
|
|
651
830
|
images_count: event.imagesCount ?? 0,
|
|
652
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
|
+
},
|
|
653
839
|
});
|
|
654
840
|
});
|
|
655
841
|
api.on("llm_output", async (event, ctx) => {
|
|
@@ -680,6 +866,10 @@ function register(api) {
|
|
|
680
866
|
usage: event.usage ?? {},
|
|
681
867
|
reasoning_effort: event.reasoningEffort,
|
|
682
868
|
fast_mode: event.fastMode,
|
|
869
|
+
_atmem_evidence: {
|
|
870
|
+
assistant_texts: responses,
|
|
871
|
+
usage: event.usage ?? {},
|
|
872
|
+
},
|
|
683
873
|
});
|
|
684
874
|
});
|
|
685
875
|
api.registerCli?.(({ program }) => {
|
|
@@ -995,6 +1185,8 @@ function register(api) {
|
|
|
995
1185
|
max_records: cfg.recall.maxRecords,
|
|
996
1186
|
max_chars: cfg.recall.maxChars,
|
|
997
1187
|
min_score: cfg.recall.minScore,
|
|
1188
|
+
require_direct_support: cfg.recall.requireDirectSupport,
|
|
1189
|
+
exclude_record_ids: personaRecordIds,
|
|
998
1190
|
reference_mode: cfg.cacheAware.enabled && cfg.cacheAware.compactReferences
|
|
999
1191
|
? "compact"
|
|
1000
1192
|
: "full",
|
|
@@ -1072,8 +1264,15 @@ function register(api) {
|
|
|
1072
1264
|
context_chars: memoryContext.length,
|
|
1073
1265
|
candidate_ids: candidateIds,
|
|
1074
1266
|
context_component_event_ids: componentEventIds,
|
|
1267
|
+
persona_record_count: personaRecordIds.length,
|
|
1268
|
+
recall_record_count: (current?.injectedRecordIds ?? []).length,
|
|
1269
|
+
context_selection_profile: cfg.recall.requireDirectSupport ? "direct-support-v1" : "rank-threshold-v1",
|
|
1075
1270
|
mode: "direct",
|
|
1076
1271
|
context_location: contextLocation,
|
|
1272
|
+
_atmem_evidence: {
|
|
1273
|
+
context: memoryContext,
|
|
1274
|
+
envelope: result,
|
|
1275
|
+
},
|
|
1077
1276
|
}, undefined, {
|
|
1078
1277
|
retrievalId: current?.retrievalId,
|
|
1079
1278
|
contextEventId: current?.contextEventId ?? personaContextEventId,
|
|
@@ -1111,7 +1310,16 @@ function register(api) {
|
|
|
1111
1310
|
derived_path_sha256: Array.isArray(event.derivedPaths)
|
|
1112
1311
|
? event.derivedPaths.map((value) => digestText(String(value)))
|
|
1113
1312
|
: [],
|
|
1313
|
+
_atmem_evidence: {
|
|
1314
|
+
params: event.params ?? {},
|
|
1315
|
+
derived_paths: event.derivedPaths ?? [],
|
|
1316
|
+
},
|
|
1114
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;
|
|
1115
1323
|
if (!cfg.takeoverActive || !cfg.nativeWorkspaces.some((workspace) => touchesNativeMemory(event, workspace)))
|
|
1116
1324
|
return;
|
|
1117
1325
|
const reason = "AtMem takeover blocked access to OpenClaw's frozen native memory " +
|
|
@@ -1134,9 +1342,18 @@ function register(api) {
|
|
|
1134
1342
|
tool_name: event.toolName,
|
|
1135
1343
|
tool_canonical_name: canonicalToolName(event.toolName),
|
|
1136
1344
|
result_sha256: digestJson(event.result ?? null),
|
|
1345
|
+
...(!event.error ? progressCardComparison(event.toolName, event.result, digestJson) : {}),
|
|
1137
1346
|
outcome: event.error ? "error" : "completed",
|
|
1138
1347
|
error_category: event.error ? "tool_error" : undefined,
|
|
1348
|
+
error_reason: event.error ? toolErrorReason(event.error) : undefined,
|
|
1349
|
+
error_sha256: event.error ? digestText(event.error) : undefined,
|
|
1350
|
+
result_present: event.result !== undefined,
|
|
1139
1351
|
duration_ms: event.durationMs ?? 0,
|
|
1352
|
+
_atmem_evidence: {
|
|
1353
|
+
params: event.params ?? {},
|
|
1354
|
+
result: event.result ?? null,
|
|
1355
|
+
error: event.error ?? null,
|
|
1356
|
+
},
|
|
1140
1357
|
}, event.toolCallId ?? ctx.toolCallId);
|
|
1141
1358
|
});
|
|
1142
1359
|
// ---- auto-capture: user turn through the pipeline, assistant as digest -
|
|
@@ -1146,11 +1363,25 @@ function register(api) {
|
|
|
1146
1363
|
tool_name: saved.name, tool_canonical_name: canonicalToolName(saved.name),
|
|
1147
1364
|
result_sha256: saved.result.digest, outcome: saved.result.error ? "error" : "completed",
|
|
1148
1365
|
error_category: saved.result.error ? "tool_error" : undefined,
|
|
1366
|
+
error_reason: saved.result.reason,
|
|
1367
|
+
error_sha256: saved.result.errorDigest,
|
|
1149
1368
|
reason: "observed_terminal_host_tool_event",
|
|
1150
1369
|
}, saved.callId);
|
|
1151
1370
|
});
|
|
1152
1371
|
const sessionKey = sessionKeyFor(ctx);
|
|
1153
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
|
+
}
|
|
1154
1385
|
const cached = pendingPrompts.get(sessionKey);
|
|
1155
1386
|
deletePendingPrompt(sessionKey);
|
|
1156
1387
|
const userText = cached?.text?.replace(INJECT_RE, "").trim();
|
|
@@ -1254,6 +1485,10 @@ function register(api) {
|
|
|
1254
1485
|
turn_messages_sha256: turnMessagesSha256,
|
|
1255
1486
|
digest_profile: "atmem-turn-messages-canonical-json-v1",
|
|
1256
1487
|
messages_count: Array.isArray(event.messages) ? event.messages.length : 0,
|
|
1488
|
+
_atmem_evidence: {
|
|
1489
|
+
messages: event.messages ?? [],
|
|
1490
|
+
error: event.error ?? null,
|
|
1491
|
+
},
|
|
1257
1492
|
}, undefined, {
|
|
1258
1493
|
retrievalId: cached?.retrievalId,
|
|
1259
1494
|
contextEventId: cached?.contextEventId,
|
|
@@ -1274,8 +1509,10 @@ function register(api) {
|
|
|
1274
1509
|
// by scanning the media directory or scraping its name from user text.
|
|
1275
1510
|
const attachmentFields = writtenMessageAttachmentFields(message);
|
|
1276
1511
|
const attachmentKeys = writtenMessageBindingKeys(message, ctx);
|
|
1277
|
-
|
|
1278
|
-
|
|
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) => {
|
|
1279
1516
|
api.logger.warn(`${TAG} persisted attachment provenance unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1280
1517
|
});
|
|
1281
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
|
+
}
|
package/dist/src/rpc-client.js
CHANGED
package/dist/src/setup.js
CHANGED
|
@@ -42,7 +42,7 @@ export function setupWrites(options) {
|
|
|
42
42
|
minScore: 0.3,
|
|
43
43
|
timeoutMs: 3000,
|
|
44
44
|
},
|
|
45
|
-
persona: { enabled:
|
|
45
|
+
persona: { enabled: false, maxChars: 600, ttlSeconds: 300 },
|
|
46
46
|
capture: { enabled: true, captureAssistant: true },
|
|
47
47
|
cacheAware: { enabled: true, compactReferences: true },
|
|
48
48
|
tools: { enabled: true },
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Retain a bounded diagnostic, never the full tool result or stack trace. */
|
|
2
|
+
export function toolErrorReason(value) {
|
|
3
|
+
let text = value;
|
|
4
|
+
if (value && typeof value === "object") {
|
|
5
|
+
const row = value;
|
|
6
|
+
text = row.error ?? row.message;
|
|
7
|
+
if (text && typeof text === "object")
|
|
8
|
+
text = text.message;
|
|
9
|
+
if (text === undefined && Array.isArray(row.content)) {
|
|
10
|
+
text = row.content.find(item => item?.type === "text" && typeof item.text === "string")?.text;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (typeof text !== "string" || !text.trim())
|
|
14
|
+
return undefined;
|
|
15
|
+
const bounded = text.slice(0, 8192);
|
|
16
|
+
// OpenClaw wraps thrown web errors as untrusted API content. Extract only
|
|
17
|
+
// its narrow status prefix, never the page text or instructions beneath it.
|
|
18
|
+
if (bounded.startsWith("SECURITY NOTICE:")) {
|
|
19
|
+
const status = bounded.match(/<<<EXTERNAL_UNTRUSTED_CONTENT id="[a-f0-9]+">>>\r?\nSource: API\r?\n---\r?\n(Web fetch failed \([1-5][0-9]{2}\)):/);
|
|
20
|
+
return status?.[1];
|
|
21
|
+
}
|
|
22
|
+
return bounded.split(/[\r\n]/, 1)[0]
|
|
23
|
+
.replace(/https?:\/\/[^\s<>]+/gi, "[redacted-url]")
|
|
24
|
+
.replace(/\b(?:Bearer|Basic)\s+\S+/gi, "[redacted-auth]")
|
|
25
|
+
.replace(/\b(?:api[_-]?key|token|password|passwd|secret|authorization|cookie)\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, "[redacted-credential]")
|
|
26
|
+
.replace(/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]+/g, "[redacted-token]")
|
|
27
|
+
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[redacted-email]")
|
|
28
|
+
.replace(/(?:[A-Za-z]:\\|\/)[^\s<>"']+/g, "[redacted-path]")
|
|
29
|
+
.replace(/"[^"\r\n]*"|'[^'\r\n]*'/g, "[redacted-value]")
|
|
30
|
+
.replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 512) || undefined;
|
|
31
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { toolErrorReason } from "./tool-errors.js";
|
|
2
3
|
const CACHE_KEY = Symbol.for("atmem.tool-observation-cache.v1");
|
|
3
4
|
const sharedCache = globalThis;
|
|
4
5
|
const cache = sharedCache[CACHE_KEY] ??= new Map();
|
|
5
6
|
/** Some hosts deny run-context writes from tool hooks after registry reload.
|
|
6
|
-
* Keep a bounded content-
|
|
7
|
+
* Keep a bounded content-minimized process fallback, isolated by installation config.
|
|
7
8
|
*/
|
|
8
9
|
export function observationContext(host, scope) {
|
|
9
10
|
const keyFor = (runId, namespace) => JSON.stringify([scope, runId, namespace]);
|
|
@@ -45,7 +46,7 @@ function canonical(name) {
|
|
|
45
46
|
const unwrapped = name.replace(/^mcp__openclaw__/, "");
|
|
46
47
|
return { Read: "read", Bash: "exec" }[unwrapped] ?? unwrapped;
|
|
47
48
|
}
|
|
48
|
-
/** Stores
|
|
49
|
+
/** Stores scope, names, digests and redacted error diagnostics in bounded ephemeral context. */
|
|
49
50
|
export class ToolObservations {
|
|
50
51
|
access;
|
|
51
52
|
digest;
|
|
@@ -88,9 +89,12 @@ export class ToolObservations {
|
|
|
88
89
|
(event.agentId !== undefined && event.agentId !== saved.ctx.agentId) ||
|
|
89
90
|
canonical(d.name) !== canonical(saved.name))
|
|
90
91
|
return;
|
|
91
|
-
const result = { digest: this.digest(d.result), error: d.isError
|
|
92
|
+
const result = { digest: this.digest(d.result), error: d.isError,
|
|
93
|
+
reason: d.isError ? toolErrorReason(d.error ?? d.result) : undefined,
|
|
94
|
+
errorDigest: d.isError && d.error !== undefined ? this.digest(d.error) : undefined };
|
|
92
95
|
const conflict = saved.conflict || (saved.result !== undefined &&
|
|
93
|
-
(saved.result.digest !== result.digest || saved.result.error !== result.error
|
|
96
|
+
(saved.result.digest !== result.digest || saved.result.error !== result.error ||
|
|
97
|
+
saved.result.errorDigest !== result.errorDigest));
|
|
94
98
|
this.access.setRunContext({ ...key, value: { ...saved, result, conflict } });
|
|
95
99
|
}
|
|
96
100
|
async flush(ctx, record) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Exact OpenClaw progress_card ↔ native relay equivalence. Never discard raw digests.
|
|
2
|
+
export function progressCardComparison(name, result, digest) {
|
|
3
|
+
if (name !== "progress_card")
|
|
4
|
+
return {};
|
|
5
|
+
const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
6
|
+
const keys = (v, expected) => Object.keys(v).sort().join(",") === [...expected].sort().join(",");
|
|
7
|
+
const native = Array.isArray(result);
|
|
8
|
+
if (!native && (!record(result) || !keys(result, ["content", "details"])))
|
|
9
|
+
return {};
|
|
10
|
+
const content = native ? result : result.content;
|
|
11
|
+
if (!Array.isArray(content) || content.length !== 2 || !content.every(v => record(v) && keys(v, ["type", "text"]) && v.type === (native ? "input_text" : "text") &&
|
|
12
|
+
typeof v.text === "string" && v.text.length <= 1024))
|
|
13
|
+
return {};
|
|
14
|
+
let payload;
|
|
15
|
+
try {
|
|
16
|
+
payload = JSON.parse(content[1].text);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
if (!record(payload) || !keys(payload, ["revision", "steps"]))
|
|
22
|
+
return {};
|
|
23
|
+
const { revision, steps } = payload;
|
|
24
|
+
if (revision !== null && (!Number.isSafeInteger(revision) || revision < 1))
|
|
25
|
+
return {};
|
|
26
|
+
if (steps !== null && (!record(steps) || !keys(steps, ["completed", "total"]) ||
|
|
27
|
+
!Number.isSafeInteger(steps.completed) || !Number.isSafeInteger(steps.total) ||
|
|
28
|
+
steps.completed < 0 || steps.total < 1 || steps.total > 50 ||
|
|
29
|
+
steps.completed > steps.total))
|
|
30
|
+
return {};
|
|
31
|
+
if (revision === null && steps !== null)
|
|
32
|
+
return {};
|
|
33
|
+
const expected = revision === null ? "Progress card cleared" : steps === null
|
|
34
|
+
? `Progress card updated (rev ${revision})`
|
|
35
|
+
: `Progress card updated (rev ${revision}, ${steps.completed}/${steps.total} done)`;
|
|
36
|
+
if (content[0].text !== expected || content[1].text !== JSON.stringify(payload, null, 2))
|
|
37
|
+
return {};
|
|
38
|
+
if (!native && digest(result.details) !== digest(payload))
|
|
39
|
+
return {};
|
|
40
|
+
return {
|
|
41
|
+
result_comparison_profile: "openclaw-progress-card-v1",
|
|
42
|
+
result_comparison_sha256: digest(payload),
|
|
43
|
+
result_observation_shape: native ? "native_input_text" : "tool_content_details",
|
|
44
|
+
};
|
|
45
|
+
}
|
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.3.2",
|
|
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": {
|
|
@@ -81,15 +81,16 @@
|
|
|
81
81
|
"enabled": { "type": "boolean", "default": true },
|
|
82
82
|
"maxRecords": { "type": "number", "default": 3, "description": "Max memories injected per turn" },
|
|
83
83
|
"maxChars": { "type": "number", "default": 1200, "description": "Total character budget for the injected block" },
|
|
84
|
+
"requireDirectSupport": { "type": "boolean", "default": true, "description": "Require direct query relevance for automatic recall; rank score alone is insufficient" },
|
|
84
85
|
"minScore": { "type": "number", "default": 0.3, "description": "Minimum relevance score; 0.3 requires a lexical match (priors alone never inject)" },
|
|
85
86
|
"timeoutMs": { "type": "number", "default": 4000, "description": "Recall timeout; on timeout the turn proceeds without injection" }
|
|
86
87
|
}
|
|
87
88
|
},
|
|
88
89
|
"persona": {
|
|
89
90
|
"type": "object",
|
|
90
|
-
"description": "
|
|
91
|
+
"description": "Optional always-on persona snapshot; disabled by default to avoid unrelated context",
|
|
91
92
|
"properties": {
|
|
92
|
-
"enabled": { "type": "boolean", "default":
|
|
93
|
+
"enabled": { "type": "boolean", "default": false },
|
|
93
94
|
"maxChars": { "type": "number", "default": 600, "description": "Character budget for the <user_persona> block" },
|
|
94
95
|
"ttlSeconds": { "type": "number", "default": 300, "description": "Persona cache lifetime; also invalidated whenever new memory is captured" }
|
|
95
96
|
}
|
|
@@ -128,13 +129,13 @@
|
|
|
128
129
|
},
|
|
129
130
|
"statePath": {
|
|
130
131
|
"type": "string",
|
|
131
|
-
"default": "~/.atmem/control-plane.json",
|
|
132
|
+
"default": "~/.atmem/config/control-plane.json",
|
|
132
133
|
"description": "Digest-bound control file written by `atmem control`."
|
|
133
134
|
},
|
|
134
135
|
"blackboxEnabled": {
|
|
135
136
|
"type": "boolean",
|
|
136
|
-
"default":
|
|
137
|
-
"description": "Record
|
|
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."
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-memory-atmem",
|
|
3
|
-
"version": "2.2
|
|
3
|
+
"version": "2.3.2",
|
|
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/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",
|