diff --git a/docs/openclaw/changelog.mdx b/docs/openclaw/changelog.mdx index 70a3ca79b..e08a735f9 100644 --- a/docs/openclaw/changelog.mdx +++ b/docs/openclaw/changelog.mdx @@ -28,9 +28,16 @@ questions: - What changed in the MemWal OpenClaw plugin changelog? - When was the MemWal OpenClaw plugin first released? answer: >- - Version 0.0.8 bounds and expires idle capture-session state, watermarks auto-capture per session and preserves message timestamps for occurredAt, aborts timed-out analyze requests without blind retries, distinguishes accepted async remember jobs from completed storage in memory_store, updates the MemWal SDK dependency to 0.1.10, and detects letter-spaced prompt injection keywords. + Version 0.0.9 skips automatic capture on conversational opt-out turns (including trailing assistant replies) and inline credentials while preserving ordinary developer statements. --- +## 0.0.9 + +### Fixed + +- Skip automatic capture for conversational opt-out directives (`do not remember`, `don't save`, `off the record`, `forget this`, `[no-save]`, `#nosave`) and suppress trailing assistant replies on opted-out user turns so echoed topics never reach `analyze()`. +- Exclude turns containing inline credentials, vendor API keys, PEM private keys, labelled 64-hex keys, seed phrases, and `key=value` or `key: value` secret assignments from automatic capture while keeping ordinary developer prose (`We never log request bodies in production`, `Our api_key is configured via Railway`) capturable. + ## 0.0.8 ### Fixed diff --git a/packages/openclaw-memory-memwal/CHANGELOG.md b/packages/openclaw-memory-memwal/CHANGELOG.md index cd1bcbc77..e1e586580 100644 --- a/packages/openclaw-memory-memwal/CHANGELOG.md +++ b/packages/openclaw-memory-memwal/CHANGELOG.md @@ -1,5 +1,12 @@ # @mysten-incubation/oc-memwal +## 0.0.9 + +### Fixed + +- Skip automatic capture for conversational opt-out directives (`do not remember`, `don't save`, `off the record`, `forget this`, `[no-save]`, `#nosave`) and suppress trailing assistant replies on opted-out user turns so echoed topics never reach `analyze()`. +- Exclude turns containing inline credentials, vendor API keys, PEM private keys, labelled 64-hex keys, seed phrases, and `key=value` or `key: value` secret assignments from automatic capture while keeping ordinary developer prose (`We never log request bodies in production`, `Our api_key is configured via Railway`) capturable. + ## 0.0.8 ### Fixed diff --git a/packages/openclaw-memory-memwal/package.json b/packages/openclaw-memory-memwal/package.json index 5ae3f0e25..866ad05d8 100644 --- a/packages/openclaw-memory-memwal/package.json +++ b/packages/openclaw-memory-memwal/package.json @@ -1,6 +1,6 @@ { "name": "@mysten-incubation/oc-memwal", - "version": "0.0.8", + "version": "0.0.9", "type": "module", "description": "NemoClaw/OpenClaw memory plugin — encrypted, decentralized long-term memory via Walrus Memory", "license": "Apache-2.0", diff --git a/packages/openclaw-memory-memwal/src/capture.ts b/packages/openclaw-memory-memwal/src/capture.ts index 60712d1af..3ff3b64d0 100644 --- a/packages/openclaw-memory-memwal/src/capture.ts +++ b/packages/openclaw-memory-memwal/src/capture.ts @@ -65,6 +65,54 @@ const ADDRESSES_MODEL = [ /^(ign[o0a]re|disregard|f[o0]rget|override|bypass|run|execute|call|invoke|reveal|print|output)\b/i, ]; +const CLAUSE_IMPERATIVE_PREFIX = + "(?:^|[.!?;:,(\\[\\n]\\s*|\\b(?:please|just)\\s+|\\byou\\s+(?:must\\s+|should\\s+|can\\s+)?)"; + +const IMPERATIVE_NEGATION_PREFIX = + "(?:^|[.!?;:,(\\[\\n]\\s*|\\b(?:and|but|please|just)\\s+|\\byou\\s+(?:must\\s+|should\\s+|can\\s+)?)"; + +/** Conversational opt-out patterns that instruct the assistant not to persist the turn. */ +const OPT_OUT_PATTERNS = [ + new RegExp( + `${CLAUSE_IMPERATIVE_PREFIX}(?:do\\s+not|don['\\u2019]?t|never)\\s+(?:ever\\s+)?(?:remember|memorize)(?:\\s+or\\s+(?:save|store|record|capture|keep))?\\b`, + "i", + ), + new RegExp( + `${IMPERATIVE_NEGATION_PREFIX}(?:do\\s+not|don['\\u2019]?t|never)\\s+(?:ever\\s+)?(?:remember|memorize)(?:\\s+or\\s+(?:save|store|record|capture|keep)\\b|\\s+(?:any(?:\\s+of)?(?:\\s+(?:this|that|these|those|it))?|anything(?:\\s+(?:here|from\\s+this))?|this|that|these|those|it|the\\s+following\\b|(?:in|to)\\s+memory|the\\s+(?:conversation|chat|message|turn|discussion|note|notes|details))\\b)`, + "i", + ), + new RegExp( + `${IMPERATIVE_NEGATION_PREFIX}(?:do\\s+not|don['\\u2019]?t|never)\\s+(?:ever\\s+)?(?:save|store|record|capture|keep)(?:\\s+or\\s+(?:remember|memorize|save|store|record|capture|keep))?(?:\\s+(?:any(?:\\s+of)?(?:\\s+(?:this|that|these|those|it))?|anything(?:\\s+(?:here|from\\s+this))?|this|that|these|those|it|what\\s+i|where\\s+i|who\\s+i|how\\s+i|my\\b|our\\b|the\\s+following\\b|(?:in|to)\\s+memory|the\\s+(?:conversation|chat|message|turn|discussion|note|notes|details))\\b|(?=\\s*(?:$|[.!?:;,)\\]])))`, + "i", + ), + new RegExp( + `${IMPERATIVE_NEGATION_PREFIX}(?:do\\s+not|don['\\u2019]?t|never)\\s+(?:ever\\s+)?log\\s+(?:this(?:\\s+(?:conversation|message|chat|turn|session))?|anything\\s+(?:here|from\\s+this)|our\\s+(?:conversation|chat))\\b`, + "i", + ), + /\b(?:off\s+the\s+record|(?:do\s+not|don['\u2019]?t|never)\s+keep\s+(?:this\s+|that\s+|it\s+)?in\s+memory|(?:please\s+)?forget\s+(?:this(?:\s+(?:conversation|message|chat|turn))?|what\s+i\s+just\s+said))\b/i, + /(?:^|\s)(?:\[\s*no[-_\s]?save\s*\]|#no[-_]?save\b)/i, +]; + +/** High-confidence inline credential and secret patterns excluded from automatic capture. */ +const SECRET_PATTERNS = [ + /\bsuiprivkey1[02-9ac-hj-np-z]{50,}\b/i, + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, + /\bsk-(?:proj-|ant-(?:api\d+-)?|live-|test-)?[A-Za-z0-9_-]{20,}\b/, + /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/, + /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, + /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, + /\bAIza[0-9A-Za-z_-]{35}\b/, + /\b(?:glpat-[A-Za-z0-9_-]{16,}|npm_[A-Za-z0-9]{36}|[sprk]k_(?:live|test)_[A-Za-z0-9]{16,}|hf_[A-Za-z0-9]{30,})\b/, + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, + /[A-Za-z][A-Za-z0-9+.-]{0,30}:\/\/[^\s/@:?=&]+:[^\s/@?=&]+@/, + /\b(?:(?:proxy-)?authorization\s*[:=]\s*(?:bearer|basic|token|digest)\s+\S+|bearer\s+[A-Za-z0-9._~+/=-]{20,})/i, + /(?:\b|(?<=[a-z0-9_-]))(?:passwords?|passwd|pwd|db[_-]?pass(?:word)?s?|passphrases?|api[_-\s]?keys?|apikeys?|secret[_-\s]?keys?|access[_-\s]?keys?|encryption[_-\s]?keys?|client[_-\s]?secrets?|secrets?|access[_-\s]?tokens?|refresh[_-\s]?tokens?|auth[_-\s]?tokens?|bearer[_-\s]?tokens?|session[_-\s]?tokens?|api[_-\s]?tokens?|private[_-\s]?keys?|priv[_-\s]?keys?|delegate[_-\s]?(?:private[_-\s]*)?keys?)["'`]?\s*[:=]\s*(?:["'`](?!\s*["'`])[^"'`\n]{4,}["'`]|(?!(?:string|number|boolean|null|undefined|none|true|false|required|optional)\b)[^\s'",;}{)\]]{6,})/i, + /\btoken["'`]?\s*[:=]\s*(?:["'`](?!\s*["'`])[^"'`\n]{8,}["'`]|(?!(?:string|number|boolean|null|undefined|none|true|false|required|optional)\b)[^\s'",;}{)\]]{12,})/i, + /(?:private[\s_-]*key|secret[\s_-]*key|delegate[\s_-]*(?:private[\s_-]*)?key|signing[\s_-]*key|api[\s_-]*key|seed)\b[^.\n]{0,32}\b(?:0x)?[0-9a-fA-F]{64}\b/i, + /\b(?:0x)?[0-9a-fA-F]{64}\b[^A-Za-z0-9\n]{0,4}(?:is|was)?[^A-Za-z0-9\n]{0,4}(?:my|the|our)?[^A-Za-z0-9\n]{0,4}(?:delegate[\s_-]*)?(?:private|secret|signing)[\s_-]*key\b/i, + /(?:\b|(?<=[a-z]))(?:(?:seed|recovery|secret|mnemonic)[\s_-]*(?:phrase|words)|mnemonic)[^A-Za-z0-9\n]{0,4}(?:is|are)?[^A-Za-z0-9\n]{0,4}(?:[a-z]{3,8}[ \t]+){11,23}[a-z]{3,8}\b/i, +]; + /** Memory trigger patterns — always capture if matched. */ const TRIGGER_PATTERNS = [ /remember|prefer|radši|zapamatuj/i, @@ -117,6 +165,25 @@ export function looksLikeInjection(text: string): boolean { ); } +/** Check if text contains an explicit conversational opt-out directive. */ +export function hasCaptureOptOut(text: string): boolean { + if (!text) return false; + if (OPT_OUT_PATTERNS.some((p) => p.test(text))) return true; + const normalized = normalizeForInjectionCheck(text); + return Boolean(normalized && OPT_OUT_PATTERNS.some((p) => p.test(normalized))); +} + +/** Alias for `hasCaptureOptOut` kept for callers checking opt-out predicates. */ +export function looksLikeOptOut(text: string): boolean { + return hasCaptureOptOut(text); +} + +/** Check if text contains high-confidence inline credentials or secret keys. */ +export function looksLikeSecret(text: string): boolean { + if (!text) return false; + return SECRET_PATTERNS.some((p) => p.test(text)); +} + /** Determine whether a conversation turn is worth capturing. */ export function shouldCapture(text: string): boolean { if (text.length < MIN_CAPTURE_LENGTH) return false; @@ -127,6 +194,8 @@ export function shouldCapture(text: string): boolean { if (emojiCount > MAX_EMOJI_COUNT) return false; if (looksLikeInjection(text)) return false; + if (hasCaptureOptOut(text)) return false; + if (looksLikeSecret(text)) return false; if (TRIGGER_PATTERNS.some((p) => p.test(text))) return true; return true; diff --git a/packages/openclaw-memory-memwal/src/hooks/capture.ts b/packages/openclaw-memory-memwal/src/hooks/capture.ts index 510b8f4a1..99974315a 100644 --- a/packages/openclaw-memory-memwal/src/hooks/capture.ts +++ b/packages/openclaw-memory-memwal/src/hooks/capture.ts @@ -8,10 +8,11 @@ import type { MemWal } from "@mysten-incubation/memwal"; import { resolveAgent } from "../config.js"; -import { shouldCapture } from "../capture.js"; +import { hasCaptureOptOut, shouldCapture } from "../capture.js"; import { extractMessages, parseMessageTimestamp, + stripMemoryTags, withRetry, withTimeout, type ExtractedMessage, @@ -50,6 +51,44 @@ function rememberBoundedKey(setOrMap: Set | Map, key: stri } } +function rawMessageText(msg: any): string { + let text = ""; + if (typeof msg?.content === "string") { + text = msg.content; + } else if (Array.isArray(msg?.content)) { + for (const block of msg.content) { + if (block?.type === "text" && typeof block.text === "string") { + text += block.text + "\n"; + } + } + } + return stripMemoryTags(text).trim(); +} + +/** + * Drop opted-out user turns and their trailing non-user replies before + * individual message filtering so assistant acknowledgements echoing an + * opted-out topic are never forwarded to `analyze()`. + */ +function extractNonOptedOutMessages(messages: any[], maxCount: number): ExtractedMessage[] { + const windowStart = Math.max(0, messages.length - maxCount); + let optOutActive = false; + const allowed: any[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (!msg || typeof msg !== "object") continue; + if (msg.role === "user") { + optOutActive = hasCaptureOptOut(rawMessageText(msg)); + } + if (i >= windowStart && !optOutActive) { + allowed.push(msg); + } + } + + return extractMessages(allowed, maxCount); +} + /** Register the agent_end hook for auto-capture. */ export function registerCaptureHook(api: any, client: MemWal, config: PluginConfig): void { const stateByScope = new Map(); @@ -115,7 +154,7 @@ export function registerCaptureHook(api: any, client: MemWal, config: PluginConf // Extract both user and assistant messages — the server LLM on analyze() // decides what's worth keeping. Assistant messages can contain user // commitments, decisions, and summaries that are valuable as memories. - const extracted = extractMessages( + const extracted = extractNonOptedOutMessages( event.messages, config.captureMaxMessages, ); diff --git a/packages/openclaw-memory-memwal/test/capture-optout.test.mjs b/packages/openclaw-memory-memwal/test/capture-optout.test.mjs new file mode 100644 index 000000000..c3e61197f --- /dev/null +++ b/packages/openclaw-memory-memwal/test/capture-optout.test.mjs @@ -0,0 +1,383 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hasCaptureOptOut, + looksLikeOptOut, + looksLikeSecret, + shouldCapture, +} from "../dist/capture.js"; +import { registerCaptureHook } from "../dist/hooks/capture.js"; + +test("shouldCapture rejects explicit conversational opt-out directives before TRIGGER_PATTERNS", () => { + const optOutTurns = [ + "Please do not remember or save this conversation (off the record): we are acquiring WidgetCorp on Friday.", + "Do not save this: I prefer working from Tokyo next month.", + "Please don't save any of this: I prefer working from Tokyo next month", + "Please don’t save any of this: I prefer working from Tokyo next month", + "Do not remember the following: I prefer working from Tokyo next month.", + "Please don't remember where I live: 123 Example Street.", + "For privacy, don't remember my home address: 123 Example Street.", + "For privacy, do not remember where I live: 123 Example Street.", + "My temporary medical appointment is at 3 PM and do not remember this.", + "My home address is 123 Example Street but do not save this in memory.", + "We are discussing executive compensation and do not log this conversation.", + "Do not save or remember this: my temporary medical appointment is at 3 PM.", + "Please don't remember my temporary medical appointment at 3 PM.", + "Never store this in memory: our internal codename is Project Aurora.", + "Never memorize my temporary hotel room number 412.", + "Do not record this conversation about executive compensation.", + "Don't capture this message about the upcoming reorg.", + "This is off the record, I am interviewing at another company.", + "Forget this conversation: we are considering a pivot next quarter.", + "Do not log this chat: our acquisition target is WidgetCorp.", + "Don't keep this in memory: my backup email is user@example.com.", + "[no-save] I decided to switch our database vendor.", + "I prefer Rust #nosave", + ]; + + for (const turn of optOutTurns) { + assert.equal(hasCaptureOptOut(turn), true, `Expected hasCaptureOptOut=true for: "${turn}"`); + assert.equal(looksLikeOptOut(turn), true, `Expected looksLikeOptOut=true for: "${turn}"`); + assert.equal(shouldCapture(turn), false, `Expected shouldCapture=false for: "${turn}"`); + } +}); + +test("shouldCapture rejects turns containing inline secrets and credentials", () => { + const secretTurns = [ + "My Sui key is suiprivkey1qp3000000000000000000000000000000000000000000000000000000000", + "Use OpenAI key sk-proj-1234567890abcdefghijklmnop for testing", + "Anthropic key is sk-ant-api03-1234567890abcdefghijklmnop", + "GitHub token ghp_1234567890abcdefghijklmnopqrstuvwx", + "Fine-grained token github_pat_11ABCDEFG0123456789_abcdefghijklmnop", + "AWS access key AKIAIOSFODNN7EXAMPLE in staging", + "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0", + "-----BEGIN ED25519 PRIVATE KEY----- AAAAC3NzaC1lZDI1NTE5", + "My delegate_key = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "My delegate private key is 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + 'Config uses api_key = "sk-1234567890abcdefghijklmnop" in local tests', + "Staging test account uses password: hunter2 for integration runs", + 'Service webhook uses secret="my_super_secret_123" for local verification', + "Remember that api_key: superSecretProductionTokenValue12345", + "My seed phrase is witch collapse practice feed shame open despair creek road again ice least", + ]; + + for (const turn of secretTurns) { + assert.equal(looksLikeSecret(turn), true, `Expected secret match for: "${turn}"`); + assert.equal(shouldCapture(turn), false, `Expected shouldCapture=false for: "${turn}"`); + } +}); + +test("ordinary developer conversations and bare identifier mentions are captured", () => { + const developerTurns = [ + "We never log request bodies in production", + "Do not index that JSONB column", + "Our api_key is configured via Railway", + "Check that pwd is writable in the container", + "Always pass token via header to the gateway", + "I don't remember why we chose Redis for rate limiting", + "I prefer Rust but don't remember when we adopted it.", + "We use Postgres and don't remember why we picked that version.", + "I prefer Rust, but do not remember when we adopted it.", + `My Sui account id is 0x${"ab".repeat(32)}`, + ]; + + for (const turn of developerTurns) { + assert.equal(hasCaptureOptOut(turn), false, `Unexpected opt-out match for: "${turn}"`); + assert.equal(looksLikeSecret(turn), false, `Unexpected secret match for: "${turn}"`); + assert.equal(shouldCapture(turn), true, `Expected shouldCapture=true for: "${turn}"`); + } + + // Exact shorter prose phrases (< 30 chars MIN_CAPTURE_LENGTH) are also free of false-positive matches. + for (const shortPhrase of ["Check that pwd is writable", "pass token via header"]) { + assert.equal(hasCaptureOptOut(shortPhrase), false); + assert.equal(looksLikeSecret(shortPhrase), false); + } +}); + +test("registerCaptureHook suppresses both opted-out user turn and trailing assistant reply", async () => { + const analyzedPayloads = []; + const mockClient = { + analyze: async (text) => { + analyzedPayloads.push(text); + return { facts: [{ id: String(analyzedPayloads.length), text }] }; + }, + }; + + let handler = null; + const mockApi = { + on: (event, fn) => { + if (event === "agent_end") handler = fn; + }, + logger: { info: () => {}, warn: () => {}, debug: () => {} }, + }; + + registerCaptureHook(mockApi, mockClient, { + defaultNamespace: "default", + captureMaxMessages: 10, + requestTimeoutMs: 5_000, + }); + + // 1. Opted-out user turn followed by an assistant acknowledgement echoing WidgetCorp. + const history = [ + { + id: "u1", + role: "user", + content: + "Please do not remember this (off the record): we are acquiring WidgetCorp on Friday.", + }, + { + id: "a1", + role: "assistant", + content: + "Understood, regarding WidgetCorp acquisition on Friday, I will keep that out of memory.", + }, + ]; + + await handler( + { success: true, messages: [...history] }, + { sessionKey: "agent:main:test-optout-turn" }, + ); + assert.equal( + analyzedPayloads.length, + 0, + "analyze() must not be called when the turn opted out, even when assistant echoes the topic", + ); + + // 2. Subsequent normal user turn is captured, while the earlier opted-out turn and its assistant reply stay excluded. + history.push( + { + id: "u2", + role: "user", + content: "We never log request bodies in production and our api_key is configured via Railway.", + }, + { + id: "a2", + role: "assistant", + content: "Got it, request bodies are never logged and api_key comes from Railway.", + }, + ); + + await handler( + { success: true, messages: [...history] }, + { sessionKey: "agent:main:test-optout-turn" }, + ); + assert.equal(analyzedPayloads.length, 1); + assert.ok(!analyzedPayloads[0].includes("WidgetCorp"), "opted-out topic must never leak on later turns"); + assert.ok(analyzedPayloads[0].includes("We never log request bodies in production")); + + // 3. Short opt-out marker ("[no-save]") also suppresses trailing assistant reply. + await handler( + { + success: true, + messages: [ + { id: "u3", role: "user", content: "[no-save]" }, + { + id: "a3", + role: "assistant", + content: "Understood, regarding Project Aurora, nothing from this turn will be stored.", + }, + ], + }, + { sessionKey: "agent:main:test-short-optout" }, + ); + assert.equal(analyzedPayloads.length, 1, "short [no-save] user turn must suppress assistant reply"); + + // 4. Hook-level regression: phrases like "don't save any of this" or "save or remember this" suppress echoes. + await handler( + { + success: true, + messages: [ + { + id: "u4", + role: "user", + content: "Please don’t save any of this: I prefer working from Tokyo next month", + }, + { + id: "a4", + role: "assistant", + content: "Noted, I will not store your Tokyo remote work preference in memory.", + }, + ], + }, + { sessionKey: "agent:main:test-tokyo-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 1, + "opt-out phrases like 'don't save any of this' must suppress assistant echoes from reaching analyze()", + ); + + await handler( + { + success: true, + messages: [ + { + id: "u5", + role: "user", + content: "Do not save or remember this: my temporary medical appointment is at 3 PM.", + }, + { + id: "a5", + role: "assistant", + content: "Understood, your 3 PM appointment will not be stored in Walrus Memory.", + }, + ], + }, + { sessionKey: "agent:main:test-medical-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 1, + "opt-out phrases like 'save or remember this' must suppress assistant echoes from reaching analyze()", + ); + + // 5. Hook-level regression: explicit remember imperatives with other objects ("remember the following", "remember where I live") suppress echoes. + await handler( + { + success: true, + messages: [ + { + id: "u6", + role: "user", + content: "Do not remember the following: I prefer working from Tokyo next month.", + }, + { + id: "a6", + role: "assistant", + content: "Understood, I will not store your Tokyo remote work arrangement.", + }, + ], + }, + { sessionKey: "agent:main:test-tokyo-remember-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 1, + "opt-out phrases like 'do not remember the following' must suppress assistant echoes from reaching analyze()", + ); + + await handler( + { + success: true, + messages: [ + { + id: "u7", + role: "user", + content: "Please don't remember where I live: 123 Example Street.", + }, + { + id: "a7", + role: "assistant", + content: "Got it, I will not store your home address in memory.", + }, + ], + }, + { sessionKey: "agent:main:test-address-remember-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 1, + "opt-out phrases like 'please don't remember where I live' must suppress assistant echoes from reaching analyze()", + ); + + // 6. Hook-level regression: coordinated first-person statements ("I prefer Rust but don't remember...") must still be captured alongside the assistant reply. + await handler( + { + success: true, + messages: [ + { + id: "u8", + role: "user", + content: "I prefer Rust but don't remember when we adopted it.", + }, + { + id: "a8", + role: "assistant", + content: "Noted, you prefer Rust for backend services.", + }, + ], + }, + { sessionKey: "agent:main:test-coordinated-first-person" }, + ); + assert.equal( + analyzedPayloads.length, + 2, + "coordinated first-person 'but don't remember' prose must be captured by the hook", + ); + assert.ok(analyzedPayloads[1].includes("I prefer Rust but don't remember when we adopted it.")); + + // 7. Hook-level regression: explicit privacy-directed remember opt-out after a comma ("For privacy, don't remember my home address: ...") suppresses echoes. + await handler( + { + success: true, + messages: [ + { + id: "u9", + role: "user", + content: "For privacy, don't remember my home address: 123 Example Street.", + }, + { + id: "a9", + role: "assistant", + content: "Understood, your address 123 Example Street will not be stored in Walrus Memory.", + }, + ], + }, + { sessionKey: "agent:main:test-privacy-comma-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 2, + "opt-out phrases after a comma like 'For privacy, don't remember...' must suppress assistant echoes from reaching analyze()", + ); + + // 8. Hook-level regression: explicit opt-outs after coordinating conjunctions ("and do not remember this", "but do not save this in memory") suppress echoes. + await handler( + { + success: true, + messages: [ + { + id: "u10", + role: "user", + content: "My temporary medical appointment is at 3 PM and do not remember this.", + }, + { + id: "a10", + role: "assistant", + content: "Understood, I will not store your 3 PM medical appointment.", + }, + ], + }, + { sessionKey: "agent:main:test-conjunction-remember-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 2, + "opt-out phrases after 'and' like 'and do not remember this' must suppress assistant echoes from reaching analyze()", + ); + + await handler( + { + success: true, + messages: [ + { + id: "u11", + role: "user", + content: "My home address is 123 Example Street but do not save this in memory.", + }, + { + id: "a11", + role: "assistant", + content: "Got it, 123 Example Street will not be saved in memory.", + }, + ], + }, + { sessionKey: "agent:main:test-conjunction-save-optout" }, + ); + assert.equal( + analyzedPayloads.length, + 2, + "opt-out phrases after 'but' like 'but do not save this in memory' must suppress assistant echoes from reaching analyze()", + ); +});