树莓派一类无 GUI 的设备能不能参与 Obsidian 笔记协作?能,但前提是不依赖 Obsidian 进程。

这次我们用 OpenClaw 插件实现双向同步:基于 PouchDB 同步协议连接 CouchDB,同时监控本地文件变更推回 CouchDB。所有代码作为 OpenClaw 插件运行,一行命令部署到任意节点。

整体架构 链接到标题

flowchart LR A["Obsidian App"] -->|LiveSync| B[("CouchDB")] C["OpenClaw Plugin"] -->|PouchDB sync| B C ---|chokidar| D["本地 Vault"] subgraph C [Plugin] direction LR E["PouchDB
(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。