@vierratale/ai 0.1.0-beta.6 → 0.1.0-beta.7
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/package.json +1 -1
- package/src/cli.js +86 -5
- package/src/config.js +6 -4
- package/src/providers/cortex.js +1 -0
- package/src/utils/webfetch.js +115 -0
package/package.json
CHANGED
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();
|
|
@@ -236,6 +297,16 @@ async function chat(provider, systemPrompt) {
|
|
|
236
297
|
continue;
|
|
237
298
|
}
|
|
238
299
|
|
|
300
|
+
if (cmd.startsWith('/fetch ') || cmd === '/fetch') {
|
|
301
|
+
const urlPart = trimmed.slice(6).trim().split(/\s+/)[0];
|
|
302
|
+
if (!urlPart) {
|
|
303
|
+
Terminal.printWarning('Usage: /fetch <url>');
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
await answerWithFetch(messages, provider, urlPart, systemPrompt);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
239
310
|
const intent = detectIntent(trimmed);
|
|
240
311
|
if (intent.type === 'knowledge') {
|
|
241
312
|
Terminal.printSuccess(`Auto-search: "${trimmed}"`);
|
|
@@ -243,6 +314,16 @@ async function chat(provider, systemPrompt) {
|
|
|
243
314
|
continue;
|
|
244
315
|
}
|
|
245
316
|
|
|
317
|
+
// If the message contains a bare URL, open it automatically and ask the AI to summarize.
|
|
318
|
+
if (/https?:\/\/\S+/i.test(trimmed)) {
|
|
319
|
+
const url = extractUrlFromText(trimmed);
|
|
320
|
+
if (url) {
|
|
321
|
+
Terminal.printSuccess(`Auto-open link detected. Fetching...`);
|
|
322
|
+
await answerWithFetch(messages, provider, url, systemPrompt);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
246
327
|
messages.push({ role: 'user', content: trimmed });
|
|
247
328
|
Session.save(messages);
|
|
248
329
|
Terminal.printAIStart();
|
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/providers/cortex.js
CHANGED
|
@@ -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
|
+
}
|