@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,247 @@
|
|
|
1
|
+
import { compositionBrief } from "./brief.ts";
|
|
2
|
+
import {
|
|
3
|
+
AGENT_LIMITS,
|
|
4
|
+
AgentTimeoutError,
|
|
5
|
+
classifyAgentError,
|
|
6
|
+
executeCall,
|
|
7
|
+
tighten,
|
|
8
|
+
type AgentBudget,
|
|
9
|
+
type AgentEvent,
|
|
10
|
+
type AgentHost,
|
|
11
|
+
type AgentTurnResult,
|
|
12
|
+
} from "./agent.ts";
|
|
13
|
+
import { AGENT_TOOLS, type AgentTool } from "./tools.ts";
|
|
14
|
+
import { parseTextReply, XcbError, type TextReply } from "./xcb.ts";
|
|
15
|
+
import { SseBudgetError } from "./sse.ts";
|
|
16
|
+
|
|
17
|
+
/** Steps per xcb turn. Each step is one full model call, so keep it small. */
|
|
18
|
+
export const XCB_MAX_STEPS = 3;
|
|
19
|
+
const MAX_PROMPT_CHARS = 8_000;
|
|
20
|
+
const MAX_FEEDBACK_CHARS = 6_000;
|
|
21
|
+
|
|
22
|
+
/** One text-in/text-out model call; `generate` must honour `signal`. */
|
|
23
|
+
export type TextGenerate = (
|
|
24
|
+
prompt: string,
|
|
25
|
+
options: { signal: AbortSignal; timeoutMs: number; maxOutputBytes: number },
|
|
26
|
+
) => Promise<string>;
|
|
27
|
+
|
|
28
|
+
export type TextAgentTurnOptions = Readonly<{
|
|
29
|
+
prompt: string;
|
|
30
|
+
generate: TextGenerate;
|
|
31
|
+
host: AgentHost;
|
|
32
|
+
onEvent?: (event: AgentEvent) => void;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
budget?: AgentBudget;
|
|
35
|
+
tools?: readonly AgentTool[];
|
|
36
|
+
newNoteId?: (trackId: string, revision: number, index: number) => string;
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
export const TEXT_AGENT_SYSTEM_PROMPT = [
|
|
40
|
+
"You are dawg, a loop composer inside a terminal music workstation.",
|
|
41
|
+
"You edit the score by returning operations. Each op is validated and applied immediately, in order, as its own revision.",
|
|
42
|
+
"Times are in beats from the loop start (0-based). Keep notes inside loopBeats unless you extend the loop first.",
|
|
43
|
+
"Prefer a few well-formed ops (one add_notes op per track part) over many tiny ones.",
|
|
44
|
+
'For drums, create a track with instrument "kit" and use add_drums; drum pitches select voices, so do not use add_notes for beats.',
|
|
45
|
+
"Effects (set_effects, set_automation): low-pass filter cutoff 20..20000 Hz and resonance 0..1; stereo delay beats 0.0625..4, feedback 0..0.9, mix 0..1; stereo reverb mix 0..1 (0.15..0.35 is a natural room) and size 0..1; pan -1..1 is equal-power stereo. Automatable lanes: volume, pan, filter, resonance, delay-feedback, delay-mix.",
|
|
46
|
+
'Reply with exactly one JSON object and nothing else, shaped {"ops":[{"tool":"<tool name>","args":{...}}],"say":"<one short sentence describing the musical change>","done":true}.',
|
|
47
|
+
'Set "done":false only if you need to see the results of these ops before continuing; you will then get each op\'s result and can send more ops.',
|
|
48
|
+
"If an op was rejected, read its diagnostic and either send a corrected op or stop with done:true.",
|
|
49
|
+
].join(" ");
|
|
50
|
+
|
|
51
|
+
/** Render the tool registry as a compact op catalog for a text-only model. */
|
|
52
|
+
export function renderToolCatalog(
|
|
53
|
+
tools: readonly AgentTool[] = AGENT_TOOLS,
|
|
54
|
+
): string {
|
|
55
|
+
return tools
|
|
56
|
+
.map(
|
|
57
|
+
(tool) =>
|
|
58
|
+
`- ${tool.name}: ${tool.description}\n args schema: ${JSON.stringify(tool.parameters)}`,
|
|
59
|
+
)
|
|
60
|
+
.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A tool loop for providers without tool calling (xcb). The model returns one
|
|
65
|
+
* JSON object of ops; each op goes through the same tool argument checks,
|
|
66
|
+
* operation validator, reducer dry run and per-op commit as the gateway path.
|
|
67
|
+
* Rejected ops or `done:false` trigger another call with diagnostics, up to
|
|
68
|
+
* `XCB_MAX_STEPS` calls and the usual turn budgets.
|
|
69
|
+
*/
|
|
70
|
+
export async function runTextAgentTurn(
|
|
71
|
+
options: TextAgentTurnOptions,
|
|
72
|
+
): Promise<AgentTurnResult> {
|
|
73
|
+
const limits = {
|
|
74
|
+
maxSteps: Math.min(
|
|
75
|
+
XCB_MAX_STEPS,
|
|
76
|
+
tighten(options.budget?.maxSteps, AGENT_LIMITS.maxSteps),
|
|
77
|
+
),
|
|
78
|
+
maxToolCalls: tighten(
|
|
79
|
+
options.budget?.maxToolCalls,
|
|
80
|
+
AGENT_LIMITS.maxToolCalls,
|
|
81
|
+
),
|
|
82
|
+
maxResponseBytes: tighten(
|
|
83
|
+
options.budget?.maxResponseBytes,
|
|
84
|
+
AGENT_LIMITS.maxResponseBytes,
|
|
85
|
+
),
|
|
86
|
+
timeoutMs: tighten(options.budget?.timeoutMs, AGENT_LIMITS.timeoutMs),
|
|
87
|
+
};
|
|
88
|
+
const emit = (event: AgentEvent) => {
|
|
89
|
+
try {
|
|
90
|
+
options.onEvent?.(event);
|
|
91
|
+
} catch {
|
|
92
|
+
// A rendering failure must never corrupt the turn.
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const tools = options.tools ?? AGENT_TOOLS;
|
|
96
|
+
const catalog = renderToolCatalog(tools);
|
|
97
|
+
const nonce = Math.random().toString(36).slice(2, 6);
|
|
98
|
+
const newNoteId =
|
|
99
|
+
options.newNoteId ??
|
|
100
|
+
((trackId: string, revision: number, index: number) =>
|
|
101
|
+
`${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
const timeout = new AbortController();
|
|
104
|
+
const timer = setTimeout(
|
|
105
|
+
() => timeout.abort(new AgentTimeoutError(limits.timeoutMs)),
|
|
106
|
+
limits.timeoutMs,
|
|
107
|
+
);
|
|
108
|
+
const signal = options.signal
|
|
109
|
+
? AbortSignal.any([options.signal, timeout.signal])
|
|
110
|
+
: timeout.signal;
|
|
111
|
+
|
|
112
|
+
let applied = 0;
|
|
113
|
+
let rejected = 0;
|
|
114
|
+
let toolCalls = 0;
|
|
115
|
+
let bytesUsed = 0;
|
|
116
|
+
let finalText = "";
|
|
117
|
+
const steering: string[] = [];
|
|
118
|
+
let feedback = "";
|
|
119
|
+
const currentRevision = () => options.host.snapshot().revision;
|
|
120
|
+
const finish = (result: AgentTurnResult): AgentTurnResult => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
emit(result);
|
|
123
|
+
return result;
|
|
124
|
+
};
|
|
125
|
+
const done = (reason: "stop" | "max-steps" | "max-tool-calls") =>
|
|
126
|
+
finish({
|
|
127
|
+
type: "done",
|
|
128
|
+
reason,
|
|
129
|
+
text: finalText,
|
|
130
|
+
applied,
|
|
131
|
+
rejected,
|
|
132
|
+
revision: currentRevision(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
for (let step = 1; step <= limits.maxSteps; step += 1) {
|
|
137
|
+
if (signal.aborted) throw signal.reason;
|
|
138
|
+
for (const steer of options.host.takeSteering?.() ?? [])
|
|
139
|
+
steering.push(steer.slice(0, 2_000));
|
|
140
|
+
emit({ type: "step", step });
|
|
141
|
+
const remainingBytes = limits.maxResponseBytes - bytesUsed;
|
|
142
|
+
if (remainingBytes <= 0)
|
|
143
|
+
throw new SseBudgetError(limits.maxResponseBytes);
|
|
144
|
+
const prompt = [
|
|
145
|
+
TEXT_AGENT_SYSTEM_PROMPT,
|
|
146
|
+
`Tools:\n${catalog}`,
|
|
147
|
+
`Composition brief (JSON): ${compositionBrief(options.host.snapshot())}`,
|
|
148
|
+
`User request: ${options.prompt.slice(0, MAX_PROMPT_CHARS)}`,
|
|
149
|
+
...steering.map((text) => `Steering from the user mid-turn: ${text}`),
|
|
150
|
+
feedback
|
|
151
|
+
? `Results of your previous ops (the brief above already reflects accepted ones):\n${feedback}`
|
|
152
|
+
: "",
|
|
153
|
+
"Reply now with the single JSON object.",
|
|
154
|
+
]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join("\n\n");
|
|
157
|
+
const remainingMs = limits.timeoutMs - (Date.now() - startedAt);
|
|
158
|
+
const reply = await options.generate(prompt, {
|
|
159
|
+
signal,
|
|
160
|
+
timeoutMs: Math.max(1_000, remainingMs),
|
|
161
|
+
maxOutputBytes: Math.min(remainingBytes, 64 * 1024),
|
|
162
|
+
});
|
|
163
|
+
if (signal.aborted) throw signal.reason;
|
|
164
|
+
bytesUsed += reply.length;
|
|
165
|
+
|
|
166
|
+
let parsed: TextReply;
|
|
167
|
+
try {
|
|
168
|
+
parsed = parseTextReply(reply);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const diagnostic =
|
|
171
|
+
error instanceof XcbError
|
|
172
|
+
? error.message
|
|
173
|
+
: "reply could not be parsed";
|
|
174
|
+
rejected += 1;
|
|
175
|
+
emit({
|
|
176
|
+
type: "tool-rejected",
|
|
177
|
+
callId: `xcb-${step}-reply`,
|
|
178
|
+
name: "reply",
|
|
179
|
+
diagnostic,
|
|
180
|
+
});
|
|
181
|
+
feedback = `Your reply was rejected: ${diagnostic}. Reply with exactly one JSON object.`;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (parsed.say) {
|
|
185
|
+
finalText = parsed.say;
|
|
186
|
+
emit({ type: "text-delta", delta: parsed.say });
|
|
187
|
+
}
|
|
188
|
+
const results: string[] = [];
|
|
189
|
+
let stepRejected = 0;
|
|
190
|
+
let overBudget = false;
|
|
191
|
+
for (const [index, op] of parsed.ops.entries()) {
|
|
192
|
+
if (signal.aborted) throw signal.reason;
|
|
193
|
+
const callId = `xcb-${step}-${index}`;
|
|
194
|
+
if (toolCalls >= limits.maxToolCalls) {
|
|
195
|
+
overBudget = true;
|
|
196
|
+
rejected += 1;
|
|
197
|
+
stepRejected += 1;
|
|
198
|
+
emit({
|
|
199
|
+
type: "tool-rejected",
|
|
200
|
+
callId,
|
|
201
|
+
name: op.tool,
|
|
202
|
+
diagnostic: "tool call budget exhausted",
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
toolCalls += 1;
|
|
207
|
+
emit({ type: "tool-start", callId, name: op.tool, step });
|
|
208
|
+
const outcome = await executeCall(
|
|
209
|
+
{ id: callId, name: op.tool, arguments: JSON.stringify(op.args) },
|
|
210
|
+
{ tools, host: options.host, newNoteId },
|
|
211
|
+
);
|
|
212
|
+
if (outcome.ok) {
|
|
213
|
+
applied += outcome.mutated ? 1 : 0;
|
|
214
|
+
if (outcome.explanation) finalText = outcome.explanation;
|
|
215
|
+
emit(outcome.event);
|
|
216
|
+
} else {
|
|
217
|
+
rejected += 1;
|
|
218
|
+
stepRejected += 1;
|
|
219
|
+
emit({
|
|
220
|
+
type: "tool-rejected",
|
|
221
|
+
callId,
|
|
222
|
+
name: op.tool,
|
|
223
|
+
diagnostic: outcome.diagnostic,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
results.push(`ops[${index}] ${op.tool}: ${outcome.content}`);
|
|
227
|
+
}
|
|
228
|
+
if (overBudget || toolCalls >= limits.maxToolCalls)
|
|
229
|
+
return done("max-tool-calls");
|
|
230
|
+
if (stepRejected === 0 && parsed.done) return done("stop");
|
|
231
|
+
feedback =
|
|
232
|
+
results.join("\n").slice(0, MAX_FEEDBACK_CHARS) || "No ops were sent.";
|
|
233
|
+
}
|
|
234
|
+
return done("max-steps");
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const { code, message } = classifyAgentError(error, signal, options.signal);
|
|
237
|
+
return finish({
|
|
238
|
+
type: "error",
|
|
239
|
+
code,
|
|
240
|
+
message,
|
|
241
|
+
applied,
|
|
242
|
+
revision: currentRevision(),
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/agent/xcb.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { CommandRunner } from "../auth/runner.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Client for xcb's application API (`xcb --json generate`): one prompt in, one
|
|
5
|
+
* untrusted text reply out, zero tools, no streaming. See
|
|
6
|
+
* https://github.com/hraness/xcb/blob/main/docs/application-api.md
|
|
7
|
+
*/
|
|
8
|
+
export const XCB_DOC_URL =
|
|
9
|
+
"https://github.com/hraness/xcb/blob/main/docs/application-api.md";
|
|
10
|
+
export const XCB_INSTALL = "curl -fsSL https://xcb.sh/install.sh | sh";
|
|
11
|
+
export const XCB_LIMITS = Object.freeze({
|
|
12
|
+
maxInputBytes: 1024 * 1024,
|
|
13
|
+
maxOutputBytes: 262_144,
|
|
14
|
+
minTimeoutMs: 1_000,
|
|
15
|
+
maxTimeoutMs: 300_000,
|
|
16
|
+
maxCapabilityBytes: 2 * 1024 * 1024,
|
|
17
|
+
maxAccounts: 128,
|
|
18
|
+
maxModelsPerAccount: 64,
|
|
19
|
+
});
|
|
20
|
+
const ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
21
|
+
const MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,200}$/;
|
|
22
|
+
const FAILURE_CODES = new Set([
|
|
23
|
+
"invalid_request",
|
|
24
|
+
"unavailable",
|
|
25
|
+
"busy",
|
|
26
|
+
"deadline",
|
|
27
|
+
"cancelled",
|
|
28
|
+
"provider_error",
|
|
29
|
+
"output_limit",
|
|
30
|
+
"custody_unproven",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export class XcbError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
message: string,
|
|
36
|
+
readonly code: string = "provider_error",
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "XcbError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type XcbModel = Readonly<{ key: string; label: string }>;
|
|
44
|
+
export type XcbAccount = Readonly<{
|
|
45
|
+
id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
provider: string;
|
|
48
|
+
available: boolean;
|
|
49
|
+
connected: boolean;
|
|
50
|
+
reason: string | null;
|
|
51
|
+
models: readonly XcbModel[];
|
|
52
|
+
/**
|
|
53
|
+
* xcb's per-account application admission, when it reports one. `pending`
|
|
54
|
+
* means xcb admits the account on first use (that call is slower); it
|
|
55
|
+
* treats it as usable. Absent on xcb builds without automatic admission.
|
|
56
|
+
*/
|
|
57
|
+
admission?: "pending" | "admitted" | "denied" | undefined;
|
|
58
|
+
}>;
|
|
59
|
+
export type XcbCapabilities = Readonly<{
|
|
60
|
+
supported: boolean;
|
|
61
|
+
accounts: readonly XcbAccount[];
|
|
62
|
+
}>;
|
|
63
|
+
|
|
64
|
+
/** `XCB_BIN` if set, otherwise `xcb` on PATH. Never installs anything. */
|
|
65
|
+
export function resolveXcbBin(
|
|
66
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
67
|
+
runner: CommandRunner,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
const explicit = env.XCB_BIN?.trim();
|
|
70
|
+
if (explicit) return explicit;
|
|
71
|
+
return runner.which("xcb");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shortString(value: unknown, max: number, fallback = ""): string {
|
|
75
|
+
return typeof value === "string"
|
|
76
|
+
? value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max)
|
|
77
|
+
: fallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse `xcb --json generate --capabilities` output from `unknown`. */
|
|
81
|
+
const ADMISSION_STATES = new Set(["pending", "admitted", "denied"]);
|
|
82
|
+
|
|
83
|
+
/** `"pending"` or `{ state | status: "pending" }`; anything else is ignored. */
|
|
84
|
+
function admissionState(value: unknown): XcbAccount["admission"] {
|
|
85
|
+
const raw = isRecord(value) ? (value.state ?? value.status) : value;
|
|
86
|
+
return typeof raw === "string" && ADMISSION_STATES.has(raw)
|
|
87
|
+
? (raw as XcbAccount["admission"])
|
|
88
|
+
: undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseCapabilities(value: unknown): XcbCapabilities {
|
|
92
|
+
if (!isRecord(value) || value.version !== 1)
|
|
93
|
+
throw new XcbError("xcb capabilities were not a version-1 object");
|
|
94
|
+
const rows = Array.isArray(value.accounts)
|
|
95
|
+
? value.accounts.slice(0, XCB_LIMITS.maxAccounts)
|
|
96
|
+
: [];
|
|
97
|
+
const accounts: XcbAccount[] = [];
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
if (
|
|
100
|
+
!isRecord(row) ||
|
|
101
|
+
typeof row.id !== "string" ||
|
|
102
|
+
!ID_PATTERN.test(row.id)
|
|
103
|
+
)
|
|
104
|
+
continue;
|
|
105
|
+
const models: XcbModel[] = [];
|
|
106
|
+
const modelRows = Array.isArray(row.models)
|
|
107
|
+
? row.models.slice(0, XCB_LIMITS.maxModelsPerAccount)
|
|
108
|
+
: [];
|
|
109
|
+
for (const model of modelRows) {
|
|
110
|
+
if (
|
|
111
|
+
!isRecord(model) ||
|
|
112
|
+
typeof model.key !== "string" ||
|
|
113
|
+
!MODEL_PATTERN.test(model.key)
|
|
114
|
+
)
|
|
115
|
+
continue;
|
|
116
|
+
models.push({
|
|
117
|
+
key: model.key,
|
|
118
|
+
label: shortString(model.label, 64, model.key) || model.key,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const label =
|
|
122
|
+
shortString(row.label, 64) ||
|
|
123
|
+
shortString(row.name, 64) ||
|
|
124
|
+
shortString(row.email, 64) ||
|
|
125
|
+
row.id;
|
|
126
|
+
const admission = admissionState(row.admission);
|
|
127
|
+
accounts.push({
|
|
128
|
+
id: row.id,
|
|
129
|
+
label,
|
|
130
|
+
provider: shortString(row.provider, 32, "unknown") || "unknown",
|
|
131
|
+
// A pending admission is usable: xcb admits the account on first call.
|
|
132
|
+
available:
|
|
133
|
+
models.length > 0 &&
|
|
134
|
+
admission !== "denied" &&
|
|
135
|
+
(row.available === true || admission === "pending"),
|
|
136
|
+
...(admission ? { admission } : {}),
|
|
137
|
+
connected: row.connected === true,
|
|
138
|
+
reason:
|
|
139
|
+
typeof row.reason === "string" ? shortString(row.reason, 64) : null,
|
|
140
|
+
models,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { supported: value.supported === true, accounts };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function readCapabilities(
|
|
147
|
+
bin: string,
|
|
148
|
+
runner: CommandRunner,
|
|
149
|
+
signal?: AbortSignal,
|
|
150
|
+
): Promise<XcbCapabilities> {
|
|
151
|
+
const result = await runner.run(
|
|
152
|
+
bin,
|
|
153
|
+
["--json", "generate", "--capabilities"],
|
|
154
|
+
{
|
|
155
|
+
timeoutMs: 15_000,
|
|
156
|
+
maxOutputBytes: XCB_LIMITS.maxCapabilityBytes,
|
|
157
|
+
...(signal ? { signal } : {}),
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
if (result.code !== 0)
|
|
161
|
+
throw new XcbError(
|
|
162
|
+
`xcb capabilities failed (exit ${result.code})`,
|
|
163
|
+
"unavailable",
|
|
164
|
+
);
|
|
165
|
+
let parsed: unknown;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(result.stdout);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new XcbError("xcb capabilities were not valid JSON");
|
|
170
|
+
}
|
|
171
|
+
return parseCapabilities(parsed);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type XcbGenerateRequest = Readonly<{
|
|
175
|
+
bin: string;
|
|
176
|
+
runner: CommandRunner;
|
|
177
|
+
account: string;
|
|
178
|
+
model: string;
|
|
179
|
+
prompt: string;
|
|
180
|
+
timeoutMs: number;
|
|
181
|
+
maxOutputBytes: number;
|
|
182
|
+
signal?: AbortSignal;
|
|
183
|
+
}>;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One `xcb --json generate` call. Aborting sends SIGTERM and waits for xcb to
|
|
187
|
+
* finish its own cleanup (it keeps the account held until the provider exits).
|
|
188
|
+
*/
|
|
189
|
+
export async function xcbGenerate(
|
|
190
|
+
request: XcbGenerateRequest,
|
|
191
|
+
): Promise<string> {
|
|
192
|
+
if (!ID_PATTERN.test(request.account))
|
|
193
|
+
throw new XcbError("invalid xcb account id", "invalid_request");
|
|
194
|
+
if (!MODEL_PATTERN.test(request.model))
|
|
195
|
+
throw new XcbError("invalid xcb model key", "invalid_request");
|
|
196
|
+
const timeoutMs = clamp(
|
|
197
|
+
Math.round(request.timeoutMs),
|
|
198
|
+
XCB_LIMITS.minTimeoutMs,
|
|
199
|
+
XCB_LIMITS.maxTimeoutMs,
|
|
200
|
+
);
|
|
201
|
+
const maxOutputBytes = clamp(
|
|
202
|
+
Math.round(request.maxOutputBytes),
|
|
203
|
+
1,
|
|
204
|
+
XCB_LIMITS.maxOutputBytes,
|
|
205
|
+
);
|
|
206
|
+
const prompt = request.prompt.replace(/\u0000/g, "");
|
|
207
|
+
if (prompt.trim().length === 0)
|
|
208
|
+
throw new XcbError("empty prompt", "invalid_request");
|
|
209
|
+
const body = JSON.stringify({
|
|
210
|
+
version: 1,
|
|
211
|
+
account: request.account,
|
|
212
|
+
model: request.model,
|
|
213
|
+
prompt,
|
|
214
|
+
timeoutMs,
|
|
215
|
+
maxOutputBytes,
|
|
216
|
+
});
|
|
217
|
+
if (new TextEncoder().encode(body).byteLength > XCB_LIMITS.maxInputBytes)
|
|
218
|
+
throw new XcbError(
|
|
219
|
+
"prompt exceeds xcb's 1 MiB input limit",
|
|
220
|
+
"invalid_request",
|
|
221
|
+
);
|
|
222
|
+
if (request.signal?.aborted) throw new XcbError("cancelled", "cancelled");
|
|
223
|
+
const result = await request.runner.run(request.bin, ["--json", "generate"], {
|
|
224
|
+
stdin: body,
|
|
225
|
+
// xcb enforces its own deadline; this is a backstop for a wedged process.
|
|
226
|
+
timeoutMs: timeoutMs + 30_000,
|
|
227
|
+
maxOutputBytes: maxOutputBytes * 2 + 16 * 1024,
|
|
228
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
229
|
+
});
|
|
230
|
+
if (result.killed || request.signal?.aborted)
|
|
231
|
+
throw new XcbError("cancelled", "cancelled");
|
|
232
|
+
let parsed: unknown;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(result.stdout);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new XcbError(`xcb generate returned no JSON (exit ${result.code})`);
|
|
237
|
+
}
|
|
238
|
+
if (!isRecord(parsed))
|
|
239
|
+
throw new XcbError("xcb generate returned a non-object");
|
|
240
|
+
if (
|
|
241
|
+
parsed.status === "completed" &&
|
|
242
|
+
result.code === 0 &&
|
|
243
|
+
typeof parsed.text === "string"
|
|
244
|
+
)
|
|
245
|
+
return parsed.text.slice(0, maxOutputBytes);
|
|
246
|
+
const code =
|
|
247
|
+
typeof parsed.code === "string" && FAILURE_CODES.has(parsed.code)
|
|
248
|
+
? parsed.code
|
|
249
|
+
: "provider_error";
|
|
250
|
+
const requestId =
|
|
251
|
+
typeof parsed.requestId === "string" &&
|
|
252
|
+
/^[A-Za-z0-9_-]{1,80}$/.test(parsed.requestId)
|
|
253
|
+
? ` (${parsed.requestId})`
|
|
254
|
+
: "";
|
|
255
|
+
throw new XcbError(`xcb generate failed: ${code}${requestId}`, code);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Find the first balanced top-level JSON object in `text`, honouring strings
|
|
260
|
+
* and escapes. Only the first `maxBytes` characters are scanned.
|
|
261
|
+
*/
|
|
262
|
+
export function extractFirstJsonObject(
|
|
263
|
+
text: string,
|
|
264
|
+
maxChars = 64 * 1024,
|
|
265
|
+
): string | undefined {
|
|
266
|
+
const scan = text.slice(0, maxChars);
|
|
267
|
+
let start = scan.indexOf("{");
|
|
268
|
+
while (start >= 0) {
|
|
269
|
+
let depth = 0;
|
|
270
|
+
let inString = false;
|
|
271
|
+
let escaped = false;
|
|
272
|
+
for (let index = start; index < scan.length; index += 1) {
|
|
273
|
+
const char = scan[index]!;
|
|
274
|
+
if (inString) {
|
|
275
|
+
if (escaped) escaped = false;
|
|
276
|
+
else if (char === "\\") escaped = true;
|
|
277
|
+
else if (char === '"') inString = false;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (char === '"') inString = true;
|
|
281
|
+
else if (char === "{") depth += 1;
|
|
282
|
+
else if (char === "}") {
|
|
283
|
+
depth -= 1;
|
|
284
|
+
if (depth === 0) {
|
|
285
|
+
const candidate = scan.slice(start, index + 1);
|
|
286
|
+
try {
|
|
287
|
+
JSON.parse(candidate);
|
|
288
|
+
return candidate;
|
|
289
|
+
} catch {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
start = scan.indexOf("{", start + 1);
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export type TextOp = Readonly<{ tool: string; args: Record<string, unknown> }>;
|
|
301
|
+
export type TextReply = Readonly<{
|
|
302
|
+
ops: readonly TextOp[];
|
|
303
|
+
say?: string;
|
|
304
|
+
done: boolean;
|
|
305
|
+
}>;
|
|
306
|
+
export const TEXT_REPLY_LIMITS = Object.freeze({
|
|
307
|
+
maxChars: 64 * 1024,
|
|
308
|
+
maxOps: 16,
|
|
309
|
+
maxSayChars: 400,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/** Parse a model reply into `{ops, say, done}` or throw with a short diagnostic. */
|
|
313
|
+
export function parseTextReply(text: string): TextReply {
|
|
314
|
+
const json = extractFirstJsonObject(text, TEXT_REPLY_LIMITS.maxChars);
|
|
315
|
+
if (json === undefined)
|
|
316
|
+
throw new XcbError(
|
|
317
|
+
text.length > TEXT_REPLY_LIMITS.maxChars
|
|
318
|
+
? "reply exceeded 64 KiB without a complete JSON object"
|
|
319
|
+
: "reply did not contain a JSON object",
|
|
320
|
+
"invalid_reply",
|
|
321
|
+
);
|
|
322
|
+
const value: unknown = JSON.parse(json);
|
|
323
|
+
if (!isRecord(value))
|
|
324
|
+
throw new XcbError("reply was not a JSON object", "invalid_reply");
|
|
325
|
+
const rawOps = value.ops ?? [];
|
|
326
|
+
if (!Array.isArray(rawOps))
|
|
327
|
+
throw new XcbError("ops must be an array", "invalid_reply");
|
|
328
|
+
if (rawOps.length > TEXT_REPLY_LIMITS.maxOps)
|
|
329
|
+
throw new XcbError(
|
|
330
|
+
`at most ${TEXT_REPLY_LIMITS.maxOps} ops per reply`,
|
|
331
|
+
"invalid_reply",
|
|
332
|
+
);
|
|
333
|
+
const ops: TextOp[] = rawOps.map((op, index) => {
|
|
334
|
+
if (
|
|
335
|
+
!isRecord(op) ||
|
|
336
|
+
typeof op.tool !== "string" ||
|
|
337
|
+
op.tool.length === 0 ||
|
|
338
|
+
op.tool.length > 64
|
|
339
|
+
)
|
|
340
|
+
throw new XcbError(`ops[${index}] needs a tool name`, "invalid_reply");
|
|
341
|
+
const args = op.args ?? {};
|
|
342
|
+
if (!isRecord(args))
|
|
343
|
+
throw new XcbError(
|
|
344
|
+
`ops[${index}].args must be an object`,
|
|
345
|
+
"invalid_reply",
|
|
346
|
+
);
|
|
347
|
+
return { tool: op.tool, args };
|
|
348
|
+
});
|
|
349
|
+
const say =
|
|
350
|
+
typeof value.say === "string" && value.say.trim()
|
|
351
|
+
? value.say.trim().slice(0, TEXT_REPLY_LIMITS.maxSayChars)
|
|
352
|
+
: undefined;
|
|
353
|
+
return { ops, done: value.done !== false, ...(say ? { say } : {}) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A friendly short label: `claude/sonnet/low` → `claude/sonnet`. */
|
|
357
|
+
export function shortModelLabel(key: string): string {
|
|
358
|
+
return key.split("/").slice(0, 2).join("/");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function clamp(value: number, min: number, max: number): number {
|
|
362
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
366
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
367
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export class TransportClock {
|
|
2
|
+
private startedAtMs: number | undefined;
|
|
3
|
+
private pausedBeat = 0;
|
|
4
|
+
private bpm: number;
|
|
5
|
+
|
|
6
|
+
constructor(bpm = 120) {
|
|
7
|
+
this.bpm = bpm;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
play(nowMs = performance.now()): void {
|
|
11
|
+
if (this.startedAtMs === undefined)
|
|
12
|
+
this.startedAtMs = nowMs - this.beatToMs(this.pausedBeat);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pause(nowMs = performance.now()): void {
|
|
16
|
+
this.pausedBeat = this.beatAt(nowMs);
|
|
17
|
+
this.startedAtMs = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toggle(nowMs = performance.now()): void {
|
|
21
|
+
if (this.startedAtMs === undefined) this.play(nowMs);
|
|
22
|
+
else this.pause(nowMs);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beatAt(nowMs = performance.now()): number {
|
|
26
|
+
if (this.startedAtMs === undefined) return this.pausedBeat;
|
|
27
|
+
return Math.max(0, (nowMs - this.startedAtMs) / this.beatToMs(1));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get playing(): boolean {
|
|
31
|
+
return this.startedAtMs !== undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setTempo(bpm: number, nowMs = performance.now()): void {
|
|
35
|
+
if (!Number.isFinite(bpm) || bpm <= 0) return;
|
|
36
|
+
const beat = this.beatAt(nowMs);
|
|
37
|
+
this.bpm = bpm;
|
|
38
|
+
if (this.startedAtMs !== undefined)
|
|
39
|
+
this.startedAtMs = nowMs - this.beatToMs(beat);
|
|
40
|
+
else this.pausedBeat = beat;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Converge to a timestamped transport event from another TUI process. */
|
|
44
|
+
sync(
|
|
45
|
+
beat: number,
|
|
46
|
+
playing: boolean,
|
|
47
|
+
eventAtMs = Date.now(),
|
|
48
|
+
nowWallMs = Date.now(),
|
|
49
|
+
nowMonotonicMs = performance.now(),
|
|
50
|
+
): void {
|
|
51
|
+
if (!Number.isFinite(beat) || beat < 0) return;
|
|
52
|
+
if (!Number.isFinite(eventAtMs) || !Number.isFinite(nowWallMs)) return;
|
|
53
|
+
const elapsed = playing
|
|
54
|
+
? Math.max(0, nowWallMs - eventAtMs) / this.beatToMs(1)
|
|
55
|
+
: 0;
|
|
56
|
+
const currentBeat = beat + elapsed;
|
|
57
|
+
if (playing) {
|
|
58
|
+
this.startedAtMs = nowMonotonicMs - this.beatToMs(currentBeat);
|
|
59
|
+
} else {
|
|
60
|
+
this.pausedBeat = currentBeat;
|
|
61
|
+
this.startedAtMs = undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private beatToMs(beats: number): number {
|
|
66
|
+
return (beats * 60_000) / this.bpm;
|
|
67
|
+
}
|
|
68
|
+
}
|