@crievo/omni 0.1.7 → 0.1.8

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 (68) hide show
  1. package/omnivideo.config.example.json +1 -0
  2. package/package.json +1 -1
  3. package/reference/cloud.md +15 -1
  4. package/reference/learnings.md +7 -0
  5. package/reference/studio.md +1 -0
  6. package/studio/api/app.ts +4 -0
  7. package/studio/api/routes/cost.ts +13 -0
  8. package/studio/api/routes/me.ts +8 -0
  9. package/studio/api/services/agent-tools.ts +2 -2
  10. package/studio/api/services/cost.ts +22 -0
  11. package/studio/api/services/me.ts +67 -0
  12. package/studio/api/services/sessions.ts +8 -2
  13. package/studio/runner/runner.ts +57 -10
  14. package/studio/shared/omni-screens.ts +1 -1
  15. package/studio/shared/schemas/cost.ts +10 -0
  16. package/studio/shared/types/api.ts +74 -0
  17. package/studio/studio.ts +27 -2
  18. package/studio/web/components/AppShell.tsx +11 -3
  19. package/studio/web/components/FirstConnection.tsx +222 -0
  20. package/studio/web/components/OfflineNotice.tsx +59 -0
  21. package/studio/web/components/SetupSteps.tsx +63 -0
  22. package/studio/web/components/Sidebar.tsx +10 -25
  23. package/studio/web/components/StudioOffline.tsx +48 -50
  24. package/studio/web/components/UserCard.tsx +79 -0
  25. package/studio/web/components/icons.tsx +2 -0
  26. package/studio/web/hooks/useComputers.ts +55 -0
  27. package/studio/web/hooks/useCost.ts +11 -0
  28. package/studio/web/hooks/useMe.ts +32 -0
  29. package/studio/web/hooks/useSetupProgress.ts +54 -0
  30. package/studio/web/lib/me.ts +17 -0
  31. package/studio/web/lib/query-client.ts +6 -0
  32. package/studio/web/lib/settings-categories.ts +2 -2
  33. package/studio/web/lib/setup-retry.ts +29 -0
  34. package/studio/web/lib/studio-offline.ts +105 -59
  35. package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
  36. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
  37. package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
  38. package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
  39. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
  40. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
  41. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
  42. package/studio/web/pages/SettingsPage/index.tsx +15 -1
  43. package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
  44. package/studio/web/router.tsx +8 -0
  45. package/studio/web/styles/components/setup-steps.css +163 -0
  46. package/studio/web/styles/components/user-card.css +121 -0
  47. package/studio/web/styles/index.css +2 -0
  48. package/studio/web/styles/pages/settings/shell.css +79 -0
  49. package/studio/web/styles/pages/settings/usage.css +47 -0
  50. package/studio/web/styles/pages/studio-offline.css +224 -38
  51. package/tools/cost.mjs +114 -0
  52. package/tools/lib/agents.mjs +3 -2
  53. package/tools/lib/channel.mjs +1 -1
  54. package/tools/lib/cloud-user.mjs +58 -0
  55. package/tools/lib/cost.mjs +357 -0
  56. package/tools/lib/deps.mjs +27 -12
  57. package/tools/lib/oauth-client.mjs +28 -14
  58. package/tools/lib/pages-style.mjs +67 -0
  59. package/tools/lib/pairing.mjs +1 -1
  60. package/tools/lib/schedule/schedule.mjs +1 -1
  61. package/tools/lib/self-update.mjs +122 -0
  62. package/tools/lib/setup-progress.mjs +315 -0
  63. package/tools/lib/up.mjs +176 -61
  64. package/tools/metrics.mjs +6 -1
  65. package/tools/omni.mjs +41 -16
  66. package/tools/omnichat.mjs +45 -9
  67. package/tools/platforms.mjs +75 -14
  68. package/tools/runner.mjs +6 -6
package/tools/cost.mjs ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /* What the agents spent on the videos: per video, stage, fork and model.
3
+
4
+ cost.mjs summary of every session (--days N for the last N days)
5
+ cost.mjs video <slug> one video: stages, forks, turns
6
+ cost.mjs turns [--n 20] the latest turns, one line each
7
+ cost.mjs ... --json the same as data (what the studio reads)
8
+
9
+ Read-only: it reads .sessions/ (the studio's claude stream-json) and keeps a cache in .cost/.
10
+ The values are the API list price Claude Code itself reports (`total_cost_usd`), which is what
11
+ the usage costs even on a subscription. The logic is tools/lib/cost.mjs. */
12
+
13
+ import { readTurns, summarize } from "./lib/cost.mjs";
14
+
15
+ const args = process.argv.slice(2);
16
+ const flag = name => {
17
+ const i = args.indexOf(name);
18
+ if (i < 0) return null;
19
+ const [v] = args.splice(i, args[i + 1]?.startsWith("--") || args[i + 1] == null ? 1 : 2).slice(1);
20
+ return v ?? true;
21
+ };
22
+ const json = !!flag("--json");
23
+ const days = Number(flag("--days")) || null;
24
+ const nTurns = Number(flag("--n")) || 20;
25
+ const cmd = args[0] && !args[0].startsWith("--") ? args.shift() : "summary";
26
+
27
+ const STAGE = {
28
+ orchestrator: "orquestrador",
29
+ "av-script": "roteiro",
30
+ "av-motion": "motion (roteiro + base)",
31
+ compose: "composição (1 fork por cena)",
32
+ wireframe: "wireframes (1 fork por cena)",
33
+ "av-narration": "narração",
34
+ "av-voice": "voz",
35
+ "av-render": "render",
36
+ "av-captions": "legendas",
37
+ "av-assemble": "montagem",
38
+ "av-editor": "editor",
39
+ "av-reference": "estilo visual",
40
+ "av-thumbnail": "thumbnail",
41
+ "av-preview": "prévia (aposentada)",
42
+ other: "outros",
43
+ };
44
+ const KIND = { video: "vídeos", edit: "editor", reference: "estilos visuais", analysis: "análise", omni: "Omni" };
45
+ const usd = n => `US$ ${n.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
46
+ const pad = (s, n) => String(s).padEnd(n);
47
+ const lpad = (s, n) => String(s).padStart(n);
48
+ const pct = (n, t) => (t ? `${Math.round((n / t) * 100)}%` : "—");
49
+ const sorted = o => Object.entries(o).sort((a, b) => b[1] - a[1]);
50
+
51
+ function table(title, entries, total, label = k => k) {
52
+ console.log(`\n${title}`);
53
+ for (const [k, v] of entries) console.log(` ${pad(label(k), 34)} ${lpad(usd(v), 13)} ${lpad(pct(v, total), 4)}`);
54
+ }
55
+
56
+ const rows = await readTurns({ since: days ? Date.now() - days * 86_400_000 : null });
57
+
58
+ if (cmd === "turns") {
59
+ const last = rows.slice(-nTurns);
60
+ if (json) console.log(JSON.stringify(last, null, 2));
61
+ else
62
+ for (const r of last)
63
+ console.log(
64
+ `${r.at.slice(0, 16).replace("T", " ")} ${pad(r.type, 9)} ${pad(r.target.slice(0, 38), 38)} t${r.n} ${lpad(r.total == null ? "sem custo (Codex)" : usd(r.total) + (r.partial ? " (parcial)" : ""), 22)} ${r.subagents} forks`,
65
+ );
66
+ } else if (cmd === "video") {
67
+ const slug = args[0];
68
+ if (!slug) {
69
+ console.error("erro: diga o vídeo — cost.mjs video <slug>");
70
+ process.exit(2);
71
+ }
72
+ const mine = rows.filter(r => r.target === slug || r.target === slug.replace(/^videos\//, "").replace(/\/$/, ""));
73
+ if (!mine.length) {
74
+ console.error(`erro: nenhuma sessão registrada para ${slug}`);
75
+ process.exit(1);
76
+ }
77
+ const s = summarize(mine);
78
+ if (json) console.log(JSON.stringify({ ...s, turns_list: mine.map(({ groups, ...r }) => r) }, null, 2));
79
+ else {
80
+ console.log(
81
+ `${slug}: ${usd(s.total)} em ${s.turns} turnos${s.partial ? ` (${s.partial} em andamento ou interrompido, estimado)` : ""}`,
82
+ );
83
+ table("por etapa", sorted(s.by_stage), s.total, k => STAGE[k] ?? k);
84
+ table("por modelo", sorted(s.by_model), s.total);
85
+ console.log("\nmaiores forks");
86
+ for (const f of s.top_forks.slice(0, 8))
87
+ console.log(` ${pad(f.label.slice(0, 34), 34)} ${lpad(usd(f.cost), 13)} ${f.messages} mensagens`);
88
+ console.log("\nturnos");
89
+ for (const r of mine)
90
+ console.log(
91
+ ` t${r.n} ${r.at.slice(0, 16).replace("T", " ")} ${pad(r.origin ?? "", 8)} ${lpad(r.total == null ? "—" : usd(r.total), 13)} ${r.subagents} forks`,
92
+ );
93
+ }
94
+ } else {
95
+ const s = summarize(rows);
96
+ if (json) console.log(JSON.stringify(s, null, 2));
97
+ else {
98
+ console.log(
99
+ `${usd(s.total)} em ${s.turns} turnos de ${s.sessions} sessões${days ? ` (últimos ${days} dias)` : ""}`,
100
+ );
101
+ if (s.partial) console.log(`aviso: ${s.partial} turno(s) sem fechamento, valor estimado`);
102
+ if (s.unmeasured) console.log(`aviso: ${s.unmeasured} turno(s) do Codex sem custo em dólar`);
103
+ table("por tipo de sessão", sorted(s.by_kind), s.total, k => KIND[k] ?? k);
104
+ table("por etapa", sorted(s.by_stage), s.total, k => STAGE[k] ?? k);
105
+ table("por modelo", sorted(s.by_model), s.total);
106
+ console.log("\npor vídeo");
107
+ for (const v of s.by_video.slice(0, 15))
108
+ console.log(
109
+ ` ${pad(v.video.slice(0, 44), 44)} ${lpad(usd(v.total), 13)} ${lpad(v.turns, 3)} turnos ${pad(v.type, 9)}`,
110
+ );
111
+ console.log("\npor dia");
112
+ for (const d of s.by_day.slice(-10)) console.log(` ${d.day} ${lpad(usd(d.cost), 13)}`);
113
+ }
114
+ }
@@ -160,7 +160,8 @@ const jwtPayload = token => {
160
160
 
161
161
  /**
162
162
  * Who is logged in to an account, read from its own files (no process, no network).
163
- * @returns {{ email: string|null, detail: string|null } | null}
163
+ * @returns {{ email: string|null, detail: string|null, name?: string|null } | null} `name` is the account's own
164
+ * display name, when its files carry one (Claude's)
164
165
  */
165
166
  export function loginOf(a) {
166
167
  if (a.provider === "claude") {
@@ -169,7 +170,7 @@ export function loginOf(a) {
169
170
  if (!o) return null;
170
171
  const org =
171
172
  o.organizationName && o.organizationName !== `${o.emailAddress}'s Organization` ? o.organizationName : null;
172
- return { email: o.emailAddress ?? null, detail: org };
173
+ return { email: o.emailAddress ?? null, detail: org, name: o.displayName ?? null };
173
174
  }
174
175
  const auth = readJson(join(a.dir, "auth.json"));
175
176
  if (!auth) return null;
@@ -122,7 +122,7 @@ export class Peer {
122
122
  this.waiting.delete(id);
123
123
  this.outbound.get(`q${id}`)?.cancel();
124
124
  this.frame({ t: "abort", id });
125
- reject(new ChannelError("o Mac não respondeu a tempo", "timeout"));
125
+ reject(new ChannelError("a máquina não respondeu a tempo", "timeout"));
126
126
  }, timeoutMs);
127
127
  this.waiting.set(id, { resolve, reject, timer });
128
128
  this.frame({
@@ -0,0 +1,58 @@
1
+ /* ============================================================================
2
+ The machine's side of the user's own cloud (docs/specs/phase-3-per-user-cloud.md, step 3).
3
+
4
+ The CLIs (omnichat.mjs, metrics.mjs) talk to "the cloud" through cloudApi (tools/platforms.mjs) with the paths
5
+ and bodies of the omnichat Worker they were written against. With `cloud.user` on, the calls of the omnichat,
6
+ the Instagram account and the metrics go to the user's own cloud instead (the studio's UserCloud, signed in with
7
+ the Omni login): this file is the one place that knows both vocabularies, so the callers don't change.
8
+
9
+ What still goes to the omnichat Worker (`userRoute` answers null): the schedule, the media published now and
10
+ the YouTube/TikTok credentials. Publishing is the last step of the migration (the Worker keeps doing it).
11
+
12
+ Pure: no network, no disk. The tests are in cloud-user.test.mjs.
13
+ ============================================================================ */
14
+
15
+ /**
16
+ * @typedef {{ method: string, path: string, body?: unknown }} Call
17
+ */
18
+
19
+ /** a config key that holds a flag (the jobs' on/off switches are config keys of the user's cloud) */
20
+ const flag = (/** @type {string} */ key, /** @type {unknown} */ on) => ({
21
+ method: "PUT",
22
+ path: "/api/cloud/config",
23
+ body: { [key]: on === true },
24
+ });
25
+
26
+ /**
27
+ * The call in the user's cloud that does what an old Worker call did, or null when the old call is not one the
28
+ * user's cloud takes yet.
29
+ * @param {string} method @param {string} path with its query, as the old Worker had it @param {unknown} [body]
30
+ * @returns {Call | null}
31
+ */
32
+ export function userRoute(method, path, body) {
33
+ const [pathname, query] = path.split("?");
34
+ const q = query ? `?${query}` : "";
35
+ const is = (/** @type {string} */ m, /** @type {string} */ p) => method === m && pathname === p;
36
+
37
+ /* what only the user's own cloud has (its status with the webhook address, the secrets): as they are */
38
+ if (pathname.startsWith("/api/cloud/")) return { method, path, body };
39
+
40
+ /* the switches and the omnichat config: config keys */
41
+ if (is("PUT", "/api/active")) return flag("omnichat.active", /** @type {any} */ (body)?.active);
42
+ if (is("PUT", "/api/metrics/active")) return flag("metrics.active", /** @type {any} */ (body)?.active);
43
+ if (is("PUT", "/api/config")) return { method, path: "/api/cloud/config", body: { "omnichat.config": body } };
44
+
45
+ if (is("GET", "/api/status")) return { method, path: "/api/cloud/omnichat/status" };
46
+ if (is("PUT", "/api/account")) return { method, path: "/api/cloud/instagram/account", body };
47
+
48
+ /* a post's rule and record, and the passes on it */
49
+ const post = /^\/api\/posts\/(\d+)(\/(?:record|retry|run|send))?$/.exec(pathname);
50
+ if (post) return { method, path: `/api/cloud/omnichat/posts/${post[1]}${post[2] ?? ""}${q}`, body };
51
+
52
+ /* the metrics */
53
+ if (is("GET", "/api/metrics")) return { method, path: "/api/cloud/metrics" };
54
+ const metrics = /^\/api\/metrics\/(videos\/\d+|account|run)$/.exec(pathname);
55
+ if (metrics) return { method, path: `/api/cloud/metrics/${metrics[1]}`, body };
56
+
57
+ return null;
58
+ }
@@ -0,0 +1,357 @@
1
+ /* What the agents spent: the cost of every Claude turn, split by video, stage, fork and model.
2
+
3
+ The source is what the studio already writes: .sessions/<id>/turn-<n>.jsonl, claude's
4
+ stream-json. Each turn ends with a `result` that carries `total_cost_usd` and, per model, the
5
+ `costUSD` of everything the session ran so far, forks included. That total is the truth, and
6
+ it is CUMULATIVE: a resumed session keeps adding to it, so a turn costs the difference to the
7
+ previous result. The per-message `usage` lines are only used to SPLIT that cost (which fork
8
+ spent what): they come with the output tokens undercounted, so the split is scaled per model
9
+ to what the result says (the price table only gives the weights between token kinds, never the
10
+ final value). A turn without `result` (running, interrupted) is the uncalibrated estimate,
11
+ marked `partial`.
12
+
13
+ A fork is a message whose `parent_tool_use_id` points at a Skill or Agent tool_use. A stage
14
+ skill (`context: fork`) shows up as a Skill, a fan-out scene as an Agent ("compose c01").
15
+ Codex reports tokens, never dollars: its turns count as turns, with no cost.
16
+
17
+ Everything here is read-only. The only thing written is a cache per finished turn file
18
+ (.cost/turns/), which never goes stale: a turn with `result` does not change. */
19
+
20
+ import {
21
+ createReadStream,
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ readdirSync,
26
+ renameSync,
27
+ statSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { createInterface } from "node:readline";
31
+ import { dirname, join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
35
+ export const SESSIONS_DIR = join(ROOT, ".sessions");
36
+ export const CACHE_DIR = join(ROOT, ".cost", "turns");
37
+ const CACHE_VERSION = 1;
38
+
39
+ /* USD per million tokens: input, output, cache read, cache write. Only the proportion between
40
+ the four matters (calibration fixes the scale); a model missing here is priced as Opus. */
41
+ const PRICES = {
42
+ opus: [4, 20, 0.2, 5],
43
+ sonnet: [2, 10, 0.2, 2.5],
44
+ haiku: [1, 5, 0.1, 1.25],
45
+ };
46
+ const priceOf = model => PRICES[Object.keys(PRICES).find(k => String(model).includes(k)) ?? "opus"];
47
+
48
+ export const shortModel = m =>
49
+ String(m ?? "?")
50
+ .replace(/^claude-/, "")
51
+ .replace(/-\d{8}$/, "");
52
+
53
+ /* the skills before the English migration, under the name they have today */
54
+ const LEGACY_SKILLS = {
55
+ "av-roteiro": "av-script",
56
+ "av-narracao": "av-narration",
57
+ "av-voz": "av-voice",
58
+ "av-legenda": "av-captions",
59
+ "av-montar": "av-assemble",
60
+ "av-previa": "av-preview",
61
+ };
62
+
63
+ /* fork description → stage. The Skill forks carry their own name (av-motion…). */
64
+ const AGENT_STAGES = {
65
+ wireframe: "wireframe",
66
+ compose: "compose",
67
+ compor: "compose",
68
+ narration: "av-narration",
69
+ narrar: "av-narration",
70
+ };
71
+ export function stageOf(name, input = {}) {
72
+ if (name === "Skill") {
73
+ const skill = String(input.skill ?? "skill").replace(/^.*:/, "");
74
+ return LEGACY_SKILLS[skill] ?? skill;
75
+ }
76
+ const word = String(input.description ?? "")
77
+ .split(/\s+/)[0]
78
+ .toLowerCase();
79
+ return AGENT_STAGES[word] ?? `agent:${input.subagent_type ?? "general-purpose"}`;
80
+ }
81
+
82
+ const costOf = (u, model) => {
83
+ const [pi, po, pr, pw] = priceOf(model);
84
+ return (
85
+ ((u.input_tokens ?? 0) * pi +
86
+ (u.output_tokens ?? 0) * po +
87
+ (u.cache_read_input_tokens ?? 0) * pr +
88
+ (u.cache_creation_input_tokens ?? 0) * pw) /
89
+ 1e6
90
+ );
91
+ };
92
+
93
+ const round = (n, d = 4) => +n.toFixed(d);
94
+
95
+ /* One turn file → what it says, still uncalibrated. Streams the file and only parses the lines
96
+ that matter: an image Read puts megabytes of base64 on one `user` line.
97
+ `cum` is the `result` (the session's running total up to the end of this turn), `groups` the
98
+ split of this turn's own messages in raw dollars. `settle` turns the two into the turn's cost. */
99
+ export async function readTurnCost(file) {
100
+ const messages = new Map(); // message.id → { model, parent, usage }: the last usage wins, it only grows
101
+ const forks = new Map(); // tool_use id → { stage, label }
102
+ let result = null;
103
+ const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
104
+ for await (const line of rl) {
105
+ /* `result` does not open with "type" (it starts at duration_api_ms), so it is found by its field */
106
+ const assistant = line.startsWith('{"type":"assistant"');
107
+ if (!assistant && !line.includes('"total_cost_usd"')) continue;
108
+ let o;
109
+ try {
110
+ o = JSON.parse(line);
111
+ } catch {
112
+ continue;
113
+ }
114
+ if (!assistant) {
115
+ if (o.type === "result" && o.total_cost_usd != null) result = o;
116
+ continue;
117
+ }
118
+ const m = o.message;
119
+ if (!m?.id || m.model === "<synthetic>") continue;
120
+ for (const b of m.content ?? []) {
121
+ if (b.type === "tool_use" && (b.name === "Skill" || b.name === "Agent")) {
122
+ forks.set(b.id, { stage: stageOf(b.name, b.input), label: b.input?.description || b.input?.skill || b.name });
123
+ }
124
+ }
125
+ const prev = messages.get(m.id);
126
+ const u = m.usage ?? {};
127
+ const keep = prev ? { ...prev.usage } : {};
128
+ for (const k of ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"]) {
129
+ keep[k] = Math.max(keep[k] ?? 0, u[k] ?? 0);
130
+ }
131
+ messages.set(m.id, {
132
+ model: shortModel(m.model ?? prev?.model),
133
+ parent: o.parent_tool_use_id ?? null,
134
+ usage: keep,
135
+ });
136
+ }
137
+
138
+ const raw = {};
139
+ const groups = new Map();
140
+ const group = key => {
141
+ if (!groups.has(key)) {
142
+ const f =
143
+ key === "root"
144
+ ? { stage: "orchestrator", label: "orquestrador" }
145
+ : (forks.get(key) ?? { stage: "other", label: "?" });
146
+ groups.set(key, {
147
+ ...f,
148
+ fork: key !== "root",
149
+ cost: 0,
150
+ messages: 0,
151
+ models: {},
152
+ tokens: { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
153
+ });
154
+ }
155
+ return groups.get(key);
156
+ };
157
+ for (const { model, parent, usage } of messages.values()) {
158
+ const cost = costOf(usage, model);
159
+ raw[model] = (raw[model] ?? 0) + cost;
160
+ const g = group(parent ?? "root");
161
+ g.cost += cost;
162
+ g.messages += 1;
163
+ g.models[model] = (g.models[model] ?? 0) + cost;
164
+ g.tokens.in += usage.input_tokens ?? 0;
165
+ g.tokens.out += usage.output_tokens ?? 0;
166
+ g.tokens.cacheRead += usage.cache_read_input_tokens ?? 0;
167
+ g.tokens.cacheWrite += usage.cache_creation_input_tokens ?? 0;
168
+ }
169
+ return {
170
+ v: CACHE_VERSION,
171
+ session_id: result?.session_id ?? null,
172
+ cum: result
173
+ ? {
174
+ total: result.total_cost_usd,
175
+ models: Object.fromEntries(
176
+ Object.entries(result.modelUsage ?? {}).map(([m, u]) => [shortModel(m), u.costUSD ?? 0]),
177
+ ),
178
+ }
179
+ : null,
180
+ raw_models: raw,
181
+ groups: [...groups.values()].sort((a, b) => b.cost - a.cost),
182
+ };
183
+ }
184
+
185
+ /* the cached reading of a turn; a finished turn never changes, an open one is read again */
186
+ export async function turnCost(file) {
187
+ const st = statSync(file);
188
+ const cache = join(CACHE_DIR, `${file.split("/").slice(-2).join("-")}.json`);
189
+ try {
190
+ const c = JSON.parse(readFileSync(cache, "utf8"));
191
+ if (c.v === CACHE_VERSION && c.cum && c.size === st.size) return c;
192
+ } catch {}
193
+ const c = { ...(await readTurnCost(file)), size: st.size };
194
+ try {
195
+ mkdirSync(CACHE_DIR, { recursive: true });
196
+ const tmp = `${cache}.${process.pid}.tmp`;
197
+ writeFileSync(tmp, JSON.stringify(c));
198
+ renameSync(tmp, cache);
199
+ } catch {}
200
+ return c;
201
+ }
202
+
203
+ const sum = o => Object.values(o).reduce((a, b) => a + b, 0);
204
+
205
+ /* The `result` of a resumed session is CUMULATIVE (cost and models grow turn after turn, same
206
+ session_id), so a turn's own cost is the difference to the previous result of its session.
207
+ A turn without `result` (running or interrupted) is the uncalibrated estimate of its
208
+ messages, marked `partial`; the next result also holds what that turn spent, so the estimate
209
+ is taken out of it and the session still adds up to its last total.
210
+ `chain` is the running state of one session: { key, total, models, pending: { total, models } }. */
211
+ export function settle(reading, chain) {
212
+ if (!reading.cum) {
213
+ const total = sum(reading.raw_models);
214
+ chain.pending.total += total;
215
+ for (const [m, c] of Object.entries(reading.raw_models))
216
+ chain.pending.models[m] = (chain.pending.models[m] ?? 0) + c;
217
+ return {
218
+ partial: true,
219
+ total: round(total),
220
+ models: roundAll(reading.raw_models),
221
+ groups: reading.groups.map(g => roundGroup(g, () => 1)),
222
+ };
223
+ }
224
+ if (reading.session_id !== chain.key) Object.assign(chain, { key: reading.session_id, total: 0, models: {} }); // another conversation: starts from zero
225
+ const delta = (cum, before, pending) => Math.max(0, cum - before - pending);
226
+ const total = delta(reading.cum.total, chain.total, chain.pending.total);
227
+ const models = {};
228
+ for (const [m, cum] of Object.entries(reading.cum.models)) {
229
+ const d = delta(cum, chain.models[m] ?? 0, chain.pending.models[m] ?? 0);
230
+ if (d > 0) models[m] = d;
231
+ }
232
+ /* each model's messages are scaled to what the result says that model spent in this turn */
233
+ const factor = m => (reading.raw_models[m] > 0 && models[m] != null ? models[m] / reading.raw_models[m] : 1);
234
+ const groups = reading.groups.map(g => roundGroup(g, factor));
235
+ Object.assign(chain, {
236
+ total: reading.cum.total,
237
+ models: { ...reading.cum.models },
238
+ pending: { total: 0, models: {} },
239
+ });
240
+ return { partial: false, total: round(total), models: roundAll(models), groups };
241
+ }
242
+
243
+ const roundAll = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, round(v)]));
244
+ const roundGroup = (g, factor) => {
245
+ const models = Object.fromEntries(Object.entries(g.models).map(([m, c]) => [m, c * factor(m)]));
246
+ return { ...g, cost: round(sum(models)), models: roundAll(models) };
247
+ };
248
+
249
+ /** every turn of every session, one row each: who it belongs to, when, what it cost
250
+ @param {{ sessionsDir?: string, since?: number | null }} [opts] `since`: epoch ms, earlier turns are not listed */
251
+ export async function readTurns({ sessionsDir = SESSIONS_DIR, since = null } = {}) {
252
+ if (!existsSync(sessionsDir)) return [];
253
+ const rows = [];
254
+ for (const id of readdirSync(sessionsDir)) {
255
+ let rec;
256
+ try {
257
+ rec = JSON.parse(readFileSync(join(sessionsDir, id, "session.json"), "utf8"));
258
+ } catch {
259
+ continue;
260
+ }
261
+ const chain = { key: null, total: 0, models: {}, pending: { total: 0, models: {} } };
262
+ for (const t of rec.turns ?? []) {
263
+ const base = {
264
+ session: rec.id,
265
+ type: rec.type,
266
+ target: rec.target,
267
+ n: t.n,
268
+ at: t.started_at,
269
+ provider: t.provider ?? "claude",
270
+ account: t.account ?? null,
271
+ origin: t.origin,
272
+ };
273
+ const file = join(sessionsDir, id, t.file);
274
+ if (base.provider !== "claude" || !existsSync(file)) {
275
+ rows.push({ ...base, total: null, partial: false, models: {}, subagents: 0, groups: [] });
276
+ continue;
277
+ }
278
+ /* a turn out of the window is still settled (the chain needs it), only not listed */
279
+ const s = settle(await turnCost(file), chain);
280
+ if (since && Date.parse(t.started_at) < since) continue;
281
+ rows.push({ ...base, ...s, subagents: s.groups.filter(g => g.fork).length });
282
+ }
283
+ }
284
+ return rows.sort((a, b) => a.at.localeCompare(b.at));
285
+ }
286
+
287
+ const add = (o, k, v) => {
288
+ o[k] = round((o[k] ?? 0) + v);
289
+ };
290
+
291
+ /* turns → the numbers the CLI and the studio show */
292
+ export function summarize(rows) {
293
+ const out = {
294
+ total: 0,
295
+ turns: rows.length,
296
+ sessions: new Set(rows.map(r => r.session)).size,
297
+ partial: 0,
298
+ unmeasured: 0,
299
+ by_kind: {},
300
+ by_stage: {},
301
+ by_model: {},
302
+ by_day: {},
303
+ by_video: {},
304
+ top_forks: [],
305
+ };
306
+ const forks = [];
307
+ for (const r of rows) {
308
+ if (r.total == null) {
309
+ out.unmeasured += 1;
310
+ continue;
311
+ }
312
+ if (r.partial) out.partial += 1;
313
+ out.total += r.total;
314
+ add(out.by_kind, r.type, r.total);
315
+ add(out.by_day, r.at.slice(0, 10), r.total);
316
+ for (const [m, c] of Object.entries(r.models)) add(out.by_model, m, c);
317
+ const v = out.by_video[r.target] ?? {
318
+ video: r.target,
319
+ type: r.type,
320
+ total: 0,
321
+ turns: 0,
322
+ first: r.at,
323
+ last: r.at,
324
+ stages: {},
325
+ models: {},
326
+ };
327
+ out.by_video[r.target] = v;
328
+ v.total += r.total;
329
+ v.turns += 1;
330
+ v.last = r.at;
331
+ for (const [m, c] of Object.entries(r.models)) add(v.models, m, c);
332
+ /* the groups are the turn's estimate calibrated per model, so they add up to its total */
333
+ for (const g of r.groups) {
334
+ add(out.by_stage, g.stage, g.cost);
335
+ add(v.stages, g.stage, g.cost);
336
+ if (g.fork)
337
+ forks.push({
338
+ video: r.target,
339
+ session: r.session,
340
+ turn: r.n,
341
+ stage: g.stage,
342
+ label: g.label,
343
+ cost: g.cost,
344
+ messages: g.messages,
345
+ });
346
+ }
347
+ }
348
+ out.total = round(out.total);
349
+ out.by_video = Object.values(out.by_video)
350
+ .map(v => ({ ...v, total: round(v.total) }))
351
+ .sort((a, b) => b.total - a.total);
352
+ out.top_forks = forks.sort((a, b) => b.cost - a.cost).slice(0, 15);
353
+ out.by_day = Object.entries(out.by_day)
354
+ .sort(([a], [b]) => a.localeCompare(b))
355
+ .map(([day, cost]) => ({ day, cost }));
356
+ return out;
357
+ }
@@ -241,26 +241,41 @@ export function checkDeps(codeRoot) {
241
241
  };
242
242
  }
243
243
 
244
+ /** the setup steps that are one of these programs (tools/lib/setup-progress.mjs names them for the user) */
245
+ export const DEP_STEPS = /** @type {const} */ (["video", "render", "voice", "style"]);
246
+
247
+ /** Fetches one program, by the id of the setup step it belongs to; throws when it can't (a retry from the page).
248
+ * @param {string} id @param {string} codeRoot */
249
+ export async function ensureDep(id, codeRoot) {
250
+ if (id === "video") return ensureFfmpeg();
251
+ if (id === "render") return ensureChrome();
252
+ if (id === "voice") return ensurePython(codeRoot);
253
+ if (id === "style") return ensureYtdlp();
254
+ throw new Error(`etapa desconhecida: ${id}`);
255
+ }
256
+
244
257
  /**
245
- * Fetches whatever doesn't answer, one piece at a time, and says how it went.
246
- * @param {string} codeRoot @param {{ log?: (line: string) => void }} [o]
258
+ * Fetches whatever doesn't answer, one piece at a time, and says how it went. `onStep` hears each piece by the
259
+ * id of the setup step it belongs to (tools/lib/setup-progress.mjs): running, then done or error.
260
+ * @param {string} codeRoot
261
+ * @param {{ log?: (line: string) => void, onStep?: (id: string, state: "running" | "done" | "error", error?: string) => void }} [o]
247
262
  * @returns {Promise<{ ok: boolean, missing: string[], errors: string[] }>}
248
263
  */
249
- export async function ensureDeps(codeRoot, { log = () => {} } = {}) {
264
+ export async function ensureDeps(codeRoot, { log = () => {}, onStep = () => {} } = {}) {
250
265
  /** @type {string[]} */
251
266
  const errors = [];
252
- const steps = /** @type {const} */ ([
253
- ["ffmpeg", () => ensureFfmpeg()],
254
- ["Chrome sem janela", () => ensureChrome()],
255
- ["Python", () => ensurePython(codeRoot)],
256
- ["yt-dlp", () => ensureYtdlp()],
257
- ]);
258
- for (const [name, run] of steps) {
267
+ const names = { video: "ffmpeg", render: "Chrome sem janela", voice: "Python", style: "yt-dlp" };
268
+ for (const id of DEP_STEPS) {
269
+ const name = names[id];
259
270
  try {
260
271
  log(`preparando ${name}…`);
261
- await run();
272
+ onStep(id, "running");
273
+ await ensureDep(id, codeRoot);
274
+ onStep(id, "done");
262
275
  } catch (e) {
263
- errors.push(`${name}: ${/** @type {Error} */ (e).message}`);
276
+ const message = /** @type {Error} */ (e).message;
277
+ errors.push(`${name}: ${message}`);
278
+ onStep(id, "error", message);
264
279
  }
265
280
  }
266
281
  const c = checkDeps(codeRoot);
@@ -20,6 +20,7 @@ import { mkdirSync, rmSync, statSync } from "node:fs";
20
20
  import { createServer } from "node:http";
21
21
  import { homedir } from "node:os";
22
22
  import { join } from "node:path";
23
+ import { confirmation, document_ } from "./pages-style.mjs";
23
24
  import { openUrl } from "./platform.mjs";
24
25
  import { defaultVault } from "./vault.mjs";
25
26
 
@@ -155,25 +156,38 @@ export async function login(studioUrl, o) {
155
156
  res.writeHead(404).end();
156
157
  return;
157
158
  }
158
- const page = (/** @type {string} */ msg) =>
159
- `<!doctype html><meta charset="utf-8"><title>OmniVideo</title><body style="font:16px system-ui;padding:48px;max-width:32em">${msg}</body>`;
159
+ const html = (/** @type {Parameters<typeof confirmation>[0]} */ c) => document_(c.title, confirmation(c));
160
160
  if (u.searchParams.get("state") !== state) {
161
- res
162
- .writeHead(400, { "content-type": "text/html; charset=utf-8" })
163
- .end(page("Esse retorno não é deste login. Rode o comando de novo."));
161
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(
162
+ html({
163
+ kind: "error",
164
+ title: "Esse retorno não é deste login",
165
+ text: "Esta página veio de outra tentativa de login. Nada foi conectado.",
166
+ hint: "Abra uma nova sessão do Claude Code para tentar de novo.",
167
+ }),
168
+ );
164
169
  return;
165
170
  }
166
171
  const code = u.searchParams.get("code");
167
172
  const error = u.searchParams.get("error");
168
- res
169
- .writeHead(code ? 200 : 400, { "content-type": "text/html; charset=utf-8" })
170
- .end(
171
- page(
172
- code
173
- ? "Pronto: este computador entrou no OmniVideo. Pode fechar esta aba."
174
- : "O login foi recusado. Pode fechar esta aba.",
175
- ),
176
- );
173
+ res.writeHead(code ? 200 : 400, { "content-type": "text/html; charset=utf-8" }).end(
174
+ html(
175
+ code
176
+ ? {
177
+ kind: "ok",
178
+ title: "Computador conectado",
179
+ text: "Pode fechar esta aba. Estamos preparando este computador em segundo plano: acompanhe cada etapa no Omni.",
180
+ action: { href: studioUrl, label: "Abrir o Omni" },
181
+ hint: "Este login vale só para este computador. Você pode desconectá-lo em Configurações → Agente de IA.",
182
+ }
183
+ : {
184
+ kind: "error",
185
+ title: "Login recusado",
186
+ text: "Este computador não foi conectado ao Omni.",
187
+ hint: "Para tentar de novo, abra uma nova sessão do Claude Code.",
188
+ },
189
+ ),
190
+ );
177
191
  settle(code ? { code } : { error: error ?? "recusado" });
178
192
  });
179
193
  await new Promise((resolve, reject) => {