@openlines/opl-pi-sht 0.1.14
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/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"provider": "searxng",
|
|
3
|
+
"providers": {
|
|
4
|
+
"gemini": {
|
|
5
|
+
"apiKeyEnv": "GEMINI_API_KEY",
|
|
6
|
+
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
|
|
7
|
+
"model": "gemini-3.5-flash-lite",
|
|
8
|
+
"_available_models": ["gemini-3.6-flash", "gemini-3.5-flash-lite", "gemini-2.5-flash", "gemini-2.5-pro"]
|
|
9
|
+
},
|
|
10
|
+
"tavily": {
|
|
11
|
+
"apiKeyEnv": "TAVILY_API_KEY",
|
|
12
|
+
"maxResults": 5
|
|
13
|
+
},
|
|
14
|
+
"ddgs": {
|
|
15
|
+
"apiUrl": "http://192.168.1.100:8000",
|
|
16
|
+
"maxResults": 12
|
|
17
|
+
},
|
|
18
|
+
"searxng": {
|
|
19
|
+
"instanceUrl": "http://192.168.1.100:8888",
|
|
20
|
+
"maxResults": 12,
|
|
21
|
+
"categories": "general",
|
|
22
|
+
"safeSearch": 0
|
|
23
|
+
},
|
|
24
|
+
"exa": {
|
|
25
|
+
"apiKeyEnv": "EXA_API_KEY",
|
|
26
|
+
"maxResults": 5,
|
|
27
|
+
"searchType": "auto",
|
|
28
|
+
"includeSummary": true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# opl-browser
|
|
2
|
+
|
|
3
|
+
Chromium browser automation for pi via [Playwright](https://playwright.dev). One
|
|
4
|
+
LLM-callable dispatcher tool covers navigation, accessibility snapshots, element
|
|
5
|
+
interaction, console/network capture, script evaluation, and screenshots —
|
|
6
|
+
enough to replace the `chrome-devtools` MCP server without running a separate
|
|
7
|
+
process.
|
|
8
|
+
|
|
9
|
+
## Commands, flags, and shortcuts
|
|
10
|
+
|
|
11
|
+
No slash commands. The extension registers a single tool, `browser`, chosen by an
|
|
12
|
+
`action` parameter:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
browser({ action: "navigate", url: "https://example.com" })
|
|
16
|
+
browser({ action: "snapshot" })
|
|
17
|
+
browser({ action: "click", selector: "button.login" })
|
|
18
|
+
browser({ action: "fill", selector: "#email", text: "a@b.com" })
|
|
19
|
+
browser({ action: "evaluate", script: "document.title" })
|
|
20
|
+
browser({ action: "console" })
|
|
21
|
+
browser({ action: "network" })
|
|
22
|
+
browser({ action: "screenshot", path: "shot.png", fullPage: true })
|
|
23
|
+
browser({ action: "get", responseId: "br-..." }) // retrieve a stored large result
|
|
24
|
+
browser({ action: "close" })
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
|
|
28
|
+
`screenshot`, `click`, `fill`, `hover`, `press`, `select`, `evaluate`, `console`,
|
|
29
|
+
`network`, `wait_for`, `pages`, `new_page`, `select_page`, `close_page`, `resize`,
|
|
30
|
+
`get`, `close`.
|
|
31
|
+
|
|
32
|
+
## Extension features
|
|
33
|
+
|
|
34
|
+
- **Single dispatcher tool.** One compact schema in the system prompt instead of
|
|
35
|
+
~29 per-tool schemas. Keeps the cached prefix small.
|
|
36
|
+
- **Handle + preview output.** Large results (snapshots, console/network logs,
|
|
37
|
+
evaluate output) are kept out of context: the tool returns a truncated preview
|
|
38
|
+
plus a `responseId`; call `action: "get"` with that id for the full text.
|
|
39
|
+
Screenshots are written to a file, never inlined as base64.
|
|
40
|
+
- **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits,
|
|
41
|
+
CSS-selector interaction, viewport control, multi-page management.
|
|
42
|
+
- **Per-page capture.** Console messages and network requests are buffered per
|
|
43
|
+
page as they occur; `console` and `network` actions return the active page's
|
|
44
|
+
buffer.
|
|
45
|
+
- **One reused browser per session.** Launched on first use, closed automatically
|
|
46
|
+
on `session_shutdown`, or on demand via `action: "close"`.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Optional `~/.pi/agent/configs/opl-browser.json` (see `opl-browser.json.sample`):
|
|
51
|
+
|
|
52
|
+
| Field | Default | Meaning |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `headless` | `true` | Run Chromium headless. Set `false` to watch. |
|
|
55
|
+
| `width` / `height` | `1280` / `800` | Initial viewport. |
|
|
56
|
+
| `navigationTimeoutMs` | `30000` | Default navigation and `wait_for` timeout. |
|
|
57
|
+
| `previewChars` | `4000` | Inline threshold; larger outputs are stored and previewed. |
|
|
58
|
+
|
|
59
|
+
### Dependencies
|
|
60
|
+
|
|
61
|
+
Playwright is a real third-party dependency, so install it in the extension
|
|
62
|
+
directory and download the Chromium binary once:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd extensions/opl-browser
|
|
66
|
+
npm install
|
|
67
|
+
npx playwright install chromium
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Architecture
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
index.ts Pi wiring: registers the single `browser` tool, TTL result store,
|
|
74
|
+
preview/handle logic, and session_shutdown cleanup.
|
|
75
|
+
browser.ts Playwright driver: browser/context/page lifecycle, per-page console
|
|
76
|
+
and network buffers, and the action switch.
|
|
77
|
+
config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Interaction is CSS-selector based. Snapshot-uid interaction (referencing elements
|
|
81
|
+
by ids returned from `snapshot`) is intentionally not implemented; use CSS
|
|
82
|
+
selectors, which are simpler and robust. The in-memory result store expires
|
|
83
|
+
entries after one hour or when the browser is closed.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { chromium, type Browser, type BrowserContext, type Page, type ConsoleMessage } from "playwright";
|
|
2
|
+
import type { BrowserConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
// ponytail: single module-level Chromium instance reused across tool calls.
|
|
5
|
+
// One browser per session is enough; add contexts only if parallel isolation matters.
|
|
6
|
+
let browser: Browser | null = null;
|
|
7
|
+
let context: BrowserContext | null = null;
|
|
8
|
+
let activeIndex = 0;
|
|
9
|
+
|
|
10
|
+
const consoleBuf = new WeakMap<Page, string[]>();
|
|
11
|
+
const networkBuf = new WeakMap<Page, string[]>();
|
|
12
|
+
|
|
13
|
+
function track(page: Page, cfg: BrowserConfig): void {
|
|
14
|
+
consoleBuf.set(page, []);
|
|
15
|
+
networkBuf.set(page, []);
|
|
16
|
+
page.setDefaultNavigationTimeout(cfg.navigationTimeoutMs);
|
|
17
|
+
page.on("console", (m: ConsoleMessage) => consoleBuf.get(page)?.push(`[${m.type()}] ${m.text()}`));
|
|
18
|
+
page.on("requestfinished", async (req) => {
|
|
19
|
+
try {
|
|
20
|
+
const res = await req.response();
|
|
21
|
+
networkBuf.get(page)?.push(`${req.method()} ${res?.status() ?? "?"} ${req.url()}`);
|
|
22
|
+
} catch {
|
|
23
|
+
/* response unavailable */
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
page.on("requestfailed", (req) =>
|
|
27
|
+
networkBuf.get(page)?.push(`${req.method()} FAILED ${req.url()} (${req.failure()?.errorText ?? "unknown"})`),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ensure(cfg: BrowserConfig): Promise<BrowserContext> {
|
|
32
|
+
if (context) return context;
|
|
33
|
+
browser = await chromium.launch({ headless: cfg.headless });
|
|
34
|
+
context = await browser.newContext({ viewport: { width: cfg.width, height: cfg.height } });
|
|
35
|
+
const page = await context.newPage();
|
|
36
|
+
track(page, cfg);
|
|
37
|
+
activeIndex = 0;
|
|
38
|
+
return context;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function page(): Page {
|
|
42
|
+
const pages = context!.pages();
|
|
43
|
+
if (!pages.length) throw new Error("no open pages");
|
|
44
|
+
return pages[activeIndex] ?? pages[pages.length - 1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BrowserActionResult {
|
|
48
|
+
text: string;
|
|
49
|
+
file?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BrowserParams {
|
|
53
|
+
action: string;
|
|
54
|
+
url?: string;
|
|
55
|
+
selector?: string;
|
|
56
|
+
text?: string;
|
|
57
|
+
key?: string;
|
|
58
|
+
values?: string[];
|
|
59
|
+
script?: string;
|
|
60
|
+
path?: string;
|
|
61
|
+
index?: number;
|
|
62
|
+
fullPage?: boolean;
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
width?: number;
|
|
65
|
+
height?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
|
|
69
|
+
if (p.action === "close") {
|
|
70
|
+
await closeBrowser();
|
|
71
|
+
return { text: "Browser closed." };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ctx = await ensure(cfg);
|
|
75
|
+
|
|
76
|
+
switch (p.action) {
|
|
77
|
+
case "navigate": {
|
|
78
|
+
const url = p.url ?? "";
|
|
79
|
+
if (url === "back") { await page().goBack(); }
|
|
80
|
+
else if (url === "forward") { await page().goForward(); }
|
|
81
|
+
else if (url === "reload") { await page().reload(); }
|
|
82
|
+
else if (url) { await page().goto(url, { waitUntil: "domcontentloaded" }); }
|
|
83
|
+
else throw new Error("navigate requires url (or back|forward|reload)");
|
|
84
|
+
return { text: `${page().url()} — ${await page().title()}` };
|
|
85
|
+
}
|
|
86
|
+
case "snapshot": {
|
|
87
|
+
const tree = await page().locator("body").ariaSnapshot();
|
|
88
|
+
return { text: tree || "(empty snapshot)" };
|
|
89
|
+
}
|
|
90
|
+
case "screenshot": {
|
|
91
|
+
const file = p.path ?? `opl-browser-${Date.now()}.png`;
|
|
92
|
+
await page().screenshot({ path: file, fullPage: p.fullPage ?? false });
|
|
93
|
+
return { text: `Screenshot saved to ${file}`, file };
|
|
94
|
+
}
|
|
95
|
+
case "click": {
|
|
96
|
+
if (!p.selector) throw new Error("click requires selector");
|
|
97
|
+
await page().click(p.selector);
|
|
98
|
+
return { text: `Clicked ${p.selector}` };
|
|
99
|
+
}
|
|
100
|
+
case "fill": {
|
|
101
|
+
if (!p.selector || p.text == null) throw new Error("fill requires selector and text");
|
|
102
|
+
await page().fill(p.selector, p.text);
|
|
103
|
+
return { text: `Filled ${p.selector}` };
|
|
104
|
+
}
|
|
105
|
+
case "hover": {
|
|
106
|
+
if (!p.selector) throw new Error("hover requires selector");
|
|
107
|
+
await page().hover(p.selector);
|
|
108
|
+
return { text: `Hovered ${p.selector}` };
|
|
109
|
+
}
|
|
110
|
+
case "press": {
|
|
111
|
+
if (!p.key) throw new Error("press requires key");
|
|
112
|
+
await page().keyboard.press(p.key);
|
|
113
|
+
return { text: `Pressed ${p.key}` };
|
|
114
|
+
}
|
|
115
|
+
case "select": {
|
|
116
|
+
if (!p.selector || !p.values?.length) throw new Error("select requires selector and values");
|
|
117
|
+
const picked = await page().selectOption(p.selector, p.values);
|
|
118
|
+
return { text: `Selected ${picked.join(", ")} in ${p.selector}` };
|
|
119
|
+
}
|
|
120
|
+
case "evaluate": {
|
|
121
|
+
if (!p.script) throw new Error("evaluate requires script");
|
|
122
|
+
const value = await page().evaluate(p.script);
|
|
123
|
+
return { text: typeof value === "string" ? value : JSON.stringify(value, null, 2) };
|
|
124
|
+
}
|
|
125
|
+
case "console": {
|
|
126
|
+
const msgs = consoleBuf.get(page()) ?? [];
|
|
127
|
+
return { text: msgs.length ? msgs.join("\n") : "(no console messages)" };
|
|
128
|
+
}
|
|
129
|
+
case "network": {
|
|
130
|
+
const reqs = networkBuf.get(page()) ?? [];
|
|
131
|
+
return { text: reqs.length ? reqs.join("\n") : "(no network requests)" };
|
|
132
|
+
}
|
|
133
|
+
case "wait_for": {
|
|
134
|
+
const timeout = p.timeoutMs ?? cfg.navigationTimeoutMs;
|
|
135
|
+
if (p.selector) await page().waitForSelector(p.selector, { timeout });
|
|
136
|
+
else if (p.text) await page().getByText(p.text).first().waitFor({ timeout });
|
|
137
|
+
else throw new Error("wait_for requires selector or text");
|
|
138
|
+
return { text: `Condition met` };
|
|
139
|
+
}
|
|
140
|
+
case "pages": {
|
|
141
|
+
const list = ctx.pages().map((pg, i) => `${i === activeIndex ? "*" : " "} [${i}] ${pg.url()}`);
|
|
142
|
+
return { text: list.join("\n") || "(no pages)" };
|
|
143
|
+
}
|
|
144
|
+
case "new_page": {
|
|
145
|
+
const pg = await ctx.newPage();
|
|
146
|
+
track(pg, cfg);
|
|
147
|
+
activeIndex = ctx.pages().length - 1;
|
|
148
|
+
if (p.url) await pg.goto(p.url, { waitUntil: "domcontentloaded" });
|
|
149
|
+
return { text: `Opened page [${activeIndex}] ${pg.url()}` };
|
|
150
|
+
}
|
|
151
|
+
case "select_page": {
|
|
152
|
+
const i = p.index ?? 0;
|
|
153
|
+
if (i < 0 || i >= ctx.pages().length) throw new Error(`no page at index ${i}`);
|
|
154
|
+
activeIndex = i;
|
|
155
|
+
return { text: `Selected page [${i}] ${page().url()}` };
|
|
156
|
+
}
|
|
157
|
+
case "close_page": {
|
|
158
|
+
const i = p.index ?? activeIndex;
|
|
159
|
+
const pages = ctx.pages();
|
|
160
|
+
if (i < 0 || i >= pages.length) throw new Error(`no page at index ${i}`);
|
|
161
|
+
await pages[i].close();
|
|
162
|
+
activeIndex = Math.max(0, ctx.pages().length - 1);
|
|
163
|
+
return { text: `Closed page [${i}]` };
|
|
164
|
+
}
|
|
165
|
+
case "resize": {
|
|
166
|
+
if (p.width == null || p.height == null) throw new Error("resize requires width and height");
|
|
167
|
+
await page().setViewportSize({ width: p.width, height: p.height });
|
|
168
|
+
return { text: `Resized to ${p.width}x${p.height}` };
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
throw new Error(`unknown action: ${p.action}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function closeBrowser(): Promise<void> {
|
|
176
|
+
try {
|
|
177
|
+
await context?.close();
|
|
178
|
+
await browser?.close();
|
|
179
|
+
} catch {
|
|
180
|
+
/* already gone */
|
|
181
|
+
}
|
|
182
|
+
context = null;
|
|
183
|
+
browser = null;
|
|
184
|
+
activeIndex = 0;
|
|
185
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface BrowserConfig {
|
|
6
|
+
headless: boolean;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
navigationTimeoutMs: number;
|
|
10
|
+
previewChars: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CONFIG: BrowserConfig = {
|
|
14
|
+
headless: true,
|
|
15
|
+
width: 1280,
|
|
16
|
+
height: 800,
|
|
17
|
+
navigationTimeoutMs: 30000,
|
|
18
|
+
previewChars: 4000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "configs", "opl-browser.json");
|
|
22
|
+
|
|
23
|
+
export function loadUserConfig(configPath = CONFIG_PATH): BrowserConfig {
|
|
24
|
+
let user: Partial<BrowserConfig> = {};
|
|
25
|
+
try {
|
|
26
|
+
user = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
user = {};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
headless: user.headless ?? DEFAULT_CONFIG.headless,
|
|
32
|
+
width: user.width ?? DEFAULT_CONFIG.width,
|
|
33
|
+
height: user.height ?? DEFAULT_CONFIG.height,
|
|
34
|
+
navigationTimeoutMs: user.navigationTimeoutMs ?? DEFAULT_CONFIG.navigationTimeoutMs,
|
|
35
|
+
previewChars: user.previewChars ?? DEFAULT_CONFIG.previewChars,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { loadUserConfig } from "./config.js";
|
|
5
|
+
import { runAction, closeBrowser, type BrowserParams } from "./browser.js";
|
|
6
|
+
|
|
7
|
+
// ponytail: in-memory store with 1h TTL. Large outputs (snapshot, console,
|
|
8
|
+
// network, evaluate) are kept out of context; only a preview + id are returned.
|
|
9
|
+
interface Stored { id: string; action: string; timestamp: number; text: string }
|
|
10
|
+
const STORE = new Map<string, Stored>();
|
|
11
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
function store(action: string, text: string): string {
|
|
14
|
+
const id = `br-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const [k, v] of STORE) if (now - v.timestamp > TTL_MS) STORE.delete(k);
|
|
17
|
+
STORE.set(id, { id, action, timestamp: now, text });
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ACTIONS =
|
|
22
|
+
"navigate|snapshot|screenshot|click|fill|hover|press|select|evaluate|console|network|wait_for|pages|new_page|select_page|close_page|resize|get|close";
|
|
23
|
+
|
|
24
|
+
export default function (pi: ExtensionAPI) {
|
|
25
|
+
const cfg = loadUserConfig();
|
|
26
|
+
|
|
27
|
+
pi.on("session_shutdown", async () => {
|
|
28
|
+
await closeBrowser();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.registerTool({
|
|
32
|
+
name: "browser",
|
|
33
|
+
label: "Browser",
|
|
34
|
+
description:
|
|
35
|
+
`Drive a real Chromium browser (Playwright) for web testing and inspection. One dispatcher tool; pick action: ${ACTIONS}. ` +
|
|
36
|
+
"navigate(url|back|forward|reload); snapshot (accessibility tree); screenshot (saved to file, not inlined); " +
|
|
37
|
+
"click/fill/hover/select by CSS selector; press(key); evaluate(script in page); console/network (captured for active page); " +
|
|
38
|
+
"wait_for(selector|text); pages/new_page/select_page/close_page; resize(width,height); get(responseId) to retrieve a stored large result; close to shut the browser. " +
|
|
39
|
+
"Large outputs return a preview + responseId; call action:get with that id for the full text.",
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
action: Type.String({ description: `One of: ${ACTIONS}` }),
|
|
42
|
+
url: Type.Optional(Type.String({ description: "navigate/new_page: URL, or back|forward|reload for navigate" })),
|
|
43
|
+
selector: Type.Optional(Type.String({ description: "CSS selector for click/fill/hover/select/wait_for" })),
|
|
44
|
+
text: Type.Optional(Type.String({ description: "fill: text to type; wait_for: text to await" })),
|
|
45
|
+
key: Type.Optional(Type.String({ description: "press: key or combo, e.g. Enter, Control+A" })),
|
|
46
|
+
values: Type.Optional(Type.Array(Type.String(), { description: "select: option values" })),
|
|
47
|
+
script: Type.Optional(Type.String({ description: "evaluate: JS expression run in the page" })),
|
|
48
|
+
path: Type.Optional(Type.String({ description: "screenshot: output file path" })),
|
|
49
|
+
fullPage: Type.Optional(Type.Boolean({ description: "screenshot: capture full scrollable page" })),
|
|
50
|
+
index: Type.Optional(Type.Number({ description: "select_page/close_page: page index" })),
|
|
51
|
+
timeoutMs: Type.Optional(Type.Number({ description: "wait_for: timeout in ms" })),
|
|
52
|
+
width: Type.Optional(Type.Number({ description: "resize: viewport width" })),
|
|
53
|
+
height: Type.Optional(Type.Number({ description: "resize: viewport height" })),
|
|
54
|
+
responseId: Type.Optional(Type.String({ description: "get: id from a previous large result" })),
|
|
55
|
+
}),
|
|
56
|
+
async execute(_toolCallId, params) {
|
|
57
|
+
const p = params as BrowserParams & { responseId?: string };
|
|
58
|
+
|
|
59
|
+
if (p.action === "get") {
|
|
60
|
+
if (!p.responseId) return err("get requires responseId");
|
|
61
|
+
const hit = STORE.get(p.responseId);
|
|
62
|
+
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 } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let result;
|
|
67
|
+
try {
|
|
68
|
+
result = await runAction(p, cfg);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return err(`browser ${p.action}: ${e instanceof Error ? e.message : String(e)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Small outputs return inline; large outputs are stored and previewed.
|
|
74
|
+
if (result.text.length <= cfg.previewChars) {
|
|
75
|
+
return { content: [{ type: "text", text: result.text }], details: result.file ? { file: result.file } : {} };
|
|
76
|
+
}
|
|
77
|
+
const id = store(p.action, result.text);
|
|
78
|
+
const preview = result.text.slice(0, cfg.previewChars);
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: `${preview}\n\n… truncated. Full result via action:get responseId: ${id}` }],
|
|
81
|
+
details: { responseId: id, action: p.action, chars: result.text.length },
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function err(text: string) {
|
|
88
|
+
return { content: [{ type: "text" as const, text: `Error: ${text}` }], isError: true, details: {} as Record<string, unknown> };
|
|
89
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opl-browser",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "opl-browser",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"playwright": "^1.49.0"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"node_modules/fsevents": {
|
|
16
|
+
"version": "2.3.2",
|
|
17
|
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
18
|
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
19
|
+
"hasInstallScript": true,
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"optional": true,
|
|
22
|
+
"os": [
|
|
23
|
+
"darwin"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"node_modules/playwright": {
|
|
30
|
+
"version": "1.62.1",
|
|
31
|
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
|
32
|
+
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
|
33
|
+
"license": "Apache-2.0",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"playwright-core": "1.62.1"
|
|
36
|
+
},
|
|
37
|
+
"bin": {
|
|
38
|
+
"playwright": "cli.js"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"optionalDependencies": {
|
|
44
|
+
"fsevents": "2.3.2"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"node_modules/playwright-core": {
|
|
48
|
+
"version": "1.62.1",
|
|
49
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
|
50
|
+
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
|
51
|
+
"license": "Apache-2.0",
|
|
52
|
+
"bin": {
|
|
53
|
+
"playwright-core": "cli.js"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opl-browser",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Chromium browser automation for pi via Playwright. A single dispatcher tool for navigation, snapshots, interaction, console/network capture, and evaluation — replaces the chrome-devtools MCP server.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"playwright": "^1.49.0"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# opl-ctxtrim
|
|
2
|
+
|
|
3
|
+
Trims the verbose `ctx_*` tool descriptions that the third-party [context-mode](https://github.com/mksglu/context-mode) MCP bridge injects into every provider request. It edits only the serialized outbound payload through Pi's documented `before_provider_request` hook, so the installed context-mode package and its tool execution are never modified.
|
|
4
|
+
|
|
5
|
+
## Commands, tools, and configuration
|
|
6
|
+
|
|
7
|
+
No commands, tools, or configuration. Loading the extension is the entire interface.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
context-mode registers eleven `ctx_*` tools whose descriptions carry multi-paragraph "Think-in-Code" prose, `WHEN`/`WHEN NOT` sections, and worked examples. Those descriptions are serialized into the tool schema on **every** provider request. `opl-ctxtrim` replaces each known tool's top-level description with a concise equivalent and shortens nested JSON Schema parameter descriptions to their first sentence.
|
|
12
|
+
|
|
13
|
+
Preserved exactly: tool names, execution routing, schema structure, `required`, `enum`, `default`, numeric bounds, and strict-mode flags. Only human-readable `description` prose changes.
|
|
14
|
+
|
|
15
|
+
Supported provider payload shapes:
|
|
16
|
+
|
|
17
|
+
- OpenAI Responses / Anthropic / Google: `{ name, description, parameters }`
|
|
18
|
+
- OpenAI Chat Completions / Mistral: `{ function: { name, description, parameters } }`
|
|
19
|
+
- Amazon Bedrock Converse: `toolConfig.tools[].toolSpec`
|
|
20
|
+
|
|
21
|
+
Any unrecognized payload shape, any non-`ctx_*` tool, and any `ctx_*` tool not in the built-in description map are returned unchanged (fail open). This means a future context-mode release that adds or renames a tool is left untouched at runtime until the extension is updated; the test suite flags such tools for review.
|
|
22
|
+
|
|
23
|
+
## Token savings
|
|
24
|
+
|
|
25
|
+
### Reproducible schema measurement
|
|
26
|
+
|
|
27
|
+
The test suite queries the installed context-mode **v1.0.169** server (11 `ctx_*` tools), serializes its real `tools/list` result as an OpenAI Responses tool array, and compares bytes before and after trimming:
|
|
28
|
+
|
|
29
|
+
| Schema state | Bytes | Change from original |
|
|
30
|
+
|---|---:|---:|
|
|
31
|
+
| Original context-mode tools | 28,019 | — |
|
|
32
|
+
| Current `opl-ctxtrim` output | 9,152 | **18,867 fewer (67.3%)** |
|
|
33
|
+
| Hypothetical empty descriptions, schemas retained | 4,683 | 23,336 fewer (83.3%) |
|
|
34
|
+
|
|
35
|
+
The final row is a ceiling, not a recommended configuration. Current replacements retain compact tool guidance and parameter descriptions; empty descriptions would remove another 4,469 bytes but make tool selection and argument construction less reliable. The remaining 4,683 bytes are mostly required schema structure, parameter names, types, enums, bounds, and `required` fields.
|
|
36
|
+
|
|
37
|
+
Run `npm run test:opl-ctxtrim` to regenerate the first two rows. It also reports a rough 3-4 bytes-per-token estimate of 4,717-6,289 tokens per request. Treat that as a byte heuristic, not billing data.
|
|
38
|
+
|
|
39
|
+
### Observed provider accounting
|
|
40
|
+
|
|
41
|
+
Fresh `/init` sessions on `litellm-proxy/gpt-5.6-terra` supplied a more useful real-world check. `cacheWrite` is the provider-reported initial cached prompt prefix; `input: 2` is only the small uncached request tail.
|
|
42
|
+
|
|
43
|
+
| Configuration | Repository | `cacheWrite` | Cache-write cost |
|
|
44
|
+
|---|---|---:|---:|
|
|
45
|
+
| ctxtrim off, Ponytail full | piper-tts-reader | 22,148 | $0.060907 |
|
|
46
|
+
| ctxtrim off, Ponytail full | openpi-memory | 22,229 | $0.061130 |
|
|
47
|
+
| ctxtrim on, Ponytail full | opl-pi-sht | 19,822 | $0.054511 |
|
|
48
|
+
| ctxtrim on, Ponytail off | openpi-memory | **16,774** | **$0.046129** |
|
|
49
|
+
|
|
50
|
+
The two untrimmed repositories were within 81 tokens (0.37%), despite different crawls. The observations indicate:
|
|
51
|
+
|
|
52
|
+
- `opl-ctxtrim` reduced a fresh initial request by about **2.3k provider-accounted tokens (10.5-10.8%)**. This comparison spans different repositories, so it is indicative rather than laboratory-controlled.
|
|
53
|
+
- With ctxtrim enabled, turning Ponytail full mode off removed another **3,048 tokens (15.4%)** in the available cross-repository comparison.
|
|
54
|
+
- In the same `openpi-memory` repository, moving from ctxtrim off/Ponytail full to ctxtrim on/Ponytail off saved **5,455 tokens (24.5%)** and **$0.015001** on the initial cache write. This is the combined effect, not an attribution split.
|
|
55
|
+
|
|
56
|
+
Savings apply to every provider request that carries the tool schema, including tool-follow-up turns. Exact token counts and costs vary by provider tokenizer, cache policy, repository crawl, loaded context files, system prompts, and skills.
|
|
57
|
+
|
|
58
|
+
### Other initial-prompt overhead
|
|
59
|
+
|
|
60
|
+
`opl-ctxtrim` only trims known context-mode tool-schema descriptions. It does not remove context-mode's routing anchor, active-memory injection, tool results, indexed search results, Pi's base prompt, project guides, or skills.
|
|
61
|
+
|
|
62
|
+
In the local setup used for the observations above:
|
|
63
|
+
|
|
64
|
+
- Ponytail full mode appended **5,252 bytes / 876 words** on every `before_agent_start`; disabling it produced the measured 3,048-token additional reduction above. `lite` is 5,225 bytes, so it is not a meaningful token-saving mode.
|
|
65
|
+
- Pi's Superpowers catalog contributed 14 names/descriptions totaling **2,168 bytes**. Its full 138.6 KB of skill instructions is loaded on demand, not in the initial prompt.
|
|
66
|
+
- The project `AGENTS.md` guide was **4,270 bytes / 500 words**. It is useful repository context, but shortening it is a quality tradeoff.
|
|
67
|
+
- `opl-modes` injects only the active mode's prompt. Its default and this measured session were `off`, so its configured chat, plan, review, and research prompts added nothing.
|
|
68
|
+
|
|
69
|
+
Removing or lazily exposing entire `ctx_*` tools could save more than description trimming, but makes those tools unavailable to the model. The extension intentionally preserves the complete tool set and all JSON Schema semantics.
|
|
70
|
+
|
|
71
|
+
## Architecture
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
index.ts before_provider_request handler; CTX_DESCRIPTIONS map;
|
|
75
|
+
provider-shape detection; description-only trimming.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Exports `trimPayload`, `CTX_DESCRIPTIONS`, and `shortenParamDescription` for testing. The extension is self-contained and dependency-free.
|