树莓派一类无 GUI 的设备能不能参与 Obsidian 笔记协作?能,但前提是不依赖 Obsidian 进程。之前的文章介绍了直接将笔记写入 CouchDB,让 Obsidian LiveSync 自动拉取。但那是单向的——其他设备改动的数据并不能自动同步回来。

这次我们用 OpenClaw 插件实现真正的双向同步:_changes 长连接实时拉取 CouchDB 变更到本地 vault,fs.watch 监控本地文件变更并按 LiveSync 协议推回 CouchDB。所有代码作为 OpenClaw 插件运行,一行命令部署到任意节点。

整体架构 链接到标题

flowchart LR A["Obsidian App"] -->|LiveSync| B[("CouchDB")] C["OpenClaw Plugin"] -->|_changes feed
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 协议:

  1. PUT 叶子文档:每个 chunk 一条,ID 是 xxhash64
  2. 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 节点参与笔记协作——这是一个轻量且可复用的方案。