@vierratale/ai 0.1.0-beta.6 → 0.1.0-beta.8
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 +6 -6
- package/package.json +1 -1
- package/src/catalog.js +6 -6
- package/src/cli.js +122 -7
- package/src/config.js +6 -4
- package/src/installer.js +20 -5
- package/src/providers/cortex.js +18 -0
- package/src/ui/branding.js +1 -1
- package/src/ui/terminal.js +49 -16
- package/src/utils/intents.js +12 -0
- package/src/utils/webfetch.js +115 -0
package/README.md
CHANGED
|
@@ -32,12 +32,12 @@ Local models run through the **Cortex** engine:
|
|
|
32
32
|
|
|
33
33
|
| Model | Backend |
|
|
34
34
|
|-------|---------|
|
|
35
|
-
| vierratale-lite |
|
|
36
|
-
| vierratale-fast |
|
|
37
|
-
| vierratale-small |
|
|
38
|
-
| vierratale-balanced | qwen2.5
|
|
39
|
-
| vierratale-plus |
|
|
40
|
-
| vierratale-pro | qwen2.5
|
|
35
|
+
| vierratale-lite | qwen3:0.6b |
|
|
36
|
+
| vierratale-fast | gemma3:1b |
|
|
37
|
+
| vierratale-small | llama3.2:1b |
|
|
38
|
+
| vierratale-balanced | qwen2.5:1.5b |
|
|
39
|
+
| vierratale-plus | qwen3:1.7b |
|
|
40
|
+
| vierratale-pro | qwen2.5:3b |
|
|
41
41
|
|
|
42
42
|
Cloud models are available when the matching provider + API key is configured:
|
|
43
43
|
|
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
const MODELS = {
|
|
2
|
-
'
|
|
3
|
-
'
|
|
4
|
-
'
|
|
5
|
-
'qwen2.5
|
|
6
|
-
'
|
|
7
|
-
'qwen2.5
|
|
2
|
+
'qwen3:0.6b': 'vierratale-lite',
|
|
3
|
+
'gemma3:1b': 'vierratale-fast',
|
|
4
|
+
'llama3.2:1b': 'vierratale-small',
|
|
5
|
+
'qwen2.5:1.5b': 'vierratale-balanced',
|
|
6
|
+
'qwen3:1.7b': 'vierratale-plus',
|
|
7
|
+
'qwen2.5:3b': 'vierratale-pro',
|
|
8
8
|
'gpt-4o-mini': 'vierratale-cloud-mini',
|
|
9
9
|
'gpt-4o': 'vierratale-cloud',
|
|
10
10
|
'claude-3-5-haiku-20241022': 'vierratale-cloud-fast',
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Branding } from './ui/branding.js';
|
|
|
7
7
|
import { Terminal } from './ui/terminal.js';
|
|
8
8
|
import { loadSystemPrompt, showBanner } from './ui/banner.js';
|
|
9
9
|
import { WebSearch } from './utils/websearch.js';
|
|
10
|
+
import { WebFetch } from './utils/webfetch.js';
|
|
10
11
|
import { detectIntent } from './utils/intents.js';
|
|
11
12
|
import { Session } from './session.js';
|
|
12
13
|
|
|
@@ -41,6 +42,7 @@ Commands (in chat):
|
|
|
41
42
|
/provider [name] Switch provider
|
|
42
43
|
/models List available models
|
|
43
44
|
/search <query> Search the web and summarize with AI
|
|
45
|
+
/fetch <url> Open a link and summarize its content
|
|
44
46
|
/clear Clear screen
|
|
45
47
|
/new Start a new conversation (clear memory)
|
|
46
48
|
/quit Exit
|
|
@@ -88,6 +90,54 @@ async function answerWithSearch(messages, provider, query, systemPrompt) {
|
|
|
88
90
|
Session.save(messages);
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
function extractUrlFromText(text) {
|
|
94
|
+
const m = text.match(/https?:\/\/[^\s<>"']+/i);
|
|
95
|
+
return m ? m[0] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function answerWithFetch(messages, provider, url, systemPrompt) {
|
|
99
|
+
Terminal.printInfo(`Opening ${url}...`);
|
|
100
|
+
let page;
|
|
101
|
+
try {
|
|
102
|
+
page = await WebFetch.fetch(url);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
Terminal.printError(err.message || 'Could not fetch the link.');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Terminal.printSuccess(`Opened: ${page.title || page.url}`);
|
|
109
|
+
const content = page.text.trim();
|
|
110
|
+
if (!content) {
|
|
111
|
+
Terminal.printWarning('Nothing readable found on that page.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
messages.push({
|
|
116
|
+
role: 'user',
|
|
117
|
+
content: `Here is the content fetched from the URL "${page.url}" (${page.title || 'no title'}):\n\n${content}\n\nPlease summarize and answer based on this content. Be concise.`,
|
|
118
|
+
});
|
|
119
|
+
Session.save(messages);
|
|
120
|
+
|
|
121
|
+
Terminal.printAIStart();
|
|
122
|
+
let response = '';
|
|
123
|
+
try {
|
|
124
|
+
for await (const chunk of provider.stream(messages, {
|
|
125
|
+
model: Config.getEffectiveModel(provider.name),
|
|
126
|
+
systemPrompt,
|
|
127
|
+
})) {
|
|
128
|
+
Terminal.printAIChunk(chunk);
|
|
129
|
+
response += chunk;
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
Terminal.printError(err.message || 'Stream error');
|
|
133
|
+
}
|
|
134
|
+
Terminal.printAIEnd();
|
|
135
|
+
if (response) {
|
|
136
|
+
messages.push({ role: 'assistant', content: response });
|
|
137
|
+
}
|
|
138
|
+
Session.save(messages);
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
async function chat(provider, systemPrompt) {
|
|
92
142
|
const messages = Session.load();
|
|
93
143
|
let exited = false;
|
|
@@ -102,21 +152,32 @@ async function chat(provider, systemPrompt) {
|
|
|
102
152
|
terminal: false,
|
|
103
153
|
});
|
|
104
154
|
|
|
155
|
+
let eof = false;
|
|
156
|
+
let pendingResolver = null;
|
|
157
|
+
|
|
105
158
|
rl.on('close', () => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
159
|
+
// Graceful shutdown: don't hard-exit mid-command. Mark EOF so the loop
|
|
160
|
+
// stops asking for more input, and let any in-flight command finish.
|
|
161
|
+
eof = true;
|
|
162
|
+
if (pendingResolver) {
|
|
163
|
+
pendingResolver(null);
|
|
164
|
+
pendingResolver = null;
|
|
109
165
|
}
|
|
110
166
|
});
|
|
111
167
|
|
|
112
168
|
const prompt = () => new Promise((resolve) => {
|
|
169
|
+
if (eof) return resolve(null);
|
|
170
|
+
pendingResolver = resolve;
|
|
113
171
|
rl.question(
|
|
114
172
|
`\n${Branding.colors.bold}${Branding.colors.primary}┌ ${Branding.USER_PROMPT}${Branding.colors.reset} `,
|
|
115
|
-
|
|
173
|
+
(ans) => {
|
|
174
|
+
pendingResolver = null;
|
|
175
|
+
resolve(ans);
|
|
176
|
+
}
|
|
116
177
|
);
|
|
117
178
|
});
|
|
118
179
|
|
|
119
|
-
while (!exited) {
|
|
180
|
+
while (!exited && !eof) {
|
|
120
181
|
let input;
|
|
121
182
|
try {
|
|
122
183
|
input = await prompt();
|
|
@@ -176,6 +237,7 @@ async function chat(provider, systemPrompt) {
|
|
|
176
237
|
Config.save({ model: modelName });
|
|
177
238
|
Config.setProviderModel(provider.name, modelName);
|
|
178
239
|
Terminal.printSuccess(`Model: ${modelName}`);
|
|
240
|
+
await ensureLocalModel(modelName);
|
|
179
241
|
} else {
|
|
180
242
|
Terminal.printError(`Unknown model: ${modelName}`);
|
|
181
243
|
}
|
|
@@ -236,16 +298,48 @@ async function chat(provider, systemPrompt) {
|
|
|
236
298
|
continue;
|
|
237
299
|
}
|
|
238
300
|
|
|
301
|
+
if (cmd.startsWith('/fetch ') || cmd === '/fetch') {
|
|
302
|
+
const urlPart = trimmed.slice(6).trim().split(/\s+/)[0];
|
|
303
|
+
if (!urlPart) {
|
|
304
|
+
Terminal.printWarning('Usage: /fetch <url>');
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
await answerWithFetch(messages, provider, urlPart, systemPrompt);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
239
311
|
const intent = detectIntent(trimmed);
|
|
312
|
+
if (intent.type === 'self') {
|
|
313
|
+
const model = Config.getEffectiveModel(provider.name);
|
|
314
|
+
const info = Catalog.getModelInfo(model);
|
|
315
|
+
Terminal.printSuccess(
|
|
316
|
+
`I'm running on ${model} (${info ? info.realModel : model}) via the ${provider.displayName} engine.`
|
|
317
|
+
);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
240
321
|
if (intent.type === 'knowledge') {
|
|
241
322
|
Terminal.printSuccess(`Auto-search: "${trimmed}"`);
|
|
242
323
|
await answerWithSearch(messages, provider, trimmed, systemPrompt);
|
|
243
324
|
continue;
|
|
244
325
|
}
|
|
245
326
|
|
|
327
|
+
// If the message contains a bare URL, open it automatically and ask the AI to summarize.
|
|
328
|
+
if (/https?:\/\/\S+/i.test(trimmed)) {
|
|
329
|
+
const url = extractUrlFromText(trimmed);
|
|
330
|
+
if (url) {
|
|
331
|
+
Terminal.printSuccess(`Auto-open link detected. Fetching...`);
|
|
332
|
+
await answerWithFetch(messages, provider, url, systemPrompt);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
246
337
|
messages.push({ role: 'user', content: trimmed });
|
|
247
338
|
Session.save(messages);
|
|
339
|
+
Terminal.printUser(trimmed);
|
|
248
340
|
Terminal.printAIStart();
|
|
341
|
+
Terminal.beginThinking();
|
|
342
|
+
Terminal._thinking = true;
|
|
249
343
|
|
|
250
344
|
let response = '';
|
|
251
345
|
try {
|
|
@@ -268,6 +362,21 @@ async function chat(provider, systemPrompt) {
|
|
|
268
362
|
}
|
|
269
363
|
}
|
|
270
364
|
|
|
365
|
+
async function ensureLocalModel(modelName) {
|
|
366
|
+
const info = Catalog.getModelInfo(modelName);
|
|
367
|
+
if (!info || !info.isLocal) return;
|
|
368
|
+
const host = Config.get('engineHost');
|
|
369
|
+
try {
|
|
370
|
+
const installed = await Installer.getInstalledModels(host);
|
|
371
|
+
if (installed.includes(info.realModel)) return;
|
|
372
|
+
Terminal.printInfo(`Downloading ${info.realModel} for "${modelName}"…`);
|
|
373
|
+
await Installer.pullModel(host, info.realModel);
|
|
374
|
+
Terminal.printSuccess(`Model ${info.realModel} ready.`);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
Terminal.printError(`Could not download model: ${err.message}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
271
380
|
export async function run() {
|
|
272
381
|
const args = parseArgs(process.argv);
|
|
273
382
|
|
|
@@ -285,8 +394,12 @@ export async function run() {
|
|
|
285
394
|
if (args.provider) Config.save({ provider: args.provider });
|
|
286
395
|
if (args.model) Config.save({ model: args.model });
|
|
287
396
|
|
|
288
|
-
Terminal.
|
|
289
|
-
|
|
397
|
+
await Terminal.showProgress(
|
|
398
|
+
['Checking engine', 'Installing dependencies', 'Downloading model', 'Optimizing'],
|
|
399
|
+
() => Installer.ensure()
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
await ensureLocalModel(Config.get('model'));
|
|
290
403
|
|
|
291
404
|
const provider = await ProviderFactory.autoDetect();
|
|
292
405
|
if (!provider) {
|
|
@@ -296,6 +409,8 @@ export async function run() {
|
|
|
296
409
|
|
|
297
410
|
Config.save({ provider: provider.name });
|
|
298
411
|
|
|
412
|
+
if (provider.warmup) provider.warmup();
|
|
413
|
+
|
|
299
414
|
if (args.clear) Terminal.clear();
|
|
300
415
|
showBanner(provider.name, Config.getEffectiveModel(provider.name), provider.displayName);
|
|
301
416
|
|
package/src/config.js
CHANGED
|
@@ -9,9 +9,10 @@ const DEFAULTS = {
|
|
|
9
9
|
provider: 'auto',
|
|
10
10
|
model: 'vierratale-fast',
|
|
11
11
|
engineHost: 'http://127.0.0.1:11434',
|
|
12
|
-
numCtx:
|
|
12
|
+
numCtx: 2048,
|
|
13
13
|
temperature: 0.7,
|
|
14
14
|
maxTokens: 4096,
|
|
15
|
+
keepAlive: '5m',
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
function loadConfigFile() {
|
|
@@ -39,9 +40,10 @@ export const Config = {
|
|
|
39
40
|
provider: getEnv('VIERRATALE_PROVIDER', file.provider || DEFAULTS.provider),
|
|
40
41
|
model: getEnv('VIERRATALE_MODEL', file.model || DEFAULTS.model),
|
|
41
42
|
engineHost: getEnv('VIERRATALE_ENGINE_HOST', engineHost),
|
|
42
|
-
numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx
|
|
43
|
-
temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature
|
|
44
|
-
maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens
|
|
43
|
+
numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx ?? DEFAULTS.numCtx))),
|
|
44
|
+
temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature ?? DEFAULTS.temperature))),
|
|
45
|
+
maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens ?? DEFAULTS.maxTokens))),
|
|
46
|
+
keepAlive: getEnv('VIERRATALE_KEEP_ALIVE', file.keepAlive ?? DEFAULTS.keepAlive),
|
|
45
47
|
openaiApiKey: getEnv('OPENAI_API_KEY', file.openaiApiKey || ''),
|
|
46
48
|
anthropicApiKey: getEnv('ANTHROPIC_API_KEY', file.anthropicApiKey || ''),
|
|
47
49
|
geminiApiKey: getEnv('GEMINI_API_KEY', file.geminiApiKey || ''),
|
package/src/installer.js
CHANGED
|
@@ -55,6 +55,14 @@ async function getInstalledModels(host) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export const Installer = {
|
|
58
|
+
async pullModel(host, model) {
|
|
59
|
+
return pullModel(host, model);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async getInstalledModels(host) {
|
|
63
|
+
return getInstalledModels(host);
|
|
64
|
+
},
|
|
65
|
+
|
|
58
66
|
async ensure() {
|
|
59
67
|
const host = Config.get('engineHost');
|
|
60
68
|
|
|
@@ -65,16 +73,23 @@ export const Installer = {
|
|
|
65
73
|
const running = await isEngineRunning(host);
|
|
66
74
|
if (!running) {
|
|
67
75
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
// No systemd available here, so launch the daemon directly, detached,
|
|
77
|
+
// and poll until it responds instead of relying on a service manager.
|
|
78
|
+
const child = exec('ollama serve', {
|
|
79
|
+
stdio: 'ignore',
|
|
80
|
+
detached: true,
|
|
81
|
+
});
|
|
82
|
+
if (child.unref) child.unref();
|
|
83
|
+
for (let i = 0; i < 30; i++) {
|
|
84
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
85
|
+
if (await isEngineRunning(host)) break;
|
|
86
|
+
}
|
|
70
87
|
} catch {}
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
const realModel = Catalog.getRealModel(Config.get('model'));
|
|
74
91
|
const installed = await getInstalledModels(host);
|
|
75
|
-
const needsPull = !installed.
|
|
76
|
-
(m) => m === realModel || m.startsWith(realModel.split(':')[0])
|
|
77
|
-
);
|
|
92
|
+
const needsPull = !installed.includes(realModel);
|
|
78
93
|
|
|
79
94
|
if (needsPull) {
|
|
80
95
|
await pullModel(host, realModel);
|
package/src/providers/cortex.js
CHANGED
|
@@ -59,6 +59,7 @@ export class CortexProvider extends BaseProvider {
|
|
|
59
59
|
model,
|
|
60
60
|
messages: engineMessages,
|
|
61
61
|
stream: true,
|
|
62
|
+
keep_alive: Config.get('keepAlive'),
|
|
62
63
|
options: {
|
|
63
64
|
num_ctx: Config.get('numCtx'),
|
|
64
65
|
temperature: options.temperature || Config.get('temperature'),
|
|
@@ -94,4 +95,21 @@ export class CortexProvider extends BaseProvider {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
|
|
99
|
+
async warmup() {
|
|
100
|
+
// Fire-and-forget: send a tiny request so ollama loads the model into
|
|
101
|
+
// memory eagerly, so the first real query doesn't pay the cold-load cost.
|
|
102
|
+
const model = Catalog.getRealModel(Config.get('model'));
|
|
103
|
+
fetch(`${this.host}/api/chat`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
model,
|
|
108
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
109
|
+
stream: false,
|
|
110
|
+
keep_alive: Config.get('keepAlive'),
|
|
111
|
+
options: { num_ctx: Config.get('numCtx') },
|
|
112
|
+
}),
|
|
113
|
+
}).catch(() => {});
|
|
114
|
+
}
|
|
97
115
|
}
|
package/src/ui/branding.js
CHANGED
package/src/ui/terminal.js
CHANGED
|
@@ -9,25 +9,40 @@ export const Terminal = {
|
|
|
9
9
|
|
|
10
10
|
printUser(text) {
|
|
11
11
|
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
12
|
+
process.stdout.write('\r\x1b[K');
|
|
13
|
+
console.log();
|
|
14
|
+
console.log(` ${C.bold}${C.primary}You${C.reset} ${C.dim}${time}${C.reset}`);
|
|
15
|
+
console.log(` ${text}`);
|
|
12
16
|
console.log();
|
|
13
|
-
console.log(`${this.divider('─', 4)} ${C.bold}${C.primary}YOU${C.reset} ${C.dim}${time}${C.reset} ${this.divider('─', 34)}`);
|
|
14
|
-
console.log(`${C.primary}┃${C.reset} ${text}`);
|
|
15
17
|
},
|
|
16
18
|
|
|
17
19
|
printAIStart() {
|
|
18
20
|
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
19
|
-
this.spinner = { frame: 0, timer: null };
|
|
20
21
|
console.log();
|
|
21
|
-
console.log(
|
|
22
|
+
console.log(` ${C.bold}${C.accent}${Branding.AI_PROMPT}${C.reset} ${C.dim}${time}${C.reset}`);
|
|
22
23
|
this.curLine = '';
|
|
23
24
|
this._printPrefix();
|
|
24
25
|
},
|
|
25
26
|
|
|
26
27
|
_printPrefix() {
|
|
27
|
-
process.stdout.write(
|
|
28
|
+
process.stdout.write(` ${C.accent}│${C.reset} `);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
beginThinking() {
|
|
32
|
+
process.stdout.write(`${C.dim}· · ·${C.reset}`);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
endThinking() {
|
|
36
|
+
process.stdout.write('\r\x1b[2K');
|
|
37
|
+
this._printPrefix();
|
|
28
38
|
},
|
|
29
39
|
|
|
30
40
|
printAIChunk(text) {
|
|
41
|
+
this._thinking = this._thinking || false;
|
|
42
|
+
if (this._thinking) {
|
|
43
|
+
this.endThinking();
|
|
44
|
+
this._thinking = false;
|
|
45
|
+
}
|
|
31
46
|
this.curLine = (this.curLine || '') + text;
|
|
32
47
|
process.stdout.write(text);
|
|
33
48
|
this._maybeWrap();
|
|
@@ -39,7 +54,7 @@ export const Terminal = {
|
|
|
39
54
|
if (words.length > 1) {
|
|
40
55
|
const lastWord = words[words.length - 1];
|
|
41
56
|
const rest = this.curLine.slice(0, this.curLine.length - lastWord.length).trimEnd();
|
|
42
|
-
process.stdout.write(`\n${C.accent}
|
|
57
|
+
process.stdout.write(`\n${C.accent}│${C.reset} `);
|
|
43
58
|
this.curLine = lastWord;
|
|
44
59
|
}
|
|
45
60
|
}
|
|
@@ -47,36 +62,54 @@ export const Terminal = {
|
|
|
47
62
|
|
|
48
63
|
printAIEnd() {
|
|
49
64
|
process.stdout.write('\n');
|
|
50
|
-
console.log(`${C.accent}┗${C.reset}${this.divider('─', 44)}`);
|
|
51
65
|
console.log();
|
|
52
66
|
},
|
|
53
67
|
|
|
54
68
|
printError(msg) {
|
|
55
|
-
console.log(
|
|
69
|
+
console.log(` ${C.error}✖${C.reset} ${msg}`);
|
|
56
70
|
},
|
|
57
71
|
|
|
58
72
|
printInfo(msg) {
|
|
59
|
-
console.log(
|
|
73
|
+
console.log(` ${C.dim}${msg}${C.reset}`);
|
|
60
74
|
},
|
|
61
75
|
|
|
62
76
|
printSuccess(msg) {
|
|
63
|
-
console.log(
|
|
77
|
+
console.log(` ${C.success}✔${C.reset} ${msg}`);
|
|
64
78
|
},
|
|
65
79
|
|
|
66
80
|
printWarning(msg) {
|
|
67
|
-
console.log(
|
|
81
|
+
console.log(` ${C.warning}⚠${C.reset} ${msg}`);
|
|
68
82
|
},
|
|
69
83
|
|
|
70
84
|
printSearchResults(results) {
|
|
71
|
-
console.log(
|
|
85
|
+
console.log(` ${C.warning}Web Search Results${C.reset}`);
|
|
72
86
|
results.forEach((r, i) => {
|
|
73
|
-
console.log(
|
|
74
|
-
` ${C.warning}${i + 1}.${C.reset} ${C.bold}${r.title}${C.reset}`
|
|
75
|
-
);
|
|
87
|
+
console.log(` ${C.warning}${i + 1}.${C.reset} ${C.bold}${r.title}${C.reset}`);
|
|
76
88
|
console.log(` ${C.dim}${r.url}${C.reset}`);
|
|
77
89
|
if (r.snippet) console.log(` ${r.snippet.slice(0, 120)}`);
|
|
78
90
|
});
|
|
79
|
-
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async showProgress(steps, work) {
|
|
94
|
+
// Hidden real progress: the work() task runs silently while we paint a
|
|
95
|
+
// fake animated progress line so the install feels alive.
|
|
96
|
+
const frames = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
97
|
+
let i = 0;
|
|
98
|
+
const timer = setInterval(() => {
|
|
99
|
+
i++;
|
|
100
|
+
const stage = steps[Math.floor((i / 8) % steps.length)];
|
|
101
|
+
const bar = frames[i % frames.length];
|
|
102
|
+
const fill = '='.repeat(1 + (i % 18)) + '>' + ' '.repeat(18 - (i % 18));
|
|
103
|
+
process.stdout.write(`\r ${C.accent}${bar}${C.reset} ${C.dim}${stage}${C.reset} [${C.accent}${fill}${C.reset}]`);
|
|
104
|
+
}, 100);
|
|
105
|
+
try {
|
|
106
|
+
await work();
|
|
107
|
+
} finally {
|
|
108
|
+
clearInterval(timer);
|
|
109
|
+
process.stdout.write('\r\x1b[2K');
|
|
110
|
+
console.log(` ${C.success}✔${C.reset} ${C.dim}Ready${C.reset}`);
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
|
80
113
|
},
|
|
81
114
|
|
|
82
115
|
clear() {
|
package/src/utils/intents.js
CHANGED
|
@@ -11,10 +11,22 @@ const KNOWLEDGE_PREFIXES = [
|
|
|
11
11
|
'latest', 'news', 'current',
|
|
12
12
|
];
|
|
13
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
|
+
|
|
14
22
|
export function detectIntent(text) {
|
|
15
23
|
const lower = text.toLowerCase().trim();
|
|
16
24
|
if (lower.startsWith('/')) return { type: 'command' };
|
|
17
25
|
|
|
26
|
+
if (SELF_PHRASES.some((p) => lower.includes(p))) {
|
|
27
|
+
return { type: 'self' };
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
const isQuestion = /\?$/.test(lower);
|
|
19
31
|
const firstWord = lower.split(/\s+/)[0] || '';
|
|
20
32
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36';
|
|
2
|
+
|
|
3
|
+
const MAX_BYTES = 200000; // 200KB cap
|
|
4
|
+
const MAX_TEXT = 8000; // ~8k chars of readable text
|
|
5
|
+
|
|
6
|
+
const BLOCK_TAGS = new Set([
|
|
7
|
+
'script', 'style', 'noscript', 'svg', 'head', 'title',
|
|
8
|
+
'nav', 'footer', 'aside', 'iframe', 'form', 'button', 'noscript',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export class WebFetch {
|
|
12
|
+
static normalizeUrl(input) {
|
|
13
|
+
const trimmed = (input || '').trim();
|
|
14
|
+
if (!trimmed) return null;
|
|
15
|
+
let url = trimmed;
|
|
16
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
17
|
+
// Already has an explicit scheme — must be http(s).
|
|
18
|
+
if (!/^https?:\/\//i.test(url)) return null;
|
|
19
|
+
} else if (!/^https?:\/\//i.test(url)) {
|
|
20
|
+
url = `https://${url}`;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
25
|
+
return u.toString();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static async fetch(rawUrl, maxText = MAX_TEXT) {
|
|
32
|
+
const url = this.normalizeUrl(rawUrl);
|
|
33
|
+
if (!url) {
|
|
34
|
+
throw new Error('Invalid URL. Provide a valid http(s) address.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let resp;
|
|
38
|
+
try {
|
|
39
|
+
resp = await fetch(url, {
|
|
40
|
+
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
|
|
41
|
+
redirect: 'follow',
|
|
42
|
+
signal: AbortSignal.timeout(15000),
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`Could not reach ${url}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!resp.ok) {
|
|
49
|
+
throw new Error(`Request failed (${resp.status}) for ${url}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Refuse to read the whole body past the cap.
|
|
53
|
+
const reader = resp.body.getReader();
|
|
54
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
55
|
+
let html = '';
|
|
56
|
+
let received = 0;
|
|
57
|
+
while (received < MAX_BYTES) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
received += value.length;
|
|
61
|
+
html += decoder.decode(value, { stream: true });
|
|
62
|
+
}
|
|
63
|
+
reader.cancel();
|
|
64
|
+
html += decoder.decode();
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
url: resp.url || url,
|
|
68
|
+
title: this._extractTitle(html),
|
|
69
|
+
text: this._extractText(html).slice(0, maxText),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static _extractTitle(html) {
|
|
74
|
+
const m = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
|
|
75
|
+
return m ? this._stripEntities(m[1].trim()) : '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static _extractText(html) {
|
|
79
|
+
// Strip block tags' contents that add no readable value.
|
|
80
|
+
let s = html.replace(/<(script|style|noscript|svg|head|iframe|form|nav|footer|aside)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
|
|
81
|
+
|
|
82
|
+
// Force spacing around block-level elements so words don't merge.
|
|
83
|
+
s = s.replace(/<\/(p|div|h[1-6]|li|tr|br|section|article)>/gi, '\n');
|
|
84
|
+
s = s.replace(/<(br|li|tr)[^>]*>/gi, '\n');
|
|
85
|
+
|
|
86
|
+
// Remove remaining tags.
|
|
87
|
+
s = s.replace(/<[^>]+>/g, ' ');
|
|
88
|
+
|
|
89
|
+
// College entities.
|
|
90
|
+
s = this._stripEntities(s);
|
|
91
|
+
|
|
92
|
+
// Collapse whitespace and trim lines.
|
|
93
|
+
return s
|
|
94
|
+
.replace(/[ \t]+/g, ' ')
|
|
95
|
+
.replace(/ *\n */g, '\n')
|
|
96
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
97
|
+
.replace(/[ \t]+/g, ' ')
|
|
98
|
+
.replace(/\u00a0/g, ' ')
|
|
99
|
+
.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static _stripEntities(text) {
|
|
103
|
+
const map = {
|
|
104
|
+
'&': '&', '<': '<', '>': '>', '"': '"',
|
|
105
|
+
''': "'", ''': "'", ''': "'", ' ': ' ',
|
|
106
|
+
'–': '–', '—': '—', '…': '...', '©': '©',
|
|
107
|
+
''': "'", '’': '’', '‘': '‘', '“': '“', '”': '”',
|
|
108
|
+
};
|
|
109
|
+
return String(text)
|
|
110
|
+
.replace(/<[^>]*>/g, '')
|
|
111
|
+
.replace(/&[a-zA-Z0-9#]+;/g, (m) => map[m] ?? '')
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
}
|