v4.2.0: 날씨 지오코딩 동명 도시 국가 구분 + PTY 워크스페이스 cwd 수정
- weather.ts: "Rome"처럼 국가 미명시 동명 도시 조회시 지오코딩 API의 index 0을 그대로 믿지 않도록 개선. Open-Meteo 결과는 인구수 기준으로 재정렬해 최적 후보를 고르고, OpenWeather 쪽은 country 미명시일 때 Open-Meteo 좌표로 재조회해 이름 모호성 자체를 피함. 국가 파싱 로직 (parseExplicitCountry)을 두 경로가 공유하도록 정리. - server-v2.ts: PTY 셸 세션 생성시 cwd 기본값을 고정된 homeclaw 경로 대신 사용자별 워크스페이스로 사용, 세션 시작 전 워크스페이스 보장. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smallclaw",
|
||||
"version": "4.1.23",
|
||||
"version": "4.2.0",
|
||||
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
||||
@@ -14683,6 +14683,7 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
||||
if (!shellId || typeof shellId !== 'string') return;
|
||||
if (ptySessions.has(shellId)) return; // already spawned
|
||||
try {
|
||||
if (user?.username) { try { ensureUserWorkspace(user.username); } catch {} }
|
||||
const ptyModule = await ptyImport;
|
||||
if (!ptyModule) { ws.send(JSON.stringify({ type: 'pty_error', shellId, error: 'node-pty not available' })); return; }
|
||||
const pty = (ptyModule as any).default || ptyModule;
|
||||
@@ -14690,7 +14691,7 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
|
||||
name: 'xterm-256color',
|
||||
cols: msg.cols || 120,
|
||||
rows: msg.rows || 30,
|
||||
cwd: msg.cwd || path.join(osModule.homedir(), 'homeclaw'),
|
||||
cwd: msg.cwd || user?.workspace || process.cwd(),
|
||||
env: { ...process.env, TERM: 'xterm-256color' } as any,
|
||||
});
|
||||
ptySessions.set(shellId, proc);
|
||||
|
||||
+55
-28
@@ -113,20 +113,56 @@ const KR_CITY_COORDS: Record<string, [number, number]> = {
|
||||
'용인': [37.2411, 127.1776], 'yongin': [37.2411, 127.1776],
|
||||
};
|
||||
|
||||
// Same-named cities across countries (e.g. "Rome" → Rome, Italy vs Rome, Georgia US)
|
||||
// are common — an unqualified geocoding lookup must not blindly trust index 0.
|
||||
// Shared by geocodeCity() (Open-Meteo) and normalizeLocationForOpenWeather() (OpenWeather).
|
||||
const COUNTRY_NAME_TO_ISO: Record<string, string> = {
|
||||
italy: 'IT', france: 'FR', germany: 'DE', spain: 'ES', portugal: 'PT',
|
||||
'united kingdom': 'GB', uk: 'GB', england: 'GB', netherlands: 'NL', belgium: 'BE',
|
||||
switzerland: 'CH', austria: 'AT', poland: 'PL', sweden: 'SE', norway: 'NO',
|
||||
denmark: 'DK', finland: 'FI', greece: 'GR', ireland: 'IE', 'czech republic': 'CZ',
|
||||
czechia: 'CZ', hungary: 'HU', russia: 'RU', china: 'CN', japan: 'JP',
|
||||
'south korea': 'KR', korea: 'KR', canada: 'CA', australia: 'AU', brazil: 'BR',
|
||||
mexico: 'MX', india: 'IN', turkey: 'TR', egypt: 'EG', 'south africa': 'ZA',
|
||||
vietnam: 'VN', thailand: 'TH', indonesia: 'ID', philippines: 'PH', singapore: 'SG',
|
||||
malaysia: 'MY', 'new zealand': 'NZ', ukraine: 'UA', romania: 'RO', bulgaria: 'BG',
|
||||
croatia: 'HR', slovakia: 'SK', slovenia: 'SI', iceland: 'IS', luxembourg: 'LU',
|
||||
'united states': 'US', usa: 'US', 'united states of america': 'US',
|
||||
};
|
||||
|
||||
// Splits "City,CC" / "City,Country Name" into a bare city name plus an ISO-3166
|
||||
// country code, if the trailing part after the last comma names one.
|
||||
function parseExplicitCountry(name: string): { city: string; countryCode?: string } {
|
||||
const lastComma = name.lastIndexOf(',');
|
||||
if (lastComma < 0) return { city: name };
|
||||
const city = name.slice(0, lastComma).trim();
|
||||
const countryPart = name.slice(lastComma + 1).trim();
|
||||
if (/^[A-Za-z]{2}$/.test(countryPart)) return { city, countryCode: countryPart.toUpperCase() };
|
||||
const iso = COUNTRY_NAME_TO_ISO[countryPart.toLowerCase()];
|
||||
if (iso) return { city, countryCode: iso };
|
||||
return { city: name };
|
||||
}
|
||||
|
||||
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(name)}&count=1&language=ko&format=json`;
|
||||
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 r = data.results?.[0];
|
||||
if (!r) return null;
|
||||
return { lat: r.latitude, lon: r.longitude, displayName: r.name };
|
||||
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;
|
||||
}
|
||||
@@ -333,33 +369,14 @@ function formatForecast(data: any, units: string): string {
|
||||
// The tool schema already documents the "City,CountryCode" format, but models often write
|
||||
// the full country name anyway — normalize the common ones rather than trust every caller
|
||||
// to follow the format, and verify the response actually landed in the right country.
|
||||
const COUNTRY_NAME_TO_ISO: Record<string, string> = {
|
||||
italy: 'IT', france: 'FR', germany: 'DE', spain: 'ES', portugal: 'PT',
|
||||
'united kingdom': 'GB', uk: 'GB', england: 'GB', netherlands: 'NL', belgium: 'BE',
|
||||
switzerland: 'CH', austria: 'AT', poland: 'PL', sweden: 'SE', norway: 'NO',
|
||||
denmark: 'DK', finland: 'FI', greece: 'GR', ireland: 'IE', 'czech republic': 'CZ',
|
||||
czechia: 'CZ', hungary: 'HU', russia: 'RU', china: 'CN', japan: 'JP',
|
||||
'south korea': 'KR', korea: 'KR', canada: 'CA', australia: 'AU', brazil: 'BR',
|
||||
mexico: 'MX', india: 'IN', turkey: 'TR', egypt: 'EG', 'south africa': 'ZA',
|
||||
vietnam: 'VN', thailand: 'TH', indonesia: 'ID', philippines: 'PH', singapore: 'SG',
|
||||
malaysia: 'MY', 'new zealand': 'NZ', ukraine: 'UA', romania: 'RO', bulgaria: 'BG',
|
||||
croatia: 'HR', slovakia: 'SK', slovenia: 'SI', iceland: 'IS', luxembourg: 'LU',
|
||||
'united states': 'US', usa: 'US', 'united states of america': 'US',
|
||||
};
|
||||
// (COUNTRY_NAME_TO_ISO / parseExplicitCountry live near geocodeCity() above, shared by both paths.)
|
||||
|
||||
// Returns the OpenWeather `q` string to send plus the ISO country code we expect back
|
||||
// (undefined if we couldn't determine one — no mismatch check in that case).
|
||||
function normalizeLocationForOpenWeather(location: string): { query: string; expectedCountry?: string } {
|
||||
const lastComma = location.lastIndexOf(',');
|
||||
if (lastComma < 0) return { query: location };
|
||||
const city = location.slice(0, lastComma).trim();
|
||||
const countryPart = location.slice(lastComma + 1).trim();
|
||||
if (/^[A-Za-z]{2}$/.test(countryPart)) {
|
||||
return { query: `${city},${countryPart.toUpperCase()}`, expectedCountry: countryPart.toUpperCase() };
|
||||
}
|
||||
const iso = COUNTRY_NAME_TO_ISO[countryPart.toLowerCase()];
|
||||
if (iso) return { query: `${city},${iso}`, expectedCountry: iso };
|
||||
return { query: location };
|
||||
const { city, countryCode } = parseExplicitCountry(location);
|
||||
if (!countryCode) return { query: location };
|
||||
return { query: `${city},${countryCode}`, expectedCountry: countryCode };
|
||||
}
|
||||
|
||||
export const weatherSearchTool = {
|
||||
@@ -389,10 +406,20 @@ export const weatherSearchTool = {
|
||||
|
||||
const coordMatch = location.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
|
||||
const normalized = coordMatch ? null : normalizeLocationForOpenWeather(location);
|
||||
const locationParams: Record<string, string> = coordMatch
|
||||
let locationParams: Record<string, string> = coordMatch
|
||||
? { lat: coordMatch[1], lon: coordMatch[2] }
|
||||
: { q: normalized!.query };
|
||||
|
||||
// Bare city name, no explicit country (e.g. "Rome") — OpenWeather's own name index
|
||||
// can rank a small same-named US town above the internationally-known city, and with
|
||||
// no expectedCountry the mismatch check below never fires. Cross-check via Open-Meteo's
|
||||
// geocoder (population-ranked, see geocodeCity()) and query OpenWeather by lat/lon
|
||||
// instead, which sidesteps the name ambiguity entirely.
|
||||
if (!coordMatch && !normalized!.expectedCountry) {
|
||||
const geo = await geocodeCity(location);
|
||||
if (geo) locationParams = { lat: String(geo.lat), lon: String(geo.lon) };
|
||||
}
|
||||
|
||||
try {
|
||||
if (type === 'forecast') {
|
||||
const data = await owFetch('forecast', { ...locationParams, units, cnt: '40' });
|
||||
|
||||
Reference in New Issue
Block a user