lumina-ai-cli 1.0.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/README.md +92 -0
- package/bin/lumina.js +3 -0
- package/package.json +43 -0
- package/src/api/anthropic.js +63 -0
- package/src/api/custom.js +100 -0
- package/src/api/google.js +63 -0
- package/src/api/groq.js +45 -0
- package/src/api/openai.js +50 -0
- package/src/api/providers.js +35 -0
- package/src/api/together.js +89 -0
- package/src/config.js +50 -0
- package/src/index.js +124 -0
- package/src/memory.js +34 -0
- package/src/onboarding.js +172 -0
- package/src/personas/coder.js +4 -0
- package/src/personas/fixer.js +4 -0
- package/src/personas/index.js +17 -0
- package/src/personas/planner.js +4 -0
- package/src/personas/reviewer.js +4 -0
- package/src/tui/app.js +244 -0
- package/src/tui/chat.js +52 -0
- package/src/tui/input.js +24 -0
- package/src/tui/statusbar.js +42 -0
- package/src/tui/syntax.js +37 -0
- package/src/utils/cost.js +33 -0
- package/src/utils/files.js +46 -0
- package/src/utils/tokens.js +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# lumina-ai-cli — Personal AI Assistant CLI
|
|
2
|
+
|
|
3
|
+
Chat with AI models from OpenAI, Anthropic, Google (Gemini), Groq, Together AI, and custom endpoints — all from your terminal.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g lumina-ai-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
lumina
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On first run, you'll be guided through:
|
|
18
|
+
1. Choosing an API provider
|
|
19
|
+
2. Entering your API key (stored locally, never shared)
|
|
20
|
+
3. Selecting a default model
|
|
21
|
+
4. Picking a persona
|
|
22
|
+
5. Setting your token budget
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
| Command | Description |
|
|
27
|
+
|---------|-------------|
|
|
28
|
+
| `lumina` | Start the interactive TUI |
|
|
29
|
+
| `lumina --config` | Re-run setup |
|
|
30
|
+
| `lumina --model <name>` | Override model for session |
|
|
31
|
+
| `lumina --persona <name>` | Override persona for session |
|
|
32
|
+
| `lumina --budget <n>` | Override token budget |
|
|
33
|
+
| `lumina --help` | Show help |
|
|
34
|
+
| `lumina --version` | Show version |
|
|
35
|
+
|
|
36
|
+
### In-Chat Commands
|
|
37
|
+
|
|
38
|
+
| Command | Description |
|
|
39
|
+
|---------|-------------|
|
|
40
|
+
| `:q` or `:quit` | Exit |
|
|
41
|
+
| `:m <model>` | Switch model |
|
|
42
|
+
| `:p <persona>` | Switch persona |
|
|
43
|
+
| `:b <budget>` | Switch token budget |
|
|
44
|
+
| `:clear` | Clear chat history |
|
|
45
|
+
| `:save <filename>` | Save conversation to file |
|
|
46
|
+
|
|
47
|
+
## Provider Setup
|
|
48
|
+
|
|
49
|
+
### OpenAI
|
|
50
|
+
Get your API key from [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
|
|
51
|
+
|
|
52
|
+
### Anthropic
|
|
53
|
+
Get your API key from [console.anthropic.com](https://console.anthropic.com)
|
|
54
|
+
|
|
55
|
+
### Google (Gemini)
|
|
56
|
+
Get your API key from [aistudio.google.com](https://aistudio.google.com)
|
|
57
|
+
|
|
58
|
+
### Groq
|
|
59
|
+
Get your API key from [console.groq.com](https://console.groq.com)
|
|
60
|
+
|
|
61
|
+
### Together AI
|
|
62
|
+
Get your API key from [api.together.xyz](https://api.together.xyz)
|
|
63
|
+
|
|
64
|
+
### Custom
|
|
65
|
+
Any OpenAI-compatible endpoint. Provide your base URL and API key.
|
|
66
|
+
|
|
67
|
+
## Personas
|
|
68
|
+
|
|
69
|
+
- **Planner** — Technical architecture and project planning
|
|
70
|
+
- **Coder** — Senior software engineer for clean code
|
|
71
|
+
- **Reviewer** — Brutal code review with severity ratings
|
|
72
|
+
- **Fixer** — Debugger with minimal fixes
|
|
73
|
+
- **General** — Default helpful assistant
|
|
74
|
+
|
|
75
|
+
## Token Efficiency
|
|
76
|
+
|
|
77
|
+
Lumina is built for extreme token efficiency:
|
|
78
|
+
- Personas enforce concise responses (default 256 tokens)
|
|
79
|
+
- Every 10 turns, conversation is summarized into a compact memory block
|
|
80
|
+
- Real-time token count and cost display
|
|
81
|
+
- Early truncation when budget exceeded
|
|
82
|
+
|
|
83
|
+
## Troubleshooting
|
|
84
|
+
|
|
85
|
+
Invalid API key: Run `lumina --config` to update your key.
|
|
86
|
+
Rate limited: Wait or switch models with `:m <model>`.
|
|
87
|
+
Connection failed: Check your internet connection and API endpoint.
|
|
88
|
+
Model not found: Run `lumina --config` to see available models.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/bin/lumina.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lumina-ai-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Personal AI Assistant CLI — chat with AI models from OpenAI, Anthropic, Google, Groq, Together AI and custom endpoints",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lumina": "bin/lumina.js",
|
|
8
|
+
"lumina-ai-cli": "bin/lumina.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18.0.0"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"ai",
|
|
16
|
+
"cli",
|
|
17
|
+
"chat",
|
|
18
|
+
"openai",
|
|
19
|
+
"anthropic",
|
|
20
|
+
"google",
|
|
21
|
+
"gemini",
|
|
22
|
+
"groq",
|
|
23
|
+
"together",
|
|
24
|
+
"assistant",
|
|
25
|
+
"terminal"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@anthropic-ai/sdk": "^0.27.0",
|
|
30
|
+
"@google/generative-ai": "^0.12.0",
|
|
31
|
+
"chalk": "^5.3.0",
|
|
32
|
+
"fs-extra": "^11.2.0",
|
|
33
|
+
"gpt-tokenizer": "^2.1.0",
|
|
34
|
+
"groq-sdk": "^0.5.0",
|
|
35
|
+
"highlight.js": "^11.9.0",
|
|
36
|
+
"ink": "^5.0.0",
|
|
37
|
+
"ink-spinner": "^5.0.0",
|
|
38
|
+
"ink-text-input": "^6.0.0",
|
|
39
|
+
"inquirer": "^9.2.0",
|
|
40
|
+
"openai": "^4.47.0",
|
|
41
|
+
"react": "^18.2.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { Provider } from './providers.js';
|
|
3
|
+
|
|
4
|
+
const ANTHROPIC_MODELS = [
|
|
5
|
+
'claude-3-5-sonnet-20241022',
|
|
6
|
+
'claude-3-5-haiku-20241022',
|
|
7
|
+
'claude-3-opus-20240229',
|
|
8
|
+
'claude-3-haiku-20240307',
|
|
9
|
+
'claude-3-sonnet-20240229',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export class AnthropicProvider extends Provider {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
super(config);
|
|
15
|
+
this.client = new Anthropic({
|
|
16
|
+
apiKey: config.apiKey,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async validateKey() {
|
|
21
|
+
try {
|
|
22
|
+
await this.client.messages.create({
|
|
23
|
+
model: 'claude-3-haiku-20240307',
|
|
24
|
+
max_tokens: 1,
|
|
25
|
+
messages: [{ role: 'user', content: 'hi' }]
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.status === 401) throw new Error(err.message);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async listModels() {
|
|
35
|
+
return ANTHROPIC_MODELS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async chat(messages, options = {}) {
|
|
39
|
+
this.abortController = new AbortController();
|
|
40
|
+
const systemMsg = messages.find(m => m.role === 'system');
|
|
41
|
+
const chatMessages = messages.filter(m => m.role !== 'system').map(m => ({
|
|
42
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
43
|
+
content: m.content
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const stream = await this.client.messages.create({
|
|
47
|
+
model: options.model || this.config.defaultModel,
|
|
48
|
+
max_tokens: options.maxTokens || (this.config.tokenBudget === 'unlimited' ? 4096 : this.config.tokenBudget),
|
|
49
|
+
system: systemMsg?.content || undefined,
|
|
50
|
+
messages: chatMessages,
|
|
51
|
+
stream: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let full = '';
|
|
55
|
+
for await (const event of stream) {
|
|
56
|
+
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
57
|
+
if (options.onToken) options.onToken(event.delta.text);
|
|
58
|
+
full += event.delta.text;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return full;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Provider } from './providers.js';
|
|
2
|
+
|
|
3
|
+
export class CustomProvider extends Provider {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
super(config);
|
|
6
|
+
this.baseUrl = config.baseUrl?.replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async _fetch(path, options = {}) {
|
|
10
|
+
const url = this.baseUrl ? `${this.baseUrl}${path}` : path;
|
|
11
|
+
const response = await fetch(url, {
|
|
12
|
+
headers: {
|
|
13
|
+
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
},
|
|
16
|
+
...options
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
const err = await response.text();
|
|
20
|
+
throw new Error(err);
|
|
21
|
+
}
|
|
22
|
+
return response.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async validateKey() {
|
|
26
|
+
try {
|
|
27
|
+
if (this.baseUrl) {
|
|
28
|
+
await this._fetch('/models');
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error(err.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async listModels() {
|
|
37
|
+
if (this.baseUrl) {
|
|
38
|
+
try {
|
|
39
|
+
const data = await this._fetch('/models');
|
|
40
|
+
if (data.data) return data.data.map(m => m.id || m.name).sort();
|
|
41
|
+
if (Array.isArray(data)) return data.map(m => m.id || m.name).sort();
|
|
42
|
+
return Object.values(data).filter(v => typeof v === 'string').sort();
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
return this.config.availableModels || ['custom-model'];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async chat(messages, options = {}) {
|
|
49
|
+
this.abortController = new AbortController();
|
|
50
|
+
const url = this.baseUrl ? `${this.baseUrl}/chat/completions` : 'https://api.openai.com/v1/chat/completions';
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
model: options.model || this.config.defaultModel,
|
|
59
|
+
messages,
|
|
60
|
+
stream: true,
|
|
61
|
+
max_tokens: options.maxTokens || (this.config.tokenBudget === 'unlimited' ? 4096 : this.config.tokenBudget),
|
|
62
|
+
}),
|
|
63
|
+
signal: this.abortController.signal
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const err = await response.text();
|
|
68
|
+
throw new Error(err);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const reader = response.body.getReader();
|
|
72
|
+
const decoder = new TextDecoder();
|
|
73
|
+
let full = '';
|
|
74
|
+
let buffer = '';
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done) break;
|
|
79
|
+
buffer += decoder.decode(value, { stream: true });
|
|
80
|
+
const lines = buffer.split('\n');
|
|
81
|
+
buffer = lines.pop() || '';
|
|
82
|
+
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
86
|
+
const data = trimmed.slice(6);
|
|
87
|
+
if (data === '[DONE]') continue;
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(data);
|
|
90
|
+
const delta = parsed.choices?.[0]?.delta?.content || '';
|
|
91
|
+
if (delta && options.onToken) {
|
|
92
|
+
options.onToken(delta);
|
|
93
|
+
}
|
|
94
|
+
full += delta;
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return full;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
import { Provider } from './providers.js';
|
|
3
|
+
|
|
4
|
+
export class GoogleProvider extends Provider {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
super(config);
|
|
7
|
+
this.client = new GoogleGenerativeAI(config.apiKey);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async validateKey() {
|
|
11
|
+
try {
|
|
12
|
+
const model = this.client.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
|
13
|
+
await model.generateContent('test');
|
|
14
|
+
return true;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(err.message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async listModels() {
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch('https://generativelanguage.googleapis.com/v1/models?key=' + this.config.apiKey);
|
|
23
|
+
const data = await response.json();
|
|
24
|
+
if (data.models) {
|
|
25
|
+
return data.models
|
|
26
|
+
.filter(m => m.name.startsWith('models/gemini'))
|
|
27
|
+
.map(m => m.name.replace('models/', ''))
|
|
28
|
+
.sort();
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
return ['gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-2.0-flash'];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async chat(messages, options = {}) {
|
|
35
|
+
this.abortController = new AbortController();
|
|
36
|
+
const model = this.client.getGenerativeModel({
|
|
37
|
+
model: options.model || this.config.defaultModel,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const chatMessages = messages.filter(m => m.role !== 'system');
|
|
41
|
+
let history = chatMessages.slice(0, -1).map(m => ({
|
|
42
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
43
|
+
parts: [{ text: m.content }]
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const lastMsg = chatMessages[chatMessages.length - 1];
|
|
47
|
+
const systemMsg = messages.find(m => m.role === 'system');
|
|
48
|
+
|
|
49
|
+
const chat = model.startChat({
|
|
50
|
+
history,
|
|
51
|
+
systemInstruction: systemMsg?.content || undefined,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = await chat.sendMessage(lastMsg.content);
|
|
55
|
+
const response = result.response;
|
|
56
|
+
const text = response.text();
|
|
57
|
+
|
|
58
|
+
if (options.onToken) {
|
|
59
|
+
options.onToken(text);
|
|
60
|
+
}
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/api/groq.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Groq from 'groq-sdk';
|
|
2
|
+
import { Provider } from './providers.js';
|
|
3
|
+
|
|
4
|
+
export class GroqProvider extends Provider {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
super(config);
|
|
7
|
+
this.client = new Groq({
|
|
8
|
+
apiKey: config.apiKey,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async validateKey() {
|
|
13
|
+
try {
|
|
14
|
+
await this.client.models.list();
|
|
15
|
+
return true;
|
|
16
|
+
} catch (err) {
|
|
17
|
+
throw new Error(err.message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async listModels() {
|
|
22
|
+
const response = await this.client.models.list();
|
|
23
|
+
return response.data.map(m => m.id).sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async chat(messages, options = {}) {
|
|
27
|
+
this.abortController = new AbortController();
|
|
28
|
+
const stream = await this.client.chat.completions.create({
|
|
29
|
+
model: options.model || this.config.defaultModel,
|
|
30
|
+
messages,
|
|
31
|
+
stream: true,
|
|
32
|
+
max_tokens: options.maxTokens || (this.config.tokenBudget === 'unlimited' ? 4096 : this.config.tokenBudget),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
let full = '';
|
|
36
|
+
for await (const chunk of stream) {
|
|
37
|
+
const delta = chunk.choices?.[0]?.delta?.content || '';
|
|
38
|
+
if (delta && options.onToken) {
|
|
39
|
+
options.onToken(delta);
|
|
40
|
+
}
|
|
41
|
+
full += delta;
|
|
42
|
+
}
|
|
43
|
+
return full;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { Provider } from './providers.js';
|
|
3
|
+
|
|
4
|
+
export class OpenAIProvider extends Provider {
|
|
5
|
+
constructor(config) {
|
|
6
|
+
super(config);
|
|
7
|
+
this.client = new OpenAI({
|
|
8
|
+
apiKey: config.apiKey,
|
|
9
|
+
baseURL: config.baseUrl || undefined,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async validateKey() {
|
|
14
|
+
try {
|
|
15
|
+
await this.client.models.list();
|
|
16
|
+
return true;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
throw new Error(err.message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async listModels() {
|
|
23
|
+
const response = await this.client.models.list();
|
|
24
|
+
return response.data
|
|
25
|
+
.filter(m => m.id.startsWith('gpt') || m.id.startsWith('o1') || m.id.startsWith('o3'))
|
|
26
|
+
.map(m => m.id)
|
|
27
|
+
.sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async chat(messages, options = {}) {
|
|
31
|
+
this.abortController = new AbortController();
|
|
32
|
+
const stream = await this.client.chat.completions.create({
|
|
33
|
+
model: options.model || this.config.defaultModel,
|
|
34
|
+
messages,
|
|
35
|
+
stream: true,
|
|
36
|
+
max_tokens: options.maxTokens || this.config.tokenBudget === 'unlimited' ? 4096 : this.config.tokenBudget,
|
|
37
|
+
signal: this.abortController.signal
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let full = '';
|
|
41
|
+
for await (const chunk of stream) {
|
|
42
|
+
const delta = chunk.choices?.[0]?.delta?.content || '';
|
|
43
|
+
if (delta && options.onToken) {
|
|
44
|
+
options.onToken(delta);
|
|
45
|
+
}
|
|
46
|
+
full += delta;
|
|
47
|
+
}
|
|
48
|
+
return full;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { encode } from 'gpt-tokenizer';
|
|
2
|
+
|
|
3
|
+
export class Provider {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.abortController = null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async validateKey() {
|
|
10
|
+
throw new Error('validateKey() must be implemented by subclass');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async listModels() {
|
|
14
|
+
throw new Error('listModels() must be implemented by subclass');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async chat(messages, options) {
|
|
18
|
+
throw new Error('chat() must be implemented by subclass');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
abort() {
|
|
22
|
+
if (this.abortController) {
|
|
23
|
+
this.abortController.abort();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
countTokens(text) {
|
|
28
|
+
if (!text) return 0;
|
|
29
|
+
try {
|
|
30
|
+
return encode(text).length;
|
|
31
|
+
} catch {
|
|
32
|
+
return Math.ceil(text.length / 4);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Provider } from './providers.js';
|
|
2
|
+
|
|
3
|
+
export class TogetherProvider extends Provider {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
super(config);
|
|
6
|
+
this.baseUrl = 'https://api.together.xyz/v1';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async _fetch(path, options = {}) {
|
|
10
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
11
|
+
headers: {
|
|
12
|
+
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
},
|
|
15
|
+
...options
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
const err = await response.text();
|
|
19
|
+
throw new Error(err);
|
|
20
|
+
}
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async validateKey() {
|
|
25
|
+
try {
|
|
26
|
+
await this._fetch('/models');
|
|
27
|
+
return true;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(err.message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async listModels() {
|
|
34
|
+
const data = await this._fetch('/models');
|
|
35
|
+
return data.map(m => m.id || m.name).sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async chat(messages, options = {}) {
|
|
39
|
+
this.abortController = new AbortController();
|
|
40
|
+
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
model: options.model || this.config.defaultModel,
|
|
48
|
+
messages,
|
|
49
|
+
stream: true,
|
|
50
|
+
max_tokens: options.maxTokens || (this.config.tokenBudget === 'unlimited' ? 4096 : this.config.tokenBudget),
|
|
51
|
+
}),
|
|
52
|
+
signal: this.abortController.signal
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const err = await response.text();
|
|
57
|
+
throw new Error(err);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let full = '';
|
|
63
|
+
let buffer = '';
|
|
64
|
+
|
|
65
|
+
while (true) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done) break;
|
|
68
|
+
buffer += decoder.decode(value, { stream: true });
|
|
69
|
+
const lines = buffer.split('\n');
|
|
70
|
+
buffer = lines.pop() || '';
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
75
|
+
const data = trimmed.slice(6);
|
|
76
|
+
if (data === '[DONE]') continue;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(data);
|
|
79
|
+
const delta = parsed.choices?.[0]?.delta?.content || '';
|
|
80
|
+
if (delta && options.onToken) {
|
|
81
|
+
options.onToken(delta);
|
|
82
|
+
}
|
|
83
|
+
full += delta;
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return full;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lumina');
|
|
6
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
provider: null,
|
|
10
|
+
apiKey: null,
|
|
11
|
+
baseUrl: null,
|
|
12
|
+
defaultModel: null,
|
|
13
|
+
availableModels: [],
|
|
14
|
+
persona: 'general',
|
|
15
|
+
tokenBudget: 256,
|
|
16
|
+
firstRun: true,
|
|
17
|
+
version: '1.0.0'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getConfigPath() {
|
|
21
|
+
return CONFIG_PATH;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getConfigDir() {
|
|
25
|
+
return CONFIG_DIR;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadConfig() {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
31
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
32
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
return { ...DEFAULTS };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveConfig(config) {
|
|
39
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
40
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
43
|
+
try {
|
|
44
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function configExists() {
|
|
49
|
+
return fs.existsSync(CONFIG_PATH);
|
|
50
|
+
}
|