claude-mem 的 Toast 通知服务:把 Observation 保存事件接入 macOS 原生通知的完整实现路线
本篇基于仓库内的 Maestro 实施 playbook Phase-01-Toast-Notification-Service.md 与配套测试 playbook Phase-02-Toast-Notification-Tests.md,完整还原 claude-mem 中“Observation 保存后弹出 macOS 原生通知”这一功能的实现路线:依赖安装、配置开关设计、ToastNotifier 功能模块的规范、在 observation 广播管线中的挂接方式,以及六个单元测试用例的覆盖策略。读完本文,你可以在任何遵循 claude-mem 架构的分支中复现一套“配置受控、平台隔离、永不阻塞主流程”的本地通知模块。
一、功能定位:为什么把通知挂在 observation 保存管线上
claude-mem 的核心工作流是:Agent 会话结束后,worker 解析 transcript、生成 observation(结构化记忆条目)、写入 SQLite,并通过 SSE 把新 observation 广播给前端 viewer。playbook 的目标非常明确——让 observation 落库这个“值得打扰用户”的事件,转化为一条 macOS 原生通知:
When saving an observation with
CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED=truein~/.claude-mem/settings.json, a native macOS notification shows the observation's title and subtitle.
这一设计决定了整个实现的四条主线,playbook 的每个 Task 一一对应:
| 主线 | 对应 Task | 关键产出 |
|---|---|---|
| 依赖引入 | 安装 node-notifier |
node-notifier@10.0.1(dependencies)、@types/node-notifier@8.0.5(devDependencies) |
| 配置开关 | 扩展 SettingsDefaultsManager |
新增 CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED,默认 'false' |
| 通知模块 | 创建 ToastNotifier.ts |
单一导出函数 sendObservationToast(title, subtitle) |
| 管线挂接 | 修改 ResponseProcessor.ts |
在 syncAndBroadcastObservations() 循环中调用 toast |
playbook 采用“每个 Task 附 Completed: ... 结论”的格式记录执行结果,最后一项确认构建通过:
Completed: Build succeeded, binary compiled (60.5 MB), marketplace synced, worker restarted and healthy
二、配置开关:CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED 的落点
playbook 要求把新开关加入 SettingsDefaultsManager.ts,具体位置有三个:
SettingsDefaults接口中,放在“Feature Toggles”区段、紧邻CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED之后;DEFAULTS对象同区段,取值'false'(所有设置值在 claude-mem 中统一以字符串存储);- 附注释
// macOS toast notifications for saved observations; - 同步加入
SettingsRoutes的settingKeys白名单与布尔校验列表,保证 worker 的设置路由能识别并校验它。
对照当前仓库的 SettingsDefaultsManager.ts 可以印证这个“区段”的组织方式:CLAUDE_MEM_WELCOME_HINT_ENABLED(第 170 行)与 CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false'(第 171 行)正是 Feature Toggles 区段的相邻成员,CLAUDE_MEM_TRANSCRIPTS_ENABLED 紧随其后——toast 开关的插入点就在这组开关之间。
理解这个开关为什么能真正生效,需要理解 SettingsDefaultsManager 的三层解析机制(当前源码中全部可见):
- 文件缺失时自动播种:
loadFromFile(settingsPath)发现文件不存在时,会把完整DEFAULTS通过writeJsonFileAtomic原子写入磁盘(见 SettingsDefaultsManager.ts 中loadFromFile的首个分支)。注释特别强调“a fresh settings.json is seeded with EVERY default ... and persisted values then win over DEFAULTS”——这正是 toast 开关需要注册进DEFAULTS的原因:未注册的键在播种时不会落盘,用户之后手动写入也会因不在白名单而被丢弃。 - 持久化值优先:加载时以
{ ...this.DEFAULTS }为底,逐键用文件中的值覆盖(if (flatSettings[key] !== undefined)),即用户写的"CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED": "true"会覆盖默认'false'。 - 环境变量最高优先:
applyEnvOverrides()遍历DEFAULTS的全部键,凡process.env[key]有定义即覆盖——所以CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED=true既可以写在~/.claude-mem/settings.json,也可以直接以环境变量注入,后者优先级更高。
设置文件路径由 paths.ts 统一导出:export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json')(第 48 行),即 ~/.claude-mem/settings.json。启用该功能的最小配置为:
{
"CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED": "true"
}
一个值得注意的事实:当前仓库快照中 SettingsDefaults 接口与 DEFAULTS 对象里已没有 CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED 这一键,package.json 的 dependencies 中也只有 better-auth 系列,没有 node-notifier。从源码结构看,playbook 所记录的这一功能在当前主线上已被移除或从未并入——本文将其作为“完整的实施档案 + 可复现的参考设计”来讲读,这也是仓库保留 .maestro/playbooks/ 的价值所在。
三、ToastNotifier:按 ObservationBroadcaster 范式编写的功能模块
playbook 对 src/services/worker/agents/ToastNotifier.ts 的规格描述极为具体,核心约束是:仿照 ObservationBroadcaster.ts 的“功能模块、非 class”模式,只导出一个函数。
先看模板本体。ObservationBroadcaster.ts 只有两个纯函数 broadcastObservation / broadcastSummary,结构是:空值守卫(if (!worker?.sseBroadcaster) return)→ 业务过滤(shouldEmitProjectRow,内部项目不发 SSE)→ 执行动作。没有状态、没有类实例,天然适合被 ResponseProcessor 这种流程文件以 fire-and-forget 方式调用。
按 playbook 的五条行为规格,sendObservationToast 的参考实现如下(依据 playbook 描述重构,非当前仓库现存代码):
import notifier from 'node-notifier';
import { logger } from '../../../utils/logger.js';
import { SettingsDefaultsManager } from '../../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../../shared/paths.js';
/**
* ToastNotifier — native macOS notifications for saved observations.
* Functional module (not a class), mirrors ObservationBroadcaster.ts.
* Fire-and-forget: never throws, must not crash the observation pipeline.
*/
export function sendObservationToast(
title: string | null,
subtitle: string | null
): void {
try {
// 1. Platform guard — macOS only
if (process.platform !== 'darwin') {
return;
}
// 2. Load settings from ~/.claude-mem/settings.json
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
// 3. Toggle check — handle both string 'true' and boolean true
const enabled = settings.CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED;
if (enabled !== 'true' && enabled !== true) {
return;
}
// 4. Native notification (no sound)
notifier.notify({
title: title || 'Observation saved',
message: subtitle || '',
sound: false
});
} catch (error) {
// 5. Log but NEVER throw
logger.warn('TOAST', 'failed to send observation toast', {
error: error instanceof Error ? error.message : String(error)
});
}
}
五条规格各自解决一个真实风险,逐条拆解:
- 平台守卫(
process.platform !== 'darwin'立即返回):node-notifier在无 GUI 或 Linux/Windows 环境下notify()可能回调 error 或产生无意义的终端提示;在入口直接短路,保证非 macOS 机器上零开销、零日志噪音。 - 设置加载走
loadFromFile而非环境变量快捷路径:与 worker 其余模块一致,以USER_SETTINGS_PATH为唯一配置事实源(环境变量仍可通过applyEnvOverrides间接生效)。 - 布尔双形态兼容:settings.json 中该键以字符串
'true'存储(SettingsDefaults接口声明为string),但用户可能手写成 JSON 布尔true;enabled !== 'true' && enabled !== true同时放行两种写法。 - 兜底文案:
title || 'Observation saved'、message: subtitle || '',保证 observation 的 title/subtitle 任一为 null 时通知仍合法(与 Phase-02 测试用例 4、5 一一对应)。 - 整体 try/catch +
logger.warn('TOAST', ...):这是整个模块最重要的契约——永不抛出。toast 属于“锦上添花”的旁路功能,一次通知失败绝不能拖垮 observation 写入与 SSE 广播主链路。这与 playbook 中“follows the same fire-and-forget pattern as the Chroma sync and folder CLAUDE.md updates”的表述一致。
日志约定也值得注意:logger.warn('TOAST', ...) 使用 claude-mem 统一的 logger(level, source, message, meta?) 格式(logger.ts),TOAST 作为独立 source 便于用 npm run worker:logs(即 worker-logs.cjs)按来源过滤排查通知失败。
四、管线挂接:syncAndBroadcastObservations 中的调用位置
playbook 指定的挂接点在 ResponseProcessor.ts 的 syncAndBroadcastObservations() 函数内(当前源码中定义于第 598 行,由第 554 行 await syncAndBroadcastObservations(...) 调用)。该函数遍历本批 observation,对每条执行 broadcastObservation(worker, {...})(当前源码第 658 行)把新 observation 推给 SSE 广播器。
playbook 要求在此循环内、紧跟现有 broadcastObservation(worker, {...}) 调用之后(约第 247 行,对应 playbook 执行时的行号)追加:
import { sendObservationToast } from './ToastNotifier.js';
// ...
broadcastObservation(worker, { /* ...既有 payload... */ });
sendObservationToast(obs.title, obs.subtitle); // 新增:fire-and-forget
从源码结构看,这个位置的选择有三个理由:
- 语义对齐:SSE 广播发生在 observation 已写入持久层之后,toast 与 SSE 是同一事件的两种“出站通知”(一个给同机的 viewer 前端,一个给操作系统通知中心),放在相邻行保持了“一次落库、两条出站”的清晰时序;
- 不 await、不 try:调用方不等待
node-notifier完成,错误已在sendObservationToast内部被吞掉,主循环的异常路径完全不受影响; - 循环内逐条调用:一批会话可能产出多条 observation,每条独立成一条通知(title/subtitle 一一对应),与 SSE 逐条广播的粒度一致。
五、测试策略:Phase-02 的六个用例如何锁定行为契约
Phase-02-Toast-Notification-Tests.md 把 Phase-01 的五条行为规格转化为 tests/services/worker/agents/ToastNotifier.test.ts 的六个可执行用例。其 mock 策略是:
vi.mock('node-notifier', ...)拦截notify()并记录调用参数;- mock
SettingsDefaultsManager.loadFromFile以精确控制CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED的取值(隔离磁盘依赖); - 平台伪造使用
vi.spyOn(process, ...)或Object.defineProperty(process, 'platform', ...)并保存/还原,避免污染其他测试。
六个用例与规格条目的映射关系:
| 用例名 | 输入条件 | 断言 | 锁定的规格 |
|---|---|---|---|
sends notification when enabled on macOS |
platform=darwin,设置 'true' |
notify 以匹配的 title/message 被调用 |
主路径 |
does not send notification when disabled |
设置 'false' |
notify 未被调用 |
规格 3(开关) |
does not send notification on non-macOS platforms |
platform=linux,设置 'true' |
notify 未被调用 |
规格 1(平台守卫) |
handles null title gracefully |
(null, 'subtitle') |
以兜底 title 调用 notify |
规格 4 |
handles null subtitle gracefully |
('title', null) |
以空 message 调用 notify |
规格 4 |
does not throw when notifier errors |
令 notify 抛出 |
sendObservationToast 不抛出 |
规格 5(永不崩溃) |
Phase-02 还包含两条工程护栏:先跑 npx vitest run tests/services/worker/agents/ToastNotifier.test.ts 单独验证,再跑全量 npx vitest run 确认无回归;并预先声明了“存在 24 个与本功能无关的既有失败”作为验收基线——若新设置键破坏了 SettingsDefaultsManager 相关 fixture,则需给 fixture 补 CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED: 'false'。这条要求揭示了一个容易踩的坑:向 SettingsDefaults 注册新键会改变 getAllDefaults() 的输出形状,任何对该形状做深比较的测试 fixture 都会失配。
六、构建与验证:npm run build-and-sync
playbook 的收尾 Task 是构建验证,命令链在 package.json 中可查:build-and-sync = build(同步插件清单、构建 hooks、生成 lockfile)+ sync-marketplace + node scripts/restart-marketplace-worker.cjs,即编译、分发与 worker 重启一气呵成。playbook 记录的结果是构建成功、二进制编译完成、marketplace 同步完成、worker 重启后健康。
由于该功能依赖 Bun/Node 混合工具链(engines 要求 node >= 20.12.0 或 bun >= 1.0.0),复现时的完整验证顺序是:npm run build-and-sync(编译与重启)→ npx vitest run(单测与回归)→ 在 macOS 上把 ~/.claude-mem/settings.json 的开关置为 "true",触发一次 observation 保存,确认通知中心出现以 observation title 为标题、subtitle 为正文的静默通知(sound: false)。
七、当前仓库状态核对
为保证事实边界清晰,对照当前仓库快照需要明确三点:
src/services/worker/agents/ToastNotifier.ts与tests/services/worker/agents/ToastNotifier.test.ts不存在于当前源码树,全仓库源码中也检索不到node-notifier、sendObservationToast或CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED的任何引用;- SettingsDefaultsManager.ts 的
SettingsDefaults接口已不含该键(接口当前以CLAUDE_MEM_SERVER_BETA_*系列收尾); - 因此本文的“参考实现”代码块是依据 playbook 规格逐条重构的示例,而配置系统、广播器范式、
ResponseProcessor挂接点等所有周边事实均以当前仓库文件为准并给出了路径。
八、小结
这套 playbook 展示了一个小型旁路功能的完整工程闭环:依赖声明 → 注册进统一设置系统(播种、持久化、环境变量三层覆盖)→ 以功能模块 + 平台守卫 + 永不抛错实现的旁路模块 → 在既有广播循环中一行挂接 → 用六个 mock 用例锁定全部行为规格 → 构建同步后健康检查。其中“fire-and-forget + 全函数 try/catch + 旁路失败只告警不中断”的模式,是 claude-mem 中 Chroma 同步、folder CLAUDE.md 更新等旁路功能共用的稳定性设计,也是在其 worker 内新增任何“通知类”功能时最值得沿用的契约。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00