@waqas/orch 0.1.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.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +303 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +177 -0
- package/dist/core/markdown.d.ts +1 -0
- package/dist/core/markdown.js +12 -0
- package/dist/core/router.d.ts +6 -0
- package/dist/core/router.js +220 -0
- package/dist/core/spawner.d.ts +3 -0
- package/dist/core/spawner.js +164 -0
- package/dist/types/index.d.ts +41 -0
- package/dist/types/index.js +1 -0
- package/package.json +81 -0
- package/readme.md +17 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { loadConfig } from '../core/config.js';
|
|
5
|
+
import { formatMarkdown } from '../core/markdown.js';
|
|
6
|
+
import { route, routeStreaming } from '../core/router.js';
|
|
7
|
+
function Prompt({ value, cursor }) {
|
|
8
|
+
const before = value.slice(0, cursor);
|
|
9
|
+
const at = value[cursor] ?? ' ';
|
|
10
|
+
const after = value.slice(cursor + 1);
|
|
11
|
+
const isEmpty = value.length === 0;
|
|
12
|
+
return (React.createElement(Box, { borderStyle: "round", borderColor: "gray", width: "100%", paddingX: 1 }, isEmpty ? (React.createElement(Text, { dimColor: true }, "Type a message...")) : (React.createElement(Text, null,
|
|
13
|
+
React.createElement(Text, { color: "cyan" }, '> '),
|
|
14
|
+
React.createElement(Text, { color: "whiteBright" },
|
|
15
|
+
before,
|
|
16
|
+
React.createElement(Text, { inverse: true }, at),
|
|
17
|
+
after)))));
|
|
18
|
+
}
|
|
19
|
+
function Message({ role, content, isStreaming, }) {
|
|
20
|
+
const renderedContent = role === 'assistant' && !isStreaming ? formatMarkdown(content) : content;
|
|
21
|
+
return (React.createElement(Box, { marginBottom: 1 },
|
|
22
|
+
React.createElement(Text, null,
|
|
23
|
+
role === 'user' ? (React.createElement(Text, { color: "cyan", bold: true },
|
|
24
|
+
"You:",
|
|
25
|
+
' ')) : (React.createElement(Text, { color: "green", bold: true },
|
|
26
|
+
"orch:",
|
|
27
|
+
' ')),
|
|
28
|
+
React.createElement(Text, null, renderedContent))));
|
|
29
|
+
}
|
|
30
|
+
function formatDuration(durationMs) {
|
|
31
|
+
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
32
|
+
}
|
|
33
|
+
export default function Index() {
|
|
34
|
+
const { exit } = useApp();
|
|
35
|
+
const activeRequestControllerRef = useRef(null);
|
|
36
|
+
const messageCountRef = useRef(0);
|
|
37
|
+
const [input, setInput] = useState('');
|
|
38
|
+
const [cursor, setCursor] = useState(0);
|
|
39
|
+
const [history, setHistory] = useState([]);
|
|
40
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
41
|
+
const [savedInput, setSavedInput] = useState('');
|
|
42
|
+
const [messages, setMessages] = useState([]);
|
|
43
|
+
const [config, setConfig] = useState(null);
|
|
44
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
45
|
+
const [showSpinner, setShowSpinner] = useState(false);
|
|
46
|
+
const [lastResponseMs, setLastResponseMs] = useState();
|
|
47
|
+
const [lastResponseCost, setLastResponseCost] = useState();
|
|
48
|
+
const workerCount = config ? Object.keys(config.workers).length : 0;
|
|
49
|
+
const statusText = lastResponseMs === undefined ||
|
|
50
|
+
lastResponseCost === undefined ||
|
|
51
|
+
workerCount === 0
|
|
52
|
+
? undefined
|
|
53
|
+
: `${workerCount} workers · ${formatDuration(lastResponseMs)} · ${lastResponseCost}`;
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let isMounted = true;
|
|
56
|
+
const initializeConfig = async () => {
|
|
57
|
+
try {
|
|
58
|
+
const loadedConfig = await loadConfig();
|
|
59
|
+
if (isMounted) {
|
|
60
|
+
setConfig(loadedConfig);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
};
|
|
65
|
+
void initializeConfig();
|
|
66
|
+
return () => {
|
|
67
|
+
isMounted = false;
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
const createMessageId = () => `message-${messageCountRef.current++}`;
|
|
71
|
+
const appendAssistantMessage = (messageId, content, isStreaming) => {
|
|
72
|
+
setMessages(previousMessages => [
|
|
73
|
+
...previousMessages,
|
|
74
|
+
{
|
|
75
|
+
id: messageId,
|
|
76
|
+
role: 'assistant',
|
|
77
|
+
content,
|
|
78
|
+
isStreaming,
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
};
|
|
82
|
+
const updateMessage = (messageId, updater) => {
|
|
83
|
+
setMessages(previousMessages => previousMessages.map(message => message.id === messageId ? updater(message) : message));
|
|
84
|
+
};
|
|
85
|
+
const submitMessage = async (prompt) => {
|
|
86
|
+
const requestController = new AbortController();
|
|
87
|
+
const requestStartedAt = Date.now();
|
|
88
|
+
const userMessageId = createMessageId();
|
|
89
|
+
let activeConfig = config ?? undefined;
|
|
90
|
+
let assistantMessageId;
|
|
91
|
+
let streamedResponse = '';
|
|
92
|
+
activeRequestControllerRef.current = requestController;
|
|
93
|
+
setMessages(previousMessages => [
|
|
94
|
+
...previousMessages,
|
|
95
|
+
{
|
|
96
|
+
id: userMessageId,
|
|
97
|
+
role: 'user',
|
|
98
|
+
content: prompt,
|
|
99
|
+
isStreaming: false,
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
setHistory(previousHistory => [...previousHistory, prompt]);
|
|
103
|
+
setHistoryIndex(-1);
|
|
104
|
+
setSavedInput('');
|
|
105
|
+
setInput('');
|
|
106
|
+
setCursor(0);
|
|
107
|
+
setIsBusy(true);
|
|
108
|
+
setShowSpinner(true);
|
|
109
|
+
try {
|
|
110
|
+
activeConfig = config ?? (await loadConfig());
|
|
111
|
+
setConfig(activeConfig);
|
|
112
|
+
const stream = routeStreaming(prompt, activeConfig, {
|
|
113
|
+
signal: requestController.signal,
|
|
114
|
+
});
|
|
115
|
+
let step = await stream.next();
|
|
116
|
+
while (true) {
|
|
117
|
+
if (step.done) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
const chunk = step.value.chunk;
|
|
121
|
+
streamedResponse += chunk;
|
|
122
|
+
if (assistantMessageId === undefined) {
|
|
123
|
+
assistantMessageId = createMessageId();
|
|
124
|
+
appendAssistantMessage(assistantMessageId, streamedResponse, true);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
updateMessage(assistantMessageId, message => ({
|
|
128
|
+
...message,
|
|
129
|
+
content: message.content + chunk,
|
|
130
|
+
isStreaming: true,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
setShowSpinner(false);
|
|
134
|
+
step = await stream.next();
|
|
135
|
+
}
|
|
136
|
+
const result = step.value;
|
|
137
|
+
if (assistantMessageId === undefined) {
|
|
138
|
+
appendAssistantMessage(createMessageId(), result.response, false);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
updateMessage(assistantMessageId, message => ({
|
|
142
|
+
...message,
|
|
143
|
+
content: result.response,
|
|
144
|
+
isStreaming: false,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
updateLastResponseMeta(activeConfig, result.handledBy, Date.now() - requestStartedAt, setLastResponseCost, setLastResponseMs);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
if (requestController.signal.aborted) {
|
|
151
|
+
if (assistantMessageId !== undefined) {
|
|
152
|
+
updateMessage(assistantMessageId, message => ({
|
|
153
|
+
...message,
|
|
154
|
+
isStreaming: false,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
activeConfig = activeConfig ?? (await loadConfig());
|
|
161
|
+
setConfig(activeConfig);
|
|
162
|
+
const result = await route(prompt, activeConfig, {
|
|
163
|
+
signal: requestController.signal,
|
|
164
|
+
});
|
|
165
|
+
if (assistantMessageId === undefined) {
|
|
166
|
+
appendAssistantMessage(createMessageId(), result.response, false);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
updateMessage(assistantMessageId, message => ({
|
|
170
|
+
...message,
|
|
171
|
+
content: result.response,
|
|
172
|
+
isStreaming: false,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
updateLastResponseMeta(activeConfig, result.handledBy, Date.now() - requestStartedAt, setLastResponseCost, setLastResponseMs);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
if (!requestController.signal.aborted) {
|
|
179
|
+
appendAssistantMessage(createMessageId(), "Sorry, I couldn't handle that. Try again.", false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (activeRequestControllerRef.current === requestController) {
|
|
185
|
+
activeRequestControllerRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
setIsBusy(false);
|
|
188
|
+
setShowSpinner(false);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
useInput((ch, key) => {
|
|
192
|
+
if (key.ctrl && ch === 'c') {
|
|
193
|
+
if (isBusy) {
|
|
194
|
+
activeRequestControllerRef.current?.abort(new Error('Request aborted.'));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
exit();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (isBusy) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (key.return) {
|
|
204
|
+
const trimmed = input.trim();
|
|
205
|
+
if (trimmed === '') {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (trimmed === 'exit' || trimmed === 'quit') {
|
|
209
|
+
exit();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
void submitMessage(trimmed);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (key.backspace || key.delete) {
|
|
216
|
+
if (cursor > 0) {
|
|
217
|
+
setInput(previousInput => previousInput.slice(0, cursor - 1) + previousInput.slice(cursor));
|
|
218
|
+
setCursor(previousCursor => previousCursor - 1);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (key.leftArrow) {
|
|
223
|
+
setCursor(previousCursor => Math.max(0, previousCursor - 1));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (key.rightArrow) {
|
|
227
|
+
setCursor(previousCursor => Math.min(input.length, previousCursor + 1));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (key.upArrow) {
|
|
231
|
+
if (history.length === 0) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (historyIndex === -1) {
|
|
235
|
+
const nextIndex = history.length - 1;
|
|
236
|
+
const nextInput = history[nextIndex];
|
|
237
|
+
if (nextInput === undefined) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
setSavedInput(input);
|
|
241
|
+
setHistoryIndex(nextIndex);
|
|
242
|
+
setInput(nextInput);
|
|
243
|
+
setCursor(nextInput.length);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const nextIndex = Math.max(0, historyIndex - 1);
|
|
247
|
+
const nextInput = history[nextIndex];
|
|
248
|
+
if (nextInput === undefined) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
setHistoryIndex(nextIndex);
|
|
252
|
+
setInput(nextInput);
|
|
253
|
+
setCursor(nextInput.length);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (key.downArrow) {
|
|
257
|
+
if (historyIndex === -1) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const nextIndex = historyIndex + 1;
|
|
261
|
+
if (nextIndex >= history.length) {
|
|
262
|
+
setHistoryIndex(-1);
|
|
263
|
+
setInput(savedInput);
|
|
264
|
+
setCursor(savedInput.length);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const nextInput = history[nextIndex];
|
|
268
|
+
if (nextInput === undefined) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
setHistoryIndex(nextIndex);
|
|
272
|
+
setInput(nextInput);
|
|
273
|
+
setCursor(nextInput.length);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
277
|
+
setInput(previousInput => previousInput.slice(0, cursor) + ch + previousInput.slice(cursor));
|
|
278
|
+
setCursor(previousCursor => previousCursor + 1);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
282
|
+
React.createElement(Box, { marginBottom: 1, flexDirection: "column" },
|
|
283
|
+
React.createElement(Text, null,
|
|
284
|
+
React.createElement(Text, { color: "cyan", bold: true }, ' orch')),
|
|
285
|
+
React.createElement(Text, { dimColor: true }, ` ${process.cwd()}`),
|
|
286
|
+
React.createElement(Text, { dimColor: true }, ' Ctrl+C to exit')),
|
|
287
|
+
messages.map(message => (React.createElement(Message, { key: message.id, role: message.role, content: message.content, isStreaming: message.isStreaming }))),
|
|
288
|
+
showSpinner ? (React.createElement(Box, { marginBottom: 1 },
|
|
289
|
+
React.createElement(Text, { color: "cyan" },
|
|
290
|
+
React.createElement(Spinner, null)),
|
|
291
|
+
React.createElement(Text, { dimColor: true }, " thinking..."))) : null,
|
|
292
|
+
React.createElement(Prompt, { value: input, cursor: cursor }),
|
|
293
|
+
statusText === undefined ? null : (React.createElement(Box, { justifyContent: "flex-end", width: "100%" },
|
|
294
|
+
React.createElement(Text, { dimColor: true }, statusText)))));
|
|
295
|
+
}
|
|
296
|
+
function updateLastResponseMeta(config, handledBy, responseMs, setLastResponseCost, setLastResponseMs) {
|
|
297
|
+
const workerCost = config.workers[handledBy]?.cost;
|
|
298
|
+
if (workerCost === undefined) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
setLastResponseCost(workerCost);
|
|
302
|
+
setLastResponseMs(responseMs);
|
|
303
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { OrchConfig } from '../types/index.js';
|
|
2
|
+
export declare const DEFAULT_ESCALATION_PROMPT = "You are orch. Respond to the user's request directly.\nIf you can handle this well, respond normally.\nIf this requires code editing or file modifications, respond with exactly: [ESCALATE:codex]\nIf this requires complex multi-step planning or architecture, respond with exactly: [ESCALATE:claude]\nDo not explain the escalation. Just output the tag.";
|
|
3
|
+
export declare const DEFAULT_CONFIG_PATH: string;
|
|
4
|
+
export declare const DEFAULT_CONFIG: OrchConfig;
|
|
5
|
+
export declare function loadConfig(configPath?: string): Promise<OrchConfig>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
const DEFAULT_PRIORITY = ['gemini', 'codex', 'claude'];
|
|
6
|
+
export const DEFAULT_ESCALATION_PROMPT = `You are orch. Respond to the user's request directly.
|
|
7
|
+
If you can handle this well, respond normally.
|
|
8
|
+
If this requires code editing or file modifications, respond with exactly: [ESCALATE:codex]
|
|
9
|
+
If this requires complex multi-step planning or architecture, respond with exactly: [ESCALATE:claude]
|
|
10
|
+
Do not explain the escalation. Just output the tag.`;
|
|
11
|
+
const DEFAULT_WORKERS = {
|
|
12
|
+
gemini: {
|
|
13
|
+
command: 'gemini',
|
|
14
|
+
flags: [],
|
|
15
|
+
promptFlag: '-p',
|
|
16
|
+
cost: 'free',
|
|
17
|
+
description: 'Large context, good for exploration and trivial tasks',
|
|
18
|
+
},
|
|
19
|
+
codex: {
|
|
20
|
+
command: 'codex',
|
|
21
|
+
flags: ['exec', '--full-auto'],
|
|
22
|
+
cost: 'low',
|
|
23
|
+
description: 'Good at code generation, refactoring, debugging',
|
|
24
|
+
},
|
|
25
|
+
claude: {
|
|
26
|
+
command: 'claude',
|
|
27
|
+
flags: ['--print'],
|
|
28
|
+
promptFlag: '-p',
|
|
29
|
+
cost: 'high',
|
|
30
|
+
description: 'Best reasoning, complex planning, architecture',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export const DEFAULT_CONFIG_PATH = join(homedir(), '.orch', 'config.yaml');
|
|
34
|
+
export const DEFAULT_CONFIG = {
|
|
35
|
+
priority: [...DEFAULT_PRIORITY],
|
|
36
|
+
workers: cloneWorkers(DEFAULT_WORKERS),
|
|
37
|
+
escalationPrompt: DEFAULT_ESCALATION_PROMPT,
|
|
38
|
+
};
|
|
39
|
+
export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
40
|
+
try {
|
|
41
|
+
const rawConfig = await readFile(configPath, 'utf8');
|
|
42
|
+
const parsedConfig = parseYamlObject(rawConfig);
|
|
43
|
+
const config = buildConfig(parsedConfig);
|
|
44
|
+
validatePriority(config.priority, config.workers);
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (isMissingFileError(error)) {
|
|
49
|
+
return createDefaultConfig();
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function buildConfig(rawConfig) {
|
|
55
|
+
return {
|
|
56
|
+
priority: parsePriority(rawConfig['priority']),
|
|
57
|
+
workers: parseWorkers(rawConfig['workers']),
|
|
58
|
+
escalationPrompt: parseEscalationPrompt(rawConfig['escalationPrompt'] ?? rawConfig['escalation_prompt']),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseYamlObject(rawConfig) {
|
|
62
|
+
const parsed = parse(rawConfig);
|
|
63
|
+
if (parsed === null) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
if (!isRecord(parsed)) {
|
|
67
|
+
throw new TypeError('Config file must contain a YAML object.');
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
function parsePriority(rawPriority) {
|
|
72
|
+
if (rawPriority === undefined) {
|
|
73
|
+
return [...DEFAULT_PRIORITY];
|
|
74
|
+
}
|
|
75
|
+
const priority = parseStringArray(rawPriority, 'priority');
|
|
76
|
+
if (priority.length === 0) {
|
|
77
|
+
throw new TypeError('Config priority must contain at least one worker.');
|
|
78
|
+
}
|
|
79
|
+
return priority;
|
|
80
|
+
}
|
|
81
|
+
function parseWorkers(rawWorkers) {
|
|
82
|
+
if (rawWorkers === undefined) {
|
|
83
|
+
return cloneWorkers(DEFAULT_WORKERS);
|
|
84
|
+
}
|
|
85
|
+
if (!isRecord(rawWorkers)) {
|
|
86
|
+
throw new TypeError('Config workers must be an object.');
|
|
87
|
+
}
|
|
88
|
+
const workers = cloneWorkers(DEFAULT_WORKERS);
|
|
89
|
+
for (const [workerName, rawWorker] of Object.entries(rawWorkers)) {
|
|
90
|
+
if (!isRecord(rawWorker)) {
|
|
91
|
+
throw new TypeError(`Config worker "${workerName}" must be an object.`);
|
|
92
|
+
}
|
|
93
|
+
const defaultWorker = DEFAULT_WORKERS[workerName];
|
|
94
|
+
workers[workerName] = {
|
|
95
|
+
command: parseRequiredString(rawWorker['command'], `${workerName}.command`, defaultWorker?.command),
|
|
96
|
+
flags: parseStringArray(rawWorker['flags'] ?? defaultWorker?.flags ?? [], `${workerName}.flags`),
|
|
97
|
+
promptFlag: parseOptionalString(rawWorker['promptFlag'] ?? rawWorker['prompt_flag'], defaultWorker?.promptFlag),
|
|
98
|
+
cost: parseWorkerCost(rawWorker['cost'], `${workerName}.cost`, defaultWorker?.cost),
|
|
99
|
+
description: parseRequiredString(rawWorker['description'], `${workerName}.description`, defaultWorker?.description),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return workers;
|
|
103
|
+
}
|
|
104
|
+
function parseEscalationPrompt(rawPrompt) {
|
|
105
|
+
return parseRequiredString(rawPrompt, 'escalation_prompt', DEFAULT_ESCALATION_PROMPT);
|
|
106
|
+
}
|
|
107
|
+
function parseOptionalString(value, defaultValue) {
|
|
108
|
+
if (value === undefined || value === null) {
|
|
109
|
+
return defaultValue;
|
|
110
|
+
}
|
|
111
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
return defaultValue;
|
|
115
|
+
}
|
|
116
|
+
function parseRequiredString(value, fieldName, defaultValue) {
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
if (defaultValue !== undefined) {
|
|
119
|
+
return defaultValue;
|
|
120
|
+
}
|
|
121
|
+
throw new TypeError(`Config field "${fieldName}" is required.`);
|
|
122
|
+
}
|
|
123
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
124
|
+
throw new TypeError(`Config field "${fieldName}" must be a non-empty string.`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
function parseStringArray(value, fieldName) {
|
|
129
|
+
if (!Array.isArray(value)) {
|
|
130
|
+
throw new TypeError(`Config field "${fieldName}" must be an array of strings.`);
|
|
131
|
+
}
|
|
132
|
+
return value.map((item, index) => {
|
|
133
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
134
|
+
throw new TypeError(`Config field "${fieldName}[${index}]" must be a non-empty string.`);
|
|
135
|
+
}
|
|
136
|
+
return item;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function parseWorkerCost(value, fieldName, defaultValue) {
|
|
140
|
+
const cost = parseRequiredString(value, fieldName, defaultValue);
|
|
141
|
+
if (cost !== 'free' && cost !== 'low' && cost !== 'high') {
|
|
142
|
+
throw new TypeError(`Config field "${fieldName}" must be one of: free, low, high.`);
|
|
143
|
+
}
|
|
144
|
+
return cost;
|
|
145
|
+
}
|
|
146
|
+
function validatePriority(priority, workers) {
|
|
147
|
+
for (const workerName of priority) {
|
|
148
|
+
if (!(workerName in workers)) {
|
|
149
|
+
throw new TypeError(`Config priority references unknown worker "${workerName}".`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function createDefaultConfig() {
|
|
154
|
+
return {
|
|
155
|
+
priority: [...DEFAULT_CONFIG.priority],
|
|
156
|
+
workers: cloneWorkers(DEFAULT_CONFIG.workers),
|
|
157
|
+
escalationPrompt: DEFAULT_CONFIG.escalationPrompt,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function cloneWorkers(workers) {
|
|
161
|
+
return Object.fromEntries(Object.entries(workers).map(([workerName, workerConfig]) => [
|
|
162
|
+
workerName,
|
|
163
|
+
{
|
|
164
|
+
...workerConfig,
|
|
165
|
+
flags: [...workerConfig.flags],
|
|
166
|
+
},
|
|
167
|
+
]));
|
|
168
|
+
}
|
|
169
|
+
function isRecord(value) {
|
|
170
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
171
|
+
}
|
|
172
|
+
function isMissingFileError(error) {
|
|
173
|
+
return (typeof error === 'object' &&
|
|
174
|
+
error !== null &&
|
|
175
|
+
'code' in error &&
|
|
176
|
+
error.code === 'ENOENT');
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function formatMarkdown(text: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import TerminalRenderer from 'marked-terminal';
|
|
3
|
+
const terminalRenderer = new TerminalRenderer();
|
|
4
|
+
export function formatMarkdown(text) {
|
|
5
|
+
return marked
|
|
6
|
+
.parse(text, {
|
|
7
|
+
renderer: terminalRenderer,
|
|
8
|
+
headerIds: false,
|
|
9
|
+
mangle: false,
|
|
10
|
+
})
|
|
11
|
+
.trimEnd();
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { OrchConfig, RouteResult, RouteStreamChunk, SpawnWorkerOptions } from '../types/index.js';
|
|
2
|
+
type RouteOptions = Pick<SpawnWorkerOptions, 'signal'>;
|
|
3
|
+
export declare function loadAndRoute(prompt: string, options?: RouteOptions): Promise<RouteResult>;
|
|
4
|
+
export declare function route(prompt: string, config: OrchConfig, options?: RouteOptions): Promise<RouteResult>;
|
|
5
|
+
export declare function routeStreaming(prompt: string, config: OrchConfig, options?: RouteOptions): AsyncGenerator<RouteStreamChunk, RouteResult>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { loadConfig } from './config.js';
|
|
2
|
+
import { spawnWorker, spawnWorkerStreaming } from './spawner.js';
|
|
3
|
+
const escalationPattern = /^\[ESCALATE:([^\]]+)\]$/u;
|
|
4
|
+
const defaultErrorResponse = "Sorry, I couldn't handle that.";
|
|
5
|
+
const escalationPrefix = '[ESCALATE:';
|
|
6
|
+
export async function loadAndRoute(prompt, options = {}) {
|
|
7
|
+
const config = await loadConfig();
|
|
8
|
+
return route(prompt, config, options);
|
|
9
|
+
}
|
|
10
|
+
export async function route(prompt, config, options = {}) {
|
|
11
|
+
const state = {
|
|
12
|
+
attemptedWorkers: new Set(),
|
|
13
|
+
escalationChain: [],
|
|
14
|
+
escalated: false,
|
|
15
|
+
};
|
|
16
|
+
const [firstWorkerName, ...fallbackWorkerNames] = config.priority;
|
|
17
|
+
if (!firstWorkerName) {
|
|
18
|
+
return createFailureResult(state);
|
|
19
|
+
}
|
|
20
|
+
const pendingWorkers = [firstWorkerName, ...fallbackWorkerNames];
|
|
21
|
+
while (pendingWorkers.length > 0) {
|
|
22
|
+
throwIfAborted(options.signal);
|
|
23
|
+
const workerName = pendingWorkers.shift();
|
|
24
|
+
if (workerName === undefined || state.attemptedWorkers.has(workerName)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const workerPrompt = state.attemptedWorkers.size === 0
|
|
28
|
+
? `${config.escalationPrompt}\n\n${prompt}`
|
|
29
|
+
: prompt;
|
|
30
|
+
const output = await runWorker(config.workers[workerName], workerName, workerPrompt, state, options);
|
|
31
|
+
state.attemptedWorkers.add(workerName);
|
|
32
|
+
if (output === undefined) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const escalationTarget = getEscalationTarget(output);
|
|
36
|
+
if (escalationTarget === undefined) {
|
|
37
|
+
return {
|
|
38
|
+
response: output,
|
|
39
|
+
handledBy: workerName,
|
|
40
|
+
escalated: state.escalated,
|
|
41
|
+
escalationChain: state.escalationChain,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (config.workers[escalationTarget] !== undefined &&
|
|
45
|
+
!state.attemptedWorkers.has(escalationTarget)) {
|
|
46
|
+
state.escalated = true;
|
|
47
|
+
pendingWorkers.unshift(escalationTarget);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return createFailureResult(state);
|
|
51
|
+
}
|
|
52
|
+
export async function* routeStreaming(prompt, config, options = {}) {
|
|
53
|
+
const state = {
|
|
54
|
+
attemptedWorkers: new Set(),
|
|
55
|
+
escalationChain: [],
|
|
56
|
+
escalated: false,
|
|
57
|
+
};
|
|
58
|
+
const [firstWorkerName, ...fallbackWorkerNames] = config.priority;
|
|
59
|
+
if (!firstWorkerName) {
|
|
60
|
+
return createFailureResult(state);
|
|
61
|
+
}
|
|
62
|
+
const pendingWorkers = [firstWorkerName, ...fallbackWorkerNames];
|
|
63
|
+
while (pendingWorkers.length > 0) {
|
|
64
|
+
throwIfAborted(options.signal);
|
|
65
|
+
const workerName = pendingWorkers.shift();
|
|
66
|
+
if (workerName === undefined || state.attemptedWorkers.has(workerName)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const workerPrompt = state.attemptedWorkers.size === 0
|
|
70
|
+
? `${config.escalationPrompt}\n\n${prompt}`
|
|
71
|
+
: prompt;
|
|
72
|
+
const result = yield* attemptWorkerStream(config.workers[workerName], workerName, workerPrompt, state, options);
|
|
73
|
+
state.attemptedWorkers.add(workerName);
|
|
74
|
+
if (result.type === 'deliver') {
|
|
75
|
+
return {
|
|
76
|
+
response: result.output,
|
|
77
|
+
handledBy: result.handledBy,
|
|
78
|
+
escalated: state.escalated,
|
|
79
|
+
escalationChain: state.escalationChain,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (result.type === 'escalate' &&
|
|
83
|
+
config.workers[result.target] !== undefined &&
|
|
84
|
+
!state.attemptedWorkers.has(result.target)) {
|
|
85
|
+
state.escalated = true;
|
|
86
|
+
pendingWorkers.unshift(result.target);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return createFailureResult(state);
|
|
90
|
+
}
|
|
91
|
+
async function* attemptWorkerStream(worker, workerName, prompt, state, options) {
|
|
92
|
+
if (worker === undefined) {
|
|
93
|
+
return { type: 'none' };
|
|
94
|
+
}
|
|
95
|
+
state.escalationChain.push(workerName);
|
|
96
|
+
const streamingWorker = spawnWorkerStreaming(worker.command, worker.flags, prompt, {
|
|
97
|
+
promptFlag: worker.promptFlag,
|
|
98
|
+
signal: options.signal,
|
|
99
|
+
});
|
|
100
|
+
let bufferedOutput = '';
|
|
101
|
+
let streamedOutput = '';
|
|
102
|
+
let isStreaming = false;
|
|
103
|
+
try {
|
|
104
|
+
for await (const chunk of streamingWorker.stream) {
|
|
105
|
+
throwIfAborted(options.signal);
|
|
106
|
+
if (!isStreaming) {
|
|
107
|
+
bufferedOutput += chunk;
|
|
108
|
+
if (canStillBeEscalation(bufferedOutput)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
isStreaming = true;
|
|
112
|
+
streamedOutput = bufferedOutput;
|
|
113
|
+
yield {
|
|
114
|
+
chunk: bufferedOutput,
|
|
115
|
+
handledBy: workerName,
|
|
116
|
+
};
|
|
117
|
+
bufferedOutput = '';
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
streamedOutput += chunk;
|
|
121
|
+
yield {
|
|
122
|
+
chunk,
|
|
123
|
+
handledBy: workerName,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
throwIfAborted(options.signal);
|
|
127
|
+
const completed = await streamingWorker.completed;
|
|
128
|
+
if (completed.error !== undefined) {
|
|
129
|
+
return { type: 'none' };
|
|
130
|
+
}
|
|
131
|
+
if (isStreaming) {
|
|
132
|
+
return streamedOutput.trim() === ''
|
|
133
|
+
? { type: 'none' }
|
|
134
|
+
: {
|
|
135
|
+
type: 'deliver',
|
|
136
|
+
output: streamedOutput,
|
|
137
|
+
handledBy: workerName,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const finalOutput = bufferedOutput.trim() === '' ? completed.output : bufferedOutput;
|
|
141
|
+
const escalationTarget = getEscalationTarget(finalOutput);
|
|
142
|
+
if (escalationTarget !== undefined) {
|
|
143
|
+
return {
|
|
144
|
+
type: 'escalate',
|
|
145
|
+
target: escalationTarget,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (finalOutput.trim() === '') {
|
|
149
|
+
return { type: 'none' };
|
|
150
|
+
}
|
|
151
|
+
yield {
|
|
152
|
+
chunk: finalOutput,
|
|
153
|
+
handledBy: workerName,
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
type: 'deliver',
|
|
157
|
+
output: finalOutput,
|
|
158
|
+
handledBy: workerName,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (options.signal?.aborted) {
|
|
163
|
+
streamingWorker.kill();
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
return { type: 'none' };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function canStillBeEscalation(output) {
|
|
170
|
+
const trimmedOutput = output.trim();
|
|
171
|
+
if (trimmedOutput === '') {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (escalationPrefix.startsWith(trimmedOutput)) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (trimmedOutput.startsWith(escalationPrefix) &&
|
|
178
|
+
!trimmedOutput.includes(']')) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
return escalationPattern.test(trimmedOutput);
|
|
182
|
+
}
|
|
183
|
+
function getEscalationTarget(output) {
|
|
184
|
+
const match = escalationPattern.exec(output.trim());
|
|
185
|
+
if (match === null) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const workerName = match[1]?.trim();
|
|
189
|
+
return workerName === '' ? undefined : workerName;
|
|
190
|
+
}
|
|
191
|
+
function createFailureResult(state) {
|
|
192
|
+
return {
|
|
193
|
+
response: defaultErrorResponse,
|
|
194
|
+
handledBy: '',
|
|
195
|
+
escalated: state.escalated,
|
|
196
|
+
escalationChain: state.escalationChain,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function runWorker(worker, workerName, prompt, state, options) {
|
|
200
|
+
if (worker === undefined) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
state.escalationChain.push(workerName);
|
|
204
|
+
const result = await spawnWorker(worker.command, worker.flags, prompt, {
|
|
205
|
+
promptFlag: worker.promptFlag,
|
|
206
|
+
signal: options.signal,
|
|
207
|
+
});
|
|
208
|
+
throwIfAborted(options.signal);
|
|
209
|
+
if (result.error !== undefined || result.output.trim() === '') {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
return result.output;
|
|
213
|
+
}
|
|
214
|
+
function throwIfAborted(signal) {
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
throw signal.reason instanceof Error
|
|
217
|
+
? signal.reason
|
|
218
|
+
: new Error('Request aborted.');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SpawnResult, SpawnWorkerOptions, StreamingSpawnResult } from '../types/index.js';
|
|
2
|
+
export declare function spawnWorker(command: string, flags: string[], prompt: string, options?: SpawnWorkerOptions): Promise<SpawnResult>;
|
|
3
|
+
export declare function spawnWorkerStreaming(command: string, flags: string[], prompt: string, options?: SpawnWorkerOptions): StreamingSpawnResult;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
const defaultTimeoutMs = 120000;
|
|
3
|
+
const failedExitCode = 1;
|
|
4
|
+
export async function spawnWorker(command, flags, prompt, options = {}) {
|
|
5
|
+
const { promptFlag, signal, timeoutMs = defaultTimeoutMs } = options;
|
|
6
|
+
const args = buildArgs(flags, prompt, promptFlag);
|
|
7
|
+
const result = await execa(command, args, {
|
|
8
|
+
reject: false,
|
|
9
|
+
stderr: 'pipe',
|
|
10
|
+
stdout: 'pipe',
|
|
11
|
+
signal,
|
|
12
|
+
timeout: timeoutMs,
|
|
13
|
+
});
|
|
14
|
+
return createSpawnResult({
|
|
15
|
+
...result,
|
|
16
|
+
stdout: result.stdout ?? '',
|
|
17
|
+
}, command, timeoutMs);
|
|
18
|
+
}
|
|
19
|
+
export function spawnWorkerStreaming(command, flags, prompt, options = {}) {
|
|
20
|
+
const { promptFlag, signal, timeoutMs = defaultTimeoutMs } = options;
|
|
21
|
+
const args = buildArgs(flags, prompt, promptFlag);
|
|
22
|
+
const workerProcess = execa(command, args, {
|
|
23
|
+
buffer: false,
|
|
24
|
+
reject: false,
|
|
25
|
+
stderr: 'pipe',
|
|
26
|
+
stdout: 'pipe',
|
|
27
|
+
signal,
|
|
28
|
+
timeout: timeoutMs,
|
|
29
|
+
});
|
|
30
|
+
let output = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
const stream = createStream(workerProcess.stdout, chunk => {
|
|
33
|
+
output += chunk;
|
|
34
|
+
});
|
|
35
|
+
const stderrDone = consumeStream(workerProcess.stderr, chunk => {
|
|
36
|
+
stderr += chunk;
|
|
37
|
+
});
|
|
38
|
+
const completed = workerProcess
|
|
39
|
+
.then(async (result) => {
|
|
40
|
+
await stderrDone;
|
|
41
|
+
return createSpawnResult({
|
|
42
|
+
...result,
|
|
43
|
+
stderr,
|
|
44
|
+
stdout: output,
|
|
45
|
+
}, command, timeoutMs);
|
|
46
|
+
})
|
|
47
|
+
.catch(async (error) => {
|
|
48
|
+
await stderrDone;
|
|
49
|
+
return createSpawnResult({
|
|
50
|
+
...normalizeProcessError(error),
|
|
51
|
+
stderr,
|
|
52
|
+
stdout: output,
|
|
53
|
+
}, command, timeoutMs);
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
stream,
|
|
57
|
+
kill: () => {
|
|
58
|
+
if (!workerProcess.killed) {
|
|
59
|
+
workerProcess.kill();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
exitCode: completed.then(result => result.exitCode),
|
|
63
|
+
completed,
|
|
64
|
+
worker: command,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function buildArgs(flags, prompt, promptFlag) {
|
|
68
|
+
return promptFlag ? [...flags, promptFlag, prompt] : [...flags, prompt];
|
|
69
|
+
}
|
|
70
|
+
function createStream(stream, onChunk) {
|
|
71
|
+
return {
|
|
72
|
+
async *[Symbol.asyncIterator]() {
|
|
73
|
+
if (stream === undefined || stream === null) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for await (const chunk of stream) {
|
|
77
|
+
const text = toTextChunk(chunk);
|
|
78
|
+
if (text === '') {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
onChunk(text);
|
|
82
|
+
yield text;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function consumeStream(stream, onChunk) {
|
|
88
|
+
if (stream === undefined || stream === null) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
for await (const chunk of stream) {
|
|
93
|
+
const text = toTextChunk(chunk);
|
|
94
|
+
if (text !== '') {
|
|
95
|
+
onChunk(text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
}
|
|
101
|
+
function toTextChunk(chunk) {
|
|
102
|
+
if (typeof chunk === 'string') {
|
|
103
|
+
return chunk;
|
|
104
|
+
}
|
|
105
|
+
if (chunk instanceof Uint8Array) {
|
|
106
|
+
return Buffer.from(chunk).toString('utf8');
|
|
107
|
+
}
|
|
108
|
+
return String(chunk);
|
|
109
|
+
}
|
|
110
|
+
function createSpawnResult(result, command, timeoutMs) {
|
|
111
|
+
const error = getErrorMessage(result, timeoutMs);
|
|
112
|
+
return {
|
|
113
|
+
output: result.stdout ?? '',
|
|
114
|
+
exitCode: result.exitCode ?? failedExitCode,
|
|
115
|
+
worker: command,
|
|
116
|
+
...(error === undefined ? {} : { error }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function normalizeProcessError(error) {
|
|
120
|
+
if (typeof error !== 'object' || error === null) {
|
|
121
|
+
return {
|
|
122
|
+
failed: true,
|
|
123
|
+
timedOut: false,
|
|
124
|
+
message: 'Worker process failed.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
exitCode: 'exitCode' in error && typeof error.exitCode === 'number'
|
|
129
|
+
? error.exitCode
|
|
130
|
+
: undefined,
|
|
131
|
+
failed: true,
|
|
132
|
+
message: 'message' in error && typeof error.message === 'string'
|
|
133
|
+
? error.message
|
|
134
|
+
: 'Worker process failed.',
|
|
135
|
+
shortMessage: 'shortMessage' in error && typeof error.shortMessage === 'string'
|
|
136
|
+
? error.shortMessage
|
|
137
|
+
: undefined,
|
|
138
|
+
stderr: 'stderr' in error && typeof error.stderr === 'string'
|
|
139
|
+
? error.stderr
|
|
140
|
+
: undefined,
|
|
141
|
+
stdout: 'stdout' in error && typeof error.stdout === 'string'
|
|
142
|
+
? error.stdout
|
|
143
|
+
: undefined,
|
|
144
|
+
timedOut: 'timedOut' in error && typeof error.timedOut === 'boolean'
|
|
145
|
+
? error.timedOut
|
|
146
|
+
: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function getErrorMessage(result, timeoutMs) {
|
|
150
|
+
const stderr = result.stderr?.trim();
|
|
151
|
+
if (result.timedOut) {
|
|
152
|
+
return stderr
|
|
153
|
+
? `Worker timed out after ${timeoutMs}ms.\n${stderr}`
|
|
154
|
+
: `Worker timed out after ${timeoutMs}ms.`;
|
|
155
|
+
}
|
|
156
|
+
if (result.failed) {
|
|
157
|
+
return stderr
|
|
158
|
+
? stderr
|
|
159
|
+
: result.shortMessage ?? result.message ?? 'Worker process failed.';
|
|
160
|
+
}
|
|
161
|
+
// Only treat stderr as error when process actually failed.
|
|
162
|
+
// Many CLIs (e.g. gemini) write debug/info logs to stderr on success.
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type WorkerCost = 'free' | 'low' | 'high';
|
|
2
|
+
export interface WorkerConfig {
|
|
3
|
+
command: string;
|
|
4
|
+
flags: string[];
|
|
5
|
+
promptFlag?: string;
|
|
6
|
+
cost: WorkerCost;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
export interface OrchConfig {
|
|
10
|
+
priority: string[];
|
|
11
|
+
workers: Record<string, WorkerConfig>;
|
|
12
|
+
escalationPrompt: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SpawnResult {
|
|
15
|
+
output: string;
|
|
16
|
+
exitCode: number;
|
|
17
|
+
error?: string;
|
|
18
|
+
worker: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SpawnWorkerOptions {
|
|
21
|
+
promptFlag?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
export interface StreamingSpawnResult {
|
|
26
|
+
stream: AsyncIterable<string>;
|
|
27
|
+
kill: () => void;
|
|
28
|
+
exitCode: Promise<number>;
|
|
29
|
+
completed: Promise<SpawnResult>;
|
|
30
|
+
worker: string;
|
|
31
|
+
}
|
|
32
|
+
export interface RouteResult {
|
|
33
|
+
response: string;
|
|
34
|
+
handledBy: string;
|
|
35
|
+
escalated: boolean;
|
|
36
|
+
escalationChain: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface RouteStreamChunk {
|
|
39
|
+
chunk: string;
|
|
40
|
+
handledBy: string;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@waqas/orch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An AI CLI that routes prompts across external AI CLIs and feels like one assistant.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"orch": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/waqaskhan137/orch.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/waqaskhan137/orch",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/waqaskhan137/orch/issues"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=16"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"dev": "tsc --watch",
|
|
28
|
+
"release:check": "node ./scripts/release.mjs check",
|
|
29
|
+
"release:patch": "node ./scripts/release.mjs patch",
|
|
30
|
+
"release:minor": "node ./scripts/release.mjs minor",
|
|
31
|
+
"release:major": "node ./scripts/release.mjs major",
|
|
32
|
+
"test": "prettier --check . && xo && ava"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"execa": "^8.0.1",
|
|
39
|
+
"ink": "^4.1.0",
|
|
40
|
+
"ink-spinner": "^5.0.0",
|
|
41
|
+
"marked": "^5.1.2",
|
|
42
|
+
"marked-terminal": "^5.2.0",
|
|
43
|
+
"pastel": "^2.0.0",
|
|
44
|
+
"react": "^18.2.0",
|
|
45
|
+
"yaml": "^2.8.3",
|
|
46
|
+
"zod": "^3.21.4"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@sindresorhus/tsconfig": "^3.0.1",
|
|
50
|
+
"@types/marked": "^5.0.2",
|
|
51
|
+
"@types/react": "^18.0.32",
|
|
52
|
+
"@vdemedes/prettier-config": "^2.0.1",
|
|
53
|
+
"ava": "^5.2.0",
|
|
54
|
+
"chalk": "^5.2.0",
|
|
55
|
+
"eslint-config-xo-react": "^0.27.0",
|
|
56
|
+
"eslint-plugin-react": "^7.32.2",
|
|
57
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
58
|
+
"ink-testing-library": "^3.0.0",
|
|
59
|
+
"prettier": "^2.8.7",
|
|
60
|
+
"ts-node": "^10.9.1",
|
|
61
|
+
"typescript": "^5.0.3",
|
|
62
|
+
"xo": "^0.54.2"
|
|
63
|
+
},
|
|
64
|
+
"ava": {
|
|
65
|
+
"extensions": {
|
|
66
|
+
"ts": "module",
|
|
67
|
+
"tsx": "module"
|
|
68
|
+
},
|
|
69
|
+
"nodeArguments": [
|
|
70
|
+
"--loader=ts-node/esm"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"xo": {
|
|
74
|
+
"extends": "xo-react",
|
|
75
|
+
"prettier": true,
|
|
76
|
+
"rules": {
|
|
77
|
+
"react/prop-types": "off"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"prettier": "@vdemedes/prettier-config"
|
|
81
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# orch
|
|
2
|
+
|
|
3
|
+
`orch` is an AI CLI that routes prompts across external AI CLIs and feels like one assistant.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --global @waqas/orch
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
orch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The installed command is still `orch`.
|