@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 +1 -1
- package/configs/opl-browser.json.sample +2 -1
- package/configs/opl-webaccess.json.sample +3 -0
- package/extensions/opl-browser/README.md +5 -2
- package/extensions/opl-browser/config.ts +3 -0
- package/extensions/opl-browser/index.ts +8 -2
- package/extensions/opl-browser/paging.ts +26 -0
- package/extensions/opl-footer/README.md +1 -1
- package/extensions/opl-footer/git-status.ts +14 -7
- package/extensions/opl-footer/index.ts +12 -9
- package/extensions/opl-footer/segments/context.ts +16 -1
- package/extensions/opl-footer/types.ts +2 -1
- package/extensions/opl-modes/README.md +1 -1
- package/extensions/opl-modes/index.ts +6 -10
- package/extensions/opl-webaccess/README.md +10 -2
- package/extensions/opl-webaccess/config.ts +15 -0
- package/extensions/opl-webaccess/index.ts +21 -6
- package/extensions/opl-webaccess/storage.ts +0 -2
- package/extensions/opl-webaccess/utils.ts +27 -0
- package/package.json +2 -2
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
|
-
>
|
|
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>
|
|
@@ -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
|
|
41
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
184
|
-
: { staged: 0, unstaged: 0, untracked: 0, timestamp:
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const contextPercent =
|
|
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 =
|
|
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.
|
|
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
|
-
// ───
|
|
860
|
+
// ─── Entry renderer for TUI-only plan cards (never sent to the model) ──────
|
|
866
861
|
|
|
867
|
-
pi.
|
|
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", "📄 " + (
|
|
872
|
-
const body = (
|
|
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"),
|
|
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"),
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
}
|