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:
kim
2026-07-20 16:52:30 +09:00
co-authored by Claude Sonnet 5
parent c271c96bdb
commit e0e9a97761
3 changed files with 58 additions and 30 deletions
+1 -1
View File
@@ -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": {
+2 -1
View File
@@ -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
View File
@@ -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' });