@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.10
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/README.md +157 -15
- package/bin/vierrataleai.js +74 -1
- package/package.json +10 -2
- package/scripts/postinstall.js +35 -0
- package/src/catalog.js +94 -45
- package/src/cli.js +1098 -51
- package/src/cmd/agent.js +1711 -0
- package/src/cmd/executor.js +257 -0
- package/src/cmd/tools.js +153 -0
- package/src/config.js +54 -15
- package/src/index.js +2 -1
- package/src/installer.js +159 -15
- package/src/prompts/system.json +2 -2
- package/src/prompts/system.md +42 -0
- package/src/providers/anthropic.js +130 -0
- package/src/providers/base.js +18 -0
- package/src/providers/cortex.js +298 -0
- package/src/providers/gemini.js +128 -0
- package/src/providers/index.js +7 -3
- package/src/providers/openai.js +79 -34
- package/src/session.js +221 -0
- package/src/ui/banner.js +23 -12
- package/src/ui/branding.js +8 -9
- package/src/ui/chatbox.js +280 -0
- package/src/ui/input.js +501 -0
- package/src/ui/terminal.js +84 -8
- package/src/utils/downloader.js +364 -0
- package/src/utils/filewriter.js +97 -0
- package/src/utils/intents.js +102 -0
- package/src/utils/logger.js +67 -0
- package/src/utils/math.js +142 -0
- package/src/utils/webfetch.js +332 -0
- package/src/utils/websearch.js +262 -0
- package/build.sh +0 -17
- package/src/providers/ollama.js +0 -89
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { execFile } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import { browserHeaders, isBlockedBody } from './webfetch.js';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
const MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024; // 500MB cap
|
|
11
|
+
|
|
12
|
+
const ARCHIVE_EXT = /\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|gz|bz2|7z|rar|xz)$/i;
|
|
13
|
+
|
|
14
|
+
const MIME_EXT = {
|
|
15
|
+
'application/zip': 'zip',
|
|
16
|
+
'application/x-zip-compressed': 'zip',
|
|
17
|
+
'application/x-tar': 'tar',
|
|
18
|
+
'application/gzip': 'gz',
|
|
19
|
+
'application/x-gzip': 'gz',
|
|
20
|
+
'image/png': 'png',
|
|
21
|
+
'image/jpeg': 'jpg',
|
|
22
|
+
'image/gif': 'gif',
|
|
23
|
+
'image/webp': 'webp',
|
|
24
|
+
'image/svg+xml': 'svg',
|
|
25
|
+
'application/pdf': 'pdf',
|
|
26
|
+
'application/json': 'json',
|
|
27
|
+
'text/html': 'html',
|
|
28
|
+
'text/plain': 'txt',
|
|
29
|
+
'text/markdown': 'md',
|
|
30
|
+
'text/csv': 'csv',
|
|
31
|
+
'application/msword': 'doc',
|
|
32
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
33
|
+
'application/vnd.ms-excel': 'xls',
|
|
34
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
35
|
+
'application/octet-stream': 'bin',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function stripInvalidFilenameChars(name) {
|
|
39
|
+
// eslint-disable-next-line no-control-regex
|
|
40
|
+
return String(name || '')
|
|
41
|
+
.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_')
|
|
42
|
+
.replace(/^\.+/, '')
|
|
43
|
+
.replace(/\.+$/, '')
|
|
44
|
+
.trim()
|
|
45
|
+
.slice(0, 200) || 'download';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function baseNameFromUrl(url) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(url);
|
|
51
|
+
const name = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '');
|
|
52
|
+
return name || (u.hostname || 'download').replace(/^www\./, '');
|
|
53
|
+
} catch {
|
|
54
|
+
return 'download';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fileNameFromHeaders(url, contentType) {
|
|
59
|
+
const base = baseNameFromUrl(url);
|
|
60
|
+
if (contentType) {
|
|
61
|
+
const ct = contentType.split(';')[0].trim().toLowerCase();
|
|
62
|
+
const ext = MIME_EXT[ct];
|
|
63
|
+
if (ext && !base.toLowerCase().endsWith(`.${ext}`)) {
|
|
64
|
+
return `${stripInvalidFilenameChars(base)}.${ext}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return stripInvalidFilenameChars(base);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isArchiveUrl(url) {
|
|
71
|
+
return ARCHIVE_EXT.test(String(url).split('?')[0]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isDirectoryListing(html) {
|
|
75
|
+
const s = String(html || '').slice(0, 20000).toLowerCase();
|
|
76
|
+
const hasLinks = /<a href=/i.test(s);
|
|
77
|
+
if (!hasLinks) return false;
|
|
78
|
+
return (
|
|
79
|
+
/<a[^>]+href=[^>]*>\s*\.\.[\s<]/.test(s) ||
|
|
80
|
+
/(?:index of \/|directory listing for|parent directory)/.test(s) ||
|
|
81
|
+
/<h1>.*listing.*<\/h1>/.test(s)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function joinUrl(base, rel) {
|
|
86
|
+
try {
|
|
87
|
+
return new URL(rel, base).toString();
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sameDomain(a, b) {
|
|
94
|
+
try {
|
|
95
|
+
return new URL(a).hostname === new URL(b).hostname;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isGithubRepoUrl(url) {
|
|
102
|
+
return /^https?:\/\/github\.com\/[\w.-]+\/[\w.-]+/i.test(url);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class Downloader {
|
|
106
|
+
static async fetchStream(url, { timeout = 30000, headers = {} } = {}) {
|
|
107
|
+
const resp = await fetch(url, {
|
|
108
|
+
headers: { ...browserHeaders('*/*'), ...headers },
|
|
109
|
+
redirect: 'follow',
|
|
110
|
+
signal: AbortSignal.timeout(timeout),
|
|
111
|
+
});
|
|
112
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
|
|
113
|
+
return resp;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Stream a raw body to a file. Returns metadata.
|
|
117
|
+
static async downloadFile(url, { dir = process.cwd(), filename, timeout = 30000, onProgress } = {}) {
|
|
118
|
+
mkdirSync(dir, { recursive: true });
|
|
119
|
+
const resp = await this.fetchStream(url, { timeout });
|
|
120
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
121
|
+
const finalUrl = resp.url || url;
|
|
122
|
+
const name = filename || fileNameFromHeaders(finalUrl, contentType);
|
|
123
|
+
const dest = join(dir, stripInvalidFilenameChars(name));
|
|
124
|
+
|
|
125
|
+
const reader = resp.body.getReader();
|
|
126
|
+
const stream = createWriteStream(dest);
|
|
127
|
+
let received = 0;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const { done, value } = await reader.read();
|
|
132
|
+
if (done) break;
|
|
133
|
+
received += value.length;
|
|
134
|
+
if (received > MAX_DOWNLOAD_BYTES) {
|
|
135
|
+
throw new Error(`Download exceeded ${MAX_DOWNLOAD_BYTES} byte cap`);
|
|
136
|
+
}
|
|
137
|
+
await new Promise((resolve, reject) => stream.write(value, (err) => (err ? reject(err) : resolve())));
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
await new Promise((resolve) => stream.close(resolve));
|
|
141
|
+
rmSync(dest, { force: true });
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
await new Promise((resolve) => stream.end(resolve));
|
|
145
|
+
if (onProgress) onProgress(received, received);
|
|
146
|
+
return { ok: true, path: dest, size: received, contentType, url: finalUrl, name };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
static async extractArchive(archivePath, destDir) {
|
|
150
|
+
const file = String(archivePath).toLowerCase();
|
|
151
|
+
mkdirSync(destDir, { recursive: true });
|
|
152
|
+
if (file.endsWith('.zip')) {
|
|
153
|
+
await execFileAsync('unzip', ['-o', archivePath, '-d', destDir]);
|
|
154
|
+
} else if (file.endsWith('.tar.gz') || file.endsWith('.tgz') || file.endsWith('.tar.bz2') ||
|
|
155
|
+
file.endsWith('.tbz2') || file.endsWith('.tar.xz') || file.endsWith('.txz') || file.endsWith('.tar')) {
|
|
156
|
+
await execFileAsync('tar', ['-xf', archivePath, '-C', destDir]);
|
|
157
|
+
} else if (file.endsWith('.7z')) {
|
|
158
|
+
await execFileAsync('7z', ['x', archivePath, `-o${destDir}`, '-y']);
|
|
159
|
+
} else if (file.endsWith('.rar')) {
|
|
160
|
+
await execFileAsync('unrar', ['x', archivePath, destDir]);
|
|
161
|
+
} else {
|
|
162
|
+
throw new Error(`Cannot extract that archive: ${archivePath}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Zip a folder. destZip must end in .zip or .tar.gz.
|
|
167
|
+
static async zipFolder(srcDir, destZip) {
|
|
168
|
+
mkdirSync(srcDir, { recursive: true });
|
|
169
|
+
const lower = destZip.toLowerCase();
|
|
170
|
+
const absZip = join(process.cwd(), destZip);
|
|
171
|
+
if (lower.endsWith('.zip')) {
|
|
172
|
+
await execFileAsync('zip', ['-r', absZip, '.'], { cwd: srcDir });
|
|
173
|
+
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
|
|
174
|
+
await execFileAsync('tar', ['-czf', absZip, '-C', srcDir, '.']);
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error('Destination must end in .zip or .tar.gz');
|
|
177
|
+
}
|
|
178
|
+
const { statSync } = await import('fs');
|
|
179
|
+
return { ok: true, path: absZip, size: statSync(absZip).size };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
static async download(url, { dir = process.cwd(), filename, timeout = 30000, onProgress, extract = true } = {}) {
|
|
183
|
+
const resp = await this.fetchStream(url, { timeout });
|
|
184
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
185
|
+
const finalUrl = resp.url || url;
|
|
186
|
+
const isHtml = /text\/html|application\/xhtml/i.test(contentType);
|
|
187
|
+
const isPlain = /text\/plain|text\/markdown|application\/json|\+json|text\/csv/i.test(contentType);
|
|
188
|
+
|
|
189
|
+
// We must peek the body once. For HTML we buffer it (capped); otherwise
|
|
190
|
+
// stream it to disk directly.
|
|
191
|
+
if (isHtml || isPlain) {
|
|
192
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
193
|
+
const html = buf.toString('utf8');
|
|
194
|
+
if (typeof onProgress === 'function') onProgress(buf.length, buf.length);
|
|
195
|
+
|
|
196
|
+
if (isHtml && isBlockedBody(html)) {
|
|
197
|
+
throw new Error('Download blocked by anti-bot protection.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// github repo URL -> grab the repo zip and extract it (its HTML page
|
|
201
|
+
// would otherwise just be saved as a file).
|
|
202
|
+
if (isGithubRepoUrl(finalUrl)) {
|
|
203
|
+
return this._downloadGithub(finalUrl, { dir, filename, onProgress });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// HTML directory listing -> recursive folder download.
|
|
207
|
+
if (isHtml && isDirectoryListing(html)) {
|
|
208
|
+
return this._downloadListing(finalUrl, html, { dir, filename, onProgress });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Otherwise save plain text / html as a file.
|
|
212
|
+
const kind = 'file';
|
|
213
|
+
const name = filename || fileNameFromHeaders(finalUrl, contentType);
|
|
214
|
+
const dest = join(dir, stripInvalidFilenameChars(name));
|
|
215
|
+
mkdirSync(dir, { recursive: true });
|
|
216
|
+
writeFileSync(dest, html, 'utf8');
|
|
217
|
+
return { ok: true, path: dest, size: buf.length, kind, url: finalUrl, name };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Binary / archive: stream to disk.
|
|
221
|
+
const result = await this.downloadFile(finalUrl, { dir, filename, timeout, onProgress });
|
|
222
|
+
let kind = 'file';
|
|
223
|
+
if (isArchiveUrl(result.name) || /application\/(zip|x-zip|x-tar|gzip)|multipart\/x-zip/i.test(result.contentType)) {
|
|
224
|
+
kind = 'archive';
|
|
225
|
+
if (extract) {
|
|
226
|
+
const extractDir = result.path.replace(/\.(zip|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz|tar|7z|rar)$/i, '');
|
|
227
|
+
await this.extractArchive(result.path, extractDir);
|
|
228
|
+
return { ...result, kind, extracted: extractDir };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { ...result, kind };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
static async _downloadListing(url, html, { dir, filename, onProgress }) {
|
|
235
|
+
const name = filename || stripInvalidFilenameChars(baseNameFromUrl(url));
|
|
236
|
+
const folderDir = join(dir, name);
|
|
237
|
+
mkdirSync(folderDir, { recursive: true });
|
|
238
|
+
const links = this._listingLinks(url, html);
|
|
239
|
+
let count = 0;
|
|
240
|
+
for (const { href } of links) {
|
|
241
|
+
const resolved = joinUrl(url, href);
|
|
242
|
+
if (!resolved) continue;
|
|
243
|
+
if (!sameDomain(url, resolved)) continue;
|
|
244
|
+
const isSubdir = href.endsWith('/') || /^[^.?]+\/$/.test(href);
|
|
245
|
+
try {
|
|
246
|
+
if (isSubdir) {
|
|
247
|
+
const subDir = join(folderDir, stripInvalidFilenameChars(baseNameFromUrl(resolved)));
|
|
248
|
+
const subHtml = await this._peekHtml(resolved);
|
|
249
|
+
if (subHtml && isDirectoryListing(subHtml)) {
|
|
250
|
+
await this._downloadListingIntoSelf(resolved, subHtml, subDir, onProgress, count);
|
|
251
|
+
count++;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
const fileName = stripInvalidFilenameChars(baseNameFromUrl(resolved));
|
|
255
|
+
await this.downloadFile(resolved, { dir: folderDir, filename: fileName, onProgress });
|
|
256
|
+
count++;
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
// ignore individual link failures during a folder crawl
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (typeof onProgress === 'function') onProgress(count, count);
|
|
263
|
+
return { ok: true, path: folderDir, kind: 'folder', url, name };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
static async _peekHtml(url) {
|
|
267
|
+
try {
|
|
268
|
+
const resp = await this.fetchStream(url, { timeout: 15000 });
|
|
269
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
270
|
+
if (!/text\/html|application\/xhtml/i.test(contentType)) return null;
|
|
271
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
272
|
+
return buf.toString('utf8').slice(0, 20000);
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
static async _downloadListingIntoSelf(url, html, folderDir, onProgress, count) {
|
|
279
|
+
mkdirSync(folderDir, { recursive: true });
|
|
280
|
+
const links = this._listingLinks(url, html);
|
|
281
|
+
for (const { href } of links) {
|
|
282
|
+
const resolved = joinUrl(url, href);
|
|
283
|
+
if (!resolved || !sameDomain(url, resolved)) continue;
|
|
284
|
+
const isSubdir = href.endsWith('/') || /^[^.?]+\/$/.test(href);
|
|
285
|
+
try {
|
|
286
|
+
if (isSubdir) {
|
|
287
|
+
const subDir = join(folderDir, stripInvalidFilenameChars(baseNameFromUrl(resolved)));
|
|
288
|
+
const subHtml = await this._peekHtml(resolved);
|
|
289
|
+
if (subHtml && isDirectoryListing(subHtml)) {
|
|
290
|
+
await this._downloadListingIntoSelf(resolved, subHtml, subDir, onProgress, count);
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
const fileName = stripInvalidFilenameChars(baseNameFromUrl(resolved));
|
|
294
|
+
await this.downloadFile(resolved, { dir: folderDir, filename: fileName, onProgress });
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// ignore individual link failures during a folder crawl
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
static _listingLinks(baseUrl, html) {
|
|
303
|
+
const out = [];
|
|
304
|
+
const re = /<a[^>]+href\s*=\s*"([^"]+)"[^>]*>(.*?)<\/a>/gi;
|
|
305
|
+
let m;
|
|
306
|
+
while ((m = re.exec(html)) !== null) {
|
|
307
|
+
const href = m[1];
|
|
308
|
+
if (!href) continue;
|
|
309
|
+
if (href.startsWith('#') || href.startsWith('?') || href.startsWith('javascript:')) continue;
|
|
310
|
+
if (href.startsWith('..') || href === '/' || href === '../') continue;
|
|
311
|
+
out.push({ href });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
static async _downloadGithub(url, { dir, filename, onProgress }) {
|
|
317
|
+
const zipUrl = await this.githubZipUrl(url);
|
|
318
|
+
const name = filename || `${baseNameFromUrl(url) || 'repo'}`;
|
|
319
|
+
const destDir = join(dir, stripInvalidFilenameChars(name));
|
|
320
|
+
mkdirSync(destDir, { recursive: true });
|
|
321
|
+
const tmpArchive = join(tmpdir(), `github-${Date.now()}.zip`);
|
|
322
|
+
try {
|
|
323
|
+
await this.downloadFile(zipUrl, { dir: tmpdir(), filename: tmpArchive.split('/').pop(), timeout: 60000, onProgress });
|
|
324
|
+
await this.extractArchive(tmpArchive, destDir);
|
|
325
|
+
} finally {
|
|
326
|
+
try { rmSync(tmpArchive, { force: true }); } catch {}
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, path: destDir, kind: 'folder', url, name };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
static async githubZipUrl(url) {
|
|
332
|
+
try {
|
|
333
|
+
const u = new URL(url);
|
|
334
|
+
const path = u.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
|
|
335
|
+
const owner = path[0];
|
|
336
|
+
const repo = path[1];
|
|
337
|
+
if (path[2] === 'tree' && path[3]) {
|
|
338
|
+
const branch = path.slice(3).join('/');
|
|
339
|
+
return `https://codeload.github.com/${owner}/${repo}/zip/refs/heads/${branch}`;
|
|
340
|
+
}
|
|
341
|
+
if (path[2] === 'archive' && path[3] && path[4]) {
|
|
342
|
+
return `https://codeload.github.com/${owner}/${repo}/zip/${path.slice(3).join('/')}`;
|
|
343
|
+
}
|
|
344
|
+
const branch = await this.defaultGitHubBranch(owner, repo);
|
|
345
|
+
return `https://codeload.github.com/${owner}/${repo}/zip/refs/heads/${branch}`;
|
|
346
|
+
} catch {
|
|
347
|
+
return `${String(url).replace(/\/+$/, '')}.zip`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
static async defaultGitHubBranch(owner, repo) {
|
|
352
|
+
try {
|
|
353
|
+
const resp = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
354
|
+
headers: { 'User-Agent': 'vierrataleai-downloader', Accept: 'application/vnd.github+json' },
|
|
355
|
+
signal: AbortSignal.timeout(10000),
|
|
356
|
+
});
|
|
357
|
+
if (!resp.ok) return 'main';
|
|
358
|
+
const data = await resp.json();
|
|
359
|
+
return data.default_branch || 'main';
|
|
360
|
+
} catch {
|
|
361
|
+
return 'main';
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, resolve, sep } from 'path';
|
|
3
|
+
|
|
4
|
+
const FILE_RE = /^FILE:\s*(.+?)\s*$/i;
|
|
5
|
+
const FOLDER_RE = /^FOLDER:\s*(.+?)\s*$/i;
|
|
6
|
+
|
|
7
|
+
export class FileWriter {
|
|
8
|
+
static parse(text) {
|
|
9
|
+
const files = [];
|
|
10
|
+
const folders = [];
|
|
11
|
+
const lines = String(text || '').split('\n');
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < lines.length) {
|
|
14
|
+
let m = FILE_RE.exec(lines[i]);
|
|
15
|
+
if (m) {
|
|
16
|
+
const path = m[1].trim();
|
|
17
|
+
let j = i + 1;
|
|
18
|
+
while (j < lines.length && lines[j].trim() === '') j++;
|
|
19
|
+
if (j < lines.length && lines[j].startsWith('```')) {
|
|
20
|
+
const start = j + 1;
|
|
21
|
+
let end = start;
|
|
22
|
+
while (end < lines.length && !lines[end].startsWith('```')) end++;
|
|
23
|
+
if (end < lines.length) {
|
|
24
|
+
files.push({
|
|
25
|
+
path,
|
|
26
|
+
language: lines[j].slice(3).trim(),
|
|
27
|
+
content: lines.slice(start, end).join('\n'),
|
|
28
|
+
});
|
|
29
|
+
i = end + 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
m = FOLDER_RE.exec(lines[i]);
|
|
35
|
+
if (m) {
|
|
36
|
+
folders.push(m[1].trim());
|
|
37
|
+
}
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
return { files, folders };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static resolveTarget(path) {
|
|
44
|
+
const base = resolve(process.cwd());
|
|
45
|
+
const target = resolve(base, path);
|
|
46
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
47
|
+
throw new Error(`Refusing to write outside the working directory: ${path}`);
|
|
48
|
+
}
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static async write({ files = [], folders = [], overwrite = false, confirm } = {}) {
|
|
53
|
+
const results = [];
|
|
54
|
+
|
|
55
|
+
for (const p of folders) {
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(this.resolveTarget(p), { recursive: true });
|
|
58
|
+
results.push({ kind: 'folder', path: p, target: this.resolveTarget(p), status: 'created' });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
results.push({ kind: 'folder', path: p, status: 'error', error: err.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const f of files) {
|
|
65
|
+
let target;
|
|
66
|
+
try {
|
|
67
|
+
target = this.resolveTarget(f.path);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
results.push({ kind: 'file', path: f.path, status: 'error', error: err.message });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
73
|
+
results.push({ kind: 'file', path: f.path, target, status: 'error', error: 'Target is a directory' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (existsSync(target) && confirm) {
|
|
77
|
+
const ok = await confirm(`Overwrite ${f.path}?`);
|
|
78
|
+
if (!ok) {
|
|
79
|
+
results.push({ kind: 'file', path: f.path, target, status: 'skipped', error: 'Declined' });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
} else if (existsSync(target) && !overwrite) {
|
|
83
|
+
results.push({ kind: 'file', path: f.path, target, status: 'skipped', error: 'File already exists' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
88
|
+
writeFileSync(target, f.content, 'utf-8');
|
|
89
|
+
results.push({ kind: 'file', path: f.path, target, status: 'written' });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
results.push({ kind: 'file', path: f.path, target, status: 'error', error: err.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const QUESTION_PREFIXES = [
|
|
2
|
+
'who', 'what', 'when', 'where', 'why', 'how',
|
|
3
|
+
'which', 'whose', 'whom', 'is', 'are', 'was', 'were',
|
|
4
|
+
'can', 'could', 'does', 'do', 'did', 'would', 'should', 'will',
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const KNOWLEDGE_PREFIXES = [
|
|
8
|
+
'tell me about', "what is", "what's", "who is", "who's",
|
|
9
|
+
'what are', 'who was', 'when did', 'when was', 'where is',
|
|
10
|
+
'where was', 'how did', 'how does', 'how to', 'why did', 'why is',
|
|
11
|
+
'latest', 'news', 'current',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const SELF_PHRASES = [
|
|
15
|
+
'what model', 'which model', 'what model are you', 'which model are you',
|
|
16
|
+
'what are you', 'who are you', "what's your name", 'what is your name',
|
|
17
|
+
'what language model', 'what llm', 'what ai model', 'what ai are you',
|
|
18
|
+
'your model', 'what model do you use', 'what model you use',
|
|
19
|
+
'what engine', 'which engine', 'are you gpt', 'are you chatgpt',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const ACTION_VERBS = 'create|make|write|save|generate|build|add|new|update|edit|install|put|set';
|
|
23
|
+
const FILE_THINGS = 'file|folder|directory|project|code|script|config|\\.json|app|readme|package\\.json|website|html|css';
|
|
24
|
+
const INFO_ASK = /^(how|what|why|when|where|which)\b/i;
|
|
25
|
+
const FILE_REQUEST = new RegExp(`\\b(${ACTION_VERBS})\\b[^.!?]{0,80}\\b(${FILE_THINGS})\\b`, 'i');
|
|
26
|
+
|
|
27
|
+
const STORY_WORDS = [
|
|
28
|
+
'story', 'stories', 'tale', 'tales', 'legend', 'legends',
|
|
29
|
+
'myth', 'myths', 'fable', 'fables', 'folklore', 'folktale',
|
|
30
|
+
'folk tale', 'fairytale', 'fairy tale',
|
|
31
|
+
].join('|');
|
|
32
|
+
const STORY_LINKS = 'about|of|regarding|recounting|called|named';
|
|
33
|
+
const TRIM_TAIL = '\\s+(?:and|in|into)\\b|\\s*[.!?]|$';
|
|
34
|
+
|
|
35
|
+
const STORY_SUBJECT_PATTERN = new RegExp(
|
|
36
|
+
`\\b(?:${STORY_WORDS})\\s+(?:${STORY_LINKS})\\s+[a-z0-9]{3,}`, 'i',
|
|
37
|
+
);
|
|
38
|
+
const STORY_ASK_PATTERN = new RegExp(
|
|
39
|
+
`\\b(?:tell|write|narrate|recite|make|create|generate|give|share|hear|listen to)\\b[^.!?\\n]{0,60}\\b(?:${STORY_WORDS})\\b`, 'i',
|
|
40
|
+
);
|
|
41
|
+
const STORY_CAPTURE = new RegExp(
|
|
42
|
+
`\\b(?:${STORY_WORDS})\\s+(?:${STORY_LINKS})\\s+([^.!?\\n]{2,}?)\\s*(?=${TRIM_TAIL})`, 'i',
|
|
43
|
+
);
|
|
44
|
+
const ABOUT_CAPTURE = /\b(?:tell me|talk|read|learn|research|hear|know|find out|look up|speak|write|give)\b[^.!?\n]{0,30}\babout\s+([^.!?\n]{2,}?)\s*(?=\s+(?:and|in|into)\b|\s*[.!?]|$)/i;
|
|
45
|
+
const PLACE_PATTERN = /\bfrom\s+[a-z][a-z\s'.-]{1,40}$/i;
|
|
46
|
+
|
|
47
|
+
const GREETINGS = new Set([
|
|
48
|
+
'hi', 'hello', 'hey', 'hii', 'heyy', 'howdy', 'yo', 'hai',
|
|
49
|
+
'halo', 'hallo', 'hola', 'hiya', 'sup', 'good morning',
|
|
50
|
+
'good afternoon', 'good evening', 'good night', 'selamat pagi',
|
|
51
|
+
'selamat siang', 'selamat sore', 'selamat malam', 'assalamualaikum',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export function looksLikeStoryRequest(text) {
|
|
55
|
+
return STORY_ASK_PATTERN.test(text) || STORY_SUBJECT_PATTERN.test(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function extractSearchTopic(text) {
|
|
59
|
+
const s = String(text).trim();
|
|
60
|
+
const story = STORY_CAPTURE.exec(s);
|
|
61
|
+
if (story) return story[1].trim();
|
|
62
|
+
const about = ABOUT_CAPTURE.exec(s);
|
|
63
|
+
return about ? about[1].trim() : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function detectIntent(text) {
|
|
67
|
+
const lower = text.toLowerCase().trim();
|
|
68
|
+
if (lower.startsWith('/')) return { type: 'command' };
|
|
69
|
+
|
|
70
|
+
if (GREETINGS.has(lower)) {
|
|
71
|
+
return { type: 'greeting' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (SELF_PHRASES.some((p) => lower.includes(p))) {
|
|
75
|
+
return { type: 'self' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (STORY_SUBJECT_PATTERN.test(lower) || PLACE_PATTERN.test(lower)) {
|
|
79
|
+
return { type: 'knowledge' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isKnowledgePrefixed = KNOWLEDGE_PREFIXES.some(
|
|
83
|
+
(p) => lower.startsWith(p + ' ') || lower === p,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!INFO_ASK.test(lower) && !isKnowledgePrefixed && FILE_REQUEST.test(lower)) {
|
|
87
|
+
return { type: 'chat' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isQuestion = /\?$/.test(lower);
|
|
91
|
+
const firstWord = lower.split(/\s+/)[0] || '';
|
|
92
|
+
|
|
93
|
+
let needsWeb = false;
|
|
94
|
+
if (isQuestion && QUESTION_PREFIXES.includes(firstWord)) {
|
|
95
|
+
needsWeb = true;
|
|
96
|
+
}
|
|
97
|
+
if (isKnowledgePrefixed) {
|
|
98
|
+
needsWeb = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { type: needsWeb ? 'knowledge' : 'chat' };
|
|
102
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { Config } from '../config.js';
|
|
4
|
+
|
|
5
|
+
const MAX_LINE = 2000;
|
|
6
|
+
|
|
7
|
+
function pad(n) {
|
|
8
|
+
return String(n).padStart(2, '0');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function dateStamp(d) {
|
|
12
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function timeStamp(d) {
|
|
16
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cap(text) {
|
|
20
|
+
const s = String(text ?? '');
|
|
21
|
+
return s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}…` : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Automatic activity logger. Writes one timestamped file per day into the
|
|
25
|
+
// config directory (logs/). A no-op until init() is called, so tests and
|
|
26
|
+
// library consumers never accidentally write into the real logs folder.
|
|
27
|
+
class Logger {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.dir = null;
|
|
30
|
+
this.file = null;
|
|
31
|
+
this._initialized = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
init(opts = {}) {
|
|
35
|
+
if (this._initialized) return this;
|
|
36
|
+
this.dir = opts.baseDir ? join(opts.baseDir, 'logs') : join(Config.getConfigDir(), 'logs');
|
|
37
|
+
mkdirSync(this.dir, { recursive: true });
|
|
38
|
+
this.file = join(this.dir, `vierrataleai-${dateStamp(new Date())}.log`);
|
|
39
|
+
this._initialized = true;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
log(event, message = '') {
|
|
44
|
+
if (!this._initialized) return '';
|
|
45
|
+
const line = `${timeStamp(new Date())} [${event}] ${cap(message)}`;
|
|
46
|
+
try {
|
|
47
|
+
appendFileSync(this.file, line + '\n');
|
|
48
|
+
} catch {}
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getPath() {
|
|
53
|
+
return this.file;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
tail(count = 30) {
|
|
57
|
+
if (!this._initialized || !existsSync(this.file)) return [];
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(this.file, 'utf-8').split('\n').filter(Boolean).slice(-count);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { Logger };
|
|
67
|
+
export const logger = new Logger();
|