@ossclip/core 0.1.27 → 0.1.29
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/package.json +1 -1
- package/src/browser.ts +63 -2
- package/src/config.ts +15 -0
- package/src/cover-headline.ts +8 -1
- package/src/cutlist.ts +109 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +37 -0
- package/src/producer/antigravity.ts +254 -31
- package/src/producer/beats.ts +45 -4
- package/src/producer/claude-cli.ts +82 -2
- package/src/producer/failure.ts +40 -0
- package/src/producer/fallback.ts +91 -0
- package/src/producer/index.ts +69 -5
- package/src/producer/usage.ts +31 -1
- package/src/recut.ts +6 -1
- package/src/retime-preview.ts +331 -0
- package/src/schema.ts +19 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import { run } from "../exec";
|
|
3
|
+
import { attemptFactsLine } from "./failure";
|
|
3
4
|
import type { LlmProvider } from "./provider";
|
|
4
5
|
import { estimateTokens, type LlmUsage } from "./usage";
|
|
5
6
|
// Shared fence-stripper for replies that wrap the JSON in prose/markdown.
|
|
@@ -8,13 +9,24 @@ import { estimateTokens, type LlmUsage } from "./usage";
|
|
|
8
9
|
import { extractJsonObject } from "./claude-cli";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Core's `run()` has no timeout of its own, so this flag is the ONLY clock on
|
|
13
|
+
* the spawn — without it a stuck call is a hang, with it a timeout surfaces as
|
|
14
|
+
* a fall back to the next provider (FINDINGS §132, §143).
|
|
15
|
+
*
|
|
16
|
+
* This is a RECOVERY DEADLINE, not a patience allowance (§149). It was 10m on
|
|
17
|
+
* the theory that a big beat sheet might legitimately need it; the theory was
|
|
18
|
+
* wrong twice over. agy's hang is intermittent and service-side — §143 probed
|
|
19
|
+
* a 95,030-token call that answered in 17.6s — so a call that has said nothing
|
|
20
|
+
* for 90s is not working slowly, it is hung. And what rescues it is the
|
|
21
|
+
* fallback, which costs seconds. Waiting 10m to start a 5s recovery cost one
|
|
22
|
+
* field run 605.9s of dead air on a 96-second video (2026-08-23: 692s total
|
|
23
|
+
* for ~86s of real work).
|
|
24
|
+
*
|
|
25
|
+
* 90s is 2x the slowest healthy call ever measured (46s) and below agy's own
|
|
26
|
+
* 5m default. Go duration format, verified against `agy --help` ("default
|
|
27
|
+
* 5m0s"). Raise it only for a call measured to SUCCEED slower than this.
|
|
16
28
|
*/
|
|
17
|
-
export const AGY_PRINT_TIMEOUT = "
|
|
29
|
+
export const AGY_PRINT_TIMEOUT = "90s";
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* agy takes the prompt as an argv argument only — no stdin — and macOS caps
|
|
@@ -24,6 +36,14 @@ export const AGY_PRINT_TIMEOUT = "10m";
|
|
|
24
36
|
*/
|
|
25
37
|
export const MAX_AGY_PROMPT_BYTES = 700_000;
|
|
26
38
|
|
|
39
|
+
/**
|
|
40
|
+
* agy's `--effort` levels. Exposed after the §143 hang incident (2026-08-22):
|
|
41
|
+
* untested at real scale whether a lower effort moves the hang, but the knob
|
|
42
|
+
* existed and we passed nothing — every call ran at agy's default with no way
|
|
43
|
+
* to try anything else.
|
|
44
|
+
*/
|
|
45
|
+
export type LlmEffort = "low" | "medium" | "high";
|
|
46
|
+
|
|
27
47
|
/**
|
|
28
48
|
* The argv for one `agy` print-mode call. Pure so the flag set is testable
|
|
29
49
|
* without spawning anything. `--disable-slash-commands` because a transcript
|
|
@@ -31,7 +51,7 @@ export const MAX_AGY_PROMPT_BYTES = 700_000;
|
|
|
31
51
|
*/
|
|
32
52
|
export function buildAgyArgs(
|
|
33
53
|
prompt: string,
|
|
34
|
-
opts: { model?: string; schemaJson: string },
|
|
54
|
+
opts: { model?: string; effort?: LlmEffort; schemaJson: string },
|
|
35
55
|
): string[] {
|
|
36
56
|
return [
|
|
37
57
|
"-p",
|
|
@@ -44,6 +64,9 @@ export function buildAgyArgs(
|
|
|
44
64
|
"--print-timeout",
|
|
45
65
|
AGY_PRINT_TIMEOUT,
|
|
46
66
|
...(opts.model ? ["--model", opts.model] : []),
|
|
67
|
+
// Omitted entirely when unset — agy's own default stands, exactly as it
|
|
68
|
+
// did before the knob existed (§143).
|
|
69
|
+
...(opts.effort ? ["--effort", opts.effort] : []),
|
|
47
70
|
];
|
|
48
71
|
}
|
|
49
72
|
|
|
@@ -107,11 +130,159 @@ export function parseAgyEnvelope(stdout: string): {
|
|
|
107
130
|
* Failures a retry cannot fix: missing auth and a bad model slug are
|
|
108
131
|
* deterministic, and each retry burns another ~24k-token baseline call (agy's
|
|
109
132
|
* own agent context) for nothing (FINDINGS §132, antigravity provider).
|
|
133
|
+
*
|
|
134
|
+
* The patterns were always right; the INPUT was wrong. Until 2026-08-22 this
|
|
135
|
+
* was handed `run()`'s rejection, and the contract §132 claimed for it — "the
|
|
136
|
+
* stderr tail is embedded, so auth/bad-slug are matchable here" — is false for
|
|
137
|
+
* real agy: a bad slug exits 1 with `invalid model selection …` in the STDOUT
|
|
138
|
+
* envelope and NOTHING on stderr (measured, 1.1.18). So the message this saw
|
|
139
|
+
* was our own echoed argv, which contains the slug but never the words
|
|
140
|
+
* "invalid model", and the documented fail-fast has never once fired in the
|
|
141
|
+
* field. It is now given `agyErrorText()` — the envelope's own error — which
|
|
142
|
+
* matches the shipped patterns as measured, with no pattern change at all.
|
|
143
|
+
*
|
|
144
|
+
* That is a real behaviour change (a bad slug now costs one call, not two),
|
|
145
|
+
* taken deliberately: it makes the code do what this comment has always said
|
|
146
|
+
* it does, rather than changing what it should do.
|
|
110
147
|
*/
|
|
111
148
|
export function isNonRetryableAgyFailure(message: string): boolean {
|
|
112
149
|
return /authentication|not logged in|login|unknown model|invalid model/i.test(message);
|
|
113
150
|
}
|
|
114
151
|
|
|
152
|
+
/**
|
|
153
|
+
* What actually went wrong, out of the two surfaces agy uses — measured
|
|
154
|
+
* against agy 1.1.18 on 2026-08-22, because none of it is documented:
|
|
155
|
+
*
|
|
156
|
+
* print timeout fires exit 1 stdout `{"status":"ERROR","error":"timeout
|
|
157
|
+
* waiting for response",…}` stderr empty
|
|
158
|
+
* unknown model slug exit 1 stdout `{"status":"ERROR","error":"invalid
|
|
159
|
+
* model selection (--model …)…"}` stderr empty
|
|
160
|
+
* bad flag / usage exit 2 stdout empty stderr usage text
|
|
161
|
+
*
|
|
162
|
+
* So the operational failures speak through the ENVELOPE and the process-level
|
|
163
|
+
* ones through stderr, and a reader of only one surface is blind to half of
|
|
164
|
+
* them. Preference order follows that: the envelope's own `error` first (it is
|
|
165
|
+
* agy's sentence about its own failure), then stderr for the exits that never
|
|
166
|
+
* reached the envelope, then the bare status, then whatever stdout held.
|
|
167
|
+
*
|
|
168
|
+
* Pure, and exported so the surfaces are testable without spawning agy. agy's
|
|
169
|
+
* wording is not a contract we control — only the two surfaces are — which is
|
|
170
|
+
* why callers classify loosely and print the raw text either way.
|
|
171
|
+
*/
|
|
172
|
+
export function agyErrorText(stdout: string, stderr: string): string {
|
|
173
|
+
const env = parseAgyEnvelope(stdout);
|
|
174
|
+
if (env.error?.trim()) return env.error.trim();
|
|
175
|
+
if (stderr.trim()) return stderr.trim().slice(-2000);
|
|
176
|
+
if (env.status) return `agy reported status ${env.status}`;
|
|
177
|
+
if (stdout.trim()) return `agy replied with no envelope: ${stdout.trim().slice(0, 300)}`;
|
|
178
|
+
return "agy printed nothing";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What the failure text says actually went wrong. See `classifyAgyFailure`. */
|
|
182
|
+
export type AgyFailureClass = "auth" | "model" | "timeout" | "schema" | "unknown";
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Classify a failure so the error can say what happened instead of guessing.
|
|
186
|
+
* Pure and exported so every class is assertable without spawning agy. The
|
|
187
|
+
* input is `agyErrorText()` — agy's own sentence — not `run()`'s rejection.
|
|
188
|
+
*
|
|
189
|
+
* The incident (2026-08-22, FINDINGS §132): a `--produce --aspect 16:9` run on
|
|
190
|
+
* an 11-minute take timed out twice at AGY_PRINT_TIMEOUT — 10m each, 25
|
|
191
|
+
* minutes burned — and then died with "Is Antigravity installed and logged
|
|
192
|
+
* in?", while agy was installed, logged in and working. The hint was appended
|
|
193
|
+
* unconditionally, so every failure was reported as an auth failure and this
|
|
194
|
+
* one sent the user to debug auth after a 25-minute wait.
|
|
195
|
+
*
|
|
196
|
+
* Anchors, and how much each is worth:
|
|
197
|
+
* - `timeout` and `model` are MEASURED (agy 1.1.18): "timeout waiting for
|
|
198
|
+
* response" and "invalid model selection (--model …)". The looser
|
|
199
|
+
* alternatives stay beside them because the exact strings are agy's to
|
|
200
|
+
* change without telling us — and because an externally killed agy (a
|
|
201
|
+
* signal, not agy's own clock) surfaces differently again.
|
|
202
|
+
* - `auth` is INFERRED, not measured: establishing it would mean signing the
|
|
203
|
+
* user out. Its patterns are exactly the ones `isNonRetryableAgyFailure`
|
|
204
|
+
* has always used, so an auth failure can never classify worse than it did
|
|
205
|
+
* before this change, and both measured samples put operational reasons in
|
|
206
|
+
* the same envelope field, so there is no reason to expect auth elsewhere.
|
|
207
|
+
* - `schema` matches what OUR OWN code leaves in `lastError` — `extractJson-
|
|
208
|
+
* Object`'s message, JSON.parse's, and zod's issue array.
|
|
209
|
+
*
|
|
210
|
+
* A miss costs the headline and the class-gated advice, never the facts: the
|
|
211
|
+
* attempt line below prints for every class, and it is what actually
|
|
212
|
+
* self-diagnoses a hang.
|
|
213
|
+
*/
|
|
214
|
+
export function classifyAgyFailure(message: string): AgyFailureClass {
|
|
215
|
+
if (/authentication|not logged in|login/i.test(message)) return "auth";
|
|
216
|
+
if (/unknown model|invalid model/i.test(message)) return "model";
|
|
217
|
+
if (/timed? ?out|ETIMEDOUT|SIGTERM|SIGKILL/i.test(message)) return "timeout";
|
|
218
|
+
const schemaish = /no JSON object in reply|is not valid JSON|Unexpected (token|end of)|"code":\s*"/i;
|
|
219
|
+
if (schemaish.test(message)) return "schema";
|
|
220
|
+
return "unknown";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The error thrown when every attempt failed. Pure so the wording is testable
|
|
225
|
+
* without spawning agy, and built around one rule learned from the 2026-08-22
|
|
226
|
+
* incident: the ATTEMPT FACTS print for every class, because they self-diagnose
|
|
227
|
+
* regardless of what the classifier decided. "2 attempts, 10m0s and 10m0s,
|
|
228
|
+
* --print-timeout 10m" tells a user the call hung better than any guess we
|
|
229
|
+
* could make, and it stays true when the guess is wrong.
|
|
230
|
+
*
|
|
231
|
+
* Guidance, by contrast, is class-gated: the sign-in hint only for `auth`, and
|
|
232
|
+
* for `timeout` the actual escape hatch — another provider, or no planner at
|
|
233
|
+
* all — named the way the oversized-prompt error above names them.
|
|
234
|
+
*/
|
|
235
|
+
export function agyFailureMessage(parts: {
|
|
236
|
+
bin: string;
|
|
237
|
+
schemaName: string;
|
|
238
|
+
lastError: string;
|
|
239
|
+
/** Wall time of every attempt that ran, in order. */
|
|
240
|
+
attemptMs: readonly number[];
|
|
241
|
+
printTimeout: string;
|
|
242
|
+
}): string {
|
|
243
|
+
const cls = classifyAgyFailure(parts.lastError);
|
|
244
|
+
const headline =
|
|
245
|
+
cls === "timeout"
|
|
246
|
+
? " — the call timed out"
|
|
247
|
+
: cls === "schema"
|
|
248
|
+
? " — the reply never matched the schema"
|
|
249
|
+
: "";
|
|
250
|
+
const detail = parts.lastError.trim().slice(0, 400) || "agy printed nothing";
|
|
251
|
+
const lines = [
|
|
252
|
+
`agy CLI ('${parts.bin}') did not produce valid ${parts.schemaName} JSON${headline}: ${detail}`,
|
|
253
|
+
attemptFactsLine(parts.attemptMs, `--print-timeout ${parts.printTimeout}`),
|
|
254
|
+
];
|
|
255
|
+
if (cls === "auth") {
|
|
256
|
+
lines.push(
|
|
257
|
+
`Is Antigravity installed and logged in? (https://antigravity.google — run 'agy' once interactively to sign in)`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (cls === "timeout") {
|
|
261
|
+
lines.push(
|
|
262
|
+
`A long take can outrun agy's ${parts.printTimeout} print timeout. ` +
|
|
263
|
+
`Use --llm claude-cli (a logged-in Claude Code subscription) or --llm gemini (needs GEMINI_API_KEY), ` +
|
|
264
|
+
`or drop --produce to cut and caption without a planner.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return lines.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The provider's terminal error, carrying the failure class as DATA. The
|
|
272
|
+
* message is written for humans and free to reword; a provider-fallback
|
|
273
|
+
* decorator that branches on what failed must read `failureClass`, never
|
|
274
|
+
* re-parse the prose.
|
|
275
|
+
*/
|
|
276
|
+
export class AgyError extends Error {
|
|
277
|
+
constructor(
|
|
278
|
+
message: string,
|
|
279
|
+
readonly failureClass: AgyFailureClass,
|
|
280
|
+
) {
|
|
281
|
+
super(message);
|
|
282
|
+
this.name = "AgyError";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
115
286
|
/**
|
|
116
287
|
* Google Antigravity via the locally installed `agy` CLI. Uses whatever auth
|
|
117
288
|
* the CLI holds — a logged-in subscription, so producing a video consumes
|
|
@@ -131,9 +302,12 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
131
302
|
readonly name = "antigravity";
|
|
132
303
|
readonly usage: LlmUsage[] = [];
|
|
133
304
|
|
|
305
|
+
// A trailing options bag rather than a fourth positional: every existing
|
|
306
|
+
// `new AntigravityProvider(model, bin)` call site keeps compiling unchanged.
|
|
134
307
|
constructor(
|
|
135
308
|
private model?: string,
|
|
136
309
|
private bin: string = process.env.OSSCLIP_AGY_BIN ?? "agy",
|
|
310
|
+
private opts: { effort?: LlmEffort } = {},
|
|
137
311
|
) {}
|
|
138
312
|
|
|
139
313
|
async complete<T>(req: {
|
|
@@ -149,6 +323,11 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
149
323
|
`No markdown fences, no commentary, no tool use — just the JSON:\n${schemaText}`;
|
|
150
324
|
|
|
151
325
|
let lastError = "";
|
|
326
|
+
// Wall time of every attempt that FAILED, in order — the facts the final
|
|
327
|
+
// error reports. Kept separately from `usage`, which only records attempts
|
|
328
|
+
// that got as far as an envelope: a call killed by --print-timeout never
|
|
329
|
+
// does, and a 10-minute hang is exactly the attempt a user needs to see.
|
|
330
|
+
const attemptMs: number[] = [];
|
|
152
331
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
153
332
|
const prompt =
|
|
154
333
|
attempt === 0
|
|
@@ -164,41 +343,78 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
164
343
|
}
|
|
165
344
|
const started = Date.now();
|
|
166
345
|
let stdout = "";
|
|
346
|
+
let stderr = "";
|
|
167
347
|
try {
|
|
168
|
-
|
|
348
|
+
// allowNonZero, and it is load-bearing: agy reports its operational
|
|
349
|
+
// failures as exit 1 with the reason in the STDOUT envelope and
|
|
350
|
+
// nothing on stderr (measured 1.1.18 — see `agyErrorText`). run()'s
|
|
351
|
+
// default reject path keeps only the stderr tail, so it threw away
|
|
352
|
+
// every one of those reasons and handed this loop our own echoed
|
|
353
|
+
// argv instead. Resolving non-zero exits and reading the envelope is
|
|
354
|
+
// what makes both the message AND the fail-fast work.
|
|
355
|
+
({ stdout, stderr } = await run(
|
|
169
356
|
this.bin,
|
|
170
|
-
buildAgyArgs(prompt, {
|
|
357
|
+
buildAgyArgs(prompt, {
|
|
358
|
+
model: this.model,
|
|
359
|
+
effort: this.opts.effort,
|
|
360
|
+
schemaJson: schemaText,
|
|
361
|
+
}),
|
|
362
|
+
{ allowNonZero: true },
|
|
171
363
|
));
|
|
172
364
|
} catch (err) {
|
|
365
|
+
// Only a spawn failure reaches here now: agy not on PATH, or not
|
|
366
|
+
// executable. Nothing was spent, and no retry can install it.
|
|
367
|
+
attemptMs.push(Date.now() - started);
|
|
173
368
|
lastError = err instanceof Error ? err.message : String(err);
|
|
174
|
-
// run() embeds the stderr tail in its rejection, so auth/bad-slug
|
|
175
|
-
// failures are matchable here and fail fast instead of re-spending.
|
|
176
369
|
if (isNonRetryableAgyFailure(lastError)) break;
|
|
370
|
+
// An externally killed spawn (SIGTERM/SIGKILL) classifies as timeout,
|
|
371
|
+
// and is as persistent as an expired --print-timeout — same fail-fast
|
|
372
|
+
// as the envelope path below.
|
|
373
|
+
if (classifyAgyFailure(lastError) === "timeout") break;
|
|
177
374
|
continue;
|
|
178
375
|
}
|
|
376
|
+
const elapsed = Date.now() - started;
|
|
179
377
|
// Recorded per ATTEMPT, before validation: a reply that failed the
|
|
180
378
|
// schema still spent the tokens, and a retry is exactly the cost a user
|
|
181
379
|
// would want to see rather than have quietly absorbed.
|
|
182
380
|
const envelope = parseAgyEnvelope(stdout);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
381
|
+
// Gated on stdout now that non-zero exits resolve: a call that printed
|
|
382
|
+
// NOTHING never reached the model (exit 2, a usage error) and must not
|
|
383
|
+
// be booked as an estimated ~24k-token call. Anything that did reply —
|
|
384
|
+
// including an ERROR envelope reporting zeros — is still recorded.
|
|
385
|
+
if (stdout.trim()) {
|
|
386
|
+
this.usage.push({
|
|
387
|
+
provider: this.name,
|
|
388
|
+
// The envelope names no model, so record what was asked for — or the
|
|
389
|
+
// honest placeholder, which the cost report declines to price.
|
|
390
|
+
model: this.model ?? "antigravity-default",
|
|
391
|
+
schemaName: req.schemaName,
|
|
392
|
+
inputTokens: envelope.inputTokens ?? estimateTokens(prompt),
|
|
393
|
+
outputTokens: envelope.outputTokens ?? estimateTokens(envelope.response ?? stdout),
|
|
394
|
+
cachedInputTokens: envelope.cachedInputTokens,
|
|
395
|
+
exact: envelope.inputTokens !== undefined,
|
|
396
|
+
// The whole point of this provider: agy's cached sign-in means the
|
|
397
|
+
// subscription pays, not a card, and agy reports no cost to forward.
|
|
398
|
+
billed: false,
|
|
399
|
+
ms: elapsed,
|
|
400
|
+
// The envelope's own verdict (§143): a failed attempt's cost stays
|
|
401
|
+
// visible, but attribution (the production.json stamp) skips it.
|
|
402
|
+
failed: envelope.status !== "SUCCESS" || undefined,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// SUCCESS is the envelope's own word for "this worked" (§132 lists the
|
|
406
|
+
// status enum), and with non-zero exits resolving it is now the ONLY
|
|
407
|
+
// success test — an exit code we no longer see cannot be one.
|
|
198
408
|
if (envelope.status !== "SUCCESS") {
|
|
199
|
-
|
|
200
|
-
|
|
409
|
+
attemptMs.push(elapsed);
|
|
410
|
+
lastError = agyErrorText(stdout, stderr);
|
|
201
411
|
if (isNonRetryableAgyFailure(lastError)) break;
|
|
412
|
+
// A timed-out call never succeeds on retry at this call size —
|
|
413
|
+
// measured 2026-08-22: a ~63k-token beat-sheet call expired at the
|
|
414
|
+
// 10m --print-timeout twice in a row, so the second attempt only
|
|
415
|
+
// doubled the wall clock to 20 minutes. Fail fast; the escape hatch
|
|
416
|
+
// is another provider, not the same call again.
|
|
417
|
+
if (classifyAgyFailure(lastError) === "timeout") break;
|
|
202
418
|
continue;
|
|
203
419
|
}
|
|
204
420
|
try {
|
|
@@ -209,12 +425,19 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
209
425
|
}
|
|
210
426
|
return req.schema.parse(JSON.parse(extractJsonObject(envelope.response ?? stdout)));
|
|
211
427
|
} catch (err) {
|
|
428
|
+
attemptMs.push(elapsed);
|
|
212
429
|
lastError = err instanceof Error ? err.message : String(err);
|
|
213
430
|
}
|
|
214
431
|
}
|
|
215
|
-
throw new
|
|
216
|
-
|
|
217
|
-
|
|
432
|
+
throw new AgyError(
|
|
433
|
+
agyFailureMessage({
|
|
434
|
+
bin: this.bin,
|
|
435
|
+
schemaName: req.schemaName,
|
|
436
|
+
lastError,
|
|
437
|
+
attemptMs,
|
|
438
|
+
printTimeout: AGY_PRINT_TIMEOUT,
|
|
439
|
+
}),
|
|
440
|
+
classifyAgyFailure(lastError),
|
|
218
441
|
);
|
|
219
442
|
}
|
|
220
443
|
}
|
package/src/producer/beats.ts
CHANGED
|
@@ -99,7 +99,37 @@ export const ClipBeatSheetSchema = BeatSheetSchema.extend({
|
|
|
99
99
|
),
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Bump whenever `producerSystem`/`buildBeatsUserPrompt` change what they ask
|
|
104
|
+
* for: a prompt change changes the answer, so the beat-sheet/scenes cache key
|
|
105
|
+
* carries this (the §78 cache-key posture, same role as
|
|
106
|
+
* YOUTUBE_PROMPT_VERSION) — an old cached sheet must not survive a new prompt.
|
|
107
|
+
*
|
|
108
|
+
* v2: the system prompt stopped calling every output "vertical" (see
|
|
109
|
+
* `producerSystem`).
|
|
110
|
+
*/
|
|
111
|
+
export const PRODUCER_PROMPT_VERSION = "v2";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The editorial system prompt, in the shape the output actually has.
|
|
115
|
+
*
|
|
116
|
+
* The opening sentence used to hardcode "short-form vertical video
|
|
117
|
+
* (Reels/Shorts/TikTok)" for every run, while `buildBeatsUserPrompt` has told
|
|
118
|
+
* 16:9 runs "Output frame: LANDSCAPE 16:9" since R21 §101 — so a landscape
|
|
119
|
+
* produce handed the model two contradictory descriptions of the same frame
|
|
120
|
+
* and left it to reconcile them (confirmed on a live 16:9 run, 2026-08).
|
|
121
|
+
*
|
|
122
|
+
* ONLY that sentence varies. Everything below it is the tuned 9:16 wording,
|
|
123
|
+
* byte-identical, and the portrait default must stay byte-identical to what
|
|
124
|
+
* shipped — the virality grammar was tuned against real runs of it, and a
|
|
125
|
+
* silent reword is a silent re-tune.
|
|
126
|
+
*/
|
|
127
|
+
export function producerSystem(aspect?: "9:16" | "16:9"): string {
|
|
128
|
+
const shape =
|
|
129
|
+
aspect === "16:9"
|
|
130
|
+
? "a landscape video (YouTube)"
|
|
131
|
+
: "a short-form vertical video (Reels/Shorts/TikTok)";
|
|
132
|
+
return `You are the producer for ${shape}. You receive a word-indexed transcript of a talking-head take that has already been cut. Your job is EDITORIAL: segment the take into moments, pick which moments deserve a graphic scene, and write the on-screen copy.
|
|
103
133
|
|
|
104
134
|
Virality grammar — follow these as hard policies:
|
|
105
135
|
- The first moment is the hook: the strongest claim or number anywhere in the take, on screen within 2 seconds.
|
|
@@ -115,10 +145,19 @@ Virality grammar — follow these as hard policies:
|
|
|
115
145
|
- The transcript is ASR output and may contain mishearings: an unfamiliar proper noun is more likely a mistranscription of a common phrase than a real entity — write on-screen copy with the common-sense reading, never a suspected mishearing.
|
|
116
146
|
- FRAMING: when the prompt carries a "Camera framing" brief, it is measured from the footage and is a HARD constraint: on words marked CLOSE, never choose a layout listed as UNAVAILABLE there — pick a \`layout\` that keeps the whole head in frame (pip-bubble, graphic-only, full-bleed) or leave the moment as "none". You may set \`layout\` on any moment; omit it to accept the component's default.
|
|
117
147
|
- COVER: also write \`coverText\` — the hook compressed to a thumbnail headline, AT MOST ${COVER_MAX_WORDS} WORDS. It is read at a glance in a profile grid, so it must stand alone without the video: the claim or the number, no lead-in, no ellipsis.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The portrait prompt, kept as a named export because it is part of
|
|
152
|
+
* @ossclip/core's public surface (`export * from "./producer/beats"`) and
|
|
153
|
+
* removing it would break an importer for nothing. Equal to
|
|
154
|
+
* `producerSystem()` by construction — never a second copy of the text.
|
|
155
|
+
*/
|
|
156
|
+
export const PRODUCER_SYSTEM = producerSystem();
|
|
118
157
|
|
|
119
158
|
/**
|
|
120
159
|
* The `--clip` request, appended to the USER prompt (R19 §93d). The tuned
|
|
121
|
-
*
|
|
160
|
+
* system prompt stays untouched — this adds the window request without
|
|
122
161
|
* rewriting the editorial instructions the beat sheet already follows.
|
|
123
162
|
*/
|
|
124
163
|
export function buildClipAddendum(targetSec: number): string {
|
|
@@ -508,7 +547,9 @@ export async function generateBeatSheet(
|
|
|
508
547
|
// Same editorial call, extended schema (R19 §93d) — the highlight and the
|
|
509
548
|
// beat sheet come from ONE judgement, so they cannot disagree.
|
|
510
549
|
const raw = await provider.complete({
|
|
511
|
-
|
|
550
|
+
// Same aspect the user prompt was built with — the two halves of one
|
|
551
|
+
// call must describe the same frame.
|
|
552
|
+
system: producerSystem(aspect),
|
|
512
553
|
user,
|
|
513
554
|
schema: ClipBeatSheetSchema,
|
|
514
555
|
schemaName: "clip_beat_sheet",
|
|
@@ -517,7 +558,7 @@ export async function generateBeatSheet(
|
|
|
517
558
|
return { ...normalizeBeatSheet(raw, transcript, null), asked, highlight: raw.highlight };
|
|
518
559
|
}
|
|
519
560
|
const raw = await provider.complete({
|
|
520
|
-
system:
|
|
561
|
+
system: producerSystem(aspect),
|
|
521
562
|
user,
|
|
522
563
|
schema: BeatSheetSchema,
|
|
523
564
|
schemaName: "beat_sheet",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import { run } from "../exec";
|
|
3
|
+
import { attemptFactsLine } from "./failure";
|
|
3
4
|
import type { LlmProvider } from "./provider";
|
|
4
5
|
import { estimateTokens, type LlmUsage } from "./usage";
|
|
5
6
|
|
|
@@ -38,6 +39,8 @@ export class ClaudeCliProvider implements LlmProvider {
|
|
|
38
39
|
`No markdown fences, no commentary, no tool use — just the JSON:\n${schemaText}`;
|
|
39
40
|
|
|
40
41
|
let lastError = "";
|
|
42
|
+
/** Wall time of every failed attempt — see `claudeCliFailureMessage`. */
|
|
43
|
+
const attemptMs: number[] = [];
|
|
41
44
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
42
45
|
const prompt =
|
|
43
46
|
attempt === 0
|
|
@@ -50,6 +53,7 @@ export class ClaudeCliProvider implements LlmProvider {
|
|
|
50
53
|
try {
|
|
51
54
|
({ stdout } = await run(this.bin, args, { stdin: prompt }));
|
|
52
55
|
} catch (err) {
|
|
56
|
+
attemptMs.push(Date.now() - started);
|
|
53
57
|
lastError = err instanceof Error ? err.message : String(err);
|
|
54
58
|
continue;
|
|
55
59
|
}
|
|
@@ -75,16 +79,92 @@ export class ClaudeCliProvider implements LlmProvider {
|
|
|
75
79
|
try {
|
|
76
80
|
return req.schema.parse(JSON.parse(extractJsonObject(unwrapCliEnvelope(stdout))));
|
|
77
81
|
} catch (err) {
|
|
82
|
+
attemptMs.push(Date.now() - started);
|
|
78
83
|
lastError = err instanceof Error ? err.message : String(err);
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
throw new Error(
|
|
82
|
-
|
|
83
|
-
`Is Claude Code installed and logged in? (npm i -g @anthropic-ai/claude-code; run 'claude' once to /login)`,
|
|
87
|
+
claudeCliFailureMessage({ bin: this.bin, schemaName: req.schemaName, lastError, attemptMs }),
|
|
84
88
|
);
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/** What the failure text says went wrong. See `classifyClaudeCliFailure`. */
|
|
93
|
+
export type ClaudeCliFailureClass = "auth" | "model" | "timeout" | "schema" | "unknown";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Classify a `claude -p` failure. Pure, and exported so each class is
|
|
97
|
+
* assertable without spawning the CLI.
|
|
98
|
+
*
|
|
99
|
+
* Same disease as agy's (FINDINGS §132): the sign-in hint below used to be
|
|
100
|
+
* appended to EVERY failure, so a bad model slug or a schema-repair loop told
|
|
101
|
+
* the user to check a login that was fine. Same cure — classify, print the
|
|
102
|
+
* attempt facts unconditionally, gate the advice.
|
|
103
|
+
*
|
|
104
|
+
* The surfaces are NOT the same, and this one was measured too (Claude Code,
|
|
105
|
+
* 2026-08-22, a bad `--model` slug): exit 1, a machine-readable
|
|
106
|
+
* `[claude-code:unrecognized_model] {…}` on STDERR, and a human sentence in
|
|
107
|
+
* the stdout envelope's `result`. Because it uses stderr — and because the
|
|
108
|
+
* prompt rides STDIN here, so `run()`'s rejection echoes a short argv rather
|
|
109
|
+
* than the whole transcript — the rejection message this receives already
|
|
110
|
+
* carries the reason, and no `allowNonZero` change is needed to see it.
|
|
111
|
+
*
|
|
112
|
+
* `auth` is inferred, not measured: establishing it would mean signing the
|
|
113
|
+
* user out. Its patterns are widened only to things that cannot mean anything
|
|
114
|
+
* else, so a real auth failure still gets the hint it always got.
|
|
115
|
+
*/
|
|
116
|
+
export function classifyClaudeCliFailure(message: string): ClaudeCliFailureClass {
|
|
117
|
+
if (/invalid api key|authentication|not logged in|\/login|unauthorized|oauth/i.test(message))
|
|
118
|
+
return "auth";
|
|
119
|
+
if (/unrecognized_model|unknown model|invalid model|model .* not (found|recognized)/i.test(message))
|
|
120
|
+
return "model";
|
|
121
|
+
if (/timed? ?out|ETIMEDOUT|SIGTERM|SIGKILL/i.test(message)) return "timeout";
|
|
122
|
+
const schemaish = /no JSON object in reply|is not valid JSON|Unexpected (token|end of)|"code":\s*"/i;
|
|
123
|
+
if (schemaish.test(message)) return "schema";
|
|
124
|
+
return "unknown";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The error thrown when both attempts failed. The attempt facts print for
|
|
129
|
+
* every class because they self-diagnose regardless of the classification
|
|
130
|
+
* (§132); the sign-in hint is gated to `auth`, and a model failure points at
|
|
131
|
+
* the flag that actually caused it.
|
|
132
|
+
*/
|
|
133
|
+
export function claudeCliFailureMessage(parts: {
|
|
134
|
+
bin: string;
|
|
135
|
+
schemaName: string;
|
|
136
|
+
lastError: string;
|
|
137
|
+
attemptMs: readonly number[];
|
|
138
|
+
}): string {
|
|
139
|
+
const cls = classifyClaudeCliFailure(parts.lastError);
|
|
140
|
+
const headline =
|
|
141
|
+
cls === "timeout"
|
|
142
|
+
? " — the call timed out"
|
|
143
|
+
: cls === "schema"
|
|
144
|
+
? " — the reply never matched the schema"
|
|
145
|
+
: "";
|
|
146
|
+
const lines = [
|
|
147
|
+
`claude CLI ('${parts.bin}') did not produce valid ${parts.schemaName} JSON${headline}: ` +
|
|
148
|
+
(parts.lastError.trim().slice(0, 400) || "claude printed nothing"),
|
|
149
|
+
attemptFactsLine(parts.attemptMs),
|
|
150
|
+
];
|
|
151
|
+
if (cls === "auth") {
|
|
152
|
+
lines.push(
|
|
153
|
+
`Is Claude Code installed and logged in? (npm i -g @anthropic-ai/claude-code; run 'claude' once to /login)`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (cls === "model") {
|
|
157
|
+
lines.push(`Check the --llm-model slug, or drop it to use the CLI's own default model.`);
|
|
158
|
+
}
|
|
159
|
+
if (cls === "timeout") {
|
|
160
|
+
lines.push(
|
|
161
|
+
`Use --llm antigravity (a logged-in Antigravity subscription) or --llm gemini (needs GEMINI_API_KEY), ` +
|
|
162
|
+
`or drop --produce to cut and caption without a planner.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
88
168
|
/**
|
|
89
169
|
* The accounting half of the same envelope: tokens, cost and model, all
|
|
90
170
|
* optional because the CLI's envelope shape is not ours to depend on. Every
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facts every failed LLM call can state without guessing why it failed.
|
|
3
|
+
*
|
|
4
|
+
* From the 2026-08-22 incident (FINDINGS §132): a `--produce --aspect 16:9`
|
|
5
|
+
* run on an 11-minute take timed out twice at agy's print timeout — 10m each,
|
|
6
|
+
* 25 minutes burned — and died with "Is Antigravity installed and logged in?"
|
|
7
|
+
* on an agy that was installed, logged in and working. What that user needed
|
|
8
|
+
* was not a better guess, it was the shape of the wait: two attempts, ten
|
|
9
|
+
* minutes each. Attempt facts self-diagnose a hang, and they stay true when
|
|
10
|
+
* the classification is wrong — so both CLI providers print them for EVERY
|
|
11
|
+
* failure class, and gate only the advice.
|
|
12
|
+
*
|
|
13
|
+
* Pure, and shared so the two providers cannot drift into two formats for the
|
|
14
|
+
* same sentence.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `600_000 → "10m0s"`, `1_500 → "1.5s"` — an at-a-glance wall time. */
|
|
18
|
+
export function formatElapsed(ms: number): string {
|
|
19
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
20
|
+
// Floored, not rounded: 1m59.6s must not print as "1m60s".
|
|
21
|
+
return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* "2 attempts, 10m0s and 10m0s" — how many calls ran and how long each took,
|
|
26
|
+
* with `extra` appended for a provider that has a clock worth naming (agy's
|
|
27
|
+
* `--print-timeout`). An empty list still says "0 attempts": a call that never
|
|
28
|
+
* spawned is itself the diagnosis.
|
|
29
|
+
*/
|
|
30
|
+
export function attemptFactsLine(attemptMs: readonly number[], extra?: string): string {
|
|
31
|
+
const times = attemptMs.map(formatElapsed);
|
|
32
|
+
const listed =
|
|
33
|
+
times.length > 1
|
|
34
|
+
? `${times.slice(0, -1).join(", ")} and ${times[times.length - 1]}`
|
|
35
|
+
: (times[0] ?? "");
|
|
36
|
+
return (
|
|
37
|
+
`${attemptMs.length} attempt${attemptMs.length === 1 ? "" : "s"}` +
|
|
38
|
+
`${listed ? `, ${listed}` : ""}${extra ? `, ${extra}` : ""}.`
|
|
39
|
+
);
|
|
40
|
+
}
|