@vellumai/cli 0.1.11 → 0.1.13

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,115 @@
1
+ import { useRef, useState, type ReactElement } from "react";
2
+ import chalk from "chalk";
3
+ import { Text, useInput } from "ink";
4
+
5
+ interface TextInputProps {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ onSubmit?: (value: string) => void;
9
+ focus?: boolean;
10
+ placeholder?: string;
11
+ }
12
+
13
+ function TextInput({
14
+ value,
15
+ onChange,
16
+ onSubmit,
17
+ focus = true,
18
+ placeholder = "",
19
+ }: TextInputProps): ReactElement {
20
+ const cursorOffsetRef = useRef(value.length);
21
+ const valueRef = useRef(value);
22
+
23
+ valueRef.current = value;
24
+
25
+ if (cursorOffsetRef.current > value.length) {
26
+ cursorOffsetRef.current = value.length;
27
+ }
28
+
29
+ const [, setRenderTick] = useState(0);
30
+
31
+ useInput(
32
+ (input, key) => {
33
+ if (
34
+ key.upArrow ||
35
+ key.downArrow ||
36
+ (key.ctrl && input === "c") ||
37
+ key.tab ||
38
+ (key.shift && key.tab)
39
+ ) {
40
+ return;
41
+ }
42
+
43
+ if (key.return) {
44
+ onSubmit?.(valueRef.current);
45
+ return;
46
+ }
47
+
48
+ const currentValue = valueRef.current;
49
+ const currentOffset = cursorOffsetRef.current;
50
+ let nextValue = currentValue;
51
+ let nextOffset = currentOffset;
52
+
53
+ if (key.leftArrow) {
54
+ nextOffset = Math.max(0, currentOffset - 1);
55
+ } else if (key.rightArrow) {
56
+ nextOffset = Math.min(currentValue.length, currentOffset + 1);
57
+ } else if (key.backspace || key.delete) {
58
+ if (currentOffset > 0) {
59
+ nextValue = currentValue.slice(0, currentOffset - 1) + currentValue.slice(currentOffset);
60
+ nextOffset = currentOffset - 1;
61
+ }
62
+ } else {
63
+ nextValue =
64
+ currentValue.slice(0, currentOffset) + input + currentValue.slice(currentOffset);
65
+ nextOffset = currentOffset + input.length;
66
+ }
67
+
68
+ cursorOffsetRef.current = nextOffset;
69
+
70
+ if (nextValue !== currentValue) {
71
+ valueRef.current = nextValue;
72
+ onChange(nextValue);
73
+ }
74
+
75
+ setRenderTick((t) => t + 1);
76
+ },
77
+ { isActive: focus },
78
+ );
79
+
80
+ const cursorOffset = cursorOffsetRef.current;
81
+ let renderedValue: string;
82
+ let renderedPlaceholder: string | undefined;
83
+
84
+ if (focus) {
85
+ renderedPlaceholder =
86
+ placeholder.length > 0
87
+ ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
88
+ : chalk.inverse(" ");
89
+
90
+ if (value.length > 0) {
91
+ renderedValue = "";
92
+ let i = 0;
93
+ for (const char of value) {
94
+ renderedValue += i === cursorOffset ? chalk.inverse(char) : char;
95
+ i++;
96
+ }
97
+ if (cursorOffset === value.length) {
98
+ renderedValue += chalk.inverse(" ");
99
+ }
100
+ } else {
101
+ renderedValue = chalk.inverse(" ");
102
+ }
103
+ } else {
104
+ renderedValue = value;
105
+ renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
106
+ }
107
+
108
+ return (
109
+ <Text>
110
+ {placeholder ? (value.length > 0 ? renderedValue : renderedPlaceholder) : renderedValue}
111
+ </Text>
112
+ );
113
+ }
114
+
115
+ export default TextInput;
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { client } from "./commands/client";
3
4
  import { hatch } from "./commands/hatch";
4
5
  import { ps } from "./commands/ps";
5
6
  import { retire } from "./commands/retire";
@@ -7,6 +8,7 @@ import { sleep } from "./commands/sleep";
7
8
  import { wake } from "./commands/wake";
8
9
 
9
10
  const commands = {
11
+ client,
10
12
  hatch,
11
13
  ps,
12
14
  retire,
@@ -24,6 +26,7 @@ async function main() {
24
26
  console.log("Usage: vellum-cli <command> [options]");
25
27
  console.log("");
26
28
  console.log("Commands:");
29
+ console.log(" client Connect to a hatched assistant");
27
30
  console.log(" hatch Create a new assistant instance");
28
31
  console.log(" ps List assistants (or processes for a specific assistant)");
29
32
  console.log(" retire Delete an assistant instance");
@@ -1,5 +1,5 @@
1
1
  export const FIREWALL_TAG = "vellum-assistant";
2
- export const GATEWAY_PORT = 7830;
2
+ export const GATEWAY_PORT = process.env.GATEWAY_PORT ? Number(process.env.GATEWAY_PORT) : 7830;
3
3
  export const VALID_REMOTE_HOSTS = ["local", "gcp", "aws", "custom"] as const;
4
4
  export type RemoteHost = (typeof VALID_REMOTE_HOSTS)[number];
5
5
  export const VALID_SPECIES = ["openclaw", "vellum"] as const;
@@ -0,0 +1,127 @@
1
+ const DOCTOR_URL = process.env.DOCTOR_URL || "https://doctor.vellum.ai";
2
+
3
+ export type ProgressPhase= "invoking_prompt" | "calling_tool" | "processing_tool_result";
4
+
5
+ export interface ProgressEvent {
6
+ phase: ProgressPhase;
7
+ toolName?: string;
8
+ }
9
+
10
+ interface DoctorResult {
11
+ assistantId: string;
12
+ diagnostics: string | null;
13
+ recommendation: string | null;
14
+ error: string | null;
15
+ }
16
+
17
+ export interface ChatLogEntry {
18
+ role: "user" | "assistant" | "error";
19
+ content: string;
20
+ }
21
+
22
+ type DoctorProgressCallback = (event: ProgressEvent) => void;
23
+ type DoctorLogCallback = (message: string) => void;
24
+
25
+ async function streamDoctorResponse(
26
+ response: globalThis.Response,
27
+ onProgress?: DoctorProgressCallback,
28
+ onLog?: DoctorLogCallback,
29
+ ): Promise<DoctorResult> {
30
+ if (!response.body) {
31
+ throw new Error(
32
+ `No response body from doctor (HTTP ${response.status} ${response.statusText})`,
33
+ );
34
+ }
35
+
36
+ let result: DoctorResult | null = null;
37
+ const decoder = new TextDecoder();
38
+ let buffer = "";
39
+ let chunkCount = 0;
40
+ const receivedEventTypes: string[] = [];
41
+
42
+ try {
43
+ for await (const chunk of response.body) {
44
+ chunkCount++;
45
+ buffer += decoder.decode(chunk, { stream: true });
46
+ const lines = buffer.split("\n");
47
+ buffer = lines.pop() ?? "";
48
+
49
+ for (const line of lines) {
50
+ if (!line.trim()) continue;
51
+ const parsed = JSON.parse(line) as { type: string } & Record<string, unknown>;
52
+ receivedEventTypes.push(parsed.type);
53
+ if (parsed.type === "progress") {
54
+ onProgress?.(parsed as unknown as ProgressEvent);
55
+ } else if (parsed.type === "log") {
56
+ onLog?.((parsed as unknown as { message: string }).message);
57
+ } else if (parsed.type === "result") {
58
+ result = parsed as unknown as DoctorResult;
59
+ }
60
+ }
61
+ }
62
+ } catch (streamErr) {
63
+ const detail = streamErr instanceof Error ? streamErr.message : String(streamErr);
64
+ throw new Error(
65
+ `Doctor stream interrupted after ${chunkCount} chunks ` +
66
+ `(received events: [${receivedEventTypes.join(", ")}]): ${detail}`,
67
+ );
68
+ }
69
+
70
+ if (buffer.trim()) {
71
+ const parsed = JSON.parse(buffer) as { type: string } & Record<string, unknown>;
72
+ receivedEventTypes.push(parsed.type);
73
+ if (parsed.type === "result") {
74
+ result = parsed as unknown as DoctorResult;
75
+ }
76
+ }
77
+
78
+ if (!result) {
79
+ throw new Error(
80
+ `No result received from doctor. ` +
81
+ `HTTP ${response.status}, ${chunkCount} chunks read, ` +
82
+ `events received: [${receivedEventTypes.join(", ")}], ` +
83
+ `trailing buffer: ${buffer.trim() ? JSON.stringify(buffer.trim().slice(0, 200)) : "(empty)"}`,
84
+ );
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ async function callDoctorDaemon(
91
+ assistantId: string,
92
+ project?: string,
93
+ zone?: string,
94
+ userPrompt?: string,
95
+ onProgress?: DoctorProgressCallback,
96
+ sessionId?: string,
97
+ chatContext?: ChatLogEntry[],
98
+ onLog?: DoctorLogCallback,
99
+ ): Promise<DoctorResult> {
100
+ const MAX_RETRIES = 2;
101
+ let lastError: unknown;
102
+
103
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
104
+ try {
105
+ const response = await fetch(`${DOCTOR_URL}/doctor`, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ assistantId, project, zone, userPrompt, sessionId, chatContext }),
109
+ });
110
+ return await streamDoctorResponse(response, onProgress, onLog);
111
+ } catch (err) {
112
+ lastError = err;
113
+ const errMsg = err instanceof Error ? err.message : String(err);
114
+ const logMsg =
115
+ `[doctor-client] Attempt ${attempt + 1}/${MAX_RETRIES} failed: ${errMsg}`;
116
+ onLog?.(logMsg);
117
+ if (attempt < MAX_RETRIES - 1) {
118
+ await new Promise((resolve) => setTimeout(resolve, 500));
119
+ }
120
+ }
121
+ }
122
+
123
+ throw lastError;
124
+ }
125
+
126
+ export { callDoctorDaemon };
127
+ export type { DoctorProgressCallback, DoctorResult };
package/src/lib/local.ts CHANGED
@@ -295,15 +295,12 @@ export async function startGateway(): Promise<string> {
295
295
  const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
296
296
  const ingressPublicBaseUrl =
297
297
  workspaceIngressPublicBaseUrl
298
- ?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
298
+ ?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL)
299
+ ?? publicUrl;
299
300
  if (ingressPublicBaseUrl) {
300
301
  gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
301
302
  console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
302
- if (!workspaceIngressPublicBaseUrl) {
303
- console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
304
- }
305
303
  }
306
- if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
307
304
 
308
305
  const gateway = spawn("bun", ["run", "src/index.ts"], {
309
306
  cwd: gatewayDir,
@@ -1,28 +0,0 @@
1
- // Read source files using Bun.file() with string concatenation (not join())
2
- // so Bun's bundler can statically analyze the paths and embed the files
3
- // in the compiled binary ($bunfs). Files must also be passed via --embed
4
- // in the bun build --compile invocation.
5
-
6
- function inlineLocalImports(source: string, constantsSource: string): string {
7
- return source
8
- .replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']*\/constants["'];?\s*\n/, constantsSource + "\n")
9
- .replace(/import\s*\{[^}]*\}\s*from\s*["']path["'];?\s*\n/, "");
10
- }
11
-
12
- export async function buildInterfacesSeed(): Promise<string> {
13
- try {
14
- const constantsSource = await Bun.file(import.meta.dir + "/constants.ts").text();
15
- const defaultMainScreenSource = await Bun.file(import.meta.dir + "/../components/DefaultMainScreen.tsx").text();
16
- const mainWindowSource = inlineLocalImports(defaultMainScreenSource, constantsSource);
17
-
18
- return `
19
- INTERFACES_SEED_DIR="/tmp/interfaces-seed"
20
- mkdir -p "\$INTERFACES_SEED_DIR/tui"
21
- cat > "\$INTERFACES_SEED_DIR/tui/main-window.tsx" << 'INTERFACES_SEED_EOF'
22
- ${mainWindowSource}INTERFACES_SEED_EOF
23
- `;
24
- } catch (err) {
25
- console.warn("⚠️ Could not embed interfaces seed files (expected in compiled binary without --embed):", (err as Error).message);
26
- return "# interfaces-seed: skipped (source files not available in compiled binary)";
27
- }
28
- }