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

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
claude plugin install memory-watch@vampik-plugins
/memory-watch shows or hides the pane. It docks beside the transcript in the fullscreen layout (110+ columns), inline above the prompt otherwise.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./clear empties it. The pane never opens by itself.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).
hooks/register.tsx 208 lines1import { 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};
208hooks/core.ts 137 lines1import 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}
137types/index.d.ts 29 lines1/** 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