@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.3

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
@@ -40,9 +40,28 @@ vierrataleai --model vierratale-pro # Use specific model
40
40
  - `/model [name]` - Switch model
41
41
  - `/provider [name]` - Switch provider
42
42
  - `/models` - List available models
43
+ - `/search <query>` - Search the web and summarize with AI
43
44
  - `/clear` - Clear screen
45
+ - `/new` - Start a new conversation (clear memory)
44
46
  - `/quit` - Exit
45
47
 
48
+ ## Smart Search
49
+
50
+ Asking a question (Who/What/When/Where/Why/How...?) automatically searches the
51
+ web and summarizes the results with AI. For example:
52
+
53
+ ```
54
+ > Who is Cristiano Ronaldo?
55
+ ```
56
+
57
+ triggers a live web search and answers from the results. Use `/search <query>`
58
+ to force a search explicitly.
59
+
60
+ ## Conversation Memory
61
+
62
+ Your conversation is saved automatically and restored on the next launch, so
63
+ you can continue where you left off. Use `/new` to start fresh.
64
+
46
65
  ## Configuration
47
66
 
48
67
  Config file: `~/.config/vierrataleai/config.json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vierratale/ai",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "VierrataleAI - Intelligent terminal assistant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -6,6 +6,9 @@ import { ProviderFactory } from './providers/index.js';
6
6
  import { Branding } from './ui/branding.js';
7
7
  import { Terminal } from './ui/terminal.js';
8
8
  import { loadSystemPrompt, showBanner } from './ui/banner.js';
9
+ import { WebSearch } from './utils/websearch.js';
10
+ import { detectIntent } from './utils/intents.js';
11
+ import { Session } from './session.js';
9
12
 
10
13
  function parseArgs(args) {
11
14
  const parsed = { provider: null, model: null, clear: false, version: false, help: false };
@@ -37,15 +40,61 @@ Commands (in chat):
37
40
  /model [name] Switch model
38
41
  /provider [name] Switch provider
39
42
  /models List available models
43
+ /search <query> Search the web and summarize with AI
40
44
  /clear Clear screen
45
+ /new Start a new conversation (clear memory)
41
46
  /quit Exit
42
47
  `);
43
48
  }
44
49
 
50
+ async function answerWithSearch(messages, provider, query) {
51
+ Terminal.printInfo('Searching the web...');
52
+ const results = await WebSearch.search(query);
53
+
54
+ if (results.length === 0) {
55
+ Terminal.printError('No results found for the query.');
56
+ return;
57
+ }
58
+
59
+ Terminal.printSearchResults(results);
60
+
61
+ const searchContext = results.map((r, i) =>
62
+ `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`
63
+ ).join('\n\n');
64
+
65
+ messages.push({
66
+ role: 'user',
67
+ content: `Web search results for "${query}":\n\n${searchContext}\n\nPlease summarize these results and answer based on them. Be concise.`,
68
+ });
69
+ Session.save(messages);
70
+
71
+ Terminal.printAIStart();
72
+ let response = '';
73
+ try {
74
+ for await (const chunk of provider.stream(messages, {
75
+ model: Config.get('model'),
76
+ systemPrompt,
77
+ })) {
78
+ Terminal.printAIChunk(chunk);
79
+ response += chunk;
80
+ }
81
+ } catch (err) {
82
+ Terminal.printError(err.message || 'Stream error');
83
+ }
84
+ Terminal.printAIEnd();
85
+ if (response) {
86
+ messages.push({ role: 'assistant', content: response });
87
+ }
88
+ }
89
+
45
90
  async function chat(provider, systemPrompt) {
46
- const messages = [];
91
+ const messages = Session.load();
47
92
  let exited = false;
48
93
 
94
+ if (messages.length > 0) {
95
+ Terminal.printInfo(`Resumed ${messages.filter((m) => m.role === 'user').length} previous message(s).`);
96
+ }
97
+
49
98
  const rl = createInterface({
50
99
  input: process.stdin,
51
100
  output: process.stdout,
@@ -61,7 +110,7 @@ async function chat(provider, systemPrompt) {
61
110
 
62
111
  const prompt = () => new Promise((resolve) => {
63
112
  rl.question(
64
- `${Branding.colors.bold}${Branding.colors.primary}${Branding.USER_PROMPT} >${Branding.colors.reset} `,
113
+ `\n${Branding.colors.bold}${Branding.colors.primary}${Branding.USER_PROMPT}${Branding.colors.reset} `,
65
114
  resolve
66
115
  );
67
116
  });
@@ -90,7 +139,7 @@ async function chat(provider, systemPrompt) {
90
139
 
91
140
  if (cmd === '/clear') {
92
141
  Terminal.clear();
93
- showBanner(provider.name, Config.get('model'));
142
+ showBanner(provider.name, Config.get('model'), provider.displayName);
94
143
  continue;
95
144
  }
96
145
 
@@ -99,6 +148,13 @@ async function chat(provider, systemPrompt) {
99
148
  continue;
100
149
  }
101
150
 
151
+ if (cmd === '/new') {
152
+ Session.clear();
153
+ messages.length = 0;
154
+ Terminal.printSuccess('Started a new conversation.');
155
+ continue;
156
+ }
157
+
102
158
  if (cmd === '/models') {
103
159
  const models = provider.name === 'ollama'
104
160
  ? Catalog.getLocalModels()
@@ -146,7 +202,25 @@ async function chat(provider, systemPrompt) {
146
202
  continue;
147
203
  }
148
204
 
205
+ if (cmd.startsWith('/search ')) {
206
+ const query = trimmed.slice(8).trim();
207
+ if (!query) {
208
+ Terminal.printWarning('Usage: /search <query>');
209
+ continue;
210
+ }
211
+ await answerWithSearch(messages, provider, query);
212
+ continue;
213
+ }
214
+
215
+ const intent = detectIntent(trimmed);
216
+ if (intent.type === 'knowledge') {
217
+ Terminal.printSuccess(`Auto-search: "${trimmed}"`);
218
+ await answerWithSearch(messages, provider, trimmed);
219
+ continue;
220
+ }
221
+
149
222
  messages.push({ role: 'user', content: trimmed });
223
+ Session.save(messages);
150
224
  Terminal.printAIStart();
151
225
 
152
226
  let response = '';
@@ -166,6 +240,7 @@ async function chat(provider, systemPrompt) {
166
240
  if (response) {
167
241
  messages.push({ role: 'assistant', content: response });
168
242
  }
243
+ Session.save(messages);
169
244
  }
170
245
  }
171
246
 
@@ -198,7 +273,7 @@ export async function run() {
198
273
  Config.save({ provider: provider.name });
199
274
 
200
275
  if (args.clear) Terminal.clear();
201
- showBanner(provider.name, Config.get('model'));
276
+ showBanner(provider.name, Config.get('model'), provider.displayName);
202
277
 
203
278
  const systemPrompt = loadSystemPrompt();
204
279
  await chat(provider, systemPrompt);
@@ -3,6 +3,10 @@ export class BaseProvider {
3
3
  this.name = name;
4
4
  }
5
5
 
6
+ get displayName() {
7
+ return 'Cortex';
8
+ }
9
+
6
10
  async *stream(messages, options = {}) {
7
11
  throw new Error('stream() must be implemented');
8
12
  }
@@ -8,6 +8,10 @@ export class OllamaProvider extends BaseProvider {
8
8
  this.host = Config.get('ollamaHost');
9
9
  }
10
10
 
11
+ get displayName() {
12
+ return 'Cortex';
13
+ }
14
+
11
15
  async isAvailable() {
12
16
  try {
13
17
  const resp = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(3000) });
@@ -7,6 +7,10 @@ export class OpenAIProvider extends BaseProvider {
7
7
  super('openai');
8
8
  }
9
9
 
10
+ get displayName() {
11
+ return 'Nebula';
12
+ }
13
+
10
14
  async isAvailable() {
11
15
  const key = Config.get('openaiApiKey');
12
16
  if (!key) return false;
package/src/session.js ADDED
@@ -0,0 +1,41 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { Config } from './config.js';
4
+
5
+ const SESSION_FILE = join(Config.getConfigDir(), 'history.json');
6
+ const MAX_MESSAGES = 20;
7
+
8
+ export const Session = {
9
+ load() {
10
+ try {
11
+ if (existsSync(SESSION_FILE)) {
12
+ const raw = readFileSync(SESSION_FILE, 'utf-8');
13
+ const data = JSON.parse(raw || '[]');
14
+ if (Array.isArray(data)) return data.slice(-MAX_MESSAGES);
15
+ }
16
+ } catch {}
17
+ return [];
18
+ },
19
+
20
+ save(messages) {
21
+ try {
22
+ const dir = Config.getConfigDir();
23
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
24
+ writeFileSync(SESSION_FILE, JSON.stringify(messages.slice(-MAX_MESSAGES), null, 2));
25
+ } catch {}
26
+ },
27
+
28
+ clear() {
29
+ try {
30
+ if (existsSync(SESSION_FILE)) writeFileSync(SESSION_FILE, '[]');
31
+ } catch {}
32
+ },
33
+
34
+ hasHistory() {
35
+ try {
36
+ return existsSync(SESSION_FILE) && (JSON.parse(readFileSync(SESSION_FILE, 'utf-8') || '[]').length > 0);
37
+ } catch {
38
+ return false;
39
+ }
40
+ },
41
+ };
package/src/ui/banner.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { Branding } from './branding.js';
5
+ import { Terminal } from './terminal.js';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
 
@@ -15,17 +16,22 @@ export function loadSystemPrompt() {
15
16
  }
16
17
  }
17
18
 
18
- export function showBanner(provider, model) {
19
- console.log(Branding.colors.primary + Branding.BANNER + Branding.colors.reset);
20
- console.log(
21
- ` ${Branding.colors.dim}Intelligent Terminal Assistant${Branding.colors.reset}`
22
- );
23
- console.log(
24
- ` ${Branding.colors.accent}Model:${Branding.colors.reset} ${model} ` +
25
- `${Branding.colors.accent}Engine:${Branding.colors.reset} ${provider}`
26
- );
27
- console.log(
28
- ` ${Branding.colors.dim}Type /help for commands, /quit to exit${Branding.colors.reset}`
29
- );
19
+ export function showBanner(providerName, model, providerDisplayName) {
20
+ const C = Branding.colors;
21
+ const width = 44;
22
+ const border = ` ╔${'═'.repeat(width)}╗`;
23
+
24
+ console.log();
25
+ console.log(`${C.primary}${Branding.BANNER}${C.reset}`);
26
+ console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ Cortex Engine${C.reset}`);
27
+ console.log();
28
+ console.log(border);
29
+ console.log(` ${C.bold}${C.primary}Model${C.reset}${' '.repeat(width - 12)}║`);
30
+ console.log(` ║ ${C.accent}${model}${C.reset}${' '.repeat(Math.max(1, width - 5 - model.length))}║`);
31
+ console.log(` ║ ${C.bold}${C.primary}Engine${C.reset}${' '.repeat(width - 13)}║`);
32
+ console.log(` ║ ${C.accent}${providerDisplayName || 'Cortex'}${C.reset}${' '.repeat(Math.max(1, width - 7 - (providerDisplayName || 'Cortex').length))}║`);
33
+ console.log(` ╚${'═'.repeat(width)}╝`);
34
+ console.log();
35
+ console.log(` ${C.dim}Type /help for commands · /quit to exit${C.reset}`);
30
36
  console.log();
31
37
  }
@@ -1,6 +1,6 @@
1
1
  export const Branding = {
2
2
  APP_NAME: 'VierrataleAI',
3
- VERSION: '0.1.0-beta.1',
3
+ VERSION: '0.1.0-beta.3',
4
4
 
5
5
  colors: {
6
6
  primary: '\x1b[38;2;124;58;237m',
@@ -3,25 +3,56 @@ import { Branding } from './branding.js';
3
3
  const C = Branding.colors;
4
4
 
5
5
  export const Terminal = {
6
+ divider(char = '─', length = 50) {
7
+ return `${C.dim}${char.repeat(length)}${C.reset}`;
8
+ },
9
+
6
10
  printUser(text) {
7
- console.log(`${C.bold}${C.primary}${Branding.USER_PROMPT} >${C.reset} ${text}`);
11
+ const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
12
+ 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}`);
8
15
  },
9
16
 
10
17
  printAIStart() {
11
- process.stdout.write(`${C.bold}${C.accent}${Branding.AI_PROMPT} >${C.reset} `);
18
+ const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
19
+ this.spinner = { frame: 0, timer: null };
20
+ 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
+ this.curLine = '';
23
+ this._printPrefix();
24
+ },
25
+
26
+ _printPrefix() {
27
+ process.stdout.write(`${C.accent}┃ ${C.reset}`);
12
28
  },
13
29
 
14
30
  printAIChunk(text) {
31
+ this.curLine = (this.curLine || '') + text;
15
32
  process.stdout.write(text);
33
+ this._maybeWrap();
34
+ },
35
+
36
+ _maybeWrap() {
37
+ if (this.curLine && this.curLine.length > 100) {
38
+ const words = this.curLine.split(' ');
39
+ if (words.length > 1) {
40
+ const lastWord = words[words.length - 1];
41
+ const rest = this.curLine.slice(0, this.curLine.length - lastWord.length).trimEnd();
42
+ process.stdout.write(`\n${C.accent}┃ ${C.reset}`);
43
+ this.curLine = lastWord;
44
+ }
45
+ }
16
46
  },
17
47
 
18
48
  printAIEnd() {
19
- console.log();
49
+ process.stdout.write('\n');
50
+ console.log(`${C.accent}┗${C.reset}${this.divider('─', 44)}`);
20
51
  console.log();
21
52
  },
22
53
 
23
54
  printError(msg) {
24
- console.error(`${C.error}Error: ${msg}${C.reset}`);
55
+ console.log(`${C.error} ${msg}${C.reset}`);
25
56
  },
26
57
 
27
58
  printInfo(msg) {
@@ -29,11 +60,23 @@ export const Terminal = {
29
60
  },
30
61
 
31
62
  printSuccess(msg) {
32
- console.log(`${C.success}${msg}${C.reset}`);
63
+ console.log(`${C.success}${msg}${C.reset}`);
33
64
  },
34
65
 
35
66
  printWarning(msg) {
36
- console.log(`${C.warning}${msg}${C.reset}`);
67
+ console.log(`${C.warning}${msg}${C.reset}`);
68
+ },
69
+
70
+ printSearchResults(results) {
71
+ console.log(`${C.warning}┌── Web Search Results ──${C.reset}`);
72
+ results.forEach((r, i) => {
73
+ console.log(
74
+ ` ${C.warning}${i + 1}.${C.reset} ${C.bold}${r.title}${C.reset}`
75
+ );
76
+ console.log(` ${C.dim}${r.url}${C.reset}`);
77
+ if (r.snippet) console.log(` ${r.snippet.slice(0, 120)}`);
78
+ });
79
+ console.log(`${C.warning}└──${C.reset}`);
37
80
  },
38
81
 
39
82
  clear() {
@@ -44,6 +87,6 @@ export const Terminal = {
44
87
  return text
45
88
  .replace(/\*\*(.*?)\*\*/g, `${C.bold}$1${C.reset}`)
46
89
  .replace(/`([^`]+)`/g, `${C.accent}\`$1\`${C.reset}`)
47
- .replace(/```(\w*)\n([\s\S]*?)```/g, `${C.dim}┌─$1─┐${C.reset}\n$2${C.dim}└─────┘${C.reset}`);
90
+ .replace(/```(\w*)\n([\s\S]*?)```/g, `${C.dim}┌─${C.reset}\n$2${C.dim}└─────${C.reset}`);
48
91
  },
49
92
  };
@@ -0,0 +1,30 @@
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
+ export function detectIntent(text) {
15
+ const lower = text.toLowerCase().trim();
16
+ if (lower.startsWith('/')) return { type: 'command' };
17
+
18
+ const isQuestion = /\?$/.test(lower);
19
+ const firstWord = lower.split(/\s+/)[0] || '';
20
+
21
+ let needsWeb = false;
22
+ if (isQuestion && QUESTION_PREFIXES.includes(firstWord)) {
23
+ needsWeb = true;
24
+ }
25
+ if (KNOWLEDGE_PREFIXES.some((p) => lower.startsWith(p + ' ') || lower === p)) {
26
+ needsWeb = true;
27
+ }
28
+
29
+ return { type: needsWeb ? 'knowledge' : 'chat' };
30
+ }
@@ -0,0 +1,93 @@
1
+ const DDG_SEARCH = 'https://html.duckduckgo.com/html/';
2
+ const WIKI_SEARCH = 'https://en.wikipedia.org/w/api.php';
3
+ const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36';
4
+
5
+ export class WebSearch {
6
+ static async search(query, maxResults = 5) {
7
+ let results = await this._searchDuckDuckGo(query, maxResults);
8
+ if (results.length === 0) {
9
+ results = await this._searchWikipedia(query, maxResults);
10
+ }
11
+ return results;
12
+ }
13
+
14
+ static async _searchDuckDuckGo(query, maxResults) {
15
+ const url = `${DDG_SEARCH}?q=${encodeURIComponent(query)}`;
16
+ try {
17
+ const resp = await fetch(url, {
18
+ headers: { 'User-Agent': UA },
19
+ signal: AbortSignal.timeout(10000),
20
+ });
21
+ if (!resp.ok) return [];
22
+ const html = await resp.text();
23
+ return this._parseDuckDuckGo(html, maxResults);
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ static async _searchWikipedia(query, maxResults) {
30
+ const params = new URLSearchParams({
31
+ action: 'query',
32
+ list: 'search',
33
+ srsearch: query,
34
+ srlimit: String(maxResults),
35
+ format: 'json',
36
+ utf8: '1',
37
+ });
38
+ const url = `${WIKI_SEARCH}?${params.toString()}`;
39
+ try {
40
+ const resp = await fetch(url, {
41
+ headers: { 'User-Agent': UA },
42
+ signal: AbortSignal.timeout(10000),
43
+ });
44
+ if (!resp.ok) return [];
45
+ const data = await resp.json();
46
+ const search = data?.query?.search || [];
47
+ return search.map((r) => ({
48
+ title: r.title,
49
+ url: `https://en.wikipedia.org/wiki/${encodeURIComponent(r.title.replace(/ /g, '_'))}`,
50
+ snippet: this._stripTags(r.snippet || ''),
51
+ source: 'wikipedia',
52
+ }));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ static _parseDuckDuckGo(html, maxResults) {
59
+ const results = [];
60
+ const resultRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>[\s\S]*?<a[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/gi;
61
+ let match;
62
+ let count = 0;
63
+ while ((match = resultRegex.exec(html)) !== null && count < maxResults) {
64
+ let url = match[1];
65
+ const snippet = this._stripTags(match[3] || '');
66
+ const title = this._stripTags(match[2] || '');
67
+ url = this._decodeDdgUrl(url);
68
+ if (url && !url.includes('duckduckgo.com/y.js')) {
69
+ results.push({ title, url, snippet, source: 'duckduckgo' });
70
+ count++;
71
+ }
72
+ }
73
+ return results;
74
+ }
75
+
76
+ static _decodeDdgUrl(url) {
77
+ const match = url.match(/uddg=([^&]+)/);
78
+ return match ? decodeURIComponent(match[1]) : url;
79
+ }
80
+
81
+ static _stripTags(text) {
82
+ const map = {
83
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
84
+ '&#039;': "'", '&#39;': "'", '&apos;': "'", '&nbsp;': ' ',
85
+ '&#x27;': "'", '&hellip;': '...',
86
+ };
87
+ return String(text)
88
+ .replace(/<[^>]*>/g, '')
89
+ .replace(/&[a-zA-Z0-9#]+;/g, (m) => map[m] ?? '')
90
+ .replace(/\s{2,}/g, ' ')
91
+ .trim();
92
+ }
93
+ }