open-jarvis-cli 0.0.0-stage → 1.1.1

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/bin/jarvis.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+ import * as cfgMod from '../src/config.js';
6
+ import * as memory from '../src/memory.js';
7
+ import { startRepl, ask } from '../src/repl.js';
8
+ import { stats, trySkill } from '../src/skills.js';
9
+ import { runSetup, addKey, changeModel, needsSetup, commandsHelp } from '../src/setup.js';
10
+ import { byId } from '../src/providers.js';
11
+ import { green, dim, red, cyan } from '../src/ui.js';
12
+
13
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'));
14
+ const [, , cmd, ...rest] = process.argv;
15
+
16
+ const USAGE = `
17
+ ${green('JARVIS PRO')} v${pkg.version} — AI assistant for your terminal
18
+ ${commandsHelp()}
19
+ ${cyan('More')}
20
+ jarvis ask "your question" one-shot answer
21
+ jarvis stats CPU / RAM / OS
22
+ jarvis memory list remembered facts
23
+ jarvis config get KEY | unset KEY | path
24
+
25
+ ${cyan('Providers')}
26
+ ChatGPT · Gemini · OpenRouter · Claude · Grok · Perplexity · Groq · DeepSeek · Mistral · Custom · Ollama
27
+ `;
28
+
29
+ function handleConfig(args) {
30
+ const [action, ...tail] = args;
31
+ if (action === 'set') {
32
+ let key, value;
33
+ const joined = tail.join(' ');
34
+ if (joined.includes('=')) { const i = joined.indexOf('='); key = joined.slice(0, i).trim(); value = joined.slice(i + 1).trim(); }
35
+ else { key = tail[0]; value = tail.slice(1).join(' '); }
36
+ if (!key || !value) return console.log(red('Usage: jarvis config set KEY=value'));
37
+ cfgMod.setValue(key, value);
38
+ console.log(green('✔ Saved ') + `${key.toUpperCase()} = ${cfgMod.mask(key.toUpperCase(), value)}`);
39
+ } else if (action === 'get') {
40
+ const k = (tail[0] || '').toUpperCase();
41
+ console.log(cfgMod.mask(k, cfgMod.load()[k]));
42
+ } else if (action === 'unset') {
43
+ cfgMod.unsetValue(tail[0] || ''); console.log(green('✔ Removed'));
44
+ } else if (action === 'list') {
45
+ const c = cfgMod.load();
46
+ for (const k of cfgMod.KEYS) console.log(`${k.padEnd(12)} ${cfgMod.mask(k, c[k])}`);
47
+ const saved = Object.entries(cfgMod.raw().profiles || {}).filter(([, v]) => v.key).map(([id]) => byId(id)?.name || id);
48
+ console.log(`${'SAVED KEYS'.padEnd(12)} ${saved.join(', ') || '(none)'}`);
49
+ } else if (action === 'path') {
50
+ console.log(cfgMod.FILE);
51
+ } else console.log(USAGE);
52
+ }
53
+
54
+ try {
55
+ if (!cmd) await startRepl();
56
+ else if (cmd === 'config') handleConfig(rest);
57
+ else if (cmd === 'setup' || cmd === 'switch') await runSetup();
58
+ else if (cmd === 'key') await addKey();
59
+ else if (cmd === 'model') await changeModel();
60
+ else if (cmd === 'ask') {
61
+ const q = rest.join(' ').trim();
62
+ if (!q) console.log(red('Usage: jarvis ask "question"'));
63
+ else {
64
+ let cfg = cfgMod.load();
65
+ const local = trySkill(q, { title: cfg.TITLE, notify: console.log });
66
+ if (local !== null) console.log(await local);
67
+ else {
68
+ if (process.stdin.isTTY && needsSetup(cfg)) {
69
+ if (!(await runSetup())) process.exit(0);
70
+ cfg = cfgMod.load();
71
+ }
72
+ await ask({ cfg }, [], q);
73
+ process.exit(0);
74
+ }
75
+ }
76
+ }
77
+ else if (cmd === 'stats') console.log(await stats());
78
+ else if (cmd === 'memory') {
79
+ const l = memory.all();
80
+ console.log(l.length ? l.map((x, i) => `${i + 1}. ${x.text}`).join('\n') : 'Memory is empty.');
81
+ }
82
+ else if (cmd === '-v' || cmd === '--version') console.log(pkg.version);
83
+ else console.log(USAGE);
84
+ } catch (e) {
85
+ console.error(red('✖ ' + e.message));
86
+ process.exit(1);
87
+ }
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "open-jarvis-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.1",
4
+ "description": "JARVIS-style AI assistant for your terminal. BYOK, streaming, free-tier friendly.",
5
+ "type": "module",
6
+ "bin": {
7
+ "jarvis": "bin/jarvis.js",
8
+ "openjarvispro": "bin/jarvis.js"
9
+ },
10
+ "files": ["bin", "src", "README.md"],
11
+ "engines": { "node": ">=18" },
12
+ "author": "Rongon Kairy",
13
+ "repository": { "type": "git", "url": "git+https://github.com/RongonKairy/openjarvispro.git" },
14
+ "homepage": "https://github.com/RongonKairy",
15
+ "keywords": ["jarvis", "ai", "cli", "assistant", "terminal"],
16
+ "license": "MIT"
17
+ }
package/src/config.js ADDED
@@ -0,0 +1,89 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { byId } from './providers.js';
5
+
6
+ export const DIR = path.join(os.homedir(), '.open-jarvis');
7
+ export const FILE = path.join(DIR, 'config.json');
8
+
9
+ export const DEFAULTS = {
10
+ AI_API_KEY: '',
11
+ AI_PROVIDER: 'auto',
12
+ AI_MODEL: '',
13
+ TITLE: 'sir',
14
+ VOICE: 'off',
15
+ OLLAMA_URL: 'http://localhost:11434',
16
+ CUSTOM_URL: '',
17
+ };
18
+ export const KEYS = Object.keys(DEFAULTS);
19
+
20
+ export function raw() {
21
+ try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return {}; }
22
+ }
23
+ function write(obj) {
24
+ fs.mkdirSync(DIR, { recursive: true });
25
+ fs.writeFileSync(FILE, JSON.stringify(obj, null, 2), { mode: 0o600 });
26
+ }
27
+ export function update(fn) { const o = raw(); fn(o); write(o); }
28
+
29
+ /** Effective settings: active provider's saved key/model are resolved into AI_API_KEY / AI_MODEL. */
30
+ export function load() {
31
+ const s = raw();
32
+ const cfg = { ...DEFAULTS };
33
+ for (const k of KEYS) if (s[k] !== undefined) cfg[k] = s[k];
34
+ const prov = cfg.AI_PROVIDER;
35
+ const prof = (s.profiles && s.profiles[prov]) || {};
36
+ cfg.AI_API_KEY = process.env.AI_API_KEY || prof.key || (prov === 'auto' ? s.AI_API_KEY : '') || '';
37
+ cfg.AI_MODEL = prof.model || s.AI_MODEL || '';
38
+ cfg.CUSTOM_URL = prof.url || s.CUSTOM_URL || '';
39
+ return cfg;
40
+ }
41
+
42
+ /** Save a provider profile and make it the active one. */
43
+ export function saveProfile(id, { key, model, url } = {}) {
44
+ update((o) => {
45
+ o.profiles = o.profiles || {};
46
+ const cur = o.profiles[id] || {};
47
+ const next = { ...cur };
48
+ if (key !== undefined) next.key = key;
49
+ if (model !== undefined) next.model = model;
50
+ if (url !== undefined) next.url = url;
51
+ o.profiles[id] = next;
52
+ o.AI_PROVIDER = id;
53
+ delete o.AI_MODEL;
54
+ });
55
+ }
56
+
57
+ export function setValue(key, value) {
58
+ key = key.toUpperCase();
59
+ if (!KEYS.includes(key)) throw new Error(`Unknown key "${key}". Valid keys: ${KEYS.join(', ')}`);
60
+ if (key === 'AI_PROVIDER' && value !== 'auto' && !byId(value)) throw new Error(`Unknown provider "${value}".`);
61
+ update((o) => {
62
+ const p = o.AI_PROVIDER;
63
+ const explicit = p && p !== 'auto';
64
+ if (key === 'AI_API_KEY' || key === 'AI_MODEL') {
65
+ if (explicit) {
66
+ o.profiles = o.profiles || {};
67
+ o.profiles[p] = { ...o.profiles[p], [key === 'AI_API_KEY' ? 'key' : 'model']: value };
68
+ } else o[key] = value;
69
+ } else if (key === 'AI_PROVIDER') {
70
+ o.AI_PROVIDER = value;
71
+ delete o.AI_MODEL;
72
+ } else o[key] = value;
73
+ });
74
+ }
75
+
76
+ export function unsetValue(key) {
77
+ key = key.toUpperCase();
78
+ update((o) => {
79
+ const p = o.AI_PROVIDER;
80
+ if (p && p !== 'auto' && o.profiles?.[p] && (key === 'AI_API_KEY' || key === 'AI_MODEL')) {
81
+ delete o.profiles[p][key === 'AI_API_KEY' ? 'key' : 'model'];
82
+ } else delete o[key];
83
+ });
84
+ }
85
+
86
+ export function mask(key, value) {
87
+ if (key !== 'AI_API_KEY' || !value) return value || '(not set)';
88
+ return value.length <= 8 ? '****' : value.slice(0, 4) + '…' + value.slice(-4);
89
+ }
package/src/llm.js ADDED
@@ -0,0 +1,178 @@
1
+ import { byId } from './providers.js';
2
+
3
+ export class ApiError extends Error {
4
+ constructor(msg, { status, kind } = {}) { super(msg); this.status = status; this.kind = kind; }
5
+ }
6
+
7
+ export function detectProvider(cfg) {
8
+ if (cfg.AI_PROVIDER && cfg.AI_PROVIDER !== 'auto') return cfg.AI_PROVIDER;
9
+ const k = cfg.AI_API_KEY || '';
10
+ if (k.startsWith('sk-ant-')) return 'anthropic';
11
+ if (k.startsWith('sk-or-')) return 'openrouter';
12
+ if (k.startsWith('AIza')) return 'gemini';
13
+ if (k.startsWith('xai-')) return 'xai';
14
+ if (k.startsWith('pplx-')) return 'perplexity';
15
+ if (k.startsWith('gsk_')) return 'groq';
16
+ if (k.startsWith('sk-')) return 'openai';
17
+ return k ? 'openrouter' : 'ollama';
18
+ }
19
+
20
+ export function modelFor(cfg, id) {
21
+ return cfg.AI_MODEL || byId(id)?.models?.[0] || '(set a model)';
22
+ }
23
+
24
+ function classify(status, text) {
25
+ if (/credit balance|insufficient|quota|billing|exhausted|exceeded|out of credits|payment/i.test(text) || status === 429 || status === 402) return 'limit';
26
+ if ([400, 404, 422].includes(status) && /model/i.test(text) && /valid|found|exist|unknown|invalid|support|decommission/i.test(text)) return 'model';
27
+ if (status === 401 || status === 403) return 'auth';
28
+ return 'other';
29
+ }
30
+
31
+ async function* sseData(res) {
32
+ const dec = new TextDecoder();
33
+ let buf = '';
34
+ for await (const chunk of res.body) {
35
+ buf += dec.decode(chunk, { stream: true });
36
+ let i;
37
+ while ((i = buf.indexOf('\n')) >= 0) {
38
+ const line = buf.slice(0, i).trim();
39
+ buf = buf.slice(i + 1);
40
+ if (line.startsWith('data:')) {
41
+ const d = line.slice(5).trim();
42
+ if (d && d !== '[DONE]') yield d;
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ async function* ndjson(res) {
49
+ const dec = new TextDecoder();
50
+ let buf = '';
51
+ for await (const chunk of res.body) {
52
+ buf += dec.decode(chunk, { stream: true });
53
+ let i;
54
+ while ((i = buf.indexOf('\n')) >= 0) {
55
+ const line = buf.slice(0, i).trim();
56
+ buf = buf.slice(i + 1);
57
+ if (line) yield line;
58
+ }
59
+ }
60
+ }
61
+
62
+ async function post(url, headers, body, signal) {
63
+ let res;
64
+ try {
65
+ res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), signal });
66
+ } catch (e) {
67
+ throw new ApiError(`fetch failed: ${e.cause?.code || e.message}`, { kind: 'network' });
68
+ }
69
+ if (!res.ok) {
70
+ const txt = (await res.text().catch(() => '')).slice(0, 300);
71
+ throw new ApiError(`${res.status} ${res.statusText} ${txt}`.trim(), { status: res.status, kind: classify(res.status, txt) });
72
+ }
73
+ return res;
74
+ }
75
+
76
+ async function run(id, cfg, system, history, onToken, signal) {
77
+ const p = byId(id);
78
+ if (!p) throw new ApiError(`Unknown provider "${id}". Run: jarvis setup`, { kind: 'other' });
79
+ const model = modelFor(cfg, id);
80
+ const key = cfg.AI_API_KEY;
81
+ let out = '';
82
+ const emit = (t) => { if (t) { out += t; onToken(t); } };
83
+
84
+ if (p.type === 'openai') {
85
+ let url = p.url;
86
+ if (p.custom) {
87
+ if (!cfg.CUSTOM_URL) throw new ApiError('No custom URL set. Run: jarvis setup', { kind: 'other' });
88
+ url = cfg.CUSTOM_URL.replace(/\/+$/, '') + '/chat/completions';
89
+ }
90
+ const res = await post(url, { authorization: `Bearer ${key}` },
91
+ { model, stream: true, messages: [{ role: 'system', content: system }, ...history] }, signal);
92
+ for await (const d of sseData(res)) emit(JSON.parse(d).choices?.[0]?.delta?.content);
93
+ } else if (p.type === 'anthropic') {
94
+ const res = await post('https://api.anthropic.com/v1/messages',
95
+ { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
96
+ { model, max_tokens: 1024, stream: true, system, messages: history }, signal);
97
+ for await (const d of sseData(res)) {
98
+ const j = JSON.parse(d);
99
+ if (j.type === 'content_block_delta') emit(j.delta?.text);
100
+ }
101
+ } else if (p.type === 'gemini') {
102
+ const res = await post(
103
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`,
104
+ { 'x-goog-api-key': key },
105
+ {
106
+ systemInstruction: { parts: [{ text: system }] },
107
+ contents: history.map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] })),
108
+ }, signal);
109
+ for await (const d of sseData(res)) emit(JSON.parse(d).candidates?.[0]?.content?.parts?.[0]?.text);
110
+ } else if (p.type === 'ollama') {
111
+ const res = await post(`${cfg.OLLAMA_URL.replace(/\/$/, '')}/api/chat`, {},
112
+ { model, stream: true, messages: [{ role: 'system', content: system }, ...history] }, signal);
113
+ for await (const l of ndjson(res)) emit(JSON.parse(l).message?.content);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Streams a reply. On network/other errors it falls back to local Ollama; limit/auth errors are surfaced. */
119
+ export async function chat(cfg, system, history, onToken, signal) {
120
+ const id = detectProvider(cfg);
121
+ const p = byId(id);
122
+ if (p && !p.nokey && !cfg.AI_API_KEY) {
123
+ throw new ApiError('No API key set. Run: jarvis setup', { kind: 'auth' });
124
+ }
125
+ try {
126
+ return await run(id, cfg, system, history, onToken, signal);
127
+ } catch (err) {
128
+ if (signal?.aborted || id === 'ollama' || err.kind === 'limit' || err.kind === 'auth' || err.kind === 'model') throw err;
129
+ try {
130
+ return await run('ollama', { ...cfg, AI_MODEL: '' }, system, history, onToken, signal);
131
+ } catch {
132
+ throw err;
133
+ }
134
+ }
135
+ }
136
+
137
+ /** Ask the provider which models exist right now. Returns an array of ids, or null on any failure. */
138
+ export async function listModels(cfg, id) {
139
+ const p = byId(id);
140
+ if (!p) return null;
141
+ const key = cfg.AI_API_KEY || '';
142
+ const ctl = new AbortController();
143
+ const timer = setTimeout(() => ctl.abort(), 6000);
144
+ try {
145
+ let url;
146
+ let headers = {};
147
+ if (p.type === 'openai') {
148
+ const chatUrl = p.custom ? (cfg.CUSTOM_URL || '').replace(/\/+$/, '') + '/chat/completions' : p.url;
149
+ url = chatUrl.replace(/\/chat\/completions$/, '/models');
150
+ headers = { authorization: `Bearer ${key}` };
151
+ } else if (p.type === 'anthropic') {
152
+ url = 'https://api.anthropic.com/v1/models?limit=100';
153
+ headers = { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
154
+ } else if (p.type === 'gemini') {
155
+ url = 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=200';
156
+ headers = { 'x-goog-api-key': key };
157
+ } else if (p.type === 'ollama') {
158
+ url = `${(cfg.OLLAMA_URL || 'http://localhost:11434').replace(/\/$/, '')}/api/tags`;
159
+ }
160
+ const res = await fetch(url, { headers, signal: ctl.signal });
161
+ if (!res.ok) return null;
162
+ const j = await res.json();
163
+ let ids;
164
+ if (p.type === 'gemini') {
165
+ ids = (j.models || []).filter((m) => (m.supportedGenerationMethods || []).includes('generateContent')).map((m) => m.name.replace(/^models\//, ''));
166
+ } else if (p.type === 'ollama') ids = (j.models || []).map((m) => m.name);
167
+ else ids = (j.data || []).map((m) => m.id);
168
+ ids = [...new Set(ids)].filter(Boolean);
169
+ if (id === 'openai') ids = ids.filter((m) => /^(gpt|o\d|chatgpt)/.test(m) && !/audio|realtime|transcribe|tts|image|embedding|moderation|search/.test(m));
170
+ if (id === 'openrouter') ids.sort((a, b) => (Number(b.endsWith(':free')) - Number(a.endsWith(':free'))) || a.localeCompare(b));
171
+ else ids.sort();
172
+ return ids.length ? ids : null;
173
+ } catch {
174
+ return null;
175
+ } finally {
176
+ clearTimeout(timer);
177
+ }
178
+ }
package/src/memory.js ADDED
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DIR } from './config.js';
4
+
5
+ const FILE = path.join(DIR, 'memory.json');
6
+
7
+ function read() {
8
+ try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return []; }
9
+ }
10
+ function write(list) {
11
+ fs.mkdirSync(DIR, { recursive: true });
12
+ fs.writeFileSync(FILE, JSON.stringify(list, null, 2));
13
+ }
14
+
15
+ export const all = read;
16
+ export function add(text) {
17
+ const list = read();
18
+ list.push({ text, at: new Date().toISOString() });
19
+ write(list.slice(-200));
20
+ }
21
+ export function remove(n) {
22
+ const list = read();
23
+ if (n < 1 || n > list.length) return false;
24
+ list.splice(n - 1, 1);
25
+ write(list);
26
+ return true;
27
+ }
28
+ export function clear() { write([]); }
package/src/menu.js ADDED
@@ -0,0 +1,86 @@
1
+ import readline from 'node:readline';
2
+ import { green, dim, cyan } from './ui.js';
3
+
4
+ /** Temporarily take over keyboard input (works even while a readline prompt exists). */
5
+ function takeover(handler) {
6
+ const stdin = process.stdin;
7
+ readline.emitKeypressEvents(stdin);
8
+ const saved = stdin.listeners('keypress');
9
+ stdin.removeAllListeners('keypress');
10
+ const wasRaw = stdin.isRaw;
11
+ const wasPaused = stdin.isPaused();
12
+ stdin.setRawMode(true);
13
+ stdin.resume();
14
+ stdin.on('keypress', handler);
15
+ return () => {
16
+ stdin.removeListener('keypress', handler);
17
+ for (const l of saved) stdin.on('keypress', l);
18
+ if (!wasRaw) stdin.setRawMode(false);
19
+ if (wasPaused) stdin.pause();
20
+ };
21
+ }
22
+
23
+ /** Arrow-key menu. Returns the chosen item, or null if cancelled. */
24
+ export function select(title, items, { initial = 0 } = {}) {
25
+ return new Promise((resolve) => {
26
+ const out = process.stdout;
27
+ if (!process.stdin.isTTY || !items.length) return resolve(null);
28
+ const n = items.length;
29
+ let idx = Math.min(Math.max(initial, 0), n - 1);
30
+ const page = Math.max(4, Math.min(n, (out.rows || 24) - 5));
31
+ let drawn = 0;
32
+ let restore = () => {};
33
+
34
+ const render = () => {
35
+ if (drawn) out.write(`\x1b[${drawn}A`);
36
+ const start = Math.max(0, Math.min(idx - Math.floor(page / 2), n - page));
37
+ const lines = [cyan(title), dim(' ↑/↓ move · Enter select · Esc cancel')];
38
+ for (let i = start; i < start + page && i < n; i++) {
39
+ const it = items[i];
40
+ const hint = it.hint ? dim(' ' + it.hint) : '';
41
+ lines.push(i === idx ? green(` ❯ ${it.label}`) + hint : ` ${it.label}` + hint);
42
+ }
43
+ while (lines.length < 2 + page) lines.push('');
44
+ for (const l of lines) out.write('\x1b[2K' + l + '\n');
45
+ drawn = lines.length;
46
+ };
47
+
48
+ const finish = (val) => {
49
+ restore();
50
+ out.write(`\x1b[${drawn}A\x1b[0J\x1b[?25h`);
51
+ if (val) out.write(green('✔ ') + dim(title + ' ') + val.label + '\n');
52
+ resolve(val);
53
+ };
54
+
55
+ out.write('\x1b[?25l');
56
+ restore = takeover((str, key = {}) => {
57
+ if (key.ctrl && key.name === 'c') { restore(); out.write('\x1b[?25h\n'); process.exit(130); }
58
+ if (key.name === 'up' || key.name === 'k') { idx = (idx - 1 + n) % n; render(); }
59
+ else if (key.name === 'down' || key.name === 'j') { idx = (idx + 1) % n; render(); }
60
+ else if (key.name === 'return' || key.name === 'enter') finish(items[idx]);
61
+ else if (key.name === 'escape') finish(null);
62
+ else if (/^[1-9]$/.test(str || '') && Number(str) <= n) { idx = Number(str) - 1; render(); }
63
+ });
64
+ render();
65
+ });
66
+ }
67
+
68
+ /** Single-line input. secret=true masks what you type. Returns string or null if cancelled. */
69
+ export function lineInput(prompt, { secret = false, def = '' } = {}) {
70
+ return new Promise((resolve) => {
71
+ const out = process.stdout;
72
+ if (!process.stdin.isTTY) return resolve(null);
73
+ let buf = '';
74
+ out.write(prompt);
75
+ const restore = takeover((str, key = {}) => {
76
+ if (key.ctrl && key.name === 'c') { restore(); out.write('\n'); process.exit(130); }
77
+ if (key.name === 'escape') { restore(); out.write('\n'); return resolve(null); }
78
+ if (key.name === 'return' || key.name === 'enter') { restore(); out.write('\n'); return resolve((buf || def).trim()); }
79
+ if (key.name === 'backspace') { if (buf.length) { buf = buf.slice(0, -1); out.write('\b \b'); } return; }
80
+ if (str && !key.ctrl && !key.meta && str >= ' ') {
81
+ buf += str;
82
+ out.write(secret ? '•'.repeat(str.length) : str);
83
+ }
84
+ });
85
+ });
86
+ }
@@ -0,0 +1,35 @@
1
+ // Every provider the setup wizard can offer. "openai" type = OpenAI-compatible chat API.
2
+ export const PROVIDERS = [
3
+ { id: 'openai', name: 'ChatGPT (OpenAI)', type: 'openai', hint: 'paid',
4
+ url: 'https://api.openai.com/v1/chat/completions', keyUrl: 'https://platform.openai.com/api-keys',
5
+ models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4.1'] },
6
+ { id: 'gemini', name: 'Gemini (Google)', type: 'gemini', hint: 'free tier available',
7
+ keyUrl: 'https://aistudio.google.com/apikey',
8
+ models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'] },
9
+ { id: 'openrouter', name: 'OpenRouter (hundreds of models)', type: 'openai', hint: 'has free models',
10
+ url: 'https://openrouter.ai/api/v1/chat/completions', keyUrl: 'https://openrouter.ai/keys',
11
+ models: ['meta-llama/llama-3.3-70b-instruct:free', 'openai/gpt-4o-mini', 'google/gemini-2.5-flash'] },
12
+ { id: 'anthropic', name: 'Claude (Anthropic)', type: 'anthropic', hint: 'paid',
13
+ keyUrl: 'https://console.anthropic.com/settings/keys',
14
+ models: ['claude-haiku-5-5', 'claude-sonnet-5-5', 'claude-opus-5-5'] },
15
+ { id: 'xai', name: 'Grok (xAI)', type: 'openai', hint: 'paid',
16
+ url: 'https://api.x.ai/v1/chat/completions', keyUrl: 'https://console.x.ai',
17
+ models: ['grok-3-mini', 'grok-3', 'grok-4'] },
18
+ { id: 'perplexity', name: 'Perplexity', type: 'openai', hint: 'web-search answers',
19
+ url: 'https://api.perplexity.ai/chat/completions', keyUrl: 'https://www.perplexity.ai/settings/api',
20
+ models: ['sonar', 'sonar-pro', 'sonar-reasoning-pro'] },
21
+ { id: 'groq', name: 'Groq (very fast)', type: 'openai', hint: 'free tier available',
22
+ url: 'https://api.groq.com/openai/v1/chat/completions', keyUrl: 'https://console.groq.com/keys',
23
+ models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'] },
24
+ { id: 'deepseek', name: 'DeepSeek', type: 'openai', hint: 'cheap',
25
+ url: 'https://api.deepseek.com/chat/completions', keyUrl: 'https://platform.deepseek.com/api_keys',
26
+ models: ['deepseek-chat', 'deepseek-reasoner'] },
27
+ { id: 'mistral', name: 'Mistral', type: 'openai', hint: 'free tier available',
28
+ url: 'https://api.mistral.ai/v1/chat/completions', keyUrl: 'https://console.mistral.ai/api-keys',
29
+ models: ['mistral-small-latest', 'mistral-large-latest'] },
30
+ { id: 'custom', name: 'Custom (any OpenAI-compatible API)', type: 'openai', custom: true, hint: 'you give the URL', models: [] },
31
+ { id: 'ollama', name: 'Ollama (local, free, no key)', type: 'ollama', nokey: true, hint: 'runs on your PC',
32
+ models: ['llama3.2', 'qwen2.5', 'mistral'] },
33
+ ];
34
+
35
+ export const byId = (id) => PROVIDERS.find((p) => p.id === id);
package/src/repl.js ADDED
@@ -0,0 +1,185 @@
1
+ import readline from 'node:readline';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import * as cfgMod from './config.js';
7
+ import * as memory from './memory.js';
8
+ import { chat, detectProvider, modelFor } from './llm.js';
9
+ import { byId } from './providers.js';
10
+ import { trySkill, HELP } from './skills.js';
11
+ import { speak, stopSpeaking } from './voice.js';
12
+ import { runSetup, addKey, needsSetup, changeModel } from './setup.js';
13
+ import { select } from './menu.js';
14
+ import { banner, spinner, green, cyan, dim, red, amber } from './ui.js';
15
+
16
+ const VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
17
+
18
+ export function buildSystem(cfg) {
19
+ const facts = memory.all().map((m) => `- ${m.text}`).join('\n') || '(none)';
20
+ return `You are JARVIS, a personal AI assistant living in the user's terminal, inspired by Iron Man's J.A.R.V.I.S.
21
+ Personality: calm, witty, polite, slightly sarcastic British butler. Address the user as "${cfg.TITLE}".
22
+ Keep answers concise and terminal-friendly (short paragraphs, plain text, minimal markdown). Reply in the language the user writes in.
23
+ Current time: ${new Date().toString()}. OS: ${os.type()} ${os.release()}.
24
+ Things the user asked you to remember:
25
+ ${facts}`;
26
+ }
27
+
28
+ function friendly(e) {
29
+ const m = String(e.message || e);
30
+ if (e.kind === 'auth') return 'The API key was rejected or missing.';
31
+ if (e.kind === 'limit') return 'Limit reached (credits/quota or rate limit).';
32
+ if (e.kind === 'model') return 'This model name is not valid for the selected provider.';
33
+ if (/fetch failed|ENOTFOUND|ECONN/i.test(m)) return 'Cannot reach the network (or Ollama is not running).';
34
+ return m;
35
+ }
36
+
37
+ async function tryOnce(cfg, history, text) {
38
+ history.push({ role: 'user', content: text });
39
+ const stop = spinner();
40
+ let first = true;
41
+ try {
42
+ const reply = await chat(cfg, buildSystem(cfg), history.slice(-20), (tok) => {
43
+ if (first) { stop(); process.stdout.write(green('JARVIS ❯ ')); first = false; }
44
+ process.stdout.write(tok);
45
+ });
46
+ if (first) stop();
47
+ process.stdout.write('\n');
48
+ history.push({ role: 'assistant', content: reply });
49
+ if (cfg.VOICE === 'on') speak(reply);
50
+ return { ok: true, reply };
51
+ } catch (err) {
52
+ if (first) stop(); else process.stdout.write('\n');
53
+ history.pop();
54
+ return { ok: false, err };
55
+ }
56
+ }
57
+
58
+ /** When the API limit is over (or the key is bad): offer a new key / another provider. */
59
+ async function recover(err, S) {
60
+ const id = detectProvider(S.cfg);
61
+ const name = byId(id)?.name || id;
62
+ const model = err.kind === 'model';
63
+ console.log(amber(model ? `⚠ ${name}: model "${modelFor(S.cfg, id)}" is not valid.`
64
+ : err.kind === 'limit' ? `⚠ ${name}: limit reached.` : `⚠ ${name}: API key problem.`));
65
+ const options = [];
66
+ if (model) options.push({ label: 'Pick a different model', value: 'model', hint: 'command: jarvis model' });
67
+ else options.push({ label: `Add a NEW API key for ${name}`, value: 'key', hint: 'command: jarvis key' });
68
+ options.push({ label: 'Switch to another provider', value: 'setup', hint: 'command: jarvis setup' });
69
+ if (!model) options.push({ label: 'Retry the same message', value: 'retry' });
70
+ options.push({ label: 'Cancel', value: 'cancel' });
71
+ const c = await select('What would you like to do?', options);
72
+ if (!c || c.value === 'cancel') return false;
73
+ if (c.value === 'key' && !(await addKey())) return false;
74
+ if (c.value === 'model' && !(await changeModel())) return false;
75
+ if (c.value === 'setup' && !(await runSetup())) return false;
76
+ S.cfg = cfgMod.load();
77
+ return true;
78
+ }
79
+
80
+ /** S = { cfg } so the config can be refreshed after the user adds a key. */
81
+ export async function ask(S, history, text) {
82
+ for (;;) {
83
+ const res = await tryOnce(S.cfg, history, text);
84
+ if (res.ok) return res.reply;
85
+ console.log(red(`✖ ${friendly(res.err)}`));
86
+ const k = res.err.kind;
87
+ if (k === 'limit' || k === 'auth' || k === 'model') {
88
+ console.log(dim(String(res.err.message).slice(0, 160)));
89
+ if (!process.stdin.isTTY) {
90
+ console.log(dim('Add a new key with: jarvis key (or) jarvis config set AI_API_KEY=your_key'));
91
+ return null;
92
+ }
93
+ if (await recover(res.err, S)) continue;
94
+ }
95
+ return null;
96
+ }
97
+ }
98
+
99
+ export async function startRepl() {
100
+ let cfg = cfgMod.load();
101
+
102
+ if (process.stdin.isTTY && needsSetup(cfg)) {
103
+ console.log('\n' + green(' JARVIS PRO') + dim(' · first-time setup'));
104
+ console.log(dim(' Pick the AI you want to use. Use ↑ ↓ and Enter.\n'));
105
+ if (!(await runSetup())) {
106
+ console.log(dim('\nSetup cancelled. Run jarvis setup whenever you are ready.'));
107
+ process.exit(0);
108
+ }
109
+ cfg = cfgMod.load();
110
+ }
111
+
112
+ const S = { cfg };
113
+ const showBanner = () => {
114
+ const id = detectProvider(S.cfg);
115
+ banner({
116
+ tool: `OpenJarvisPro v${VERSION}`,
117
+ agent: `${byId(id)?.name || id} · ${modelFor(S.cfg, id)}`,
118
+ key: S.cfg.AI_API_KEY ? cfgMod.mask('AI_API_KEY', S.cfg.AI_API_KEY) : (byId(id)?.nokey ? dim('not needed') : red('not set')),
119
+ });
120
+ };
121
+ showBanner();
122
+ if (!S.cfg.AI_API_KEY && !byId(detectProvider(S.cfg))?.nokey) {
123
+ console.log(amber('⚠ No API key yet. Run: jarvis setup') + '\n' + dim(' (Skills like open/weather/timer/stats work without a key.)\n'));
124
+ }
125
+
126
+ const history = [];
127
+ const hr = new Date().getHours();
128
+ const greet = hr < 12 ? 'Good morning' : hr < 18 ? 'Good afternoon' : 'Good evening';
129
+ console.log(green('JARVIS ❯ ') + `${greet}, ${S.cfg.TITLE}. At your service.\n`);
130
+
131
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: cyan('you ❯ ') });
132
+ const ctx = {
133
+ get title() { return S.cfg.TITLE; },
134
+ notify: (msg) => { process.stdout.write(`\n${msg}\x07\n`); rl.prompt(true); if (S.cfg.VOICE === 'on') speak(msg); },
135
+ };
136
+
137
+ rl.prompt();
138
+ rl.on('line', async (line) => {
139
+ const text = line.trim();
140
+ if (!text) return rl.prompt();
141
+ rl.pause();
142
+ try {
143
+ stopSpeaking();
144
+ if (text.startsWith('/')) {
145
+ const [cmd, arg] = text.slice(1).split(/\s+/);
146
+ if (cmd === 'exit' || cmd === 'quit') return rl.close();
147
+ else if (cmd === 'help') console.log(HELP);
148
+ else if (cmd === 'clear') { history.length = 0; console.log(dim('Conversation cleared.')); }
149
+ else if (cmd === 'voice') {
150
+ cfgMod.setValue('VOICE', arg === 'on' ? 'on' : 'off');
151
+ S.cfg = cfgMod.load();
152
+ console.log(dim(`Voice ${S.cfg.VOICE}.`));
153
+ } else if (cmd === 'model') {
154
+ const id = detectProvider(S.cfg);
155
+ console.log(`${byId(id)?.name || id} / ${modelFor(S.cfg, id)}`);
156
+ if (await changeModel()) S.cfg = cfgMod.load();
157
+ } else if (cmd === 'setup' || cmd === 'switch') {
158
+ if (await runSetup()) { S.cfg = cfgMod.load(); history.length = 0; }
159
+ } else if (cmd === 'key') {
160
+ if (await addKey()) S.cfg = cfgMod.load();
161
+ } else console.log(dim('Unknown command. Try /help'));
162
+ } else {
163
+ const local = trySkill(text, ctx);
164
+ if (local !== null) {
165
+ const out = await local;
166
+ console.log(green('JARVIS ❯ ') + out);
167
+ if (S.cfg.VOICE === 'on' && typeof out === 'string') speak(out);
168
+ } else {
169
+ await ask(S, history, text);
170
+ }
171
+ }
172
+ } catch (e) {
173
+ console.log(red(`✖ ${e.message}`));
174
+ }
175
+ console.log();
176
+ rl.resume();
177
+ rl.prompt();
178
+ });
179
+
180
+ rl.on('close', () => {
181
+ stopSpeaking();
182
+ console.log(`\n${green('JARVIS ❯ ')}Goodbye, ${S.cfg.TITLE}.`);
183
+ process.exit(0);
184
+ });
185
+ }
package/src/setup.js ADDED
@@ -0,0 +1,129 @@
1
+ import * as cfgMod from './config.js';
2
+ import { PROVIDERS, byId } from './providers.js';
3
+ import { detectProvider, listModels } from './llm.js';
4
+ import { select, lineInput } from './menu.js';
5
+ import { green, dim, cyan, red } from './ui.js';
6
+
7
+ export function commandsHelp() {
8
+ return `
9
+ ${cyan('Handy commands')}
10
+ ${green('jarvis')} start chatting
11
+ ${green('jarvis setup')} change provider / model ${dim('(↑ ↓ + Enter)')}
12
+ ${green('jarvis model')} pick a different model of the same provider
13
+ ${green('jarvis key')} add a NEW API key ${dim('(when your limit is over)')}
14
+ ${green('jarvis config set AI_API_KEY=')}${dim('your_key')} same thing, one line
15
+ ${green('jarvis config list')} show current settings
16
+ ${dim('inside chat:')} /setup /key /model /help /exit
17
+ `;
18
+ }
19
+
20
+ export function needsSetup(cfg) {
21
+ const p = cfgMod.raw().AI_PROVIDER;
22
+ return (!p || p === 'auto') && !cfg.AI_API_KEY;
23
+ }
24
+
25
+ const clean = (k) => (k || '').replace(/^["']|["']$/g, '').trim();
26
+
27
+ /** Full wizard: provider → key → model. Returns true when saved. */
28
+ export async function runSetup() {
29
+ if (!process.stdin.isTTY) {
30
+ console.log(red('Setup needs an interactive terminal. Run: jarvis setup'));
31
+ return false;
32
+ }
33
+ const s = cfgMod.raw();
34
+ const profiles = s.profiles || {};
35
+ const items = PROVIDERS.map((p) => ({
36
+ label: p.name, value: p,
37
+ hint: [profiles[p.id]?.key ? '✓ key saved' : '', p.hint || ''].filter(Boolean).join(' · '),
38
+ }));
39
+ const pick = await select('Choose your AI provider', items,
40
+ { initial: Math.max(0, PROVIDERS.findIndex((p) => p.id === s.AI_PROVIDER)) });
41
+ if (!pick) return false;
42
+ const p = pick.value;
43
+ const prof = { ...(profiles[p.id] || {}) };
44
+
45
+ if (p.custom) {
46
+ const url = await lineInput(`Base URL ${dim('(e.g. https://api.example.com/v1)')}: `, { def: prof.url || '' });
47
+ if (!url) return false;
48
+ prof.url = url.replace(/\/+$/, '');
49
+ }
50
+
51
+ if (!p.nokey) {
52
+ let key = prof.key;
53
+ if (key) {
54
+ const c = await select(`API key for ${p.name}`, [
55
+ { label: `Use saved key (${cfgMod.mask('AI_API_KEY', key)})`, value: 'use' },
56
+ { label: 'Enter a new API key', value: 'new' },
57
+ ]);
58
+ if (!c) return false;
59
+ if (c.value === 'new') key = '';
60
+ }
61
+ if (!key) {
62
+ if (p.keyUrl) console.log(dim(` Get a key here: ${p.keyUrl}`));
63
+ key = clean(await lineInput('Paste your API key (hidden): ', { secret: true }));
64
+ if (!key) { console.log(red('No key entered. Cancelled.')); return false; }
65
+ }
66
+ prof.key = key;
67
+ }
68
+
69
+ const model = await pickModel(p, { ...cfgMod.load(), AI_API_KEY: prof.key || '', CUSTOM_URL: prof.url || '' });
70
+ if (!model) return false;
71
+
72
+ cfgMod.saveProfile(p.id, { key: prof.key, model, url: prof.url });
73
+ console.log(`\n${green('✔ Ready:')} ${p.name} · ${model}`);
74
+ if (p.id === 'ollama') console.log(dim(' Make sure Ollama is running: ollama serve (and ollama pull ' + model + ')'));
75
+ console.log(commandsHelp());
76
+ return true;
77
+ }
78
+
79
+ /** Add / replace the API key of the current provider (use when the old one hit its limit). */
80
+ export async function addKey() {
81
+ const cfg = cfgMod.load();
82
+ const id = detectProvider(cfg);
83
+ const p = byId(id);
84
+ if (!process.stdin.isTTY) { console.log(red('Run this in an interactive terminal, or use: jarvis config set AI_API_KEY=your_key')); return false; }
85
+ if (!p || p.nokey) { console.log(dim('This provider needs no key. Run jarvis setup to choose another.')); return false; }
86
+ console.log(dim(` New key for ${p.name}${p.keyUrl ? ' · get one: ' + p.keyUrl : ''}`));
87
+ const key = clean(await lineInput('Paste NEW API key (hidden): ', { secret: true }));
88
+ if (!key) { console.log(red('No key entered. Cancelled.')); return false; }
89
+ cfgMod.saveProfile(id, { key });
90
+ console.log(green('✔ New key saved ') + dim(`(${cfgMod.mask('AI_API_KEY', key)})`));
91
+ return true;
92
+ }
93
+
94
+ /** Model picker: loads the provider's live model list; falls back to common models. Returns id or null. */
95
+ export async function pickModel(p, cfgLike) {
96
+ process.stdout.write(dim(' Loading available models…'));
97
+ const live = await listModels(cfgLike, p.id);
98
+ process.stdout.write('\r\x1b[2K');
99
+ const curated = p.models || [];
100
+ if (!live && curated.length) console.log(dim(' (could not load the live model list — showing common models)'));
101
+ const list = live || curated;
102
+ const custom = { label: '✎ Type a model name…', value: '__custom' };
103
+ const items = list.map((m) => ({ label: m, value: m, hint: m.endsWith(':free') ? 'free' : m === curated[0] ? 'recommended' : '' }));
104
+ if (list.length > 12) items.unshift(custom); else items.push(custom);
105
+ const pick = items.length > 1
106
+ ? await select(`Choose a model for ${p.name}${live ? ' (live list)' : ''}`, items, { initial: list.length > 12 ? 1 : 0 })
107
+ : custom;
108
+ if (!pick) return null;
109
+ if (pick.value === '__custom') {
110
+ const m = await lineInput('Model name: ');
111
+ if (!m) { console.log(red('No model entered.')); return null; }
112
+ return m;
113
+ }
114
+ return pick.value;
115
+ }
116
+
117
+ /** Change only the model of the current provider (keeps the key). */
118
+ export async function changeModel() {
119
+ const cfg = cfgMod.load();
120
+ const id = detectProvider(cfg);
121
+ const p = byId(id);
122
+ if (!process.stdin.isTTY) { console.log(red('Run this in an interactive terminal, or use: jarvis config set AI_MODEL=model-name')); return false; }
123
+ if (!p) return false;
124
+ const model = await pickModel(p, cfg);
125
+ if (!model) return false;
126
+ cfgMod.saveProfile(id, { model });
127
+ console.log(green('✔ Model set: ') + model);
128
+ return true;
129
+ }
package/src/skills.js ADDED
@@ -0,0 +1,130 @@
1
+ import os from 'node:os';
2
+ import { spawn } from 'node:child_process';
3
+ import * as memory from './memory.js';
4
+ import { bar, green, dim, cyan } from './ui.js';
5
+
6
+ const SITES = {
7
+ youtube: 'https://www.youtube.com', google: 'https://www.google.com',
8
+ github: 'https://github.com', gmail: 'https://mail.google.com',
9
+ facebook: 'https://www.facebook.com', chatgpt: 'https://chatgpt.com',
10
+ claude: 'https://claude.ai', whatsapp: 'https://web.whatsapp.com',
11
+ netflix: 'https://www.netflix.com', spotify: 'https://open.spotify.com',
12
+ };
13
+
14
+ function launch(target, isUrl) {
15
+ const p = process.platform;
16
+ let cmd, args;
17
+ if (p === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', target]; }
18
+ else if (p === 'darwin') { cmd = 'open'; args = isUrl ? [target] : ['-a', target]; }
19
+ else { cmd = isUrl ? 'xdg-open' : target; args = isUrl ? [target] : []; }
20
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
21
+ child.on('error', () => {});
22
+ child.unref();
23
+ }
24
+
25
+ export function openThing(name) {
26
+ const clean = name.trim();
27
+ if (!/^[\w .:/?=#%@+-]{1,200}$/.test(clean)) return false;
28
+ const site = SITES[clean.toLowerCase()];
29
+ if (site) { launch(site, true); return `Opening ${clean}.`; }
30
+ if (/^https?:\/\//i.test(clean)) { launch(clean, true); return `Opening ${clean}.`; }
31
+ if (/^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(clean)) { launch('https://' + clean, true); return `Opening ${clean}.`; }
32
+ launch(clean, false);
33
+ return `Launching ${clean}.`;
34
+ }
35
+
36
+ async function cpuPercent() {
37
+ const snap = () => os.cpus().reduce((a, c) => {
38
+ const t = Object.values(c.times).reduce((x, y) => x + y, 0);
39
+ return { idle: a.idle + c.times.idle, total: a.total + t };
40
+ }, { idle: 0, total: 0 });
41
+ const a = snap();
42
+ await new Promise((r) => setTimeout(r, 400));
43
+ const b = snap();
44
+ const total = b.total - a.total;
45
+ return total ? 100 - ((b.idle - a.idle) / total) * 100 : 0;
46
+ }
47
+
48
+ export async function stats() {
49
+ const cpu = await cpuPercent();
50
+ const total = os.totalmem(), used = total - os.freemem();
51
+ const gb = (n) => (n / 1024 ** 3).toFixed(1);
52
+ const up = os.uptime();
53
+ return [
54
+ cyan('SYSTEM STATUS'),
55
+ ` CPU ${bar(cpu)} ${os.cpus().length} cores`,
56
+ ` RAM ${bar((used / total) * 100)} ${gb(used)} / ${gb(total)} GB`,
57
+ ` OS ${os.type()} ${os.release()} (${os.arch()})`,
58
+ ` UP ${Math.floor(up / 3600)}h ${Math.floor((up % 3600) / 60)}m`,
59
+ ].join('\n');
60
+ }
61
+
62
+ const UNITS = { second: 1, sec: 1, minute: 60, min: 60, hour: 3600, hr: 3600 };
63
+
64
+ /**
65
+ * Local skills: handled without calling the LLM.
66
+ * Returns a string (or promise of string) when handled, otherwise null.
67
+ */
68
+ export function trySkill(input, ctx) {
69
+ const t = input.trim();
70
+ let m;
71
+
72
+ if (/^(what('| i)?s )?(the )?(time|date)\b/i.test(t) || /^time\??$/i.test(t))
73
+ return new Date().toLocaleString();
74
+
75
+ if (/^(system )?(stats|status)$/i.test(t) || /\b(cpu|ram)\b.*\b(usage|status)\b/i.test(t)) return stats();
76
+
77
+ if ((m = t.match(/^remember(?: that)?\s+(.+)/i))) {
78
+ memory.add(m[1]);
79
+ return `Noted, ${ctx.title}. I shall remember: "${m[1]}"`;
80
+ }
81
+ if (/^what do you (remember|know about me)/i.test(t) || /^memory$/i.test(t)) {
82
+ const list = memory.all();
83
+ return list.length ? list.map((x, i) => `${i + 1}. ${x.text}`).join('\n') : 'My memory banks are empty, sir.';
84
+ }
85
+ if ((m = t.match(/^forget\s+#?(\d+)$/i))) {
86
+ return memory.remove(Number(m[1])) ? 'Done. Forgotten.' : 'No such memory number.';
87
+ }
88
+ if (/^forget (everything|all)$/i.test(t)) { memory.clear(); return 'Memory wiped.'; }
89
+
90
+ if ((m = t.match(/^(?:open|launch|start)\s+(.+)/i))) return openThing(m[1]) || 'I would rather not run that, sir.';
91
+
92
+ if ((m = t.match(/^(?:calc(?:ulate)?|math)\s+(.+)/i))) {
93
+ const expr = m[1].replace(/\^/g, '**');
94
+ if (!/^[\d+\-*/().%\s]+$/.test(expr)) return 'Numbers and + - * / ( ) % only, please.';
95
+ try { return `= ${Function(`"use strict"; return (${expr})`)()}`; } catch { return 'That does not compute.'; }
96
+ }
97
+
98
+ if ((m = t.match(/^weather(?: in| of| for)?\s*(.*)$/i))) {
99
+ const city = m[1].trim();
100
+ return fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`)
101
+ .then((r) => r.text()).then((s) => s.trim()).catch(() => 'Unable to reach the weather service.');
102
+ }
103
+
104
+ if ((m = t.match(/^(?:set (?:a )?)?(?:timer|remind me)(?: in| for)?\s+(\d+)\s*(seconds?|secs?|minutes?|mins?|hours?|hrs?)(?:\s+(?:to|about)\s+(.+))?$/i))) {
105
+ const secs = Number(m[1]) * UNITS[m[2].toLowerCase().replace(/s$/, '')];
106
+ const what = m[3] || 'Timer finished';
107
+ setTimeout(() => ctx.notify(`⏰ ${what}`), secs * 1000).unref?.();
108
+ return `Timer set for ${m[1]} ${m[2]}.`;
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ export const HELP = `
115
+ ${green('Chat')} just type anything and press Enter
116
+ ${green('Skills')} ${dim('(no API call needed)')}
117
+ open youtube | open notepad | open github.com
118
+ weather <city> time
119
+ stats calculate 12*(3+4)
120
+ remember that <fact> what do you remember
121
+ forget <number> forget all
122
+ timer 5 minutes remind me in 10 minutes to stretch
123
+ ${green('Slash')}
124
+ /voice on|off speak replies aloud
125
+ /clear reset conversation
126
+ /model show current model and pick another
127
+ /setup change provider / model (arrow keys)
128
+ /key add a NEW API key
129
+ /help /exit
130
+ `;
package/src/ui.js ADDED
@@ -0,0 +1,71 @@
1
+ const c = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
2
+ export const green = c(0, 255, 170);
3
+ export const cyan = c(0, 220, 255);
4
+ export const dim = c(110, 140, 130);
5
+ export const red = c(255, 80, 100);
6
+ export const amber = c(255, 190, 50);
7
+ export const bold = (s) => `\x1b[1m${s}\x1b[0m`;
8
+
9
+ const JARVIS = [
10
+ ' ██╗ █████╗ ██████╗ ██╗ ██╗██╗███████╗',
11
+ ' ██║██╔══██╗██╔══██╗██║ ██║██║██╔════╝',
12
+ ' ██║███████║██████╔╝██║ ██║██║███████╗',
13
+ '██ ██║██╔══██║██╔══██╗╚██╗ ██╔╝██║╚════██║',
14
+ '╚█████╔╝██║ ██║██║ ██║ ╚████╔╝ ██║███████║',
15
+ ' ╚════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚══════╝',
16
+ ];
17
+ const PRO = [
18
+ '██████╗ ██████╗ ██████╗ ',
19
+ '██╔══██╗██╔══██╗██╔═══██╗',
20
+ '██████╔╝██████╔╝██║ ██║',
21
+ '██╔═══╝ ██╔══██╗██║ ██║',
22
+ '██║ ██║ ██║╚██████╔╝',
23
+ '╚═╝ ╚═╝ ╚═╝ ╚═════╝ ',
24
+ ];
25
+
26
+ export const CREDIT = {
27
+ name: 'Rongon Kairy',
28
+ github: 'https://github.com/RongonKairy',
29
+ };
30
+
31
+ const blue = c(60, 150, 255);
32
+ const purple = c(170, 70, 200);
33
+ const gray = c(150, 150, 150);
34
+
35
+ /** info = { tool, agent, key } */
36
+ export function printLogo() {
37
+ const wide = (process.stdout.columns || 80) >= 74;
38
+ const logo = JARVIS.map((l, i) => (wide ? l.padEnd(45) + PRO[i] : l));
39
+ console.log('\n' + logo.map((l) => green(l)).join('\n') + '\n');
40
+ }
41
+
42
+ export function banner(info) {
43
+ printLogo();
44
+ const tag = (label, col) => col(`[ ${label} ]`);
45
+ console.log(` ${tag('NEURAL CORE ONLINE', blue)}`);
46
+ console.log(` ${tag('TOOL ', purple)} ${info.tool}`);
47
+ console.log(` ${tag('AGENT', purple)} ${info.agent}`);
48
+ console.log(` ${tag('KEY ', purple)} ${info.key}`);
49
+ console.log(` ${tag('EXIT ', gray)} Press ${red('Ctrl + C')} or type ${red('/exit')} to stop`);
50
+ const rule = '='.repeat(58);
51
+ console.log(rule);
52
+ console.log(` ${blue('CREATED BY')} ${CREDIT.name}`);
53
+ console.log(` ${blue('GITHUB ')} ${CREDIT.github}`);
54
+ console.log(rule);
55
+ console.log(dim(' Type /help for commands.\n'));
56
+ }
57
+
58
+ export function spinner(label = 'thinking') {
59
+ const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
60
+ let i = 0;
61
+ const t = setInterval(() => {
62
+ process.stdout.write(`\r${green(frames[i++ % frames.length])} ${dim(label)} `);
63
+ }, 80);
64
+ return () => { clearInterval(t); process.stdout.write('\r\x1b[2K'); };
65
+ }
66
+
67
+ export function bar(pct, width = 20) {
68
+ const filled = Math.round((pct / 100) * width);
69
+ const col = pct > 85 ? red : pct > 65 ? amber : green;
70
+ return col('█'.repeat(filled)) + dim('░'.repeat(width - filled)) + ` ${pct.toFixed(0)}%`;
71
+ }
package/src/voice.js ADDED
@@ -0,0 +1,27 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ let current = null;
4
+
5
+ export function stopSpeaking() {
6
+ if (current) { try { current.kill(); } catch {} current = null; }
7
+ }
8
+
9
+ export function speak(text) {
10
+ stopSpeaking();
11
+ const clean = text.replace(/[`*_#>]/g, '').replace(/\s+/g, ' ').trim().slice(0, 1500);
12
+ if (!clean) return;
13
+ const env = { ...process.env, JARVIS_TEXT: clean };
14
+ let p;
15
+ if (process.platform === 'win32') {
16
+ p = spawn('powershell', ['-NoProfile', '-Command',
17
+ 'Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Speak($env:JARVIS_TEXT)'],
18
+ { env, stdio: 'ignore', windowsHide: true });
19
+ } else if (process.platform === 'darwin') {
20
+ p = spawn('say', [clean], { stdio: 'ignore' });
21
+ } else {
22
+ p = spawn('espeak-ng', [clean], { stdio: 'ignore' });
23
+ }
24
+ p.on('error', () => {});
25
+ p.on('exit', () => { if (current === p) current = null; });
26
+ current = p;
27
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.