@matterailab/orbcode 0.1.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/LICENSE +21 -0
- package/README.md +471 -0
- package/bin/orbcode.js +2 -0
- package/dist/api/client.js +141 -0
- package/dist/api/headers.js +14 -0
- package/dist/api/models.js +49 -0
- package/dist/api/stream.js +1 -0
- package/dist/auth/auth.js +172 -0
- package/dist/branding.js +31 -0
- package/dist/config/promptHistory.js +33 -0
- package/dist/config/settings.js +112 -0
- package/dist/core/agent.js +459 -0
- package/dist/core/events.js +1 -0
- package/dist/core/sessions.js +44 -0
- package/dist/headless.js +64 -0
- package/dist/index.js +84 -0
- package/dist/prompts/system.js +379 -0
- package/dist/tools/executors/executeCommand.js +58 -0
- package/dist/tools/executors/files.js +197 -0
- package/dist/tools/executors/listFiles.js +65 -0
- package/dist/tools/executors/searchFiles.js +104 -0
- package/dist/tools/executors/web.js +72 -0
- package/dist/tools/index.js +85 -0
- package/dist/tools/schemas/ask_followup_question.js +40 -0
- package/dist/tools/schemas/attempt_completion.js +19 -0
- package/dist/tools/schemas/browser_action.js +60 -0
- package/dist/tools/schemas/check_past_chat_memories.js +23 -0
- package/dist/tools/schemas/codebase_search.js +23 -0
- package/dist/tools/schemas/execute_command.js +31 -0
- package/dist/tools/schemas/fetch_instructions.js +20 -0
- package/dist/tools/schemas/file_edit.js +31 -0
- package/dist/tools/schemas/file_write.js +27 -0
- package/dist/tools/schemas/generate_image.js +27 -0
- package/dist/tools/schemas/index.js +29 -0
- package/dist/tools/schemas/list_code_definition_names.js +19 -0
- package/dist/tools/schemas/list_files.js +23 -0
- package/dist/tools/schemas/lsp.js +46 -0
- package/dist/tools/schemas/multi_file_edit.js +42 -0
- package/dist/tools/schemas/new_task.js +27 -0
- package/dist/tools/schemas/read_file.js +27 -0
- package/dist/tools/schemas/run_slash_command.js +23 -0
- package/dist/tools/schemas/search_files.js +27 -0
- package/dist/tools/schemas/switch_mode.js +23 -0
- package/dist/tools/schemas/update_todo_list.js +19 -0
- package/dist/tools/schemas/use_skill.js +19 -0
- package/dist/tools/schemas/web_fetch.js +19 -0
- package/dist/tools/schemas/web_search.js +19 -0
- package/dist/tools/types.js +7 -0
- package/dist/ui/App.js +569 -0
- package/dist/ui/LoginView.js +82 -0
- package/dist/ui/components/ApprovalPrompt.js +21 -0
- package/dist/ui/components/FollowupPrompt.js +45 -0
- package/dist/ui/components/Header.js +12 -0
- package/dist/ui/components/InputBox.js +220 -0
- package/dist/ui/components/ModelPicker.js +51 -0
- package/dist/ui/components/SessionPicker.js +52 -0
- package/dist/ui/components/Spinner.js +19 -0
- package/dist/ui/components/StatusBar.js +18 -0
- package/dist/ui/components/rows.js +106 -0
- package/dist/ui/markdown.js +64 -0
- package/dist/utils/diff.js +144 -0
- package/dist/utils/shell.js +19 -0
- package/package.json +62 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { AxonClient } from "../api/client.js";
|
|
5
|
+
import { buildSystemPrompt } from "../prompts/system.js";
|
|
6
|
+
import { describeToolCall, executeTool, getActiveTools, getApprovalKind, } from "../tools/index.js";
|
|
7
|
+
import { walkFiles } from "../tools/executors/listFiles.js";
|
|
8
|
+
import { previewFileChange } from "../tools/executors/files.js";
|
|
9
|
+
import { saveSession } from "./sessions.js";
|
|
10
|
+
const MAX_STEPS_PER_TURN = 50;
|
|
11
|
+
const RESULT_PREVIEW_LINES = 6;
|
|
12
|
+
function detectRepo(cwd) {
|
|
13
|
+
try {
|
|
14
|
+
const remote = execSync("git config --get remote.origin.url", {
|
|
15
|
+
cwd,
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
})
|
|
18
|
+
.toString()
|
|
19
|
+
.trim();
|
|
20
|
+
if (remote)
|
|
21
|
+
return remote;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// not a git repo or no remote
|
|
25
|
+
}
|
|
26
|
+
return path.basename(cwd);
|
|
27
|
+
}
|
|
28
|
+
function getGitSummary(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] })
|
|
31
|
+
.toString()
|
|
32
|
+
.trim();
|
|
33
|
+
const status = execSync("git status --short", { cwd, stdio: ["ignore", "pipe", "ignore"] })
|
|
34
|
+
.toString()
|
|
35
|
+
.trimEnd()
|
|
36
|
+
.split("\n")
|
|
37
|
+
.slice(0, 20)
|
|
38
|
+
.join("\n");
|
|
39
|
+
return `## Git Repository Information\n- Current Branch: ${branch}\n${status ? `\n### Working Tree Changes\n${status}` : "- Working tree clean"}`;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The user's message is wrapped in <user_query> tags internally so the TUI can
|
|
47
|
+
* identify user-authored text when replaying a session. The wrapper is only an
|
|
48
|
+
* internal marker and must be stripped before the message is sent to the model.
|
|
49
|
+
*/
|
|
50
|
+
function stripUserQueryTags(text) {
|
|
51
|
+
return text.replace(/<user_query>\n?/g, "").replace(/\n?<\/user_query>/g, "");
|
|
52
|
+
}
|
|
53
|
+
export class Agent {
|
|
54
|
+
options;
|
|
55
|
+
client;
|
|
56
|
+
systemPrompt;
|
|
57
|
+
messages = [];
|
|
58
|
+
todos = "";
|
|
59
|
+
firstMessageSent = false;
|
|
60
|
+
sessionApproveEdits;
|
|
61
|
+
sessionApproveCommands = false;
|
|
62
|
+
abortController;
|
|
63
|
+
totalCost = 0;
|
|
64
|
+
title = "";
|
|
65
|
+
createdAt = new Date().toISOString();
|
|
66
|
+
taskId;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.options = options;
|
|
69
|
+
this.taskId = options.resume?.id ?? randomUUID();
|
|
70
|
+
if (options.resume) {
|
|
71
|
+
this.messages = options.resume.messages;
|
|
72
|
+
this.todos = options.resume.todos;
|
|
73
|
+
this.totalCost = options.resume.totalCost;
|
|
74
|
+
this.title = options.resume.title;
|
|
75
|
+
this.createdAt = options.resume.createdAt;
|
|
76
|
+
this.firstMessageSent = this.messages.length > 0;
|
|
77
|
+
}
|
|
78
|
+
this.sessionApproveEdits = options.autoApproveEdits;
|
|
79
|
+
this.systemPrompt = buildSystemPrompt(options.cwd);
|
|
80
|
+
this.client = new AxonClient({
|
|
81
|
+
token: options.token,
|
|
82
|
+
modelId: options.modelId,
|
|
83
|
+
taskId: this.taskId,
|
|
84
|
+
organizationId: options.organizationId,
|
|
85
|
+
repo: detectRepo(options.cwd),
|
|
86
|
+
baseUrl: options.baseUrl,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
setModel(modelId) {
|
|
90
|
+
this.options.modelId = modelId;
|
|
91
|
+
this.client = new AxonClient({
|
|
92
|
+
token: this.options.token,
|
|
93
|
+
modelId,
|
|
94
|
+
taskId: this.taskId,
|
|
95
|
+
organizationId: this.options.organizationId,
|
|
96
|
+
repo: detectRepo(this.options.cwd),
|
|
97
|
+
baseUrl: this.options.baseUrl,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
get modelId() {
|
|
101
|
+
return this.options.modelId;
|
|
102
|
+
}
|
|
103
|
+
/** Replace the prompt-derived title with the backend-generated one. */
|
|
104
|
+
setTitle(title) {
|
|
105
|
+
this.title = title;
|
|
106
|
+
this.persist();
|
|
107
|
+
}
|
|
108
|
+
/** Update auto-approval behavior mid-session (shift+tab cycling in the TUI). */
|
|
109
|
+
setApprovalMode(autoApproveEdits, autoApproveSafeCommands) {
|
|
110
|
+
this.sessionApproveEdits = autoApproveEdits;
|
|
111
|
+
this.options.autoApproveEdits = autoApproveEdits;
|
|
112
|
+
this.options.autoApproveSafeCommands = autoApproveSafeCommands;
|
|
113
|
+
if (!autoApproveSafeCommands)
|
|
114
|
+
this.sessionApproveCommands = false;
|
|
115
|
+
}
|
|
116
|
+
clear() {
|
|
117
|
+
this.messages = [];
|
|
118
|
+
this.todos = "";
|
|
119
|
+
this.firstMessageSent = false;
|
|
120
|
+
this.totalCost = 0;
|
|
121
|
+
this.title = "";
|
|
122
|
+
}
|
|
123
|
+
/** Write the current conversation to the sessions directory. */
|
|
124
|
+
persist() {
|
|
125
|
+
if (this.messages.length === 0)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
saveSession({
|
|
129
|
+
id: this.taskId,
|
|
130
|
+
cwd: this.options.cwd,
|
|
131
|
+
model: this.options.modelId,
|
|
132
|
+
title: this.title,
|
|
133
|
+
createdAt: this.createdAt,
|
|
134
|
+
updatedAt: new Date().toISOString(),
|
|
135
|
+
totalCost: this.totalCost,
|
|
136
|
+
todos: this.todos,
|
|
137
|
+
messages: this.messages,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// persistence is best-effort; never break the session over it
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
abort() {
|
|
145
|
+
this.abortController?.abort();
|
|
146
|
+
}
|
|
147
|
+
get isIdle() {
|
|
148
|
+
return this.abortController === undefined;
|
|
149
|
+
}
|
|
150
|
+
buildEnvironmentDetails() {
|
|
151
|
+
const files = walkFiles(this.options.cwd, true, 200);
|
|
152
|
+
const git = getGitSummary(this.options.cwd);
|
|
153
|
+
const now = new Date();
|
|
154
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
155
|
+
const timeZoneOffset = -now.getTimezoneOffset() / 60;
|
|
156
|
+
const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset));
|
|
157
|
+
const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60));
|
|
158
|
+
const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}`;
|
|
159
|
+
return `# Environment Details
|
|
160
|
+
|
|
161
|
+
## Current Workspace Directory (${this.options.cwd}) Files
|
|
162
|
+
${files.join("\n") || "(empty directory)"}
|
|
163
|
+
${files.length >= 200 ? "\n(File list truncated.)" : ""}
|
|
164
|
+
|
|
165
|
+
${git}
|
|
166
|
+
|
|
167
|
+
## Current Time
|
|
168
|
+
Current time in ISO 8601 UTC format: ${now.toISOString()}
|
|
169
|
+
User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
|
|
170
|
+
}
|
|
171
|
+
/** Conversation history with internal markers stripped, ready for the model. */
|
|
172
|
+
outgoingMessages() {
|
|
173
|
+
return this.messages.map((message) => message.role === "user" && typeof message.content === "string"
|
|
174
|
+
? { ...message, content: stripUserQueryTags(message.content) }
|
|
175
|
+
: message);
|
|
176
|
+
}
|
|
177
|
+
toolContext() {
|
|
178
|
+
return {
|
|
179
|
+
cwd: this.options.cwd,
|
|
180
|
+
token: this.options.token,
|
|
181
|
+
getTodos: () => this.todos,
|
|
182
|
+
setTodos: (todos) => {
|
|
183
|
+
this.todos = todos;
|
|
184
|
+
this.options.callbacks.onEvent({ type: "todos", todos });
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async runTurn(userText) {
|
|
189
|
+
const { onEvent } = this.options.callbacks;
|
|
190
|
+
this.abortController = new AbortController();
|
|
191
|
+
if (!this.title) {
|
|
192
|
+
this.title = userText.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
193
|
+
}
|
|
194
|
+
let userContent = `<user_query>\n${userText}\n</user_query>`;
|
|
195
|
+
if (!this.firstMessageSent) {
|
|
196
|
+
userContent = `${this.buildEnvironmentDetails()}\n\n${userContent}`;
|
|
197
|
+
this.firstMessageSent = true;
|
|
198
|
+
}
|
|
199
|
+
this.messages.push({ role: "user", content: userContent });
|
|
200
|
+
try {
|
|
201
|
+
for (let step = 0; step < MAX_STEPS_PER_TURN; step++) {
|
|
202
|
+
const done = await this.runStep();
|
|
203
|
+
if (done)
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error.name === "AbortError" || this.abortController.signal.aborted) {
|
|
209
|
+
onEvent({ type: "error", message: "Interrupted." });
|
|
210
|
+
// Keep the conversation consistent: note the interruption for the model.
|
|
211
|
+
this.messages.push({
|
|
212
|
+
role: "user",
|
|
213
|
+
content: "System reminder: The user interrupted this response before it finished.",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
onEvent({ type: "error", message: error.message });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
this.abortController = undefined;
|
|
222
|
+
this.persist();
|
|
223
|
+
onEvent({ type: "turn-end" });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Summarize the conversation so far and replace history with the summary. */
|
|
227
|
+
async compact() {
|
|
228
|
+
const { onEvent } = this.options.callbacks;
|
|
229
|
+
if (this.messages.length === 0) {
|
|
230
|
+
onEvent({ type: "error", message: "Nothing to compact yet." });
|
|
231
|
+
onEvent({ type: "turn-end" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.abortController = new AbortController();
|
|
235
|
+
const signal = this.abortController.signal;
|
|
236
|
+
try {
|
|
237
|
+
const request = [
|
|
238
|
+
...this.outgoingMessages(),
|
|
239
|
+
{
|
|
240
|
+
role: "user",
|
|
241
|
+
content: "Summarize this conversation so it can replace the full history. Capture the user's goals, decisions made, files created or modified (with paths), important code details, and any remaining next steps. Be thorough but concise. Respond with only the summary.",
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
let summary = "";
|
|
245
|
+
for await (const chunk of this.client.createMessage(this.systemPrompt, request, [], signal)) {
|
|
246
|
+
if (signal.aborted)
|
|
247
|
+
throw new DOMException("aborted", "AbortError");
|
|
248
|
+
if (chunk.type === "text") {
|
|
249
|
+
summary += chunk.text;
|
|
250
|
+
onEvent({ type: "text-delta", text: chunk.text });
|
|
251
|
+
}
|
|
252
|
+
else if (chunk.type === "usage") {
|
|
253
|
+
this.totalCost += chunk.totalCost ?? 0;
|
|
254
|
+
onEvent({
|
|
255
|
+
type: "usage",
|
|
256
|
+
inputTokens: chunk.inputTokens,
|
|
257
|
+
outputTokens: chunk.outputTokens,
|
|
258
|
+
cost: chunk.totalCost ?? 0,
|
|
259
|
+
totalCost: this.totalCost,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (summary) {
|
|
264
|
+
onEvent({ type: "text-done" });
|
|
265
|
+
this.messages = [
|
|
266
|
+
{
|
|
267
|
+
role: "user",
|
|
268
|
+
content: `# Conversation Summary\n\nThe conversation history was compacted. Summary of everything so far:\n\n${summary}`,
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
onEvent({ type: "error", message: "Compaction produced no summary; history left unchanged." });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error.name === "AbortError" || signal.aborted) {
|
|
278
|
+
onEvent({ type: "error", message: "Compaction interrupted; history left unchanged." });
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
onEvent({ type: "error", message: error.message });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
this.abortController = undefined;
|
|
286
|
+
this.persist();
|
|
287
|
+
onEvent({ type: "turn-end" });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Run one model request + tool execution round. Returns true when the turn is over. */
|
|
291
|
+
async runStep() {
|
|
292
|
+
const { onEvent } = this.options.callbacks;
|
|
293
|
+
const signal = this.abortController.signal;
|
|
294
|
+
let assistantText = "";
|
|
295
|
+
let hadReasoning = false;
|
|
296
|
+
let reasoningStart = 0;
|
|
297
|
+
const toolCallsByIndex = new Map();
|
|
298
|
+
let nextSyntheticIndex = 10000;
|
|
299
|
+
const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(), signal);
|
|
300
|
+
for await (const chunk of stream) {
|
|
301
|
+
if (signal.aborted)
|
|
302
|
+
throw new DOMException("aborted", "AbortError");
|
|
303
|
+
switch (chunk.type) {
|
|
304
|
+
case "text":
|
|
305
|
+
assistantText += chunk.text;
|
|
306
|
+
onEvent({ type: "text-delta", text: chunk.text });
|
|
307
|
+
break;
|
|
308
|
+
case "reasoning":
|
|
309
|
+
if (!hadReasoning) {
|
|
310
|
+
hadReasoning = true;
|
|
311
|
+
reasoningStart = Date.now();
|
|
312
|
+
}
|
|
313
|
+
onEvent({ type: "reasoning-delta", text: chunk.text });
|
|
314
|
+
break;
|
|
315
|
+
case "native_tool_calls":
|
|
316
|
+
for (const tc of chunk.toolCalls) {
|
|
317
|
+
const index = tc.index ?? nextSyntheticIndex++;
|
|
318
|
+
let pending = toolCallsByIndex.get(index);
|
|
319
|
+
if (!pending) {
|
|
320
|
+
pending = { id: tc.id || `call_${index}_${Date.now()}`, name: "", arguments: "" };
|
|
321
|
+
toolCallsByIndex.set(index, pending);
|
|
322
|
+
}
|
|
323
|
+
if (tc.id)
|
|
324
|
+
pending.id = tc.id;
|
|
325
|
+
if (tc.function?.name)
|
|
326
|
+
pending.name = tc.function.name;
|
|
327
|
+
if (tc.function?.arguments)
|
|
328
|
+
pending.arguments += tc.function.arguments;
|
|
329
|
+
}
|
|
330
|
+
break;
|
|
331
|
+
case "usage":
|
|
332
|
+
this.totalCost += chunk.totalCost ?? 0;
|
|
333
|
+
onEvent({
|
|
334
|
+
type: "usage",
|
|
335
|
+
inputTokens: chunk.inputTokens,
|
|
336
|
+
outputTokens: chunk.outputTokens,
|
|
337
|
+
cost: chunk.totalCost ?? 0,
|
|
338
|
+
totalCost: this.totalCost,
|
|
339
|
+
});
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (hadReasoning) {
|
|
344
|
+
onEvent({ type: "reasoning-done", durationMs: Date.now() - reasoningStart });
|
|
345
|
+
}
|
|
346
|
+
if (assistantText) {
|
|
347
|
+
onEvent({ type: "text-done" });
|
|
348
|
+
}
|
|
349
|
+
const toolCalls = [...toolCallsByIndex.entries()].sort(([a], [b]) => a - b).map(([, tc]) => tc);
|
|
350
|
+
const assistantMessage = {
|
|
351
|
+
role: "assistant",
|
|
352
|
+
content: assistantText || null,
|
|
353
|
+
};
|
|
354
|
+
if (toolCalls.length > 0) {
|
|
355
|
+
assistantMessage.tool_calls = toolCalls.map((tc) => ({
|
|
356
|
+
id: tc.id,
|
|
357
|
+
type: "function",
|
|
358
|
+
function: { name: tc.name, arguments: tc.arguments || "{}" },
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
this.messages.push(assistantMessage);
|
|
362
|
+
if (toolCalls.length === 0) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
let completed = false;
|
|
366
|
+
for (const toolCall of toolCalls) {
|
|
367
|
+
const resultText = await this.handleToolCall(toolCall);
|
|
368
|
+
this.messages.push({ role: "tool", tool_call_id: toolCall.id, content: resultText });
|
|
369
|
+
if (toolCall.name === "attempt_completion") {
|
|
370
|
+
completed = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return completed;
|
|
374
|
+
}
|
|
375
|
+
async handleToolCall(toolCall) {
|
|
376
|
+
const { onEvent, requestApproval, requestFollowup } = this.options.callbacks;
|
|
377
|
+
let args;
|
|
378
|
+
try {
|
|
379
|
+
args = toolCall.arguments ? JSON.parse(toolCall.arguments) : {};
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
const message = `Invalid JSON arguments for ${toolCall.name}: ${error.message}`;
|
|
383
|
+
onEvent({
|
|
384
|
+
type: "tool-end",
|
|
385
|
+
id: toolCall.id,
|
|
386
|
+
name: toolCall.name,
|
|
387
|
+
summary: toolCall.name,
|
|
388
|
+
resultPreview: message,
|
|
389
|
+
isError: true,
|
|
390
|
+
});
|
|
391
|
+
return message;
|
|
392
|
+
}
|
|
393
|
+
const summary = describeToolCall(toolCall.name, args);
|
|
394
|
+
if (toolCall.name === "attempt_completion") {
|
|
395
|
+
onEvent({ type: "completion", result: String(args.result ?? "") });
|
|
396
|
+
return "The user has been shown the completion result.";
|
|
397
|
+
}
|
|
398
|
+
if (toolCall.name === "ask_followup_question") {
|
|
399
|
+
const question = String(args.question ?? "");
|
|
400
|
+
const suggestions = (Array.isArray(args.follow_up) ? args.follow_up : [])
|
|
401
|
+
.map((s) => ({ text: String(s?.text ?? "") }))
|
|
402
|
+
.filter((s) => s.text);
|
|
403
|
+
const answer = await requestFollowup(question, suggestions);
|
|
404
|
+
return `<answer>\n${answer}\n</answer>`;
|
|
405
|
+
}
|
|
406
|
+
onEvent({ type: "tool-start", id: toolCall.id, name: toolCall.name, summary });
|
|
407
|
+
const approvalKind = getApprovalKind(toolCall.name, args);
|
|
408
|
+
const diff = approvalKind === "edit" ? previewFileChange(toolCall.name, args, this.options.cwd) : undefined;
|
|
409
|
+
const isDangerous = toolCall.name === "execute_command" && Boolean(args.isDangerous);
|
|
410
|
+
let needsApproval = false;
|
|
411
|
+
if (approvalKind === "edit" && !this.sessionApproveEdits)
|
|
412
|
+
needsApproval = true;
|
|
413
|
+
if (approvalKind === "command") {
|
|
414
|
+
needsApproval = isDangerous || !(this.sessionApproveCommands || this.options.autoApproveSafeCommands);
|
|
415
|
+
}
|
|
416
|
+
if (needsApproval) {
|
|
417
|
+
const decision = await requestApproval({
|
|
418
|
+
kind: approvalKind,
|
|
419
|
+
toolName: toolCall.name,
|
|
420
|
+
summary,
|
|
421
|
+
detail: approvalKind === "command" ? String(args.command ?? "") : summary,
|
|
422
|
+
diff,
|
|
423
|
+
isDangerous,
|
|
424
|
+
});
|
|
425
|
+
if (decision === "no") {
|
|
426
|
+
const message = "The user denied this operation.";
|
|
427
|
+
onEvent({
|
|
428
|
+
type: "tool-end",
|
|
429
|
+
id: toolCall.id,
|
|
430
|
+
name: toolCall.name,
|
|
431
|
+
summary,
|
|
432
|
+
resultPreview: "Denied by user",
|
|
433
|
+
isError: true,
|
|
434
|
+
});
|
|
435
|
+
return message;
|
|
436
|
+
}
|
|
437
|
+
if (decision === "always") {
|
|
438
|
+
if (approvalKind === "edit")
|
|
439
|
+
this.sessionApproveEdits = true;
|
|
440
|
+
if (approvalKind === "command" && !isDangerous)
|
|
441
|
+
this.sessionApproveCommands = true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const result = await executeTool(toolCall.name, args, this.toolContext());
|
|
445
|
+
const previewLines = result.text.split("\n");
|
|
446
|
+
const resultPreview = previewLines.slice(0, RESULT_PREVIEW_LINES).join("\n") +
|
|
447
|
+
(previewLines.length > RESULT_PREVIEW_LINES ? `\n… (${previewLines.length} lines)` : "");
|
|
448
|
+
onEvent({
|
|
449
|
+
type: "tool-end",
|
|
450
|
+
id: toolCall.id,
|
|
451
|
+
name: toolCall.name,
|
|
452
|
+
summary,
|
|
453
|
+
resultPreview,
|
|
454
|
+
isError: Boolean(result.isError),
|
|
455
|
+
diff: result.isError ? undefined : diff,
|
|
456
|
+
});
|
|
457
|
+
return result.text;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getConfigDir } from "../config/settings.js";
|
|
4
|
+
const MAX_SESSIONS_LISTED = 25;
|
|
5
|
+
function getSessionsDir() {
|
|
6
|
+
return path.join(getConfigDir(), "sessions");
|
|
7
|
+
}
|
|
8
|
+
export function saveSession(data) {
|
|
9
|
+
const dir = getSessionsDir();
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
fs.writeFileSync(path.join(dir, `${data.id}.json`), JSON.stringify(data), { mode: 0o600 });
|
|
12
|
+
}
|
|
13
|
+
export function loadSessionById(id) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(path.join(getSessionsDir(), `${id}.json`), "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Sessions for a workspace, most recently updated first. */
|
|
22
|
+
export function listSessions(cwd) {
|
|
23
|
+
let files;
|
|
24
|
+
try {
|
|
25
|
+
files = fs.readdirSync(getSessionsDir()).filter((f) => f.endsWith(".json"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const sessions = [];
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(fs.readFileSync(path.join(getSessionsDir(), file), "utf8"));
|
|
34
|
+
if (data.cwd === cwd && Array.isArray(data.messages) && data.messages.length > 0) {
|
|
35
|
+
sessions.push(data);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// skip unreadable session files
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
sessions.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
43
|
+
return sessions.slice(0, MAX_SESSIONS_LISTED);
|
|
44
|
+
}
|
package/dist/headless.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getAuthToken, loadSettings } from "./config/settings.js";
|
|
2
|
+
import { Agent } from "./core/agent.js";
|
|
3
|
+
/** Non-interactive `orbcode -p "prompt"` mode: prints the final response to stdout. */
|
|
4
|
+
export async function runHeadless(prompt, yolo) {
|
|
5
|
+
const settings = loadSettings();
|
|
6
|
+
const token = getAuthToken(settings);
|
|
7
|
+
if (!token) {
|
|
8
|
+
console.error("Not signed in. Run `orbcode login`, set ORBCODE_TOKEN, or put an apiKey in settings.json.");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
let exitCode = 0;
|
|
12
|
+
// Only the final content is printed: either the attempt_completion result
|
|
13
|
+
// or, failing that, the last assistant text. Intermediate text and tool
|
|
14
|
+
// activity are suppressed.
|
|
15
|
+
let textBuffer = "";
|
|
16
|
+
let lastText = "";
|
|
17
|
+
let completionResult = "";
|
|
18
|
+
const agent = new Agent({
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
token,
|
|
21
|
+
modelId: settings.model,
|
|
22
|
+
organizationId: settings.organizationId,
|
|
23
|
+
baseUrl: settings.baseUrl,
|
|
24
|
+
autoApproveEdits: yolo,
|
|
25
|
+
autoApproveSafeCommands: yolo,
|
|
26
|
+
callbacks: {
|
|
27
|
+
onEvent: (event) => {
|
|
28
|
+
switch (event.type) {
|
|
29
|
+
case "text-delta":
|
|
30
|
+
textBuffer += event.text;
|
|
31
|
+
break;
|
|
32
|
+
case "text-done":
|
|
33
|
+
lastText = textBuffer;
|
|
34
|
+
textBuffer = "";
|
|
35
|
+
break;
|
|
36
|
+
case "completion":
|
|
37
|
+
completionResult = event.result;
|
|
38
|
+
break;
|
|
39
|
+
case "error":
|
|
40
|
+
process.stderr.write(`error: ${event.message}\n`);
|
|
41
|
+
exitCode = 1;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
// In headless mode there is nobody to ask; deny unless --yolo.
|
|
46
|
+
requestApproval: async (request) => {
|
|
47
|
+
if (yolo)
|
|
48
|
+
return "yes";
|
|
49
|
+
process.stderr.write(`[denied] ${request.toolName}: ${request.summary} (pass --yolo to auto-approve)\n`);
|
|
50
|
+
return "no";
|
|
51
|
+
},
|
|
52
|
+
requestFollowup: async (question) => {
|
|
53
|
+
process.stderr.write(`[followup auto-answered] ${question}\n`);
|
|
54
|
+
return "Proceed with your best judgment; the user is not available to answer.";
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
await agent.runTurn(prompt);
|
|
59
|
+
const finalContent = completionResult || lastText || textBuffer;
|
|
60
|
+
if (finalContent) {
|
|
61
|
+
process.stdout.write(finalContent.trimEnd() + "\n");
|
|
62
|
+
}
|
|
63
|
+
process.exit(exitCode);
|
|
64
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { PRODUCT_NAME, VERSION } from "./branding.js";
|
|
4
|
+
import { App } from "./ui/App.js";
|
|
5
|
+
import { runHeadless } from "./headless.js";
|
|
6
|
+
import { loadSessionById } from "./core/sessions.js";
|
|
7
|
+
function printHelp() {
|
|
8
|
+
console.log(`${PRODUCT_NAME} v${VERSION}
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
orbcode start an interactive session
|
|
12
|
+
orbcode "<prompt>" start an interactive session with an initial prompt
|
|
13
|
+
orbcode login sign in to MatterAI
|
|
14
|
+
orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
|
|
15
|
+
orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
|
|
16
|
+
orbcode --model <id> use a specific model for this run
|
|
17
|
+
orbcode --resume <id> resume a previous session by id
|
|
18
|
+
orbcode --version print version
|
|
19
|
+
orbcode --help show this help
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
/** Pop `flag <value>` (accepting -flag and --flag) out of args; returns the value. */
|
|
23
|
+
function takeFlagValue(args, name) {
|
|
24
|
+
const index = args.findIndex((a) => a === `--${name}` || a === `-${name}`);
|
|
25
|
+
if (index === -1)
|
|
26
|
+
return undefined;
|
|
27
|
+
const value = args[index + 1];
|
|
28
|
+
if (!value || value.startsWith("-")) {
|
|
29
|
+
console.error(`Missing value after --${name}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
args.splice(index, 2);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
async function main() {
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
38
|
+
console.log(VERSION);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
42
|
+
printHelp();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
|
|
46
|
+
if (model) {
|
|
47
|
+
// loadSettings() treats ORBCODE_MODEL as the highest-precedence override,
|
|
48
|
+
// so the flag reaches both the TUI and headless mode without plumbing.
|
|
49
|
+
process.env.ORBCODE_MODEL = model;
|
|
50
|
+
}
|
|
51
|
+
let initialSession;
|
|
52
|
+
const resumeId = takeFlagValue(args, "resume") ?? takeFlagValue(args, "r");
|
|
53
|
+
if (resumeId) {
|
|
54
|
+
initialSession = loadSessionById(resumeId);
|
|
55
|
+
if (!initialSession) {
|
|
56
|
+
console.error(`Session "${resumeId}" not found.`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const printIndex = args.findIndex((a) => a === "-p" || a === "--print");
|
|
61
|
+
if (printIndex !== -1) {
|
|
62
|
+
const prompt = args[printIndex + 1];
|
|
63
|
+
if (!prompt) {
|
|
64
|
+
console.error("Missing prompt after -p");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
await runHeadless(prompt, args.includes("--yolo"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const initialView = args[0] === "login" ? "login" : undefined;
|
|
71
|
+
// Any other bare argument starts the TUI with that text as the first prompt.
|
|
72
|
+
const initialPrompt = initialView ? undefined : args.find((a) => !a.startsWith("-"));
|
|
73
|
+
// Take over the full terminal: clear the visible screen and home the cursor
|
|
74
|
+
// so the TUI always starts at the top (prior output stays in scrollback).
|
|
75
|
+
if (process.stdout.isTTY) {
|
|
76
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
77
|
+
process.stdout.write("\x1b]0;orbcode\x07");
|
|
78
|
+
}
|
|
79
|
+
render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession }));
|
|
80
|
+
}
|
|
81
|
+
main().catch((error) => {
|
|
82
|
+
console.error(error);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|