@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
const MAX_RESULTS = 5000;
|
|
4
|
+
/**
|
|
5
|
+
* Match a single path segment against a glob segment.
|
|
6
|
+
* - `*` matches any characters except `/`
|
|
7
|
+
* - `**` matches any number of path segments (including zero)
|
|
8
|
+
* - `?` matches exactly one character except `/`
|
|
9
|
+
* - `[...]` character class
|
|
10
|
+
*/
|
|
11
|
+
function matchSegment(segment, value) {
|
|
12
|
+
if (segment === "**")
|
|
13
|
+
return true;
|
|
14
|
+
if (segment === value)
|
|
15
|
+
return true;
|
|
16
|
+
// Build a regex from the segment.
|
|
17
|
+
let re = "";
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < segment.length) {
|
|
20
|
+
const ch = segment[i];
|
|
21
|
+
if (ch === "*") {
|
|
22
|
+
if (segment[i + 1] === "*") {
|
|
23
|
+
// `**` within a segment means match anything including /
|
|
24
|
+
re += ".*";
|
|
25
|
+
i += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
re += "[^/]*";
|
|
29
|
+
i += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === "?") {
|
|
33
|
+
re += "[^/]";
|
|
34
|
+
i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === "[") {
|
|
38
|
+
let j = i + 1;
|
|
39
|
+
if (j < segment.length && segment[j] === "!")
|
|
40
|
+
j++;
|
|
41
|
+
if (j < segment.length && segment[j] === "]")
|
|
42
|
+
j++;
|
|
43
|
+
while (j < segment.length && segment[j] !== "]")
|
|
44
|
+
j++;
|
|
45
|
+
if (j >= segment.length) {
|
|
46
|
+
// No closing bracket - treat literally.
|
|
47
|
+
re += "\\[";
|
|
48
|
+
i += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let cls = segment.slice(i + 1, j);
|
|
52
|
+
if (cls.startsWith("!"))
|
|
53
|
+
cls = "^" + cls.slice(1);
|
|
54
|
+
re += "[" + cls + "]";
|
|
55
|
+
i = j + 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
return new RegExp("^" + re + "$").test(value);
|
|
62
|
+
}
|
|
63
|
+
function splitPattern(pattern) {
|
|
64
|
+
return pattern.replace(/\\/g, "/").replace(/^\.\//, "").split("/").filter((s) => s !== "");
|
|
65
|
+
}
|
|
66
|
+
function* walkMatches(segments, prefix, cwd, depth) {
|
|
67
|
+
const seg = segments[depth];
|
|
68
|
+
const isLast = depth === segments.length - 1;
|
|
69
|
+
if (seg === "**") {
|
|
70
|
+
// `**` matches the current directory and any depth below it.
|
|
71
|
+
if (isLast) {
|
|
72
|
+
yield prefix;
|
|
73
|
+
}
|
|
74
|
+
// Recurse into the current directory.
|
|
75
|
+
yield* walkMatches(segments, prefix, cwd, depth + 1);
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const e of entries) {
|
|
84
|
+
if (e.name.startsWith("."))
|
|
85
|
+
continue;
|
|
86
|
+
const sub = path.join(cwd, e.name);
|
|
87
|
+
const rel = prefix ? prefix + "/" + e.name : e.name;
|
|
88
|
+
if (e.isDirectory()) {
|
|
89
|
+
yield* walkMatches(segments, rel, sub, depth);
|
|
90
|
+
}
|
|
91
|
+
else if (isLast) {
|
|
92
|
+
yield rel;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
if (e.name.startsWith("."))
|
|
106
|
+
continue;
|
|
107
|
+
if (!matchSegment(seg, e.name))
|
|
108
|
+
continue;
|
|
109
|
+
const sub = path.join(cwd, e.name);
|
|
110
|
+
const rel = prefix ? prefix + "/" + e.name : e.name;
|
|
111
|
+
if (isLast) {
|
|
112
|
+
yield rel;
|
|
113
|
+
}
|
|
114
|
+
else if (e.isDirectory()) {
|
|
115
|
+
yield* walkMatches(segments, rel, sub, depth + 1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function makeGlobTool(rootDir) {
|
|
120
|
+
return {
|
|
121
|
+
def: {
|
|
122
|
+
name: "Glob",
|
|
123
|
+
description: "Find files matching a glob pattern (e.g. **/*.ts, src/*.js). Returns matching paths relative to project root, sorted.",
|
|
124
|
+
parameters: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
pattern: {
|
|
128
|
+
type: "string",
|
|
129
|
+
description: "glob pattern like **/*.ts",
|
|
130
|
+
},
|
|
131
|
+
path: {
|
|
132
|
+
type: "string",
|
|
133
|
+
default: ".",
|
|
134
|
+
description: "root dir to search",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
required: ["pattern"],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
execute: async (args) => {
|
|
141
|
+
const pattern = String(args.pattern ?? "").trim();
|
|
142
|
+
if (!pattern) {
|
|
143
|
+
return "ERROR: pattern is required";
|
|
144
|
+
}
|
|
145
|
+
const relRoot = String(args.path ?? ".").trim() || ".";
|
|
146
|
+
const target = path.resolve(rootDir, relRoot);
|
|
147
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
148
|
+
return `ERROR: path "${relRoot}" escapes the project root`;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
if (!fs.existsSync(target)) {
|
|
152
|
+
return `ERROR: path not found: ${relRoot}`;
|
|
153
|
+
}
|
|
154
|
+
const segments = splitPattern(pattern);
|
|
155
|
+
if (segments.length === 0) {
|
|
156
|
+
return "ERROR: empty pattern";
|
|
157
|
+
}
|
|
158
|
+
const results = [];
|
|
159
|
+
for (const match of walkMatches(segments, "", target, 0)) {
|
|
160
|
+
results.push(match);
|
|
161
|
+
if (results.length >= MAX_RESULTS)
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
results.sort();
|
|
165
|
+
if (results.length === 0)
|
|
166
|
+
return "No matches";
|
|
167
|
+
return results.join("\n");
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
return `ERROR globbing "${pattern}": ${message}`;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
const MAX_RESULTS = 5000;
|
|
4
|
+
const MAX_LINE_CHARS = 5000;
|
|
5
|
+
function matchesInclude(name, include) {
|
|
6
|
+
if (!include)
|
|
7
|
+
return true;
|
|
8
|
+
const patterns = include.split(",").map((s) => s.trim()).filter(Boolean);
|
|
9
|
+
for (const pat of patterns) {
|
|
10
|
+
if (matchGlob(pat, name))
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
function matchGlob(pattern, value) {
|
|
16
|
+
const re = globToRegex(pattern);
|
|
17
|
+
return re.test(value);
|
|
18
|
+
}
|
|
19
|
+
function globToRegex(pattern) {
|
|
20
|
+
let re = "";
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < pattern.length) {
|
|
23
|
+
const ch = pattern[i];
|
|
24
|
+
if (ch === "*") {
|
|
25
|
+
if (pattern[i + 1] === "*") {
|
|
26
|
+
re += ".*";
|
|
27
|
+
i += 2;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
re += "[^/]*";
|
|
31
|
+
i += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === "?") {
|
|
35
|
+
re += "[^/]";
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === "[") {
|
|
40
|
+
let j = i + 1;
|
|
41
|
+
if (j < pattern.length && pattern[j] === "!")
|
|
42
|
+
j++;
|
|
43
|
+
if (j < pattern.length && pattern[j] === "]")
|
|
44
|
+
j++;
|
|
45
|
+
while (j < pattern.length && pattern[j] !== "]")
|
|
46
|
+
j++;
|
|
47
|
+
if (j >= pattern.length) {
|
|
48
|
+
re += "\\[";
|
|
49
|
+
i += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let cls = pattern.slice(i + 1, j);
|
|
53
|
+
if (cls.startsWith("!"))
|
|
54
|
+
cls = "^" + cls.slice(1);
|
|
55
|
+
re += "[" + cls + "]";
|
|
56
|
+
i = j + 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
return new RegExp("^" + re + "$");
|
|
63
|
+
}
|
|
64
|
+
function isBinary(buf) {
|
|
65
|
+
// Detect null bytes (common in binary files).
|
|
66
|
+
for (let i = 0; i < Math.min(buf.length, 4096); i++) {
|
|
67
|
+
if (buf[i] === 0)
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
function* walkFiles(dir) {
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (e.name.startsWith("."))
|
|
82
|
+
continue;
|
|
83
|
+
const full = path.join(dir, e.name);
|
|
84
|
+
if (e.isDirectory()) {
|
|
85
|
+
yield* walkFiles(full);
|
|
86
|
+
}
|
|
87
|
+
else if (e.isFile()) {
|
|
88
|
+
yield full;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function makeGrepTool(rootDir) {
|
|
93
|
+
return {
|
|
94
|
+
def: {
|
|
95
|
+
name: "Grep",
|
|
96
|
+
description: "Search file contents for a regex pattern. Returns matching lines with file path and line number. Use include filter like *.ts.",
|
|
97
|
+
parameters: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: {
|
|
100
|
+
pattern: {
|
|
101
|
+
type: "string",
|
|
102
|
+
description: "regex pattern",
|
|
103
|
+
},
|
|
104
|
+
path: {
|
|
105
|
+
type: "string",
|
|
106
|
+
default: ".",
|
|
107
|
+
description: "dir or file to search",
|
|
108
|
+
},
|
|
109
|
+
include: {
|
|
110
|
+
type: "string",
|
|
111
|
+
default: "",
|
|
112
|
+
description: "file filter e.g. *.ts",
|
|
113
|
+
},
|
|
114
|
+
maxResults: {
|
|
115
|
+
type: "number",
|
|
116
|
+
default: 50,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
required: ["pattern"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
execute: async (args) => {
|
|
123
|
+
const pattern = String(args.pattern ?? "");
|
|
124
|
+
if (!pattern) {
|
|
125
|
+
return "ERROR: pattern is required";
|
|
126
|
+
}
|
|
127
|
+
let regex;
|
|
128
|
+
try {
|
|
129
|
+
regex = new RegExp(pattern);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
return `ERROR: invalid regex: ${message}`;
|
|
134
|
+
}
|
|
135
|
+
const relPath = String(args.path ?? ".").trim() || ".";
|
|
136
|
+
const target = path.resolve(rootDir, relPath);
|
|
137
|
+
if (!target.startsWith(path.resolve(rootDir))) {
|
|
138
|
+
return `ERROR: path "${relPath}" escapes the project root`;
|
|
139
|
+
}
|
|
140
|
+
const include = String(args.include ?? "");
|
|
141
|
+
const maxResults = Math.max(1, Math.min(MAX_RESULTS, Number(args.maxResults) || 50));
|
|
142
|
+
try {
|
|
143
|
+
if (!fs.existsSync(target)) {
|
|
144
|
+
return `ERROR: path not found: ${relPath}`;
|
|
145
|
+
}
|
|
146
|
+
const files = [];
|
|
147
|
+
if (fs.statSync(target).isFile()) {
|
|
148
|
+
files.push(target);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
for (const f of walkFiles(target)) {
|
|
152
|
+
files.push(f);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const matches = [];
|
|
156
|
+
for (const full of files) {
|
|
157
|
+
const rel = path.relative(rootDir, full).replace(/\\/g, "/");
|
|
158
|
+
if (!matchesInclude(rel, include))
|
|
159
|
+
continue;
|
|
160
|
+
try {
|
|
161
|
+
const buf = fs.readFileSync(full);
|
|
162
|
+
if (isBinary(buf))
|
|
163
|
+
continue;
|
|
164
|
+
const text = buf.toString("utf8");
|
|
165
|
+
const lines = text.split("\n");
|
|
166
|
+
for (let ln = 1; ln <= lines.length; ln++) {
|
|
167
|
+
if (matches.length >= maxResults)
|
|
168
|
+
break;
|
|
169
|
+
const line = lines[ln - 1];
|
|
170
|
+
if (line.length > MAX_LINE_CHARS)
|
|
171
|
+
continue;
|
|
172
|
+
if (regex.test(line)) {
|
|
173
|
+
matches.push(`${rel}:${ln}: ${line}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// skip unreadable files
|
|
179
|
+
}
|
|
180
|
+
if (matches.length >= maxResults)
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (matches.length === 0)
|
|
184
|
+
return "No matches";
|
|
185
|
+
return matches.join("\n");
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
189
|
+
return `ERROR grepping "${pattern}": ${message}`;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export class ToolRegistry {
|
|
2
|
+
tools = new Map();
|
|
3
|
+
register(def, execute) {
|
|
4
|
+
this.tools.set(def.name, { ...def, execute });
|
|
5
|
+
}
|
|
6
|
+
get(name) {
|
|
7
|
+
return this.tools.get(name);
|
|
8
|
+
}
|
|
9
|
+
list() {
|
|
10
|
+
return Array.from(this.tools.values()).map((t) => ({
|
|
11
|
+
name: t.name,
|
|
12
|
+
description: t.description,
|
|
13
|
+
parameters: t.parameters,
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
toJSON() {
|
|
17
|
+
return this.list().map((t) => ({
|
|
18
|
+
type: "function",
|
|
19
|
+
function: {
|
|
20
|
+
name: t.name,
|
|
21
|
+
description: t.description,
|
|
22
|
+
parameters: t.parameters,
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
async executeTool(name, args) {
|
|
27
|
+
const registered = this.tools.get(name);
|
|
28
|
+
if (!registered) {
|
|
29
|
+
return `ERROR: Unknown tool "${name}". Available tools: ${Array.from(this.tools.keys()).join(", ")}`;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return await registered.execute(args ?? {});
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
36
|
+
return `ERROR executing tool "${name}": ${message}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getConfig } from "../config.js";
|
|
4
|
+
import { createProvider } from "../providers/index.js";
|
|
5
|
+
// Locally-installed vision-capable model (verified 2026-09-02).
|
|
6
|
+
const VISION_MODEL = "hf.co/OBLITERATUS/Qwen3.8-27B-OBLITERATED:Q4_K_M";
|
|
7
|
+
/**
|
|
8
|
+
* Find a vision-capable provider by inspecting the configured models for the
|
|
9
|
+
* known vision model. Falls back to any provider that lists the vision model.
|
|
10
|
+
*/
|
|
11
|
+
export async function findVisionProvider() {
|
|
12
|
+
const cfg = getConfig();
|
|
13
|
+
// 1. Any provider that has the vision model configured. Force the provider
|
|
14
|
+
// to use ONLY the vision model so resolveModel()/models[0] picks it.
|
|
15
|
+
for (const p of cfg.providers) {
|
|
16
|
+
if (!p.enabled)
|
|
17
|
+
continue;
|
|
18
|
+
if (p.models.includes(VISION_MODEL)) {
|
|
19
|
+
try {
|
|
20
|
+
const visionConfig = { ...p, models: [VISION_MODEL] };
|
|
21
|
+
return wrapProvider(await createProvider(visionConfig), p.name);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// try next
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// 2. Any provider whose listModels() includes the vision model.
|
|
29
|
+
for (const p of cfg.providers) {
|
|
30
|
+
if (!p.enabled)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const probe = await createProvider(p);
|
|
34
|
+
const models = await probe.listModels();
|
|
35
|
+
if (models.includes(VISION_MODEL)) {
|
|
36
|
+
const visionConfig = { ...p, models: [VISION_MODEL] };
|
|
37
|
+
return wrapProvider(await createProvider(visionConfig), p.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function wrapProvider(provider, name) {
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
async *chatVision(messages, opts) {
|
|
50
|
+
try {
|
|
51
|
+
// Ollama's OpenAI-compatible endpoint expects `content` as a string
|
|
52
|
+
// plus a top-level `images` array of base64 strings on the message.
|
|
53
|
+
// Other OpenAI-compatible providers accept content arrays; send the
|
|
54
|
+
// Ollama form since that is the primary vision path here.
|
|
55
|
+
const serialised = messages.map((m) => ({
|
|
56
|
+
role: m.role,
|
|
57
|
+
content: m.content,
|
|
58
|
+
...(m.images ? { images: m.images } : {}),
|
|
59
|
+
}));
|
|
60
|
+
for await (const chunk of provider.chat(serialised, [], {
|
|
61
|
+
signal: opts?.signal,
|
|
62
|
+
maxTokens: opts?.maxTokens,
|
|
63
|
+
})) {
|
|
64
|
+
if (chunk.type === "text" && chunk.text)
|
|
65
|
+
yield { text: chunk.text };
|
|
66
|
+
if (chunk.type === "error" && chunk.error)
|
|
67
|
+
yield { error: chunk.error };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
yield { error: err.message };
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function makeVisionTool(rootDir) {
|
|
77
|
+
return {
|
|
78
|
+
def: {
|
|
79
|
+
name: "DescribeImage",
|
|
80
|
+
description: "Describe an image file at a given path using a vision-capable model. Returns a textual description.",
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
path: { type: "string", description: "path to the image file" },
|
|
85
|
+
question: { type: "string", default: "Describe this image in detail.", description: "question about the image" },
|
|
86
|
+
},
|
|
87
|
+
required: ["path"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
execute: async (args) => {
|
|
91
|
+
const rel = String(args.path ?? "");
|
|
92
|
+
// Resolve relative to the project root, but allow absolute paths to
|
|
93
|
+
// image files anywhere on the filesystem (that is the tool's purpose).
|
|
94
|
+
const target = path.isAbsolute(rel)
|
|
95
|
+
? rel
|
|
96
|
+
: path.resolve(rootDir, rel);
|
|
97
|
+
const question = String(args.question ?? "Describe this image in detail.");
|
|
98
|
+
let buf;
|
|
99
|
+
try {
|
|
100
|
+
if (!fs.existsSync(target)) {
|
|
101
|
+
return `ERROR: image not found: ${rel}`;
|
|
102
|
+
}
|
|
103
|
+
if (!fs.statSync(target).isFile()) {
|
|
104
|
+
return `ERROR: path "${rel}" is not a file`;
|
|
105
|
+
}
|
|
106
|
+
buf = fs.readFileSync(target);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
110
|
+
return `ERROR reading image "${rel}": ${message}`;
|
|
111
|
+
}
|
|
112
|
+
const b64 = buf.toString("base64");
|
|
113
|
+
const provider = await findVisionProvider();
|
|
114
|
+
if (!provider) {
|
|
115
|
+
return "No vision-capable provider available.";
|
|
116
|
+
}
|
|
117
|
+
const messages = [
|
|
118
|
+
{
|
|
119
|
+
role: "user",
|
|
120
|
+
content: question,
|
|
121
|
+
images: [b64],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
let text = "";
|
|
125
|
+
try {
|
|
126
|
+
for await (const chunk of provider.chatVision(messages, { maxTokens: 1024 })) {
|
|
127
|
+
if (chunk.text)
|
|
128
|
+
text += chunk.text;
|
|
129
|
+
if (chunk.error)
|
|
130
|
+
return `ERROR: vision provider failed: ${chunk.error}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
return `ERROR: vision provider failed: ${message}`;
|
|
136
|
+
}
|
|
137
|
+
return text.trim() || "(no description returned)";
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const DDG_URL = "https://html.duckduckgo.com/html/";
|
|
2
|
+
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
3
|
+
/**
|
|
4
|
+
* Parse DuckDuckGo HTML results into a list of {title, url, snippet}.
|
|
5
|
+
* DDG wraps real URLs in a redirect (`uddg` query param); we unwrap them.
|
|
6
|
+
*/
|
|
7
|
+
export function parseDdgResults(html) {
|
|
8
|
+
const results = [];
|
|
9
|
+
// Each result is an <a class="result__a"> whose href points at the redirect.
|
|
10
|
+
const linkRe = /<a\b[^>]*class="[^"]*\bresult__a\b[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
11
|
+
// Snippets live in <a class="result__snippet"> ... </a>.
|
|
12
|
+
const snippetRe = /<a\b[^>]*class="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
13
|
+
const rawSnippets = [];
|
|
14
|
+
let sm;
|
|
15
|
+
while ((sm = snippetRe.exec(html)) !== null) {
|
|
16
|
+
rawSnippets.push(stripTags(sm[1] ?? ""));
|
|
17
|
+
}
|
|
18
|
+
let idx = 0;
|
|
19
|
+
let lm;
|
|
20
|
+
while ((lm = linkRe.exec(html)) !== null) {
|
|
21
|
+
const href = lm[1] ?? "";
|
|
22
|
+
const title = stripTags(lm[2] ?? "").trim();
|
|
23
|
+
if (!title)
|
|
24
|
+
continue;
|
|
25
|
+
const url = unwrapRedirect(href);
|
|
26
|
+
if (!url)
|
|
27
|
+
continue;
|
|
28
|
+
const snippet = rawSnippets[idx] ?? "";
|
|
29
|
+
results.push({ title, url, snippet });
|
|
30
|
+
idx++;
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
function stripTags(s) {
|
|
35
|
+
return s
|
|
36
|
+
.replace(/<[^>]+>/g, " ")
|
|
37
|
+
.replace(/&/g, "&")
|
|
38
|
+
.replace(/</g, "<")
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/"/g, '"')
|
|
41
|
+
.replace(/'|'/g, "'")
|
|
42
|
+
.replace(/\s+/g, " ")
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
function unwrapRedirect(href) {
|
|
46
|
+
// DDG wraps real URLs in a redirect like:
|
|
47
|
+
// //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage&rut=...
|
|
48
|
+
// The real destination is the `uddg` query param. The hostname can vary
|
|
49
|
+
// (html.duckduckgo.com or duckduckgo.com) and the link may be protocol-
|
|
50
|
+
// relative, so we key off the /l/ path and the uddg param instead.
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(href, "https://html.duckduckgo.com/");
|
|
53
|
+
if (u.pathname === "/l/") {
|
|
54
|
+
const target = u.searchParams.get("uddg");
|
|
55
|
+
if (target)
|
|
56
|
+
return decodeURIComponent(target);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// fall through
|
|
61
|
+
}
|
|
62
|
+
// Some results are absolute URLs directly.
|
|
63
|
+
if (/^https?:\/\//i.test(href))
|
|
64
|
+
return href;
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
export function makeWebSearchTool() {
|
|
68
|
+
return {
|
|
69
|
+
def: {
|
|
70
|
+
name: "WebSearch",
|
|
71
|
+
description: "Search the web for current information. Returns titles, URLs, and snippets. Use for recent events or facts not in the project.",
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
query: { type: "string", description: "the search query" },
|
|
76
|
+
maxResults: { type: "number", default: 5, description: "max number of results to return" },
|
|
77
|
+
},
|
|
78
|
+
required: ["query"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
execute: async (args) => {
|
|
82
|
+
const query = String(args.query ?? "").trim();
|
|
83
|
+
if (!query)
|
|
84
|
+
return "ERROR: query is required";
|
|
85
|
+
const maxResults = Math.max(1, Math.min(20, Number(args.maxResults) || 5));
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
const url = `${DDG_URL}?${new URLSearchParams({ q: query }).toString()}`;
|
|
89
|
+
res = await fetch(url, {
|
|
90
|
+
method: "GET",
|
|
91
|
+
headers: { "User-Agent": USER_AGENT, "Accept": "text/html" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return `ERROR: WebSearch network failure: ${message}`;
|
|
97
|
+
}
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
return `ERROR: WebSearch failed: HTTP ${res.status}`;
|
|
100
|
+
}
|
|
101
|
+
let html = "";
|
|
102
|
+
try {
|
|
103
|
+
html = await res.text();
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
return `ERROR: WebSearch failed to read response: ${message}`;
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseDdgResults(html).slice(0, maxResults);
|
|
110
|
+
if (parsed.length === 0) {
|
|
111
|
+
return "No matches found";
|
|
112
|
+
}
|
|
113
|
+
return parsed
|
|
114
|
+
.map((r, i) => `${i + 1}. ${r.title} - ${r.url}\n ${r.snippet}`)
|
|
115
|
+
.join("\n\n");
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|