@ossclip/core 0.1.15 → 0.1.16
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/blooper.ts +13 -0
- package/src/producer/antigravity.ts +220 -0
- package/src/producer/index.ts +37 -9
- package/src/producer/provider.ts +1 -1
package/package.json
CHANGED
package/src/blooper.ts
CHANGED
|
@@ -92,12 +92,25 @@ function matchMarker(wordText: string, want: string): MarkerMatch | null {
|
|
|
92
92
|
if (!norm) return null;
|
|
93
93
|
if (norm === want) return { surface: norm, exact: true };
|
|
94
94
|
if (want.length < FUZZY_MIN_MARKER_LEN) return null;
|
|
95
|
+
// A plain English inflection of the marker is a REAL word the speaker can
|
|
96
|
+
// say on purpose — "it removes the bloopers", describing the feature, sits
|
|
97
|
+
// at distance 1 from a "blooper" marker and the fuzzy arm cut 7.08s of a
|
|
98
|
+
// good announce take back to its sentence start (FINDINGS §133). Fuzzy
|
|
99
|
+
// exists for ASR mishearings of the SPOKEN marker; an inflection is far
|
|
100
|
+
// more likely content, so it stays exact-only, in both directions.
|
|
101
|
+
if (isPluralPair(norm, want)) return null;
|
|
95
102
|
if (levenshtein(norm, want) <= FUZZY_MAX_DISTANCE) {
|
|
96
103
|
return { surface: norm, exact: false };
|
|
97
104
|
}
|
|
98
105
|
return null;
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
/** Whether one token is the plain s/es plural of the other. */
|
|
109
|
+
function isPluralPair(a: string, b: string): boolean {
|
|
110
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
111
|
+
return long === `${short}s` || long === `${short}es`;
|
|
112
|
+
}
|
|
113
|
+
|
|
101
114
|
/**
|
|
102
115
|
* Spans the speaker marked as bloopers.
|
|
103
116
|
*
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { run } from "../exec";
|
|
3
|
+
import type { LlmProvider } from "./provider";
|
|
4
|
+
import { estimateTokens, type LlmUsage } from "./usage";
|
|
5
|
+
// Shared fence-stripper for replies that wrap the JSON in prose/markdown.
|
|
6
|
+
// A formatting concern, not a Claude dependency (FINDINGS §132, antigravity
|
|
7
|
+
// provider).
|
|
8
|
+
import { extractJsonObject } from "./claude-cli";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* agy's own print timeout defaults to 5m, which a beat-sheet call on a long
|
|
12
|
+
* transcript can exceed. Core's `run()` has no timeout of its own, so this
|
|
13
|
+
* flag is the ONLY clock on the spawn — without it a stuck call is a hang,
|
|
14
|
+
* with it a timeout surfaces as retry-then-throw (FINDINGS §132, antigravity
|
|
15
|
+
* provider).
|
|
16
|
+
*/
|
|
17
|
+
export const AGY_PRINT_TIMEOUT = "10m";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* agy takes the prompt as an argv argument only — no stdin — and macOS caps
|
|
21
|
+
* ARG_MAX around 1MB. Refuse before the OS does: a pre-spawn check turns
|
|
22
|
+
* E2BIG into a directed error naming providers that can take the prompt
|
|
23
|
+
* (FINDINGS §132, antigravity provider).
|
|
24
|
+
*/
|
|
25
|
+
export const MAX_AGY_PROMPT_BYTES = 700_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The argv for one `agy` print-mode call. Pure so the flag set is testable
|
|
29
|
+
* without spawning anything. `--disable-slash-commands` because a transcript
|
|
30
|
+
* prompt that happens to start with `/` must not expand as a skill.
|
|
31
|
+
*/
|
|
32
|
+
export function buildAgyArgs(
|
|
33
|
+
prompt: string,
|
|
34
|
+
opts: { model?: string; schemaJson: string },
|
|
35
|
+
): string[] {
|
|
36
|
+
return [
|
|
37
|
+
"-p",
|
|
38
|
+
prompt,
|
|
39
|
+
"--output-format",
|
|
40
|
+
"json",
|
|
41
|
+
"--disable-slash-commands",
|
|
42
|
+
"--json-schema",
|
|
43
|
+
opts.schemaJson,
|
|
44
|
+
"--print-timeout",
|
|
45
|
+
AGY_PRINT_TIMEOUT,
|
|
46
|
+
...(opts.model ? ["--model", opts.model] : []),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The agy JSON envelope, tolerated rather than trusted: every field absent is
|
|
52
|
+
* a valid outcome — the caller falls back to estimates and says so — so this
|
|
53
|
+
* never throws on a shape it doesn't recognise (same contract as
|
|
54
|
+
* `parseCliEnvelope`).
|
|
55
|
+
*
|
|
56
|
+
* Token mapping into `LlmUsage` terms:
|
|
57
|
+
* - `outputTokens` folds `thinking_tokens` in: thinking is output-side
|
|
58
|
+
* spend and LlmUsage has no thinking field of its own.
|
|
59
|
+
* - Whether `input_tokens` already includes cache reads is not documented,
|
|
60
|
+
* so it is cross-checked against `total_tokens`: if
|
|
61
|
+
* input + output + thinking + cache_read exceeds the total, input is
|
|
62
|
+
* already cache-inclusive and stands alone; otherwise cache reads are
|
|
63
|
+
* added. Worst case is a conservative over-count — the safe direction
|
|
64
|
+
* for a number a user might budget against (FINDINGS §132, antigravity
|
|
65
|
+
* provider).
|
|
66
|
+
*/
|
|
67
|
+
export function parseAgyEnvelope(stdout: string): {
|
|
68
|
+
status?: string;
|
|
69
|
+
response?: string;
|
|
70
|
+
error?: string;
|
|
71
|
+
structuredOutput?: unknown;
|
|
72
|
+
inputTokens?: number;
|
|
73
|
+
outputTokens?: number;
|
|
74
|
+
cachedInputTokens?: number;
|
|
75
|
+
} {
|
|
76
|
+
let env: Record<string, unknown>;
|
|
77
|
+
try {
|
|
78
|
+
env = JSON.parse(stdout.trim()) as Record<string, unknown>;
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
if (!env || typeof env !== "object") return {};
|
|
83
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
84
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
85
|
+
const u = (env.usage ?? {}) as Record<string, unknown>;
|
|
86
|
+
const input = num(u.input_tokens);
|
|
87
|
+
const output = num(u.output_tokens);
|
|
88
|
+
const thinking = num(u.thinking_tokens) ?? 0;
|
|
89
|
+
const cacheRead = num(u.cache_read_tokens) ?? 0;
|
|
90
|
+
const total = num(u.total_tokens);
|
|
91
|
+
const cacheInclusive =
|
|
92
|
+
input !== undefined && total !== undefined
|
|
93
|
+
? input + (output ?? 0) + thinking + cacheRead > total
|
|
94
|
+
: false;
|
|
95
|
+
return {
|
|
96
|
+
status: str(env.status),
|
|
97
|
+
response: str(env.response),
|
|
98
|
+
error: str(env.error),
|
|
99
|
+
structuredOutput: env.structured_output,
|
|
100
|
+
inputTokens: input === undefined ? undefined : cacheInclusive ? input : input + cacheRead,
|
|
101
|
+
outputTokens: output === undefined ? undefined : output + thinking,
|
|
102
|
+
cachedInputTokens: cacheRead || undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Failures a retry cannot fix: missing auth and a bad model slug are
|
|
108
|
+
* deterministic, and each retry burns another ~24k-token baseline call (agy's
|
|
109
|
+
* own agent context) for nothing (FINDINGS §132, antigravity provider).
|
|
110
|
+
*/
|
|
111
|
+
export function isNonRetryableAgyFailure(message: string): boolean {
|
|
112
|
+
return /authentication|not logged in|login|unknown model|invalid model/i.test(message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Google Antigravity via the locally installed `agy` CLI. Uses whatever auth
|
|
117
|
+
* the CLI holds — a logged-in subscription, so producing a video consumes
|
|
118
|
+
* plan usage rather than pay-per-token API credits (`billed: false`, and agy
|
|
119
|
+
* reports no cost of its own).
|
|
120
|
+
*
|
|
121
|
+
* The schema rides twice: `--json-schema` for server-side enforcement AND
|
|
122
|
+
* stated in the prompt — the prompt copy is what makes the self-repair retry
|
|
123
|
+
* meaningful, and server enforcement is not our contract, so the reply is
|
|
124
|
+
* still zod-validated here.
|
|
125
|
+
*
|
|
126
|
+
* Requirements: `agy` on PATH (or OSSCLIP_AGY_BIN) and a prior interactive
|
|
127
|
+
* sign-in. The envelope carries no model id, so usage records the requested
|
|
128
|
+
* model or "antigravity-default" (FINDINGS §132, antigravity provider).
|
|
129
|
+
*/
|
|
130
|
+
export class AntigravityProvider implements LlmProvider {
|
|
131
|
+
readonly name = "antigravity";
|
|
132
|
+
readonly usage: LlmUsage[] = [];
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
private model?: string,
|
|
136
|
+
private bin: string = process.env.OSSCLIP_AGY_BIN ?? "agy",
|
|
137
|
+
) {}
|
|
138
|
+
|
|
139
|
+
async complete<T>(req: {
|
|
140
|
+
system: string;
|
|
141
|
+
user: string;
|
|
142
|
+
schema: z.ZodType<T>;
|
|
143
|
+
schemaName: string;
|
|
144
|
+
}): Promise<T> {
|
|
145
|
+
const schemaText = JSON.stringify(z.toJSONSchema(req.schema));
|
|
146
|
+
const base =
|
|
147
|
+
`${req.system}\n\n${req.user}\n\n` +
|
|
148
|
+
`Respond with ONLY a JSON object valid against this JSON Schema ("${req.schemaName}"). ` +
|
|
149
|
+
`No markdown fences, no commentary, no tool use — just the JSON:\n${schemaText}`;
|
|
150
|
+
|
|
151
|
+
let lastError = "";
|
|
152
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
153
|
+
const prompt =
|
|
154
|
+
attempt === 0
|
|
155
|
+
? base
|
|
156
|
+
: `${base}\n\nYour previous reply failed validation:\n${lastError}\nReturn ONLY the corrected JSON.`;
|
|
157
|
+
const promptBytes = Buffer.byteLength(prompt, "utf8");
|
|
158
|
+
if (promptBytes > MAX_AGY_PROMPT_BYTES) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`prompt is ${promptBytes.toLocaleString("en-US")} bytes, over the ${MAX_AGY_PROMPT_BYTES.toLocaleString("en-US")}-byte limit for agy — ` +
|
|
161
|
+
`it accepts the prompt only as a command-line argument. ` +
|
|
162
|
+
`Use --llm claude-cli or --llm gemini for a take this long.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
let stdout = "";
|
|
167
|
+
try {
|
|
168
|
+
({ stdout } = await run(
|
|
169
|
+
this.bin,
|
|
170
|
+
buildAgyArgs(prompt, { model: this.model, schemaJson: schemaText }),
|
|
171
|
+
));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
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
|
+
if (isNonRetryableAgyFailure(lastError)) break;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Recorded per ATTEMPT, before validation: a reply that failed the
|
|
180
|
+
// schema still spent the tokens, and a retry is exactly the cost a user
|
|
181
|
+
// would want to see rather than have quietly absorbed.
|
|
182
|
+
const envelope = parseAgyEnvelope(stdout);
|
|
183
|
+
this.usage.push({
|
|
184
|
+
provider: this.name,
|
|
185
|
+
// The envelope names no model, so record what was asked for — or the
|
|
186
|
+
// honest placeholder, which the cost report declines to price.
|
|
187
|
+
model: this.model ?? "antigravity-default",
|
|
188
|
+
schemaName: req.schemaName,
|
|
189
|
+
inputTokens: envelope.inputTokens ?? estimateTokens(prompt),
|
|
190
|
+
outputTokens: envelope.outputTokens ?? estimateTokens(envelope.response ?? stdout),
|
|
191
|
+
cachedInputTokens: envelope.cachedInputTokens,
|
|
192
|
+
exact: envelope.inputTokens !== undefined,
|
|
193
|
+
// The whole point of this provider: agy's cached sign-in means the
|
|
194
|
+
// subscription pays, not a card, and agy reports no cost to forward.
|
|
195
|
+
billed: false,
|
|
196
|
+
ms: Date.now() - started,
|
|
197
|
+
});
|
|
198
|
+
if (envelope.status !== "SUCCESS") {
|
|
199
|
+
lastError =
|
|
200
|
+
envelope.error ?? `agy reported status ${envelope.status ?? "unknown"}: ${stdout.slice(0, 300)}`;
|
|
201
|
+
if (isNonRetryableAgyFailure(lastError)) break;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
// Prefer the server-parsed object, but still validate it — schema
|
|
206
|
+
// enforcement on their side is not a contract on ours.
|
|
207
|
+
if (envelope.structuredOutput !== undefined) {
|
|
208
|
+
return req.schema.parse(envelope.structuredOutput);
|
|
209
|
+
}
|
|
210
|
+
return req.schema.parse(JSON.parse(extractJsonObject(envelope.response ?? stdout)));
|
|
211
|
+
} catch (err) {
|
|
212
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw new Error(
|
|
216
|
+
`agy CLI ('${this.bin}') did not produce valid ${req.schemaName} JSON: ${lastError.slice(0, 400)}\n` +
|
|
217
|
+
`Is Antigravity installed and logged in? (https://antigravity.google — run 'agy' once interactively to sign in)`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/producer/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
6
|
import { ClaudeCliProvider } from "./claude-cli";
|
|
6
7
|
import { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
7
8
|
import { MockProvider } from "./mock";
|
|
@@ -27,6 +28,7 @@ export * from "./beats";
|
|
|
27
28
|
export * from "./scene-props";
|
|
28
29
|
export * from "./repair";
|
|
29
30
|
export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
|
|
31
|
+
export { AntigravityProvider } from "./antigravity";
|
|
30
32
|
export { ClaudeCliProvider } from "./claude-cli";
|
|
31
33
|
export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
32
34
|
export { MockProvider } from "./mock";
|
|
@@ -41,6 +43,11 @@ export function createProvider(name: ProviderName, model?: string): LlmProvider
|
|
|
41
43
|
return new ClaudeCliProvider(model);
|
|
42
44
|
case "gemini":
|
|
43
45
|
return new GeminiProvider(model ?? DEFAULT_GEMINI_MODEL);
|
|
46
|
+
case "antigravity":
|
|
47
|
+
// Rides agy's cached subscription sign-in. No default model on purpose:
|
|
48
|
+
// the editorial tier runs whatever the user configured agy itself to
|
|
49
|
+
// use (FINDINGS §132, antigravity provider).
|
|
50
|
+
return new AntigravityProvider(model);
|
|
44
51
|
case "mock":
|
|
45
52
|
return new MockProvider();
|
|
46
53
|
}
|
|
@@ -57,6 +64,11 @@ export const DEFAULT_FAST_MODEL: Partial<Record<ProviderName, string>> = {
|
|
|
57
64
|
claude: "claude-haiku-4-5-20251001",
|
|
58
65
|
"claude-cli": "claude-haiku-4-5-20251001",
|
|
59
66
|
gemini: "gemini-3.5-flash-lite",
|
|
67
|
+
// Substring-matches the `gemini-3.6-flash` pricing family, so mechanical
|
|
68
|
+
// calls price from the existing table with no changes; the editorial tier
|
|
69
|
+
// (agy's own default, reported as "antigravity-default") matches nothing and
|
|
70
|
+
// reports "cost unknown" — the house honesty rule, not an oversight.
|
|
71
|
+
antigravity: "gemini-3.6-flash-low",
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
export interface TieringOptions {
|
|
@@ -87,17 +99,33 @@ export function createTieredProvider(
|
|
|
87
99
|
/**
|
|
88
100
|
* Default provider when --llm isn't given, in preference order.
|
|
89
101
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
102
|
+
* Subscription CLIs beat ambient env keys (2026-08 decision, FINDINGS §132,
|
|
103
|
+
* antigravity provider): a logged-in `agy` or `claude` is an explicit,
|
|
104
|
+
* already-paid choice the user made on this machine, while an API key in the
|
|
105
|
+
* environment may just be lying around — and picking the key spends real
|
|
106
|
+
* per-token money the subscription would have covered. agy carries the same
|
|
107
|
+
* ~24k-token harness baseline per call that claude-cli does, but it is
|
|
108
|
+
* subscription-covered, so the weight costs nothing.
|
|
96
109
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
110
|
+
* Among the keys, Gemini leads on measured evidence, not vendor preference: on
|
|
111
|
+
* the same clip it ran 3,540 input tokens against the Claude CLI's 83,378 —
|
|
112
|
+
* the CLI re-sends its whole harness prefix per invocation — for ~$0.05
|
|
113
|
+
* against ~$0.85 and 27s against 171s, with editorial output that held up.
|
|
114
|
+
* Both models recovered the mishearing that matters ("coach and" → "code
|
|
115
|
+
* churn"); Claude is stronger only at recovering a mangled PROPER NOUN, which
|
|
116
|
+
* `--speaker` addresses directly.
|
|
117
|
+
*
|
|
118
|
+
* Falling back to the Claude Code CLI last keeps the nothing-configured path
|
|
119
|
+
* failing with install guidance rather than silence. The `hasBin` default of
|
|
120
|
+
* `() => false` keeps this pure — callers that can see a filesystem (the CLI)
|
|
121
|
+
* inject a real checker; everyone else gets the key-order behavior unchanged.
|
|
99
122
|
*/
|
|
100
|
-
export function defaultProviderName(
|
|
123
|
+
export function defaultProviderName(
|
|
124
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
125
|
+
hasBin: (bin: string) => boolean = () => false,
|
|
126
|
+
): ProviderName {
|
|
127
|
+
if (hasBin(env.OSSCLIP_AGY_BIN ?? "agy")) return "antigravity";
|
|
128
|
+
if (hasBin(env.OSSCLIP_CLAUDE_BIN ?? "claude")) return "claude-cli";
|
|
101
129
|
if (env.GEMINI_API_KEY) return "gemini";
|
|
102
130
|
if (env.ANTHROPIC_API_KEY) return "claude";
|
|
103
131
|
return "claude-cli";
|
package/src/producer/provider.ts
CHANGED