@openlines/opl-pi-sht 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -17,7 +17,7 @@ A portable collection of various Pi coding agent extensions. Repository director
17
17
  <div align="center">
18
18
 
19
19
  >
20
- > ## v0.2.4 (22 Sept 2026) - Pi 0.87 host-loader smoke test now runs in CI<br/>see [CHANGELOG.md](CHANGELOG.md) for more details.
20
+ > ### v0.2.6 (24 Sept 2026) - **Post-compaction context estimate**: `opl-footer` keeps the context bar visible with explicitly approximate values until Pi reports fresh usage. <br/>see [CHANGELOG.md](CHANGELOG.md) for more details.
21
21
  >
22
22
 
23
23
  </div>
@@ -4,5 +4,6 @@
4
4
  "width": 1280,
5
5
  "height": 800,
6
6
  "navigationTimeoutMs": 30000,
7
- "previewChars": 4000
7
+ "previewChars": 4000,
8
+ "getChars": 30000
8
9
  }
@@ -1,4 +1,7 @@
1
1
  {
2
+ "_comment_limits": "Optional top-level caps: maxContentChars caps the initial web_search/fetch_content body (default 30000); maxRetrievalChars caps one get_search_content page (default 30000). Pass offset=N to get_search_content to continue a truncated retrieval.",
3
+ "maxContentChars": 30000,
4
+ "maxRetrievalChars": 30000,
2
5
  "provider": "searxng",
3
6
  "providers": {
4
7
  "gemini": {
@@ -37,8 +37,9 @@ Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
37
37
  ~29 per-tool schemas. Keeps the cached prefix small.
38
38
  - **Handle + preview output.** Large results (snapshots, console/network logs,
39
39
  evaluate output) are kept out of context: the tool returns a truncated preview
40
- plus a `responseId`; call `action: "get"` with that id for the full text.
41
- Screenshots are written to a file, never inlined as base64.
40
+ plus a `responseId`; call `action: "get"` with that id to page through the full
41
+ text (`getChars` per page, pass `offset` to continue). Screenshots are written to
42
+ a file, never inlined as base64.
42
43
  - **Structured extraction.** `extract` runs Readability + Turndown over the
43
44
  *rendered* (post-JS) DOM — the complement to `opl-webaccess`'s `fetch_content`,
44
45
  which only sees raw HTTP HTML. Static pages: `fetch_content`; rendered or
@@ -66,6 +67,7 @@ Optional `~/.pi/agent/configs/opl-browser.json` (see `opl-browser.json.sample`):
66
67
  | `width` / `height` | `1280` / `800` | Initial viewport. |
67
68
  | `navigationTimeoutMs` | `30000` | Default navigation and `wait_for` timeout. |
68
69
  | `previewChars` | `4000` | Inline threshold; larger outputs are stored and previewed. |
70
+ | `getChars` | `30000` | Characters returned by one `action: "get"` page. |
69
71
 
70
72
  ### Dependencies
71
73
 
@@ -87,6 +89,7 @@ browser.ts Playwright driver: browser/context/page lifecycle, per-page console
87
89
  and network buffers, and the action switch.
88
90
  validate.ts URL/path guards: http/https-only navigation, screenshot path confined
89
91
  to the project directory.
92
+ paging.ts Bounded `get` pagination and continuation metadata.
90
93
  extract.ts Pure rendered-HTML → markdown pipeline (linkedom + Readability +
91
94
  turndown), duplicated from opl-webaccess to keep installs independent.
92
95
  config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
@@ -8,6 +8,7 @@ export interface BrowserConfig {
8
8
  height: number;
9
9
  navigationTimeoutMs: number;
10
10
  previewChars: number;
11
+ getChars: number;
11
12
  }
12
13
 
13
14
  export const DEFAULT_CONFIG: BrowserConfig = {
@@ -16,6 +17,7 @@ export const DEFAULT_CONFIG: BrowserConfig = {
16
17
  height: 800,
17
18
  navigationTimeoutMs: 30000,
18
19
  previewChars: 4000,
20
+ getChars: 30000,
19
21
  };
20
22
 
21
23
  const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "configs", "opl-browser.json");
@@ -33,5 +35,6 @@ export function loadUserConfig(configPath = CONFIG_PATH): BrowserConfig {
33
35
  height: user.height ?? DEFAULT_CONFIG.height,
34
36
  navigationTimeoutMs: user.navigationTimeoutMs ?? DEFAULT_CONFIG.navigationTimeoutMs,
35
37
  previewChars: user.previewChars ?? DEFAULT_CONFIG.previewChars,
38
+ getChars: user.getChars ?? DEFAULT_CONFIG.getChars,
36
39
  };
37
40
  }
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
 
4
4
  import { loadUserConfig } from "./config.js";
5
5
  import { runAction, closeBrowser, type BrowserParams } from "./browser.js";
6
+ import { paginateStored, continuationNotice } from "./paging.js";
6
7
 
7
8
  // ponytail: in-memory store with 1h TTL. Large outputs (snapshot, console,
8
9
  // network, evaluate) are kept out of context; only a preview + id are returned.
@@ -52,15 +53,20 @@ export default function (pi: ExtensionAPI) {
52
53
  width: Type.Optional(Type.Number({ description: "resize: viewport width" })),
53
54
  height: Type.Optional(Type.Number({ description: "resize: viewport height" })),
54
55
  responseId: Type.Optional(Type.String({ description: "get: id from a previous large result" })),
56
+ offset: Type.Optional(Type.Number({ description: "get: character offset to continue a truncated retrieval" })),
55
57
  }),
56
58
  async execute(_toolCallId, params) {
57
- const p = params as BrowserParams & { responseId?: string };
59
+ const p = params as BrowserParams & { responseId?: string; offset?: number };
58
60
 
59
61
  if (p.action === "get") {
60
62
  if (!p.responseId) return err("get requires responseId");
61
63
  const hit = STORE.get(p.responseId);
62
64
  if (!hit) return err(`No stored result for ${p.responseId} (expires after 1h or on browser close).`);
63
- return { content: [{ type: "text", text: hit.text }], details: { responseId: hit.id, action: hit.action } };
65
+ const page = paginateStored(hit.text, p.offset ?? 0, cfg.getChars);
66
+ return {
67
+ content: [{ type: "text", text: page.text + continuationNotice(page) }],
68
+ details: { responseId: hit.id, action: hit.action, chars: hit.text.length, offset: page.offset },
69
+ };
64
70
  }
65
71
 
66
72
  let result;
@@ -0,0 +1,26 @@
1
+ export interface StoredPage {
2
+ text: string;
3
+ offset: number;
4
+ totalChars: number;
5
+ nextOffset: number | null;
6
+ }
7
+
8
+ /** Slice stored browser output to one bounded page. Offsets past the end yield empty text. */
9
+ export function paginateStored(text: string, offset: number, maxChars: number): StoredPage {
10
+ const totalChars = text.length;
11
+ const safeOffset = Number.isFinite(offset) ? Math.floor(offset) : 0;
12
+ const start = Math.min(Math.max(0, safeOffset), totalChars);
13
+ const end = Math.min(start + maxChars, totalChars);
14
+ return {
15
+ text: text.slice(start, end),
16
+ offset: start,
17
+ totalChars,
18
+ nextOffset: end < totalChars ? end : null,
19
+ };
20
+ }
21
+
22
+ /** Model-facing continuation metadata, empty when the page was complete. */
23
+ export function continuationNotice(page: StoredPage): string {
24
+ if (page.nextOffset === null) return "";
25
+ return `\n\n[Content truncated at ${page.offset + page.text.length} of ${page.totalChars} chars. Continue with action:get offset=${page.nextOffset}.]`;
26
+ }
@@ -75,7 +75,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
75
75
  | `model` | Model name in pink + `(provider)` in dim | No icon; provider omitted if unavailable |
76
76
  | `path` | Current working directory | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
77
77
  | `git` | Git branch and dirty indicators | `showBranch`, `showStaged`, `showUnstaged`, `showUntracked` (all bool) |
78
- | `context_pct` | Gradient bar + `X.X%` + max tokens | Bar fully configurable via `segmentOptions.contextBar` (see below). % and max tokens use `contextLabel` colour. Max tokens formatted with K/M suffix (e.g. `128k`, `2M`). Set `DEBUG_PCT` in `context.ts` to a number (0–100) to pin the bar at a fixed value for visual testing. |
78
+ | `context_pct` | Gradient bar + `X.X%` + max tokens | Bar fully configurable via `segmentOptions.contextBar` (see below). % and max tokens use `contextLabel` colour. Max tokens formatted with K/M suffix (e.g. `128k`, `2M`). Usage comes from `ctx.getContextUsage()`; when Pi reports it as unknown right after compaction, the segment estimates the rebuilt projection with Pi’s `estimateTokens()` and marks the bar and values with `≈` until a fresh assistant response provides exact usage. If no projection is available, it renders `(--%)` rather than a stale value. Set `DEBUG_PCT` in `context.ts` to a number (0–100) to pin the bar at a fixed value for visual testing. |
79
79
  | `cost` | `$<amount>` (4 decimals, e.g. `$0.0123`) | `$` dim, amount in `cost` colour (`muted` by default). Four decimals keep cheap local or short sessions distinguishable instead of pinning at `$0.00`. Shows dim `(no pricing)` when the session total is zero on a non-local model (provider has no pricing configured), and `(local model)` for local models |
80
80
  | `thinking` | `Thinking: <LEVEL>` | Dim label, CAPS level with per-level colour; always visible |
81
81
  | `mode_switcher` | Unified active mode label | Reads state published by `opl-modes`; `appearance.modeColor` controls the mode value, with hardcoded `muted` fallback |
@@ -29,9 +29,16 @@ let branchInvalidationCounter = 0;
29
29
  let repoAbsentUntil = 0;
30
30
  let repoCheckInFlight = false;
31
31
 
32
+ let clock: () => number = () => Date.now();
33
+
34
+ /** Test seam: override the time source so TTL/back-off behaviour is deterministic. */
35
+ export function setClock(fn?: () => number): void {
36
+ clock = fn ?? (() => Date.now());
37
+ }
38
+
32
39
  /** Assume "not a repo" immediately, then confirm asynchronously so a real repo recovers. */
33
40
  function noteProbeFailure(): void {
34
- repoAbsentUntil = Date.now() + NOT_A_REPO_TTL_MS;
41
+ repoAbsentUntil = clock() + NOT_A_REPO_TTL_MS;
35
42
  if (repoCheckInFlight) return;
36
43
  repoCheckInFlight = true;
37
44
  void runGit(["rev-parse", "--is-inside-work-tree"]).then((out) => {
@@ -41,7 +48,7 @@ function noteProbeFailure(): void {
41
48
  }
42
49
 
43
50
  function repoAbsent(): boolean {
44
- return Date.now() < repoAbsentUntil;
51
+ return clock() < repoAbsentUntil;
45
52
  }
46
53
 
47
54
  function parseGitStatusOutput(output: string): { staged: number; unstaged: number; untracked: number } {
@@ -122,7 +129,7 @@ async function fetchGitStatus(): Promise<{ staged: number; unstaged: number; unt
122
129
  }
123
130
 
124
131
  export function getCurrentBranch(providerBranch: string | null): string | null {
125
- const now = Date.now();
132
+ const now = clock();
126
133
 
127
134
  if (now < repoAbsentUntil) {
128
135
  return cachedBranch ? cachedBranch.branch : providerBranch;
@@ -141,7 +148,7 @@ export function getCurrentBranch(providerBranch: string | null): string | null {
141
148
  if (result === null) noteProbeFailure();
142
149
  cachedBranch = {
143
150
  branch: result,
144
- timestamp: Date.now(),
151
+ timestamp: clock(),
145
152
  };
146
153
  }
147
154
  pendingBranchFetch = null;
@@ -152,7 +159,7 @@ export function getCurrentBranch(providerBranch: string | null): string | null {
152
159
  }
153
160
 
154
161
  export function getGitStatus(providerBranch: string | null): GitStatus {
155
- const now = Date.now();
162
+ const now = clock();
156
163
 
157
164
  if (now < repoAbsentUntil) {
158
165
  const branch = cachedBranch ? cachedBranch.branch : providerBranch;
@@ -180,8 +187,8 @@ export function getGitStatus(providerBranch: string | null): GitStatus {
180
187
  if (fetchId === invalidationCounter) {
181
188
  if (result === null) noteProbeFailure();
182
189
  cachedStatus = result
183
- ? { staged: result.staged, unstaged: result.unstaged, untracked: result.untracked, timestamp: Date.now() }
184
- : { staged: 0, unstaged: 0, untracked: 0, timestamp: Date.now() };
190
+ ? { staged: result.staged, unstaged: result.unstaged, untracked: result.untracked, timestamp: clock() }
191
+ : { staged: 0, unstaged: 0, untracked: 0, timestamp: clock() };
185
192
  }
186
193
  pendingFetch = null;
187
194
  });
@@ -5,6 +5,7 @@ import type { TUI } from "@earendil-works/pi-tui";
5
5
 
6
6
  import type { SegmentContext, StatusLineSegmentId, UsageStats, SessionStats, SessionEvent, ThinkingLevelEvent, AssistantMessageEvent, ToolResultEvent, UserBashEvent } from "./types.js";
7
7
  import { renderSegment } from "./segments/index.js";
8
+ import { estimateContextUsage } from "./segments/context.js";
8
9
  import { createAgentStatusTracker } from "./segments/status.js";
9
10
  import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
10
11
  import { getEffectiveConfig } from "./config.js";
@@ -278,15 +279,16 @@ export default function footer(pi: ExtensionAPI) {
278
279
  ? thinkingEvents.reduce((_, e) => e.thinkingLevel ?? "off", "off")
279
280
  : null;
280
281
 
281
- const lastAssistant = completedMessages.at(-1);
282
-
283
- // Calculate context percentage
284
- const contextTokens = lastAssistant
285
- ? lastAssistant.usage.input + lastAssistant.usage.output +
286
- lastAssistant.usage.cacheRead + lastAssistant.usage.cacheWrite
287
- : 0;
288
- const contextWindow = ctx.model?.contextWindow || 0;
289
- const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
282
+ // Prefer Pi's canonical context usage (0.87). Immediately after compaction it is
283
+ // null until the next assistant reply, so estimate the rebuilt projection instead.
284
+ const usage = typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined;
285
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
286
+ const projectedMessages = ctx.sessionManager?.buildSessionProjection?.().messages;
287
+ const estimatedContextUsage = usage?.percent === null && contextWindow > 0 && projectedMessages
288
+ ? estimateContextUsage(projectedMessages, contextWindow)
289
+ : null;
290
+ const contextPercent = usage?.percent ?? estimatedContextUsage?.percent ?? null;
291
+ const contextEstimated = estimatedContextUsage !== null;
290
292
 
291
293
  // Get git status (cached). Skip the probes entirely when no visible row renders the
292
294
  // git segment — otherwise an unused cell keeps spawning git once per second.
@@ -325,6 +327,7 @@ export default function footer(pi: ExtensionAPI) {
325
327
  sessionId: ctx.sessionManager?.getSessionId?.(),
326
328
  usageStats,
327
329
  contextPercent,
330
+ contextEstimated,
328
331
  contextWindow,
329
332
  usingSubscription,
330
333
  sessionStartTime,
@@ -1,3 +1,5 @@
1
+ import { estimateTokens } from "@earendil-works/pi-coding-agent";
2
+
1
3
  import type { RenderedSegment, SegmentContext } from "../types.js";
2
4
  import { applyColor, resolveColorToRgb } from "../theme.js";
3
5
  import { color } from "./helpers.js";
@@ -19,6 +21,14 @@ export function lerp(a: number, b: number, t: number): number {
19
21
  return Math.round(a + (b - a) * t);
20
22
  }
21
23
 
24
+ export function estimateContextUsage(
25
+ messages: Parameters<typeof estimateTokens>[0][],
26
+ contextWindow: number,
27
+ ): { tokens: number; percent: number } {
28
+ const tokens = messages.reduce((total, message) => total + estimateTokens(message), 0);
29
+ return { tokens, percent: (tokens / contextWindow) * 100 };
30
+ }
31
+
22
32
  function positionColor(
23
33
  pos: number,
24
34
  barWidth: number,
@@ -46,6 +56,9 @@ function positionColor(
46
56
  export const contextPctSegment = {
47
57
  id: "context_pct" as const,
48
58
  render(ctx: SegmentContext): RenderedSegment {
59
+ if (ctx.contextPercent === null) {
60
+ return { content: color(ctx, "contextLabel", "(--%)"), visible: true };
61
+ }
49
62
  const pct = DEBUG_PCT ?? ctx.contextPercent;
50
63
  const barOpts = ctx.options.contextBar ?? {};
51
64
 
@@ -72,7 +85,9 @@ export const contextPctSegment = {
72
85
 
73
86
  // Show: (00.00%) 99999 / 99999
74
87
  const used = Math.round((pct / 100) * ctx.contextWindow);
75
- const pctLabel = `(${pct.toFixed(2)}%) ${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`;
88
+ const pctLabel = ctx.contextEstimated
89
+ ? `≈${pct.toFixed(2)}% ≈${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`
90
+ : `(${pct.toFixed(2)}%) ${formatTokens(used)} / ${formatTokens(ctx.contextWindow)}`;
76
91
  const pctStr = color(ctx, "contextLabel", pctLabel);
77
92
 
78
93
  return { content: `${bar} ${pctStr}`, visible: true };
@@ -118,7 +118,8 @@ export interface SegmentContext {
118
118
  thinkingLevel: string;
119
119
  sessionId: string | undefined;
120
120
  usageStats: UsageStats;
121
- contextPercent: number;
121
+ contextPercent: number | null;
122
+ contextEstimated: boolean;
122
123
  contextWindow: number;
123
124
  usingSubscription: boolean;
124
125
  sessionStartTime: number;
@@ -11,7 +11,7 @@ Unified mode manager for Pi. It provides normal, read-only chat, read-only plann
11
11
  - `--chat` and `--plan` start Pi in the corresponding read-only mode.
12
12
  - The configured cycle shortcut rotates through enabled visible modes. Execute mode is excluded from cycling because it requires an active plan. The default `shift+tab` is also Pi's built-in `app.thinking.cycle`, so both fire on one press; rebind either action in `~/.pi/agent/keybindings.json` (or pick another `shortcuts.cycleMode`) if you want them separate.
13
13
 
14
- Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
14
+ Plans are Markdown files under `.pi/plans/` with the `plan-` filename prefix. A plan name must contain at least one letter or digit, so `/plan .` or `/plan --` is rejected instead of producing an untitled `plan-.md`. Loading a plan appends a TUI-only card (via `registerEntryRenderer`) so the plan text never enters the model context; the execute-mode system prompt is the single model-facing copy of the plan. `plan_complete` is available in execute mode and in any mode that sets `allowPlanComplete`. On completion, the plan file is deleted when `cleanup.cleanupOnComplete` is enabled. If execution ends without `plan_complete`, execute mode is exited automatically at Pi's `agent_before_settle` boundary (added in Pi 0.87.0) — but only when the run settled as `completed`: an aborted (ESC) or errored run keeps execute mode active so the plan can be resumed. On Pi < 0.87.0 this auto-exit is unavailable because the boundary is never emitted; `plan_complete` still exits the mode on any version.
15
15
 
16
16
  ## Extension features
17
17
 
@@ -853,23 +853,19 @@ export default function modeSwitcher(pi: ExtensionAPI) {
853
853
  const filePath = join(process.cwd(), PLAN_DIR, filename);
854
854
  if (existsSync(filePath)) {
855
855
  const planContent = readFileSync(filePath, "utf-8");
856
- pi.sendMessage({
857
- customType: "plan-mode",
858
- content: planContent,
859
- display: true,
860
- details: { title: `Active plan: ${displayName}` },
861
- });
856
+ pi.appendEntry("plan-mode", { title: displayName, plan: planContent });
862
857
  }
863
858
  }
864
859
 
865
- // ─── Message renderer for plan messages ────────────────────────────────────
860
+ // ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
866
861
 
867
- pi.registerMessageRenderer("plan-mode", (message, _options, theme) => {
862
+ pi.registerEntryRenderer("plan-mode", (entry, _options, theme) => {
863
+ const data = (entry.data ?? {}) as { title?: string; plan?: string };
868
864
  const border = new DynamicBorder((s: string) => theme.fg("border", s));
869
865
  const container = new Container();
870
866
  container.addChild(border);
871
- container.addChild(new Text(theme.fg("text", "📄 " + (message.content as string).split("\n")[0])));
872
- const body = (message.content as string).split("\n").slice(1).join("\n");
867
+ container.addChild(new Text(theme.fg("text", "📄 " + (data.title ?? ""))));
868
+ const body = (data.plan ?? "").split("\n").slice(1).join("\n");
873
869
  if (body) {
874
870
  container.addChild(new Spacer());
875
871
  container.addChild(new Text(body));
@@ -7,7 +7,7 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
7
7
  - Tools: `web_search`, `fetch_content`, and `get_search_content`.
8
8
  - `web_search` accepts `query` or parallel `queries`.
9
9
  - `fetch_content` accepts `url` or `urls` with at most three requests in flight.
10
- - `get_search_content` uses a prior `responseId`, with `queryIndex`, `urlIndex`, or exact `url` selection.
10
+ - `get_search_content` uses a prior `responseId`, with `queryIndex`, `urlIndex`, or exact `url` selection. It returns one bounded page; pass `offset` to continue a truncated retrieval.
11
11
  - No slash commands or shortcuts.
12
12
 
13
13
  ## Extension features
@@ -15,7 +15,8 @@ Provides configurable web search and readable URL/PDF retrieval with session-bac
15
15
  - Searches through `gemini`, `tavily`, `ddgs`, `searxng`, or `exa`; multiple queries run concurrently and results include citations where available.
16
16
  - Extracts HTML with Readability and Markdown conversion, falls back to full-document Turndown conversion, passes PDF responses to the PDF extractor, and returns plain text, Markdown, and JSON directly.
17
17
  - Restricts `fetch_content` to http/https, caps each response at 10 MB, and applies a 30s timeout; Gemini keys are sent via the `x-goog-api-key` header rather than the query string.
18
- - Caps initial tool output at 30,000 characters, then keeps it for retrieval for one hour or until the session ends.
18
+ - Caps initial tool output at a configurable length (default 30,000 characters), then keeps it for retrieval for one hour or until the session ends.
19
+ - Pages `get_search_content` results (default 30,000 characters per page) with `offset` continuation, so a model never loads the whole stored body in one call.
19
20
  - Honors abort signals and returns provider, HTTP, and per-result failures through the tool boundary rather than throwing.
20
21
 
21
22
  ## Configuration
@@ -34,6 +35,13 @@ Copy [`configs/opl-webaccess.json.sample`](../../configs/opl-webaccess.json.samp
34
35
 
35
36
  Provider fields include `apiKeyEnv`, `apiUrl`, `baseUrl`, `model`, `maxResults`, `instanceUrl`, `categories`, `safeSearch`, `searchType`, and `includeSummary`. API keys are read only from named environment variables. Missing or invalid config falls back to Gemini defaults; an unknown provider returns an error.
36
37
 
38
+ Optional top-level caps control how much content reaches the model:
39
+
40
+ | Field | Default | Meaning |
41
+ |---|---|---|
42
+ | `maxContentChars` | `30000` | Cap on the initial `web_search`/`fetch_content` body. |
43
+ | `maxRetrievalChars` | `30000` | Cap on one `get_search_content` page. Pass `offset` to continue. |
44
+
37
45
  Install extraction dependencies before use:
38
46
 
39
47
  ```bash
@@ -18,6 +18,21 @@ export interface ProviderConfig {
18
18
  export interface WebAccessConfig {
19
19
  provider: string;
20
20
  providers: Record<string, ProviderConfig>;
21
+ /** Cap on the initial web_search/fetch_content body (chars). */
22
+ maxContentChars?: number;
23
+ /** Cap on one get_search_content retrieval page (chars). */
24
+ maxRetrievalChars?: number;
25
+ }
26
+
27
+ export const DEFAULT_MAX_CONTENT_CHARS = 30_000;
28
+ export const DEFAULT_MAX_RETRIEVAL_CHARS = 30_000;
29
+
30
+ /** Resolve configurable content caps, falling back to defaults. */
31
+ export function resolveCaps(cfg: Pick<WebAccessConfig, "maxContentChars" | "maxRetrievalChars">) {
32
+ return {
33
+ maxContentChars: cfg.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS,
34
+ maxRetrievalChars: cfg.maxRetrievalChars ?? DEFAULT_MAX_RETRIEVAL_CHARS,
35
+ };
21
36
  }
22
37
 
23
38
  const CONFIG_PATH = join(getAgentDir(), "configs", "opl-webaccess.json");
@@ -8,12 +8,14 @@ import {
8
8
  getResult,
9
9
  persistResult,
10
10
  restoreFromSession,
11
- MAX_CONTENT_CHARS,
12
11
  } from "./storage.js";
13
- import { truncate, errorMessage } from "./utils.js";
12
+ import { truncate, errorMessage, paginateContent, continuationNotice } from "./utils.js";
13
+ import { loadConfig, resolveCaps } from "./config.js";
14
14
  import type { StoredData } from "./types.js";
15
15
 
16
16
  export default function (pi: ExtensionAPI) {
17
+ const caps = resolveCaps(loadConfig());
18
+
17
19
  pi.on("session_start", async (_event, ctx) => {
18
20
  restoreFromSession(ctx);
19
21
  });
@@ -67,7 +69,7 @@ export default function (pi: ExtensionAPI) {
67
69
  return `## Query: "${r.query}"\n\n${r.answer}${sources}`;
68
70
  });
69
71
 
70
- const body = truncate(sections.join("\n\n---\n\n"), MAX_CONTENT_CHARS);
72
+ const body = truncate(sections.join("\n\n---\n\n"), caps.maxContentChars);
71
73
  return {
72
74
  content: [{ type: "text", text: `${body}\n\n*responseId: ${id}*` }],
73
75
  details: { responseId: id, queryCount: results.length },
@@ -120,7 +122,7 @@ export default function (pi: ExtensionAPI) {
120
122
  return `## ${r.url}\n\n${title}${r.content}`;
121
123
  });
122
124
 
123
- const body = truncate(sections.join("\n\n---\n\n"), MAX_CONTENT_CHARS);
125
+ const body = truncate(sections.join("\n\n---\n\n"), caps.maxContentChars);
124
126
  return {
125
127
  content: [{ type: "text", text: `${body}\n\n*responseId: ${id}*` }],
126
128
  details: { responseId: id, urlCount: results.length },
@@ -148,6 +150,9 @@ export default function (pi: ExtensionAPI) {
148
150
  url: Type.Optional(
149
151
  Type.String({ description: "Retrieve content for a specific URL (fetch_content)" })
150
152
  ),
153
+ offset: Type.Optional(
154
+ Type.Number({ description: "Character offset to continue a truncated retrieval" })
155
+ ),
151
156
  }),
152
157
  async execute(_toolCallId, params) {
153
158
  const data = getResult(params.responseId);
@@ -184,8 +189,13 @@ export default function (pi: ExtensionAPI) {
184
189
  ? "\n\n**Sources:**\n" +
185
190
  result.results.map((s, i) => `${i + 1}. [${s.title}](${s.url})`).join("\n")
186
191
  : "";
192
+ const page = paginateContent(
193
+ `## Query: "${result.query}"\n\n${result.answer}${sources}`,
194
+ params.offset ?? 0,
195
+ caps.maxRetrievalChars,
196
+ );
187
197
  return {
188
- content: [{ type: "text", text: `## Query: "${result.query}"\n\n${result.answer}${sources}` }],
198
+ content: [{ type: "text", text: page.text + continuationNotice(page) }],
189
199
  details: { responseId: params.responseId },
190
200
  };
191
201
  }
@@ -216,8 +226,13 @@ export default function (pi: ExtensionAPI) {
216
226
  };
217
227
  }
218
228
  const title = result.title ? `**${result.title}**\n\n` : "";
229
+ const page = paginateContent(
230
+ `## ${result.url}\n\n${title}${result.content}`,
231
+ params.offset ?? 0,
232
+ caps.maxRetrievalChars,
233
+ );
219
234
  return {
220
- content: [{ type: "text", text: `## ${result.url}\n\n${title}${result.content}` }],
235
+ content: [{ type: "text", text: page.text + continuationNotice(page) }],
221
236
  details: { responseId: params.responseId },
222
237
  };
223
238
  }
@@ -4,8 +4,6 @@ import type { StoredData } from "./types.js";
4
4
  const CUSTOM_TYPE = "web-access-results";
5
5
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
6
6
 
7
- export const MAX_CONTENT_CHARS = 30_000;
8
-
9
7
  const store = new Map<string, StoredData>();
10
8
 
11
9
  /** Evict expired entries to prevent unbounded growth. */
@@ -15,6 +15,33 @@ export function truncate(text: string, maxChars: number): string {
15
15
  );
16
16
  }
17
17
 
18
+ export interface ContentPage {
19
+ text: string;
20
+ offset: number;
21
+ totalChars: number;
22
+ nextOffset: number | null;
23
+ }
24
+
25
+ /** Slice stored content to one bounded page. Offsets past the end yield empty text. */
26
+ export function paginateContent(text: string, offset: number, maxChars: number): ContentPage {
27
+ const totalChars = text.length;
28
+ const safeOffset = Number.isFinite(offset) ? Math.floor(offset) : 0;
29
+ const start = Math.min(Math.max(0, safeOffset), totalChars);
30
+ const end = Math.min(start + maxChars, totalChars);
31
+ return {
32
+ text: text.slice(start, end),
33
+ offset: start,
34
+ totalChars,
35
+ nextOffset: end < totalChars ? end : null,
36
+ };
37
+ }
38
+
39
+ /** Model-facing continuation metadata, empty when the page was complete. */
40
+ export function continuationNotice(page: ContentPage): string {
41
+ if (page.nextOffset === null) return "";
42
+ return `\n\n[Content truncated at ${page.offset + page.text.length} of ${page.totalChars} chars. Continue with get_search_content offset=${page.nextOffset}.]`;
43
+ }
44
+
18
45
  export function isPdfUrl(url: string): boolean {
19
46
  try {
20
47
  return new URL(url).pathname.toLowerCase().endsWith(".pdf");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlines/opl-pi-sht",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, safe modes, fingerprinted /init, model benchmarking, web access, and browser automation.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,6 +55,6 @@
55
55
  "test:pi-host": "bun test tests/pi-host-loader.test.mjs"
56
56
  },
57
57
  "devDependencies": {
58
- "@earendil-works/pi-coding-agent": "0.87.0"
58
+ "@earendil-works/pi-coding-agent": "^0.87.0"
59
59
  }
60
60
  }