@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 CHANGED
@@ -32,12 +32,12 @@ Local models run through the **Cortex** engine:
32
32
 
33
33
  | Model | Backend |
34
34
  |-------|---------|
35
- | vierratale-lite | qwen2.5-coder:0.5b |
36
- | vierratale-fast | qwen2.5-coder:1.5b |
37
- | vierratale-small | qwen2.5-coder:3b |
38
- | vierratale-balanced | qwen2.5-coder:7b |
39
- | vierratale-plus | qwen2.5-coder:14b |
40
- | vierratale-pro | qwen2.5-coder:32b |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vierratale/ai",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.8",
4
4
  "description": "VierrataleAI - Intelligent terminal assistant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/catalog.js CHANGED
@@ -1,10 +1,10 @@
1
1
  const MODELS = {
2
- 'qwen2.5-coder:0.5b': 'vierratale-lite',
3
- 'qwen2.5-coder:1.5b': 'vierratale-fast',
4
- 'qwen2.5-coder:3b': 'vierratale-small',
5
- 'qwen2.5-coder:7b': 'vierratale-balanced',
6
- 'qwen2.5-coder:14b': 'vierratale-plus',
7
- 'qwen2.5-coder:32b': 'vierratale-pro',
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
- 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();
@@ -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.printInfo('Initializing...');
289
- await Installer.ensure();
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: 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 || ''),
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
- execSync('ollama serve &', { stdio: 'ignore' });
69
- await new Promise((r) => setTimeout(r, 3000));
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.some(
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);
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  export const Branding = {
2
2
  APP_NAME: 'VierrataleAI',
3
- VERSION: '0.1.0-beta.6',
3
+ VERSION: '0.1.0-beta.8',
4
4
 
5
5
  colors: {
6
6
  primary: '\x1b[38;2;124;58;237m',
@@ -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(`${this.divider('─', 4)} ${C.bold}${C.accent}${Branding.AI_PROMPT}${C.reset} ${C.dim}${time}${C.reset} ${this.divider('─', 34)}`);
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(`${C.accent}┃ ${C.reset}`);
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}┃ ${C.reset}`);
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(`${C.error}✖ ${msg}${C.reset}`);
69
+ console.log(` ${C.error}✖${C.reset} ${msg}`);
56
70
  },
57
71
 
58
72
  printInfo(msg) {
59
- console.log(`${C.dim}${msg}${C.reset}`);
73
+ console.log(` ${C.dim}${msg}${C.reset}`);
60
74
  },
61
75
 
62
76
  printSuccess(msg) {
63
- console.log(`${C.success}✔ ${msg}${C.reset}`);
77
+ console.log(` ${C.success}✔${C.reset} ${msg}`);
64
78
  },
65
79
 
66
80
  printWarning(msg) {
67
- console.log(`${C.warning}⚠ ${msg}${C.reset}`);
81
+ console.log(` ${C.warning}⚠${C.reset} ${msg}`);
68
82
  },
69
83
 
70
84
  printSearchResults(results) {
71
- console.log(`${C.warning}┌── Web Search Results ──${C.reset}`);
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
- console.log(`${C.warning}└──${C.reset}`);
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() {
@@ -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
+ '&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
+ }