@hemansubedi/aether-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +3 -0
- package/.github/workflows/live-stats.yml +42 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/update-preview.yml +41 -0
- package/INSTALL.md +59 -0
- package/LICENSE +21 -0
- package/README.md +397 -0
- package/assets/aether-arena.svg +72 -0
- package/assets/aether-banner.svg +62 -0
- package/assets/aether-router.svg +129 -0
- package/dist/agent.js +125 -0
- package/dist/arena.js +486 -0
- package/dist/checkpoint.js +105 -0
- package/dist/client.js +95 -0
- package/dist/combos.js +176 -0
- package/dist/commands.js +483 -0
- package/dist/config.js +104 -0
- package/dist/cost.js +176 -0
- package/dist/git.js +52 -0
- package/dist/health.js +81 -0
- package/dist/index.js +272 -0
- package/dist/keys.js +128 -0
- package/dist/memory.js +98 -0
- package/dist/modes.js +68 -0
- package/dist/providers/index.js +32 -0
- package/dist/providers/ollama.js +206 -0
- package/dist/providers/openai-compat.js +181 -0
- package/dist/providers/openrouter.js +189 -0
- package/dist/providers/registry.js +211 -0
- package/dist/router-engine.js +200 -0
- package/dist/router.js +171 -0
- package/dist/server.js +210 -0
- package/dist/session.js +97 -0
- package/dist/settings.js +97 -0
- package/dist/skills.js +100 -0
- package/dist/tokensaver.js +50 -0
- package/dist/tools/filesystem.js +243 -0
- package/dist/tools/git.js +53 -0
- package/dist/tools/glob.js +175 -0
- package/dist/tools/grep.js +193 -0
- package/dist/tools/registry.js +39 -0
- package/dist/tools/vision.js +140 -0
- package/dist/tools/websearch.js +118 -0
- package/dist/tui.js +562 -0
- package/dist/types.js +8 -0
- package/docs/preview.txt +51 -0
- package/docs/screenshots.md +110 -0
- package/docs/stats.md +5 -0
- package/install.ps1 +170 -0
- package/install.sh +196 -0
- package/package.json +34 -0
- package/scripts/generate-stats-card.ts +62 -0
- package/scripts/patch_index.ps1 +17 -0
- package/scripts/release.sh +7 -0
- package/src/agent.ts +146 -0
- package/src/arena.ts +584 -0
- package/src/checkpoint.ts +111 -0
- package/src/client.ts +172 -0
- package/src/combos.ts +199 -0
- package/src/commands.ts +973 -0
- package/src/config.ts +122 -0
- package/src/cost.ts +206 -0
- package/src/git.ts +68 -0
- package/src/health.ts +90 -0
- package/src/index.ts +281 -0
- package/src/keys.ts +135 -0
- package/src/memory.ts +101 -0
- package/src/modes.ts +84 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/ollama.ts +222 -0
- package/src/providers/openai-compat.ts +188 -0
- package/src/providers/openrouter.ts +198 -0
- package/src/providers/registry.ts +223 -0
- package/src/router-engine.ts +214 -0
- package/src/router.ts +195 -0
- package/src/server.ts +242 -0
- package/src/session.ts +111 -0
- package/src/settings.ts +125 -0
- package/src/skills.ts +106 -0
- package/src/tokensaver.ts +57 -0
- package/src/tools/filesystem.ts +258 -0
- package/src/tools/git.ts +53 -0
- package/src/tools/glob.ts +180 -0
- package/src/tools/grep.ts +192 -0
- package/src/tools/registry.ts +54 -0
- package/src/tools/vision.ts +152 -0
- package/src/tools/websearch.ts +130 -0
- package/src/tui.ts +664 -0
- package/src/types.ts +77 -0
- package/tsconfig.json +16 -0
package/dist/settings.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
const SETTINGS_DIR = path.join(os.homedir(), ".aether");
|
|
5
|
+
const SETTINGS_FILE = path.join(SETTINGS_DIR, "settings.json");
|
|
6
|
+
const SETTINGS_VERSION = 1;
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
theme: "dark",
|
|
9
|
+
streaming: true,
|
|
10
|
+
maxSteps: 15,
|
|
11
|
+
temperature: 0.7,
|
|
12
|
+
confirmTools: true,
|
|
13
|
+
autoSave: true,
|
|
14
|
+
};
|
|
15
|
+
function isBool(v) {
|
|
16
|
+
return typeof v === "boolean";
|
|
17
|
+
}
|
|
18
|
+
function isNum(v) {
|
|
19
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
20
|
+
}
|
|
21
|
+
function isStr(v) {
|
|
22
|
+
return typeof v === "string";
|
|
23
|
+
}
|
|
24
|
+
function coerce(key, v) {
|
|
25
|
+
const def = DEFAULTS[key];
|
|
26
|
+
if (typeof def === "boolean")
|
|
27
|
+
return isBool(v) ? v : def;
|
|
28
|
+
if (typeof def === "number")
|
|
29
|
+
return isNum(v) ? v : def;
|
|
30
|
+
return isStr(v) ? v : def;
|
|
31
|
+
}
|
|
32
|
+
export class Settings {
|
|
33
|
+
settings = { ...DEFAULTS };
|
|
34
|
+
dirty = false;
|
|
35
|
+
constructor() { }
|
|
36
|
+
get(key) {
|
|
37
|
+
return this.settings[key];
|
|
38
|
+
}
|
|
39
|
+
set(key, value) {
|
|
40
|
+
this.settings[key] = coerce(key, value);
|
|
41
|
+
this.dirty = true;
|
|
42
|
+
}
|
|
43
|
+
getAll() {
|
|
44
|
+
return { ...this.settings };
|
|
45
|
+
}
|
|
46
|
+
reset() {
|
|
47
|
+
this.settings = { ...DEFAULTS };
|
|
48
|
+
this.dirty = true;
|
|
49
|
+
}
|
|
50
|
+
save() {
|
|
51
|
+
try {
|
|
52
|
+
if (!fs.existsSync(SETTINGS_DIR)) {
|
|
53
|
+
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
const snapshot = { version: SETTINGS_VERSION, settings: { ...this.settings } };
|
|
56
|
+
const tmp = SETTINGS_FILE + ".tmp";
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(snapshot, null, 2), "utf8");
|
|
58
|
+
fs.renameSync(tmp, SETTINGS_FILE);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// best-effort persistence
|
|
62
|
+
}
|
|
63
|
+
this.dirty = false;
|
|
64
|
+
}
|
|
65
|
+
restore(snapshot) {
|
|
66
|
+
if (!snapshot || typeof snapshot !== "object")
|
|
67
|
+
return;
|
|
68
|
+
const src = snapshot.settings ?? {};
|
|
69
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
70
|
+
if (key in src) {
|
|
71
|
+
this.settings[key] = coerce(key, src[key]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
static load() {
|
|
76
|
+
const s = new Settings();
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
79
|
+
const raw = fs.readFileSync(SETTINGS_FILE, "utf8");
|
|
80
|
+
s.restore(JSON.parse(raw));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// ignore malformed settings file
|
|
85
|
+
}
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
static instanceCache = new Map();
|
|
89
|
+
static instance(name = "default") {
|
|
90
|
+
let s = Settings.instanceCache.get(name);
|
|
91
|
+
if (!s) {
|
|
92
|
+
s = name === "default" ? Settings.load() : new Settings();
|
|
93
|
+
Settings.instanceCache.set(name, s);
|
|
94
|
+
}
|
|
95
|
+
return s;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
export class Skills {
|
|
5
|
+
dir;
|
|
6
|
+
skills;
|
|
7
|
+
constructor(dir) {
|
|
8
|
+
this.dir = dir ?? path.join(os.homedir(), ".aether", "skills");
|
|
9
|
+
this.skills = new Map();
|
|
10
|
+
this.load();
|
|
11
|
+
}
|
|
12
|
+
load() {
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(this.dir)) {
|
|
15
|
+
this.ensureDefault();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".md"));
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const skill = this.parseFile(path.join(this.dir, f));
|
|
21
|
+
if (skill)
|
|
22
|
+
this.skills.set(skill.name, skill);
|
|
23
|
+
}
|
|
24
|
+
if (this.skills.size === 0)
|
|
25
|
+
this.ensureDefault();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
this.ensureDefault();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
ensureDefault() {
|
|
32
|
+
const def = {
|
|
33
|
+
name: "explain",
|
|
34
|
+
description: "Explain a concept in simple terms",
|
|
35
|
+
arguments: ["topic"],
|
|
36
|
+
template: "Explain {{topic}} in simple terms with an analogy. Keep it under 200 words."
|
|
37
|
+
};
|
|
38
|
+
this.skills.set(def.name, def);
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
41
|
+
const file = path.join(this.dir, "explain.md");
|
|
42
|
+
if (!fs.existsSync(file)) {
|
|
43
|
+
fs.writeFileSync(file, `---\nname: explain\ndescription: Explain a concept in simple terms\narguments:\n - topic\n---\n\nExplain {{topic}} in simple terms with an analogy. Keep it under 200 words.`, "utf8");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// best effort
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
parseFile(file) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
53
|
+
const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
54
|
+
if (!fmMatch)
|
|
55
|
+
return null;
|
|
56
|
+
const fm = fmMatch[1];
|
|
57
|
+
const body = fmMatch[2].trim();
|
|
58
|
+
const name = fm.match(/^name:\s*(.+)$/m)?.[1].trim() ?? "";
|
|
59
|
+
const description = fm.match(/^description:\s*(.+)$/m)?.[1].trim() ?? "";
|
|
60
|
+
const argsSection = fm.match(/^arguments:\s*\n((?:\s+-\s*.+\n?)+)/m);
|
|
61
|
+
const args = [];
|
|
62
|
+
if (argsSection) {
|
|
63
|
+
const re = /^\s+-\s*(.+)$/gm;
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = re.exec(argsSection[1])) !== null)
|
|
66
|
+
args.push(m[1].trim());
|
|
67
|
+
}
|
|
68
|
+
if (!name)
|
|
69
|
+
return null;
|
|
70
|
+
return { name, description, arguments: args, template: body };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
list() {
|
|
77
|
+
return Array.from(this.skills.values());
|
|
78
|
+
}
|
|
79
|
+
get(name) {
|
|
80
|
+
return this.skills.get(name);
|
|
81
|
+
}
|
|
82
|
+
render(name, args) {
|
|
83
|
+
const skill = this.skills.get(name);
|
|
84
|
+
if (!skill)
|
|
85
|
+
return `ERROR: unknown skill "${name}"`;
|
|
86
|
+
let out = skill.template;
|
|
87
|
+
for (const [k, v] of Object.entries(args)) {
|
|
88
|
+
out = out.replace(new RegExp(`{{${k}}}`, "g"), v);
|
|
89
|
+
}
|
|
90
|
+
// Replace any remaining {{arg}} with the joined args string.
|
|
91
|
+
out = out.replace(/{{(\w+)}}/g, (_, k) => args[k] ?? "");
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
static instance_ = null;
|
|
95
|
+
static instance() {
|
|
96
|
+
if (!Skills.instance_)
|
|
97
|
+
Skills.instance_ = new Skills();
|
|
98
|
+
return Skills.instance_;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function estimateTokens(text) {
|
|
2
|
+
if (!text)
|
|
3
|
+
return 0;
|
|
4
|
+
return Math.ceil(text.split(/\s+/).filter(Boolean).length * 1.3);
|
|
5
|
+
}
|
|
6
|
+
export function estimateMessageTokens(m) {
|
|
7
|
+
let n = estimateTokens(typeof m.content === "string" ? m.content : m.content.map((p) => p.type === "text" ? p.text : "").join(""));
|
|
8
|
+
if (m.tool_calls) {
|
|
9
|
+
for (const tc of m.tool_calls) {
|
|
10
|
+
n += estimateTokens(tc.function.name) + estimateTokens(tc.function.arguments);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return n + 4; // role/name overhead
|
|
14
|
+
}
|
|
15
|
+
export function compressText(text) {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
|
20
|
+
export function compressHistory(messages, maxTokens) {
|
|
21
|
+
if (messages.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
const system = messages.find((m) => m.role === "system");
|
|
24
|
+
const rest = system ? messages.slice(1) : messages;
|
|
25
|
+
let budget = maxTokens;
|
|
26
|
+
if (system)
|
|
27
|
+
budget -= estimateMessageTokens(system);
|
|
28
|
+
const kept = [];
|
|
29
|
+
let used = 0;
|
|
30
|
+
// Walk from the newest backwards.
|
|
31
|
+
for (let i = rest.length - 1; i >= 0; i--) {
|
|
32
|
+
const cost = estimateMessageTokens(rest[i]);
|
|
33
|
+
if (used + cost > budget)
|
|
34
|
+
break;
|
|
35
|
+
kept.unshift(rest[i]);
|
|
36
|
+
used += cost;
|
|
37
|
+
}
|
|
38
|
+
const dropped = rest.length - kept.length;
|
|
39
|
+
const result = [];
|
|
40
|
+
if (system)
|
|
41
|
+
result.push(system);
|
|
42
|
+
if (dropped > 0) {
|
|
43
|
+
result.push({
|
|
44
|
+
role: "system",
|
|
45
|
+
content: `[${dropped} earlier message(s) omitted to fit context]`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
result.push(...kept);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { Checkpoint } from "../checkpoint.js";
|
|
5
|
+
const MAX_READ_CHARS = 50_000;
|
|
6
|
+
const MAX_OUTPUT_CHARS = 30_000;
|
|
7
|
+
function truncate(s, max) {
|
|
8
|
+
if (s.length <= max)
|
|
9
|
+
return s;
|
|
10
|
+
return (s.slice(0, max) +
|
|
11
|
+
`\n\n[...truncated ${s.length - max} more characters...]\n`);
|
|
12
|
+
}
|
|
13
|
+
export function makeReadFileTool(rootDir) {
|
|
14
|
+
return {
|
|
15
|
+
def: {
|
|
16
|
+
name: "ReadFile",
|
|
17
|
+
description: "Read the contents of a file at a given relative path. Returns file contents.",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
path: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "relative path from project root",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ["path"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
execute: async (args) => {
|
|
30
|
+
const rel = String(args.path ?? "");
|
|
31
|
+
const target = path.resolve(rootDir, rel);
|
|
32
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
33
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.existsSync(target)) {
|
|
37
|
+
return `ERROR: file not found: ${rel}`;
|
|
38
|
+
}
|
|
39
|
+
if (fs.statSync(target).isDirectory()) {
|
|
40
|
+
return `ERROR: path "${rel}" is a directory, not a file`;
|
|
41
|
+
}
|
|
42
|
+
const content = fs.readFileSync(target, "utf8");
|
|
43
|
+
return truncate(content, MAX_READ_CHARS);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
47
|
+
return `ERROR reading "${rel}": ${message}`;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function makeWriteFileTool(rootDir) {
|
|
53
|
+
return {
|
|
54
|
+
def: {
|
|
55
|
+
name: "WriteFile",
|
|
56
|
+
description: "Write content to a file, creating parent directories if needed. Returns confirmation.",
|
|
57
|
+
parameters: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
path: { type: "string" },
|
|
61
|
+
content: { type: "string" },
|
|
62
|
+
},
|
|
63
|
+
required: ["path", "content"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
execute: async (args) => {
|
|
67
|
+
const rel = String(args.path ?? "");
|
|
68
|
+
const content = String(args.content ?? "");
|
|
69
|
+
const target = path.resolve(rootDir, rel);
|
|
70
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
71
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
try {
|
|
75
|
+
Checkpoint.instance().autoCheckpoint(rootDir, rel);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
const parent = path.dirname(target);
|
|
79
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
80
|
+
fs.writeFileSync(target, content, "utf8");
|
|
81
|
+
return `Wrote ${Buffer.byteLength(content, "utf8")} bytes to ${rel}`;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
return `ERROR writing "${rel}": ${message}`;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function makeEditFileTool(rootDir) {
|
|
91
|
+
return {
|
|
92
|
+
def: {
|
|
93
|
+
name: "EditFile",
|
|
94
|
+
description: "Replace exact text in a file. oldText must match exactly once. Returns confirmation.",
|
|
95
|
+
parameters: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
path: { type: "string" },
|
|
99
|
+
oldText: { type: "string" },
|
|
100
|
+
newText: { type: "string" },
|
|
101
|
+
},
|
|
102
|
+
required: ["path", "oldText", "newText"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
execute: async (args) => {
|
|
106
|
+
const rel = String(args.path ?? "");
|
|
107
|
+
const oldText = String(args.oldText ?? "");
|
|
108
|
+
const newText = String(args.newText ?? "");
|
|
109
|
+
const target = path.resolve(rootDir, rel);
|
|
110
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
111
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
if (!fs.existsSync(target)) {
|
|
115
|
+
return `ERROR: file not found: ${rel}`;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
Checkpoint.instance().autoCheckpoint(rootDir, rel);
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
const original = fs.readFileSync(target, "utf8");
|
|
122
|
+
const first = original.indexOf(oldText);
|
|
123
|
+
if (first === -1) {
|
|
124
|
+
return `ERROR: oldText not found in "${rel}". The file may have changed; read it first.`;
|
|
125
|
+
}
|
|
126
|
+
const second = original.indexOf(oldText, first + 1);
|
|
127
|
+
if (second !== -1) {
|
|
128
|
+
return `ERROR: oldText found multiple times in "${rel}". oldText must match exactly once.`;
|
|
129
|
+
}
|
|
130
|
+
const updated = original.slice(0, first) +
|
|
131
|
+
newText +
|
|
132
|
+
original.slice(first + oldText.length);
|
|
133
|
+
fs.writeFileSync(target, updated, "utf8");
|
|
134
|
+
return `Edited ${rel}: ${countLines(oldText)} old line(s) -> ${countLines(newText)} new line(s)`;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
138
|
+
return `ERROR editing "${rel}": ${message}`;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function countLines(s) {
|
|
144
|
+
if (s.length === 0)
|
|
145
|
+
return 0;
|
|
146
|
+
return s.split("\n").length;
|
|
147
|
+
}
|
|
148
|
+
export function makeListDirTool(rootDir) {
|
|
149
|
+
return {
|
|
150
|
+
def: {
|
|
151
|
+
name: "ListDir",
|
|
152
|
+
description: "List files and subdirectories at a path (recursive, like tree).",
|
|
153
|
+
parameters: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
path: { type: "string", default: "." },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
execute: async (args) => {
|
|
161
|
+
const rel = String(args.path ?? ".");
|
|
162
|
+
const target = path.resolve(rootDir, rel);
|
|
163
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
164
|
+
return `ERROR: path "${rel}" escapes the project root`;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
if (!fs.existsSync(target)) {
|
|
168
|
+
return `ERROR: path not found: ${rel}`;
|
|
169
|
+
}
|
|
170
|
+
const lines = [];
|
|
171
|
+
const rootLabel = rel === "." ? "." : rel;
|
|
172
|
+
lines.push(rootLabel);
|
|
173
|
+
walk(target, "", lines);
|
|
174
|
+
return truncate(lines.join("\n"), MAX_OUTPUT_CHARS);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
178
|
+
return `ERROR listing "${rel}": ${message}`;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function walk(dir, prefix, lines) {
|
|
184
|
+
let entries;
|
|
185
|
+
try {
|
|
186
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
192
|
+
const filtered = entries.filter((e) => !e.name.startsWith("."));
|
|
193
|
+
filtered.forEach((entry, i) => {
|
|
194
|
+
const isLast = i === filtered.length - 1;
|
|
195
|
+
const connector = isLast ? "+-- " : "+-- ";
|
|
196
|
+
lines.push(`${prefix}${connector}${entry.name}`);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
walk(path.join(dir, entry.name), prefix + (isLast ? " " : "� "), lines);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export function makeBashTool(rootDir) {
|
|
203
|
+
return {
|
|
204
|
+
def: {
|
|
205
|
+
name: "Bash",
|
|
206
|
+
description: "Execute a shell command in the project directory. Returns stdout+stderr. Use for running commands, tests, installs. Prefer non-interactive commands.",
|
|
207
|
+
parameters: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
command: {
|
|
211
|
+
type: "string",
|
|
212
|
+
description: "the shell command",
|
|
213
|
+
},
|
|
214
|
+
timeoutMs: { type: "number", default: 30000 },
|
|
215
|
+
},
|
|
216
|
+
required: ["command"],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
execute: async (args) => {
|
|
220
|
+
const command = String(args.command ?? "");
|
|
221
|
+
if (!command.trim()) {
|
|
222
|
+
return "ERROR: empty command";
|
|
223
|
+
}
|
|
224
|
+
const timeoutMs = Math.max(1000, Math.min(600_000, Number(args.timeoutMs) || 30_000));
|
|
225
|
+
try {
|
|
226
|
+
const stdout = execSync(command, {
|
|
227
|
+
cwd: rootDir,
|
|
228
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
229
|
+
timeout: timeoutMs,
|
|
230
|
+
encoding: "utf8",
|
|
231
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
232
|
+
});
|
|
233
|
+
return truncate(stdout, MAX_OUTPUT_CHARS);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
const stderr = err?.stderr ? String(err.stderr) : "";
|
|
237
|
+
const message = err?.message ? String(err.message) : String(err);
|
|
238
|
+
const stdout = err?.stdout ? String(err.stdout) : "";
|
|
239
|
+
return truncate(`ERROR: ${stderr || message}\nSTDOUT: ${stdout}`, MAX_OUTPUT_CHARS);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { GitTool } from "../git.js";
|
|
2
|
+
export function makeGitTool(rootDir) {
|
|
3
|
+
return {
|
|
4
|
+
def: {
|
|
5
|
+
name: "Git",
|
|
6
|
+
description: "Run git operations: status, diff, commit, log, branch. Use this to inspect version control state or make commits.",
|
|
7
|
+
parameters: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
action: { type: "string", enum: ["status", "diff", "commit", "log", "branch"], description: "The git operation to perform" },
|
|
11
|
+
message: { type: "string", description: "Commit message (required for commit)" },
|
|
12
|
+
file: { type: "string", description: "Optional file path to limit diff" },
|
|
13
|
+
count: { type: "number", description: "Number of commits to show (for log)", default: 5 }
|
|
14
|
+
},
|
|
15
|
+
required: ["action"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
execute: async (args) => {
|
|
19
|
+
if (!GitTool.isRepo(rootDir)) {
|
|
20
|
+
return `ERROR: ${rootDir} is not a git repository.`;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
switch (args.action) {
|
|
24
|
+
case "status": {
|
|
25
|
+
const files = await GitTool.status(rootDir);
|
|
26
|
+
if (files.length === 0)
|
|
27
|
+
return "Working tree clean.";
|
|
28
|
+
return files.map((f) => `${f.status}\t${f.file}`).join("\n");
|
|
29
|
+
}
|
|
30
|
+
case "diff": {
|
|
31
|
+
const d = await GitTool.diff(rootDir, args.file);
|
|
32
|
+
return d || "(no differences)";
|
|
33
|
+
}
|
|
34
|
+
case "commit": {
|
|
35
|
+
return await GitTool.commit(rootDir, args.message || "");
|
|
36
|
+
}
|
|
37
|
+
case "log": {
|
|
38
|
+
const commits = await GitTool.log(rootDir, args.count ?? 5);
|
|
39
|
+
return commits.map((c) => `${c.hash} ${c.date} ${c.message}`).join("\n");
|
|
40
|
+
}
|
|
41
|
+
case "branch": {
|
|
42
|
+
return `Current branch: ${await GitTool.branch(rootDir)}`;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
return `ERROR: unknown git action "${args.action}"`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return `ERROR: ${err.message}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|