Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions electron/stt/whisperServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ export class WhisperServerManager {

private recordError(message: string): void {
this.lastError = message;
// ponytail: also to the log, not only to the field. `lastError` is read by
// the `status` getter, which nothing on the transcribe path calls — so a
// missing or non-executable helper used to leave no trace anywhere in the
// main process, and the only way to find out was to instrument the code.
// The renderer does toast the failure now; a packaged build still needs a
// line someone can point at in a bug report.
console.error(`[stt] ${message}`);
}

/** True when a process is alive and a model is loaded. */
Expand Down
60 changes: 60 additions & 0 deletions src/lib/ai-edition/timeline/cursor-track.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,66 @@ describe("buildCursorTrack — compression", () => {
expect(worst).toBeLessThanOrEqual(0.02);
});

it("keeps the apex of an out-and-back — the case path-space simplification loses", () => {
// The regression test for the choice `simplifyAxis` makes: Douglas–Peucker per
// axis AGAINST TIME, not over the (x,y) path. Both give the same answer on a
// monotonic sweep — the traverse above cannot tell them apart — so this is the
// trajectory that separates them. The pointer runs out to 0.9 and comes back
// along the same line, which in path space deviates from its chord by nothing:
// the whole excursion collapses and interpolation then swears it never happened.
const outAndBack: CursorTrackSample[] = Array.from({ length: 200 }, (_, i) => ({
timeMs: i * 50,
cx: i <= 100 ? 0.1 + i * 0.008 : 0.9 - (i - 100) * 0.008,
cy: 0.5, // constant, so the path IS its own chord
assetId: "arrow",
interactionType: "move" as const,
}));
const track = build(outAndBack, 5);

// The turning point survives. Path-space simplification drops it and the best
// remaining point sits near 0.74, so this alone fails on the wrong implementation.
expect(Math.max(...track.points.map((p) => p.cx))).toBeGreaterThan(0.85);

// And the same bound the straight traverse claims still holds here.
let worst = 0;
for (const sample of outAndBack) {
const t = sample.timeMs / 1000;
const after = track.points.findIndex((p) => p.atSec >= t);
if (after <= 0) continue;
const a = track.points[after - 1];
const b = track.points[after];
const k = (t - a.atSec) / (b.atSec - a.atSec || 1);
worst = Math.max(worst, Math.abs(a.cx + k * (b.cx - a.cx) - sample.cx));
}
expect(worst).toBeLessThanOrEqual(0.02);
});

it("says so when the mandatory points push it over maxPoints", () => {
// The ceiling is soft: `maxPoints` budgets the rate and the gap floor, and the
// mandatory points are exempt. `sweep` samples every 50 ms and the shape here
// alternates on every index, so every sample is mandatory and the budget cannot
// hold — and the track has to say so rather than let the model read 100 rows as
// "within budget".
const flipping = sweep(200, { shape: (i) => (i % 2 === 0 ? "arrow" : "text") });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const track = buildCursorTrack({
assetId: "asset_1",
samples: flipping,
durationSec: 60,
clips: CLIPS,
hz: 5,
maxPoints: 20,
});

expect(track.pointCount).toBeGreaterThan(20);
expect(track.overBudget).toMatch(/ceiling of 20/);
// `truncated` is the other direction — the rate WAS cut — and stays its own signal.
expect(track.truncated).toBe(true);
});

it("leaves overBudget off when the ceiling holds", () => {
expect(build(sweep(400), 5).overBudget).toBeUndefined();
});

it("restores per-point virtualSec once the two axes diverge", () => {
const shiftedClips: AxcutClip[] = [
{
Expand Down
23 changes: 23 additions & 0 deletions src/lib/ai-edition/timeline/cursor-track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export interface CursorTrack {
shapeCount: number;
/** True when maxPoints forced a coarser rate than `hz` would give. */
truncated: boolean;
/** Present ONLY when the ceiling did not hold. `maxPoints` budgets the rate and
* the gap floor; the MANDATORY points are exempt and stack on top — the first
* and last sample, a pointer-shape change, a non-move event, the ends of a run
* longer than the max gap — so a capture rich in them lands above the ceiling.
* Absent means the budget held. It is a separate field from `truncated` on
* purpose: that one says "you are seeing less than you asked for", this one
* says the opposite. */
overBudget?: string;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/** When true, every point's virtual-timeline position equals its `atSec`, and
* `virtualSec` is omitted from the points. Goes false as soon as a clip is
* moved, cut or reordered and the two axes diverge. */
Expand Down Expand Up @@ -313,6 +321,20 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
return point;
});

// ponytail: the ceiling is soft, and saying so is the whole point. `maxPoints`
// is spent on the gap floor and the rate above; the mandatory points are added
// afterwards and answer to neither. Charging them to the budget instead would
// mean dropping a shape change to stay under it, which is the one thing this
// track must never do — so the overflow is reported rather than prevented. The
// field is absent when the budget held, so the common payload is unchanged.
const overBudget =
points.length > maxPoints
? `${points.length} points for a ceiling of ${maxPoints}: the mandatory points are ` +
`never dropped — the first and last sample, pointer-shape changes, non-move ` +
`events and the ends of a parked run — and this recording has enough of them ` +
`to land above the budget.`
: undefined;

return {
assetId,
sampleCount: samples.length,
Expand All @@ -321,6 +343,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack {
coveredSec,
shapeCount: shapeIndex.size,
truncated,
...(overBudget ? { overBudget } : {}),
virtualEqualsSource: !shifted,
timeBase:
"atSec is SOURCE time of the asset (the recording's own clock). virtualSec is the same " +
Expand Down
2 changes: 1 addition & 1 deletion technical-documentation/testing/manual-e2e-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Zoom, speed, annotation, and full-camera regions are stored against a clip in th
### Local transcription and captions — v1.8.0

- [ ] Confirm the transcript pane states that transcription runs locally and that no upload occurs when it is started.
- [ ] With the Whisper helper binary absent, activate the transcribe action and confirm the UI reports why nothing happened. Observed 2026-07-31: the button produces no message, no error state, and not one line in the main-process log — a build shipped without the helper gives the user a dead button and no way to find out. Verify against a build whose helper was deliberately not packaged, not only against a working one.
- [ ] With the Whisper helper binary absent, activate the transcribe action and confirm the UI reports why nothing happened, and that the main-process log carries a matching `[stt]` line. The failure now reaches a toast (`transcriptionStore.ts`) and the log (`whisperServer.ts`), but the text it shows is the helper's own — a sentence about a build script, which is not an answer for someone running a packaged build. Verify against a build whose helper was deliberately not packaged, not only against a working one.
- [ ] Run transcription in the packaged build and confirm the model is fetched or reused without an error about a missing cache directory.
- [ ] Confirm a second transcription reuses the cached model instead of downloading it again.
- [ ] Confirm the completed transcript reports the detected language on the media asset card.
Expand Down
35 changes: 25 additions & 10 deletions vitest.workbench.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
import { DEFAULT_TURN_TIMEOUT_MS } from "./workbench/lib/harness";

// ponytail: separate from vitest.config.ts on purpose — `workbench/` is outside
// that config's include glob AND outside tsconfig.test.json's include, so the
Expand All @@ -15,18 +16,32 @@ export default defineConfig({
globals: true,
environment: "node",
include: ["workbench/**/*.wb.ts"],
testTimeout: 120_000,
// ponytail: derived from the harness cutoff, and deliberately ABOVE it. A
// `.wb.ts` driving a live turn is cut by whichever deadline fires first;
// this one used to sit at 120 s while the harness moved to 300 s, so vitest
// killed the turn before the harness could classify it. Equal values would
// only make that race unbiased — the margin is what guarantees the harness
// wins and the run gets a TIMEOUT verdict instead of a dead worker.
testTimeout: DEFAULT_TURN_TIMEOUT_MS + 30_000,
reporters: ["default"],
// ponytail: the fixed cost of the suite is the dynamic
// `await import("deepagents")` in chat-service.ts:346 — hundreds of
// milliseconds, paid ONCE PER WORKER. One non-isolated thread makes the
// marginal cost of a new file its own runtime.
// ponytail: the fixed cost of the suite is `runChat`'s dynamic
// `await import("./deep-agent/service")` — measured at ~1.25 s, of which
// ~0.38 s is `langchain` itself and the rest is the agent graph, the tool
// schemas and the document model behind them. It is paid ONCE PER WORKER,
// and 7 of the 19 `.wb.ts` files reach that path, so isolating them would
// re-pay it six more times. One non-isolated thread makes the marginal
// cost of a new file its own runtime.
//
// The trade this accepts: `sessionsByProject` (chat-service.ts:36) and
// `messageCheckpointsBySession` (:48) are module Maps with no exported
// reset, so state now leaks between files. `runScenario` mints a unique
// projectId per run, which is what makes that safe — anything calling
// `runChat` directly would bypass the guard.
// (This used to name `deepagents`, which 0e53709a removed from the
// dependencies. The cost survived the package: it was never that factory,
// it was the graph underneath. Re-measure before trusting the figure —
// `await import(…)` timed inside a `.wb.ts` is enough.)
//
// The trade this accepts: `sessionsByProject` (chat-service.ts:38) and
// `messageCheckpointsBySession` (:50) are module Maps with no exported
// reset, so state leaks between files. The harness mints a unique
// projectId per run (`lib/harness.ts:239`), which is what makes that
// safe — anything calling `runChat` directly would bypass the guard.
pool: "threads",
maxWorkers: 1,
isolate: false,
Expand Down
49 changes: 39 additions & 10 deletions workbench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
Fait tourner l'agent LLM d'OpenScreen **sans interface graphique**, pour itérer vite sur les
prompts et sur le contexte fourni au modèle.

Ce fichier décrit le banc tel qu'il est. Ce qu'il a révélé et qui reste à traiter vit à côté, dans
[agent-improvement-leads.md](agent-improvement-leads.md).

Deux axes sont notés séparément, jamais moyennés ensemble :

| axe | question | source de vérité |
Expand Down Expand Up @@ -319,9 +322,16 @@ modèle qui apprend la forme de notre générateur obtient une bonne note sans a
`realScreencastDocument()` charge à la place une **vraie prise** — 66,154 s de screencast,
transcrites par le Whisper local (129 mots français horodatés, aucun silence stocké : ils se
déduisent des écarts), avec son sidecar de curseur (1521 échantillons, ~23 Hz, 11 formes de
pointeur, aucun clic). Les deux fichiers sont dans `workbench/fixtures/`, avec leur provenance et
la liste de ce qui en a été retiré : `workbench/fixtures/README.md`. Rien d'autre ne doit les
ouvrir.
pointeur, aucun clic). Les deux fichiers vivent dans `workbench/fixtures/`, **gitignoré**
(`.gitignore:126`) : ils ne sont dans aucun clone, et leur provenance est ce paragraphe — il n'y a
pas de `fixtures/README.md` versionné à aller lire, seulement celui que se garde qui possède la
prise. Rien d'autre que `lib/real-fixture.ts` ne doit les ouvrir.

Conséquence à connaître avant de lancer le banc : **44 tests L0 échouent dans un clone neuf**,
tous sur le même `ENOENT` (`l0/real-fixture.wb.ts`, `real-screencast-truth.wb.ts`,
`quality.wb.ts`, et `score.wb.ts` qui construit le document de chaque scénario du registre).
Fournir sa propre prise donnera d'autres chiffres que ceux assertés ici. Rien de tout cela n'est
vu par le CI, qui ne lance pas le banc.

Le document arrive **tel qu'il est sur le disque**, y compris son `cameraTrack: null` alors qu'un
fichier webcam existe à côté de l'enregistrement. Ce n'est pas un oubli de la copie ; c'est l'état
Expand All @@ -333,12 +343,17 @@ au-dessus de `electron/media/cursorSidecar.ts`, le parseur de production. Un sc
par `cursorReader:` — **exclusif** de `cursorTelemetry:`, que `defineScenario` refuse de voir
coexister avec lui.

**Ce que ça coûte au tour, mesuré** : `getCursorTrack` rend **356 points, 24 238 caractères**
(5 Hz + 56 points gardés pour des changements de forme du pointeur). C'est 2,3× le transcript
entier, et la requête suivante passe de ~17 k à ~45 k caractères. Les chiffres sont **assertés**
dans `l0/real-fixture.wb.ts` : ils bougent quand `buildCursorTrack` bouge, et c'est voulu.
Au-delà de ~25 000 caractères, c'est une trouvaille à signaler — pas un défaut à faire disparaître
en baissant `DEFAULT_TRACK_HZ`.
**Ce que ça coûte au tour, mesuré** : `getCursorTrack` rend **148 points, 7 797 caractères** —
une réduction en keyframes des 1521 échantillons, plus les points qu'aucune interpolation ne
remet (changement de forme du pointeur, événement autre qu'un déplacement, bornes d'un arrêt).
C'est **sous** le transcript (10 496), et un appel ajoute ~9 k à la requête. Les chiffres sont
**assertés** dans `l0/real-fixture.wb.ts` : ils bougent quand `buildCursorTrack` bouge, et c'est
voulu. Au-delà de ~25 000 caractères, c'est une trouvaille à signaler — pas un défaut à faire
disparaître en baissant `DEFAULT_TRACK_HZ`.

C'était 356 points et 24 238 caractères avant la réduction, soit 2,3× le transcript. Le chiffre
est gardé ici parce qu'il continue de circuler dans les notes de l'époque : s'il réapparaît
quelque part, c'est qu'on lit un texte périmé.

Quatre scénarios notés tournent maintenant sur cette fixture (`scenarios/real-screencast.scn.ts`).
Ce qu'ils mesurent a besoin de la vérité terrain — ce que l'utilisateur faisait, annoté à la main —
Expand Down Expand Up @@ -379,6 +394,20 @@ trajectoire » sont **deux checks séparés**.
repose** : observation live, ou mécanisme lu dans le code. Une prédiction n'y a pas sa place.
9. `npm run wb && npm run wb:typecheck && npx biome check --write workbench`.

### Répondre à un échec sans surajuster au banc

Un échec mesuré donne envie d'ajouter la ligne de prompt qui règle ce cas précis. Fait huit fois,
le prompt système devient la liste des réponses au jeu de tests, et le banc mesure sa propre
mémoire. Le garde-fou est une question, à se poser avant de committer :

> **Ce correctif se justifie-t-il sans mentionner le scénario qui l'a révélé ?**

Si la seule façon de le défendre est « sinon `describe-zooms` est rouge », ce n'est pas un
correctif, c'est une réponse apprise. Un correctif légitime se formule comme une propriété du
produit — « le modèle n'a aucun moyen de savoir qu'un `customScale` rend le `depth` inerte » — et
le scénario n'en est que le témoin. Cela vaut pour le prompt système comme pour les descriptions
d'outils, qui sont du prompt sous un autre nom.

### Où vit quoi

```
Expand All @@ -393,7 +422,7 @@ lib/persist.ts les tours bruts sur disque, bornés, derrière la barrière a
lib/language.ts les prédicats de texte partagés, épinglés dans les deux sens
lib/fixtures.ts les documents de référence, écrits en code
lib/real-fixture.ts le chargeur de la PRISE RÉELLE (projet + sidecar de curseur sur disque)
fixtures/ les deux fichiers de cette prise, et d'où ils viennent (README.md)
fixtures/ les deux fichiers de cette prise — GITIGNORÉ, absent de tout clone
lib/score.ts deux axes, porte min(), checks structurels injectés partout
lib/baseline.ts le ratchet bidirectionnel
l0/ sans LLM, sans réseau (~0,4 s)
Expand Down
Loading
Loading