@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
@@ -0,0 +1,310 @@
1
+ // MIT License — personal-ai
2
+ import http from 'node:http';
3
+ import net from 'node:net';
4
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import fs from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import express from 'express';
10
+ import cors from 'cors';
11
+ import { WebSocketServer, WebSocket } from 'ws';
12
+ import { AssistantEngine } from '../../core/assistant.js';
13
+ import { ConversationContext } from '../../core/context.js';
14
+ import { buildSystemPrompt, isGemma3Model } from '../../persona/system-prompt.js';
15
+ import { loadPersona } from '../../persona/loader.js';
16
+ import { logger } from '../../core/logger.js';
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const CLIENT_DIR = path.join(__dirname, 'client');
19
+ function findFreePort(start) {
20
+ return new Promise(resolve => {
21
+ const srv = net.createServer();
22
+ srv.unref();
23
+ srv.on('error', () => resolve(findFreePort(start + 1)));
24
+ srv.listen(start, () => { srv.close(() => resolve(start)); });
25
+ });
26
+ }
27
+ export async function createWebServer(opts) {
28
+ const { provider, memory, profileManager, registry, modelManager, personaPath } = opts;
29
+ const preferred = opts.port ?? parseInt(process.env['PORT'] ?? '3000', 10);
30
+ const PORT = await findFreePort(preferred);
31
+ // Security: per-session bearer token. Required on every /api request and WS
32
+ // upgrade. The launch URL carries it once (?token=…); the client stores it.
33
+ // Override with WEB_AUTH_TOKEN for a stable token across restarts.
34
+ const TOKEN = process.env['WEB_AUTH_TOKEN'] ?? randomBytes(16).toString('hex');
35
+ const app = express();
36
+ // Security: validate Host header to block DNS-rebinding attacks.
37
+ // The server binds to 127.0.0.1 only; this guards against a malicious domain
38
+ // resolving to 127.0.0.1 and bypassing the same-origin policy.
39
+ const isLocalHost = (host) => {
40
+ if (!host)
41
+ return false;
42
+ const name = host.split(':')[0] ?? '';
43
+ return name === 'localhost' || name === '127.0.0.1' || name === '[::1]';
44
+ };
45
+ app.use((req, res, next) => {
46
+ if (!isLocalHost(req.headers.host)) {
47
+ res.status(403).json({ error: 'forbidden: invalid host' });
48
+ return;
49
+ }
50
+ next();
51
+ });
52
+ const tokenOk = (candidate) => {
53
+ if (!candidate)
54
+ return false;
55
+ const a = Buffer.from(candidate);
56
+ const b = Buffer.from(TOKEN);
57
+ return a.length === b.length && timingSafeEqual(a, b);
58
+ };
59
+ // Security: every /api request must carry the session token
60
+ // (Authorization: Bearer <token> or ?token=<token>). Static files are
61
+ // exempt — the client page itself reads the token from the launch URL.
62
+ app.use('/api', (req, res, next) => {
63
+ const header = req.headers.authorization;
64
+ const bearer = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
65
+ const query = typeof req.query['token'] === 'string' ? req.query['token'] : undefined;
66
+ if (!tokenOk(bearer ?? query)) {
67
+ res.status(401).json({ error: 'unauthorized: missing or invalid token' });
68
+ return;
69
+ }
70
+ next();
71
+ });
72
+ app.use(cors({ origin: [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`] }));
73
+ app.use(express.json({ limit: '256kb' }));
74
+ app.use(express.static(CLIENT_DIR));
75
+ // ── REST endpoints ──────────────────────────────────────────────────
76
+ app.get('/api/health', (_req, res) => {
77
+ void (async () => {
78
+ if (provider.healthCheck) {
79
+ const h = await provider.healthCheck();
80
+ res.json(h);
81
+ }
82
+ else {
83
+ res.json({ ok: true, latencyMs: 0, model: provider.model });
84
+ }
85
+ })();
86
+ });
87
+ app.get('/api/provider', (_req, res) => {
88
+ res.json({
89
+ name: provider.name,
90
+ model: modelManager ? modelManager.getCurrentModel() : provider.model,
91
+ supportsToolUse: provider.supportsToolUse,
92
+ });
93
+ });
94
+ app.get('/api/memories', (req, res) => {
95
+ if (!memory) {
96
+ res.json([]);
97
+ return;
98
+ }
99
+ const q = req.query['q'];
100
+ const results = q ? memory.search(q, 20) : memory.getRecent(20);
101
+ res.json(results);
102
+ });
103
+ app.post('/api/memories', (req, res) => {
104
+ if (!memory) {
105
+ res.status(503).json({ error: 'memory not enabled' });
106
+ return;
107
+ }
108
+ const body = req.body;
109
+ if (!body.content || !body.type) {
110
+ res.status(400).json({ error: 'content and type required' });
111
+ return;
112
+ }
113
+ const saved = memory.save(body);
114
+ res.status(201).json(saved);
115
+ });
116
+ app.delete('/api/memories/:id', (req, res) => {
117
+ if (!memory) {
118
+ res.status(503).json({ error: 'memory not enabled' });
119
+ return;
120
+ }
121
+ memory.archive(req.params['id']);
122
+ res.json({ archived: true });
123
+ });
124
+ app.get('/api/tasks', (_req, res) => {
125
+ if (!registry) {
126
+ res.json([]);
127
+ return;
128
+ }
129
+ const taskTool = registry.getAll().find(t => t.definition.name === 'tasks');
130
+ if (!taskTool) {
131
+ res.json([]);
132
+ return;
133
+ }
134
+ void taskTool.execute({ action: 'list', filter: 'all' })
135
+ .then(r => res.json(r.data ?? []))
136
+ .catch(() => res.json([]));
137
+ });
138
+ app.post('/api/tasks', (req, res) => {
139
+ if (!registry) {
140
+ res.status(503).json({ error: 'registry not available' });
141
+ return;
142
+ }
143
+ const taskTool = registry.getAll().find(t => t.definition.name === 'tasks');
144
+ if (!taskTool) {
145
+ res.status(503).json({ error: 'tasks tool not registered' });
146
+ return;
147
+ }
148
+ void taskTool.execute({ action: 'create', ...req.body })
149
+ .then(r => r.success ? res.status(201).json(r.data) : res.status(400).json({ error: r.error }))
150
+ .catch(e => res.status(500).json({ error: String(e) }));
151
+ });
152
+ app.patch('/api/tasks/:id', (req, res) => {
153
+ if (!registry) {
154
+ res.status(503).json({ error: 'registry not available' });
155
+ return;
156
+ }
157
+ const taskTool = registry.getAll().find(t => t.definition.name === 'tasks');
158
+ if (!taskTool) {
159
+ res.status(503).json({ error: 'tasks tool not registered' });
160
+ return;
161
+ }
162
+ void taskTool.execute({ action: 'update', id: req.params['id'], ...req.body })
163
+ .then(r => r.success ? res.json(r.data) : res.status(400).json({ error: r.error }))
164
+ .catch(e => res.status(500).json({ error: String(e) }));
165
+ });
166
+ app.get('/api/profile', (_req, res) => {
167
+ res.json({
168
+ name: profileManager.getActiveName(),
169
+ config: profileManager.getActive(),
170
+ all: profileManager.getAll(),
171
+ });
172
+ });
173
+ app.put('/api/profile', (req, res) => {
174
+ const { name } = req.body;
175
+ if (!name) {
176
+ res.status(400).json({ error: 'name required' });
177
+ return;
178
+ }
179
+ try {
180
+ profileManager.setActive(name);
181
+ res.json({ name, config: profileManager.getActive() });
182
+ }
183
+ catch (e) {
184
+ res.status(400).json({ error: String(e) });
185
+ }
186
+ });
187
+ app.get('/api/stats', (_req, res) => {
188
+ res.json({
189
+ memories: memory ? memory.getStats() : null,
190
+ model: modelManager
191
+ ? modelManager.getStats()
192
+ : { current: provider.model, mode: 'manual', config: {} },
193
+ tools: registry
194
+ ? registry.getAll().map(t => ({ name: t.definition.name, description: t.definition.description }))
195
+ : [],
196
+ });
197
+ });
198
+ app.get('/api/system', (_req, res) => {
199
+ const totalMem = os.totalmem();
200
+ const freeMem = os.freemem();
201
+ const usedMem = totalMem - freeMem;
202
+ const cpus = os.cpus();
203
+ const loadAvg1m = os.loadavg()[0] ?? 0;
204
+ res.json({
205
+ totalMemMb: Math.round(totalMem / 1024 / 1024),
206
+ freeMemMb: Math.round(freeMem / 1024 / 1024),
207
+ usedMemMb: Math.round(usedMem / 1024 / 1024),
208
+ usedMemPct: Math.round((usedMem / totalMem) * 100),
209
+ cpuModel: cpus[0]?.model ?? 'Unknown',
210
+ cpuCount: cpus.length,
211
+ loadAvg1m: Math.round(loadAvg1m * 100) / 100,
212
+ platform: os.platform(),
213
+ arch: os.arch(),
214
+ });
215
+ });
216
+ app.get('/api/ollama/ps', (_req, res) => {
217
+ const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? 'http://localhost:11434';
218
+ void fetch(`${ollamaUrl}/api/ps`)
219
+ .then(r => r.json())
220
+ .then(data => res.json(data))
221
+ .catch(() => res.json({ models: [] }));
222
+ });
223
+ // ── WebSocket chat ──────────────────────────────────────────────────
224
+ const server = http.createServer(app);
225
+ const wss = new WebSocketServer({ server, path: '/api/chat' });
226
+ wss.on('connection', (ws, req) => {
227
+ // Security: reject cross-site WebSocket connections. Browsers send the
228
+ // page's Origin on WS upgrade — any non-localhost origin means a foreign
229
+ // website is trying to hijack the local assistant (and its tools).
230
+ const origin = req.headers.origin;
231
+ const host = req.headers.host;
232
+ const originOk = !origin || /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin);
233
+ const wsToken = new URL(req.url ?? '', `http://${host ?? 'localhost'}`).searchParams.get('token') ?? undefined;
234
+ if (!originOk || !isLocalHost(host) || !tokenOk(wsToken)) {
235
+ logger.warn('web', `rejected WS connection (origin=${origin ?? 'none'}, host=${host ?? 'none'}, token=${wsToken ? 'bad' : 'missing'})`);
236
+ ws.close(1008, 'forbidden');
237
+ return;
238
+ }
239
+ logger.debug('web', 'WS client connected');
240
+ const context = new ConversationContext();
241
+ let currentPersona = loadPersona(personaPath);
242
+ const makeSystemPrompt = (memories, toolsSection) => buildSystemPrompt(currentPersona, profileManager.getActive(), memories, toolsSection, new Date(), isGemma3Model(modelManager ? modelManager.getCurrentModel() : provider.model));
243
+ const engine = new AssistantEngine({
244
+ provider,
245
+ getSystemPrompt: makeSystemPrompt,
246
+ memory,
247
+ registry,
248
+ profileManager,
249
+ context,
250
+ modelManager,
251
+ });
252
+ const send = (data) => {
253
+ if (ws.readyState === WebSocket.OPEN)
254
+ ws.send(JSON.stringify(data));
255
+ };
256
+ ws.on('message', (raw) => {
257
+ void (async () => {
258
+ let msg;
259
+ try {
260
+ msg = JSON.parse(raw.toString());
261
+ }
262
+ catch {
263
+ send({ type: 'error', message: 'invalid JSON' });
264
+ return;
265
+ }
266
+ if (msg.type === 'profile' && msg.name) {
267
+ try {
268
+ profileManager.setActive(msg.name);
269
+ currentPersona = loadPersona(personaPath);
270
+ send({ type: 'profile_changed', name: msg.name });
271
+ }
272
+ catch (e) {
273
+ send({ type: 'error', message: String(e) });
274
+ }
275
+ return;
276
+ }
277
+ if (msg.type === 'chat' && msg.content) {
278
+ for await (const chunk of engine.chat(msg.content)) {
279
+ send(chunk);
280
+ }
281
+ return;
282
+ }
283
+ send({ type: 'error', message: `unknown message type: ${msg.type}` });
284
+ })();
285
+ });
286
+ ws.on('close', () => {
287
+ logger.debug('web', `WS disconnected (${context.messageCount} messages)`);
288
+ const sessDir = path.join(process.env['HOME'] ?? process.env['USERPROFILE'] ?? '', '.personal-ai', 'sessions');
289
+ try {
290
+ fs.mkdirSync(sessDir, { recursive: true });
291
+ const file = path.join(sessDir, `session-${Date.now()}.json`);
292
+ fs.writeFileSync(file, JSON.stringify({
293
+ messages: context.getMessages(),
294
+ savedAt: new Date().toISOString(),
295
+ }, null, 2));
296
+ }
297
+ catch { /* non-critical */ }
298
+ });
299
+ ws.on('error', (err) => { logger.error('web', 'WS error', err); });
300
+ });
301
+ // Security: bind to loopback only — never expose the assistant (and its
302
+ // file/memory tools) to the LAN. Remote access requires explicit opt-in
303
+ // via a reverse proxy with authentication.
304
+ await new Promise(resolve => server.listen(PORT, '127.0.0.1', resolve));
305
+ logger.debug('web', `listening on :${PORT}`);
306
+ return { server, port: PORT, token: TOKEN };
307
+ }
308
+ export function getServerUrl(port = 3000, token) {
309
+ return token ? `http://localhost:${port}/?token=${token}` : `http://localhost:${port}`;
310
+ }
@@ -0,0 +1,3 @@
1
+ // MIT License — personal-ai
2
+ // Stub — implemented in M11
3
+ export {};
@@ -0,0 +1,3 @@
1
+ // MIT License — personal-ai
2
+ // Stub — implemented in M11
3
+ export {};
package/dist/web.js ADDED
@@ -0,0 +1,63 @@
1
+ // MIT License — personal-ai
2
+ // Standalone web server entrypoint — `npm run web`
3
+ import 'dotenv/config';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { watchProfiles } from './persona/loader.js';
7
+ import { ModelManager } from './core/model-manager.js';
8
+ import { createWebServer, getServerUrl } from './ui/web/server.js';
9
+ import { logger } from './core/logger.js';
10
+ import { toolRegistry } from './tools/registry.js';
11
+ import { createAppCore } from './bootstrap.js';
12
+ void logger;
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const CONFIG = path.join(__dirname, '..', 'config');
15
+ async function main() {
16
+ const boot = await createAppCore(CONFIG);
17
+ if (!boot.ok) {
18
+ console.error(`Failed to initialize provider: ${boot.error}`);
19
+ process.exit(1);
20
+ }
21
+ const { provider, profileManager, memory } = boot.core;
22
+ watchProfiles(path.join(CONFIG, 'profiles.yaml'), p => profileManager.reload(p));
23
+ // Web UI: two-model routing — qwen2.5:14b for tools/logic, gemma3:12b for chat/research/tutor
24
+ const defaultModel = process.env['OLLAMA_MODEL'] ?? 'qwen2.5:14b';
25
+ const chatModel = process.env['OLLAMA_CHAT_MODEL'] ?? 'gemma3:12b';
26
+ const modelManager = provider.name === 'ollama'
27
+ ? new ModelManager({
28
+ default: defaultModel,
29
+ tasks: {
30
+ tools: defaultModel,
31
+ coding: defaultModel,
32
+ reasoning: defaultModel,
33
+ chat: chatModel,
34
+ longcontext: chatModel,
35
+ quick: chatModel,
36
+ },
37
+ }, profileManager)
38
+ : new ModelManager({ default: provider.model, tasks: {} }, profileManager);
39
+ // Pre-load both models so first request is fast
40
+ void provider.warmUp?.(defaultModel);
41
+ if (provider.name === 'ollama' && chatModel !== defaultModel)
42
+ void provider.warmUp?.(chatModel);
43
+ const preferred = parseInt(process.env['PORT'] ?? '3000', 10);
44
+ const { port, token } = await createWebServer({
45
+ provider,
46
+ memory,
47
+ profileManager,
48
+ registry: toolRegistry,
49
+ modelManager,
50
+ personaPath: path.join(CONFIG, 'persona.yaml'),
51
+ port: preferred,
52
+ });
53
+ const url = getServerUrl(port, token);
54
+ console.log(`\n PersonalAI Web UI`);
55
+ console.log(` ${url}`);
56
+ console.log(` (URL includes your session token — don't share it)\n`);
57
+ process.on('SIGINT', () => {
58
+ memory.close();
59
+ console.log('\nBye.');
60
+ process.exit(0);
61
+ });
62
+ }
63
+ main().catch(err => { console.error(err); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@nandansai08/personal-ai",
3
+ "version": "0.8.0",
4
+ "description": "Personal AI assistant — any model, local or cloud",
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "tsx src/index.ts",
8
+ "dev": "tsx watch src/index.ts",
9
+ "web": "tsx src/web.ts",
10
+ "build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/ui/web/client','dist/ui/web/client',{recursive:true})\"",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "typecheck": "tsc --noEmit",
14
+ "lint": "eslint src tests",
15
+ "prepublishOnly": "npm run build && npm test && npm run typecheck"
16
+ },
17
+ "dependencies": {
18
+ "@anthropic-ai/sdk": "^0.104.1",
19
+ "@google/generative-ai": "^0.24.1",
20
+ "better-sqlite3": "^12.10.0",
21
+ "chalk": "^5.3.0",
22
+ "chokidar": "^5.0.0",
23
+ "cors": "^2.8.6",
24
+ "dotenv": "^16.4.0",
25
+ "express": "^5.2.1",
26
+ "js-yaml": "^4.2.0",
27
+ "openai": "^6.42.0",
28
+ "ws": "^8.21.0",
29
+ "zod": "^4.4.3"
30
+ },
31
+ "devDependencies": {
32
+ "@eslint/js": "^10.0.1",
33
+ "@types/better-sqlite3": "^7.6.13",
34
+ "@types/chokidar": "^1.7.5",
35
+ "@types/cors": "^2.8.19",
36
+ "@types/express": "^5.0.6",
37
+ "@types/js-yaml": "^4.0.9",
38
+ "@types/node": "^20.0.0",
39
+ "@types/ws": "^8.18.1",
40
+ "@vitest/coverage-istanbul": "^3.2.6",
41
+ "@vitest/coverage-v8": "^3.2.6",
42
+ "eslint": "^10.4.1",
43
+ "tsx": "^4.22.4",
44
+ "typescript": "^5.4.0",
45
+ "typescript-eslint": "^8.61.0",
46
+ "vitest": "^3.2.6"
47
+ },
48
+ "bin": {
49
+ "personal-ai": "bin/personal-ai.js"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "files": [
55
+ "dist",
56
+ "bin",
57
+ "config",
58
+ ".env.example"
59
+ ],
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/Nandansai08/personal-ai.git"
63
+ },
64
+ "homepage": "https://github.com/Nandansai08/personal-ai#readme",
65
+ "bugs": {
66
+ "url": "https://github.com/Nandansai08/personal-ai/issues"
67
+ }
68
+ }