树莓派一类无 GUI 的设备能不能参与 Obsidian 笔记协作?能,但前提是不依赖 Obsidian 进程。之前的文章介绍了直接将笔记写入 CouchDB,让 Obsidian LiveSync 自动拉取。但那是单向的——其他设备改动的数据并不能自动同步回来。
这次我们用 OpenClaw 插件实现真正的双向同步:_changes 长连接实时拉取 CouchDB 变更到本地 vault,fs.watch 监控本地文件变更并按 LiveSync 协议推回 CouchDB。所有代码作为 OpenClaw 插件运行,一行命令部署到任意节点。
整体架构 链接到标题
continuous pull| B C -->|Rabin-Karp CDC
PUT leaf + plain| B C ---|fs.watch| D["本地 Vault"] style A fill:#e3f2fd,stroke:#1565c0 style B fill:#fff3e0,stroke:#e65100 style C fill:#e8f5e9,stroke:#2e7d32 style D fill:#f3e5f5,stroke:#7b1fa2
两条同步路径:
- Pull 方向(CouchDB → 本地):通过
_changes?feed=continuous长连接监听变更,拿到新文档或更新后重建本地文件 - Push 方向(本地 → CouchDB):
fs.watch监控文件变更,经 Rabin-Karp 分块后按 LiveSync 协议写入 CouchDB,其他设备的 Obsidian LiveSync 自动拉取
CouchDB 数据模型 链接到标题
Obsidian LiveSync 使用两级文档结构:
| 文档类型 | 说明 | 示例 |
|---|---|---|
plain |
笔记元数据,包含路径、时间戳、引用叶子列表 | Inbox/笔记.md |
leaf |
内容分块,天然去重 | h:abc123def456 |
一篇笔记的分块过程:
原始内容 (UTF-8)
↓ Rabin-Karp 滚动哈希分块
[chunk1, chunk2, chunk3, ...]
↓ xxhash64 生成 leaf ID
["h:xxx", "h:yyy", "h:zzz", ...]
↓ PUT 到 CouchDB
plain 文档: { _id: "path/to/note.md", type: "plain", children: ["h:xxx", ...] }
leaf 文档: { _id: "h:xxx", type: "leaf", data: "..." }
Pull 方向:实时拉取 CouchDB 变更 链接到标题
_changes 长连接
链接到标题
CouchDB 提供 _changes 接口,feed=continuous 模式建立一个 HTTP 长连接,服务端有变更时实时推送:
const url = `${baseUrl}/${dbName}/_changes?feed=continuous&include_docs=true&since=${lastSeq}`;
const req = http.request(url, { auth, headers: { Accept: 'text/event-stream' } }, (res) => {
let buffer = '';
res.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || line.startsWith(':')) continue;
try {
const event = JSON.parse(line);
handleChange(event); // 处理每条变更
} catch { /* 忽略解析错误 */ }
}
});
});
since 参数记录上一次处理的 seq,断连后重新连接时从断点续传。
初始全量同步 链接到标题
首次启动时拉取所有已存在的文档,搭建本地 vault:
async function initialSync() {
// 获取所有文档
const allDocs = await getAllDocs();
for (const doc of allDocs.rows) {
if (doc.id.startsWith('_') || doc.id === 'obsydian_livesync_version') continue;
const plainDoc = await getDoc(doc.id);
if (plainDoc.type !== 'plain' || !plainDoc.children) continue;
// 拼接叶子内容
let content = '';
for (const leafId of plainDoc.children) {
const leaf = await getDoc(leafId);
content += leaf.data;
}
// 写入本地文件
const filePath = path.join(vaultPath, plainDoc.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, 'utf-8');
}
}
实时变更处理 链接到标题
长连接推送的每个变更事件做增量处理:
deleted: true→ 删除本地文件type === 'plain'→ 重组叶子内容,更新本地文件- 其他类型(
leaf、元数据文档)→ 跳过
Push 方向:本地变更推回 CouchDB 链接到标题
文件监控 链接到标题
const watcher = fs.watch(vaultPath, { recursive: true }, (eventType, filename) => {
if (shouldIgnore(filename)) return;
debouncedPush(filename); // 2s 防抖
});
2 秒防抖避免批量写入时频繁触发。过滤规则:隐藏文件、临时文件、非 .md 文件均跳过。
Rabin-Karp 内容分块 链接到标题
内容分块是实现去重的关键。使用滚动哈希(Rabin-Karp 指纹)在固定大小的窗口上计算哈希,满足特定比特条件时切分:
export function chunkContent(content: string): Buffer[] {
const input = Buffer.from(content, 'utf-8');
const chunks: Buffer[] = [];
let start = 0;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 1) + input[i]) & 0xffffffff;
if ((hash % avgChunkSize) === (unit - 1) || i - start >= maxChunkSize) {
chunks.push(input.subarray(start, i + 1));
start = i + 1;
hash = 0;
}
}
// 剩余部分
if (start < input.length) {
chunks.push(input.subarray(start));
}
return chunks;
}
实际配置参数:unit=64, avg=256, max=1024, min=128, window=48。
Leaf ID 生成 链接到标题
每块内容用 xxhash64 生成 ID,相同内容产生相同 ID,天然去重:
import { h64 } from 'xxhash-wasm';
export async function leafId(data: Buffer): Promise<string> {
const hash = await h64(data.toString() + '-' + data.length);
return 'h:' + hash.toString(36);
}
写入 CouchDB 链接到标题
两步写入,按 LiveSync 协议:
- PUT 叶子文档:每个 chunk 一条,ID 是 xxhash64
- PUT plain 文档:引用叶子列表,path 字段与文件名一致
// 第一步:写入叶子
for (const chunk of chunks) {
const id = await leafId(chunk);
if (!(await leafExists(id))) {
await putRaw(id, JSON.stringify({
_id: id, type: 'leaf', data: chunk.toString('utf-8'),
}));
}
}
// 第二步:写入 plain 文档
await putRaw(relativePath, JSON.stringify({
_id: relativePath,
type: 'plain',
path: relativePath,
children: leafIds,
mtime: Date.now(),
size: content.length,
}));
关于 HTTP 客户端 链接到标题
OpenClaw gateway 运行时重写了全局 fetch,导致容器内访问 http://内网地址:5984 这类内部 IP 时路由异常。解决方案是直接用 node:http 模块:
import * as http from 'node:http';
function request(options: http.RequestOptions, body?: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`CouchDB ${res.statusCode}: ${data}`));
} else {
resolve(data);
}
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
OpenClaw 插件封装 链接到标题
插件是上述所有逻辑的载体,负责初始化、生命周期管理、配置读取。
插件清单 链接到标题
{
"id": "obsidian-sync",
"name": "Obsidian Sync",
"version": "0.3.0",
"entrypoints": { "main": "dist/index.js" },
"activation": { "onStartup": true },
"contracts": { "tools": ["obsidian_sync_status"] }
}
入口代码 链接到标题
import type { PluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
import { SyncDaemon } from './sync.js';
const entry: PluginEntry = {
onStartup: async (api) => {
// 读取配置
const config = api.config.plugins.entries['obsidian-sync']?.config;
const daemon = new SyncDaemon({
couchdbUrl: config.couchdbUrl, // http://内网地址:5984
couchdbUser: config.couchdbUser,
couchdbPassword: config.couchdbPassword,
dbName: config.dbName, // obsidiannotes
vaultPath: config.vaultPath, // vault 目录路径
logLevel: config.logLevel || 'info',
});
// 启动双向同步
daemon.start();
// 注册 tool,提供状态查询
api.registerTools([{
id: 'obsidian_sync_status',
name: 'Obsidian Sync Status',
handler: async () => ({
running: daemon.isRunning(),
watching: daemon.isWatching(),
stats: daemon.getStats(),
}),
}]);
},
onShutdown: async () => {
daemon?.stop();
},
};
export default entry;
配置注入 链接到标题
在目标节点的 openclaw.json 中添加:
{
"plugins": {
"allow": ["obsidian-sync"],
"entries": {
"obsidian-sync": {
"enabled": true,
"config": {
"couchdbUrl": "http://内网地址:5984",
"couchdbUser": "sync_user",
"couchdbPassword": "***",
"dbName": "obsidiannotes",
"vaultPath": "/home/user/.openclaw/obsidian-vault",
"logLevel": "info"
}
}
}
}
}
双向同步验证 链接到标题
部署后通过日志确认同步状态:
# 查看插件日志
docker logs openclaw-openclaw-gateway-1 2>&1 | grep obsidian-sync
Pull 方向验证 链接到标题
[obsidian-sync] [INFO] Initial sync complete: 491 synced, 267 skipped
[obsidian-sync] [INFO] Connecting to _changes feed (since=3614-...)
初始全量同步完成后,进入长连接监听模式。在其他设备上修改笔记,本地的日志应立刻出现 pull 事件,vault 目录下的对应文件自动更新。
Push 方向验证 链接到标题
[obsidian-sync] [INFO] Starting file watcher for push...
[obsidian-sync] [INFO] File watcher started
...(编辑文件后 2 秒)...
[obsidian-sync] [INFO] Pushed: Inbox/笔记.md (2 chunks)
在节点本地直接修改 vault 内的 .md 文件,日志中应出现 Pushed 记录。在 CouchDB 中确认文档已更新:
curl -u "sync_user:密码" "http://内网地址:5984/obsidiannotes/Inbox%2F笔记.md"
返回的 _rev 应为新版本,children 包含最新的 leaf 引用。
注意事项 链接到标题
- 非
.md文件被忽略:图片、附件等二进制文件当前不作处理 - 分块参数影响性能:
avg越小分块越多,去重率越高但写入量增大;avg越大相反。256 是平衡值 - 冲突处理:当前策略是"最后写入者胜",适合单人使用。多人协作场景需要接入 CouchDB 的冲突 API
- 容器内网络:OpenClaw 的全局 fetch 覆盖可能导致内网请求异常,优先使用
node:http - 首次同步可能较慢:全量扫描所有文档并重建文件,取决于 vault 大小,但之后增量同步很快
总结 链接到标题
这个插件的价值在于让任何能运行 OpenClaw gateway 的设备(包括树莓派)都能加入 Obsidian 的协作网络。从架构上看,没有复杂的中心化服务,所有同步通过 CouchDB 完成——CouchDB 本身只做文档存储和变更通知,不关心业务逻辑。
插件代码约 500 行 TypeScript,核心依赖只有 xxhash-wasm(Rabin-Karp 分块算法手写实现)。对于有类似需求的场景——让 CLI-only 节点参与笔记协作——这是一个轻量且可复用的方案。