birdclaw 0.8.1 → 0.8.3

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.
Files changed (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.8.3 - 2026-06-15
4
+
5
+ ### Changed
6
+
7
+ - Centralize SQLite writes and migrations, unify live-sync ingestion, and move retained web server state to TanStack Query for faster sidebar navigation and fewer duplicate reads.
8
+ - Isolate web reads onto query-only SQLite connections while serializing action and sync writes through one measured writer queue.
9
+ - Share page caps, cursor progression, delays, and repeated-cursor protection across live timeline, DM, and follow-graph sync.
10
+ - Stream archive arrays and backup JSONL records through resumable batches instead of buffering entire source files.
11
+ - Split query access into typed timeline, DM, status, resource, and action modules while retaining the existing compatibility import.
12
+ - Retain Blocks, Inbox, Data Sources, and Rate Limits server state in TanStack Query across sidebar navigation and targeted refreshes.
13
+ - Split compose, inbox, follow-graph, database, and backup CLI registration into domain-owned command modules.
14
+
15
+ ### Fixed
16
+
17
+ - Preload hover-preview avatars after page load so citation and profile cards open with cached thumbnails.
18
+
19
+ ## 0.8.2 - 2026-06-15
20
+
21
+ ### Fixed
22
+
23
+ - Wait for concurrent SQLite writers before starting write transactions and avoid unnecessary legacy backfill writes during normal startup.
24
+ - Retain timeline and DM data across sidebar navigation, deduplicate status reads, debounce searches, skip unused Spotlight archive scans in web status, and remove TanStack debug controls.
25
+ - Keep tweet and profile hover previews outside wrapped links, flip them to the roomier vertical side, and constrain them to the viewport.
26
+ - Expand Twitter Articles into titled preview cards and citation popovers instead of showing bare `t.co` links.
27
+
3
28
  ## 0.8.1 - 2026-06-13
4
29
 
5
30
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
@@ -54,10 +54,8 @@
54
54
  "dependencies": {
55
55
  "@steipete/sweet-cookie": "^0.4.0",
56
56
  "@tailwindcss/vite": "^4.3.0",
57
- "@tanstack/devtools-vite": "0.7.0",
58
- "@tanstack/react-devtools": "0.10.5",
57
+ "@tanstack/react-query": "^5.101.0",
59
58
  "@tanstack/react-router": "1.170.15",
60
- "@tanstack/react-router-devtools": "1.167.0",
61
59
  "@tanstack/react-router-ssr-query": "1.167.1",
62
60
  "@tanstack/react-start": "1.168.25",
63
61
  "@tanstack/router-plugin": "^1.168.18",
@@ -18,6 +18,33 @@ const SCENARIOS = {
18
18
  path: "/mentions",
19
19
  ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
20
20
  },
21
+ "sidebar-roundtrip": {
22
+ path: "/",
23
+ ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
24
+ action: async (page) => {
25
+ await page.getByRole("link", { name: "Mentions" }).click();
26
+ await page.waitForURL("**/mentions");
27
+ await waitForAny(page, ['[data-perf="timeline-card"]']);
28
+ const queryCallsBeforeReturn = await page.evaluate(
29
+ () =>
30
+ performance
31
+ .getEntriesByType("resource")
32
+ .filter((entry) => entry.name.includes("/api/query")).length,
33
+ );
34
+ await page.getByRole("link", { name: "Home" }).click();
35
+ await page.waitForURL((url) => url.pathname === "/");
36
+ await waitForAny(page, ['[data-perf="timeline-card"]']);
37
+ const queryCallsAfterReturn = await page.evaluate(
38
+ () =>
39
+ performance
40
+ .getEntriesByType("resource")
41
+ .filter((entry) => entry.name.includes("/api/query")).length,
42
+ );
43
+ if (queryCallsAfterReturn !== queryCallsBeforeReturn) {
44
+ throw new Error("Returning Home issued a new query request");
45
+ }
46
+ },
47
+ },
21
48
  "mentions-search": {
22
49
  path: "/mentions",
23
50
  ready: async (page) => {
@@ -0,0 +1,17 @@
1
+ import type { Command } from "commander";
2
+
3
+ export interface CliCommandContext {
4
+ program: Command;
5
+ print: (data: unknown, asJson: boolean) => void;
6
+ asJson: () => boolean;
7
+ autoSyncAfterWrite: () => Promise<void>;
8
+ autoUpdateBeforeRead: () => Promise<void>;
9
+ parseNonNegativeIntegerOption: (
10
+ value: string | undefined,
11
+ option: string,
12
+ ) => number | undefined;
13
+ parsePositiveIntegerOption: (
14
+ value: string | undefined,
15
+ option: string,
16
+ ) => number | undefined;
17
+ }
@@ -0,0 +1,500 @@
1
+ import type { CliCommandContext } from "./command-context";
2
+ import {
3
+ normalizeDigestLanguage,
4
+ streamPeriodDigest,
5
+ type PeriodDigestOptions,
6
+ type PeriodDigestPreset,
7
+ } from "#/lib/period-digest";
8
+ import {
9
+ streamProfileAnalysis,
10
+ type ProfileAnalysisOptions,
11
+ } from "#/lib/profile-analysis";
12
+ import { runResearchMode } from "#/lib/research";
13
+ import {
14
+ streamSearchDiscussion,
15
+ type SearchDiscussionOptions,
16
+ type SearchDiscussionSource,
17
+ } from "#/lib/search-discussion";
18
+
19
+ export function registerAnalysisCommands({
20
+ program,
21
+ print,
22
+ autoUpdateBeforeRead,
23
+ parseNonNegativeIntegerOption,
24
+ parsePositiveIntegerOption,
25
+ }: CliCommandContext) {
26
+ function printError(error: string) {
27
+ console.error(JSON.stringify({ error }));
28
+ }
29
+
30
+ function parseDigestLiveModeOption(
31
+ value: string | undefined,
32
+ ): PeriodDigestOptions["liveSyncMode"] {
33
+ const normalized = (value ?? "xurl").trim().toLowerCase();
34
+ if (
35
+ normalized === "auto" ||
36
+ normalized === "bird" ||
37
+ normalized === "xurl"
38
+ ) {
39
+ return normalized;
40
+ }
41
+ printError("--live-mode must be auto, bird, or xurl");
42
+ process.exitCode = 1;
43
+ return undefined;
44
+ }
45
+ function parseDigestPeriod(value: string | undefined): PeriodDigestPreset {
46
+ const normalized = value?.trim().toLowerCase();
47
+ if (normalized === "yesterday") return "yesterday";
48
+ if (normalized === "24h" || normalized === "day") return "24h";
49
+ if (normalized === "week" || normalized === "7d") return "week";
50
+ return "today";
51
+ }
52
+
53
+ function buildDigestOptions(
54
+ period: string | undefined,
55
+ options: {
56
+ account?: string;
57
+ includeDms?: boolean;
58
+ model?: string;
59
+ language?: string;
60
+ refresh?: boolean;
61
+ since?: string;
62
+ until?: string;
63
+ maxTweets?: string;
64
+ maxLinks?: string;
65
+ liveSync?: boolean;
66
+ liveMode?: string;
67
+ },
68
+ ): PeriodDigestOptions | null {
69
+ const maxTweets = parseNonNegativeIntegerOption(
70
+ options.maxTweets,
71
+ "--max-tweets",
72
+ );
73
+ if (options.maxTweets !== undefined && maxTweets === undefined) {
74
+ return null;
75
+ }
76
+ const maxLinks = parseNonNegativeIntegerOption(
77
+ options.maxLinks,
78
+ "--max-links",
79
+ );
80
+ if (options.maxLinks !== undefined && maxLinks === undefined) {
81
+ return null;
82
+ }
83
+ const liveSyncMode = parseDigestLiveModeOption(options.liveMode);
84
+ if (liveSyncMode === undefined) {
85
+ return null;
86
+ }
87
+ let language: string | undefined;
88
+ try {
89
+ language = normalizeDigestLanguage(options.language);
90
+ } catch (error) {
91
+ printError(error instanceof Error ? error.message : String(error));
92
+ process.exitCode = 1;
93
+ return null;
94
+ }
95
+ return {
96
+ period: parseDigestPeriod(period),
97
+ since: options.since,
98
+ until: options.until,
99
+ account: options.account,
100
+ includeDms: Boolean(options.includeDms),
101
+ refresh: Boolean(options.refresh),
102
+ model: options.model,
103
+ language,
104
+ maxTweets,
105
+ maxLinks,
106
+ liveSync: options.liveSync !== false,
107
+ liveSyncMode,
108
+ };
109
+ }
110
+
111
+ function runDigestCli(options: PeriodDigestOptions) {
112
+ const asJson = Boolean(program.opts().json);
113
+ return streamPeriodDigest(options, {
114
+ onDelta: asJson
115
+ ? undefined
116
+ : (delta) => {
117
+ process.stdout.write(delta);
118
+ },
119
+ }).then((result) => {
120
+ if (asJson) {
121
+ print(result, true);
122
+ return;
123
+ }
124
+ if (!result.markdown.endsWith("\n")) {
125
+ process.stdout.write("\n");
126
+ }
127
+ });
128
+ }
129
+
130
+ function parseSearchDiscussionSource(
131
+ value: string | undefined,
132
+ ): SearchDiscussionSource | undefined {
133
+ const normalized = (value ?? "all").trim().toLowerCase();
134
+ if (
135
+ normalized === "all" ||
136
+ normalized === "home" ||
137
+ normalized === "mentions" ||
138
+ normalized === "authored" ||
139
+ normalized === "search" ||
140
+ normalized === "likes" ||
141
+ normalized === "bookmarks"
142
+ ) {
143
+ return normalized;
144
+ }
145
+ printError(
146
+ "--source must be all, search, home, mentions, authored, likes, or bookmarks",
147
+ );
148
+ process.exitCode = 1;
149
+ return undefined;
150
+ }
151
+
152
+ function parseTweetSearchMode(value: string | undefined) {
153
+ const normalized = (value ?? "auto").trim().toLowerCase();
154
+ if (
155
+ normalized === "auto" ||
156
+ normalized === "bird" ||
157
+ normalized === "xurl" ||
158
+ normalized === "local"
159
+ ) {
160
+ return normalized;
161
+ }
162
+ printError("--mode must be auto, bird, xurl, or local");
163
+ process.exitCode = 1;
164
+ return undefined;
165
+ }
166
+
167
+ function buildSearchDiscussionOptions(
168
+ query: string,
169
+ options: {
170
+ account?: string;
171
+ source?: string;
172
+ includeDms?: boolean;
173
+ since?: string;
174
+ until?: string;
175
+ question?: string;
176
+ originalsOnly?: boolean;
177
+ hideLowQuality?: boolean;
178
+ mode?: string;
179
+ model?: string;
180
+ refresh?: boolean;
181
+ limit?: string;
182
+ maxPages?: string;
183
+ },
184
+ ): SearchDiscussionOptions | null {
185
+ const source = parseSearchDiscussionSource(options.source);
186
+ if (!source) return null;
187
+ const mode = parseTweetSearchMode(options.mode);
188
+ if (!mode) return null;
189
+ const limit = parsePositiveIntegerOption(options.limit, "--limit");
190
+ if (options.limit !== undefined && limit === undefined) {
191
+ return null;
192
+ }
193
+ const maxPages = parsePositiveIntegerOption(
194
+ options.maxPages,
195
+ "--max-pages",
196
+ );
197
+ if (options.maxPages !== undefined && maxPages === undefined) {
198
+ return null;
199
+ }
200
+ return {
201
+ query,
202
+ account: options.account,
203
+ source,
204
+ includeDms: Boolean(options.includeDms),
205
+ since: options.since,
206
+ until: options.until,
207
+ question: options.question,
208
+ originalsOnly: Boolean(options.originalsOnly),
209
+ hideLowQuality: Boolean(options.hideLowQuality),
210
+ mode,
211
+ model: options.model,
212
+ refresh: Boolean(options.refresh),
213
+ limit,
214
+ maxPages,
215
+ };
216
+ }
217
+
218
+ function runSearchDiscussionCli(options: SearchDiscussionOptions) {
219
+ const asJson = Boolean(program.opts().json);
220
+ return streamSearchDiscussion(options, {
221
+ onDelta: asJson
222
+ ? undefined
223
+ : (delta) => {
224
+ process.stdout.write(delta);
225
+ },
226
+ }).then((result) => {
227
+ if (asJson) {
228
+ print(result, true);
229
+ return;
230
+ }
231
+ if (!result.markdown.endsWith("\n")) {
232
+ process.stdout.write("\n");
233
+ }
234
+ });
235
+ }
236
+
237
+ function buildProfileAnalysisOptions(
238
+ handle: string,
239
+ options: {
240
+ account?: string;
241
+ model?: string;
242
+ refresh?: boolean;
243
+ maxTweets?: string;
244
+ maxPages?: string;
245
+ maxConversations?: string;
246
+ maxConversationPages?: string;
247
+ conversationDelayMs?: string;
248
+ rateLimitRetryMs?: string;
249
+ rateLimitRetries?: string;
250
+ },
251
+ ): ProfileAnalysisOptions | null {
252
+ const maxTweets = parsePositiveIntegerOption(
253
+ options.maxTweets,
254
+ "--max-tweets",
255
+ );
256
+ if (options.maxTweets !== undefined && maxTweets === undefined) {
257
+ return null;
258
+ }
259
+ const maxPages = parsePositiveIntegerOption(
260
+ options.maxPages,
261
+ "--max-pages",
262
+ );
263
+ if (options.maxPages !== undefined && maxPages === undefined) {
264
+ return null;
265
+ }
266
+ const maxConversations = parsePositiveIntegerOption(
267
+ options.maxConversations,
268
+ "--max-conversations",
269
+ );
270
+ if (
271
+ options.maxConversations !== undefined &&
272
+ maxConversations === undefined
273
+ ) {
274
+ return null;
275
+ }
276
+ const maxConversationPages = parsePositiveIntegerOption(
277
+ options.maxConversationPages,
278
+ "--max-conversation-pages",
279
+ );
280
+ if (
281
+ options.maxConversationPages !== undefined &&
282
+ maxConversationPages === undefined
283
+ ) {
284
+ return null;
285
+ }
286
+ const conversationDelayMs = parseNonNegativeIntegerOption(
287
+ options.conversationDelayMs,
288
+ "--conversation-delay-ms",
289
+ );
290
+ if (
291
+ options.conversationDelayMs !== undefined &&
292
+ conversationDelayMs === undefined
293
+ ) {
294
+ return null;
295
+ }
296
+ const rateLimitRetryMs = parseNonNegativeIntegerOption(
297
+ options.rateLimitRetryMs,
298
+ "--rate-limit-retry-ms",
299
+ );
300
+ if (
301
+ options.rateLimitRetryMs !== undefined &&
302
+ rateLimitRetryMs === undefined
303
+ ) {
304
+ return null;
305
+ }
306
+ const rateLimitMaxRetries = parseNonNegativeIntegerOption(
307
+ options.rateLimitRetries,
308
+ "--rate-limit-retries",
309
+ );
310
+ if (
311
+ options.rateLimitRetries !== undefined &&
312
+ rateLimitMaxRetries === undefined
313
+ ) {
314
+ return null;
315
+ }
316
+ return {
317
+ handle,
318
+ account: options.account,
319
+ model: options.model,
320
+ refresh: Boolean(options.refresh),
321
+ maxTweets,
322
+ maxPages,
323
+ maxConversations,
324
+ maxConversationPages,
325
+ conversationDelayMs,
326
+ rateLimitRetryMs,
327
+ rateLimitMaxRetries,
328
+ };
329
+ }
330
+
331
+ function runProfileAnalysisCli(options: ProfileAnalysisOptions) {
332
+ const asJson = Boolean(program.opts().json);
333
+ return streamProfileAnalysis(options, {
334
+ onDelta: asJson
335
+ ? undefined
336
+ : (delta) => {
337
+ process.stdout.write(delta);
338
+ },
339
+ onEvent: asJson
340
+ ? undefined
341
+ : (event) => {
342
+ if (event.type === "status") {
343
+ process.stderr.write(
344
+ event.detail
345
+ ? `${event.label}: ${event.detail}\n`
346
+ : `${event.label}\n`,
347
+ );
348
+ }
349
+ },
350
+ }).then((result) => {
351
+ if (asJson) {
352
+ print(result, true);
353
+ return;
354
+ }
355
+ if (!result.markdown.endsWith("\n")) {
356
+ process.stdout.write("\n");
357
+ }
358
+ });
359
+ }
360
+ program
361
+ .command("research [query]")
362
+ .description("Build a markdown research brief from bookmarked threads")
363
+ .option("--account <accountId>", "Account id")
364
+ .option("--limit <n>", "Seed bookmark limit", "20")
365
+ .option("--thread-depth <n>", "Maximum ancestor walk depth", "10")
366
+ .option("--out <path>", "Write the markdown brief to a file")
367
+ .action(async (query, options) => {
368
+ await autoUpdateBeforeRead();
369
+ const report = await runResearchMode({
370
+ account: options.account,
371
+ query,
372
+ limit: Number(options.limit),
373
+ maxThreadDepth: Number(options.threadDepth),
374
+ outPath: options.out,
375
+ });
376
+ print(
377
+ program.opts().json ? report : report.markdown,
378
+ program.opts().json ?? false,
379
+ );
380
+ });
381
+
382
+ program
383
+ .command("discuss <query>")
384
+ .description("Search live/local tweets and summarize the results with AI")
385
+ .option("--account <accountId>", "Account id")
386
+ .option(
387
+ "--source <source>",
388
+ "all, search, home, mentions, authored, likes, or bookmarks",
389
+ "search",
390
+ )
391
+ .option("--mode <mode>", "auto, bird, xurl, or local", "xurl")
392
+ .option("--include-dms", "Include private DM search matches")
393
+ .option(
394
+ "--since <isoDate>",
395
+ "Include matches created at or after this date",
396
+ )
397
+ .option("--until <isoDate>", "Include matches created before this date")
398
+ .option("--question <prompt>", "Discussion question or angle")
399
+ .option("--originals-only", "Exclude authored replies that start with @")
400
+ .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
401
+ .option("--model <model>", "OpenAI model id")
402
+ .option("--refresh", "Bypass the local discussion cache")
403
+ .option("--limit <n>", "Maximum tweet context", "20000")
404
+ .option("--max-pages <n>", "Maximum live search pages", "200")
405
+ .action(async (query, options) => {
406
+ await autoUpdateBeforeRead();
407
+ const discussionOptions = buildSearchDiscussionOptions(query, options);
408
+ if (!discussionOptions) return;
409
+ await runSearchDiscussionCli(discussionOptions);
410
+ });
411
+
412
+ program
413
+ .command("profile-analyze <handle>")
414
+ .alias("profile-analyse")
415
+ .description("Backfill a profile with xurl and summarize it with AI")
416
+ .option("--account <accountId>", "Account id")
417
+ .option("--model <model>", "OpenAI model id")
418
+ .option("--refresh", "Bypass profile fetch and analysis caches")
419
+ .option("--max-tweets <n>", "Maximum profile tweets", "10000")
420
+ .option("--max-pages <n>", "Maximum profile timeline pages", "100")
421
+ .option(
422
+ "--max-conversations <n>",
423
+ "Maximum conversations to backfill",
424
+ "80",
425
+ )
426
+ .option(
427
+ "--max-conversation-pages <n>",
428
+ "Maximum pages per conversation",
429
+ "3",
430
+ )
431
+ .option(
432
+ "--conversation-delay-ms <n>",
433
+ "Delay between conversation search calls",
434
+ )
435
+ .option(
436
+ "--rate-limit-retry-ms <n>",
437
+ "Delay before retrying conversation 429s",
438
+ )
439
+ .option("--rate-limit-retries <n>", "Conversation 429 retry count")
440
+ .action(async (handle, options) => {
441
+ await autoUpdateBeforeRead();
442
+ const analysisOptions = buildProfileAnalysisOptions(handle, options);
443
+ if (!analysisOptions) return;
444
+ await runProfileAnalysisCli(analysisOptions);
445
+ });
446
+
447
+ program
448
+ .command("today")
449
+ .description("Stream an AI digest of what happened today")
450
+ .option("--account <accountId>", "Account id")
451
+ .option("--include-dms", "Include private DM context")
452
+ .option("--model <model>", "OpenAI model id")
453
+ .option(
454
+ "--language <tag>",
455
+ "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
456
+ )
457
+ .option("--refresh", "Bypass the local digest cache")
458
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
459
+ .option("--max-links <n>", "Maximum linked articles", "12")
460
+ .option("--no-live-sync", "Use only the local database")
461
+ .option(
462
+ "--live-mode <mode>",
463
+ "Live timeline mode: xurl, bird, or auto",
464
+ "xurl",
465
+ )
466
+ .action(async (options) => {
467
+ await autoUpdateBeforeRead();
468
+ const digestOptions = buildDigestOptions("today", options);
469
+ if (!digestOptions) return;
470
+ await runDigestCli(digestOptions);
471
+ });
472
+
473
+ program
474
+ .command("digest [period]")
475
+ .description("Stream an AI digest for today, 24h, yesterday, or week")
476
+ .option("--account <accountId>", "Account id")
477
+ .option("--include-dms", "Include private DM context")
478
+ .option("--since <isoDate>", "Start of explicit window")
479
+ .option("--until <isoDate>", "End of explicit window")
480
+ .option("--model <model>", "OpenAI model id")
481
+ .option(
482
+ "--language <tag>",
483
+ "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
484
+ )
485
+ .option("--refresh", "Bypass the local digest cache")
486
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
487
+ .option("--max-links <n>", "Maximum linked articles", "12")
488
+ .option("--no-live-sync", "Use only the local database")
489
+ .option(
490
+ "--live-mode <mode>",
491
+ "Live timeline mode: xurl, bird, or auto",
492
+ "xurl",
493
+ )
494
+ .action(async (period, options) => {
495
+ await autoUpdateBeforeRead();
496
+ const digestOptions = buildDigestOptions(period, options);
497
+ if (!digestOptions) return;
498
+ await runDigestCli(digestOptions);
499
+ });
500
+ }
@@ -0,0 +1,40 @@
1
+ import { createDmReply, createPost, createTweetReply } from "#/lib/queries";
2
+ import type { CliCommandContext } from "./command-context";
3
+
4
+ export function registerComposeCommands({
5
+ program,
6
+ print,
7
+ asJson,
8
+ autoSyncAfterWrite,
9
+ }: CliCommandContext) {
10
+ const composeCommand = program
11
+ .command("compose")
12
+ .description("Create local/xurl actions");
13
+
14
+ composeCommand
15
+ .command("post <text>")
16
+ .option("--account <accountId>", "Account id", "acct_primary")
17
+ .action(async (text, options) => {
18
+ const result = await createPost(options.account, text);
19
+ await autoSyncAfterWrite();
20
+ print(result, asJson());
21
+ });
22
+
23
+ composeCommand
24
+ .command("reply <tweetId> <text>")
25
+ .option("--account <accountId>", "Account id", "acct_primary")
26
+ .action(async (tweetId, text, options) => {
27
+ const result = await createTweetReply(options.account, tweetId, text);
28
+ await autoSyncAfterWrite();
29
+ print(result, asJson());
30
+ });
31
+
32
+ composeCommand
33
+ .command("dm <conversationId> <text>")
34
+ .description("Reply inside an existing DM conversation")
35
+ .action(async (conversationId, text) => {
36
+ const result = await createDmReply(conversationId, text);
37
+ await autoSyncAfterWrite();
38
+ print(result, asJson());
39
+ });
40
+ }