@crievo/omni 0.1.7 → 0.1.9

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.
Files changed (76) hide show
  1. package/omnivideo.config.example.json +2 -0
  2. package/package.json +1 -1
  3. package/reference/cloud.md +15 -1
  4. package/reference/learnings.md +7 -0
  5. package/reference/scenes-contract.md +3 -2
  6. package/reference/studio.md +3 -2
  7. package/studio/api/app.ts +4 -0
  8. package/studio/api/routes/cost.ts +13 -0
  9. package/studio/api/routes/me.ts +8 -0
  10. package/studio/api/services/agent-persona.ts +1 -0
  11. package/studio/api/services/agent-tools.ts +2 -2
  12. package/studio/api/services/cost.ts +22 -0
  13. package/studio/api/services/me.ts +67 -0
  14. package/studio/api/services/sessions.ts +8 -2
  15. package/studio/api/services/sound.ts +17 -6
  16. package/studio/runner/runner.ts +57 -10
  17. package/studio/shared/omni-screens.ts +1 -1
  18. package/studio/shared/schemas/cost.ts +10 -0
  19. package/studio/shared/types/api.ts +77 -1
  20. package/studio/studio.ts +27 -2
  21. package/studio/web/components/AppShell.tsx +11 -3
  22. package/studio/web/components/FirstConnection.tsx +222 -0
  23. package/studio/web/components/OfflineNotice.tsx +59 -0
  24. package/studio/web/components/SetupSteps.tsx +63 -0
  25. package/studio/web/components/Sidebar.tsx +10 -25
  26. package/studio/web/components/StudioOffline.tsx +48 -50
  27. package/studio/web/components/UserCard.tsx +79 -0
  28. package/studio/web/components/icons.tsx +2 -0
  29. package/studio/web/hooks/useComputers.ts +55 -0
  30. package/studio/web/hooks/useCost.ts +11 -0
  31. package/studio/web/hooks/useMe.ts +32 -0
  32. package/studio/web/hooks/useSetupProgress.ts +54 -0
  33. package/studio/web/lib/me.ts +17 -0
  34. package/studio/web/lib/query-client.ts +6 -0
  35. package/studio/web/lib/settings-categories.ts +2 -2
  36. package/studio/web/lib/setup-retry.ts +29 -0
  37. package/studio/web/lib/sound-timeline.ts +33 -0
  38. package/studio/web/lib/studio-offline.ts +105 -59
  39. package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
  40. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
  41. package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
  42. package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
  43. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
  44. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
  45. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
  46. package/studio/web/pages/SettingsPage/index.tsx +15 -1
  47. package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
  48. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
  49. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
  50. package/studio/web/router.tsx +8 -0
  51. package/studio/web/styles/components/setup-steps.css +163 -0
  52. package/studio/web/styles/components/user-card.css +121 -0
  53. package/studio/web/styles/index.css +2 -0
  54. package/studio/web/styles/pages/settings/shell.css +79 -0
  55. package/studio/web/styles/pages/settings/usage.css +47 -0
  56. package/studio/web/styles/pages/studio-offline.css +224 -38
  57. package/tools/cost.mjs +114 -0
  58. package/tools/lib/agents.mjs +3 -2
  59. package/tools/lib/channel.mjs +1 -1
  60. package/tools/lib/cloud-user.mjs +58 -0
  61. package/tools/lib/cost.mjs +357 -0
  62. package/tools/lib/deps.mjs +27 -12
  63. package/tools/lib/oauth-client.mjs +28 -14
  64. package/tools/lib/pages-style.mjs +67 -0
  65. package/tools/lib/pairing.mjs +1 -1
  66. package/tools/lib/schedule/schedule.mjs +1 -1
  67. package/tools/lib/self-update.mjs +122 -0
  68. package/tools/lib/setup-progress.mjs +315 -0
  69. package/tools/lib/up.mjs +176 -61
  70. package/tools/metrics.mjs +6 -1
  71. package/tools/omni.mjs +41 -16
  72. package/tools/omnichat.mjs +45 -9
  73. package/tools/platforms.mjs +75 -14
  74. package/tools/runner.mjs +6 -6
  75. package/tools/setup.mjs +18 -5
  76. package/tools/sound.mjs +198 -94
@@ -136,6 +136,7 @@
136
136
  ]
137
137
  },
138
138
  "kit": "kit/sfx",
139
+ "default_kit": "motion-ui",
139
140
  "roles": {
140
141
  "click": {
141
142
  "db": -4
@@ -293,6 +294,7 @@
293
294
  "omnichat": false,
294
295
  "metrics": false,
295
296
  "schedule": false,
297
+ "user": false,
296
298
  "studio": false,
297
299
  "studio_url": ""
298
300
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crievo/omni",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "OmniVideo no seu computador: o CLI omni, o studio local e as ferramentas do pipeline.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@ and the studio talk to the Worker with `OMNICHAT_CLOUD_SECRET` (the Worker's `SY
39
39
  back if it stops, log in `.runner/studio.log`; `service restart` after a `git pull`). Setup:
40
40
  `node cloud/studio/setup-auth.mjs` (tables and secrets), then `npx wrangler deploy` in `cloud/studio`.
41
41
  Every write that goes through to the Mac is logged (email, method, path, status; the last 2000, at
42
- `<studio>/runner/audit`) and limited to 120 a minute per user (429 beyond it); a runner and a hub on
42
+ Configurações → Agente de IA, from `<studio>/session/audit`) and limited to 120 a minute per user (429 beyond it); a runner and a hub on
43
43
  different protocol numbers refuse each other and say which side is old.
44
44
  Protocol in `tools/lib/channel.mjs`, plan in `docs/specs/phase-1-split-api.md`.
45
45
  - **Each job has an off switch in the database** (`settings`: `active`, `metrics_active`,
@@ -55,6 +55,20 @@ and the studio talk to the Worker with `OMNICHAT_CLOUD_SECRET` (the Worker's `SY
55
55
 
56
56
  Turning back: the flag to `false` and the push again (the cloud stops), and the local agent on.
57
57
 
58
+ ## The per-user cloud (Phase 3, in progress)
59
+
60
+ The jobs above are moving from the shared `omnichat` Worker into one Durable Object per user, `UserCloud` in the studio
61
+ Worker (`cloud/studio/src/cloud-*.mjs`), with their own config, secrets, webhook (`/hook/<cloudId>`), media
62
+ (`u/<cloudId>/` in R2) and one alarm in place of the crons and the KV gate. Step 2 (the jobs inside the object) is built and
63
+ tested but not deployed or used by a machine yet; the `omnichat` Worker above still does the real work until steps 3–4.
64
+ Routes, flags and what differs from the sketch: `docs/specs/phase-3-per-user-cloud.md`, "Step 2, as built".
65
+
66
+ On the machine, `cloud.user: true` (with `cloud.studio_url`, signed in with the Omni login) points the omnichat, the
67
+ Instagram account and the metrics at that cloud instead of the Worker; publishing (the schedule, the media published
68
+ now, the YouTube/TikTok credentials) stays on the Worker until the last step. Off by default: turning it on points the
69
+ CLIs at an empty cloud, so see "Step 3, as built so far" in the spec for the order (push first, and the Worker stops
70
+ answering before the new cloud starts).
71
+
58
72
  ## Free plans and their limits
59
73
 
60
74
  - **Workers Free:** 10 ms of CPU and 50 subrequests per run. That is why a cloud pass sends at most 5 DMs
@@ -814,3 +814,10 @@ these voices).
814
814
  - **Symptom:** none yet; found reading the queue for a Windows port.
815
815
  - **Cause:** on Windows, Python's `os.kill(pid, sig)` treats `sig == 0` as `CTRL_C_EVENT` and sends it to the process group of `pid`; any other number is a `TerminateProcess`. The Node `process.kill(pid, 0)` is a real probe on both systems.
816
816
  - **Fixed:** `tools/job_queue.py` asks the kernel (`OpenProcess` + `GetExitCodeProcess`, `STILL_ACTIVE`) on Windows and keeps `os.kill` plus the `ps` zombie check elsewhere. Also on Windows: `select()` works on sockets only (the guardian reads the Node pipe in a thread), an atomic replace fails while a reader has the file open (retry), and a text file is written in the code page unless `encoding="utf-8"` says otherwise.
817
+
818
+ ## A turn's `total_cost_usd` is the session's running total, not the turn's (18/10/2026)
819
+
820
+ - **Symptom:** the studio's session list showed US$ 220,93 for the Grok video, whose last turn closed at US$ 53,79. Measuring cost by hand from the transcripts had given a different number each time.
821
+ - **Cause:** `claude -p --resume` keeps adding to the conversation's totals. The `result` of turn N carries `total_cost_usd`, `modelUsage[].costUSD` and `duration_api_ms` since the conversation began (same `session_id`, only growing: 4,01 → 10,07 → 22,98 → … → 53,79). Summing the turns' costs counts the early turns again and again. The per-message `usage` in the stream-json is no substitute: it is a partial reading, with the output tokens undercounted (about 10 to 30 % under the real cost).
822
+ - **Fixed:** `tools/lib/cost.mjs` takes a turn's cost as the difference to the previous `result` of the same `session_id`, and uses the messages' `usage` only to split that cost between forks (scaled per model to what the `result` says). A turn that never closed (running, interrupted) is an estimate, and the next `result` gives that estimate back so the session still adds up to its last total. `summarizeSession` counts each conversation once, by its last total. Rule: to measure what an agent spent, run `node tools/cost.mjs`; do not add up `result` costs by hand, and count messages by `message.id`, never by transcript line.
823
+
@@ -45,7 +45,7 @@ the others only read it. A full example is in `templates/scenes.example.json`.
45
45
  | `element` | 3 | name of what enters; becomes the CSS variable `--b-<element>` |
46
46
  | `t` | 3 | provisional time in seconds, used until a voice exists |
47
47
  | `enters_with` | 4 | reference to the line: `c01f2`, `c01f2:end`, `c01f2:palavra`, `c01f2:palavra#2`, with optional `+0.3`/`-0.3`. The word is the spoken (PT) word; `:fim` is still accepted as a legacy alias of `:end` |
48
- | `sound` | 3, optional | effect on the beat: a file name in the global kit (`kit/sfx/kit.json`) or in `<video>/sfx/`, captured from Epidemic and registered in `sfx/sources.json` (`"clique-seco.mp3"`), an object with adjustments, or a list of them (layers at the same instant). Fields below |
48
+ | `sound` | 3, optional | effect on the beat: a file name in a sound kit (`kit/sfx/<kit>/`, `"clique-seco.mp3"`) or in `<video>/sfx/` (captured from Epidemic or sent by the user, registered in `sfx/sources.json`), an object with adjustments, or a list of them (layers at the same instant). Fields below |
49
49
 
50
50
  `tools/timing.mjs` → `beatTime(scene, beat)` resolves it: `enters_with` if
51
51
  present, otherwise `t`.
@@ -54,7 +54,8 @@ present, otherwise `t`.
54
54
 
55
55
  | field | default | what it is |
56
56
  |---|---|---|
57
- | `name` | required | file in the video's `sfx/` or in the global kit (`kit/sfx/`); the video's one wins. From the kit, the role's `db` is added (`sound.roles`) |
57
+ | `name` | required | file in the video's `sfx/` or in a kit; the video's one wins. From a kit, the role's `db` is added (`sound.roles`) |
58
+ | `kit` | the default kit (`sound.default_kit`, `motion-ui`) | which kit the file comes from when it is not in the video's `sfx/`: `"epidemic-sounds"` for the Epidemic effects. A sound never falls back to another kit: a name only in another kit fails and says which one to name |
58
59
  | `anchor` | `"peak"` | which point of the effect lands on the instant: `"peak"` (the loudest sample) or `"start"` |
59
60
  | `offset` | 0 | seconds after the instant (negative: before) |
60
61
  | `db` | 0 | adjustment over the default level: `sound.level_db` below the voice's integrated loudness |
@@ -589,11 +589,12 @@ comes, that it be the studio's own.
589
589
  | `GET /api/videos/<slug>/captions/page` | the video's `captions.html` (`?preview=1` marks the safe area and accepts `postMessage({ position, t })`) |
590
590
  | `GET /api/videos/<slug>/captions/frame?t=` | JPEG of the motion at second `t` of the video, from the scene's MP4 |
591
591
  | `POST /api/videos/<slug>/captions/position` | `{ scope: "video" \| "scene" \| "caption", target?, value }` → `captions` in `scenes.json`; `value: null` removes a scene's or caption's adjustment |
592
- | `GET /api/videos/<slug>/sound` | sound timeline: total, scenes, lines (video time), sound places (each beat with its instant and each scene change, with the sounds), the bed, the catalog (kit by role and the video's `sfx/`, measured) and the export's base MP4 |
592
+ | `GET /api/videos/<slug>/sound` | sound timeline: total, scenes, lines (video time), sound places (each beat with its instant and each scene change, with the sounds), the bed, the catalog (the kits by role, each effect named `kit/name.mp3`, and the video's `sfx/`, measured) and the export's base MP4 |
593
593
  | `POST /api/videos/<slug>/sound/plan` | `{ places, bed }` (draft) → the `sound.mjs` plan with the URL of each finished WAV under `/sound-draft/<slug>/…`; nothing is written to the video |
594
594
  | `POST /api/videos/<slug>/sound` | `{ places, bed }` → `sound` on the beats, `transition.sound` and `bed` in `scenes.json` (validated; defaults are dropped) |
595
595
  | `POST /api/videos/<slug>/sound/reassemble` | runs `assemble.mjs` (waits for its turn in the queue) |
596
- | `GET /kit/<name>` | MP3 from the global kit (`kit/sfx/`), for the waveform |
596
+ | `GET /kit/<kit>%2F<name>` | MP3 from a sound kit (`kit/sfx/<kit>/`), by the catalog's name `kit/name.mp3`, for the waveform |
597
+ | `GET /api/cost` | `tools/lib/cost.mjs`: what the Claude sessions spent, by video, stage, fork, model and day (`?days=N`); reads `.sessions/`, cache in `.cost/` |
597
598
  | `GET /api/usage` | `tools/usage.py summary --json`: Fish balance, Cartesia quota, usage per video and latest generations (only reads the local log) |
598
599
  | `POST /api/usage/refresh` | the same with `--live`: asks the platforms now and writes the reading to `.usage/` |
599
600
  | `GET /api/analytics` | `tools/analysis.mjs context` (account, medians at the current reading and at the 24 h and 72 h milestones, also per thousand views, the channel's hour-by-hour `progression` and the `publications` in it, the playbook's `before` range, each Reel with metrics, strategy and hook, the agent) plus the text of the latest channel analysis |
package/studio/api/app.ts CHANGED
@@ -28,6 +28,7 @@ import { onboarding } from "./routes/onboarding";
28
28
  import { publish } from "./routes/publish";
29
29
  import { queue } from "./routes/queue";
30
30
  import { references } from "./routes/references";
31
+ import { me } from "./routes/me";
31
32
  import { runner } from "./routes/runner";
32
33
  import { schedule } from "./routes/schedule";
33
34
  import { services } from "./routes/services";
@@ -36,6 +37,7 @@ import { settings } from "./routes/settings";
36
37
  import { sound, SOUND_UPLOAD_PATH } from "./routes/sound";
37
38
  import { thumbnail } from "./routes/thumbnail";
38
39
  import { usage } from "./routes/usage";
40
+ import { cost } from "./routes/cost";
39
41
  import { videos } from "./routes/videos";
40
42
  import { voices } from "./routes/voices";
41
43
 
@@ -63,8 +65,10 @@ export function createApp({ port }: AppOptions) {
63
65
  .route("/", references)
64
66
  .route("/", queue)
65
67
  .route("/", runner)
68
+ .route("/", me)
66
69
  .route("/", services)
67
70
  .route("/", usage)
71
+ .route("/", cost)
68
72
  .route("/", analytics)
69
73
  .route("/", settings)
70
74
  .route("/", clone)
@@ -0,0 +1,13 @@
1
+ /* Agent cost (settings Uso e custo): what the Claude sessions spent, by video, stage and model. */
2
+
3
+ import { router } from "../lib/hono";
4
+ import { shaped } from "../lib/result";
5
+ import { query } from "../lib/validate";
6
+ import { getCost } from "../services/cost";
7
+ import { costQuery } from "../../shared/schemas/cost";
8
+ import type { AgentCost } from "../../shared/types/api";
9
+
10
+ export const cost = router().get("/cost", query(costQuery), async c => {
11
+ const days = Number(c.req.valid("query").days) || null;
12
+ return c.json(shaped<AgentCost>(await getCost(days)));
13
+ });
@@ -0,0 +1,8 @@
1
+ /* Who is using the studio (services/me.ts), for the menu's user card at 127.0.0.1, and its sign-out. */
2
+
3
+ import { router } from "../lib/hono";
4
+ import { getMe, signOutOmni } from "../services/me";
5
+
6
+ export const me = router()
7
+ .get("/me", async c => c.json({ me: await getMe() }))
8
+ .post("/me/logout", async c => c.json(await signOutOmni()));
@@ -27,6 +27,7 @@ O que você faz, e só isso:
27
27
  - Olhar a composição e o resultado (inspect_content): o quadro de uma cena (ou o do segundo at), a folha de contato dela (o movimento), o corte que a abre, o wireframe, várias cenas lado a lado, o vídeo exportado e os slides do carrossel. Para "essa cena está boa?", pergunte algo objetivo (o elemento principal aparece? o texto é legível?).
28
28
  - Mostrar as coisas abrindo a tela delas, não no seu painel: "mostra as thumbs" é navigate com stage publish e tab thumbnail; "o Omnichat desse vídeo", navigate com screen omnichat_rule e o slug (content carousel para o do carrossel); o vídeo exportado, stage publish; o carrossel, stage carousel-publish; os números, video_metrics. Um pedido (thumbnail, publicação, Omnichat, aprovação, alteração) já abre sozinho a tela onde o resultado aparece: não chame navigate para ela de novo. O card no seu painel (show_card) é só quando o usuário pedir para ver ali, sem sair da tela onde está.
29
29
  - No editor de um vídeo já montado: tocar e pausar (control_editor) e adicionar uma música ou um efeito da Epidemic Sound (add_epidemic_sound, com a busca em inglês, como "calm piano"), só quando o usuário pedir. Avise que o Claude do editor baixa a faixa em um ou dois minutos e que ela entra nos sons do vídeo.
30
+ - Os efeitos vêm de kits (pastas de sons). O padrão é o kit motion-ui (sons de interface sintetizados, do bencho.dev): quando o usuário pede um som sem dizer de onde, é desse kit. O kit epidemic-sounds tem os efeitos capturados da Epidemic. Quando o usuário pedir para editar um som, diga de qual kit ele é, e não troque o kit de um som sem pedido: o Claude do editor é quem muda o som, então passe o kit no pedido. add_epidemic_sound baixa só para este vídeo, não para um kit.
30
31
  - A agenda (get_schedule): o que está agendado e o que foi publicado em cada dia, com a hora, as plataformas e o Omnichat de cada um. Para "o que sai amanhã", "o que tem essa semana", "o que publiquei ontem".
31
32
  - O Omnichat de um conteúdo publicado ou agendado (omnichat_status, set_omnichat): você configura sozinha, sem card. Leia a regra (omnichat_status), decida e grave com set_omnichat: palavras, link, o texto da DM, as respostas públicas e as fases da DM (botão; só para quem segue, com a mensagem de quem não segue e o "Receber sem seguir"; o link num botão). O Omnichat de um conteúdo se configura sempre na tela dele, /omnichat/video/<slug> (screen omnichat_rule), nunca na etapa Publicar: ela abre sozinha com o set_omnichat e mostra a regra mudando. Depois diga numa frase o que gravou e, se faltar algo para ligar (missing_to_enable), o que falta. Ligar as respostas automáticas responde em nome da conta: só quando o usuário pedir para ligar, com essas palavras; desligar, quando ele pedir.
32
33
  - Quando o pedido depende de um link ou de um texto que o usuário precisa colar (um vídeo para virar estilo visual, uma página, um roteiro pronto), chame ask_in_chat e diga numa frase o que colar no chat. O chat abre com o campo pronto; se o usuário responder por voz que não quer ou mudar de assunto, ele fecha sozinho. Não peça para ele ditar um link.
@@ -853,7 +853,7 @@ const SCHEDULE_STATE: Record<string, string> = {
853
853
  scheduled: "agendado",
854
854
  publishing: "publicando agora",
855
855
  failed: "falhou",
856
- missed: "perdido (o Mac estava desligado)",
856
+ missed: "perdido (a máquina estava desligada)",
857
857
  };
858
858
 
859
859
  export async function toolSchedule(from?: string, to?: string, now = new Date()): Promise<ToolOutput> {
@@ -1858,7 +1858,7 @@ async function toolOmnichatStatus(slug?: string, content?: "video" | "carousel")
1858
1858
  account: st.account?.ready ? { username: st.account.username ?? null } : { problem: st.account?.reason ?? null },
1859
1859
  comments_permission: st.comments?.ready ?? null,
1860
1860
  /* who answers the comments: the cloud Worker, or this Mac's launchd agent */
1861
- answered_by: st.agent?.cloud?.ready ? "nuvem" : st.agent?.loaded ? "este Mac" : "ninguém (agente parado)",
1861
+ answered_by: st.agent?.cloud?.ready ? "nuvem" : st.agent?.loaded ? "esta máquina" : "ninguém (agente parado)",
1862
1862
  pause: st.pause,
1863
1863
  rules: (st.rules ?? []).map((r: any) => ({
1864
1864
  content: r.type,
@@ -0,0 +1,22 @@
1
+ /* What the agents spent on the videos. The accounting lives in tools/lib/cost.mjs, which
2
+ tools/cost.mjs (the terminal) also uses; the studio only shows what it returns.
3
+ A finished turn is cached on disk, but the open ones are read again, so the answer is kept for
4
+ a few seconds: the UI reloads on every queue change. */
5
+
6
+ import { readTurns, summarize } from "../../../tools/lib/cost.mjs";
7
+
8
+ const cached = new Map<string, { at: number; data: Promise<unknown> }>();
9
+
10
+ export function getCost(days: number | null) {
11
+ const key = String(days ?? "all");
12
+ const hit = cached.get(key);
13
+ if (hit && Date.now() - hit.at < 5000) return hit.data;
14
+ const data = readTurns({ since: days ? Date.now() - days * 86_400_000 : null }).then(rows => ({
15
+ days,
16
+ ...summarize(rows),
17
+ }));
18
+ cached.set(key, { at: Date.now(), data });
19
+ /* a failed reading is not kept */
20
+ data.catch(() => cached.delete(key));
21
+ return data;
22
+ }
@@ -0,0 +1,67 @@
1
+ /* Who is using the studio, for the menu's user card (GET /api/me). The hosted page doesn't ask this: the Worker
2
+ answers /session/me from the signed-in session, even while this computer is away. Here, at 127.0.0.1, it is
3
+ the account signed in to Omni on this computer (the plugin's login: the Worker's /account), else the Claude
4
+ Code account that runs the agent (its name and email, read from its own files), which also fills in a name the
5
+ Omni account doesn't carry yet. Without either, nobody:
6
+ the card hides. Cached a minute: the menu asks on every page and the Worker needn't hear that often. */
7
+
8
+ import { accountsOf, loginOf } from "../../../tools/lib/agents.mjs";
9
+ import { accessToken, logout, readTokens } from "../../../tools/lib/oauth-client.mjs";
10
+ import { cloudSettings } from "../../../tools/platforms.mjs";
11
+ import type { Me } from "../../shared/types/api";
12
+ import { readConfig } from "./settings";
13
+
14
+ const TTL_MS = 60_000;
15
+ let cached: { at: number; me: Me | null } | null = null;
16
+
17
+ /* the Omni account of this computer: its login's token trades for the account at the Worker */
18
+ async function omniAccount(): Promise<Me | null> {
19
+ const url = cloudSettings().studio.url;
20
+ if (!url) return null;
21
+ try {
22
+ const token = await accessToken(url);
23
+ if (!token) return null;
24
+ const r = await fetch(`${url}/account`, {
25
+ headers: { authorization: `Bearer ${token}` },
26
+ signal: AbortSignal.timeout(4000),
27
+ });
28
+ if (!r.ok) return null;
29
+ const a = (await r.json()) as { email?: string | null; name?: string | null; image?: string | null };
30
+ return a.email ? { name: a.name ?? null, email: a.email, image: a.image ?? null, source: "omni" } : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /* the Claude Code account the agent runs on */
37
+ function claudeAccount(): Me | null {
38
+ for (const a of accountsOf(readConfig() ?? {}) as { provider: string }[]) {
39
+ if (a.provider !== "claude") continue;
40
+ const login = loginOf(a) as { email: string | null; name?: string | null } | null;
41
+ if (login?.email) return { name: login.name ?? null, email: login.email, image: null, source: "claude" };
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export async function getMe(now = Date.now()): Promise<Me | null> {
47
+ if (cached && now - cached.at < TTL_MS) return cached.me;
48
+ const claude = claudeAccount();
49
+ const omni = await omniAccount();
50
+ /* the Worker may not know the name yet: the Claude Code account of the same email does */
51
+ const me = omni ? { ...omni, name: omni.name ?? (claude?.email === omni.email ? claude.name : null) } : claude;
52
+ cached = { at: now, me };
53
+ return me;
54
+ }
55
+
56
+ /* Sair da conta Omni deste computador: the same as `omni logout`. The Worker is told to end this computer's login
57
+ and the tokens here are forgotten; the card then falls back to the Claude Code account, or hides. */
58
+ export async function signOutOmni(): Promise<{ ok: boolean; output?: string; error?: string }> {
59
+ const url = cloudSettings().studio.url;
60
+ if (!url || !readTokens(url)) return { ok: false, error: "Este computador não está logado na conta Omni." };
61
+ const confirmed = await logout(url);
62
+ cached = null;
63
+ return {
64
+ ok: true,
65
+ output: confirmed ? "Saiu da conta Omni." : "Saiu da conta Omni aqui; o studio hospedado não confirmou.",
66
+ };
67
+ }
@@ -336,7 +336,13 @@ function stateOf(r: SessionRecord): { state: SessionState; result: TurnResult |
336
336
  export function summarizeSession(r: SessionRecord) {
337
337
  const { state, result, now } = stateOf(r);
338
338
  const t = r.turns.at(-1);
339
- const costs = r.turns.map(x => turnInfo(r, x).result?.cost ?? 0);
339
+ /* a turn's `cost` is the conversation's running total (--resume keeps adding to it), so a
340
+ conversation counts once, by its last total; a handoff to another account is a new one */
341
+ const running = new Map<string, number>();
342
+ for (const x of r.turns) {
343
+ const cost = turnInfo(r, x).result?.cost;
344
+ if (cost != null) running.set(x.engine_id ?? r.id, Math.max(running.get(x.engine_id ?? r.id) ?? 0, cost));
345
+ }
340
346
  const account = t ? accountOfTurn(r, t) : null;
341
347
  let activity = Date.parse(r.created_at);
342
348
  try {
@@ -355,7 +361,7 @@ export function summarizeSession(r: SessionRecord) {
355
361
  turns: r.turns.length,
356
362
  turn: t ? { n: t.n, origin: t.origin, prompt: t.prompt, started_at: t.started_at, pid: t.pid, result } : null,
357
363
  now,
358
- total_cost: +costs.reduce((a, b) => a + b, 0).toFixed(4),
364
+ total_cost: +[...running.values()].reduce((a, b) => a + b, 0).toFixed(4),
359
365
  agent: t ? { provider: providerOf(t), account: account?.id ?? null, label: account?.label ?? null } : null,
360
366
  };
361
367
  }
@@ -18,7 +18,8 @@ import {
18
18
  sanitizeBed,
19
19
  sanitizeSpeed,
20
20
  finalClock,
21
- kitDir,
21
+ kitDirOf,
22
+ kitNames,
22
23
  } from "../../../tools/sound.mjs";
23
24
  import { videoClips, lineClips, lineDuration } from "../../../tools/soundtrack.mjs";
24
25
  import { normalizeCuts } from "../../../tools/cuts.mjs";
@@ -28,7 +29,12 @@ import type { ApiResult } from "./sessions";
28
29
  type Result = { ok: true; output: string } | { ok: false; error: string };
29
30
  const DRAFT_DIR = join(ROOT, ".sound-draft");
30
31
 
31
- const normalizeSound = (s: any) => (typeof s === "string" ? { name: s } : { ...s });
32
+ /* a sound from scenes.json: its kit's effect is shown by the ref "kit/name.mp3", as the catalog names it */
33
+ const normalizeSound = (s: any) => {
34
+ const o = typeof s === "string" ? { name: s } : { ...s };
35
+ if (o.kit && typeof o.name === "string" && !o.name.includes("/")) o.name = `${o.kit}/${o.name}`;
36
+ return o;
37
+ };
32
38
  const asList = (sound: any) => (sound == null ? [] : (Array.isArray(sound) ? sound : [sound]).map(normalizeSound));
33
39
 
34
40
  /* the export's base MP4 (without the speed), for the timeline picture, and the cuts it already has */
@@ -164,10 +170,15 @@ export function draftFile(slug: string, rest: string): string | null {
164
170
  return abs.startsWith(base + sep) && abs.endsWith(".wav") && existsSync(abs) ? abs : null;
165
171
  }
166
172
 
167
- /* a file from the global kit, for the waveform */
168
- export function kitFile(name: string): string | null {
169
- const abs = resolve(kitDir, name);
170
- return abs.startsWith(resolve(kitDir) + sep) && abs.endsWith(".mp3") && existsSync(abs) ? abs : null;
173
+ /* a file of a kit, for the waveform: the ref "kit/name.mp3" (the catalog's name for a kit effect) */
174
+ export function kitFile(ref: string): string | null {
175
+ const i = ref.indexOf("/");
176
+ const kit = i > 0 ? ref.slice(0, i) : "";
177
+ const name = i > 0 ? ref.slice(i + 1) : "";
178
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(kit) || !kitNames().includes(kit)) return null;
179
+ const dir = kitDirOf(kit);
180
+ const abs = resolve(dir, name);
181
+ return abs.startsWith(resolve(dir) + sep) && abs.endsWith(".mp3") && existsSync(abs) ? abs : null;
171
182
  }
172
183
 
173
184
  /* writes the draft to scenes.json: the sound of each beat, each transition, the bed and the cuts */
@@ -25,6 +25,9 @@ const DEAD_MS = 75_000;
25
25
  const KEY_CHECK_MS = 30_000;
26
26
  /* a refused key pairs again by itself at most this often (the approval page opens on this Mac) */
27
27
  const PAIR_EVERY_MS = 60 * 60_000;
28
+ /* with no login (never entered, signed out, or disconnected at the studio) the approval page opens on this computer by
29
+ itself, at most this often: it asks for a click, and someone who closed it didn't want it every hour */
30
+ const LOGIN_EVERY_MS = 6 * 60 * 60_000;
28
31
 
29
32
  export type RunnerOptions = {
30
33
  /** the hosted studio, https://… */
@@ -42,6 +45,18 @@ export type RunnerOptions = {
42
45
  /** pairs this Mac again (opens the approval page, waits, saves the new key): the key, or null. Called when
43
46
  * the cloud refuses the key, never after a "Desconectar", which is the user's own decision */
44
47
  pair?: () => Promise<string | null>;
48
+ /** signs this computer in again: opens the approval page in the browser and waits for the user to approve
49
+ * (tools/lib/oauth-client.mjs login). Called when there is no usable login, so nobody has to open a terminal or
50
+ * Claude Code to get back in; the user only approves */
51
+ login?: () => Promise<void>;
52
+ /** the hub says this machine and the hosted studio speak different protocols (UPGRADE). Which side is old isn't
53
+ * in the close reason, so whoever answers this looks (tools/lib/self-update.mjs: is there a newer version of
54
+ * the CLI?) and updates this machine when it is the one behind. Called on every retry, a minute apart. */
55
+ onOutdated?: () => void | Promise<void>;
56
+ /** the waits, in ms; the tests shorten them */
57
+ keyCheckMs?: number;
58
+ loginEveryMs?: number;
59
+ protocolRetryMs?: number;
45
60
  };
46
61
 
47
62
  /** Range on a media response: 206 from a Blob slice, 416 out of bounds; the rest as it is. */
@@ -107,6 +122,10 @@ export function startRunner(o: RunnerOptions) {
107
122
  let hinted = false;
108
123
  let pairing = false;
109
124
  let pairedAt = 0;
125
+ const keyCheckMs = o.keyCheckMs ?? KEY_CHECK_MS;
126
+ const loginEveryMs = o.loginEveryMs ?? LOGIN_EVERY_MS;
127
+ let signingIn = false;
128
+ let signedInAt = 0;
110
129
 
111
130
  const serve = async (request: Request) => withRange(request, await o.handle(asLocal(request, o.port)));
112
131
 
@@ -114,9 +133,10 @@ export function startRunner(o: RunnerOptions) {
114
133
  /* an unreachable studio while refreshing says nothing about the login: try again later */
115
134
  const token = await Promise.resolve(o.token()).catch(() => "");
116
135
  if (!token || token === revoked) {
117
- if (!hinted) log("aviso: este Mac não está pareado com o studio hospedado: npx -y @crievo/omni login");
136
+ if (!hinted) log("aviso: esta máquina não está conectada ao studio hospedado");
118
137
  hinted = true;
119
- timer = setTimeout(connect, KEY_CHECK_MS);
138
+ void signIn();
139
+ timer = setTimeout(connect, keyCheckMs);
120
140
  return;
121
141
  }
122
142
  hinted = false;
@@ -171,17 +191,20 @@ export function startRunner(o: RunnerOptions) {
171
191
  /* the key was deleted at /runner: wait for a new pairing instead of knocking with the old key */
172
192
  if (e.code === REVOKED) {
173
193
  revoked = token;
174
- log("aviso: este Mac foi desconectado do studio hospedado; para voltar: node tools/runner.mjs login");
194
+ log("aviso: esta máquina foi desconectada do studio hospedado");
175
195
  hinted = true;
176
- timer = setTimeout(connect, KEY_CHECK_MS);
196
+ /* the next look finds no login and opens the approval page (once): the user can approve or refuse it */
197
+ timer = setTimeout(connect, keyCheckMs);
177
198
  return;
178
199
  }
179
200
  /* the hub and this Mac speak different protocols: say which side is old, and look again in a minute
180
201
  (the studio may be redeployed, or this Mac updated, without a restart) */
181
202
  if (e.code === UPGRADE) {
182
- if (!warned) log(`aviso: este Mac e o studio hospedado não se entendem (${e.reason}); tento de novo em 1 min`);
203
+ if (!warned)
204
+ log(`aviso: esta máquina e o studio hospedado não se entendem (${e.reason}); tento de novo em 1 min`);
183
205
  warned = true;
184
- timer = setTimeout(connect, 60_000);
206
+ void Promise.resolve(o.onOutdated?.()).catch(() => {});
207
+ timer = setTimeout(connect, o.protocolRetryMs ?? 60_000);
185
208
  return;
186
209
  }
187
210
  if (was || !warned) {
@@ -200,6 +223,30 @@ export function startRunner(o: RunnerOptions) {
200
223
  /* onclose follows */
201
224
  };
202
225
  };
226
+ /* No usable login: opens the approval page on this computer and waits for the user to approve, then connects with
227
+ the login it stored. At most once in a while, and never twice at the same time. */
228
+ const signIn = async () => {
229
+ if (!o.login || stopped || signingIn) return;
230
+ if (signedInAt && Date.now() - signedInAt < loginEveryMs) return;
231
+ signingIn = true;
232
+ signedInAt = Date.now();
233
+ log("aviso: abri a página de aprovação no navegador desta máquina: aprove para reconectar");
234
+ try {
235
+ await o.login();
236
+ log("cloud: login refeito");
237
+ delay = 1000;
238
+ hinted = false;
239
+ revoked = "";
240
+ if (!stopped) {
241
+ if (timer) clearTimeout(timer);
242
+ void connect();
243
+ }
244
+ } catch (e) {
245
+ log(`aviso: o login não terminou (${(e as Error).message})`);
246
+ } finally {
247
+ signingIn = false;
248
+ }
249
+ };
203
250
  /* The key was refused (the login was redone, the key went missing in the cloud…): pair again, which opens
204
251
  the approval page on this Mac, and connect with the new key. True when it took over the retry. */
205
252
  const repair = async (token: string): Promise<boolean> => {
@@ -208,13 +255,13 @@ export function startRunner(o: RunnerOptions) {
208
255
  if (k.valid !== false) return false;
209
256
  revoked = token;
210
257
  if (Date.now() - pairedAt < PAIR_EVERY_MS) {
211
- timer = setTimeout(connect, KEY_CHECK_MS);
258
+ timer = setTimeout(connect, keyCheckMs);
212
259
  return true;
213
260
  }
214
261
  pairing = true;
215
262
  pairedAt = Date.now();
216
263
  log(
217
- "aviso: o studio hospedado recusou a chave deste Mac; abri a página de aprovação no navegador: aprove para reconectar",
264
+ "aviso: o studio hospedado recusou a chave desta máquina; abri a página de aprovação no navegador: aprove para reconectar",
218
265
  );
219
266
  const key = await o.pair!().catch(e => {
220
267
  log(
@@ -225,11 +272,11 @@ export function startRunner(o: RunnerOptions) {
225
272
  pairing = false;
226
273
  if (stopped) return true;
227
274
  if (key) {
228
- log("cloud: Mac pareado de novo");
275
+ log("cloud: máquina pareada de novo");
229
276
  delay = 1000;
230
277
  hinted = false;
231
278
  void connect();
232
- } else timer = setTimeout(connect, KEY_CHECK_MS);
279
+ } else timer = setTimeout(connect, keyCheckMs);
233
280
  return true;
234
281
  };
235
282
 
@@ -39,7 +39,7 @@ export const SCREENS: Record<ScreenId, { label: string; path: string }> = {
39
39
  /* where a content's Omnichat is set up, always: /omnichat/video/<slug> (or carousel) */
40
40
  omnichat_rule: { label: "Omnichat de um conteúdo", path: "/omnichat/$kind/$id" },
41
41
  settings: { label: "Configurações", path: "/settings" },
42
- usage: { label: "Uso das vozes", path: "/usage" },
42
+ usage: { label: "Uso e custo", path: "/usage" },
43
43
  omni_measures: { label: "Medições do Omni", path: "/agent" },
44
44
  getting_started: { label: "Primeiros passos", path: "/getting-started" },
45
45
  };
@@ -0,0 +1,10 @@
1
+ /* Agent cost (settings Uso e custo): ?days=N limits the window, none = everything recorded. */
2
+
3
+ import { z } from "zod";
4
+
5
+ export const costQuery = z.object({
6
+ days: z
7
+ .string()
8
+ .regex(/^\d{1,4}$/)
9
+ .optional(),
10
+ });
@@ -337,10 +337,11 @@ export type SoundBed = {
337
337
  } | null;
338
338
  /* an area cut (tools/cuts.mjs), in scenes.json time */
339
339
  export type SoundCut = { from: number; to: number };
340
- /* a sound of the kit or of the video's sfx/ */
340
+ /* a sound of a kit or of the video's sfx/ (a kit's effect is named "kit/name.mp3") */
341
341
  export type SoundEffectInfo = {
342
342
  name: string;
343
343
  origin: "kit" | "video";
344
+ kit?: string | null;
344
345
  role: string | null;
345
346
  title: string;
346
347
  music: boolean;
@@ -356,6 +357,7 @@ export type SoundClip = { from: number; to: number; offset: number; speed?: numb
356
357
  export type SoundCatalog = {
357
358
  kit: SoundEffectInfo[];
358
359
  video: SoundEffectInfo[];
360
+ defaultKit?: string;
359
361
  roles: Record<string, { db: number; stretchable?: boolean }>;
360
362
  level_db: number;
361
363
  ducking: { db: number };
@@ -939,6 +941,44 @@ export type UsageData = {
939
941
 
940
942
  export type Usage = UsageData | { error: string };
941
943
 
944
+ /* what the agents spent (tools/lib/cost.mjs): the Claude turns in .sessions/, by video, stage and model */
945
+ export type AgentCostVideo = {
946
+ video: string;
947
+ type: string;
948
+ total: number;
949
+ turns: number;
950
+ first: string;
951
+ last: string;
952
+ stages: Record<string, number>;
953
+ models: Record<string, number>;
954
+ };
955
+ export type AgentCostFork = {
956
+ video: string;
957
+ session: string;
958
+ turn: number;
959
+ stage: string;
960
+ label: string;
961
+ cost: number;
962
+ messages: number;
963
+ };
964
+ export type AgentCost = {
965
+ /* the window in days; null = everything recorded */
966
+ days: number | null;
967
+ total: number;
968
+ turns: number;
969
+ sessions: number;
970
+ /* turns that never closed: their cost is an estimate */
971
+ partial: number;
972
+ /* turns with no dollar cost (Codex) */
973
+ unmeasured: number;
974
+ by_kind: Record<string, number>;
975
+ by_stage: Record<string, number>;
976
+ by_model: Record<string, number>;
977
+ by_day: { day: string; cost: number }[];
978
+ by_video: AgentCostVideo[];
979
+ top_forks: AgentCostFork[];
980
+ };
981
+
942
982
  /* --- settings ---------------------------------------------------------------------------- */
943
983
  export type KeyStatus = { name: string; masked: string | null; in_env: boolean };
944
984
 
@@ -1184,6 +1224,42 @@ export type RunnerProblem =
1184
1224
  | { reason: "protocol"; at: string; side: "mac" | "cloud"; host?: string }
1185
1225
  | { reason: "revoked"; at: string; name?: string };
1186
1226
 
1227
+ /* the first-time setup the CLI reports to the user's cloud while the studio isn't connected yet
1228
+ (tools/lib/setup-progress.mjs; read from GET /api/cloud/config, key `setup`) */
1229
+ export type SetupStepState = "pending" | "running" | "done" | "error";
1230
+ export type SetupStep = { id: string; label: string; detail: string; state: SetupStepState; error?: string };
1231
+ export type SetupRecord = {
1232
+ v: 1;
1233
+ state: "running" | "done" | "error";
1234
+ device: string;
1235
+ started_at: number;
1236
+ updated_at: number;
1237
+ steps: SetupStep[];
1238
+ /* the last time the process waiting for a retry looked (the page offers "Tentar de novo" while it is recent) and the
1239
+ id of the last retry request it answered */
1240
+ watching_at?: number;
1241
+ handled?: string;
1242
+ };
1243
+ /* what the page writes to ask for a retry (`setup_retry` of the cloud config): a step id, or "all" */
1244
+ export type SetupRetryRequest = { id: string; step: string; at: number };
1245
+
1246
+ /* who is using the studio: the menu's user card (GET /api/me here; the Worker's /session/me when hosted).
1247
+ `source` is where a local studio found them: the Omni login, or the Claude Code account */
1248
+ export type Me = { name: string | null; email: string; image: string | null; source: "omni" | "claude" | "session" };
1249
+
1250
+ /* the computers signed in to the hosted studio, and the actions that went through to them
1251
+ (the Worker's /session/computers and /session/audit: cloud/studio/src/account-routes.mjs) */
1252
+ export type Computer = {
1253
+ id: string;
1254
+ name: string;
1255
+ /* "login": entered with the plugin; "key": the old pairing key (tools/runner.mjs) */
1256
+ kind: "login" | "key";
1257
+ created_at: number;
1258
+ last_seen: number | null;
1259
+ online: boolean;
1260
+ };
1261
+ export type AuditEntry = { at: number; who: string; method: string; path: string; status: number; ms: number };
1262
+
1187
1263
  /* --- the admin panel (GET /api/admin) ------------------------------------------------------------ */
1188
1264
  export type OmniAdminSettings = {
1189
1265
  enabled: boolean;