@matterailab/orbcode 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +405 -18
- package/dist/api/models.js +19 -3
- package/dist/commands/mcp.js +266 -0
- package/dist/config/settings.js +27 -0
- package/dist/core/agent.js +28 -7
- package/dist/headless.js +18 -0
- package/dist/index.js +9 -0
- package/dist/mcp/auth.js +289 -0
- package/dist/mcp/client.js +132 -0
- package/dist/mcp/config.js +270 -0
- package/dist/mcp/manager.js +277 -0
- package/dist/mcp/types.js +8 -0
- package/dist/memory/loader.js +167 -0
- package/dist/memory/types.js +1 -0
- package/dist/prompts/system.js +26 -7
- package/dist/skills/loader.js +132 -0
- package/dist/skills/types.js +1 -0
- package/dist/tools/executors/skills.js +28 -0
- package/dist/tools/index.js +22 -3
- package/dist/tools/schemas/index.js +6 -3
- package/dist/tools/schemas/use_skill.js +1 -1
- package/dist/ui/App.js +219 -53
- package/dist/ui/components/McpApprovalPrompt.js +61 -0
- package/dist/ui/components/McpAuthScreen.js +55 -0
- package/dist/ui/components/McpPicker.js +262 -0
- package/dist/ui/components/ModelPicker.js +2 -2
- package/dist/ui/components/StatusBar.js +28 -9
- package/dist/utils/clipboard.js +45 -0
- package/package.json +2 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { callMcpTool, connectMcpServer } from "./client.js";
|
|
2
|
+
import { configPathForScope, loadMcpConfig } from "./config.js";
|
|
3
|
+
import { hasStoredAuth, isOAuthConfig } from "./auth.js";
|
|
4
|
+
/**
|
|
5
|
+
* Owns the lifecycle of all MCP server connections for a session.
|
|
6
|
+
*
|
|
7
|
+
* On `start()`, it loads the merged config, filters by the enabled/disabled
|
|
8
|
+
* lists, connects to each enabled server in parallel, and exposes the union of
|
|
9
|
+
* their tools as OpenAI-compatible tool definitions. The agent calls
|
|
10
|
+
* `callTool()` for any `mcp__*` tool name; unknown names return an error.
|
|
11
|
+
*/
|
|
12
|
+
export class McpManager {
|
|
13
|
+
cwd;
|
|
14
|
+
connections = new Map();
|
|
15
|
+
configs = new Map();
|
|
16
|
+
states = new Map();
|
|
17
|
+
/** server names the user has explicitly disabled (persisted in settings). */
|
|
18
|
+
disabled;
|
|
19
|
+
/** server names the user has explicitly enabled (for unapproved project servers). */
|
|
20
|
+
enabled;
|
|
21
|
+
/** project-scope server names that require user approval before connecting. */
|
|
22
|
+
pendingApproval = new Set();
|
|
23
|
+
started = false;
|
|
24
|
+
constructor(cwd, disabled, enabled) {
|
|
25
|
+
this.cwd = cwd;
|
|
26
|
+
this.disabled = new Set(disabled);
|
|
27
|
+
this.enabled = new Set(enabled);
|
|
28
|
+
}
|
|
29
|
+
/** Load config and connect to all approved, non-disabled servers. */
|
|
30
|
+
async start() {
|
|
31
|
+
if (this.started)
|
|
32
|
+
return this.snapshot();
|
|
33
|
+
this.started = true;
|
|
34
|
+
const { servers } = loadMcpConfig(this.cwd);
|
|
35
|
+
this.configs.clear();
|
|
36
|
+
this.states.clear();
|
|
37
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
38
|
+
this.configs.set(name, cfg);
|
|
39
|
+
this.states.set(name, {
|
|
40
|
+
name,
|
|
41
|
+
scope: cfg.scope,
|
|
42
|
+
status: "disabled",
|
|
43
|
+
toolCount: 0,
|
|
44
|
+
disabled: this.disabled.has(name),
|
|
45
|
+
detail: undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Project-scope servers need explicit approval (they ship in the repo).
|
|
49
|
+
// User/local-scope servers are trusted (the user wrote them).
|
|
50
|
+
const toConnect = [];
|
|
51
|
+
for (const [name, cfg] of this.configs) {
|
|
52
|
+
if (this.disabled.has(name))
|
|
53
|
+
continue;
|
|
54
|
+
if (cfg.scope === "project" && !this.enabled.has(name)) {
|
|
55
|
+
this.pendingApproval.add(name);
|
|
56
|
+
this.states.get(name).status = "disabled";
|
|
57
|
+
this.states.get(name).detail = "awaiting approval";
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
toConnect.push(name);
|
|
61
|
+
}
|
|
62
|
+
// At startup, skip the OAuth browser flow for servers with no stored
|
|
63
|
+
// tokens — mark them needs-auth so the user can auth explicitly from /mcp.
|
|
64
|
+
await Promise.all(toConnect.map((name) => this.connectOne(name, { skipUnauthOAuth: true })));
|
|
65
|
+
return this.snapshot();
|
|
66
|
+
}
|
|
67
|
+
/** Connect (or reconnect) a single server by name.
|
|
68
|
+
* When `skipUnauthOAuth` is set, OAuth servers with no stored tokens are
|
|
69
|
+
* marked needs-auth instead of opening a browser (used at startup).
|
|
70
|
+
* When `authIntercept` is provided, the caller controls the OAuth UI
|
|
71
|
+
* (surfacing the auth URL, providing the code via callback or paste).
|
|
72
|
+
* When `forceOAuth` is set, an http/sse server without an explicit `oauth`
|
|
73
|
+
* config is treated as `oauth: true` (auto-detected OAuth — used when the
|
|
74
|
+
* user triggers auth after a 401). */
|
|
75
|
+
async connectOne(name, options) {
|
|
76
|
+
const cfg = this.configs.get(name);
|
|
77
|
+
if (!cfg)
|
|
78
|
+
return;
|
|
79
|
+
const state = this.states.get(name);
|
|
80
|
+
const isRemote = cfg.type === "http" || cfg.type === "sse";
|
|
81
|
+
// At startup, don't open a browser for OAuth servers that haven't been
|
|
82
|
+
// authenticated yet — surface them as needs-auth so the user can trigger
|
|
83
|
+
// auth explicitly from the /mcp picker. This applies to both explicitly
|
|
84
|
+
// configured OAuth servers and any remote server (which may require OAuth
|
|
85
|
+
// we don't know about yet — we'll find out on the first connect attempt).
|
|
86
|
+
if (options?.skipUnauthOAuth && isOAuthConfig(cfg) && !hasStoredAuth(name)) {
|
|
87
|
+
state.status = "needs-auth";
|
|
88
|
+
state.detail = "not authenticated — press a to authenticate";
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
state.status = "connecting";
|
|
92
|
+
state.detail = undefined;
|
|
93
|
+
try {
|
|
94
|
+
const { scope: _scope, ...serverConfig } = cfg;
|
|
95
|
+
// Auto-detect: if forceOAuth is set and this is a remote server without
|
|
96
|
+
// explicit oauth config, inject oauth: true so the auth transport is used.
|
|
97
|
+
if (options?.forceOAuth && isRemote && !isOAuthConfig(cfg)) {
|
|
98
|
+
;
|
|
99
|
+
serverConfig.oauth = true;
|
|
100
|
+
}
|
|
101
|
+
const connection = await connectMcpServer(name, serverConfig, options?.authIntercept);
|
|
102
|
+
this.connections.set(name, connection);
|
|
103
|
+
state.status = "connected";
|
|
104
|
+
state.toolCount = connection.tools.length;
|
|
105
|
+
state.detail = `${connection.tools.length} tool${connection.tools.length === 1 ? "" : "s"}`;
|
|
106
|
+
this.pendingApproval.delete(name);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const msg = error.message;
|
|
110
|
+
// Any remote server that returns 401/unauthorized/invalid_token needs
|
|
111
|
+
// auth — surface as needs-auth regardless of whether `oauth` was
|
|
112
|
+
// explicitly configured (auto-detection, like Claude Code).
|
|
113
|
+
if (isRemote && /unauthorized|auth|401|oauth|invalid_token/i.test(msg)) {
|
|
114
|
+
state.status = "needs-auth";
|
|
115
|
+
state.detail = "authentication required — press a to authenticate";
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
state.status = "failed";
|
|
119
|
+
state.detail = msg;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Disconnect a single server (keeps config; marks it disabled). */
|
|
124
|
+
async disconnectOne(name) {
|
|
125
|
+
const connection = this.connections.get(name);
|
|
126
|
+
if (connection) {
|
|
127
|
+
await connection.close();
|
|
128
|
+
this.connections.delete(name);
|
|
129
|
+
}
|
|
130
|
+
const state = this.states.get(name);
|
|
131
|
+
if (state) {
|
|
132
|
+
state.status = "disabled";
|
|
133
|
+
state.toolCount = 0;
|
|
134
|
+
state.detail = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Enable a project server (approving it) and connect. Uses
|
|
138
|
+
* skipUnauthOAuth so enabling doesn't open a browser — the user auths
|
|
139
|
+
* explicitly from /mcp. */
|
|
140
|
+
async enableServer(name) {
|
|
141
|
+
this.enabled.add(name);
|
|
142
|
+
this.disabled.delete(name);
|
|
143
|
+
const state = this.states.get(name);
|
|
144
|
+
if (state)
|
|
145
|
+
state.disabled = false;
|
|
146
|
+
await this.connectOne(name, { skipUnauthOAuth: true });
|
|
147
|
+
}
|
|
148
|
+
/** Disable a server and disconnect it. */
|
|
149
|
+
async disableServer(name) {
|
|
150
|
+
this.disabled.add(name);
|
|
151
|
+
this.enabled.delete(name);
|
|
152
|
+
const state = this.states.get(name);
|
|
153
|
+
if (state)
|
|
154
|
+
state.disabled = true;
|
|
155
|
+
await this.disconnectOne(name);
|
|
156
|
+
}
|
|
157
|
+
/** Re-authenticate a server in the needs-auth state: disconnect, clear
|
|
158
|
+
* persisted OAuth tokens, then reconnect with `forceOAuth` so even servers
|
|
159
|
+
* without an explicit `oauth` config use the OAuth flow (auto-detect).
|
|
160
|
+
* The `authIntercept` lets the TUI surface the auth URL and provide the
|
|
161
|
+
* code via callback or paste. */
|
|
162
|
+
async reauthServer(name, authIntercept) {
|
|
163
|
+
await this.disconnectOne(name);
|
|
164
|
+
try {
|
|
165
|
+
const fs = await import("node:fs");
|
|
166
|
+
const path = await import("node:path");
|
|
167
|
+
const { getConfigDir } = await import("../config/settings.js");
|
|
168
|
+
fs.unlinkSync(path.join(getConfigDir(), "mcp-auth", `${name}.json`));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// best-effort; no stored tokens to clear
|
|
172
|
+
}
|
|
173
|
+
this.disabled.delete(name);
|
|
174
|
+
await this.connectOne(name, { authIntercept, forceOAuth: true });
|
|
175
|
+
}
|
|
176
|
+
/** Close every connection. Call on session end / exit. */
|
|
177
|
+
async stop() {
|
|
178
|
+
await Promise.all([...this.connections.values()].map((c) => c.close().catch(() => { })));
|
|
179
|
+
this.connections.clear();
|
|
180
|
+
this.started = false;
|
|
181
|
+
}
|
|
182
|
+
/** All tools from all connected servers, as OpenAI tool definitions. */
|
|
183
|
+
getTools() {
|
|
184
|
+
const tools = [];
|
|
185
|
+
for (const connection of this.connections.values()) {
|
|
186
|
+
for (const tool of connection.tools) {
|
|
187
|
+
tools.push({
|
|
188
|
+
type: "function",
|
|
189
|
+
function: {
|
|
190
|
+
name: tool.name,
|
|
191
|
+
description: tool.description ?? `MCP tool ${tool.originalName} from server ${tool.server}`,
|
|
192
|
+
parameters: tool.inputSchema ?? {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return tools;
|
|
201
|
+
}
|
|
202
|
+
/** Route an `mcp__<server>__<tool>` call to the right connection. */
|
|
203
|
+
async callTool(toolName, args) {
|
|
204
|
+
const match = /^mcp__([^_]+)__(.+)$/.exec(toolName);
|
|
205
|
+
if (!match) {
|
|
206
|
+
return { text: `Invalid MCP tool name: ${toolName}`, isError: true };
|
|
207
|
+
}
|
|
208
|
+
const [, server, originalName] = match;
|
|
209
|
+
const connection = this.connections.get(server);
|
|
210
|
+
if (!connection) {
|
|
211
|
+
return { text: `MCP server "${server}" is not connected.`, isError: true };
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
return await callMcpTool(connection, originalName, args);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return { text: `MCP tool ${toolName} failed: ${error.message}`, isError: true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** True if `toolName` is an MCP tool managed by this manager. */
|
|
221
|
+
hasTool(toolName) {
|
|
222
|
+
return /^mcp__[^_]+__/.test(toolName);
|
|
223
|
+
}
|
|
224
|
+
/** Current snapshot for the TUI / status display. */
|
|
225
|
+
snapshot() {
|
|
226
|
+
return {
|
|
227
|
+
servers: [...this.states.values()],
|
|
228
|
+
tools: [...this.connections.values()].flatMap((c) => c.tools),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Project-scope server names awaiting the user's approval. */
|
|
232
|
+
getPendingApproval() {
|
|
233
|
+
return [...this.pendingApproval];
|
|
234
|
+
}
|
|
235
|
+
/** Persisted enabled list (for saving to settings). */
|
|
236
|
+
getEnabled() {
|
|
237
|
+
return [...this.enabled];
|
|
238
|
+
}
|
|
239
|
+
/** Persisted disabled list (for saving to settings). */
|
|
240
|
+
getDisabled() {
|
|
241
|
+
return [...this.disabled];
|
|
242
|
+
}
|
|
243
|
+
/** All configured server names. */
|
|
244
|
+
getServerNames() {
|
|
245
|
+
return [...this.configs.keys()];
|
|
246
|
+
}
|
|
247
|
+
/** A config by name (for the TUI detail view). */
|
|
248
|
+
getConfig(name) {
|
|
249
|
+
return this.configs.get(name);
|
|
250
|
+
}
|
|
251
|
+
/** The file path where a server's config lives (for the TUI detail view). */
|
|
252
|
+
getConfigPath(name) {
|
|
253
|
+
const cfg = this.configs.get(name);
|
|
254
|
+
if (!cfg)
|
|
255
|
+
return undefined;
|
|
256
|
+
return configPathForScope(this.cwd, cfg.scope);
|
|
257
|
+
}
|
|
258
|
+
/** True if a server uses OAuth (explicitly configured or auto-detected from
|
|
259
|
+
* a 401/needs-auth state). Remote servers in needs-auth state are treated
|
|
260
|
+
* as OAuth so the detail panel shows "not authenticated". */
|
|
261
|
+
isOAuthServer(name) {
|
|
262
|
+
const cfg = this.configs.get(name);
|
|
263
|
+
if (!cfg)
|
|
264
|
+
return false;
|
|
265
|
+
if (isOAuthConfig(cfg))
|
|
266
|
+
return true;
|
|
267
|
+
// Auto-detect: a remote server in needs-auth state requires OAuth.
|
|
268
|
+
const state = this.states.get(name);
|
|
269
|
+
if (state?.status === "needs-auth" && (cfg.type === "http" || cfg.type === "sse"))
|
|
270
|
+
return true;
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
/** True if a server has persisted OAuth tokens (already authenticated). */
|
|
274
|
+
isAuthenticated(name) {
|
|
275
|
+
return hasStoredAuth(name);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server configuration and connection types.
|
|
3
|
+
*
|
|
4
|
+
* These are OrbCode's own types (not re-exported from the SDK) so the config
|
|
5
|
+
* layer stays decoupled from the SDK's internal schema shape. The connection
|
|
6
|
+
* manager converts these into SDK transports/clients.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getConfigDir } from "../config/settings.js";
|
|
5
|
+
/**
|
|
6
|
+
* AGENTS.md memory loader.
|
|
7
|
+
*
|
|
8
|
+
* OrbCode's memory system mirrors Claude Code's CLAUDE.md discovery, but uses
|
|
9
|
+
* the open `AGENTS.md` filename (the cross-tool standard) instead of a
|
|
10
|
+
* vendor-specific name. Files are loaded in this order (lowest precedence
|
|
11
|
+
* first), with closer-to-cwd and higher-precedence types winning:
|
|
12
|
+
*
|
|
13
|
+
* 1. User memory: ~/.orbcode/AGENTS.md
|
|
14
|
+
* 2. Project memory: AGENTS.md and .orbcode/AGENTS.md in cwd and every
|
|
15
|
+
* parent directory (closer-to-cwd wins)
|
|
16
|
+
* 3. Local memory: AGENTS.local.md in cwd and parents (highest precedence)
|
|
17
|
+
*
|
|
18
|
+
* Memory files support `@path` include directives (relative, `~/`, or
|
|
19
|
+
* absolute) to pull in other files. Includes are resolved recursively up to a
|
|
20
|
+
* small depth limit, with cycle detection.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_INCLUDE_DEPTH = 5;
|
|
23
|
+
const MAX_TOTAL_CHARS = 200_000;
|
|
24
|
+
/** Read a file as UTF-8, returning undefined if it's missing or unreadable. */
|
|
25
|
+
function readText(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(filePath, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Resolve an @include path relative to the including file's directory. */
|
|
34
|
+
function resolveInclude(ref, baseDir) {
|
|
35
|
+
if (ref.startsWith("~/"))
|
|
36
|
+
return path.join(os.homedir(), ref.slice(2));
|
|
37
|
+
if (path.isAbsolute(ref))
|
|
38
|
+
return ref;
|
|
39
|
+
return path.resolve(baseDir, ref);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Extract `@path` references from leaf text (not inside code spans/blocks).
|
|
43
|
+
* A simple, conservative parser: skips fenced code blocks and inline code.
|
|
44
|
+
*/
|
|
45
|
+
function extractIncludes(content, baseDir) {
|
|
46
|
+
const refs = [];
|
|
47
|
+
let inFence = false;
|
|
48
|
+
for (const line of content.split(/\r?\n/)) {
|
|
49
|
+
if (/^```/.test(line.trim())) {
|
|
50
|
+
inFence = !inFence;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (inFence)
|
|
54
|
+
continue;
|
|
55
|
+
// Strip inline code spans before scanning for @refs.
|
|
56
|
+
const stripped = line.replace(/`[^`]*`/g, "");
|
|
57
|
+
const regex = /(?:^|\s)@((?:\.\/|~\/|\/)?[^\s]+)/g;
|
|
58
|
+
let match;
|
|
59
|
+
while ((match = regex.exec(stripped)) !== null) {
|
|
60
|
+
const ref = match[1].replace(/\\ /g, " ");
|
|
61
|
+
// Avoid matching emails, @mentions, etc.
|
|
62
|
+
if (!/^[A-Za-z0-9._~/-]/.test(ref))
|
|
63
|
+
continue;
|
|
64
|
+
refs.push(resolveInclude(ref, baseDir));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return refs;
|
|
68
|
+
}
|
|
69
|
+
/** Recursively load a memory file and all its @includes. */
|
|
70
|
+
function loadWithIncludes(filePath, type, processed, depth) {
|
|
71
|
+
const real = safeRealpath(filePath);
|
|
72
|
+
if (processed.has(real) || depth >= MAX_INCLUDE_DEPTH)
|
|
73
|
+
return [];
|
|
74
|
+
const raw = readText(filePath);
|
|
75
|
+
if (raw === undefined)
|
|
76
|
+
return [];
|
|
77
|
+
processed.add(real);
|
|
78
|
+
const baseDir = path.dirname(filePath);
|
|
79
|
+
const includePaths = extractIncludes(raw, baseDir);
|
|
80
|
+
const results = [];
|
|
81
|
+
// Includes come first (so they appear before the including file's content).
|
|
82
|
+
for (const includePath of includePaths) {
|
|
83
|
+
results.push(...loadWithIncludes(includePath, type, processed, depth + 1));
|
|
84
|
+
}
|
|
85
|
+
results.push({ path: filePath, content: raw, type });
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
/** realpathSync that never throws (falls back to the input path). */
|
|
89
|
+
function safeRealpath(p) {
|
|
90
|
+
try {
|
|
91
|
+
return fs.realpathSync(p);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return path.resolve(p);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Walk from cwd up to root, collecting directories (root last). */
|
|
98
|
+
function ancestorDirs(cwd) {
|
|
99
|
+
const dirs = [];
|
|
100
|
+
let current = path.resolve(cwd);
|
|
101
|
+
const root = path.parse(current).root;
|
|
102
|
+
while (current !== root) {
|
|
103
|
+
dirs.push(current);
|
|
104
|
+
const parent = path.dirname(current);
|
|
105
|
+
if (parent === current)
|
|
106
|
+
break;
|
|
107
|
+
current = parent;
|
|
108
|
+
}
|
|
109
|
+
return dirs;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Load all AGENTS.md memory files, in precedence order (lowest first). The
|
|
113
|
+
* caller concatenates them in order so higher-precedence files appear last
|
|
114
|
+
* (and thus get more weight from the model).
|
|
115
|
+
*/
|
|
116
|
+
export function loadMemoryFiles(cwd = process.cwd()) {
|
|
117
|
+
const processed = new Set();
|
|
118
|
+
const files = [];
|
|
119
|
+
// 1. User memory (~/.orbcode/AGENTS.md)
|
|
120
|
+
files.push(...loadWithIncludes(path.join(getConfigDir(), "AGENTS.md"), "user", processed, 0));
|
|
121
|
+
// 2. Project memory (AGENTS.md + .orbcode/AGENTS.md), root -> cwd
|
|
122
|
+
for (const dir of ancestorDirs(cwd).reverse()) {
|
|
123
|
+
files.push(...loadWithIncludes(path.join(dir, "AGENTS.md"), "project", processed, 0));
|
|
124
|
+
files.push(...loadWithIncludes(path.join(dir, ".orbcode", "AGENTS.md"), "project", processed, 0));
|
|
125
|
+
}
|
|
126
|
+
// 3. Local memory (AGENTS.local.md), root -> cwd — highest precedence
|
|
127
|
+
for (const dir of ancestorDirs(cwd).reverse()) {
|
|
128
|
+
files.push(...loadWithIncludes(path.join(dir, "AGENTS.local.md"), "local", processed, 0));
|
|
129
|
+
}
|
|
130
|
+
return files;
|
|
131
|
+
}
|
|
132
|
+
/** Render the memory files into a single system-prompt section. */
|
|
133
|
+
export function renderMemorySection(files) {
|
|
134
|
+
const valid = files.filter((f) => f.content.trim());
|
|
135
|
+
if (valid.length === 0)
|
|
136
|
+
return "";
|
|
137
|
+
const parts = [
|
|
138
|
+
"# Project & User Instructions (AGENTS.md)",
|
|
139
|
+
"",
|
|
140
|
+
"Instructions from AGENTS.md files are shown below. Adhere to these instructions; they override default behavior. Treat them as authoritative guidance from the user about this codebase.",
|
|
141
|
+
"",
|
|
142
|
+
];
|
|
143
|
+
let total = 0;
|
|
144
|
+
for (const file of valid) {
|
|
145
|
+
if (total >= MAX_TOTAL_CHARS) {
|
|
146
|
+
parts.push("… (remaining memory files truncated to stay within context budget)");
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const label = labelFor(file);
|
|
150
|
+
parts.push(`## ${label}: \`${file.path}\``);
|
|
151
|
+
parts.push("");
|
|
152
|
+
parts.push(file.content.trim());
|
|
153
|
+
parts.push("");
|
|
154
|
+
total += file.content.length;
|
|
155
|
+
}
|
|
156
|
+
return parts.join("\n");
|
|
157
|
+
}
|
|
158
|
+
function labelFor(file) {
|
|
159
|
+
switch (file.type) {
|
|
160
|
+
case "user":
|
|
161
|
+
return "User memory";
|
|
162
|
+
case "project":
|
|
163
|
+
return "Project memory";
|
|
164
|
+
case "local":
|
|
165
|
+
return "Local memory";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/prompts/system.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
2
|
import { getShell } from "../utils/shell.js";
|
|
3
|
+
import { renderMemorySection } from "../memory/loader.js";
|
|
4
|
+
import { renderSkillCatalog } from "../skills/loader.js";
|
|
3
5
|
// Role definition and tool guide ported verbatim from the Orbital extension
|
|
4
6
|
// (agent mode roleDefinition + applyDiffToolDescription). Only the system
|
|
5
7
|
// information section is adapted from the IDE to the CLI environment.
|
|
@@ -51,6 +53,18 @@ Bias towards not asking the user for help if you can find the answer yourself.
|
|
|
51
53
|
|
|
52
54
|
Code chunks that you receive (via tool calls or from user) may include inline line numbers in the form LINE_NUMBER|LINE_CONTENT. Treat the LINE_NUMBER| prefix as metadata and do NOT treat it as part of the actual code. LINE_NUMBER is right-aligned number padded with spaces to 6 characters.
|
|
53
55
|
|
|
56
|
+
# Project & User Instructions (AGENTS.md)
|
|
57
|
+
|
|
58
|
+
Your system prompt may include an "Project & User Instructions (AGENTS.md)" section. These are instructions from AGENTS.md files in the user's home directory, the project root, and parent directories. They contain project-specific guidance: build commands, code style, architecture notes, conventions. Treat them as authoritative instructions from the user about this codebase and follow them exactly. They override default behavior.
|
|
59
|
+
|
|
60
|
+
# Skills
|
|
61
|
+
|
|
62
|
+
Your system prompt may include an "Available Skills" section listing skills by name with a description and when-to-use hint. Skills are reusable instruction sets stored in ~/.orbcode/skills/ and .orbcode/skills/. When a task matches a skill's when-to-use condition, invoke the \`use_skill\` tool with the skill's name to load its full instructions, then follow them for the current task.
|
|
63
|
+
|
|
64
|
+
# MCP Tools
|
|
65
|
+
|
|
66
|
+
Tools whose names start with \`mcp__\` are provided by external MCP servers the user has configured. They work exactly like native tools — call them with the standard tool call format when the task requires their capabilities. Their descriptions and parameter schemas come from the MCP servers.
|
|
67
|
+
|
|
54
68
|
CRITICAL: For any task, small or big, you will always and always use the update_todo_list tool to create the TODO list, always keep is upto date with updates to the status and updating/editing the list as needed.`;
|
|
55
69
|
const toolGuide = `
|
|
56
70
|
Common tool calls and explanations
|
|
@@ -373,11 +387,16 @@ function getSystemInfoSection(cwd) {
|
|
|
373
387
|
|
|
374
388
|
The Current Workspace Directory is the directory the user launched OrbCode CLI from, and is therefore the default directory for all tool operations. Commands run in the current workspace directory unless a different cwd is passed; changing directories inside a command does not modify the workspace directory. When the user initially gives you a task, a listing of filepaths in the current workspace directory will be included in the Environment Details section. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.`;
|
|
375
389
|
}
|
|
376
|
-
export function buildSystemPrompt(cwd) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
390
|
+
export function buildSystemPrompt(cwd, options = {}) {
|
|
391
|
+
const memorySection = options.memoryFiles ? renderMemorySection(options.memoryFiles) : "";
|
|
392
|
+
const skillSection = options.skills ? renderSkillCatalog(options.skills) : "";
|
|
393
|
+
return [
|
|
394
|
+
roleDefinition,
|
|
395
|
+
toolGuide,
|
|
396
|
+
getSystemInfoSection(cwd),
|
|
397
|
+
memorySection,
|
|
398
|
+
skillSection,
|
|
399
|
+
]
|
|
400
|
+
.filter(Boolean)
|
|
401
|
+
.join("\n\n");
|
|
383
402
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getConfigDir } from "../config/settings.js";
|
|
4
|
+
/**
|
|
5
|
+
* Skill loader.
|
|
6
|
+
*
|
|
7
|
+
* Skills are markdown files that inject specialized instructions when the model
|
|
8
|
+
* invokes the `use_skill` tool. Discovery mirrors Claude Code's layout:
|
|
9
|
+
*
|
|
10
|
+
* - User skills: ~/.orbcode/skills/<name>/SKILL.md
|
|
11
|
+
* - Project skills: .orbcode/skills/<name>/SKILL.md (in cwd and parents)
|
|
12
|
+
*
|
|
13
|
+
* Only the directory format (`<name>/SKILL.md`) is supported, matching Claude
|
|
14
|
+
* Code's current skills/ convention. Project skills override user skills on
|
|
15
|
+
* name collisions (closer-to-cwd wins).
|
|
16
|
+
*/
|
|
17
|
+
/** Parse a leading `---\n...---\n` YAML frontmatter block. */
|
|
18
|
+
export function parseFrontmatter(raw) {
|
|
19
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
20
|
+
if (!match)
|
|
21
|
+
return { frontmatter: {}, content: raw };
|
|
22
|
+
const [, yamlBlock, body] = match;
|
|
23
|
+
const frontmatter = {};
|
|
24
|
+
for (const line of yamlBlock.split(/\r?\n/)) {
|
|
25
|
+
const idx = line.indexOf(":");
|
|
26
|
+
if (idx === -1)
|
|
27
|
+
continue;
|
|
28
|
+
const key = line.slice(0, idx).trim();
|
|
29
|
+
const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
|
|
30
|
+
if (key)
|
|
31
|
+
frontmatter[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return { frontmatter, content: body.trimStart() };
|
|
34
|
+
}
|
|
35
|
+
/** Extract a description from the first markdown paragraph if frontmatter lacks one. */
|
|
36
|
+
function descriptionFromBody(content) {
|
|
37
|
+
const text = content
|
|
38
|
+
.replace(/^#+\s.*$/gm, "")
|
|
39
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
40
|
+
.trim();
|
|
41
|
+
const firstPara = text.split(/\n\s*\n/)[0] ?? "";
|
|
42
|
+
return firstPara.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
43
|
+
}
|
|
44
|
+
/** Load a single skill from a `<dir>/SKILL.md` path. */
|
|
45
|
+
function loadSkill(skillDir, source) {
|
|
46
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = fs.readFileSync(skillFile, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const name = path.basename(skillDir);
|
|
55
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
description: frontmatter.description || descriptionFromBody(content),
|
|
59
|
+
whenToUse: frontmatter.when_to_use,
|
|
60
|
+
content,
|
|
61
|
+
source,
|
|
62
|
+
dir: skillDir,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Load all skills from a `skills/` directory (each subdirectory is one skill). */
|
|
66
|
+
function loadSkillsDir(skillsDir, source) {
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const skills = [];
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
77
|
+
continue;
|
|
78
|
+
const skill = loadSkill(path.join(skillsDir, entry.name), source);
|
|
79
|
+
if (skill)
|
|
80
|
+
skills.push(skill);
|
|
81
|
+
}
|
|
82
|
+
return skills;
|
|
83
|
+
}
|
|
84
|
+
/** Walk from cwd up to root, collecting `.orbcode/skills` dirs (root last). */
|
|
85
|
+
function projectSkillsDirs(cwd) {
|
|
86
|
+
const dirs = [];
|
|
87
|
+
let current = path.resolve(cwd);
|
|
88
|
+
const root = path.parse(current).root;
|
|
89
|
+
while (current !== root) {
|
|
90
|
+
dirs.push(path.join(current, ".orbcode", "skills"));
|
|
91
|
+
const parent = path.dirname(current);
|
|
92
|
+
if (parent === current)
|
|
93
|
+
break;
|
|
94
|
+
current = parent;
|
|
95
|
+
}
|
|
96
|
+
return dirs;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Load all skills. Project skills (closer-to-cwd) override user skills on name
|
|
100
|
+
* collisions. Returns a map keyed by skill name.
|
|
101
|
+
*/
|
|
102
|
+
export function loadSkills(cwd = process.cwd()) {
|
|
103
|
+
const skills = new Map();
|
|
104
|
+
// User skills first (lowest precedence).
|
|
105
|
+
for (const skill of loadSkillsDir(path.join(getConfigDir(), "skills"), "user")) {
|
|
106
|
+
skills.set(skill.name, skill);
|
|
107
|
+
}
|
|
108
|
+
// Project skills, root -> cwd so cwd wins.
|
|
109
|
+
for (const dir of projectSkillsDirs(cwd).reverse()) {
|
|
110
|
+
for (const skill of loadSkillsDir(dir, "project")) {
|
|
111
|
+
skills.set(skill.name, skill);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return skills;
|
|
115
|
+
}
|
|
116
|
+
/** Render the skill catalog for the system prompt (names + when_to_use). */
|
|
117
|
+
export function renderSkillCatalog(skills) {
|
|
118
|
+
if (skills.size === 0)
|
|
119
|
+
return "";
|
|
120
|
+
const lines = ["# Available Skills", ""];
|
|
121
|
+
for (const skill of skills.values()) {
|
|
122
|
+
const when = skill.whenToUse ? ` — use when: ${skill.whenToUse}` : "";
|
|
123
|
+
lines.push(`- \`${skill.name}\`: ${skill.description}${when}`);
|
|
124
|
+
}
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push("Use the `use_skill` tool with a `skill_name` to load a skill's full instructions.");
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
/** Substitute ${SKILL_DIR} in a skill's content with its directory path. */
|
|
130
|
+
export function renderSkillContent(skill) {
|
|
131
|
+
return skill.content.replace(/\$\{SKILL_DIR\}/g, skill.dir);
|
|
132
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { loadSkills, renderSkillContent } from "../../skills/loader.js";
|
|
2
|
+
/**
|
|
3
|
+
* `use_skill` executor: loads a skill's full markdown instructions and returns
|
|
4
|
+
* them to the model so it can follow the skill's guidance for the current task.
|
|
5
|
+
*
|
|
6
|
+
* Skills are discovered from ~/.orbcode/skills/ and .orbcode/skills/ (see the
|
|
7
|
+
* skills loader). The model sees the catalog in the system prompt and invokes
|
|
8
|
+
* this tool with a `skill_name`.
|
|
9
|
+
*/
|
|
10
|
+
export async function useSkill(args, context) {
|
|
11
|
+
const skillName = String(args.skill_name ?? "").trim();
|
|
12
|
+
if (!skillName) {
|
|
13
|
+
return { text: "No skill_name provided.", isError: true };
|
|
14
|
+
}
|
|
15
|
+
const skills = loadSkills(context.cwd);
|
|
16
|
+
const skill = skills.get(skillName);
|
|
17
|
+
if (!skill) {
|
|
18
|
+
const available = [...skills.keys()].join(", ") || "(none)";
|
|
19
|
+
return {
|
|
20
|
+
text: `Skill "${skillName}" not found. Available skills: ${available}`,
|
|
21
|
+
isError: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const content = renderSkillContent(skill);
|
|
25
|
+
return {
|
|
26
|
+
text: `# Skill: ${skill.name}\n\n${content}\n\n---\nFollow this skill's instructions for the current task.`,
|
|
27
|
+
};
|
|
28
|
+
}
|