v2.3.3
This commit is contained in:
+15
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user