little-coder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.pi/extensions/benchmark-profiles/index.ts +159 -0
  2. package/.pi/extensions/benchmark-profiles/profiles.test.ts +78 -0
  3. package/.pi/extensions/browser/index.ts +304 -0
  4. package/.pi/extensions/browser-extract-retention/index.ts +170 -0
  5. package/.pi/extensions/browser-extract-retention/live-integration.test.ts +176 -0
  6. package/.pi/extensions/browser-extract-retention/retention.test.ts +195 -0
  7. package/.pi/extensions/checkpoint/index.ts +66 -0
  8. package/.pi/extensions/evidence/evidence.test.ts +30 -0
  9. package/.pi/extensions/evidence/index.ts +119 -0
  10. package/.pi/extensions/evidence-compact/bridge.test.ts +25 -0
  11. package/.pi/extensions/evidence-compact/index.ts +32 -0
  12. package/.pi/extensions/extra-tools/index.ts +139 -0
  13. package/.pi/extensions/finalize-warn/index.ts +73 -0
  14. package/.pi/extensions/hello/index.ts +7 -0
  15. package/.pi/extensions/knowledge-inject/index.ts +149 -0
  16. package/.pi/extensions/knowledge-inject/scoring.test.ts +81 -0
  17. package/.pi/extensions/llama-cpp-provider/index.ts +58 -0
  18. package/.pi/extensions/output-parser/index.ts +56 -0
  19. package/.pi/extensions/output-parser/parser.test.ts +90 -0
  20. package/.pi/extensions/output-parser/parser.ts +126 -0
  21. package/.pi/extensions/permission-gate/index.ts +53 -0
  22. package/.pi/extensions/permission-gate/permission.test.ts +26 -0
  23. package/.pi/extensions/quality-monitor/index.ts +70 -0
  24. package/.pi/extensions/quality-monitor/quality.test.ts +75 -0
  25. package/.pi/extensions/quality-monitor/quality.ts +84 -0
  26. package/.pi/extensions/shell-session/helpers.test.ts +62 -0
  27. package/.pi/extensions/shell-session/helpers.ts +58 -0
  28. package/.pi/extensions/shell-session/index.ts +139 -0
  29. package/.pi/extensions/skill-inject/frontmatter.test.ts +72 -0
  30. package/.pi/extensions/skill-inject/frontmatter.ts +39 -0
  31. package/.pi/extensions/skill-inject/index.ts +256 -0
  32. package/.pi/extensions/skill-inject/selector.test.ts +91 -0
  33. package/.pi/extensions/thinking-budget/budget.test.ts +182 -0
  34. package/.pi/extensions/thinking-budget/index.ts +105 -0
  35. package/.pi/extensions/tool-gating/index.ts +38 -0
  36. package/.pi/extensions/turn-cap/index.ts +37 -0
  37. package/.pi/extensions/write-guard/index.ts +61 -0
  38. package/.pi/settings.json +76 -0
  39. package/AGENTS.md +61 -0
  40. package/CHANGELOG.md +618 -0
  41. package/LICENSE +201 -0
  42. package/NOTICE +22 -0
  43. package/README.md +245 -0
  44. package/bin/little-coder.mjs +99 -0
  45. package/models.json +45 -0
  46. package/package.json +46 -0
  47. package/skills/knowledge/bfs_state_space.md +9 -0
  48. package/skills/knowledge/binary_search.md +9 -0
  49. package/skills/knowledge/dfs_vs_bfs.md +9 -0
  50. package/skills/knowledge/dynamic_programming.md +9 -0
  51. package/skills/knowledge/hash_vs_tree.md +9 -0
  52. package/skills/knowledge/io_wrapper.md +9 -0
  53. package/skills/knowledge/recursion_backtracking.md +9 -0
  54. package/skills/knowledge/rule_string_transform.md +9 -0
  55. package/skills/knowledge/sorting_choice.md +9 -0
  56. package/skills/knowledge/tree_rerooting.md +9 -0
  57. package/skills/knowledge/tree_zipper.md +9 -0
  58. package/skills/knowledge/two_pointers.md +9 -0
  59. package/skills/knowledge/workspace_docs.md +10 -0
  60. package/skills/protocols/cite_before_answer.md +19 -0
  61. package/skills/protocols/research_protocol.md +20 -0
  62. package/skills/protocols/task_decomposition.md +24 -0
  63. package/skills/tools/agent.md +24 -0
  64. package/skills/tools/bash.md +29 -0
  65. package/skills/tools/browser_click.md +25 -0
  66. package/skills/tools/browser_extract.md +24 -0
  67. package/skills/tools/browser_navigate.md +22 -0
  68. package/skills/tools/browser_type.md +22 -0
  69. package/skills/tools/edit.md +30 -0
  70. package/skills/tools/evidence_add.md +23 -0
  71. package/skills/tools/glob.md +28 -0
  72. package/skills/tools/grep.md +29 -0
  73. package/skills/tools/read.md +28 -0
  74. package/skills/tools/shell_session.md +31 -0
  75. package/skills/tools/webfetch.md +22 -0
  76. package/skills/tools/write.md +29 -0
@@ -0,0 +1,159 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // Port of local/config.py::MODEL_PROFILES + get_model_profile with
7
+ // benchmark_overrides. Reads .pi/settings.json's little_coder.model_profiles
8
+ // block, applies the matching per-model profile (plus benchmark_overrides
9
+ // when LITTLE_CODER_BENCHMARK=terminal_bench|gaia is set), and publishes
10
+ // the resolved values on event.systemPromptOptions.littleCoder so the
11
+ // other extensions (skill-inject, knowledge-inject, thinking-budget,
12
+ // turn-cap) read them from a single source of truth.
13
+
14
+ interface ModelProfile {
15
+ context_limit?: number;
16
+ max_tokens?: number;
17
+ thinking_budget?: number;
18
+ skill_token_budget?: number;
19
+ knowledge_token_budget?: number;
20
+ system_prompt_budget?: number;
21
+ max_retries?: number;
22
+ temperature?: number;
23
+ max_turns?: number;
24
+ prefer_text_tools?: boolean;
25
+ benchmark_overrides?: Record<string, Partial<ModelProfile>>;
26
+ }
27
+
28
+ interface LittleCoderSettings {
29
+ default_model_profile?: ModelProfile;
30
+ model_profiles?: Record<string, ModelProfile>;
31
+ }
32
+
33
+ let settings: LittleCoderSettings | null = null;
34
+ let loaded = false;
35
+
36
+ function repoRoot(): string {
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+ return join(here, "..", "..", "..");
39
+ }
40
+
41
+ function loadSettings(): void {
42
+ if (loaded) return;
43
+ loaded = true;
44
+ // Try project .pi/settings.json first, then ~/.pi/agent/settings.json
45
+ const candidates = [
46
+ join(repoRoot(), ".pi", "settings.json"),
47
+ join(process.env.HOME ?? "", ".pi", "agent", "settings.json"),
48
+ ];
49
+ for (const p of candidates) {
50
+ if (!existsSync(p)) continue;
51
+ try {
52
+ const raw = JSON.parse(readFileSync(p, "utf-8"));
53
+ if (raw && typeof raw === "object" && raw.little_coder) {
54
+ settings = raw.little_coder as LittleCoderSettings;
55
+ return;
56
+ }
57
+ } catch {
58
+ // ignore malformed settings
59
+ }
60
+ }
61
+ }
62
+
63
+ function resolveProfile(providerSlashModel: string): ModelProfile {
64
+ loadSettings();
65
+ if (!settings) return {};
66
+ const profiles = settings.model_profiles ?? {};
67
+ const bench = process.env.LITTLE_CODER_BENCHMARK;
68
+
69
+ // Exact match first, then prefix match (mirrors get_model_profile)
70
+ let base: ModelProfile | undefined = profiles[providerSlashModel];
71
+ if (!base) {
72
+ for (const [pattern, p] of Object.entries(profiles)) {
73
+ if (providerSlashModel.startsWith(pattern)) {
74
+ base = p;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ if (!base) base = settings.default_model_profile ?? {};
80
+
81
+ // Strip + apply benchmark_overrides if set
82
+ const { benchmark_overrides, ...basePlain } = { ...base };
83
+ if (bench && benchmark_overrides && benchmark_overrides[bench]) {
84
+ return { ...basePlain, ...benchmark_overrides[bench] };
85
+ }
86
+ return basePlain;
87
+ }
88
+
89
+ // Per-benchmark tools that should always have skill cards present on turn 1,
90
+ // even before the agent has used them. Without this, skill-inject relies on
91
+ // recency / error-recovery / intent-matching, none of which fire on the
92
+ // opening turn — and the wrong skills (Edit/Write) can win the budget on a
93
+ // pure research question.
94
+ const BENCHMARK_REQUIRED_TOOLS: Record<string, string[]> = {
95
+ gaia: ["BrowserNavigate", "BrowserExtract", "EvidenceAdd"],
96
+ };
97
+
98
+ function toLittleCoderOptions(p: ModelProfile): Record<string, unknown> {
99
+ const benchmark = process.env.LITTLE_CODER_BENCHMARK;
100
+ const out: Record<string, unknown> = {
101
+ contextLimit: p.context_limit,
102
+ maxTokens: p.max_tokens,
103
+ thinkingBudget: p.thinking_budget,
104
+ skillTokenBudget: p.skill_token_budget,
105
+ knowledgeTokenBudget: p.knowledge_token_budget,
106
+ systemPromptBudget: p.system_prompt_budget,
107
+ maxRetries: p.max_retries,
108
+ temperature: p.temperature,
109
+ maxTurns: p.max_turns,
110
+ preferTextTools: p.prefer_text_tools,
111
+ benchmark,
112
+ };
113
+ if (benchmark && BENCHMARK_REQUIRED_TOOLS[benchmark]) {
114
+ out.requiredTools = BENCHMARK_REQUIRED_TOOLS[benchmark];
115
+ }
116
+ return out;
117
+ }
118
+
119
+ export default function (pi: ExtensionAPI) {
120
+ // Shared across handlers so before_provider_request can re-read the most
121
+ // recently resolved temperature without re-parsing settings every turn.
122
+ let resolvedTemperature: number | undefined;
123
+
124
+ pi.on("before_agent_start", async (event, ctx) => {
125
+ const model = ctx.model;
126
+ if (!model) return;
127
+ const key = `${model.provider}/${model.id}`;
128
+ const profile = resolveProfile(key);
129
+
130
+ const opts: any = (event as any).systemPromptOptions ?? {};
131
+ const existing = opts.littleCoder ?? {};
132
+ const resolved = toLittleCoderOptions(profile);
133
+
134
+ // Merge; existing (set by other extensions earlier) wins over defaults
135
+ // from this profile, but undefined existing values fall back.
136
+ opts.littleCoder = { ...resolved, ...existing };
137
+ // Re-copy so undefined existing values don't overwrite resolved values
138
+ for (const [k, v] of Object.entries(resolved)) {
139
+ if (opts.littleCoder[k] === undefined) opts.littleCoder[k] = v;
140
+ }
141
+
142
+ resolvedTemperature = opts.littleCoder.temperature;
143
+ });
144
+
145
+ // Inject the profile's temperature onto the outgoing provider payload.
146
+ // Without this, pi-ai uses the provider default (typically ~0.8 for
147
+ // llama.cpp), which adds measurable stochastic variance on hard
148
+ // algorithmic exercises. Matches local-coder's profiles[].temperature=0.3.
149
+ //
150
+ // IMPORTANT: pi's runner passes payload by reference but only adopts
151
+ // *returned* values. Mutating in place is discarded between handlers, so
152
+ // we build a new payload object and return it explicitly.
153
+ pi.on("before_provider_request", async (event) => {
154
+ if (resolvedTemperature === undefined) return;
155
+ const payload: any = (event as any).payload;
156
+ if (!payload || typeof payload !== "object") return;
157
+ return { ...payload, temperature: resolvedTemperature };
158
+ });
159
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const settingsPath = join(here, "..", "..", "settings.json");
8
+
9
+ // Mirror the resolution logic so we can test it as a pure function without
10
+ // instantiating the extension.
11
+ interface ModelProfile {
12
+ thinking_budget?: number;
13
+ max_turns?: number;
14
+ temperature?: number;
15
+ context_limit?: number;
16
+ benchmark_overrides?: Record<string, Partial<ModelProfile>>;
17
+ }
18
+
19
+ function resolveProfile(
20
+ settings: { model_profiles?: Record<string, ModelProfile>; default_model_profile?: ModelProfile },
21
+ key: string,
22
+ benchmark?: string,
23
+ ): ModelProfile {
24
+ const profiles = settings.model_profiles ?? {};
25
+ let base: ModelProfile | undefined = profiles[key];
26
+ if (!base) {
27
+ for (const [pattern, p] of Object.entries(profiles)) {
28
+ if (key.startsWith(pattern)) { base = p; break; }
29
+ }
30
+ }
31
+ if (!base) base = settings.default_model_profile ?? {};
32
+ const { benchmark_overrides, ...basePlain } = { ...base };
33
+ if (benchmark && benchmark_overrides && benchmark_overrides[benchmark]) {
34
+ return { ...basePlain, ...benchmark_overrides[benchmark] };
35
+ }
36
+ return basePlain;
37
+ }
38
+
39
+ describe("benchmark-profiles resolution against real settings.json", () => {
40
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8")).little_coder;
41
+
42
+ it("resolves base profile for llamacpp/qwen3.6-35b-a3b", () => {
43
+ const p = resolveProfile(settings, "llamacpp/qwen3.6-35b-a3b");
44
+ expect(p.thinking_budget).toBe(2048);
45
+ expect(p.context_limit).toBe(32768);
46
+ expect(p.max_turns).toBeUndefined();
47
+ });
48
+
49
+ it("applies terminal_bench overrides", () => {
50
+ const p = resolveProfile(settings, "llamacpp/qwen3.6-35b-a3b", "terminal_bench");
51
+ expect(p.thinking_budget).toBe(3000);
52
+ expect(p.temperature).toBe(0.2);
53
+ expect(p.max_turns).toBe(40);
54
+ // Non-overridden fields fall through from base
55
+ expect(p.context_limit).toBe(32768);
56
+ });
57
+
58
+ it("applies gaia overrides", () => {
59
+ const p = resolveProfile(settings, "llamacpp/qwen3.6-35b-a3b", "gaia");
60
+ expect(p.thinking_budget).toBe(2000);
61
+ expect(p.temperature).toBe(0.4);
62
+ expect(p.max_turns).toBe(40);
63
+ expect(p.context_limit).toBe(65536);
64
+ });
65
+
66
+ it("unknown model falls back to default_model_profile", () => {
67
+ const p = resolveProfile(settings, "fake-provider/fake-model");
68
+ // Default profile defined in settings.json
69
+ expect(p.thinking_budget).toBe(2048);
70
+ expect(p.context_limit).toBe(32768);
71
+ });
72
+
73
+ it("unknown benchmark name yields base profile unchanged", () => {
74
+ const p = resolveProfile(settings, "llamacpp/qwen3.6-35b-a3b", "totally_made_up");
75
+ expect(p.thinking_budget).toBe(2048);
76
+ expect(p.max_turns).toBeUndefined();
77
+ });
78
+ });
@@ -0,0 +1,304 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+
4
+ // Port of local/tools/browser.py. Playwright-powered Browser* tools with
5
+ // per-session lazy Page launch, chunked Readability extract, history stack,
6
+ // graceful degradation when Playwright isn't installed.
7
+ //
8
+ // To enable: `npm install playwright && npx playwright install chromium`.
9
+ // Without those the tools register but return a clear error.
10
+
11
+ const CHUNK_SIZE = 2048;
12
+
13
+ // Inlined "Readability-lite": remove heavy structural nodes and collapse
14
+ // whitespace. Passed to page.evaluate as a real function (not a string) —
15
+ // Playwright silently returns undefined when given a string function literal
16
+ // like `"() => {...}"` because it evaluates to a function *value*, not an
17
+ // invocation. `document` is only defined in the page context.
18
+ function readablePageText(): string {
19
+ const doc = (globalThis as any).document;
20
+ const clone = doc.body.cloneNode(true);
21
+ const drop = clone.querySelectorAll(
22
+ "script, style, noscript, iframe, nav, header, footer, aside, form",
23
+ );
24
+ drop.forEach((n: any) => n.remove());
25
+ const text = (clone.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
26
+ return text;
27
+ }
28
+ function fallbackPageText(): string {
29
+ const doc = (globalThis as any).document;
30
+ return doc.body ? doc.body.innerText : "";
31
+ }
32
+
33
+ interface BrowserSession {
34
+ pw: any;
35
+ browser: any;
36
+ context: any;
37
+ page: any;
38
+ history: string[];
39
+ extractCache: Map<string, string>;
40
+ error: string;
41
+ }
42
+
43
+ const sessions = new Map<string, BrowserSession>();
44
+
45
+ function sessionKey(): string {
46
+ return process.env.LITTLE_CODER_SESSION_ID || "default";
47
+ }
48
+
49
+ function headful(): boolean {
50
+ return !!process.env.BROWSER_HEADFUL;
51
+ }
52
+
53
+ async function ensureSession(): Promise<BrowserSession> {
54
+ const key = sessionKey();
55
+ let sess = sessions.get(key);
56
+ if (!sess) {
57
+ sess = {
58
+ pw: null, browser: null, context: null, page: null,
59
+ history: [], extractCache: new Map(), error: "",
60
+ };
61
+ sessions.set(key, sess);
62
+ }
63
+ if (sess.page || sess.error) return sess;
64
+ try {
65
+ // Optional runtime dep; cast avoids requiring @types/playwright at build time
66
+ const playwright: any = await import("playwright" as any);
67
+ const browser = await playwright.chromium.launch({ headless: !headful() });
68
+ const context = await browser.newContext({
69
+ userAgent: "Mozilla/5.0 (little-coder research agent)",
70
+ viewport: { width: 1280, height: 900 },
71
+ });
72
+ const page = await context.newPage();
73
+ page.setDefaultTimeout(20_000);
74
+ sess.pw = playwright;
75
+ sess.browser = browser;
76
+ sess.context = context;
77
+ sess.page = page;
78
+ } catch (e: any) {
79
+ if (e?.code === "ERR_MODULE_NOT_FOUND" || /Cannot find module 'playwright'/.test(e?.message ?? "")) {
80
+ sess.error = "Playwright is not installed. Run: npm install playwright && npx playwright install chromium";
81
+ } else {
82
+ sess.error = `Browser launch failed: ${e?.message ?? e}`;
83
+ }
84
+ }
85
+ return sess;
86
+ }
87
+
88
+ export async function resetBrowserSession(sessionId?: string): Promise<void> {
89
+ const key = sessionId ?? sessionKey();
90
+ const sess = sessions.get(key);
91
+ if (!sess) return;
92
+ sessions.delete(key);
93
+ try { if (sess.page) await sess.page.close(); } catch {}
94
+ try { if (sess.context) await sess.context.close(); } catch {}
95
+ try { if (sess.browser) await sess.browser.close(); } catch {}
96
+ }
97
+
98
+ function errorResult(text: string) {
99
+ return { content: [{ type: "text" as const, text }], details: {}, isError: true };
100
+ }
101
+ function textResult(text: string) {
102
+ return { content: [{ type: "text" as const, text }], details: {} };
103
+ }
104
+
105
+ export default function (pi: ExtensionAPI) {
106
+ pi.on("session_shutdown", async () => {
107
+ await resetBrowserSession();
108
+ });
109
+
110
+ // ── BrowserNavigate ──────────────────────────────────────────────────
111
+ pi.registerTool({
112
+ name: "BrowserNavigate",
113
+ label: "BrowserNavigate",
114
+ description: "Navigate the browser to a URL. Must start with http:// or https://.",
115
+ parameters: Type.Object({
116
+ url: Type.String({ description: "URL to navigate to" }),
117
+ }),
118
+ async execute(_id, { url }) {
119
+ const u = (url ?? "").trim();
120
+ if (!u) return errorResult("Error: url is required");
121
+ if (!u.startsWith("http://") && !u.startsWith("https://")) {
122
+ return errorResult("Error: url must start with http:// or https://");
123
+ }
124
+ const sess = await ensureSession();
125
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
126
+ try {
127
+ const resp = await sess.page.goto(u, { waitUntil: "domcontentloaded" });
128
+ sess.history.push(sess.page.url());
129
+ sess.extractCache.clear();
130
+ const status = resp ? resp.status() : "?";
131
+ const title = await sess.page.title();
132
+ return textResult(`[status=${status}] ${sess.page.url()}\ntitle: ${title}`);
133
+ } catch (e: any) {
134
+ return errorResult(`Error navigating to ${u}: ${e?.message ?? e}`);
135
+ }
136
+ },
137
+ });
138
+
139
+ // ── BrowserClick ─────────────────────────────────────────────────────
140
+ pi.registerTool({
141
+ name: "BrowserClick",
142
+ label: "BrowserClick",
143
+ description: "Click an element by CSS selector, or by ARIA role (with optional accessible name).",
144
+ parameters: Type.Object({
145
+ selector: Type.Optional(Type.String({ description: "CSS selector" })),
146
+ role: Type.Optional(Type.String({ description: "ARIA role (e.g. button, link)" })),
147
+ name: Type.Optional(Type.String({ description: "Accessible name for role" })),
148
+ }),
149
+ async execute(_id, { selector, role, name }) {
150
+ const sel = (selector ?? "").trim();
151
+ const r = (role ?? "").trim();
152
+ const n = (name ?? "").trim();
153
+ if (!sel && !r) {
154
+ return errorResult("Error: provide either 'selector' or 'role' (+ optional 'name')");
155
+ }
156
+ const sess = await ensureSession();
157
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
158
+ try {
159
+ const loc = r
160
+ ? (n ? sess.page.getByRole(r, { name: n }) : sess.page.getByRole(r))
161
+ : sess.page.locator(sel);
162
+ await loc.first().click();
163
+ await sess.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
164
+ sess.history.push(sess.page.url());
165
+ sess.extractCache.clear();
166
+ return textResult(`clicked. url=${sess.page.url()}`);
167
+ } catch (e: any) {
168
+ return errorResult(`Error clicking: ${e?.message ?? e}`);
169
+ }
170
+ },
171
+ });
172
+
173
+ // ── BrowserType ──────────────────────────────────────────────────────
174
+ pi.registerTool({
175
+ name: "BrowserType",
176
+ label: "BrowserType",
177
+ description: "Fill a form field by selector. Optionally submit by pressing Enter.",
178
+ parameters: Type.Object({
179
+ selector: Type.String({ description: "CSS selector of the input" }),
180
+ text: Type.String({ description: "Text to type" }),
181
+ submit: Type.Optional(Type.Boolean({ description: "Press Enter after typing" })),
182
+ }),
183
+ async execute(_id, { selector, text, submit }) {
184
+ const sel = (selector ?? "").trim();
185
+ const t = text ?? "";
186
+ if (!sel) return errorResult("Error: selector is required");
187
+ const sess = await ensureSession();
188
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
189
+ try {
190
+ await sess.page.fill(sel, t);
191
+ if (submit) {
192
+ await sess.page.press(sel, "Enter");
193
+ await sess.page.waitForLoadState("domcontentloaded", { timeout: 10_000 });
194
+ sess.history.push(sess.page.url());
195
+ sess.extractCache.clear();
196
+ }
197
+ return textResult(`typed ${t.length} chars into ${sel}${submit ? " + Enter" : ""}`);
198
+ } catch (e: any) {
199
+ return errorResult(`Error typing: ${e?.message ?? e}`);
200
+ }
201
+ },
202
+ });
203
+
204
+ // ── BrowserScroll ────────────────────────────────────────────────────
205
+ pi.registerTool({
206
+ name: "BrowserScroll",
207
+ label: "BrowserScroll",
208
+ description: "Scroll the current page up or down by a pixel amount (default 800px down).",
209
+ parameters: Type.Object({
210
+ direction: Type.Optional(Type.String({ description: "up or down" })),
211
+ amount: Type.Optional(Type.Integer({ description: "Pixels (default 800)" })),
212
+ }),
213
+ async execute(_id, { direction, amount }) {
214
+ const dir = ((direction ?? "down") as string).toLowerCase();
215
+ const amt = typeof amount === "number" ? amount : 800;
216
+ const sess = await ensureSession();
217
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
218
+ const dy = dir === "down" ? amt : -amt;
219
+ try {
220
+ await sess.page.evaluate(`window.scrollBy(0, ${dy})`);
221
+ return textResult(`scrolled ${dir} by ${amt}px`);
222
+ } catch (e: any) {
223
+ return errorResult(`Error scrolling: ${e?.message ?? e}`);
224
+ }
225
+ },
226
+ });
227
+
228
+ // ── BrowserExtract ───────────────────────────────────────────────────
229
+ pi.registerTool({
230
+ name: "BrowserExtract",
231
+ label: "BrowserExtract",
232
+ description:
233
+ "Extract the current page's readable text. Returns a 2KB chunk with cursor+has_more " +
234
+ "so one page can't swamp context. Call repeatedly with the last 'next=' cursor.",
235
+ parameters: Type.Object({
236
+ cursor: Type.Optional(Type.String({ description: "Byte offset to start from (default 0)" })),
237
+ }),
238
+ async execute(_id, { cursor }) {
239
+ const sess = await ensureSession();
240
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
241
+ try {
242
+ if (!sess.extractCache.has("full")) {
243
+ let text: string = await sess.page.evaluate(readablePageText);
244
+ if (!text) {
245
+ text = await sess.page.evaluate(fallbackPageText);
246
+ }
247
+ sess.extractCache.set("full", text ?? "");
248
+ }
249
+ const full = sess.extractCache.get("full") ?? "";
250
+ const startRaw = parseInt((cursor ?? "0") as string, 10);
251
+ const start = Number.isFinite(startRaw) ? startRaw : 0;
252
+ if (start < 0 || start >= full.length) {
253
+ return textResult(`[cursor=${start} past end (length=${full.length})]`);
254
+ }
255
+ const end = Math.min(start + CHUNK_SIZE, full.length);
256
+ const chunk = full.slice(start, end);
257
+ const hasMore = end < full.length;
258
+ const footerBits = [
259
+ `cursor=${start}`,
260
+ `next=${hasMore ? end : "null"}`,
261
+ `total=${full.length}`,
262
+ ];
263
+ if (hasMore) footerBits.push("has_more=true");
264
+ return textResult(`${chunk}\n[${footerBits.join(" ")}]`);
265
+ } catch (e: any) {
266
+ return errorResult(`Error extracting: ${e?.message ?? e}`);
267
+ }
268
+ },
269
+ });
270
+
271
+ // ── BrowserBack ──────────────────────────────────────────────────────
272
+ pi.registerTool({
273
+ name: "BrowserBack",
274
+ label: "BrowserBack",
275
+ description: "Navigate back in the browser's history.",
276
+ parameters: Type.Object({}),
277
+ async execute() {
278
+ const sess = await ensureSession();
279
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
280
+ try {
281
+ await sess.page.goBack({ waitUntil: "domcontentloaded" });
282
+ sess.extractCache.clear();
283
+ return textResult(`back. url=${sess.page.url()}`);
284
+ } catch (e: any) {
285
+ return errorResult(`Error going back: ${e?.message ?? e}`);
286
+ }
287
+ },
288
+ });
289
+
290
+ // ── BrowserHistory ───────────────────────────────────────────────────
291
+ pi.registerTool({
292
+ name: "BrowserHistory",
293
+ label: "BrowserHistory",
294
+ description: "List the last 20 URLs visited in this session.",
295
+ parameters: Type.Object({}),
296
+ async execute() {
297
+ const sess = await ensureSession();
298
+ if (sess.error) return errorResult(`Error: ${sess.error}`);
299
+ if (sess.history.length === 0) return textResult("(no pages visited yet)");
300
+ const lines = sess.history.slice(-20).map((u, i) => `${i + 1}. ${u}`);
301
+ return textResult(lines.join("\n"));
302
+ },
303
+ });
304
+ }