@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/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -45,11 +45,72 @@ export {
|
|
|
45
45
|
// types only). The editor's load-path repair needs the MAP, not the raw span
|
|
46
46
|
// array, to decide whether a repair is possible at all — see
|
|
47
47
|
// `anchorCaptionLines` (§137): a non-empty array can still build an empty map.
|
|
48
|
-
|
|
48
|
+
// `mapsClose` rides along since cut review step 4: the editor's playhead
|
|
49
|
+
// hand-off across a live re-cut needs "did the clock actually change" to be
|
|
50
|
+
// the SAME float-tolerant comparison `livePreviewMap`'s identity gate uses,
|
|
51
|
+
// or a 1-ulp drift could seek the player for nothing.
|
|
52
|
+
export { mapFromKeptSpans, mapsClose, TimeMap, type KeptSpan } from "./timemap";
|
|
49
53
|
// The §35 cover word cap. The editor's CoverPanel shows the trimmed headline
|
|
50
54
|
// live as you type, and restating the trimming rules there would drift from
|
|
51
55
|
// the one the regenerate endpoint actually renders with. Imported from
|
|
52
56
|
// ./cover-headline, NOT ./cover — that module is node all the way down
|
|
53
57
|
// (node:fs, ./exec), which is exactly what this surface exists to keep out.
|
|
54
58
|
export { COVER_MAX_WORDS, coverHeadline } from "./cover-headline";
|
|
55
|
-
|
|
59
|
+
// The cleanup veto layer (cut review step 3), VALUE exports and browser-safe:
|
|
60
|
+
// cutlist.ts imports nothing but types from ./schema — verified before this
|
|
61
|
+
// export, zero node built-ins in its graph. The editor marks vetoed seams
|
|
62
|
+
// with the SAME `applyCleanupChoices` produce renders with (the
|
|
63
|
+
// buildCoverRender one-implementation-two-callers pattern); a browser copy is
|
|
64
|
+
// how the preview and the render would drift. `buildCutlist` itself rides
|
|
65
|
+
// along in the module graph but stays unexported here on purpose — the
|
|
66
|
+
// editor must never rebuild the proposal, only apply choices to the one
|
|
67
|
+
// produce recorded.
|
|
68
|
+
export {
|
|
69
|
+
applyCleanupChoices,
|
|
70
|
+
cleanupVetoable,
|
|
71
|
+
vetoedRemovals,
|
|
72
|
+
type CleanupChoices,
|
|
73
|
+
} from "./cutlist";
|
|
74
|
+
// The live post-veto preview (cut review step 4), VALUE exports and
|
|
75
|
+
// browser-safe: retime-preview.ts composes cutlist + recut + timemap — all
|
|
76
|
+
// already in this surface's runtime graph (recut.ts imports only overrides
|
|
77
|
+
// and timemap, zero node built-ins, verified before this export). The editor
|
|
78
|
+
// re-cuts its preview clock with the SAME `applyCleanupChoices` +
|
|
79
|
+
// `subtractRangesFromCutlist` sequence produce runs and re-times every prop
|
|
80
|
+
// through the SAME `remapPoint` produce re-anchors with — one implementation,
|
|
81
|
+
// two callers, so the preview cannot drift from the render.
|
|
82
|
+
// `previewClockMappers` rides along (step 4 follow-up): the surfaces that
|
|
83
|
+
// speak in single instants — transcript seeks, ghost bands, the cover
|
|
84
|
+
// panel's playhead — need the same walk as a point function, identity when
|
|
85
|
+
// no re-cut is live, threaded by App so no consumer learns the machinery.
|
|
86
|
+
// `cutRangeToOldClock` is the WRITE direction's range half (the follow-up's
|
|
87
|
+
// follow-up): a cut gesture's live window converted to the old-clock frame
|
|
88
|
+
// `doc.cuts` speaks, shared by the Inspector's button and App's delete
|
|
89
|
+
// modal so the shrink/refuse verdict cannot drift between the two.
|
|
90
|
+
export {
|
|
91
|
+
cutRangeToOldClock,
|
|
92
|
+
livePreviewMap,
|
|
93
|
+
previewClockMappers,
|
|
94
|
+
retimeForPreview,
|
|
95
|
+
type LivePreviewClocks,
|
|
96
|
+
type OldClockCutRange,
|
|
97
|
+
type PreviewClockMappers,
|
|
98
|
+
type RetimeablePreviewProps,
|
|
99
|
+
type RetimedPreviewFields,
|
|
100
|
+
} from "./retime-preview";
|
|
101
|
+
export type {
|
|
102
|
+
Probe,
|
|
103
|
+
Production,
|
|
104
|
+
// `RemovalReason` rides along with `Segment` (cut review step 2): the
|
|
105
|
+
// editor's reason→colour map is a `Record<RemovalReason, string>` precisely
|
|
106
|
+
// so a NEW reason in the vocabulary fails typecheck in the editor instead
|
|
107
|
+
// of silently drawing an uncoloured seam. (Since step 3 the schema module
|
|
108
|
+
// is in the runtime graph anyway — ./overrides imports RemovalReasonSchema
|
|
109
|
+
// for the `cleanup` key — but schema.ts is zod + scene-schema only, both
|
|
110
|
+
// already on this surface, so it stays browser-safe.)
|
|
111
|
+
RemovalReason,
|
|
112
|
+
RenderSettings,
|
|
113
|
+
Segment,
|
|
114
|
+
Transcript,
|
|
115
|
+
Word,
|
|
116
|
+
} from "./schema";
|
package/src/config.ts
CHANGED
|
@@ -19,6 +19,16 @@ export interface OssclipConfig {
|
|
|
19
19
|
* sheet always uses the main model. "same" disables tiering (FINDINGS §37).
|
|
20
20
|
*/
|
|
21
21
|
fastModel?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Reasoning effort for the antigravity provider — low | medium | high,
|
|
24
|
+
* agy's own `--effort` vocabulary. Consumed ONLY by antigravity today
|
|
25
|
+
* (§143: exposed after the hang incident — the knob existed and we passed
|
|
26
|
+
* nothing); every other provider ignores it. `--llm-effort` wins over this
|
|
27
|
+
* per run. File-only like `dictionary`; validated at the consumer
|
|
28
|
+
* (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` is one
|
|
29
|
+
* warning and an ignored key, never a coerced effort level.
|
|
30
|
+
*/
|
|
31
|
+
llmEffort?: string;
|
|
22
32
|
/**
|
|
23
33
|
* Download URLs for models the ggerganov mirror doesn't host, keyed by the
|
|
24
34
|
* bare model name — a user's own fine-tune needs one line:
|
|
@@ -196,6 +206,11 @@ export function loadConfig(): OssclipConfig {
|
|
|
196
206
|
modelDir: process.env.OSSCLIP_MODEL_DIR ?? fileCfg.modelDir ?? DEFAULTS.modelDir,
|
|
197
207
|
model: process.env.OSSCLIP_MODEL ?? fileCfg.model ?? DEFAULTS.model,
|
|
198
208
|
fastModel: process.env.OSSCLIP_FAST_MODEL ?? fileCfg.fastModel,
|
|
209
|
+
// File-only, the `dictionary` posture — and deliberately NO env spelling
|
|
210
|
+
// (flag + config are the whole interface): validated where it is USED
|
|
211
|
+
// (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` earns one
|
|
212
|
+
// warning there and agy's default, never a coerced effort.
|
|
213
|
+
llmEffort: fileCfg.llmEffort,
|
|
199
214
|
speaker: process.env.OSSCLIP_SPEAKER ?? fileCfg.speaker,
|
|
200
215
|
openEditorAfterProduce: (process.env.OSSCLIP_OPEN_EDITOR ??
|
|
201
216
|
fileCfg.openEditorAfterProduce) as OpenEditorPref | undefined,
|
package/src/cover-headline.ts
CHANGED
|
@@ -26,10 +26,17 @@
|
|
|
26
26
|
*/
|
|
27
27
|
export const COVER_MAX_WORDS = 9;
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Trailing words that cannot end a headline — the truncation reads as broken.
|
|
31
|
+
* Auxiliaries dangle exactly like prepositions: a real run (2026-08-22)
|
|
32
|
+
* truncated to "AI Gave Me Too Many Ideas. I Had" because the set had none.
|
|
33
|
+
* "i" is here for the same incident: popping "Had" alone leaves "…Ideas. I",
|
|
34
|
+
* a subject with its sentence cut off.
|
|
35
|
+
*/
|
|
30
36
|
const DANGLING = new Set([
|
|
31
37
|
"a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "is", "it",
|
|
32
38
|
"of", "on", "or", "the", "to", "with", "that", "this", "my", "your", "so",
|
|
39
|
+
"had", "has", "have", "was", "were", "will", "can", "should", "i",
|
|
33
40
|
]);
|
|
34
41
|
|
|
35
42
|
/**
|
package/src/cutlist.ts
CHANGED
|
@@ -329,3 +329,112 @@ export function buildCutlist({
|
|
|
329
329
|
|
|
330
330
|
return segments;
|
|
331
331
|
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The user's veto layer over the automatic cutlist (cut review step 3):
|
|
335
|
+
* `buildCutlist` PROPOSES removals, this is how the user DECLINES some of
|
|
336
|
+
* them — per category ("keep all pauses") and per individual span. Persisted
|
|
337
|
+
* as `overrides.json`'s `cleanup` key (`OverrideDocSchema.cleanup`, which
|
|
338
|
+
* owns the on-disk contract); this structural interface is what the pure
|
|
339
|
+
* functions below accept, so this module stays free of zod and of the
|
|
340
|
+
* overrides module — callable from produce AND from the editor bundle with
|
|
341
|
+
* nothing but the schema's own types.
|
|
342
|
+
*/
|
|
343
|
+
export interface CleanupChoices {
|
|
344
|
+
/**
|
|
345
|
+
* Category master switches. `false` = do not remove this reason's spans;
|
|
346
|
+
* absent (or a tolerated `true` on disk) = the default, remove as proposed.
|
|
347
|
+
*/
|
|
348
|
+
reasons?: Partial<Record<RemovalReason, boolean>>;
|
|
349
|
+
/** Individual vetoes, SOURCE seconds — see `vetoedRemovals` for the
|
|
350
|
+
* overlap-based matching rule. */
|
|
351
|
+
kept?: readonly { srcIn: number; srcOut: number }[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Whether a removal reason CAN be declined at all. `user` cuts come from the
|
|
356
|
+
* `cuts[]` array — declining your own cut is Restore on the cut, an existing
|
|
357
|
+
* gesture, and a second way to undo it would leave the entry behind as dead
|
|
358
|
+
* weight (there is no "not cut" state for a `cuts` entry to hold, per its
|
|
359
|
+
* schema comment). `clip` is the `--clip` window, a selection decision, not a
|
|
360
|
+
* cleanup — "keeping" a clip removal would silently un-clip the video. One
|
|
361
|
+
* predicate, exported, so produce, the panel's checkboxes and the timeline's
|
|
362
|
+
* seam handler cannot disagree about what is toggleable.
|
|
363
|
+
*/
|
|
364
|
+
export function cleanupVetoable(reason: RemovalReason | undefined): boolean {
|
|
365
|
+
return reason !== "user" && reason !== "clip";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The `remove` spans of `cutlist` that `choices` declines — the shared
|
|
370
|
+
* predicate under `applyCleanupChoices` (which re-keeps exactly these) and
|
|
371
|
+
* the editor's vetoed-seam state / produce's "kept N pause removal(s)" line
|
|
372
|
+
* (which both NAME them). One list, computed once, so what the seam shows as
|
|
373
|
+
* "will be kept" and what the render actually keeps cannot drift.
|
|
374
|
+
*
|
|
375
|
+
* Individual vetoes match by OVERLAP, deliberately NOT by float equality of
|
|
376
|
+
* endpoints: a re-produce can shift a removal's boundary by a frame (a
|
|
377
|
+
* changed silence threshold, a repair that re-stamps a word), and an
|
|
378
|
+
* exact-match veto that silently stops matching is the failure mode — the
|
|
379
|
+
* pause the user declined would quietly start being cut again. A partial
|
|
380
|
+
* overlap re-keeps the WHOLE removal span: a removal is one decision, not
|
|
381
|
+
* divisible.
|
|
382
|
+
*/
|
|
383
|
+
export function vetoedRemovals(
|
|
384
|
+
cutlist: readonly Segment[],
|
|
385
|
+
choices: CleanupChoices | undefined,
|
|
386
|
+
): Segment[] {
|
|
387
|
+
if (!choices) return [];
|
|
388
|
+
const kept = choices.kept ?? [];
|
|
389
|
+
return cutlist.filter(
|
|
390
|
+
(seg) =>
|
|
391
|
+
seg.kind === "remove" &&
|
|
392
|
+
cleanupVetoable(seg.reason) &&
|
|
393
|
+
((seg.reason !== undefined && choices.reasons?.[seg.reason] === false) ||
|
|
394
|
+
kept.some((k) => k.srcIn < seg.srcOut && k.srcOut > seg.srcIn)),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Apply the user's cleanup choices to the automatic cutlist: every vetoed
|
|
400
|
+
* removal (see `vetoedRemovals`) becomes a keep again, and adjacent keeps
|
|
401
|
+
* merge so the result stays the same canonical shape `buildCutlist` emits —
|
|
402
|
+
* a full partition of the input's range with no gaps, no overlaps, monotonic
|
|
403
|
+
* (the invariants `TimeMap`'s constructor checks). The partition holds BY
|
|
404
|
+
* CONSTRUCTION: spans are only relabelled and merged, never moved, so the
|
|
405
|
+
* covered range cannot change.
|
|
406
|
+
*
|
|
407
|
+
* `undefined`/empty choices return the input content unchanged — the
|
|
408
|
+
* regression anchor: an overrides.json with no `cleanup` key must produce a
|
|
409
|
+
* byte-identical render.
|
|
410
|
+
*
|
|
411
|
+
* ONE implementation, two callers (the `buildCoverRender` pattern): produce
|
|
412
|
+
* calls this between `buildCutlist` and the `TimeMap`; the editor calls it
|
|
413
|
+
* (via `@ossclip/core/browser`) to mark vetoed seams. A preview that
|
|
414
|
+
* disagrees with the render is worse than no preview.
|
|
415
|
+
*/
|
|
416
|
+
export function applyCleanupChoices(
|
|
417
|
+
cutlist: readonly Segment[],
|
|
418
|
+
choices: CleanupChoices | undefined,
|
|
419
|
+
): Segment[] {
|
|
420
|
+
const vetoed = new Set(vetoedRemovals(cutlist, choices));
|
|
421
|
+
if (vetoed.size === 0) return [...cutlist];
|
|
422
|
+
const out: Segment[] = [];
|
|
423
|
+
for (const seg of cutlist) {
|
|
424
|
+
// A re-kept span drops `reason`/`confidence` — they described a removal
|
|
425
|
+
// that is no longer happening, and a keep carrying "pause" would read as
|
|
426
|
+
// a seventh segment kind everywhere the partition is consumed.
|
|
427
|
+
const next: Segment = vetoed.has(seg)
|
|
428
|
+
? { srcIn: seg.srcIn, srcOut: seg.srcOut, kind: "keep" }
|
|
429
|
+
: seg;
|
|
430
|
+
const prev = out[out.length - 1];
|
|
431
|
+
if (prev !== undefined && prev.kind === "keep" && next.kind === "keep") {
|
|
432
|
+
// Merge in place — `prev` is always this function's own object (pushed
|
|
433
|
+
// below as a fresh literal when it is a keep), never the caller's.
|
|
434
|
+
prev.srcOut = next.srcOut;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
out.push(next.kind === "keep" ? { srcIn: next.srcIn, srcOut: next.srcOut, kind: "keep" } : next);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
package/src/index.ts
CHANGED
package/src/overrides.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SceneComponentId,
|
|
8
8
|
type Theme,
|
|
9
9
|
} from "./scene-schema";
|
|
10
|
+
import { RemovalReasonSchema } from "./schema";
|
|
10
11
|
import { resolveSceneProps } from "./scene-registry";
|
|
11
12
|
import type { CaptionLine, CaptionWord } from "./captions";
|
|
12
13
|
|
|
@@ -435,6 +436,42 @@ export const OverrideDocSchema = z.object({
|
|
|
435
436
|
}),
|
|
436
437
|
)
|
|
437
438
|
.default([]),
|
|
439
|
+
/**
|
|
440
|
+
* The user's VETO over the automatic cutlist (cut review step 3). `cuts`
|
|
441
|
+
* above is the user ADDING a removal; this is the user DECLINING one the
|
|
442
|
+
* pipeline proposed — opposite directions, deliberately not merged.
|
|
443
|
+
* Consumed by `applyCleanupChoices` (cutlist.ts, which owns the matching
|
|
444
|
+
* semantics), in produce and in the editor alike.
|
|
445
|
+
*
|
|
446
|
+
* `reasons` are the category master switches ("keep all pauses"). Only
|
|
447
|
+
* `false` is ever WRITTEN — a `true` entry restates the default, and the
|
|
448
|
+
* editor DELETES the key instead (the `hidden`/`captionsHidden` rule: an
|
|
449
|
+
* override with nothing to say). A `true` on disk is still parsed and
|
|
450
|
+
* means default, tolerantly. `user` and `clip` keys parse but are inert
|
|
451
|
+
* (`cleanupVetoable`): declining your own cut is Restore on the cut, and
|
|
452
|
+
* "keeping" the --clip window's removal would silently un-clip the video.
|
|
453
|
+
*
|
|
454
|
+
* `kept` are individual vetoes, in SOURCE seconds — and that anchoring is
|
|
455
|
+
* the whole trick, same as `cuts[].src` above: a bare output-seconds pair
|
|
456
|
+
* is meaningless once its own re-cut has happened, while source time is
|
|
457
|
+
* stable across every re-cut. This layer is therefore RECUT-IMMUNE BY
|
|
458
|
+
* CONSTRUCTION and — unlike `splits` and `scenes[*].timing` — needs NO
|
|
459
|
+
* entry in `remapOverridesThroughRecut`. Matching against the (possibly
|
|
460
|
+
* re-produced) cutlist is by OVERLAP, never float equality of endpoints;
|
|
461
|
+
* `vetoedRemovals` (cutlist.ts) states why.
|
|
462
|
+
*
|
|
463
|
+
* Optional-with-default like `splits`/`cuts`, so every overrides.json
|
|
464
|
+
* written before the key existed parses byte-identically; absent means
|
|
465
|
+
* today's behaviour exactly.
|
|
466
|
+
*/
|
|
467
|
+
cleanup: z
|
|
468
|
+
.object({
|
|
469
|
+
reasons: z.partialRecord(RemovalReasonSchema, z.boolean()).default({}),
|
|
470
|
+
kept: z
|
|
471
|
+
.array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
|
|
472
|
+
.default([]),
|
|
473
|
+
})
|
|
474
|
+
.default({ reasons: {}, kept: [] }),
|
|
438
475
|
});
|
|
439
476
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
440
477
|
|
|
@@ -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.
|
|
@@ -24,6 +25,14 @@ export const AGY_PRINT_TIMEOUT = "10m";
|
|
|
24
25
|
*/
|
|
25
26
|
export const MAX_AGY_PROMPT_BYTES = 700_000;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* agy's `--effort` levels. Exposed after the §143 hang incident (2026-08-22):
|
|
30
|
+
* untested at real scale whether a lower effort moves the hang, but the knob
|
|
31
|
+
* existed and we passed nothing — every call ran at agy's default with no way
|
|
32
|
+
* to try anything else.
|
|
33
|
+
*/
|
|
34
|
+
export type LlmEffort = "low" | "medium" | "high";
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
37
|
* The argv for one `agy` print-mode call. Pure so the flag set is testable
|
|
29
38
|
* without spawning anything. `--disable-slash-commands` because a transcript
|
|
@@ -31,7 +40,7 @@ export const MAX_AGY_PROMPT_BYTES = 700_000;
|
|
|
31
40
|
*/
|
|
32
41
|
export function buildAgyArgs(
|
|
33
42
|
prompt: string,
|
|
34
|
-
opts: { model?: string; schemaJson: string },
|
|
43
|
+
opts: { model?: string; effort?: LlmEffort; schemaJson: string },
|
|
35
44
|
): string[] {
|
|
36
45
|
return [
|
|
37
46
|
"-p",
|
|
@@ -44,6 +53,9 @@ export function buildAgyArgs(
|
|
|
44
53
|
"--print-timeout",
|
|
45
54
|
AGY_PRINT_TIMEOUT,
|
|
46
55
|
...(opts.model ? ["--model", opts.model] : []),
|
|
56
|
+
// Omitted entirely when unset — agy's own default stands, exactly as it
|
|
57
|
+
// did before the knob existed (§143).
|
|
58
|
+
...(opts.effort ? ["--effort", opts.effort] : []),
|
|
47
59
|
];
|
|
48
60
|
}
|
|
49
61
|
|
|
@@ -107,11 +119,159 @@ export function parseAgyEnvelope(stdout: string): {
|
|
|
107
119
|
* Failures a retry cannot fix: missing auth and a bad model slug are
|
|
108
120
|
* deterministic, and each retry burns another ~24k-token baseline call (agy's
|
|
109
121
|
* own agent context) for nothing (FINDINGS §132, antigravity provider).
|
|
122
|
+
*
|
|
123
|
+
* The patterns were always right; the INPUT was wrong. Until 2026-08-22 this
|
|
124
|
+
* was handed `run()`'s rejection, and the contract §132 claimed for it — "the
|
|
125
|
+
* stderr tail is embedded, so auth/bad-slug are matchable here" — is false for
|
|
126
|
+
* real agy: a bad slug exits 1 with `invalid model selection …` in the STDOUT
|
|
127
|
+
* envelope and NOTHING on stderr (measured, 1.1.18). So the message this saw
|
|
128
|
+
* was our own echoed argv, which contains the slug but never the words
|
|
129
|
+
* "invalid model", and the documented fail-fast has never once fired in the
|
|
130
|
+
* field. It is now given `agyErrorText()` — the envelope's own error — which
|
|
131
|
+
* matches the shipped patterns as measured, with no pattern change at all.
|
|
132
|
+
*
|
|
133
|
+
* That is a real behaviour change (a bad slug now costs one call, not two),
|
|
134
|
+
* taken deliberately: it makes the code do what this comment has always said
|
|
135
|
+
* it does, rather than changing what it should do.
|
|
110
136
|
*/
|
|
111
137
|
export function isNonRetryableAgyFailure(message: string): boolean {
|
|
112
138
|
return /authentication|not logged in|login|unknown model|invalid model/i.test(message);
|
|
113
139
|
}
|
|
114
140
|
|
|
141
|
+
/**
|
|
142
|
+
* What actually went wrong, out of the two surfaces agy uses — measured
|
|
143
|
+
* against agy 1.1.18 on 2026-08-22, because none of it is documented:
|
|
144
|
+
*
|
|
145
|
+
* print timeout fires exit 1 stdout `{"status":"ERROR","error":"timeout
|
|
146
|
+
* waiting for response",…}` stderr empty
|
|
147
|
+
* unknown model slug exit 1 stdout `{"status":"ERROR","error":"invalid
|
|
148
|
+
* model selection (--model …)…"}` stderr empty
|
|
149
|
+
* bad flag / usage exit 2 stdout empty stderr usage text
|
|
150
|
+
*
|
|
151
|
+
* So the operational failures speak through the ENVELOPE and the process-level
|
|
152
|
+
* ones through stderr, and a reader of only one surface is blind to half of
|
|
153
|
+
* them. Preference order follows that: the envelope's own `error` first (it is
|
|
154
|
+
* agy's sentence about its own failure), then stderr for the exits that never
|
|
155
|
+
* reached the envelope, then the bare status, then whatever stdout held.
|
|
156
|
+
*
|
|
157
|
+
* Pure, and exported so the surfaces are testable without spawning agy. agy's
|
|
158
|
+
* wording is not a contract we control — only the two surfaces are — which is
|
|
159
|
+
* why callers classify loosely and print the raw text either way.
|
|
160
|
+
*/
|
|
161
|
+
export function agyErrorText(stdout: string, stderr: string): string {
|
|
162
|
+
const env = parseAgyEnvelope(stdout);
|
|
163
|
+
if (env.error?.trim()) return env.error.trim();
|
|
164
|
+
if (stderr.trim()) return stderr.trim().slice(-2000);
|
|
165
|
+
if (env.status) return `agy reported status ${env.status}`;
|
|
166
|
+
if (stdout.trim()) return `agy replied with no envelope: ${stdout.trim().slice(0, 300)}`;
|
|
167
|
+
return "agy printed nothing";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What the failure text says actually went wrong. See `classifyAgyFailure`. */
|
|
171
|
+
export type AgyFailureClass = "auth" | "model" | "timeout" | "schema" | "unknown";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify a failure so the error can say what happened instead of guessing.
|
|
175
|
+
* Pure and exported so every class is assertable without spawning agy. The
|
|
176
|
+
* input is `agyErrorText()` — agy's own sentence — not `run()`'s rejection.
|
|
177
|
+
*
|
|
178
|
+
* The incident (2026-08-22, FINDINGS §132): a `--produce --aspect 16:9` run on
|
|
179
|
+
* an 11-minute take timed out twice at AGY_PRINT_TIMEOUT — 10m each, 25
|
|
180
|
+
* minutes burned — and then died with "Is Antigravity installed and logged
|
|
181
|
+
* in?", while agy was installed, logged in and working. The hint was appended
|
|
182
|
+
* unconditionally, so every failure was reported as an auth failure and this
|
|
183
|
+
* one sent the user to debug auth after a 25-minute wait.
|
|
184
|
+
*
|
|
185
|
+
* Anchors, and how much each is worth:
|
|
186
|
+
* - `timeout` and `model` are MEASURED (agy 1.1.18): "timeout waiting for
|
|
187
|
+
* response" and "invalid model selection (--model …)". The looser
|
|
188
|
+
* alternatives stay beside them because the exact strings are agy's to
|
|
189
|
+
* change without telling us — and because an externally killed agy (a
|
|
190
|
+
* signal, not agy's own clock) surfaces differently again.
|
|
191
|
+
* - `auth` is INFERRED, not measured: establishing it would mean signing the
|
|
192
|
+
* user out. Its patterns are exactly the ones `isNonRetryableAgyFailure`
|
|
193
|
+
* has always used, so an auth failure can never classify worse than it did
|
|
194
|
+
* before this change, and both measured samples put operational reasons in
|
|
195
|
+
* the same envelope field, so there is no reason to expect auth elsewhere.
|
|
196
|
+
* - `schema` matches what OUR OWN code leaves in `lastError` — `extractJson-
|
|
197
|
+
* Object`'s message, JSON.parse's, and zod's issue array.
|
|
198
|
+
*
|
|
199
|
+
* A miss costs the headline and the class-gated advice, never the facts: the
|
|
200
|
+
* attempt line below prints for every class, and it is what actually
|
|
201
|
+
* self-diagnoses a hang.
|
|
202
|
+
*/
|
|
203
|
+
export function classifyAgyFailure(message: string): AgyFailureClass {
|
|
204
|
+
if (/authentication|not logged in|login/i.test(message)) return "auth";
|
|
205
|
+
if (/unknown model|invalid model/i.test(message)) return "model";
|
|
206
|
+
if (/timed? ?out|ETIMEDOUT|SIGTERM|SIGKILL/i.test(message)) return "timeout";
|
|
207
|
+
const schemaish = /no JSON object in reply|is not valid JSON|Unexpected (token|end of)|"code":\s*"/i;
|
|
208
|
+
if (schemaish.test(message)) return "schema";
|
|
209
|
+
return "unknown";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The error thrown when every attempt failed. Pure so the wording is testable
|
|
214
|
+
* without spawning agy, and built around one rule learned from the 2026-08-22
|
|
215
|
+
* incident: the ATTEMPT FACTS print for every class, because they self-diagnose
|
|
216
|
+
* regardless of what the classifier decided. "2 attempts, 10m0s and 10m0s,
|
|
217
|
+
* --print-timeout 10m" tells a user the call hung better than any guess we
|
|
218
|
+
* could make, and it stays true when the guess is wrong.
|
|
219
|
+
*
|
|
220
|
+
* Guidance, by contrast, is class-gated: the sign-in hint only for `auth`, and
|
|
221
|
+
* for `timeout` the actual escape hatch — another provider, or no planner at
|
|
222
|
+
* all — named the way the oversized-prompt error above names them.
|
|
223
|
+
*/
|
|
224
|
+
export function agyFailureMessage(parts: {
|
|
225
|
+
bin: string;
|
|
226
|
+
schemaName: string;
|
|
227
|
+
lastError: string;
|
|
228
|
+
/** Wall time of every attempt that ran, in order. */
|
|
229
|
+
attemptMs: readonly number[];
|
|
230
|
+
printTimeout: string;
|
|
231
|
+
}): string {
|
|
232
|
+
const cls = classifyAgyFailure(parts.lastError);
|
|
233
|
+
const headline =
|
|
234
|
+
cls === "timeout"
|
|
235
|
+
? " — the call timed out"
|
|
236
|
+
: cls === "schema"
|
|
237
|
+
? " — the reply never matched the schema"
|
|
238
|
+
: "";
|
|
239
|
+
const detail = parts.lastError.trim().slice(0, 400) || "agy printed nothing";
|
|
240
|
+
const lines = [
|
|
241
|
+
`agy CLI ('${parts.bin}') did not produce valid ${parts.schemaName} JSON${headline}: ${detail}`,
|
|
242
|
+
attemptFactsLine(parts.attemptMs, `--print-timeout ${parts.printTimeout}`),
|
|
243
|
+
];
|
|
244
|
+
if (cls === "auth") {
|
|
245
|
+
lines.push(
|
|
246
|
+
`Is Antigravity installed and logged in? (https://antigravity.google — run 'agy' once interactively to sign in)`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (cls === "timeout") {
|
|
250
|
+
lines.push(
|
|
251
|
+
`A long take can outrun agy's ${parts.printTimeout} print timeout. ` +
|
|
252
|
+
`Use --llm claude-cli (a logged-in Claude Code subscription) or --llm gemini (needs GEMINI_API_KEY), ` +
|
|
253
|
+
`or drop --produce to cut and caption without a planner.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The provider's terminal error, carrying the failure class as DATA. The
|
|
261
|
+
* message is written for humans and free to reword; a provider-fallback
|
|
262
|
+
* decorator that branches on what failed must read `failureClass`, never
|
|
263
|
+
* re-parse the prose.
|
|
264
|
+
*/
|
|
265
|
+
export class AgyError extends Error {
|
|
266
|
+
constructor(
|
|
267
|
+
message: string,
|
|
268
|
+
readonly failureClass: AgyFailureClass,
|
|
269
|
+
) {
|
|
270
|
+
super(message);
|
|
271
|
+
this.name = "AgyError";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
115
275
|
/**
|
|
116
276
|
* Google Antigravity via the locally installed `agy` CLI. Uses whatever auth
|
|
117
277
|
* the CLI holds — a logged-in subscription, so producing a video consumes
|
|
@@ -131,9 +291,12 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
131
291
|
readonly name = "antigravity";
|
|
132
292
|
readonly usage: LlmUsage[] = [];
|
|
133
293
|
|
|
294
|
+
// A trailing options bag rather than a fourth positional: every existing
|
|
295
|
+
// `new AntigravityProvider(model, bin)` call site keeps compiling unchanged.
|
|
134
296
|
constructor(
|
|
135
297
|
private model?: string,
|
|
136
298
|
private bin: string = process.env.OSSCLIP_AGY_BIN ?? "agy",
|
|
299
|
+
private opts: { effort?: LlmEffort } = {},
|
|
137
300
|
) {}
|
|
138
301
|
|
|
139
302
|
async complete<T>(req: {
|
|
@@ -149,6 +312,11 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
149
312
|
`No markdown fences, no commentary, no tool use — just the JSON:\n${schemaText}`;
|
|
150
313
|
|
|
151
314
|
let lastError = "";
|
|
315
|
+
// Wall time of every attempt that FAILED, in order — the facts the final
|
|
316
|
+
// error reports. Kept separately from `usage`, which only records attempts
|
|
317
|
+
// that got as far as an envelope: a call killed by --print-timeout never
|
|
318
|
+
// does, and a 10-minute hang is exactly the attempt a user needs to see.
|
|
319
|
+
const attemptMs: number[] = [];
|
|
152
320
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
153
321
|
const prompt =
|
|
154
322
|
attempt === 0
|
|
@@ -164,41 +332,78 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
164
332
|
}
|
|
165
333
|
const started = Date.now();
|
|
166
334
|
let stdout = "";
|
|
335
|
+
let stderr = "";
|
|
167
336
|
try {
|
|
168
|
-
|
|
337
|
+
// allowNonZero, and it is load-bearing: agy reports its operational
|
|
338
|
+
// failures as exit 1 with the reason in the STDOUT envelope and
|
|
339
|
+
// nothing on stderr (measured 1.1.18 — see `agyErrorText`). run()'s
|
|
340
|
+
// default reject path keeps only the stderr tail, so it threw away
|
|
341
|
+
// every one of those reasons and handed this loop our own echoed
|
|
342
|
+
// argv instead. Resolving non-zero exits and reading the envelope is
|
|
343
|
+
// what makes both the message AND the fail-fast work.
|
|
344
|
+
({ stdout, stderr } = await run(
|
|
169
345
|
this.bin,
|
|
170
|
-
buildAgyArgs(prompt, {
|
|
346
|
+
buildAgyArgs(prompt, {
|
|
347
|
+
model: this.model,
|
|
348
|
+
effort: this.opts.effort,
|
|
349
|
+
schemaJson: schemaText,
|
|
350
|
+
}),
|
|
351
|
+
{ allowNonZero: true },
|
|
171
352
|
));
|
|
172
353
|
} catch (err) {
|
|
354
|
+
// Only a spawn failure reaches here now: agy not on PATH, or not
|
|
355
|
+
// executable. Nothing was spent, and no retry can install it.
|
|
356
|
+
attemptMs.push(Date.now() - started);
|
|
173
357
|
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
358
|
if (isNonRetryableAgyFailure(lastError)) break;
|
|
359
|
+
// An externally killed spawn (SIGTERM/SIGKILL) classifies as timeout,
|
|
360
|
+
// and is as persistent as an expired --print-timeout — same fail-fast
|
|
361
|
+
// as the envelope path below.
|
|
362
|
+
if (classifyAgyFailure(lastError) === "timeout") break;
|
|
177
363
|
continue;
|
|
178
364
|
}
|
|
365
|
+
const elapsed = Date.now() - started;
|
|
179
366
|
// Recorded per ATTEMPT, before validation: a reply that failed the
|
|
180
367
|
// schema still spent the tokens, and a retry is exactly the cost a user
|
|
181
368
|
// would want to see rather than have quietly absorbed.
|
|
182
369
|
const envelope = parseAgyEnvelope(stdout);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
370
|
+
// Gated on stdout now that non-zero exits resolve: a call that printed
|
|
371
|
+
// NOTHING never reached the model (exit 2, a usage error) and must not
|
|
372
|
+
// be booked as an estimated ~24k-token call. Anything that did reply —
|
|
373
|
+
// including an ERROR envelope reporting zeros — is still recorded.
|
|
374
|
+
if (stdout.trim()) {
|
|
375
|
+
this.usage.push({
|
|
376
|
+
provider: this.name,
|
|
377
|
+
// The envelope names no model, so record what was asked for — or the
|
|
378
|
+
// honest placeholder, which the cost report declines to price.
|
|
379
|
+
model: this.model ?? "antigravity-default",
|
|
380
|
+
schemaName: req.schemaName,
|
|
381
|
+
inputTokens: envelope.inputTokens ?? estimateTokens(prompt),
|
|
382
|
+
outputTokens: envelope.outputTokens ?? estimateTokens(envelope.response ?? stdout),
|
|
383
|
+
cachedInputTokens: envelope.cachedInputTokens,
|
|
384
|
+
exact: envelope.inputTokens !== undefined,
|
|
385
|
+
// The whole point of this provider: agy's cached sign-in means the
|
|
386
|
+
// subscription pays, not a card, and agy reports no cost to forward.
|
|
387
|
+
billed: false,
|
|
388
|
+
ms: elapsed,
|
|
389
|
+
// The envelope's own verdict (§143): a failed attempt's cost stays
|
|
390
|
+
// visible, but attribution (the production.json stamp) skips it.
|
|
391
|
+
failed: envelope.status !== "SUCCESS" || undefined,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// SUCCESS is the envelope's own word for "this worked" (§132 lists the
|
|
395
|
+
// status enum), and with non-zero exits resolving it is now the ONLY
|
|
396
|
+
// success test — an exit code we no longer see cannot be one.
|
|
198
397
|
if (envelope.status !== "SUCCESS") {
|
|
199
|
-
|
|
200
|
-
|
|
398
|
+
attemptMs.push(elapsed);
|
|
399
|
+
lastError = agyErrorText(stdout, stderr);
|
|
201
400
|
if (isNonRetryableAgyFailure(lastError)) break;
|
|
401
|
+
// A timed-out call never succeeds on retry at this call size —
|
|
402
|
+
// measured 2026-08-22: a ~63k-token beat-sheet call expired at the
|
|
403
|
+
// 10m --print-timeout twice in a row, so the second attempt only
|
|
404
|
+
// doubled the wall clock to 20 minutes. Fail fast; the escape hatch
|
|
405
|
+
// is another provider, not the same call again.
|
|
406
|
+
if (classifyAgyFailure(lastError) === "timeout") break;
|
|
202
407
|
continue;
|
|
203
408
|
}
|
|
204
409
|
try {
|
|
@@ -209,12 +414,19 @@ export class AntigravityProvider implements LlmProvider {
|
|
|
209
414
|
}
|
|
210
415
|
return req.schema.parse(JSON.parse(extractJsonObject(envelope.response ?? stdout)));
|
|
211
416
|
} catch (err) {
|
|
417
|
+
attemptMs.push(elapsed);
|
|
212
418
|
lastError = err instanceof Error ? err.message : String(err);
|
|
213
419
|
}
|
|
214
420
|
}
|
|
215
|
-
throw new
|
|
216
|
-
|
|
217
|
-
|
|
421
|
+
throw new AgyError(
|
|
422
|
+
agyFailureMessage({
|
|
423
|
+
bin: this.bin,
|
|
424
|
+
schemaName: req.schemaName,
|
|
425
|
+
lastError,
|
|
426
|
+
attemptMs,
|
|
427
|
+
printTimeout: AGY_PRINT_TIMEOUT,
|
|
428
|
+
}),
|
|
429
|
+
classifyAgyFailure(lastError),
|
|
218
430
|
);
|
|
219
431
|
}
|
|
220
432
|
}
|