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
+12 -2
View File
@@ -122,6 +122,8 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. | | **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
| **Model** | None | Required. Any model your key can reach. | | **Model** | None | Required. Any model your key can reach. |
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. | | **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
| **Location** | None | The place Lam should treat as yours, for example `Cebu City, Philippines`. Optional. The only exact location available, and the only one that stops anything being inferred. |
| **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipapi.co to resolve a rough city. |
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. | | **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. |
| **Web Tools** | on | Enables `web_search` and `web_fetch`. | | **Web Tools** | on | Enables `web_search` and `web_fetch`. |
| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. | | **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. |
@@ -136,11 +138,19 @@ Lam calls these on its own when they would improve an answer. All four are read-
| Tool | What it does | | Tool | What it does |
| --- | --- | | --- | --- |
| `get_datetime` | Current local date, time, weekday, timezone, and UTC offset. | | `get_datetime` | Current local date, time, weekday, timezone, and UTC offset. |
| `get_location` | Timezone, UTC offset, locale, region, and city. | | `get_location` | The place you configured, or a rough city from your IP if you enabled that, plus timezone, UTC offset, locale and region. |
| `web_search` | Searches the web; returns titles, URLs, and snippets. | | `web_search` | Searches the web; returns titles, URLs, and snippets. |
| `web_fetch` | Fetches one page and returns its content as markdown. | | `web_fetch` | Fetches one page and returns its content as markdown. |
`get_location` is derived from your device timezone rather than GPS or an IP lookup, so no third-party geolocation service sees your address. Treat it as "which region am I in", not "where am I". `get_location` reports the **Location** you set, if you set one, because that is the only answer here that is actually about where you are. Everything else is inferred from your device, and the inference is coarser than it looks.
**A timezone is not a city.** This is the trap the tool used to fall into: it reported `City: Manila`, taken from the last segment of the IANA zone name. That name is the database's representative place, not yours, and a single zone is often a whole country. `Asia/Manila` is the Philippines, `Asia/Kolkata` is India, `Europe/Oslo` is Norway, `Australia/Brisbane` is Queensland. Reporting the zone's namesake as your city was wrong for anyone outside it, and confidently so, because a model asked about the weather will use it without hesitating. The zone's label is still reported, as `Timezone area`, and named for what it is.
The **region** comes from your locale, which is a declaration rather than an inference: a locale of `en-PH` says the Philippines because that is what you set. It costs nothing and it cannot be wrong the way a zone name can.
If you turn on **IP Geolocation**, and only then, `get_location` resolves a rough city from your IP address via [ipapi.co](https://ipapi.co). That is a third party learning approximately where you are, so it is off by default, it is skipped entirely whenever you have set a Location (the better answer is already there, and there is no reason to spend your privacy on a worse one), and the tool output names the provider and says the result is city-level rather than exact. The answer is cached for 6 hours rather than looked up per question, because a city does not change between questions and every lookup hands over another copy of your address. `Set Up Lam Agent` clears both that cache and the capability cache when you re-test.
This extension does not use GPS. That would mean CoreLocation on macOS, which has no command-line interface, or GeoClue on Linux, whose usual provider resolves nearby Wi-Fi networks through a Mozilla service anyway. Neither is worth it for "is it late where you are".
Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping. Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping.
+9
View File
@@ -72,6 +72,15 @@
"type": "textfield", "type": "textfield",
"required": false "required": false
}, },
{
"name": "ipGeolocation",
"title": "IP Geolocation",
"label": "Look up my city from my IP address",
"description": "Off by default. When this is on, and the Location preference above is empty, Lam sends your IP address to ipapi.co to work out your city. That is a third party learning roughly where you are, which is why it is opt-in, and why setting Location makes it unnecessary.",
"type": "checkbox",
"required": false,
"default": false
},
{ {
"name": "webSearchEnabled", "name": "webSearchEnabled",
"title": "Web Tools", "title": "Web Tools",
+205
View File
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
import { GEO_TTL_MS, buildPlace, forgetGeoPlace, ipPlace } from "../lib/geo";
/**
* The only code in the extension that sends anything about the user to a third
* party, so the parts worth pinning down are that it caches (rather than leaking
* the address on every question), that it never throws, and that a failure is
* reported instead of being served as a coarser answer.
*/
const fetchMock = vi.fn();
function answer(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
const CEBU = {
city: "Cebu City",
region: "Central Visayas",
country_name: "Philippines",
latitude: 10.3167,
longitude: 123.8907,
timezone: "Asia/Manila",
};
beforeEach(() => {
__resetLocalStorage();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("buildPlace", () => {
it("joins the parts that exist", () => {
expect(buildPlace({ city: "Cebu City", region: "Central Visayas", country: "Philippines" })).toBe(
"Cebu City, Central Visayas, Philippines",
);
});
it("drops repeats, which providers return for city-states", () => {
// "Singapore, Singapore, Singapore" reads like a bug rather than a place.
expect(buildPlace({ city: "Singapore", region: "Singapore", country: "Singapore" })).toBe("Singapore");
});
it("ignores repeats that differ only in case", () => {
expect(buildPlace({ city: "Manila", region: "manila" })).toBe("Manila");
});
it("drops empty and whitespace parts", () => {
expect(buildPlace({ city: " ", region: "", country: "Philippines" })).toBe("Philippines");
});
it("returns nothing when there is nothing", () => {
expect(buildPlace({})).toBe("");
});
});
describe("ipPlace", () => {
it("resolves a place from a provider answer", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
const result = await ipPlace({ now: 1_000 });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines");
expect(result.place.latitude).toBe(10.3167);
expect(result.place.timezone).toBe("Asia/Manila");
expect(result.place.source).toBe("ipapi.co");
});
it("asks for JSON with no cache-busting that a cache would defeat", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ipapi.co/json/");
expect(init.headers.Accept).toBe("application/json");
});
it("serves a repeat from the cache instead of re-sending the address", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
const second = await ipPlace({ now: 2_000 });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second.status).toBe("ok");
});
it("re-fetches once the cached place is stale", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
const later = await ipPlace({ now: 1_000 + GEO_TTL_MS + 1 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(later.status === "ok" && later.place.city).toBe("Davao City");
});
it("treats exactly the TTL as still fresh", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
await ipPlace({ now: 1_000 + GEO_TTL_MS });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("ignores the cache when asked to refresh", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
const refreshed = await ipPlace({ now: 1_001, refresh: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(refreshed.status === "ok" && refreshed.place.city).toBe("Davao City");
});
it("reports a rate limit by status rather than pretending to know the place", async () => {
fetchMock.mockResolvedValueOnce(new Response("slow down", { status: 429 }));
const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "ipapi.co answered HTTP 429" });
});
it("reports a provider that answers with error: true", async () => {
// ipapi.co signals failure in the body while still answering 200.
fetchMock.mockResolvedValueOnce(answer({ error: true, reason: "RateLimited", message: "Too many" }));
const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "Too many" });
});
it("reports a body that is not JSON", async () => {
fetchMock.mockResolvedValueOnce(new Response("<html>nope</html>", { status: 200 }));
const result = await ipPlace({ now: 1_000 });
expect(result.status).toBe("failed");
});
it("reports an answer with no usable place", async () => {
fetchMock.mockResolvedValueOnce(answer({ city: null, region: null, country_name: null }));
const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "ipapi.co returned no usable place" });
});
it("survives the network being down without throwing", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
const result = await ipPlace({ now: 1_000 });
expect(result.status).toBe("failed");
});
it("does not cache a failure", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
await ipPlace({ now: 1_000 });
fetchMock.mockResolvedValueOnce(answer(CEBU));
const second = await ipPlace({ now: 1_001 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(second.status).toBe("ok");
});
it("ignores a corrupt cache entry rather than failing", async () => {
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
await LocalStorage.setItem("lam.geo", "{not json");
fetchMock.mockResolvedValueOnce(answer(CEBU));
await expect(ipPlace({ now: 1_000 })).resolves.toMatchObject({ status: "ok" });
});
});
describe("forgetGeoPlace", () => {
it("drops the cached place so a move is not pinned for hours", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 });
await forgetGeoPlace();
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, city: "Davao City" }));
const after = await ipPlace({ now: 1_001 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(after.status === "ok" && after.place.city).toBe("Davao City");
});
});
+8
View File
@@ -57,6 +57,14 @@ describe("resolvePreferences", () => {
expect(resolved.place).toBe(""); expect(resolved.place).toBe("");
}); });
it("leaves IP geolocation off unless it was switched on", () => {
// The one setting that sends something about the user to a third party is
// opted into, never defaulted into.
expect(resolvePreferences({}).ipGeolocation).toBe(false);
expect(resolvePreferences({ ipGeolocation: true }).ipGeolocation).toBe(true);
expect(resolvePreferences({ ipGeolocation: false }).ipGeolocation).toBe(false);
});
it("keeps the configured location, trimmed", () => { it("keeps the configured location, trimmed", () => {
expect(resolvePreferences({ location: " Cebu City, Philippines " }).place).toBe( expect(resolvePreferences({ location: " Cebu City, Philippines " }).place).toBe(
"Cebu City, Philippines", "Cebu City, Philippines",
+77
View File
@@ -264,6 +264,83 @@ describe("local tools", () => {
}); });
}); });
describe("get_location with IP geolocation", () => {
const resolved = {
status: "ok" as const,
place: {
place: "Cebu City, Central Visayas, Philippines",
city: "Cebu City",
region: "Central Visayas",
country: "Philippines",
timezone: "Asia/Manila",
source: "ipapi.co",
fetchedAt: 1_000,
},
};
it("reports the resolved place and says how approximate it is", async () => {
const ipPlace = vi.fn(async () => resolved);
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.isError).toBe(false);
expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines");
expect(result.content).toContain("city-level rather than exact");
expect(result.content).toContain("ipapi.co");
});
it("does not look the address up at all when the user has said where they are", async () => {
// The preference is both more accurate and free, so sending the IP anyway
// would be a privacy cost for nothing.
const ipPlace = vi.fn(async () => resolved);
const result = await executeTool("get_location", {}, {
...context,
place: "Cebu City, Philippines",
ipPlace,
});
expect(ipPlace).not.toHaveBeenCalled();
expect(result.content).toContain("Place: Cebu City, Philippines");
expect(result.content).not.toContain("ipapi.co");
});
it("surfaces a failed lookup instead of quietly serving the region answer", async () => {
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipapi.co answered HTTP 429" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).toContain("IP geolocation is switched on but did not answer");
expect(result.content).toContain("HTTP 429");
// The region answer is still there, so the tool stays useful.
expect(result.content).toContain("Timezone:");
});
it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => {
const ipPlace = vi.fn(async () => ({
status: "ok" as const,
place: { ...resolved.place, timezone: "America/New_York" },
}));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).toContain("differs from the device timezone");
});
it("says nothing about a mismatch when the timezones agree", async () => {
const ipPlace = vi.fn(async () => resolved);
const result = await executeTool("get_location", {}, { ...context, ipPlace });
// The test host's own zone varies, so this asserts the absence of the note
// only for the case where the provider echoed the same value.
if (result.content.includes("IP-reported timezone")) {
expect(result.content).toContain("differs");
} else {
expect(result.content).not.toContain("IP-reported timezone");
}
});
});
describe("dispatch", () => { describe("dispatch", () => {
it("reports an unknown tool instead of throwing", async () => { it("reports an unknown tool instead of throwing", async () => {
const result = await executeTool("delete_everything", {}, context); const result = await executeTool("delete_everything", {}, context);
+220
View File
@@ -0,0 +1,220 @@
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);
}
+4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { runAgent } from "../agent"; import { runAgent } from "../agent";
import { toStoredAttachment } from "../attachments"; import { toStoredAttachment } from "../attachments";
import { ipPlace } from "../geo";
import { buildMessages, composeSystemPrompt } from "../history"; import { buildMessages, composeSystemPrompt } from "../history";
import { OllamaError } from "../ollama"; import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences"; import type { ResolvedPreferences } from "../preferences";
@@ -178,6 +179,9 @@ export function useChat(options: UseChatOptions): UseChat {
host: preferences.host, host: preferences.host,
apiKey: preferences.apiKey, apiKey: preferences.apiKey,
place: preferences.place, place: preferences.place,
// Only wired up when the user switched it on, so an off setting
// cannot reach the network for a location at all.
ipPlace: preferences.ipGeolocation ? ipPlace : undefined,
}, },
signal: controller.signal, signal: controller.signal,
events: { events: {
+6
View File
@@ -16,6 +16,7 @@ export interface LamPreferences {
ollamaHost?: string; ollamaHost?: string;
systemPrompt?: string; systemPrompt?: string;
location?: string; location?: string;
ipGeolocation?: boolean;
webSearchEnabled?: boolean; webSearchEnabled?: boolean;
thinking?: string; thinking?: string;
showToolCalls?: boolean; showToolCalls?: boolean;
@@ -37,6 +38,8 @@ export interface ResolvedPreferences {
* city, and a timezone name is not a city at all. * city, and a timezone name is not a city at all.
*/ */
place: string; place: string;
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
ipGeolocation: boolean;
webEnabled: boolean; webEnabled: boolean;
/** Undefined when reasoning is off, which is what the API client expects. */ /** Undefined when reasoning is off, which is what the API client expects. */
think?: string; think?: string;
@@ -88,6 +91,9 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST, host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
systemPrompt: (raw.systemPrompt ?? "").trim(), systemPrompt: (raw.systemPrompt ?? "").trim(),
place: (raw.location ?? "").trim(), place: (raw.location ?? "").trim(),
// Defaults to off: this is the one setting that sends something about the
// user to a third party, so it is a deliberate choice rather than a default.
ipGeolocation: raw.ipGeolocation ?? false,
// Defaults to on: the preference is a checkbox the user has to actively untick. // Defaults to on: the preference is a checkbox the user has to actively untick.
webEnabled: raw.webSearchEnabled ?? true, webEnabled: raw.webSearchEnabled ?? true,
think: thinking && thinking !== "off" ? thinking : undefined, think: thinking && thinking !== "off" ? thinking : undefined,
+44 -9
View File
@@ -1,5 +1,6 @@
import { formatNow, getLocationContext, weekdayName } from "./location"; import { formatNow, getLocationContext, weekdayName } from "./location";
import { webFetch, webSearch } from "./ollama"; import { webFetch, webSearch } from "./ollama";
import type { GeoLookup } from "./geo";
import type { ToolDefinition } from "./types"; import type { ToolDefinition } from "./types";
import { errorMessage } from "./utils"; import { errorMessage } from "./utils";
@@ -26,6 +27,14 @@ export interface ToolContext {
* Vicinae imports and preferences arrive through that API. * Vicinae imports and preferences arrive through that API.
*/ */
place?: string; place?: string;
/**
* Resolves a rough place from the user's IP address, when that is switched on.
*
* Injected for the same reason as `place`, and absent when IP geolocation is
* off, which is what keeps this module from being able to reach the network
* for a location on its own.
*/
ipPlace?: (options: { signal?: AbortSignal }) => Promise<GeoLookup>;
} }
export interface ToolResult { export interface ToolResult {
@@ -147,7 +156,7 @@ export async function executeTool(
return { content: describeNow(context.now), isError: false }; return { content: describeNow(context.now), isError: false };
case "get_location": case "get_location":
return { content: describeLocation(context.now, context.place), isError: false }; return { content: await describeLocation(context), isError: false };
case "web_search": case "web_search":
return await runWebSearch(args, context); return await runWebSearch(args, context);
@@ -179,14 +188,22 @@ function describeNow(now?: Date): string {
].join("\n"); ].join("\n");
} }
function describeLocation(now?: Date, place?: string): string { async function describeLocation(context: ToolContext): Promise<string> {
const date = now ?? new Date(); const date = context.now ?? new Date();
const location = getLocationContext(date, undefined, place); const configured = context.place?.trim() || undefined;
// A place the user typed wins outright, and while it is set there is no reason
// to hand their IP address to anyone.
const lookup =
configured || !context.ipPlace ? undefined : await context.ipPlace({ signal: context.signal });
const ip = lookup?.status === "ok" ? lookup.place : undefined;
const location = getLocationContext(date, undefined, configured ?? ip?.place);
const lines: string[] = []; const lines: string[] = [];
// The configured place comes first because it is the only exact thing here, // The place comes first because it is the only answer that is about where the
// and the model should reach for it before anything else. // user actually is, rather than about which zone the clock is set to.
if (location.place) lines.push(`Place: ${location.place}`); if (location.place) lines.push(`Place: ${location.place}`);
lines.push( lines.push(
@@ -202,11 +219,29 @@ function describeLocation(now?: Date, place?: string): string {
lines.push(`Timezone area: ${location.timezoneArea}`); lines.push(`Timezone area: ${location.timezoneArea}`);
} }
// A mismatch between the clock and the IP is worth surfacing: it usually means
// a VPN or a device still set to somewhere the user has left.
if (ip?.timezone && ip.timezone !== location.timezone) {
lines.push(`IP-reported timezone: ${ip.timezone}, which differs from the device timezone`);
}
if (configured) {
lines.push( lines.push(
location.place "Note: Place is the user's own setting, so prefer it for anything that depends on exactly where they are.",
? "Note: Place is the user's own setting, so prefer it for anything that depends on exactly where they are."
: "Note: no Location is set in preferences. Everything above is derived from the device timezone and locale, so it is accurate to a region and not to a city. Ask which city they are in when an exact place matters.",
); );
} else if (ip) {
lines.push(
`Note: Place came from an IP address lookup via ${ip.source}, so it is city-level rather than exact, and it can be out of date if the user has moved since.`,
);
} else {
lines.push(
"Note: no Location is set in preferences. Everything above is derived from the device timezone and locale, so it is accurate to a region and not to a city. Ask which city they are in when an exact place matters.",
);
}
if (lookup?.status === "failed") {
lines.push(`IP geolocation is switched on but did not answer: ${lookup.message}.`);
}
if (location.timezoneArea) { if (location.timezoneArea) {
lines.push("The timezone area is the timezone's own name, not the user's city."); lines.push("The timezone area is the timezone's own name, not the user's city.");
+17
View File
@@ -2,6 +2,7 @@ import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vici
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences"; import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences";
import { listModels } from "./lib/ollama"; import { listModels } from "./lib/ollama";
import { forgetGeoPlace } from "./lib/geo";
import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision"; import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision";
import type { ModelInfo } from "./lib/types"; import type { ModelInfo } from "./lib/types";
import { errorMessage } from "./lib/utils"; 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 // 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". // chat view gates the snip action on rather than leaving a stale "no".
await forgetVisionSupport(); 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( setVision(
await visionSupport({ await visionSupport({
host: preferences.host, host: preferences.host,
@@ -176,6 +181,18 @@ export default function SetupCommand() {
} }
actions={settingsActions} 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> </List.Section>
{error ? ( {error ? (