This commit is contained in:
kim
2026-05-03 18:56:13 +09:00
parent f92e5ef782
commit cb131e8047
12 changed files with 870 additions and 122 deletions
+15 -1
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import { ToolResult } from '../types.js';
import { getVault } from '../security/vault.js';
// ── Config ────────────────────────────────────────────────────────────────────
@@ -40,6 +41,19 @@ function getEmailConfig(accountId?: string): EmailConfig | null {
}
if (!e?.imap_host || !e?.username) return null;
// Resolve vault: references for password
let password = e.password || '';
if (password.startsWith('vault:')) {
try {
const vault = getVault(path.dirname(cfgPath));
const secret = vault.get(password.slice(6), 'email:resolve');
password = secret ? secret.expose() : '';
} catch {
password = '';
}
}
return {
imap_host: e.imap_host,
imap_port: Number(e.imap_port ?? 993),
@@ -50,7 +64,7 @@ function getEmailConfig(accountId?: string): EmailConfig | null {
smtp_secure: e.smtp_secure === true,
smtp_reject_unauthorized: e.smtp_reject_unauthorized !== false,
username: e.username,
password: e.password || '',
password,
};
} catch {
return null;
+3 -1
View File
@@ -1,7 +1,7 @@
import { ToolResult } from '../types.js';
import { shellTool } from './shell.js';
import { readTool, writeTool, editTool, listTool, deleteTool, renameTool, copyTool, mkdirTool, statTool, appendTool, applyPatchTool } from './files.js';
import { webSearchTool, webFetchTool } from './web.js';
import { webSearchTool, webFetchTool, ollamaWebSearchTool, ollamaWebFetchTool } from './web.js';
import { memorySearchTool, memoryWriteTool } from './memory.js';
import { memoryReadTool } from './memory-read.js';
import { memoryFileSearchTool } from './memory-file-search.js';
@@ -147,6 +147,8 @@ class ToolRegistry {
// Web tools
this.registerSafe(webSearchTool);
this.registerSafe(webFetchTool);
this.registerSafe(ollamaWebSearchTool);
this.registerSafe(ollamaWebFetchTool);
// Memory tools
this.registerSafe(memoryWriteTool);
this.registerSafe(memorySearchTool);
+2 -1
View File
@@ -1,4 +1,3 @@
import PTYManager from '../gateway/pty-manager';
import path from 'path';
import { getConfig } from '../config/config.js';
import { ToolResult } from '../types.js';
@@ -104,6 +103,8 @@ export async function executeShell(args: ShellToolArgs): Promise<ToolResult> {
}
try {
// Lazy-load node-pty (63MB native module) only when shell is actually invoked
const { default: PTYManager } = await import('../gateway/pty-manager.js');
const pty = PTYManager.getInstance();
const output = await pty.runCommand(args.command);
return {
+127 -33
View File
@@ -25,7 +25,7 @@ function stripHtml(html: string, preserveStructure = false): string {
type StructuredSource = { id: number; tier: 'A' | 'B' | 'C'; title: string; url: string; snippet: string; score: number };
type StructuredEvidence = { id: number; source_id: number; excerpt: string; score: number };
type StructuredFact = { id: number; claim: string; evidence_ids: number[]; source_ids: number[]; confidence: number };
type SearchProvider = 'tavily' | 'google' | 'brave' | 'ddg' | 'ddg_html' | 'ollama';
type SearchProvider = 'tavily' | 'google' | 'brave' | 'ddg' | 'ddg_html' | 'searxng';
type SearchProviderAttempt = {
provider: SearchProvider;
status: 'success' | 'failed' | 'skipped';
@@ -35,8 +35,8 @@ type SearchProviderAttempt = {
};
type SearchDiagnostics = {
query: string;
preferred_provider: 'tavily' | 'google' | 'brave' | 'ddg';
provider_order: Array<'tavily' | 'google' | 'brave' | 'ddg'>;
preferred_provider: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng';
provider_order: Array<'tavily' | 'google' | 'brave' | 'ddg' | 'searxng'>;
attempted: SearchProviderAttempt[];
selected_provider?: SearchProvider;
};
@@ -465,7 +465,7 @@ function rankResults(query: string, results: SearchResultItem[]) {
// ── Load optional API keys from ~/.smallclaw/config.json ─────────────────────
// Cached with 5-minute TTL so config changes are picked up without restart.
type SearchConfig = { preferred: 'tavily' | 'google' | 'brave' | 'ddg' | 'ollama'; tavilyKey?: string; googleKey?: string; googleCx?: string; braveKey?: string; ollamaEndpoint?: string; ollamaModel?: string };
type SearchConfig = { preferred: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng'; tavilyKey?: string; googleKey?: string; googleCx?: string; braveKey?: string; searxngUrl?: string };
let _searchConfigCache: { value: SearchConfig; expiresAt: number } | null = null;
function getSearchConfig(): SearchConfig {
@@ -478,15 +478,15 @@ function getSearchConfig(): SearchConfig {
if (fs.existsSync(cfg)) {
const data = JSON.parse(fs.readFileSync(cfg, 'utf-8'));
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
const preferred = (['tavily', 'google', 'brave', 'ddg', 'ollama'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
const searxngRaw = typeof data.search?.searxng_url === 'string' ? data.search.searxng_url.trim().replace(/\/+$/, '') : '';
value = {
preferred,
tavilyKey: data.search?.tavily_api_key,
googleKey: data.search?.google_api_key,
googleCx: data.search?.google_cx,
braveKey: data.search?.brave_api_key,
ollamaEndpoint: data.search?.ollama_endpoint || data.ollama?.endpoint || 'http://localhost:11434',
ollamaModel: data.search?.ollama_model || data.models?.primary || data.ollama?.providers?.ollama?.model || '',
tavilyKey: data.search?.tavily_api_key,
googleKey: data.search?.google_api_key,
googleCx: data.search?.google_cx,
braveKey: data.search?.brave_api_key,
searxngUrl: searxngRaw || undefined,
};
}
} catch {}
@@ -590,6 +590,34 @@ async function searchBrave(query: string, limit: number, apiKey: string): Promis
};
}
// ── SearXNG (self-hosted/public metasearch, no key) ──────────────────────────
async function searchSearXNG(query: string, limit: number, baseUrl: string): Promise<ToolResult> {
const base = baseUrl.replace(/\/+$/, '');
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json`;
const res = await fetch(url, {
headers: { 'Accept': 'application/json', 'User-Agent': 'SmallClaw/1.0' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`SearXNG HTTP ${res.status}`);
const data: any = await res.json();
const raw: SearchResultItem[] = (data.results || []).slice(0, limit).map((r: any) => ({
title: r.title || '',
url: r.url || '',
snippet: r.content || '',
}));
const ranked = rankResults(query, raw);
const answer = buildDirectPriceAnswer(query, ranked);
return {
success: true,
data: { query, results: ranked, answer: answer || undefined },
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r, i) =>
`[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 400)}`
).join('\n\n'),
};
}
// ── DuckDuckGo JSON endpoint (no key, more stable than HTML scrape) ───────────
async function searchDDG(query: string, limit: number): Promise<ToolResult> {
// DDG instant answer API — gives structured results without scraping HTML
@@ -770,30 +798,11 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
const cfg = getSearchConfig();
// Ollama provider: model-assisted search (bypasses normal provider loop)
if (cfg.preferred === 'ollama') {
const endpoint = cfg.ollamaEndpoint || 'http://localhost:11434';
const model = cfg.ollamaModel || '';
if (!model) {
console.warn('[web_search] ollama provider: no model configured, falling back to DDG');
} else {
const started = Date.now();
try {
const res = await searchOllama(args.query, limit, endpoint, model);
res.data = { ...(res.data || {}), provider: 'ollama', duration_ms: Date.now() - started };
return res;
} catch (err: any) {
console.warn('[web_search] ollama search failed, falling back to DDG:', err.message);
}
}
}
const candidates: Array<'tavily' | 'google' | 'brave' | 'ddg'> = ['tavily', 'google', 'brave', 'ddg'];
const preferredFallback = cfg.preferred === 'ollama' ? 'ddg' : cfg.preferred as 'tavily' | 'google' | 'brave' | 'ddg';
const providerOrder = [preferredFallback, ...candidates.filter(p => p !== preferredFallback)];
const candidates: Array<'tavily' | 'searxng' | 'google' | 'brave' | 'ddg'> = ['tavily', 'searxng', 'google', 'brave', 'ddg'];
const providerOrder = [cfg.preferred, ...candidates.filter(p => p !== cfg.preferred)];
const diagnostics: SearchDiagnostics = {
query: args.query,
preferred_provider: preferredFallback,
preferred_provider: cfg.preferred,
provider_order: providerOrder,
attempted: [],
};
@@ -812,6 +821,10 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_brave_api_key' });
continue;
}
if (provider === 'searxng' && !cfg.searxngUrl) {
diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_searxng_url' });
continue;
}
const started = Date.now();
try {
@@ -829,6 +842,20 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
res.data = { ...(res.data || {}), provider: 'tavily', search_diagnostics: diagnostics };
return res;
}
if (provider === 'searxng') {
const res = await searchSearXNG(args.query, limit, cfg.searxngUrl as string);
await augmentEventContract(args.query, res);
const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0;
diagnostics.attempted.push({
provider,
status: 'success',
duration_ms: Date.now() - started,
result_count: resultCount,
});
diagnostics.selected_provider = 'searxng';
res.data = { ...(res.data || {}), provider: 'searxng', search_diagnostics: diagnostics };
return res;
}
if (provider === 'google') {
const res = await searchGoogle(args.query, limit, cfg.googleKey as string, cfg.googleCx as string);
await augmentEventContract(args.query, res);
@@ -968,3 +995,70 @@ export const webFetchTool = {
max_chars: 'number (optional, default 10000) - Max characters to return',
},
};
function getOllamaConfig(): { endpoint: string; model: string } {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const endpoint = String(data.ollama?.endpoint || 'http://localhost:11434');
const model = String(data.llm?.providers?.ollama?.model || data.models?.primary || 'llama3');
return { endpoint, model };
}
} catch {}
return { endpoint: 'http://localhost:11434', model: 'llama3' };
}
export const ollamaWebSearchTool = {
name: 'ollama_web_search',
description: 'Ollama 모델이 DuckDuckGo 웹 검색을 수행하고 결과를 종합해서 자연어로 반환합니다. 일반 web_search와 달리 모델이 검색 결과를 해석·요약해서 답변합니다.',
schema: {
query: 'string (required) - 검색 질문 또는 키워드',
max_results: 'number (optional, default 5) - 최대 검색 결과 수',
},
execute: async (args: { query: string; max_results?: number }): Promise<ToolResult> => {
if (!args.query?.trim()) return { success: false, error: 'query is required' };
const { endpoint, model } = getOllamaConfig();
const limit = Math.min(args.max_results ?? 5, 10);
try {
return await searchOllama(args.query, limit, endpoint, model);
} catch (err: any) {
return { success: false, error: `ollama_web_search failed: ${err.message}` };
}
},
};
export const ollamaWebFetchTool = {
name: 'ollama_web_fetch',
description: 'URL을 가져온 후 Ollama 모델이 내용을 요약합니다. web_fetch로 가져온 원문 대신 모델이 핵심만 정리해서 반환합니다.',
schema: {
url: 'string (required) - 가져올 URL (https:// 포함)',
instruction: 'string (optional) - 요약 지시 (예: "주요 수치만 뽑아줘", "3줄 요약")',
},
execute: async (args: { url: string; instruction?: string }): Promise<ToolResult> => {
if (!args.url?.trim()) return { success: false, error: 'url is required' };
const { endpoint, model } = getOllamaConfig();
try {
const fetchResult = await executeWebFetch({ url: args.url, max_chars: 8000 });
if (!fetchResult.success) return fetchResult;
const pageText = fetchResult.stdout || '';
const prompt = args.instruction
? `다음 웹 페이지 내용을 읽고 "${args.instruction}":\n\n${pageText}`
: `다음 웹 페이지 내용을 핵심 위주로 간결하게 요약해줘:\n\n${pageText}`;
const res = await fetch(`${endpoint}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: false }),
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
const data: any = await res.json();
const answer = String(data.message?.content || '');
if (!answer) throw new Error('Ollama returned empty response');
return { success: true, stdout: answer, data: { url: args.url, provider: 'ollama', answer } };
} catch (err: any) {
return { success: false, error: `ollama_web_fetch failed: ${err.message}` };
}
},
};