@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,265 @@
1
+ // MIT License — personal-ai
2
+ import { eventBus } from '../core/events.js';
3
+ import { logger } from '../core/logger.js';
4
+ import { readStreamLines } from './utils.js';
5
+ function parseOllamaError(raw) {
6
+ try {
7
+ const parsed = JSON.parse(raw);
8
+ if (typeof parsed?.error === 'string')
9
+ return parsed.error;
10
+ }
11
+ catch { /* not JSON, use raw */ }
12
+ return raw;
13
+ }
14
+ const NATIVE_TOOL_PREFIXES = [
15
+ 'qwen2.5:', 'qwen2.5-coder:', 'llama3.1:', 'llama3.2:', 'mistral-nemo:', 'mistral:',
16
+ ];
17
+ function isNativeToolModel(model) {
18
+ return NATIVE_TOOL_PREFIXES.some(p => model.startsWith(p));
19
+ }
20
+ function xmlToolInstructions(tools) {
21
+ const defs = tools
22
+ .map(t => ` ${t.name}: ${t.description}\n params: ${JSON.stringify(t.parameters)}`)
23
+ .join('\n\n');
24
+ return [
25
+ 'To call a tool output ONLY:',
26
+ '<tool>tool_name</tool>',
27
+ '<args>{"key": "value"}</args>',
28
+ '',
29
+ 'Available tools:',
30
+ defs,
31
+ ].join('\n');
32
+ }
33
+ function* parseXmlChunks(content) {
34
+ const re = /<tool>([\s\S]*?)<\/tool>\s*<args>([\s\S]*?)<\/args>/g;
35
+ let last = 0;
36
+ let match;
37
+ while ((match = re.exec(content)) !== null) {
38
+ if (match.index > last)
39
+ yield { type: 'text', delta: content.slice(last, match.index) };
40
+ const name = (match[1] ?? '').trim();
41
+ const argsRaw = (match[2] ?? '').trim();
42
+ let args = {};
43
+ try {
44
+ args = JSON.parse(argsRaw);
45
+ }
46
+ catch {
47
+ args = { raw: argsRaw };
48
+ }
49
+ yield { type: 'tool_call', id: `xml_${Date.now()}`, name, arguments: args };
50
+ last = re.lastIndex;
51
+ }
52
+ if (last < content.length)
53
+ yield { type: 'text', delta: content.slice(last) };
54
+ }
55
+ function buildMessages(request, useXml) {
56
+ const messages = [];
57
+ if (request.systemPrompt) {
58
+ let sys = request.systemPrompt;
59
+ if (useXml && request.tools)
60
+ sys += '\n\n' + xmlToolInstructions(request.tools);
61
+ messages.push({ role: 'system', content: sys });
62
+ }
63
+ else if (useXml && request.tools) {
64
+ messages.push({ role: 'system', content: xmlToolInstructions(request.tools) });
65
+ }
66
+ for (const m of request.messages) {
67
+ messages.push({ role: m.role, content: m.content, ...(m.name ? { name: m.name } : {}) });
68
+ }
69
+ return messages;
70
+ }
71
+ function* processStreamChunk(chunk, useXml) {
72
+ const msg = chunk.message;
73
+ if (!msg)
74
+ return;
75
+ if (msg.tool_calls?.length) {
76
+ for (const tc of msg.tool_calls) {
77
+ const name = tc.function?.name ?? tc['name'] ?? '';
78
+ let args = tc.function?.arguments ?? {};
79
+ // Ollama sometimes returns arguments as a JSON string
80
+ if (typeof args === 'string') {
81
+ try {
82
+ args = JSON.parse(args);
83
+ }
84
+ catch {
85
+ args = { raw: args };
86
+ }
87
+ }
88
+ yield { type: 'tool_call', id: `tc_${Date.now()}`, name, arguments: args };
89
+ }
90
+ }
91
+ else if (msg.content) {
92
+ if (useXml) {
93
+ yield* parseXmlChunks(msg.content);
94
+ }
95
+ else {
96
+ yield { type: 'text', delta: msg.content };
97
+ }
98
+ }
99
+ }
100
+ async function* readNdjsonStream(body, useXml) {
101
+ for await (const line of readStreamLines(body)) {
102
+ const trimmed = line.trim();
103
+ if (!trimmed)
104
+ continue;
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(trimmed);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ if (parsed.done) {
113
+ const d = parsed;
114
+ yield { usage: { input: d.prompt_eval_count ?? 0, output: d.eval_count ?? 0 } };
115
+ }
116
+ else {
117
+ yield* processStreamChunk(parsed, useXml);
118
+ }
119
+ }
120
+ }
121
+ export class OllamaProvider {
122
+ name = 'ollama';
123
+ supportsStreaming = true;
124
+ model;
125
+ supportsToolUse;
126
+ baseUrl;
127
+ defaultModel;
128
+ numCtx;
129
+ numPredict;
130
+ temperature;
131
+ constructor() {
132
+ this.baseUrl = process.env['OLLAMA_BASE_URL'] ?? 'http://localhost:11434';
133
+ this.defaultModel = process.env['OLLAMA_MODEL'] ?? 'qwen2.5:14b';
134
+ this.model = this.defaultModel;
135
+ // 8192 default: 2048 silently truncates the system prompt once history
136
+ // grows past a few messages. Lower via OLLAMA_NUM_CTX on RAM-tight machines.
137
+ this.numCtx = parseInt(process.env['OLLAMA_NUM_CTX'] ?? '8192', 10);
138
+ this.numPredict = parseInt(process.env['OLLAMA_NUM_PREDICT'] ?? '512', 10);
139
+ this.temperature = parseFloat(process.env['OLLAMA_TEMPERATURE'] ?? '0.7');
140
+ this.supportsToolUse = isNativeToolModel(this.model);
141
+ }
142
+ // fallow-ignore-next-line unused-class-member
143
+ setModel(model) {
144
+ this.model = model;
145
+ this.supportsToolUse = isNativeToolModel(model);
146
+ }
147
+ async *chat(request) {
148
+ const startMs = Date.now();
149
+ // request.model is authoritative — avoids mutating shared provider state
150
+ // when multiple sessions (CLI + web) use the same instance concurrently
151
+ const model = request.model ?? this.model;
152
+ const useNative = isNativeToolModel(model) && (request.tools?.length ?? 0) > 0;
153
+ const useXml = !useNative && (request.tools?.length ?? 0) > 0;
154
+ const body = {
155
+ model,
156
+ messages: buildMessages(request, useXml),
157
+ stream: true,
158
+ keep_alive: -1,
159
+ options: { num_ctx: this.numCtx, num_predict: this.numPredict, temperature: request.temperature ?? this.temperature },
160
+ };
161
+ if (useNative && request.tools) {
162
+ body['tools'] = request.tools.map(t => ({
163
+ type: 'function',
164
+ function: { name: t.name, description: t.description, parameters: t.parameters },
165
+ }));
166
+ }
167
+ let response;
168
+ try {
169
+ response = await fetch(`${this.baseUrl}/api/chat`, {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify(body),
173
+ });
174
+ }
175
+ catch (err) {
176
+ yield { type: 'error', message: `Ollama connection failed: ${err instanceof Error ? err.message : String(err)}` };
177
+ return;
178
+ }
179
+ if (!response.ok) {
180
+ const errText = await response.text();
181
+ const errMsg = parseOllamaError(errText);
182
+ // Model not pulled — fall back to default and retry once
183
+ if (response.status === 404 && errText.includes('not found') && body['model'] !== this.defaultModel) {
184
+ const missing = String(body['model']);
185
+ logger.warn('ollama', `model "${missing}" not found, falling back to "${this.defaultModel}" (run: ollama pull ${missing})`);
186
+ yield { type: 'model_switch', from: missing, to: this.defaultModel };
187
+ body['model'] = this.defaultModel;
188
+ try {
189
+ response = await fetch(`${this.baseUrl}/api/chat`, {
190
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
191
+ });
192
+ }
193
+ catch (err) {
194
+ yield { type: 'error', message: `Ollama connection failed: ${err instanceof Error ? err.message : String(err)}` };
195
+ return;
196
+ }
197
+ if (!response.ok) {
198
+ yield { type: 'error', message: parseOllamaError(await response.text()) };
199
+ return;
200
+ }
201
+ }
202
+ else {
203
+ yield { type: 'error', message: errMsg };
204
+ return;
205
+ }
206
+ }
207
+ if (!response.body) {
208
+ yield { type: 'error', message: 'No response body' };
209
+ return;
210
+ }
211
+ let usage;
212
+ for await (const item of readNdjsonStream(response.body, useXml)) {
213
+ if ('usage' in item) {
214
+ usage = item.usage;
215
+ continue;
216
+ }
217
+ yield item;
218
+ }
219
+ const latencyMs = Date.now() - startMs;
220
+ eventBus.emit('provider_latency', { provider: 'ollama', model: String(body['model']), latencyMs });
221
+ if (usage)
222
+ eventBus.emit('tokens_used', { input: usage.input, output: usage.output, provider: 'ollama' });
223
+ yield { type: 'done', usage };
224
+ }
225
+ async healthCheck() {
226
+ const start = Date.now();
227
+ try {
228
+ const res = await fetch(`${this.baseUrl}/api/tags`);
229
+ const latencyMs = Date.now() - start;
230
+ if (!res.ok)
231
+ return { ok: false, latencyMs, model: this.model, error: `HTTP ${res.status}` };
232
+ eventBus.emit('session_started', { provider: 'ollama', model: this.model });
233
+ return { ok: true, latencyMs, model: this.model };
234
+ }
235
+ catch (err) {
236
+ return { ok: false, latencyMs: Date.now() - start, model: this.model, error: String(err) };
237
+ }
238
+ }
239
+ async warmUp(model) {
240
+ const target = model ?? this.model;
241
+ try {
242
+ await fetch(`${this.baseUrl}/api/generate`, {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json' },
245
+ body: JSON.stringify({ model: target, prompt: '', keep_alive: -1 }),
246
+ });
247
+ logger.debug('ollama', `warmed up ${target}`);
248
+ }
249
+ catch { /* non-fatal */ }
250
+ }
251
+ async listModels() {
252
+ try {
253
+ const res = await fetch(`${this.baseUrl}/api/tags`);
254
+ if (!res.ok)
255
+ return [];
256
+ const data = await res.json();
257
+ return (data.models ?? []).map(m => ({
258
+ id: m.name, name: m.name, supportsTools: isNativeToolModel(m.name),
259
+ }));
260
+ }
261
+ catch {
262
+ return [];
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,110 @@
1
+ // MIT License — personal-ai
2
+ import OpenAI from 'openai';
3
+ import { eventBus } from '../core/events.js';
4
+ import { logger } from '../core/logger.js';
5
+ import { buildOAIMessages, flushToolCalls, runHealthCheck } from './utils.js';
6
+ function toOAITools(tools) {
7
+ return tools.map(t => ({
8
+ type: 'function',
9
+ function: { name: t.name, description: t.description, parameters: t.parameters },
10
+ }));
11
+ }
12
+ function processOAIChunk(chunk, toolNames, toolArgs) {
13
+ const choice = chunk.choices[0];
14
+ const yields = [];
15
+ if (!choice)
16
+ return { yields };
17
+ const delta = choice.delta;
18
+ if (delta.content)
19
+ yields.push({ type: 'text', delta: delta.content });
20
+ for (const tc of delta.tool_calls ?? []) {
21
+ const idx = String(tc.index);
22
+ if (tc.function?.name)
23
+ toolNames[idx] = (toolNames[idx] ?? '') + tc.function.name;
24
+ if (tc.function?.arguments)
25
+ toolArgs[idx] = (toolArgs[idx] ?? '') + tc.function.arguments;
26
+ }
27
+ if (choice.finish_reason === 'tool_calls')
28
+ yields.push(...flushToolCalls(toolNames, toolArgs, 'tc'));
29
+ return {
30
+ yields,
31
+ inputTokens: chunk.usage?.prompt_tokens,
32
+ outputTokens: chunk.usage?.completion_tokens,
33
+ };
34
+ }
35
+ /**
36
+ * Abstract base for all OpenAI-compatible providers (OpenAI, Groq, LMStudio, Together).
37
+ * Subclasses set name, baseURL, defaultModel, supportsToolUse.
38
+ */
39
+ export class OpenAICompatibleProvider {
40
+ supportsStreaming = true;
41
+ model;
42
+ client;
43
+ temperature;
44
+ constructor(apiKey, baseURL, model, temperature = 0.7) {
45
+ this.model = model;
46
+ this.temperature = temperature;
47
+ this.client = new OpenAI({ apiKey, baseURL });
48
+ }
49
+ async *chat(request) {
50
+ const startMs = Date.now();
51
+ const messages = buildOAIMessages(request.messages, request.systemPrompt);
52
+ const params = {
53
+ model: request.model ?? this.model,
54
+ messages,
55
+ temperature: request.temperature ?? this.temperature,
56
+ stream: true,
57
+ };
58
+ if (request.tools?.length && this.supportsToolUse) {
59
+ params.tools = toOAITools(request.tools);
60
+ }
61
+ if (request.maxTokens)
62
+ params.max_tokens = request.maxTokens;
63
+ let stream;
64
+ try {
65
+ stream = await this.client.chat.completions.create(params);
66
+ }
67
+ catch (err) {
68
+ yield { type: 'error', message: `${this.name} request failed: ${String(err)}` };
69
+ return;
70
+ }
71
+ let inputTokens = 0;
72
+ let outputTokens = 0;
73
+ const toolArgs = {};
74
+ const toolNames = {};
75
+ try {
76
+ for await (const chunk of stream) {
77
+ const result = processOAIChunk(chunk, toolNames, toolArgs);
78
+ for (const c of result.yields)
79
+ yield c;
80
+ if (result.inputTokens !== undefined)
81
+ inputTokens = result.inputTokens;
82
+ if (result.outputTokens !== undefined)
83
+ outputTokens = result.outputTokens;
84
+ }
85
+ }
86
+ catch (err) {
87
+ yield { type: 'error', message: `${this.name} stream error: ${String(err)}` };
88
+ return;
89
+ }
90
+ const latencyMs = Date.now() - startMs;
91
+ eventBus.emit('provider_latency', { provider: this.name, model: this.model, latencyMs });
92
+ if (inputTokens || outputTokens) {
93
+ eventBus.emit('tokens_used', { input: inputTokens, output: outputTokens, provider: this.name });
94
+ }
95
+ logger.debug(this.name, `done in ${latencyMs}ms`);
96
+ yield { type: 'done', usage: { input: inputTokens, output: outputTokens } };
97
+ }
98
+ async healthCheck() {
99
+ return runHealthCheck(this.model, () => this.client.models.list().then(() => undefined));
100
+ }
101
+ async listModels() {
102
+ try {
103
+ const res = await this.client.models.list();
104
+ return res.data.map(m => ({ id: m.id, name: m.id }));
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,14 @@
1
+ // MIT License — personal-ai
2
+ import { OpenAICompatibleProvider } from './openai-compatible.js';
3
+ // fallow-ignore-next-line unused-export
4
+ export class OpenAIProvider extends OpenAICompatibleProvider {
5
+ name = 'openai';
6
+ supportsToolUse = true;
7
+ constructor() {
8
+ const key = process.env['OPENAI_API_KEY'] ?? '';
9
+ const base = process.env['OPENAI_BASE_URL'];
10
+ const model = process.env['OPENAI_MODEL'] ?? 'gpt-4o-mini';
11
+ const temp = parseFloat(process.env['OPENAI_TEMPERATURE'] ?? '0.7');
12
+ super(key, base, model, temp);
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ // MIT License — personal-ai
2
+ import { OpenAICompatibleProvider } from './openai-compatible.js';
3
+ const TOGETHER_BASE_URL = 'https://api.together.xyz/v1';
4
+ // fallow-ignore-next-line unused-export
5
+ export class TogetherProvider extends OpenAICompatibleProvider {
6
+ name = 'together';
7
+ supportsToolUse = false;
8
+ constructor() {
9
+ const key = process.env['TOGETHER_API_KEY'] ?? '';
10
+ const model = process.env['TOGETHER_MODEL'] ?? 'meta-llama/Llama-3.3-70B-Instruct-Turbo';
11
+ const temp = parseFloat(process.env['TOGETHER_TEMPERATURE'] ?? '0.7');
12
+ super(key, TOGETHER_BASE_URL, model, temp);
13
+ }
14
+ }
@@ -0,0 +1,57 @@
1
+ /** Convert internal Message[] to OpenAI-compatible message array. */
2
+ export function buildOAIMessages(messages, systemPrompt) {
3
+ const out = [];
4
+ if (systemPrompt)
5
+ out.push({ role: 'system', content: systemPrompt });
6
+ for (const m of messages) {
7
+ if (m.role === 'system')
8
+ continue;
9
+ out.push({
10
+ role: m.role,
11
+ content: m.content,
12
+ ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
13
+ });
14
+ }
15
+ return out;
16
+ }
17
+ /** Yield each newline-delimited text line from a ReadableStream. */
18
+ export async function* readStreamLines(body) {
19
+ const reader = body.getReader();
20
+ const decoder = new TextDecoder();
21
+ let buffer = '';
22
+ while (true) {
23
+ const { done, value } = await reader.read();
24
+ if (done)
25
+ break;
26
+ buffer += decoder.decode(value, { stream: true });
27
+ const lines = buffer.split('\n');
28
+ buffer = lines.pop() ?? '';
29
+ for (const line of lines)
30
+ yield line;
31
+ }
32
+ }
33
+ /** Wrap a provider health-check probe in the standard start/try/catch/return envelope. */
34
+ export async function runHealthCheck(model, probe) {
35
+ const start = Date.now();
36
+ try {
37
+ await probe();
38
+ return { ok: true, latencyMs: Date.now() - start, model };
39
+ }
40
+ catch (err) {
41
+ return { ok: false, latencyMs: Date.now() - start, model, error: String(err) };
42
+ }
43
+ }
44
+ /** Flush accumulated streaming tool-call chunks into ChatChunk events. */
45
+ export function flushToolCalls(toolNames, toolArgs, idPrefix) {
46
+ return Object.entries(toolNames).map(([idx, name]) => {
47
+ const argsRaw = toolArgs[idx] ?? '{}';
48
+ let args = {};
49
+ try {
50
+ args = JSON.parse(argsRaw);
51
+ }
52
+ catch {
53
+ args = { raw: argsRaw };
54
+ }
55
+ return { type: 'tool_call', id: `${idPrefix}_${idx}_${Date.now()}`, name, arguments: args };
56
+ });
57
+ }
@@ -0,0 +1,44 @@
1
+ // MIT License — personal-ai
2
+ const ALLOWED = /^[\d\s+\-*/%.()e,Ee]+$/;
3
+ /**
4
+ * Safe expression evaluator. Only allows numeric chars + basic operators.
5
+ * No eval of arbitrary code.
6
+ */
7
+ function safeEval(expr) {
8
+ const cleaned = expr.replace(/\s/g, '');
9
+ if (!ALLOWED.test(cleaned)) {
10
+ throw new Error(`Invalid characters in expression: ${expr}`);
11
+ }
12
+ // Use Function constructor with numeric-only whitelist validated above
13
+ const result = new Function(`"use strict"; return (${cleaned})`)();
14
+ if (typeof result !== 'number' || !isFinite(result)) {
15
+ throw new Error(`Expression did not evaluate to a finite number: ${expr}`);
16
+ }
17
+ return result;
18
+ }
19
+ export const calculatorTool = {
20
+ definition: {
21
+ name: 'calculator',
22
+ description: 'Evaluate math expression.',
23
+ parameters: {
24
+ type: 'object',
25
+ properties: {
26
+ expression: { type: 'string', description: 'e.g. "(2+3)*4"' },
27
+ },
28
+ required: ['expression'],
29
+ },
30
+ },
31
+ async execute(args) {
32
+ const a = args;
33
+ const expression = String(a['expression'] ?? '').trim();
34
+ if (!expression)
35
+ return { success: false, data: null, error: 'expression required' };
36
+ try {
37
+ const result = safeEval(expression);
38
+ return { success: true, data: { expression, result } };
39
+ }
40
+ catch (err) {
41
+ return { success: false, data: null, error: String(err) };
42
+ }
43
+ },
44
+ };
@@ -0,0 +1,101 @@
1
+ // MIT License — personal-ai
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ const MAX_BYTES = 100_000; // 100 KB read limit
6
+ // Security: filenames that must never be readable by the model, regardless of
7
+ // location — credentials, keys, and shell history are prime prompt-injection
8
+ // exfiltration targets.
9
+ const DENIED_NAMES = /^(\.env(\..*)?|id_rsa.*|id_ed25519.*|id_ecdsa.*|.*\.pem|.*\.key|credentials(\..*)?|\.netrc|\.npmrc|\.bash_history|\.zsh_history)$/i;
10
+ const DENIED_DIRS = ['.ssh', '.gnupg', '.aws', '.azure', '.kube', '.docker'];
11
+ /** Resolve path, expanding ~. */
12
+ function resolveSafe(filePath) {
13
+ return path.resolve(filePath.startsWith('~')
14
+ ? filePath.replace('~', os.homedir())
15
+ : filePath);
16
+ }
17
+ /**
18
+ * Security gate: deny sensitive files and directories. Allow-list roots are
19
+ * configurable via FILE_READER_ROOTS (comma-separated); defaults to home dir
20
+ * and current working directory.
21
+ */
22
+ function checkAccess(resolved) {
23
+ const base = path.basename(resolved);
24
+ if (DENIED_NAMES.test(base)) {
25
+ return `Access denied: ${base} may contain credentials`;
26
+ }
27
+ const segments = resolved.split(/[\\/]/);
28
+ for (const dir of DENIED_DIRS) {
29
+ if (segments.includes(dir))
30
+ return `Access denied: ${dir} directory is protected`;
31
+ }
32
+ const rootsEnv = process.env['FILE_READER_ROOTS'];
33
+ const roots = rootsEnv
34
+ ? rootsEnv.split(',').map(r => path.resolve(r.trim()))
35
+ : [os.homedir(), process.cwd()];
36
+ const inRoot = roots.some(root => {
37
+ const rel = path.relative(root, resolved);
38
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
39
+ });
40
+ if (!inRoot) {
41
+ return `Access denied: path outside allowed roots (${roots.join(', ')}). Set FILE_READER_ROOTS in .env to extend.`;
42
+ }
43
+ return null;
44
+ }
45
+ export const fileReaderTool = {
46
+ requiresConfirmation: true,
47
+ definition: {
48
+ name: 'file_reader',
49
+ description: 'Read local text file, max 100KB.',
50
+ parameters: {
51
+ type: 'object',
52
+ properties: {
53
+ path: { type: 'string', description: 'File path (absolute or ~)' },
54
+ encoding: { type: 'string', description: 'File encoding (default utf-8)' },
55
+ lines: { type: 'number', description: 'Max lines to read (default: all)' },
56
+ },
57
+ required: ['path'],
58
+ },
59
+ },
60
+ async execute(args) {
61
+ const a = args;
62
+ const filePath = String(a['path'] ?? '').trim();
63
+ if (!filePath)
64
+ return { success: false, data: null, error: 'path required' };
65
+ const resolved = resolveSafe(filePath);
66
+ const denied = checkAccess(resolved);
67
+ if (denied)
68
+ return { success: false, data: null, error: denied };
69
+ if (!fs.existsSync(resolved)) {
70
+ return { success: false, data: null, error: `File not found: ${resolved}` };
71
+ }
72
+ // Re-check the real path — a symlink inside an allowed root must not
73
+ // escape to a denied location.
74
+ const real = fs.realpathSync(resolved);
75
+ if (real !== resolved) {
76
+ const deniedReal = checkAccess(real);
77
+ if (deniedReal)
78
+ return { success: false, data: null, error: deniedReal };
79
+ }
80
+ const stat = fs.statSync(resolved);
81
+ if (!stat.isFile()) {
82
+ return { success: false, data: null, error: `Not a file: ${resolved}` };
83
+ }
84
+ if (stat.size > MAX_BYTES) {
85
+ return { success: false, data: null, error: `File too large (${stat.size} bytes, max ${MAX_BYTES})` };
86
+ }
87
+ try {
88
+ const encoding = a['encoding'] ?? 'utf-8';
89
+ const raw = fs.readFileSync(resolved, encoding);
90
+ const maxLines = a['lines'] ? Number(a['lines']) : undefined;
91
+ const content = maxLines ? raw.split('\n').slice(0, maxLines).join('\n') : raw;
92
+ return {
93
+ success: true,
94
+ data: { path: resolved, size: stat.size, content },
95
+ };
96
+ }
97
+ catch (err) {
98
+ return { success: false, data: null, error: `Read error: ${String(err)}` };
99
+ }
100
+ },
101
+ };
@@ -0,0 +1,58 @@
1
+ // MIT License — personal-ai
2
+ /**
3
+ * Creates the memory tool bound to a LongTermMemory instance.
4
+ * Kept as a factory so the tool shares the same DB connection as the assistant.
5
+ */
6
+ export function createMemoryTool(memory) {
7
+ return {
8
+ definition: {
9
+ name: 'memory',
10
+ description: 'Save, search, or retrieve memories about the user. Use to remember important facts, preferences, and context.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ action: { type: 'string', description: 'save | search | list | stats', enum: ['save', 'search', 'list', 'stats'] },
15
+ content: { type: 'string', description: 'Memory content (for save)' },
16
+ type: { type: 'string', description: 'fact | preference | context | episodic | education | career | project | personal', enum: ['fact', 'preference', 'context', 'episodic', 'education', 'career', 'project', 'personal'] },
17
+ query: { type: 'string', description: 'Search query (for search)' },
18
+ limit: { type: 'number', description: 'Max results (for search/list, default 8)' },
19
+ importance: { type: 'number', description: 'Importance 1-10 (for save, default 5)' },
20
+ },
21
+ required: ['action'],
22
+ },
23
+ },
24
+ async execute(args) {
25
+ const a = args;
26
+ const action = String(a['action'] ?? '').trim();
27
+ switch (action) {
28
+ case 'save': {
29
+ const content = String(a['content'] ?? '').trim();
30
+ if (!content)
31
+ return { success: false, data: null, error: 'content required' };
32
+ const type = a['type'] ?? 'fact';
33
+ const importance = Math.min(10, Math.max(1, Number(a['importance'] ?? 5)));
34
+ const saved = memory.save({ content, type, importance });
35
+ return { success: true, data: saved };
36
+ }
37
+ case 'search': {
38
+ const query = String(a['query'] ?? '').trim();
39
+ if (!query)
40
+ return { success: false, data: null, error: 'query required' };
41
+ const limit = Number(a['limit'] ?? 8);
42
+ const results = memory.search(query, limit);
43
+ return { success: true, data: results };
44
+ }
45
+ case 'list': {
46
+ const limit = Number(a['limit'] ?? 10);
47
+ const results = memory.getRecent(limit);
48
+ return { success: true, data: results };
49
+ }
50
+ case 'stats': {
51
+ return { success: true, data: memory.getStats() };
52
+ }
53
+ default:
54
+ return { success: false, data: null, error: `Unknown action: ${action}` };
55
+ }
56
+ },
57
+ };
58
+ }