@hypit/hypit 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 (46) hide show
  1. package/README.md +15 -4
  2. package/dist/public/endpoint-kit.d.ts +1 -1
  3. package/dist/public/runtime-kit.d.ts +3 -1
  4. package/examples/provider-package/README.md +12 -0
  5. package/examples/provider-package/packages/provider-images/src/provider.ts +37 -8
  6. package/package.json +1 -1
  7. package/packages/cli/README.md +35 -0
  8. package/packages/cli/src/arguments.ts +12 -1
  9. package/packages/cli/src/command-hint.ts +20 -0
  10. package/packages/cli/src/commands/environment.ts +39 -18
  11. package/packages/cli/src/commands/execution.ts +34 -18
  12. package/packages/cli/src/commands/results.ts +8 -3
  13. package/packages/cli/src/machine-view.ts +4 -2
  14. package/packages/cli/src/main.ts +25 -23
  15. package/packages/cli/src/observation.ts +7 -2
  16. package/packages/cli/src/output.ts +8 -0
  17. package/packages/cli/src/usage-error.ts +8 -0
  18. package/packages/cli/src/view.ts +4 -1
  19. package/packages/driver-node/README.md +5 -0
  20. package/packages/driver-node/src/driver.ts +20 -15
  21. package/packages/endpoint-kit/README.md +11 -2
  22. package/packages/endpoint-kit/src/index.ts +1 -1
  23. package/packages/generation/README.md +10 -0
  24. package/packages/provider-hypihub/README.md +41 -6
  25. package/packages/provider-hypihub/src/errors.ts +61 -0
  26. package/packages/provider-hypihub/src/mapping.ts +10 -25
  27. package/packages/provider-hypihub/src/oauth.ts +4 -1
  28. package/packages/provider-hypihub/src/provider.ts +79 -74
  29. package/packages/provider-hypihub/src/routes.ts +24 -3
  30. package/packages/provider-hypihub/src/upload.ts +8 -18
  31. package/packages/provider-whisperx-local/README.md +7 -1
  32. package/packages/provider-whisperx-local/src/program.ts +2 -0
  33. package/packages/runtime-host-node/src/index.ts +4 -0
  34. package/packages/runtime-kit/README.md +3 -0
  35. package/packages/runtime-kit/src/index.ts +2 -0
  36. package/packages/runtime-local/README.md +17 -0
  37. package/packages/runtime-local/package.json +2 -1
  38. package/packages/runtime-local/src/program-lock.ts +40 -0
  39. package/packages/runtime-local/src/programs.ts +156 -79
  40. package/packages/video-cli/README.md +33 -6
  41. package/packages/video-cli/src/cli.ts +4 -1
  42. package/packages/video-cli/src/creation.ts +7 -2
  43. package/packages/video-cli/src/index.ts +2 -0
  44. package/packages/video-cli/src/version.ts +90 -0
  45. package/services/whisperx/README.md +10 -3
  46. package/services/whisperx/src/hypit_whisperx_service/engine.py +19 -1
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { setTimeout as sleep } from "node:timers/promises";
5
5
 
6
6
  import type { CapabilityRef } from "@hypit/protocol";
@@ -30,6 +30,8 @@ export type ManagedProgramReport = {
30
30
  /** Why the action fell short. Never a copy of what `state` already says. */
31
31
  readonly detail?: string;
32
32
  readonly logPath?: string;
33
+ readonly installationLogPath?: string;
34
+ readonly errorLogPath?: string;
33
35
  readonly pid?: number;
34
36
  };
35
37
 
@@ -37,6 +39,7 @@ export type ManagedProgramProgress = {
37
39
  readonly id: string;
38
40
  readonly phase: "checking" | "installing" | "starting" | "waiting" | "ready";
39
41
  readonly logPath?: string;
42
+ readonly detail?: string;
40
43
  };
41
44
 
42
45
  export type ManagedProgramOptions = LoadRuntimeConfigOptions & {
@@ -99,9 +102,40 @@ async function readPid(root: string, program: ManagedProgram): Promise<number |
99
102
  }
100
103
  }
101
104
 
105
+ async function existingLogs(root: string, program: ManagedProgram) {
106
+ const home = directory(root, program);
107
+ const paths = {
108
+ logPath: join(home, "program.log"),
109
+ installationLogPath: join(home, "install.log"),
110
+ ...(process.platform === "win32" ? { errorLogPath: processErrorLogPath(join(home, "program.log")) } : {}),
111
+ };
112
+ const found: { logPath?: string; installationLogPath?: string; errorLogPath?: string } = {};
113
+ for (const [key, path] of Object.entries(paths)) {
114
+ try { await stat(path); found[key as keyof typeof found] = path; }
115
+ catch (error) { if (!nodeError(error, "ENOENT")) throw error; }
116
+ }
117
+ return found;
118
+ }
119
+
120
+ async function programReport(root: string, program: ManagedProgram, endpoint: string): Promise<ManagedProgramReport> {
121
+ const state = await program.probe();
122
+ const pid = await readPid(root, program);
123
+ return { id: program.id, endpoint, state, ...await existingLogs(root, program),
124
+ ...(pid !== undefined && processAlive(pid) ? { pid } : {}),
125
+ };
126
+ }
127
+
128
+ function commandLabel(command: ManagedProgramCommand): string {
129
+ // Labels describe purpose. Arguments and environment values can contain credentials.
130
+ return (command.label ?? basename(command.command)).replace(/\s+/gu, " ").trim();
131
+ }
132
+
102
133
  async function run(root: string, command: ManagedProgramCommand, logPath: string): Promise<{ ok: boolean; detail: string }> {
103
134
  const log = await open(logPath, "a+");
104
135
  try {
136
+ const started = Date.now();
137
+ const label = commandLabel(command);
138
+ await log.appendFile(`\n${new Date().toISOString()} Starting: ${label}\n`);
105
139
  const offset = (await log.stat()).size;
106
140
  const exit = await new Promise<{ code: number | null; error?: string }>((resolve) => {
107
141
  const child = spawn(command.command, [...command.args], {
@@ -114,25 +148,24 @@ async function run(root: string, command: ManagedProgramCommand, logPath: string
114
148
  child.on("error", (error) => resolve({ code: null, error: error.message }));
115
149
  child.on("close", (code) => resolve({ code }));
116
150
  });
117
- if (exit.code === 0) return { ok: true, detail: "" };
151
+ if (exit.code === 0) {
152
+ await log.appendFile(`${new Date().toISOString()} Finished: ${label} (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
153
+ return { ok: true, detail: "" };
154
+ }
118
155
  const end = (await log.stat()).size;
119
156
  const start = Math.max(offset, end - 8192);
120
157
  const tail = Buffer.alloc(end - start);
121
158
  const { bytesRead } = await log.read(tail, 0, tail.length, start);
122
159
  const line = tail.subarray(0, bytesRead).toString().trim().split(/\r?\n/u).at(-1);
160
+ await log.appendFile(`${new Date().toISOString()} Failed: ${label} (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
123
161
  return { ok: false, detail: exit.error ?? (line || `exited ${exit.code}`) };
124
162
  } finally {
125
163
  await log.close();
126
164
  }
127
165
  }
128
166
 
129
- /**
130
- * Poll until the program answers as itself. `down` is expected while it loads
131
- * weights; `mismatch` is not, and stops the wait — a program that is answering
132
- * with another identity will not become the right one by waiting.
133
- */
134
167
  /** POSIX: its own session, and stdio already pointed at the log. */
135
- async function startDetached(start: ManagedProgramCommand, root: string, logFd: number): Promise<number | undefined> {
168
+ async function startDetached(start: ManagedProgramCommand, root: string, logFd: number): Promise<{ pid?: number; detail?: string }> {
136
169
  const child = spawn(start.command, [...start.args], {
137
170
  cwd: start.cwd ?? root,
138
171
  env: { ...process.env, ...start.env },
@@ -141,8 +174,13 @@ async function startDetached(start: ManagedProgramCommand, root: string, logFd:
141
174
  windowsHide: true,
142
175
  stdio: ["ignore", logFd, logFd],
143
176
  });
144
- child.unref();
145
- return child.pid;
177
+ return await new Promise((resolve) => {
178
+ child.once("error", (error) => resolve({ detail: error.message }));
179
+ child.once("spawn", () => {
180
+ child.unref();
181
+ resolve(child.pid === undefined ? { detail: "spawn reported no process id" } : { pid: child.pid });
182
+ });
183
+ });
146
184
  }
147
185
 
148
186
  /** A PowerShell single-quoted literal, which escapes by doubling the quote and nothing else. */
@@ -243,11 +281,12 @@ async function waitForReady(program: ManagedProgram, pid: number, maxWaitMs: num
243
281
  return state;
244
282
  }
245
283
 
246
- async function bringUp(
284
+ async function bringUpOwned(
247
285
  root: string,
248
286
  program: ManagedProgram,
249
287
  endpoint: string,
250
288
  maxWaitMs: number,
289
+ release: () => void,
251
290
  onProgress?: (event: ManagedProgramProgress) => void,
252
291
  ): Promise<ManagedProgramReport> {
253
292
  const base = { id: program.id, endpoint };
@@ -265,13 +304,28 @@ async function bringUp(
265
304
  let installed = false;
266
305
  const logPath = join(directory(root, program), "program.log");
267
306
  const installLogPath = join(directory(root, program), "install.log");
307
+ const existingPid = await readPid(root, program);
308
+ if (existingPid !== undefined && processAlive(existingPid)) {
309
+ release();
310
+ onProgress?.({ id: program.id, phase: "waiting", logPath,
311
+ detail: `observing existing process ${existingPid}` });
312
+ const state = await waitForReady(program, existingPid, maxWaitMs);
313
+ const alive = processAlive(existingPid);
314
+ return { ...base, action: state.state === "ready" ? "already-running" : "unchanged", state, logPath,
315
+ ...(alive ? { pid: existingPid } : {}),
316
+ ...(state.state === "ready" ? {} : { detail: alive
317
+ ? `process ${existingPid} is still running; readiness has not been established; see ${logPath}`
318
+ : `process exited; see ${logPath}` }),
319
+ };
320
+ }
321
+ if (existingPid !== undefined) await rm(join(directory(root, program), "process.pid"), { force: true });
268
322
  if (program.installation !== undefined) {
269
323
  const installation = await program.installation.probe();
270
324
  if (installation.state !== "ready" || program.installation.prepareBeforeStart === true) {
271
325
  await mkdir(directory(root, program), { recursive: true });
272
326
  await rotateLog(installLogPath);
273
- onProgress?.({ id: program.id, phase: "installing", logPath: installLogPath });
274
327
  for (const command of program.installation.commands) {
328
+ onProgress?.({ id: program.id, phase: "installing", logPath: installLogPath, detail: commandLabel(command) });
275
329
  const result = await run(root, command, installLogPath);
276
330
  if (!result.ok) {
277
331
  return { ...base, action: "unchanged", state: initial, logPath: installLogPath, detail: `${command.command} failed: ${result.detail}` };
@@ -317,7 +371,7 @@ async function bringUp(
317
371
  // creates a new console and hides it, which is the combination neither spawn option reaches.
318
372
  const started = process.platform === "win32"
319
373
  ? await startWithOwnConsole(program.start, root, logPath)
320
- : { pid: await startDetached(program.start, root, log.fd) };
374
+ : await startDetached(program.start, root, log.fd);
321
375
  if (started.pid === undefined) {
322
376
  const refusal = "detail" in started && started.detail !== undefined ? `: ${started.detail}` : "";
323
377
  return {
@@ -329,11 +383,27 @@ async function bringUp(
329
383
  };
330
384
  }
331
385
  const child = { pid: started.pid };
386
+ // Publish ownership before observing readiness. Another up can now reuse this process,
387
+ // and down can stop it even if loading is slow. The observation owns no startup lock.
388
+ const pidPath = join(directory(root, program), "process.pid");
389
+ // Under the startup lock the old record is already absent. Rename publishes a complete
390
+ // record to read-only observers without replacing a file they might have open on Windows.
391
+ await writeFile(`${pidPath}.tmp`, `${child.pid}\n`);
392
+ await rename(`${pidPath}.tmp`, pidPath);
393
+ release();
332
394
  onProgress?.({ id: program.id, phase: "waiting" });
333
395
  const state = await waitForReady(program, child.pid, maxWaitMs);
334
396
  if (state.state === "ready") onProgress?.({ id: program.id, phase: "ready" });
335
397
  const alive = processAlive(child.pid);
336
- if (alive) await writeFile(join(directory(root, program), "process.pid"), `${child.pid}\n`);
398
+ if (!alive) {
399
+ const { tryProgramLock } = await import("./program-lock.js");
400
+ const releaseCleanup = tryProgramLock(directory(root, program));
401
+ if (releaseCleanup !== undefined) {
402
+ try {
403
+ if (await readPid(root, program) === child.pid) await rm(pidPath, { force: true });
404
+ } finally { releaseCleanup(); }
405
+ }
406
+ }
337
407
  return {
338
408
  ...base,
339
409
  action: state.state === "ready" ? (alive ? "started" : "already-running") : "unchanged",
@@ -341,7 +411,7 @@ async function bringUp(
341
411
  ...(alive ? { pid: child.pid } : {}),
342
412
  logPath,
343
413
  ...(state.state === "ready" ? {} : {
344
- detail: alive ? `see ${logPath}` : `process exited; see ${logPath}`,
414
+ detail: alive ? `process ${child.pid} is still running; readiness has not been established; see ${logPath}` : `process exited; see ${logPath}`,
345
415
  }),
346
416
  };
347
417
  } finally {
@@ -349,6 +419,22 @@ async function bringUp(
349
419
  }
350
420
  }
351
421
 
422
+ async function bringUp(
423
+ root: string, program: ManagedProgram, endpoint: string, maxWaitMs: number,
424
+ onProgress?: (event: ManagedProgramProgress) => void,
425
+ ): Promise<ManagedProgramReport> {
426
+ const home = directory(root, program);
427
+ await mkdir(home, { recursive: true });
428
+ const { tryProgramLock } = await import("./program-lock.js");
429
+ const release = tryProgramLock(home);
430
+ if (release === undefined) return { ...await programReport(root, program, endpoint), action: "unchanged",
431
+ detail: "another command owns this Program's preparation or lifecycle change; inspect its logs", };
432
+ try {
433
+ const report = await bringUpOwned(root, program, endpoint, maxWaitMs, release, onProgress);
434
+ return { ...await existingLogs(root, program), ...report };
435
+ } finally { release(); }
436
+ }
437
+
352
438
  /** Install when needed and start every external program the Runtime Profile implies. */
353
439
  export async function bringManagedProgramsUp(
354
440
  path: string,
@@ -371,60 +457,68 @@ export async function takeManagedProgramsDown(
371
457
  const { dataRoot, programs } = await declaredManagedPrograms(path, options);
372
458
  const reports = await Promise.all(independentPrograms(programs).map(async ({ instance, program }): Promise<ManagedProgramReport> => {
373
459
  const base = { id: program.id, endpoint: instance };
374
- const pid = await readPid(dataRoot, program);
375
- if (pid === undefined || !processAlive(pid)) {
376
- if (pid !== undefined) await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
377
- const state = await program.probe();
378
- return state.state === "down"
379
- ? { ...base, action: "nothing-to-stop", state }
380
- // Someone else's process, or one started by hand. Killing it is not this
381
- // command's business; saying so is.
382
- : { ...base, action: "not-ours", state, detail: `${program.id} is running without a Hypit process record` };
383
- }
384
- const term = await stopProcessTree(pid);
385
- if (term === "denied") {
386
- return {
387
- ...base,
388
- action: "unchanged",
389
- state: await program.probe(),
390
- pid,
391
- detail: `process ${pid} is running but this environment cannot stop it`,
392
- };
393
- }
394
- if (term === "gone") {
395
- await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
396
- const state = await program.probe();
397
- return state.state === "down"
398
- ? { ...base, action: "nothing-to-stop", state }
399
- : { ...base, action: "not-ours", state, detail: `${program.id} is now served by another process` };
400
- }
401
- const deadline = Date.now() + 15_000;
402
- while (processAlive(pid) && Date.now() < deadline) await sleep(200);
403
- if (processAlive(pid)) {
404
- if (await stopProcessTree(pid, true) === "denied") {
460
+ const home = directory(dataRoot, program);
461
+ await mkdir(home, { recursive: true });
462
+ const { tryProgramLock } = await import("./program-lock.js");
463
+ const release = tryProgramLock(home);
464
+ if (release === undefined) return { ...await programReport(dataRoot, program, instance), action: "unchanged",
465
+ detail: "another command owns this Program's preparation or lifecycle change; inspect its logs", };
466
+ try {
467
+ const pid = await readPid(dataRoot, program);
468
+ if (pid === undefined || !processAlive(pid)) {
469
+ if (pid !== undefined) await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
470
+ const state = await program.probe();
471
+ return state.state === "down"
472
+ ? { ...base, action: "nothing-to-stop", state }
473
+ // Someone else's process, or one started by hand. Killing it is not this
474
+ // command's business; saying so is.
475
+ : { ...base, action: "not-ours", state, detail: `${program.id} is running without a Hypit process record` };
476
+ }
477
+ const term = await stopProcessTree(pid);
478
+ if (term === "denied") {
405
479
  return {
406
480
  ...base,
407
481
  action: "unchanged",
408
482
  state: await program.probe(),
409
483
  pid,
410
- detail: `process ${pid} ignored graceful termination and cannot be force-stopped`,
484
+ detail: `process ${pid} is running but this environment cannot stop it`,
411
485
  };
412
486
  }
413
- const forceDeadline = Date.now() + 2_000;
414
- while (processAlive(pid) && Date.now() < forceDeadline) await sleep(50);
487
+ if (term === "gone") {
488
+ await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
489
+ const state = await program.probe();
490
+ return state.state === "down"
491
+ ? { ...base, action: "nothing-to-stop", state }
492
+ : { ...base, action: "not-ours", state, detail: `${program.id} is now served by another process` };
493
+ }
494
+ const deadline = Date.now() + 15_000;
495
+ while (processAlive(pid) && Date.now() < deadline) await sleep(200);
415
496
  if (processAlive(pid)) {
416
- return {
417
- ...base,
418
- action: "unchanged",
419
- state: await program.probe(),
420
- pid,
421
- detail: `process ${pid} remained alive after forced termination`,
422
- };
497
+ if (await stopProcessTree(pid, true) === "denied") {
498
+ return {
499
+ ...base,
500
+ action: "unchanged",
501
+ state: await program.probe(),
502
+ pid,
503
+ detail: `process ${pid} ignored graceful termination and cannot be force-stopped`,
504
+ };
505
+ }
506
+ const forceDeadline = Date.now() + 2_000;
507
+ while (processAlive(pid) && Date.now() < forceDeadline) await sleep(50);
508
+ if (processAlive(pid)) {
509
+ return {
510
+ ...base,
511
+ action: "unchanged",
512
+ state: await program.probe(),
513
+ pid,
514
+ detail: `process ${pid} remained alive after forced termination`,
515
+ };
516
+ }
423
517
  }
424
- }
425
- await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
426
- return { ...base, action: "stopped", state: await program.probe(), pid };
427
- }));
518
+ await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
519
+ return { ...base, action: "stopped", state: await program.probe(), pid };
520
+ } finally { release(); }
521
+ }));
428
522
  return { dataRoot, programs: reports };
429
523
  }
430
524
 
@@ -434,24 +528,7 @@ export async function reportManagedPrograms(
434
528
  options: ManagedProgramOptions = {},
435
529
  ): Promise<{ readonly dataRoot: string; readonly programs: readonly ManagedProgramReport[] }> {
436
530
  const { dataRoot, programs } = await declaredManagedPrograms(path, options);
437
- const reports = await Promise.all(independentPrograms(programs).map(async ({ instance, program }): Promise<ManagedProgramReport> => {
438
- const state = await program.probe();
439
- const pid = await readPid(dataRoot, program);
440
- const logPath = join(directory(dataRoot, program), "program.log");
441
- let hasLog = false;
442
- try {
443
- await stat(logPath);
444
- hasLog = true;
445
- } catch (error) {
446
- if (!nodeError(error, "ENOENT")) throw error;
447
- }
448
- return {
449
- id: program.id,
450
- endpoint: instance,
451
- state,
452
- ...(pid !== undefined && processAlive(pid) ? { pid } : {}),
453
- ...(hasLog ? { logPath } : {}),
454
- };
455
- }));
531
+ const reports = await Promise.all(independentPrograms(programs).map(async ({ instance, program }) =>
532
+ await programReport(dataRoot, program, instance)));
456
533
  return { dataRoot, programs: reports };
457
534
  }
@@ -11,15 +11,38 @@ reusable Source directly, for example
11
11
  `<import as="ugc" source="@hypit/gpt-image-kits/phone-ugc-v1"/>`; resolving that Source does not
12
12
  activate package code.
13
13
 
14
- From any project:
14
+ `hypit version` reports the Distribution version, physical root and launcher independently of a
15
+ project or Runtime. `hypit version --check` also reads the package's `latest` tag at
16
+ `https://registry.npmjs.org/`; `--registry <url>` explicitly selects another registry. `--json`
17
+ returns `hypit.cli-version@1`. A failed check retains local facts, leaves the remote version unknown
18
+ and exits nonzero. A different version is not automatically newer: the launcher may be a newer
19
+ checkout or the mirror may lag. The command links release notes and never installs or updates.
20
+ Skill installers own their separate installed copies; this command does not scan Agent directories.
21
+ `hypit --version` remains a local version-only query.
22
+
23
+ Commands below serve independent authoring decisions. Start with the current project's material;
24
+ local inspection and estimation need no generation account:
15
25
 
16
26
  ```bash
17
27
  cd path/to/project
18
- hypit runtime init
19
- hypit auth login hypihub.default
20
- hypit doctor
21
- hypit runtime up
22
28
  hypit check main.svml
29
+ hypit measure main.svml --segment hook --language en
30
+ ```
31
+
32
+ When the work needs execution, the project selects a Runtime Profile. `hypit runtime init` creates
33
+ an editable starter; its Endpoint entries describe available routes, not choices made by the user.
34
+ Keep an existing chosen service, or configure the chosen local or hosted Provider and its capability
35
+ bindings. HypiHub is the recommended integrated hosted route in the official Distribution; other
36
+ services use project Provider packages. If the user chooses HypiHub,
37
+ `hypit auth login hypihub.default` connects that account.
38
+ `hypit doctor --endpoint <name>` checks a selected Endpoint;
39
+ `hypit runtime up --endpoint <name>` prepares that Endpoint and starts the Worker. Repeat the flag
40
+ for several chosen Endpoints; omitting it prepares the whole Profile. `hypit programs up --endpoint
41
+ <name>` prepares a local helper independently of the Worker.
42
+
43
+ With the selected execution environment:
44
+
45
+ ```bash
23
46
  hypit plan build.svrun
24
47
  hypit build build.svrun --follow
25
48
  hypit status <build-id> --watch
@@ -29,7 +52,6 @@ hypit history <source-output-name> [--source ./main.svml]
29
52
  hypit inspect <build-id> [--output <source-output-name>]
30
53
  hypit get <build-id> --output final.video --to ./final.mp4
31
54
  hypit cancel <build-id>
32
- hypit doctor
33
55
  ```
34
56
 
35
57
  `transcribe` runs one immediate request through the selected Runtime Profile, with no Build,
@@ -63,6 +85,11 @@ that produced them, so they are declared in the Source and go through `plan` and
63
85
  voice or learn a passage's real length before authoring the rest, build a Run whose target is that
64
86
  speech output and reuse it as a Candidate.
65
87
 
88
+ Provider selection is checked before `transcribe` invokes the service. Multiple matching Endpoints
89
+ can remain in the Profile: `bindings` chooses one for the capability. An unsupported request reports
90
+ the selected binding and Provider-owned rejection reasons so the author can adjust the request or
91
+ choose a compatible Endpoint. It does not imply that an account needs payment or login.
92
+
66
93
  Two more families are local, stateless and spend nothing. `hypit media` exposes the source at chosen
67
94
  times and scales, and `hypit vocabulary`
68
95
  prints what a Source may write:
@@ -5,6 +5,7 @@ import { creationCommands, isCreationCommand, writeCreationHelp } from "./creati
5
5
  import { isMediaCommand, mediaCommands, writeMediaHelp } from "./media.js";
6
6
  import { writeVocabularyHelp } from "./vocabulary.js";
7
7
  import { writeCaptureHelp } from "./capture.js";
8
+ import { runVersionCli, writeVersionHelp } from "./version.js";
8
9
 
9
10
  const argv = process.argv.slice(2);
10
11
  const json = argv.includes("--json");
@@ -67,6 +68,7 @@ async function main(): Promise<void> {
67
68
  const topic = argv[0] === "help" ? argv[1]
68
69
  : argv[0] === "--help" ? undefined
69
70
  : argv.includes("--help") ? argv[0] : undefined;
71
+ if (topic === "version") { writeVersionHelp(io); return; }
70
72
  if (isCreationCommand(topic)) {
71
73
  writeCreationHelp(io, topic);
72
74
  return;
@@ -86,12 +88,13 @@ async function main(): Promise<void> {
86
88
  }
87
89
  writeCliHelp(io, topic);
88
90
  if (topic === undefined) {
89
- io.write(`\nCreation tools (one request through the selected Runtime Profile, no Build)\n${
91
+ io.write(`\nInstallation\n version [--check] [--registry <url>] [--json]\n\nCreation tools (one request through the selected Runtime Profile, no Build)\n${
90
92
  creationCommands.map((item) => ` ${item}`).join("\n")}\n hypit help <tool> for each\n`
91
93
  + `\nStudio\n studio --run <build.svrun>\n hypit studio --help for session options\n\nPreparation (local tools and project files)\n media ${mediaCommands.join(" | ")}\n capture screenshot | run | install-browser\n vocabulary\n hypit help media, hypit help capture, hypit help vocabulary\n`);
92
94
  }
93
95
  return;
94
96
  }
97
+ if (argv[0] === "version") { await runVersionCli(argv, io); return; }
95
98
  const { runVideoCli } = await import("./index.js");
96
99
  await runVideoCli(argv, io);
97
100
  }
@@ -159,9 +159,14 @@ async function selectedProvider(host: CreationHost, need: Need, profile: string)
159
159
  }]);
160
160
  const subject = capabilityName(need.capability);
161
161
  assert(provider !== undefined && provider.status !== "unresolved",
162
- `No Endpoint in ${profile} serves ${subject}; hypit plan --runtime ${profile} shows which Endpoint each capability needs`);
162
+ `No Endpoint in ${profile} serves ${subject}; configure an Endpoint that supports this capability and check its binding in that Profile`);
163
163
  assert(provider.status !== "ambiguous",
164
- `Several Endpoints in ${profile} serve ${subject}: ${(provider.endpoints ?? []).join(", ")}; keep exactly one`);
164
+ `Several Endpoints in ${profile} serve ${subject}: ${(provider.endpoints ?? []).join(", ")}; select one with bindings[${JSON.stringify(subject)}] in that Profile`);
165
+ assert(provider.status !== "unsupported",
166
+ `The configured Endpoints in ${profile} do not support this request for ${subject}`
167
+ + (provider.binding === undefined ? "" : ` (binding: ${provider.binding})`)
168
+ + `: ${(provider.rejections ?? []).map((item) => `${item.endpoint}: ${item.message}`).join("; ") || "no support reason supplied"}`
169
+ + "; adjust the request or select a compatible Endpoint in that Profile");
165
170
  return provider;
166
171
  }
167
172
 
@@ -12,6 +12,7 @@ import { videoCliDistribution } from "./distribution.js";
12
12
  import { runMediaCli } from "./media.js";
13
13
  import { runVocabularyCli } from "./vocabulary.js";
14
14
  import { runCaptureCli } from "./capture.js";
15
+ import { runVersionCli } from "./version.js";
15
16
 
16
17
  export {
17
18
  createVideoCompiler,
@@ -35,6 +36,7 @@ export function runVideoCli(
35
36
  io: CliIo,
36
37
  packages: readonly LoadedPackage[] = [],
37
38
  ): Promise<void> {
39
+ if (argv[0] === "version") return runVersionCli(argv, io);
38
40
  installDistributionPackageResolution(videoCliDistribution.packageRoot === undefined
39
41
  ? []
40
42
  : [videoCliDistribution.packageRoot]);
@@ -0,0 +1,90 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import type { CliIo } from "@hypit/cli";
4
+ import { requestDeadline } from "@hypit/runtime-kit";
5
+
6
+ type VersionEnvironment = {
7
+ readonly packageRoot: string;
8
+ readonly launcher?: string;
9
+ readonly fetch: typeof globalThis.fetch;
10
+ readonly timeoutMs?: number;
11
+ };
12
+
13
+ export function writeVersionHelp(io: CliIo): void {
14
+ io.write("Usage: hypit version [--check] [--registry <url>] [--json]\n\n"
15
+ + "Show the executing Distribution and launcher without opening a project or Runtime.\n"
16
+ + "--check queries npm latest (registry.npmjs.org by default); --registry selects a mirror.\n"
17
+ + "No packages or Skills are updated. hypit --version remains a local version-only query.\n");
18
+ }
19
+
20
+ /** Installation discovery belongs to the executable, independent of video execution and Skill installers. */
21
+ export async function runVersionCli(argv: readonly string[], io: CliIo, environment: VersionEnvironment = {
22
+ packageRoot: resolve(import.meta.dirname, "../../.."),
23
+ ...(process.env.HYPIT_CLI_LAUNCHER === undefined ? {} : { launcher: process.env.HYPIT_CLI_LAUNCHER }),
24
+ fetch: globalThis.fetch,
25
+ }): Promise<void> {
26
+ let check = false;
27
+ let json = false;
28
+ let registry = "https://registry.npmjs.org/";
29
+ let selectedRegistry = false;
30
+ for (let i = 1; i < argv.length; i++) {
31
+ const arg = argv[i];
32
+ if (arg === "--check") check = true;
33
+ else if (arg === "--json") json = true;
34
+ else if (arg === "--registry") {
35
+ selectedRegistry = true;
36
+ const value = argv[++i];
37
+ if (value === undefined || value.startsWith("--")) throw new Error("--registry requires a URL");
38
+ const url = new URL(value);
39
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
40
+ throw new Error("--registry requires an HTTP(S) registry URL without credentials, query or fragment");
41
+ }
42
+ registry = `${url.href.replace(/\/+$/u, "")}/`;
43
+ } else if (arg === "--help") { writeVersionHelp(io); return; }
44
+ else if (arg !== "--no-color" && arg !== "--debug") throw new Error(`Unknown version option: ${arg}`);
45
+ }
46
+ if (selectedRegistry && !check) throw new Error("--registry selects the source for --check; add --check to query it");
47
+ const manifest = JSON.parse(await readFile(resolve(environment.packageRoot, "package.json"), "utf8")) as {
48
+ name: string; version: string; repository?: { url?: string };
49
+ };
50
+ const repository = manifest.repository?.url?.replace(/^git\+/u, "").replace(/\.git$/u, "");
51
+ const releases = repository === undefined ? undefined : `${repository}/releases`;
52
+ let latest: { registry: string; version?: string; matchesInstalled?: boolean; error?: string } | undefined;
53
+ if (check) {
54
+ latest = { registry };
55
+ const deadline = requestDeadline(environment.timeoutMs ?? 10_000,
56
+ () => new Error("Registry version query timed out; latest version is unknown"));
57
+ try {
58
+ const response = await deadline.wait(environment.fetch(new URL(`${encodeURIComponent(manifest.name)}/latest`, registry), {
59
+ signal: deadline.signal, headers: { accept: "application/json" },
60
+ }));
61
+ if (!response.ok) throw new Error(`Registry returned HTTP ${response.status}; latest version is unknown`);
62
+ const body = await deadline.wait(response.json()) as { name?: unknown; version?: unknown };
63
+ if (body.name !== manifest.name || typeof body.version !== "string" || body.version.length === 0) {
64
+ throw new Error("Registry returned no matching package version; latest version is unknown");
65
+ }
66
+ latest.version = body.version;
67
+ latest.matchesInstalled = body.version === manifest.version;
68
+ } catch (error) {
69
+ latest.error = error instanceof Error ? error.message : String(error);
70
+ io.setExitCode?.(1);
71
+ } finally { deadline.finish(); }
72
+ }
73
+ const report = {
74
+ format: "hypit.cli-version@1", package: manifest.name, version: manifest.version,
75
+ distribution: resolve(environment.packageRoot),
76
+ ...(environment.launcher === undefined ? {} : { launcher: resolve(environment.launcher) }),
77
+ ...(releases === undefined ? {} : { releases }),
78
+ ...(latest === undefined ? {} : { latest }),
79
+ };
80
+ if (json) { io.write(`${JSON.stringify(report, null, 2)}\n`); return; }
81
+ io.write(`${manifest.name} ${manifest.version}\nDistribution: ${report.distribution}\n`
82
+ + (report.launcher === undefined ? "" : `Launcher: ${report.launcher}\n`));
83
+ if (latest !== undefined) {
84
+ io.write(`npm latest: ${latest.version === undefined ? "unknown" : `${latest.version} (${latest.matchesInstalled ? "matches installed" : "differs from installed"})`}\nRegistry: ${registry}\n`);
85
+ if (latest.error !== undefined) io.write(`${latest.error}\n`);
86
+ if (releases !== undefined) io.write(`Release notes: ${releases}\n`);
87
+ io.write("Update through this installation's package manager or checkout; preserve the project's chosen version.\n"
88
+ + "Skill updates belong to its installer and are separate from this executable.\n");
89
+ }
90
+ }
@@ -19,8 +19,8 @@ semantic projection combines this evidence with one explicit Script Segment late
19
19
  ## Install
20
20
 
21
21
  For an ordinary installed Distribution, select the local WhisperX Endpoint and run
22
- `hypit runtime up`. The Runtime creates this environment in the machine Program Home only when it is
23
- missing, and reuses it across projects and sessions. The commands below are contributor/operator
22
+ `hypit programs up --endpoint <instance>`. The Runtime creates or reconciles the cold environment in
23
+ the machine Program Home and reuses a running service across projects and sessions. The commands below are contributor/operator
24
24
  diagnostics for a deliberately managed deployment:
25
25
 
26
26
  WhisperX 3.8.6 supports Python 3.10 through 3.13. The checked-in lock selects Python 3.13:
@@ -32,7 +32,8 @@ uv run --project services/whisperx --frozen hypit-whisperx-prepare
32
32
  uv run --project services/whisperx --frozen hypit-whisperx-check
33
33
  ```
34
34
 
35
- The first model start may download ASR and alignment weights. Production should put the relevant
35
+ The first model start may download ASR weights; first use of a language can download its alignment
36
+ model during the request. Production should put the relevant
36
37
  Hugging Face cache on persistent storage. `hypit-whisperx-prepare` separately installs NLTK's
37
38
  `punkt_tab` sentence data through NLTK's own downloader. This resource is required by WhisperX
38
39
  alignment and is prepared explicitly before the warm service starts, never inside an inference request.
@@ -71,6 +72,12 @@ Configuration is deployment state:
71
72
  The Node Provider must configure the same model, device, compute, batch size, service version and
72
73
  WhisperX version. A mismatch fails before transcription results are accepted.
73
74
 
75
+ The service logs ASR loading, transcription, language-model loading and word alignment where those
76
+ operations run. Completion entries include elapsed times. A loading entry means the library call
77
+ has begun and may include a weight download; the download client supplies any transfer progress.
78
+ Transcripts and audio content are not included in these service progress entries. `/health` answers
79
+ after ASR loading, so a healthy service may still prepare a language model on its first request.
80
+
74
81
  ## Package preparation
75
82
 
76
83
  Local package preparation includes `src/**/*.py` in uv's package cache inputs. Updating service code