@ossclip/core 0.1.29 → 0.1.30

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.29",
3
+ "version": "0.1.30",
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/browser.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export * from "./scene-schema";
8
8
  export * from "./scene-registry";
9
+ export * from "./scene-props-controls";
9
10
  export * from "./overrides";
10
11
  // The editor derives its plain takes with the SAME function the pipeline
11
12
  // uses — a copy would drift and the two timelines would disagree.
@@ -36,6 +36,50 @@ export const AGY_PRINT_TIMEOUT = "90s";
36
36
  */
37
37
  export const MAX_AGY_PROMPT_BYTES = 700_000;
38
38
 
39
+ /**
40
+ * Remove the constraints agy would REJECT a whole generation over and we can
41
+ * absorb ourselves (§151).
42
+ *
43
+ * agy does not constrain decoding — it generates, validates server-side
44
+ * against the schema we hand it, and on failure regenerates. Captured from a
45
+ * real call:
46
+ *
47
+ * "error": "invalid arguments:\n- at '/hook': maxLength: got 136, want 120"
48
+ *
49
+ * Sixteen characters over, and the whole attempt is discarded. `maxLength`
50
+ * buys nothing there, because `cappedText` already truncates an overshoot at a
51
+ * word boundary — so the cap on the wire could only ever cost a generation,
52
+ * never save one.
53
+ *
54
+ * SCOPE, stated because the obvious guess is wrong: this is NOT why agy times
55
+ * out. That was the theory this function was written under, and it was
56
+ * refuted by replaying the exact failing beat-sheet request standalone — with
57
+ * maxLength stripped it still timed out, and with `--json-schema` dropped
58
+ * ENTIRELY it still timed out, agy's own error being "timeout waiting for
59
+ * response" with an empty body. The hang is upstream and prompt-triggered
60
+ * (§143, §149); this only removes one real-but-separate way a generation gets
61
+ * thrown away.
62
+ *
63
+ * `maxItems`, `enum`, `const`, `type` and `required` all stay: a 25th moment
64
+ * or an invented sceneKind is not something truncation can quietly repair, and
65
+ * that is the part of the contract worth paying a retry for.
66
+ *
67
+ * Only agy needs this. claude-cli receives the schema as prompt text, and
68
+ * gemini constrains during decoding, so neither turns a long string into a
69
+ * discarded generation.
70
+ */
71
+ export function stripAbsorbableCaps(schema: unknown): unknown {
72
+ if (Array.isArray(schema)) return schema.map(stripAbsorbableCaps);
73
+ if (schema && typeof schema === "object") {
74
+ return Object.fromEntries(
75
+ Object.entries(schema as Record<string, unknown>)
76
+ .filter(([k]) => k !== "maxLength")
77
+ .map(([k, v]) => [k, stripAbsorbableCaps(v)]),
78
+ );
79
+ }
80
+ return schema;
81
+ }
82
+
39
83
  /**
40
84
  * agy's `--effort` levels. Exposed after the §143 hang incident (2026-08-22):
41
85
  * untested at real scale whether a lower effort moves the hang, but the knob
@@ -316,7 +360,9 @@ export class AntigravityProvider implements LlmProvider {
316
360
  schema: z.ZodType<T>;
317
361
  schemaName: string;
318
362
  }): Promise<T> {
319
- const schemaText = JSON.stringify(z.toJSONSchema(req.schema));
363
+ // Stripped before it goes on the wire (§151) — agy validates against this
364
+ // after generating, and a length overshoot costs the whole attempt.
365
+ const schemaText = JSON.stringify(stripAbsorbableCaps(z.toJSONSchema(req.schema)));
320
366
  const base =
321
367
  `${req.system}\n\n${req.user}\n\n` +
322
368
  `Respond with ONLY a JSON object valid against this JSON Schema ("${req.schemaName}"). ` +
@@ -20,6 +20,15 @@ import type { LlmProvider } from "./provider";
20
20
  * so the model is still ASKED for the limit; it just no longer costs a run
21
21
  * when the model misses by a word. Truncation prefers the last word boundary,
22
22
  * and adds no ellipsis — the prompt explicitly forbids one on cover text.
23
+ *
24
+ * One provider is now an exception (§151). agy does not constrain decoding: it
25
+ * generates, validates against the schema server-side, and REGENERATES on a
26
+ * miss — so there, "still asked" cost the entire attempt, and a run of near
27
+ * misses walked into the print-timeout as a hang. `stripAbsorbableCaps` drops
28
+ * maxLength from agy's copy of the schema, and this truncation is what makes
29
+ * that safe. Every capped string in the beat sheet routes through here for
30
+ * exactly that reason — a single bare `.max()` would turn agy's retry loop
31
+ * into a hard local failure instead.
23
32
  */
24
33
  export function cappedText(max: number): z.ZodType<string> {
25
34
  return z.preprocess((v) => {
@@ -86,10 +95,14 @@ export type BeatSheet = z.infer<typeof BeatSheetSchema>;
86
95
  export const ClipHighlightSchema = z.object({
87
96
  startWord: z.number().int().nonnegative(),
88
97
  endWord: z.number().int().nonnegative(),
89
- reason: z
90
- .string()
91
- .max(200)
92
- .describe("one line: why THIS window is the strongest stretch of the take"),
98
+ // cappedText, not a bare .max(200) (§151): this was the ONE capped string in
99
+ // the beat sheet that REJECTED an overshoot instead of truncating it. That
100
+ // asymmetry is load-bearing now — the agy request drops maxLength from the
101
+ // wire schema precisely because every cap can be absorbed locally, and a
102
+ // single rejecting field would turn a retry loop into a hard failure.
103
+ reason: cappedText(200).describe(
104
+ "one line: why THIS window is the strongest stretch of the take",
105
+ ),
93
106
  });
94
107
  export type ClipHighlight = z.infer<typeof ClipHighlightSchema>;
95
108
 
@@ -0,0 +1,63 @@
1
+ import { z } from "zod/v4";
2
+ import { SCENE_REGISTRY } from "./scene-registry";
3
+ import type { SceneComponentId } from "./scene-schema";
4
+
5
+ /**
6
+ * The Inspector's controls for a scene's non-text props, derived from the
7
+ * component's own schema (§153).
8
+ *
9
+ * Every string prop already has an editor: select the element and type in the
10
+ * Text field. Nothing else did. `inverted`, `kenBurns`, `emphasizeLast` and
11
+ * `fanOut` were reachable only by hand-editing overrides.json, because
12
+ * `elementTextOf` returns null for anything that is not a string and the Text
13
+ * field never renders.
14
+ *
15
+ * Derived rather than hand-listed on purpose. Hand-wiring per component is
16
+ * exactly how ScreenshotFrame shipped a `data-edit-id` naming no prop at all —
17
+ * the UI and the schema drifted and nothing connected them. Reading the schema
18
+ * means a component that gains a boolean gets a control the day it lands.
19
+ *
20
+ * Lives in core, not the editor, and ships through the `browser` entry: zod
21
+ * is already in that module graph (scene-schema + scene-registry), so the
22
+ * editor gets the derivation without pulling a schema library into its own
23
+ * bundle — the same reason `browser.ts` exists at all.
24
+ */
25
+ export type PropControl = {
26
+ key: string;
27
+ kind: "boolean" | "enum";
28
+ /** The schema's own default — what the scene renders as when unset. */
29
+ fallback?: boolean;
30
+ options?: string[];
31
+ };
32
+
33
+ type JsonSchemaProp = {
34
+ type?: string;
35
+ enum?: unknown[];
36
+ default?: unknown;
37
+ };
38
+
39
+ export function scalarPropControls(component: SceneComponentId): PropControl[] {
40
+ const meta = SCENE_REGISTRY[component];
41
+ if (!meta) return [];
42
+ const schema = z.toJSONSchema(meta.propsSchema as unknown as z.ZodType) as {
43
+ properties?: Record<string, JsonSchemaProp>;
44
+ };
45
+ const controls: PropControl[] = [];
46
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
47
+ // Enums first: a string prop with an enum is a CHOICE, not free text, and
48
+ // the Text field would let you type a value the component cannot render.
49
+ if (Array.isArray(prop.enum) && prop.enum.every((v) => typeof v === "string")) {
50
+ controls.push({ key, kind: "enum", options: prop.enum as string[] });
51
+ continue;
52
+ }
53
+ if (prop.type === "boolean") {
54
+ // The default matters: kenBurns is true when unset, so a checkbox that
55
+ // assumed false would describe the scene wrongly before you touched it.
56
+ controls.push({ key, kind: "boolean", fallback: prop.default === true });
57
+ }
58
+ // Strings and arrays fall through by design — the per-element Text field
59
+ // and element selection already own them, and a second control writing the
60
+ // same prop is how two sources of truth start disagreeing.
61
+ }
62
+ return controls;
63
+ }