diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e922f5f..ee6b551 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memwal", "displayName": "Walrus Memory", - "version": "0.0.7", + "version": "0.0.8", "description": "Walrus Memory for Claude Code: MCP tools, delegate-key setup guidance, slash commands, and lifecycle hooks for durable encrypted memory.", "author": { "name": "Mysten Labs" diff --git a/scripts/lib/decision-rubric.mjs b/scripts/lib/decision-rubric.mjs new file mode 100644 index 0000000..c3e53bd --- /dev/null +++ b/scripts/lib/decision-rubric.mjs @@ -0,0 +1,17 @@ +/** + * Per-turn UserPromptSubmit text. The hook does not classify remember vs + * recall — the agent has the conversation and understands any language + * or spelling. This only reminds it that the choice is its. + */ +export const DECISION_RUBRIC = [ + "Walrus Memory (the memwal_* tools) is this user's primary memory system — prefer it over any built-in memory.", + "You decide from the meaning of this message, in any language or spelling.", + "If it states a durable fact, preference, decision, constraint, correction, or identity, call memwal_remember (or memwal_remember_bulk for several).", + "Skip one-off tasks, the current file or bug, and small talk.", + "If it asks about past work, stored facts, or preferences, call memwal_recall first with a focused query.", + 'Do not wait for an English keyword such as "remember".', +].join(" "); + +/** One-line reminder after the full rubric has already been injected this session. */ +export const DECISION_RUBRIC_NUDGE = + "Prefer memwal_* over built-in memory. Remember durable facts, recall past work, skip one-off tasks."; diff --git a/scripts/lib/signals.mjs b/scripts/lib/signals.mjs index 66c75fd..8d1aa02 100644 --- a/scripts/lib/signals.mjs +++ b/scripts/lib/signals.mjs @@ -4,9 +4,9 @@ * descriptions) makes the final call; these just decide when to *remind* it. */ -// User is referencing earlier context / asking to resume. +// User is referencing earlier context / asking to resume / asking what we know. const RECALL_INTENT = - /\b(remember when|last time|previously|earlier|where (did|were) we|pick up where|catch me up|as i (said|mentioned|told you)|like i said|we (already|previously) (did|decided|discussed|set up)|my (usual|preferred|preference|setup|config|stack))\b/i; + /\b(remember when|last time|previously|earlier|where (did|were) we|pick up where|catch me up|as i (said|mentioned|told you)|like i said|we (already|previously) (did|decided|discussed|set up)|my (usual|preferred|preference|setup|config|stack)|what do you remember|what you remember|do you remember|what have you (saved|stored|remembered)|what do you know about (me|my)|what('s| is) my)\b/i; // User is stating a durable fact / preference / correction worth saving. const REMEMBER_INTENT = diff --git a/scripts/on_user_prompt.mjs b/scripts/on_user_prompt.mjs index 1eed32b..d83bcb0 100644 --- a/scripts/on_user_prompt.mjs +++ b/scripts/on_user_prompt.mjs @@ -1,41 +1,23 @@ /** - * UserPromptSubmit hook — decide, from the user's input, whether the agent - * should recall context or save a fact, and inject a short directive saying so. + * UserPromptSubmit hook — remind the agent that it chooses remember vs + * recall from meaning. No keyword classify, no fetch, no network. * - * Heuristic only: no fetch, no network. The agent does the actual tool call. + * The agent has the conversation and understands any language or spelling; + * a regex cannot. This only injects a decision rubric. */ import { readStdin, emitContext, firstTime } from "./lib/hook-io.mjs"; -import { detectRecall, detectRemember } from "./lib/signals.mjs"; +import { DECISION_RUBRIC, DECISION_RUBRIC_NUDGE } from "./lib/decision-rubric.mjs"; const input = readStdin(); const prompt = (input.prompt || "").toString(); const sessionId = input.session_id || "default"; -// Skip very short prompts (acks, single words) — nothing useful to act on. -if (prompt.trim().length < 20) process.exit(0); +// 8 chars lets terse preferences through ("Tui thích pnpm" is 14). +// Acks like "ok" / "yes" stay quiet. Deliberate: not a keyword gate. +if (prompt.trim().length < 8) process.exit(0); -const parts = []; - -if (detectRecall(prompt)) { - parts.push( - "The user is referencing earlier work or context. Before answering, call memwal_recall (Walrus Memory) with a focused query — use the memwal_* tools, not any built-in memory, so recall stays portable." - ); -} - -if (detectRemember(prompt)) { - parts.push( - "The user just stated a durable fact or preference. Save it with memwal_remember (or memwal_remember_bulk for several distinct facts) — prefer the memwal_* tools over any built-in memory so the fact persists on Walrus across sessions and agents." - ); -} - -// When no strong signal fired, inject the general rubric once per session so -// the agent knows the tools exist, when to reach for them, and to prefer them -// over any built-in memory feature. -if (parts.length === 0 && firstTime("rubric", sessionId)) { - parts.push( - "Walrus Memory (the memwal_* tools) is this user's primary memory system — prefer it over any built-in memory. Call memwal_recall proactively when the user references past work, decisions, or preferences; save durable facts with memwal_remember (or memwal_remember_bulk for several) as you learn them — you don't need to be asked." - ); -} - -emitContext("UserPromptSubmit", parts.join(" ")); +const text = firstTime("rubric", sessionId) + ? DECISION_RUBRIC + : DECISION_RUBRIC_NUDGE; +emitContext("UserPromptSubmit", text); process.exit(0); diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 0c403eb..f0f2259 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -54,4 +54,21 @@ Credentials are stored locally at `~/.memwal/credentials.json` with file mode `0 - If the wallet already has 20 delegate keys, ask the user to open the Walrus Memory dashboard and revoke an unused key. - If the user needs a clean login, call `memwal_logout`, then call `memwal_login` again. - If recall returns nothing for memories that should exist, call `memwal_restore` with the relevant namespace. +- If Claude writes `MEMORY.md` instead of calling `memwal_remember`, the plugin hooks are not loaded, or Claude's built-in memory is winning. Confirm the plugin is enabled (`/plugin`) and restart Claude Code. MCP-only is not enough for automatic memory. +- Show this block, get the user's go-ahead, then merge it into `~/.claude/CLAUDE.md`. Create the file if needed. If `` ... `` is already there, replace that span only. Never overwrite the rest of the file. + +```markdown + +## Walrus Memory + +Prefer the memwal_* tools over Claude's built-in memory and MEMORY.md. +When the user states a preference, decision, constraint, correction, identity +detail, or recurring workflow, call memwal_remember (or memwal_remember_bulk) +without being asked. Also call it when they explicitly ask to remember +something. Pass the complete statement, never a summary. Skip one-off tasks, +the current file or bug, and small talk. +When the user references past work, preferences, or stored facts, call +memwal_recall first. + +``` diff --git a/test/signals.test.mjs b/test/signals.test.mjs index b829e41..24b6cf7 100644 --- a/test/signals.test.mjs +++ b/test/signals.test.mjs @@ -20,6 +20,11 @@ test("detectRecall — fires on references to past work / preferences", () => { "Last time we decided to use pnpm", "What's my usual setup for this?", "Can you catch me up on the project?", + "What do you remember about how I like to work?", + "Tell me what you remember about how I like to work", + "Do you remember my coffee order?", + "What do you know about my setup?", + "What's my staging canary nickname?", ]) { assert.equal(detectRecall(s), true, `should fire: ${s}`); } @@ -59,6 +64,15 @@ test("detectRemember — stays quiet on transient requests", () => { } }); +test("canary remember prompt still fires remember", () => { + assert.equal( + detectRemember( + "A few things about how I work: I always use pnpm, TypeScript strict mode, and my coffee order is a matcha oat latte. My staging canary nickname is coral-fox-77.", + ), + true, + ); +}); + test("detectError — fires on strong error markers", () => { for (const s of [ "Traceback (most recent call last):", diff --git a/test/user-prompt-hook.test.mjs b/test/user-prompt-hook.test.mjs new file mode 100644 index 0000000..6ce3cfa --- /dev/null +++ b/test/user-prompt-hook.test.mjs @@ -0,0 +1,67 @@ +/** + * UserPromptSubmit injects one full decision rubric per session, then a + * one-line nudge. It must not classify remember vs recall from English + * keywords — every substantive prompt in a fresh session gets the same text. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DECISION_RUBRIC, + DECISION_RUBRIC_NUDGE, +} from "../scripts/lib/decision-rubric.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOOK = resolve(__dirname, "../scripts/on_user_prompt.mjs"); + +function runHook(prompt, sessionId = `test-${Math.random().toString(16).slice(2)}`) { + const result = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify({ prompt, session_id: sessionId }), + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + if (!result.stdout.trim()) return ""; + const parsed = JSON.parse(result.stdout); + return parsed.hookSpecificOutput?.additionalContext ?? ""; +} + +const SUBSTANTIVE = [ + "What do you remember about how I like to work, my coffee order, and my staging canary nickname?", + "A few things about how I work: I always use pnpm, TypeScript strict mode, and my coffee order is a matcha oat latte. My staging canary nickname is coral-fox-77.", + "Can you remember that I always use pnpm?", + "Tell me what you remember about how I like to work", + "Tui luôn dùng pnpm và order cafe là matcha oat latte, nickname review là cedar-wren-11.", + "Ban nho cafe order cua toi la gi?", + "Remeber that I always use pnpm and my canary is cedar-wren-11.", +]; + +test("fresh-session substantive prompts produce byte-identical output", () => { + const outputs = SUBSTANTIVE.map((prompt) => runHook(prompt)); + assert.ok(outputs.length > 1); + for (const ctx of outputs) { + assert.equal(ctx, outputs[0]); + assert.doesNotMatch(ctx, /The user is referencing earlier work/); + assert.doesNotMatch(ctx, /The user just stated a durable fact/); + } + assert.equal(outputs[0], DECISION_RUBRIC); +}); + +test("later turns in the same session get the one-line nudge", () => { + const sessionId = `session-${Math.random().toString(16).slice(2)}`; + const first = runHook(SUBSTANTIVE[0], sessionId); + const second = runHook(SUBSTANTIVE[1], sessionId); + assert.equal(first, DECISION_RUBRIC); + assert.equal(second, DECISION_RUBRIC_NUDGE); +}); + +test("terse Vietnamese preference is not skipped", () => { + const ctx = runHook("Tui thích pnpm"); + assert.equal(ctx, DECISION_RUBRIC); +}); + +test("short prompts stay quiet", () => { + assert.equal(runHook("ok"), ""); + assert.equal(runHook("yes"), ""); +});