For contributors: how the code is layered, what happens from start to end of one sync, what each package owns. For the product-level "what/why" see the README. This document is the spec of record — when behavior and documentation disagree, fix whichever is wrong, in the same change.
cmd/opendoc ──→ internal/cli ──→ internal/engine ──→ internal/adapter (interface)
│ │ ↑ implementations
│ ├─ internal/manifest │
│ ├─ internal/frontmatter
│ ├─ internal/naming ├─ internal/notion
└─ internal/config│ └─ internal/feishu
internal/layout└─ internal/ratelimit (shared by both adapters)
Core invariant: the engine knows nothing about platforms — it only knows the adapter.Adapter interface. Adding a new platform (Yuque, Confluence, …) = write a new package implementing that interface + register it at the CLI wiring point, zero changes to the engine.
The entire contract lives in one file: adapter.go — the comments are the spec. Three required methods:
| Method | Responsibility |
|---|---|
Enumerate(ctx) |
Streams out metadata for every document within the authorized scope (RemoteDoc: ID/AltID/Type/ParentID/Title/EditedAt/URL) — metadata only, no body |
FetchMarkdown(ctx, doc) |
Fetches one document's body, returns FetchResult: the raw Markdown (the object hashed for content_hash) + the degraded Body + asset references + inter-document links + degradation count |
DownloadAsset(ctx, ref, destPath) |
Downloads an asset; URLs are short-lived, must be fetched immediately |
Two optional capabilities, which the engine probes for via type assertion:
DatabaseExpander— fetches all rows' properties for a database in a single query (Notion implementation; queried once per db node per run).IncrementalEnumerator— enumerates only changed documents based on a checkpoint (Notion implementation; Feishu doesn't have one — its enumeration is inherently a full listing, so every run there is a reconciliation run). Implementing it changes the engine's contract: in an incremental run, "not enumerated" ≠ "deleted," so the engine turns off delete/move detection.
The orchestration entry point is Engine.Sync (engine.go):
- Preparation: ensure the directory structure exists, open the manifest.
- Mode decision (mode.go): decide per-platform whether this run does a full or incremental pass. Any one of the full-run conditions triggers a full run:
--full, no usable checkpoint, first run of the day (the first run each day forces reconciliation), or thenotion_reconcile_every_runscadence coming due. Feishu is always full. - Platforms run in parallel: each platform runs its own pipeline in one goroutine (id spaces don't overlap, each writes its own
sync_runsrow, no cross-platform contention; a fatal failure on one platform doesn't affect the other). - Whole-repo finalization (once, after all platforms finish): expired trash cleanup → regenerate
INDEX.md.
Full Run (Reconciliation Run): runFull (pipeline.go)
Enumerate the full listing → buildTree (build tree from parent pointers, resolve paths/breadcrumbs)
→ processNode per node (sequential loop)
→ reconcileDeletes (delete/move detection)
→ finalizeLinks (whole-repo internal link rewriting + empty directory sweep)
→ persist checkpoint
The order inside processNode (the order matters — read the comments before changing it):
Alias registration (before any skip — links must still resolve even if the target gets skipped)
→ mkdir → container node short-circuit (folder/db has no body)
→ move/rename follow (manifest local_path ≠ this run's path → move the file)
→ pre-fetch skip (remote_edited unchanged → skip the whole fetch)
→ FetchMarkdown → compute content_hash (on the raw Markdown; for db rows, properties are folded into the Canonical serialization)
→ register assets/links → download assets and replace in-body image URLs with relative paths
→ post-fetch skip (content_hash unchanged → don't write to disk)
→ frontmatter + atomic write (temp file + rename) → manifest upsert
Incremental Run: runIncremental (incremental.go)
Only enumerates changed documents; position resolution is done against the manifest (for ancestors not enumerated in this run), not against this run's tree; only dirty databases are pre-queried; no delete/move detection is performed. Three classes of scenarios are intentionally deferred (directory shape changes, _index.md regeneration, a parent moving while the node itself wasn't edited) — all are backstopped by the next reconciliation run. The file header comment lists them in full.
The leaf-to-directory conversion that happens the first time a leaf gains a child (<x>.md → <x>/README.md) is also handled in incremental runs (leafToDirNode).
- content_hash is always computed on the "fetched original" (before degradation, before rewriting), so degraded output and link rewriting never make a document look "dirty." Link rewriting also deliberately doesn't write back the hash.
- All disk writes go through
atomicWrite(temp + fsync + rename); readers never see a half-written file. - Frontmatter is hand-rendered (no YAML library), guaranteeing deterministic key order and byte-for-byte stability across runs (frontmatter.go).
- The degradation contract: loss is never silent. Conversion is inevitably lossy (whiteboards, embedded tables, oversized pages, unsupported blocks), but every degraded resource block must land at least two of three things: readable degraded content, a drillable ID, and a jumpable online link — and every degradation increments a counter in the sync report. An unknown block is preserved as its verbatim tag rather than dropped. The exact marker shapes the engine emits, per platform, are catalogued in plugin/skills/opendoc/references/degradation-tags.md (that file ships in the plugin and must stay in sync with the emitters in
internal/feishu/degrade.go/internal/notion/degrade.go).
A pure-Go driver, modernc.org/sqlite (no cgo), single connection (SetMaxOpenConns(1)) so that database/sql serializes all statements — the load bottleneck is network I/O, not the ledger. The schema is idempotent CREATE TABLE IF NOT EXISTS (constants at the top of manifest.go); deleting the database and rerunning rebuilds it from scratch. Five tables:
| Table | Key | What it records |
|---|---|---|
documents |
id (platform-native ID) |
The main ledger: type/parent/title/local_path(UNIQUE)/remote_edited/content_hash/status(active|trashed|error|pending_assets) |
assets |
remote_key (the platform's stable key, not a temporary URL) |
sha256, on-disk path, done|pending |
links |
(from_id, to_id) | Backlinks: who references me — used to fix up referrers on rename/move |
doc_aliases |
alias |
Secondary ID → primary ID (Feishu wiki node_token, Notion's hyphen-stripped form) |
sync_runs |
auto-increment id | Per-run audit: checkpoint high-water mark + stats JSON (including mode, feeding the next run's cadence decision) |
- Root resolution priority:
--root>OPENDOC_ROOT>~/.opendoc; the internal directory is called.internal(not.opendoc, to avoid~/.opendoc/.opendoc). - README/leaf duality: a node with children or a container type → directory +
README.md; a leaf →<name>.md. A database → directory +_index.md(row-property index) + one file per row. - Naming rule chain (naming.go
Component, in order): slug (strip only illegal characters, keep CJK and spaces) → empty title →untitled-<id prefix>→ reserved-name suffix (readme/_index/claude/agents…) → 200-byte truncation → same-directory casefold collision gets-<first 8 chars of id>appended → numeric fallback. First one there gets the clean name. - Orphan pages: a node whose parent isn't in the enumeration results → filed under
_orphans/at the platform root.
Content-addressed storage: sha256 the downloaded bytes → assets/<first 2 chars of sha>/<sha><ext> (extension is sniffed from the bytes first, falling back to the filename suffix). Two levels of dedup: at the remote_key level (already done and the file is still there → don't re-download) and at the content level (multiple references to the same bytes share one pool file). A failed download never loses the body: the asset is marked pending, the body keeps the original URL and appends <!-- opendoc:asset-pending -->, the document is marked pending_assets, and the next run forces reprocessing.
Two phases:
- At write time (per document): asset URLs in the body are replaced in place with relative paths.
- At platform finalization (whole repo): scan the entire links table (not limited to this run — old links whose target arrives later also get fixed), rewriting platform document URLs in the body to relative paths. Frontmatter is split off first; the
url:field is never rewritten. The alias table is consulted here to resolve Feishu/wiki/<node_token>and Notion's hyphen-stripped IDs.
When a link that has already been rewritten to a relative path later has its endpoint move, fixupMovedLinks (lifecycle.go) recomputes the old/new relative paths from the links table and replaces it.
Delete detection only happens on reconciliation runs: a document that's active in the manifest but wasn't enumerated this run → moved into .internal/trash/<date>/<original relative path>, the manifest row is kept and flipped to trashed. Permission-flap guard: if the enumeration result is less than 80% of the active count, deletion is aborted with a loud warning (to prevent a temporary loss of permissions from wiping out the mirror). Trash is purged past trash_keep_days (default 30 days).
Two zero-dependency primitives: Bucket (a steady-rate token bucket, burst 1, claims the slot before sleeping under a mutex, safe across goroutines) and Backoff (capped exponential backoff, HTTP Retry-After hints take priority over the computed value, retryable-ness is injectable). Notion API/assets are each 3 QPS, Feishu fetch/assets are each 5 QPS.
- Pure
net/http, no SDK, strictly read-only (only GET and the read-only POST/v1/search). The token is an integration token, sourced from the environment variable named by config'stoken_env(resolved at the CLI layer) — the adapter only receives a string and never logs it. - Enumeration:
POST /v1/searchpaginates flat to the end; the tree is rebuilt by the engine from parent pointers. Onlypageanddata_sourceare mirrored. A page whose parent is a data_source →db_row. - Body:
GET /v1/pages/{id}/markdown(enhanced markdown). Two degradation signals,truncated/unknown_block_ids→ marked with an HTML comment in the body + counted. - Dual ID forms: the API uses hyphenated UUIDs, URLs/body use 32-char unhyphenated hex; the unhyphenated form is stored in
AltIDvia the alias table. - Incremental: search paginates in descending
last_edited_timeorder, stopping once it reaches earlier thancheckpoint − a 5-minute safety window(the minute-precision timestamp is absorbed by the double safeguard of the safety window plus content_hash). - Database expansion:
POST /v1/data_sources/{id}/querygets all rows' properties in one call; each property is rendered as a grep-able plain string (see the mapping table in notion-properties-mapping.md), sorted by name for determinism; lossy types leave a drill-down clue rather than being dropped.
- Credentials are fully delegated to the embedded lark engine (OAuth, token refresh, keychain) — opendoc never touches a Feishu secret. The engine =
github.com/larksuite/clicompiled into the opendoc binary (version-pinned in go.mod), invoked busybox-style via self-exec through the hidden subcommandopendoc lark-engine(cmd/opendoc/main.godispatches onargv[1]; self-exec rather than an in-process call preserves stdout capture, stderr error classification, cancellation, and crash isolation). All calls funnel through theRunnerinterface in larkcli.go — this is both the test seam (fakeRunner) and the escape-hatch attachment point (whenOPENDOC_LARK_CLIoverrides to an external lark-cli binary, doctor's F1 check requires ≥ 1.0.69). - Why go through the lark engine instead of the official markdown endpoint: the official endpoint silently drops images, whiteboards, and tables — a direct violation of the degradation contract;
docs +fetchmakes two calls per document — markdown gets the body (as a bonus, whiteboards come with mermaid inlined for free), XML gets stable asset tokens and inter-document link references. - Enumeration: wiki recurses per space via
+node-list(obj_tokenis the primary ID,node_tokengoes intoAltID); drive recurses viaGET /drive/v1/files; thenmetas/batch_query(200/batch) backfills URL and edit time uniformly. Unknown obj types → placeholderTypeFile, leaving no holes in the tree. - Envelope discipline: engine output is always an
{ok, data, error}envelope;envelope.goexplicitly asserts the shape (the source of F4 drift alerts), and error code ranges map to F2-NOAUTH / F3-SCOPE. - bitable degradation has three branches: small tables are inlined as a GFM table; beyond
bitable_inline_max_rows(default 200) → schema + row count + link; fetch failure → a comment-preserving tag + link. The list of degradation tags must stay in sync with degradation-tags.md.
Manual dispatch with stdlib flag (no command framework, to control dependencies). The Env struct carries all I/O to make testing easy. Exit code contract:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime failure; doctor has any fail probe |
| 2 | Usage error |
| 3 | ExitNotInitialized — not initialized, stderr points to setup.md (all commands except init; doctor is special: it prints the full report first, then exits 3) |
resolve uses a narrower set: 0 found / 1 not found / 2 usage.
The doctor probes are the foundation of onboarding: the structured failure codes (G0–G2 / F1–F5 / N1–N3) are the agent's routing keys (see setup.md); only fail affects the exit code, an unconfigured platform produces a skip line rather than a failure — --json consumers always see a stable schema. The probes are entirely dependency-injected (fake Runner, fake Notion probe, fixed clock) and unit-testable without network.
<root>/.internal/config.toml:[feishu]wiki_spaces/drive_folders/include_my_library;[notion]token_env(stores the variable name, not the token);[sync]bitable_inline_max_rows / trash_keep_days / notion_reconcile_every_runs.- Environment variables:
OPENDOC_ROOT(mirror root),OPENDOC_LARK_CLI(override the embedded engine with an external lark-cli, for debugging),$<token_env>(defaults toNOTION_TOKEN). - Env file fallback:
<root>/.internal/env(0600, shell-styleexport KEY="value"but deliberately not shell-evaluated — no variable expansion, no command substitution). The process environment takes priority; the file is only read when it's empty; overly permissive permissions trigger a doctor warning. Interactive use and launchd share this one file.
./scripts/build-skill.sh # CGO_ENABLED=0 static build → plugin/bin/opendoc-devplugin/ is the plugin root, dual-manifested for both supported agents: .claude-plugin/plugin.json (Claude Code) + .codex-plugin/plugin.json (Codex) + bin/ + the skill itself under skills/opendoc/ (SKILL.md + references/ + scripts/). Distribution is marketplace-only, via the separate catalog repo arcships/plugins, whose entries use git-subdir sources pointing at this repo's plugin/ — installs sparse-fetch only that directory, never the Go source. The catalogs at this repo's root (.claude-plugin/marketplace.json, .agents/plugins/marketplace.json) are the dev-only arcships-dev marketplace that installs from the local working tree. End users get the engine binary via the bundled scripts/download-binary.sh, which installs it at the stable, plugin-independent path ~/.opendoc/bin/opendoc — plugin directories are unsuitable as the binary's home because hosts variously re-provision them per session (Claude desktop app), mount them read-only (claude.ai cloud), or version-stamp them so each update starts from a clean package (Codex, Claude Code CLI cache). What bin/ ships instead is a committed shim (bin/opendoc) that execs, in order: $OPENDOC_ENGINE → bin/opendoc-dev (the gitignored dev build) → ~/.opendoc/bin/opendoc. Claude Code puts an enabled plugin's bin/ on the Bash tool's PATH, so bare opendoc works there; Codex has no PATH mechanism, so SKILL.md's invocation contract resolves the path without ever exiting non-zero: OPENDOC=$(command -v opendoc || echo "$HOME/.opendoc/bin/opendoc"). Unattended launchd invokes the binary via an absolute path — the stable path keeps the plist valid across plugin updates (see plugin/skills/opendoc/references/launchd/).