@hraness/dawg 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
configDir,
|
|
5
|
+
defaultAuthEnv,
|
|
6
|
+
PROVIDER_CHOICES,
|
|
7
|
+
readConfig,
|
|
8
|
+
resolveGatewayKey,
|
|
9
|
+
resolveOpenRouterKey,
|
|
10
|
+
type AuthEnv,
|
|
11
|
+
type CredentialSource,
|
|
12
|
+
type DawgConfig,
|
|
13
|
+
type ProviderChoice,
|
|
14
|
+
type SubscriptionSlot,
|
|
15
|
+
} from "../auth/credentials.ts";
|
|
16
|
+
import { systemRunner } from "../auth/runner.ts";
|
|
17
|
+
import {
|
|
18
|
+
runAgentTurn,
|
|
19
|
+
type AgentBudget,
|
|
20
|
+
type AgentEvent,
|
|
21
|
+
type AgentHost,
|
|
22
|
+
type AgentTurnResult,
|
|
23
|
+
} from "./agent.ts";
|
|
24
|
+
import {
|
|
25
|
+
createGatewayClient,
|
|
26
|
+
createOpenRouterClient,
|
|
27
|
+
type ApiProvider,
|
|
28
|
+
type GatewayClient,
|
|
29
|
+
} from "./gateway.ts";
|
|
30
|
+
import {
|
|
31
|
+
defaultModelId,
|
|
32
|
+
MODEL_CATALOG,
|
|
33
|
+
modelAlias,
|
|
34
|
+
resolveModelChoice,
|
|
35
|
+
} from "./models.ts";
|
|
36
|
+
import { runTextAgentTurn } from "./xcb-agent.ts";
|
|
37
|
+
import {
|
|
38
|
+
describeReason,
|
|
39
|
+
readCapabilities,
|
|
40
|
+
resolveXcbBin,
|
|
41
|
+
shortModelLabel,
|
|
42
|
+
type XcbAccount,
|
|
43
|
+
xcbGenerateWithRetry,
|
|
44
|
+
type XcbCapabilities,
|
|
45
|
+
} from "./xcb.ts";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which model backend a turn uses. `gateway` and `openrouter` run the
|
|
49
|
+
* streaming tool-calling loop on an OpenAI-compatible API; `xcb` emulates
|
|
50
|
+
* tools over `xcb --json generate` with a Codex or Claude subscription;
|
|
51
|
+
* `offline` means only direct commands.
|
|
52
|
+
*/
|
|
53
|
+
export type ApiSelection = Readonly<{
|
|
54
|
+
kind: ApiProvider;
|
|
55
|
+
choice: ProviderChoice;
|
|
56
|
+
apiKey: string;
|
|
57
|
+
source: CredentialSource;
|
|
58
|
+
/** The exact provider model ID (`anthropic/claude-opus-5.5`). */
|
|
59
|
+
modelId: string;
|
|
60
|
+
}>;
|
|
61
|
+
export type XcbSelection = Readonly<{
|
|
62
|
+
kind: "xcb";
|
|
63
|
+
choice: ProviderChoice;
|
|
64
|
+
/** `codex` or `claude` (or another xcb provider for `--xcb`). */
|
|
65
|
+
family: string;
|
|
66
|
+
bin: string;
|
|
67
|
+
account: string;
|
|
68
|
+
accountLabel: string;
|
|
69
|
+
model: string;
|
|
70
|
+
/** xcb admits this account on its first call, which takes longer. */
|
|
71
|
+
admissionPending?: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
export type ProviderSelection =
|
|
74
|
+
| ApiSelection
|
|
75
|
+
| XcbSelection
|
|
76
|
+
| Readonly<{
|
|
77
|
+
kind: "offline";
|
|
78
|
+
choice: ProviderChoice;
|
|
79
|
+
reason: string;
|
|
80
|
+
/**
|
|
81
|
+
* Set when a saved choice stopped working (key revoked, account gone):
|
|
82
|
+
* the host says so once and offers the picker, never another provider.
|
|
83
|
+
*/
|
|
84
|
+
invalidSaved?: boolean;
|
|
85
|
+
}>;
|
|
86
|
+
|
|
87
|
+
export function isApiSelection(
|
|
88
|
+
selection: ProviderSelection,
|
|
89
|
+
): selection is ApiSelection {
|
|
90
|
+
return selection.kind === "gateway" || selection.kind === "openrouter";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const LOGIN_HINT = "run `dawg login` to enable the agent";
|
|
94
|
+
/** The small, cheap model used for one-line helpers such as session names. */
|
|
95
|
+
export const GATEWAY_SMALL_MODEL = "anthropic/claude-haiku-4.5";
|
|
96
|
+
export const OPENROUTER_SMALL_MODEL = "anthropic/claude-haiku-4.5";
|
|
97
|
+
|
|
98
|
+
export function apiClient(selection: ApiSelection): GatewayClient {
|
|
99
|
+
return selection.kind === "openrouter"
|
|
100
|
+
? createOpenRouterClient({ apiKey: selection.apiKey })
|
|
101
|
+
: createGatewayClient({ apiKey: selection.apiKey });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function providerChoice(
|
|
105
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
106
|
+
saved: ProviderChoice | undefined,
|
|
107
|
+
): ProviderChoice {
|
|
108
|
+
const fromEnv = env.DAWG_PROVIDER?.trim().toLowerCase();
|
|
109
|
+
if (fromEnv && (PROVIDER_CHOICES as readonly string[]).includes(fromEnv))
|
|
110
|
+
return fromEnv as ProviderChoice;
|
|
111
|
+
return saved ?? "auto";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The model for a key-based provider: `DAWG_MODEL`, then the model saved by
|
|
116
|
+
* `/model` or `dawg login`, then Opus 5.5. Throws on an unknown `DAWG_MODEL`.
|
|
117
|
+
*/
|
|
118
|
+
export function apiModelId(
|
|
119
|
+
service: ApiProvider,
|
|
120
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
121
|
+
config: DawgConfig,
|
|
122
|
+
): string {
|
|
123
|
+
const fromEnv = env.DAWG_MODEL?.trim();
|
|
124
|
+
if (fromEnv) {
|
|
125
|
+
const id = resolveModelChoice(service, fromEnv);
|
|
126
|
+
if (!id)
|
|
127
|
+
throw new Error(
|
|
128
|
+
`unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID`,
|
|
129
|
+
);
|
|
130
|
+
return id;
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
(service === "openrouter" ? config.openrouterModel : config.gatewayModel) ??
|
|
134
|
+
defaultModelId(service)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const FAMILY_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
|
139
|
+
codex: "ChatGPT/Codex subscription",
|
|
140
|
+
claude: "Claude subscription",
|
|
141
|
+
xcb: "xcb account",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the provider: `DAWG_PROVIDER`, then the choice saved by `dawg
|
|
146
|
+
* login` or `/model`, then `auto` (AI Gateway key, then OpenRouter key, then
|
|
147
|
+
* a ready Codex or Claude subscription). A saved choice that stops working
|
|
148
|
+
* resolves to `offline` with `invalidSaved`; it never falls through to a
|
|
149
|
+
* different provider.
|
|
150
|
+
*/
|
|
151
|
+
export async function selectProvider(
|
|
152
|
+
auth: AuthEnv = defaultAuthEnv(systemRunner),
|
|
153
|
+
options: { capabilities?: XcbCapabilities } = {},
|
|
154
|
+
): Promise<ProviderSelection> {
|
|
155
|
+
const config = await readConfig(auth);
|
|
156
|
+
const choice = providerChoice(auth.env, config.provider);
|
|
157
|
+
const explicit = choice !== "auto";
|
|
158
|
+
const offline = (reason: string): ProviderSelection => ({
|
|
159
|
+
kind: "offline",
|
|
160
|
+
choice,
|
|
161
|
+
reason,
|
|
162
|
+
...(explicit && config.provider === choice && !auth.env.DAWG_PROVIDER
|
|
163
|
+
? { invalidSaved: true }
|
|
164
|
+
: {}),
|
|
165
|
+
});
|
|
166
|
+
for (const service of ["gateway", "openrouter"] as const) {
|
|
167
|
+
if (choice !== service && choice !== "auto") continue;
|
|
168
|
+
const key =
|
|
169
|
+
service === "gateway"
|
|
170
|
+
? await resolveGatewayKey(auth)
|
|
171
|
+
: await resolveOpenRouterKey(auth);
|
|
172
|
+
if (key) {
|
|
173
|
+
let modelId: string;
|
|
174
|
+
try {
|
|
175
|
+
modelId = apiModelId(service, auth.env, config);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return offline((error as Error).message);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
kind: service,
|
|
181
|
+
choice,
|
|
182
|
+
apiKey: key.key,
|
|
183
|
+
source: key.source,
|
|
184
|
+
modelId,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (choice === service)
|
|
188
|
+
return offline(
|
|
189
|
+
`no ${service === "gateway" ? "AI Gateway" : "OpenRouter"} key any more; run \`dawg login ${service}\``,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const bin = resolveXcbBin(auth.env, auth.runner);
|
|
193
|
+
if (!bin)
|
|
194
|
+
return offline(
|
|
195
|
+
explicit
|
|
196
|
+
? `xcb is not installed; run \`dawg login ${choice === "xcb" ? "--xcb" : choice}\``
|
|
197
|
+
: `no model configured; ${LOGIN_HINT}`,
|
|
198
|
+
);
|
|
199
|
+
let capabilities: XcbCapabilities;
|
|
200
|
+
try {
|
|
201
|
+
capabilities =
|
|
202
|
+
options.capabilities ?? (await readCapabilities(bin, auth.runner));
|
|
203
|
+
} catch {
|
|
204
|
+
return offline(`xcb capabilities unavailable; ${LOGIN_HINT}`);
|
|
205
|
+
}
|
|
206
|
+
const toSelection = (
|
|
207
|
+
family: string,
|
|
208
|
+
account: XcbAccount,
|
|
209
|
+
model: string,
|
|
210
|
+
): XcbSelection => ({
|
|
211
|
+
kind: "xcb",
|
|
212
|
+
choice,
|
|
213
|
+
family,
|
|
214
|
+
bin,
|
|
215
|
+
account: account.id,
|
|
216
|
+
accountLabel: account.label,
|
|
217
|
+
model,
|
|
218
|
+
...(account.admission === "pending" ? { admissionPending: true } : {}),
|
|
219
|
+
});
|
|
220
|
+
const families: SubscriptionSlot[] =
|
|
221
|
+
choice === "auto"
|
|
222
|
+
? ["codex", "claude"]
|
|
223
|
+
: explicit
|
|
224
|
+
? [choice as SubscriptionSlot]
|
|
225
|
+
: [];
|
|
226
|
+
for (const family of families) {
|
|
227
|
+
const saved = config[family];
|
|
228
|
+
const inFamily = (account: XcbAccount) =>
|
|
229
|
+
family === "xcb" || account.provider === family;
|
|
230
|
+
if (saved) {
|
|
231
|
+
const account = capabilities.accounts.find(
|
|
232
|
+
(row) => row.id === saved.account,
|
|
233
|
+
);
|
|
234
|
+
if (
|
|
235
|
+
account?.available &&
|
|
236
|
+
account.models.some((model) => model.key === saved.model)
|
|
237
|
+
)
|
|
238
|
+
return toSelection(
|
|
239
|
+
family === "xcb" ? account.provider : family,
|
|
240
|
+
account,
|
|
241
|
+
saved.model,
|
|
242
|
+
);
|
|
243
|
+
if (choice === family)
|
|
244
|
+
return offline(
|
|
245
|
+
`your ${FAMILY_NAMES[family]} (${account?.label ?? saved.account.slice(0, 12)}) is ${
|
|
246
|
+
!account
|
|
247
|
+
? "no longer listed by xcb"
|
|
248
|
+
: account.available
|
|
249
|
+
? `missing model ${saved.model}`
|
|
250
|
+
: describeReason(account.reason).replace(
|
|
251
|
+
"<account>",
|
|
252
|
+
account.id,
|
|
253
|
+
)
|
|
254
|
+
}; run \`dawg login ${family === "xcb" ? "--xcb" : family}\``,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
// No saved pick: the first usable account, ready before admission pending.
|
|
258
|
+
const usable = capabilities.accounts
|
|
259
|
+
.filter(
|
|
260
|
+
(account) =>
|
|
261
|
+
account.available && inFamily(account) && account.models.length > 0,
|
|
262
|
+
)
|
|
263
|
+
.sort(
|
|
264
|
+
(a, b) =>
|
|
265
|
+
Number(a.admission === "pending") - Number(b.admission === "pending"),
|
|
266
|
+
);
|
|
267
|
+
const account = usable[0];
|
|
268
|
+
if (account)
|
|
269
|
+
return toSelection(
|
|
270
|
+
family === "xcb" ? account.provider : family,
|
|
271
|
+
account,
|
|
272
|
+
account.models[0]!.key,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return offline(
|
|
276
|
+
explicit
|
|
277
|
+
? `no ${FAMILY_NAMES[choice] ?? choice} is ready; run \`dawg login ${choice === "xcb" ? "--xcb" : choice}\``
|
|
278
|
+
: `no model configured; ${LOGIN_HINT}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A cheap fingerprint of everything on disk and in the environment that
|
|
284
|
+
* `selectProvider` reads (config.json, credentials.json, provider env vars).
|
|
285
|
+
* Two stats per call; the host re-resolves the provider when it changes.
|
|
286
|
+
*/
|
|
287
|
+
export function providerFingerprint(
|
|
288
|
+
dir: string = configDir(),
|
|
289
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
290
|
+
): string {
|
|
291
|
+
const parts = ["config.json", "credentials.json"].map((name) => {
|
|
292
|
+
try {
|
|
293
|
+
const info = statSync(join(dir, name));
|
|
294
|
+
return `${name}:${info.size}:${info.mtimeMs}:${info.ino}`;
|
|
295
|
+
} catch {
|
|
296
|
+
return `${name}:-`;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// Presence only: a process's environment cannot change under it, and the
|
|
300
|
+
// fingerprint must never carry secret material.
|
|
301
|
+
parts.push(
|
|
302
|
+
`env:${env.DAWG_PROVIDER ?? "-"}:${env.AI_GATEWAY_API_KEY ? 1 : 0}:${env.OPENROUTER_API_KEY ? 1 : 0}`,
|
|
303
|
+
);
|
|
304
|
+
return parts.join("|");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** `opus-5.5 · gateway`, `sonnet · claude`, or `offline`. */
|
|
308
|
+
export function providerLabel(selection: ProviderSelection): string {
|
|
309
|
+
if (isApiSelection(selection))
|
|
310
|
+
return `${modelAlias(selection.kind, selection.modelId)} · ${selection.kind}`;
|
|
311
|
+
if (selection.kind === "xcb")
|
|
312
|
+
return `${shortModelLabel(selection.model)} · ${selection.family}`;
|
|
313
|
+
return "offline";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The model half of the label, for the header and spend line. */
|
|
317
|
+
export function selectionModel(
|
|
318
|
+
selection: ProviderSelection,
|
|
319
|
+
): string | undefined {
|
|
320
|
+
if (isApiSelection(selection))
|
|
321
|
+
return modelAlias(selection.kind, selection.modelId);
|
|
322
|
+
if (selection.kind === "xcb") return shortModelLabel(selection.model);
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type ProviderTurnOptions = Readonly<{
|
|
327
|
+
selection: ProviderSelection;
|
|
328
|
+
prompt: string;
|
|
329
|
+
/** Overrides the selection's model (tests); normally unset. */
|
|
330
|
+
model?: string;
|
|
331
|
+
host: AgentHost;
|
|
332
|
+
onEvent?: (event: AgentEvent) => void;
|
|
333
|
+
signal?: AbortSignal;
|
|
334
|
+
budget?: AgentBudget;
|
|
335
|
+
/** Injected for tests; defaults to a client built from the selection's key. */
|
|
336
|
+
gatewayClient?: GatewayClient;
|
|
337
|
+
runner?: AuthEnv["runner"];
|
|
338
|
+
}>;
|
|
339
|
+
|
|
340
|
+
/** Run one agent turn on whichever provider was selected. */
|
|
341
|
+
export async function runProviderTurn(
|
|
342
|
+
options: ProviderTurnOptions,
|
|
343
|
+
): Promise<AgentTurnResult> {
|
|
344
|
+
const { selection } = options;
|
|
345
|
+
const common = {
|
|
346
|
+
prompt: options.prompt,
|
|
347
|
+
host: options.host,
|
|
348
|
+
...(options.onEvent ? { onEvent: options.onEvent } : {}),
|
|
349
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
350
|
+
...(options.budget ? { budget: options.budget } : {}),
|
|
351
|
+
};
|
|
352
|
+
if (isApiSelection(selection))
|
|
353
|
+
return runAgentTurn({
|
|
354
|
+
...common,
|
|
355
|
+
model: options.model ?? selection.modelId,
|
|
356
|
+
client: options.gatewayClient ?? apiClient(selection),
|
|
357
|
+
});
|
|
358
|
+
if (selection.kind === "xcb") {
|
|
359
|
+
const runner = options.runner ?? systemRunner;
|
|
360
|
+
return runTextAgentTurn({
|
|
361
|
+
...common,
|
|
362
|
+
generate: (prompt, call) =>
|
|
363
|
+
xcbGenerateWithRetry({
|
|
364
|
+
bin: selection.bin,
|
|
365
|
+
runner,
|
|
366
|
+
account: selection.account,
|
|
367
|
+
model: selection.model,
|
|
368
|
+
prompt,
|
|
369
|
+
timeoutMs: call.timeoutMs,
|
|
370
|
+
maxOutputBytes: call.maxOutputBytes,
|
|
371
|
+
signal: call.signal,
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const result: AgentTurnResult = {
|
|
376
|
+
type: "error",
|
|
377
|
+
code: "provider",
|
|
378
|
+
message: selection.reason,
|
|
379
|
+
applied: 0,
|
|
380
|
+
revision: options.host.snapshot().revision,
|
|
381
|
+
};
|
|
382
|
+
options.onEvent?.(result);
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export type GenerateTextOptions = Readonly<{
|
|
387
|
+
/** Upper bound on reply tokens (gateway `max_tokens`; xcb gets ~8 bytes per token). */
|
|
388
|
+
maxTokens: number;
|
|
389
|
+
signal?: AbortSignal;
|
|
390
|
+
timeoutMs?: number;
|
|
391
|
+
/** Reuse an already-resolved provider; otherwise one is selected now. */
|
|
392
|
+
selection?: ProviderSelection;
|
|
393
|
+
gatewayClient?: GatewayClient;
|
|
394
|
+
runner?: AuthEnv["runner"];
|
|
395
|
+
}>;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* One short, tool-free completion on whichever provider is configured, for
|
|
399
|
+
* helpers such as the session auto-namer. Gateway uses `GATEWAY_SMALL_MODEL`;
|
|
400
|
+
* xcb uses the selected account and model with a small output cap. Throws when
|
|
401
|
+
* no provider is available (callers should fall back to a local default).
|
|
402
|
+
* The returned text is untrusted, trimmed, and at most 512 characters.
|
|
403
|
+
*/
|
|
404
|
+
export async function generateText(
|
|
405
|
+
prompt: string,
|
|
406
|
+
options: GenerateTextOptions,
|
|
407
|
+
): Promise<string> {
|
|
408
|
+
const selection = options.selection ?? (await selectProvider());
|
|
409
|
+
const maxTokens = Math.min(1024, Math.max(1, Math.floor(options.maxTokens)));
|
|
410
|
+
const timeoutMs = Math.min(
|
|
411
|
+
60_000,
|
|
412
|
+
Math.max(1_000, options.timeoutMs ?? 20_000),
|
|
413
|
+
);
|
|
414
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
415
|
+
const signal = options.signal
|
|
416
|
+
? AbortSignal.any([options.signal, timeout])
|
|
417
|
+
: timeout;
|
|
418
|
+
const input = prompt.slice(0, 16_000);
|
|
419
|
+
if (isApiSelection(selection)) {
|
|
420
|
+
const client = options.gatewayClient ?? apiClient(selection);
|
|
421
|
+
let text = "";
|
|
422
|
+
for await (const event of client.stream(
|
|
423
|
+
{
|
|
424
|
+
model: "opus-5.5",
|
|
425
|
+
modelId:
|
|
426
|
+
selection.kind === "openrouter"
|
|
427
|
+
? OPENROUTER_SMALL_MODEL
|
|
428
|
+
: GATEWAY_SMALL_MODEL,
|
|
429
|
+
maxTokens,
|
|
430
|
+
messages: [{ role: "user", content: input }],
|
|
431
|
+
maxResponseBytes: 16 * 1024,
|
|
432
|
+
},
|
|
433
|
+
signal,
|
|
434
|
+
)) {
|
|
435
|
+
if (event.type === "text") text += event.delta;
|
|
436
|
+
if (text.length > 2048) break;
|
|
437
|
+
}
|
|
438
|
+
return text.trim().slice(0, 512);
|
|
439
|
+
}
|
|
440
|
+
if (selection.kind === "xcb") {
|
|
441
|
+
const text = await xcbGenerateWithRetry({
|
|
442
|
+
bin: selection.bin,
|
|
443
|
+
runner: options.runner ?? systemRunner,
|
|
444
|
+
account: selection.account,
|
|
445
|
+
model: selection.model,
|
|
446
|
+
prompt: input,
|
|
447
|
+
timeoutMs,
|
|
448
|
+
maxOutputBytes: Math.min(4096, Math.max(64, maxTokens * 8)),
|
|
449
|
+
signal,
|
|
450
|
+
});
|
|
451
|
+
return text.trim().slice(0, 512);
|
|
452
|
+
}
|
|
453
|
+
throw new Error(selection.reason);
|
|
454
|
+
}
|
package/src/agent/sse.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, bounded Server-Sent Events reader for streamed chat completions.
|
|
3
|
+
*
|
|
4
|
+
* It yields the `data:` payload of each event (multi-line data is joined with
|
|
5
|
+
* `\n`), skips comments and other fields, and stops at `[DONE]`. Every byte
|
|
6
|
+
* read counts against `maxBytes`, and an abort signal cancels the underlying
|
|
7
|
+
* reader even when the fetch implementation does not observe the signal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class SseBudgetError extends Error {
|
|
11
|
+
constructor(readonly maxBytes: number) {
|
|
12
|
+
super(`agent response exceeded ${maxBytes} bytes`);
|
|
13
|
+
this.name = "SseBudgetError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function* readSseData(
|
|
18
|
+
body: ReadableStream<Uint8Array>,
|
|
19
|
+
options: { maxBytes: number; signal?: AbortSignal },
|
|
20
|
+
): AsyncGenerator<string> {
|
|
21
|
+
const reader = body.getReader();
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
let buffer = "";
|
|
24
|
+
let data: string[] = [];
|
|
25
|
+
let total = 0;
|
|
26
|
+
const signal = options.signal;
|
|
27
|
+
let onAbort: (() => void) | undefined;
|
|
28
|
+
const aborted =
|
|
29
|
+
signal === undefined
|
|
30
|
+
? undefined
|
|
31
|
+
: new Promise<never>((_, reject) => {
|
|
32
|
+
onAbort = () => reject(abortReason(signal));
|
|
33
|
+
if (signal.aborted) onAbort();
|
|
34
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
35
|
+
});
|
|
36
|
+
// Avoid an unhandled rejection when the stream finishes before an abort.
|
|
37
|
+
aborted?.catch(() => undefined);
|
|
38
|
+
try {
|
|
39
|
+
while (true) {
|
|
40
|
+
const chunk = aborted
|
|
41
|
+
? await Promise.race([reader.read(), aborted])
|
|
42
|
+
: await reader.read();
|
|
43
|
+
if (chunk.done) break;
|
|
44
|
+
total += chunk.value.byteLength;
|
|
45
|
+
if (total > options.maxBytes) throw new SseBudgetError(options.maxBytes);
|
|
46
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
47
|
+
// Scan forward with indexOf and slice the consumed prefix once per
|
|
48
|
+
// chunk. A trailing "\r" is held back: it may be the first half of a
|
|
49
|
+
// "\r\n" split across chunks, and dispatching it early would double
|
|
50
|
+
// an empty line and end the event too soon.
|
|
51
|
+
let start = 0;
|
|
52
|
+
while (true) {
|
|
53
|
+
const next = lineBreak(buffer, start);
|
|
54
|
+
if (next === undefined) break;
|
|
55
|
+
const line = buffer.slice(start, next.index);
|
|
56
|
+
start = next.index + next.width;
|
|
57
|
+
if (line === "") {
|
|
58
|
+
if (data.length > 0) {
|
|
59
|
+
const payload = data.join("\n");
|
|
60
|
+
data = [];
|
|
61
|
+
if (payload === "[DONE]") return;
|
|
62
|
+
yield payload;
|
|
63
|
+
}
|
|
64
|
+
} else if (line.startsWith("data:")) {
|
|
65
|
+
data.push(line.slice(line[5] === " " ? 6 : 5));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (start > 0) buffer = buffer.slice(start);
|
|
69
|
+
}
|
|
70
|
+
buffer += decoder.decode();
|
|
71
|
+
if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
|
|
72
|
+
if (buffer.startsWith("data:"))
|
|
73
|
+
data.push(buffer.slice(buffer[5] === " " ? 6 : 5));
|
|
74
|
+
if (data.length > 0) {
|
|
75
|
+
const payload = data.join("\n");
|
|
76
|
+
if (payload !== "[DONE]") yield payload;
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
80
|
+
await reader.cancel().catch(() => undefined);
|
|
81
|
+
try {
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
} catch {
|
|
84
|
+
// A pending read is rejected by cancel(); the lock is already moot.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The next line terminator at or after `from`: "\n", "\r\n", or a lone
|
|
91
|
+
* "\r" that is already followed by another character. A "\r" at the very
|
|
92
|
+
* end of the buffer is not a terminator yet.
|
|
93
|
+
*/
|
|
94
|
+
function lineBreak(
|
|
95
|
+
buffer: string,
|
|
96
|
+
from: number,
|
|
97
|
+
): { index: number; width: number } | undefined {
|
|
98
|
+
const newline = buffer.indexOf("\n", from);
|
|
99
|
+
const carriage = buffer.indexOf("\r", from);
|
|
100
|
+
if (carriage < 0 || (newline >= 0 && newline < carriage))
|
|
101
|
+
return newline < 0 ? undefined : { index: newline, width: 1 };
|
|
102
|
+
if (carriage === buffer.length - 1) return undefined;
|
|
103
|
+
return buffer[carriage + 1] === "\n"
|
|
104
|
+
? { index: carriage, width: 2 }
|
|
105
|
+
: { index: carriage, width: 1 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function abortReason(signal: AbortSignal): Error {
|
|
109
|
+
const reason: unknown = signal.reason;
|
|
110
|
+
if (reason instanceof Error) return reason;
|
|
111
|
+
const error = new Error("agent request was aborted");
|
|
112
|
+
error.name = "AbortError";
|
|
113
|
+
return error;
|
|
114
|
+
}
|