@nandansai08/personal-ai 0.8.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/.env.example +62 -0
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/bin/personal-ai.js +4 -0
- package/config/mcp.json +3 -0
- package/config/models.yaml +23 -0
- package/config/persona.yaml +24 -0
- package/config/profiles.yaml +61 -0
- package/config/providers.yaml +22 -0
- package/dist/bootstrap.js +41 -0
- package/dist/core/assistant.js +170 -0
- package/dist/core/context.js +35 -0
- package/dist/core/events.js +45 -0
- package/dist/core/logger.js +67 -0
- package/dist/core/model-manager.js +101 -0
- package/dist/index.js +98 -0
- package/dist/mcp/client.js +3 -0
- package/dist/mcp/loader.js +3 -0
- package/dist/memory/embeddings.js +53 -0
- package/dist/memory/intent.js +113 -0
- package/dist/memory/long-term.js +312 -0
- package/dist/memory/short-term.js +63 -0
- package/dist/memory/types.js +5 -0
- package/dist/memory/vector-store.js +57 -0
- package/dist/persona/loader.js +56 -0
- package/dist/persona/profiles.js +51 -0
- package/dist/persona/system-prompt.js +99 -0
- package/dist/persona/types.js +22 -0
- package/dist/plugins/interface.js +1 -0
- package/dist/plugins/loader.js +3 -0
- package/dist/providers/anthropic.js +112 -0
- package/dist/providers/factory.js +40 -0
- package/dist/providers/gemini.js +86 -0
- package/dist/providers/groq.js +14 -0
- package/dist/providers/interface.js +2 -0
- package/dist/providers/lmstudio.js +13 -0
- package/dist/providers/metadata.js +96 -0
- package/dist/providers/mistral.js +133 -0
- package/dist/providers/ollama.js +265 -0
- package/dist/providers/openai-compatible.js +110 -0
- package/dist/providers/openai.js +14 -0
- package/dist/providers/together.js +14 -0
- package/dist/providers/utils.js +57 -0
- package/dist/tools/calculator.js +44 -0
- package/dist/tools/file-reader.js +101 -0
- package/dist/tools/memory-tool.js +58 -0
- package/dist/tools/notes.js +121 -0
- package/dist/tools/parser.js +119 -0
- package/dist/tools/registry.js +88 -0
- package/dist/tools/tasks.js +134 -0
- package/dist/tools/types.js +3 -0
- package/dist/tools/web-search.js +108 -0
- package/dist/ui/cli-helpers.js +153 -0
- package/dist/ui/cli.js +647 -0
- package/dist/ui/setup.js +196 -0
- package/dist/ui/web/client/index.html +2081 -0
- package/dist/ui/web/server.js +310 -0
- package/dist/voice/stt.js +3 -0
- package/dist/voice/tts.js +3 -0
- package/dist/web.js +63 -0
- package/package.json +68 -0
package/dist/ui/cli.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// MIT License — personal-ai
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { logger } from '../core/logger.js';
|
|
8
|
+
import { eventBus } from '../core/events.js';
|
|
9
|
+
import { PROVIDER_META, inferProvider } from '../providers/metadata.js';
|
|
10
|
+
import { makeToolXmlStripper, patchEnvFile, friendlyError, createStreamRenderer } from './cli-helpers.js';
|
|
11
|
+
// Re-export for tests and external callers
|
|
12
|
+
export { inferProvider, makeToolXmlStripper, patchEnvFile, friendlyError, createStreamRenderer };
|
|
13
|
+
function modelEnvKeyFor(provider) {
|
|
14
|
+
return PROVIDER_META[provider]?.modelEnvKey;
|
|
15
|
+
}
|
|
16
|
+
const BANNER = `
|
|
17
|
+
${chalk.cyan('╔═══════════════════════════════════════╗')}
|
|
18
|
+
${chalk.cyan('║')} ${chalk.bold('PersonalAI')} ${chalk.dim('v0.7.0')} ${chalk.cyan('║')}
|
|
19
|
+
${chalk.cyan('║')} ${chalk.dim('Local-first. Any model.')} ${chalk.cyan('║')}
|
|
20
|
+
${chalk.cyan('╚═══════════════════════════════════════╝')}
|
|
21
|
+
`;
|
|
22
|
+
const HELP = `
|
|
23
|
+
${chalk.bold('Commands')}
|
|
24
|
+
${chalk.cyan('/exit')} Quit
|
|
25
|
+
${chalk.cyan('/clear')} Clear conversation history
|
|
26
|
+
${chalk.cyan('/models')} List available models
|
|
27
|
+
${chalk.cyan('/health')} Check provider health
|
|
28
|
+
${chalk.cyan('/logs')} Show log file path
|
|
29
|
+
${chalk.cyan('/model')} Show current model routing
|
|
30
|
+
${chalk.cyan('/model')} <name> Pin to a specific model
|
|
31
|
+
${chalk.cyan('/model auto')} Resume auto task-based routing
|
|
32
|
+
${chalk.cyan('/switch')} Show provider-switch instructions
|
|
33
|
+
${chalk.cyan('/switch')} <provider> Show env vars for a provider switch
|
|
34
|
+
${chalk.cyan('/memory')} Memory stats
|
|
35
|
+
${chalk.cyan('/memory list')} List recent memories
|
|
36
|
+
${chalk.cyan('/memory search')} <q> Search memories (keyword)
|
|
37
|
+
${chalk.cyan('/memory semantic')} <q> Semantic similarity search
|
|
38
|
+
${chalk.cyan('/memory rebuild-index')} Re-embed all memories
|
|
39
|
+
${chalk.cyan('/memory stats')} Stats incl. vector index
|
|
40
|
+
${chalk.cyan('/memory save')} <t> <c> Save memory (type: fact|preference|context|episodic)
|
|
41
|
+
${chalk.cyan('/profile')} Show active profile
|
|
42
|
+
${chalk.cyan('/profile list')} List all profiles
|
|
43
|
+
${chalk.cyan('/profile')} <name> Switch profile
|
|
44
|
+
${chalk.cyan('/coder')} Switch to coder profile
|
|
45
|
+
${chalk.cyan('/research')} Switch to researcher profile
|
|
46
|
+
${chalk.cyan('/tutor')} Switch to tutor profile
|
|
47
|
+
${chalk.cyan('/tools')} List registered tools
|
|
48
|
+
${chalk.cyan('/save')} [name] Save conversation to a named session
|
|
49
|
+
${chalk.cyan('/load')} [name] Restore a saved session (no name = list)
|
|
50
|
+
${chalk.cyan('/cost')} Show session token usage and estimated cost
|
|
51
|
+
${chalk.cyan('/web')} Start web UI server (default port 3000)
|
|
52
|
+
${chalk.cyan('/help')} Show this message
|
|
53
|
+
`;
|
|
54
|
+
function makePrompt(provider, profileManager, modelManager) {
|
|
55
|
+
const model = modelManager ? modelManager.getCurrentModel() : provider.model;
|
|
56
|
+
const profile = profileManager?.getActiveName();
|
|
57
|
+
const label = profile && profile !== 'assistant' ? `${model}|${profile}` : model;
|
|
58
|
+
return chalk.cyan(`[${label}] `) + chalk.bold('> ');
|
|
59
|
+
}
|
|
60
|
+
const TYPE_LABELS = {
|
|
61
|
+
personal: 'Personal', education: 'Education', career: 'Career',
|
|
62
|
+
project: 'Projects', preference: 'Preferences', fact: 'Facts',
|
|
63
|
+
context: 'Context', episodic: 'Episodic',
|
|
64
|
+
};
|
|
65
|
+
async function handleMemoryCmd(parts, memory) {
|
|
66
|
+
const sub = parts[1]?.toLowerCase();
|
|
67
|
+
if (!sub) {
|
|
68
|
+
const s = memory.getStats();
|
|
69
|
+
if (s.total === 0) {
|
|
70
|
+
console.log(chalk.dim('No memories yet. Say "remember …" to save one.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
console.log(chalk.bold(`\nMemory (${s.total} stored, avg importance ${s.avgImportance}):`));
|
|
74
|
+
for (const [type, label] of Object.entries(TYPE_LABELS)) {
|
|
75
|
+
const items = memory.getByType(type, 5);
|
|
76
|
+
if (!items.length)
|
|
77
|
+
continue;
|
|
78
|
+
console.log(chalk.cyan(`\n ${label}:`));
|
|
79
|
+
for (const m of items) {
|
|
80
|
+
console.log(` • ${m.content.slice(0, 90)}${m.content.length > 90 ? '…' : ''}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
console.log(chalk.dim('\n More: /memory list · /memory search <q> · /memory stats\n'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (sub === 'stats') {
|
|
87
|
+
const s = memory.getStats();
|
|
88
|
+
const idx = memory.getIndexStats();
|
|
89
|
+
console.log(chalk.bold('\nMemory stats:'));
|
|
90
|
+
console.log(` Total: ${s.total} Avg importance: ${s.avgImportance}`);
|
|
91
|
+
for (const [type, n] of Object.entries(s.byType)) {
|
|
92
|
+
if (n > 0)
|
|
93
|
+
console.log(` ${type.padEnd(12)} ${n}`);
|
|
94
|
+
}
|
|
95
|
+
if (s.mostAccessed)
|
|
96
|
+
console.log(` Most accessed: "${s.mostAccessed.content.slice(0, 60)}"`);
|
|
97
|
+
console.log(idx.embedder
|
|
98
|
+
? ` Semantic index: ${idx.indexed}/${s.total} embedded (${idx.embedder})`
|
|
99
|
+
: chalk.dim(' Semantic index: off — pull nomic-embed-text and run /memory rebuild-index'));
|
|
100
|
+
console.log();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (sub === 'semantic') {
|
|
104
|
+
const query = parts.slice(2).join(' ');
|
|
105
|
+
if (!query) {
|
|
106
|
+
console.log(chalk.yellow('Usage: /memory semantic <query>'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const results = await memory.searchSemantic(query, 10);
|
|
110
|
+
if (!results.length) {
|
|
111
|
+
console.log(chalk.dim('No matches.'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
console.log(chalk.bold(`\nSemantic matches for "${query}":`));
|
|
115
|
+
for (const m of results) {
|
|
116
|
+
console.log(` ${chalk.cyan(`[${m.type}]`)} ${m.content.slice(0, 90)}`);
|
|
117
|
+
}
|
|
118
|
+
console.log();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (sub === 'rebuild-index') {
|
|
122
|
+
process.stdout.write(chalk.dim(' Rebuilding vector index… '));
|
|
123
|
+
const n = await memory.rebuildIndex();
|
|
124
|
+
console.log(n > 0
|
|
125
|
+
? chalk.green(`✓ ${n} memories embedded`)
|
|
126
|
+
: chalk.yellow('0 embedded — is Ollama running with nomic-embed-text pulled? (ollama pull nomic-embed-text)'));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (sub === 'list') {
|
|
130
|
+
const recent = memory.getRecent(10);
|
|
131
|
+
if (!recent.length) {
|
|
132
|
+
console.log(chalk.dim('No memories yet.'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
console.log(chalk.bold('\nRecent memories:'));
|
|
136
|
+
for (const m of recent) {
|
|
137
|
+
console.log(` ${chalk.cyan(`[${m.type}]`)} ${chalk.dim(`imp:${m.importance}`)} ${m.content.slice(0, 80)}`);
|
|
138
|
+
}
|
|
139
|
+
console.log();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (sub === 'search') {
|
|
143
|
+
const query = parts.slice(2).join(' ');
|
|
144
|
+
if (!query) {
|
|
145
|
+
console.log(chalk.yellow('Usage: /memory search <query>'));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const results = memory.search(query, 10);
|
|
149
|
+
if (!results.length) {
|
|
150
|
+
console.log(chalk.dim(`No results for "${query}".`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(chalk.bold(`\nResults for "${query}":`));
|
|
154
|
+
for (const m of results) {
|
|
155
|
+
console.log(` ${chalk.cyan(`[${m.type}]`)} ${chalk.dim(`imp:${m.importance} acc:${m.access_count}`)} ${m.content.slice(0, 80)}`);
|
|
156
|
+
}
|
|
157
|
+
console.log();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (sub === 'save') {
|
|
161
|
+
const typeArg = parts[2];
|
|
162
|
+
const content = parts.slice(3).join(' ');
|
|
163
|
+
const valid = ['fact', 'preference', 'context', 'episodic'];
|
|
164
|
+
if (!typeArg || !valid.includes(typeArg) || !content) {
|
|
165
|
+
console.log(chalk.yellow('Usage: /memory save <fact|preference|context|episodic> <content>'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const saved = memory.save({ content, type: typeArg, importance: 7 });
|
|
169
|
+
console.log(chalk.green(`✓ Saved [${saved.type}]: ${saved.content.slice(0, 60)}`));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
console.log(chalk.yellow(`Unknown: /memory ${sub}. Try /help.`));
|
|
173
|
+
}
|
|
174
|
+
function handleProfileCmd(parts, profileManager) {
|
|
175
|
+
const sub = parts[1]?.toLowerCase();
|
|
176
|
+
if (!sub) {
|
|
177
|
+
const p = profileManager.getActive();
|
|
178
|
+
console.log(`Active: ${chalk.bold(p.name)} — ${p.description}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (sub === 'list') {
|
|
182
|
+
const all = profileManager.getAll();
|
|
183
|
+
console.log(chalk.bold('\nProfiles:'));
|
|
184
|
+
for (const [key, p] of Object.entries(all)) {
|
|
185
|
+
const active = key === profileManager.getActiveName() ? chalk.green(' ◀') : '';
|
|
186
|
+
console.log(` ${chalk.cyan(key)}${active} — ${p.description}`);
|
|
187
|
+
}
|
|
188
|
+
console.log();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
switchProfile(sub, profileManager);
|
|
192
|
+
}
|
|
193
|
+
/** Derive /switch env-var instructions from the central provider table. */
|
|
194
|
+
function switchHelpFor(key) {
|
|
195
|
+
const meta = PROVIDER_META[key];
|
|
196
|
+
if (!meta)
|
|
197
|
+
return undefined;
|
|
198
|
+
const lines = [`PROVIDER=${meta.key}`];
|
|
199
|
+
if (meta.key === 'ollama')
|
|
200
|
+
lines.push('OLLAMA_BASE_URL=http://localhost:11434');
|
|
201
|
+
if (meta.key === 'lmstudio')
|
|
202
|
+
lines.push('LMSTUDIO_BASE_URL=http://localhost:1234/v1');
|
|
203
|
+
if (meta.envKey)
|
|
204
|
+
lines.push(`${meta.envKey}=...`);
|
|
205
|
+
lines.push(`${meta.modelEnvKey}=${meta.defaultModel}`);
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
function handleSwitchCmd(parts) {
|
|
209
|
+
const provider = parts[1]?.toLowerCase();
|
|
210
|
+
const names = Object.keys(PROVIDER_META);
|
|
211
|
+
if (!provider) {
|
|
212
|
+
console.log(chalk.bold('\nProvider switching:'));
|
|
213
|
+
console.log(' Edit .env, set PROVIDER, then restart PersonalAI.');
|
|
214
|
+
console.log(` Providers: ${names.map(n => chalk.cyan(n)).join(', ')}`);
|
|
215
|
+
console.log(` Example: ${chalk.cyan('/switch ollama')}\n`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const lines = switchHelpFor(provider);
|
|
219
|
+
if (!lines) {
|
|
220
|
+
console.log(chalk.yellow(`Unknown provider "${provider}". Valid: ${names.join(', ')}`));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
console.log(chalk.bold(`\nSwitch to ${provider}:`));
|
|
224
|
+
for (const line of lines)
|
|
225
|
+
console.log(` ${line}`);
|
|
226
|
+
console.log(chalk.dim(' Restart PersonalAI after editing .env.\n'));
|
|
227
|
+
}
|
|
228
|
+
function switchProfile(name, profileManager) {
|
|
229
|
+
try {
|
|
230
|
+
profileManager.setActive(name);
|
|
231
|
+
const p = profileManager.getActive();
|
|
232
|
+
console.log(chalk.green(`✓ Switched to ${p.name} — ${p.description}`));
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
console.log(chalk.red(String(e)));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function handleModelCmd(parts, modelManager, engine, providerName, envPath, reloadProvider) {
|
|
239
|
+
const sub = parts[1]?.toLowerCase();
|
|
240
|
+
if (!sub) {
|
|
241
|
+
const stats = modelManager.getStats();
|
|
242
|
+
console.log(chalk.bold('\nModel routing:'));
|
|
243
|
+
console.log(` Current: ${chalk.cyan(stats.current)} (mode: ${stats.mode})`);
|
|
244
|
+
console.log(` Default: ${stats.config.default}`);
|
|
245
|
+
const tasks = stats.config.tasks;
|
|
246
|
+
for (const [task, model] of Object.entries(tasks)) {
|
|
247
|
+
if (model)
|
|
248
|
+
console.log(` ${task.padEnd(12)} → ${model}`);
|
|
249
|
+
}
|
|
250
|
+
console.log();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (sub === 'auto') {
|
|
254
|
+
modelManager.setAuto();
|
|
255
|
+
console.log(chalk.green('✓ Auto task-based routing enabled'));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const modelName = parts.slice(1).join(' ');
|
|
259
|
+
const targetProvider = inferProvider(modelName);
|
|
260
|
+
// Provider switch needed
|
|
261
|
+
if (targetProvider && targetProvider !== providerName) {
|
|
262
|
+
const modelKey = modelEnvKeyFor(targetProvider);
|
|
263
|
+
const isBareProvider = modelName.toLowerCase() === targetProvider;
|
|
264
|
+
// When user types bare provider name (e.g. /model ollama), keep existing model env var
|
|
265
|
+
const actualModel = isBareProvider
|
|
266
|
+
? (modelKey ? (process.env[modelKey] ?? modelName) : modelName)
|
|
267
|
+
: modelName;
|
|
268
|
+
const changes = { PROVIDER: targetProvider };
|
|
269
|
+
if (modelKey && !isBareProvider)
|
|
270
|
+
changes[modelKey] = modelName;
|
|
271
|
+
patchEnvFile(envPath, changes);
|
|
272
|
+
process.env['PROVIDER'] = targetProvider;
|
|
273
|
+
if (modelKey && !isBareProvider)
|
|
274
|
+
process.env[modelKey] = modelName;
|
|
275
|
+
process.stdout.write(chalk.dim(` Switching provider ${providerName} → ${targetProvider}…`));
|
|
276
|
+
try {
|
|
277
|
+
const newProvider = await reloadProvider();
|
|
278
|
+
engine.setProvider(newProvider);
|
|
279
|
+
modelManager.setModel(actualModel);
|
|
280
|
+
console.log(chalk.green(` ✓\n✓ Pinned to ${actualModel} (${targetProvider})`));
|
|
281
|
+
console.log(chalk.dim(` .env updated: PROVIDER=${targetProvider}`));
|
|
282
|
+
return newProvider;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
console.log(chalk.red(` ✗\nFailed to switch: ${String(err)}`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
modelManager.setModel(modelName);
|
|
290
|
+
console.log(chalk.green(`✓ Pinned to ${modelManager.getCurrentModel()}`));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.personal-ai', 'sessions');
|
|
294
|
+
function sessionPath(name) {
|
|
295
|
+
const safe = name.replace(/[^\w-]/g, '_');
|
|
296
|
+
return path.join(SESSIONS_DIR, `${safe}.json`);
|
|
297
|
+
}
|
|
298
|
+
function handleSaveCmd(parts, context) {
|
|
299
|
+
if (context.messageCount === 0) {
|
|
300
|
+
console.log(chalk.yellow('Nothing to save yet.'));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const name = parts[1] ?? `session-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}`;
|
|
304
|
+
try {
|
|
305
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
306
|
+
fs.writeFileSync(sessionPath(name), JSON.stringify({
|
|
307
|
+
messages: context.getMessages(),
|
|
308
|
+
savedAt: new Date().toISOString(),
|
|
309
|
+
}, null, 2));
|
|
310
|
+
console.log(chalk.green(`✓ Saved ${context.messageCount} messages as "${name}"`));
|
|
311
|
+
console.log(chalk.dim(` Restore with: /load ${name}`));
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
console.log(chalk.red(`Save failed: ${String(e)}`));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function handleLoadCmd(parts, context) {
|
|
318
|
+
const name = parts[1];
|
|
319
|
+
if (!name) {
|
|
320
|
+
// List available sessions
|
|
321
|
+
try {
|
|
322
|
+
const files = fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'));
|
|
323
|
+
if (!files.length) {
|
|
324
|
+
console.log(chalk.dim('No saved sessions. Save with /save [name].'));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
console.log(chalk.bold('\nSaved sessions:'));
|
|
328
|
+
for (const f of files)
|
|
329
|
+
console.log(` ${chalk.cyan(f.replace(/\.json$/, ''))}`);
|
|
330
|
+
console.log(chalk.dim('\n Load with: /load <name>\n'));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
console.log(chalk.dim('No saved sessions.'));
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const raw = JSON.parse(fs.readFileSync(sessionPath(name), 'utf8'));
|
|
339
|
+
if (!Array.isArray(raw.messages)) {
|
|
340
|
+
console.log(chalk.red('Invalid session file.'));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
context.restore(raw.messages);
|
|
344
|
+
console.log(chalk.green(`✓ Restored "${name}" (${context.messageCount} messages)`));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
console.log(chalk.red(`Session "${name}" not found. Run /load to list.`));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async function handleCommand(parts, provider, context, memory, profileManager, registry, modelManager, startWeb, getCost) {
|
|
351
|
+
const cmd = parts[0]?.toLowerCase();
|
|
352
|
+
switch (cmd) {
|
|
353
|
+
case '/exit':
|
|
354
|
+
console.log(chalk.dim('Goodbye.'));
|
|
355
|
+
process.exit(0);
|
|
356
|
+
break;
|
|
357
|
+
case '/clear':
|
|
358
|
+
context.clear();
|
|
359
|
+
console.log(chalk.dim('Conversation cleared.'));
|
|
360
|
+
break;
|
|
361
|
+
case '/models':
|
|
362
|
+
if (provider.listModels) {
|
|
363
|
+
const models = await provider.listModels();
|
|
364
|
+
console.log(chalk.bold('\nAvailable models:'));
|
|
365
|
+
for (const m of models) {
|
|
366
|
+
console.log(` ${m.name}${m.supportsTools ? chalk.green(' [tools]') : ''}`);
|
|
367
|
+
}
|
|
368
|
+
console.log();
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
console.log(chalk.dim(`Current model: ${provider.model}`));
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
case '/switch':
|
|
375
|
+
handleSwitchCmd(parts);
|
|
376
|
+
break;
|
|
377
|
+
case '/health':
|
|
378
|
+
if (provider.healthCheck) {
|
|
379
|
+
const h = await provider.healthCheck();
|
|
380
|
+
console.log(h.ok
|
|
381
|
+
? chalk.green(`✓ OK — ${h.model} (${h.latencyMs}ms)`)
|
|
382
|
+
: chalk.red(`✗ ${h.error ?? 'unhealthy'}`));
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
case '/cost':
|
|
386
|
+
console.log(getCost ? getCost() : chalk.yellow('No session data yet.'));
|
|
387
|
+
break;
|
|
388
|
+
case '/logs': {
|
|
389
|
+
const logPath = logger.getLogPath();
|
|
390
|
+
console.log(chalk.dim(logPath));
|
|
391
|
+
try {
|
|
392
|
+
const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean);
|
|
393
|
+
const errors = lines.filter(l => /error/i.test(l)).slice(-5);
|
|
394
|
+
if (errors.length) {
|
|
395
|
+
console.log(chalk.dim('\nRecent errors:'));
|
|
396
|
+
for (const l of errors)
|
|
397
|
+
console.log(chalk.red(` ${l.slice(0, 120)}`));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch { /* log file may not exist yet */ }
|
|
401
|
+
console.log();
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case '/memory':
|
|
405
|
+
if (!memory) {
|
|
406
|
+
console.log(chalk.yellow('Memory not enabled.'));
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
await handleMemoryCmd(parts, memory);
|
|
410
|
+
break;
|
|
411
|
+
case '/profile':
|
|
412
|
+
if (!profileManager) {
|
|
413
|
+
console.log(chalk.yellow('Profiles not loaded.'));
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
handleProfileCmd(parts, profileManager);
|
|
417
|
+
break;
|
|
418
|
+
case '/coder':
|
|
419
|
+
if (profileManager)
|
|
420
|
+
switchProfile('coder', profileManager);
|
|
421
|
+
break;
|
|
422
|
+
case '/research':
|
|
423
|
+
if (profileManager)
|
|
424
|
+
switchProfile('researcher', profileManager);
|
|
425
|
+
break;
|
|
426
|
+
case '/tutor':
|
|
427
|
+
if (profileManager)
|
|
428
|
+
switchProfile('tutor', profileManager);
|
|
429
|
+
break;
|
|
430
|
+
case '/tools':
|
|
431
|
+
if (!registry || registry.count() === 0) {
|
|
432
|
+
console.log(chalk.dim('No tools registered.'));
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
console.log(chalk.bold(`\nRegistered tools (${registry.count()}):`));
|
|
436
|
+
for (const t of registry.getAll()) {
|
|
437
|
+
console.log(` ${chalk.cyan(t.definition.name)} — ${t.definition.description}`);
|
|
438
|
+
}
|
|
439
|
+
console.log();
|
|
440
|
+
break;
|
|
441
|
+
case '/web':
|
|
442
|
+
if (!startWeb) {
|
|
443
|
+
console.log(chalk.yellow('Web UI not configured.'));
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const url = await startWeb();
|
|
448
|
+
console.log(chalk.green(`✓ Web UI running at ${chalk.bold(url)}`));
|
|
449
|
+
console.log(chalk.dim(` Open in browser: ${url}`));
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
console.log(chalk.red(`Failed to start web: ${String(e)}`));
|
|
453
|
+
}
|
|
454
|
+
break;
|
|
455
|
+
case '/save':
|
|
456
|
+
handleSaveCmd(parts, context);
|
|
457
|
+
break;
|
|
458
|
+
case '/load':
|
|
459
|
+
case '/sessions':
|
|
460
|
+
handleLoadCmd(parts, context);
|
|
461
|
+
break;
|
|
462
|
+
case '/help':
|
|
463
|
+
console.log(HELP);
|
|
464
|
+
break;
|
|
465
|
+
default:
|
|
466
|
+
console.log(chalk.yellow(`Unknown command: ${cmd}. Type /help.`));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
export async function startCLI(provider, engine, context, memory, profileManager, registry, modelManager, startWeb, reloadProvider, envPath) {
|
|
470
|
+
let activeProvider = provider;
|
|
471
|
+
console.log(BANNER);
|
|
472
|
+
if (activeProvider.healthCheck) {
|
|
473
|
+
process.stdout.write(chalk.dim('Connecting to provider…'));
|
|
474
|
+
const health = await activeProvider.healthCheck();
|
|
475
|
+
if (health.ok) {
|
|
476
|
+
console.log(`\r${chalk.green('✓')} ${activeProvider.name} / ${chalk.bold(health.model)} ${chalk.dim(`(${health.latencyMs}ms)`)}\n`);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
console.log(`\r${chalk.red('✗')} ${activeProvider.name} unreachable: ${health.error ?? 'unknown'}`);
|
|
480
|
+
console.log(chalk.yellow(' Start Ollama or set PROVIDER env var.\n'));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (memory) {
|
|
484
|
+
const stats = memory.getStats();
|
|
485
|
+
console.log(chalk.dim(` Memory: ${stats.total} stored`));
|
|
486
|
+
}
|
|
487
|
+
if (registry && registry.count() > 0) {
|
|
488
|
+
console.log(chalk.dim(` Tools: ${registry.count()} registered`));
|
|
489
|
+
}
|
|
490
|
+
if (profileManager) {
|
|
491
|
+
const p = profileManager.getActive();
|
|
492
|
+
console.log(chalk.dim(` Profile: ${p.name} — ${p.description}\n`));
|
|
493
|
+
}
|
|
494
|
+
// ── session token tracking ──────────────────────────────────────────
|
|
495
|
+
const sessTokens = { input: 0, output: 0 };
|
|
496
|
+
const PRICE = {
|
|
497
|
+
anthropic: [3, 15],
|
|
498
|
+
openai: [5, 15],
|
|
499
|
+
groq: [0.59, 0.79],
|
|
500
|
+
};
|
|
501
|
+
const getCost = () => {
|
|
502
|
+
const inK = sessTokens.input;
|
|
503
|
+
const outK = sessTokens.output;
|
|
504
|
+
if (inK === 0 && outK === 0)
|
|
505
|
+
return chalk.dim('No tokens used this session.');
|
|
506
|
+
const prices = PRICE[activeProvider.name];
|
|
507
|
+
if (!prices) {
|
|
508
|
+
return chalk.cyan(`Session: ${inK.toLocaleString()} in / ${outK.toLocaleString()} out | `) + chalk.green('FREE (local)');
|
|
509
|
+
}
|
|
510
|
+
const est = (inK / 1_000_000) * prices[0] + (outK / 1_000_000) * prices[1];
|
|
511
|
+
return chalk.cyan(`Session: ${inK.toLocaleString()} in / ${outK.toLocaleString()} out | `) +
|
|
512
|
+
chalk.yellow(`Est. $${est.toFixed(4)}`);
|
|
513
|
+
};
|
|
514
|
+
const saveSession = () => {
|
|
515
|
+
const sessDir = path.join(os.homedir(), '.personal-ai', 'sessions');
|
|
516
|
+
try {
|
|
517
|
+
fs.mkdirSync(sessDir, { recursive: true });
|
|
518
|
+
fs.writeFileSync(path.join(sessDir, `session-${Date.now()}.json`), JSON.stringify({ messages: context.getMessages(), savedAt: new Date().toISOString() }, null, 2));
|
|
519
|
+
// keep max 10 sessions
|
|
520
|
+
const files = fs.readdirSync(sessDir)
|
|
521
|
+
.filter(f => f.startsWith('session-'))
|
|
522
|
+
.sort();
|
|
523
|
+
for (const old of files.slice(0, Math.max(0, files.length - 10))) {
|
|
524
|
+
fs.unlinkSync(path.join(sessDir, old));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch { /* non-critical */ }
|
|
528
|
+
};
|
|
529
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
530
|
+
const refreshPrompt = () => {
|
|
531
|
+
rl.setPrompt(makePrompt(activeProvider, profileManager, modelManager));
|
|
532
|
+
rl.prompt();
|
|
533
|
+
};
|
|
534
|
+
// Memory visibility: show a dim note whenever a memory is auto-saved.
|
|
535
|
+
// Queued while a response is streaming — printing mid-stream splits output.
|
|
536
|
+
const pendingNotices = [];
|
|
537
|
+
eventBus.on('memory_saved', ({ type, importance }) => {
|
|
538
|
+
pendingNotices.push(chalk.dim(` 💾 memory saved (${type}, importance ${importance}) — review with /memory list`));
|
|
539
|
+
});
|
|
540
|
+
const flushNotices = () => {
|
|
541
|
+
for (const n of pendingNotices.splice(0))
|
|
542
|
+
console.log(n);
|
|
543
|
+
};
|
|
544
|
+
// Security: dangerous tools (file_reader) need explicit per-call approval
|
|
545
|
+
if (registry) {
|
|
546
|
+
registry.setConfirmHandler(async (name, args) => {
|
|
547
|
+
const argStr = JSON.stringify(args);
|
|
548
|
+
const preview = argStr.length > 120 ? argStr.slice(0, 120) + '…' : argStr;
|
|
549
|
+
const answer = await new Promise(resolve => rl.question(chalk.yellow(`\n ⚠ Allow ${name}(${preview})? [y/N] `), resolve));
|
|
550
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
refreshPrompt();
|
|
554
|
+
let busy = false;
|
|
555
|
+
rl.on('line', async (line) => {
|
|
556
|
+
if (busy)
|
|
557
|
+
return;
|
|
558
|
+
const input = line.trim();
|
|
559
|
+
if (!input) {
|
|
560
|
+
refreshPrompt();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (input === '/exit') {
|
|
564
|
+
const memCount = memory ? memory.getStats().total : 0;
|
|
565
|
+
console.log(chalk.dim(`\nSession memories stored: ${memCount}`));
|
|
566
|
+
console.log(getCost());
|
|
567
|
+
saveSession();
|
|
568
|
+
console.log(chalk.dim('\nGoodbye.'));
|
|
569
|
+
rl.close();
|
|
570
|
+
process.exit(0);
|
|
571
|
+
}
|
|
572
|
+
if (input.startsWith('/model') && modelManager && reloadProvider && envPath) {
|
|
573
|
+
const newProvider = await handleModelCmd(input.split(' '), modelManager, engine, activeProvider.name, envPath, reloadProvider);
|
|
574
|
+
if (newProvider)
|
|
575
|
+
activeProvider = newProvider;
|
|
576
|
+
refreshPrompt();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (input.startsWith('/')) {
|
|
580
|
+
await handleCommand(input.split(' '), activeProvider, context, memory, profileManager, registry, modelManager, startWeb, getCost);
|
|
581
|
+
refreshPrompt();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
busy = true;
|
|
585
|
+
rl.pause();
|
|
586
|
+
// Spinner while waiting for first token
|
|
587
|
+
const frames = ['⠋', '⠙', '⠸', '⠴', '⠦', '⠇'];
|
|
588
|
+
let frame = 0;
|
|
589
|
+
process.stdout.write(chalk.dim('\nAssistant: '));
|
|
590
|
+
const spinner = setInterval(() => {
|
|
591
|
+
process.stdout.write(`\r${chalk.dim('Assistant: ')}${chalk.dim(frames[frame++ % frames.length])}`);
|
|
592
|
+
}, 100);
|
|
593
|
+
let firstToken = true;
|
|
594
|
+
const clearSpinner = () => {
|
|
595
|
+
if (firstToken) {
|
|
596
|
+
clearInterval(spinner);
|
|
597
|
+
process.stdout.write(`\r${chalk.dim('Assistant: ')}`);
|
|
598
|
+
firstToken = false;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
try {
|
|
602
|
+
const renderer = createStreamRenderer(s => process.stdout.write(s));
|
|
603
|
+
for await (const chunk of engine.chat(input)) {
|
|
604
|
+
if (chunk.type !== 'done')
|
|
605
|
+
clearSpinner();
|
|
606
|
+
if (chunk.type === 'text') {
|
|
607
|
+
renderer.text(chunk.delta);
|
|
608
|
+
}
|
|
609
|
+
else if (chunk.type === 'tool_call') {
|
|
610
|
+
renderer.toolCall(chunk.name);
|
|
611
|
+
}
|
|
612
|
+
else if (chunk.type === 'tool_result') {
|
|
613
|
+
renderer.toolResult();
|
|
614
|
+
}
|
|
615
|
+
else if (chunk.type === 'model_switch') {
|
|
616
|
+
// No refreshPrompt() here — redrawing the prompt mid-stream
|
|
617
|
+
// injects prompt text into the streamed response
|
|
618
|
+
renderer.modelSwitch(chunk.from, chunk.to);
|
|
619
|
+
}
|
|
620
|
+
else if (chunk.type === 'error') {
|
|
621
|
+
renderer.error(friendlyError(chunk.message, activeProvider.name));
|
|
622
|
+
}
|
|
623
|
+
else if (chunk.type === 'done' && chunk.usage) {
|
|
624
|
+
clearSpinner();
|
|
625
|
+
sessTokens.input += chunk.usage.input;
|
|
626
|
+
sessTokens.output += chunk.usage.output;
|
|
627
|
+
renderer.usage(chunk.usage.input, chunk.usage.output);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
clearSpinner();
|
|
631
|
+
renderer.finish();
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
clearSpinner();
|
|
635
|
+
logger.error('cli', 'chat error', err);
|
|
636
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
637
|
+
console.error(chalk.red(`\n${friendlyError(msg, activeProvider.name)}`));
|
|
638
|
+
console.error(chalk.dim(' Run /logs for details.'));
|
|
639
|
+
}
|
|
640
|
+
console.log();
|
|
641
|
+
flushNotices();
|
|
642
|
+
busy = false;
|
|
643
|
+
rl.resume();
|
|
644
|
+
refreshPrompt();
|
|
645
|
+
});
|
|
646
|
+
rl.on('close', () => { console.log(chalk.dim('\nSession ended.')); });
|
|
647
|
+
}
|