@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
+ }
@@ -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
- * Gemini leads on measured evidence, not vendor preference: on the same clip
91
- * it ran 3,540 input tokens against the Claude CLI's 83,378 — the CLI re-sends
92
- * its whole harness prefix per invocation — for ~$0.05 against ~$0.85 and 27s
93
- * against 171s, with editorial output that held up. Both models recovered the
94
- * mishearing that matters ("coach and" → "code churn"); Claude is stronger only
95
- * at recovering a mangled PROPER NOUN, which `--speaker` addresses directly.
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
- * Falling back to the Claude Code CLI last keeps the no-keys-configured path
98
- * working on a Pro/Max subscription rather than failing.
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(env: NodeJS.ProcessEnv = process.env): ProviderName {
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";
@@ -39,4 +39,4 @@ export interface LlmProvider {
39
39
  }): Promise<T>;
40
40
  }
41
41
 
42
- export type ProviderName = "claude" | "claude-cli" | "gemini" | "mock";
42
+ export type ProviderName = "claude" | "claude-cli" | "gemini" | "antigravity" | "mock";