@neta-art/cohub-cli 10.1.0 → 10.3.0

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.
@@ -0,0 +1,4 @@
1
+ export declare function collectOption(value: string, previous?: string[]): string[];
2
+ export declare function readPromptContent(words: string[], options?: {
3
+ allowEmpty?: boolean;
4
+ }): Promise<string>;
@@ -0,0 +1,16 @@
1
+ import { error } from "./output.js";
2
+ export function collectOption(value, previous = []) {
3
+ return [...previous, value];
4
+ }
5
+ export async function readPromptContent(words, options = {}) {
6
+ let content = words.join(" ");
7
+ if (!content && !process.stdin.isTTY) {
8
+ const chunks = [];
9
+ for await (const chunk of process.stdin)
10
+ chunks.push(chunk);
11
+ content = Buffer.concat(chunks).toString().trim();
12
+ }
13
+ if (!content && !options.allowEmpty)
14
+ return error("No content", "Pass as argument or pipe via stdin");
15
+ return content;
16
+ }
@@ -1,9 +1,7 @@
1
1
  import { resolveExecutionAppId, } from "@neta-art/cohub";
2
2
  import { createClient } from "../client.js";
3
+ import { collectOption } from "../command-input.js";
3
4
  import { error, formatLocalDateTime, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
4
- function collectOption(value, previous = []) {
5
- return [...previous, value];
6
- }
7
5
  function requireText(value, label, flag) {
8
6
  const text = value?.trim();
9
7
  if (text)
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
- import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
5
+ import { BOARD_PRESET_NAMES, BOARD_SCHEMA_TARGETS, BOARD_SCRIPT_API, boardJsonSchema, boardPresetTracks, boardSchemaOverview, isBoardPresetName, listBoardPresets, } from "@neta-art/cohub/board";
6
6
  import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
7
7
  import { formatBoardTime, parseBoardTime, parseBoardTimes } from "../board-time.js";
8
8
  import { createClient, createRealtimeClient } from "../client.js";
@@ -283,13 +283,18 @@ export function registerBoards(program) {
283
283
  .description("Read, write, animate and export Boards (by id or .board path)")
284
284
  .addHelpText("after", `
285
285
  A Board is one JSON document: { board, items, animations }.
286
- get read it
287
- apply write it with a JSON Merge Patch; null deletes
288
- schema the schema of any part, and what tracks can animate
289
- preset tracks for a preset motion, ready to apply
290
- history list versions, or restore one
291
- export render an image, a frame sequence or a video
292
- examples starter documents
286
+ get read it
287
+ apply write it with a JSON Merge Patch; null deletes
288
+ schema the fields of any part and what they mean; also the script API
289
+ examples starter documents and a custom script module
290
+ preset tracks for a preset motion, ready to apply
291
+ sequence join chapter animations into one timeline
292
+ path sample a polyline into a position track
293
+ inspect measure items and report layout hints
294
+ export render an image, a frame sequence or a video
295
+ play shared playback; also pause, resume, next, seek, stop
296
+ watch stream changes and playback
297
+ history list versions, or restore one
293
298
  Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
294
299
  withJson(boards.command("create <path>")
295
300
  .description("Create a Board file, optionally with an initial document")
@@ -444,9 +449,9 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
444
449
  boards.command("schema [target]")
445
450
  .description("Print JSON Schema for a part of a Board")
446
451
  .addHelpText("after", `
447
- With a target: one item type, or animation, track, or camera.
448
- Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}
449
- Without a target: units, item types, colors and every animatable property.`)
452
+ Without a target: units, item types, colors and every animatable property.
453
+ With a target: its fields and what they mean; script also lists the module API.
454
+ Targets: ${BOARD_SCHEMA_TARGETS.join(", ")}`)
450
455
  .action((target) => {
451
456
  try {
452
457
  if (!target)
@@ -454,7 +459,7 @@ Without a target: units, item types, colors and every animatable property.`)
454
459
  const schema = boardJsonSchema(target);
455
460
  if (!schema)
456
461
  throw new Error(`Unknown target ${target}; expected ${BOARD_SCHEMA_TARGETS.join(", ")}.`);
457
- outJson(schema);
462
+ outJson(target === "script" ? { ...schema, api: BOARD_SCRIPT_API } : schema);
458
463
  }
459
464
  catch (cause) {
460
465
  handleHttp(cause);
@@ -0,0 +1,4 @@
1
+ import type { DecisionStateImage } from "@neta-art/cohub";
2
+ import type { Command } from "commander";
3
+ export declare function mergeDecisionState(state: unknown, text: string, images: DecisionStateImage[]): unknown;
4
+ export declare function registerDecisionCommand(parent: Command): void;
@@ -0,0 +1,113 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { uploadChatImageAsset } from "../avatar.js";
3
+ import { createClient } from "../client.js";
4
+ import { collectOption, readPromptContent } from "../command-input.js";
5
+ import { error, handleHttp, json as outJson, jsonRequested, table } from "../output.js";
6
+ import { resolveSpace } from "../space.js";
7
+ const REMOTE_URL = /^https?:\/\//i;
8
+ async function readJsonObject(source, label) {
9
+ const text = source.trim().startsWith("{")
10
+ ? source
11
+ : await readFile(source, "utf-8").catch(() => error(`Cannot read ${label}`, source));
12
+ let value;
13
+ try {
14
+ value = JSON.parse(text);
15
+ }
16
+ catch {
17
+ return error(`Invalid ${label}`, "Expected a JSON object or a path to a JSON file");
18
+ }
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
20
+ return error(`Invalid ${label}`, "Expected a JSON object");
21
+ }
22
+ return value;
23
+ }
24
+ export function mergeDecisionState(state, text, images) {
25
+ if (!text && images.length === 0)
26
+ return state;
27
+ if (state !== undefined && typeof state !== "string" && !Array.isArray(state)) {
28
+ throw new Error("Cannot combine a JSON object state with text or --image; use a text/image array in --input instead.");
29
+ }
30
+ const parts = [
31
+ ...(Array.isArray(state) ? state : typeof state === "string" ? [state] : []),
32
+ ...(text ? [text] : []),
33
+ ...images,
34
+ ];
35
+ return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts;
36
+ }
37
+ function formatAnswer(answer) {
38
+ if (answer.type === "noul")
39
+ return (answer.noul ?? 0).toFixed(2);
40
+ if (answer.type === "choice")
41
+ return answer.choice ?? "";
42
+ const top = Object.entries(answer.probabilities ?? {}).sort(([, a], [, b]) => b - a)[0]?.[0];
43
+ const label = top === undefined ? undefined : answer.legend?.[top];
44
+ return `${(answer.score ?? 0).toFixed(2)}${label ? ` ${label}` : ""}`;
45
+ }
46
+ function printResult(result) {
47
+ table(Object.entries(result.answers).map(([key, answer]) => ({
48
+ question: key,
49
+ type: answer.type,
50
+ answer: formatAnswer(answer),
51
+ confidence: answer.confidence,
52
+ })), [
53
+ { key: "question", label: "QUESTION" },
54
+ { key: "type", label: "TYPE" },
55
+ { key: "answer", label: "ANSWER" },
56
+ { key: "confidence", label: "CONFIDENCE", format: (value) => (typeof value === "number" ? value.toFixed(2) : "") },
57
+ ]);
58
+ const cost = result.usage.cost?.total;
59
+ process.stderr.write(`\n ${result.provider}/${result.model}${typeof cost === "number" ? ` cost: $${cost}` : ""}\n`);
60
+ }
61
+ async function runDecisionCommand(command, words, opts) {
62
+ const base = opts.input ? await readJsonObject(opts.input, "input") : {};
63
+ const questions = opts.questions ? await readJsonObject(opts.questions, "questions") : base.questions;
64
+ if (!questions)
65
+ return error("Questions required", "Pass --questions or --input");
66
+ const spaceId = await resolveSpace(command, { home: true });
67
+ const client = createClient();
68
+ try {
69
+ const images = opts.image ?? [];
70
+ const text = words.length > 0 || base.state === undefined
71
+ ? await readPromptContent(words, { allowEmpty: images.length > 0 })
72
+ : "";
73
+ const imageParts = images.map((url) => ({ type: "image_url", image_url: { url } }));
74
+ const state = mergeDecisionState(base.state, text, imageParts);
75
+ await Promise.all(imageParts.map(async (part) => {
76
+ if (!REMOTE_URL.test(part.image_url.url)) {
77
+ part.image_url.url = (await uploadChatImageAsset({ client, spaceId, path: part.image_url.url })).publicUrl;
78
+ }
79
+ }));
80
+ const input = {
81
+ ...base,
82
+ provider: opts.provider ?? base.provider,
83
+ model: opts.model ?? base.model,
84
+ state,
85
+ questions,
86
+ };
87
+ const result = await client.space(spaceId).decision(input);
88
+ if (jsonRequested(opts))
89
+ return outJson(result);
90
+ printResult(result);
91
+ }
92
+ catch (e) {
93
+ handleHttp(e);
94
+ }
95
+ }
96
+ export function registerDecisionCommand(parent) {
97
+ parent
98
+ .command("decision [state...]")
99
+ .description("Answer yes/no, choice and score questions about text or images")
100
+ .option("-q, --questions <json|path>", "Questions as inline JSON or a JSON file")
101
+ .option("--input <path>", "Full request JSON file (state, questions, model)")
102
+ .option("--image <path-or-url>", "Attach an image to the state", collectOption, [])
103
+ .option("-m, --model <model>", "Decision model ID")
104
+ .option("-p, --provider <provider>", "Provider name")
105
+ .option("--json", "Output as JSON")
106
+ .addHelpText("after", `
107
+ Examples:
108
+ cohub decision "I was charged twice" -q '{"refund":{"type":"noul","instructions":"Refund?"}}'
109
+ cat ticket.txt | cohub decision -q questions.json --json
110
+ cohub decision "Duplicate charge?" --image receipt.png -q questions.json
111
+ cohub decision --input request.json`)
112
+ .action((words, opts) => runDecisionCommand(parent, words, opts));
113
+ }
@@ -0,0 +1,17 @@
1
+ import type { Readable } from "node:stream";
2
+ type Input = {
3
+ op: "publish" | "send";
4
+ type: string;
5
+ data: unknown;
6
+ } | {
7
+ op: "presence";
8
+ presence: Record<string, unknown> | null;
9
+ };
10
+ export declare function parseRoomInput(line: string): Input;
11
+ export declare function readRoomLines(input: Readable, maxBytes?: number): AsyncGenerator<string>;
12
+ export declare function connectToRoom(roomId: string, options: {
13
+ after?: string;
14
+ input?: boolean;
15
+ json: boolean;
16
+ }): Promise<void>;
17
+ export {};
@@ -0,0 +1,140 @@
1
+ import { createRealtimeClient } from "../client.js";
2
+ const MAX_LINE_BYTES = 20 * 1024;
3
+ const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024;
4
+ export function parseRoomInput(line) {
5
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES)
6
+ throw new Error("Room input line is too large");
7
+ let value;
8
+ try {
9
+ value = JSON.parse(line);
10
+ }
11
+ catch {
12
+ throw new Error("Room input must be JSON");
13
+ }
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ throw new Error("Room input must be an object");
16
+ const input = value;
17
+ if (input.op === "presence" && (input.presence === null || typeof input.presence === "object" && !Array.isArray(input.presence))) {
18
+ return { op: "presence", presence: input.presence };
19
+ }
20
+ if ((input.op === "publish" || input.op === "send") && typeof input.type === "string" && "data" in input) {
21
+ return { op: input.op, type: input.type, data: input.data };
22
+ }
23
+ throw new Error("Use {\"op\":\"publish|send\",\"type\":\"name\",\"data\":...} or {\"op\":\"presence\",\"presence\":...}");
24
+ }
25
+ export async function* readRoomLines(input, maxBytes = MAX_LINE_BYTES) {
26
+ let pending = Buffer.alloc(0);
27
+ for await (const chunk of input) {
28
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ let start = 0;
30
+ for (;;) {
31
+ const end = bytes.indexOf(10, start);
32
+ const part = bytes.subarray(start, end < 0 ? bytes.length : end);
33
+ if (pending.length + part.length > maxBytes)
34
+ throw new Error("Room input line is too large");
35
+ pending = pending.length ? Buffer.concat([pending, part]) : part;
36
+ if (end < 0)
37
+ break;
38
+ yield pending.toString("utf8").replace(/\r$/, "");
39
+ pending = Buffer.alloc(0);
40
+ start = end + 1;
41
+ }
42
+ }
43
+ if (pending.length)
44
+ yield pending.toString("utf8");
45
+ }
46
+ export async function connectToRoom(roomId, options) {
47
+ const after = options.after === undefined ? undefined : Number(options.after);
48
+ if (after !== undefined && (!Number.isSafeInteger(after) || after < 0))
49
+ throw new Error("--after must be a non-negative sequence number");
50
+ const client = createRealtimeClient();
51
+ let room = null;
52
+ let stopped = false;
53
+ let finish;
54
+ const done = new Promise((resolve) => { finish = resolve; });
55
+ const stop = () => {
56
+ if (stopped)
57
+ return;
58
+ stopped = true;
59
+ finish();
60
+ };
61
+ const write = (kind, value) => {
62
+ if (stopped)
63
+ return;
64
+ if (process.stdout.writableLength > MAX_OUTPUT_BUFFER_BYTES) {
65
+ process.stderr.write("Room output is too slow; disconnecting.\n");
66
+ process.exitCode = 1;
67
+ stop();
68
+ return;
69
+ }
70
+ process.stdout.write(options.json ? `${JSON.stringify({ kind, ...value })}\n` : `${kind}: ${JSON.stringify(value)}\n`);
71
+ };
72
+ const onSignal = () => { stop(); void room?.disconnect().catch(() => undefined); void client.disconnect(); };
73
+ process.once("SIGINT", onSignal);
74
+ process.once("SIGTERM", onSignal);
75
+ let inputTask = null;
76
+ try {
77
+ room = await client.rooms.connect(roomId, {
78
+ after,
79
+ onRoom: (active) => {
80
+ room = active;
81
+ if (stopped) {
82
+ void active.disconnect();
83
+ return;
84
+ }
85
+ active.subscribeAll((event) => write("event", { event }));
86
+ active.onMembersChanged((members) => write("members", { members }));
87
+ active.onOutOfSync((from, to) => write("gap", { from, to }));
88
+ active.onSendError((failure) => write("error", { message: failure.message }));
89
+ active.onStateChange((state) => {
90
+ write("state", { state });
91
+ if (state === "closed" || state === "expired")
92
+ stop();
93
+ });
94
+ },
95
+ });
96
+ if (!stopped)
97
+ write("connected", { roomId, participantId: room.participantId, sequence: room.sequence });
98
+ if (!stopped && options.input) {
99
+ const active = room;
100
+ inputTask = (async () => {
101
+ let lineNumber = 0;
102
+ for await (const line of readRoomLines(process.stdin)) {
103
+ if (stopped)
104
+ break;
105
+ lineNumber++;
106
+ if (!line.trim())
107
+ continue;
108
+ try {
109
+ const input = parseRoomInput(line);
110
+ if (input.op === "presence")
111
+ await active.setPresence(input.presence);
112
+ else if (input.op === "send")
113
+ active.send(input.type, input.data);
114
+ else {
115
+ const result = await active.publish(input.type, input.data);
116
+ write("ack", { line: lineNumber, sequence: result.sequence, eventId: result.eventId });
117
+ }
118
+ }
119
+ catch (cause) {
120
+ write("input_error", { line: lineNumber, message: cause instanceof Error ? cause.message : String(cause) });
121
+ }
122
+ }
123
+ })().catch((cause) => {
124
+ write("input_error", { message: cause instanceof Error ? cause.message : String(cause) });
125
+ process.exitCode = 1;
126
+ stop();
127
+ });
128
+ }
129
+ await done;
130
+ }
131
+ finally {
132
+ if (options.input)
133
+ process.stdin.destroy();
134
+ await room?.disconnect().catch(() => undefined);
135
+ await client.disconnect();
136
+ await inputTask?.catch(() => undefined);
137
+ process.removeListener("SIGINT", onSignal);
138
+ process.removeListener("SIGTERM", onSignal);
139
+ }
140
+ }
@@ -0,0 +1,2 @@
1
+ export declare const RTC_HELPER_VERSION: string;
2
+ export declare function ensureRtcBinary(local?: string): Promise<string>;
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, createWriteStream } from "node:fs";
3
+ import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { Readable, Transform } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { createGunzip } from "node:zlib";
9
+ // Pin a global release tag after its helper artifacts are published.
10
+ export const RTC_HELPER_VERSION = "v2.67.0";
11
+ const MAX_BINARY_BYTES = 128 * 1024 * 1024;
12
+ async function digest(path) {
13
+ const hash = createHash("sha256");
14
+ await pipeline(createReadStream(path), hash);
15
+ return hash.digest("hex");
16
+ }
17
+ async function readText(response, limit) {
18
+ if (!response.body)
19
+ throw new Error("Empty RTC helper response");
20
+ const reader = response.body.getReader();
21
+ const parts = [];
22
+ let bytes = 0;
23
+ try {
24
+ for (;;) {
25
+ const { done, value } = await reader.read();
26
+ if (done)
27
+ break;
28
+ bytes += value.length;
29
+ if (bytes > limit)
30
+ throw new Error("RTC helper metadata exceeds the size limit");
31
+ parts.push(value);
32
+ }
33
+ }
34
+ finally {
35
+ await reader.cancel();
36
+ }
37
+ return Buffer.concat(parts).toString("utf8");
38
+ }
39
+ export async function ensureRtcBinary(local) {
40
+ const override = local || process.env.COHUB_RTC_BIN?.trim();
41
+ if (override) {
42
+ if (!(await lstat(override)).isFile())
43
+ throw new Error("RTC helper must be a regular file");
44
+ return resolve(override);
45
+ }
46
+ const platform = { linux: "linux", darwin: "darwin" }[process.platform];
47
+ const arch = { x64: "amd64", arm64: "arm64" }[process.arch];
48
+ if (!platform || !arch)
49
+ throw new Error("Unsupported RTC helper platform; use --helper with a local binary");
50
+ const version = process.env.COHUB_RTC_VERSION?.trim() || RTC_HELPER_VERSION;
51
+ if (!version)
52
+ throw new Error("No RTC helper release is pinned yet; use --helper or COHUB_RTC_VERSION with a published global release");
53
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(version))
54
+ throw new Error("Invalid RTC helper version");
55
+ const directory = join(homedir(), ".cache", "cohub", "rtc", version, `${platform}-${arch}`);
56
+ const binary = join(directory, "cohub-rtc");
57
+ const checksum = `${binary}.sha256`;
58
+ const expected = await readFile(checksum, "utf8").catch(() => "");
59
+ if (/^[a-f0-9]{64}$/.test(expected) && (await lstat(binary).catch(() => null))?.isFile() && await digest(binary) === expected)
60
+ return binary;
61
+ await mkdir(directory, { recursive: true });
62
+ const temporary = await mkdtemp(join(directory, ".download-"));
63
+ const base = (process.env.COHUB_RTC_CDN_BASE_URL?.trim() || "https://public.cohub.live/rtc").replace(/\/+$/, "");
64
+ const url = `${base}/${version}/cohub-rtc_${platform}_${arch}.gz`;
65
+ const signal = AbortSignal.timeout(120_000);
66
+ try {
67
+ const manifest = await fetch(`${url}.sha256`, { signal });
68
+ if (!manifest.ok)
69
+ throw new Error(`RTC helper ${version} is unavailable (${manifest.status}); use --helper with a locally built cohub-rtc`);
70
+ const text = await readText(manifest, 1024);
71
+ const expectedArchive = text.trim().split(/\s+/)[0];
72
+ if (!expectedArchive || !/^[a-f0-9]{64}$/.test(expectedArchive))
73
+ throw new Error("Invalid RTC helper checksum");
74
+ const response = await fetch(url, { signal });
75
+ if (!response.ok || !response.body)
76
+ throw new Error(`RTC helper download failed (${response.status})`);
77
+ let bytes = 0;
78
+ const bound = () => new Transform({ transform(chunk, _encoding, done) {
79
+ bytes += chunk.length;
80
+ done(bytes > MAX_BINARY_BYTES ? new Error("RTC helper exceeds the size limit") : null, chunk);
81
+ } });
82
+ const archive = join(temporary, "archive.gz");
83
+ await pipeline(Readable.fromWeb(response.body), bound(), createWriteStream(archive, { flags: "wx" }), { signal });
84
+ if (await digest(archive) !== expectedArchive)
85
+ throw new Error("RTC helper checksum mismatch");
86
+ for (const name of ["LICENSE", "NOTICE"]) {
87
+ const response = await fetch(`${base}/${version}/${name}`, { signal });
88
+ if (!response.ok)
89
+ throw new Error(`RTC helper ${name} is unavailable (${response.status})`);
90
+ const text = await readText(response, 64 * 1024);
91
+ await writeFile(join(temporary, name), text, { flag: "wx" });
92
+ await rename(join(temporary, name), join(directory, name));
93
+ }
94
+ const extracted = join(temporary, "cohub-rtc");
95
+ bytes = 0;
96
+ await pipeline(createReadStream(archive), createGunzip(), bound(), createWriteStream(extracted, { flags: "wx", mode: 0o700 }), { signal });
97
+ await chmod(extracted, 0o700);
98
+ const hash = await digest(extracted);
99
+ await writeFile(join(temporary, "checksum"), hash, { flag: "wx" });
100
+ await rename(extracted, binary);
101
+ await rename(join(temporary, "checksum"), checksum);
102
+ return binary;
103
+ }
104
+ finally {
105
+ await rm(temporary, { recursive: true, force: true });
106
+ }
107
+ }
@@ -0,0 +1,21 @@
1
+ export type RtcOptions = {
2
+ helper?: string;
3
+ audio?: string;
4
+ video?: string;
5
+ audioFormat: "pcm" | "opus";
6
+ videoFormat: "i420" | "vp8";
7
+ size?: string;
8
+ fps: string;
9
+ rate: string;
10
+ channels: string;
11
+ output?: string;
12
+ receiveFormat: "rtp" | "raw";
13
+ receiveSize: string;
14
+ maxBytes: string;
15
+ participant?: string;
16
+ channel?: string;
17
+ delivery: "reliable" | "latest";
18
+ dataInput?: string;
19
+ json?: boolean;
20
+ };
21
+ export declare function runRoomRtc(roomId: string, options: RtcOptions): Promise<void>;