@lanonasis/recall-forge 1.1.1

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.
Files changed (68) hide show
  1. package/.claw/skills/SKILL.md +347 -0
  2. package/CHANGELOG.md +162 -0
  3. package/LICENSE +21 -0
  4. package/README.md +302 -0
  5. package/SETUP.md +190 -0
  6. package/dist/cli-common.d.ts +25 -0
  7. package/dist/cli-common.js +338 -0
  8. package/dist/cli-memory.d.ts +6 -0
  9. package/dist/cli-memory.js +146 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.js +135 -0
  12. package/dist/client.d.ts +116 -0
  13. package/dist/client.js +643 -0
  14. package/dist/config.d.ts +41 -0
  15. package/dist/config.js +125 -0
  16. package/dist/enrichment/capture-filter.d.ts +4 -0
  17. package/dist/enrichment/capture-filter.js +44 -0
  18. package/dist/enrichment/prompt-safety.d.ts +13 -0
  19. package/dist/enrichment/prompt-safety.js +83 -0
  20. package/dist/enrichment/tag-extractor.d.ts +1 -0
  21. package/dist/enrichment/tag-extractor.js +47 -0
  22. package/dist/enrichment/type-detector.d.ts +2 -0
  23. package/dist/enrichment/type-detector.js +95 -0
  24. package/dist/extraction/cli-extract.d.ts +8 -0
  25. package/dist/extraction/cli-extract.js +66 -0
  26. package/dist/extraction/format-adapters.d.ts +8 -0
  27. package/dist/extraction/format-adapters.js +268 -0
  28. package/dist/extraction/index.d.ts +7 -0
  29. package/dist/extraction/index.js +7 -0
  30. package/dist/extraction/jsonl-extractor.d.ts +32 -0
  31. package/dist/extraction/jsonl-extractor.js +207 -0
  32. package/dist/extraction/markdown-extractor.d.ts +23 -0
  33. package/dist/extraction/markdown-extractor.js +228 -0
  34. package/dist/extraction/secret-redactor.d.ts +7 -0
  35. package/dist/extraction/secret-redactor.js +112 -0
  36. package/dist/extraction/sqlite-extractor.d.ts +15 -0
  37. package/dist/extraction/sqlite-extractor.js +245 -0
  38. package/dist/extraction/types.d.ts +50 -0
  39. package/dist/extraction/types.js +1 -0
  40. package/dist/hooks/capture.d.ts +23 -0
  41. package/dist/hooks/capture.js +162 -0
  42. package/dist/hooks/context-engine.d.ts +4 -0
  43. package/dist/hooks/context-engine.js +54 -0
  44. package/dist/hooks/local-fallback.d.ts +5 -0
  45. package/dist/hooks/local-fallback.js +31 -0
  46. package/dist/hooks/recall.d.ts +21 -0
  47. package/dist/hooks/recall.js +123 -0
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.js +103 -0
  50. package/dist/plugin-sdk-stub.d.ts +53 -0
  51. package/dist/plugin-sdk-stub.js +3 -0
  52. package/dist/privacy/privacy-guard.d.ts +33 -0
  53. package/dist/privacy/privacy-guard.js +130 -0
  54. package/dist/privacy/privacy-log.d.ts +6 -0
  55. package/dist/privacy/privacy-log.js +44 -0
  56. package/dist/tools/memory-forget.d.ts +3 -0
  57. package/dist/tools/memory-forget.js +109 -0
  58. package/dist/tools/memory-get.d.ts +3 -0
  59. package/dist/tools/memory-get.js +46 -0
  60. package/dist/tools/memory-search.d.ts +4 -0
  61. package/dist/tools/memory-search.js +95 -0
  62. package/dist/tools/memory-store.d.ts +5 -0
  63. package/dist/tools/memory-store.js +199 -0
  64. package/openclaw.plugin.json +315 -0
  65. package/package.json +90 -0
  66. package/setup/agents-memory.md +63 -0
  67. package/setup/heartbeat-memory.md +53 -0
  68. package/setup/install.sh +179 -0
@@ -0,0 +1,130 @@
1
+ // Privacy Guard — two-stage protection pipeline
2
+ // Stage 1: secret-redactor (credentials — always on)
3
+ // Stage 2: privacy-sdk (PII — controlled by privacyMode)
4
+ //
5
+ // This is the module that fixes the gap: before this, redactSecrets() was only
6
+ // wired into the `extract` CLI path. Every memory write path (memory_store tool,
7
+ // capture hooks, local fallback) ran unredacted content through to storage.
8
+ import { PrivacySDK } from "@lanonasis/privacy-sdk";
9
+ import { redactSecrets } from "../extraction/secret-redactor.js";
10
+ function topSensitivity(detected) {
11
+ const order = { critical: 4, high: 3, medium: 2, low: 1 };
12
+ if (detected.length === 0)
13
+ return "none";
14
+ return detected.reduce((top, r) => (order[r.sensitivity] ?? 0) > (order[top.sensitivity] ?? 0) ? r : top).sensitivity;
15
+ }
16
+ export class PrivacyGuard {
17
+ sdk;
18
+ mode;
19
+ locale;
20
+ notifyUrl;
21
+ logger;
22
+ constructor(cfg, logger) {
23
+ this.mode = cfg.privacyMode ?? "mask";
24
+ this.locale = cfg.privacyLocale ?? "US";
25
+ this.notifyUrl = cfg.privacyNotifyUrl ?? "";
26
+ this.logger = logger;
27
+ this.sdk = new PrivacySDK({
28
+ enableMasking: true,
29
+ enableAutoDetect: true,
30
+ confidenceThreshold: 0.85,
31
+ detectFieldNames: true,
32
+ gdprMode: true,
33
+ auditLog: false,
34
+ });
35
+ }
36
+ process(content) {
37
+ // Stage 1: credential stripping — always-on, mode does not disable this
38
+ const { text: stage1, secretsFound, types: secretTypes } = redactSecrets(content);
39
+ if (this.mode === "off") {
40
+ const action = secretsFound > 0 ? "redacted" : "passthrough";
41
+ const report = {
42
+ secretsFound, secretTypes, piiFound: false,
43
+ piiTypes: [], piiSensitivity: "none", regulations: [],
44
+ action, timestamp: new Date().toISOString(),
45
+ };
46
+ if (action !== "passthrough")
47
+ this.notify(report);
48
+ return { content: stage1, report };
49
+ }
50
+ // Stage 2: PII detection via privacy-sdk
51
+ const locale = this.locale;
52
+ const detected = this.sdk.detect(stage1, { locale })
53
+ .filter((r) => r.confidence >= 0.85);
54
+ const piiFound = detected.length > 0;
55
+ const finalContent = this.mode === "mask" && piiFound
56
+ ? this.sdk.detectAndMask(stage1, { locale })
57
+ : stage1;
58
+ let action = "passthrough";
59
+ if (secretsFound > 0 && piiFound)
60
+ action = "redacted+masked";
61
+ else if (secretsFound > 0)
62
+ action = "redacted";
63
+ else if (piiFound)
64
+ action = this.mode === "mask" ? "masked" : "detected";
65
+ const report = {
66
+ secretsFound,
67
+ secretTypes,
68
+ piiFound,
69
+ piiTypes: [...new Set(detected.map((r) => r.type))],
70
+ piiSensitivity: topSensitivity(detected),
71
+ regulations: [...new Set(detected.flatMap((r) => r.regulations))],
72
+ action,
73
+ timestamp: new Date().toISOString(),
74
+ };
75
+ if (action !== "passthrough")
76
+ this.notify(report);
77
+ return { content: finalContent, report };
78
+ }
79
+ /** Tags to merge into memory tags based on what was found */
80
+ tagsFrom(report) {
81
+ const tags = [];
82
+ if (report.secretsFound > 0)
83
+ tags.push("privacy:redacted");
84
+ for (const type of report.piiTypes)
85
+ tags.push(`pii:${type}`);
86
+ for (const reg of report.regulations)
87
+ tags.push(`compliant:${reg.toLowerCase()}`);
88
+ return tags;
89
+ }
90
+ /** Metadata to merge into memory.metadata — omitted entirely if passthrough */
91
+ metaFrom(report) {
92
+ if (report.action === "passthrough")
93
+ return undefined;
94
+ return {
95
+ privacy: {
96
+ action: report.action,
97
+ ...(report.secretsFound > 0 && { secretsFound: report.secretsFound }),
98
+ ...(report.secretTypes.length > 0 && { secretTypes: report.secretTypes }),
99
+ ...(report.piiFound && { piiTypes: report.piiTypes }),
100
+ ...(report.piiSensitivity !== "none" && { piiSensitivity: report.piiSensitivity }),
101
+ ...(report.regulations.length > 0 && { regulations: report.regulations }),
102
+ timestamp: report.timestamp,
103
+ },
104
+ };
105
+ }
106
+ /** Fire-and-forget webhook — never blocks the write path, never throws */
107
+ notify(report) {
108
+ if (!this.notifyUrl)
109
+ return;
110
+ fetch(this.notifyUrl, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({
114
+ event: "privacy.intervention",
115
+ plugin: "recall-forge",
116
+ action: report.action,
117
+ secretsFound: report.secretsFound,
118
+ piiTypes: report.piiTypes,
119
+ piiSensitivity: report.piiSensitivity,
120
+ regulations: report.regulations,
121
+ timestamp: report.timestamp,
122
+ }),
123
+ }).catch((err) => {
124
+ if (this.logger) {
125
+ const msg = err instanceof Error ? err.message : "unknown";
126
+ this.logger.warn(`[recall-forge] privacy webhook failed: ${msg}`);
127
+ }
128
+ });
129
+ }
130
+ }
@@ -0,0 +1,6 @@
1
+ import type { PrivacyReport } from "./privacy-guard.js";
2
+ export declare class PrivacyLogWriter {
3
+ private resolvePath;
4
+ constructor(resolvePath: (p: string) => string);
5
+ write(report: PrivacyReport): Promise<void>;
6
+ }
@@ -0,0 +1,44 @@
1
+ // Privacy Log Writer — daily markdown audit trail
2
+ // Writes to workspace/memory/privacy/YYYY-MM-DD.md when an intervention occurs.
3
+ // Only writes when action !== 'passthrough'. Silent on all errors.
4
+ import { promises as fs } from "fs";
5
+ import { join } from "path";
6
+ export class PrivacyLogWriter {
7
+ resolvePath;
8
+ constructor(resolvePath) {
9
+ this.resolvePath = resolvePath;
10
+ }
11
+ async write(report) {
12
+ if (report.action === "passthrough")
13
+ return;
14
+ try {
15
+ const today = report.timestamp.slice(0, 10);
16
+ const filePath = this.resolvePath(join("memory", "privacy", `${today}.md`));
17
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
18
+ await fs.mkdir(dir, { recursive: true });
19
+ // Check if file exists to decide whether to write the header
20
+ let needsHeader = false;
21
+ try {
22
+ await fs.access(filePath);
23
+ }
24
+ catch {
25
+ needsHeader = true;
26
+ }
27
+ let entry = "";
28
+ if (needsHeader) {
29
+ entry += `# Privacy Shield Log — ${today}\n\n`;
30
+ entry += `| Time | Action | Secrets | PII Types | Sensitivity | Regulations |\n`;
31
+ entry += `|------|--------|---------|-----------|-------------|-------------|\n`;
32
+ }
33
+ const time = report.timestamp.slice(11, 19);
34
+ const pii = report.piiTypes.join(", ") || "—";
35
+ const regs = report.regulations.join(", ") || "—";
36
+ const sensitivity = report.piiSensitivity !== "none" ? report.piiSensitivity : "—";
37
+ entry += `| ${time} | ${report.action} | ${report.secretsFound} | ${pii} | ${sensitivity} | ${regs} |\n`;
38
+ await fs.appendFile(filePath, entry, "utf-8");
39
+ }
40
+ catch {
41
+ // Never throws — log failure must not affect memory write
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { OpenClawPluginApi } from "../plugin-sdk-stub.js";
2
+ import type { LanonasisClient } from "../client.js";
3
+ export declare function registerMemoryForgetTool(api: OpenClawPluginApi, client: LanonasisClient): void;
@@ -0,0 +1,109 @@
1
+ export function registerMemoryForgetTool(api, client) {
2
+ api.registerTool({
3
+ name: "memory_forget",
4
+ description: "Delete a memory by ID or semantic query. Query mode requires high confidence.",
5
+ parameters: {
6
+ type: "object",
7
+ properties: {
8
+ id: {
9
+ type: "string",
10
+ description: "Memory ID or displayed prefix to delete",
11
+ },
12
+ query: {
13
+ type: "string",
14
+ description: "Semantic query to find memory (deletes if single high-confidence match)",
15
+ },
16
+ },
17
+ },
18
+ async execute(_id, params) {
19
+ try {
20
+ const id = params.id;
21
+ const query = params.query;
22
+ // ID path
23
+ if (id) {
24
+ const candidate = id.trim();
25
+ if (!candidate) {
26
+ return {
27
+ content: [
28
+ {
29
+ type: "text",
30
+ text: "Memory ID is required.",
31
+ },
32
+ ],
33
+ };
34
+ }
35
+ if (candidate.length < 8) {
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: `Memory ID prefix must be at least 8 characters or a full UUID: ${candidate}`,
41
+ },
42
+ ],
43
+ };
44
+ }
45
+ const resolvedId = await client.resolveMemoryId(candidate);
46
+ await client.deleteMemory(resolvedId);
47
+ return {
48
+ content: [
49
+ { type: "text", text: `Forgotten: ${resolvedId}` },
50
+ ],
51
+ };
52
+ }
53
+ // Query path
54
+ if (query) {
55
+ const results = await client.searchMemories({
56
+ query,
57
+ threshold: 0.7,
58
+ limit: 5,
59
+ });
60
+ if (!results || results.length === 0) {
61
+ return {
62
+ content: [{ type: "text", text: "No matching memories found." }],
63
+ };
64
+ }
65
+ // Single high-confidence match (>0.9)
66
+ if (results.length === 1 && (results[0].similarity || 0) > 0.9) {
67
+ await client.deleteMemory(results[0].id);
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: `Forgotten: **${results[0].title}** (id: ${results[0].id})`,
73
+ },
74
+ ],
75
+ };
76
+ }
77
+ // Multiple results - return candidate list
78
+ const lines = results.map((r, i) => `${i + 1}. [${r.memory_type ?? r.type}] **${r.title}** (score: ${(r.similarity || r.similarity_score || 0).toFixed(2)}) - id: ${r.id}`);
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text",
83
+ text: `Multiple matches found. Specify which to delete:\n\n${lines.join("\n")}`,
84
+ },
85
+ ],
86
+ };
87
+ }
88
+ return {
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: "Provide either 'id' or 'query' parameter.",
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ catch (err) {
98
+ return {
99
+ content: [
100
+ {
101
+ type: "text",
102
+ text: `Forget error: ${err instanceof Error ? err.message : "unknown"}`,
103
+ },
104
+ ],
105
+ };
106
+ }
107
+ },
108
+ });
109
+ }
@@ -0,0 +1,3 @@
1
+ import type { OpenClawPluginApi } from "../plugin-sdk-stub.js";
2
+ import type { LanonasisClient } from "../client.js";
3
+ export declare function registerMemoryGetTool(api: OpenClawPluginApi, client: LanonasisClient): void;
@@ -0,0 +1,46 @@
1
+ export function registerMemoryGetTool(api, client) {
2
+ api.registerTool({
3
+ name: "memory_get",
4
+ description: "Fetch full memory content by ID.",
5
+ parameters: {
6
+ type: "object",
7
+ properties: {
8
+ id: {
9
+ type: "string",
10
+ description: "Memory ID or displayed prefix",
11
+ },
12
+ },
13
+ required: ["id"],
14
+ },
15
+ async execute(_id, params) {
16
+ try {
17
+ const id = params.id;
18
+ const memory = await client.getMemory(id);
19
+ return {
20
+ content: [
21
+ {
22
+ type: "text",
23
+ text: `ID: ${memory.id}\n**${memory.title}** [${memory.type}]\n\n${memory.content}`,
24
+ },
25
+ ],
26
+ details: memory,
27
+ };
28
+ }
29
+ catch (err) {
30
+ const message = err instanceof Error ? err.message : "unknown";
31
+ if (message.includes("404")) {
32
+ return {
33
+ content: [
34
+ { type: "text", text: `Memory not found: ${params.id}` },
35
+ ],
36
+ };
37
+ }
38
+ return {
39
+ content: [
40
+ { type: "text", text: `Error: ${message}` },
41
+ ],
42
+ };
43
+ }
44
+ },
45
+ });
46
+ }
@@ -0,0 +1,4 @@
1
+ import type { OpenClawPluginApi } from "../plugin-sdk-stub.js";
2
+ import type { LanonasisClient } from "../client.js";
3
+ import type { LanonasisConfig } from "../config.js";
4
+ export declare function registerMemorySearchTool(api: OpenClawPluginApi, client: LanonasisClient, cfg: LanonasisConfig): void;
@@ -0,0 +1,95 @@
1
+ export function registerMemorySearchTool(api, client, cfg) {
2
+ api.registerTool({
3
+ name: "memory_search",
4
+ description: "Semantic search through memories. Returns ranked results with previews.",
5
+ parameters: {
6
+ type: "object",
7
+ properties: {
8
+ query: {
9
+ type: "string",
10
+ description: "Search query (required)",
11
+ },
12
+ limit: {
13
+ type: "number",
14
+ description: "Max results (default 5, max 10)",
15
+ default: 5,
16
+ },
17
+ type: {
18
+ type: "string",
19
+ description: "Filter by type: context, project, knowledge, reference, personal, workflow",
20
+ },
21
+ threshold: {
22
+ type: "number",
23
+ description: "Similarity threshold (overrides config)",
24
+ },
25
+ agent_id: {
26
+ type: "string",
27
+ description: "Filter to specific agent's memories",
28
+ },
29
+ },
30
+ required: ["query"],
31
+ },
32
+ async execute(_id, params) {
33
+ try {
34
+ const query = params.query;
35
+ const limit = Math.min(params.limit || 5, 10);
36
+ const type = params.type;
37
+ const threshold = params.threshold;
38
+ const agentId = params.agent_id;
39
+ const metadata = {};
40
+ if (agentId)
41
+ metadata.agent_id = agentId;
42
+ const memories = await client.searchMemories({
43
+ query,
44
+ threshold: threshold || cfg.searchThreshold,
45
+ limit,
46
+ type: type,
47
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
48
+ });
49
+ if (!memories || memories.length === 0) {
50
+ return {
51
+ content: [{ type: "text", text: "No matching memories found." }],
52
+ };
53
+ }
54
+ // Format: numbered list with 200 char previews
55
+ const lines = memories.map((m, i) => {
56
+ const preview = (m.content ?? '').slice(0, 200).replace(/\n/g, " ");
57
+ const score = m.similarity !== undefined
58
+ ? ` (score: ${m.similarity.toFixed(2)})`
59
+ : "";
60
+ const type = m.memory_type ?? m.type ?? 'context';
61
+ return `${i + 1}. [${type}] **${m.title}**${score}\n ID: ${m.id}\n ${preview}...`;
62
+ });
63
+ return {
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: `Found ${memories.length} memories:\n\n${lines.join("\n\n")}`,
68
+ },
69
+ ],
70
+ details: {
71
+ count: memories.length,
72
+ memories: memories.map((m) => ({
73
+ id: m.id,
74
+ title: m.title,
75
+ type: m.memory_type ?? m.type,
76
+ tags: m.tags,
77
+ similarity: m.similarity,
78
+ content_preview: (m.content ?? '').slice(0, 200),
79
+ })),
80
+ },
81
+ };
82
+ }
83
+ catch (err) {
84
+ return {
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: `Search error: ${err instanceof Error ? err.message : "unknown"}`,
89
+ },
90
+ ],
91
+ };
92
+ }
93
+ },
94
+ });
95
+ }
@@ -0,0 +1,5 @@
1
+ import type { OpenClawPluginApi } from "../plugin-sdk-stub.js";
2
+ import type { LanonasisClient } from "../client.js";
3
+ import type { LanonasisConfig } from "../config.js";
4
+ import type { PrivacyGuard } from "../privacy/privacy-guard.js";
5
+ export declare function registerMemoryStoreTool(api: OpenClawPluginApi, client: LanonasisClient, cfg: LanonasisConfig, guard?: PrivacyGuard): void;
@@ -0,0 +1,199 @@
1
+ import { detectMemoryType } from "../enrichment/type-detector.js";
2
+ import { extractTags } from "../enrichment/tag-extractor.js";
3
+ const MEMORY_TYPES = [
4
+ "context",
5
+ "project",
6
+ "knowledge",
7
+ "reference",
8
+ "personal",
9
+ "workflow",
10
+ ];
11
+ function normalizeOptionalString(label, value) {
12
+ if (value === undefined)
13
+ return undefined;
14
+ if (typeof value !== "string") {
15
+ throw new Error(`${label} must be a string.`);
16
+ }
17
+ const normalized = value.trim();
18
+ if (!normalized) {
19
+ throw new Error(`${label} cannot be empty or whitespace-only.`);
20
+ }
21
+ return normalized;
22
+ }
23
+ function normalizeOptionalType(value) {
24
+ const normalized = normalizeOptionalString("Type", value);
25
+ if (!normalized)
26
+ return undefined;
27
+ if (!MEMORY_TYPES.includes(normalized)) {
28
+ throw new Error(`Type must be one of: ${MEMORY_TYPES.join(", ")}.`);
29
+ }
30
+ return normalized;
31
+ }
32
+ function normalizeTags(value) {
33
+ if (value === undefined)
34
+ return undefined;
35
+ if (!Array.isArray(value)) {
36
+ throw new Error("Tags must be an array of strings.");
37
+ }
38
+ const tags = [...new Set(value.map((tag) => {
39
+ if (typeof tag !== "string") {
40
+ throw new Error("Tags must be an array of strings.");
41
+ }
42
+ return tag.trim();
43
+ }).filter(Boolean))];
44
+ if (tags.length === 0) {
45
+ throw new Error("Tags cannot be empty.");
46
+ }
47
+ return tags;
48
+ }
49
+ export function registerMemoryStoreTool(api, client, cfg, guard) {
50
+ api.registerTool({
51
+ name: "memory_store",
52
+ description: "Store or update a memory. New memories are deduplicated before creation.",
53
+ parameters: {
54
+ type: "object",
55
+ properties: {
56
+ id: {
57
+ type: "string",
58
+ description: "Existing memory ID to update",
59
+ },
60
+ content: {
61
+ type: "string",
62
+ description: "Memory content (required for create, optional for update)",
63
+ },
64
+ title: {
65
+ type: "string",
66
+ description: "Memory title (auto-generated on create if absent)",
67
+ },
68
+ type: {
69
+ type: "string",
70
+ description: "Type: context, project, knowledge, reference, personal, workflow (auto-detected if absent)",
71
+ },
72
+ tags: {
73
+ type: "array",
74
+ items: { type: "string" },
75
+ description: "Tags (auto-extracted if absent)",
76
+ },
77
+ },
78
+ },
79
+ async execute(_id, params) {
80
+ try {
81
+ const id = normalizeOptionalString("Memory ID", params.id);
82
+ const content = normalizeOptionalString("Content", params.content);
83
+ let title = normalizeOptionalString("Title", params.title);
84
+ let type = normalizeOptionalType(params.type);
85
+ let tags = normalizeTags(params.tags);
86
+ if (id) {
87
+ const updates = {};
88
+ if (params.title !== undefined)
89
+ updates.title = title;
90
+ if (params.content !== undefined)
91
+ updates.content = content;
92
+ if (params.type !== undefined)
93
+ updates.type = type;
94
+ if (params.tags !== undefined)
95
+ updates.tags = tags;
96
+ if (Object.keys(updates).length === 0) {
97
+ throw new Error("Provide at least one field to update when `id` is set.");
98
+ }
99
+ const memory = await client.updateMemory(id, updates);
100
+ const resolvedType = memory.memory_type ?? memory.type;
101
+ return {
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text: `Updated: **${memory.title}** [${resolvedType}] (id: ${memory.id})`,
106
+ },
107
+ ],
108
+ updated: true,
109
+ id: memory.id,
110
+ title: memory.title,
111
+ type: resolvedType,
112
+ };
113
+ }
114
+ if (!content) {
115
+ throw new Error("Content is required when creating a memory.");
116
+ }
117
+ // Run privacy guard — stage 1: redact credentials, stage 2: mask PII
118
+ const guardResult = guard ? guard.process(content) : null;
119
+ const safeContent = guardResult ? guardResult.content : content;
120
+ // Auto-generate if absent
121
+ if (!title) {
122
+ title = safeContent.slice(0, 80).replace(/\s+/g, " ").trim();
123
+ }
124
+ if (!type) {
125
+ type = detectMemoryType(safeContent);
126
+ }
127
+ if (!tags || tags.length === 0) {
128
+ const baseTags = extractTags(safeContent);
129
+ const privacyTags = guardResult && guard ? guard.tagsFrom(guardResult.report) : [];
130
+ tags = [...new Set([...baseTags, ...privacyTags])];
131
+ }
132
+ else if (guardResult && guard) {
133
+ // Merge privacy tags with caller-supplied tags
134
+ const privacyTags = guard.tagsFrom(guardResult.report);
135
+ tags = [...new Set([...tags, ...privacyTags])];
136
+ }
137
+ // Dedup check using configurable threshold
138
+ const existing = await client.searchMemories({
139
+ query: safeContent,
140
+ threshold: cfg.dedupeThreshold,
141
+ limit: 1,
142
+ });
143
+ if (existing && existing.length > 0) {
144
+ // Structured signal: stored:false lets the agent distinguish no-op from write
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: `Not stored — similar memory exists: **${existing[0].title}** (id: ${existing[0].id})`,
150
+ },
151
+ ],
152
+ stored: false,
153
+ reason: "duplicate",
154
+ threshold: cfg.dedupeThreshold,
155
+ existingTitle: existing[0].title,
156
+ existingId: existing[0].id,
157
+ };
158
+ }
159
+ // Create memory (with sanitized content + privacy metadata)
160
+ const privacyMeta = guardResult && guard ? guard.metaFrom(guardResult.report) : undefined;
161
+ const memory = await client.createMemory({
162
+ title,
163
+ content: safeContent,
164
+ type: type,
165
+ tags,
166
+ metadata: {
167
+ agent_id: cfg.agentId,
168
+ source: "openclaw",
169
+ captured_at: new Date().toISOString(),
170
+ ...privacyMeta,
171
+ },
172
+ });
173
+ const resolvedType = memory.memory_type ?? memory.type;
174
+ return {
175
+ content: [
176
+ {
177
+ type: "text",
178
+ text: `Stored: **${title}** [${resolvedType}] (id: ${memory.id})`,
179
+ },
180
+ ],
181
+ stored: true,
182
+ id: memory.id,
183
+ title,
184
+ type: resolvedType,
185
+ };
186
+ }
187
+ catch (err) {
188
+ return {
189
+ content: [
190
+ {
191
+ type: "text",
192
+ text: `Store error: ${err instanceof Error ? err.message : "unknown"}`,
193
+ },
194
+ ],
195
+ };
196
+ }
197
+ },
198
+ });
199
+ }