@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/src/index.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import { RouterEngine } from "./router-engine.js";
|
|
4
|
+
import { HealthTracker } from "./health.js";
|
|
5
|
+
import { getConfig } from "./config.js";
|
|
6
|
+
import { ToolRegistry } from "./tools/registry.js";
|
|
7
|
+
import { makeReadFileTool, makeWriteFileTool, makeEditFileTool, makeListDirTool, makeBashTool } from "./tools/filesystem.js";
|
|
8
|
+
import { makeGlobTool } from "./tools/glob.js";
|
|
9
|
+
import { makeGrepTool } from "./tools/grep.js";
|
|
10
|
+
import { makeWebSearchTool } from "./tools/websearch.js";
|
|
11
|
+
import { makeVisionTool } from "./tools/vision.js";
|
|
12
|
+
import { makeGitTool } from "./tools/git.js";
|
|
13
|
+
import { Agent } from "./agent.js";
|
|
14
|
+
import { Session } from "./session.js";
|
|
15
|
+
import { Arena } from "./arena.js";
|
|
16
|
+
import { Memory } from "./memory.js";
|
|
17
|
+
import { ModeManager } from "./modes.js";
|
|
18
|
+
import { CostTracker } from "./cost.js";
|
|
19
|
+
import { Settings } from "./settings.js";
|
|
20
|
+
import { createTUI } from "./tui.js";
|
|
21
|
+
import type { ChatChunk, Message, ToolDef } from "./types.js";
|
|
22
|
+
import { FreeRouterClient, type ChatResult } from "./client.js";
|
|
23
|
+
import { KeyManager } from "./keys.js";
|
|
24
|
+
|
|
25
|
+
export async function runChat(
|
|
26
|
+
messages: Message[],
|
|
27
|
+
tools: ToolDef[] = [],
|
|
28
|
+
opts?: { temperature?: number; maxTokens?: number; signal?: AbortSignal }
|
|
29
|
+
): Promise<{ text: string; toolCalls: any[]; usage?: { input_tokens: number; output_tokens: number } }> {
|
|
30
|
+
const cfg = getConfig();
|
|
31
|
+
const engine = new RouterEngine(undefined, undefined, KeyManager.instance());
|
|
32
|
+
let text = "";
|
|
33
|
+
const toolCalls: any[] = [];
|
|
34
|
+
let usage: ChatChunk["usage"];
|
|
35
|
+
try {
|
|
36
|
+
const result = await engine.chat(messages, tools, opts);
|
|
37
|
+
text = result.text;
|
|
38
|
+
toolCalls.push(...result.toolCalls);
|
|
39
|
+
usage = result.usage;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error((err as Error).message);
|
|
42
|
+
}
|
|
43
|
+
return { text, toolCalls, usage };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeRouterAdapter(engine: RouterEngine) {
|
|
47
|
+
const adapter: any = {
|
|
48
|
+
configs: engine.configs_,
|
|
49
|
+
activeProvider: undefined as string | undefined,
|
|
50
|
+
activeModel: undefined as string | undefined,
|
|
51
|
+
chat: (messages: any, tools: any, opts?: any) => engine.chatStream(messages, tools, opts),
|
|
52
|
+
select: () => null,
|
|
53
|
+
setActiveProvider: (n?: string) => { adapter.activeProvider = n; },
|
|
54
|
+
setActiveModel: (m: string) => { adapter.activeModel = m; },
|
|
55
|
+
getActiveProvider: () => adapter.activeProvider,
|
|
56
|
+
getActiveModel: () => adapter.activeModel,
|
|
57
|
+
getProviderNames: () => engine.configs_.filter((c: any) => c.enabled).map((c: any) => c.name),
|
|
58
|
+
getModelsFor: (name?: string) => {
|
|
59
|
+
const c = engine.configs_.find((cfg: any) => cfg.name === (name ?? adapter.activeProvider));
|
|
60
|
+
return c?.models ?? [];
|
|
61
|
+
},
|
|
62
|
+
listAllModels: () => engine.listFreeModels(),
|
|
63
|
+
healthAll: () => engine.healthAll(),
|
|
64
|
+
resetHealth: () => engine.resetHealth(),
|
|
65
|
+
keys: engine.keys,
|
|
66
|
+
setKey: (name: string, key: string) => engine.setKey(name, key),
|
|
67
|
+
};
|
|
68
|
+
return adapter;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createAgent(rootDir: string = process.cwd()): Agent {
|
|
72
|
+
const cfg = getConfig();
|
|
73
|
+
const engine = new RouterEngine(undefined, undefined, KeyManager.instance());
|
|
74
|
+
const router = makeRouterAdapter(engine);
|
|
75
|
+
const registry = new ToolRegistry();
|
|
76
|
+
const factories = [
|
|
77
|
+
makeReadFileTool,
|
|
78
|
+
makeWriteFileTool,
|
|
79
|
+
makeEditFileTool,
|
|
80
|
+
makeListDirTool,
|
|
81
|
+
makeBashTool,
|
|
82
|
+
makeGlobTool,
|
|
83
|
+
makeGrepTool,
|
|
84
|
+
makeWebSearchTool,
|
|
85
|
+
makeVisionTool,
|
|
86
|
+
makeGitTool,
|
|
87
|
+
];
|
|
88
|
+
for (const make of factories) {
|
|
89
|
+
const tool = make(rootDir);
|
|
90
|
+
registry.register(tool.def, tool.execute);
|
|
91
|
+
}
|
|
92
|
+
const memory = new Memory();
|
|
93
|
+
const modeManager = new ModeManager();
|
|
94
|
+
return new Agent(router, registry, { memory, modeManager });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createAgentFromServer(baseURL?: string): Agent {
|
|
98
|
+
const client = new FreeRouterClient(baseURL);
|
|
99
|
+
const adapter: any = {
|
|
100
|
+
configs: [],
|
|
101
|
+
activeProvider: undefined as string | undefined,
|
|
102
|
+
activeModel: undefined as string | undefined,
|
|
103
|
+
select: () => null,
|
|
104
|
+
setActiveProvider: (n?: string) => { adapter.activeProvider = n; },
|
|
105
|
+
setActiveModel: (m: string) => { adapter.activeModel = m; },
|
|
106
|
+
getActiveProvider: () => adapter.activeProvider,
|
|
107
|
+
getActiveModel: () => adapter.activeModel,
|
|
108
|
+
getProviderNames: async () => {
|
|
109
|
+
const statuses = await client.providers();
|
|
110
|
+
return statuses.map((s) => s.provider);
|
|
111
|
+
},
|
|
112
|
+
getModelsFor: async (_name?: string) => {
|
|
113
|
+
const list = await client.listModels();
|
|
114
|
+
return list.data.map((m) => m.id);
|
|
115
|
+
},
|
|
116
|
+
listAllModels: async () => {
|
|
117
|
+
const list = await client.listModels();
|
|
118
|
+
const out: Record<string, string[]> = {};
|
|
119
|
+
for (const m of list.data) {
|
|
120
|
+
const owner = m.owned_by || "server";
|
|
121
|
+
(out[owner] ??= []).push(m.id);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
},
|
|
125
|
+
healthAll: async () => client.providers(),
|
|
126
|
+
resetHealth: async () => { await client.resetHealth(); },
|
|
127
|
+
};
|
|
128
|
+
adapter.chat = async function* (messages: any, tools: any, opts?: any): AsyncGenerator<ChatChunk> {
|
|
129
|
+
const result: ChatResult = await client.chat(messages, {
|
|
130
|
+
model: opts?.model,
|
|
131
|
+
tools,
|
|
132
|
+
temperature: opts?.temperature,
|
|
133
|
+
maxTokens: opts?.maxTokens,
|
|
134
|
+
stream: false,
|
|
135
|
+
});
|
|
136
|
+
adapter.activeProvider = result.provider;
|
|
137
|
+
adapter.activeModel = result.model;
|
|
138
|
+
if (result.text) yield { type: "text", text: result.text } as ChatChunk;
|
|
139
|
+
for (const tc of result.toolCalls ?? []) {
|
|
140
|
+
yield { type: "tool_call", tool_call: tc } as ChatChunk;
|
|
141
|
+
}
|
|
142
|
+
yield { type: "done", usage: result.usage } as ChatChunk;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const registry = new ToolRegistry();
|
|
146
|
+
const factories = [
|
|
147
|
+
makeReadFileTool, makeWriteFileTool, makeEditFileTool, makeListDirTool, makeBashTool,
|
|
148
|
+
makeGlobTool, makeGrepTool, makeWebSearchTool, makeVisionTool, makeGitTool,
|
|
149
|
+
];
|
|
150
|
+
for (const make of factories) {
|
|
151
|
+
const tool = make(process.cwd());
|
|
152
|
+
registry.register(tool.def, tool.execute);
|
|
153
|
+
}
|
|
154
|
+
return new Agent(adapter, registry, { memory: new Memory(), modeManager: new ModeManager() });
|
|
155
|
+
}
|
|
156
|
+
export function createTUIContext(rootDir: string = process.cwd()) {
|
|
157
|
+
const cfg = getConfig();
|
|
158
|
+
const engine = new RouterEngine(undefined, undefined, KeyManager.instance());
|
|
159
|
+
const router = makeRouterAdapter(engine);
|
|
160
|
+
const registry = new ToolRegistry();
|
|
161
|
+
for (const make of [makeReadFileTool, makeWriteFileTool, makeEditFileTool, makeListDirTool, makeBashTool, makeGlobTool, makeGrepTool, makeWebSearchTool, makeVisionTool, makeGitTool]) {
|
|
162
|
+
const tool = make(rootDir);
|
|
163
|
+
registry.register(tool.def, tool.execute);
|
|
164
|
+
}
|
|
165
|
+
const agent = new Agent(router, registry, { memory: new Memory(), modeManager: new ModeManager() });
|
|
166
|
+
const session = new Session();
|
|
167
|
+
const arena = new Arena(router);
|
|
168
|
+
const costTracker = CostTracker.load();
|
|
169
|
+
const settings = Settings.load();
|
|
170
|
+
const skills = Skills.instance();
|
|
171
|
+
const checkpoint = Checkpoint.instance();
|
|
172
|
+
const tui = createTUI({ agent, router, session, arena, costTracker, settings, skills, checkpoint });
|
|
173
|
+
return { agent, router, session, arena, costTracker, settings, tui };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { RouterEngine, HealthTracker, KeyManager, getConfig, Agent, ToolRegistry, Session, Arena, createTUI, Memory, ModeManager, CostTracker, GitTool, Checkpoint, Skills, FreeRouterClient };
|
|
177
|
+
import { GitTool } from "./git.js";
|
|
178
|
+
import { Checkpoint } from "./checkpoint.js";
|
|
179
|
+
import { Skills } from "./skills.js";
|
|
180
|
+
|
|
181
|
+
// CLI entrypoint
|
|
182
|
+
function isMainModule(): boolean {
|
|
183
|
+
// Robust across tsx/Node and Windows path formatting.
|
|
184
|
+
const self = import.meta.url.replace(/\/$/g, "");
|
|
185
|
+
const argv1 = "file://" + path.resolve(process.argv[1] ?? "");
|
|
186
|
+
if (self === argv1) return true;
|
|
187
|
+
// Also match when invoked via `tsx` where argv may be a .ts source file.
|
|
188
|
+
try {
|
|
189
|
+
const selfPath = new URL(self).pathname;
|
|
190
|
+
const argvPath = path.resolve(process.argv[1] ?? "");
|
|
191
|
+
const norm = (p: string) => p.toLowerCase().replace(/\\/g, "/").replace(/^\//, "");
|
|
192
|
+
if (norm(selfPath) === norm(argvPath)) return true;
|
|
193
|
+
} catch {
|
|
194
|
+
// ignore
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (isMainModule()) {
|
|
200
|
+
// Parse CLI flags: --plan and --yolo set the agent mode and are stripped
|
|
201
|
+
// from the prompt args.
|
|
202
|
+
let modeFlag: string | null = null;
|
|
203
|
+
const promptArgs: string[] = [];
|
|
204
|
+
for (const arg of process.argv.slice(2)) {
|
|
205
|
+
if (arg === "--plan") {
|
|
206
|
+
modeFlag = "plan";
|
|
207
|
+
} else if (arg === "--yolo") {
|
|
208
|
+
modeFlag = "yolo";
|
|
209
|
+
} else {
|
|
210
|
+
promptArgs.push(arg);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const prompt = promptArgs.join(" ").trim();
|
|
214
|
+
|
|
215
|
+
if (!prompt) {
|
|
216
|
+
// Interactive TUI mode.
|
|
217
|
+
const { tui } = createTUIContext();
|
|
218
|
+
tui.start();
|
|
219
|
+
} else if (prompt.startsWith("/")) {
|
|
220
|
+
// A single command in non-interactive mode.
|
|
221
|
+
const { tui } = createTUIContext();
|
|
222
|
+
tui.handleCommand(prompt).then(() => process.exit(0)).catch((err) => {
|
|
223
|
+
process.stderr.write(`error: ${(err as Error).message}\n`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
// One-shot prompt: run the agent, print the answer, save the session.
|
|
228
|
+
(async () => {
|
|
229
|
+
try {
|
|
230
|
+
const agent = createAgent();
|
|
231
|
+
// Honor AETHER_MODEL / AETHER_PROVIDER env vars in one-shot mode so
|
|
232
|
+
// local Ollama models can be selected without an interactive TUI.
|
|
233
|
+
if (process.env.AETHER_PROVIDER) {
|
|
234
|
+
agent.router.setActiveProvider(process.env.AETHER_PROVIDER);
|
|
235
|
+
}
|
|
236
|
+
if (process.env.AETHER_MODEL) {
|
|
237
|
+
agent.router.setActiveModel(process.env.AETHER_MODEL);
|
|
238
|
+
}
|
|
239
|
+
const session = new Session();
|
|
240
|
+
const costTracker = CostTracker.load();
|
|
241
|
+
const settings = Settings.load();
|
|
242
|
+
const provider = agent.router.getActiveProvider() ?? "unknown";
|
|
243
|
+
const model = agent.router.getActiveModel() ?? "default";
|
|
244
|
+
if (modeFlag) agent.setMode(modeFlag);
|
|
245
|
+
|
|
246
|
+
let assistantText = "";
|
|
247
|
+
for await (const chunk of agent.run(prompt, session.messages)) {
|
|
248
|
+
if (chunk.type === "text" && chunk.text) {
|
|
249
|
+
process.stdout.write(chunk.text);
|
|
250
|
+
assistantText += chunk.text;
|
|
251
|
+
}
|
|
252
|
+
if (chunk.type === "tool_call" && chunk.tool_call) {
|
|
253
|
+
process.stderr.write(`\n[Tool: ${chunk.tool_call.function.name}]\n`);
|
|
254
|
+
}
|
|
255
|
+
if (chunk.type === "error" && chunk.error) {
|
|
256
|
+
process.stderr.write(`\n[error] ${chunk.error}\n`);
|
|
257
|
+
}
|
|
258
|
+
if (chunk.type === "done" && chunk.usage) {
|
|
259
|
+
costTracker.record(provider, model, chunk.usage.input_tokens, chunk.usage.output_tokens);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
CostTracker.save(costTracker);
|
|
263
|
+
|
|
264
|
+
if (agent.lastMessages.length > 0) {
|
|
265
|
+
session.messages = agent.lastMessages.filter((m) => m.role !== "system");
|
|
266
|
+
}
|
|
267
|
+
const sessions = Session.list();
|
|
268
|
+
const file = sessions[0]?.file ?? path.join(os.homedir(), ".aether", "sessions", "session.json");
|
|
269
|
+
Session.save(file, session);
|
|
270
|
+
process.stdout.write("\n");
|
|
271
|
+
} catch (err) {
|
|
272
|
+
process.stderr.write(`error: ${(err as Error).message}\n`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// API key manager for Aether.
|
|
2
|
+
//
|
|
3
|
+
// WARNING: Keys are stored PLAINTEXT in ~/.aether/keys.json on this machine.
|
|
4
|
+
// This is intentional for a local CLI tool running on your own machine --
|
|
5
|
+
// anyone with read access to that file can use your keys. Do not share the
|
|
6
|
+
// file, do not commit it, and do not run Aether on machines you do not trust.
|
|
7
|
+
// For temporary keys, prefer environment variables (they are never written
|
|
8
|
+
// to disk by this module).
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
// Provider name -> environment variable that may hold its API key.
|
|
15
|
+
export const ENV_MAP: Record<string, string> = {
|
|
16
|
+
"openrouter-free": "OPENROUTER_API_KEY",
|
|
17
|
+
groq: "GROQ_API_KEY",
|
|
18
|
+
mistral: "MISTRAL_API_KEY",
|
|
19
|
+
cohere: "COHERE_API_KEY",
|
|
20
|
+
huggingface: "HUGGINGFACE_API_KEY",
|
|
21
|
+
fireworks: "FIREWORKS_API_KEY",
|
|
22
|
+
together: "TOGETHER_API_KEY",
|
|
23
|
+
deepseek: "DEEPSEEK_API_KEY",
|
|
24
|
+
gemini: "GEMINI_API_KEY",
|
|
25
|
+
xai: "XAI_API_KEY",
|
|
26
|
+
perplexity: "PERPLEXITY_API_KEY",
|
|
27
|
+
cerebras: "CEREBRAS_API_KEY",
|
|
28
|
+
nvidia: "NVIDIA_NIM_API_KEY",
|
|
29
|
+
jina: "JINA_API_KEY",
|
|
30
|
+
parasail: "PARASAIL_API_KEY",
|
|
31
|
+
featherless: "FEATHERLESS_API_KEY",
|
|
32
|
+
voyage: "VOYAGE_API_KEY",
|
|
33
|
+
cloudflare: "CLOUDFLARE_API_KEY",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function keysPath(): string {
|
|
37
|
+
return path.join(os.homedir(), ".aether", "keys.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class KeyManager {
|
|
41
|
+
private keys: Map<string, string> = new Map();
|
|
42
|
+
private loaded = false;
|
|
43
|
+
|
|
44
|
+
private ensureLoaded(): void {
|
|
45
|
+
if (this.loaded) return;
|
|
46
|
+
this.loaded = true;
|
|
47
|
+
this.loadFromDisk();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private loadFromDisk(): void {
|
|
51
|
+
try {
|
|
52
|
+
const p = keysPath();
|
|
53
|
+
if (!fs.existsSync(p)) return;
|
|
54
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
55
|
+
const obj = JSON.parse(raw);
|
|
56
|
+
if (obj && typeof obj === "object") {
|
|
57
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
58
|
+
if (typeof v === "string" && v) this.keys.set(k, v);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Ignore a missing or corrupt keys file; we'll just have no keys.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Store a key (or remove it when given an empty/falsy value). */
|
|
67
|
+
set(providerName: string, key: string): void {
|
|
68
|
+
this.ensureLoaded();
|
|
69
|
+
if (key && key.trim()) this.keys.set(providerName, key.trim());
|
|
70
|
+
else this.keys.delete(providerName);
|
|
71
|
+
this.save();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(providerName: string): string | undefined {
|
|
75
|
+
this.ensureLoaded();
|
|
76
|
+
return this.keys.get(providerName);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
has(providerName: string): boolean {
|
|
80
|
+
this.ensureLoaded();
|
|
81
|
+
return this.keys.has(providerName);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remove(providerName: string): void {
|
|
85
|
+
this.ensureLoaded();
|
|
86
|
+
this.keys.delete(providerName);
|
|
87
|
+
this.save();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every known provider and whether a key is currently configured. */
|
|
91
|
+
list(): { provider: string; hasKey: boolean }[] {
|
|
92
|
+
this.ensureLoaded();
|
|
93
|
+
return Object.keys(ENV_MAP).map((provider) => ({
|
|
94
|
+
provider,
|
|
95
|
+
hasKey: this.keys.has(provider),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Scan process.env for known key env vars and load any found into the map. */
|
|
100
|
+
detectFromEnv(): this {
|
|
101
|
+
this.ensureLoaded();
|
|
102
|
+
for (const [provider, envVar] of Object.entries(ENV_MAP)) {
|
|
103
|
+
const v = process.env[envVar];
|
|
104
|
+
if (v && v.trim()) this.keys.set(provider, v.trim());
|
|
105
|
+
}
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Persist the current in-memory keys to disk atomically. */
|
|
110
|
+
save(): void {
|
|
111
|
+
this.ensureLoaded();
|
|
112
|
+
const file = keysPath();
|
|
113
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
114
|
+
const obj: Record<string, string> = {};
|
|
115
|
+
for (const [k, v] of this.keys) obj[k] = v;
|
|
116
|
+
const tmp = file + ".tmp";
|
|
117
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
|
|
118
|
+
fs.renameSync(tmp, file);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Singleton: load from disk AND env. */
|
|
122
|
+
static load(): KeyManager {
|
|
123
|
+
if (!KeyManager._instance) KeyManager._instance = new KeyManager();
|
|
124
|
+
const km = KeyManager._instance;
|
|
125
|
+
km.ensureLoaded();
|
|
126
|
+
km.detectFromEnv();
|
|
127
|
+
return km;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static instance(): KeyManager {
|
|
131
|
+
return KeyManager.load();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private static _instance: KeyManager | null = null;
|
|
135
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
|
|
5
|
+
const MEMORY_DIR = path.join(os.homedir(), ".aether", "memory");
|
|
6
|
+
|
|
7
|
+
export class Memory {
|
|
8
|
+
private facts: string[] = [];
|
|
9
|
+
private dirty = false;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
this.load();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static path(): string {
|
|
16
|
+
return path.join(MEMORY_DIR, "MEMORY.md");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
load(): void {
|
|
20
|
+
this.facts = [];
|
|
21
|
+
const file = Memory.path();
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(file)) {
|
|
24
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
25
|
+
for (const line of raw.split("\n")) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (trimmed.startsWith("- ")) {
|
|
28
|
+
const fact = trimmed.slice(2).trim();
|
|
29
|
+
if (fact) this.facts.push(fact);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore read errors; start with empty memory
|
|
35
|
+
}
|
|
36
|
+
this.dirty = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
save(): void {
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.existsSync(MEMORY_DIR)) {
|
|
42
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
const lines = this.facts.map((f) => `- ${f}`);
|
|
45
|
+
const content = lines.length ? lines.join("\n") + "\n" : "";
|
|
46
|
+
const tmp = Memory.path() + ".tmp";
|
|
47
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
48
|
+
fs.renameSync(tmp, Memory.path());
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
throw new Error(`Failed to save memory: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
this.dirty = false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
add(fact: string): void {
|
|
57
|
+
const trimmed = fact.trim();
|
|
58
|
+
if (!trimmed) return;
|
|
59
|
+
const lower = trimmed.toLowerCase();
|
|
60
|
+
for (let i = 0; i < this.facts.length; i++) {
|
|
61
|
+
if (this.facts[i].toLowerCase() === lower) {
|
|
62
|
+
// Move to end (refresh).
|
|
63
|
+
this.facts.splice(i, 1);
|
|
64
|
+
this.facts.push(trimmed);
|
|
65
|
+
this.dirty = true;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
this.facts.push(trimmed);
|
|
70
|
+
this.dirty = true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
remove(fact: string): void {
|
|
74
|
+
const trimmed = fact.trim();
|
|
75
|
+
if (!trimmed) return;
|
|
76
|
+
const lower = trimmed.toLowerCase();
|
|
77
|
+
const before = this.facts.length;
|
|
78
|
+
this.facts = this.facts.filter((f) => f.toLowerCase() !== lower);
|
|
79
|
+
if (this.facts.length !== before) this.dirty = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
search(query: string): string[] {
|
|
83
|
+
const q = query.trim().toLowerCase();
|
|
84
|
+
if (!q) return [];
|
|
85
|
+
return this.facts.filter((f) => f.toLowerCase().includes(q));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getAll(): string[] {
|
|
89
|
+
return this.facts.slice();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
toContext(): string {
|
|
93
|
+
if (this.facts.length === 0) return "";
|
|
94
|
+
const lines = this.facts.map((f) => `- ${f}`);
|
|
95
|
+
return "## Long-term Memory\n" + lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ensureSaved(): void {
|
|
99
|
+
if (this.dirty) this.save();
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/modes.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type ModeName = "plan" | "yolo" | "normal";
|
|
2
|
+
|
|
3
|
+
export class ModeManager {
|
|
4
|
+
static readonly MODES: Record<ModeName, ModeName> = {
|
|
5
|
+
plan: "plan",
|
|
6
|
+
yolo: "yolo",
|
|
7
|
+
normal: "normal",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
private mode: ModeName = "normal";
|
|
11
|
+
|
|
12
|
+
constructor(mode?: ModeName) {
|
|
13
|
+
if (mode) this.setMode(mode);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setMode(name: string): ModeName {
|
|
17
|
+
const key = name.trim().toLowerCase();
|
|
18
|
+
if (!(key in ModeManager.MODES)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Unknown mode "${name}". Available: ${Object.keys(ModeManager.MODES).join(", ")}`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
this.mode = key as ModeName;
|
|
24
|
+
return this.mode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getMode(): ModeName {
|
|
28
|
+
return this.mode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getSystemPromptModifier(): string {
|
|
32
|
+
switch (this.mode) {
|
|
33
|
+
case "plan":
|
|
34
|
+
return (
|
|
35
|
+
"You are in PLAN mode. Do NOT modify files or run commands. " +
|
|
36
|
+
"Explore only with ReadFile, Glob, Grep, ListDir. Produce a detailed plan."
|
|
37
|
+
);
|
|
38
|
+
case "yolo":
|
|
39
|
+
return (
|
|
40
|
+
"You are in YOLO mode. Proceed with all actions without asking for " +
|
|
41
|
+
"confirmation. Be efficient and direct."
|
|
42
|
+
);
|
|
43
|
+
default:
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
requiresConfirmation(toolName: string): boolean {
|
|
49
|
+
switch (this.mode) {
|
|
50
|
+
case "plan":
|
|
51
|
+
// Plan mode should not even expose these tools; if called, block.
|
|
52
|
+
return true;
|
|
53
|
+
case "yolo":
|
|
54
|
+
return false;
|
|
55
|
+
default:
|
|
56
|
+
// Normal mode: confirm destructive actions.
|
|
57
|
+
return (
|
|
58
|
+
toolName === "Bash" ||
|
|
59
|
+
toolName === "WriteFile" ||
|
|
60
|
+
toolName === "EditFile"
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
allowedTools(mode?: ModeName): string[] {
|
|
66
|
+
const m = (mode ?? this.mode) as ModeName;
|
|
67
|
+
switch (m) {
|
|
68
|
+
case "plan":
|
|
69
|
+
return ["ReadFile", "Glob", "Grep", "ListDir"];
|
|
70
|
+
case "yolo":
|
|
71
|
+
case "normal":
|
|
72
|
+
default:
|
|
73
|
+
return [
|
|
74
|
+
"ReadFile",
|
|
75
|
+
"WriteFile",
|
|
76
|
+
"EditFile",
|
|
77
|
+
"ListDir",
|
|
78
|
+
"Bash",
|
|
79
|
+
"Glob",
|
|
80
|
+
"Grep",
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatChunk,
|
|
3
|
+
HealthStatus,
|
|
4
|
+
Message,
|
|
5
|
+
ProviderConfig,
|
|
6
|
+
ToolDef,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
|
|
9
|
+
export type { ProviderConfig };
|
|
10
|
+
|
|
11
|
+
export interface Provider {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly config: ProviderConfig;
|
|
14
|
+
listModels(): Promise<string[]>;
|
|
15
|
+
chat(
|
|
16
|
+
messages: Message[],
|
|
17
|
+
tools: ToolDef[],
|
|
18
|
+
opts?: { signal?: AbortSignal; temperature?: number; maxTokens?: number }
|
|
19
|
+
): AsyncIterable<ChatChunk>;
|
|
20
|
+
health(): Promise<Omit<HealthStatus, "provider">>;
|
|
21
|
+
countTokens?(text: string): Promise<number>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchWithTimeout(
|
|
25
|
+
url: string,
|
|
26
|
+
opts: RequestInit = {},
|
|
27
|
+
timeoutMs: number
|
|
28
|
+
): Promise<Response> {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
31
|
+
const signal = opts.signal
|
|
32
|
+
? AbortSignal.any([controller.signal, opts.signal])
|
|
33
|
+
: controller.signal;
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(url, { ...opts, signal });
|
|
36
|
+
return res;
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createProvider(config: ProviderConfig): Promise<Provider> {
|
|
43
|
+
switch (config.type) {
|
|
44
|
+
case "ollama": {
|
|
45
|
+
const { OllamaProvider } = await import("./ollama.js");
|
|
46
|
+
return new OllamaProvider(config);
|
|
47
|
+
}
|
|
48
|
+
case "openrouter": {
|
|
49
|
+
const { OpenRouterProvider } = await import("./openrouter.js");
|
|
50
|
+
return new OpenRouterProvider(config);
|
|
51
|
+
}
|
|
52
|
+
case "openai-compatible": {
|
|
53
|
+
const { OpenAICompatProvider } = await import("./openai-compat.js");
|
|
54
|
+
return new OpenAICompatProvider(config);
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
throw new Error(`Unknown provider type: ${config.type}`);
|
|
58
|
+
}
|
|
59
|
+
}
|