SLOPSHOPPER

memory-watch

A side pane summarizing each auto-memory file the session creates or updates.

newpanebandguardcommand
v0.1.3Apache-2.0updated 2026-10-14vampik33/claude-plugins/plugins/memory-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-watch
│ ┃ Memory ✕ › fix the failing auth test and add an audit log call │ ┃ No memory written this session. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /memory-watch │ ⎿ memory-watch: Memory pane opened. │ │ ⟨Claude Code's own drawing⟩ memory [ hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ memory [ hide ]
Pane · Memory
No memory written this session.
README

memory-watch

A side pane summarizing each auto-memory file the session writes: what was created or updated, the memory's name, type and description, and how many lines changed.

version-bumps
09:05 created · feedback · +8 −0
Always bump the plugin version after changes

MEMORY.md (index)
09:05 updated · +1 −0

Installation

claude plugin install memory-watch@vampik-plugins

Behaviour

  • /memory-watch shows or hides the pane. It docks beside the transcript in the fullscreen layout (110+ columns), inline above the prompt otherwise.
  • A memory show line sits above the prompt once the session has written memory (or while the pane is open): show / hide toggles the pane (hotkey m once the band has the keys, ctrl+x tab or a click). While the pane is hidden, ● N new counts the writes since it was last open.
  • Newest first, the last 50 writes of the session; /clear empties it. The pane never opens by itself.

How It Works

A tool.call hook on Write and Edit looks at each call that went through (not denied, not failed, not held for review) whose path is …/.claude/projects/<slug>/memory/<file>.md, $CLAUDE_CONFIG_DIR/projects/<slug>/memory/<file>.md, or <file>.md in the directory the autoMemoryDirectory setting names. The summary comes from the file's frontmatter (name, description, type, at any indent, so metadata.type counts) and the tool result's patch. No model is called.

Not seen: writes by other sessions, by hand, or through Bash (rm, mv, sed -i), so deletions are not listed.

Needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).

Source 3 files
hooks/register.tsx 208 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, SettingsSource } from "claude-code";
3
4import type { MemoryChange } from "../types";
5import {
6  addChange,
7  headline,
8  memoryFile,
9  normalizeDir,
10  title,
11  toChange,
12  type Hunk,
13  type MemoryRoots,
14} from "./core.ts";
15
16const PANE = "memory-watch";
17
18const changes = atom(
19  { plugin: "memory-watch", key: "changes" } as const,
20  [] as MemoryChange[],
21);
22const pane = atom({ plugin: "memory-watch", key: "pane" } as const, false);
23const unread = atom({ plugin: "memory-watch", key: "unread" } as const, 0);
24
25/** Lists one change; counts it unread while the pane is closed. */
26async function record($: EngineInterface, change: MemoryChange) {
27  await update($, changes, (list) => addChange(list, change));
28  if (!(await read($, pane))) await update($, unread, (n) => n + 1);
29}
30
31/** The settings sources `autoMemoryDirectory` is taken from, highest first:
32 * the engine ignores it in the checked-in project settings. */
33const MEMORY_DIR_SOURCES: readonly SettingsSource[] = [
34  "policy",
35  "flag",
36  "local",
37  "user",
38];
39
40/** Where memory lives besides `~/.claude`, read at each write (both can change). */
41async function memoryRoots($: EngineInterface): Promise<MemoryRoots> {
42  const home = await $.env.get("HOME");
43  const configDir = normalizeDir(await $.env.get("CLAUDE_CONFIG_DIR"), home);
44  let memoryDir = "";
45  for (const source of MEMORY_DIR_SOURCES) {
46    const s = await $.settings.read({ source }).catch(() => ({}));
47    const dir = (s as { autoMemoryDirectory?: unknown }).autoMemoryDirectory;
48    if (typeof dir === "string" && dir.trim() !== "") {
49      memoryDir = normalizeDir(dir, home);
50      break;
51    }
52  }
53  return { configDir, memoryDir };
54}
55
56/**
57 * Records a write when the path is an auto-memory file. `text` is the file's
58 * new text; without it (an Edit's result holds the text before) it is read.
59 */
60async function note(
61  $: EngineInterface,
62  path: string,
63  action: MemoryChange["action"],
64  patch: readonly Hunk[],
65  text?: string,
66) {
67  const file = memoryFile(path, await memoryRoots($));
68  if (!file) return;
69  const after = text ?? (await $.fs.read(path).catch(() => ""));
70  await record($, toChange(file, action, after, patch, await $.clock.now()));
71}
72
73async function openPane($: EngineInterface) {
74  const opened = await $.ui.open({ id: PANE, title: "Memory" });
75  await update($, pane, () => opened.isPlaced);
76  await update($, unread, () => 0);
77  return opened;
78}
79
80/** Closes the pane if the stored state says open, opens it if not. */
81async function togglePane($: EngineInterface) {
82  if (await read($, pane)) {
83    await $.ui.close({ id: PANE });
84    await update($, pane, () => false);
85    return undefined;
86  }
87  return openPane($);
88}
89
90export const register: Register = (on) => {
91  on("session.start", async ($, e, next) => {
92    const r = await next(e);
93    await $.command.register({
94      name: "memory-watch",
95      description:
96        "Show or hide the pane summarizing this session's memory writes",
97    });
98    // a reload keeps an open pane
99    const panes = await $.ui.panes().catch(() => []);
100    await update($, pane, () => panes.some((p) => p.id === PANE && p.isPlaced));
101    return r;
102  });
103
104  // a /clear starts a new conversation: its memory writes start over
105  on("session.end", async ($, e, next) => {
106    const r = await next(e);
107    if (e.reason === "clear") {
108      await update($, changes, () => []);
109      await update($, unread, () => 0);
110    }
111    return r;
112  });
113
114  on("command.run", { command: "memory-watch" }, async ($) => {
115    const opened = await togglePane($);
116    return {
117      text: !opened
118        ? "Memory pane closed."
119        : opened.isPlaced
120          ? "Memory pane opened."
121          : "Memory pane is waiting for a wider terminal.",
122    };
123  });
124
125  // recording never fails the tool: what it ran is returned whatever note() does
126  on("tool.call", { tool: "Write" }, async ($, e, next) => {
127    const ran = await next(e);
128    if (ran.deny === undefined && !ran.isError && !ran.result.staged) {
129      const { type, content, structuredPatch } = ran.result;
130      const action = type === "create" ? "created" : "updated";
131      await note($, e.file_path, action, structuredPatch, content).catch(
132        () => {},
133      );
134    }
135    return ran;
136  });
137
138  on("tool.call", { tool: "Edit" }, async ($, e, next) => {
139    const ran = await next(e);
140    if (ran.deny === undefined && !ran.isError && !ran.result.staged)
141      await note($, e.file_path, "updated", ran.result.structuredPatch).catch(
142        () => {},
143      );
144    return ran;
145  });
146
147  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
148    const { Box, Text } = $.ui.resolve(e);
149    const list = await read($, changes);
150    return (
151      <Box flexDirection="column" width={e.props.bodyColumns}>
152        {list.length === 0 && (
153          <Text dimColor>No memory written this session.</Text>
154        )}
155        {list.map((c, i) => (
156          <Box
157            key={`change:${list.length - i}`}
158            flexDirection="column"
159            marginTop={i === 0 ? 0 : 1}
160          >
161            <Text bold wrap="truncate-end">
162              {title(c)}
163            </Text>
164            <Text dimColor wrap="truncate-end">
165              {headline(c)}
166            </Text>
167            {c.description && <Text>{c.description}</Text>}
168          </Box>
169        ))}
170      </Box>
171    );
172  });
173
174  // its own line under the other bands: a show/hide toggle, once there is
175  // something to show (or the pane is open, so it can be hidden)
176  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
177    if (e.props.hasSurvey) return next(e);
178    const open = await read($, pane);
179    if (!open && (await read($, changes)).length === 0) return next(e);
180    const fresh = open ? 0 : await read($, unread);
181    const below = await next(e);
182    const { Box, Button, Text } = $.ui.resolve(e);
183    return (
184      <Box flexDirection="column">
185        {below}
186        <Box flexDirection="row" columnGap={2} marginTop={1}>
187          <Text dimColor>memory</Text>
188          {fresh > 0 && <Text color="yellow">{`● ${fresh} new`}</Text>}
189          <Button
190            key="memory-watch-toggle"
191            hotkey="m"
192            dimColor
193            onPress={() => togglePane($)}
194          >
195            {open ? "hide" : "show"}
196          </Button>
197        </Box>
198      </Box>
199    );
200  });
201
202  on("ui.close", { id: PANE }, async ($, e, next) => {
203    const r = await next(e);
204    await update($, pane, () => false);
205    return r;
206  });
207};
208
hooks/core.ts 137 lines
1import type { MemoryChange } from "../types";
2
3export const MAX_CHANGES = 50;
4
5/** `~/.claude/projects/<slug>/memory/<file>.md`, any project's. */
6const MEMORY_PATH = /\/\.claude\/projects\/[^/]+\/memory\/([^/]+\.md)$/;
7/** `<slug>/memory/<file>.md` under a config dir's `projects/`. */
8const UNDER_PROJECTS = /^[^/]+\/memory\/([^/]+\.md)$/;
9/** `<file>.md` directly in a memory dir. */
10const IN_DIR = /^([^/]+\.md)$/;
11
12/** Where auto-memory lives besides `~/.claude`: both absolute, `/`-separated. */
13export type MemoryRoots = {
14  /** `CLAUDE_CONFIG_DIR`: memory is `<configDir>/projects/<slug>/memory/`. */
15  configDir?: string;
16  /** The `autoMemoryDirectory` setting: memory is `<memoryDir>/`. */
17  memoryDir?: string;
18};
19
20/** `/` separators, `~` expanded with `home`, no trailing slash or space; "" for none. */
21export function normalizeDir(dir: string | undefined, home?: string): string {
22  const strip = (s: string) => s.replaceAll("\\", "/").replace(/[\s/]+$/, "");
23  let d = strip((dir ?? "").trimStart());
24  if (d === "~" || d.startsWith("~/")) {
25    if (!home) return "";
26    d = strip(home) + d.slice(1);
27  }
28  return strip(d);
29}
30
31/** The file's name when the path is an auto-memory file, else undefined. */
32export function memoryFile(
33  path: string,
34  roots: MemoryRoots = {},
35): string | undefined {
36  const p = path.replaceAll("\\", "/");
37  const inside = (root: string | undefined, re: RegExp) =>
38    root && p.startsWith(`${root}/`)
39      ? re.exec(p.slice(root.length + 1))?.[1]
40      : undefined;
41  return (
42    MEMORY_PATH.exec(p)?.[1] ??
43    inside(roots.configDir && `${roots.configDir}/projects`, UNDER_PROJECTS) ??
44    inside(roots.memoryDir, IN_DIR)
45  );
46}
47
48export type Frontmatter = {
49  name?: string;
50  description?: string;
51  type?: string;
52};
53
54const FIELD = /^\s*(name|description|type):\s*(.*?)\s*$/;
55
56/**
57 * `name`, `description` and `type` from a leading `---` block, at any indent
58 * (`type` sits under `metadata:`); the first of each wins, quotes stripped.
59 */
60export function parseFrontmatter(text: string): Frontmatter {
61  const lines = text.split(/\r?\n/);
62  if (lines[0]?.trim() !== "---") return {};
63  const out: Frontmatter = {};
64  for (const line of lines.slice(1)) {
65    if (line.trim() === "---") return out;
66    const m = FIELD.exec(line);
67    if (!m) continue;
68    const key = m[1] as keyof Frontmatter;
69    const value = (m[2] ?? "").replace(/^(["'])(.*)\1$/, "$2");
70    if (out[key] === undefined && value !== "") out[key] = value;
71  }
72  return {}; // never closed: not frontmatter
73}
74
75export type Hunk = { lines: readonly string[] };
76
77/** Lines added and removed across a tool result's `structuredPatch`. */
78export function countLines(patch: readonly Hunk[]): {
79  added: number;
80  removed: number;
81} {
82  let added = 0;
83  let removed = 0;
84  for (const h of patch)
85    for (const l of h.lines) {
86      if (l.startsWith("+")) added++;
87      else if (l.startsWith("-")) removed++;
88    }
89  return { added, removed };
90}
91
92/** One change from the file's new text and the write's patch. */
93export function toChange(
94  file: string,
95  action: MemoryChange["action"],
96  text: string,
97  patch: readonly Hunk[],
98  at: number,
99): MemoryChange {
100  const counted = countLines(patch);
101  // a new file's patch can come back empty: every line is added
102  if (action === "created" && patch.length === 0)
103    counted.added =
104      text === "" ? 0 : text.replace(/\r?\n$/, "").split(/\r?\n/).length;
105  return { at, file, action, ...parseFrontmatter(text), ...counted };
106}
107
108/** Newest first, at most `max`. */
109export function addChange(
110  list: readonly MemoryChange[],
111  change: MemoryChange,
112  max = MAX_CHANGES,
113): MemoryChange[] {
114  return [change, ...list].slice(0, max);
115}
116
117/** `09:05`, local time. */
118export function fmtTime(at: number): string {
119  const d = new Date(at);
120  const pad = (n: number) => String(n).padStart(2, "0");
121  return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
122}
123
124/** `09:05 created · feedback · +12 −0` */
125export function headline(c: MemoryChange): string {
126  const parts = [`${fmtTime(c.at)} ${c.action}`];
127  if (c.type) parts.push(c.type);
128  parts.push(`+${c.added} −${c.removed}`);
129  return parts.join(" · ");
130}
131
132/** What names the memory: its frontmatter name, else the file (the index says so). */
133export function title(c: MemoryChange): string {
134  if (c.name) return c.name;
135  return c.file === "MEMORY.md" ? "MEMORY.md (index)" : c.file;
136}
137
types/index.d.ts 29 lines
1/** One write to an auto-memory file, as the pane lists it. */
2export type MemoryChange = {
3  /** When it was written, ms since epoch. */
4  at: number;
5  /** The file's name in the memory dir: `feedback_testing.md`, `MEMORY.md`. */
6  file: string;
7  action: "created" | "updated";
8  /** The frontmatter's `name`, `description` and `type`, where it has them. */
9  name?: string;
10  description?: string;
11  type?: string;
12  /** Lines the write added and removed. */
13  added: number;
14  removed: number;
15};
16
17declare module "claude-code" {
18  interface PluginState {
19    "memory-watch": {
20      /** Newest first, at most MAX_CHANGES. */
21      changes: MemoryChange[];
22      /** Whether the pane is open, as the last open or close left it. */
23      pane: boolean;
24      /** Changes recorded since the pane was last open. */
25      unread: number;
26    };
27  }
28}
29