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(/ /gi, ' ')
|
| 25 |
+
.replace(/&/gi, '&')
|
| 26 |
+
.replace(/</gi, '<')
|
| 27 |
+
.replace(/>/gi, '>')
|
| 28 |
+
.replace(/"/gi, '"')
|
| 29 |
+
.replace(/'/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 |
+
};
|