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
@@ -0,0 +1,164 @@
1
+ import { Effect } from "effect";
2
+ import type { Database } from "./sqlite";
3
+ import { findArchivesCachedEffect } from "./archive-finder";
4
+ import { getReadDb } from "./db";
5
+ import { runEffectPromise } from "./effect-runtime";
6
+ import type { AccountRecord, QueryEnvelope } from "./types";
7
+ import { getTransportStatusEffect } from "./xurl";
8
+
9
+ export type { QueryEnvelope } from "./types";
10
+
11
+ function toError(error: unknown) {
12
+ return error instanceof Error ? error : new Error(String(error));
13
+ }
14
+
15
+ function trySync<T>(try_: () => T) {
16
+ return Effect.try({ try: try_, catch: toError });
17
+ }
18
+
19
+ function countTimelineEdges(db: Database, kind: "home" | "mention") {
20
+ const row = db
21
+ .prepare(
22
+ `
23
+ select count(distinct tweet_id) as count
24
+ from (
25
+ select edge.tweet_id
26
+ from tweet_account_edges edge
27
+ where edge.kind = ?
28
+ and exists (
29
+ select 1
30
+ from tweets t
31
+ where t.id = edge.tweet_id
32
+ )
33
+ union all
34
+ select legacy.id as tweet_id
35
+ from tweets legacy
36
+ where legacy.kind = ?
37
+ and not exists (
38
+ select 1
39
+ from tweet_account_edges edge
40
+ where edge.account_id = legacy.account_id
41
+ and edge.tweet_id = legacy.id
42
+ and edge.kind = legacy.kind
43
+ )
44
+ )
45
+ `,
46
+ )
47
+ .get(kind, kind) as { count: number | bigint } | undefined;
48
+ return Number(row?.count ?? 0);
49
+ }
50
+
51
+ function getAccountProfileMeta(
52
+ db: Database,
53
+ account: { handle: string; external_user_id: string | null },
54
+ ) {
55
+ const handle = account.handle.replace(/^@/, "");
56
+ const externalProfileId = account.external_user_id
57
+ ? `profile_user_${account.external_user_id}`
58
+ : "";
59
+ return db
60
+ .prepare(
61
+ `
62
+ select id, avatar_hue, avatar_url
63
+ from profiles
64
+ where id = ?
65
+ or lower(handle) = lower(?)
66
+ order by case
67
+ when id = 'profile_me' then 0
68
+ when id = ? then 1
69
+ else 2
70
+ end
71
+ limit 1
72
+ `,
73
+ )
74
+ .get(externalProfileId, handle, externalProfileId) as
75
+ | { id: string; avatar_hue: number; avatar_url: string | null }
76
+ | undefined;
77
+ }
78
+
79
+ export function getQueryEnvelopeEffect({
80
+ includeArchives = true,
81
+ }: { includeArchives?: boolean } = {}): Effect.Effect<QueryEnvelope, unknown> {
82
+ return Effect.gen(function* () {
83
+ const nativeDb = yield* trySync(() => getReadDb());
84
+ const homeCount = yield* trySync(() =>
85
+ countTimelineEdges(nativeDb, "home"),
86
+ );
87
+ const mentionCount = yield* trySync(() =>
88
+ countTimelineEdges(nativeDb, "mention"),
89
+ );
90
+ const counts = yield* Effect.all({
91
+ dms: trySync(
92
+ () =>
93
+ nativeDb
94
+ .prepare("select count(*) as count from dm_conversations")
95
+ .get() as { count: number },
96
+ ),
97
+ needsReply: trySync(
98
+ () =>
99
+ nativeDb
100
+ .prepare(
101
+ "select count(*) as count from dm_conversations where needs_reply = 1",
102
+ )
103
+ .get() as { count: number },
104
+ ),
105
+ accounts: trySync(
106
+ () =>
107
+ nativeDb
108
+ .prepare(
109
+ "select * from accounts order by is_default desc, name asc",
110
+ )
111
+ .all() as Array<{
112
+ id: string;
113
+ name: string;
114
+ handle: string;
115
+ external_user_id: string | null;
116
+ transport: string;
117
+ is_default: number;
118
+ created_at: string;
119
+ }>,
120
+ ),
121
+ archives: includeArchives
122
+ ? findArchivesCachedEffect()
123
+ : Effect.succeed([]),
124
+ transport: getTransportStatusEffect(),
125
+ });
126
+
127
+ return {
128
+ stats: {
129
+ home: homeCount,
130
+ mentions: mentionCount,
131
+ dms: Number(counts.dms.count),
132
+ needsReply: Number(counts.needsReply.count),
133
+ inbox: mentionCount + Number(counts.needsReply.count),
134
+ },
135
+ accounts: counts.accounts.map((row) => {
136
+ const profile = getAccountProfileMeta(nativeDb, row);
137
+ return {
138
+ id: row.id,
139
+ name: row.name,
140
+ handle: row.handle,
141
+ externalUserId: row.external_user_id,
142
+ ...(profile
143
+ ? {
144
+ profileId: profile.id,
145
+ avatarHue: Number(profile.avatar_hue),
146
+ ...(profile.avatar_url
147
+ ? { avatarUrl: profile.avatar_url }
148
+ : {}),
149
+ }
150
+ : {}),
151
+ transport: row.transport,
152
+ isDefault: row.is_default,
153
+ createdAt: row.created_at,
154
+ };
155
+ }) satisfies AccountRecord[],
156
+ archives: counts.archives,
157
+ transport: counts.transport,
158
+ };
159
+ });
160
+ }
161
+
162
+ export function getQueryEnvelope(): Promise<QueryEnvelope> {
163
+ return runEffectPromise(getQueryEnvelopeEffect());
164
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { Effect } from "effect";
4
4
  import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
- import { listTimelineItems } from "./queries";
6
+ import { listTimelineItems } from "./timeline-read-model";
7
7
  import { lookupTweetsByIdsEffect } from "./tweet-lookup";
8
8
  import { renderTweetMarkdown, renderTweetPlainText } from "./tweet-render";
9
9
  import type { TweetEntities, XurlMentionUser } from "./types";
@@ -0,0 +1,20 @@
1
+ export interface RuntimeServices {
2
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
3
+ now(): Date;
4
+ random(): number;
5
+ env(name: string): string | undefined;
6
+ }
7
+
8
+ export const defaultRuntimeServices: RuntimeServices = {
9
+ fetch: (input, init) => globalThis.fetch(input, init),
10
+ now: () => new Date(),
11
+ random: () => Math.random(),
12
+ env: (name) =>
13
+ typeof process === "undefined" ? undefined : process.env[name],
14
+ };
15
+
16
+ export function createRuntimeServices(
17
+ overrides: Partial<RuntimeServices> = {},
18
+ ): RuntimeServices {
19
+ return { ...defaultRuntimeServices, ...overrides };
20
+ }
@@ -1,14 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Effect } from "effect";
3
3
  import { z } from "zod";
4
- import { prefetchCachedAvatarsForProfileIdsEffect } from "./avatar-cache";
5
4
  import {
6
- runEffectBackground,
7
- runEffectPromise,
8
- tryPromise,
9
- } from "./effect-runtime";
5
+ createAnalysisRequestBody,
6
+ type HybridAnalysisResult,
7
+ parseHybridAnalysis,
8
+ resolveAnalysisModelSettings,
9
+ streamHybridAnalysisEffect,
10
+ } from "./analysis-runtime";
11
+ import { prefetchCachedAvatarsForProfileIdsEffect } from "./avatar-cache";
12
+ import { runEffectBackground, runEffectPromise } from "./effect-runtime";
10
13
  import { getNativeDb } from "./db";
11
- import { listDmConversations, listTimelineItems } from "./queries";
14
+ import { listDmConversations } from "./dm-read-model";
15
+ import {
16
+ type OpenAIStreamState,
17
+ processOpenAIResponseSseChunk,
18
+ } from "./openai-response-runtime";
19
+ import { listTimelineItems } from "./timeline-read-model";
12
20
  import { readSyncCache, writeSyncCache } from "./sync-cache";
13
21
  import {
14
22
  syncTweetSearchEffect,
@@ -129,24 +137,10 @@ export type SearchDiscussionStreamEvent =
129
137
  | { type: "done"; result: SearchDiscussionRunResult }
130
138
  | { type: "error"; error: string };
131
139
 
132
- interface OpenAIStreamState {
133
- eventBuffer: string;
134
- rawText: string;
135
- pendingVisible: string;
136
- jsonMode: boolean;
137
- responseId?: string;
138
- usage?: unknown;
139
- error?: string;
140
- }
141
-
142
- const DEFAULT_MODEL = "gpt-5.5";
143
- const DEFAULT_REASONING_EFFORT = "medium";
144
- const DEFAULT_SERVICE_TIER = "priority";
145
140
  const DEFAULT_LIMIT = 20_000;
146
141
  const DEFAULT_MAX_PAGES = 200;
147
142
  const MAX_PROMPT_DATA_CHARS = 1_200_000;
148
143
  const DELIMITER_PATTERN = /\n---\s*\n/;
149
- const VISIBLE_DELIMITER_HOLD = 8;
150
144
 
151
145
  function toError(error: unknown) {
152
146
  return error instanceof Error ? error : new Error(String(error));
@@ -159,12 +153,6 @@ function trySearchSync<T>(try_: () => T): Effect.Effect<T, Error> {
159
153
  });
160
154
  }
161
155
 
162
- function trySearchPromise<T>(
163
- try_: () => PromiseLike<T>,
164
- ): Effect.Effect<T, Error> {
165
- return tryPromise(try_).pipe(Effect.mapError(toError));
166
- }
167
-
168
156
  function tweetUrl(handle: string, id: string) {
169
157
  return `https://x.com/${handle}/status/${id}`;
170
158
  }
@@ -524,27 +512,15 @@ function prefetchDiscussionAvatars(context: SearchDiscussionContext) {
524
512
  }
525
513
 
526
514
  function modelFromOptions(options: SearchDiscussionOptions) {
527
- return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
515
+ return resolveAnalysisModelSettings(options).model;
528
516
  }
529
517
 
530
518
  function reasoningEffortFromOptions(options: SearchDiscussionOptions) {
531
- return (
532
- options.reasoningEffort ??
533
- (process.env.BIRDCLAW_OPENAI_REASONING_EFFORT as
534
- | SearchDiscussionOptions["reasoningEffort"]
535
- | undefined) ??
536
- DEFAULT_REASONING_EFFORT
537
- );
519
+ return resolveAnalysisModelSettings(options).reasoningEffort;
538
520
  }
539
521
 
540
522
  function serviceTierFromOptions(options: SearchDiscussionOptions) {
541
- return (
542
- options.serviceTier ??
543
- (process.env.BIRDCLAW_OPENAI_SERVICE_TIER as
544
- | SearchDiscussionOptions["serviceTier"]
545
- | undefined) ??
546
- DEFAULT_SERVICE_TIER
547
- );
523
+ return resolveAnalysisModelSettings(options).serviceTier;
548
524
  }
549
525
 
550
526
  function cacheKey(
@@ -665,115 +641,13 @@ function parseDiscussionFromHybridText(
665
641
  context: SearchDiscussionContext,
666
642
  rawText: string,
667
643
  ): { discussion: SearchDiscussion; markdown: string } {
668
- const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
669
- const markdown = (markdownPart ?? rawText).trim();
670
- const candidate = jsonPart?.slice(
671
- jsonPart.indexOf("{"),
672
- jsonPart.lastIndexOf("}") + 1,
673
- );
674
- if (candidate?.startsWith("{")) {
675
- try {
676
- return {
677
- markdown,
678
- discussion: SearchDiscussionSchema.parse(JSON.parse(candidate)),
679
- };
680
- } catch {
681
- return { markdown, discussion: fallbackDiscussion(context, markdown) };
682
- }
683
- }
684
- return { markdown, discussion: fallbackDiscussion(context, markdown) };
685
- }
686
-
687
- function emitVisibleDelta(
688
- state: OpenAIStreamState,
689
- delta: string,
690
- handlers: SearchDiscussionStreamHandlers,
691
- ) {
692
- state.rawText += delta;
693
- if (state.jsonMode) return;
694
-
695
- const combined = state.pendingVisible + delta;
696
- const delimiterIndex = combined.search(DELIMITER_PATTERN);
697
- if (delimiterIndex >= 0) {
698
- const visible = combined.slice(0, delimiterIndex);
699
- if (visible) {
700
- handlers.onDelta?.(visible);
701
- handlers.onEvent?.({ type: "delta", delta: visible });
702
- }
703
- state.pendingVisible = "";
704
- state.jsonMode = true;
705
- return;
706
- }
707
-
708
- if (combined.length <= VISIBLE_DELIMITER_HOLD) {
709
- state.pendingVisible = combined;
710
- return;
711
- }
712
-
713
- const visible = combined.slice(0, -VISIBLE_DELIMITER_HOLD);
714
- state.pendingVisible = combined.slice(-VISIBLE_DELIMITER_HOLD);
715
- if (visible) {
716
- handlers.onDelta?.(visible);
717
- handlers.onEvent?.({ type: "delta", delta: visible });
718
- }
719
- }
720
-
721
- function flushPendingVisible(
722
- state: OpenAIStreamState,
723
- handlers: SearchDiscussionStreamHandlers,
724
- ) {
725
- if (state.jsonMode || !state.pendingVisible) return;
726
- const delta = state.pendingVisible;
727
- state.pendingVisible = "";
728
- handlers.onDelta?.(delta);
729
- handlers.onEvent?.({ type: "delta", delta });
730
- }
731
-
732
- function handleOpenAIEvent(
733
- state: OpenAIStreamState,
734
- event: Record<string, unknown>,
735
- handlers: SearchDiscussionStreamHandlers,
736
- ) {
737
- const type = typeof event.type === "string" ? event.type : "";
738
- if (
739
- type === "response.output_text.delta" &&
740
- typeof event.delta === "string"
741
- ) {
742
- emitVisibleDelta(state, event.delta, handlers);
743
- return;
744
- }
745
- if (type === "response.completed") {
746
- const response = event.response;
747
- if (response && typeof response === "object") {
748
- const record = response as Record<string, unknown>;
749
- state.responseId = typeof record.id === "string" ? record.id : undefined;
750
- state.usage = record.usage;
751
- }
752
- return;
753
- }
754
- if (type === "response.error" || type === "error") {
755
- const error = event.error;
756
- state.error =
757
- error && typeof error === "object" && "message" in error
758
- ? String((error as { message?: unknown }).message)
759
- : "OpenAI stream failed";
760
- return;
761
- }
762
- if (type === "response.failed" || type === "response.incomplete") {
763
- const response = event.response;
764
- const record =
765
- response && typeof response === "object"
766
- ? (response as Record<string, unknown>)
767
- : {};
768
- const error = record.error;
769
- const incomplete = record.incomplete_details;
770
- state.error =
771
- error && typeof error === "object" && "message" in error
772
- ? String((error as { message?: unknown }).message)
773
- : incomplete && typeof incomplete === "object" && "reason" in incomplete
774
- ? `OpenAI response incomplete: ${String((incomplete as { reason?: unknown }).reason)}`
775
- : "OpenAI stream failed";
776
- }
644
+ const parsed = parseHybridAnalysis({
645
+ rawText,
646
+ parse: (value) => SearchDiscussionSchema.parse(value),
647
+ fallback: (markdown) => fallbackDiscussion(context, markdown),
648
+ delimiterPattern: DELIMITER_PATTERN,
649
+ });
650
+ return { markdown: parsed.markdown, discussion: parsed.value };
777
651
  }
778
652
 
779
653
  function processSseChunk(
@@ -781,126 +655,59 @@ function processSseChunk(
781
655
  chunk: string,
782
656
  handlers: SearchDiscussionStreamHandlers,
783
657
  ) {
784
- state.eventBuffer += chunk;
785
- let boundary = state.eventBuffer.indexOf("\n\n");
786
- while (boundary >= 0) {
787
- const block = state.eventBuffer.slice(0, boundary);
788
- state.eventBuffer = state.eventBuffer.slice(boundary + 2);
789
- const data = block
790
- .split("\n")
791
- .filter((line) => line.startsWith("data:"))
792
- .map((line) => line.slice(5).trimStart())
793
- .join("\n");
794
- if (data && data !== "[DONE]") {
795
- try {
796
- handleOpenAIEvent(
797
- state,
798
- JSON.parse(data) as Record<string, unknown>,
799
- handlers,
800
- );
801
- } catch {
802
- // Final hybrid parse decides whether malformed output can be used.
803
- }
804
- }
805
- boundary = state.eventBuffer.indexOf("\n\n");
806
- }
658
+ processOpenAIResponseSseChunk(state, chunk, {
659
+ delimiterPattern: DELIMITER_PATTERN,
660
+ onDelta: (delta) => {
661
+ handlers.onDelta?.(delta);
662
+ handlers.onEvent?.({ type: "delta", delta });
663
+ },
664
+ });
807
665
  }
808
666
 
809
667
  function createOpenAIRequestBody(
810
668
  context: SearchDiscussionContext,
811
669
  options: SearchDiscussionOptions,
812
670
  ) {
813
- return {
814
- model: modelFromOptions(options),
815
- reasoning: { effort: reasoningEffortFromOptions(options) },
816
- service_tier: serviceTierFromOptions(options),
817
- store: false,
671
+ return createAnalysisRequestBody({
672
+ settings: resolveAnalysisModelSettings(options),
673
+ system:
674
+ "You are a precise local Twitter archive analyst. Stream Markdown first, then emit the requested JSON object after the delimiter. Do not invent events not present in the dataset.",
675
+ prompt: buildPrompt(context),
818
676
  stream: true,
819
- max_output_tokens: 7000,
820
- input: [
821
- {
822
- role: "system",
823
- content:
824
- "You are a precise local Twitter archive analyst. Stream Markdown first, then emit the requested JSON object after the delimiter. Do not invent events not present in the dataset.",
825
- },
826
- {
827
- role: "user",
828
- content: buildPrompt(context),
829
- },
830
- ],
831
- };
677
+ });
832
678
  }
833
679
 
834
- function readOpenAIStreamEffect(
835
- response: Response,
680
+ function completeOpenAIStreamEffect(
681
+ stream: HybridAnalysisResult<SearchDiscussion>,
836
682
  context: SearchDiscussionContext,
837
683
  options: SearchDiscussionOptions,
838
684
  handlers: SearchDiscussionStreamHandlers,
839
685
  ): Effect.Effect<SearchDiscussionRunResult, Error> {
840
- const reader = response.body?.getReader();
841
- if (!reader) {
842
- return Effect.fail(new Error("OpenAI response did not include a stream"));
843
- }
844
-
845
- const decoder = new TextDecoder();
846
- const state: OpenAIStreamState = {
847
- eventBuffer: "",
848
- rawText: "",
849
- pendingVisible: "",
850
- jsonMode: false,
851
- };
852
-
853
686
  return Effect.gen(function* () {
854
- for (;;) {
855
- const { done, value } = yield* trySearchPromise(() => reader.read());
856
- if (!done) {
857
- processSseChunk(
858
- state,
859
- decoder.decode(value, { stream: true }),
860
- handlers,
861
- );
862
- continue;
863
- }
864
-
865
- flushPendingVisible(state, handlers);
866
- if (state.error) {
867
- return yield* Effect.fail(new Error(state.error));
868
- }
869
-
870
- const parsed = yield* trySearchSync(() =>
871
- parseDiscussionFromHybridText(context, state.rawText),
872
- );
873
- const updatedAt = yield* trySearchSync(() =>
874
- writeSyncCache(cacheKey(context, options), {
875
- discussion: parsed.discussion,
876
- markdown: parsed.markdown,
877
- model: modelFromOptions(options),
878
- reasoningEffort: reasoningEffortFromOptions(options),
879
- serviceTier: serviceTierFromOptions(options),
880
- usage: state.usage,
881
- responseId: state.responseId,
882
- }),
883
- );
884
- const result = {
885
- context,
886
- discussion: parsed.discussion,
887
- markdown: parsed.markdown,
687
+ const updatedAt = yield* trySearchSync(() =>
688
+ writeSyncCache(cacheKey(context, options), {
689
+ discussion: stream.value,
690
+ markdown: stream.markdown,
888
691
  model: modelFromOptions(options),
889
692
  reasoningEffort: reasoningEffortFromOptions(options),
890
693
  serviceTier: serviceTierFromOptions(options),
891
- cached: false,
892
- updatedAt,
893
- };
894
- handlers.onEvent?.({ type: "done", result });
895
- return result;
896
- }
897
- }).pipe(
898
- Effect.ensuring(
899
- Effect.sync(() => {
900
- reader.releaseLock();
694
+ usage: stream.usage,
695
+ responseId: stream.responseId,
901
696
  }),
902
- ),
903
- );
697
+ );
698
+ const result = {
699
+ context,
700
+ discussion: stream.value,
701
+ markdown: stream.markdown,
702
+ model: modelFromOptions(options),
703
+ reasoningEffort: reasoningEffortFromOptions(options),
704
+ serviceTier: serviceTierFromOptions(options),
705
+ cached: false,
706
+ updatedAt,
707
+ };
708
+ handlers.onEvent?.({ type: "done", result });
709
+ return result;
710
+ });
904
711
  }
905
712
 
906
713
  export function streamSearchDiscussionEffect(
@@ -969,35 +776,24 @@ export function streamSearchDiscussionEffect(
969
776
  return result;
970
777
  }
971
778
 
972
- const apiKey = process.env.OPENAI_API_KEY;
973
- if (!apiKey) {
974
- return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
975
- }
976
-
977
779
  handlers.onEvent?.({ type: "start", context, cached: false });
978
- const response = yield* trySearchPromise(() =>
979
- fetch("https://api.openai.com/v1/responses", {
980
- method: "POST",
981
- signal: options.signal,
982
- headers: {
983
- authorization: `Bearer ${apiKey}`,
984
- "content-type": "application/json",
985
- },
986
- body: JSON.stringify(createOpenAIRequestBody(context, options)),
987
- }),
780
+ const stream = yield* streamHybridAnalysisEffect({
781
+ body: createOpenAIRequestBody(context, options),
782
+ signal: options.signal,
783
+ parse: (value) => SearchDiscussionSchema.parse(value),
784
+ fallback: (markdown) => fallbackDiscussion(context, markdown),
785
+ delimiterPattern: DELIMITER_PATTERN,
786
+ onDelta: (delta) => {
787
+ handlers.onDelta?.(delta);
788
+ handlers.onEvent?.({ type: "delta", delta });
789
+ },
790
+ });
791
+ return yield* completeOpenAIStreamEffect(
792
+ stream,
793
+ context,
794
+ options,
795
+ handlers,
988
796
  );
989
- if (!response.ok) {
990
- const text = yield* trySearchPromise(() => response.text());
991
- return yield* Effect.fail(
992
- new Error(
993
- `OpenAI request failed: ${String(response.status)} ${text.slice(
994
- 0,
995
- 400,
996
- )}`,
997
- ),
998
- );
999
- }
1000
- return yield* readOpenAIStreamEffect(response, context, options, handlers);
1001
797
  });
1002
798
  }
1003
799
 
@@ -0,0 +1,30 @@
1
+ import { getBirdclawPaths, type BirdclawPaths } from "./config";
2
+ import { getNativeDb, type InitDatabaseOptions } from "./db";
3
+ import {
4
+ createRuntimeServices,
5
+ defaultRuntimeServices,
6
+ type RuntimeServices,
7
+ } from "./runtime-services";
8
+ import type { Database } from "./sqlite";
9
+
10
+ export interface ServerRuntimeServices extends RuntimeServices {
11
+ getDatabase(options?: InitDatabaseOptions): Database;
12
+ getPaths(): BirdclawPaths;
13
+ }
14
+
15
+ export const defaultServerRuntimeServices: ServerRuntimeServices = {
16
+ ...defaultRuntimeServices,
17
+ getDatabase: (options) => getNativeDb(options),
18
+ getPaths: () => getBirdclawPaths(),
19
+ };
20
+
21
+ export function createServerRuntimeServices(
22
+ overrides: Partial<ServerRuntimeServices> = {},
23
+ ): ServerRuntimeServices {
24
+ return {
25
+ ...createRuntimeServices(overrides),
26
+ getDatabase:
27
+ overrides.getDatabase ?? defaultServerRuntimeServices.getDatabase,
28
+ getPaths: overrides.getPaths ?? defaultServerRuntimeServices.getPaths,
29
+ };
30
+ }