birdclaw 0.1.1 → 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 +22 -0
- package/README.md +116 -10
- package/package.json +4 -3
- package/src/cli.ts +228 -3
- package/src/components/AppNav.tsx +2 -0
- package/src/components/SavedTimelineView.tsx +115 -0
- package/src/components/TimelineCard.tsx +26 -17
- package/src/lib/archive-import.ts +193 -16
- package/src/lib/backup.ts +1617 -0
- package/src/lib/bird.ts +163 -24
- package/src/lib/blocks-write.ts +2 -2
- package/src/lib/blocks.ts +2 -7
- package/src/lib/bookmark-sync-job.ts +446 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/db.ts +82 -4
- package/src/lib/mentions-live.ts +2 -5
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/mutes-write.ts +2 -2
- package/src/lib/queries.ts +20 -1
- package/src/lib/seed.ts +4 -2
- package/src/lib/theme-transition.ts +6 -10
- package/src/lib/theme.tsx +13 -5
- package/src/lib/timeline-collections-live.ts +406 -0
- package/src/lib/types.ts +4 -1
- package/src/lib/xurl.ts +90 -4
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +6 -1
- package/src/routes/api/action.tsx +14 -4
- package/src/routes/api/inbox.tsx +3 -1
- package/src/routes/api/query.tsx +5 -1
- package/src/routes/api/status.tsx +6 -3
- package/src/routes/bookmarks.tsx +18 -0
- package/src/routes/likes.tsx +18 -0
- package/tsconfig.json +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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
|
+
|
|
5
27
|
## 0.1.1 - 2026-04-27
|
|
6
28
|
|
|
7
29
|
### 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
|
|
@@ -180,6 +199,18 @@ pnpm cli blocks import ~/triage/blocklist.txt --account acct_primary --json
|
|
|
180
199
|
pnpm cli search tweets "local-first" --json
|
|
181
200
|
pnpm cli search tweets "sync engine" --limit 20 --json
|
|
182
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
|
|
183
214
|
```
|
|
184
215
|
|
|
185
216
|
### Export mentions for agents
|
|
@@ -205,13 +236,13 @@ Home config lives in `~/.birdclaw/config.json`. Example:
|
|
|
205
236
|
|
|
206
237
|
```json
|
|
207
238
|
{
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
239
|
+
"actions": {
|
|
240
|
+
"transport": "auto"
|
|
241
|
+
},
|
|
242
|
+
"mentions": {
|
|
243
|
+
"dataSource": "bird",
|
|
244
|
+
"birdCommand": "/Users/steipete/Projects/bird/bird"
|
|
245
|
+
}
|
|
215
246
|
}
|
|
216
247
|
```
|
|
217
248
|
|
|
@@ -226,6 +257,7 @@ Notes:
|
|
|
226
257
|
- `actions.transport` accepts `auto`, `bird`, or `xurl`
|
|
227
258
|
- `bird` mode uses your local `bird` CLI and caches its mentions output into birdclaw's canonical store
|
|
228
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
|
|
229
261
|
|
|
230
262
|
### Search and triage DMs
|
|
231
263
|
|
|
@@ -324,6 +356,79 @@ pnpm cli compose reply tweet_004 "On it."
|
|
|
324
356
|
pnpm cli compose dm dm_003 "Send it over."
|
|
325
357
|
```
|
|
326
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
|
+
|
|
327
432
|
## Typical Workflow
|
|
328
433
|
|
|
329
434
|
1. import your archive if you have one
|
|
@@ -341,8 +446,9 @@ pnpm cli compose dm dm_003 "Send it over."
|
|
|
341
446
|
Current preference:
|
|
342
447
|
|
|
343
448
|
- `xurl` first
|
|
449
|
+
- `bird` fallback for surfaces where cookie-backed reads work better
|
|
344
450
|
|
|
345
|
-
Without `xurl`, `birdclaw` still works in local/archive mode.
|
|
451
|
+
Without `xurl` or `bird`, `birdclaw` still works in local/archive mode.
|
|
346
452
|
|
|
347
453
|
Check transport:
|
|
348
454
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "birdclaw",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local
|
|
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
|
|
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
|
|
|
@@ -139,8 +171,11 @@ searchCommand
|
|
|
139
171
|
.option("--until <date>", "Include tweets created before this date")
|
|
140
172
|
.option("--originals-only", "Exclude authored replies that start with @")
|
|
141
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")
|
|
142
176
|
.option("--limit <n>", "Limit results", "20")
|
|
143
|
-
.action((query, options) => {
|
|
177
|
+
.action(async (query, options) => {
|
|
178
|
+
await autoUpdateBeforeRead();
|
|
144
179
|
const replyFilter = options.replied
|
|
145
180
|
? "replied"
|
|
146
181
|
: options.unreplied
|
|
@@ -154,6 +189,8 @@ searchCommand
|
|
|
154
189
|
until: options.until,
|
|
155
190
|
includeReplies: !options.originalsOnly,
|
|
156
191
|
qualityFilter: options.hideLowQuality ? "summary" : "all",
|
|
192
|
+
likedOnly: Boolean(options.liked),
|
|
193
|
+
bookmarkedOnly: Boolean(options.bookmarked),
|
|
157
194
|
limit: Number(options.limit),
|
|
158
195
|
});
|
|
159
196
|
print(items, program.opts().json ?? false);
|
|
@@ -170,7 +207,8 @@ searchCommand
|
|
|
170
207
|
.option("--replied", "Only replied threads")
|
|
171
208
|
.option("--unreplied", "Only unreplied threads")
|
|
172
209
|
.option("--limit <n>", "Limit results", "20")
|
|
173
|
-
.action((query, options) => {
|
|
210
|
+
.action(async (query, options) => {
|
|
211
|
+
await autoUpdateBeforeRead();
|
|
174
212
|
const replyFilter = options.replied
|
|
175
213
|
? "replied"
|
|
176
214
|
: options.unreplied
|
|
@@ -218,6 +256,7 @@ mentionsCommand
|
|
|
218
256
|
)
|
|
219
257
|
.option("--limit <n>", "Limit results", "20")
|
|
220
258
|
.action(async (query, options) => {
|
|
259
|
+
await autoUpdateBeforeRead();
|
|
221
260
|
const replyFilter = options.replied
|
|
222
261
|
? "replied"
|
|
223
262
|
: options.unreplied
|
|
@@ -236,6 +275,7 @@ mentionsCommand
|
|
|
236
275
|
refresh: Boolean(options.refresh),
|
|
237
276
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
238
277
|
});
|
|
278
|
+
await autoSyncAfterWrite();
|
|
239
279
|
print(payload, true);
|
|
240
280
|
return;
|
|
241
281
|
}
|
|
@@ -250,6 +290,7 @@ mentionsCommand
|
|
|
250
290
|
refresh: Boolean(options.refresh),
|
|
251
291
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
252
292
|
});
|
|
293
|
+
await autoSyncAfterWrite();
|
|
253
294
|
print(payload, true);
|
|
254
295
|
return;
|
|
255
296
|
}
|
|
@@ -280,6 +321,108 @@ profilesCommand
|
|
|
280
321
|
|
|
281
322
|
const dmsCommand = program.command("dms").description("Direct messages");
|
|
282
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
|
+
|
|
283
426
|
dmsCommand
|
|
284
427
|
.command("list")
|
|
285
428
|
.option("--account <accountId>", "Account id")
|
|
@@ -307,6 +450,9 @@ dmsCommand
|
|
|
307
450
|
refresh: true,
|
|
308
451
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
309
452
|
});
|
|
453
|
+
await autoSyncAfterWrite();
|
|
454
|
+
} else {
|
|
455
|
+
await autoUpdateBeforeRead();
|
|
310
456
|
}
|
|
311
457
|
const items = listDmConversations({
|
|
312
458
|
account: options.account,
|
|
@@ -344,6 +490,7 @@ dmsCommand
|
|
|
344
490
|
refresh: Boolean(options.refresh),
|
|
345
491
|
cacheTtlMs: Number(options.cacheTtl) * 1000,
|
|
346
492
|
});
|
|
493
|
+
await autoSyncAfterWrite();
|
|
347
494
|
print(result, true);
|
|
348
495
|
});
|
|
349
496
|
|
|
@@ -364,6 +511,7 @@ composeCommand
|
|
|
364
511
|
.option("--account <accountId>", "Account id", "acct_primary")
|
|
365
512
|
.action(async (text, options) => {
|
|
366
513
|
const result = await createPost(options.account, text);
|
|
514
|
+
await autoSyncAfterWrite();
|
|
367
515
|
print(result, program.opts().json ?? false);
|
|
368
516
|
});
|
|
369
517
|
|
|
@@ -372,6 +520,7 @@ composeCommand
|
|
|
372
520
|
.option("--account <accountId>", "Account id", "acct_primary")
|
|
373
521
|
.action(async (tweetId, text, options) => {
|
|
374
522
|
const result = await createTweetReply(options.account, tweetId, text);
|
|
523
|
+
await autoSyncAfterWrite();
|
|
375
524
|
print(result, program.opts().json ?? false);
|
|
376
525
|
});
|
|
377
526
|
|
|
@@ -380,6 +529,7 @@ composeCommand
|
|
|
380
529
|
.description("Reply inside an existing DM conversation")
|
|
381
530
|
.action(async (conversationId, text) => {
|
|
382
531
|
const result = await createDmReply(conversationId, text);
|
|
532
|
+
await autoSyncAfterWrite();
|
|
383
533
|
print(result, program.opts().json ?? false);
|
|
384
534
|
});
|
|
385
535
|
|
|
@@ -391,6 +541,7 @@ program
|
|
|
391
541
|
.option("--score", "Score top items with OpenAI before listing")
|
|
392
542
|
.option("--limit <n>", "Limit results", "20")
|
|
393
543
|
.action(async (options) => {
|
|
544
|
+
await autoUpdateBeforeRead();
|
|
394
545
|
const kind =
|
|
395
546
|
options.kind === "mentions" || options.kind === "dms"
|
|
396
547
|
? options.kind
|
|
@@ -400,6 +551,7 @@ program
|
|
|
400
551
|
kind,
|
|
401
552
|
limit: Number(options.limit),
|
|
402
553
|
});
|
|
554
|
+
await autoSyncAfterWrite();
|
|
403
555
|
}
|
|
404
556
|
print(
|
|
405
557
|
listInboxItems({
|
|
@@ -416,6 +568,7 @@ program
|
|
|
416
568
|
.command("db stats")
|
|
417
569
|
.description("Show local storage and dataset stats")
|
|
418
570
|
.action(async () => {
|
|
571
|
+
await autoUpdateBeforeRead();
|
|
419
572
|
const meta = await getQueryEnvelope();
|
|
420
573
|
const paths = getBirdclawPaths();
|
|
421
574
|
print(
|
|
@@ -428,10 +581,82 @@ program
|
|
|
428
581
|
);
|
|
429
582
|
});
|
|
430
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
|
+
|
|
431
655
|
program
|
|
432
656
|
.command("serve")
|
|
433
657
|
.description("Run the local web app")
|
|
434
|
-
.action(() => {
|
|
658
|
+
.action(async () => {
|
|
659
|
+
await autoUpdateBeforeRead();
|
|
435
660
|
const child = spawn(
|
|
436
661
|
process.execPath,
|
|
437
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;
|