Supermemory hooks: SessionStart loads project memories; UserPromptSubmit recalls memories relevant to the prompt; PreToolUse approves read-only Supermemory…

给 WorkBuddy / CodeBuddy 加上跨会话的持久记忆,后端用 Supermemory:自动捕获你的编码会话、在每个新会话里召回相关上下文——不用手动"记住这个"。
本仓库是官方 supermemoryai/claude-supermemory 插件 hook 层的移植,适配 WorkBuddy / CodeBuddy。WorkBuddy 的 hook 引擎与 Claude Code 协议同构,所以行为和 Claude Code 插件完全一致:同样的自动捕获、同样的召回注入。
安装时往 WorkBuddy 的用户级配置里注册三个 hook:
| Hook | 触发时机 | 效果 |
|---|---|---|
session-start.js | 新会话开始 | 把本项目的 profile + 近期记忆注入为会话上下文 |
recall-directive.js | 每次提交 prompt | 按 prompt 语义搜索记忆,注入相关条目 |
capture.js | 每轮结束(Stop) | 把新增的对话内容上传,抽取为记忆 |
所有 hook 都是 fail-open:记忆服务连不上、key 缺失时,WorkBuddy 照常运行、只是没有记忆,不会报错。
~/.workbuddy/settings.json)node 执行)supermemory-server(http://127.0.0.1:6767),见自托管文档;或帮我安装 workbuddy-supermemory,让 WorkBuddy 拥有跨会话持久记忆,步骤如下:
1. 先探测本地后端:curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:6767/
- 返回 200:用本地服务,跳到第 2 步(key 会自动从 ~/.supermemory/api-key 读取)。
- 连不上:问我有没有云端 API key(sm_ 开头);如果都没有,先执行
curl -fsSL https://supermemory.ai/install | bash
装好本地 supermemory-server 并启动(首次启动会自动生成 key 到 ~/.supermemory/api-key)。
2. 克隆并进入仓库:
git clone https://github.com/Akiyama-sama/workbuddy-supermemory && cd workbuddy-supermemory
3. 通读 README.md 和 install.sh 源码,确认它只做一件事:把三个 hook 合并进
~/.workbuddy/settings.json(脚本会先自动备份该文件),然后执行:
- 本地后端:./install.sh
- 云端后端:./install.sh https://api.supermemory.ai sm_你的key
4. 安装后离线验证 hook 链路(不需要重启 WorkBuddy):
echo '{"hook_event_name":"SessionStart","session_id":"verify-1","cwd":"你的项目绝对路径"}' \
| SUPERMEMORY_API_URL=<第3步用的地址> CODEBUDDY_PLUGIN_ROOT=<仓库绝对路径> \
node hooks/session-start.js
输出里有 "supermemory" 和 memories loaded / No previous memories 字样即为成功。
5. 告诉我:重启 WorkBuddy,开新会话应看到 "◪ supermemory · N memories loaded"。
卸载运行 ./uninstall.sh(只移除 hook,保留数据和记忆)。
git clone https://github.com/Akiyama-sama/workbuddy-supermemory
cd workbuddy-supermemory
# 本地服务(默认,自动读 ~/.supermemory/api-key):
./install.sh
# 云端:显式传地址和 key
./install.sh https://api.supermemory.ai sm_YOUR_API_KEY
install.sh 做三件事:① 带时间戳备份 ~/.workbuddy/settings.json;② 把 API key 写入 ~/.supermemory-codebuddy/credentials.json(权限 600);③ 把三个 hook 合并进 settings.json 的 hooks 段。
装完重启 WorkBuddy,新会话应出现 ◪ supermemory · N memories loaded for <项目名>。
重复运行 install.sh 是幂等的(只会替换自己名下的条目,不动其他 hook,比如账户切换类的)。卸载:
./uninstall.sh # 移除 hook;保留 ~/.supermemory-codebuddy 和服务器里的全部记忆
记忆存放在以仓库 git remote 哈希命名的容器里:repo_<项目名>__<remote-hash>。这与 Claude Code、Codex 的官方 Supermemory 插件用的是同一套命名规则——所以如果你在同一个仓库已经用了那两家,WorkBuddy 会直接读写同一个记忆容器,三家 harness 的记忆天然共享,无需任何迁移。没有 git remote 的目录回退到本地路径身份。
想显式指定容器,启动 WorkBuddy 前设 SUPERMEMORY_REPO_TAG。
hooks/ 主体是未改动的上游代码,只改了 harness 相关的部分:
| 项目 | claude-supermemory | 本移植 |
|---|---|---|
| 插件根变量 | ${CLAUDE_PLUGIN_ROOT} | ${CODEBUDDY_PLUGIN_ROOT} |
| 项目级配置目录 | .claude/.supermemory-claude/ | .codebuddy/.supermemory-codebuddy/ |
| 用户数据目录 | ~/.supermemory-claude/ | ~/.supermemory-codebuddy/ |
| statusline 清理目标 | ~/.claude/settings.json | ~/.workbuddy/settings.json |
PreToolUse recall-approve | 启用(自动放行 MCP 只读工具) | 未安装,原因见下 |
mcp-proxy.js | 有 | 移除(hooks 未引用) |
为什么不装 MCP / PreToolUse:上游的 PreToolUse hook 只服务于 Supermemory MCP 工具的自动放行,recall-directive 的发现文案也会引导模型去调 MCP search_memory 工具。而 WorkBuddy 的 MCP server 只能通过 App 界面配置、没法脚本注入,所以本移植完全不走 MCP——召回和捕获全靠 hook 直连 REST API,功能完整。发现文案已裁剪,不再提示一个不存在的工具。
SUPERMEMORY_API_URL —— 后端地址(默认 https://api.supermemory.ai);install.sh 会把它写进 hook 命令里。SUPERMEMORY_REPO_TAG —— 强制指定容器标签。SUPERMEMORY_ISOLATE_WORKTREES=true —— 按 worktree 路径隔离记忆(默认按 remote)。SUPERMEMORY_DEBUG=1 —— hook 输出调试日志。SUPERMEMORY_CC_API_KEY —— 内联 API key(优先于 ~/.supermemory-codebuddy/credentials.json)。hooks/ 移植过来的 hook 脚本 + lib/(核心逻辑)
.codebuddy-plugin/ 插件清单(name/version/description)
install.sh 把 hook 合并进 ~/.workbuddy/settings.json(自动备份)
uninstall.sh 反向操作,保留数据
MIT。本项目衍生自 supermemoryai/claude-supermemory(上游 manifest 声明 MIT),见 LICENSE。
hooks/recall-strip.js 167 lines1function returnedFacts(contexts, tag) {
2 const facts = [];
3 for (const context of contexts || []) {
4 if (typeof context !== 'string') continue;
5 const block = context.match(
6 new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`),
7 )?.[1];
8 if (!block) continue;
9 let current = -1;
10 for (const line of block.split('\n')) {
11 if (
12 line.startsWith('## User Profile') ||
13 line.startsWith('## Recent Context')
14 ) {
15 current = -1;
16 continue;
17 }
18 const text = line.match(/^- ◪ (.+)$/)?.[1];
19 if (text) {
20 current = facts.length;
21 facts.push(text);
22 } else if (tag === 'supermemory-context' && current >= 0) {
23 facts[current] += `\n${line}`;
24 }
25 }
26 }
27 return facts.map((fact) => fact.trimEnd());
28}
29
30const headlines = [
31 (count, narrow) =>
32 narrow
33 ? `clarified ${count}`
34 : `supermemory clarified ${count} ${count === 1 ? 'thing' : 'things'}`,
35 (count, narrow) =>
36 narrow
37 ? `surfaced ${count}`
38 : `supermemory surfaced ${count} ${count === 1 ? 'detail' : 'details'}`,
39 (count, narrow) =>
40 narrow
41 ? `${count} brought back`
42 : `supermemory brought back ${count} ${count === 1 ? 'memory' : 'memories'}`,
43 (count, narrow) =>
44 narrow
45 ? `found ${count}`
46 : `supermemory found ${count} ${count === 1 ? 'useful detail' : 'useful details'}`,
47 (count, narrow) =>
48 narrow
49 ? `uncovered ${count}`
50 : `supermemory uncovered ${count} ${count === 1 ? 'detail' : 'details'}`,
51 (count, narrow) =>
52 narrow
53 ? `remembers ${count}`
54 : `supermemory remembered ${count} ${count === 1 ? 'thing' : 'things'}`,
55];
56
57export function register(on) {
58 let facts = [];
59 let expanded = false;
60 let active = 0;
61 let headlineIndex = Math.floor(Math.random() * headlines.length) - 1;
62
63 on('classic.SessionStart', async ($, e, next) => {
64 facts = [];
65 expanded = false;
66 active = 0;
67 try {
68 const result = await next(e);
69 facts = returnedFacts(result.additionalContext, 'supermemory-context');
70 if (facts.length) headlineIndex = (headlineIndex + 1) % headlines.length;
71 return result;
72 } finally {
73 $.ui.invalidate('ui.render');
74 }
75 });
76
77 on('classic.UserPromptSubmit', async ($, e, next) => {
78 try {
79 facts = [];
80 expanded = false;
81 active = 0;
82 const result = await next(e);
83 facts = returnedFacts(result.additionalContext, 'supermemory-recall');
84 if (facts.length) headlineIndex = (headlineIndex + 1) % headlines.length;
85 return result;
86 } finally {
87 $.ui.invalidate('ui.render');
88 }
89 });
90
91 on(
92 'ui.render',
93 { component: 'AbovePrompt', surface: 'terminal' },
94 async ($, e, next) => {
95 const original = await next(e);
96 if (e.props.hasSurvey || facts.length === 0) return original;
97
98 const { Box, Text, Button } = $.ui.resolve(e);
99 const width = Math.min(
100 e.props.bodyColumns,
101 Math.max(1, Math.min(64, e.props.bodyColumns - 4)),
102 );
103 const label = `◪ ${headlines[headlineIndex](facts.length, e.props.bodyColumns < 32)}`;
104
105 return Box({
106 flexDirection: 'column',
107 children: [
108 original,
109 Button({
110 key: 'recall-details',
111 label: `${label} ${expanded ? '▴' : '▾'}`,
112 plain: true,
113 dimColor: true,
114 onPress: () => {
115 expanded = !expanded;
116 if (expanded) active = 0;
117 $.ui.invalidate('ui.render');
118 },
119 }),
120 expanded
121 ? Box({
122 paddingX: 1,
123 width,
124 flexDirection: 'column',
125 children: [
126 Text({ wrap: 'wrap', children: facts[active] }),
127 facts.length > 1
128 ? Box({
129 flexDirection: 'row',
130 children: [
131 Button({
132 key: 'recall-previous',
133 label: '‹',
134 plain: true,
135 dimColor: true,
136 onPress: () => {
137 active =
138 (active - 1 + facts.length) % facts.length;
139 $.ui.invalidate('ui.render');
140 },
141 }),
142 Text({
143 dimColor: true,
144 children: ` ${active + 1}/${facts.length} `,
145 }),
146 Button({
147 key: 'recall-next',
148 label: '›',
149 plain: true,
150 dimColor: true,
151 onPress: () => {
152 active = (active + 1) % facts.length;
153 $.ui.invalidate('ui.render');
154 },
155 }),
156 ],
157 })
158 : null,
159 ],
160 })
161 : null,
162 ],
163 });
164 },
165 );
166}
167