birdclaw 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.2.0 - 2026-04-27
6
+
7
+ ### Added
8
+
9
+ - Add live likes and bookmarks sync through `xurl`/`bird`, local search filters, archive import support, and dedicated Likes/Bookmarks web views.
10
+ - Add Git-friendly JSONL backup sync, export, import, validation, and stale-aware auto-sync for rebuilding or merging the local SQLite store from text shards across machines.
11
+ - Add a scheduled bookmark sync job with launchd installation, JSONL audit logging, overlap locking, and automatic Git backup sync after each refresh.
12
+ - Add launchd env-file support so scheduled bookmark sync can source `bird` credentials without storing secrets in the plist.
13
+
14
+ ### Changed
15
+
16
+ - Update the README tagline and package description for local X memory across archives, DMs, likes, bookmarks, and moderation.
17
+ - Refresh dependencies, including `jsdom` 29.1.0.
18
+ - Hide reply state and reply actions in saved likes/bookmarks web lanes.
19
+ - Shard backup DMs by year and route unknown tweet dates to `data/tweets/unknown.jsonl` so Git backups stay compact and avoid bogus 1970 files.
20
+ - Speed up archive imports plus JSONL backup export, import, and validation for large local datasets.
21
+
22
+ ### Fixed
23
+
24
+ - Fix live bookmark sync to use stored X user ids, force OAuth2 for `xurl` collection reads, and tolerate large/current `bird` bookmark payloads.
25
+ - Fix fresh-machine backup sync so demo data is never exported into Git backups, and keep no-op syncs from creating metadata-only commits.
26
+
27
+ ## 0.1.1 - 2026-04-27
28
+
29
+ ### Added
30
+
31
+ - Add opt-in low-quality timeline filtering for year-scale tweet review, including date windows, originals-only mode, and CLI/API flags for hiding retweets, tiny replies, and link-only noise.
32
+
33
+ ### Fixed
34
+
35
+ - Fix fresh npm installs so the packaged `birdclaw` binary includes its TypeScript runtime dependency.
36
+
5
37
  ## 0.1.0 - 2026-04-27
6
38
 
7
39
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # birdclaw
1
+ # birdclaw 🪶 — Local X memory in SQLite: archives, DMs, likes, bookmarks
2
2
 
3
- `birdclaw` is a local-first X workspace: archive import, cached live reads, focused triage, and reply flows in one local web app + CLI.
3
+ `birdclaw` is a local-first X workspace: archive import, cached live reads, focused triage, and reply flows in one local web app + CLI. Built by [@steipete](https://github.com/steipete/).
4
4
 
5
5
  Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and rough edges while the core settles.
6
6
 
@@ -21,6 +21,9 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
21
21
  - FTS5 search over tweets and DMs
22
22
  - archive autodiscovery on macOS
23
23
  - archive import for tweets, likes, profiles, and full DMs
24
+ - archive import for bookmark exports when present
25
+ - live likes and bookmarks sync through `xurl` or `bird`
26
+ - Git-friendly text backups with yearly tweet shards and per-conversation DM shards
24
27
  - profile hydration from live X metadata
25
28
  - local avatar cache
26
29
  - local media cache root under `~/.birdclaw`
@@ -29,6 +32,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
29
32
 
30
33
  - `Home` timeline
31
34
  - `Mentions` queue
35
+ - `Likes` and `Bookmarks` review lanes
32
36
  - `DMs` workspace with two-column layout
33
37
  - `Inbox` for mixed mention + DM triage
34
38
  - `Blocks` for local blocklist maintenance
@@ -44,6 +48,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
44
48
  - AI-ranked inbox for mentions + DMs
45
49
  - OpenAI scoring hook for low-signal filtering
46
50
  - cached live mentions export in `xurl`-compatible JSON
51
+ - liked/bookmarked tweet filters for archive and live-synced collections
47
52
  - live profile-reply inspection for borderline AI/slop triage
48
53
  - one-shot blocklist import from a file for batch moderation passes
49
54
 
@@ -79,6 +84,7 @@ If you need polished product-grade sync parity today, this is not there yet.
79
84
 
80
85
  - `Home`: read and reply without fighting the main X timeline
81
86
  - `Mentions`: work the reply queue with clean filters
87
+ - `Likes` / `Bookmarks`: revisit saved posts from archive or live sync
82
88
  - `DMs`: triage by sender context, follower count, and influence
83
89
  - `Inbox`: let heuristics / OpenAI float likely-important items
84
90
  - `Blocks`: maintain a local-first account-scoped blocklist
@@ -110,6 +116,7 @@ export BIRDCLAW_HOME=/path/to/custom/root
110
116
  - `pnpm`
111
117
  - macOS recommended for Spotlight archive discovery
112
118
  - `xurl` optional for live reads / writes
119
+ - `bird` optional for cookie-backed likes, bookmarks, mentions, DMs, and write fallback
113
120
  - OpenAI API key optional for inbox scoring
114
121
 
115
122
  ## Install
@@ -158,6 +165,18 @@ birdclaw import archive ~/Downloads/twitter-archive-2025.zip --json
158
165
  birdclaw import hydrate-profiles --json
159
166
  ```
160
167
 
168
+ Back up the local SQLite store as canonical JSONL text:
169
+
170
+ ```bash
171
+ birdclaw backup sync --repo ~/Projects/backup-birdclaw --remote https://github.com/steipete/backup-birdclaw.git --json
172
+ ```
173
+
174
+ Merge the backup into the current `BIRDCLAW_HOME`:
175
+
176
+ ```bash
177
+ birdclaw backup import ~/Projects/backup-birdclaw --json
178
+ ```
179
+
161
180
  Start the app:
162
181
 
163
182
  ```bash
@@ -179,6 +198,19 @@ pnpm cli blocks import ~/triage/blocklist.txt --account acct_primary --json
179
198
  ```bash
180
199
  pnpm cli search tweets "local-first" --json
181
200
  pnpm cli search tweets "sync engine" --limit 20 --json
201
+ pnpm cli search tweets --since 2020-01-01 --until 2021-01-01 --originals-only --hide-low-quality --limit 500 --json
202
+ pnpm cli search tweets --liked --limit 20 --json
203
+ pnpm cli search tweets --bookmarked --limit 20 --json
204
+ ```
205
+
206
+ ### Sync likes and bookmarks
207
+
208
+ `auto` tries `xurl` first, then falls back to `bird`. Use `bird` directly when the API path is unavailable for the account/token you have locally.
209
+
210
+ ```bash
211
+ pnpm cli sync likes --mode auto --limit 100 --refresh --json
212
+ pnpm cli sync bookmarks --mode auto --limit 100 --refresh --json
213
+ pnpm cli sync bookmarks --mode bird --all --max-pages 5 --limit 100 --refresh --json
182
214
  ```
183
215
 
184
216
  ### Export mentions for agents
@@ -204,13 +236,13 @@ Home config lives in `~/.birdclaw/config.json`. Example:
204
236
 
205
237
  ```json
206
238
  {
207
- "actions": {
208
- "transport": "auto"
209
- },
210
- "mentions": {
211
- "dataSource": "bird",
212
- "birdCommand": "/Users/steipete/Projects/bird/bird"
213
- }
239
+ "actions": {
240
+ "transport": "auto"
241
+ },
242
+ "mentions": {
243
+ "dataSource": "bird",
244
+ "birdCommand": "/Users/steipete/Projects/bird/bird"
245
+ }
214
246
  }
215
247
  ```
216
248
 
@@ -225,6 +257,7 @@ Notes:
225
257
  - `actions.transport` accepts `auto`, `bird`, or `xurl`
226
258
  - `bird` mode uses your local `bird` CLI and caches its mentions output into birdclaw's canonical store
227
259
  - filters still work in `xurl` mode; filtered payloads are rebuilt from the local canonical store after sync
260
+ - `sync likes` and `sync bookmarks` store live results in the same local timeline table, so `search tweets --liked` and `search tweets --bookmarked` work across archive and live data
228
261
 
229
262
  ### Search and triage DMs
230
263
 
@@ -323,6 +356,79 @@ pnpm cli compose reply tweet_004 "On it."
323
356
  pnpm cli compose dm dm_003 "Send it over."
324
357
  ```
325
358
 
359
+ ### Text Backup
360
+
361
+ `birdclaw backup export` writes deterministic JSONL shards that can rebuild the local SQLite index without committing SQLite WAL/SHM files, FTS shadow tables, or transient live caches.
362
+
363
+ Layout:
364
+
365
+ ```text
366
+ manifest.json
367
+ data/accounts.jsonl
368
+ data/profiles.jsonl
369
+ data/tweets/YYYY.jsonl
370
+ data/tweets/unknown.jsonl
371
+ data/collections/likes.jsonl
372
+ data/collections/bookmarks.jsonl
373
+ data/dms/conversations.jsonl
374
+ data/dms/YYYY.jsonl
375
+ data/moderation/blocks.jsonl
376
+ data/moderation/mutes.jsonl
377
+ ```
378
+
379
+ Tweets are sharded by year for human browsing and yearly analysis. Collection-only tweets whose real creation date is unknown go into `data/tweets/unknown.jsonl` instead of pretending they belong to 1970. DMs are sharded by year with `conversation_id` in each row; this keeps Git fast while preserving conversation membership. Likes and bookmarks are stored as collection edges in `data/collections` and mirrored into the timeline rows for current query compatibility.
380
+
381
+ Use `backup sync` when the target is a private Git repo. It pulls first, merge-imports the remote backup into local SQLite, exports the local union back into text shards, commits, and pushes.
382
+
383
+ ```bash
384
+ pnpm cli backup sync --repo ~/Projects/backup-birdclaw --remote https://github.com/steipete/backup-birdclaw.git --json
385
+ pnpm cli backup validate ~/Projects/backup-birdclaw --json
386
+ ```
387
+
388
+ Configure stale-aware backup reads in `~/.birdclaw/config.json`:
389
+
390
+ ```json
391
+ {
392
+ "backup": {
393
+ "repoPath": "/Users/steipete/Projects/backup-birdclaw",
394
+ "remote": "https://github.com/steipete/backup-birdclaw.git",
395
+ "autoSync": true,
396
+ "staleAfterSeconds": 900
397
+ }
398
+ }
399
+ ```
400
+
401
+ Read paths such as CLI search, inbox, API status/query, and web startup pull + merge from Git only when the last backup check is stale. Data-changing commands run a full backup sync afterward when this config is enabled. Set `BIRDCLAW_BACKUP_AUTO_SYNC=0` to disable that behavior for one process.
402
+
403
+ ### Scheduled Bookmark Sync
404
+
405
+ `birdclaw jobs sync-bookmarks` refreshes live bookmarks and appends one JSONL audit entry per run. Each entry includes host, timestamps, duration, before/after bookmark counts, source transport, fetched count, backup sync result, and any error.
406
+
407
+ ```bash
408
+ birdclaw --json jobs sync-bookmarks --mode auto --limit 100 --max-pages 5 --refresh
409
+ tail -n 5 ~/.birdclaw/audit/bookmarks-sync.jsonl | jq .
410
+ ```
411
+
412
+ After a successful bookmark refresh, the job runs the normal backup auto-sync path. If `~/.birdclaw/config.json` has `backup.autoSync` enabled, the changed local data is merged into the configured Git backup repo, committed, and pushed. The audit entry records that backup result so scheduled runs are inspectable later.
413
+
414
+ On macOS, install the 3-hour LaunchAgent after choosing the Birdclaw executable path for that machine:
415
+
416
+ ```bash
417
+ birdclaw --json jobs install-bookmarks-launchd --program /opt/homebrew/bin/birdclaw
418
+ ```
419
+
420
+ If the machine uses `bird` with browser cookies that are not available to launchd, write an export-only env file with mode `0600` and install with `--env-file ~/.config/bird/env.sh`. Birdclaw sources that file inside the scheduled process without storing the secrets in the plist.
421
+
422
+ The LaunchAgent writes `~/Library/LaunchAgents/com.steipete.birdclaw.bookmarks-sync.plist`, runs at load, then every 10,800 seconds. It writes the audit log to `~/.birdclaw/audit/bookmarks-sync.jsonl` and stdout/stderr to `~/.birdclaw/logs/bookmarks-sync.*.log`. A lock file prevents overlapping runs and records an `already-running` skip when needed. The default job fetches up to 5 pages every 3 hours; pass `--all` if you want every retrievable page each run.
423
+
424
+ Useful checks:
425
+
426
+ ```bash
427
+ launchctl print gui/$(id -u)/com.steipete.birdclaw.bookmarks-sync
428
+ launchctl kickstart -k gui/$(id -u)/com.steipete.birdclaw.bookmarks-sync
429
+ tail -n 1 ~/.birdclaw/audit/bookmarks-sync.jsonl | jq .
430
+ ```
431
+
326
432
  ## Typical Workflow
327
433
 
328
434
  1. import your archive if you have one
@@ -340,8 +446,9 @@ pnpm cli compose dm dm_003 "Send it over."
340
446
  Current preference:
341
447
 
342
448
  - `xurl` first
449
+ - `bird` fallback for surfaces where cookie-backed reads work better
343
450
 
344
- Without `xurl`, `birdclaw` still works in local/archive mode.
451
+ Without `xurl` or `bird`, `birdclaw` still works in local/archive mode.
345
452
 
346
453
  Check transport:
347
454
 
package/bin/birdclaw.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
+ import { createRequire } from "node:module";
3
4
  import { dirname, join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
 
6
7
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
- const tsxCli = join(packageRoot, "node_modules", "tsx", "dist", "cli.mjs");
8
+ const require = createRequire(import.meta.url);
9
+ const tsxCli = require.resolve("tsx/cli");
8
10
  const birdclawCli = join(packageRoot, "src", "cli.ts");
9
11
 
10
12
  const result = spawnSync(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.1.0",
4
- "description": "Local-first X workspace for archives, DMs, mentions, and moderation",
3
+ "version": "0.2.0",
4
+ "description": "Local X memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -41,6 +41,7 @@
41
41
  "test": "env -u NODE_OPTIONS node ./scripts/run-vitest.mjs run",
42
42
  "coverage": "env -u NODE_OPTIONS node ./scripts/run-vitest.mjs run --coverage",
43
43
  "e2e": "playwright test",
44
+ "typecheck": "tsgo --noEmit",
44
45
  "format": "oxfmt --write src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
45
46
  "format:check": "oxfmt --check src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
46
47
  "lint": "oxlint --import-plugin --node-plugin --vitest-plugin --deny-warnings -A require-mock-type-parameters -A no-control-regex src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
@@ -80,7 +81,7 @@
80
81
  "@types/react": "^19.2.14",
81
82
  "@types/react-dom": "^19.2.3",
82
83
  "@vitest/coverage-v8": "^4.1.5",
83
- "jsdom": "^29.0.2",
84
+ "jsdom": "^29.1.0",
84
85
  "oxfmt": "^0.46.0",
85
86
  "oxlint": "^1.61.0",
86
87
  "typescript": "^6.0.3",
package/src/cli.ts CHANGED
@@ -8,6 +8,18 @@ import { Command } from "commander";
8
8
  import { registerModerationCommands } from "#/cli-moderation";
9
9
  import { findArchives } from "#/lib/archive-finder";
10
10
  import { importArchive } from "#/lib/archive-import";
11
+ import {
12
+ exportBackup,
13
+ importBackup,
14
+ maybeAutoSyncBackup,
15
+ maybeAutoUpdateBackup,
16
+ syncBackup,
17
+ validateBackup,
18
+ } from "#/lib/backup";
19
+ import {
20
+ installBookmarkSyncLaunchAgent,
21
+ runBookmarkSyncJob,
22
+ } from "#/lib/bookmark-sync-job";
11
23
  import { importBlocklist } from "#/lib/blocklist";
12
24
  import {
13
25
  type ActionsTransport,
@@ -32,6 +44,10 @@ import {
32
44
  listDmConversations,
33
45
  listTimelineItems,
34
46
  } from "#/lib/queries";
47
+ import {
48
+ syncTimelineCollection,
49
+ type TimelineCollectionMode,
50
+ } from "#/lib/timeline-collections-live";
35
51
 
36
52
  const program = new Command();
37
53
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -53,6 +69,20 @@ function resolveActionOptions(options: { transport?: string }) {
53
69
  };
54
70
  }
55
71
 
72
+ async function autoUpdateBeforeRead() {
73
+ const result = await maybeAutoUpdateBackup();
74
+ if (!result.ok) {
75
+ console.error(`birdclaw backup auto-sync failed: ${result.error}`);
76
+ }
77
+ }
78
+
79
+ async function autoSyncAfterWrite() {
80
+ const result = await maybeAutoSyncBackup();
81
+ if (!result.ok) {
82
+ console.error(`birdclaw backup sync failed: ${result.error}`);
83
+ }
84
+ }
85
+
56
86
  program
57
87
  .name("birdclaw")
58
88
  .description("Local-first X workspace")
@@ -115,6 +145,7 @@ importCommand
115
145
  }
116
146
 
117
147
  const result = await importArchive(resolvedArchivePath);
148
+ await autoSyncAfterWrite();
118
149
  print(result, program.opts().json ?? false);
119
150
  });
120
151
 
@@ -123,6 +154,7 @@ importCommand
123
154
  .description("Backfill archive-imported profiles from live X metadata")
124
155
  .action(async () => {
125
156
  const result = await hydrateProfilesFromX();
157
+ await autoSyncAfterWrite();
126
158
  print(result, program.opts().json ?? false);
127
159
  });
128
160
 
@@ -131,12 +163,19 @@ const searchCommand = program
131
163
  .description("Search local data");
132
164
 
133
165
  searchCommand
134
- .command("tweets <query>")
166
+ .command("tweets [query]")
135
167
  .option("--resource <resource>", "home or mentions", "home")
136
168
  .option("--replied", "Only replied items")
137
169
  .option("--unreplied", "Only unreplied items")
170
+ .option("--since <date>", "Include tweets created at or after this date")
171
+ .option("--until <date>", "Include tweets created before this date")
172
+ .option("--originals-only", "Exclude authored replies that start with @")
173
+ .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
174
+ .option("--liked", "Only liked tweets")
175
+ .option("--bookmarked", "Only bookmarked tweets")
138
176
  .option("--limit <n>", "Limit results", "20")
139
- .action((query, options) => {
177
+ .action(async (query, options) => {
178
+ await autoUpdateBeforeRead();
140
179
  const replyFilter = options.replied
141
180
  ? "replied"
142
181
  : options.unreplied
@@ -146,6 +185,12 @@ searchCommand
146
185
  resource: options.resource === "mentions" ? "mentions" : "home",
147
186
  search: query,
148
187
  replyFilter,
188
+ since: options.since,
189
+ until: options.until,
190
+ includeReplies: !options.originalsOnly,
191
+ qualityFilter: options.hideLowQuality ? "summary" : "all",
192
+ likedOnly: Boolean(options.liked),
193
+ bookmarkedOnly: Boolean(options.bookmarked),
149
194
  limit: Number(options.limit),
150
195
  });
151
196
  print(items, program.opts().json ?? false);
@@ -162,7 +207,8 @@ searchCommand
162
207
  .option("--replied", "Only replied threads")
163
208
  .option("--unreplied", "Only unreplied threads")
164
209
  .option("--limit <n>", "Limit results", "20")
165
- .action((query, options) => {
210
+ .action(async (query, options) => {
211
+ await autoUpdateBeforeRead();
166
212
  const replyFilter = options.replied
167
213
  ? "replied"
168
214
  : options.unreplied
@@ -210,6 +256,7 @@ mentionsCommand
210
256
  )
211
257
  .option("--limit <n>", "Limit results", "20")
212
258
  .action(async (query, options) => {
259
+ await autoUpdateBeforeRead();
213
260
  const replyFilter = options.replied
214
261
  ? "replied"
215
262
  : options.unreplied
@@ -228,6 +275,7 @@ mentionsCommand
228
275
  refresh: Boolean(options.refresh),
229
276
  cacheTtlMs: Number(options.cacheTtl) * 1000,
230
277
  });
278
+ await autoSyncAfterWrite();
231
279
  print(payload, true);
232
280
  return;
233
281
  }
@@ -242,6 +290,7 @@ mentionsCommand
242
290
  refresh: Boolean(options.refresh),
243
291
  cacheTtlMs: Number(options.cacheTtl) * 1000,
244
292
  });
293
+ await autoSyncAfterWrite();
245
294
  print(payload, true);
246
295
  return;
247
296
  }
@@ -272,6 +321,108 @@ profilesCommand
272
321
 
273
322
  const dmsCommand = program.command("dms").description("Direct messages");
274
323
 
324
+ const syncCommand = program
325
+ .command("sync")
326
+ .description("Refresh live X collections into the local store");
327
+
328
+ for (const kind of ["likes", "bookmarks"] as const) {
329
+ syncCommand
330
+ .command(kind)
331
+ .description(`Refresh live ${kind} through xurl or bird`)
332
+ .option("--account <accountId>", "Account id")
333
+ .option("--mode <mode>", "auto, xurl, or bird", "auto")
334
+ .option("--limit <n>", "Per-page/result limit", "20")
335
+ .option("--all", "Fetch every retrievable page")
336
+ .option("--max-pages <n>", "Stop after N pages when using --all")
337
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
338
+ .option("--refresh", "Bypass live-cache freshness window")
339
+ .action(async (options) => {
340
+ const result = await syncTimelineCollection({
341
+ kind,
342
+ account: options.account,
343
+ mode: options.mode as TimelineCollectionMode,
344
+ limit: Number(options.limit),
345
+ all: Boolean(options.all) || options.maxPages !== undefined,
346
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
347
+ refresh: Boolean(options.refresh),
348
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
349
+ });
350
+ await autoSyncAfterWrite();
351
+ print(result, true);
352
+ });
353
+ }
354
+
355
+ const jobsCommand = program
356
+ .command("jobs")
357
+ .description("Run and install background Birdclaw jobs");
358
+
359
+ jobsCommand
360
+ .command("sync-bookmarks")
361
+ .description("Refresh live bookmarks and append a JSONL audit entry")
362
+ .option("--account <accountId>", "Account id")
363
+ .option("--mode <mode>", "auto, xurl, or bird", "auto")
364
+ .option("--limit <n>", "Per-page/result limit", "100")
365
+ .option("--all", "Fetch every retrievable page")
366
+ .option("--max-pages <n>", "Stop after N pages", "5")
367
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
368
+ .option("--refresh", "Bypass live-cache freshness window")
369
+ .option("--log <path>", "Audit JSONL path")
370
+ .action(async (options) => {
371
+ const result = await runBookmarkSyncJob({
372
+ account: options.account,
373
+ mode: options.mode as TimelineCollectionMode,
374
+ limit: Number(options.limit),
375
+ all: Boolean(options.all) || options.maxPages !== undefined,
376
+ maxPages: options.all ? undefined : Number(options.maxPages),
377
+ refresh: Boolean(options.refresh),
378
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
379
+ logPath: options.log,
380
+ });
381
+ print(result, true);
382
+ if (!result.ok) {
383
+ process.exitCode = 1;
384
+ }
385
+ });
386
+
387
+ jobsCommand
388
+ .command("install-bookmarks-launchd")
389
+ .description("Install a LaunchAgent that runs bookmark sync every 3 hours")
390
+ .option("--label <label>", "LaunchAgent label")
391
+ .option("--interval-seconds <seconds>", "Launch interval", "10800")
392
+ .option("--program <path>", "birdclaw executable or command", "birdclaw")
393
+ .option("--mode <mode>", "auto, xurl, or bird", "auto")
394
+ .option("--limit <n>", "Per-page/result limit", "100")
395
+ .option("--all", "Fetch every retrievable page")
396
+ .option("--max-pages <n>", "Stop after N pages", "5")
397
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
398
+ .option("--no-refresh", "Allow live-cache reuse")
399
+ .option("--log <path>", "Audit JSONL path")
400
+ .option("--env-file <path>", "Shell env file to source before running")
401
+ .option("--stdout <path>", "launchd stdout path")
402
+ .option("--stderr <path>", "launchd stderr path")
403
+ .option("--launch-agents-dir <path>", "LaunchAgents directory")
404
+ .option("--no-load", "Write plist without loading it")
405
+ .action(async (options) => {
406
+ const result = await installBookmarkSyncLaunchAgent({
407
+ label: options.label,
408
+ intervalSeconds: Number(options.intervalSeconds),
409
+ program: options.program,
410
+ mode: options.mode as TimelineCollectionMode,
411
+ limit: Number(options.limit),
412
+ all: Boolean(options.all) || options.maxPages !== undefined,
413
+ maxPages: options.all ? undefined : Number(options.maxPages),
414
+ refresh: options.refresh,
415
+ cacheTtlSeconds: Number(options.cacheTtl),
416
+ logPath: options.log,
417
+ envFile: options.envFile,
418
+ stdoutPath: options.stdout,
419
+ stderrPath: options.stderr,
420
+ launchAgentsDir: options.launchAgentsDir,
421
+ load: options.load,
422
+ });
423
+ print(result, true);
424
+ });
425
+
275
426
  dmsCommand
276
427
  .command("list")
277
428
  .option("--account <accountId>", "Account id")
@@ -299,6 +450,9 @@ dmsCommand
299
450
  refresh: true,
300
451
  cacheTtlMs: Number(options.cacheTtl) * 1000,
301
452
  });
453
+ await autoSyncAfterWrite();
454
+ } else {
455
+ await autoUpdateBeforeRead();
302
456
  }
303
457
  const items = listDmConversations({
304
458
  account: options.account,
@@ -336,6 +490,7 @@ dmsCommand
336
490
  refresh: Boolean(options.refresh),
337
491
  cacheTtlMs: Number(options.cacheTtl) * 1000,
338
492
  });
493
+ await autoSyncAfterWrite();
339
494
  print(result, true);
340
495
  });
341
496
 
@@ -356,6 +511,7 @@ composeCommand
356
511
  .option("--account <accountId>", "Account id", "acct_primary")
357
512
  .action(async (text, options) => {
358
513
  const result = await createPost(options.account, text);
514
+ await autoSyncAfterWrite();
359
515
  print(result, program.opts().json ?? false);
360
516
  });
361
517
 
@@ -364,6 +520,7 @@ composeCommand
364
520
  .option("--account <accountId>", "Account id", "acct_primary")
365
521
  .action(async (tweetId, text, options) => {
366
522
  const result = await createTweetReply(options.account, tweetId, text);
523
+ await autoSyncAfterWrite();
367
524
  print(result, program.opts().json ?? false);
368
525
  });
369
526
 
@@ -372,6 +529,7 @@ composeCommand
372
529
  .description("Reply inside an existing DM conversation")
373
530
  .action(async (conversationId, text) => {
374
531
  const result = await createDmReply(conversationId, text);
532
+ await autoSyncAfterWrite();
375
533
  print(result, program.opts().json ?? false);
376
534
  });
377
535
 
@@ -383,6 +541,7 @@ program
383
541
  .option("--score", "Score top items with OpenAI before listing")
384
542
  .option("--limit <n>", "Limit results", "20")
385
543
  .action(async (options) => {
544
+ await autoUpdateBeforeRead();
386
545
  const kind =
387
546
  options.kind === "mentions" || options.kind === "dms"
388
547
  ? options.kind
@@ -392,6 +551,7 @@ program
392
551
  kind,
393
552
  limit: Number(options.limit),
394
553
  });
554
+ await autoSyncAfterWrite();
395
555
  }
396
556
  print(
397
557
  listInboxItems({
@@ -408,6 +568,7 @@ program
408
568
  .command("db stats")
409
569
  .description("Show local storage and dataset stats")
410
570
  .action(async () => {
571
+ await autoUpdateBeforeRead();
411
572
  const meta = await getQueryEnvelope();
412
573
  const paths = getBirdclawPaths();
413
574
  print(
@@ -420,10 +581,82 @@ program
420
581
  );
421
582
  });
422
583
 
584
+ const backupCommand = program
585
+ .command("backup")
586
+ .description("Export, import, and validate Git-friendly text backups");
587
+
588
+ backupCommand
589
+ .command("export")
590
+ .description("Export canonical JSONL backup shards")
591
+ .requiredOption("--repo <path>", "Backup repository/path")
592
+ .option("--commit", "Create a git commit in the backup repo")
593
+ .option("--push", "Push the backup repo after committing")
594
+ .option(
595
+ "--message <message>",
596
+ "Git commit message",
597
+ "archive: update birdclaw backup",
598
+ )
599
+ .option("--no-validate", "Skip post-export validation")
600
+ .action(async (options) => {
601
+ const result = await exportBackup({
602
+ repoPath: options.repo,
603
+ commit: Boolean(options.commit) || Boolean(options.push),
604
+ push: Boolean(options.push),
605
+ message: options.message,
606
+ validate: options.validate,
607
+ });
608
+ print(result, true);
609
+ });
610
+
611
+ backupCommand
612
+ .command("import <repo>")
613
+ .description("Merge a canonical JSONL backup into the local SQLite store")
614
+ .option("--no-validate", "Skip backup validation before import")
615
+ .option("--replace", "Replace local portable tables instead of merging")
616
+ .action(async (repo, options) => {
617
+ const result = await importBackup({
618
+ repoPath: repo,
619
+ validate: options.validate,
620
+ mode: options.replace ? "replace" : "merge",
621
+ });
622
+ print(result, true);
623
+ });
624
+
625
+ backupCommand
626
+ .command("sync")
627
+ .description("Pull, merge-import, export, commit, and push a backup repo")
628
+ .requiredOption("--repo <path>", "Backup repository/path")
629
+ .option("--remote <url>", "Git remote to clone/configure")
630
+ .option(
631
+ "--message <message>",
632
+ "Git commit message",
633
+ "archive: sync birdclaw backup",
634
+ )
635
+ .action(async (options) => {
636
+ const result = await syncBackup({
637
+ repoPath: options.repo,
638
+ remote: options.remote,
639
+ message: options.message,
640
+ });
641
+ print(result, true);
642
+ });
643
+
644
+ backupCommand
645
+ .command("validate <repo>")
646
+ .description("Validate backup manifest, shard hashes, and JSONL rows")
647
+ .action(async (repo) => {
648
+ const result = await validateBackup(repo);
649
+ print(result, true);
650
+ if (!result.ok) {
651
+ process.exitCode = 1;
652
+ }
653
+ });
654
+
423
655
  program
424
656
  .command("serve")
425
657
  .description("Run the local web app")
426
- .action(() => {
658
+ .action(async () => {
659
+ await autoUpdateBeforeRead();
427
660
  const child = spawn(
428
661
  process.execPath,
429
662
  ["node_modules/vite/bin/vite.js", "dev", "--port", "3000"],
@@ -14,6 +14,8 @@ const links = [
14
14
  { to: "/inbox", label: "Inbox" },
15
15
  { to: "/", label: "Home" },
16
16
  { to: "/mentions", label: "Mentions" },
17
+ { to: "/likes", label: "Likes" },
18
+ { to: "/bookmarks", label: "Bookmarks" },
17
19
  { to: "/dms", label: "DMs" },
18
20
  { to: "/blocks", label: "Blocks" },
19
21
  ] as const;