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.
This commit is contained in:
2026-10-04 15:52:22 +08:00
parent 400f81fa6c
commit 51c16b678f
10 changed files with 604 additions and 13 deletions
+17
View File
@@ -2,6 +2,7 @@ import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vici
import { useCallback, useEffect, useMemo, useState } from "react";
import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences";
import { listModels } from "./lib/ollama";
import { forgetGeoPlace } from "./lib/geo";
import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision";
import type { ModelInfo } from "./lib/types";
import { errorMessage } from "./lib/utils";
@@ -60,6 +61,10 @@ export default function SetupCommand() {
// re-pull, a retagged build), so re-testing drops the cached answer that the
// chat view gates the snip action on rather than leaving a stale "no".
await forgetVisionSupport();
// Same reasoning for the IP-derived place: a re-test is the moment to stop
// trusting an answer that was resolved hours ago, possibly in another city.
await forgetGeoPlace();
setVision(
await visionSupport({
host: preferences.host,
@@ -176,6 +181,18 @@ export default function SetupCommand() {
}
actions={settingsActions}
/>
<List.Item
icon={preferences.place || preferences.ipGeolocation ? Icon.Person : Icon.QuestionMarkCircle}
title="Location"
subtitle={
preferences.place
? `${preferences.place} (from preferences)`
: preferences.ipGeolocation
? "Rough city, resolved from the IP address via ipapi.co"
: "Region only, from the timezone and locale"
}
actions={settingsActions}
/>
</List.Section>
{error ? (