Commit Graph
1127 Commits
Author SHA1 Message Date
Miguel Ángel 0c33b2dc7a feat(cli): keep your edits when you reinstall a catalog item (#3193)
* feat(cli): keep your edits when you reinstall a catalog item

Running add again overwrote whatever was on disk, so a project that had tuned
an installed block lost that work without being asked or told.

The installer now records a hash of each file as it installs it, and compares
before writing. A file that still matches is replaced as before; one that does
not is left alone and reported. A file we have no record of counts as changed,
which covers both a project that wrote the file itself and one that installed
before the record existed.

--force restores the old behaviour for when you do want the registry's version.

* test(cli): cover the dependency plan install path
2026-08-10 19:11:16 -04:00
Vance Ingalls bd1c1af291 chore: release v0.7.105 (#3152) 2026-08-10 08:08:16 -07:00
Miguel Ángel 68205dbbc1 feat(cli): search the catalog by meaning, on this machine (#3089)
* feat(cli): search the catalog by meaning, in three named tiers

Browsing the registry means matching names and tags, which fails whenever the
author's wording differs from yours. "make the pace feel faster" finds nothing
when the move is described as "velocity-driven blur". This ranks by meaning
instead.

Three tiers, and the command always says which one answered:

  words       shared vocabulary, free, offline, no account
  on-device   bge-small, free, offline, one opt-in download
  hosted      Gemini, free for signed-in HeyGen users

The tier is stated because a quietly worse answer looks exactly like a good
one. --json carries it as a token alongside dropped, shown, total and
top_score, so an agent reads provenance as data rather than matching English
that is written to be reworded.

Two consents, asked once each, and never conflated. Sending a query is a
privacy question, so the prompt says the query is sent. Downloading a model is
a disk and bandwidth question, so that prompt talks about size. Neither fires
without a terminal: an unattended run sends nothing and downloads nothing
unless a flag records that a person agreed.

The catalog is derived from registry-item.json rather than from a separate
document, so the set that is ranked and the set that can be installed are the
same object by construction. Only the on-device vectors are committed; the
hosted vectors are nine megabytes and belong on the server.

top_score is reported and never acted on. A "nothing matched" threshold looked
clean on long briefs and collapsed on the short queries people type: "a logo
appears" scores 0.6181 and keyboard mash scores 0.6417, so any cut that catches
the noise rejects the real query. The measurement is in the evals directory
rather than in this branch.

Not covered here. The published recall figures were measured against a separate
hand-written document, not against registry text, so they should not be quoted
for this catalog until re-measured. The offline tier needs a normal install: a
single-file build cannot load the native ONNX runtime, which the command now
reports instead of silently degrading. And the drop-detection path has never
been observed firing outside its author's tests.

* fix(cli): make this branch pass the repo's own gates

Three things `bun run lint` and `fallow audit --base origin/main` rejected.
CI runs both, so none of this branch would have gone green. Found by running
them, not by reading the diff.

process.exit in catalog.ts, twice: an invalid --type and a cancelled picker.
check:cli-process-ownership reserves that for cli.ts, and the rule is not
cosmetic — process.exit tears the process down where it stands, so anything
cli.ts has queued to run on the way out is dropped. finishCommand throws a
CliResultSignal that cli.ts turns into the exit code, which is what init.ts
already does for a cancelled prompt.

Three exports with no consumers. normalize keeps its body and loses its export;
localEmbedder is the only caller. modelsDirectory goes entirely, having no
caller inside its file or out. The WordPieceConfig re-export goes, and with it
the import it existed to forward: the type is exported from wordpiece.ts, where
its consumers already take it from.

Complexity. prepareOnDeviceTier is lifted out of run(), which took run from 64
cyclomatic and CRAP 948 to 54 and 684. That block is one decision — can the
offline tier run, and if not, why not — and its only product is a list of
warnings, so it reads and tests as a unit, which it could not do inline.

The rest is suppressed rather than refactored, each with its reason on the line
above. Finishing run() means extracting its three output paths, and that is a
refactor of a command this branch already changes for other reasons: a separate
initiative, not something to absorb here. Every suppression says what shape the
function has and why; a bare marker on a function nobody can justify is how a
threshold stops meaning anything.

Verified: `bun run lint` exits 0, fallow reports no issues across 27 changed
files, and 2540 CLI tests pass.

* feat(cli): ship the local search tiers only, drop the hosted one

Search now has two tiers, both local: shared-vocabulary word matching, and the
opt-in on-device model. The hosted tier, which sent the query to a HeyGen
endpoint and ranked it with a hosted model, is removed.

This is a scope decision, not a defect. The endpoint works and its own change is
reviewed and green; it is simply not what we want to ship first. Landing local
only means the feature has no backend dependency, no auth requirement, and
nothing leaves the machine unless someone opts into downloading a model.

Gone: registry/smartSearch.ts and its test, the --smart and --no-smart flags,
the outcome plumbing through the command, the remote branch of applySearch, the
remote tier, and the hosted-only JSON fields (ranking, catalog_version,
top_score). Also the smartSearchEnabled consent field in telemetry config, which
was the persisted storage behind the hosted consent and would otherwise have
been left as dead configuration surface.

Kept exactly as they were: both local tiers, the --on-device and --yes flags,
the download consent prompt, and the runtime check that happens before the
download rather than after it. The --json envelope still reports query, tier,
tier_detail, shown, total, dropped, warnings and results, so an agent can still
tell which tier answered and why. tierToken now distinguishes on-device from
words.

Verified: lint exits 0, fallow reports no issues, 2522 CLI tests pass, and the
command was exercised directly. A query answers on the on-device tier where the
model is installed and falls back to word matching where it is not, reporting
that fallback in warnings rather than silently. An unknown --type still exits 1
with a readable message, and --smart is now rejected as an unknown flag.

* fix(cli): count only moves this registry cannot install as dropped

The dropped count was computed against the list left after the user's own
--type and --tag filters, so every move the user excluded was reported as one
the registry is missing. Filtering made the number go up: the same query
reported 277 unfiltered and 302 with --type block.

The count exists so a caller can tell "nothing matched your words" apart from
"the ranker suggested things this project cannot install". Conflating it with
user filtering destroys exactly that signal, and worse, genuine index skew and a
self-inflicted filter printed a byte-identical line with opposite remedies --
one means refresh the shelf, the other means drop a flag, and refreshing does
nothing.

Now counted against the registry rather than the filtered view. The manifest is
already fetched whole and narrowed in memory, so keeping the unnarrowed name set
costs no extra request, and item loading still runs only on the filtered subset.

Verified against ground truth rather than by eye: the vector artifact holds 411
names, the registry holds 168 installable items, and 134 of those names exist in
both, so 277 are genuinely uninstallable. The count now reads 277 unfiltered,
277 under --type block, 277 under --type component and 277 under --tag, and the
skew it reports is real -- the artifact predates dropping the UI primitives and
still ranks moves that are no longer on the shelf.

Reported by Vance Ingalls, who also noted this closes an item the status doc
listed as unverified. Two earlier sweeps could not make the count fire because
neither combined a filter with a query.

Tests pin the three cases: a genuinely absent name counts, a filter-excluded
name does not, and a fully installable ranking reports zero.

* fix(cli): tell the user when meaning search cannot see the catalog

The on-device index was fetched once and never revalidated: the only
freshness check was two existsSync calls. A move added after that fetch was
invisible to meaning search permanently, not down-ranked but absent from the
candidate set. The registry manifest on the same command carries a 24h TTL,
so the two halves of one feature disagreed about staleness.

The dropped count reported over-coverage only, names the index has that the
registry lacks. Under-coverage was never computed, so the harmless direction
was instrumented and the costly one was silent. Reproduced with an index
truncated to 120 of 168 moves: dropped read 0, perfect health, while 48
moves were unreachable.

Counts under-coverage from the name list the artifact already carries, so no
extra request. Warns only when non-zero, and names the remedy.

The remedy had to be made true: --on-device could not refresh a stale index
because hasLocalVectors short-circuited the fetch. That flag now refetches
when the index is absent or no longer covering.

Two defects the reproduction surfaced. A failed refresh reported the tier
unavailable while the old vectors were still on disk and still ranking. And
the fetch wrote its two files one at a time, so failing between them paired
a new name list with an old matrix, a hard load error rather than stale
data. It now writes both or neither, which matters more once refresh runs on
staleness.

top_score returns, scoped to the on-device tier and set to the score of the
best result actually shown rather than the ranking head, which can describe
a row the caller never received.

Also: scripts/ is now typechecked. It never was, which is how a build script
that crashes after the paid embedding call, and two scripts whose imports do
not resolve at all, went unnoticed. 43 errors fixed, no suppressions.

And the docs stop describing a --smart hosted tier that was deleted, an
item that does not exist, and a registry refresh that cannot fix a stale
vector index.

* ci: fail when the search index stops covering the registry

The catalog vector artifact is regenerated by hand. Nothing in CI, in
package.json or in a hook rebuilds it, because embedding needs the 32 MB
model. So adding a registry item silently makes it invisible to meaning
search until someone remembers to regenerate.

The failure is asymmetric, which is what makes it easy to miss. Removing an
item is self-healing: the ranker still scores the dead vector, then filters
the name before display, so a user is never offered something they cannot
install. Adding one is not: the item is absent from the candidate set
entirely, not ranked low.

Comparing the two name lists needs neither the model nor a network call, so
the gate runs in seconds. CI checks rather than fixes, for the same reason
it cannot regenerate.

Scoped to blocks and components. Examples are starter projects a user
scaffolds, never something catalog ranks, and the artifact carries no vector
for them, so demanding one would keep this gate permanently red and it would
be ignored within a week.

Verified in both directions rather than assumed: adding an unindexed item
exits 1 and names it, restoring the registry exits 0.

* fix(catalog): rebuild the search index from the registry

build-local-vectors.ts read registry/catalog-artifact/catalog.json, a file no script in this repo writes and which is not committed, so the documented regeneration command failed on a missing path. That is why the index could drift from the registry with nothing to run to fix it.

It now reads registry/blocks/* and registry/components/* through catalogFromRegistry, the existing helper that already produced the right shape but had no caller. Rebuilding reproduces the shipped 168 rows byte for byte.

A lefthook catalog-index command regenerates and re-stages both artifact files whenever a staged registry-item.json changes, mirroring the skills-manifest pattern, so adding or removing an item keeps the index in sync without anyone remembering to. Verified end to end: staging a new item took the artifact 168 to 169 rows and staged it in 0.80s.

* fix(cli): refuse a half-downloaded vector cache

The two artifact files have to agree on how many rows there are, and until now nothing checked that before writing them. A truncated or wrong-model response landed in the cache and only failed at load, on every later search, until someone cleared it by hand. The pair is now checked first and refused as a unit, and the cache is created 0o700 with 0o600 files rather than inheriting the umask of a directory the caller may have pointed anywhere.

Also lifts the capture setup the two preview generators had drifted into sharing into scripts/preview-capture.ts, and splits the vector builders batching and packing out of main. Both were findings the audit attributed to this branch.

* fix(cli): keep the catalog vitest run with the tests it runs

Restacking took the base package.json wholesale, which dropped the vitest dependency and the scripts/catalog run this PR adds. Both belong here rather than under it.

* fix(cli): stop the declined model download from happening anyway

Answering no to the on-device download offer recorded no and warned, then carried on. The guard below it is localModelConsent() !== false, which the decline had just made false, so it was skipped rather than taken: control reached recordLocalModelConsent(true), overwrote the answer with yes, and fetched the 32 MB model the user had refused. Next run it never asked again.

No test could catch it. The stub pinned localModelStatus to ready, so the prompt never fired, and recordLocalModelConsent was a no-op that recorded nothing.

Two tests now cover the offer, and they need three things the old stubs did not model: the run has to look like a terminal, because off one the command treats --on-device as the consent and never asks; the ONNX probe has to answer true, or an accepted offer returns at the runtime guard before it can download; and the status has to follow the recorded answer, or the second offer later in the run fires as well. Removing the return makes the decline test fail.

* fix(catalog): let someone without the model still add a component

The pre-commit hook rebuilds the search index, and rebuilding needs the 32 MB embedding model. An outside contributor adding a registry item does not have it, so their commit died inside the ONNX loader on an ENOENT naming a path they never set, and the CI gate then told them to run the command that had just crashed.

The model is an opt-in for search, not a build dependency, so nobody is charged for it to contribute. The builder checks first and explains itself, exiting 3 for cannot as distinct from 1 for failed. The hook treats 3 as skip and lets the commit through. The gate now names both paths: regenerate if you have the model, leave it if you do not and a maintainer will.

Verified both ways: with no model the builder explains and the hook exits 0; with the model it still regenerates byte-identically.

* docs: say that anyone can add a registry item, and stop hand-editing a generated file

Two defects, one of them the reason 64 stale entries survived in registry.json.

The checklist told contributors to add their item to registry/registry.json. That file is generated from the item directories, so an entry added by hand survives until the next regeneration and then vanishes, and one left behind for a directory that no longer exists is worse: hyperframes add resolves the name and then fails on missing files. Both CONTRIBUTING.md and the agent-facing skill reference now run the generator instead.

Nothing said contribution was maintainer-only, but nothing said it was not either, and two steps do need assets an outside contributor has no reason to install. Those are now named in a table with what happens if you do not have them, matching how the preview image was already handled. The search index is the new one: the model behind it is a 32 MB opt-in for search, not a build dependency.

* fix(cli): harden on-device catalog search

* fix(cli): refresh stale catalog vectors

* test: create catalog vector temp dirs securely
2026-08-09 22:59:15 -07:00
Miguel Ángel db3de4c1bf fix(cli): follow every redirect a host may answer with (#3148)
* fix(cli): follow every redirect a host may answer with

downloadFile handled 301 and 302 and passed the Location header straight back as a request target. Hosts answer with relative locations far more often than that assumed, and 303, 307 and 308 are all reachable, so a CDN handoff failed on a URL that was never a URL.

Locations now resolve against the URL that sent them, the code set covers all five, and a hop cap ends a redirect loop rather than recursing.

Keeps mains idle-response test, which asserts the request timeout fires and clears the partial file. An earlier version of this branch replaced the file wholesale and lost it, leaving downloadFile with no test that calls it at all.

* fix(cli): bound and isolate model downloads
2026-08-09 21:49:43 -07:00
Miguel Ángel c96b30c717 chore: release v0.7.104 (#3147) 2026-08-09 17:19:31 -07:00
Miguel Ángel bea32b8aae fix(studio): stop a Studio edit from reloading the preview (#3137)
* fix(studio): stop a Studio edit from reloading the preview as if it were external

Every mutation route wrote the file without leaving a write receipt, so the
watcher's broadcast of Studio's own edit arrived with no identity on it. The
external-change coordinator could not tell that echo from an agent or an editor
writing the file behind Studio's back, so it took the safe branch and did a full
iframe reload. That reload hides the stage for the length of the reload, which is
what the flash after a text edit was.

Every mutation write now goes through one helper that records the receipt, and
the client claims the write before the request goes out rather than after it: the
server writes and the watcher fires while the request is still in flight, so a
token marked from the response can arrive after the echo it was meant to match.

Reproduced in the browser before and after, with the reload path traced end to
end. Before, a patch-element write logged `token: null` then a reload from the
coordinator; after, the same write logs the token and `suppressed: own write
token`, with no reload.

Adds `hf-reload-debug` (localStorage, off by default) alongside the existing
`hf-resize-debug`: it records each file-change decision and its reason, plus the
stack of whoever asked for a full reload.

* fix(studio): claim the timeline and caption writes too, not just the DOM ones

The receipt only helps when the client marked the token it sent, and the GSAP
mutation writers never sent one. A drag commits through gsap-mutations, so the
server minted a token the client had never seen, the change came back looking
like someone else's, and the preview did the full reload the receipt was meant
to prevent.

Same one-line claim on both GSAP mutation writers, the timing sync's mutation
call, and the caption auto-save PUT.

The rollback call stays deliberately unclaimed and says why: it runs because a
mutation did not converge, so the preview is on bytes nobody can vouch for and
the reload is the point.

Verified live: a drag-shaped update-properties on the timeline now logs
`suppressed: own write token` with no reload, where it logged a coordinator
reload before.

* refactor(studio): keep timelineTimingSync under the size cap

Claiming the timeline writes pushed this file one line past the 600-line
gate. Same change as the branch made later, landed with the commit that
caused it.

* fix(studio): cover remaining write receipt paths

* fix(studio): preserve batch write receipts

* fix(cli): emit every file in a watcher burst
2026-08-09 19:10:02 -04:00
Vance Ingalls adb13ce125 chore: release v0.7.103 (#3127) 2026-08-09 03:14:27 -07:00
Vance IngallsandClaude Fable 5 19defeabfe feat(core,cli): ship the DE parallel router fleet-wide — remove the canary gate
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard
in render.ts together, leaving the producer's default-ON in place. Net effect
for users: the parallel drawElement router is on for everyone again.

## Why, and why not a ramp

Gating at 5% was itself the regression. Measured 2026-08-08, the day after
v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of
non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are
explicitly disarmed and #2840 deleted the everyone-armed trial in the same
change. 2,537 installs lost a feature they already had. Severity is speed
only, never output, and nothing is persisted to disk.

PR #2840's body claimed "the canary does not make exposure smaller; it makes
it chosen and revertible." That was true of the end state and false of the
first step. This lands the end state.

Entry and guard go together deliberately: at >=100 the evaluator
short-circuits ahead of the CI/seedless exclusions, so removing only the entry
would have flipped whatever still resolved false at deletion time, unstaged.

## Both stated blockers are void

- **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0
  of 4,281 across every CPU tier, software GL gates it out — and the router
  requires it. No percentage could ever expose Docker, so no ramp closes that
  gap. ≤4 CPUs yields ~42 drawElement candidates in three days.
- **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93,
  0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore
  this router. It is real, still live on 0.7.101, and belongs to the
  screenshot/beginframe path. 11 reproduction runs across four configurations
  on the enriched profile (darwin/arm64 25.5.0) came back clean.

## Safety unchanged

The per-install circuit breaker and the per-render self-verify are untouched;
`HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary
data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) —
consistent with the 2.75-3.16% baseline.

Revert path is now a code revert rather than a registry edit. That is the
trade this shape accepts in exchange for one release instead of two.

## Corrects two claims that shipped wrong

`~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and
`~11% of installs already route` was an OUTCOME (the share clearing
eligibility and the old 25-render cap), not an exposure setting — read as a
rollout knob it inverts the arithmetic, which is how gating at 5% came to cut
exposure rather than ramp it. Both are recorded in render.ts so they are not
reintroduced.

## Tests

Removed the core wiring assertion and the two CLI canary-gating tests, which
pinned a gate that no longer exists. Added the inverse guarantee in its place:
an ordinary install must come out of the breaker with the var UNSET so the
producer default applies — writing "false" there is precisely what disarmed
the fleet at 5%.

core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures
in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area).
oxlint and oxfmt clean.

Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router`
and `canary_reason_de_parallel_router` are emitted from the registry, so the
`Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go
blank once this ships. Watch drawElement engagement on 1807532 instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:10:25 -07:00
Miguel Ángel b1f7d8881b chore: release v0.7.102 (#3119) 2026-08-08 13:13:50 -07:00
Miguel Ángel b6ff3ab745 fix: preserve the composition query and serve the runtime before author scripts (#3114)
* fix(player): stop re-encoding the composition query

Every src the player sets goes through withShaderQueryParams, which parsed
the author's whole query with URLSearchParams and re-serialised it with
toString(). That is a form encoder: it writes a space as +, while callers
percent-encode and read back with decodeURIComponent. Those two codecs are
not inverses, so any space in any query value arrived corrupted.

It ran even when there was nothing to inject. With no shader attributes
both params are deleted, so the round-trip was pure loss, on every src,
for every consumer.

Append the two params to the raw query instead of re-serialising it. The
player now hands a composition its query back byte-identical.

Empirically space was the only casualty: plus, ampersand, equals, hash,
percent, question mark, quotes and non-ASCII all survived a URLSearchParams
round-trip. That is narrow, but a space in a headline or in SVG path data
is the common case, and invalid path data renders nothing at all.

Latent until now: no shipped consumer depended on query preservation, so
this surfaced only once compositions began carrying variable payloads.

* fix(cli): serve the runtime ahead of every author script

injectRuntime appended its script before </body>, so it landed after any
inline script the composition carried. At the moment a composition's own
script ran, window.__hyperframes was undefined and getVariables() was
unreachable: our documented API did not exist at the point authors are
told to call it.

Served order was gsap at line 6, the composition's init script at 20, the
runtime at 37. A probe inside the composition's IIFE recorded
hfTypeAtInit undefined with no variable keys, and the element rendered
its hardcoded fallback rather than the declared value.

The runtime is designed to load early. Its entry assigns __timelines,
installs the authored-opacity capture (whose own comment says it must run
while the document is still parsing), and exposes __hyperframes
synchronously, deferring real work to DOMContentLoaded. End-of-body
injection defeated all three, and nothing in it needs a parsed DOM, so no
defer is wanted.

Injects at head start instead, reusing the placement cascade
injectScriptsAtHeadStart already implemented rather than adding a fourth
copy of it. Head start rather than the closing tag so the runtime also
precedes author scripts inside head.

injectRuntime has exactly one consumer, the play server's composition
route. Every other surface reaches the runtime through the bundler, which
already injects into head, or deliberately serves raw.

Two registry blocks had independently worked around this by parsing the
authored attribute themselves. Those stay, but the workaround is no
longer the only way to read a variable at init.
2026-08-08 13:07:10 -07:00
James Russo ed3ff98ce0 fix(cli): stop skills update deleting skills the manifest never covered (#3118)
`hyperframes skills update` deleted skills that the same command had just
installed, from every agent directory on the machine, and reported them as
"no longer published".

`skills add --skill '*'` installs every skill in the repo — including the
repo-native ones under `.claude/skills/` and `.agents/skills/` — and the
upstream lock attributes all of them to `heygen-com/hyperframes`. The published
manifest is generated from `<repoRoot>/skills` only (gen-skills-manifest.ts), so
it never lists those. detectRemoved read that silence as "removed upstream" and
pruned them, so `check || update` could not converge: `add` reinstalled them and
the next `update` deleted them again.

Scope removed-detection to skills the manifest is actually authoritative for,
using the lock's `skillPath` — the only field that separates a skill installed
from `skills/` from one installed out of the same repo's other skill roots
(`source` is identical for both). An entry with no `skillPath` is treated as not
covered: this is a delete path, so unknown provenance fails safe.

Also resolve the prune's manifest canonically. Its notion of "still published"
could otherwise come from any `skills-manifest.json` within 16 parent
directories of cwd, which — since HyperFrames' own manifest declares
`source: heygen-com/hyperframes` — matches lock attribution and drives deletion.
The install-side check already did this (#2176); the deleting path did not, and
the comment claiming that was deliberate and "tested separately" had no such
test. An explicit `--source` still wins.

Verified end to end against the real CLI in a sandboxed HOME. Before: `add`
installed 25 skills, `update` printed "Removing 6 skill(s) no longer published:
captions-overlay, changelog-video, cut-the-curve, motion-doctrine,
oversized-cursor, seam-craft" and deleted all six (27 dirs -> 21). After: no
removal line, 27 -> 27. Both new regression tests fail on the pre-fix source.

Fixes #3111
2026-08-08 12:43:53 -07:00
Vance Ingalls eba96feda7 chore: release v0.7.101 2026-08-07 19:50:48 -07:00
Vance Ingalls 867eeabc0f Merge pull request #2840 from heygen-com/07-27-feat_producer_enable_parallel-de_router_by_default
feat(cli,core,producer): ramp the parallel-DE router through the canary at 5%
2026-08-07 19:49:32 -07:00
Vance IngallsandClaude Opus 5 1033d03271 docs(cli): stop calling the router trial an opt-in in risk prose
It is not a user opt-in — execute.ts arms it automatically on the CLI render
path, so ~11% of installs already route without anyone choosing it. The
opt-in is at the CALL SITE: the flag defaults off and only the two CLI sites
set it, excluding programmatic renderLocal consumers because the mechanism
mutates process.env. That polarity guards embedding contexts, not users.

Calling it opt-in understates today's exposure, which changes how a reviewer
judges the ramp: it is not protecting users from a feature they chose, it is
governing exposure already happening without their choice.

Leaves the accurate uses alone — 'explicit user opt-in' means someone setting
HF_DE_PARALLEL_ROUTER themselves, and the call-site flag is genuinely opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:38:28 -07:00
Vance IngallsandMiguel Ángel d9b00e57eb chore: release v0.7.100 (#3093)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-08-07 16:12:25 -07:00
Miguel Ángel 0bda6b55b8 feat(cli): track which registry items add installs (#3099)
* feat(cli): track which registry items `add` installs

`cli_command` records that `add` ran and nothing about what it installed, and
the registry is served from raw.githubusercontent.com, which gives no per-item
counter either — so there is no way to tell which block or component people
actually pull, and no way to know what is worth building more of.

Emit one `registry_item_added` event per item written into a project, from
`runAdd` after the install succeeds. That is the single choke point: the bulk
`add <tag>` path re-enters it per item, and a failed or compatibility-refused
install throws before it, so a refused install is never counted as a download.

`requested` separates the item the user named from the transitive
`registryDependencies` pulled in behind it; without it a popular dependency
outranks everything that depends on it.

Item names are public registry identifiers, never user content or project data,
and the event goes through `trackEvent` — an install that opted out via
`hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK`
sends nothing.

* test(cli): cover `add` telemetry end to end against the built CLI

The unit tests assert the emit seam and nothing past it. `shouldTrack()`
short-circuits whenever `isDevMode()` is true, and that is true for any `.ts`
entry, so under vitest a real event and no event are indistinguishable and the
transport is never exercised at all.

Drive the built CLI instead and assert on the HTTP body it actually produces:
one event per installed item, the dependency reported with `requested: false`,
an opted-out install sending no request at all (not merely one without this
event), and a refused install counting nothing.

Two fixtures, because neither case is reachable through the real registry. The
registry origin is a first-class project setting, so a local one supplies the
`registryDependencies` edge that no shipped catalog item declares today; and
`globalThis.fetch` is wrapped to capture the batch rather than send it. The
faked 200 is load-bearing: only a failed flush leaves events queued, and only a
non-empty queue spawns the detached `flushSync` child that would bypass the
hook and reach production analytics.

Verified the check can fail — forcing `requested: true` for every item turns it
red on exactly the dependency assertion.
2026-08-07 16:00:23 -07:00
Vance IngallsandClaude Opus 5 4a2514232b feat(cli,core): ramp the default-on router through the canary
Rebased onto main (was 308 behind) and gated the new default-on behaviour on
the de-parallel-router canary, at 5%.

Default-ON without a ramp is a ~17x exposure jump: from ~6% of eligible
renders today to all of them, landing on profiles the opt-in trial never
covered (<=4 CPUs and Docker, ~12% of eligible renders between them).
0.7.60-0.7.64 is why that matters — every unclamped render reverted for five
consecutive releases and nobody noticed.

The gate reuses the breaker's own disarm: non-enrolled installs get an
explicit HF_DE_PARALLEL_ROUTER=false, because with default-ON polarity
deleting the var means ON. Setting the registry percentage to 0 is therefore
a full fleet-wide revert with no release.

Today's ~11% of installs routing is emergent — the product of eligibility
rules and a capped trial — so it drifts with fleet composition and cannot be
turned off without shipping. The point of the canary is that the number
becomes chosen and revertible, not that it is smaller.

Also replaces the registry test that pinned the percentage to 0. Its intent
was 'ramp only alongside the circuit breaker', but pinning 0 blocks the ramp
forever and never checks the wiring it names. It now asserts the wiring
directly, and fails if either the canary gate or the breaker consult is
removed.

Hold at 5% until PRINFRA-372 resolves: --workers auto crashes every worker on
macOS arm64 while --workers 1 is clean, and the router forces 3 workers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:10:47 -07:00
Vance IngallsandClaude Opus 5 af535080a2 fix(cli): keep a set-but-empty router env var breaker-managed (review)
Ownership detection classified ANY defined HF_DE_PARALLEL_ROUTER as a user
choice, but both parsers read empty/whitespace as "unset -> default ON".
Launching with `HF_DE_PARALLEL_ROUTER=` therefore routed the render (empty
parses as ON) while exempting the install from its circuit breaker: after a
verified fallback applyDeParallelRouterBreaker() no-op'd, so the install kept
retrying the failing router instead of latching off. That is the exact
first-fallback protection this PR exists to provide, lost on a documented
default path. Ownership now uses the same normalization as the parsers.

Also: only announce a trip the breaker could act on. With an explicit user
opt-in the breaker is deliberately a no-op, so "now off for this install" was
factually wrong — and reprinted on every later revert, since the user's value
keeps the router active.

Tests: set-but-empty and whitespace both latch off and persist the fired flag
(fault-injection verified — restoring the old check fails both); explicit
"true" survives a fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:01:50 -07:00
Vance IngallsandClaude Opus 5 c6df112ac1 feat(producer): enable the parallel-DE router by default, behind a per-install circuit breaker
The DE parallel router (HF_DE_PARALLEL_ROUTER) becomes default-ON. The soak
answered the safety question it was gated on: zero damaged frames shipped —
every fallback was the self-verification net catching a bad frame and
recovering on the screenshot path. Verify PSNR p10 sits flat near 40 dB
against a 32 dB floor. The residual 2.31% revert rate is an efficiency cost
(a revert forfeits the speedup, never the output), accepted in exchange for
parallelizing the >=700-frame band — roughly 80% of all DE capture
wall-clock, frame-weighted.

Default-ON is safe because the per-install circuit breaker stays underneath
it. That distinction matters: 9.8% of installs hit a revert, and they are
latched off permanently after the first one. Without the breaker those
installs would go from "one slow render, then protected" to "every eligible
render is slow".

The breaker, adapted for a default-ON flag:

- Writes an explicit HF_DE_PARALLEL_ROUTER=false and persists it to
  ~/.hyperframes/config.json, so the install stays off across processes.
  Absent no longer means off, so the switch has to be written, not unset.
- Trips only on a real fallback, never on render count — a healthy install
  keeps the speedup indefinitely.
- Independent of telemetry state: opting out of analytics must not cost a
  user the faster renderer. Telemetry governs reporting, not behavior.
- An explicit user value wins in both directions, latched before the breaker
  can write the var and make the two indistinguishable.
- The user is told when it trips and how to re-enable.

isDeParallelRouterEnabled() parses the kill switch properly: false/0/off/no
(case- and space-insensitive) disable; unset or empty is the default. A bare
`!== "false"` would silently ignore every spelling but one and hand parallel
DE to a user who asked for none.

Refs PRINFRA-384

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:01:49 -07:00
James 7640adc5f2 fix(cli): refresh identity persistence classification 2026-08-07 09:31:59 -07:00
Miguel Ángel 9aa90f6e3e chore: release v0.7.99 2026-08-07 15:31:57 +00:00
Miguel Ángel dd629697d3 fix(cli): harden publish retry behavior 2026-08-07 15:13:04 +00:00
Miguel Ángel f2d6ce3245 fix(cli): recover transient publish failures 2026-08-07 14:53:20 +00:00
Vance Ingalls 6114749d8e chore: release v0.7.98 2026-08-06 23:37:23 -07:00
Miguel Ángel c4b41072c7 chore: release v0.7.97 2026-08-07 05:16:27 +00:00
Vance Ingalls 026e6941ac chore: release v0.7.96 2026-08-06 14:53:26 -07:00
Vance IngallsandClaude Opus 5 b3990ac789 feat(studio): emit the canary reason on Studio events too
Review caught that the same anti-pattern was still live in the Studio binding:
canaryEventProperties destructured only `enabled` and dropped the reason. Its
own doc comment promised 'identical shape to the CLI, so a rollout spanning
both reads as one flag' — which the CLI-only fix had just made false.

This matters beyond symmetry. A CLI-launched Studio adopts the CLI's decisions
and shares its bucket seed, so a cohort flip can surface on either surface.
Emitting attribution on only one leaves Studio-observed flips unattributable
and makes the two flip counts irreconcilable — and Studio is the surface most
likely to expose a shared-seed-with-diverging-id pattern, which is the open
question the reason exists to answer.

Also adds the no_unit_id emission test the CLI side advertised but never
asserted, and a Studio pair pinning that a URL override and a cohort roll
produce the same assignment with different reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:39:56 -07:00
Vance IngallsandClaude Opus 5 076657a639 feat(core,cli): emit the canary decision reason alongside the assignment
The calibration contract deferred this until the stability check came back
dirty. It did: the first fleet read found 304 installs (1.08%) reporting both
values for a canary whose percentage never moved, and the genuinely anomalous
ones could not be separated from a developer toggling HF_CANARY_*, because the
assignment alone is identical in both cases.

resolveCanary has always computed the reason and canaryEventProperties dropped
it. Now every canary emits canary_reason_<name> beside its assignment.

Deliberately outside the $feature/ namespace: PostHog treats those as flag
values, and a non-boolean there would corrupt the flag's own breakdowns.

Two of the six wire values are immediately useful beyond override attribution.
'excluded' identifies CI installs, which today have to be dropped by joining
on is_ci — conflating them with out_of_cohort is what made the first accuracy
read look like a significant failure (9.22% against a 10% target) when it was
not. 'no_unit_id' surfaces the fails-closed corner.

The reason is optional on the core helper so existing callers are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:57:08 -07:00
Miguel Ángel fc0298de4a chore: release v0.7.95 (#3068) 2026-08-06 10:12:20 -07:00
WaterrrForever 3b65321a20 feat(cli): classify identity persistence on every telemetry event (#3065)
* feat(cli): classify identity persistence on every telemetry event

Install-grain metrics currently trust every anonymousId equally, but
ephemeral/isolated-HOME workloads mint a fresh id per run — one machine
produced 2,956 rotating render identities since Jul 30 (94.4% seen on a
single render command), inflating acquisition and diluting per-install
penetration while looking like real product usage.

Every event now carries:

- identity_persistence: durable (id loaded from a preexisting config —
  proven to survive a process boundary) | unknown (minted+persisted this
  run; an ephemeral HOME is indistinguishable from a genuine first run
  from inside one process) | process_only (persist failed). Sticky per
  process so a fresh install re-reading its own write cannot self-promote.
- config_write_outcome: ok | ok_unmirrored | failed for the identity-
  establishing write; absent when the id came from disk.
- invocation_id: random uuid per CLI process, so one invocation's events
  group even when the install identity is untrustworthy (unlike run_id,
  which needs an orchestrator to set HYPERFRAMES_RUN_ID).

Install metrics can then count only durable identities, and a daily
churn monitor can alert on the unknown share.

* fix(cli): require the anonymousId to come off disk before classifying durable

Review finding: materializeConfig mints a replacement anonymousId when a
hand-edited/image-baked config lacks one. That replacement only reaches
disk when the bucket-seed backfill happens to write; with a seed present
the read path performs no write at all, so the install re-mints a fresh
id every run while the unconditional durable branch stamped each of them
with the one label durable-only counting is allowed to trust.

durable now requires parseNonEmptyString(parsed.anonymousId): a minted
replacement classifies like a fresh mint — by the backfill write outcome
when that path runs (unknown/process_only), and process_only on the
no-write path where the id provably dies with the process. Two tests pin
both shapes.
2026-08-06 22:19:14 +08:00
Miguel ÁngelandCodex 96861cbafc perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails

* perf(studio): bound thumbnail decoding resources

* perf(studio): virtualize timeline thumbnail media

* perf(studio): prioritize timeline thumbnail work

* perf(studio-server): coordinate cancelable thumbnail generation

---------

Co-authored-by: Codex <codex@local>
2026-08-05 20:41:35 -07:00
Miguel Ángel 29f004cfc0 chore: release v0.7.94 (#3045) 2026-08-04 20:42:17 -07:00
Miguel Ángel b9233525b2 feat(cli): add device authorization login (#2836)
* feat(cli): add device authorization login

* refactor(auth): simplify device authorization flow

* fix(cli): harden device authorization flow

* refactor(cli): simplify device auth validation tests
2026-08-04 20:36:42 -07:00
Miguel Ángel a4eb602ee2 chore: release v0.7.93 (#3044) 2026-08-04 20:27:07 -07:00
James Russo bbdfee1166 fix(engine): validate remote download integrity (#2938) 2026-08-04 18:02:01 -07:00
Vance Ingalls f9ec93459f Merge pull request #2945 from heygen-com/ffprobe-6-argv-sweep
fix(cli,core,lint,producer,studio-server): terminate ffprobe options everywhere, pin the contract
2026-08-04 10:43:14 -07:00
Vance IngallsandClaude Opus 5 47564ab94c fix(cli,core,lint,producer): terminate ffprobe options at every call site
#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.

Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.

Terminated at all of them:
  producer/services/render/audioPadTrim.ts (x2)
  producer/plan-parity-analysis.ts
  cli/commands/init.ts
  cli/utils/webmAlphaCheck.ts
  cli/whisper/transcribe.ts (x2)
  core/mediaGradeAnalyzer.ts
  lint/hevcPreviewLint.ts

audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:34 -07:00
Vance Ingalls df8b9604fe chore: release v0.7.92 2026-08-04 01:49:05 -07:00
Vance Ingalls 8c6cf90ff9 chore: release v0.7.91 2026-08-03 22:48:00 -07:00
Vance Ingalls 71fd96bbf1 Merge pull request #2854 from heygen-com/feat/canary-rollouts
feat(core): percentage-based canary rollouts + calibration experiment
2026-08-03 22:35:37 -07:00
Miguel Ángel 4e7fcf7f2a fix(core): resolve sub-composition sibling asset paths everywhere (#2994)
Extends the studio-preview fix to the render path and the asset-discovery
utilities, which share the same resolver and had the same defect.

`rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref
authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the
composition's own directory when that sibling exists on disk; project-root refs
with no sibling (the registry's `assets/logo.png` convention) stay as authored.
Callers that can see the filesystem supply the probe, so the module stays free
of node:fs.

Also fixes a second defect in the inliner: `<head>` <link> hrefs and external
script srcs are hoisted into the root document but never went through the
rewrite at all, so even the documented `../` form escaped the project and 404'd
at render time.

Wired into the preview bundler, the producer compiler, the studio preview
builder, the HEVC preview lint, the project lint's asset scans, publish proxy
baking, and media-treatment source resolution.
2026-08-03 21:13:35 -07:00
Xinyu YANG 70cc4f59a1 fix(cli): stop bare --frame-check from swallowing the next flag (#2966)
citty parses string options greedily, so a bare --frame-check consumed
the following flag as its value (--caption-zone silently dropped,
--json disabled) and failed with an error blaming --caption-zone.

Normalize rawArgs at the check command boundary (bare --frame-check
followed by a flag or at end becomes --frame-check=), attribute
frame-check spec errors to --frame-check, and detect dash-prefixed
values with corrective guidance.

Fixes #2965
2026-08-04 04:03:04 +02:00
WaterrrForever bc6dbc7e21 fix(cli): stop dropping queued telemetry when process.exit races the final flush (#2970)
* fix(cli): stop dropping queued telemetry when process.exit races the final flush

Two exit-path defects introduced by the 0.7.65 process-lifecycle refactor:

1. The 'exit' handler returned early once finalizeCli had started, which
   also skipped the flushSync() fallback. When an agent-pipe EPIPE killed
   the process mid-flush (the NORMAL teardown under Claude Code / Codex),
   the still-queued render_complete was silently dropped — fleet delivery
   fell from ~90% (0.7.55-0.7.64) to ~35%. flushSync() is now
   unconditional: empty queue is a no-op, event uuids dedupe re-sends.

2. The EPIPE handlers set commandFailed unconditionally, so every piped
   successful render scored success:false in cli_command_result (fleet
   success rate collapsed 89% -> 5-25%). EPIPE now only marks failure
   when the pipe died before the render artifact was validated, matching
   the existing isRenderSucceeded() exemption on the uncaughtException
   path.

Regression tests cover both: flushSync-after-finalize, and EPIPE
before/after artifact validation.

* fix(cli): don't score a validated render as failed due to pre-artifact noise

Review follow-up: commandFailed can be set by noise that precedes artifact
validation — a stray unhandledRejection mid-render, or an EPIPE firing
before markRenderSucceeded on a run that still completes. Once the
artifact validates, that earlier noise must not flip the run's
cli_command_result to success:false. Genuine failures keep a non-zero
exit code and are still caught by the exitCode check.

Extracted commandSucceededForTelemetry() and applied it at both tracking
sites (finalizeCli and the exit handler), with a regression test.

* test(cli): pin the production-reachable producer of the stale-failure override

Review note: the pre-artifact-noise test drives the scenario with an
EPIPE, which only reaches 'render validates afterwards' because
process.exit is mocked — that sequence can't occur in production. Add a
test for the reachable producer: an unhandledRejection before validation
(the handler deliberately does not exit), followed by a validated render,
must score success:true at exit code 0. Verified red on the pre-override
cli.ts.
2026-08-04 02:02:55 +08:00
James Russo 1e51eaec2c chore: release v0.7.90 (#2958) 2026-08-02 21:57:19 -07:00
Miguel Ángel 411ada0d90 chore: release v0.7.89 (#2953) 2026-08-02 20:25:14 +02:00
Miguel Ángel 59fe32b633 Merge pull request #2952 from heygen-com/fix/2951-ignore-generated-cache-watcher
fix(cli): ignore generated caches in project watcher
2026-08-02 19:51:43 +02:00
Miguel Ángel 1d01b9f2cf fix(studio): exclude generated caches from project metadata 2026-08-02 17:37:51 +00:00
Miguel Ángel 3eb7b1ffd5 test(cli): clarify watcher exclusion coverage 2026-08-02 17:25:17 +00:00
Miguel Ángel bf739a4db2 fix(cli): ignore waveform cache in project watcher 2026-08-02 17:19:51 +00:00
Miguel Ángel 7ea8250f50 fix(cli): ignore generated caches in project watcher 2026-08-02 17:13:19 +00:00