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.
221 lines
7.0 KiB
TypeScript
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);
|
|
}
|