@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.
Files changed (61) hide show
  1. package/.env.example +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/bin/personal-ai.js +4 -0
  5. package/config/mcp.json +3 -0
  6. package/config/models.yaml +23 -0
  7. package/config/persona.yaml +24 -0
  8. package/config/profiles.yaml +61 -0
  9. package/config/providers.yaml +22 -0
  10. package/dist/bootstrap.js +41 -0
  11. package/dist/core/assistant.js +170 -0
  12. package/dist/core/context.js +35 -0
  13. package/dist/core/events.js +45 -0
  14. package/dist/core/logger.js +67 -0
  15. package/dist/core/model-manager.js +101 -0
  16. package/dist/index.js +98 -0
  17. package/dist/mcp/client.js +3 -0
  18. package/dist/mcp/loader.js +3 -0
  19. package/dist/memory/embeddings.js +53 -0
  20. package/dist/memory/intent.js +113 -0
  21. package/dist/memory/long-term.js +312 -0
  22. package/dist/memory/short-term.js +63 -0
  23. package/dist/memory/types.js +5 -0
  24. package/dist/memory/vector-store.js +57 -0
  25. package/dist/persona/loader.js +56 -0
  26. package/dist/persona/profiles.js +51 -0
  27. package/dist/persona/system-prompt.js +99 -0
  28. package/dist/persona/types.js +22 -0
  29. package/dist/plugins/interface.js +1 -0
  30. package/dist/plugins/loader.js +3 -0
  31. package/dist/providers/anthropic.js +112 -0
  32. package/dist/providers/factory.js +40 -0
  33. package/dist/providers/gemini.js +86 -0
  34. package/dist/providers/groq.js +14 -0
  35. package/dist/providers/interface.js +2 -0
  36. package/dist/providers/lmstudio.js +13 -0
  37. package/dist/providers/metadata.js +96 -0
  38. package/dist/providers/mistral.js +133 -0
  39. package/dist/providers/ollama.js +265 -0
  40. package/dist/providers/openai-compatible.js +110 -0
  41. package/dist/providers/openai.js +14 -0
  42. package/dist/providers/together.js +14 -0
  43. package/dist/providers/utils.js +57 -0
  44. package/dist/tools/calculator.js +44 -0
  45. package/dist/tools/file-reader.js +101 -0
  46. package/dist/tools/memory-tool.js +58 -0
  47. package/dist/tools/notes.js +121 -0
  48. package/dist/tools/parser.js +119 -0
  49. package/dist/tools/registry.js +88 -0
  50. package/dist/tools/tasks.js +134 -0
  51. package/dist/tools/types.js +3 -0
  52. package/dist/tools/web-search.js +108 -0
  53. package/dist/ui/cli-helpers.js +153 -0
  54. package/dist/ui/cli.js +647 -0
  55. package/dist/ui/setup.js +196 -0
  56. package/dist/ui/web/client/index.html +2081 -0
  57. package/dist/ui/web/server.js +310 -0
  58. package/dist/voice/stt.js +3 -0
  59. package/dist/voice/tts.js +3 -0
  60. package/dist/web.js +63 -0
  61. 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
+ }