树莓派一类无 GUI 的设备能不能参与 Obsidian 笔记协作?能,但前提是不依赖 Obsidian 进程。
这次我们用 OpenClaw 插件实现双向同步:基于 PouchDB 同步协议连接 CouchDB,同时监控本地文件变更推回 CouchDB。所有代码作为 OpenClaw 插件运行,一行命令部署到任意节点。
整体架构 链接到标题
(LevelDB)"] -- 写入/读取 --> F["vault/"] end style A fill:#e3f2fd,stroke:#1565c0 style B fill:#fff3e0,stroke:#e65100 style C fill:#e8f5e9,stroke:#2e7d32 style D fill:#f3e5f5,stroke:#7b1fa2
同步引擎使用 PouchDB 双向同步(db.sync(remoteDB, { live: true, retry: true })),PouchDB LevelDB 作为本地缓存层,监控文件变更后写入 PouchDB 自动推送到 CouchDB。
CouchDB 数据模型 链接到标题
Obsidian LiveSync 使用两级文档结构:
| 类型 | 说明 | 示例 |
|---|---|---|
plain |
文本笔记元数据,含路径、时间戳、叶子引用 | inbox/笔记.md |
newnote |
二进制文件(图片等),叶子存 base64 | assets/图.png |
leaf |
内容块,xxhash64 去重 | h:abc123def456 |
一篇笔记存储为:单个 plain/newnote 文档 + 引用一个或多个 leaf 文档。
同步引擎:PouchDB sync 链接到标题
核心依赖 PouchDB 的复制协议,负责全量同步和实时增量:
import PouchDB from 'pouchdb-core';
import level from 'pouchdb-adapter-leveldb';
import http from 'pouchdb-adapter-http';
import repl from 'pouchdb-replication';
PouchDB.plugin(level).plugin(http).plugin(repl);
const localDB = new PouchDB('./data', { adapter: 'leveldb' });
const remoteDB = new PouchDB('http://user:pass@couchdb:5984/my-ai-notes');
// 初始全量拉取
await localDB.replicate.from(remoteDB, { checkpoint: false });
// 本地已有文件推送到 CouchDB
await localDB.replicate.to(remoteDB, { checkpoint: false });
// 开启实时双向同步
const handler = localDB.sync(remoteDB, { live: true, retry: true, checkpoint: true });
handler.on('change', (info) => {
if (info.direction !== 'pull') return;
for (const doc of info.change.docs) {
if (doc.deleted) deleteFile(vaultDir, doc.path);
else if (doc.type === 'plain' || doc.type === 'newnote') {
const content = reconstructFile(localDB, doc);
writeFile(vaultDir, doc.path, content);
}
}
});
localDB.replicate.from(remoteDB) 做首次全量拉取,之后 sync({ live: true }) 通过 CouchDB 的 _changes 长轮询维持实时增量。
文件监控 链接到标题
用 chokidar 监控 vault 目录,比 fs.watch 更稳定且跨平台一致:
import chokidar from 'chokidar';
chokidar.watch(vaultDir, {
ignored: /(^|\/)\../, // 忽略隐藏文件
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
})
.on('add', async (filePath) => {
const content = await fs.readFile(filePath);
await writeDocToDB(localDB, relPath, content);
})
.on('change', async (filePath) => { /* 同上 */ })
.on('unlink', async (filePath) => {
await deleteDocFromDB(localDB, relPath);
});
内容写入 链接到标题
文件变更后写入 PouchDB,按 LiveSync 协议存储。xxhash64 生成 leaf ID,相同内容自动去重:
import xxhash from 'xxhash-wasm';
const hasher = await xxhash();
const leafId = (data: string) => 'h:' + hasher.h64(data + '-' + data.length).toString(36);
async function writeDocToDB(db, filePath, content) {
const isBinary = typeof content !== 'string';
const chunkData = isBinary ? content.toString('base64') : content;
const chunkId = leafId(chunkData);
const docId = filePath.toLowerCase(); // LiveSync 兼容
// 检查 leaf 是否存在
try { await db.get(chunkId); } catch {
await db.put({ _id: chunkId, data: chunkData, type: 'leaf' });
}
// 写入 plain/newnote 文档
const doc = {
_id: docId, path: filePath,
children: [chunkId], type: isBinary ? 'newnote' : 'plain',
ctime, mtime: Date.now(), size: content.length, eden: {},
};
await db.put(doc);
}
关键细节:
_id转小写——Obsidian LiveSync 的path2id()内部转小写查询,大小写不匹配会找不到文档- 单 chunk 存储——文本文件整体 hash,不进一步分块(与 LiveSync 的 Rabin-Karp CDC 不同,小于 50MB 文件无影响)
OpenClaw 插件封装 链接到标题
插件清单 链接到标题
{
"id": "obsidian-sync",
"name": "Obsidian LiveSync",
"version": "0.4.15",
"activation": { "onStartup": true },
"configSchema": {
"type": "object",
"required": ["couchdbUrl", "couchdbUser", "couchdbPassword", "vaultPath"],
"properties": {
"couchdbUrl": { "type": "string" },
"couchdbDb": { "type": "string", "default": "my-ai-notes" },
"couchdbUser": { "type": "string" },
"couchdbPassword": { "type": "string" },
"localDbPath": { "type": "string", "default": "./data" },
"vaultPath": { "type": "string" }
}
}
}
入口代码 链接到标题
使用 OpenClaw SDK 的 definePluginEntry 注册生命周期:
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { startSync } from "./sync.js";
export default definePluginEntry({
id: "obsidian-sync",
name: "Obsidian LiveSync",
register(api) {
const cfg = api.pluginConfig;
const config = {
couchdbUrl: cfg.couchdbUrl,
couchdbDb: cfg.couchdbDb || "my-ai-notes",
couchdbUser: cfg.couchdbUser,
couchdbPassword: cfg.couchdbPassword,
localDbPath: cfg.localDbPath || "./data",
vaultPath: cfg.vaultPath,
};
const result = await startSync(config, api.logger);
api.lifecycle.registerRuntimeLifecycle({
id: "obsidian-sync-cleanup",
async cleanup() {
await result.watcher.close();
result.syncHandler.cancel();
await result.localDB.close();
},
});
},
});
配置注入 链接到标题
在目标节点的 openclaw.json 中:
{
"plugins": {
"entries": {
"obsidian-sync": {
"enabled": true,
"config": {
"couchdbUrl": "http://couchdb:5984",
"couchdbDb": "my-ai-notes",
"couchdbUser": "couchdb_user",
"couchdbPassword": "***",
"localDbPath": "/home/node/.openclaw/obsidian-sync-data/pouchdb",
"vaultPath": "/home/node/.openclaw/obsidian-sync-data/vault"
}
}
}
}
}
双向同步验证 链接到标题
部署后通过日志确认状态:
[obsidian-sync] Initial sync done
[obsidian-sync] Initial upload done
[obsidian-sync] Catch-up done
[obsidian-sync] Live sync running
在其他设备修改笔记 → 日志出现 ✓ inbox/笔记.md;在节点本地编辑 vault 文件 → 日志出现 ~ inbox/笔记.md。
验证 CouchDB 文档:
curl -u couchdb_user:密码 "http://couchdb:5984/my-ai-notes/inbox%2F笔记.md"
总结 链接到标题
这个插件让任何能运行 OpenClaw gateway 的设备(包括树莓派)通过 PouchDB 同步协议加入 Obsidian 协作网络。所有同步通过 CouchDB 完成,代码约 500 行 TypeScript,核心依赖为 PouchDB + chokidar + xxhash-wasm。