gambi 0.2.3
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.
- package/README.md +1 -0
- package/package.json +60 -0
- package/src/cli.ts +32 -0
- package/src/commands/create.ts +160 -0
- package/src/commands/join.ts +937 -0
- package/src/commands/list.ts +104 -0
- package/src/commands/monitor.ts +27 -0
- package/src/commands/serve.ts +116 -0
- package/src/utils/network-endpoint.test.ts +40 -0
- package/src/utils/network-endpoint.ts +136 -0
- package/src/utils/option.ts +10 -0
- package/src/utils/prompt.ts +24 -0
- package/src/utils/runtime-config.ts +211 -0
- package/src/utils/specs.ts +149 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { confirm, intro, text } from "@clack/prompts";
|
|
2
|
+
import { Command, Option } from "../utils/option.ts";
|
|
3
|
+
import {
|
|
4
|
+
handleCancel,
|
|
5
|
+
hasExplicitFlags,
|
|
6
|
+
isInteractive,
|
|
7
|
+
} from "../utils/prompt.ts";
|
|
8
|
+
|
|
9
|
+
interface RoomInfo {
|
|
10
|
+
id: string;
|
|
11
|
+
code: string;
|
|
12
|
+
name: string;
|
|
13
|
+
hostId: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
participantCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ListRoomsResponse {
|
|
19
|
+
rooms: RoomInfo[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ErrorResponse {
|
|
23
|
+
error: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ListCommand extends Command {
|
|
27
|
+
static override paths = [["list"]];
|
|
28
|
+
|
|
29
|
+
static override usage = Command.Usage({
|
|
30
|
+
description: "List available rooms on a hub",
|
|
31
|
+
examples: [
|
|
32
|
+
["List rooms (interactive)", "gambi list"],
|
|
33
|
+
["List on custom hub", "gambi list --hub http://192.168.1.10:3000"],
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
hub = Option.String("--hub,-H", "http://localhost:3000", {
|
|
38
|
+
description: "Hub URL",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
json = Option.Boolean("--json,-j", false, {
|
|
42
|
+
description: "Output as JSON",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async execute(): Promise<number> {
|
|
46
|
+
let hub = this.hub;
|
|
47
|
+
let json = this.json;
|
|
48
|
+
|
|
49
|
+
if (!hasExplicitFlags() && isInteractive()) {
|
|
50
|
+
intro("gambi list");
|
|
51
|
+
|
|
52
|
+
const hubResult = await text({
|
|
53
|
+
message: "Hub URL:",
|
|
54
|
+
defaultValue: "http://localhost:3000",
|
|
55
|
+
placeholder: "http://localhost:3000",
|
|
56
|
+
});
|
|
57
|
+
handleCancel(hubResult);
|
|
58
|
+
hub = hubResult as string;
|
|
59
|
+
|
|
60
|
+
const jsonResult = await confirm({
|
|
61
|
+
message: "Output as JSON?",
|
|
62
|
+
initialValue: false,
|
|
63
|
+
});
|
|
64
|
+
handleCancel(jsonResult);
|
|
65
|
+
json = jsonResult as boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`${hub}/rooms`);
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const data = (await response.json()) as ErrorResponse;
|
|
73
|
+
this.context.stderr.write(`Error: ${data.error}\n`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = (await response.json()) as ListRoomsResponse;
|
|
78
|
+
const rooms = data.rooms;
|
|
79
|
+
|
|
80
|
+
if (json) {
|
|
81
|
+
this.context.stdout.write(`${JSON.stringify(rooms, null, 2)}\n`);
|
|
82
|
+
} else if (rooms.length === 0) {
|
|
83
|
+
this.context.stdout.write("No rooms available.\n");
|
|
84
|
+
} else {
|
|
85
|
+
this.context.stdout.write("Available rooms:\n\n");
|
|
86
|
+
for (const room of rooms) {
|
|
87
|
+
this.context.stdout.write(` ${room.code} ${room.name}\n`);
|
|
88
|
+
this.context.stdout.write(
|
|
89
|
+
` Participants: ${room.participantCount}\n`
|
|
90
|
+
);
|
|
91
|
+
this.context.stdout.write(
|
|
92
|
+
` Created: ${new Date(room.createdAt).toLocaleString()}\n\n`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return 0;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.context.stderr.write(`Failed to connect to hub at ${hub}\n`);
|
|
100
|
+
this.context.stderr.write(`${err}\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Command, Option } from "../utils/option.ts";
|
|
2
|
+
|
|
3
|
+
export class MonitorCommand extends Command {
|
|
4
|
+
static override paths = [["monitor"]];
|
|
5
|
+
|
|
6
|
+
static override usage = Command.Usage({
|
|
7
|
+
description: "Open TUI to monitor rooms in real-time",
|
|
8
|
+
examples: [
|
|
9
|
+
["Monitor local hub", "gambi monitor"],
|
|
10
|
+
[
|
|
11
|
+
"Monitor remote hub",
|
|
12
|
+
"gambi monitor --hub http://192.168.1.100:3000",
|
|
13
|
+
],
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
hub = Option.String("--hub,-h", "http://localhost:3000", {
|
|
18
|
+
description: "Hub URL to connect to",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async execute(): Promise<number> {
|
|
22
|
+
this.context.stderr.write(
|
|
23
|
+
"TUI is not bundled in the standalone binary.\nInstall via npm/bun for TUI support: npm install -g gambi\nThen run: gambi monitor\n",
|
|
24
|
+
);
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { hostname as getHostname } from "node:os";
|
|
2
|
+
import { confirm, intro, text } from "@clack/prompts";
|
|
3
|
+
import { createHub } from "@gambi/core/hub";
|
|
4
|
+
import { printLogo } from "@gambi/core/logo";
|
|
5
|
+
import { Command, Option } from "../utils/option.ts";
|
|
6
|
+
import {
|
|
7
|
+
handleCancel,
|
|
8
|
+
hasExplicitFlags,
|
|
9
|
+
isInteractive,
|
|
10
|
+
} from "../utils/prompt.ts";
|
|
11
|
+
|
|
12
|
+
export class ServeCommand extends Command {
|
|
13
|
+
static override paths = [["serve"]];
|
|
14
|
+
|
|
15
|
+
static override usage = Command.Usage({
|
|
16
|
+
description: "Start the Gambi hub server",
|
|
17
|
+
examples: [
|
|
18
|
+
["Start with interactive setup", "gambi serve"],
|
|
19
|
+
["Start on default port 3000", "gambi serve --port 3000"],
|
|
20
|
+
["Start on custom port", "gambi serve --port 8080"],
|
|
21
|
+
["Start with mDNS discovery", "gambi serve --mdns"],
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
port = Option.String("--port,-p", "3000", {
|
|
26
|
+
description: "Port to listen on",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
host = Option.String("--host,-h", "0.0.0.0", {
|
|
30
|
+
description: "Host to bind to",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
mdns = Option.Boolean("--mdns,-m", false, {
|
|
34
|
+
description: "Enable mDNS (Bonjour/Zeroconf) for local network discovery",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
quiet = Option.Boolean("--quiet,-q", false, {
|
|
38
|
+
description: "Suppress logo output",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async execute(): Promise<number> {
|
|
42
|
+
let port = this.port;
|
|
43
|
+
let host = this.host;
|
|
44
|
+
let mdns = this.mdns;
|
|
45
|
+
|
|
46
|
+
if (!hasExplicitFlags() && isInteractive()) {
|
|
47
|
+
intro("gambi serve");
|
|
48
|
+
|
|
49
|
+
const portResult = await text({
|
|
50
|
+
message: "Port:",
|
|
51
|
+
defaultValue: "3000",
|
|
52
|
+
placeholder: "3000",
|
|
53
|
+
});
|
|
54
|
+
handleCancel(portResult);
|
|
55
|
+
port = portResult as string;
|
|
56
|
+
|
|
57
|
+
const hostResult = await text({
|
|
58
|
+
message: "Host:",
|
|
59
|
+
defaultValue: "0.0.0.0",
|
|
60
|
+
placeholder: "0.0.0.0",
|
|
61
|
+
});
|
|
62
|
+
handleCancel(hostResult);
|
|
63
|
+
host = hostResult as string;
|
|
64
|
+
|
|
65
|
+
const mdnsResult = await confirm({
|
|
66
|
+
message: "Enable mDNS discovery?",
|
|
67
|
+
initialValue: false,
|
|
68
|
+
});
|
|
69
|
+
handleCancel(mdnsResult);
|
|
70
|
+
mdns = mdnsResult as boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!this.quiet) {
|
|
74
|
+
printLogo();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const portNum = Number.parseInt(port, 10);
|
|
78
|
+
if (Number.isNaN(portNum)) {
|
|
79
|
+
this.context.stderr.write(`Invalid port: ${port}\n`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const hub = createHub({
|
|
84
|
+
port: portNum,
|
|
85
|
+
hostname: host,
|
|
86
|
+
mdns,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this.context.stdout.write(`Hub started at ${hub.url}\n`);
|
|
90
|
+
this.context.stdout.write(
|
|
91
|
+
`Health check: http://${host}:${portNum}/health\n`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (hub.mdnsName) {
|
|
95
|
+
const localHostname = getHostname();
|
|
96
|
+
this.context.stdout.write(
|
|
97
|
+
`mDNS: http://${localHostname}.local:${portNum}\n`
|
|
98
|
+
);
|
|
99
|
+
this.context.stdout.write(
|
|
100
|
+
` Service: ${hub.mdnsName}._gambi._tcp.local\n`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.context.stdout.write("\nPress Ctrl+C to stop\n");
|
|
105
|
+
|
|
106
|
+
process.on("SIGINT", () => {
|
|
107
|
+
this.context.stdout.write("\nShutting down...\n");
|
|
108
|
+
hub.close();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Keep process running until SIGINT
|
|
113
|
+
await new Promise(() => undefined);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isLoopbackLikeHost,
|
|
4
|
+
isRemoteHubUrl,
|
|
5
|
+
rankNetworkCandidatesForHub,
|
|
6
|
+
replaceEndpointHost,
|
|
7
|
+
} from "./network-endpoint.ts";
|
|
8
|
+
|
|
9
|
+
describe("network endpoint helpers", () => {
|
|
10
|
+
test("detects loopback-like hosts", () => {
|
|
11
|
+
expect(isLoopbackLikeHost("localhost")).toBe(true);
|
|
12
|
+
expect(isLoopbackLikeHost("127.0.0.1")).toBe(true);
|
|
13
|
+
expect(isLoopbackLikeHost("0.0.0.0")).toBe(true);
|
|
14
|
+
expect(isLoopbackLikeHost("192.168.1.25")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("distinguishes local and remote hubs", () => {
|
|
18
|
+
expect(isRemoteHubUrl("http://localhost:3000")).toBe(false);
|
|
19
|
+
expect(isRemoteHubUrl("http://127.0.0.1:3000")).toBe(false);
|
|
20
|
+
expect(isRemoteHubUrl("http://192.168.1.10:3000")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("prioritizes candidates on the same /24 subnet as the hub", () => {
|
|
24
|
+
const ranked = rankNetworkCandidatesForHub("http://192.168.1.10:3000", [
|
|
25
|
+
{ address: "10.0.0.25", interfaceName: "en1" },
|
|
26
|
+
{ address: "192.168.1.25", interfaceName: "en0" },
|
|
27
|
+
{ address: "192.168.2.30", interfaceName: "en2" },
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
expect(ranked).toEqual([
|
|
31
|
+
{ address: "192.168.1.25", interfaceName: "en0" },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("replaces only the endpoint host", () => {
|
|
36
|
+
expect(
|
|
37
|
+
replaceEndpointHost("http://localhost:11434/v1/models", "192.168.1.25")
|
|
38
|
+
).toBe("http://192.168.1.25:11434/v1/models");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
2
|
+
|
|
3
|
+
export interface NetworkCandidate {
|
|
4
|
+
address: string;
|
|
5
|
+
interfaceName: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const IPV4_FAMILY = "IPv4";
|
|
9
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
10
|
+
const UNSPECIFIED_HOSTS = new Set(["0.0.0.0", "::"]);
|
|
11
|
+
|
|
12
|
+
function getIpv4Octets(address: string): number[] | null {
|
|
13
|
+
const segments = address.split(".");
|
|
14
|
+
if (segments.length !== 4) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const octets = segments.map((segment) => Number(segment));
|
|
19
|
+
return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
|
|
20
|
+
? octets
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isLinkLocalIpv4(address: string): boolean {
|
|
25
|
+
const octets = getIpv4Octets(address);
|
|
26
|
+
return octets !== null && octets[0] === 169 && octets[1] === 254;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isPrivateIpv4(address: string): boolean {
|
|
30
|
+
const octets = getIpv4Octets(address);
|
|
31
|
+
if (octets === null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const first = octets[0];
|
|
36
|
+
const second = octets[1];
|
|
37
|
+
if (first === undefined || second === undefined) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
first === 10 ||
|
|
43
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
44
|
+
(first === 192 && second === 168)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSameSubnet24(left: string, right: string): boolean {
|
|
49
|
+
const leftOctets = getIpv4Octets(left);
|
|
50
|
+
const rightOctets = getIpv4Octets(right);
|
|
51
|
+
if (!(leftOctets && rightOctets)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
leftOctets[0] === rightOctets[0] &&
|
|
57
|
+
leftOctets[1] === rightOctets[1] &&
|
|
58
|
+
leftOctets[2] === rightOctets[2]
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isLoopbackHost(hostname: string): boolean {
|
|
63
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isUnspecifiedHost(hostname: string): boolean {
|
|
67
|
+
return UNSPECIFIED_HOSTS.has(hostname.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isLoopbackLikeHost(hostname: string): boolean {
|
|
71
|
+
return isLoopbackHost(hostname) || isUnspecifiedHost(hostname);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isRemoteHubUrl(hubUrl: string): boolean {
|
|
75
|
+
const { hostname } = new URL(hubUrl);
|
|
76
|
+
return !isLoopbackLikeHost(hostname);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function listNetworkCandidates(): NetworkCandidate[] {
|
|
80
|
+
const candidates: NetworkCandidate[] = [];
|
|
81
|
+
const interfaces = networkInterfaces();
|
|
82
|
+
|
|
83
|
+
for (const [interfaceName, entries] of Object.entries(interfaces)) {
|
|
84
|
+
if (!entries) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (
|
|
90
|
+
entry.family !== IPV4_FAMILY ||
|
|
91
|
+
entry.internal ||
|
|
92
|
+
isLinkLocalIpv4(entry.address)
|
|
93
|
+
) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
candidates.push({
|
|
98
|
+
interfaceName,
|
|
99
|
+
address: entry.address,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return candidates;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function rankNetworkCandidatesForHub(
|
|
108
|
+
hubUrl: string,
|
|
109
|
+
candidates: NetworkCandidate[]
|
|
110
|
+
): NetworkCandidate[] {
|
|
111
|
+
const { hostname } = new URL(hubUrl);
|
|
112
|
+
|
|
113
|
+
const sameSubnet = candidates.filter((candidate) =>
|
|
114
|
+
isSameSubnet24(candidate.address, hostname)
|
|
115
|
+
);
|
|
116
|
+
if (sameSubnet.length > 0) {
|
|
117
|
+
return sameSubnet;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isPrivateIpv4(hostname)) {
|
|
121
|
+
const privateCandidates = candidates.filter((candidate) =>
|
|
122
|
+
isPrivateIpv4(candidate.address)
|
|
123
|
+
);
|
|
124
|
+
if (privateCandidates.length > 0) {
|
|
125
|
+
return privateCandidates;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return candidates;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function replaceEndpointHost(endpoint: string, hostname: string): string {
|
|
133
|
+
const url = new URL(endpoint);
|
|
134
|
+
url.hostname = hostname;
|
|
135
|
+
return url.toString();
|
|
136
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Workaround for Bun bundler bug: Clipanion's ESM barrel export of `String`
|
|
2
|
+
// collides with the global `String` during `bun build --compile`.
|
|
3
|
+
// Using require() instead of import bypasses the ESM re-export resolution.
|
|
4
|
+
// See: https://github.com/oven-sh/bun/issues — Bun bundler String collision
|
|
5
|
+
const clipanion = require("clipanion");
|
|
6
|
+
|
|
7
|
+
export const Command: typeof import("clipanion").Command = clipanion.Command;
|
|
8
|
+
export const Option: typeof import("clipanion").Option = clipanion.Option;
|
|
9
|
+
export const Builtins: typeof import("clipanion").Builtins = clipanion.Builtins;
|
|
10
|
+
export const Cli: typeof import("clipanion").Cli = clipanion.Cli;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cancel, isCancel } from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
export function isInteractive(): boolean {
|
|
4
|
+
return !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function handleCancel(value: unknown): void {
|
|
8
|
+
if (isCancel(value)) {
|
|
9
|
+
cancel("Operation cancelled.");
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasExplicitFlags(): boolean {
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
// First arg is the command name, check if there are more args after it
|
|
17
|
+
return args.length > 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const LLM_PROVIDERS = [
|
|
21
|
+
{ name: "Ollama", port: 11_434 },
|
|
22
|
+
{ name: "LM Studio", port: 1234 },
|
|
23
|
+
{ name: "vLLM", port: 8000 },
|
|
24
|
+
] as const;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { confirm, text } from "@clack/prompts";
|
|
3
|
+
import {
|
|
4
|
+
RuntimeConfig,
|
|
5
|
+
type RuntimeConfig as RuntimeConfigValue,
|
|
6
|
+
} from "@gambi/core/types";
|
|
7
|
+
import { handleCancel } from "./prompt.ts";
|
|
8
|
+
|
|
9
|
+
function parseOptionalNumber(
|
|
10
|
+
input: string,
|
|
11
|
+
fieldName: string
|
|
12
|
+
): number | undefined {
|
|
13
|
+
const trimmed = input.trim();
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parsed = Number(trimmed);
|
|
19
|
+
if (Number.isNaN(parsed)) {
|
|
20
|
+
throw new Error(`Invalid ${fieldName}: expected a number.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseOptionalStopSequences(input: string): string[] | undefined {
|
|
27
|
+
const trimmed = input.trim();
|
|
28
|
+
if (!trimmed) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const values = trimmed
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((value) => value.trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
|
|
37
|
+
return values.length > 0 ? values : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getCurrentValue<T>(value: T | undefined): string {
|
|
41
|
+
if (value === undefined) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
return value.join(", ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return String(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function hasRuntimeConfig(
|
|
53
|
+
config: RuntimeConfigValue | undefined
|
|
54
|
+
): config is RuntimeConfigValue {
|
|
55
|
+
return config !== undefined && Object.keys(config).length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function loadRuntimeConfigFile(
|
|
59
|
+
configPath: string
|
|
60
|
+
): Promise<RuntimeConfigValue> {
|
|
61
|
+
const resolvedPath = resolve(configPath);
|
|
62
|
+
const file = Bun.file(resolvedPath);
|
|
63
|
+
|
|
64
|
+
if (!(await file.exists())) {
|
|
65
|
+
throw new Error(`Config file not found: ${resolvedPath}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let parsedJson: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsedJson = JSON.parse(await file.text());
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Invalid JSON in config file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = RuntimeConfig.safeParse(parsedJson);
|
|
78
|
+
if (!parsed.success) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Invalid runtime config in ${resolvedPath}: ${parsed.error.message}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return parsed.data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function promptRuntimeConfig(
|
|
88
|
+
scope: "participant" | "room",
|
|
89
|
+
initialConfig: RuntimeConfigValue = {}
|
|
90
|
+
): Promise<RuntimeConfigValue> {
|
|
91
|
+
const shouldConfigure = await confirm({
|
|
92
|
+
message:
|
|
93
|
+
scope === "participant"
|
|
94
|
+
? "Configure participant defaults (instructions, temperature, etc.)?"
|
|
95
|
+
: "Configure room defaults (instructions, temperature, etc.)?",
|
|
96
|
+
initialValue: hasRuntimeConfig(initialConfig),
|
|
97
|
+
});
|
|
98
|
+
handleCancel(shouldConfigure);
|
|
99
|
+
|
|
100
|
+
if (!shouldConfigure) {
|
|
101
|
+
return initialConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const instructionsResult = await text({
|
|
105
|
+
message: "Instructions / system prompt:",
|
|
106
|
+
placeholder: getCurrentValue(initialConfig.instructions),
|
|
107
|
+
});
|
|
108
|
+
handleCancel(instructionsResult);
|
|
109
|
+
|
|
110
|
+
const temperatureResult = await text({
|
|
111
|
+
message: "Temperature:",
|
|
112
|
+
placeholder: getCurrentValue(initialConfig.temperature),
|
|
113
|
+
});
|
|
114
|
+
handleCancel(temperatureResult);
|
|
115
|
+
|
|
116
|
+
const topPResult = await text({
|
|
117
|
+
message: "Top-p:",
|
|
118
|
+
placeholder: getCurrentValue(initialConfig.top_p),
|
|
119
|
+
});
|
|
120
|
+
handleCancel(topPResult);
|
|
121
|
+
|
|
122
|
+
const maxTokensResult = await text({
|
|
123
|
+
message: "Max tokens:",
|
|
124
|
+
placeholder: getCurrentValue(initialConfig.max_tokens),
|
|
125
|
+
});
|
|
126
|
+
handleCancel(maxTokensResult);
|
|
127
|
+
|
|
128
|
+
const stopResult = await text({
|
|
129
|
+
message: "Stop sequences (comma-separated):",
|
|
130
|
+
placeholder: getCurrentValue(initialConfig.stop),
|
|
131
|
+
});
|
|
132
|
+
handleCancel(stopResult);
|
|
133
|
+
|
|
134
|
+
const frequencyPenaltyResult = await text({
|
|
135
|
+
message: "Frequency penalty:",
|
|
136
|
+
placeholder: getCurrentValue(initialConfig.frequency_penalty),
|
|
137
|
+
});
|
|
138
|
+
handleCancel(frequencyPenaltyResult);
|
|
139
|
+
|
|
140
|
+
const presencePenaltyResult = await text({
|
|
141
|
+
message: "Presence penalty:",
|
|
142
|
+
placeholder: getCurrentValue(initialConfig.presence_penalty),
|
|
143
|
+
});
|
|
144
|
+
handleCancel(presencePenaltyResult);
|
|
145
|
+
|
|
146
|
+
const seedResult = await text({
|
|
147
|
+
message: "Seed:",
|
|
148
|
+
placeholder: getCurrentValue(initialConfig.seed),
|
|
149
|
+
});
|
|
150
|
+
handleCancel(seedResult);
|
|
151
|
+
|
|
152
|
+
const config: RuntimeConfigValue = {
|
|
153
|
+
...initialConfig,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const instructions = String(instructionsResult).trim();
|
|
157
|
+
if (instructions) {
|
|
158
|
+
config.instructions = instructions;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const temperature = parseOptionalNumber(
|
|
162
|
+
String(temperatureResult),
|
|
163
|
+
"temperature"
|
|
164
|
+
);
|
|
165
|
+
if (temperature !== undefined) {
|
|
166
|
+
config.temperature = temperature;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const topP = parseOptionalNumber(String(topPResult), "top_p");
|
|
170
|
+
if (topP !== undefined) {
|
|
171
|
+
config.top_p = topP;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const maxTokens = parseOptionalNumber(String(maxTokensResult), "max_tokens");
|
|
175
|
+
if (maxTokens !== undefined) {
|
|
176
|
+
config.max_tokens = maxTokens;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const stop = parseOptionalStopSequences(String(stopResult));
|
|
180
|
+
if (stop !== undefined) {
|
|
181
|
+
config.stop = stop;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const frequencyPenalty = parseOptionalNumber(
|
|
185
|
+
String(frequencyPenaltyResult),
|
|
186
|
+
"frequency_penalty"
|
|
187
|
+
);
|
|
188
|
+
if (frequencyPenalty !== undefined) {
|
|
189
|
+
config.frequency_penalty = frequencyPenalty;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const presencePenalty = parseOptionalNumber(
|
|
193
|
+
String(presencePenaltyResult),
|
|
194
|
+
"presence_penalty"
|
|
195
|
+
);
|
|
196
|
+
if (presencePenalty !== undefined) {
|
|
197
|
+
config.presence_penalty = presencePenalty;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const seed = parseOptionalNumber(String(seedResult), "seed");
|
|
201
|
+
if (seed !== undefined) {
|
|
202
|
+
config.seed = seed;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const parsed = RuntimeConfig.safeParse(config);
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
throw new Error(`Invalid runtime config: ${parsed.error.message}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return parsed.data;
|
|
211
|
+
}
|