Files
lam-agent-vicinae/src/lib/geo.ts
T
adrianbonpin 51c16b678f feat: opt-in IP geolocation for get_location
Adds the one thing the extension could not answer before: roughly where
the user actually is, rather than which zone their clock is set to.

- `lib/geo.ts` resolves a place from ipapi.co. Chosen because it is HTTPS
  and needs no API key; ip-api.com, the other obvious free option, is
  HTTP-only on its free tier, which is not a place to send an IP address.
- Off by default, and skipped entirely when the user has set Location: the
  better answer is already there, so spending their privacy on a worse one
  would be indefensible. `ipPlace` is only wired into ToolContext when the
  preference is on, so an off setting cannot reach the network at all.
- Cached for 6 hours. A city does not change between questions, and every
  lookup hands the provider another copy of the address. Set Up Lam Agent
  clears it on re-test, alongside the vision cache.
- Failures are returned, not swallowed: a rate limit or an offline machine
  says so in the tool output while the region answer stays available. The
  provider signals some failures with error: true and HTTP 200, so the body
  is checked as well as the status.
- The tool output always names the provider and calls the result city-level
  rather than exact. It also flags an IP timezone that disagrees with the
  device clock, which usually means a VPN.
- The README claimed no geolocation service ever saw the user's address.
  That is now conditional, so the section was rewritten rather than left to
  quietly become untrue.

Deliberately not done: GPS. CoreLocation has no CLI on macOS and GeoClue's
usual provider resolves nearby Wi-Fi through a Mozilla service anyway.

Verified: 341 tests (25 new), tsc, eslint, prettier, and vici build. I did
not make a live API call, because doing so would send this machine's IP to
the provider as the exact flow being opted into.
2026-10-04 15:52:22 +08:00

221 lines
7.0 KiB
TypeScript

import { LocalStorage } from "@vicinae/api";
import { z } from "zod";
import { errorMessage } from "./utils";
/**
* IP geolocation, opt-in.
*
* This is the one place in the extension that tells a third party anything about
* the user. The README said no geolocation service ever saw the user's address,
* and this is what makes that conditional, so it is off unless asked for, it is
* skipped entirely when the user has already said where they are, and the tool
* output names the provider so the model can qualify what it repeats.
*
* The answer is cached for hours rather than looked up per call. A city does not
* change between questions, and every lookup hands the provider another copy of
* the user's IP address.
*
* Imports Vicinae for `LocalStorage` only, which is the same footing as
* `sessions.ts` and `vision.ts`. Everything else about it is plain `fetch`, so
* the parsing and the cache are unit tested without a runtime.
*/
/** Where the lookup goes. Swapping providers means changing this and the parser below. */
export const GEO_PROVIDER = "https://ipapi.co/json/";
export const GEO_PROVIDER_NAME = "ipapi.co";
/** How long a resolved place is trusted. Long enough to not re-leak the IP on every question. */
export const GEO_TTL_MS = 6 * 60 * 60 * 1000;
/** An IP lookup is a nice-to-have, so it must never hold up an answer. */
const GEO_TIMEOUT_MS = 8_000;
const STORAGE_KEY = "lam.geo";
export interface GeoPlace {
/** Ready to show, e.g. "Cebu City, Central Visayas, Philippines". */
place: string;
city?: string;
region?: string;
country?: string;
latitude?: number;
longitude?: number;
timezone?: string;
/** Named in the tool output, so the model can say where this came from. */
source: string;
fetchedAt: number;
}
export type GeoLookup = { status: "ok"; place: GeoPlace } | { status: "failed"; message: string };
export interface GeoOptions {
signal?: AbortSignal;
/** Injected so the cache is deterministic under test. */
now?: number;
/** Ignores a cached answer. */
refresh?: boolean;
}
const CachedSchema = z.object({
place: z.string(),
city: z.string().optional(),
region: z.string().optional(),
country: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
timezone: z.string().optional(),
source: z.string(),
fetchedAt: z.number(),
});
/** The subset of ipapi.co's response this needs, with every field optional so a shape change degrades instead of throwing. */
const ResponseSchema = z.object({
error: z.unknown().optional(),
reason: z.string().optional(),
message: z.string().optional(),
city: z.string().nullable().optional(),
region: z.string().nullable().optional(),
country_name: z.string().nullable().optional(),
latitude: z.number().nullable().optional(),
longitude: z.number().nullable().optional(),
timezone: z.string().nullable().optional(),
});
/**
* Joins the parts of a place, dropping duplicates.
*
* Providers often return the city twice (once as `city`, once as `region`) for
* city-states and single-city regions, and "Singapore, Singapore, Singapore"
* reads like a bug.
*/
export function buildPlace(parts: { city?: string; region?: string; country?: string }): string {
const seen = new Set<string>();
const kept: string[] = [];
for (const part of [parts.city, parts.region, parts.country]) {
const trimmed = part?.trim();
const key = trimmed?.toLowerCase();
if (!trimmed || !key || seen.has(key)) continue;
seen.add(key);
kept.push(trimmed);
}
return kept.join(", ");
}
/** Aborts on either the caller's signal or a timeout, whichever comes first. */
async function fetchWithTimeout(
url: string,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const forward = () => controller.abort();
signal?.addEventListener("abort", forward, { once: true });
try {
return await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" } });
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", forward);
}
}
async function readCache(now: number): Promise<GeoPlace | undefined> {
try {
const raw = await LocalStorage.getItem<string>(STORAGE_KEY);
if (!raw) return undefined;
const parsed = CachedSchema.safeParse(JSON.parse(raw));
if (!parsed.success) return undefined;
if (now - parsed.data.fetchedAt > GEO_TTL_MS) return undefined;
return parsed.data;
} catch {
return undefined;
}
}
async function writeCache(place: GeoPlace): Promise<void> {
await LocalStorage.setItem(STORAGE_KEY, JSON.stringify(place)).catch(() => undefined);
}
/**
* Resolves the user's rough place from their IP address.
*
* Never throws: a lookup failure leaves the region-level answer intact, and the
* message is passed on so the model can say what went wrong rather than quietly
* presenting a coarser answer as though it were the same thing.
*/
export async function ipPlace(options: GeoOptions = {}): Promise<GeoLookup> {
const now = options.now ?? Date.now();
if (!options.refresh) {
const cached = await readCache(now);
if (cached) return { status: "ok", place: cached };
}
let response: Response;
try {
response = await fetchWithTimeout(GEO_PROVIDER, options.signal, GEO_TIMEOUT_MS);
} catch (thrown) {
return { status: "failed", message: `could not reach ${GEO_PROVIDER_NAME} (${errorMessage(thrown)})` };
}
if (!response.ok) {
return { status: "failed", message: `${GEO_PROVIDER_NAME} answered HTTP ${response.status}` };
}
let payload: unknown;
try {
payload = await response.json();
} catch {
return { status: "failed", message: `${GEO_PROVIDER_NAME} did not answer with JSON` };
}
const parsed = ResponseSchema.safeParse(payload);
if (!parsed.success) {
return { status: "failed", message: `${GEO_PROVIDER_NAME} answered in an unexpected shape` };
}
const data = parsed.data;
if (data.error) {
return {
status: "failed",
message: data.message ?? data.reason ?? `${GEO_PROVIDER_NAME} refused the lookup`,
};
}
const city = data.city ?? undefined;
const region = data.region ?? undefined;
const country = data.country_name ?? undefined;
const place = buildPlace({ city, region, country });
if (!place) {
return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` };
}
const resolved: GeoPlace = {
place,
city,
region,
country,
latitude: data.latitude ?? undefined,
longitude: data.longitude ?? undefined,
timezone: data.timezone ?? undefined,
source: GEO_PROVIDER_NAME,
fetchedAt: now,
};
await writeCache(resolved);
return { status: "ok", place: resolved };
}
/** Drops the cached place, so a move or a provider change is not pinned for hours. */
export async function forgetGeoPlace(): Promise<void> {
await LocalStorage.removeItem(STORAGE_KEY).catch(() => undefined);
}