@hraness/dawg 0.0.0-stage → 0.2.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 +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -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 +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -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,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCORE_LIMITS,
|
|
3
|
+
AUTOMATION_LANES,
|
|
4
|
+
isAutomationParameter,
|
|
5
|
+
normalizeDelay,
|
|
6
|
+
normalizeFilter,
|
|
7
|
+
normalizeReverb,
|
|
8
|
+
scoreFromJSON,
|
|
9
|
+
type ScoreOperation,
|
|
10
|
+
} from "../../core/score.ts";
|
|
11
|
+
import { DRUM_VOICES } from "../../core/drums.ts";
|
|
12
|
+
|
|
13
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_OPERATIONS = 32;
|
|
15
|
+
|
|
16
|
+
export type CompositionPlan = Readonly<{
|
|
17
|
+
operations: readonly ScoreOperation[];
|
|
18
|
+
explanation?: string;
|
|
19
|
+
}>;
|
|
20
|
+
|
|
21
|
+
/** Parse the only model output shape that the local reducer is allowed to apply. */
|
|
22
|
+
export function parseCompositionPlan(text: string): CompositionPlan {
|
|
23
|
+
if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BYTES)
|
|
24
|
+
throw new Error("agent response is too large");
|
|
25
|
+
const candidate = text.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] ?? text;
|
|
26
|
+
let value: unknown;
|
|
27
|
+
try {
|
|
28
|
+
value = JSON.parse(candidate.trim());
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error("agent response was not valid JSON");
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
!isRecord(value) ||
|
|
34
|
+
!Array.isArray(value.operations) ||
|
|
35
|
+
value.operations.length > MAX_OPERATIONS
|
|
36
|
+
) {
|
|
37
|
+
throw new Error("agent response must contain a bounded operations array");
|
|
38
|
+
}
|
|
39
|
+
const operations = value.operations.map(parseOperation);
|
|
40
|
+
const explanation =
|
|
41
|
+
typeof value.explanation === "string"
|
|
42
|
+
? value.explanation.slice(0, 2_000)
|
|
43
|
+
: undefined;
|
|
44
|
+
return explanation === undefined
|
|
45
|
+
? { operations }
|
|
46
|
+
: { operations, explanation };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate one foreign operation against the bounded planner contract. Every
|
|
51
|
+
* tool call and legacy JSON plan passes through here before the reducer sees it.
|
|
52
|
+
*/
|
|
53
|
+
export function validateAgentOperation(value: unknown): ScoreOperation {
|
|
54
|
+
return parseOperation(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseOperation(value: unknown): ScoreOperation {
|
|
58
|
+
if (!isRecord(value) || typeof value.type !== "string")
|
|
59
|
+
throw new Error("agent operation is malformed");
|
|
60
|
+
if (value.type === "addTrack") {
|
|
61
|
+
const track = scoreFromJSON({ tracks: [value.track] }).tracks[0]!;
|
|
62
|
+
return { type: "addTrack", track };
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
value.type === "setBars" &&
|
|
66
|
+
typeof value.bars === "number" &&
|
|
67
|
+
Number.isInteger(value.bars) &&
|
|
68
|
+
value.bars >= 1 &&
|
|
69
|
+
value.bars <= SCORE_LIMITS.maxBars
|
|
70
|
+
) {
|
|
71
|
+
return { type: "setBars", bars: value.bars };
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
value.type === "removeNote" &&
|
|
75
|
+
typeof value.noteId === "string" &&
|
|
76
|
+
value.noteId.length <= 64
|
|
77
|
+
) {
|
|
78
|
+
return { type: "removeNote", noteId: value.noteId };
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
value.type === "setTempo" &&
|
|
82
|
+
typeof value.tempoBpm === "number" &&
|
|
83
|
+
Number.isFinite(value.tempoBpm)
|
|
84
|
+
) {
|
|
85
|
+
return { type: "setTempo", tempoBpm: value.tempoBpm };
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
value.type === "clearTrack" &&
|
|
89
|
+
typeof value.trackId === "string" &&
|
|
90
|
+
value.trackId.length <= 64
|
|
91
|
+
) {
|
|
92
|
+
return { type: "clearTrack", trackId: value.trackId };
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
value.type === "updateTrack" &&
|
|
96
|
+
typeof value.trackId === "string" &&
|
|
97
|
+
value.trackId.length <= 64 &&
|
|
98
|
+
isRecord(value.patch)
|
|
99
|
+
) {
|
|
100
|
+
const patch = value.patch;
|
|
101
|
+
const safe: Record<string, unknown> = {};
|
|
102
|
+
if (typeof patch.name === "string") safe.name = patch.name.slice(0, 96);
|
|
103
|
+
if (typeof patch.instrument === "string")
|
|
104
|
+
safe.instrument = patch.instrument.slice(0, 64);
|
|
105
|
+
if (typeof patch.muted === "boolean") safe.muted = patch.muted;
|
|
106
|
+
if (typeof patch.volume === "number" && Number.isFinite(patch.volume))
|
|
107
|
+
safe.volume = patch.volume;
|
|
108
|
+
if (typeof patch.pan === "number" && Number.isFinite(patch.pan))
|
|
109
|
+
safe.pan = patch.pan;
|
|
110
|
+
if (typeof patch.solo === "boolean") safe.solo = patch.solo;
|
|
111
|
+
// Effects reuse the score's bounded validators; null removes an effect.
|
|
112
|
+
if (patch.filter !== undefined)
|
|
113
|
+
safe.filter = normalizeFilter(patch.filter) ?? null;
|
|
114
|
+
if (patch.delay !== undefined)
|
|
115
|
+
safe.delay = normalizeDelay(patch.delay) ?? null;
|
|
116
|
+
if (patch.reverb !== undefined)
|
|
117
|
+
safe.reverb = normalizeReverb(patch.reverb) ?? null;
|
|
118
|
+
return { type: "updateTrack", trackId: value.trackId, patch: safe };
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
value.type === "setAutomation" &&
|
|
122
|
+
typeof value.trackId === "string" &&
|
|
123
|
+
value.trackId.length <= SCORE_LIMITS.maxIdLength &&
|
|
124
|
+
isAutomationParameter(value.parameter) &&
|
|
125
|
+
Array.isArray(value.points) &&
|
|
126
|
+
value.points.length <= SCORE_LIMITS.maxAutomationPoints
|
|
127
|
+
) {
|
|
128
|
+
const { min: minValue, max: maxValue } = AUTOMATION_LANES[value.parameter];
|
|
129
|
+
const points = value.points.map((candidate) => {
|
|
130
|
+
if (!isRecord(candidate))
|
|
131
|
+
throw new Error("agent automation point is malformed");
|
|
132
|
+
if (
|
|
133
|
+
typeof candidate.tick !== "number" ||
|
|
134
|
+
!Number.isInteger(candidate.tick) ||
|
|
135
|
+
candidate.tick < 0 ||
|
|
136
|
+
candidate.tick > SCORE_LIMITS.maxTick ||
|
|
137
|
+
typeof candidate.value !== "number" ||
|
|
138
|
+
!Number.isFinite(candidate.value) ||
|
|
139
|
+
candidate.value < minValue ||
|
|
140
|
+
candidate.value > maxValue
|
|
141
|
+
) {
|
|
142
|
+
throw new Error("agent automation point is invalid");
|
|
143
|
+
}
|
|
144
|
+
return { tick: candidate.tick, value: candidate.value };
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
type: "setAutomation",
|
|
148
|
+
trackId: value.trackId,
|
|
149
|
+
parameter: value.parameter,
|
|
150
|
+
points,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
value.type === "updateNote" &&
|
|
155
|
+
typeof value.noteId === "string" &&
|
|
156
|
+
value.noteId.length <= 64 &&
|
|
157
|
+
isRecord(value.patch)
|
|
158
|
+
) {
|
|
159
|
+
const patch = value.patch;
|
|
160
|
+
const safe: Record<string, number> = {};
|
|
161
|
+
for (const key of [
|
|
162
|
+
"startTick",
|
|
163
|
+
"durationTicks",
|
|
164
|
+
"pitch",
|
|
165
|
+
"velocity",
|
|
166
|
+
] as const) {
|
|
167
|
+
if (typeof patch[key] === "number" && Number.isFinite(patch[key]))
|
|
168
|
+
safe[key] = patch[key];
|
|
169
|
+
}
|
|
170
|
+
return { type: "updateNote", noteId: value.noteId, patch: safe };
|
|
171
|
+
}
|
|
172
|
+
if (value.type === "addNote" && isRecord(value.note)) {
|
|
173
|
+
const note = value.note;
|
|
174
|
+
if (
|
|
175
|
+
typeof note.id !== "string" ||
|
|
176
|
+
typeof note.trackId !== "string" ||
|
|
177
|
+
typeof note.pitch !== "number" ||
|
|
178
|
+
typeof note.velocity !== "number"
|
|
179
|
+
) {
|
|
180
|
+
throw new Error("agent note is malformed");
|
|
181
|
+
}
|
|
182
|
+
const ticks =
|
|
183
|
+
typeof note.startTick === "number" &&
|
|
184
|
+
typeof note.durationTicks === "number";
|
|
185
|
+
const start = ticks ? note.startTick : note.start;
|
|
186
|
+
const duration = ticks ? note.durationTicks : note.duration;
|
|
187
|
+
if (typeof start !== "number" || typeof duration !== "number")
|
|
188
|
+
throw new Error("agent note timing is malformed");
|
|
189
|
+
if (
|
|
190
|
+
!Number.isFinite(start) ||
|
|
191
|
+
!Number.isFinite(duration) ||
|
|
192
|
+
duration <= 0 ||
|
|
193
|
+
!Number.isFinite(note.pitch) ||
|
|
194
|
+
!Number.isFinite(note.velocity)
|
|
195
|
+
) {
|
|
196
|
+
throw new Error("agent note timing is invalid");
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
type: "addNote",
|
|
200
|
+
note: {
|
|
201
|
+
id: note.id.slice(0, 64),
|
|
202
|
+
trackId: note.trackId.slice(0, 64),
|
|
203
|
+
...(ticks
|
|
204
|
+
? { startTick: start, durationTicks: duration }
|
|
205
|
+
: { start, duration }),
|
|
206
|
+
pitch: note.pitch,
|
|
207
|
+
velocity: note.velocity,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
throw new Error("unsupported agent operation");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
215
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** "kick=36, snare=38, ..." for the model's drum vocabulary. */
|
|
219
|
+
export function drumVoiceGuide(): string {
|
|
220
|
+
return DRUM_VOICES.map((info) => `${info.voice}=${info.pitch}`).join(", ");
|
|
221
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
type AuthEnv,
|
|
10
|
+
type CredentialSource,
|
|
11
|
+
type ProviderChoice,
|
|
12
|
+
} from "../auth/credentials.ts";
|
|
13
|
+
import { systemRunner } from "../auth/runner.ts";
|
|
14
|
+
import {
|
|
15
|
+
runAgentTurn,
|
|
16
|
+
type AgentBudget,
|
|
17
|
+
type AgentEvent,
|
|
18
|
+
type AgentHost,
|
|
19
|
+
type AgentTurnResult,
|
|
20
|
+
} from "./agent.ts";
|
|
21
|
+
import {
|
|
22
|
+
createGatewayClient,
|
|
23
|
+
type GatewayClient,
|
|
24
|
+
type GatewayModel,
|
|
25
|
+
} from "./gateway.ts";
|
|
26
|
+
import { runTextAgentTurn } from "./xcb-agent.ts";
|
|
27
|
+
import {
|
|
28
|
+
readCapabilities,
|
|
29
|
+
resolveXcbBin,
|
|
30
|
+
shortModelLabel,
|
|
31
|
+
xcbGenerate,
|
|
32
|
+
type XcbCapabilities,
|
|
33
|
+
} from "./xcb.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which model backend a turn uses. `gateway` is the streaming tool-calling
|
|
37
|
+
* loop on the Vercel AI Gateway; `xcb` emulates tools over `xcb --json
|
|
38
|
+
* generate` with an AI subscription; `offline` means only direct commands.
|
|
39
|
+
*/
|
|
40
|
+
export type ProviderSelection =
|
|
41
|
+
| Readonly<{
|
|
42
|
+
kind: "gateway";
|
|
43
|
+
choice: ProviderChoice;
|
|
44
|
+
apiKey: string;
|
|
45
|
+
source: CredentialSource;
|
|
46
|
+
}>
|
|
47
|
+
| Readonly<{
|
|
48
|
+
kind: "xcb";
|
|
49
|
+
choice: ProviderChoice;
|
|
50
|
+
bin: string;
|
|
51
|
+
account: string;
|
|
52
|
+
accountLabel: string;
|
|
53
|
+
model: string;
|
|
54
|
+
/** xcb admits this account on its first call, which takes longer. */
|
|
55
|
+
admissionPending?: boolean;
|
|
56
|
+
}>
|
|
57
|
+
| Readonly<{ kind: "offline"; choice: ProviderChoice; reason: string }>;
|
|
58
|
+
|
|
59
|
+
export const LOGIN_HINT = "run `dawg login` to enable the agent";
|
|
60
|
+
/** The small, cheap model used for one-line helpers such as session names. */
|
|
61
|
+
export const GATEWAY_SMALL_MODEL = "anthropic/claude-haiku-4.5";
|
|
62
|
+
|
|
63
|
+
export function providerChoice(
|
|
64
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
65
|
+
saved: ProviderChoice | undefined,
|
|
66
|
+
): ProviderChoice {
|
|
67
|
+
const fromEnv = env.DAWG_PROVIDER?.trim().toLowerCase();
|
|
68
|
+
if (fromEnv && (PROVIDER_CHOICES as readonly string[]).includes(fromEnv))
|
|
69
|
+
return fromEnv as ProviderChoice;
|
|
70
|
+
return saved ?? "auto";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the provider: `DAWG_PROVIDER`, then the choice saved by `dawg
|
|
75
|
+
* login`, then `auto` (gateway when a key exists, else an available xcb
|
|
76
|
+
* account, else offline).
|
|
77
|
+
*/
|
|
78
|
+
export async function selectProvider(
|
|
79
|
+
auth: AuthEnv = defaultAuthEnv(systemRunner),
|
|
80
|
+
options: { capabilities?: XcbCapabilities } = {},
|
|
81
|
+
): Promise<ProviderSelection> {
|
|
82
|
+
const config = await readConfig(auth);
|
|
83
|
+
const choice = providerChoice(auth.env, config.provider);
|
|
84
|
+
if (choice === "gateway" || choice === "auto") {
|
|
85
|
+
const key = await resolveGatewayKey(auth);
|
|
86
|
+
if (key)
|
|
87
|
+
return { kind: "gateway", choice, apiKey: key.key, source: key.source };
|
|
88
|
+
if (choice === "gateway")
|
|
89
|
+
return {
|
|
90
|
+
kind: "offline",
|
|
91
|
+
choice,
|
|
92
|
+
reason: `no AI Gateway key; ${LOGIN_HINT}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const bin = resolveXcbBin(auth.env, auth.runner);
|
|
96
|
+
if (!bin)
|
|
97
|
+
return {
|
|
98
|
+
kind: "offline",
|
|
99
|
+
choice,
|
|
100
|
+
reason:
|
|
101
|
+
choice === "xcb"
|
|
102
|
+
? "xcb is not installed; see `dawg login --xcb`"
|
|
103
|
+
: `no model configured; ${LOGIN_HINT}`,
|
|
104
|
+
};
|
|
105
|
+
let capabilities: XcbCapabilities;
|
|
106
|
+
try {
|
|
107
|
+
capabilities =
|
|
108
|
+
options.capabilities ?? (await readCapabilities(bin, auth.runner));
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
kind: "offline",
|
|
112
|
+
choice,
|
|
113
|
+
reason: `xcb capabilities unavailable; ${LOGIN_HINT}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const saved = config.xcb;
|
|
117
|
+
if (saved) {
|
|
118
|
+
const account = capabilities.accounts.find(
|
|
119
|
+
(row) => row.id === saved.account,
|
|
120
|
+
);
|
|
121
|
+
if (
|
|
122
|
+
account?.available &&
|
|
123
|
+
account.models.some((model) => model.key === saved.model)
|
|
124
|
+
)
|
|
125
|
+
return {
|
|
126
|
+
kind: "xcb",
|
|
127
|
+
choice,
|
|
128
|
+
bin,
|
|
129
|
+
account: account.id,
|
|
130
|
+
accountLabel: account.label,
|
|
131
|
+
model: saved.model,
|
|
132
|
+
...(account.admission === "pending" ? { admissionPending: true } : {}),
|
|
133
|
+
};
|
|
134
|
+
if (choice === "xcb")
|
|
135
|
+
return {
|
|
136
|
+
kind: "offline",
|
|
137
|
+
choice,
|
|
138
|
+
reason: `xcb account ${saved.account.slice(0, 12)} is ${account?.reason ?? "missing"}; run \`dawg login --xcb\``,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const account = capabilities.accounts.find((row) => row.available);
|
|
142
|
+
if (account)
|
|
143
|
+
return {
|
|
144
|
+
kind: "xcb",
|
|
145
|
+
choice,
|
|
146
|
+
bin,
|
|
147
|
+
account: account.id,
|
|
148
|
+
accountLabel: account.label,
|
|
149
|
+
model: account.models[0]!.key,
|
|
150
|
+
...(account.admission === "pending" ? { admissionPending: true } : {}),
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
kind: "offline",
|
|
154
|
+
choice,
|
|
155
|
+
reason:
|
|
156
|
+
choice === "xcb"
|
|
157
|
+
? "no xcb account is qualified for applications; run `dawg login --xcb`"
|
|
158
|
+
: `no model configured; ${LOGIN_HINT}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A cheap fingerprint of everything on disk and in the environment that
|
|
164
|
+
* `selectProvider` reads (config.json, credentials.json, provider env vars).
|
|
165
|
+
* Two stats per call; the host re-resolves the provider when it changes.
|
|
166
|
+
*/
|
|
167
|
+
export function providerFingerprint(
|
|
168
|
+
dir: string = configDir(),
|
|
169
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
170
|
+
): string {
|
|
171
|
+
const parts = ["config.json", "credentials.json"].map((name) => {
|
|
172
|
+
try {
|
|
173
|
+
const info = statSync(join(dir, name));
|
|
174
|
+
return `${name}:${info.size}:${info.mtimeMs}:${info.ino}`;
|
|
175
|
+
} catch {
|
|
176
|
+
return `${name}:-`;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
// Presence only: a process's environment cannot change under it, and the
|
|
180
|
+
// fingerprint must never carry secret material.
|
|
181
|
+
parts.push(
|
|
182
|
+
`env:${env.DAWG_PROVIDER ?? "-"}:${env.AI_GATEWAY_API_KEY ? 1 : 0}`,
|
|
183
|
+
);
|
|
184
|
+
return parts.join("|");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `opus-5.5 · gateway`, `claude/sonnet · xcb`, or `offline`. */
|
|
188
|
+
export function providerLabel(
|
|
189
|
+
selection: ProviderSelection,
|
|
190
|
+
gatewayModel: GatewayModel,
|
|
191
|
+
): string {
|
|
192
|
+
if (selection.kind === "gateway") return `${gatewayModel} · gateway`;
|
|
193
|
+
if (selection.kind === "xcb")
|
|
194
|
+
return `${shortModelLabel(selection.model)} · xcb`;
|
|
195
|
+
return "offline";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type ProviderTurnOptions = Readonly<{
|
|
199
|
+
selection: ProviderSelection;
|
|
200
|
+
prompt: string;
|
|
201
|
+
model: GatewayModel;
|
|
202
|
+
host: AgentHost;
|
|
203
|
+
onEvent?: (event: AgentEvent) => void;
|
|
204
|
+
signal?: AbortSignal;
|
|
205
|
+
budget?: AgentBudget;
|
|
206
|
+
/** Injected for tests; defaults to a client built from the selection's key. */
|
|
207
|
+
gatewayClient?: GatewayClient;
|
|
208
|
+
runner?: AuthEnv["runner"];
|
|
209
|
+
}>;
|
|
210
|
+
|
|
211
|
+
/** Run one agent turn on whichever provider was selected. */
|
|
212
|
+
export async function runProviderTurn(
|
|
213
|
+
options: ProviderTurnOptions,
|
|
214
|
+
): Promise<AgentTurnResult> {
|
|
215
|
+
const { selection } = options;
|
|
216
|
+
const common = {
|
|
217
|
+
prompt: options.prompt,
|
|
218
|
+
host: options.host,
|
|
219
|
+
...(options.onEvent ? { onEvent: options.onEvent } : {}),
|
|
220
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
221
|
+
...(options.budget ? { budget: options.budget } : {}),
|
|
222
|
+
};
|
|
223
|
+
if (selection.kind === "gateway")
|
|
224
|
+
return runAgentTurn({
|
|
225
|
+
...common,
|
|
226
|
+
model: options.model,
|
|
227
|
+
client:
|
|
228
|
+
options.gatewayClient ??
|
|
229
|
+
createGatewayClient({ apiKey: selection.apiKey }),
|
|
230
|
+
});
|
|
231
|
+
if (selection.kind === "xcb") {
|
|
232
|
+
const runner = options.runner ?? systemRunner;
|
|
233
|
+
return runTextAgentTurn({
|
|
234
|
+
...common,
|
|
235
|
+
generate: (prompt, call) =>
|
|
236
|
+
xcbGenerate({
|
|
237
|
+
bin: selection.bin,
|
|
238
|
+
runner,
|
|
239
|
+
account: selection.account,
|
|
240
|
+
model: selection.model,
|
|
241
|
+
prompt,
|
|
242
|
+
timeoutMs: call.timeoutMs,
|
|
243
|
+
maxOutputBytes: call.maxOutputBytes,
|
|
244
|
+
signal: call.signal,
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const result: AgentTurnResult = {
|
|
249
|
+
type: "error",
|
|
250
|
+
code: "provider",
|
|
251
|
+
message: selection.reason,
|
|
252
|
+
applied: 0,
|
|
253
|
+
revision: options.host.snapshot().revision,
|
|
254
|
+
};
|
|
255
|
+
options.onEvent?.(result);
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type GenerateTextOptions = Readonly<{
|
|
260
|
+
/** Upper bound on reply tokens (gateway `max_tokens`; xcb gets ~8 bytes per token). */
|
|
261
|
+
maxTokens: number;
|
|
262
|
+
signal?: AbortSignal;
|
|
263
|
+
timeoutMs?: number;
|
|
264
|
+
/** Reuse an already-resolved provider; otherwise one is selected now. */
|
|
265
|
+
selection?: ProviderSelection;
|
|
266
|
+
gatewayClient?: GatewayClient;
|
|
267
|
+
runner?: AuthEnv["runner"];
|
|
268
|
+
}>;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* One short, tool-free completion on whichever provider is configured, for
|
|
272
|
+
* helpers such as the session auto-namer. Gateway uses `GATEWAY_SMALL_MODEL`;
|
|
273
|
+
* xcb uses the selected account and model with a small output cap. Throws when
|
|
274
|
+
* no provider is available (callers should fall back to a local default).
|
|
275
|
+
* The returned text is untrusted, trimmed, and at most 512 characters.
|
|
276
|
+
*/
|
|
277
|
+
export async function generateText(
|
|
278
|
+
prompt: string,
|
|
279
|
+
options: GenerateTextOptions,
|
|
280
|
+
): Promise<string> {
|
|
281
|
+
const selection = options.selection ?? (await selectProvider());
|
|
282
|
+
const maxTokens = Math.min(1024, Math.max(1, Math.floor(options.maxTokens)));
|
|
283
|
+
const timeoutMs = Math.min(
|
|
284
|
+
60_000,
|
|
285
|
+
Math.max(1_000, options.timeoutMs ?? 20_000),
|
|
286
|
+
);
|
|
287
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
288
|
+
const signal = options.signal
|
|
289
|
+
? AbortSignal.any([options.signal, timeout])
|
|
290
|
+
: timeout;
|
|
291
|
+
const input = prompt.slice(0, 16_000);
|
|
292
|
+
if (selection.kind === "gateway") {
|
|
293
|
+
const client =
|
|
294
|
+
options.gatewayClient ??
|
|
295
|
+
createGatewayClient({ apiKey: selection.apiKey });
|
|
296
|
+
let text = "";
|
|
297
|
+
for await (const event of client.stream(
|
|
298
|
+
{
|
|
299
|
+
model: "opus-5.5",
|
|
300
|
+
modelId: GATEWAY_SMALL_MODEL,
|
|
301
|
+
maxTokens,
|
|
302
|
+
messages: [{ role: "user", content: input }],
|
|
303
|
+
maxResponseBytes: 16 * 1024,
|
|
304
|
+
},
|
|
305
|
+
signal,
|
|
306
|
+
)) {
|
|
307
|
+
if (event.type === "text") text += event.delta;
|
|
308
|
+
if (text.length > 2048) break;
|
|
309
|
+
}
|
|
310
|
+
return text.trim().slice(0, 512);
|
|
311
|
+
}
|
|
312
|
+
if (selection.kind === "xcb") {
|
|
313
|
+
const text = await xcbGenerate({
|
|
314
|
+
bin: selection.bin,
|
|
315
|
+
runner: options.runner ?? systemRunner,
|
|
316
|
+
account: selection.account,
|
|
317
|
+
model: selection.model,
|
|
318
|
+
prompt: input,
|
|
319
|
+
timeoutMs,
|
|
320
|
+
maxOutputBytes: Math.min(4096, Math.max(64, maxTokens * 8)),
|
|
321
|
+
signal,
|
|
322
|
+
});
|
|
323
|
+
return text.trim().slice(0, 512);
|
|
324
|
+
}
|
|
325
|
+
throw new Error(selection.reason);
|
|
326
|
+
}
|
package/src/agent/sse.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
let newline = buffer.search(/\r\n|\r|\n/);
|
|
48
|
+
while (newline >= 0) {
|
|
49
|
+
const line = buffer.slice(0, newline);
|
|
50
|
+
const width =
|
|
51
|
+
buffer[newline] === "\r" && buffer[newline + 1] === "\n" ? 2 : 1;
|
|
52
|
+
buffer = buffer.slice(newline + width);
|
|
53
|
+
if (line === "") {
|
|
54
|
+
if (data.length > 0) {
|
|
55
|
+
const payload = data.join("\n");
|
|
56
|
+
data = [];
|
|
57
|
+
if (payload === "[DONE]") return;
|
|
58
|
+
yield payload;
|
|
59
|
+
}
|
|
60
|
+
} else if (line.startsWith("data:")) {
|
|
61
|
+
data.push(line.slice(line[5] === " " ? 6 : 5));
|
|
62
|
+
}
|
|
63
|
+
newline = buffer.search(/\r\n|\r|\n/);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
buffer += decoder.decode();
|
|
67
|
+
if (buffer.startsWith("data:"))
|
|
68
|
+
data.push(buffer.slice(buffer[5] === " " ? 6 : 5));
|
|
69
|
+
if (data.length > 0) {
|
|
70
|
+
const payload = data.join("\n");
|
|
71
|
+
if (payload !== "[DONE]") yield payload;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
75
|
+
await reader.cancel().catch(() => undefined);
|
|
76
|
+
try {
|
|
77
|
+
reader.releaseLock();
|
|
78
|
+
} catch {
|
|
79
|
+
// A pending read is rejected by cancel(); the lock is already moot.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function abortReason(signal: AbortSignal): Error {
|
|
85
|
+
const reason: unknown = signal.reason;
|
|
86
|
+
if (reason instanceof Error) return reason;
|
|
87
|
+
const error = new Error("agent request was aborted");
|
|
88
|
+
error.name = "AbortError";
|
|
89
|
+
return error;
|
|
90
|
+
}
|