@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,121 @@
1
+ // MIT License — personal-ai
2
+ import Database from 'better-sqlite3';
3
+ import { randomUUID as uuidv4 } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import fs from 'node:fs';
7
+ const DB_DIR = path.join(os.homedir(), '.personal-ai');
8
+ const DB_PATH = path.join(DB_DIR, 'notes.db');
9
+ function rowToNote(row) {
10
+ return {
11
+ id: row.id,
12
+ title: row.title,
13
+ content: row.content,
14
+ tags: JSON.parse(row.tags),
15
+ created_at: row.created_at,
16
+ updated_at: row.updated_at,
17
+ };
18
+ }
19
+ function getDb() {
20
+ fs.mkdirSync(DB_DIR, { recursive: true });
21
+ const db = new Database(DB_PATH);
22
+ db.pragma('journal_mode = WAL');
23
+ db.exec(`
24
+ CREATE TABLE IF NOT EXISTS notes (
25
+ id TEXT PRIMARY KEY,
26
+ title TEXT NOT NULL,
27
+ content TEXT NOT NULL DEFAULT '',
28
+ tags TEXT NOT NULL DEFAULT '[]',
29
+ created_at TEXT NOT NULL,
30
+ updated_at TEXT NOT NULL
31
+ );
32
+ CREATE INDEX IF NOT EXISTS idx_notes_title ON notes(title);
33
+ `);
34
+ return db;
35
+ }
36
+ function saveNote(title, content, tags) {
37
+ const db = getDb();
38
+ const now = new Date().toISOString();
39
+ const existing = db.prepare('SELECT * FROM notes WHERE title = ?').get(title);
40
+ if (existing) {
41
+ db.prepare('UPDATE notes SET content = ?, tags = ?, updated_at = ? WHERE id = ?')
42
+ .run(content, JSON.stringify(tags), now, existing.id);
43
+ return rowToNote({ ...existing, content, tags: JSON.stringify(tags), updated_at: now });
44
+ }
45
+ const id = uuidv4();
46
+ db.prepare('INSERT INTO notes (id,title,content,tags,created_at,updated_at) VALUES (?,?,?,?,?,?)')
47
+ .run(id, title, content, JSON.stringify(tags), now, now);
48
+ return { id, title, content, tags, created_at: now, updated_at: now };
49
+ }
50
+ function getNote(title) {
51
+ const db = getDb();
52
+ const row = db.prepare('SELECT * FROM notes WHERE title = ?').get(title);
53
+ return row ? rowToNote(row) : null;
54
+ }
55
+ function listNotes(tag) {
56
+ const db = getDb();
57
+ const rows = db.prepare('SELECT * FROM notes ORDER BY updated_at DESC LIMIT 50').all();
58
+ const notes = rows.map(rowToNote);
59
+ if (tag)
60
+ return notes.filter(n => n.tags.includes(tag));
61
+ return notes;
62
+ }
63
+ function deleteNote(title) {
64
+ const db = getDb();
65
+ const result = db.prepare('DELETE FROM notes WHERE title = ?').run(title);
66
+ return result.changes > 0;
67
+ }
68
+ export const notesTool = {
69
+ definition: {
70
+ name: 'notes',
71
+ description: 'Save, retrieve, list, or delete personal notes.',
72
+ parameters: {
73
+ type: 'object',
74
+ properties: {
75
+ action: { type: 'string', description: 'save | get | list | delete', enum: ['save', 'get', 'list', 'delete'] },
76
+ title: { type: 'string', description: 'Note title' },
77
+ content: { type: 'string', description: 'Content (for save)' },
78
+ tags: { type: 'string', description: 'Tags, comma-separated' },
79
+ },
80
+ required: ['action'],
81
+ },
82
+ },
83
+ async execute(args) {
84
+ const a = args;
85
+ const action = String(a['action'] ?? '').trim();
86
+ switch (action) {
87
+ case 'save': {
88
+ const title = String(a['title'] ?? '').trim();
89
+ const content = String(a['content'] ?? '').trim();
90
+ if (!title)
91
+ return { success: false, data: null, error: 'title required for save' };
92
+ const tags = a['tags'] ? String(a['tags']).split(',').map(t => t.trim()).filter(Boolean) : [];
93
+ const note = saveNote(title, content, tags);
94
+ return { success: true, data: note };
95
+ }
96
+ case 'get': {
97
+ const title = String(a['title'] ?? '').trim();
98
+ if (!title)
99
+ return { success: false, data: null, error: 'title required for get' };
100
+ const note = getNote(title);
101
+ if (!note)
102
+ return { success: false, data: null, error: `Note "${title}" not found` };
103
+ return { success: true, data: note };
104
+ }
105
+ case 'list': {
106
+ const tag = a['tags'] ? String(a['tags']).trim() : undefined;
107
+ const notes = listNotes(tag);
108
+ return { success: true, data: notes };
109
+ }
110
+ case 'delete': {
111
+ const title = String(a['title'] ?? '').trim();
112
+ if (!title)
113
+ return { success: false, data: null, error: 'title required for delete' };
114
+ const deleted = deleteNote(title);
115
+ return { success: true, data: { deleted, title } };
116
+ }
117
+ default:
118
+ return { success: false, data: null, error: `Unknown action: ${action}` };
119
+ }
120
+ },
121
+ };
@@ -0,0 +1,119 @@
1
+ // MIT License — personal-ai
2
+ let _idCounter = 0;
3
+ function genId() {
4
+ return `tc_${Date.now()}_${_idCounter++}`;
5
+ }
6
+ /** Strategy 1: Ollama native JSON tool_calls array */
7
+ function parseNativeJson(text) {
8
+ try {
9
+ const parsed = JSON.parse(text.trim());
10
+ if (!Array.isArray(parsed))
11
+ return null;
12
+ const calls = [];
13
+ for (const item of parsed) {
14
+ if (typeof item === 'object' && item !== null && 'name' in item) {
15
+ const obj = item;
16
+ calls.push({
17
+ id: genId(),
18
+ name: String(obj['name'] ?? ''),
19
+ arguments: (obj['arguments'] ?? obj['parameters'] ?? {}),
20
+ });
21
+ }
22
+ }
23
+ return calls.length > 0 ? calls : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** Strategy 2: Gemma3 two-tag XML <tool>name</tool><args>{}</args> */
30
+ function parseGemmaXml(text) {
31
+ const toolPattern = /<tool>([\s\S]*?)<\/tool>/g;
32
+ const argsPattern = /<args>([\s\S]*?)<\/args>/g;
33
+ const toolMatches = [...text.matchAll(toolPattern)];
34
+ const argsMatches = [...text.matchAll(argsPattern)];
35
+ if (toolMatches.length === 0)
36
+ return null;
37
+ const calls = [];
38
+ for (let i = 0; i < toolMatches.length; i++) {
39
+ const name = toolMatches[i][1].trim();
40
+ let args = {};
41
+ if (argsMatches[i]) {
42
+ try {
43
+ args = JSON.parse(argsMatches[i][1].trim());
44
+ }
45
+ catch {
46
+ args = {};
47
+ }
48
+ }
49
+ calls.push({ id: genId(), name, arguments: args });
50
+ }
51
+ return calls;
52
+ }
53
+ /** Strategy 3: JSON code block ```json\n{"name":...,"arguments":...}\n``` */
54
+ function parseJsonBlock(text) {
55
+ const blockPattern = /```(?:json)?\s*\n([\s\S]*?)\n```/g;
56
+ const calls = [];
57
+ for (const match of text.matchAll(blockPattern)) {
58
+ try {
59
+ const parsed = JSON.parse(match[1].trim());
60
+ if (typeof parsed === 'object' && parsed !== null && 'name' in parsed) {
61
+ const obj = parsed;
62
+ calls.push({
63
+ id: genId(),
64
+ name: String(obj['name'] ?? ''),
65
+ arguments: (obj['arguments'] ?? obj['parameters'] ?? {}),
66
+ });
67
+ }
68
+ }
69
+ catch {
70
+ // not a tool call block
71
+ }
72
+ }
73
+ return calls.length > 0 ? calls : null;
74
+ }
75
+ /**
76
+ * Strategy 4: named-tool XML — what some models (Gemini) emit as plain text:
77
+ * <web_search><query>x</query><count>1</count></web_search>
78
+ * <memory><action>save</action><content>…</content></memory>
79
+ * Also tolerates the malformed close `</args>` instead of `</tool_name>`.
80
+ * Callers MUST filter results against the tool registry — this pattern is
81
+ * generic enough to false-positive on arbitrary XML-ish text.
82
+ */
83
+ function parseNamedToolXml(text) {
84
+ const outer = /<([a-z][\w]*)>([\s\S]*?)(<\/\1>|<\/args>)/gi;
85
+ const calls = [];
86
+ for (const match of text.matchAll(outer)) {
87
+ const name = match[1].toLowerCase();
88
+ const inner = match[2];
89
+ if (name === 'tool' || name === 'args')
90
+ continue; // strategy-2 territory
91
+ const args = {};
92
+ const child = /<(\w+)>([\s\S]*?)<\/\1>/g;
93
+ let hasChildren = false;
94
+ for (const c of inner.matchAll(child)) {
95
+ hasChildren = true;
96
+ const raw = c[2].trim();
97
+ const num = Number(raw);
98
+ args[c[1]] = raw !== '' && !Number.isNaN(num) ? num : raw;
99
+ }
100
+ if (!hasChildren)
101
+ continue; // <b>bold</b> etc. — not a tool call
102
+ calls.push({ id: genId(), name, arguments: args });
103
+ }
104
+ return calls.length > 0 ? calls : null;
105
+ }
106
+ /**
107
+ * Parse tool calls from model output using 4 strategies in priority order:
108
+ * 1. Native JSON array (Ollama tool_calls)
109
+ * 2. Gemma3 two-tag XML
110
+ * 3. JSON code block
111
+ * 4. Named-tool XML (Gemini-style text leakage) — filter against registry!
112
+ */
113
+ export function parseToolCalls(text) {
114
+ return (parseNativeJson(text) ??
115
+ parseGemmaXml(text) ??
116
+ parseJsonBlock(text) ??
117
+ parseNamedToolXml(text) ??
118
+ []);
119
+ }
@@ -0,0 +1,88 @@
1
+ // MIT License — personal-ai
2
+ import { eventBus } from '../core/events.js';
3
+ export class ToolRegistry {
4
+ tools = new Map();
5
+ confirmHandler;
6
+ /**
7
+ * Install a confirmation handler for tools marked requiresConfirmation.
8
+ * Without a handler such tools run unconfirmed (legacy behavior) — the CLI
9
+ * installs an interactive y/n prompt at startup.
10
+ */
11
+ setConfirmHandler(handler) {
12
+ this.confirmHandler = handler;
13
+ }
14
+ /** Register a tool. Overwrites if same name. */
15
+ register(tool) {
16
+ this.tools.set(tool.definition.name, tool);
17
+ }
18
+ /** Returns true if tool exists. */
19
+ has(name) {
20
+ return this.tools.has(name);
21
+ }
22
+ /** Total registered tool count. */
23
+ count() {
24
+ return this.tools.size;
25
+ }
26
+ /** All registered tools. */
27
+ getAll() {
28
+ return [...this.tools.values()];
29
+ }
30
+ /**
31
+ * Execute a tool by name. Never throws — returns error ToolResult on failure.
32
+ * Emits tool_called and tool_result events with timing.
33
+ */
34
+ async dispatch(name, args) {
35
+ const tool = this.tools.get(name);
36
+ const start = Date.now();
37
+ if (!name) {
38
+ return { success: false, data: null, error: 'Tool name was empty — model sent malformed tool call' };
39
+ }
40
+ if (!tool) {
41
+ const result = { success: false, data: null, error: `Unknown tool: ${name}` };
42
+ return result;
43
+ }
44
+ if (tool.requiresConfirmation && this.confirmHandler) {
45
+ const approved = await this.confirmHandler(name, args);
46
+ if (!approved) {
47
+ eventBus.emit('tool_called', { name, args, durationMs: 0 });
48
+ eventBus.emit('tool_result', { name, success: false, resultSize: 0 });
49
+ return { success: false, data: null, error: `User denied ${name} call` };
50
+ }
51
+ }
52
+ try {
53
+ const result = await tool.execute(args);
54
+ const durationMs = Date.now() - start;
55
+ eventBus.emit('tool_called', { name, args, durationMs });
56
+ eventBus.emit('tool_result', { name, success: result.success, resultSize: JSON.stringify(result.data).length });
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ const durationMs = Date.now() - start;
61
+ const error = err instanceof Error ? err.message : String(err);
62
+ const result = { success: false, data: null, error };
63
+ eventBus.emit('tool_called', { name, args, durationMs });
64
+ eventBus.emit('tool_result', { name, success: false, resultSize: 0 });
65
+ return result;
66
+ }
67
+ }
68
+ /** Format tools as OpenAI-compatible native tool definitions. */
69
+ formatNative() {
70
+ return this.getAll().map(t => t.definition);
71
+ }
72
+ /** Format tools as XML instructions for Gemma3 / XML-fallback models. */
73
+ formatForPrompt() {
74
+ if (this.tools.size === 0)
75
+ return '';
76
+ const lines = ['Available tools (respond with <tool>name</tool><args>{...}</args>):'];
77
+ for (const tool of this.tools.values()) {
78
+ const { name, description, parameters } = tool.definition;
79
+ const params = Object.entries(parameters.properties)
80
+ .map(([k, v]) => ` ${k} (${v.type}): ${v.description ?? ''}`)
81
+ .join('\n');
82
+ lines.push(`\n${name}: ${description}\nParameters:\n${params}`);
83
+ }
84
+ return lines.join('\n');
85
+ }
86
+ }
87
+ /** Singleton registry shared across the application. */
88
+ export const toolRegistry = new ToolRegistry();
@@ -0,0 +1,134 @@
1
+ // MIT License — personal-ai
2
+ import Database from 'better-sqlite3';
3
+ import { randomUUID as uuidv4 } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import fs from 'node:fs';
7
+ const DB_DIR = path.join(os.homedir(), '.personal-ai');
8
+ const DB_PATH = path.join(DB_DIR, 'tasks.db');
9
+ function rowToTask(row) {
10
+ return {
11
+ id: row.id,
12
+ title: row.title,
13
+ description: row.description,
14
+ status: row.status,
15
+ priority: row.priority,
16
+ due_date: row.due_date,
17
+ created_at: row.created_at,
18
+ updated_at: row.updated_at,
19
+ };
20
+ }
21
+ function getDb() {
22
+ fs.mkdirSync(DB_DIR, { recursive: true });
23
+ const db = new Database(DB_PATH);
24
+ db.pragma('journal_mode = WAL');
25
+ db.exec(`
26
+ CREATE TABLE IF NOT EXISTS tasks (
27
+ id TEXT PRIMARY KEY,
28
+ title TEXT NOT NULL,
29
+ description TEXT NOT NULL DEFAULT '',
30
+ status TEXT NOT NULL DEFAULT 'pending',
31
+ priority TEXT NOT NULL DEFAULT 'medium',
32
+ due_date TEXT,
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
37
+ CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
38
+ `);
39
+ return db;
40
+ }
41
+ export const tasksTool = {
42
+ definition: {
43
+ name: 'tasks',
44
+ description: 'Manage tasks: create, update, list.',
45
+ parameters: {
46
+ type: 'object',
47
+ properties: {
48
+ action: { type: 'string', description: 'create | update | list | get | delete', enum: ['create', 'update', 'list', 'get', 'delete'] },
49
+ id: { type: 'string', description: 'Task ID (for update/get/delete)' },
50
+ title: { type: 'string', description: 'Task title' },
51
+ description: { type: 'string', description: 'Task description' },
52
+ status: { type: 'string', description: 'pending | in_progress | done', enum: ['pending', 'in_progress', 'done'] },
53
+ priority: { type: 'string', description: 'low | medium | high', enum: ['low', 'medium', 'high'] },
54
+ due_date: { type: 'string', description: 'Due date ISO string' },
55
+ filter: { type: 'string', description: 'Filter: pending|in_progress|done|all' },
56
+ },
57
+ required: ['action'],
58
+ },
59
+ },
60
+ async execute(args) {
61
+ const a = args;
62
+ const action = String(a['action'] ?? '').trim();
63
+ const db = getDb();
64
+ const now = new Date().toISOString();
65
+ switch (action) {
66
+ case 'create': {
67
+ const title = String(a['title'] ?? '').trim();
68
+ if (!title)
69
+ return { success: false, data: null, error: 'title required' };
70
+ const task = {
71
+ id: uuidv4(),
72
+ title,
73
+ description: String(a['description'] ?? ''),
74
+ status: 'pending',
75
+ priority: a['priority'] ?? 'medium',
76
+ due_date: a['due_date'] ? String(a['due_date']) : null,
77
+ created_at: now,
78
+ updated_at: now,
79
+ };
80
+ db.prepare('INSERT INTO tasks (id,title,description,status,priority,due_date,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)')
81
+ .run(task.id, task.title, task.description, task.status, task.priority, task.due_date, task.created_at, task.updated_at);
82
+ return { success: true, data: task };
83
+ }
84
+ case 'update': {
85
+ const id = String(a['id'] ?? '').trim();
86
+ if (!id)
87
+ return { success: false, data: null, error: 'id required for update' };
88
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
89
+ if (!existing)
90
+ return { success: false, data: null, error: `Task ${id} not found` };
91
+ const updates = { updated_at: now };
92
+ if (a['title'])
93
+ updates.title = String(a['title']);
94
+ if (a['description'])
95
+ updates.description = String(a['description']);
96
+ if (a['status'])
97
+ updates.status = String(a['status']);
98
+ if (a['priority'])
99
+ updates.priority = String(a['priority']);
100
+ if (a['due_date'])
101
+ updates.due_date = String(a['due_date']);
102
+ const merged = { ...existing, ...updates };
103
+ db.prepare('UPDATE tasks SET title=?,description=?,status=?,priority=?,due_date=?,updated_at=? WHERE id=?')
104
+ .run(merged.title, merged.description, merged.status, merged.priority, merged.due_date, merged.updated_at, id);
105
+ return { success: true, data: rowToTask(merged) };
106
+ }
107
+ case 'list': {
108
+ const filter = String(a['filter'] ?? 'all');
109
+ const rows = filter === 'all'
110
+ ? db.prepare('SELECT * FROM tasks ORDER BY priority DESC, created_at DESC').all()
111
+ : db.prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC').all(filter);
112
+ return { success: true, data: rows.map(rowToTask) };
113
+ }
114
+ case 'get': {
115
+ const id = String(a['id'] ?? '').trim();
116
+ if (!id)
117
+ return { success: false, data: null, error: 'id required' };
118
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
119
+ if (!row)
120
+ return { success: false, data: null, error: `Task ${id} not found` };
121
+ return { success: true, data: rowToTask(row) };
122
+ }
123
+ case 'delete': {
124
+ const id = String(a['id'] ?? '').trim();
125
+ if (!id)
126
+ return { success: false, data: null, error: 'id required' };
127
+ const result = db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
128
+ return { success: true, data: { deleted: result.changes > 0, id } };
129
+ }
130
+ default:
131
+ return { success: false, data: null, error: `Unknown action: ${action}` };
132
+ }
133
+ },
134
+ };
@@ -0,0 +1,3 @@
1
+ // MIT License — personal-ai
2
+ // fallow-ignore-file unused-files
3
+ export {};
@@ -0,0 +1,108 @@
1
+ // MIT License — personal-ai
2
+ const SERPER_ENDPOINT = 'https://google.serper.dev/search';
3
+ const BRAVE_ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
4
+ const DDG_ENDPOINT = 'https://api.duckduckgo.com/';
5
+ async function searchSerper(query, count) {
6
+ const key = process.env['SERPER_API_KEY'];
7
+ if (!key)
8
+ throw new Error('SERPER_API_KEY not set');
9
+ const res = await fetch(SERPER_ENDPOINT, {
10
+ method: 'POST',
11
+ headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
12
+ body: JSON.stringify({ q: query, num: count }),
13
+ });
14
+ if (!res.ok)
15
+ throw new Error(`Serper HTTP ${res.status}`);
16
+ const data = await res.json();
17
+ const results = (data.organic ?? []).slice(0, count).map(r => ({
18
+ title: r.title,
19
+ url: r.link,
20
+ snippet: r.snippet ?? '',
21
+ }));
22
+ // Prepend answerBox/knowledgeGraph as a top result if present
23
+ const direct = data.answerBox?.answer ?? data.answerBox?.snippet ?? data.knowledgeGraph?.description;
24
+ if (direct) {
25
+ results.unshift({
26
+ title: data.answerBox?.title ?? data.knowledgeGraph?.title ?? 'Direct Answer',
27
+ url: '',
28
+ snippet: direct,
29
+ });
30
+ }
31
+ return results;
32
+ }
33
+ async function searchBrave(query, count) {
34
+ const key = process.env['BRAVE_SEARCH_API_KEY'];
35
+ if (!key)
36
+ throw new Error('BRAVE_SEARCH_API_KEY not set');
37
+ const url = `${BRAVE_ENDPOINT}?q=${encodeURIComponent(query)}&count=${count}`;
38
+ const res = await fetch(url, {
39
+ headers: { 'Accept': 'application/json', 'X-Subscription-Token': key },
40
+ });
41
+ if (!res.ok)
42
+ throw new Error(`Brave HTTP ${res.status}`);
43
+ const data = await res.json();
44
+ return (data.web?.results ?? []).map(r => ({ title: r.title, url: r.url, snippet: r.description ?? '' }));
45
+ }
46
+ async function searchDdg(query, count) {
47
+ const url = `${DDG_ENDPOINT}?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
48
+ const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
49
+ if (!res.ok)
50
+ throw new Error(`DDG HTTP ${res.status}`);
51
+ const data = await res.json();
52
+ const results = (data.RelatedTopics ?? [])
53
+ .filter((t) => typeof t.Text === 'string' && typeof t.FirstURL === 'string')
54
+ .slice(0, count)
55
+ .map(t => ({ title: t.Text.split(' - ')[0] ?? t.Text, url: t.FirstURL, snippet: t.Text }));
56
+ if (results.length === 0)
57
+ throw new Error('DDG returned no results');
58
+ return results;
59
+ }
60
+ // ── Orchestrator ─────────────────────────────────────────────────────────────
61
+ /** Priority: Serper → Brave → DDG. Returns up to `count` results. */
62
+ export async function webSearch(query, count = 5) {
63
+ const noSerper = !process.env['SERPER_API_KEY'];
64
+ const noBrave = !process.env['BRAVE_SEARCH_API_KEY'];
65
+ const engines = [];
66
+ if (!noSerper)
67
+ engines.push({ name: 'serper', fn: () => searchSerper(query, count) });
68
+ if (!noBrave)
69
+ engines.push({ name: 'brave', fn: () => searchBrave(query, count) });
70
+ engines.push({ name: 'duckduckgo', fn: () => searchDdg(query, count) });
71
+ let lastErr = '';
72
+ for (const engine of engines) {
73
+ try {
74
+ const results = await engine.fn();
75
+ return { success: true, data: { query, source: engine.name, results } };
76
+ }
77
+ catch (err) {
78
+ lastErr = String(err);
79
+ }
80
+ }
81
+ // All engines failed
82
+ const hint = (noSerper && noBrave)
83
+ ? 'No search API key configured. Add SERPER_API_KEY to .env — free at https://serper.dev (2500 searches, no card needed).'
84
+ : `All search engines failed: ${lastErr}`;
85
+ return { success: false, data: null, error: hint };
86
+ }
87
+ export const webSearchTool = {
88
+ definition: {
89
+ name: 'web_search',
90
+ description: 'Search web for current/live information.',
91
+ parameters: {
92
+ type: 'object',
93
+ properties: {
94
+ query: { type: 'string', description: 'Search query' },
95
+ count: { type: 'number', description: 'Results count, max 10' },
96
+ },
97
+ required: ['query'],
98
+ },
99
+ },
100
+ async execute(args) {
101
+ const a = args;
102
+ const query = String(a['query'] ?? '').trim();
103
+ if (!query)
104
+ return { success: false, data: null, error: 'query is required' };
105
+ const count = Math.min(Number(a['count'] ?? 5), 10);
106
+ return webSearch(query, count);
107
+ },
108
+ };