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.
- package/CHANGELOG.md +16 -0
- package/package.json +2 -1
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +84 -330
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +36 -110
- package/src/lib/queries.ts +6 -2381
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +48 -12
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -4
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- 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
|
+
}
|
package/src/lib/research.ts
CHANGED
|
@@ -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 "./
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
);
|
|
674
|
-
|
|
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
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
|
835
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
892
|
-
|
|
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
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
+
}
|