birdclaw 0.8.2 → 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 (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
package/src/lib/sqlite.ts CHANGED
@@ -6,6 +6,7 @@ type DatabaseOptions = {
6
6
  readonly?: boolean;
7
7
  fileMustExist?: boolean;
8
8
  timeout?: number;
9
+ onStatement?: (sql: string, durationMs: number) => void;
9
10
  };
10
11
 
11
12
  type PragmaOptions = {
@@ -53,41 +54,72 @@ function normalizeRow(row: unknown): unknown {
53
54
  class NativeSqliteStatement {
54
55
  readonly reader: boolean;
55
56
 
56
- constructor(private readonly statement: StatementSync) {
57
+ constructor(
58
+ private readonly statement: StatementSync,
59
+ private readonly sql: string,
60
+ private readonly onStatement?: (sql: string, durationMs: number) => void,
61
+ ) {
57
62
  this.reader = statement.columns().length > 0;
58
63
  }
59
64
 
65
+ private track<T>(operation: () => T) {
66
+ const startedAt = performance.now();
67
+ try {
68
+ return operation();
69
+ } finally {
70
+ this.onStatement?.(this.sql, performance.now() - startedAt);
71
+ }
72
+ }
73
+
60
74
  all(...parameters: unknown[]): unknown[] {
61
- return this.statement.all(...bindArgs(parameters)).map(normalizeRow);
75
+ return this.track(() =>
76
+ this.statement.all(...bindArgs(parameters)).map(normalizeRow),
77
+ );
62
78
  }
63
79
 
64
80
  get(...parameters: unknown[]): unknown {
65
- return normalizeRow(this.statement.get(...bindArgs(parameters)));
81
+ return this.track(() =>
82
+ normalizeRow(this.statement.get(...bindArgs(parameters))),
83
+ );
66
84
  }
67
85
 
68
86
  run(...parameters: unknown[]): RunResult {
69
- const result = this.statement.run(...bindArgs(parameters));
70
- return {
71
- changes: Number(result.changes),
72
- lastInsertRowid: Number(result.lastInsertRowid),
73
- };
87
+ return this.track(() => {
88
+ const result = this.statement.run(...bindArgs(parameters));
89
+ return {
90
+ changes: Number(result.changes),
91
+ lastInsertRowid: Number(result.lastInsertRowid),
92
+ };
93
+ });
74
94
  }
75
95
 
76
96
  iterate(...parameters: unknown[]): IterableIterator<unknown> {
77
97
  const rows = this.statement.iterate(...bindArgs(parameters));
98
+ const startedAt = performance.now();
99
+ const onStatement = this.onStatement;
100
+ const sql = this.sql;
78
101
  return (function* () {
79
- for (const row of rows) {
80
- yield normalizeRow(row);
102
+ try {
103
+ for (const row of rows) {
104
+ yield normalizeRow(row);
105
+ }
106
+ } finally {
107
+ onStatement?.(sql, performance.now() - startedAt);
81
108
  }
82
109
  })();
83
110
  }
84
111
  }
85
112
 
86
113
  export class NativeSqliteDatabase {
114
+ readonly writeIdentity: string | object;
87
115
  private transactionDepth = 0;
88
116
  private readonly db: DatabaseSync;
89
117
 
90
- constructor(path: string, options: DatabaseOptions = {}) {
118
+ constructor(
119
+ path: string,
120
+ private readonly options: DatabaseOptions = {},
121
+ ) {
122
+ this.writeIdentity = path === ":memory:" ? this : path;
91
123
  this.db = new DatabaseSync(path, {
92
124
  readOnly: options.readonly,
93
125
  timeout: options.timeout ?? SQLITE_BUSY_TIMEOUT_MS,
@@ -117,7 +149,11 @@ export class NativeSqliteDatabase {
117
149
  }
118
150
 
119
151
  prepare(sql: string): NativeSqliteStatement {
120
- return new NativeSqliteStatement(this.db.prepare(sql));
152
+ return new NativeSqliteStatement(
153
+ this.db.prepare(sql),
154
+ sql,
155
+ this.options.onStatement,
156
+ );
121
157
  }
122
158
 
123
159
  transaction<TArgs extends unknown[], TResult>(
@@ -0,0 +1,240 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+ import { Effect } from "effect";
4
+
5
+ export interface IngestionCheckpoint {
6
+ processed: number;
7
+ }
8
+
9
+ export interface IngestionSource<T> {
10
+ id: string;
11
+ stream: () => AsyncIterable<T>;
12
+ }
13
+
14
+ export interface IngestionSourceCheckpoint extends IngestionCheckpoint {
15
+ sourceId: string;
16
+ sourceIndex: number;
17
+ sourceProcessed: number;
18
+ sources: number;
19
+ }
20
+
21
+ export async function* streamJsonLines(
22
+ filePath: string,
23
+ ): AsyncGenerator<{ lineNumber: number; value: Record<string, unknown> }> {
24
+ const input = createReadStream(filePath, { encoding: "utf8" });
25
+ const lines = createInterface({ input, crlfDelay: Infinity });
26
+ let lineNumber = 0;
27
+ for await (const line of lines) {
28
+ lineNumber += 1;
29
+ if (!line.trim()) continue;
30
+ yield {
31
+ lineNumber,
32
+ value: JSON.parse(line) as Record<string, unknown>,
33
+ };
34
+ }
35
+ }
36
+
37
+ export async function* streamAssignedJsonArray(
38
+ source: AsyncIterable<Buffer | string>,
39
+ ): AsyncGenerator<Record<string, unknown>> {
40
+ let started = false;
41
+ let item = "";
42
+ let depth = 0;
43
+ let inString = false;
44
+ let escaped = false;
45
+
46
+ const flush = () => {
47
+ const value = item.trim();
48
+ item = "";
49
+ return value ? (JSON.parse(value) as Record<string, unknown>) : undefined;
50
+ };
51
+
52
+ for await (const chunk of source) {
53
+ for (const character of String(chunk)) {
54
+ if (!started) {
55
+ if (character === "[") started = true;
56
+ continue;
57
+ }
58
+
59
+ if (inString) {
60
+ item += character;
61
+ if (escaped) {
62
+ escaped = false;
63
+ } else if (character === "\\") {
64
+ escaped = true;
65
+ } else if (character === '"') {
66
+ inString = false;
67
+ }
68
+ continue;
69
+ }
70
+
71
+ if (character === '"') {
72
+ inString = true;
73
+ item += character;
74
+ continue;
75
+ }
76
+ if (character === "{" || character === "[") {
77
+ depth += 1;
78
+ item += character;
79
+ continue;
80
+ }
81
+ if (character === "}" || (character === "]" && depth > 0)) {
82
+ depth -= 1;
83
+ item += character;
84
+ continue;
85
+ }
86
+ if (character === "]" && depth === 0) {
87
+ const value = flush();
88
+ if (value) yield value;
89
+ return;
90
+ }
91
+ if (character === "," && depth === 0) {
92
+ const value = flush();
93
+ if (value) yield value;
94
+ continue;
95
+ }
96
+ if (item.length > 0 || !/\s/.test(character)) {
97
+ item += character;
98
+ }
99
+ }
100
+ }
101
+
102
+ if (!started) return;
103
+ const value = flush();
104
+ if (value) yield value;
105
+ }
106
+
107
+ export function ingestStreamInBatchesEffect<T>({
108
+ batchSize = 250,
109
+ onCheckpoint,
110
+ processBatch,
111
+ resumeAfter = 0,
112
+ source,
113
+ }: {
114
+ batchSize?: number;
115
+ onCheckpoint?: (checkpoint: IngestionCheckpoint) => void | Promise<void>;
116
+ processBatch: (
117
+ batch: T[],
118
+ checkpoint: IngestionCheckpoint,
119
+ ) => void | Promise<void>;
120
+ resumeAfter?: number;
121
+ source: () => AsyncIterable<T>;
122
+ }): Effect.Effect<IngestionCheckpoint, unknown> {
123
+ return Effect.tryPromise({
124
+ try: async () => {
125
+ const normalizedBatchSize = Math.max(1, Math.trunc(batchSize));
126
+ const normalizedResumeAfter = Math.max(0, Math.trunc(resumeAfter));
127
+ let visited = 0;
128
+ let processed = normalizedResumeAfter;
129
+ let batch: T[] = [];
130
+
131
+ const flush = async () => {
132
+ if (batch.length === 0) return;
133
+ processed += batch.length;
134
+ const checkpoint = { processed };
135
+ await processBatch(batch, checkpoint);
136
+ await onCheckpoint?.(checkpoint);
137
+ batch = [];
138
+ };
139
+
140
+ for await (const item of source()) {
141
+ visited += 1;
142
+ if (visited <= normalizedResumeAfter) continue;
143
+ batch.push(item);
144
+ if (batch.length >= normalizedBatchSize) {
145
+ await flush();
146
+ }
147
+ }
148
+ await flush();
149
+ return { processed };
150
+ },
151
+ catch: (error) =>
152
+ error instanceof Error ? error : new Error(String(error)),
153
+ });
154
+ }
155
+
156
+ export function ingestSourcesInBatchesEffect<T>({
157
+ batchSize,
158
+ onCheckpoint,
159
+ onSourceComplete,
160
+ processBatch,
161
+ sources,
162
+ }: {
163
+ batchSize?: number;
164
+ onCheckpoint?: (
165
+ checkpoint: IngestionSourceCheckpoint,
166
+ ) => void | Promise<void>;
167
+ onSourceComplete?: (
168
+ checkpoint: IngestionSourceCheckpoint,
169
+ ) => void | Promise<void>;
170
+ processBatch: (
171
+ batch: T[],
172
+ checkpoint: IngestionSourceCheckpoint,
173
+ ) => void | Promise<void>;
174
+ sources: IngestionSource<T>[];
175
+ }): Effect.Effect<IngestionCheckpoint, unknown> {
176
+ return Effect.gen(function* () {
177
+ let processed = 0;
178
+
179
+ for (const [sourceIndex, source] of sources.entries()) {
180
+ const sourceResult = yield* ingestStreamInBatchesEffect({
181
+ batchSize,
182
+ source: source.stream,
183
+ processBatch: (batch, checkpoint) =>
184
+ processBatch(batch, {
185
+ ...checkpoint,
186
+ processed: processed + checkpoint.processed,
187
+ sourceId: source.id,
188
+ sourceIndex,
189
+ sourceProcessed: checkpoint.processed,
190
+ sources: sources.length,
191
+ }),
192
+ onCheckpoint: onCheckpoint
193
+ ? (checkpoint) =>
194
+ onCheckpoint({
195
+ ...checkpoint,
196
+ processed: processed + checkpoint.processed,
197
+ sourceId: source.id,
198
+ sourceIndex,
199
+ sourceProcessed: checkpoint.processed,
200
+ sources: sources.length,
201
+ })
202
+ : undefined,
203
+ });
204
+ processed += sourceResult.processed;
205
+ yield* Effect.tryPromise({
206
+ try: () =>
207
+ Promise.resolve(
208
+ onSourceComplete?.({
209
+ processed,
210
+ sourceId: source.id,
211
+ sourceIndex,
212
+ sourceProcessed: sourceResult.processed,
213
+ sources: sources.length,
214
+ }),
215
+ ),
216
+ catch: (error) =>
217
+ error instanceof Error ? error : new Error(String(error)),
218
+ });
219
+ }
220
+
221
+ return { processed };
222
+ });
223
+ }
224
+
225
+ export function collectIngestionSourcesEffect<T>(
226
+ sources: IngestionSource<T>[],
227
+ options: { batchSize?: number } = {},
228
+ ): Effect.Effect<T[], unknown> {
229
+ return Effect.gen(function* () {
230
+ const rows: T[] = [];
231
+ yield* ingestSourcesInBatchesEffect({
232
+ ...options,
233
+ sources,
234
+ processBatch: (batch) => {
235
+ rows.push(...batch);
236
+ },
237
+ });
238
+ return rows;
239
+ });
240
+ }
@@ -1,4 +1,8 @@
1
1
  import { getNativeDb } from "./db";
2
+ import {
3
+ defaultServerRuntimeServices,
4
+ type ServerRuntimeServices,
5
+ } from "./server-runtime-services";
2
6
 
3
7
  export interface SyncCacheEntry<T> {
4
8
  value: T;
@@ -45,8 +49,9 @@ export function writeSyncCache(
45
49
  cacheKey: string,
46
50
  value: unknown,
47
51
  db = getNativeDb(),
52
+ runtime: ServerRuntimeServices = defaultServerRuntimeServices,
48
53
  ) {
49
- const updatedAt = new Date().toISOString();
54
+ const updatedAt = runtime.now().toISOString();
50
55
  db.prepare(
51
56
  `
52
57
  insert into sync_cache (cache_key, value_json, updated_at)
@@ -0,0 +1,175 @@
1
+ import { Effect } from "effect";
2
+
3
+ export type PaginationStopReason =
4
+ | "boundary"
5
+ | "exhausted"
6
+ | "item-limit"
7
+ | "page-limit"
8
+ | "repeated-cursor";
9
+
10
+ export interface PaginationPageContext<Page> {
11
+ cursor?: string;
12
+ fetched: number;
13
+ page: Page;
14
+ pageIndex: number;
15
+ pageNumber: number;
16
+ stopReason?: PaginationStopReason;
17
+ done: boolean;
18
+ }
19
+
20
+ export type SyncPlanStopReason = PaginationStopReason | "error";
21
+
22
+ export interface SyncPlanPageContext<Page> extends Omit<
23
+ PaginationPageContext<Page>,
24
+ "stopReason"
25
+ > {
26
+ nextCursor?: string;
27
+ stopReason?: SyncPlanStopReason;
28
+ }
29
+
30
+ export interface SyncPlanResult<Page, ErrorType> {
31
+ complete: boolean;
32
+ fetched: number;
33
+ nextCursor?: string;
34
+ pages: Page[];
35
+ stopReason: SyncPlanStopReason;
36
+ error?: ErrorType;
37
+ }
38
+
39
+ function normalizeCursor(value: string | null | undefined) {
40
+ const normalized = value?.trim();
41
+ return normalized || undefined;
42
+ }
43
+
44
+ export function runSyncPlanEffect<Page, FetchError, PageError = never>({
45
+ fetchPage,
46
+ getItemCount,
47
+ getNextCursor,
48
+ initialCursor,
49
+ maxItems,
50
+ maxPages,
51
+ onPage,
52
+ pageDelayMs,
53
+ persistPage,
54
+ shouldStop,
55
+ allowPartialFailure = false,
56
+ }: {
57
+ fetchPage: (context: {
58
+ cursor?: string;
59
+ fetched: number;
60
+ pageIndex: number;
61
+ }) => Effect.Effect<Page, FetchError>;
62
+ getItemCount?: (page: Page) => number;
63
+ getNextCursor: (page: Page) => string | null | undefined;
64
+ initialCursor?: string;
65
+ maxItems?: number;
66
+ maxPages?: number;
67
+ onPage?: (context: SyncPlanPageContext<Page>) => void;
68
+ pageDelayMs?: number;
69
+ persistPage?: (
70
+ context: SyncPlanPageContext<Page>,
71
+ ) => Effect.Effect<void, PageError>;
72
+ shouldStop?: (
73
+ context: Omit<SyncPlanPageContext<Page>, "done" | "nextCursor">,
74
+ ) => boolean;
75
+ allowPartialFailure?: boolean;
76
+ }): Effect.Effect<SyncPlanResult<Page, FetchError>, FetchError | PageError> {
77
+ return Effect.gen(function* () {
78
+ const pages: Page[] = [];
79
+ const seenCursors = new Set<string>();
80
+ let cursor = normalizeCursor(initialCursor);
81
+ let fetched = 0;
82
+ const pageLimit =
83
+ maxPages === undefined ? Number.POSITIVE_INFINITY : Math.max(1, maxPages);
84
+ const itemLimit =
85
+ maxItems === undefined ? Number.POSITIVE_INFINITY : Math.max(1, maxItems);
86
+
87
+ if (cursor) seenCursors.add(cursor);
88
+
89
+ while (pages.length < pageLimit) {
90
+ const pageIndex = pages.length;
91
+ const outcome = yield* fetchPage({ cursor, fetched, pageIndex }).pipe(
92
+ Effect.map((page) => ({ ok: true as const, page })),
93
+ Effect.catchAll((error) =>
94
+ Effect.succeed({ ok: false as const, error }),
95
+ ),
96
+ );
97
+ if (!outcome.ok) {
98
+ if (!allowPartialFailure || pages.length === 0) {
99
+ return yield* Effect.fail(outcome.error);
100
+ }
101
+ return {
102
+ complete: false,
103
+ fetched,
104
+ ...(cursor ? { nextCursor: cursor } : {}),
105
+ pages,
106
+ stopReason: "error",
107
+ error: outcome.error,
108
+ };
109
+ }
110
+
111
+ const page = outcome.page;
112
+ pages.push(page);
113
+ fetched += Math.max(0, getItemCount?.(page) ?? 0);
114
+ const nextCursor = normalizeCursor(getNextCursor(page));
115
+ const baseContext = {
116
+ cursor,
117
+ fetched,
118
+ page,
119
+ pageIndex,
120
+ pageNumber: pageIndex + 1,
121
+ };
122
+ let stopReason: PaginationStopReason | undefined;
123
+
124
+ if (!nextCursor) {
125
+ stopReason = "exhausted";
126
+ } else if (shouldStop?.(baseContext)) {
127
+ stopReason = "boundary";
128
+ } else if (fetched >= itemLimit) {
129
+ stopReason = "item-limit";
130
+ } else if (pages.length >= pageLimit) {
131
+ stopReason = "page-limit";
132
+ } else if (seenCursors.has(nextCursor)) {
133
+ stopReason = "repeated-cursor";
134
+ }
135
+
136
+ const context: SyncPlanPageContext<Page> = {
137
+ ...baseContext,
138
+ ...(nextCursor ? { nextCursor } : {}),
139
+ done: Boolean(stopReason),
140
+ ...(stopReason ? { stopReason } : {}),
141
+ };
142
+ if (persistPage) {
143
+ yield* persistPage(context);
144
+ }
145
+ onPage?.(context);
146
+
147
+ if (stopReason) {
148
+ return {
149
+ complete:
150
+ stopReason === "exhausted" ||
151
+ stopReason === "boundary" ||
152
+ stopReason === "item-limit",
153
+ fetched,
154
+ ...(nextCursor ? { nextCursor } : {}),
155
+ pages,
156
+ stopReason,
157
+ };
158
+ }
159
+
160
+ cursor = nextCursor;
161
+ seenCursors.add(nextCursor!);
162
+ if (typeof pageDelayMs === "number" && pageDelayMs > 0) {
163
+ yield* Effect.sleep(pageDelayMs);
164
+ }
165
+ }
166
+
167
+ return {
168
+ complete: false,
169
+ fetched,
170
+ ...(cursor ? { nextCursor: cursor } : {}),
171
+ pages,
172
+ stopReason: "page-limit",
173
+ };
174
+ });
175
+ }