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 +7 -0
- package/README.md +44 -3
- package/cache.ts +181 -0
- package/index.ts +2 -2
- package/package.json +2 -1
- package/usage.ts +48 -12
- package/utils.ts +6 -0
- package/web-tools.ts +262 -57
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
|
|
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
|
|
182
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
156
|
-
* the
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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 =
|
|
190
|
-
.map((r, i) =>
|
|
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: [
|
|
195
|
-
|
|
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
|
-
|
|
213
|
-
"
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
signal,
|
|
235
|
-
);
|
|
401
|
+
WEB_TOOLS_TIMEOUT_MS,
|
|
402
|
+
signal,
|
|
403
|
+
);
|
|
236
404
|
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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:
|
|
257
|
-
details: { title:
|
|
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) {
|