@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.
- package/bun.lock +7 -0
- package/package.json +9 -2
- package/src/commands/client.ts +110 -0
- package/src/commands/hatch.ts +74 -5
- package/src/components/DefaultMainScreen.tsx +1738 -49
- package/src/components/TextInput.tsx +115 -0
- package/src/index.ts +3 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor-client.ts +127 -0
- package/src/lib/local.ts +2 -5
- package/src/lib/interfaces-seed.ts +0 -28
|
@@ -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");
|
package/src/lib/constants.ts
CHANGED
|
@@ -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
|
-
}
|