@orbit-intelligence/orbit-agent 0.3.12

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.
Files changed (80) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +23 -0
  3. package/bin/orbit +26 -0
  4. package/dist/prompts/system.js +80 -0
  5. package/dist/src/cli/args.js +145 -0
  6. package/dist/src/cli/orchestrate.js +100 -0
  7. package/dist/src/cli/run.js +393 -0
  8. package/dist/src/config/config-schema.js +151 -0
  9. package/dist/src/config/index.js +57 -0
  10. package/dist/src/core/agent/agent-loop.js +402 -0
  11. package/dist/src/core/agents/delegate.js +120 -0
  12. package/dist/src/core/agents/orchestrator.js +58 -0
  13. package/dist/src/core/agents/prompts.js +82 -0
  14. package/dist/src/core/agents/types.js +1 -0
  15. package/dist/src/core/context/context-manager.js +167 -0
  16. package/dist/src/core/events.js +23 -0
  17. package/dist/src/core/llm/http.js +207 -0
  18. package/dist/src/core/llm/index.js +93 -0
  19. package/dist/src/core/llm/models.js +228 -0
  20. package/dist/src/core/llm/providers/gemini.js +211 -0
  21. package/dist/src/core/llm/providers/openai-compat.js +31 -0
  22. package/dist/src/core/llm/router.js +125 -0
  23. package/dist/src/core/llm/secrets.js +121 -0
  24. package/dist/src/core/llm/types.js +10 -0
  25. package/dist/src/core/orchestration/dispatcher.js +74 -0
  26. package/dist/src/core/orchestration/messenger.js +139 -0
  27. package/dist/src/core/orchestration/roles.js +129 -0
  28. package/dist/src/core/orchestration/runtime.js +122 -0
  29. package/dist/src/core/orchestration/session.js +204 -0
  30. package/dist/src/core/orchestration/shared-context.js +88 -0
  31. package/dist/src/core/orchestration/tools.js +187 -0
  32. package/dist/src/core/orchestration/types.js +3 -0
  33. package/dist/src/core/permissions/index.js +58 -0
  34. package/dist/src/core/project-context.js +115 -0
  35. package/dist/src/core/skill-loader.js +31 -0
  36. package/dist/src/core/tools/edit.js +142 -0
  37. package/dist/src/core/tools/filesystem.js +203 -0
  38. package/dist/src/core/tools/git.js +138 -0
  39. package/dist/src/core/tools/registry.js +73 -0
  40. package/dist/src/core/tools/search.js +90 -0
  41. package/dist/src/core/tools/shell.js +65 -0
  42. package/dist/src/core/tools/types.js +6 -0
  43. package/dist/src/core/types.js +3 -0
  44. package/dist/src/index.js +11 -0
  45. package/dist/src/session/event-log.js +55 -0
  46. package/dist/src/session/store.js +76 -0
  47. package/dist/src/setup/wizard.js +401 -0
  48. package/dist/src/tui/InkApp.js +67 -0
  49. package/dist/src/tui/ansi.js +142 -0
  50. package/dist/src/tui/app.js +768 -0
  51. package/dist/src/tui/colors.js +13 -0
  52. package/dist/src/tui/components/AgentDock.js +46 -0
  53. package/dist/src/tui/components/Composer.js +35 -0
  54. package/dist/src/tui/components/Header.js +23 -0
  55. package/dist/src/tui/components/ModelPicker.js +23 -0
  56. package/dist/src/tui/components/PermissionModal.js +29 -0
  57. package/dist/src/tui/components/SlashMenu.js +15 -0
  58. package/dist/src/tui/components/StatusLine.js +27 -0
  59. package/dist/src/tui/components/Transcript.js +31 -0
  60. package/dist/src/tui/components/WorkingStatus.js +29 -0
  61. package/dist/src/tui/components/input.js +246 -0
  62. package/dist/src/tui/components/markdown.js +384 -0
  63. package/dist/src/tui/components/message.js +105 -0
  64. package/dist/src/tui/context.js +8 -0
  65. package/dist/src/tui/geometry.js +40 -0
  66. package/dist/src/tui/renderer.js +116 -0
  67. package/dist/src/tui/rows.js +247 -0
  68. package/dist/src/tui/scheduler.js +32 -0
  69. package/dist/src/tui/store.js +127 -0
  70. package/dist/src/tui/style.js +151 -0
  71. package/dist/src/tui/term.js +309 -0
  72. package/dist/src/tui/text.js +104 -0
  73. package/dist/src/tui/themes/index.js +15 -0
  74. package/dist/src/tui/themes/palettes.js +137 -0
  75. package/dist/src/tui/themes/types.js +1 -0
  76. package/dist/src/utils/diff.js +161 -0
  77. package/dist/src/utils/platform.js +71 -0
  78. package/dist/src/utils/signals.js +26 -0
  79. package/dist/src/version.js +4 -0
  80. package/package.json +71 -0
@@ -0,0 +1,31 @@
1
+ import { toolError, okToolOk } from './tools/types.js';
2
+ /**
3
+ * load_project_skill — exposes the bodies of user-authored skills from
4
+ * `.orbit/skills/` (or `.agents/skills/`) so the model can load instructions
5
+ * on demand instead of them inflating every single prompt.
6
+ */
7
+ export function createSkillLoaderTool(project) {
8
+ return {
9
+ name: 'load_project_skill',
10
+ description: 'Load the full instructions of a named project skill (from .orbit/skills/*.md). Returns the skill body verbatim. Skills documented in your system prompt — load them before starting matching work.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ name: { type: 'string', description: 'Skill name (markdown filename without the .md suffix).' },
15
+ },
16
+ required: ['name'],
17
+ },
18
+ async run(args, ctx) {
19
+ void ctx;
20
+ const name = String(args.name ?? '').trim();
21
+ if (!name)
22
+ return toolError('load_project_skill requires a skill name.');
23
+ const body = project.skillBodies[name];
24
+ if (body === undefined) {
25
+ const available = project.skills.map((s) => s.name).join(', ') || '(none found)';
26
+ return toolError(`unknown skill "${name}". Available: ${available}`);
27
+ }
28
+ return okToolOk(body);
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,142 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { resolve, relative, basename } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { toolError } from './types.js';
5
+ function resolvePath(p, cwd) {
6
+ if (p === '~')
7
+ return homedir();
8
+ if (p.startsWith('~/'))
9
+ return joinPath(homedir(), p.slice(2));
10
+ return resolve(cwd, p);
11
+ }
12
+ function joinPath(a, b) {
13
+ return `${a.replace(/\/$/, '')}/${b}`;
14
+ }
15
+ function countOccurrences(haystack, needle) {
16
+ let count = 0;
17
+ let idx = 0;
18
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
19
+ count++;
20
+ idx += needle.length;
21
+ if (count > 1)
22
+ return count;
23
+ }
24
+ return count;
25
+ }
26
+ /**
27
+ * edit_file — a surgical alternative to write_file. Prefers exact string
28
+ * replacement over full rewrites: the model only sends the changed fragment,
29
+ * so it preserves style and reduces the chance of clobbering unrelated code.
30
+ *
31
+ * Two modes:
32
+ * 1. old_string/new_string — replace the single unique occurrence of a substring.
33
+ * 2. from_line/to_line/new_string — replace an inclusive line range.
34
+ */
35
+ export const editFileTool = {
36
+ name: 'edit_file',
37
+ description: 'Apply a targeted edit to an existing text file. Use this instead of write_file for small changes: it preserves the rest of the file. Provide old_string + new_string (unique substring) to make an exact replacement, or from_line/to_line + new_string to replace a line range. Returns a compact diff of what changed.',
38
+ parameters: {
39
+ type: 'object',
40
+ properties: {
41
+ path: { type: 'string', description: 'Path to the file to edit (relative or absolute).' },
42
+ old_string: { type: 'string', description: 'Exact text to find. Must appear exactly once. Omit when using line ranges.' },
43
+ new_string: { type: 'string', description: 'Replacement text.' },
44
+ from_line: { type: 'number', description: '1-based first line to replace (requires to_line).' },
45
+ to_line: { type: 'number', description: '1-based last line to replace (inclusive, requires from_line).' },
46
+ },
47
+ required: ['path'],
48
+ },
49
+ async run(args, ctx) {
50
+ if (!ctx.canWrite)
51
+ return toolError('filesystem is read-only in this context.');
52
+ const p = resolvePath(String(args.path ?? ''), ctx.cwd);
53
+ const newString = String(args.new_string ?? '');
54
+ const oldString = typeof args.old_string === 'string' ? args.old_string : '';
55
+ const fromLine = typeof args.from_line === 'number' ? args.from_line : undefined;
56
+ const toLine = typeof args.to_line === 'number' ? args.to_line : undefined;
57
+ if (oldString && (fromLine !== undefined || toLine !== undefined)) {
58
+ return toolError('provide either old_string OR a line range, not both.');
59
+ }
60
+ if (!oldString && (fromLine === undefined || toLine === undefined)) {
61
+ return toolError('provide old_string (with new_string) or a from_line/to_line range.');
62
+ }
63
+ let content;
64
+ try {
65
+ content = await readFile(p, 'utf8');
66
+ }
67
+ catch (err) {
68
+ return toolError(`cannot read ${p}: ${err.message}`);
69
+ }
70
+ let before;
71
+ let after;
72
+ let changed = null;
73
+ if (oldString) {
74
+ const occurrences = countOccurrences(content, oldString);
75
+ if (occurrences === 0)
76
+ return toolError(`old_string not found in ${relativePathFor(p, ctx.cwd)}.`);
77
+ if (occurrences > 1)
78
+ return toolError(`old_string matched ${occurrences} times — include more surrounding context to make it unique.`);
79
+ const idx = content.indexOf(oldString);
80
+ before = content.slice(0, idx);
81
+ after = content.slice(idx + oldString.length);
82
+ changed = {
83
+ start: content.slice(0, idx).split('\n').length,
84
+ end: content.slice(0, idx + oldString.length).split('\n').length,
85
+ };
86
+ }
87
+ else {
88
+ const lines = content.split('\n');
89
+ const n = lines.length;
90
+ const from = Math.max(1, fromLine);
91
+ const to = Math.min(n, toLine ?? from);
92
+ if (fromLine > toLine) {
93
+ return toolError('from_line must be <= to_line.');
94
+ }
95
+ before = lines.slice(0, from - 1).join('\n');
96
+ after = lines.slice(to).join('\n');
97
+ changed = { start: from, end: to };
98
+ }
99
+ const next = (before.length === 0 ? '' : before) + newString + after;
100
+ // Preserve a single trailing newline if the original had one.
101
+ const final = content.endsWith('\n') && !next.endsWith('\n') ? next + '\n' : next;
102
+ try {
103
+ const parent = p.split('/').slice(0, -1).join('/') || '.';
104
+ await mkdir(parent, { recursive: true });
105
+ await writeFile(p, final, 'utf8');
106
+ }
107
+ catch (err) {
108
+ return toolError(`write failed: ${err.message}`);
109
+ }
110
+ const rel = relativePathFor(p, ctx.cwd);
111
+ const removedLines = changed.end - changed.start + 1;
112
+ const addedLines = newString.split('\n').length;
113
+ // Compact diff window: show up to 3 lines context before/after.
114
+ const ctxBefore = lastN(before, 3);
115
+ const ctxAfter = firstN(after, 3);
116
+ const diffLines = [
117
+ `Edited ${rel} (lines ${changed.start}–${changed.end}, ${removedLines}→${addedLines} lines):`,
118
+ ];
119
+ for (const l of ctxBefore)
120
+ diffLines.push(` ${l}`);
121
+ for (const l of oldString.split('\n'))
122
+ diffLines.push(`- ${l}`);
123
+ for (const l of newString.split('\n'))
124
+ diffLines.push(`+ ${l}`);
125
+ for (const l of ctxAfter)
126
+ diffLines.push(` ${l}`);
127
+ if (diffLines.length > 40) {
128
+ diffLines.push(` … (${diffLines.length - 40} diff rows elided)`);
129
+ }
130
+ return { content: diffLines.join('\n'), isError: false };
131
+ },
132
+ };
133
+ // --- helpers ---
134
+ function relativePathFor(p, cwd) {
135
+ return relative(cwd, p) || basename(p);
136
+ }
137
+ function lastN(s, n) {
138
+ return s.split('\n').slice(-n);
139
+ }
140
+ function firstN(s, n) {
141
+ return s.split('\n').slice(0, n);
142
+ }
@@ -0,0 +1,203 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, relative, resolve, basename, extname } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { okToolOk, toolError } from './types.js';
6
+ function resolvePath(p, cwd) {
7
+ if (p === '~')
8
+ return homedir();
9
+ if (p.startsWith('~/'))
10
+ return join(homedir(), p.slice(2));
11
+ return resolve(cwd, p);
12
+ }
13
+ export const readFileTool = {
14
+ name: 'read_file',
15
+ description: 'Read the full contents of a text file. Returns raw bytes as text; truncates very large files.',
16
+ parameters: {
17
+ type: 'object',
18
+ properties: {
19
+ path: { type: 'string', description: 'Absolute or relative path to read.' },
20
+ },
21
+ required: ['path'],
22
+ },
23
+ async run(args, ctx) {
24
+ const p = resolvePath(String(args.path ?? ''), ctx.cwd);
25
+ try {
26
+ const content = await readFile(p, 'utf8');
27
+ const maxLen = 200_000;
28
+ const out = content.length > maxLen ? `${content.slice(0, maxLen)}\n… [truncated ${content.length - maxLen} bytes]` : content;
29
+ return okToolOk(out);
30
+ }
31
+ catch (err) {
32
+ return toolError(err.message);
33
+ }
34
+ },
35
+ };
36
+ export const writeFileTool = {
37
+ name: 'write_file',
38
+ description: 'Write content to a file, creating parent directories as needed.',
39
+ parameters: {
40
+ type: 'object',
41
+ properties: {
42
+ path: { type: 'string', description: 'Path to write to.' },
43
+ content: { type: 'string', description: 'Full file content to write.' },
44
+ },
45
+ required: ['path', 'content'],
46
+ },
47
+ async run(args, ctx) {
48
+ if (!ctx.canWrite) {
49
+ return toolError('filesystem is read-only in this context.');
50
+ }
51
+ const p = resolvePath(String(args.path ?? ''), ctx.cwd);
52
+ try {
53
+ const parent = p.split('/').slice(0, -1).join('/') || '.';
54
+ await mkdir(parent, { recursive: true });
55
+ await writeFile(p, String(args.content ?? ''), 'utf8');
56
+ const rel = relative(ctx.cwd, p) || basename(p);
57
+ return okToolOk(`Wrote ${rel} (${Buffer.byteLength(String(args.content ?? ''))} bytes).`);
58
+ }
59
+ catch (err) {
60
+ return toolError(err.message);
61
+ }
62
+ },
63
+ };
64
+ export const listDirTool = {
65
+ name: 'list_dir',
66
+ description: 'List the contents of a directory (recursive only up to depth 1 by default).',
67
+ parameters: {
68
+ type: 'object',
69
+ properties: {
70
+ path: { type: 'string', description: 'Directory to list. Defaults to the working directory.' },
71
+ depth: { type: 'number', description: 'Recurse up to this depth (default 0).', default: 0 },
72
+ },
73
+ required: [],
74
+ },
75
+ async run(args, ctx) {
76
+ const p = resolvePath(String(args.path ?? '.'), ctx.cwd);
77
+ const depth = Math.min(Math.max(Number(args.depth ?? 0), 0), 2);
78
+ try {
79
+ const lines = await walk(p, depth, ctx.cwd);
80
+ if (lines.length === 0)
81
+ return okToolOk(`(empty directory: ${relative(ctx.cwd, p) || '.'})`);
82
+ return okToolOk(lines.join('\n'));
83
+ }
84
+ catch (err) {
85
+ return toolError(err.message);
86
+ }
87
+ },
88
+ };
89
+ async function walk(dir, depth, cwd) {
90
+ let entries;
91
+ try {
92
+ entries = await readdir(dir, { withFileTypes: true });
93
+ }
94
+ catch {
95
+ return [`(unreadable: ${dir})`];
96
+ }
97
+ entries.sort((a, b) => a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? 1 : -1);
98
+ const lines = [];
99
+ for (const e of entries) {
100
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist')
101
+ continue;
102
+ const full = join(dir, e.name);
103
+ const rel = relative(cwd, full) || e.name;
104
+ if (e.isDirectory()) {
105
+ lines.push(`${rel}/`);
106
+ if (depth > 0) {
107
+ const sub = await walk(full, depth - 1, cwd);
108
+ for (const s of sub.slice(0, 100))
109
+ lines.push(s);
110
+ }
111
+ }
112
+ else {
113
+ lines.push(rel);
114
+ }
115
+ }
116
+ return lines;
117
+ }
118
+ /** Simple glob: supports **, *, ? against the resolved path. */
119
+ export const globTool = {
120
+ name: 'glob',
121
+ description: 'Find files matching a glob pattern (e.g. "src/**/*.ts").',
122
+ parameters: {
123
+ type: 'object',
124
+ properties: {
125
+ pattern: { type: 'string', description: 'Glob pattern to match.' },
126
+ path: { type: 'string', description: 'Base directory.' },
127
+ },
128
+ required: ['pattern'],
129
+ },
130
+ async run(args, ctx) {
131
+ const base = resolvePath(String(args.path ?? '.'), ctx.cwd);
132
+ const pattern = String(args.pattern ?? '');
133
+ try {
134
+ const results = await globWalk(base, pattern, ctx.cwd);
135
+ return okToolOk(results.length ? results.join('\n') : `(no matches for "${pattern}")`);
136
+ }
137
+ catch (err) {
138
+ return toolError(err.message);
139
+ }
140
+ },
141
+ };
142
+ async function globWalk(base, pattern, cwd) {
143
+ const all = [];
144
+ const segments = pattern.split('/');
145
+ await walkMode(base, segments, cwd, all, 0);
146
+ return all;
147
+ }
148
+ async function walkMode(dir, pattern, cwd, out, depth) {
149
+ if (depth > 6)
150
+ return;
151
+ if (pattern.length === 0)
152
+ return;
153
+ const seg = pattern[0];
154
+ let entries;
155
+ try {
156
+ entries = await readdir(dir, { withFileTypes: true });
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ for (const e of entries) {
162
+ if (e.name === 'node_modules' || e.name === '.git')
163
+ continue;
164
+ const full = join(dir, e.name);
165
+ const rel = relative(cwd, full) || e.name;
166
+ const rest = pattern.slice(1);
167
+ if (seg === '**') {
168
+ if (e.isDirectory()) {
169
+ // match zero or more directories
170
+ await walkMode(full, pattern, cwd, out, depth + 1);
171
+ if (rest.length === 0)
172
+ continue;
173
+ }
174
+ if (matchSeg(seg, e.name)) {
175
+ if (rest.length === 0) {
176
+ out.push(rel);
177
+ }
178
+ else if (e.isDirectory()) {
179
+ await walkMode(full, rest, cwd, out, depth + 1);
180
+ }
181
+ }
182
+ continue;
183
+ }
184
+ if (!matchSeg(seg, e.name))
185
+ continue;
186
+ if (rest.length === 0) {
187
+ out.push(rel);
188
+ }
189
+ else if (e.isDirectory()) {
190
+ await walkMode(full, rest, cwd, out, depth + 1);
191
+ }
192
+ }
193
+ }
194
+ function matchSeg(pattern, str) {
195
+ if (pattern === '**')
196
+ return true;
197
+ const re = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '.') + '$');
198
+ return re.test(str);
199
+ }
200
+ export function isBinaryish(p) {
201
+ const BIN_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.wasm', '.woff', '.woff2', '.ttf', '.o', '.a', '.so', '.dll', '.exe', '.pdf', '.zip', '.gz']);
202
+ return BIN_EXTS.has(extname(p).toLowerCase());
203
+ }
@@ -0,0 +1,138 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { okToolOk, toolError } from './types.js';
3
+ export function runGit(cwd, args, timeoutMs = 15_000, signal) {
4
+ return new Promise((resolve) => {
5
+ const child = execFile('git', args, { cwd, timeout: timeoutMs, signal, maxBuffer: 2 * 1024 * 1024 }, (err, stdout, stderr) => {
6
+ if (err && err.code === 'ENOENT') {
7
+ resolve({ code: 127, stdout: '', stderr: 'git: command not found — install git on this device.' });
8
+ return;
9
+ }
10
+ const code = typeof err?.code === 'number'
11
+ ? err.code
12
+ : err ? 1 : 0;
13
+ resolve({ code, stdout, stderr });
14
+ });
15
+ });
16
+ }
17
+ const base = {
18
+ type: 'object',
19
+ properties: {
20
+ cwd: { type: 'string', description: 'Repository directory (default: current working directory).' },
21
+ },
22
+ required: [],
23
+ };
24
+ export const gitStatusTool = {
25
+ name: 'git_status',
26
+ description: 'Show the repository status: current branch and working-tree changes (short porcelain format). Read-only.',
27
+ parameters: { ...base },
28
+ async run(args, ctx) {
29
+ const cwd = String(args.cwd ?? ctx.cwd);
30
+ const { code, stdout, stderr } = await runGit(cwd, ['status', '--short', '--branch']);
31
+ if (code !== 0)
32
+ return toolError(`git status failed: ${stderr.trim() || stdout.trim()}`);
33
+ const out = stdout.trim() || '(clean working tree)';
34
+ return okToolOk(`git status:\n${out}\n\nRun git_diff for details.`);
35
+ },
36
+ };
37
+ export const gitDiffTool = {
38
+ name: 'git_diff',
39
+ description: 'Show the working-tree diff (or staged with staged=true). Optionally scoped to one path. Read-only.',
40
+ parameters: {
41
+ type: 'object',
42
+ properties: {
43
+ ...base.properties,
44
+ path: { type: 'string', description: 'Restrict diff to this path.' },
45
+ staged: { type: 'boolean', description: 'Show staged changes (git diff --staged).' },
46
+ stat: { type: 'boolean', description: 'Summary of changed files instead of full hunks.' },
47
+ },
48
+ required: [],
49
+ },
50
+ async run(args, ctx) {
51
+ const cwd = String(args.cwd ?? ctx.cwd);
52
+ const gitArgs = ['diff'];
53
+ if (args.staged)
54
+ gitArgs.push('--staged');
55
+ if (args.stat)
56
+ gitArgs.push('--stat');
57
+ if (args.path)
58
+ gitArgs.push('--', String(args.path));
59
+ const { code, stdout, stderr } = await runGit(cwd, gitArgs);
60
+ if (code !== 0)
61
+ return toolError(`git diff failed: ${stderr.trim() || stdout.trim()}`);
62
+ const out = stdout.trim();
63
+ if (!out)
64
+ return okToolOk('(no changes)');
65
+ const maxLen = 60_000;
66
+ const truncated = out.length > maxLen ? `${out.slice(0, maxLen)}\n… [truncated]` : out;
67
+ return okToolOk(truncated);
68
+ },
69
+ };
70
+ export const gitLogTool = {
71
+ name: 'git_log',
72
+ description: 'Show recent commit history (one line per commit). Read-only.',
73
+ parameters: {
74
+ type: 'object',
75
+ properties: {
76
+ ...base.properties,
77
+ count: { type: 'number', description: 'Number of commits to show (default 15).' },
78
+ },
79
+ required: [],
80
+ },
81
+ async run(args, ctx) {
82
+ const cwd = String(args.cwd ?? ctx.cwd);
83
+ const count = Math.min(Math.max(Number(args.count ?? 15), 1), 50);
84
+ const { code, stdout, stderr } = await runGit(cwd, ['log', `--max-count=${count}`, '--oneline']);
85
+ if (code !== 0)
86
+ return toolError(`git log failed: ${stderr.trim() || stdout.trim()}`);
87
+ return okToolOk(stdout.trim() || '(no commits yet)');
88
+ },
89
+ };
90
+ export const gitAddTool = {
91
+ name: 'git_add',
92
+ description: 'Stage files with `git add`. Mutates the index — permission-gated. Provide paths as a relative path/glob string or list.',
93
+ parameters: {
94
+ type: 'object',
95
+ properties: {
96
+ ...base.properties,
97
+ paths: { type: 'array', items: { type: 'string' }, description: 'Paths or globs to stage (default: ".")' },
98
+ },
99
+ required: [],
100
+ },
101
+ async run(args, ctx) {
102
+ const cwd = String(args.cwd ?? ctx.cwd);
103
+ const paths = Array.isArray(args.paths) && args.paths.length > 0 ? args.paths.map(String) : ['.'];
104
+ const { code, stdout, stderr } = await runGit(cwd, ['add', '--', ...paths]);
105
+ if (code !== 0)
106
+ return toolError(`git add failed: ${stderr.trim() || stdout.trim()}`);
107
+ return okToolOk(stdout.trim() ? `git add: ${stdout.trim()}` : `staged ${paths.join(', ')}`);
108
+ },
109
+ };
110
+ export const gitCommitTool = {
111
+ name: 'git_commit',
112
+ description: 'Create a commit with the given message. Staged changes are required first. Permission-gated.',
113
+ parameters: {
114
+ type: 'object',
115
+ properties: {
116
+ ...base.properties,
117
+ message: { type: 'string', description: 'Commit message.' },
118
+ },
119
+ required: ['message'],
120
+ },
121
+ async run(args, ctx) {
122
+ const cwd = String(args.cwd ?? ctx.cwd);
123
+ const message = String(args.message ?? '');
124
+ if (!message.trim())
125
+ return toolError('commit message is required.');
126
+ const { code, stdout, stderr } = await runGit(cwd, ['commit', '-m', message]);
127
+ if (code !== 0)
128
+ return toolError(`git commit failed: ${stderr.trim() || stdout.trim()}`);
129
+ return okToolOk(stdout.trim() || `committed: ${message}`);
130
+ },
131
+ };
132
+ export const gitTools = [
133
+ gitStatusTool,
134
+ gitDiffTool,
135
+ gitLogTool,
136
+ gitAddTool,
137
+ gitCommitTool,
138
+ ];
@@ -0,0 +1,73 @@
1
+ import { readFileTool, writeFileTool, listDirTool, globTool } from './filesystem.js';
2
+ import { editFileTool } from './edit.js';
3
+ import { createShellTool } from './shell.js';
4
+ import { grepTool } from './search.js';
5
+ import { gitTools } from './git.js';
6
+ /**
7
+ * ToolRegistry — ordered, dependency-light tool container.
8
+ * Supports role-based restriction for delegated sub-agents:
9
+ * a restricted registry shares the same tool implementations but only
10
+ * exposes a whitelist of names (read-only roles drop shell mutations etc.).
11
+ */
12
+ export class ToolRegistry {
13
+ tools = new Map();
14
+ names = [];
15
+ ctx;
16
+ constructor(init) {
17
+ this.ctx = { cwd: init.cwd, canWrite: init.canWrite ?? true };
18
+ this.register(readFileTool);
19
+ this.register(editFileTool);
20
+ this.register(writeFileTool);
21
+ this.register(listDirTool);
22
+ this.register(globTool);
23
+ this.register(grepTool);
24
+ if (init.withGit ?? true) {
25
+ for (const t of gitTools)
26
+ this.register(t);
27
+ }
28
+ if (init.shell ?? true) {
29
+ this.register(createShellTool({ shell: init.shellPath }));
30
+ }
31
+ }
32
+ register(tool) {
33
+ const exists = this.tools.has(tool.name);
34
+ this.tools.set(tool.name, tool);
35
+ if (!exists)
36
+ this.names.push(tool.name);
37
+ }
38
+ get(name) {
39
+ return this.tools.get(name);
40
+ }
41
+ all() {
42
+ return this.names.map((n) => this.tools.get(n)).filter(Boolean);
43
+ }
44
+ /** A new registry restricted to the given tool names, sharing implementations. */
45
+ restrict(allowed) {
46
+ const r = new ToolRegistry({ cwd: this.ctx.cwd, canWrite: this.ctx.canWrite, shell: false, withGit: false });
47
+ r.tools.clear();
48
+ r.names = [];
49
+ for (const name of allowed) {
50
+ const t = this.tools.get(name);
51
+ if (t)
52
+ r.register(t);
53
+ }
54
+ return r;
55
+ }
56
+ toToolDefs() {
57
+ return this.all().map((t) => ({
58
+ type: 'function',
59
+ function: { name: t.name, description: t.description, parameters: t.parameters },
60
+ }));
61
+ }
62
+ createToolContext() {
63
+ return { ...this.ctx, notify: () => { } };
64
+ }
65
+ }
66
+ export const READ_ONLY_TOOLS = [
67
+ 'read_file', 'list_dir', 'glob', 'grep',
68
+ 'git_status', 'git_diff', 'git_log',
69
+ ];
70
+ export const CODER_TOOLS = [
71
+ 'read_file', 'edit_file', 'write_file', 'list_dir', 'glob', 'grep',
72
+ 'run_shell', 'git_status', 'git_diff', 'git_log', 'git_add', 'git_commit',
73
+ ];
@@ -0,0 +1,90 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { okToolOk, toolError } from './types.js';
4
+ const SEARCH_DEPTH = 5;
5
+ const FILE_LIMIT = 60_000;
6
+ export const grepTool = {
7
+ name: 'grep',
8
+ description: 'Search file contents in a directory for a regex pattern. Returns matching lines.',
9
+ parameters: {
10
+ type: 'object',
11
+ properties: {
12
+ pattern: { type: 'string', description: 'Regex to search for.' },
13
+ path: { type: 'string', description: 'Directory to search.' },
14
+ include: { type: 'string', description: 'Optional filename glob filter, e.g. "*.ts".' },
15
+ },
16
+ required: ['pattern'],
17
+ },
18
+ async run(args, ctx) {
19
+ const dir = resolve(String(args.path ?? '.'), ctx.cwd);
20
+ const pattern = String(args.pattern ?? '');
21
+ let re;
22
+ try {
23
+ re = new RegExp(pattern, 'i');
24
+ }
25
+ catch (err) {
26
+ return toolError(`invalid regex: ${err.message}`);
27
+ }
28
+ const include = String(args.include ?? '');
29
+ try {
30
+ const matches = [];
31
+ await search(dir, dir, re, include, matches, 0, ctx.cwd);
32
+ if (matches.length === 0)
33
+ return okToolOk(`(no matches for /${pattern}/)`);
34
+ const truncated = matches.slice(0, 200);
35
+ const more = matches.length > 200 ? `… ${matches.length - 200} more` : '';
36
+ return okToolOk(truncated.join('\n') + (more ? `\n${more}` : ''));
37
+ }
38
+ catch (err) {
39
+ return toolError(err.message);
40
+ }
41
+ },
42
+ };
43
+ async function search(root, dir, re, include, out, depth, cwd) {
44
+ if (depth > SEARCH_DEPTH || out.length >= FILE_LIMIT)
45
+ return;
46
+ let entries;
47
+ try {
48
+ entries = await readdir(dir, { withFileTypes: true });
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ for (const e of entries) {
54
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist')
55
+ continue;
56
+ const full = join(dir, e.name);
57
+ if (e.isDirectory()) {
58
+ await search(root, full, re, include, out, depth + 1, cwd);
59
+ continue;
60
+ }
61
+ if (include && !matchGlob(include, e.name))
62
+ continue;
63
+ try {
64
+ const st = await stat(full);
65
+ if (st.size > 500_000)
66
+ continue;
67
+ const content = await readFile(full, 'utf8');
68
+ const rel = relative(cwd, full) || e.name;
69
+ const lines = content.split('\n');
70
+ for (let i = 0; i < lines.length; i++) {
71
+ if (re.test(lines[i])) {
72
+ out.push(`${rel}:${i + 1}: ${lines[i].slice(0, 180)}`);
73
+ if (out.length >= FILE_LIMIT)
74
+ return;
75
+ }
76
+ }
77
+ }
78
+ catch {
79
+ /* skip unreadable */
80
+ }
81
+ }
82
+ }
83
+ function matchGlob(pattern, name) {
84
+ const re = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '.') + '$');
85
+ return re.test(name);
86
+ }
87
+ export function isText(buf) {
88
+ const sample = buf.subarray(0, 8000);
89
+ return !sample.includes(0);
90
+ }