open-jarvis-cli 0.0.0-stage → 1.1.0
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 +86 -0
- package/package.json +30 -4
- package/src/config.js +89 -0
- package/src/llm.js +134 -0
- package/src/memory.js +28 -0
- package/src/menu.js +86 -0
- package/src/providers.js +35 -0
- package/src/repl.js +179 -0
- package/src/setup.js +100 -0
- package/src/skills.js +130 -0
- package/src/ui.js +71 -0
- package/src/voice.js +27 -0
- package/README.md +0 -3
package/bin/jarvis.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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, 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 === 'ask') {
|
|
60
|
+
const q = rest.join(' ').trim();
|
|
61
|
+
if (!q) console.log(red('Usage: jarvis ask "question"'));
|
|
62
|
+
else {
|
|
63
|
+
let cfg = cfgMod.load();
|
|
64
|
+
const local = trySkill(q, { title: cfg.TITLE, notify: console.log });
|
|
65
|
+
if (local !== null) console.log(await local);
|
|
66
|
+
else {
|
|
67
|
+
if (process.stdin.isTTY && needsSetup(cfg)) {
|
|
68
|
+
if (!(await runSetup())) process.exit(0);
|
|
69
|
+
cfg = cfgMod.load();
|
|
70
|
+
}
|
|
71
|
+
await ask({ cfg }, [], q);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (cmd === 'stats') console.log(await stats());
|
|
77
|
+
else if (cmd === 'memory') {
|
|
78
|
+
const l = memory.all();
|
|
79
|
+
console.log(l.length ? l.map((x, i) => `${i + 1}. ${x.text}`).join('\n') : 'Memory is empty.');
|
|
80
|
+
}
|
|
81
|
+
else if (cmd === '-v' || cmd === '--version') console.log(pkg.version);
|
|
82
|
+
else console.log(USAGE);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error(red('✖ ' + e.message));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-jarvis-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
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": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"author": "Rongon Kairy",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/RongonKairy/openjarvispro.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/RongonKairy",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"jarvis",
|
|
26
|
+
"ai",
|
|
27
|
+
"cli",
|
|
28
|
+
"assistant",
|
|
29
|
+
"terminal"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|
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,134 @@
|
|
|
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 (status === 401 || status === 403) return 'auth';
|
|
27
|
+
return 'other';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function* sseData(res) {
|
|
31
|
+
const dec = new TextDecoder();
|
|
32
|
+
let buf = '';
|
|
33
|
+
for await (const chunk of res.body) {
|
|
34
|
+
buf += dec.decode(chunk, { stream: true });
|
|
35
|
+
let i;
|
|
36
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
37
|
+
const line = buf.slice(0, i).trim();
|
|
38
|
+
buf = buf.slice(i + 1);
|
|
39
|
+
if (line.startsWith('data:')) {
|
|
40
|
+
const d = line.slice(5).trim();
|
|
41
|
+
if (d && d !== '[DONE]') yield d;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function* ndjson(res) {
|
|
48
|
+
const dec = new TextDecoder();
|
|
49
|
+
let buf = '';
|
|
50
|
+
for await (const chunk of res.body) {
|
|
51
|
+
buf += dec.decode(chunk, { stream: true });
|
|
52
|
+
let i;
|
|
53
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
54
|
+
const line = buf.slice(0, i).trim();
|
|
55
|
+
buf = buf.slice(i + 1);
|
|
56
|
+
if (line) yield line;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function post(url, headers, body, signal) {
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), signal });
|
|
65
|
+
} catch (e) {
|
|
66
|
+
throw new ApiError(`fetch failed: ${e.cause?.code || e.message}`, { kind: 'network' });
|
|
67
|
+
}
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const txt = (await res.text().catch(() => '')).slice(0, 300);
|
|
70
|
+
throw new ApiError(`${res.status} ${res.statusText} ${txt}`.trim(), { status: res.status, kind: classify(res.status, txt) });
|
|
71
|
+
}
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function run(id, cfg, system, history, onToken, signal) {
|
|
76
|
+
const p = byId(id);
|
|
77
|
+
if (!p) throw new ApiError(`Unknown provider "${id}". Run: jarvis setup`, { kind: 'other' });
|
|
78
|
+
const model = modelFor(cfg, id);
|
|
79
|
+
const key = cfg.AI_API_KEY;
|
|
80
|
+
let out = '';
|
|
81
|
+
const emit = (t) => { if (t) { out += t; onToken(t); } };
|
|
82
|
+
|
|
83
|
+
if (p.type === 'openai') {
|
|
84
|
+
let url = p.url;
|
|
85
|
+
if (p.custom) {
|
|
86
|
+
if (!cfg.CUSTOM_URL) throw new ApiError('No custom URL set. Run: jarvis setup', { kind: 'other' });
|
|
87
|
+
url = cfg.CUSTOM_URL.replace(/\/+$/, '') + '/chat/completions';
|
|
88
|
+
}
|
|
89
|
+
const res = await post(url, { authorization: `Bearer ${key}` },
|
|
90
|
+
{ model, stream: true, messages: [{ role: 'system', content: system }, ...history] }, signal);
|
|
91
|
+
for await (const d of sseData(res)) emit(JSON.parse(d).choices?.[0]?.delta?.content);
|
|
92
|
+
} else if (p.type === 'anthropic') {
|
|
93
|
+
const res = await post('https://api.anthropic.com/v1/messages',
|
|
94
|
+
{ 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
95
|
+
{ model, max_tokens: 1024, stream: true, system, messages: history }, signal);
|
|
96
|
+
for await (const d of sseData(res)) {
|
|
97
|
+
const j = JSON.parse(d);
|
|
98
|
+
if (j.type === 'content_block_delta') emit(j.delta?.text);
|
|
99
|
+
}
|
|
100
|
+
} else if (p.type === 'gemini') {
|
|
101
|
+
const res = await post(
|
|
102
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`,
|
|
103
|
+
{ 'x-goog-api-key': key },
|
|
104
|
+
{
|
|
105
|
+
systemInstruction: { parts: [{ text: system }] },
|
|
106
|
+
contents: history.map((m) => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] })),
|
|
107
|
+
}, signal);
|
|
108
|
+
for await (const d of sseData(res)) emit(JSON.parse(d).candidates?.[0]?.content?.parts?.[0]?.text);
|
|
109
|
+
} else if (p.type === 'ollama') {
|
|
110
|
+
const res = await post(`${cfg.OLLAMA_URL.replace(/\/$/, '')}/api/chat`, {},
|
|
111
|
+
{ model, stream: true, messages: [{ role: 'system', content: system }, ...history] }, signal);
|
|
112
|
+
for await (const l of ndjson(res)) emit(JSON.parse(l).message?.content);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Streams a reply. On network/other errors it falls back to local Ollama; limit/auth errors are surfaced. */
|
|
118
|
+
export async function chat(cfg, system, history, onToken, signal) {
|
|
119
|
+
const id = detectProvider(cfg);
|
|
120
|
+
const p = byId(id);
|
|
121
|
+
if (p && !p.nokey && !cfg.AI_API_KEY) {
|
|
122
|
+
throw new ApiError('No API key set. Run: jarvis setup', { kind: 'auth' });
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return await run(id, cfg, system, history, onToken, signal);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (signal?.aborted || id === 'ollama' || err.kind === 'limit' || err.kind === 'auth') throw err;
|
|
128
|
+
try {
|
|
129
|
+
return await run('ollama', { ...cfg, AI_MODEL: '' }, system, history, onToken, signal);
|
|
130
|
+
} catch {
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
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
|
+
}
|
package/src/providers.js
ADDED
|
@@ -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,179 @@
|
|
|
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 } 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 (/fetch failed|ENOTFOUND|ECONN/i.test(m)) return 'Cannot reach the network (or Ollama is not running).';
|
|
33
|
+
return m;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function tryOnce(cfg, history, text) {
|
|
37
|
+
history.push({ role: 'user', content: text });
|
|
38
|
+
const stop = spinner();
|
|
39
|
+
let first = true;
|
|
40
|
+
try {
|
|
41
|
+
const reply = await chat(cfg, buildSystem(cfg), history.slice(-20), (tok) => {
|
|
42
|
+
if (first) { stop(); process.stdout.write(green('JARVIS ❯ ')); first = false; }
|
|
43
|
+
process.stdout.write(tok);
|
|
44
|
+
});
|
|
45
|
+
if (first) stop();
|
|
46
|
+
process.stdout.write('\n');
|
|
47
|
+
history.push({ role: 'assistant', content: reply });
|
|
48
|
+
if (cfg.VOICE === 'on') speak(reply);
|
|
49
|
+
return { ok: true, reply };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (first) stop(); else process.stdout.write('\n');
|
|
52
|
+
history.pop();
|
|
53
|
+
return { ok: false, err };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** When the API limit is over (or the key is bad): offer a new key / another provider. */
|
|
58
|
+
async function recover(err, S) {
|
|
59
|
+
const id = detectProvider(S.cfg);
|
|
60
|
+
const name = byId(id)?.name || id;
|
|
61
|
+
console.log(amber(err.kind === 'limit' ? `⚠ ${name}: limit reached.` : `⚠ ${name}: API key problem.`));
|
|
62
|
+
const c = await select('What would you like to do?', [
|
|
63
|
+
{ label: `Add a NEW API key for ${name}`, value: 'key', hint: 'command: jarvis key' },
|
|
64
|
+
{ label: 'Switch to another provider / model', value: 'setup', hint: 'command: jarvis setup' },
|
|
65
|
+
{ label: 'Retry the same message', value: 'retry' },
|
|
66
|
+
{ label: 'Cancel', value: 'cancel' },
|
|
67
|
+
]);
|
|
68
|
+
if (!c || c.value === 'cancel') return false;
|
|
69
|
+
if (c.value === 'key' && !(await addKey())) return false;
|
|
70
|
+
if (c.value === 'setup' && !(await runSetup())) return false;
|
|
71
|
+
S.cfg = cfgMod.load();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** S = { cfg } so the config can be refreshed after the user adds a key. */
|
|
76
|
+
export async function ask(S, history, text) {
|
|
77
|
+
for (;;) {
|
|
78
|
+
const res = await tryOnce(S.cfg, history, text);
|
|
79
|
+
if (res.ok) return res.reply;
|
|
80
|
+
console.log(red(`✖ ${friendly(res.err)}`));
|
|
81
|
+
const k = res.err.kind;
|
|
82
|
+
if (k === 'limit' || k === 'auth') {
|
|
83
|
+
console.log(dim(String(res.err.message).slice(0, 160)));
|
|
84
|
+
if (!process.stdin.isTTY) {
|
|
85
|
+
console.log(dim('Add a new key with: jarvis key (or) jarvis config set AI_API_KEY=your_key'));
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (await recover(res.err, S)) continue;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function startRepl() {
|
|
95
|
+
let cfg = cfgMod.load();
|
|
96
|
+
|
|
97
|
+
if (process.stdin.isTTY && needsSetup(cfg)) {
|
|
98
|
+
console.log('\n' + green(' JARVIS PRO') + dim(' · first-time setup'));
|
|
99
|
+
console.log(dim(' Pick the AI you want to use. Use ↑ ↓ and Enter.\n'));
|
|
100
|
+
if (!(await runSetup())) {
|
|
101
|
+
console.log(dim('\nSetup cancelled. Run jarvis setup whenever you are ready.'));
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
cfg = cfgMod.load();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const S = { cfg };
|
|
108
|
+
const showBanner = () => {
|
|
109
|
+
const id = detectProvider(S.cfg);
|
|
110
|
+
banner({
|
|
111
|
+
tool: `OpenJarvisPro v${VERSION}`,
|
|
112
|
+
agent: `${byId(id)?.name || id} · ${modelFor(S.cfg, id)}`,
|
|
113
|
+
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')),
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
showBanner();
|
|
117
|
+
if (!S.cfg.AI_API_KEY && !byId(detectProvider(S.cfg))?.nokey) {
|
|
118
|
+
console.log(amber('⚠ No API key yet. Run: jarvis setup') + '\n' + dim(' (Skills like open/weather/timer/stats work without a key.)\n'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const history = [];
|
|
122
|
+
const hr = new Date().getHours();
|
|
123
|
+
const greet = hr < 12 ? 'Good morning' : hr < 18 ? 'Good afternoon' : 'Good evening';
|
|
124
|
+
console.log(green('JARVIS ❯ ') + `${greet}, ${S.cfg.TITLE}. At your service.\n`);
|
|
125
|
+
|
|
126
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: cyan('you ❯ ') });
|
|
127
|
+
const ctx = {
|
|
128
|
+
get title() { return S.cfg.TITLE; },
|
|
129
|
+
notify: (msg) => { process.stdout.write(`\n${msg}\x07\n`); rl.prompt(true); if (S.cfg.VOICE === 'on') speak(msg); },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
rl.prompt();
|
|
133
|
+
rl.on('line', async (line) => {
|
|
134
|
+
const text = line.trim();
|
|
135
|
+
if (!text) return rl.prompt();
|
|
136
|
+
rl.pause();
|
|
137
|
+
try {
|
|
138
|
+
stopSpeaking();
|
|
139
|
+
if (text.startsWith('/')) {
|
|
140
|
+
const [cmd, arg] = text.slice(1).split(/\s+/);
|
|
141
|
+
if (cmd === 'exit' || cmd === 'quit') return rl.close();
|
|
142
|
+
else if (cmd === 'help') console.log(HELP);
|
|
143
|
+
else if (cmd === 'clear') { history.length = 0; console.log(dim('Conversation cleared.')); }
|
|
144
|
+
else if (cmd === 'voice') {
|
|
145
|
+
cfgMod.setValue('VOICE', arg === 'on' ? 'on' : 'off');
|
|
146
|
+
S.cfg = cfgMod.load();
|
|
147
|
+
console.log(dim(`Voice ${S.cfg.VOICE}.`));
|
|
148
|
+
} else if (cmd === 'model') {
|
|
149
|
+
const id = detectProvider(S.cfg);
|
|
150
|
+
console.log(`${byId(id)?.name || id} / ${modelFor(S.cfg, id)} ${dim('(change with /setup)')}`);
|
|
151
|
+
} else if (cmd === 'setup' || cmd === 'switch') {
|
|
152
|
+
if (await runSetup()) { S.cfg = cfgMod.load(); history.length = 0; }
|
|
153
|
+
} else if (cmd === 'key') {
|
|
154
|
+
if (await addKey()) S.cfg = cfgMod.load();
|
|
155
|
+
} else console.log(dim('Unknown command. Try /help'));
|
|
156
|
+
} else {
|
|
157
|
+
const local = trySkill(text, ctx);
|
|
158
|
+
if (local !== null) {
|
|
159
|
+
const out = await local;
|
|
160
|
+
console.log(green('JARVIS ❯ ') + out);
|
|
161
|
+
if (S.cfg.VOICE === 'on' && typeof out === 'string') speak(out);
|
|
162
|
+
} else {
|
|
163
|
+
await ask(S, history, text);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.log(red(`✖ ${e.message}`));
|
|
168
|
+
}
|
|
169
|
+
console.log();
|
|
170
|
+
rl.resume();
|
|
171
|
+
rl.prompt();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
rl.on('close', () => {
|
|
175
|
+
stopSpeaking();
|
|
176
|
+
console.log(`\n${green('JARVIS ❯ ')}Goodbye, ${S.cfg.TITLE}.`);
|
|
177
|
+
process.exit(0);
|
|
178
|
+
});
|
|
179
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as cfgMod from './config.js';
|
|
2
|
+
import { PROVIDERS, byId } from './providers.js';
|
|
3
|
+
import { detectProvider } 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 key')} add a NEW API key ${dim('(when your limit is over)')}
|
|
13
|
+
${green('jarvis config set AI_API_KEY=')}${dim('your_key')} same thing, one line
|
|
14
|
+
${green('jarvis config list')} show current settings
|
|
15
|
+
${dim('inside chat:')} /setup /key /model /help /exit
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function needsSetup(cfg) {
|
|
20
|
+
const p = cfgMod.raw().AI_PROVIDER;
|
|
21
|
+
return (!p || p === 'auto') && !cfg.AI_API_KEY;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clean = (k) => (k || '').replace(/^["']|["']$/g, '').trim();
|
|
25
|
+
|
|
26
|
+
/** Full wizard: provider → key → model. Returns true when saved. */
|
|
27
|
+
export async function runSetup() {
|
|
28
|
+
if (!process.stdin.isTTY) {
|
|
29
|
+
console.log(red('Setup needs an interactive terminal. Run: jarvis setup'));
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const s = cfgMod.raw();
|
|
33
|
+
const profiles = s.profiles || {};
|
|
34
|
+
const items = PROVIDERS.map((p) => ({
|
|
35
|
+
label: p.name, value: p,
|
|
36
|
+
hint: [profiles[p.id]?.key ? '✓ key saved' : '', p.hint || ''].filter(Boolean).join(' · '),
|
|
37
|
+
}));
|
|
38
|
+
const pick = await select('Choose your AI provider', items,
|
|
39
|
+
{ initial: Math.max(0, PROVIDERS.findIndex((p) => p.id === s.AI_PROVIDER)) });
|
|
40
|
+
if (!pick) return false;
|
|
41
|
+
const p = pick.value;
|
|
42
|
+
const prof = { ...(profiles[p.id] || {}) };
|
|
43
|
+
|
|
44
|
+
if (p.custom) {
|
|
45
|
+
const url = await lineInput(`Base URL ${dim('(e.g. https://api.example.com/v1)')}: `, { def: prof.url || '' });
|
|
46
|
+
if (!url) return false;
|
|
47
|
+
prof.url = url.replace(/\/+$/, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!p.nokey) {
|
|
51
|
+
let key = prof.key;
|
|
52
|
+
if (key) {
|
|
53
|
+
const c = await select(`API key for ${p.name}`, [
|
|
54
|
+
{ label: `Use saved key (${cfgMod.mask('AI_API_KEY', key)})`, value: 'use' },
|
|
55
|
+
{ label: 'Enter a new API key', value: 'new' },
|
|
56
|
+
]);
|
|
57
|
+
if (!c) return false;
|
|
58
|
+
if (c.value === 'new') key = '';
|
|
59
|
+
}
|
|
60
|
+
if (!key) {
|
|
61
|
+
if (p.keyUrl) console.log(dim(` Get a key here: ${p.keyUrl}`));
|
|
62
|
+
key = clean(await lineInput('Paste your API key (hidden): ', { secret: true }));
|
|
63
|
+
if (!key) { console.log(red('No key entered. Cancelled.')); return false; }
|
|
64
|
+
}
|
|
65
|
+
prof.key = key;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mItems = [
|
|
69
|
+
...(p.models || []).map((m, i) => ({ label: m, value: m, hint: i === 0 ? 'recommended' : '' })),
|
|
70
|
+
{ label: '✎ Type a model name…', value: '__custom' },
|
|
71
|
+
];
|
|
72
|
+
let model;
|
|
73
|
+
const mp = mItems.length > 1 ? await select(`Choose a model for ${p.name}`, mItems) : mItems[0];
|
|
74
|
+
if (!mp) return false;
|
|
75
|
+
if (mp.value === '__custom') {
|
|
76
|
+
model = await lineInput('Model name: ');
|
|
77
|
+
if (!model) { console.log(red('No model entered. Cancelled.')); return false; }
|
|
78
|
+
} else model = mp.value;
|
|
79
|
+
|
|
80
|
+
cfgMod.saveProfile(p.id, { key: prof.key, model, url: prof.url });
|
|
81
|
+
console.log(`\n${green('✔ Ready:')} ${p.name} · ${model}`);
|
|
82
|
+
if (p.id === 'ollama') console.log(dim(' Make sure Ollama is running: ollama serve (and ollama pull ' + model + ')'));
|
|
83
|
+
console.log(commandsHelp());
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Add / replace the API key of the current provider (use when the old one hit its limit). */
|
|
88
|
+
export async function addKey() {
|
|
89
|
+
const cfg = cfgMod.load();
|
|
90
|
+
const id = detectProvider(cfg);
|
|
91
|
+
const p = byId(id);
|
|
92
|
+
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; }
|
|
93
|
+
if (!p || p.nokey) { console.log(dim('This provider needs no key. Run jarvis setup to choose another.')); return false; }
|
|
94
|
+
console.log(dim(` New key for ${p.name}${p.keyUrl ? ' · get one: ' + p.keyUrl : ''}`));
|
|
95
|
+
const key = clean(await lineInput('Paste NEW API key (hidden): ', { secret: true }));
|
|
96
|
+
if (!key) { console.log(red('No key entered. Cancelled.')); return false; }
|
|
97
|
+
cfgMod.saveProfile(id, { key });
|
|
98
|
+
console.log(green('✔ New key saved ') + dim(`(${cfgMod.mask('AI_API_KEY', key)})`));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
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 provider & model
|
|
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