mo commited on
Commit
3046dfd
·
1 Parent(s): 20765cf

feat(tools): persistent memory + web research tools for the agent

Browse files
apps/desktop/src/tools/index.js CHANGED
@@ -9,8 +9,10 @@ import { files } from './files.js';
9
  import { net } from './net.js';
10
  import { apps } from './apps.js';
11
  import { browser } from './browser.js';
 
 
12
 
13
- const BUILTIN = [sysinfo, shell, files, net, apps, browser];
14
  const TIMEOUT_MS = 30_000;
15
 
16
  class ToolRegistry {
 
9
  import { net } from './net.js';
10
  import { apps } from './apps.js';
11
  import { browser } from './browser.js';
12
+ import { web } from './web.js';
13
+ import { memory } from './memory.js';
14
 
15
+ const BUILTIN = [sysinfo, shell, files, net, apps, browser, web, memory];
16
  const TIMEOUT_MS = 30_000;
17
 
18
  class ToolRegistry {
apps/desktop/src/tools/memory.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Persistent memory — the agent remembers across tasks and restarts.
2
+ // Plain markdown files under ~/.mona-agent/memory/.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ const MEM_DIR = process.env.MONA_MEMORY_DIR || path.join(os.homedir(), '.mona-agent', 'memory');
9
+ const MAX_NOTE = 4000;
10
+
11
+ function ensureDir() {
12
+ fs.mkdirSync(MEM_DIR, { recursive: true });
13
+ }
14
+
15
+ function todayFile() {
16
+ const d = new Date();
17
+ const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
18
+ return path.join(MEM_DIR, `${ymd}.md`);
19
+ }
20
+
21
+ function allFiles() {
22
+ ensureDir();
23
+ return fs.readdirSync(MEM_DIR).filter((f) => f.endsWith('.md')).map((f) => path.join(MEM_DIR, f));
24
+ }
25
+
26
+ export const memory = {
27
+ name: 'memory',
28
+ description: 'Persistent memory across tasks: remember facts, recall/search them, list recent notes.',
29
+ args: {
30
+ action: 'string — remember | recall | list',
31
+ note: 'string — text to remember (for remember)',
32
+ query: 'string — search terms (for recall)',
33
+ },
34
+ platform: 'any',
35
+
36
+ async run(args) {
37
+ const action = String(args.action || 'recall').toLowerCase();
38
+
39
+ if (action === 'remember') {
40
+ const note = String(args.note || '').trim().slice(0, MAX_NOTE);
41
+ if (!note) return { error: 'note required' };
42
+ ensureDir();
43
+ const stamp = new Date().toISOString();
44
+ fs.appendFileSync(todayFile(), `\n## ${stamp}\n${note}\n`);
45
+ return { ok: true, stored: todayFile(), chars: note.length };
46
+ }
47
+
48
+ if (action === 'list') {
49
+ ensureDir();
50
+ const files = allFiles().map((f) => ({
51
+ file: path.basename(f),
52
+ bytes: fs.statSync(f).size,
53
+ }));
54
+ return { files: files.reverse().slice(0, 14) };
55
+ }
56
+
57
+ // recall (default): substring search across memory files, newest first
58
+ const query = String(args.query || '').trim();
59
+ const files = allFiles().sort().reverse();
60
+ const hits = [];
61
+ for (const f of files.slice(0, 40)) {
62
+ let content;
63
+ try { content = fs.readFileSync(f, 'utf8'); } catch { continue; }
64
+ const lines = content.split('\n');
65
+ for (let i = 0; i < lines.length; i++) {
66
+ if (!query || lines[i].toLowerCase().includes(query.toLowerCase())) {
67
+ hits.push({ file: path.basename(f), line: i + 1, text: lines[i].slice(0, 300) });
68
+ if (hits.length >= 20) break;
69
+ }
70
+ }
71
+ if (hits.length >= 20) break;
72
+ }
73
+ return { query: query || '(all recent)', hits };
74
+ },
75
+ };
apps/desktop/src/tools/web.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Web research — search the web and fetch page content.
2
+ // Pure Node, no external dependencies, multi-OS.
3
+
4
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_8) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15 mona-agent/2.x';
5
+ const FETCH_TIMEOUT_MS = 15000;
6
+ const MAX_TEXT = 10000;
7
+
8
+ async function httpGet(url, headers = {}) {
9
+ const res = await fetch(url, {
10
+ headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml', ...headers },
11
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
12
+ });
13
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
14
+ return res.text();
15
+ }
16
+
17
+ /** Crude but effective HTML → text extraction (no external deps). */
18
+ export function htmlToText(html) {
19
+ let t = String(html || '');
20
+ t = t.replace(/<script[\s\S]*?<\/script>/gi, ' ')
21
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
22
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ');
23
+ t = t.replace(/<[^>]+>/g, ' ');
24
+ t = t.replace(/&nbsp;/gi, ' ')
25
+ .replace(/&amp;/gi, '&')
26
+ .replace(/&lt;/gi, '<')
27
+ .replace(/&gt;/gi, '>')
28
+ .replace(/&quot;/gi, '"')
29
+ .replace(/&#39;/gi, "'");
30
+ t = t.replace(/\s+/g, ' ').trim();
31
+ return t.slice(0, MAX_TEXT);
32
+ }
33
+
34
+ /** DuckDuckGo HTML search → [{title, url, snippet}] (no API key needed). */
35
+ export async function ddgSearch(query, max = 8) {
36
+ const q = encodeURIComponent(String(query || '').slice(0, 200));
37
+ const html = await httpGet(`https://html.duckduckgo.com/html/?q=${q}`);
38
+ const results = [];
39
+ const itemRe = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
40
+ let m;
41
+ while ((m = itemRe.exec(html)) !== null && results.length < max) {
42
+ const rawUrl = m[1];
43
+ let url = rawUrl;
44
+ if (rawUrl.startsWith('//')) url = 'https:' + rawUrl;
45
+ // DuckDuckGo redirect links: extract the real target
46
+ const uddg = rawUrl.match(/uddg=([^&]+)/);
47
+ if (uddg) {
48
+ try { url = decodeURIComponent(uddg[1]); } catch { /* keep raw */ }
49
+ }
50
+ if (!url.startsWith('http')) continue;
51
+ results.push({
52
+ title: htmlToText(m[2]).slice(0, 120),
53
+ url,
54
+ snippet: htmlToText(m[3]).slice(0, 300),
55
+ });
56
+ }
57
+ return results;
58
+ }
59
+
60
+ export const web = {
61
+ name: 'web',
62
+ description: 'Web research: search the web (DuckDuckGo) and fetch page content as text.',
63
+ args: {
64
+ action: 'string — search | fetch',
65
+ query: 'string — search terms (for search)',
66
+ url: 'string — full URL (for fetch)',
67
+ max: 'number — max results (search, default 8)',
68
+ },
69
+ platform: 'any',
70
+
71
+ async run(args) {
72
+ const action = String(args.action || 'search').toLowerCase();
73
+ if (action === 'search') {
74
+ const query = String(args.query || '').trim();
75
+ if (!query) return { error: 'query required' };
76
+ const results = await ddgSearch(query, Math.min(parseInt(args.max || 8, 10) || 8, 10));
77
+ if (!results.length) return { results: [], note: 'No results returned.' };
78
+ return { results };
79
+ }
80
+ if (action === 'fetch') {
81
+ const url = String(args.url || '').trim();
82
+ if (!/^https?:\/\//i.test(url)) return { error: 'A valid http(s) URL is required' };
83
+ const html = await httpGet(url);
84
+ const text = htmlToText(html);
85
+ return { url, textLength: text.length, text: text.slice(0, MAX_TEXT) };
86
+ }
87
+ return { error: `Unknown action: ${action} (use search | fetch)` };
88
+ },
89
+ };