@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.
Files changed (90) hide show
  1. package/.gitattributes +3 -0
  2. package/.github/workflows/live-stats.yml +42 -0
  3. package/.github/workflows/publish.yml +34 -0
  4. package/.github/workflows/update-preview.yml +41 -0
  5. package/INSTALL.md +59 -0
  6. package/LICENSE +21 -0
  7. package/README.md +397 -0
  8. package/assets/aether-arena.svg +72 -0
  9. package/assets/aether-banner.svg +62 -0
  10. package/assets/aether-router.svg +129 -0
  11. package/dist/agent.js +125 -0
  12. package/dist/arena.js +486 -0
  13. package/dist/checkpoint.js +105 -0
  14. package/dist/client.js +95 -0
  15. package/dist/combos.js +176 -0
  16. package/dist/commands.js +483 -0
  17. package/dist/config.js +104 -0
  18. package/dist/cost.js +176 -0
  19. package/dist/git.js +52 -0
  20. package/dist/health.js +81 -0
  21. package/dist/index.js +272 -0
  22. package/dist/keys.js +128 -0
  23. package/dist/memory.js +98 -0
  24. package/dist/modes.js +68 -0
  25. package/dist/providers/index.js +32 -0
  26. package/dist/providers/ollama.js +206 -0
  27. package/dist/providers/openai-compat.js +181 -0
  28. package/dist/providers/openrouter.js +189 -0
  29. package/dist/providers/registry.js +211 -0
  30. package/dist/router-engine.js +200 -0
  31. package/dist/router.js +171 -0
  32. package/dist/server.js +210 -0
  33. package/dist/session.js +97 -0
  34. package/dist/settings.js +97 -0
  35. package/dist/skills.js +100 -0
  36. package/dist/tokensaver.js +50 -0
  37. package/dist/tools/filesystem.js +243 -0
  38. package/dist/tools/git.js +53 -0
  39. package/dist/tools/glob.js +175 -0
  40. package/dist/tools/grep.js +193 -0
  41. package/dist/tools/registry.js +39 -0
  42. package/dist/tools/vision.js +140 -0
  43. package/dist/tools/websearch.js +118 -0
  44. package/dist/tui.js +562 -0
  45. package/dist/types.js +8 -0
  46. package/docs/preview.txt +51 -0
  47. package/docs/screenshots.md +110 -0
  48. package/docs/stats.md +5 -0
  49. package/install.ps1 +170 -0
  50. package/install.sh +196 -0
  51. package/package.json +34 -0
  52. package/scripts/generate-stats-card.ts +62 -0
  53. package/scripts/patch_index.ps1 +17 -0
  54. package/scripts/release.sh +7 -0
  55. package/src/agent.ts +146 -0
  56. package/src/arena.ts +584 -0
  57. package/src/checkpoint.ts +111 -0
  58. package/src/client.ts +172 -0
  59. package/src/combos.ts +199 -0
  60. package/src/commands.ts +973 -0
  61. package/src/config.ts +122 -0
  62. package/src/cost.ts +206 -0
  63. package/src/git.ts +68 -0
  64. package/src/health.ts +90 -0
  65. package/src/index.ts +281 -0
  66. package/src/keys.ts +135 -0
  67. package/src/memory.ts +101 -0
  68. package/src/modes.ts +84 -0
  69. package/src/providers/index.ts +59 -0
  70. package/src/providers/ollama.ts +222 -0
  71. package/src/providers/openai-compat.ts +188 -0
  72. package/src/providers/openrouter.ts +198 -0
  73. package/src/providers/registry.ts +223 -0
  74. package/src/router-engine.ts +214 -0
  75. package/src/router.ts +195 -0
  76. package/src/server.ts +242 -0
  77. package/src/session.ts +111 -0
  78. package/src/settings.ts +125 -0
  79. package/src/skills.ts +106 -0
  80. package/src/tokensaver.ts +57 -0
  81. package/src/tools/filesystem.ts +258 -0
  82. package/src/tools/git.ts +53 -0
  83. package/src/tools/glob.ts +180 -0
  84. package/src/tools/grep.ts +192 -0
  85. package/src/tools/registry.ts +54 -0
  86. package/src/tools/vision.ts +152 -0
  87. package/src/tools/websearch.ts +130 -0
  88. package/src/tui.ts +664 -0
  89. package/src/types.ts +77 -0
  90. package/tsconfig.json +16 -0
package/src/server.ts ADDED
@@ -0,0 +1,242 @@
1
+ import * as http from "node:http";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { RouterEngine } from "./router-engine.js";
5
+ import type { ChatChunk } from "./types.js";
6
+
7
+ export interface ServerOptions {
8
+ port?: number;
9
+ host?: string;
10
+ engine?: RouterEngine;
11
+ }
12
+
13
+ function parseJsonBody(req: http.IncomingMessage): Promise<any> {
14
+ return new Promise((resolve, reject) => {
15
+ const chunks: Buffer[] = [];
16
+ req.on("data", (c: Buffer) => chunks.push(c));
17
+ req.on("end", () => {
18
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
19
+ if (!raw) return resolve({});
20
+ try {
21
+ resolve(JSON.parse(raw));
22
+ } catch {
23
+ reject(new Error("Invalid JSON body"));
24
+ }
25
+ });
26
+ req.on("error", reject);
27
+ });
28
+ }
29
+
30
+ function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
31
+ const payload = JSON.stringify(body);
32
+ res.writeHead(status, {
33
+ "Content-Type": "application/json",
34
+ "Content-Length": Buffer.byteLength(payload).toString(),
35
+ });
36
+ res.end(payload);
37
+ }
38
+
39
+ function sendSSE(res: http.ServerResponse, chunk: ChatChunk): void {
40
+ const data = JSON.stringify(chunk);
41
+ res.write(`data: ${data}\n\n`);
42
+ }
43
+
44
+ async function writeSSEStream(
45
+ res: http.ServerResponse,
46
+ gen: AsyncGenerator<ChatChunk>
47
+ ): Promise<void> {
48
+ try {
49
+ for await (const chunk of gen) {
50
+ if (res.writableEnded) break;
51
+ sendSSE(res, chunk);
52
+ }
53
+ } finally {
54
+ if (!res.writableEnded) {
55
+ res.write("data: [DONE]\n\n");
56
+ res.end();
57
+ }
58
+ }
59
+ }
60
+
61
+ function modelsToList(models: Record<string, string[]>) {
62
+ const data: { id: string; object: string; owned_by?: string }[] = [];
63
+ for (const [provider, list] of Object.entries(models)) {
64
+ for (const id of list) {
65
+ data.push({ id, object: "model", owned_by: provider });
66
+ }
67
+ }
68
+ return { object: "list", data };
69
+ }
70
+
71
+ export function createServer(opts: ServerOptions = {}): http.Server {
72
+ const engine = opts.engine ?? new RouterEngine();
73
+
74
+ const server = http.createServer(async (req, res) => {
75
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
76
+ const path = url.pathname;
77
+ const method = req.method ?? "GET";
78
+
79
+ res.setHeader("Access-Control-Allow-Origin", "*");
80
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
81
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
82
+
83
+ if (method === "OPTIONS") {
84
+ res.writeHead(204);
85
+ res.end();
86
+ return;
87
+ }
88
+
89
+ try {
90
+ if (method === "GET" && path === "/health") {
91
+ const [all, healthies] = await Promise.all([
92
+ engine.listFreeModels(),
93
+ engine.healthAll(),
94
+ ]);
95
+ const providerCount = Object.keys(all).length;
96
+ const healthyCount = healthies.filter((h) => h.healthy).length;
97
+ return sendJson(res, 200, {
98
+ status: "ok",
99
+ providers: providerCount,
100
+ healthy: healthyCount,
101
+ });
102
+ }
103
+
104
+ if (method === "GET" && (path === "/v1/models" || path === "/models")) {
105
+ const models = await engine.listFreeModels();
106
+ return sendJson(res, 200, modelsToList(models));
107
+ }
108
+
109
+ if (method === "GET" && path === "/providers") {
110
+ const statuses = await engine.healthAll();
111
+ return sendJson(res, 200, statuses);
112
+ }
113
+
114
+ if (method === "POST" && path === "/reset-health") {
115
+ engine.resetHealth();
116
+ return sendJson(res, 200, { status: "ok", message: "Health state reset" });
117
+ }
118
+
119
+ if (method === "POST" && path === "/v1/chat/completions") {
120
+ const body = await parseJsonBody(req);
121
+ const { model, messages, tools, stream, temperature, max_tokens } = body ?? {};
122
+ if (!Array.isArray(messages)) {
123
+ return sendJson(res, 400, { error: { message: "messages array is required", type: "invalid_request_error" } });
124
+ }
125
+
126
+ const opts = {
127
+ temperature: typeof temperature === "number" ? temperature : undefined,
128
+ maxTokens: typeof max_tokens === "number" ? max_tokens : undefined,
129
+ };
130
+
131
+ if (stream) {
132
+ res.writeHead(200, {
133
+ "Content-Type": "text/event-stream",
134
+ "Cache-Control": "no-cache",
135
+ Connection: "keep-alive",
136
+ });
137
+ const gen = engine.chatStream(messages, tools ?? [], opts);
138
+ await writeSSEStream(res, gen);
139
+ return;
140
+ }
141
+
142
+ const result = await engine.chat(messages, tools ?? [], opts);
143
+ const chosenModel = model || result.model;
144
+ return sendJson(res, 200, {
145
+ id: `chatcmpl-${Date.now()}`,
146
+ object: "chat.completion",
147
+ created: Math.floor(Date.now() / 1000),
148
+ model: chosenModel,
149
+ choices: [
150
+ {
151
+ index: 0,
152
+ message: {
153
+ role: "assistant",
154
+ content: result.text,
155
+ ...(result.toolCalls.length
156
+ ? { tool_calls: result.toolCalls.map((tc: any) => ({
157
+ id: tc.id || `call_${Date.now()}`,
158
+ type: "function",
159
+ function: { name: tc.function.name, arguments: tc.function.arguments ?? "" },
160
+ })) }
161
+ : {}),
162
+ },
163
+ finish_reason: result.toolCalls.length ? "tool_calls" : "stop",
164
+ },
165
+ ],
166
+ usage: result.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
167
+ _aether: {
168
+ attempts: result.attempts,
169
+ provider: result.provider,
170
+ },
171
+ });
172
+ }
173
+
174
+ if (method === "POST" && path === "/v1/chat") {
175
+ const body = await parseJsonBody(req);
176
+ const { model, messages, tools, temperature, max_tokens } = body ?? {};
177
+ if (!Array.isArray(messages)) {
178
+ return sendJson(res, 400, { error: "messages array is required" });
179
+ }
180
+ const result = await engine.chat(messages, tools ?? [], {
181
+ temperature: typeof temperature === "number" ? temperature : undefined,
182
+ maxTokens: typeof max_tokens === "number" ? max_tokens : undefined,
183
+ });
184
+ return sendJson(res, 200, {
185
+ text: result.text,
186
+ toolCalls: result.toolCalls,
187
+ usage: result.usage,
188
+ attempts: result.attempts,
189
+ provider: result.provider,
190
+ model: model || result.model,
191
+ });
192
+ }
193
+
194
+ sendJson(res, 404, { error: { message: `Not found: ${method} ${path}`, type: "not_found_error" } });
195
+ } catch (err) {
196
+ const msg = (err as Error).message;
197
+ sendJson(res, 500, { error: { message: msg, type: "internal_error" } });
198
+ }
199
+ });
200
+
201
+ return server;
202
+ }
203
+
204
+ export function startServer(opts: ServerOptions = {}): http.Server {
205
+ const envPort = Number(process.env.AETHER_PORT);
206
+ const port = opts.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : 31415);
207
+ const host = opts.host ?? "0.0.0.0";
208
+ const engine = opts.engine ?? new RouterEngine();
209
+ const server = createServer({ ...opts, engine });
210
+ server.listen(port, host, () => {
211
+ const providerCount = engine.configs_.filter((c) => c.enabled).length;
212
+ console.log(`Aether free-model server running at http://localhost:${port}`);
213
+ console.log(`Endpoints:`);
214
+ console.log(` GET /health`);
215
+ console.log(` GET /v1/models`);
216
+ console.log(` POST /v1/chat/completions`);
217
+ console.log(` POST /v1/chat`);
218
+ console.log(` GET /providers`);
219
+ console.log(` POST /reset-health`);
220
+ console.log(`Providers: ${providerCount} configured`);
221
+ });
222
+ return server;
223
+ }
224
+
225
+
226
+
227
+
228
+ if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
229
+ try {
230
+ const server = startServer();
231
+ server.on("error", (err: NodeJS.ErrnoException) => {
232
+ console.error("Failed to start server:", err);
233
+ process.exit(1);
234
+ });
235
+ } catch (err) {
236
+ console.error("Failed to start server:", err);
237
+ process.exit(1);
238
+ }
239
+ }
240
+
241
+
242
+
package/src/session.ts ADDED
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import type { Message } from "./types.js";
5
+ import { compressHistory } from "./tokensaver.js";
6
+
7
+ const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".aether", "sessions");
8
+
9
+ export interface SessionData {
10
+ version: number;
11
+ createdAt: number;
12
+ updatedAt: number;
13
+ messages: Message[];
14
+ }
15
+
16
+ export class Session {
17
+ messages: Message[] = [];
18
+ createdAt: number = Date.now();
19
+ updatedAt: number = Date.now();
20
+
21
+ add(m: Message): void {
22
+ this.messages.push(m);
23
+ this.updatedAt = Date.now();
24
+ }
25
+
26
+ clear(): void {
27
+ this.messages = [];
28
+ this.updatedAt = Date.now();
29
+ }
30
+
31
+ get size(): number {
32
+ return this.messages.length;
33
+ }
34
+
35
+ toJSON(): SessionData {
36
+ return {
37
+ version: 1,
38
+ createdAt: this.createdAt,
39
+ updatedAt: this.updatedAt,
40
+ messages: this.messages,
41
+ };
42
+ }
43
+
44
+ fromJSON(obj: SessionData): Session {
45
+ this.messages = obj.messages ?? [];
46
+ this.createdAt = obj.createdAt ?? Date.now();
47
+ this.updatedAt = obj.updatedAt ?? Date.now();
48
+ return this;
49
+ }
50
+
51
+ summarizeOld(router: any, keepRecent: number = 6): Message[] {
52
+ // Keep the most recent messages and compress older ones via tokensaver.
53
+ // If a router is available, a weak model could be asked to summarize the
54
+ // dropped tail; we keep it simple and fall back to compressHistory.
55
+ if (this.messages.length <= keepRecent) {
56
+ return this.messages.slice();
57
+ }
58
+ const recent = this.messages.slice(-keepRecent);
59
+ const older = this.messages.slice(0, -keepRecent);
60
+ const compressed = compressHistory(older, 4096);
61
+ return [...compressed, ...recent];
62
+ }
63
+
64
+ static load(filePath: string): Session {
65
+ const session = new Session();
66
+ try {
67
+ if (!fs.existsSync(filePath)) return session;
68
+ const raw = fs.readFileSync(filePath, "utf8");
69
+ const obj = JSON.parse(raw);
70
+ session.fromJSON(obj);
71
+ } catch {
72
+ // ignore malformed sessions
73
+ }
74
+ return session;
75
+ }
76
+
77
+ static save(filePath: string, session: Session): void {
78
+ const dir = path.dirname(filePath);
79
+ if (!fs.existsSync(dir)) {
80
+ fs.mkdirSync(dir, { recursive: true });
81
+ }
82
+ const tmp = filePath + ".tmp";
83
+ fs.writeFileSync(tmp, JSON.stringify(session.toJSON(), null, 2), "utf8");
84
+ fs.renameSync(tmp, filePath);
85
+ }
86
+
87
+ static list(dir: string = DEFAULT_SESSION_DIR): Array<{ file: string; mtime: number; size: number }> {
88
+ try {
89
+ if (!fs.existsSync(dir)) return [];
90
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
91
+ const out: Array<{ file: string; mtime: number; size: number }> = [];
92
+ for (const e of entries) {
93
+ if (!e.isFile()) continue;
94
+ if (!e.name.endsWith(".json")) continue;
95
+ const full = path.join(dir, e.name);
96
+ try {
97
+ const st = fs.statSync(full);
98
+ out.push({ file: full, mtime: st.mtimeMs, size: st.size });
99
+ } catch {
100
+ // skip
101
+ }
102
+ }
103
+ out.sort((a, b) => b.mtime - a.mtime);
104
+ return out;
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+ }
110
+
111
+
@@ -0,0 +1,125 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+
5
+ export interface SettingsData {
6
+ theme: string;
7
+ streaming: boolean;
8
+ maxSteps: number;
9
+ temperature: number;
10
+ confirmTools: boolean;
11
+ autoSave: boolean;
12
+ }
13
+
14
+ const SETTINGS_DIR = path.join(os.homedir(), ".aether");
15
+ const SETTINGS_FILE = path.join(SETTINGS_DIR, "settings.json");
16
+ const SETTINGS_VERSION = 1;
17
+
18
+ const DEFAULTS: SettingsData = {
19
+ theme: "dark",
20
+ streaming: true,
21
+ maxSteps: 15,
22
+ temperature: 0.7,
23
+ confirmTools: true,
24
+ autoSave: true,
25
+ };
26
+
27
+ interface SettingsSnapshot {
28
+ version: number;
29
+ settings: Partial<SettingsData>;
30
+ }
31
+
32
+ function isBool(v: unknown): v is boolean {
33
+ return typeof v === "boolean";
34
+ }
35
+
36
+ function isNum(v: unknown): v is number {
37
+ return typeof v === "number" && Number.isFinite(v);
38
+ }
39
+
40
+ function isStr(v: unknown): v is string {
41
+ return typeof v === "string";
42
+ }
43
+
44
+ function coerce(key: keyof SettingsData, v: unknown): any {
45
+ const def = DEFAULTS[key];
46
+ if (typeof def === "boolean") return isBool(v) ? v : def;
47
+ if (typeof def === "number") return isNum(v) ? v : def;
48
+ return isStr(v) ? v : def;
49
+ }
50
+
51
+ export class Settings {
52
+ private settings: SettingsData = { ...DEFAULTS };
53
+ private dirty = false;
54
+
55
+ constructor() {}
56
+
57
+ get(key: keyof SettingsData): SettingsData[keyof SettingsData] {
58
+ return this.settings[key];
59
+ }
60
+
61
+ set(key: keyof SettingsData, value: SettingsData[keyof SettingsData]): void {
62
+ (this.settings as any)[key] = coerce(key, value);
63
+ this.dirty = true;
64
+ }
65
+
66
+ getAll(): SettingsData {
67
+ return { ...this.settings };
68
+ }
69
+
70
+ reset(): void {
71
+ this.settings = { ...DEFAULTS };
72
+ this.dirty = true;
73
+ }
74
+
75
+ save(): void {
76
+ try {
77
+ if (!fs.existsSync(SETTINGS_DIR)) {
78
+ fs.mkdirSync(SETTINGS_DIR, { recursive: true });
79
+ }
80
+ const snapshot: SettingsSnapshot = { version: SETTINGS_VERSION, settings: { ...this.settings } };
81
+ const tmp = SETTINGS_FILE + ".tmp";
82
+ fs.writeFileSync(tmp, JSON.stringify(snapshot, null, 2), "utf8");
83
+ fs.renameSync(tmp, SETTINGS_FILE);
84
+ } catch {
85
+ // best-effort persistence
86
+ }
87
+ this.dirty = false;
88
+ }
89
+
90
+ private restore(snapshot: SettingsSnapshot): void {
91
+ if (!snapshot || typeof snapshot !== "object") return;
92
+ const src = snapshot.settings ?? {};
93
+ for (const key of Object.keys(DEFAULTS) as (keyof SettingsData)[]) {
94
+ if (key in src) {
95
+ (this.settings as any)[key] = coerce(key, (src as any)[key]);
96
+ }
97
+ }
98
+ }
99
+
100
+ static load(): Settings {
101
+ const s = new Settings();
102
+ try {
103
+ if (fs.existsSync(SETTINGS_FILE)) {
104
+ const raw = fs.readFileSync(SETTINGS_FILE, "utf8");
105
+ s.restore(JSON.parse(raw));
106
+ }
107
+ } catch {
108
+ // ignore malformed settings file
109
+ }
110
+ return s;
111
+ }
112
+
113
+ private static instanceCache = new Map<string, Settings>();
114
+
115
+ static instance(name: string = "default"): Settings {
116
+ let s = Settings.instanceCache.get(name);
117
+ if (!s) {
118
+ s = name === "default" ? Settings.load() : new Settings();
119
+ Settings.instanceCache.set(name, s);
120
+ }
121
+ return s;
122
+ }
123
+ }
124
+
125
+
package/src/skills.ts ADDED
@@ -0,0 +1,106 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+
5
+ export interface Skill {
6
+ name: string;
7
+ description: string;
8
+ arguments: string[];
9
+ template: string;
10
+ }
11
+
12
+ export class Skills {
13
+ private dir: string;
14
+ private skills: Map<string, Skill>;
15
+
16
+ constructor(dir?: string) {
17
+ this.dir = dir ?? path.join(os.homedir(), ".aether", "skills");
18
+ this.skills = new Map();
19
+ this.load();
20
+ }
21
+
22
+ private load(): void {
23
+ try {
24
+ if (!fs.existsSync(this.dir)) {
25
+ this.ensureDefault();
26
+ return;
27
+ }
28
+ const files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".md"));
29
+ for (const f of files) {
30
+ const skill = this.parseFile(path.join(this.dir, f));
31
+ if (skill) this.skills.set(skill.name, skill);
32
+ }
33
+ if (this.skills.size === 0) this.ensureDefault();
34
+ } catch {
35
+ this.ensureDefault();
36
+ }
37
+ }
38
+
39
+ private ensureDefault(): void {
40
+ const def: Skill = {
41
+ name: "explain",
42
+ description: "Explain a concept in simple terms",
43
+ arguments: ["topic"],
44
+ template: "Explain {{topic}} in simple terms with an analogy. Keep it under 200 words."
45
+ };
46
+ this.skills.set(def.name, def);
47
+ try {
48
+ fs.mkdirSync(this.dir, { recursive: true });
49
+ const file = path.join(this.dir, "explain.md");
50
+ if (!fs.existsSync(file)) {
51
+ 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");
52
+ }
53
+ } catch {
54
+ // best effort
55
+ }
56
+ }
57
+
58
+ private parseFile(file: string): Skill | null {
59
+ try {
60
+ const raw = fs.readFileSync(file, "utf8");
61
+ const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
62
+ if (!fmMatch) return null;
63
+ const fm = fmMatch[1];
64
+ const body = fmMatch[2].trim();
65
+ const name = fm.match(/^name:\s*(.+)$/m)?.[1].trim() ?? "";
66
+ const description = fm.match(/^description:\s*(.+)$/m)?.[1].trim() ?? "";
67
+ const argsSection = fm.match(/^arguments:\s*\n((?:\s+-\s*.+\n?)+)/m);
68
+ const args: string[] = [];
69
+ if (argsSection) {
70
+ const re = /^\s+-\s*(.+)$/gm;
71
+ let m;
72
+ while ((m = re.exec(argsSection[1])) !== null) args.push(m[1].trim());
73
+ }
74
+ if (!name) return null;
75
+ return { name, description, arguments: args, template: body };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ list(): Skill[] {
82
+ return Array.from(this.skills.values());
83
+ }
84
+
85
+ get(name: string): Skill | undefined {
86
+ return this.skills.get(name);
87
+ }
88
+
89
+ render(name: string, args: Record<string, string>): string {
90
+ const skill = this.skills.get(name);
91
+ if (!skill) return `ERROR: unknown skill "${name}"`;
92
+ let out = skill.template;
93
+ for (const [k, v] of Object.entries(args)) {
94
+ out = out.replace(new RegExp(`{{${k}}}`, "g"), v);
95
+ }
96
+ // Replace any remaining {{arg}} with the joined args string.
97
+ out = out.replace(/{{(\w+)}}/g, (_, k) => args[k] ?? "");
98
+ return out;
99
+ }
100
+
101
+ private static instance_: Skills | null = null;
102
+ static instance(): Skills {
103
+ if (!Skills.instance_) Skills.instance_ = new Skills();
104
+ return Skills.instance_;
105
+ }
106
+ }
@@ -0,0 +1,57 @@
1
+ import type { Message } from "./types.js";
2
+
3
+ export function estimateTokens(text: string): number {
4
+ if (!text) return 0;
5
+ return Math.ceil(text.split(/\s+/).filter(Boolean).length * 1.3);
6
+ }
7
+
8
+ export function estimateMessageTokens(m: Message): number {
9
+ let n = estimateTokens(typeof m.content === "string" ? m.content : m.content.map((p) => p.type === "text" ? p.text : "").join(""));
10
+ if (m.tool_calls) {
11
+ for (const tc of m.tool_calls) {
12
+ n += estimateTokens(tc.function.name) + estimateTokens(tc.function.arguments);
13
+ }
14
+ }
15
+ return n + 4; // role/name overhead
16
+ }
17
+
18
+ export function compressText(text: string): string {
19
+ return text
20
+ .replace(/\s+/g, " ")
21
+ .trim();
22
+ }
23
+
24
+ export function compressHistory(
25
+ messages: Message[],
26
+ maxTokens: number
27
+ ): Message[] {
28
+ if (messages.length === 0) return [];
29
+
30
+ const system = messages.find((m) => m.role === "system");
31
+ const rest = system ? messages.slice(1) : messages;
32
+
33
+ let budget = maxTokens;
34
+ if (system) budget -= estimateMessageTokens(system);
35
+
36
+ const kept: Message[] = [];
37
+ let used = 0;
38
+ // Walk from the newest backwards.
39
+ for (let i = rest.length - 1; i >= 0; i--) {
40
+ const cost = estimateMessageTokens(rest[i]);
41
+ if (used + cost > budget) break;
42
+ kept.unshift(rest[i]);
43
+ used += cost;
44
+ }
45
+
46
+ const dropped = rest.length - kept.length;
47
+ const result: Message[] = [];
48
+ if (system) result.push(system);
49
+ if (dropped > 0) {
50
+ result.push({
51
+ role: "system",
52
+ content: `[${dropped} earlier message(s) omitted to fit context]`,
53
+ });
54
+ }
55
+ result.push(...kept);
56
+ return result;
57
+ }