feat: snip a region into chat, and make links clickable #14

Merged
adrianbonpin merged 11 commits from feat/snip-to-chat-and-clickable-links into prod 2026-10-05 08:38:55 +00:00
7 changed files with 102 additions and 39 deletions
Showing only changes of commit af325b4a62 - Show all commits
+8 -2
View File
@@ -123,7 +123,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **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. | | **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. | | **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipwho.is to resolve a rough area. |
| **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. |
@@ -148,10 +148,16 @@ Lam calls these on its own when they would improve an answer. All four are read-
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. 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. If you turn on **IP Geolocation**, and only then, `get_location` resolves a rough area from your IP address via [ipwho.is](https://ipwho.is). 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 calls the result a rough guess rather than a city.
**It is coarser than "city-level" suggests, and that is worth knowing before trusting it.** An IP address identifies the point where the traffic left the ISP, not the person. Measured from one machine on Globe Telecom, an IPv4 lookup resolved to Cebu City and an IPv6 lookup moments later resolved to Talisay in Calabarzon, roughly 500km apart. If you want a place you can rely on, set **Location** and turn this off.
The answer is cached for 6 hours rather than looked up per question, because an area does not change between questions and every lookup hands over another copy of your address. `Set Up Lam Agent` clears 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". 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".
**The provider has to be one that answers an unauthenticated caller.** The first choice was ipapi.co, which no longer does: a request with no User-Agent now returns `HTTP 429` with `"Please sign up for a paid plan"`, and adding a browser User-Agent gets an `HTTP 403` Cloudflare bot challenge instead. ipwho.is answers without a key and signals failures with `success: false` on an `HTTP 200`, so the body is checked as well as the status. `GEO_PROVIDER` is a single constant, and swapping it means changing the parser beside it.
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.
## Architecture ## Architecture
+1 -1
View File
@@ -76,7 +76,7 @@
"name": "ipGeolocation", "name": "ipGeolocation",
"title": "IP Geolocation", "title": "IP Geolocation",
"label": "Look up my city from my IP address", "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.", "description": "Off by default. When this is on, and the Location preference above is empty, Lam sends your IP address to ipwho.is to work out roughly where you are. That is a third party learning approximately where you are, which is why it is opt-in, and why setting Location makes it unnecessary. It is coarser than a city: an IP address locates the point where traffic left your ISP, which can be hundreds of kilometres away.",
"type": "checkbox", "type": "checkbox",
"required": false, "required": false,
"default": false "default": false
+25 -12
View File
@@ -18,13 +18,15 @@ function answer(payload: unknown, status = 200): Response {
}); });
} }
/** A real ipwho.is payload, trimmed. Note the nested timezone and `country`. */
const CEBU = { const CEBU = {
success: true,
city: "Cebu City", city: "Cebu City",
region: "Central Visayas", region: "Central Visayas",
country_name: "Philippines", country: "Philippines",
latitude: 10.3167, latitude: 10.3167,
longitude: 123.8907, longitude: 123.8907,
timezone: "Asia/Manila", timezone: { id: "Asia/Manila", utc: "+08:00" },
}; };
beforeEach(() => { beforeEach(() => {
@@ -74,16 +76,26 @@ describe("ipPlace", () => {
expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines"); expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines");
expect(result.place.latitude).toBe(10.3167); expect(result.place.latitude).toBe(10.3167);
expect(result.place.timezone).toBe("Asia/Manila"); expect(result.place.timezone).toBe("Asia/Manila");
expect(result.place.source).toBe("ipapi.co"); expect(result.place.source).toBe("ipwho.is");
}); });
it("asks for JSON with no cache-busting that a cache would defeat", async () => { it("names itself to the provider rather than arriving anonymously", async () => {
fetchMock.mockResolvedValueOnce(answer(CEBU)); fetchMock.mockResolvedValueOnce(answer(CEBU));
await ipPlace({ now: 1_000 }); await ipPlace({ now: 1_000 });
const [url, init] = fetchMock.mock.calls[0]; const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ipapi.co/json/"); expect(url).toBe("https://ipwho.is/");
expect(init.headers.Accept).toBe("application/json"); expect(init.headers.Accept).toBe("application/json");
expect(init.headers["User-Agent"]).toContain("lam-agent");
});
it("reads a timezone handed back as a plain string too", async () => {
// Providers differ here: ipwho.is nests it, others return a bare zone name.
fetchMock.mockResolvedValueOnce(answer({ ...CEBU, timezone: "Asia/Manila" }));
const result = await ipPlace({ now: 1_000 });
expect(result.status === "ok" && result.place.timezone).toBe("Asia/Manila");
}); });
it("serves a repeat from the cache instead of re-sending the address", async () => { it("serves a repeat from the cache instead of re-sending the address", async () => {
@@ -132,16 +144,17 @@ describe("ipPlace", () => {
const result = await ipPlace({ now: 1_000 }); const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "ipapi.co answered HTTP 429" }); expect(result).toEqual({ status: "failed", message: "ipwho.is answered HTTP 429" });
}); });
it("reports a provider that answers with error: true", async () => { it("reports a provider that refuses in the body while answering 200", async () => {
// ipapi.co signals failure in the body while still answering 200. // ipwho.is signals failure with success: false rather than a status code,
fetchMock.mockResolvedValueOnce(answer({ error: true, reason: "RateLimited", message: "Too many" })); // so checking the status alone would read a refusal as a success.
fetchMock.mockResolvedValueOnce(answer({ success: false, message: "Reserved range" }));
const result = await ipPlace({ now: 1_000 }); const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "Too many" }); expect(result).toEqual({ status: "failed", message: "Reserved range" });
}); });
it("reports a body that is not JSON", async () => { it("reports a body that is not JSON", async () => {
@@ -153,11 +166,11 @@ describe("ipPlace", () => {
}); });
it("reports an answer with no usable place", async () => { it("reports an answer with no usable place", async () => {
fetchMock.mockResolvedValueOnce(answer({ city: null, region: null, country_name: null })); fetchMock.mockResolvedValueOnce(answer({ success: true, city: null, region: null, country: null }));
const result = await ipPlace({ now: 1_000 }); const result = await ipPlace({ now: 1_000 });
expect(result).toEqual({ status: "failed", message: "ipapi.co returned no usable place" }); expect(result).toEqual({ status: "failed", message: "ipwho.is returned no usable place" });
}); });
it("survives the network being down without throwing", async () => { it("survives the network being down without throwing", async () => {
+26 -6
View File
@@ -273,7 +273,7 @@ describe("get_location with IP geolocation", () => {
region: "Central Visayas", region: "Central Visayas",
country: "Philippines", country: "Philippines",
timezone: "Asia/Manila", timezone: "Asia/Manila",
source: "ipapi.co", source: "ipwho.is",
fetchedAt: 1_000, fetchedAt: 1_000,
}, },
}; };
@@ -285,8 +285,8 @@ describe("get_location with IP geolocation", () => {
expect(result.isError).toBe(false); expect(result.isError).toBe(false);
expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines"); expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines");
expect(result.content).toContain("city-level rather than exact"); expect(result.content).toContain("rough guess");
expect(result.content).toContain("ipapi.co"); expect(result.content).toContain("ipwho.is");
}); });
it("does not look the address up at all when the user has said where they are", async () => { it("does not look the address up at all when the user has said where they are", async () => {
@@ -302,20 +302,40 @@ describe("get_location with IP geolocation", () => {
expect(ipPlace).not.toHaveBeenCalled(); expect(ipPlace).not.toHaveBeenCalled();
expect(result.content).toContain("Place: Cebu City, Philippines"); expect(result.content).toContain("Place: Cebu City, Philippines");
expect(result.content).not.toContain("ipapi.co"); expect(result.content).not.toContain("ipwho.is");
}); });
it("surfaces a failed lookup instead of quietly serving the region answer", async () => { 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 ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace }); 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("IP geolocation is switched on but the lookup failed");
expect(result.content).toContain("HTTP 429"); expect(result.content).toContain("HTTP 429");
// The region answer is still there, so the tool stays useful. // The region answer is still there, so the tool stays useful.
expect(result.content).toContain("Timezone:"); expect(result.content).toContain("Timezone:");
}); });
it("does not blame an unset preference when the lookup itself failed", async () => {
// These are different problems and send the user to different places. The
// original wording said "no Location is set in preferences" for both, which
// is exactly what made a broken lookup look like a missing setting.
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).not.toContain("no Location is set in preferences");
});
it("tells the model to pass a failed lookup on, since it is the only channel to the user", async () => {
const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "offline" }));
const result = await executeTool("get_location", {}, { ...context, ipPlace });
expect(result.content).toContain("Tell the user");
expect(result.content).toContain("Setting Location in preferences avoids the lookup");
});
it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => { it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => {
const ipPlace = vi.fn(async () => ({ const ipPlace = vi.fn(async () => ({
status: "ok" as const, status: "ok" as const,
+32 -12
View File
@@ -11,6 +11,13 @@ import { errorMessage } from "./utils";
* skipped entirely when the user has already said where they are, and the tool * 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. * output names the provider so the model can qualify what it repeats.
* *
* How accurate is it? Less than the name suggests, and the honest answer is that
* an IP address locates the point where the traffic left the ISP, not the person.
* Measured from one machine on Globe Telecom, an IPv4 query resolved to Cebu City
* and an IPv6 query, moments later, to Talisay in Calabarzon: about 500km apart.
* The cache is therefore not just a privacy measure, and the tool output calls
* the result a rough guess rather than a city.
*
* The answer is cached for hours rather than looked up per call. A city does not * 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 * change between questions, and every lookup hands the provider another copy of
* the user's IP address. * the user's IP address.
@@ -21,8 +28,8 @@ import { errorMessage } from "./utils";
*/ */
/** Where the lookup goes. Swapping providers means changing this and the parser below. */ /** 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 = "https://ipwho.is/";
export const GEO_PROVIDER_NAME = "ipapi.co"; export const GEO_PROVIDER_NAME = "ipwho.is";
/** How long a resolved place is trusted. Long enough to not re-leak the IP on every question. */ /** 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; export const GEO_TTL_MS = 6 * 60 * 60 * 1000;
@@ -68,17 +75,20 @@ const CachedSchema = z.object({
fetchedAt: z.number(), fetchedAt: z.number(),
}); });
/** The subset of ipapi.co's response this needs, with every field optional so a shape change degrades instead of throwing. */ /** The subset of the provider's response this needs, with every field optional so a shape change degrades instead of throwing. */
const ResponseSchema = z.object({ const ResponseSchema = z.object({
error: z.unknown().optional(), success: z.boolean().optional(),
reason: z.string().optional(),
message: z.string().optional(), message: z.string().optional(),
city: z.string().nullable().optional(), city: z.string().nullable().optional(),
region: z.string().nullable().optional(), region: z.string().nullable().optional(),
country_name: z.string().nullable().optional(), country: z.string().nullable().optional(),
latitude: z.number().nullable().optional(), latitude: z.number().nullable().optional(),
longitude: z.number().nullable().optional(), longitude: z.number().nullable().optional(),
timezone: z.string().nullable().optional(), // ipwho.is nests the timezone; some providers hand back a plain string.
timezone: z
.union([z.string(), z.object({ id: z.string().optional() })])
.nullable()
.optional(),
}); });
/** /**
@@ -117,7 +127,15 @@ async function fetchWithTimeout(
signal?.addEventListener("abort", forward, { once: true }); signal?.addEventListener("abort", forward, { once: true });
try { try {
return await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" } }); return await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
// Named rather than anonymous. Node's default UA is bare, and "who is
// asking" is the one thing a geolocation provider has a fair interest in.
"User-Agent": "lam-agent-vicinae",
},
});
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
signal?.removeEventListener("abort", forward); signal?.removeEventListener("abort", forward);
@@ -182,22 +200,24 @@ export async function ipPlace(options: GeoOptions = {}): Promise<GeoLookup> {
} }
const data = parsed.data; const data = parsed.data;
if (data.error) { if (data.success === false) {
return { return {
status: "failed", status: "failed",
message: data.message ?? data.reason ?? `${GEO_PROVIDER_NAME} refused the lookup`, message: data.message ?? `${GEO_PROVIDER_NAME} refused the lookup`,
}; };
} }
const city = data.city ?? undefined; const city = data.city ?? undefined;
const region = data.region ?? undefined; const region = data.region ?? undefined;
const country = data.country_name ?? undefined; const country = data.country ?? undefined;
const place = buildPlace({ city, region, country }); const place = buildPlace({ city, region, country });
if (!place) { if (!place) {
return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` }; return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` };
} }
const timezone = typeof data.timezone === "string" ? data.timezone : data.timezone?.id;
const resolved: GeoPlace = { const resolved: GeoPlace = {
place, place,
city, city,
@@ -205,7 +225,7 @@ export async function ipPlace(options: GeoOptions = {}): Promise<GeoLookup> {
country, country,
latitude: data.latitude ?? undefined, latitude: data.latitude ?? undefined,
longitude: data.longitude ?? undefined, longitude: data.longitude ?? undefined,
timezone: data.timezone ?? undefined, timezone: timezone ?? undefined,
source: GEO_PROVIDER_NAME, source: GEO_PROVIDER_NAME,
fetchedAt: now, fetchedAt: now,
}; };
+9 -5
View File
@@ -231,7 +231,15 @@ async function describeLocation(context: ToolContext): Promise<string> {
); );
} else if (ip) { } else if (ip) {
lines.push( 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.`, `Note: Place came from an IP address lookup via ${ip.source}, so treat it as a rough guess rather than the user's city. An IP address locates where the request left the ISP's network, which can be hundreds of kilometres away, and it can be out of date if they have moved since.`,
);
} else if (lookup?.status === "failed") {
// Deliberately not the "no Location is set" wording below. A failed lookup
// and an unset preference are different problems, and reporting the wrong
// one sends the user looking in the wrong place. This is also the only
// channel that reaches them: the model paraphrases, so it is told to say so.
lines.push(
`Note: IP geolocation is switched on but the lookup failed (${lookup.message}), so no place could be resolved. Tell the user this plainly. Setting Location in preferences avoids the lookup and the network entirely. Everything below is derived from the device timezone and locale, so it is region-level.`,
); );
} else { } else {
lines.push( lines.push(
@@ -239,10 +247,6 @@ async function describeLocation(context: ToolContext): Promise<string> {
); );
} }
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.");
} }
+1 -1
View File
@@ -188,7 +188,7 @@ export default function SetupCommand() {
preferences.place preferences.place
? `${preferences.place} (from preferences)` ? `${preferences.place} (from preferences)`
: preferences.ipGeolocation : preferences.ipGeolocation
? "Rough city, resolved from the IP address via ipapi.co" ? "Rough area, resolved from the IP address via ipwho.is"
: "Region only, from the timezone and locale" : "Region only, from the timezone and locale"
} }
actions={settingsActions} actions={settingsActions}