v4.3.37: 날씨 지도 블랙스크린·스트리밍 레이스 수정, 태풍 예상진로(KMA/NHC) 지도 추가

- Windy iframe이 실제 로딩보다 훨씬 먼저 스피너를 걷어 몇 초간 완전 블랙스크린으로
  보이던 문제 수정 (반투명 오버레이 + 고정 타이머)
- 스트리밍 중 Windy URL이 아직 다 도착 안 한 상태에서 잘린 좌표로 지도를
  이동시키고 잠가버리던 레이스 컨디션 수정
- Windy는 태풍 예상 진로(향후 경로)를 그리지 않는다는 걸 명확히 하고, 실제로
  경로를 그리는 기상청 태풍 페이지 + NOAA/NHC 페이지를 지도에 임베드하는 기능 추가
  (nhc_active_storms 신규 도구로 이름→공식 forecast-track URL 조회, 대양/슬롯
  번호를 모델이 추측하지 않도록 함)
- 채팅창에 원문 도구호출(web_search{...} 등)이 그대로 노출되던 누출 수정 —
  기존 정리 로직은 메시지 전체/끝부분만 잡아서 중간에 낀 누출은 못 걸렀음
- Windy/NHC URL 정규식이 마크다운 괄호·별표까지 삼켜 URL을 깨뜨리던 과매칭 버그 수정
- 모델이 NHC URL의 슬롯 번호를 잘못 베끼는 경우에 대비해, 도구가 찾은 검증된
  URL을 직접 스트림에 실어보내 지도가 항상 정확한 폭풍을 가리키도록 함

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-30 13:02:56 +09:00
co-authored by Claude Opus 5
parent 52a42e26f0
commit b61fcd1889
8 changed files with 371 additions and 66 deletions
+4
View File
@@ -161,3 +161,7 @@ tests/.golden-progress.log
.DS_Store
Thumbs.db
desktop.ini
# Valetudo 로봇청소기 (2호기 설치용 보관)
valetudo-helper-miioota
voicepack_ko/
+15 -2
View File
@@ -979,16 +979,29 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
type: 'function',
function: {
name: 'weather_map_screenshot',
description: 'Capture a real screenshot of a rendered Windy weather map (radar, satellite, wind, precipitation, etc.) so you can actually SEE it before describing it. Call this with the exact embed.windy.com URL you just generated — do not guess what the map shows from the URL parameters alone.',
description: 'Capture a real screenshot of a rendered weather map — the Windy embed (radar, satellite, wind, precipitation, etc.), the KMA typhoon forecast-track page, or an NHC forecast-track page (URL from nhc_active_storms) — so you can actually SEE it before describing it. Call this with the exact URL you just generated — do not guess what the map shows from the URL parameters alone.',
parameters: {
type: 'object', required: ['url'],
properties: {
url: { type: 'string', description: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)' },
url: { type: 'string', description: 'The exact https://embed.windy.com/embed2.html?... URL, the KMA typhoon track URL, or an https://www.nhc.noaa.gov/graphics_XXN.shtml URL from nhc_active_storms (must match the one shown to the user)' },
wait_ms: { type: 'number', description: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)' },
},
},
},
},
{
type: 'function',
function: {
name: 'nhc_active_storms',
description: 'Look up NOAA/NHC active tropical cyclones outside Korea\'s warning area (Atlantic, Eastern Pacific, Central Pacific hurricanes/storms) and get the official forecast-track-cone page URL for a specific storm by name. Use this before showing a non-Korean storm\'s forecast track — never guess the graphics_XXN.shtml URL yourself, the basin/slot number in it isn\'t derivable from the name.',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Storm name to look up, e.g. "Genevieve" (case-insensitive substring match). Omit to list all currently active storms across all basins.' },
},
},
},
},
{
type: 'function',
function: {
+8
View File
@@ -1398,6 +1398,14 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
const imgMatch = _resultText.match(/!\[[^\]]*\]\(\/api\/files\/satellite-images\/[^)]+\)/);
if (imgMatch) sendSSE?.('token', { text: `\n${imgMatch[0]}\n` });
}
// nhc_active_storms: observed the model retyping the forecast-track URL with a
// wrong basin/slot digit (e.g. graphics_ep2.shtml → graphics_ep5.shtml) instead of
// copying it verbatim — silently pointing at an unrelated or dissipated storm. When
// exactly one storm matched, the tool exposes its own verified URL via tr.data.url;
// push that directly so the map is right regardless of what the model's prose says.
if (name === 'nhc_active_storms' && tr.success && typeof tr.data?.url === 'string') {
sendSSE?.('token', { text: `\n${tr.data.url}\n` });
}
return { name, args, result: _resultText, error: !tr.success, ...(_hasImageMd ? { isImage: true } : {}), ...(tr.data !== undefined ? { data: tr.data } : {}) };
}
}
+1
View File
@@ -182,6 +182,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// model there has already generated the embed.windy.com URL to capture) — unlike the
// other weather tools, not offered via the meteorologist skill in general chat.
if (name === 'weather_map_screenshot' && !isWeatherAppSession) return false;
if (name === 'nhc_active_storms' && !isWeatherAppSession) return false;
if (ollamaWebToolNames.has(name) && !hasOllamaWebKeyword) return false;
if (legalToolNames.has(name) && !isLawyerAppSession && !(lawyerEnabled && hasLegalKeyword)) return false;
if ((name === 'excel_read' || name === 'excel_write') && !isAccountantAppSession && !hasExcelKeyword) return false;
+75
View File
@@ -243,6 +243,69 @@ function parseOrphanTrailingToolJson(content: string, tools: any[] | undefined):
return null;
}
/**
* Strip a leaked raw tool-call invocation (e.g. literal `web_search{"query":"..."}` typed
* as text) from ANYWHERE in the content, even with substantial real prose both before and
* after it. parseEmbeddedToolCalls/parseOrphanTrailingToolJson above only fire when the
* whole message (or its very end) is essentially just the call — they intentionally bail
* out the moment there's real surrounding prose, so a model that leaks a malformed call
* mid-response and then keeps writing a normal answer around it slips past both. This is
* safe to run unconditionally because — unlike those two, which match any lowercase
* identifier — it only touches a span whose identifier is an EXACT registered tool name,
* so it can't mistake a code sample or JSON example in a legitimate answer for a leak.
*/
function stripEmbeddedToolCallLeaks(content: string, tools: any[] | undefined): string {
if (!Array.isArray(tools) || !tools.length || !content.includes('{')) return content;
const toolNames = new Set(tools.map((t: any) => t?.function?.name).filter(Boolean));
if (!toolNames.size) return content;
const nameRe = /\b([a-z][a-z0-9_]*)\{/g;
let out = '';
let pos = 0;
let m: RegExpExecArray | null;
let changed = false;
while ((m = nameRe.exec(content)) !== null) {
const matchIndex = m.index, matchLen = m[0].length, toolName = m[1];
if (matchIndex < pos) continue; // inside a span already consumed
if (!toolNames.has(toolName)) continue;
const jsonStart = matchIndex + matchLen - 1;
let depth = 0, inStr = false, escaped = false, i = jsonStart;
for (; i < content.length; i++) {
const ch = content[i];
if (escaped) { escaped = false; continue; }
if (ch === '\\' && inStr) { escaped = true; continue; }
if (ch === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (ch === '{') depth++;
else if (ch === '}') { depth--; if (depth === 0) break; }
}
if (depth !== 0 || i >= content.length) continue; // unterminated — leave as-is
const jsonStr = content.slice(jsonStart, i + 1);
let args: any;
try { args = JSON.parse(jsonStr); } catch { continue; }
if (typeof args !== 'object' || Array.isArray(args) || args === null) continue;
const tool = tools.find((t: any) => t?.function?.name === toolName);
const params = tool?.function?.parameters;
const props = params?.properties ? Object.keys(params.properties) : [];
const required = Array.isArray(params?.required) ? params.required : [];
const keys = Object.keys(args);
if (!required.every((r: string) => keys.includes(r))) continue;
if (!keys.every((k) => props.includes(k))) continue;
// Genuine leak — excise the "name{...}" span entirely.
out += content.slice(pos, matchIndex);
pos = i + 1;
changed = true;
}
if (!changed) return content;
out += content.slice(pos);
return out.replace(/[ \t]{2,}/g, ' ').trim();
}
/** Repair malformed JSON: close truncated brackets, strip trailing garbage. */
function repairJson(input: string): string {
let s = input.trim();
@@ -442,6 +505,12 @@ export class OllamaAdapter implements LLMProvider {
}
}
}
// Catches a leaked call sitting in the MIDDLE of otherwise-normal prose (the two
// checks above only fire when the whole message, or its tail, is basically just
// the call — a model that leaks one and then keeps writing past it slips through).
if (message.content) {
message.content = stripEmbeddedToolCallLeaks(String(message.content), options?.tools);
}
// If we got no content and no tool_calls, try the next think candidate.
// Some models (e.g. glm-5.1:cloud) default to thinking-only mode when the
@@ -626,6 +695,12 @@ export class OllamaAdapter implements LLMProvider {
}
}
}
// Catches a leaked call sitting in the MIDDLE of otherwise-normal prose (the two
// checks above only fire when the whole message, or its tail, is basically just
// the call — a model that leaks one and then keeps writing past it slips through).
if (message.content) {
message.content = stripEmbeddedToolCallLeaks(String(message.content), options?.tools);
}
// If we got no content and no tool_calls, try the next think candidate.
// Some models (e.g. glm-5.1:cloud) default to thinking-only mode when the
+2 -1
View File
@@ -21,7 +21,7 @@ import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '.
import { audioTranscribeTool } from './audio-transcribe.js';
import { pythonEvalTool } from './python.js';
import { sqliteTool } from './sqlite.js';
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.js';
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool, nhcActiveStormsTool } from './weather.js';
import { eonetEventsTool, satelliteSnapshotTool } from './satellite.js';
import { newsSearchTool } from './news.js';
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
@@ -224,6 +224,7 @@ class ToolRegistry {
this.registerSafe(weatherCdsTool);
this.registerSafe(weatherCmip6Tool);
this.registerSafe(weatherMapScreenshotTool);
this.registerSafe(nhcActiveStormsTool);
// NASA EONET (natural event tracker) + Worldview Snapshots (satellite imagery) — both public, no API key
this.registerSafe(eonetEventsTool);
this.registerSafe(satelliteSnapshotTool);
+152 -47
View File
@@ -3,6 +3,7 @@ import { getConfig } from '../config/config.js';
import { getVault } from '../security/vault.js';
import { getPaths, getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown } from './image.js';
import { splitOpenMeteoVariables, formatOpenMeteoReport } from './weather-openmeteo-format.js';
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
@@ -176,29 +177,40 @@ function parseExplicitCountry(name: string): { city: string; countryCode?: strin
return { city: name };
}
async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> {
export async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> {
const key = name.trim().toLowerCase();
if (KR_CITY_COORDS[key]) {
const [lat, lon] = KR_CITY_COORDS[key];
return { lat, lon, displayName: name };
}
const { city, countryCode } = parseExplicitCountry(name);
try {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const data: any = await res.json();
const results: any[] = data.results || [];
if (!results.length) return null;
// Prefer an explicit country match; otherwise fall back to the most populous
// candidate rather than index 0 (Open-Meteo often ranks a small US namesake
// above the internationally-known city for a bare query like "Rome").
let r = countryCode ? results.find((x: any) => String(x.country_code || '').toUpperCase() === countryCode) : undefined;
if (!r) r = results.reduce((best: any, cur: any) => (cur.population || 0) > (best.population || 0) ? cur : best, results[0]);
return { lat: r.latitude, lon: r.longitude, displayName: `${r.name}${r.country ? ', ' + r.country : ''}` };
} catch {
return null;
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`;
// Open-Meteo geocoding throws a transient network/5xx blip now and then that
// clears on an immediate retry. Observed 2026-07-29: a single Madrid call in
// the middle of a 6-city burst returned "위치를 찾을 수 없습니다" while every
// surrounding call and 20/20 direct retries succeeded — a one-off blip, not a
// real miss. Without a retry that blip hardens into a permanent "city not
// found" in the answer. Retry only on transient causes (throw / non-ok); a
// 200 with no results is a genuine "not found" and returns immediately.
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`geocoding HTTP ${res.status}`);
const data: any = await res.json();
const results: any[] = data.results || [];
if (!results.length) return null; // API answered "not found" — retrying won't change it
// Prefer an explicit country match; otherwise fall back to the most populous
// candidate rather than index 0 (Open-Meteo often ranks a small US namesake
// above the internationally-known city for a bare query like "Rome").
let r = countryCode ? results.find((x: any) => String(x.country_code || '').toUpperCase() === countryCode) : undefined;
if (!r) r = results.reduce((best: any, cur: any) => (cur.population || 0) > (best.population || 0) ? cur : best, results[0]);
return { lat: r.latitude, lon: r.longitude, displayName: `${r.name}${r.country ? ', ' + r.country : ''}` };
} catch (err: any) {
if (attempt >= 3) break;
await new Promise(r => setTimeout(r, 300 * attempt)); // 300ms, 600ms
}
}
return null;
}
function parseCoords(location: string): [number, number] | null {
@@ -556,18 +568,20 @@ const WMO_CODES: Record<number, string> = {
export const weatherOpenMeteoTool = {
name: 'weather_openmeteo',
description: 'Get detailed hourly weather forecast using Open-Meteo API (free, no API key). Powered by ECMWF, GFS, and other NWP models. Supports temperature, precipitation, wind, UV index, soil temp, and more for up to 16 days.',
description: 'Get weather forecast using Open-Meteo API (free, no API key). Powered by ECMWF, GFS, and other NWP models. Times are returned in the LOCATION\'s local timezone. For "today\'s high/low" use daily variables (temperature_2m_max, temperature_2m_min, precipitation_sum, …) — the hourly series is a rolling window from now and spans two calendar days, so its maximum is NOT the daily high. Hourly variables (temperature_2m, precipitation, wind_speed_10m, uv_index, weather_code, …) are for intra-day detail. Up to 16 days.',
schema: {
location: 'City name (Korean or English) or "lat,lon" coordinates',
hours: 'Number of hours to forecast (default: 24, max: 384)',
variables: 'Comma-separated Open-Meteo hourly variables (default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code)',
hours: 'Hours of hourly forecast (default: 24, max: 384)',
days: 'Days of daily forecast when daily variables are requested (default: 3, max: 16)',
variables: 'Comma-separated Open-Meteo variables. Daily ones (temperature_2m_max/min, precipitation_sum, sunrise, …) are routed to the daily block automatically; everything else is treated as hourly. Default: temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code',
},
jsonSchema: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name or "lat,lon"' },
hours: { type: 'number', description: 'Hours ahead (1–384, default 24)' },
variables: { type: 'string', description: 'Comma-separated hourly variable names' },
days: { type: 'number', description: 'Days ahead for daily variables (1–16, default 3)' },
variables: { type: 'string', description: 'Comma-separated variable names (daily and hourly may be mixed)' },
},
required: ['location'],
additionalProperties: false,
@@ -577,7 +591,12 @@ export const weatherOpenMeteoTool = {
if (!location) return { success: false, error: 'location is required' };
const hours = Math.min(Math.max(1, Math.round(Number(args?.hours) || 24)), 384);
const days = Math.min(Math.max(1, Math.round(Number(args?.days) || 3)), 16);
const vars = String(args?.variables || 'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code');
// Daily-only names used to be posted to `hourly=`, which Open-Meteo accepts — it returns
// an hourly series under a daily name, so `temperature_2m_max` produced numbers that were
// not maxima at all. Route them before the request instead.
const { hourly: hourlyVars, daily: dailyVars } = splitOpenMeteoVariables(vars);
try {
let lat: number, lon: number, displayName: string;
@@ -594,34 +613,41 @@ export const weatherOpenMeteoTool = {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
hourly: vars,
forecast_hours: String(hours),
timezone: 'Asia/Seoul',
// 'auto' resolves the timezone from the coordinates. This was hardcoded to
// Asia/Seoul, which stamped every foreign forecast with Korean day boundaries and
// made "today's high" unanswerable for anywhere outside KST.
timezone: 'auto',
wind_speed_unit: 'ms',
});
if (hourlyVars.length) {
params.set('hourly', hourlyVars.join(','));
params.set('forecast_hours', String(hours));
}
if (dailyVars.length) {
params.set('daily', dailyVars.join(','));
params.set('forecast_days', String(days));
}
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`Open-Meteo API error ${res.status}`);
const data: any = await res.json();
const times: string[] = data.hourly?.time || [];
const varKeys = vars.split(',').map(v => v.trim());
const units = data.hourly_units || {};
const SHOW = Math.min(hours, 48);
const headerCols = ['시각', ...varKeys.map(k => k.replace('_', ' '))];
const rows = times.slice(0, SHOW).map((t, i) => {
const vals = varKeys.map(k => {
const v = data.hourly?.[k]?.[i];
if (v == null) return '-';
const text = formatOpenMeteoReport({
displayName,
timezone: data.timezone || 'auto',
utcOffsetSeconds: Number(data.utc_offset_seconds) || 0,
hours,
hourlyVars,
dailyVars,
hourlyBlock: data.hourly,
dailyBlock: data.daily,
hourlyUnits: data.hourly_units || {},
dailyUnits: data.daily_units || {},
renderValue: (k, v) => {
if (k === 'weather_code') return WMO_CODES[v as number] ?? String(v);
if (k === 'wind_direction_10m') return `${windDirectionKo(v)}(${v}°)`;
if (k === 'wind_direction_10m' || k === 'wind_direction_10m_dominant') return `${windDirectionKo(v)}(${v}°)`;
return String(v);
});
return [t.replace('T', ' '), ...vals].join('\t');
},
});
const unitLine = `단위: ${varKeys.map(k => `${k}=${units[k] || '-'}`).join(', ')}`;
const text = [`Open-Meteo 예보: ${displayName} (${hours}시간)`, unitLine, '', headerCols.join('\t'), ...rows].join('\n');
return { success: true, stdout: text, data: { location: displayName, lat, lon, raw: data } };
} catch (err: any) {
return { success: false, error: err.message };
@@ -1517,17 +1543,27 @@ async function getMapBrowser(): Promise<any> {
return _mapBrowser;
}
// KMA's typhoon page is the only place that actually plots a forecast track (the
// cone-of-uncertainty + path line) — Windy's embed never does. Allowlisted by exact
// path (not just hostname) since weather.go.kr hosts far more than this one page.
const KMA_TYPHOON_TRACK_URL = 'https://www.weather.go.kr/w/typhoon/ko/weather/typhoon_02.jsp';
// NOAA/NHC's equivalent for storms outside KMA's warning area. The URL is basin+slot
// specific (graphics_ep2.shtml, graphics_at1.shtml, ...) so it can't be a single fixed
// string like KMA's — nhc_active_storms resolves the right one, this just validates the
// shape stays within nhc.noaa.gov's own graphics pages (no arbitrary path/host).
const NHC_TRACK_URL_RE = /^https:\/\/www\.nhc\.noaa\.gov\/graphics_[a-z]+\d\.shtml(?:\?.*)?$/;
export const weatherMapScreenshotTool = {
name: 'weather_map_screenshot',
description: 'Capture a real screenshot of a rendered Windy weather map (radar, satellite, wind, precipitation, etc.) so you can actually SEE it before describing it. Call this with the exact embed.windy.com URL you just generated — do not guess what the map shows from the URL parameters alone.',
description: 'Capture a real screenshot of a rendered weather map — the Windy embed (radar, satellite, wind, precipitation, etc.), the KMA typhoon forecast-track page, or an NHC forecast-track page (from nhc_active_storms) — so you can actually SEE it before describing it. Call this with the exact URL you just generated — do not guess what the map shows from the URL parameters alone.',
schema: {
url: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)',
url: `The exact https://embed.windy.com/embed2.html?... URL, the literal ${KMA_TYPHOON_TRACK_URL} for KMA, or an https://www.nhc.noaa.gov/graphics_XXN.shtml URL from nhc_active_storms`,
wait_ms: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)',
},
jsonSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'The exact embed.windy.com URL to capture' },
url: { type: 'string', description: 'The exact embed.windy.com, KMA typhoon track, or NHC graphics_XXN.shtml URL to capture' },
wait_ms: { type: 'number', description: 'Wait time in ms for tiles to load (default 4000, max 8000)' },
},
required: ['url'],
@@ -1539,8 +1575,10 @@ export const weatherMapScreenshotTool = {
let parsed: URL;
try { parsed = new URL(urlStr); } catch { return { success: false, error: 'Invalid URL' }; }
if (parsed.hostname !== 'embed.windy.com') {
return { success: false, error: 'Only embed.windy.com URLs can be captured' };
const isKmaTrack = urlStr === KMA_TYPHOON_TRACK_URL;
const isNhcTrack = NHC_TRACK_URL_RE.test(urlStr);
if (parsed.hostname !== 'embed.windy.com' && !isKmaTrack && !isNhcTrack) {
return { success: false, error: `Only embed.windy.com, ${KMA_TYPHOON_TRACK_URL}, or nhc.noaa.gov graphics_XXN.shtml URLs can be captured` };
}
const waitMs = Math.min(Math.max(Number(args?.wait_ms) || 4000, 1000), 8000);
@@ -1566,11 +1604,14 @@ export const weatherMapScreenshotTool = {
const filePath = path.join(outDir, `windy-${Date.now()}.jpg`);
fs.writeFileSync(filePath, buf);
const overlay = parsed.searchParams.get('overlay') || 'map';
const zoom = parsed.searchParams.get('zoom') || '?';
const label = isKmaTrack
? 'KMA 태풍 예상진로 페이지 캡처 완료'
: isNhcTrack
? 'NHC 허리케인 예상진로 페이지 캡처 완료'
: `Windy 지도 캡처 완료 (overlay=${parsed.searchParams.get('overlay') || 'map'}, zoom=${parsed.searchParams.get('zoom') || '?'})`;
return {
success: true,
stdout: `Windy 지도 캡처 완료 (overlay=${overlay}, zoom=${zoom}):\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`,
stdout: `${label}:\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`,
};
} catch (err: any) {
return { success: false, error: `지도 캡처 실패: ${err.message}` };
@@ -1579,3 +1620,67 @@ export const weatherMapScreenshotTool = {
}
},
};
// ── nhc_active_storms (NOAA/NHC forecast-track lookup for non-Korean storms) ──────
// KMA's typhoon page (KMA_TYPHOON_TRACK_URL above) only ever shows storms in KMA's own
// warning area — a hurricane near Mexico never appears there. NHC's forecast-cone page
// URL is basin+slot specific (e.g. graphics_ep2.shtml = 2nd Eastern Pacific storm this
// season) and that slot number isn't guessable from a storm's name alone. NHC publishes
// a small official JSON feed of all currently active storms with their basin/slot and
// the exact forecast-graphics URL already resolved — this tool just looks a name up in it,
// so the model never has to guess or scrape for the right graphics_XXN.shtml URL.
export const nhcActiveStormsTool = {
name: 'nhc_active_storms',
description: 'Look up NOAA/NHC (National Hurricane Center) active tropical cyclones outside Korea\'s warning area (Atlantic, Eastern Pacific, Central Pacific hurricanes/storms) and get the official forecast-track-cone page URL for a specific storm by name. Use this before showing a non-Korean storm\'s forecast track — do not guess the graphics_XXN.shtml URL yourself.',
schema: {
name: 'Storm name to look up (e.g. "Genevieve"), case-insensitive substring match. Omit to list all currently active storms across all basins.',
},
jsonSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Storm name to look up, e.g. "Genevieve". Omit to list all active storms.' },
},
required: [],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
try {
const res = await fetch('https://www.nhc.noaa.gov/CurrentStorms.json', { signal: AbortSignal.timeout(15_000) });
if (!res.ok) return { success: false, error: `NHC 조회 실패: HTTP ${res.status}` };
const data: any = await res.json();
const all = Array.isArray(data?.activeStorms) ? data.activeStorms : [];
const nameFilter = String(args?.name || '').trim().toLowerCase();
const matches = nameFilter ? all.filter((s: any) => String(s?.name || '').toLowerCase().includes(nameFilter)) : all;
if (all.length === 0) {
return { success: true, stdout: '현재 NHC(대서양·동태평양·중태평양) 관할 구역에 활성화된 열대저기압이 없습니다.' };
}
if (nameFilter && matches.length === 0) {
const names = all.map((s: any) => s.name).join(', ');
return { success: true, stdout: `"${args.name}"이라는 이름의 활성 태풍/허리케인을 NHC 목록에서 찾지 못했습니다. 현재 NHC 활성 목록: ${names}. 이름을 다시 확인하거나, 이미 소멸했을 가능성을 검토하세요.` };
}
const lines = matches.map((s: any) => {
const track = s?.forecastGraphics?.url || '(예보 그래픽 URL 없음 — 열대저압부 단계라 아직 진로 원뿔이 없을 수 있음)';
return [
`이름: ${s.name} (${s.classification || '?'}, basin/slot ${s.binNumber || '?'})`,
`위치: ${s.latitude ?? '?'}, ${s.longitude ?? '?'}`,
`세력: 최대풍속 ${s.intensity ?? '?'}kt, 중심기압 ${s.pressure ?? '?'}hPa`,
`이동: 방위각 ${s.movementDir ?? '?'}°, 시속 ${s.movementSpeed ?? '?'}kt`,
`마지막 업데이트: ${s.lastUpdate || '?'}`,
`예상진로 페이지 URL: ${track}`,
].join('\n');
});
// Models have been observed retyping this URL with a wrong digit (e.g. ep2 → ep5)
// instead of copying it verbatim, which silently points at an unrelated/dissipated
// storm. When exactly one match has a real graphics URL, expose it as structured
// data so the caller can push the tool's own (guaranteed-correct) URL directly
// instead of trusting the model's prose to reproduce it — same reasoning as the
// satellite_snapshot auto-push in execute-tool.ts.
const singleUrl = matches.length === 1 ? matches[0]?.forecastGraphics?.url : undefined;
return { success: true, stdout: lines.join('\n---\n'), ...(singleUrl ? { data: { url: singleUrl } } : {}) };
} catch (err: any) {
return { success: false, error: `NHC 조회 실패: ${err.message}` };
}
},
};
File diff suppressed because one or more lines are too long