SLOPSHOPPER

workbuddy-supermemory

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

band
v?NOASSERTIONupdated 2026-10-18Akiyama-sama/workbuddy-supermemory
A shopper browsing a rack in a slop shop
README

workbuddy-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(存在 ~/.workbuddy/settings.json)
  • Node.js 在 PATH 上(hook 用 node 执行)
  • 一个 Supermemory 后端,二选一:
  • 本地自托管 —— 跑着的 supermemory-server(http://127.0.0.1:6767),见自托管文档;或
  • 云端 —— 在 console.supermemory.ai 拿 API key。

让 Agent 帮你装(复制下面这段,粘贴给 Claude Code / Codex / WorkBuddy 均可)

帮我安装 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 和服务器里的全部记忆

记忆如何按项目隔离(跨 harness 共享的原理)

记忆存放在以仓库 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。

Source 1 files
hooks/recall-strip.js 167 lines
1function 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