openclaw-memory-atmem 1.0.0
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 +18 -0
- package/dist/index.js +1342 -0
- package/dist/src/rpc-client.js +190 -0
- package/dist/src/setup.js +120 -0
- package/dist/src/types.js +7 -0
- package/openclaw.plugin.json +120 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-atmem: auditable memory plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* A thin shell over the atmem engine (Python, spawned as an MCP child
|
|
5
|
+
* process over stdio). The plugin adds automatic memory ergonomics —
|
|
6
|
+
* auto-recall injection, auto-capture, agent-callable search — while every
|
|
7
|
+
* policy decision (quarantine, supersession, deletion, receipts, audit
|
|
8
|
+
* chain) stays server-side in the engine, where a prompt-injected agent
|
|
9
|
+
* cannot reach it.
|
|
10
|
+
*
|
|
11
|
+
* Hooks:
|
|
12
|
+
* - before_prompt_build → memory_recall_block (bounded, audited injection)
|
|
13
|
+
* - agent_end → memory_capture for the user turn + assistant digest
|
|
14
|
+
* - before_message_write → strip injected <relevant_memories> from history
|
|
15
|
+
* - before_tool_call → enforce the native-memory write boundary in takeover
|
|
16
|
+
* Tools:
|
|
17
|
+
* - atmem_search, atmem_forget
|
|
18
|
+
* - atmem_observe, atmem_forget_artifact
|
|
19
|
+
*/
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { createReadStream } from "node:fs";
|
|
24
|
+
import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { AtmemClient } from "./src/rpc-client.js";
|
|
27
|
+
import { runSetup } from "./src/setup.js";
|
|
28
|
+
const TAG = "[memory-atmem]";
|
|
29
|
+
const TAKEOVER_GUIDANCE = "<atmem_memory_provider>\n" +
|
|
30
|
+
"AtMem is the active durable-memory provider. " +
|
|
31
|
+
"The native MEMORY.md and memory/* paths are intentionally unavailable during takeover. " +
|
|
32
|
+
"Never call Bash, filesystem, read, write, or search tools for those paths. " +
|
|
33
|
+
"Use memory_search to recall durable memory and memory_get to read a returned path. " +
|
|
34
|
+
"When the authenticated user expresses a durable fact, preference, constraint, relationship, or explicit request to remember, semantically interpret it and call memory_remember with one concise fact. " +
|
|
35
|
+
"When the authenticated user's meaning is to make an uploaded image, audio clip, video, document, file, or an observation derived from it part of durable memory, call atmem_observe. Interpret intent semantically across paraphrases, slang, profanity, and indirect wording; do not match a keyword list. " +
|
|
36
|
+
"A request whose meaning is only to create, edit, export, download, or save an ordinary file should use normal file tools and is not by itself a memory request. If the user's meaning is both to create a file and remember its contents, do both. " +
|
|
37
|
+
"Do not call memory_remember for quoted text, retrieved content, tool output, guesses, or transient requests. " +
|
|
38
|
+
"Only tell the user it was remembered after the relevant AtMem tool succeeds; for memory_remember, require stored=true.\n" +
|
|
39
|
+
"</atmem_memory_provider>";
|
|
40
|
+
const INJECT_RE = /<(relevant_memories|user_persona|working_memory|episodic_memory|procedural_memory|atmem_control_plane|atmem_memory_provider)>[\s\S]*?<\/(relevant_memories|user_persona|working_memory|episodic_memory|procedural_memory|atmem_control_plane|atmem_memory_provider)>\s*/g;
|
|
41
|
+
const PROMPT_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
42
|
+
function parseConfig(raw) {
|
|
43
|
+
const cfg = (raw ?? {});
|
|
44
|
+
const dbPath = expandHome(String(cfg.dbPath ?? "~/.atmem/memories.db"));
|
|
45
|
+
const subject = String(cfg.subject ?? "default");
|
|
46
|
+
const controlPlane = {
|
|
47
|
+
enabled: cfg.controlPlane?.enabled === true,
|
|
48
|
+
statePath: expandHome(String(cfg.controlPlane?.statePath ?? "~/.atmem/control-plane.json")),
|
|
49
|
+
blackboxEnabled: cfg.controlPlane?.enabled === true || cfg.controlPlane?.blackboxEnabled === true,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
command: String(cfg.command ?? "atmem"),
|
|
53
|
+
commandArgs: controlPlane.enabled
|
|
54
|
+
? ["control", "mcp", "--state", controlPlane.statePath]
|
|
55
|
+
: Array.isArray(cfg.commandArgs)
|
|
56
|
+
? cfg.commandArgs.map(String)
|
|
57
|
+
: ["mcp", "--db", dbPath, "--subject", subject],
|
|
58
|
+
dbPath,
|
|
59
|
+
subject,
|
|
60
|
+
takeoverActive: cfg.takeoverActive === true,
|
|
61
|
+
nativeWorkspace: expandHome(String(cfg.nativeWorkspace ?? "")),
|
|
62
|
+
recall: {
|
|
63
|
+
enabled: cfg.recall?.enabled !== false,
|
|
64
|
+
maxRecords: Number(cfg.recall?.maxRecords ?? 3),
|
|
65
|
+
maxChars: Number(cfg.recall?.maxChars ?? 1200),
|
|
66
|
+
minScore: Number(cfg.recall?.minScore ?? 0.3),
|
|
67
|
+
timeoutMs: Number(cfg.recall?.timeoutMs ?? 4000),
|
|
68
|
+
},
|
|
69
|
+
persona: {
|
|
70
|
+
enabled: cfg.persona?.enabled !== false,
|
|
71
|
+
maxChars: Number(cfg.persona?.maxChars ?? 600),
|
|
72
|
+
ttlSeconds: Number(cfg.persona?.ttlSeconds ?? 300),
|
|
73
|
+
},
|
|
74
|
+
capture: {
|
|
75
|
+
enabled: cfg.capture?.enabled !== false,
|
|
76
|
+
captureAssistant: cfg.capture?.captureAssistant !== false,
|
|
77
|
+
},
|
|
78
|
+
cacheAware: {
|
|
79
|
+
enabled: cfg.cacheAware?.enabled === true,
|
|
80
|
+
compactReferences: cfg.cacheAware?.compactReferences !== false,
|
|
81
|
+
},
|
|
82
|
+
tools: { enabled: cfg.tools?.enabled !== false },
|
|
83
|
+
controlPlane,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const FILE_TOOL_HINTS = [
|
|
87
|
+
"bash", "shell", "exec", "write", "edit", "patch", "file", "filesystem",
|
|
88
|
+
"read", "delete", "move", "copy", "search",
|
|
89
|
+
];
|
|
90
|
+
function isFileLikeTool(toolName) {
|
|
91
|
+
const normalized = toolName.toLowerCase();
|
|
92
|
+
return FILE_TOOL_HINTS.some((hint) => normalized.includes(hint));
|
|
93
|
+
}
|
|
94
|
+
function isProtectedNativePath(candidate, workspace) {
|
|
95
|
+
if (!candidate || !workspace)
|
|
96
|
+
return false;
|
|
97
|
+
const root = path.resolve(workspace);
|
|
98
|
+
const resolved = path.isAbsolute(candidate)
|
|
99
|
+
? path.resolve(candidate)
|
|
100
|
+
: path.resolve(root, candidate);
|
|
101
|
+
const memoryFile = path.join(root, "MEMORY.md");
|
|
102
|
+
const memoryDir = path.join(root, "memory");
|
|
103
|
+
return (resolved === memoryFile ||
|
|
104
|
+
resolved === memoryDir ||
|
|
105
|
+
resolved.startsWith(memoryDir + path.sep));
|
|
106
|
+
}
|
|
107
|
+
function collectStrings(value, output = []) {
|
|
108
|
+
if (typeof value === "string")
|
|
109
|
+
output.push(value);
|
|
110
|
+
else if (Array.isArray(value)) {
|
|
111
|
+
for (const item of value)
|
|
112
|
+
collectStrings(item, output);
|
|
113
|
+
}
|
|
114
|
+
else if (value && typeof value === "object") {
|
|
115
|
+
for (const item of Object.values(value)) {
|
|
116
|
+
collectStrings(item, output);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return output;
|
|
120
|
+
}
|
|
121
|
+
function commandMentionsNativeMemory(command, workspace) {
|
|
122
|
+
const normalizedWorkspace = path.resolve(workspace);
|
|
123
|
+
if (command.includes(path.join(normalizedWorkspace, "MEMORY.md")))
|
|
124
|
+
return true;
|
|
125
|
+
if (command.includes(path.join(normalizedWorkspace, "memory")))
|
|
126
|
+
return true;
|
|
127
|
+
// OpenClaw's agent tools normally execute relative paths from its workspace.
|
|
128
|
+
// Match path tokens, not prose such as "tell me about memory".
|
|
129
|
+
return /(?:^|[\s'"`=;|&:(])(?:\.\/)?MEMORY\.md(?=$|[\s'"`;|&:)])/i.test(command) ||
|
|
130
|
+
/(?:^|[\s'"`=;|&:(])(?:\.\/)?memory(?:\/[^\s'"`;|&)]*)?(?=$|[\s'"`;|&:)])/i.test(command);
|
|
131
|
+
}
|
|
132
|
+
function touchesNativeMemory(event, workspace) {
|
|
133
|
+
if (!workspace)
|
|
134
|
+
return false;
|
|
135
|
+
if ((event.derivedPaths ?? []).some((candidate) => isProtectedNativePath(candidate, workspace)))
|
|
136
|
+
return true;
|
|
137
|
+
if (!isFileLikeTool(event.toolName))
|
|
138
|
+
return false;
|
|
139
|
+
const params = event.params ?? {};
|
|
140
|
+
const cwdValue = typeof params.cwd === "string" ? params.cwd : workspace;
|
|
141
|
+
const cwd = path.isAbsolute(cwdValue)
|
|
142
|
+
? path.resolve(cwdValue)
|
|
143
|
+
: path.resolve(workspace, cwdValue);
|
|
144
|
+
const workspaceRoot = path.resolve(workspace);
|
|
145
|
+
const runsInWorkspace = cwd === workspaceRoot || cwd.startsWith(workspaceRoot + path.sep);
|
|
146
|
+
for (const value of collectStrings(params)) {
|
|
147
|
+
if (path.isAbsolute(value) && isProtectedNativePath(value, workspace))
|
|
148
|
+
return true;
|
|
149
|
+
if (runsInWorkspace && isProtectedNativePath(value, workspace))
|
|
150
|
+
return true;
|
|
151
|
+
if (runsInWorkspace && commandMentionsNativeMemory(value, workspace))
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
function expandHome(filePath) {
|
|
157
|
+
return filePath.startsWith("~")
|
|
158
|
+
? path.join(os.homedir(), filePath.slice(1))
|
|
159
|
+
: filePath;
|
|
160
|
+
}
|
|
161
|
+
/** Extract plain text from an OpenClaw message content shape. */
|
|
162
|
+
function messageText(content) {
|
|
163
|
+
if (typeof content === "string")
|
|
164
|
+
return content;
|
|
165
|
+
if (Array.isArray(content)) {
|
|
166
|
+
return content
|
|
167
|
+
.map((part) => part && typeof part === "object" && part.type === "text"
|
|
168
|
+
? String(part.text ?? "")
|
|
169
|
+
: "")
|
|
170
|
+
.join("");
|
|
171
|
+
}
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
function attachmentBindingPath(root, key) {
|
|
175
|
+
return path.join(root, `${createHash("sha256").update(key).digest("hex")}.json`);
|
|
176
|
+
}
|
|
177
|
+
function validAttachmentEvidence(value) {
|
|
178
|
+
if (!value || typeof value !== "object")
|
|
179
|
+
return false;
|
|
180
|
+
const row = value;
|
|
181
|
+
return (typeof row.mediaSha256 === "string" && /^[a-f0-9]{64}$/.test(row.mediaSha256) &&
|
|
182
|
+
typeof row.hostReference === "string" && row.hostReference.length > 0 &&
|
|
183
|
+
["image", "audio", "video", "document"].includes(String(row.modality)) &&
|
|
184
|
+
typeof row.mimeType === "string" &&
|
|
185
|
+
typeof row.bytes === "number" && Number.isSafeInteger(row.bytes) && row.bytes >= 0 &&
|
|
186
|
+
typeof row.capturedAt === "number" && Number.isFinite(row.capturedAt));
|
|
187
|
+
}
|
|
188
|
+
async function writeAttachmentBinding(root, key, items) {
|
|
189
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
190
|
+
const target = attachmentBindingPath(root, key);
|
|
191
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
192
|
+
const payload = {
|
|
193
|
+
format: "atmem-openclaw-attachment-binding-v1",
|
|
194
|
+
keySha256: createHash("sha256").update(key).digest("hex"),
|
|
195
|
+
items,
|
|
196
|
+
updatedAt: Date.now(),
|
|
197
|
+
};
|
|
198
|
+
await writeFile(temporary, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
|
|
199
|
+
try {
|
|
200
|
+
await rename(temporary, target);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
await unlink(temporary).catch(() => undefined);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function readAttachmentBinding(root, key) {
|
|
208
|
+
try {
|
|
209
|
+
const decoded = JSON.parse(await readFile(attachmentBindingPath(root, key), "utf8"));
|
|
210
|
+
if (decoded.format !== "atmem-openclaw-attachment-binding-v1" ||
|
|
211
|
+
decoded.keySha256 !== createHash("sha256").update(key).digest("hex") ||
|
|
212
|
+
typeof decoded.updatedAt !== "number" ||
|
|
213
|
+
Date.now() - decoded.updatedAt > PROMPT_CACHE_TTL_MS ||
|
|
214
|
+
!Array.isArray(decoded.items) ||
|
|
215
|
+
!decoded.items.every(validAttachmentEvidence))
|
|
216
|
+
return null;
|
|
217
|
+
return { items: decoded.items, ts: decoded.updatedAt };
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function modalityFromMime(mimeType) {
|
|
224
|
+
const mime = mimeType.toLowerCase();
|
|
225
|
+
if (mime.startsWith("image/"))
|
|
226
|
+
return "image";
|
|
227
|
+
if (mime.startsWith("audio/"))
|
|
228
|
+
return "audio";
|
|
229
|
+
if (mime.startsWith("video/"))
|
|
230
|
+
return "video";
|
|
231
|
+
if (mime === "application/pdf" || mime.startsWith("text/") || mime.includes("document")) {
|
|
232
|
+
return "document";
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function withinPath(candidate, root) {
|
|
237
|
+
return candidate === root || candidate.startsWith(root + path.sep);
|
|
238
|
+
}
|
|
239
|
+
async function hashInboundAttachment(filePath, mimeType) {
|
|
240
|
+
const mediaRoot = await realpath(process.env.ATMEM_OPENCLAW_MEDIA_ROOT
|
|
241
|
+
? expandHome(process.env.ATMEM_OPENCLAW_MEDIA_ROOT)
|
|
242
|
+
: path.join(os.homedir(), ".openclaw", "media"));
|
|
243
|
+
const resolved = await realpath(expandHome(filePath));
|
|
244
|
+
if (!withinPath(resolved, mediaRoot)) {
|
|
245
|
+
throw new Error("OpenClaw attachment path is outside the managed media directory");
|
|
246
|
+
}
|
|
247
|
+
const before = await lstat(resolved);
|
|
248
|
+
if (!before.isFile() || before.size > 512 * 1024 * 1024) {
|
|
249
|
+
throw new Error("OpenClaw attachment is not a bounded regular file");
|
|
250
|
+
}
|
|
251
|
+
const hash = createHash("sha256");
|
|
252
|
+
for await (const chunk of createReadStream(resolved))
|
|
253
|
+
hash.update(chunk);
|
|
254
|
+
const after = await lstat(resolved);
|
|
255
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
256
|
+
throw new Error("OpenClaw attachment changed while its digest was computed");
|
|
257
|
+
}
|
|
258
|
+
const mediaSha256 = hash.digest("hex");
|
|
259
|
+
const modality = modalityFromMime(mimeType);
|
|
260
|
+
if (!modality)
|
|
261
|
+
throw new Error(`unsupported attachment MIME type: ${mimeType || "unknown"}`);
|
|
262
|
+
return {
|
|
263
|
+
mediaSha256,
|
|
264
|
+
hostReference: `openclaw-media://sha256/${mediaSha256}`,
|
|
265
|
+
modality,
|
|
266
|
+
mimeType,
|
|
267
|
+
bytes: before.size,
|
|
268
|
+
capturedAt: Date.now(),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function recordIdFromPath(value) {
|
|
272
|
+
const prefix = "atmem://record/";
|
|
273
|
+
if (!value.startsWith(prefix))
|
|
274
|
+
return null;
|
|
275
|
+
const recordId = value.slice(prefix.length).trim();
|
|
276
|
+
return recordId || null;
|
|
277
|
+
}
|
|
278
|
+
function register(api) {
|
|
279
|
+
const cfg = parseConfig(api.pluginConfig);
|
|
280
|
+
const attachmentBindingRoot = path.join(path.dirname(cfg.controlPlane.enabled ? cfg.controlPlane.statePath : cfg.dbPath), "openclaw-attachment-bindings");
|
|
281
|
+
const client = new AtmemClient({
|
|
282
|
+
command: cfg.command,
|
|
283
|
+
args: cfg.commandArgs,
|
|
284
|
+
log: (message) => api.logger.debug?.(`${TAG} ${message}`),
|
|
285
|
+
logError: (message) => api.logger.warn(`${TAG} ${message}`),
|
|
286
|
+
});
|
|
287
|
+
const blackboxClient = cfg.controlPlane.enabled
|
|
288
|
+
? client
|
|
289
|
+
: new AtmemClient({
|
|
290
|
+
command: cfg.command,
|
|
291
|
+
args: ["control", "mcp", "--state", cfg.controlPlane.statePath],
|
|
292
|
+
log: (message) => api.logger.debug?.(`${TAG} ${message}`),
|
|
293
|
+
logError: (message) => api.logger.warn(`${TAG} ${message}`),
|
|
294
|
+
});
|
|
295
|
+
// Let long-lived hosts close the stdio child during lifecycle shutdown.
|
|
296
|
+
// The client's bounded idle shutdown also covers one-shot local runners.
|
|
297
|
+
api.registerService?.({
|
|
298
|
+
id: "memory-atmem-mcp",
|
|
299
|
+
start: () => client.connect(),
|
|
300
|
+
stop: () => client.close(),
|
|
301
|
+
});
|
|
302
|
+
if (blackboxClient !== client && cfg.controlPlane.blackboxEnabled) {
|
|
303
|
+
api.registerService?.({
|
|
304
|
+
id: "memory-atmem-blackbox",
|
|
305
|
+
start: () => blackboxClient.connect(),
|
|
306
|
+
stop: () => blackboxClient.close(),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
// Per-turn recall state. Semantic admission uses a short-lived SQLite handoff
|
|
310
|
+
// because OpenClaw may run prompt hooks and agent tools in separate runtimes.
|
|
311
|
+
const pendingPrompts = new Map();
|
|
312
|
+
const inboundAttachments = new Map();
|
|
313
|
+
const inboundAttachmentGeneration = new Map();
|
|
314
|
+
let nextAttachmentGeneration = 0;
|
|
315
|
+
const contextIds = (ctx) => [...new Set([ctx.runId, ctx.sessionKey, ctx.sessionId].filter((value) => Boolean(value)))];
|
|
316
|
+
const digestText = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
317
|
+
const stableValue = (value) => {
|
|
318
|
+
if (Array.isArray(value))
|
|
319
|
+
return value.map(stableValue);
|
|
320
|
+
if (value && typeof value === "object") {
|
|
321
|
+
return Object.fromEntries(Object.entries(value)
|
|
322
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
323
|
+
.map(([key, item]) => [key, stableValue(item)]));
|
|
324
|
+
}
|
|
325
|
+
return value;
|
|
326
|
+
};
|
|
327
|
+
const digestJson = (value) => {
|
|
328
|
+
try {
|
|
329
|
+
return digestText(JSON.stringify(stableValue(value)));
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return digestText(String(value));
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const flightRunId = (eventRunId, ctx) => eventRunId ?? ctx.runId ?? ctx.sessionKey ?? ctx.sessionId ?? "unidentified-run";
|
|
336
|
+
const recordBlackbox = async (eventType, eventRunId, ctx, payload, toolCallId) => {
|
|
337
|
+
if (!cfg.controlPlane.blackboxEnabled)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
await blackboxClient.callTool("control_record_blackbox_event", {
|
|
341
|
+
event_type: eventType,
|
|
342
|
+
run_id: flightRunId(eventRunId, ctx),
|
|
343
|
+
session_id: ctx.sessionId ?? ctx.sessionKey,
|
|
344
|
+
tool_call_id: toolCallId,
|
|
345
|
+
payload,
|
|
346
|
+
}, cfg.recall.timeoutMs);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
api.logger.warn(`${TAG} blackbox event ${eventType} was not recorded: ${error instanceof Error ? error.message : String(error)}`);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const resolveInboundAttachmentSet = async (ctx) => {
|
|
353
|
+
// A run id identifies one exact user turn. When OpenClaw supplies it, do
|
|
354
|
+
// not fall back to a session binding that could belong to the prior turn.
|
|
355
|
+
const ids = ctx.runId ? [ctx.runId] : contextIds(ctx);
|
|
356
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
357
|
+
const memory = ids.map((key) => inboundAttachments.get(key)).find((value) => value && value.items.length > 0);
|
|
358
|
+
if (memory)
|
|
359
|
+
return memory;
|
|
360
|
+
for (const key of ids) {
|
|
361
|
+
const durable = await readAttachmentBinding(attachmentBindingRoot, key);
|
|
362
|
+
if (durable?.items.length)
|
|
363
|
+
return durable;
|
|
364
|
+
}
|
|
365
|
+
if (attempt < 19)
|
|
366
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
};
|
|
370
|
+
const stageInbound = async (text, ctx) => {
|
|
371
|
+
const ids = contextIds(ctx);
|
|
372
|
+
if (!ids.length)
|
|
373
|
+
return;
|
|
374
|
+
await client.callTool("memory_stage_user_message", {
|
|
375
|
+
message: text.trim(),
|
|
376
|
+
source_aliases: ids,
|
|
377
|
+
run_id: ctx.runId,
|
|
378
|
+
ttl_seconds: 600,
|
|
379
|
+
}, cfg.recall.timeoutMs);
|
|
380
|
+
};
|
|
381
|
+
const bindInboundAttachments = async (keys, paths, types) => {
|
|
382
|
+
const generation = ++nextAttachmentGeneration;
|
|
383
|
+
for (const key of keys) {
|
|
384
|
+
inboundAttachments.delete(key);
|
|
385
|
+
inboundAttachmentGeneration.set(key, generation);
|
|
386
|
+
}
|
|
387
|
+
await Promise.all(keys.map((key) => writeAttachmentBinding(attachmentBindingRoot, key, [])));
|
|
388
|
+
if (!paths.length)
|
|
389
|
+
return;
|
|
390
|
+
const items = await Promise.all(paths.map((filePath, index) => hashInboundAttachment(filePath, types[index] ?? types[0] ?? "application/octet-stream")));
|
|
391
|
+
for (const key of keys) {
|
|
392
|
+
if (inboundAttachmentGeneration.get(key) === generation) {
|
|
393
|
+
inboundAttachments.set(key, { items, ts: Date.now() });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
await Promise.all(keys.map((key) => inboundAttachmentGeneration.get(key) === generation
|
|
397
|
+
? writeAttachmentBinding(attachmentBindingRoot, key, items)
|
|
398
|
+
: Promise.resolve()));
|
|
399
|
+
api.logger.info(`${TAG} bound ${items.length} inbound attachment digest(s) to the turn`);
|
|
400
|
+
};
|
|
401
|
+
const writtenMessageAttachmentFields = (message) => {
|
|
402
|
+
const rawPaths = message.MediaPaths ?? message.mediaPaths;
|
|
403
|
+
const rawPath = message.MediaPath ?? message.mediaPath;
|
|
404
|
+
const paths = Array.isArray(rawPaths)
|
|
405
|
+
? rawPaths.filter((value) => typeof value === "string")
|
|
406
|
+
: typeof rawPath === "string"
|
|
407
|
+
? [rawPath]
|
|
408
|
+
: [];
|
|
409
|
+
const rawTypes = message.MediaTypes ?? message.mediaTypes;
|
|
410
|
+
const rawType = message.MediaType ?? message.mediaType;
|
|
411
|
+
const types = Array.isArray(rawTypes)
|
|
412
|
+
? rawTypes.filter((value) => typeof value === "string")
|
|
413
|
+
: typeof rawType === "string"
|
|
414
|
+
? [rawType]
|
|
415
|
+
: [];
|
|
416
|
+
return { paths, types };
|
|
417
|
+
};
|
|
418
|
+
const writtenMessageBindingKeys = (message, ctx) => {
|
|
419
|
+
const idempotencyKey = typeof message.idempotencyKey === "string"
|
|
420
|
+
? message.idempotencyKey.trim()
|
|
421
|
+
: "";
|
|
422
|
+
const turnId = idempotencyKey.endsWith(":user")
|
|
423
|
+
? idempotencyKey.slice(0, -":user".length)
|
|
424
|
+
: idempotencyKey;
|
|
425
|
+
return [...new Set([
|
|
426
|
+
turnId,
|
|
427
|
+
idempotencyKey,
|
|
428
|
+
...contextIds(ctx),
|
|
429
|
+
].filter((value) => Boolean(value)))];
|
|
430
|
+
};
|
|
431
|
+
api.on("message_received", async (event, ctx) => {
|
|
432
|
+
const metadata = event.metadata ?? {};
|
|
433
|
+
const paths = Array.isArray(metadata.mediaPaths)
|
|
434
|
+
? metadata.mediaPaths.filter((value) => typeof value === "string")
|
|
435
|
+
: typeof metadata.mediaPath === "string"
|
|
436
|
+
? [metadata.mediaPath]
|
|
437
|
+
: [];
|
|
438
|
+
const types = Array.isArray(metadata.mediaTypes)
|
|
439
|
+
? metadata.mediaTypes.filter((value) => typeof value === "string")
|
|
440
|
+
: typeof metadata.mediaType === "string"
|
|
441
|
+
? [metadata.mediaType]
|
|
442
|
+
: [];
|
|
443
|
+
const keys = [...new Set([
|
|
444
|
+
event.sessionKey,
|
|
445
|
+
event.runId,
|
|
446
|
+
...contextIds(ctx),
|
|
447
|
+
].filter((value) => Boolean(value)))];
|
|
448
|
+
try {
|
|
449
|
+
await bindInboundAttachments(keys, paths, types);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
api.logger.warn(`${TAG} inbound attachment provenance unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
// OpenClaw documents this as the current prompt before model selection. It
|
|
456
|
+
// is a typed per-turn input surface, not a rendered transcript or history.
|
|
457
|
+
api.on("before_model_resolve", async (event, ctx) => {
|
|
458
|
+
if (!event.prompt?.trim())
|
|
459
|
+
return;
|
|
460
|
+
await recordBlackbox("turn.input", undefined, ctx, {
|
|
461
|
+
prompt_sha256: digestText(event.prompt),
|
|
462
|
+
prompt_chars: event.prompt.length,
|
|
463
|
+
images_count: Array.isArray(event.attachments) ? event.attachments.length : 0,
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
await stageInbound(event.prompt, ctx);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
api.logger.warn(`${TAG} semantic source handoff unavailable; memory writes will fail closed: ${error instanceof Error ? error.message : String(error)}`);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
api.on("llm_input", async (event, ctx) => {
|
|
473
|
+
await recordBlackbox("model.input", event.runId, ctx, {
|
|
474
|
+
provider: event.provider,
|
|
475
|
+
model: event.model,
|
|
476
|
+
prompt_sha256: digestText(event.prompt ?? ""),
|
|
477
|
+
prompt_chars: (event.prompt ?? "").length,
|
|
478
|
+
system_sha256: digestText(event.systemPrompt ?? ""),
|
|
479
|
+
system_chars: (event.systemPrompt ?? "").length,
|
|
480
|
+
history_sha256: digestJson(event.historyMessages ?? []),
|
|
481
|
+
history_count: Array.isArray(event.historyMessages) ? event.historyMessages.length : 0,
|
|
482
|
+
images_count: event.imagesCount ?? 0,
|
|
483
|
+
tools_count: Array.isArray(event.tools) ? event.tools.length : 0,
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
api.on("llm_output", async (event, ctx) => {
|
|
487
|
+
const responses = Array.isArray(event.assistantTexts) ? event.assistantTexts : [];
|
|
488
|
+
await recordBlackbox("model.output", event.runId, ctx, {
|
|
489
|
+
provider: event.provider,
|
|
490
|
+
model: event.model,
|
|
491
|
+
resolved_ref: event.resolvedRef,
|
|
492
|
+
harness_id: event.harnessId,
|
|
493
|
+
response_sha256: digestJson(responses),
|
|
494
|
+
response_chars: responses.reduce((total, value) => total + String(value).length, 0),
|
|
495
|
+
response_count: responses.length,
|
|
496
|
+
usage: event.usage ?? {},
|
|
497
|
+
reasoning_effort: event.reasoningEffort,
|
|
498
|
+
fast_mode: event.fastMode,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
api.registerCli?.(({ program }) => {
|
|
502
|
+
const root = program
|
|
503
|
+
.command("atmem")
|
|
504
|
+
.description("Configure and inspect AtMem for OpenClaw");
|
|
505
|
+
root
|
|
506
|
+
.command("dashboard")
|
|
507
|
+
.description("Open the authenticated AtMem dashboard in your browser")
|
|
508
|
+
.action(() => {
|
|
509
|
+
const runDashboard = (action) => spawnSync(cfg.command, ["dashboard", "daemon", action], { stdio: "inherit", env: process.env });
|
|
510
|
+
let result = runDashboard("open");
|
|
511
|
+
if (result.error)
|
|
512
|
+
throw result.error;
|
|
513
|
+
if (result.status !== 0) {
|
|
514
|
+
const started = runDashboard("start");
|
|
515
|
+
if (started.error)
|
|
516
|
+
throw started.error;
|
|
517
|
+
if (started.status !== 0) {
|
|
518
|
+
throw new Error("AtMem dashboard could not be started; run `atmem dashboard daemon status` for details");
|
|
519
|
+
}
|
|
520
|
+
result = runDashboard("open");
|
|
521
|
+
if (result.error)
|
|
522
|
+
throw result.error;
|
|
523
|
+
}
|
|
524
|
+
if (result.status !== 0) {
|
|
525
|
+
throw new Error("AtMem dashboard could not be opened; run `atmem dashboard daemon status` for the protected URL");
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
root
|
|
529
|
+
.command("setup")
|
|
530
|
+
.description("Apply safe single-user defaults and enable automatic memory hooks")
|
|
531
|
+
.option("--single-user", "Acknowledge this plugin instance has one memory subject")
|
|
532
|
+
.option("--subject <id>", "Stable single-user memory subject", "you")
|
|
533
|
+
.option("--command <path>", "AtMem executable", cfg.command)
|
|
534
|
+
.option("--db-path <path>", "AtMem SQLite database", cfg.dbPath)
|
|
535
|
+
.option("--no-restart", "Do not restart the OpenClaw gateway")
|
|
536
|
+
.action(async (options) => {
|
|
537
|
+
await runSetup({
|
|
538
|
+
subject: String(options.subject),
|
|
539
|
+
command: String(options.command),
|
|
540
|
+
dbPath: String(options.dbPath),
|
|
541
|
+
restart: options.restart !== false,
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}, { commands: ["atmem"] });
|
|
545
|
+
// L3 persona cache: rebuilt on TTL expiry and invalidated when capture
|
|
546
|
+
// writes new memory, so the snapshot never lags a correction.
|
|
547
|
+
let personaCache = null;
|
|
548
|
+
const sweep = () => {
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
for (const [key, value] of pendingPrompts) {
|
|
551
|
+
if (now - value.ts > PROMPT_CACHE_TTL_MS)
|
|
552
|
+
pendingPrompts.delete(key);
|
|
553
|
+
}
|
|
554
|
+
for (const [key, value] of inboundAttachments) {
|
|
555
|
+
if (now - value.ts > PROMPT_CACHE_TTL_MS)
|
|
556
|
+
inboundAttachments.delete(key);
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
async function personaBlock(sessionKey) {
|
|
560
|
+
if (!cfg.persona.enabled)
|
|
561
|
+
return "";
|
|
562
|
+
const now = Date.now();
|
|
563
|
+
if (personaCache && now - personaCache.ts < cfg.persona.ttlSeconds * 1000) {
|
|
564
|
+
return personaCache.block;
|
|
565
|
+
}
|
|
566
|
+
const result = (await client.callTool("memory_persona", {
|
|
567
|
+
session_id: sessionKey,
|
|
568
|
+
max_chars: cfg.persona.maxChars,
|
|
569
|
+
reference_mode: cfg.cacheAware.enabled && cfg.cacheAware.compactReferences
|
|
570
|
+
? "compact"
|
|
571
|
+
: "full",
|
|
572
|
+
}, cfg.recall.timeoutMs));
|
|
573
|
+
personaCache = { block: result?.block ?? "", ts: now };
|
|
574
|
+
return personaCache.block;
|
|
575
|
+
}
|
|
576
|
+
// ---- auto-recall: persona + bounded, audited recall injection ---------
|
|
577
|
+
api.on("before_prompt_build", async (event, ctx) => {
|
|
578
|
+
const userText = event.prompt;
|
|
579
|
+
if (!userText)
|
|
580
|
+
return;
|
|
581
|
+
const sessionKey = ctx.sessionKey ?? ctx.sessionId ?? "default-session";
|
|
582
|
+
const takeoverGuidance = cfg.takeoverActive ? TAKEOVER_GUIDANCE : "";
|
|
583
|
+
pendingPrompts.set(sessionKey, { text: userText, ts: Date.now() });
|
|
584
|
+
sweep();
|
|
585
|
+
if (cfg.controlPlane.enabled) {
|
|
586
|
+
try {
|
|
587
|
+
const prepared = (await client.callTool("control_prepare", { query: userText, session_id: sessionKey }, cfg.recall.timeoutMs));
|
|
588
|
+
pendingPrompts.set(sessionKey, {
|
|
589
|
+
text: userText,
|
|
590
|
+
ts: Date.now(),
|
|
591
|
+
exposureId: prepared.exposure_id,
|
|
592
|
+
});
|
|
593
|
+
await recordBlackbox(prepared.inject && prepared.context ? "context.injected" : "context.prepared", undefined, ctx, {
|
|
594
|
+
context_sha256: digestText(prepared.context ?? ""),
|
|
595
|
+
context_chars: (prepared.context ?? "").length,
|
|
596
|
+
candidate_ids: prepared.candidate_ids ?? [],
|
|
597
|
+
exposure_id: prepared.exposure_id,
|
|
598
|
+
mode: prepared.mode,
|
|
599
|
+
});
|
|
600
|
+
if (prepared.inject && prepared.context) {
|
|
601
|
+
api.logger.info(`${TAG} memory control plane ${prepared.mode ?? "active"} context exposed`);
|
|
602
|
+
return { appendContext: prepared.context };
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
api.logger.warn(`${TAG} memory control plane failed closed: ${error instanceof Error ? error.message : String(error)}`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!cfg.recall.enabled && !cfg.persona.enabled) {
|
|
612
|
+
if (takeoverGuidance)
|
|
613
|
+
return { appendSystemContext: takeoverGuidance };
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
let persona = "";
|
|
617
|
+
let recall = "";
|
|
618
|
+
try {
|
|
619
|
+
persona = await personaBlock(sessionKey);
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
api.logger.warn(`${TAG} persona skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
if (cfg.recall.enabled) {
|
|
626
|
+
const result = (await client.callTool("memory_recall_block", {
|
|
627
|
+
query: userText,
|
|
628
|
+
session_id: sessionKey,
|
|
629
|
+
max_records: cfg.recall.maxRecords,
|
|
630
|
+
max_chars: cfg.recall.maxChars,
|
|
631
|
+
min_score: cfg.recall.minScore,
|
|
632
|
+
reference_mode: cfg.cacheAware.enabled && cfg.cacheAware.compactReferences
|
|
633
|
+
? "compact"
|
|
634
|
+
: "full",
|
|
635
|
+
}, cfg.recall.timeoutMs));
|
|
636
|
+
if (result?.block) {
|
|
637
|
+
api.logger.info(`${TAG} injected ${result.count} memories (${result.block.length} chars)`);
|
|
638
|
+
recall = result.block;
|
|
639
|
+
const current = pendingPrompts.get(sessionKey);
|
|
640
|
+
if (current) {
|
|
641
|
+
pendingPrompts.set(sessionKey, {
|
|
642
|
+
...current,
|
|
643
|
+
injectedRecordIds: result.record_ids ?? [],
|
|
644
|
+
retrievalId: result.retrieval_id,
|
|
645
|
+
contextEventId: result.context_event_id,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
// Never block the turn on recall problems.
|
|
653
|
+
api.logger.warn(`${TAG} auto-recall skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
654
|
+
}
|
|
655
|
+
if (cfg.cacheAware.enabled) {
|
|
656
|
+
const result = {};
|
|
657
|
+
const systemParts = [takeoverGuidance, persona].filter(Boolean);
|
|
658
|
+
if (systemParts.length)
|
|
659
|
+
result.appendSystemContext = systemParts.join("\n\n");
|
|
660
|
+
if (recall)
|
|
661
|
+
result.appendContext = recall;
|
|
662
|
+
if (Object.keys(result).length)
|
|
663
|
+
return result;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const parts = [persona, recall].filter(Boolean);
|
|
667
|
+
const result = {};
|
|
668
|
+
if (parts.length)
|
|
669
|
+
result.prependContext = parts.join("\n\n") + "\n\n";
|
|
670
|
+
if (takeoverGuidance)
|
|
671
|
+
result.appendSystemContext = takeoverGuidance;
|
|
672
|
+
if (Object.keys(result).length)
|
|
673
|
+
return result;
|
|
674
|
+
});
|
|
675
|
+
// ---- flight recorder + takeover enforcement --------------------------
|
|
676
|
+
api.on("before_tool_call", async (event, ctx) => {
|
|
677
|
+
await recordBlackbox("tool.requested", event.runId, ctx, {
|
|
678
|
+
tool_name: event.toolName,
|
|
679
|
+
tool_kind: event.toolKind,
|
|
680
|
+
params_sha256: digestJson(event.params ?? {}),
|
|
681
|
+
param_keys: Object.keys(event.params ?? {}).sort(),
|
|
682
|
+
derived_path_sha256: Array.isArray(event.derivedPaths)
|
|
683
|
+
? event.derivedPaths.map((value) => digestText(String(value)))
|
|
684
|
+
: [],
|
|
685
|
+
}, event.toolCallId);
|
|
686
|
+
if (!cfg.takeoverActive || !touchesNativeMemory(event, cfg.nativeWorkspace))
|
|
687
|
+
return;
|
|
688
|
+
const reason = "AtMem takeover blocked access to OpenClaw's frozen native memory " +
|
|
689
|
+
"(MEMORY.md or memory/*). Use memory_remember for durable user facts, " +
|
|
690
|
+
"and memory_search or memory_get for recall.";
|
|
691
|
+
await recordBlackbox("tool.completed", event.runId, ctx, {
|
|
692
|
+
tool_name: event.toolName,
|
|
693
|
+
outcome: "error",
|
|
694
|
+
error_category: "blocked_by_memory_boundary",
|
|
695
|
+
result_sha256: digestText(reason),
|
|
696
|
+
duration_ms: 0,
|
|
697
|
+
}, event.toolCallId);
|
|
698
|
+
api.logger.warn(`${TAG} ${reason} Tool: ${event.toolName}`);
|
|
699
|
+
return { block: true, blockReason: reason };
|
|
700
|
+
});
|
|
701
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
702
|
+
await recordBlackbox("tool.completed", event.runId, ctx, {
|
|
703
|
+
tool_name: event.toolName,
|
|
704
|
+
result_sha256: digestJson(event.result ?? null),
|
|
705
|
+
outcome: event.error ? "error" : "completed",
|
|
706
|
+
error_category: event.error ? "tool_error" : undefined,
|
|
707
|
+
duration_ms: event.durationMs ?? 0,
|
|
708
|
+
}, event.toolCallId);
|
|
709
|
+
});
|
|
710
|
+
// ---- auto-capture: user turn through the pipeline, assistant as digest -
|
|
711
|
+
api.on("agent_end", async (event, ctx) => {
|
|
712
|
+
const sessionKey = ctx.sessionKey ?? ctx.sessionId ?? "default-session";
|
|
713
|
+
const cached = pendingPrompts.get(sessionKey);
|
|
714
|
+
pendingPrompts.delete(sessionKey);
|
|
715
|
+
const userText = cached?.text?.replace(INJECT_RE, "").trim();
|
|
716
|
+
try {
|
|
717
|
+
if (cfg.takeoverActive) {
|
|
718
|
+
await client.callTool("memory_clear_user_message", { source_aliases: contextIds(ctx) }, cfg.recall.timeoutMs);
|
|
719
|
+
}
|
|
720
|
+
if (cfg.controlPlane.enabled) {
|
|
721
|
+
if (cached?.exposureId) {
|
|
722
|
+
await client.callTool("control_exposure_shown", { exposure_id: cached.exposureId }, cfg.recall.timeoutMs);
|
|
723
|
+
}
|
|
724
|
+
if (event.success !== false) {
|
|
725
|
+
await client.callTool("control_sync_openclaw_memory", {}, cfg.recall.timeoutMs);
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (cfg.takeoverActive && cached?.injectedRecordIds?.length) {
|
|
730
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
731
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
732
|
+
const message = messages[index];
|
|
733
|
+
if (message?.role !== "assistant")
|
|
734
|
+
continue;
|
|
735
|
+
const responseText = messageText(message.content);
|
|
736
|
+
if (responseText) {
|
|
737
|
+
await client.callTool("memory_log_action", {
|
|
738
|
+
action_type: "agent.response_after_memory",
|
|
739
|
+
payload: {
|
|
740
|
+
response_sha256: createHash("sha256")
|
|
741
|
+
.update(responseText, "utf8")
|
|
742
|
+
.digest("hex"),
|
|
743
|
+
injected_record_ids: cached.injectedRecordIds,
|
|
744
|
+
retrieval_id: cached.retrievalId,
|
|
745
|
+
context_event_id: cached.contextEventId,
|
|
746
|
+
response_content_stored: false,
|
|
747
|
+
success: event.success !== false,
|
|
748
|
+
},
|
|
749
|
+
session_id: sessionKey,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (cfg.capture.enabled &&
|
|
756
|
+
!cfg.takeoverActive &&
|
|
757
|
+
event.success !== false &&
|
|
758
|
+
userText) {
|
|
759
|
+
await client.callTool("memory_capture", {
|
|
760
|
+
role: "user",
|
|
761
|
+
content: userText,
|
|
762
|
+
session_id: sessionKey,
|
|
763
|
+
});
|
|
764
|
+
personaCache = null; // new memory may change the persona
|
|
765
|
+
}
|
|
766
|
+
if (cfg.capture.enabled &&
|
|
767
|
+
!cfg.takeoverActive &&
|
|
768
|
+
event.success !== false &&
|
|
769
|
+
cfg.capture.captureAssistant) {
|
|
770
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
771
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
772
|
+
const message = messages[index];
|
|
773
|
+
if (message?.role === "assistant") {
|
|
774
|
+
const text = messageText(message.content);
|
|
775
|
+
if (text) {
|
|
776
|
+
await client.callTool("memory_capture", {
|
|
777
|
+
role: "assistant",
|
|
778
|
+
content: text,
|
|
779
|
+
session_id: sessionKey,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
api.logger.warn(`${TAG} auto-capture failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
await recordBlackbox("turn.ended", event.runId, ctx, {
|
|
792
|
+
success: event.success !== false,
|
|
793
|
+
cancelled: false,
|
|
794
|
+
error_category: event.error ? "agent_error" : undefined,
|
|
795
|
+
duration_ms: event.durationMs ?? 0,
|
|
796
|
+
messages_sha256: digestJson(event.messages ?? []),
|
|
797
|
+
messages_count: Array.isArray(event.messages) ? event.messages.length : 0,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
// ---- keep injected blocks out of persisted history ---------------------
|
|
802
|
+
api.on("before_message_write", (event, ctx = {}) => {
|
|
803
|
+
const message = event.message;
|
|
804
|
+
if (message.role !== "user")
|
|
805
|
+
return;
|
|
806
|
+
// Internal OpenClaw webchat persists the host-managed MediaPath on the
|
|
807
|
+
// current user message before the model can call a tool. Earlier prompt
|
|
808
|
+
// hooks intentionally omit that path. Capture this structured host field
|
|
809
|
+
// here and hash the exact managed bytes asynchronously; never infer a file
|
|
810
|
+
// by scanning the media directory or scraping its name from user text.
|
|
811
|
+
const attachmentFields = writtenMessageAttachmentFields(message);
|
|
812
|
+
const attachmentKeys = writtenMessageBindingKeys(message, ctx);
|
|
813
|
+
if (attachmentKeys.length) {
|
|
814
|
+
void bindInboundAttachments(attachmentKeys, attachmentFields.paths, attachmentFields.types).catch((error) => {
|
|
815
|
+
api.logger.warn(`${TAG} persisted attachment provenance unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
const hasInjection = (text) => text.includes("<relevant_memories>") ||
|
|
819
|
+
text.includes("<user_persona>") ||
|
|
820
|
+
text.includes("<working_memory>") ||
|
|
821
|
+
text.includes("<episodic_memory>") ||
|
|
822
|
+
text.includes("<procedural_memory>") ||
|
|
823
|
+
text.includes("<atmem_control_plane>") ||
|
|
824
|
+
text.includes("<atmem_memory_provider>");
|
|
825
|
+
if (typeof message.content === "string") {
|
|
826
|
+
if (!hasInjection(message.content))
|
|
827
|
+
return;
|
|
828
|
+
const cleaned = message.content.replace(INJECT_RE, "").trim();
|
|
829
|
+
return { message: { ...message, content: cleaned } };
|
|
830
|
+
}
|
|
831
|
+
if (Array.isArray(message.content)) {
|
|
832
|
+
let changed = false;
|
|
833
|
+
const parts = message.content.map((part) => {
|
|
834
|
+
if (part.type !== "text" || typeof part.text !== "string")
|
|
835
|
+
return part;
|
|
836
|
+
if (!hasInjection(part.text))
|
|
837
|
+
return part;
|
|
838
|
+
changed = true;
|
|
839
|
+
return { ...part, text: part.text.replace(INJECT_RE, "").trim() };
|
|
840
|
+
});
|
|
841
|
+
if (changed)
|
|
842
|
+
return { message: { ...message, content: parts } };
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
// ---- agent-callable tools ----------------------------------------------
|
|
846
|
+
if (cfg.tools.enabled && !cfg.controlPlane.enabled) {
|
|
847
|
+
if (cfg.takeoverActive) {
|
|
848
|
+
api.registerTool((toolCtx) => ({
|
|
849
|
+
name: "memory_remember",
|
|
850
|
+
label: "Memory Remember",
|
|
851
|
+
description: "Store one durable fact that you semantically inferred from the current " +
|
|
852
|
+
"authenticated user's own message. Call this for durable preferences, " +
|
|
853
|
+
"facts, constraints, relationships, or explicit remember requests. Never " +
|
|
854
|
+
"use it for quoted/retrieved/tool content or guesses. Only claim success " +
|
|
855
|
+
"when this tool returns stored=true.",
|
|
856
|
+
parameters: {
|
|
857
|
+
type: "object",
|
|
858
|
+
properties: {
|
|
859
|
+
fact: {
|
|
860
|
+
type: "string",
|
|
861
|
+
description: "One concise, standalone fact, e.g. 'User likes blue cars.'",
|
|
862
|
+
},
|
|
863
|
+
factKey: {
|
|
864
|
+
type: "string",
|
|
865
|
+
description: "Optional stable slot for replaceable facts, e.g. favorite_color.",
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
required: ["fact"],
|
|
869
|
+
additionalProperties: false,
|
|
870
|
+
},
|
|
871
|
+
async execute(toolCallId, params) {
|
|
872
|
+
const sessionKey = toolCtx.sessionKey ?? toolCtx.sessionId;
|
|
873
|
+
const sourceAliases = [toolCtx.sessionKey, toolCtx.sessionId]
|
|
874
|
+
.filter((value) => Boolean(value));
|
|
875
|
+
if (!sessionKey || !sourceAliases.length) {
|
|
876
|
+
throw new Error("no current authenticated user message is available; memory was not stored");
|
|
877
|
+
}
|
|
878
|
+
const fact = String(params.fact ?? "").trim();
|
|
879
|
+
if (!fact)
|
|
880
|
+
throw new Error("fact must not be empty");
|
|
881
|
+
const interpreter = toolCtx.activeModel?.modelRef ??
|
|
882
|
+
[toolCtx.activeModel?.provider, toolCtx.activeModel?.modelId]
|
|
883
|
+
.filter(Boolean)
|
|
884
|
+
.join(":") ??
|
|
885
|
+
"openclaw-agent";
|
|
886
|
+
const result = (await client.callTool("memory_remember", {
|
|
887
|
+
source_aliases: sourceAliases,
|
|
888
|
+
interpreted_fact: fact,
|
|
889
|
+
interpreted_fact_key: params.factKey,
|
|
890
|
+
interpreter: interpreter || "openclaw-agent",
|
|
891
|
+
session_id: sessionKey,
|
|
892
|
+
turn_id: toolCallId,
|
|
893
|
+
source_type: "user_message",
|
|
894
|
+
}));
|
|
895
|
+
const record = result.records?.[0];
|
|
896
|
+
const duplicateId = result.duplicate_ids?.[0];
|
|
897
|
+
const stored = Boolean(record || duplicateId);
|
|
898
|
+
if (stored) {
|
|
899
|
+
personaCache = null;
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
content: [{
|
|
903
|
+
type: "text",
|
|
904
|
+
text: JSON.stringify({
|
|
905
|
+
stored,
|
|
906
|
+
record_id: record?.id ?? duplicateId ?? null,
|
|
907
|
+
fact: record?.content ?? fact,
|
|
908
|
+
status: record?.status ?? (duplicateId ? "already_stored" : null),
|
|
909
|
+
provider: "atmem",
|
|
910
|
+
receipt: stored ? "audit-bound" : "none",
|
|
911
|
+
}),
|
|
912
|
+
}],
|
|
913
|
+
details: { stored, recordId: record?.id ?? duplicateId ?? null, sessionKey },
|
|
914
|
+
};
|
|
915
|
+
},
|
|
916
|
+
}), { names: ["memory_remember"] });
|
|
917
|
+
}
|
|
918
|
+
// Preserve OpenClaw's standard memory contract after native memory-core
|
|
919
|
+
// is disabled. Existing agent prompts and workflows can keep using the
|
|
920
|
+
// same tool names; only the governed storage/retrieval implementation
|
|
921
|
+
// changes underneath them.
|
|
922
|
+
api.registerTool({
|
|
923
|
+
name: "memory_search",
|
|
924
|
+
label: "Memory Search",
|
|
925
|
+
description: "Search governed AtMem long-term memory. Compatible with OpenClaw's " +
|
|
926
|
+
"standard memory_search contract. The active takeover supports the " +
|
|
927
|
+
"memory corpus; session and wiki corpora must be migrated explicitly.",
|
|
928
|
+
parameters: {
|
|
929
|
+
type: "object",
|
|
930
|
+
properties: {
|
|
931
|
+
query: { type: "string" },
|
|
932
|
+
maxResults: { type: "integer", minimum: 1 },
|
|
933
|
+
minScore: { type: "number" },
|
|
934
|
+
corpus: {
|
|
935
|
+
type: "string",
|
|
936
|
+
enum: ["memory", "wiki", "all", "sessions"],
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
required: ["query"],
|
|
940
|
+
additionalProperties: false,
|
|
941
|
+
},
|
|
942
|
+
async execute(toolCallId, params) {
|
|
943
|
+
const corpus = String(params.corpus ?? "memory");
|
|
944
|
+
if (corpus === "wiki" || corpus === "sessions") {
|
|
945
|
+
return {
|
|
946
|
+
content: [{
|
|
947
|
+
type: "text",
|
|
948
|
+
text: JSON.stringify({
|
|
949
|
+
results: [],
|
|
950
|
+
disabled: true,
|
|
951
|
+
error: `${corpus} corpus is not enabled in this AtMem takeover`,
|
|
952
|
+
}),
|
|
953
|
+
}],
|
|
954
|
+
details: { count: 0, corpus, disabled: true },
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
const sessionId = `openclaw-memory-search:${toolCallId}`;
|
|
958
|
+
const maxResults = Math.min(Math.max(Number(params.maxResults) || 6, 1), 20);
|
|
959
|
+
const records = (await client.callTool("memory_recall", {
|
|
960
|
+
query: String(params.query ?? ""),
|
|
961
|
+
session_id: sessionId,
|
|
962
|
+
limit: maxResults,
|
|
963
|
+
min_score: params.minScore === undefined
|
|
964
|
+
? cfg.recall.minScore
|
|
965
|
+
: Number(params.minScore),
|
|
966
|
+
include_scores: true,
|
|
967
|
+
}));
|
|
968
|
+
const results = records.map((record) => ({
|
|
969
|
+
path: `atmem://record/${record.id}`,
|
|
970
|
+
startLine: 1,
|
|
971
|
+
endLine: Math.max(record.content.split("\n").length, 1),
|
|
972
|
+
score: Number(record.score ?? 0),
|
|
973
|
+
snippet: record.content,
|
|
974
|
+
source: "atmem",
|
|
975
|
+
corpus: "memory",
|
|
976
|
+
id: record.id,
|
|
977
|
+
sourceType: record.source_type,
|
|
978
|
+
updatedAt: record.created_at,
|
|
979
|
+
citation: `atmem:${record.id}`,
|
|
980
|
+
}));
|
|
981
|
+
return {
|
|
982
|
+
content: [{
|
|
983
|
+
type: "text",
|
|
984
|
+
text: JSON.stringify({
|
|
985
|
+
results,
|
|
986
|
+
provider: "atmem",
|
|
987
|
+
model: "deterministic-record-rank-v1",
|
|
988
|
+
citations: "auto",
|
|
989
|
+
mode: "governed",
|
|
990
|
+
}),
|
|
991
|
+
}],
|
|
992
|
+
details: { count: results.length, corpus, sessionId },
|
|
993
|
+
};
|
|
994
|
+
},
|
|
995
|
+
}, { name: "memory_search" });
|
|
996
|
+
api.registerTool({
|
|
997
|
+
name: "memory_get",
|
|
998
|
+
label: "Memory Get",
|
|
999
|
+
description: "Read one exact governed AtMem record returned by memory_search. " +
|
|
1000
|
+
"The read is bounded and added to the AtMem audit trail.",
|
|
1001
|
+
parameters: {
|
|
1002
|
+
type: "object",
|
|
1003
|
+
properties: {
|
|
1004
|
+
path: { type: "string" },
|
|
1005
|
+
from: { type: "integer", minimum: 1 },
|
|
1006
|
+
lines: { type: "integer", minimum: 1 },
|
|
1007
|
+
corpus: { type: "string", enum: ["memory", "wiki", "all"] },
|
|
1008
|
+
},
|
|
1009
|
+
required: ["path"],
|
|
1010
|
+
additionalProperties: false,
|
|
1011
|
+
},
|
|
1012
|
+
async execute(toolCallId, params) {
|
|
1013
|
+
const lookup = String(params.path ?? "");
|
|
1014
|
+
const recordId = recordIdFromPath(lookup);
|
|
1015
|
+
if (!recordId) {
|
|
1016
|
+
const sessionId = `openclaw-memory-get:${toolCallId}`;
|
|
1017
|
+
const sourceResult = (await client.callTool("memory_get_source", {
|
|
1018
|
+
path: lookup,
|
|
1019
|
+
session_id: sessionId,
|
|
1020
|
+
}));
|
|
1021
|
+
if (sourceResult?.text !== undefined) {
|
|
1022
|
+
const allLines = sourceResult.text.split("\n");
|
|
1023
|
+
const from = Math.max(Number(params.from) || 1, 1);
|
|
1024
|
+
const requested = Math.min(Math.max(Number(params.lines) || 50, 1), 200);
|
|
1025
|
+
const selected = allLines.slice(from - 1, from - 1 + requested);
|
|
1026
|
+
const payload = {
|
|
1027
|
+
path: lookup,
|
|
1028
|
+
text: selected.join("\n"),
|
|
1029
|
+
from,
|
|
1030
|
+
lines: selected.length,
|
|
1031
|
+
totalLines: allLines.length,
|
|
1032
|
+
truncated: from - 1 + selected.length < allLines.length,
|
|
1033
|
+
source: "atmem-frozen-openclaw",
|
|
1034
|
+
provenance: sourceResult.source ?? {},
|
|
1035
|
+
};
|
|
1036
|
+
return {
|
|
1037
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1038
|
+
details: { found: true, sessionId },
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
return {
|
|
1042
|
+
content: [{
|
|
1043
|
+
type: "text",
|
|
1044
|
+
text: JSON.stringify({
|
|
1045
|
+
path: lookup,
|
|
1046
|
+
text: "",
|
|
1047
|
+
disabled: true,
|
|
1048
|
+
error: "No governed record or frozen OpenClaw memory file matched this path",
|
|
1049
|
+
}),
|
|
1050
|
+
}],
|
|
1051
|
+
details: { found: false },
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
const sessionId = `openclaw-memory-get:${toolCallId}`;
|
|
1055
|
+
const result = (await client.callTool("memory_get_record", {
|
|
1056
|
+
record_id: recordId,
|
|
1057
|
+
session_id: sessionId,
|
|
1058
|
+
}));
|
|
1059
|
+
const allLines = String(result?.record?.content ?? "").split("\n");
|
|
1060
|
+
const from = Math.max(Number(params.from) || 1, 1);
|
|
1061
|
+
const requested = Math.min(Math.max(Number(params.lines) || 50, 1), 200);
|
|
1062
|
+
const selected = allLines.slice(from - 1, from - 1 + requested);
|
|
1063
|
+
const payload = {
|
|
1064
|
+
path: lookup,
|
|
1065
|
+
text: selected.join("\n"),
|
|
1066
|
+
from,
|
|
1067
|
+
lines: selected.length,
|
|
1068
|
+
totalLines: allLines.length,
|
|
1069
|
+
truncated: from - 1 + selected.length < allLines.length,
|
|
1070
|
+
source: "atmem",
|
|
1071
|
+
provenance: result?.source ?? {},
|
|
1072
|
+
};
|
|
1073
|
+
return {
|
|
1074
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1075
|
+
details: { found: Boolean(result?.record), sessionId },
|
|
1076
|
+
};
|
|
1077
|
+
},
|
|
1078
|
+
}, { name: "memory_get" });
|
|
1079
|
+
api.registerTool({
|
|
1080
|
+
name: "atmem_search",
|
|
1081
|
+
label: "Memory Search (atmem)",
|
|
1082
|
+
description: "Search the user's long-term auditable memory. Use when you need " +
|
|
1083
|
+
"preferences, facts, or context from previous conversations that " +
|
|
1084
|
+
"were not auto-injected.",
|
|
1085
|
+
parameters: {
|
|
1086
|
+
type: "object",
|
|
1087
|
+
properties: {
|
|
1088
|
+
query: { type: "string", description: "What to recall about the user" },
|
|
1089
|
+
limit: { type: "number", description: "Max results (default 5)" },
|
|
1090
|
+
},
|
|
1091
|
+
required: ["query"],
|
|
1092
|
+
},
|
|
1093
|
+
async execute(toolCallId, params) {
|
|
1094
|
+
const sessionId = `openclaw-tool:${toolCallId}`;
|
|
1095
|
+
const records = (await client.callTool("memory_recall", {
|
|
1096
|
+
query: String(params.query ?? ""),
|
|
1097
|
+
session_id: sessionId,
|
|
1098
|
+
limit: Math.min(Math.max(Number(params.limit) || 5, 1), 20),
|
|
1099
|
+
}));
|
|
1100
|
+
const text = records.length
|
|
1101
|
+
? records.map((record) => `- [${record.id}] ${record.content}`).join("\n")
|
|
1102
|
+
: "No matching memories.";
|
|
1103
|
+
return {
|
|
1104
|
+
content: [{ type: "text", text }],
|
|
1105
|
+
details: { count: records.length, sessionId },
|
|
1106
|
+
};
|
|
1107
|
+
},
|
|
1108
|
+
}, { name: "atmem_search" });
|
|
1109
|
+
api.registerTool({
|
|
1110
|
+
name: "atmem_forget",
|
|
1111
|
+
label: "Memory Forget (atmem)",
|
|
1112
|
+
description: "Delete the user's memories matching their request — only call when " +
|
|
1113
|
+
"the user explicitly asks to forget something. Deletion purges " +
|
|
1114
|
+
"content and returns a verifiable receipt; report the purged count " +
|
|
1115
|
+
"back to the user.",
|
|
1116
|
+
parameters: {
|
|
1117
|
+
type: "object",
|
|
1118
|
+
properties: {
|
|
1119
|
+
utterance: {
|
|
1120
|
+
type: "string",
|
|
1121
|
+
description: 'The user\'s words, e.g. "Forget my backup email."',
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
required: ["utterance"],
|
|
1125
|
+
},
|
|
1126
|
+
async execute(toolCallId, params) {
|
|
1127
|
+
const sessionId = `openclaw-tool:${toolCallId}`;
|
|
1128
|
+
const result = (await client.callTool("memory_forget", {
|
|
1129
|
+
utterance: String(params.utterance ?? ""),
|
|
1130
|
+
session_id: sessionId,
|
|
1131
|
+
turn_id: toolCallId,
|
|
1132
|
+
}));
|
|
1133
|
+
if (result.deleted)
|
|
1134
|
+
personaCache = null;
|
|
1135
|
+
const text = result.deleted
|
|
1136
|
+
? `Deleted ${result.record_ids.length} memorie(s). Receipt: ${JSON.stringify(result.receipt)}`
|
|
1137
|
+
: "No matching memories found to delete.";
|
|
1138
|
+
return {
|
|
1139
|
+
content: [{ type: "text", text }],
|
|
1140
|
+
details: { deleted: result.deleted, sessionId },
|
|
1141
|
+
};
|
|
1142
|
+
},
|
|
1143
|
+
}, { name: "atmem_forget" });
|
|
1144
|
+
api.registerTool((toolCtx) => ({
|
|
1145
|
+
name: "atmem_observe",
|
|
1146
|
+
label: "Media Observation (atmem)",
|
|
1147
|
+
description: "When the authenticated user's meaning is to remember or retain an uploaded " +
|
|
1148
|
+
"image, audio clip, video, document, or something observed from it for later " +
|
|
1149
|
+
"agent use, store one " +
|
|
1150
|
+
"typed text observation. For a current OpenClaw upload, exact-byte " +
|
|
1151
|
+
"SHA-256 provenance is supplied automatically by the trusted host hook. The " +
|
|
1152
|
+
"observation is quarantined until explicitly promoted. Confidence is " +
|
|
1153
|
+
"evidence only and never grants trust.",
|
|
1154
|
+
parameters: {
|
|
1155
|
+
type: "object",
|
|
1156
|
+
properties: {
|
|
1157
|
+
text: { type: "string", description: "What the extractor observed" },
|
|
1158
|
+
modality: {
|
|
1159
|
+
type: "string",
|
|
1160
|
+
enum: ["image", "audio", "video", "document"],
|
|
1161
|
+
},
|
|
1162
|
+
media_sha256: {
|
|
1163
|
+
type: "string",
|
|
1164
|
+
description: "Optional SHA-256 when no current OpenClaw upload is bound",
|
|
1165
|
+
},
|
|
1166
|
+
host_reference: {
|
|
1167
|
+
type: "string",
|
|
1168
|
+
description: "Optional secretless reference when no upload is bound",
|
|
1169
|
+
},
|
|
1170
|
+
segment: {
|
|
1171
|
+
type: "object",
|
|
1172
|
+
description: "Optional location inside the media. Omit for a whole-file observation.",
|
|
1173
|
+
properties: {
|
|
1174
|
+
page: {
|
|
1175
|
+
type: "integer",
|
|
1176
|
+
minimum: 1,
|
|
1177
|
+
description: "One-based document page",
|
|
1178
|
+
},
|
|
1179
|
+
timestamp_start: {
|
|
1180
|
+
type: "number",
|
|
1181
|
+
minimum: 0,
|
|
1182
|
+
description: "Audio/video start time in seconds",
|
|
1183
|
+
},
|
|
1184
|
+
timestamp_end: {
|
|
1185
|
+
type: "number",
|
|
1186
|
+
minimum: 0,
|
|
1187
|
+
description: "Audio/video end time in seconds",
|
|
1188
|
+
},
|
|
1189
|
+
region: {
|
|
1190
|
+
type: "string",
|
|
1191
|
+
minLength: 1,
|
|
1192
|
+
maxLength: 500,
|
|
1193
|
+
description: "Human-readable region, for example 'upper-left logo'",
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
additionalProperties: false,
|
|
1197
|
+
},
|
|
1198
|
+
extractor: {
|
|
1199
|
+
type: "object",
|
|
1200
|
+
description: "Extractor identity: provider, model, version, and optional model_digest",
|
|
1201
|
+
properties: {
|
|
1202
|
+
provider: { type: "string" },
|
|
1203
|
+
model: { type: "string" },
|
|
1204
|
+
version: { type: "string" },
|
|
1205
|
+
model_digest: { type: "string" },
|
|
1206
|
+
},
|
|
1207
|
+
additionalProperties: false,
|
|
1208
|
+
},
|
|
1209
|
+
confidence: {
|
|
1210
|
+
type: "number",
|
|
1211
|
+
description: "Extractor-local score from 0 to 1",
|
|
1212
|
+
},
|
|
1213
|
+
observed_at: { type: "string", description: "Optional ISO-8601 timestamp" },
|
|
1214
|
+
attachment_index: {
|
|
1215
|
+
type: "number",
|
|
1216
|
+
description: "Zero-based upload index when the user supplied multiple files",
|
|
1217
|
+
},
|
|
1218
|
+
},
|
|
1219
|
+
required: ["text", "modality"],
|
|
1220
|
+
},
|
|
1221
|
+
async execute(toolCallId, params) {
|
|
1222
|
+
const sessionId = `openclaw-tool:${toolCallId}`;
|
|
1223
|
+
sweep();
|
|
1224
|
+
const attachmentSet = await resolveInboundAttachmentSet(toolCtx);
|
|
1225
|
+
const requestedIndex = Math.max(0, Math.floor(Number(params.attachment_index) || 0));
|
|
1226
|
+
const attachment = attachmentSet?.items[requestedIndex];
|
|
1227
|
+
const requestedModality = String(params.modality ?? "");
|
|
1228
|
+
if (attachment && attachment.modality !== requestedModality) {
|
|
1229
|
+
throw new Error(`bound upload ${requestedIndex} is ${attachment.modality}, not ${requestedModality}`);
|
|
1230
|
+
}
|
|
1231
|
+
const mediaSha256 = attachment?.mediaSha256 ?? String(params.media_sha256 ?? "");
|
|
1232
|
+
const hostReference = attachment?.hostReference ?? String(params.host_reference ?? "");
|
|
1233
|
+
if (!/^[a-f0-9]{64}$/i.test(mediaSha256) || !hostReference) {
|
|
1234
|
+
throw new Error("No exact uploaded-file provenance is bound to this session. " +
|
|
1235
|
+
"Attach the file in the same OpenClaw message or provide its SHA-256 and secretless reference.");
|
|
1236
|
+
}
|
|
1237
|
+
const suppliedRaw = params.extractor && typeof params.extractor === "object"
|
|
1238
|
+
? params.extractor
|
|
1239
|
+
: {};
|
|
1240
|
+
const suppliedExtractor = {};
|
|
1241
|
+
for (const key of ["provider", "model", "version", "model_digest"]) {
|
|
1242
|
+
if (typeof suppliedRaw[key] === "string")
|
|
1243
|
+
suppliedExtractor[key] = suppliedRaw[key];
|
|
1244
|
+
}
|
|
1245
|
+
const extractor = {
|
|
1246
|
+
...suppliedExtractor,
|
|
1247
|
+
provider: toolCtx.activeModel?.provider ?? suppliedExtractor.provider ?? "openclaw",
|
|
1248
|
+
model: toolCtx.activeModel?.modelId ?? suppliedExtractor.model ?? "unknown",
|
|
1249
|
+
version: suppliedExtractor.version ?? "openclaw-host-observation-v1",
|
|
1250
|
+
};
|
|
1251
|
+
const suppliedSegment = params.segment && typeof params.segment === "object"
|
|
1252
|
+
? params.segment
|
|
1253
|
+
: {};
|
|
1254
|
+
const segment = {};
|
|
1255
|
+
for (const key of ["page", "timestamp_start", "timestamp_end", "region"]) {
|
|
1256
|
+
if (suppliedSegment[key] !== undefined)
|
|
1257
|
+
segment[key] = suppliedSegment[key];
|
|
1258
|
+
}
|
|
1259
|
+
const result = (await client.callTool("memory_observe", {
|
|
1260
|
+
text: String(params.text ?? ""),
|
|
1261
|
+
modality: requestedModality,
|
|
1262
|
+
media_sha256: mediaSha256,
|
|
1263
|
+
host_reference: hostReference,
|
|
1264
|
+
segment,
|
|
1265
|
+
extractor,
|
|
1266
|
+
confidence: params.confidence,
|
|
1267
|
+
observed_at: params.observed_at,
|
|
1268
|
+
session_id: sessionId,
|
|
1269
|
+
turn_id: toolCallId,
|
|
1270
|
+
}));
|
|
1271
|
+
return {
|
|
1272
|
+
content: [
|
|
1273
|
+
{
|
|
1274
|
+
type: "text",
|
|
1275
|
+
text: `Media observation ${result.duplicate ? "already existed" : "stored"} ` +
|
|
1276
|
+
`as quarantined record ${result.record.id}.`,
|
|
1277
|
+
},
|
|
1278
|
+
],
|
|
1279
|
+
details: {
|
|
1280
|
+
success: true,
|
|
1281
|
+
artifactId: result.artifact.id,
|
|
1282
|
+
observationId: result.observation.id,
|
|
1283
|
+
recordId: result.record.id,
|
|
1284
|
+
status: result.record.status,
|
|
1285
|
+
duplicate: result.duplicate,
|
|
1286
|
+
provenanceSource: attachment ? "openclaw-upload" : "caller",
|
|
1287
|
+
mediaSha256,
|
|
1288
|
+
sessionId,
|
|
1289
|
+
},
|
|
1290
|
+
};
|
|
1291
|
+
},
|
|
1292
|
+
}), { name: "atmem_observe" });
|
|
1293
|
+
api.registerTool({
|
|
1294
|
+
name: "atmem_forget_artifact",
|
|
1295
|
+
label: "Forget Media Artifact (atmem)",
|
|
1296
|
+
description: "Only when the user explicitly requests deletion, purge all AtMem " +
|
|
1297
|
+
"observations derived from one exact-byte SHA-256. This does not " +
|
|
1298
|
+
"delete the host's original file or a re-encoded copy.",
|
|
1299
|
+
parameters: {
|
|
1300
|
+
type: "object",
|
|
1301
|
+
properties: {
|
|
1302
|
+
media_sha256: {
|
|
1303
|
+
type: "string",
|
|
1304
|
+
description: "SHA-256 of the exact media byte stream",
|
|
1305
|
+
},
|
|
1306
|
+
artifact_id: {
|
|
1307
|
+
type: "string",
|
|
1308
|
+
description: "Optional artifact id that must match the digest",
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
required: ["media_sha256"],
|
|
1312
|
+
},
|
|
1313
|
+
async execute(toolCallId, params) {
|
|
1314
|
+
const sessionId = `openclaw-tool:${toolCallId}`;
|
|
1315
|
+
const result = (await client.callTool("memory_forget_artifact", {
|
|
1316
|
+
media_sha256: String(params.media_sha256 ?? ""),
|
|
1317
|
+
artifact_id: params.artifact_id,
|
|
1318
|
+
session_id: sessionId,
|
|
1319
|
+
turn_id: toolCallId,
|
|
1320
|
+
}));
|
|
1321
|
+
if (result.deleted)
|
|
1322
|
+
personaCache = null;
|
|
1323
|
+
const text = result.deleted
|
|
1324
|
+
? `Purged ${result.record_ids.length} derived memorie(s). Receipt: ${JSON.stringify(result.receipt)}`
|
|
1325
|
+
: "No active AtMem artifact matched that exact digest.";
|
|
1326
|
+
return {
|
|
1327
|
+
content: [{ type: "text", text }],
|
|
1328
|
+
details: { deleted: result.deleted, sessionId },
|
|
1329
|
+
};
|
|
1330
|
+
},
|
|
1331
|
+
}, { name: "atmem_forget_artifact" });
|
|
1332
|
+
}
|
|
1333
|
+
api.logger.info(`${TAG} registered (db=${cfg.dbPath}, subject=${cfg.subject}, ` +
|
|
1334
|
+
`recall=${cfg.recall.enabled}, capture=${cfg.capture.enabled}, ` +
|
|
1335
|
+
`controlPlane=${cfg.controlPlane.enabled})`);
|
|
1336
|
+
}
|
|
1337
|
+
export default {
|
|
1338
|
+
id: "memory-atmem",
|
|
1339
|
+
name: "Memory (atmem)",
|
|
1340
|
+
description: "Automatic, auditable memory for OpenClaw backed by AtMem",
|
|
1341
|
+
register,
|
|
1342
|
+
};
|