@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vierratale/ai",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.7",
4
4
  "description": "VierrataleAI - Intelligent terminal assistant",
5
5
  "type": "module",
6
6
  "bin": {
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
- if (!exited) {
107
- exited = true;
108
- process.exit(0);
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
- resolve
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: 8192,
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 || DEFAULTS.numCtx))),
43
- temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature || DEFAULTS.temperature))),
44
- maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens || DEFAULTS.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 || ''),
@@ -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'),
@@ -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
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
105
+ '&#39;': "'", '&#039;': "'", '&apos;': "'", '&nbsp;': ' ',
106
+ '&ndash;': '–', '&mdash;': '—', '&hellip;': '...', '&copy;': '©',
107
+ '&#x27;': "'", '&rsquo;': '’', '&lsquo;': '‘', '&ldquo;': '“', '&rdquo;': '”',
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
+ }