pi-ollama-cloud 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ - Fix `/ollama-cloud-usage` and the usage status bar failing with "unexpected response shape" after the undocumented `/api/usage` endpoint flipped between a single `limits.monthly` bucket and `limits.session` plus `limits.weekly` (the shape has flip-flopped repeatedly as of 2026-09). Any bucket present (`monthly`, `session`, `weekly`) is accepted alone or in combination, and whichever are present are displayed as `5h`/`7d`/`30d` segments. Thanks @johanngyger (#56).
8
+ - Cache `ollama_web_search` results (24h) and `ollama_web_fetch` pages (24h success / 15 min failure) on disk under the pi agent home, so repeated queries and page reads cost 0 API calls. Expired entries are pruned on write, the cache is capped at 500 entries per kind (oldest evicted beyond the cap; `PI_OLLAMA_SEARCH_MAX_ENTRIES`), a partially corrupted cache file is validated per entry and degrades to "no cache" instead of crashing tool calls, and the file is written with `0600` permissions since it stores page content and URLs that can embed credentials. Tune with `PI_OLLAMA_SEARCH_TTL_HOURS`, `PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES`, `PI_OLLAMA_SEARCH_MAX_ENTRIES`, and `PI_OLLAMA_SEARCH_CACHE_PATH`.
9
+ - Bound web tool context usage: search snippets truncate to 500 chars with `[truncated]`/`[complete]` markers, `expand=<index>` returns a truncated result's full content from the cached search (0 extra API calls), and `ollama_web_fetch` pages long pages in 3000-char chunks via `offset`/`full` with a `Continue:` hint for the next offset (`PI_OLLAMA_SEARCH_SNIPPET_CHARS`/`PI_OLLAMA_SEARCH_CHUNK_CHARS` to tune).
10
+ - Add `refresh=true` to both web tools to bypass the cache (including a cached failure) and re-call the API; the fresh result replaces the cache entry.
11
+ - Failed page fetches are negative-cached for 15 min with a diagnostic message (likely cause + next steps) instead of a bare error. Auth (401/403), rate-limit (429), transport (timeout/abort/network), server (5xx), and unexpected-response-shape failures are never cached — search reports transport errors as `transport error` instead of a confusing `status 0` — so retrying after a fixed key, an expired rate-limit window, or a transient server blip re-calls the API immediately.
12
+ - Note: `ollama_web_fetch` tool results now carry `details: { title, totalChars, links }` (previously `{ title, content, links }`); paged content is read via the tool output text, not `details.content`.
13
+
7
14
  ## [0.10.0] - 2026-09-03
8
15
 
9
16
  - **Breaking:** Adapt to the changed `/api/usage` response shape. The endpoint now returns a single `limits.monthly` bucket (replacing `limits.session` and `limits.weekly`) and adds an `activity.models` array. `UsageData`, `isUsageResponse`, `formatUsage`, and `formatUsageStatusColored` now read the monthly limit; the status bar shows a single `30d` segment instead of `5h`/`7d`.
package/README.md CHANGED
@@ -167,19 +167,60 @@ See [docs/think-experiment.md](docs/think-experiment.md) for the testing methodo
167
167
 
168
168
  Both tools use the same Ollama Cloud API key configured for the provider. No local Ollama server is needed.
169
169
 
170
+ ### Caching
171
+
172
+ Both tools cache results on disk (under the pi agent home, `~/.pi/agent/cache/pi-ollama-cloud/cache.json`). A repeated search query or page fetch within the TTL is served from cache and costs 0 API calls:
173
+
174
+ - Successful searches and pages: cached for 24h
175
+ - Failed page fetches: negative-cached for 15 min, so retrying a dead page does not re-call the API. Auth (401/403), rate-limit (429), transport (timeouts, aborts, network errors), and server (5xx) failures are not cached — fixing the key, waiting out the limit, or a transient blip lets a retry through immediately
176
+ - Expired entries are pruned on write and the cache is capped at 500 entries per kind (searches/pages), evicting the oldest first. This bounds entry count, not file size: full page and search content can still make `cache.json` large, and loading it parses the whole file
177
+ - The cache file is written with `0600` permissions. It stores full page content and raw URLs, which can embed credentials in query strings — avoid fetching URLs that carry secrets in the query string, or set a custom `PI_OLLAMA_SEARCH_CACHE_PATH`
178
+ - Concurrent pi processes share the cache file on a last-writer-wins basis (no cross-process locking): one process's save can drop another's fresh entries, at the cost of a redundant API call
179
+ - `refresh=true` on either tool bypasses the cache (including a cached failure) and re-calls the API; the fresh result replaces the cache entry
180
+
181
+ ### `ollama_web_search`
182
+
183
+ Returns up to 5 results by default (`max_results`, max 10; title, URL, 500-char snippet). Snippets are marked `[truncated]` when the source is longer than the snippet. Output ends with `# live query` or `# from cache` to show whether the API was called.
184
+
185
+ The search API returns each result's full content; it is cached in full, so a truncated result can be expanded without a separate fetch:
186
+
187
+ - `expand=<index>` — return the full content of that result (1-based) from the cached search, 0 extra API calls. The cache key includes `max_results`, so expanding hits the cache only when the query was searched with the same `max_results`; otherwise the search runs live first.
188
+ - Use `ollama_web_fetch` only when the search result's content is not enough (e.g. you need a different page, or the search excerpt is shorter than the full page).
189
+
190
+ ### `ollama_web_fetch`
191
+
192
+ Returns the page title, a 3000-char slice of the content, and links. Long pages are read in chunks to keep the context window small:
193
+
194
+ - `offset=N` — continue reading from character N (the output tells you the next offset)
195
+ - `full=true` — return all remaining content from `offset` in one call
196
+
197
+ A failed fetch throws a diagnostic message (likely cause + next steps) instead of a bare error.
198
+
199
+ ### Tuning
200
+
201
+ | Env var | Default | Meaning |
202
+ |---|---|---|
203
+ | `PI_OLLAMA_SEARCH_TTL_HOURS` | `24` | Success cache TTL |
204
+ | `PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES` | `15` | Failure (negative) cache TTL |
205
+ | `PI_OLLAMA_SEARCH_CACHE_PATH` | `<pi agent home>/cache/pi-ollama-cloud/cache.json` | Cache file location |
206
+ | `PI_OLLAMA_SEARCH_MAX_ENTRIES` | `500` | Max cached entries per kind (searches/pages); oldest evicted beyond the cap |
207
+ | `PI_OLLAMA_SEARCH_SNIPPET_CHARS` | `500` | Search snippet length |
208
+ | `PI_OLLAMA_SEARCH_CHUNK_CHARS` | `3000` | Fetch chunk size |
209
+
170
210
  ## Commands
171
211
 
172
212
  | Command | Description |
173
213
  |---|---|
174
214
  | `/ollama-webtools [on\|off\|enable\|disable]` | Enable or disable the `ollama_web_search` and `ollama_web_fetch` tools. Toggles if no argument given. |
175
- | `/ollama-cloud-usage` | Show Ollama Cloud monthly usage limits, per-model request counts, and the 4-week activity cost. |
215
+ | `/ollama-cloud-usage` | Show Ollama Cloud usage limits (one section per limit bucket the API reports), per-model request counts, and the 4-week activity cost. |
176
216
  | `/ollama-usage-status [on\|off\|enable\|disable]` | Enable or disable the footer usage status bar. Toggles if no argument given. |
177
217
 
178
218
  ## Usage status bar
179
219
 
180
220
  While an `ollama-cloud` model is the active provider, the footer shows a compact
181
- live usage readout (`30d ▕███░░░░░░░▏ 34%`) that refreshes
182
- every 5 minutes and after each agent turn (but no more often than every 5 minutes). It is colored by how close
221
+ live usage readout with one segment per limit bucket the API reports
222
+ (`5h ▕███░░░░░░░▏ 34% 7d ▕█░░░░░░░░░▏ 7%`, or a single `30d` segment) that
223
+ refreshes every 5 minutes and after each agent turn (but no more often than every 5 minutes). It is colored by how close
183
224
  it is to the cap: green below 60%, yellow at 60-79%, red at 80%+. It reads the
184
225
  same undocumented `/api/usage` endpoint as `/ollama-cloud-usage` and clears
185
226
  itself on transient errors or when you switch to a non-Ollama-Cloud provider.
package/cache.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { envInt } from "./utils.ts";
6
+
7
+ export const CACHE_PATH =
8
+ process.env.PI_OLLAMA_SEARCH_CACHE_PATH ?? join(getAgentDir(), "cache", "pi-ollama-cloud", "cache.json");
9
+ export const CACHE_TTL_MS = envInt("PI_OLLAMA_SEARCH_TTL_HOURS", 24) * 60 * 60 * 1000;
10
+ export const FAIL_TTL_MS = envInt("PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES", 15) * 60 * 1000;
11
+ /** Max entries per map (searches/pages); oldest-ts entries are evicted beyond this. */
12
+ export const MAX_ENTRIES = envInt("PI_OLLAMA_SEARCH_MAX_ENTRIES", 500);
13
+
14
+ export interface SearchResult {
15
+ title: string;
16
+ url: string;
17
+ content: string;
18
+ }
19
+
20
+ export interface SearchCacheEntry {
21
+ ts: number;
22
+ q: string;
23
+ results: SearchResult[];
24
+ }
25
+
26
+ export interface PageCacheEntry {
27
+ ts: number;
28
+ status?: number;
29
+ title?: string;
30
+ content?: string;
31
+ links?: string[] | null;
32
+ error?: string;
33
+ errorType?: "response-shape";
34
+ }
35
+
36
+ export interface CacheData {
37
+ searches: Record<string, SearchCacheEntry>;
38
+ pages: Record<string, PageCacheEntry>;
39
+ }
40
+
41
+ interface CacheOptions {
42
+ path: string;
43
+ ttlMs: number;
44
+ failTtlMs: number;
45
+ maxEntries: number;
46
+ }
47
+
48
+ function isRecord(v: unknown): v is Record<string, unknown> {
49
+ return typeof v === "object" && v !== null && !Array.isArray(v);
50
+ }
51
+
52
+ /** Keys that must never come from a parsed JSON file (prototype pollution). */
53
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
54
+
55
+ export function isSafeKey(key: string): boolean {
56
+ return !UNSAFE_KEYS.has(key);
57
+ }
58
+
59
+ /** Shallow shape checks so a partially corrupted cache file degrades instead of crashing tool calls. */
60
+ function isSearchEntry(v: unknown): v is SearchCacheEntry {
61
+ return (
62
+ isRecord(v) &&
63
+ typeof v.ts === "number" &&
64
+ typeof v.q === "string" &&
65
+ Array.isArray(v.results) &&
66
+ v.results.every(
67
+ (r) => isRecord(r) && typeof r.title === "string" && typeof r.url === "string" && typeof r.content === "string",
68
+ )
69
+ );
70
+ }
71
+
72
+ function isPageEntry(v: unknown): v is PageCacheEntry {
73
+ if (!isRecord(v) || typeof v.ts !== "number") return false;
74
+ const fieldsValid =
75
+ (v.status === undefined || typeof v.status === "number") &&
76
+ (v.title === undefined || typeof v.title === "string") &&
77
+ (v.content === undefined || typeof v.content === "string") &&
78
+ (v.links === null ||
79
+ v.links === undefined ||
80
+ (Array.isArray(v.links) && v.links.every((l) => typeof l === "string"))) &&
81
+ (v.error === undefined || (typeof v.error === "string" && v.error !== "")) &&
82
+ (v.errorType === undefined || v.errorType === "response-shape");
83
+ if (!fieldsValid) return false;
84
+ // Must be either a real failure or a real success; anything else (e.g. an
85
+ // entry with neither content nor a non-empty error) would render as a fake
86
+ // empty success.
87
+ return (
88
+ (typeof v.error === "string" && v.error !== "") || (typeof v.title === "string" && typeof v.content === "string")
89
+ );
90
+ }
91
+
92
+ export interface CacheStore {
93
+ loadCache(): CacheData;
94
+ saveCache(): void;
95
+ isFresh(entry: { ts: number; error?: string } | undefined): boolean;
96
+ }
97
+
98
+ export function createCache(options: Partial<CacheOptions> = {}): CacheStore {
99
+ const path = options.path ?? CACHE_PATH;
100
+ const ttlMs = options.ttlMs ?? CACHE_TTL_MS;
101
+ const failTtlMs = options.failTtlMs ?? FAIL_TTL_MS;
102
+ const maxEntries = options.maxEntries ?? MAX_ENTRIES;
103
+ let cacheData: CacheData | null = null;
104
+
105
+ function loadCache(): CacheData {
106
+ if (cacheData) return cacheData;
107
+ try {
108
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
109
+ if (isRecord(raw) && isRecord(raw.searches) && isRecord(raw.pages)) {
110
+ // Per-entry validation: drop poisoned entries so a partially corrupt
111
+ // file degrades to "those entries are gone" instead of crashing calls.
112
+ cacheData = { searches: {}, pages: {} };
113
+ for (const [key, entry] of Object.entries(raw.searches)) {
114
+ if (isSafeKey(key) && isSearchEntry(entry)) cacheData.searches[key] = entry;
115
+ }
116
+ for (const [key, entry] of Object.entries(raw.pages)) {
117
+ if (isSafeKey(key) && isPageEntry(entry)) cacheData.pages[key] = entry;
118
+ }
119
+ return cacheData;
120
+ }
121
+ } catch {
122
+ // First run or corrupt file, start fresh.
123
+ }
124
+ cacheData = { searches: {}, pages: {} };
125
+ return cacheData;
126
+ }
127
+
128
+ function isFresh(entry: { ts: number; error?: string } | undefined): boolean {
129
+ if (!entry) return false;
130
+ // A future ts (hand-edited file) would otherwise be fresh forever; treat as stale.
131
+ if (entry.ts > Date.now()) return false;
132
+ return Date.now() - entry.ts < (entry.error ? failTtlMs : ttlMs);
133
+ }
134
+
135
+ function evictOldest(map: Record<string, { ts: number }>): void {
136
+ const keys = Object.keys(map);
137
+ if (keys.length <= maxEntries) return;
138
+ const overflow = keys.sort((a, b) => map[a].ts - map[b].ts).slice(0, keys.length - maxEntries);
139
+ for (const key of overflow) delete map[key];
140
+ }
141
+
142
+ function saveCache(): void {
143
+ const data = loadCache();
144
+ for (const [key, entry] of Object.entries(data.searches)) if (!isFresh(entry)) delete data.searches[key];
145
+ for (const [key, entry] of Object.entries(data.pages)) if (!isFresh(entry)) delete data.pages[key];
146
+ // TTL bounds entry age; the cap bounds entry count so an aggressive session
147
+ // cannot grow the file without limit.
148
+ evictOldest(data.searches);
149
+ evictOldest(data.pages);
150
+ try {
151
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
152
+ // Use a unique temporary path so concurrent processes cannot overwrite
153
+ // one another's in-progress writes.
154
+ const tmp = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
155
+ try {
156
+ // 0o600: the cache stores full page content and URLs, which can embed
157
+ // credentials in query strings; it should not be world-readable.
158
+ writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
159
+ renameSync(tmp, path);
160
+ // Fix perms of a file written by a pre-0600 version.
161
+ chmodSync(path, 0o600);
162
+ } catch (error) {
163
+ rmSync(tmp, { force: true });
164
+ throw error;
165
+ }
166
+ } catch {
167
+ // Cache is best-effort; a failed write must not break the tool call.
168
+ }
169
+ }
170
+
171
+ return { loadCache, saveCache, isFresh };
172
+ }
173
+
174
+ export const defaultCache = createCache();
175
+ export const loadCache = defaultCache.loadCache;
176
+ export const saveCache = defaultCache.saveCache;
177
+ export const isFresh = defaultCache.isFresh;
178
+
179
+ export function searchCacheKey(query: string, maxResults: number): string {
180
+ return createHash("sha1").update(`${query}\n${maxResults}`).digest("hex");
181
+ }
package/index.ts CHANGED
@@ -134,7 +134,7 @@ export default async function (pi: ExtensionAPI) {
134
134
  // --- Usage Command ---
135
135
 
136
136
  pi.registerCommand("ollama-cloud-usage", {
137
- description: "Show Ollama Cloud monthly usage limits.",
137
+ description: "Show Ollama Cloud usage limits.",
138
138
  handler: async (_args, ctx) => {
139
139
  const apiKey = await getCloudApiKey(ctx);
140
140
  if (!apiKey) {
@@ -152,7 +152,7 @@ export default async function (pi: ExtensionAPI) {
152
152
 
153
153
  // --- Usage Status Bar ---
154
154
 
155
- // Footer status showing live monthly usage while ollama-cloud is the
155
+ // Footer status showing live usage while ollama-cloud is the
156
156
  // active provider. Refreshes on a 5-minute timer; agent_end also triggers a
157
157
  // refresh but is throttled to the same cooldown so a turn never hammers the
158
158
  // undocumented /api/usage endpoint. The quota-bar concept is inspired by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ollama-cloud",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "index.ts",
10
10
  "config.ts",
11
+ "cache.ts",
11
12
  "limits.generated.ts",
12
13
  "models.ts",
13
14
  "models.generated.ts",
package/usage.ts CHANGED
@@ -11,6 +11,12 @@
11
11
  * fetch degrades gracefully: distinct HTTP statuses map to distinct
12
12
  * user-facing errors, and a malformed body raises a clear error rather than
13
13
  * crashing.
14
+ *
15
+ * The response shape has flipped twice: through 2026-09-02 it carried
16
+ * limits.session and limits.weekly, on 2026-09-03 it switched to a single
17
+ * limits.monthly bucket (0.10.0 adapted to that), and by 2026-09-07 it
18
+ * returned session and weekly again. All three buckets are therefore optional
19
+ * and whichever are present are displayed.
14
20
  */
15
21
 
16
22
  import type { Theme } from "@earendil-works/pi-coding-agent";
@@ -43,7 +49,12 @@ export interface UsageActivity {
43
49
 
44
50
  export interface UsageData {
45
51
  limits: {
46
- monthly: UsageLimit;
52
+ /** Monthly (30d) bucket, served by the API between 2026-09-03 and 2026-09-07. */
53
+ monthly?: UsageLimit;
54
+ /** Session (5h) bucket, served before 2026-09-03 and again as of 2026-09-07. */
55
+ session?: UsageLimit;
56
+ /** Weekly (7d) bucket, served before 2026-09-03 and again as of 2026-09-07. */
57
+ weekly?: UsageLimit;
47
58
  };
48
59
  activity?: UsageActivity;
49
60
  }
@@ -71,11 +82,18 @@ export function isUsageLimit(data: unknown): data is UsageLimit {
71
82
  );
72
83
  }
73
84
 
74
- /** Validate a parsed /api/usage response: must have a monthly limit. */
85
+ /**
86
+ * Validate a parsed /api/usage response: needs at least one valid limit bucket.
87
+ * The endpoint is undocumented and flips shape unpredictably (monthly-only,
88
+ * session+weekly, possibly other combinations), so any bucket present alone or
89
+ * in any combination is accepted and rendered.
90
+ */
75
91
  export function isUsageResponse(data: unknown): data is UsageData {
76
92
  if (data == null || typeof data !== "object") return false;
77
93
  const d = data as UsageData;
78
- return d.limits != null && typeof d.limits === "object" && isUsageLimit(d.limits.monthly);
94
+ if (d.limits == null || typeof d.limits !== "object" || Array.isArray(d.limits)) return false;
95
+ const buckets = [d.limits.monthly, d.limits.session, d.limits.weekly];
96
+ return buckets.some(isUsageLimit) && buckets.every((bucket) => bucket === undefined || isUsageLimit(bucket));
79
97
  }
80
98
 
81
99
  // --- Fetch ---
@@ -121,14 +139,30 @@ function usagePercent(usage: number): number {
121
139
  return Math.min(Math.max(Math.round(usage * 100), 0), 100);
122
140
  }
123
141
 
142
+ /** The limit buckets present in a response, in display order. */
143
+ function limitSegments(data: UsageData): Array<{ label: string; short: string; limit: UsageLimit }> {
144
+ const segs: Array<{ label: string; short: string; limit: UsageLimit }> = [];
145
+ if (isUsageLimit(data.limits.session)) {
146
+ segs.push({ label: "Session (5h)", short: "5h", limit: data.limits.session });
147
+ }
148
+ if (isUsageLimit(data.limits.weekly)) {
149
+ segs.push({ label: "Weekly (7d)", short: "7d", limit: data.limits.weekly });
150
+ }
151
+ if (isUsageLimit(data.limits.monthly)) {
152
+ segs.push({ label: "Monthly (30d)", short: "30d", limit: data.limits.monthly });
153
+ }
154
+ return segs;
155
+ }
156
+
124
157
  /** Format usage for the /ollama-cloud-usage command output. */
125
158
  export function formatUsage(data: UsageData): string {
126
159
  const lines: string[] = ["Ollama Cloud usage:"];
127
160
 
128
- const monthlyPct = usagePercent(data.limits.monthly.usage);
129
- lines.push(` Monthly (30d): ${monthlyPct}%`);
130
- for (const m of data.limits.monthly.models) {
131
- lines.push(` - ${m.name}: ${m.request_count} request${m.request_count === 1 ? "" : "s"}`);
161
+ for (const seg of limitSegments(data)) {
162
+ lines.push(` ${seg.label}: ${usagePercent(seg.limit.usage)}%`);
163
+ for (const m of seg.limit.models) {
164
+ lines.push(` - ${m.name}: ${m.request_count} request${m.request_count === 1 ? "" : "s"}`);
165
+ }
132
166
  }
133
167
 
134
168
  if (typeof data.activity?.cost === "string") {
@@ -151,11 +185,13 @@ function colorSegment(theme: Theme, label: string, pct: number): string {
151
185
  }
152
186
 
153
187
  /**
154
- * Compact one-line usage for the footer status bar, colored by usage level.
155
- * The endpoint exposes a monthly reset period but not an exact timestamp, so
156
- * the color reflects the usage fraction rather than pace.
188
+ * Compact one-line usage for the footer status bar, colored by usage level,
189
+ * with one segment per limit bucket present in the response (5h and 7d, or
190
+ * 30d when the API serves the monthly shape). The color reflects the usage
191
+ * fraction rather than pace because the bucket periods differ per shape.
157
192
  */
158
193
  export function formatUsageStatusColored(theme: Theme, data: UsageData): string {
159
- const monthly = usagePercent(data.limits.monthly.usage);
160
- return colorSegment(theme, "30d", monthly);
194
+ return limitSegments(data)
195
+ .map((seg) => colorSegment(theme, seg.short, usagePercent(seg.limit.usage)))
196
+ .join(" ");
161
197
  }
package/utils.ts CHANGED
@@ -111,3 +111,9 @@ export function httpError(op: string, status: number, error?: string): never {
111
111
  `Ollama Cloud ${op} failed: unexpected response (status ${status}${error ? `: ${error}` : ""}). Try again shortly.`,
112
112
  );
113
113
  }
114
+
115
+ /** Parse a positive-integer env var, falling back when unset or invalid (NaN, non-integer, <= 0). */
116
+ export function envInt(name: string, fallback: number): number {
117
+ const value = Number(process.env[name]);
118
+ return Number.isInteger(value) && value > 0 ? value : fallback;
119
+ }
package/web-tools.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * - pi-coding-agent - ExtensionAPI, ExtensionContext, keyHint, truncateToVisualLines
7
7
  * - pi-tui - Text, truncateToWidth
8
8
  * - utils.ts - fetchJsonWithTimeout
9
+ * - cache.ts - disk-backed cache (24h success / 15min failure TTL)
9
10
  * Does NOT depend on provider registration or model fetching internals.
10
11
  *
11
12
  * API key resolution: each tool's execute() receives an ExtensionContext whose
@@ -26,8 +27,18 @@ import {
26
27
  } from "@earendil-works/pi-coding-agent";
27
28
  import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
28
29
  import { Type } from "@sinclair/typebox";
30
+ import {
31
+ CACHE_TTL_MS,
32
+ type CacheStore,
33
+ defaultCache,
34
+ FAIL_TTL_MS,
35
+ isSafeKey,
36
+ type PageCacheEntry,
37
+ type SearchResult,
38
+ searchCacheKey,
39
+ } from "./cache.ts";
29
40
  import { OLLAMA_BASE } from "./models.ts";
30
- import { fetchJsonWithTimeout, getCloudApiKey, httpError } from "./utils.ts";
41
+ import { envInt, fetchJsonWithTimeout, getCloudApiKey, httpError } from "./utils.ts";
31
42
 
32
43
  // --- Types ---
33
44
 
@@ -48,6 +59,12 @@ interface FetchResponse {
48
59
  // --- Helpers ---
49
60
 
50
61
  const WEB_TOOLS_TIMEOUT_MS = 15000;
62
+ // Search snippets and fetch chunks are capped so a single call never floods the
63
+ // context window; the agent pages through long pages with offset/full.
64
+ const SNIPPET_LIMIT = envInt("PI_OLLAMA_SEARCH_SNIPPET_CHARS", 500);
65
+ const READ_CHUNK = envInt("PI_OLLAMA_SEARCH_CHUNK_CHARS", 3000);
66
+ const SUCCESS_TTL_HOURS = CACHE_TTL_MS / 3_600_000;
67
+ const FAIL_TTL_MINUTES = Math.round(FAIL_TTL_MS / 60_000);
51
68
 
52
69
  /** Throw a no-API-key error. */
53
70
  function noApiKeyError(): never {
@@ -135,15 +152,61 @@ export function isFetchResponse(data: unknown): data is FetchResponse {
135
152
  );
136
153
  }
137
154
 
155
+ /** Build a failure message with likely causes and next steps (thrown, per the AgentToolResult contract). */
156
+ function fetchFailureMessage(
157
+ url: string,
158
+ entry: PageCacheEntry,
159
+ failureState: "cached" | "live-cached" | "live-uncached",
160
+ ): string {
161
+ const lines = [
162
+ `Ollama Cloud fetch failed: ${url}`,
163
+ `API error: ${entry.error}`,
164
+ entry.errorType === "response-shape"
165
+ ? "Status: live request returned an unexpected response shape (not cached; the next call retries the API)"
166
+ : failureState === "cached"
167
+ ? `Status: from cache (failure cached for ${FAIL_TTL_MINUTES} min; pass refresh=true to force a live retry)`
168
+ : failureState === "live-cached"
169
+ ? `Status: live request failed (failure cached for ${FAIL_TTL_MINUTES} min; pass refresh=true to force a live retry)`
170
+ : "Status: live request failed (not cached; the next call retries the API)",
171
+ ];
172
+ if (entry.status === 401 || entry.status === 403) {
173
+ lines.push(
174
+ "Likely cause: authentication error.",
175
+ "Suggestion: check your API key in OLLAMA_API_KEY or auth.json, then retry (auth failures are not cached).",
176
+ );
177
+ } else if (entry.status === 429) {
178
+ lines.push("Likely cause: rate limited.", "Suggestion: try again shortly (rate-limit failures are not cached).");
179
+ } else if (entry.errorType === "response-shape") {
180
+ lines.push(
181
+ "Likely cause: Ollama Cloud returned an unexpected response shape.",
182
+ "Suggestion: try again shortly; this failure is not cached.",
183
+ );
184
+ } else if (entry.status !== undefined && entry.status >= 500) {
185
+ lines.push(
186
+ "Likely cause: Ollama server error.",
187
+ "Suggestion: try again shortly (server failures are not cached; the next call retries the API).",
188
+ );
189
+ } else {
190
+ lines.push(
191
+ "Likely cause: anti-bot / login wall, JS-rendered page, or malformed URL.",
192
+ "Suggestion: 1) use ollama_web_search for the site/topic; 2) check the URL; 3) retrying with offset/full will also fail — use refresh=true only if you believe the failure was transient.",
193
+ );
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+
138
198
  // --- Registrations ---
139
199
 
140
- export function registerWebSearchTool(pi: ExtensionAPI) {
200
+ export function registerWebSearchTool(pi: ExtensionAPI, cacheStore: CacheStore = defaultCache) {
141
201
  pi.registerTool({
142
202
  name: "ollama_web_search",
143
203
  label: "Ollama Web Search",
144
204
  description:
145
205
  "Search the web for real-time information using Ollama Cloud's web search API. " +
146
- "Returns relevant results with titles, URLs, and content snippets. " +
206
+ `Returns up to max_results results (default 5, max 10; title, URL, ${SNIPPET_LIMIT}-char snippet; [truncated] means the source is longer — ` +
207
+ "pass expand=<index> to get that result's full content from the cached search, 0 extra API calls). " +
208
+ `Results are cached for ${SUCCESS_TTL_HOURS}h: the same query within that window costs 0 API calls. ` +
209
+ "Pass refresh=true to bypass the cache and re-call the API (e.g. results look stale). " +
147
210
  "Requires an Ollama Cloud API key.",
148
211
  parameters: Type.Object({
149
212
  query: Type.String({ description: "The search query to execute" }),
@@ -155,6 +218,21 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
155
218
  maximum: 10,
156
219
  }),
157
220
  ),
221
+ refresh: Type.Optional(
222
+ Type.Boolean({
223
+ description:
224
+ "Bypass the cached search and re-call the API (default: false). The fresh result replaces the cache entry.",
225
+ default: false,
226
+ }),
227
+ ),
228
+ expand: Type.Optional(
229
+ Type.Integer({
230
+ description:
231
+ "Return the full content of this result (1-based index) instead of snippets. " +
232
+ "Served from the cached search — 0 API calls if the query was searched recently.",
233
+ minimum: 1,
234
+ }),
235
+ ),
158
236
  }),
159
237
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
160
238
  const apiKey = await getCloudApiKey(ctx);
@@ -162,37 +240,97 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
162
240
  noApiKeyError();
163
241
  }
164
242
 
165
- const res = await fetchJsonWithTimeout<SearchResponse>(
166
- `${OLLAMA_BASE}/api/web_search`,
167
- {
168
- method: "POST",
169
- headers: {
170
- Authorization: `Bearer ${apiKey}`,
171
- "Content-Type": "application/json",
243
+ const maxResults = params.max_results ?? 5;
244
+ const cache = cacheStore.loadCache();
245
+ const key = searchCacheKey(params.query, maxResults);
246
+ const cached = cache.searches[key];
247
+ let live = false;
248
+ let results: SearchResult[];
249
+
250
+ if (!params.refresh && cacheStore.isFresh(cached)) {
251
+ results = cached!.results;
252
+ } else {
253
+ live = true;
254
+ const res = await fetchJsonWithTimeout<SearchResponse>(
255
+ `${OLLAMA_BASE}/api/web_search`,
256
+ {
257
+ method: "POST",
258
+ headers: {
259
+ Authorization: `Bearer ${apiKey}`,
260
+ "Content-Type": "application/json",
261
+ },
262
+ body: JSON.stringify({
263
+ query: params.query,
264
+ max_results: maxResults,
265
+ }),
172
266
  },
173
- body: JSON.stringify({
174
- query: params.query,
175
- max_results: params.max_results ?? 5,
176
- }),
177
- },
178
- WEB_TOOLS_TIMEOUT_MS,
179
- signal,
180
- );
267
+ WEB_TOOLS_TIMEOUT_MS,
268
+ signal,
269
+ );
270
+
271
+ if (!res.ok) {
272
+ if (res.status === 0) {
273
+ // Transport failure (timeout, abort, network); not a server answer.
274
+ throw new Error(
275
+ `Ollama Cloud search failed: transport error (${res.error ?? "unknown"}). Not cached; the next call retries the API.`,
276
+ );
277
+ }
278
+ httpError("search", res.status, res.error);
279
+ }
280
+ if (!isSearchResponse(res.data)) {
281
+ throw new Error("Web search failed: unexpected response shape from the API.");
282
+ }
181
283
 
182
- if (!res.ok) {
183
- httpError("search", res.status, res.error);
284
+ results = res.data.results.map((r) => ({
285
+ title: r.title,
286
+ url: r.url,
287
+ content: r.content,
288
+ }));
289
+ cache.searches[key] = { ts: Date.now(), q: params.query, results };
290
+ cacheStore.saveCache();
184
291
  }
185
- if (!isSearchResponse(res.data)) {
186
- throw new Error("Web search failed: unexpected response shape from the API.");
292
+
293
+ // Expand mode: return the full content of one result from the cached search.
294
+ if (params.expand !== undefined) {
295
+ const idx = params.expand;
296
+ if (idx < 1 || idx > results.length) {
297
+ throw new Error(
298
+ `Web search expand: index ${idx} out of range (this search has ${results.length} results). ` +
299
+ "Use ollama_web_fetch to read a specific URL instead.",
300
+ );
301
+ }
302
+ const r = results[idx - 1];
303
+ return {
304
+ content: [
305
+ {
306
+ type: "text",
307
+ text: `Result ${idx} of "${params.query}" (full content, ${r.content.length} chars):\n\n${r.content}\n\n${live ? "# live query" : "# from cache"}`,
308
+ },
309
+ ],
310
+ details: { results: [r] },
311
+ };
187
312
  }
188
313
 
189
- const formatted = res.data.results
190
- .map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.content}`)
314
+ const formatted = results
315
+ .map((r, i) => {
316
+ const truncated = r.content.length > SNIPPET_LIMIT;
317
+ return `${i + 1}. ${truncated ? "[truncated]" : "[complete]"} ${r.title}\n URL: ${r.url}\n ${r.content.slice(0, SNIPPET_LIMIT)}`;
318
+ })
191
319
  .join("\n\n");
192
320
 
321
+ const hasTruncated = results.some((r) => r.content.length > SNIPPET_LIMIT);
322
+ const expandHint = hasTruncated
323
+ ? `\n\nExpand: call ollama_web_search(query=${JSON.stringify(params.query)}, max_results=${maxResults}, expand=<index>) to read a [truncated] result in full — 0 extra API calls.`
324
+ : "";
325
+
193
326
  return {
194
- content: [{ type: "text", text: formatted || "No results found." }],
195
- details: { results: res.data.results },
327
+ content: [
328
+ {
329
+ type: "text",
330
+ text: `${formatted || "No results found."}${expandHint}\n\n${live ? "# live query" : "# from cache"}`,
331
+ },
332
+ ],
333
+ details: { results },
196
334
  };
197
335
  },
198
336
  renderCall(args, theme, _context) {
@@ -203,16 +341,39 @@ export function registerWebSearchTool(pi: ExtensionAPI) {
203
341
  });
204
342
  }
205
343
 
206
- export function registerWebFetchTool(pi: ExtensionAPI) {
344
+ export function registerWebFetchTool(pi: ExtensionAPI, cacheStore: CacheStore = defaultCache) {
207
345
  pi.registerTool({
208
346
  name: "ollama_web_fetch",
209
347
  label: "Ollama Web Fetch",
210
348
  description:
211
349
  "Fetch and extract text content from a web page URL using Ollama Cloud's web fetch API. " +
212
- "Returns the page title, main content, and links found on the page. " +
213
- "Requires an Ollama Cloud API key.",
350
+ `Returns the page title, a ${READ_CHUNK}-char slice of the content, and links. Pages are cached for ${SUCCESS_TTL_HOURS}h. ` +
351
+ "Pass offset=N to continue reading from char N (the output tells you the next offset), or " +
352
+ `full=true to get all remaining content from offset in one call. A failed URL is cached for ${FAIL_TTL_MINUTES} min ` +
353
+ `— retrying it within the failure TTL (${FAIL_TTL_MINUTES} min) costs 0 API calls and fails the same way; pass refresh=true to ` +
354
+ "force a live retry. Requires an Ollama Cloud API key.",
214
355
  parameters: Type.Object({
215
356
  url: Type.String({ description: "URL to fetch and extract content from", format: "uri" }),
357
+ offset: Type.Optional(
358
+ Type.Integer({
359
+ description: "Start reading from this character index (default: 0)",
360
+ default: 0,
361
+ minimum: 0,
362
+ }),
363
+ ),
364
+ full: Type.Optional(
365
+ Type.Boolean({
366
+ description: "Return all remaining content from offset in one call (default: false)",
367
+ default: false,
368
+ }),
369
+ ),
370
+ refresh: Type.Optional(
371
+ Type.Boolean({
372
+ description:
373
+ "Bypass the cached page (or cached failure) and re-call the API (default: false). The fresh result replaces the cache entry.",
374
+ default: false,
375
+ }),
376
+ ),
216
377
  }),
217
378
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
218
379
  const apiKey = await getCloudApiKey(ctx);
@@ -220,41 +381,85 @@ export function registerWebFetchTool(pi: ExtensionAPI) {
220
381
  noApiKeyError();
221
382
  }
222
383
 
223
- const res = await fetchJsonWithTimeout<FetchResponse>(
224
- `${OLLAMA_BASE}/api/web_fetch`,
225
- {
226
- method: "POST",
227
- headers: {
228
- Authorization: `Bearer ${apiKey}`,
229
- "Content-Type": "application/json",
384
+ const cache = cacheStore.loadCache();
385
+ let entry = cache.pages[params.url];
386
+ let live = false;
387
+ let liveCacheable = false;
388
+
389
+ if (params.refresh || !cacheStore.isFresh(entry)) {
390
+ live = true;
391
+ const res = await fetchJsonWithTimeout<FetchResponse>(
392
+ `${OLLAMA_BASE}/api/web_fetch`,
393
+ {
394
+ method: "POST",
395
+ headers: {
396
+ Authorization: `Bearer ${apiKey}`,
397
+ "Content-Type": "application/json",
398
+ },
399
+ body: JSON.stringify({ url: params.url }),
230
400
  },
231
- body: JSON.stringify({ url: params.url }),
232
- },
233
- WEB_TOOLS_TIMEOUT_MS,
234
- signal,
235
- );
401
+ WEB_TOOLS_TIMEOUT_MS,
402
+ signal,
403
+ );
236
404
 
237
- if (!res.ok) {
238
- httpError("fetch", res.status, res.error);
405
+ if (!res.ok) {
406
+ entry = { ts: Date.now(), status: res.status, error: `HTTP ${res.status}: ${res.error ?? "unknown error"}` };
407
+ // Auth, rate-limit, transport (status 0: timeout, abort, DNS/connection
408
+ // errors), and server (5xx) failures are not negative-cached: a fixed
409
+ // key, an expired rate-limit window, or a transient server/network
410
+ // issue should let the next retry through.
411
+ liveCacheable =
412
+ res.status !== 0 && res.status < 500 && res.status !== 401 && res.status !== 403 && res.status !== 429;
413
+ } else if (!isFetchResponse(res.data)) {
414
+ // A shape failure is a transient server-side bug, not a durable
415
+ // property of the page; never negative-cache it.
416
+ entry = { ts: Date.now(), error: "unexpected response shape from the API", errorType: "response-shape" };
417
+ liveCacheable = false;
418
+ } else {
419
+ entry = {
420
+ ts: Date.now(),
421
+ title: res.data.title,
422
+ content: res.data.content,
423
+ links: res.data.links,
424
+ };
425
+ liveCacheable = true;
426
+ }
427
+ if (liveCacheable && isSafeKey(params.url)) {
428
+ cache.pages[params.url] = entry;
429
+ cacheStore.saveCache();
430
+ }
239
431
  }
240
- if (!isFetchResponse(res.data)) {
241
- throw new Error("Web fetch failed: unexpected response shape from the API.");
432
+
433
+ if (entry.error) {
434
+ throw new Error(
435
+ fetchFailureMessage(params.url, entry, live ? (liveCacheable ? "live-cached" : "live-uncached") : "cached"),
436
+ );
242
437
  }
243
438
 
244
- const data = res.data;
245
- const formatted = [
246
- `Title: ${data.title}`,
247
- "",
248
- "Content:",
249
- data.content,
250
- "",
251
- `Links found: ${data.links?.length ?? 0}`,
252
- ...(data.links?.slice(0, 10).map((l) => ` - ${l}`) ?? []),
253
- ].join("\n");
439
+ const content = entry.content ?? "";
440
+ const total = content.length;
441
+ const start = params.offset ?? 0;
442
+ const end = params.full ? total : Math.min(start + READ_CHUNK, total);
443
+ const lines = [
444
+ `Title: ${entry.title} (${total} chars total)`,
445
+ start >= total
446
+ ? "Already at the end, no more content."
447
+ : `Chars ${start + 1}-${end} of ${total}${end < total ? ` (${total - end} remaining)` : ""}:`,
448
+ content.slice(start, end),
449
+ ];
450
+ if (!params.full && end < total) {
451
+ lines.push(`\nContinue: call ollama_web_fetch(url="${params.url}", offset=${end})`);
452
+ }
453
+ if ((params.offset ?? 0) === 0 && !params.full) {
454
+ const links = entry.links ?? [];
455
+ lines.push(`\nLinks (${links.length}):`);
456
+ lines.push(...links.slice(0, 10).map((l) => ` - ${l}`));
457
+ }
458
+ lines.push(live ? "# live query" : "# from cache");
254
459
 
255
460
  return {
256
- content: [{ type: "text", text: formatted }],
257
- details: { title: data.title, content: data.content, links: data.links },
461
+ content: [{ type: "text", text: lines.join("\n") }],
462
+ details: { title: entry.title, totalChars: total, links: entry.links },
258
463
  };
259
464
  },
260
465
  renderCall(args, theme, _context) {