Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
17 changes: 17 additions & 0 deletions scripts/lib/decision-rubric.mjs
Original file line number Diff line number Diff line change
@@ -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.";
4 changes: 2 additions & 2 deletions scripts/lib/signals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
42 changes: 12 additions & 30 deletions scripts/on_user_prompt.mjs
Original file line number Diff line number Diff line change
@@ -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);
17 changes: 17 additions & 0 deletions skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- memwal:start -->` ... `<!-- memwal:end -->` is already there, replace that span only. Never overwrite the rest of the file.

```markdown
<!-- memwal:start -->
## 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.
<!-- memwal:end -->
```

14 changes: 14 additions & 0 deletions test/signals.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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):",
Expand Down
67 changes: 67 additions & 0 deletions test/user-prompt-hook.test.mjs
Original file line number Diff line number Diff line change
@@ -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"), "");
});
Loading