@ossclip/core 0.1.27 → 0.1.28
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 +237 -25
- 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 +65 -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
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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { z } from "zod/v4";
|
|
2
|
+
import { AgyError } from "./antigravity";
|
|
3
|
+
import type { CallTier, LlmProvider } from "./provider";
|
|
4
|
+
import type { LlmUsage } from "./usage";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a fallback announcement needs to say: who failed, who is answering
|
|
8
|
+
* instead, on which call, and (first line of) the failure's own sentence.
|
|
9
|
+
*/
|
|
10
|
+
export interface FallbackInfo {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
schemaName: string;
|
|
14
|
+
detail: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Falls back to a second provider when — and only when — the primary TIMES
|
|
19
|
+
* OUT (2026-08-22, FINDINGS §143). agy is healthy at small scale and then
|
|
20
|
+
* hangs persistently on the real beat-sheet call: measured 10-minute
|
|
21
|
+
* `--print-timeout` expiries on an 11-minute take while claude-cli planned
|
|
22
|
+
* the same video without complaint — and auto-detection picks agy whenever
|
|
23
|
+
* the CLI is on PATH, so without this every Antigravity user's first real
|
|
24
|
+
* produce dies after the wait.
|
|
25
|
+
*
|
|
26
|
+
* Only the `timeout` class falls back: auth and bad-model failures are
|
|
27
|
+
* deterministic and must keep failing fast with their own guidance —
|
|
28
|
+
* answering them from another provider would paper over a config error the
|
|
29
|
+
* user needs to see. Exactly ONE fallback attempt, no loop: the fallback
|
|
30
|
+
* provider runs its own internal retries.
|
|
31
|
+
*
|
|
32
|
+
* `name` stays the primary's — the detection line already announced it — and
|
|
33
|
+
* the truth of who actually answered lives in `usage` (each record keeps the
|
|
34
|
+
* provider that really made the call) plus the caller's out-loud `onFallback`
|
|
35
|
+
* line. Silent substitution would be worse than the hang.
|
|
36
|
+
*/
|
|
37
|
+
export class FallbackProvider implements LlmProvider {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
private readonly records: LlmUsage[] = [];
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly primary: LlmProvider,
|
|
43
|
+
private readonly fallback: LlmProvider,
|
|
44
|
+
private readonly onFallback?: (info: FallbackInfo) => void,
|
|
45
|
+
) {
|
|
46
|
+
this.name = primary.name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Merged in call order — the sub-providers each keep their own log. */
|
|
50
|
+
get usage(): readonly LlmUsage[] {
|
|
51
|
+
return this.records;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async complete<T>(req: {
|
|
55
|
+
system: string;
|
|
56
|
+
user: string;
|
|
57
|
+
schema: z.ZodType<T>;
|
|
58
|
+
schemaName: string;
|
|
59
|
+
maxTokens?: number;
|
|
60
|
+
tier?: CallTier;
|
|
61
|
+
}): Promise<T> {
|
|
62
|
+
const beforePrimary = this.primary.usage.length;
|
|
63
|
+
const beforeFallback = this.fallback.usage.length;
|
|
64
|
+
try {
|
|
65
|
+
try {
|
|
66
|
+
return await this.primary.complete(req);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// Branch on the class as DATA — AgyError carries it precisely so a
|
|
69
|
+
// fallback decorator never re-parses human-facing prose
|
|
70
|
+
// (antigravity.ts). Everything that is not an agy timeout rethrows.
|
|
71
|
+
if (!(err instanceof AgyError) || err.failureClass !== "timeout") throw err;
|
|
72
|
+
this.onFallback?.({
|
|
73
|
+
from: this.primary.name,
|
|
74
|
+
to: this.fallback.name,
|
|
75
|
+
schemaName: req.schemaName,
|
|
76
|
+
// First line only: agyFailureMessage is multi-line guidance, and
|
|
77
|
+
// the announcement needs the sentence, not the manual.
|
|
78
|
+
detail: err.message.split("\n")[0]!.trim(),
|
|
79
|
+
});
|
|
80
|
+
return await this.fallback.complete(req);
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
// Drain whatever BOTH sub-providers logged, including for calls that
|
|
84
|
+
// threw — a failed attempt still spent the tokens (tiered.ts, §37).
|
|
85
|
+
// Each record keeps the provider that really made the call, which is
|
|
86
|
+
// the attribution the cache keys and the usage report depend on.
|
|
87
|
+
this.records.push(...this.primary.usage.slice(beforePrimary));
|
|
88
|
+
this.records.push(...this.fallback.usage.slice(beforeFallback));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/producer/index.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { Transcript } from "../schema";
|
|
|
2
2
|
import type { Scene, SceneComponentId } from "../scene-schema";
|
|
3
3
|
import type { LlmProvider, ProviderName } from "./provider";
|
|
4
4
|
import { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
|
|
5
|
-
import { AntigravityProvider } from "./antigravity";
|
|
5
|
+
import { AntigravityProvider, type LlmEffort } from "./antigravity";
|
|
6
6
|
import { ClaudeCliProvider } from "./claude-cli";
|
|
7
7
|
import { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
8
8
|
import { MockProvider } from "./mock";
|
|
9
9
|
import { TieredProvider } from "./tiered";
|
|
10
|
+
import { FallbackProvider, type FallbackInfo } from "./fallback";
|
|
10
11
|
import {
|
|
11
12
|
generateBeatSheet,
|
|
12
13
|
normalizeBeatSheet,
|
|
@@ -29,13 +30,22 @@ export * from "./youtube";
|
|
|
29
30
|
export * from "./scene-props";
|
|
30
31
|
export * from "./repair";
|
|
31
32
|
export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
|
|
32
|
-
export { AntigravityProvider } from "./antigravity";
|
|
33
|
+
export { AntigravityProvider, type LlmEffort } from "./antigravity";
|
|
33
34
|
export { ClaudeCliProvider } from "./claude-cli";
|
|
34
35
|
export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
35
36
|
export { MockProvider } from "./mock";
|
|
36
37
|
export { TieredProvider } from "./tiered";
|
|
38
|
+
export { FallbackProvider, type FallbackInfo } from "./fallback";
|
|
37
39
|
|
|
38
|
-
export function createProvider(
|
|
40
|
+
export function createProvider(
|
|
41
|
+
name: ProviderName,
|
|
42
|
+
model?: string,
|
|
43
|
+
// A trailing bag, not a third positional per knob: every existing
|
|
44
|
+
// (name, model) call site keeps compiling. Only antigravity reads `effort`
|
|
45
|
+
// today (§143) — the other providers have no such flag, and inventing a
|
|
46
|
+
// mapping for them would be a coercion of intent.
|
|
47
|
+
opts: { effort?: LlmEffort } = {},
|
|
48
|
+
): LlmProvider {
|
|
39
49
|
switch (name) {
|
|
40
50
|
case "claude":
|
|
41
51
|
return new AnthropicProvider(model ?? DEFAULT_CLAUDE_MODEL);
|
|
@@ -48,7 +58,7 @@ export function createProvider(name: ProviderName, model?: string): LlmProvider
|
|
|
48
58
|
// Rides agy's cached subscription sign-in. No default model on purpose:
|
|
49
59
|
// the editorial tier runs whatever the user configured agy itself to
|
|
50
60
|
// use (FINDINGS §132, antigravity provider).
|
|
51
|
-
return new AntigravityProvider(model);
|
|
61
|
+
return new AntigravityProvider(model, undefined, { effort: opts.effort });
|
|
52
62
|
case "mock":
|
|
53
63
|
return new MockProvider();
|
|
54
64
|
}
|
|
@@ -80,6 +90,26 @@ export interface TieringOptions {
|
|
|
80
90
|
* tiering and sends everything to the editorial model.
|
|
81
91
|
*/
|
|
82
92
|
fastModel?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Provider to fall back to when the editorial call TIMES OUT (2026-08-22,
|
|
95
|
+
* FINDINGS §143). Honored only when the primary is antigravity — the one
|
|
96
|
+
* provider measured to hang on the real beat-sheet call. See
|
|
97
|
+
* `fallbackProviderName` for who is eligible.
|
|
98
|
+
*/
|
|
99
|
+
fallback?: ProviderName;
|
|
100
|
+
/**
|
|
101
|
+
* Fired once, before the fallback call — the caller announces it out loud.
|
|
102
|
+
* Silent substitution is the failure mode the fallback exists to avoid.
|
|
103
|
+
*/
|
|
104
|
+
onFallback?: (info: FallbackInfo) => void;
|
|
105
|
+
/**
|
|
106
|
+
* `agy --effort` for the EDITORIAL antigravity call only (§143: exposed
|
|
107
|
+
* after the hang incident — the knob existed and we passed nothing). The
|
|
108
|
+
* mechanical tier keeps agy's default (its small calls never hung), and the
|
|
109
|
+
* §143 fallback never sees it — it is a different provider, and the
|
|
110
|
+
* primary's effort level means nothing to it.
|
|
111
|
+
*/
|
|
112
|
+
effort?: LlmEffort;
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
/**
|
|
@@ -91,7 +121,16 @@ export function createTieredProvider(
|
|
|
91
121
|
name: ProviderName,
|
|
92
122
|
opts: TieringOptions = {},
|
|
93
123
|
): LlmProvider {
|
|
94
|
-
|
|
124
|
+
// The §143 effort knob rides the editorial call only — see TieringOptions.
|
|
125
|
+
let editorial = createProvider(name, opts.model, { effort: opts.effort });
|
|
126
|
+
// Timeout fallback (2026-08-22, FINDINGS §143): only the editorial tier
|
|
127
|
+
// wraps — the beat-sheet call is the one measured to outrun agy's print
|
|
128
|
+
// timeout; mechanical calls are small enough to finish. The fallback gets
|
|
129
|
+
// NO model override: the primary's model name means nothing to a different
|
|
130
|
+
// provider, so the fallback runs its own default.
|
|
131
|
+
if (name === "antigravity" && opts.fallback) {
|
|
132
|
+
editorial = new FallbackProvider(editorial, createProvider(opts.fallback), opts.onFallback);
|
|
133
|
+
}
|
|
95
134
|
const fast = opts.fastModel === "same" ? undefined : opts.fastModel ?? DEFAULT_FAST_MODEL[name];
|
|
96
135
|
if (!fast || fast === opts.model) return editorial;
|
|
97
136
|
return new TieredProvider(editorial, createProvider(name, fast));
|
|
@@ -132,6 +171,27 @@ export function defaultProviderName(
|
|
|
132
171
|
return "claude-cli";
|
|
133
172
|
}
|
|
134
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Where a timed-out antigravity run falls back to (2026-08-22, FINDINGS
|
|
176
|
+
* §143). Only antigravity gets one: it is the sole provider measured to hang
|
|
177
|
+
* persistently on the real beat-sheet call, and handing every provider a
|
|
178
|
+
* second choice would turn one measured incident into a general substitution
|
|
179
|
+
* policy. The order is `defaultProviderName`'s with agy removed — a logged-in
|
|
180
|
+
* claude CLI beats the gemini key for the same §132 subscription-first
|
|
181
|
+
* reasons — and the `hasBin` default of `() => false` keeps this pure the
|
|
182
|
+
* same way: callers that can see a filesystem inject a real checker.
|
|
183
|
+
*/
|
|
184
|
+
export function fallbackProviderName(
|
|
185
|
+
primary: ProviderName,
|
|
186
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
187
|
+
hasBin: (bin: string) => boolean = () => false,
|
|
188
|
+
): ProviderName | undefined {
|
|
189
|
+
if (primary !== "antigravity") return undefined;
|
|
190
|
+
if (hasBin(env.OSSCLIP_CLAUDE_BIN ?? "claude")) return "claude-cli";
|
|
191
|
+
if (env.GEMINI_API_KEY) return "gemini";
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
135
195
|
export interface ProduceScenesResult {
|
|
136
196
|
beatSheet: BeatSheet;
|
|
137
197
|
beatIssues: BeatsValidationIssue[];
|
package/src/producer/usage.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface LlmUsage {
|
|
|
45
45
|
billed: boolean;
|
|
46
46
|
/** Wall-clock milliseconds, so a slow call is visible beside a costly one. */
|
|
47
47
|
ms?: number;
|
|
48
|
+
/**
|
|
49
|
+
* True when this ATTEMPT did not produce the artifact (a timed-out or
|
|
50
|
+
* errored call, §143). The cost is real and stays in every usage report —
|
|
51
|
+
* the flag exists so ATTRIBUTION can skip it: a production.json stamp that
|
|
52
|
+
* listed the failed attempt's placeholder model read "planned by
|
|
53
|
+
* claude-cli (antigravity-default)" after a fallback run (2026-08-22).
|
|
54
|
+
*/
|
|
55
|
+
failed?: boolean;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export interface ModelPrice {
|
|
@@ -288,6 +296,20 @@ export function formatUsageReport(
|
|
|
288
296
|
if (records.length === 0) return "";
|
|
289
297
|
const t = summarizeUsage(records, pricing);
|
|
290
298
|
const first = records[0]!;
|
|
299
|
+
// After a §143 timeout fallback (2026-08-22) the records genuinely span two
|
|
300
|
+
// providers, and a header naming only records[0]'s would credit the whole
|
|
301
|
+
// run to the one that failed the editorial call. First-seen order joined
|
|
302
|
+
// " → " says who handed off to whom; the model suffix drops in that case
|
|
303
|
+
// because one model name would be attributed to calls another model made.
|
|
304
|
+
// Single-provider runs render byte-identically to before.
|
|
305
|
+
const providers: string[] = [];
|
|
306
|
+
for (const r of records) {
|
|
307
|
+
if (!providers.includes(r.provider)) providers.push(r.provider);
|
|
308
|
+
}
|
|
309
|
+
const who =
|
|
310
|
+
providers.length > 1
|
|
311
|
+
? providers.join(" → ")
|
|
312
|
+
: `${first.provider}${first.model ? ` · ${first.model}` : ""}`;
|
|
291
313
|
// A column of $0.0000 says nothing; an offline run's cost column is a dash.
|
|
292
314
|
const free = t.allUnbilled && t.equivalentUsd === 0;
|
|
293
315
|
const money = (v: number | null): string => (free ? "—" : v === null ? "?" : usd(v));
|
|
@@ -326,7 +348,7 @@ export function formatUsageReport(
|
|
|
326
348
|
notes.push(" Prices are the built-in per-family assumption; override in ~/.ossclip/config.json.");
|
|
327
349
|
}
|
|
328
350
|
return (
|
|
329
|
-
`\nllm usage (${
|
|
351
|
+
`\nllm usage (${who}` +
|
|
330
352
|
`${t.ms > 0 ? `, ${secs(t.ms)}` : ""}):\n` +
|
|
331
353
|
rows.join("\n") +
|
|
332
354
|
"\n" +
|
|
@@ -387,6 +409,9 @@ function modelsOfLog(log: Pick<UsageLog, "runs" | "records">): string[] {
|
|
|
387
409
|
}
|
|
388
410
|
const seen: string[] = [];
|
|
389
411
|
for (const r of log.records) {
|
|
412
|
+
// Failed attempts never contribute a model (§143) — same rule as the
|
|
413
|
+
// per-run list below.
|
|
414
|
+
if (r.failed) continue;
|
|
390
415
|
if (r.model && !seen.includes(r.model)) seen.push(r.model);
|
|
391
416
|
}
|
|
392
417
|
return seen;
|
|
@@ -404,6 +429,11 @@ export function appendUsageRun(
|
|
|
404
429
|
const cached = records.length === 0;
|
|
405
430
|
const models: string[] = [];
|
|
406
431
|
for (const r of records) {
|
|
432
|
+
// A run's model list answers "who made this", not "who was asked" — a
|
|
433
|
+
// failed attempt's model (the timed-out agy placeholder, §143) stays in
|
|
434
|
+
// the records and every cost report, but never in the attribution list a
|
|
435
|
+
// cached run inherits or the production stamp copies.
|
|
436
|
+
if (r.failed) continue;
|
|
407
437
|
if (r.model && !models.includes(r.model)) models.push(r.model);
|
|
408
438
|
}
|
|
409
439
|
// A cached run inherits the models it is REUSING the output of, exactly as
|
package/src/recut.ts
CHANGED
|
@@ -25,8 +25,13 @@ export interface RecutRemap {
|
|
|
25
25
|
* caption/overlay boundaries, so a value pushed onto a cut lands exactly
|
|
26
26
|
* where the timeline's own dead-region rendering shows the cut's edge.
|
|
27
27
|
* `label` is only for the report string — it carries no behavior.
|
|
28
|
+
*
|
|
29
|
+
* Exported since cut review step 4: `retimeForPreview` (retime-preview.ts)
|
|
30
|
+
* moves every output-timed render prop through THIS function so the editor's
|
|
31
|
+
* live post-veto preview and produce's own re-anchoring can never disagree
|
|
32
|
+
* about what "the same moment on the new clock" means.
|
|
28
33
|
*/
|
|
29
|
-
function remapPoint(
|
|
34
|
+
export function remapPoint(
|
|
30
35
|
label: string,
|
|
31
36
|
t: number,
|
|
32
37
|
oldMap: TimeMap,
|