@oh-my-pi/pi-coding-agent 14.5.13 → 14.5.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/CHANGELOG.md +13 -0
- package/package.json +7 -7
- package/src/commit/pipeline.ts +4 -3
- package/src/config.ts +9 -6
- package/src/eval/py/kernel.ts +4 -4
- package/src/main.ts +10 -0
- package/src/mcp/manager.ts +22 -0
- package/src/modes/rpc/rpc-client.ts +9 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/sdk.ts +19 -4
- package/src/session/agent-session.ts +110 -4
- package/src/task/agents.ts +4 -5
- package/src/tools/archive-reader.ts +9 -3
- package/src/tools/browser/readable.ts +11 -6
- package/src/tools/browser/tab-supervisor.ts +2 -2
- package/src/tools/browser.ts +5 -3
- package/src/tools/image-gen.ts +2 -2
- package/src/tools/write.ts +8 -1
- package/src/web/scrapers/crossref.ts +3 -3
- package/src/web/scrapers/devto.ts +1 -1
- package/src/web/scrapers/discourse.ts +5 -5
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/flathub.ts +2 -2
- package/src/web/scrapers/gitlab.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
- package/src/web/scrapers/mastodon.ts +9 -9
- package/src/web/scrapers/mdn.ts +11 -7
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/rawg.ts +3 -3
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/spdx.ts +1 -1
- package/src/web/scrapers/stackoverflow.ts +2 -2
- package/src/web/scrapers/types.ts +53 -39
- package/src/web/scrapers/w3c.ts +1 -1
- package/src/web/search/providers/gemini.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.5.14] - 2026-05-01
|
|
6
|
+
### Changed
|
|
7
|
+
|
|
8
|
+
- Changed markdown conversion and archive tooling to defer loading heavy dependencies (Turndown, fflate, and browser agent content) until first use, reducing startup overhead for CLI startup and command initialization
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Fixed changelog state tracking by flushing `lastChangelogVersion` to settings immediately when showing new entries, so the updated version is persisted across restarts
|
|
13
|
+
|
|
5
14
|
## [14.5.13] - 2026-05-01
|
|
6
15
|
|
|
7
16
|
### Breaking Changes
|
|
@@ -34,6 +43,10 @@
|
|
|
34
43
|
|
|
35
44
|
- Fixed eval startup messaging to report `eval` as unavailable when Python is unreachable and JavaScript backend is disabled
|
|
36
45
|
|
|
46
|
+
### Fixed
|
|
47
|
+
- Stabilized MCP tool ordering so reconnects and refreshes no longer reorder the tools array sent to the model. Anthropic prompt caching is keyed on byte-identical tool definitions; previously, the order depended on connection sequence and a single MCP server reconnect could shuffle tools across servers and invalidate the tools cache breakpoint.
|
|
48
|
+
- Skipped redundant system-prompt rebuilds in `AgentSession.refreshMCPTools` when the active tool set is unchanged. MCP transport flapping (e.g. routine 5-minute SSE reconnects) used to call `rebuildSystemPrompt` on every reconnect even though the resulting prompt was byte-identical, eating CPU and risking cache misses if the rebuild ever became non-deterministic. The applied-tool signature also covers `customWireName` so a wire-name flip with the rest of the tool metadata constant still forces a rebuild.
|
|
49
|
+
|
|
37
50
|
## [14.5.12] - 2026-04-30
|
|
38
51
|
|
|
39
52
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.5.
|
|
4
|
+
"version": "14.5.14",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.20.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.5.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.5.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.5.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.5.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.5.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.5.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.5.14",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.5.14",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.5.14",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.5.14",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.5.14",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.5.14",
|
|
55
55
|
"@puppeteer/browsers": "^2.13.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34.49",
|
|
57
57
|
"@xterm/headless": "^6.0.0",
|
package/src/commit/pipeline.ts
CHANGED
|
@@ -25,7 +25,8 @@ import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
|
|
|
25
25
|
|
|
26
26
|
const SUMMARY_MAX_CHARS = 72;
|
|
27
27
|
const RECENT_COMMITS_COUNT = 8;
|
|
28
|
-
|
|
28
|
+
let _typesDescription: string | undefined;
|
|
29
|
+
const TYPES_DESCRIPTION = (): string => (_typesDescription ??= prompt.render(typesDescriptionPrompt));
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Execute the omp commit pipeline for staged changes.
|
|
@@ -176,7 +177,7 @@ async function generateAnalysis(input: {
|
|
|
176
177
|
diff: input.diff,
|
|
177
178
|
stat: input.stat,
|
|
178
179
|
scopeCandidates: input.scopeCandidates,
|
|
179
|
-
typesDescription: TYPES_DESCRIPTION,
|
|
180
|
+
typesDescription: TYPES_DESCRIPTION(),
|
|
180
181
|
settings: {
|
|
181
182
|
enabled: input.commitSettings.mapReduceEnabled,
|
|
182
183
|
minFiles: input.commitSettings.mapReduceMinFiles,
|
|
@@ -193,7 +194,7 @@ async function generateAnalysis(input: {
|
|
|
193
194
|
thinkingLevel: input.primaryThinkingLevel,
|
|
194
195
|
contextFiles: input.contextFiles,
|
|
195
196
|
userContext: input.userContext,
|
|
196
|
-
typesDescription: TYPES_DESCRIPTION,
|
|
197
|
+
typesDescription: TYPES_DESCRIPTION(),
|
|
197
198
|
recentCommits: input.recentCommits,
|
|
198
199
|
scopeCandidates: input.scopeCandidates,
|
|
199
200
|
stat: input.stat,
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { TSchema } from "@sinclair/typebox";
|
|
13
13
|
import { Value } from "@sinclair/typebox/value";
|
|
14
|
-
import {
|
|
14
|
+
import type { ErrorObject } from "ajv";
|
|
15
15
|
import { JSONC, YAML } from "bun";
|
|
16
16
|
import { expandTilde } from "./tools/path-utils";
|
|
17
17
|
|
|
@@ -143,7 +143,6 @@ export type LoadResult<T> =
|
|
|
143
143
|
| { value: T; error?: undefined; status: "ok" }
|
|
144
144
|
| { value?: null; error?: unknown; status: "not-found" };
|
|
145
145
|
|
|
146
|
-
const ajv = new Ajv();
|
|
147
146
|
export class ConfigFile<T> implements IConfigFile<T> {
|
|
148
147
|
readonly #basePath: string;
|
|
149
148
|
#cache?: LoadResult<T>;
|
|
@@ -221,13 +220,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
|
|
|
221
220
|
throw new Error(`Invalid config file path: ${this.#basePath}`);
|
|
222
221
|
}
|
|
223
222
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
223
|
+
if (!Value.Check(this.schema, parsed)) {
|
|
224
|
+
const schemaErrors: ErrorObject[] = [];
|
|
225
|
+
for (const err of Value.Errors(this.schema, parsed)) {
|
|
226
|
+
schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject);
|
|
227
|
+
if (schemaErrors.length >= 50) break;
|
|
228
|
+
}
|
|
229
|
+
const error = new ConfigError(this.id, schemaErrors);
|
|
227
230
|
logger.warn("Failed to parse config file", { path: this.path(), error });
|
|
228
231
|
return this.#storeCache({ error, status: "error" });
|
|
229
232
|
}
|
|
230
|
-
return this.#storeCache({ value: parsed, status: "ok" });
|
|
233
|
+
return this.#storeCache({ value: parsed as T, status: "ok" });
|
|
231
234
|
} catch (error) {
|
|
232
235
|
if (isEnoent(error)) {
|
|
233
236
|
return this.#storeCache({ status: "not-found" });
|
package/src/eval/py/kernel.ts
CHANGED
|
@@ -271,10 +271,10 @@ function normalizeDisplayText(text: string): string {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
/** Renders a Jupyter display_data message into text and structured outputs. */
|
|
274
|
-
export function renderKernelDisplay(content: Record<string, unknown>): {
|
|
274
|
+
export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
|
|
275
275
|
text: string;
|
|
276
276
|
outputs: KernelDisplayOutput[];
|
|
277
|
-
} {
|
|
277
|
+
}> {
|
|
278
278
|
const data = content.data as Record<string, unknown> | undefined;
|
|
279
279
|
if (!data) return { text: "", outputs: [] };
|
|
280
280
|
|
|
@@ -307,7 +307,7 @@ export function renderKernelDisplay(content: Record<string, unknown>): {
|
|
|
307
307
|
return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
|
|
308
308
|
}
|
|
309
309
|
if (data["text/html"] !== undefined) {
|
|
310
|
-
const markdown = htmlToBasicMarkdown(String(data["text/html"])) || "";
|
|
310
|
+
const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
|
|
311
311
|
return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
|
|
312
312
|
}
|
|
313
313
|
return { text: "", outputs };
|
|
@@ -872,7 +872,7 @@ export class PythonKernel {
|
|
|
872
872
|
}
|
|
873
873
|
case "execute_result":
|
|
874
874
|
case "display_data": {
|
|
875
|
-
const { text, outputs } = renderKernelDisplay(response.content);
|
|
875
|
+
const { text, outputs } = await renderKernelDisplay(response.content);
|
|
876
876
|
if (text && options?.onChunk) {
|
|
877
877
|
await options.onChunk(text);
|
|
878
878
|
}
|
package/src/main.ts
CHANGED
|
@@ -253,12 +253,14 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
|
|
|
253
253
|
if (!lastVersion) {
|
|
254
254
|
if (entries.length > 0) {
|
|
255
255
|
settings.set("lastChangelogVersion", VERSION);
|
|
256
|
+
await flushChangelogVersion();
|
|
256
257
|
return entries.map(e => e.content).join("\n\n");
|
|
257
258
|
}
|
|
258
259
|
} else {
|
|
259
260
|
const newEntries = getNewEntries(entries, lastVersion);
|
|
260
261
|
if (newEntries.length > 0) {
|
|
261
262
|
settings.set("lastChangelogVersion", VERSION);
|
|
263
|
+
await flushChangelogVersion();
|
|
262
264
|
return newEntries.map(e => e.content).join("\n\n");
|
|
263
265
|
}
|
|
264
266
|
}
|
|
@@ -266,6 +268,14 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
|
|
|
266
268
|
return undefined;
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
async function flushChangelogVersion(): Promise<void> {
|
|
272
|
+
try {
|
|
273
|
+
await settings.flush();
|
|
274
|
+
} catch (error: unknown) {
|
|
275
|
+
logger.warn("Failed to persist lastChangelogVersion", { error });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
269
279
|
async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
|
|
270
280
|
if (parsed.fork) {
|
|
271
281
|
if (parsed.noSession) {
|
package/src/mcp/manager.ts
CHANGED
|
@@ -78,6 +78,21 @@ function delay(ms: number): Promise<void> {
|
|
|
78
78
|
return Bun.sleep(ms);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Stable, total ordering on MCP tools by name.
|
|
83
|
+
*
|
|
84
|
+
* Anthropic prompt caching keys on byte-identical tool definitions: any reorder
|
|
85
|
+
* of the tools array invalidates the tools cache breakpoint and forces a full
|
|
86
|
+
* prefix rebuild on the next request. MCP servers connect/reconnect at arbitrary
|
|
87
|
+
* times, so the natural "insertion order" of `#tools` is non-deterministic.
|
|
88
|
+
* Sorting after every mutation makes the array bytes independent of connection
|
|
89
|
+
* sequence.
|
|
90
|
+
*/
|
|
91
|
+
export function sortMCPToolsByName<T extends { name: string }>(tools: T[]): T[] {
|
|
92
|
+
tools.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
93
|
+
return tools;
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
export function resolveSubscriptionPostAction(
|
|
82
97
|
notificationsEnabled: boolean,
|
|
83
98
|
currentEpoch: number,
|
|
@@ -459,6 +474,10 @@ export class MCPManager {
|
|
|
459
474
|
}
|
|
460
475
|
}
|
|
461
476
|
|
|
477
|
+
// Stable sort by name so the order is independent of connection completion.
|
|
478
|
+
// See `sortMCPToolsByName` for the cache-stability rationale.
|
|
479
|
+
sortMCPToolsByName(allTools);
|
|
480
|
+
|
|
462
481
|
// Update cached tools
|
|
463
482
|
this.#tools = allTools;
|
|
464
483
|
allowBackgroundLogging = true;
|
|
@@ -474,6 +493,9 @@ export class MCPManager {
|
|
|
474
493
|
#replaceServerTools(name: string, tools: CustomTool<TSchema, MCPToolDetails>[]): void {
|
|
475
494
|
this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp__${name}_`));
|
|
476
495
|
this.#tools.push(...tools);
|
|
496
|
+
// Stable sort by name so reconnect order does not perturb the array.
|
|
497
|
+
// See `sortMCPToolsByName` for the cache-stability rationale.
|
|
498
|
+
sortMCPToolsByName(this.#tools);
|
|
477
499
|
}
|
|
478
500
|
|
|
479
501
|
#triggerNotificationRefresh(serverName: string, kind: "tools" | "resources" | "prompts"): void {
|
|
@@ -11,6 +11,7 @@ import type { SessionStats } from "../../session/agent-session";
|
|
|
11
11
|
import type { CompactionResult } from "../../session/compaction";
|
|
12
12
|
import type {
|
|
13
13
|
RpcCommand,
|
|
14
|
+
RpcHandoffResult,
|
|
14
15
|
RpcHostToolCallRequest,
|
|
15
16
|
RpcHostToolCancelRequest,
|
|
16
17
|
RpcHostToolDefinition,
|
|
@@ -457,6 +458,14 @@ export class RpcClient {
|
|
|
457
458
|
return this.#getData(response);
|
|
458
459
|
}
|
|
459
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Hand off session context to a new session.
|
|
463
|
+
*/
|
|
464
|
+
async handoff(customInstructions?: string): Promise<RpcHandoffResult | null> {
|
|
465
|
+
const response = await this.#send({ type: "handoff", customInstructions });
|
|
466
|
+
return this.#getData(response);
|
|
467
|
+
}
|
|
468
|
+
|
|
460
469
|
/**
|
|
461
470
|
* Export session to HTML.
|
|
462
471
|
*/
|
|
@@ -574,6 +574,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|
|
574
574
|
description: tool.description,
|
|
575
575
|
parameters: tool.parameters,
|
|
576
576
|
})),
|
|
577
|
+
contextUsage: session.getContextUsage(),
|
|
577
578
|
};
|
|
578
579
|
return success(id, "get_state", state);
|
|
579
580
|
}
|
|
@@ -741,6 +742,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|
|
741
742
|
return success(id, "set_session_name");
|
|
742
743
|
}
|
|
743
744
|
|
|
745
|
+
case "handoff": {
|
|
746
|
+
const result = await session.handoff(command.customInstructions);
|
|
747
|
+
return success(id, "handoff", result ? { savedPath: result.savedPath } : null);
|
|
748
|
+
}
|
|
749
|
+
|
|
744
750
|
// =================================================================
|
|
745
751
|
// Messages
|
|
746
752
|
// =================================================================
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
8
8
|
import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
|
|
9
9
|
import type { BashResult } from "../../exec/bash-executor";
|
|
10
|
+
import type { ContextUsage } from "../../extensibility/extensions/types";
|
|
10
11
|
import type { SessionStats } from "../../session/agent-session";
|
|
11
12
|
import type { CompactionResult } from "../../session/compaction";
|
|
12
13
|
import type { TodoPhase } from "../../tools/todo-write";
|
|
@@ -63,6 +64,7 @@ export type RpcCommand =
|
|
|
63
64
|
| { id?: string; type: "get_branch_messages" }
|
|
64
65
|
| { id?: string; type: "get_last_assistant_text" }
|
|
65
66
|
| { id?: string; type: "set_session_name"; name: string }
|
|
67
|
+
| { id?: string; type: "handoff"; customInstructions?: string }
|
|
66
68
|
|
|
67
69
|
// Messages
|
|
68
70
|
| { id?: string; type: "get_messages" };
|
|
@@ -89,6 +91,12 @@ export interface RpcSessionState {
|
|
|
89
91
|
/** For session dump / export (plain-text parity with /dump). */
|
|
90
92
|
systemPrompt?: string;
|
|
91
93
|
dumpTools?: Array<{ name: string; description: string; parameters: unknown }>;
|
|
94
|
+
/** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
|
|
95
|
+
contextUsage?: ContextUsage;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RpcHandoffResult {
|
|
99
|
+
savedPath?: string;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
// ============================================================================
|
|
@@ -180,6 +188,7 @@ export type RpcResponse =
|
|
|
180
188
|
data: { text: string | null };
|
|
181
189
|
}
|
|
182
190
|
| { id?: string; type: "response"; command: "set_session_name"; success: true }
|
|
191
|
+
| { id?: string; type: "response"; command: "handoff"; success: true; data: RpcHandoffResult | null }
|
|
183
192
|
|
|
184
193
|
// Messages
|
|
185
194
|
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
|
package/src/sdk.ts
CHANGED
|
@@ -300,7 +300,6 @@ function getDefaultAgentDir(): string {
|
|
|
300
300
|
*/
|
|
301
301
|
export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise<AuthStorage> {
|
|
302
302
|
const dbPath = getAgentDbPath(agentDir);
|
|
303
|
-
logger.debug("discoverAuthStorage", { agentDir, dbPath });
|
|
304
303
|
|
|
305
304
|
const storage = await AuthStorage.create(dbPath, { configValueResolver: resolveConfigValue });
|
|
306
305
|
await storage.reload();
|
|
@@ -429,6 +428,9 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
|
|
|
429
428
|
|
|
430
429
|
const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
|
|
431
430
|
|
|
431
|
+
/** Matches the truncation applied to per-server instructions inside `rebuildSystemPrompt`. */
|
|
432
|
+
const MAX_MCP_INSTRUCTIONS_LENGTH = 4000;
|
|
433
|
+
|
|
432
434
|
let sshCleanupRegistered = false;
|
|
433
435
|
|
|
434
436
|
async function cleanupSshResources(): Promise<void> {
|
|
@@ -1338,7 +1340,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1338
1340
|
const serverInstructions = mcpManager?.getServerInstructions();
|
|
1339
1341
|
let appendPrompt: string | undefined = memoryInstructions ?? undefined;
|
|
1340
1342
|
if (serverInstructions && serverInstructions.size > 0) {
|
|
1341
|
-
const MAX_INSTRUCTIONS_LENGTH = 4000;
|
|
1342
1343
|
const parts: string[] = [];
|
|
1343
1344
|
if (appendPrompt) parts.push(appendPrompt);
|
|
1344
1345
|
parts.push(
|
|
@@ -1346,8 +1347,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1346
1347
|
);
|
|
1347
1348
|
for (const [srvName, srvInstructions] of serverInstructions) {
|
|
1348
1349
|
const truncated =
|
|
1349
|
-
srvInstructions.length >
|
|
1350
|
-
? `${srvInstructions.slice(0,
|
|
1350
|
+
srvInstructions.length > MAX_MCP_INSTRUCTIONS_LENGTH
|
|
1351
|
+
? `${srvInstructions.slice(0, MAX_MCP_INSTRUCTIONS_LENGTH)}\n[truncated]`
|
|
1351
1352
|
: srvInstructions;
|
|
1352
1353
|
parts.push(`### ${srvName}\n${truncated}`);
|
|
1353
1354
|
}
|
|
@@ -1627,6 +1628,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1627
1628
|
onResponse,
|
|
1628
1629
|
convertToLlm: convertToLlmFinal,
|
|
1629
1630
|
rebuildSystemPrompt,
|
|
1631
|
+
getMcpServerInstructions: mcpManager
|
|
1632
|
+
? () => {
|
|
1633
|
+
const raw = mcpManager.getServerInstructions();
|
|
1634
|
+
if (!raw || raw.size === 0) return raw;
|
|
1635
|
+
const out = new Map<string, string>();
|
|
1636
|
+
for (const [name, text] of raw) {
|
|
1637
|
+
out.set(
|
|
1638
|
+
name,
|
|
1639
|
+
text.length > MAX_MCP_INSTRUCTIONS_LENGTH ? text.slice(0, MAX_MCP_INSTRUCTIONS_LENGTH) : text,
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
return out;
|
|
1643
|
+
}
|
|
1644
|
+
: undefined,
|
|
1630
1645
|
mcpDiscoveryEnabled,
|
|
1631
1646
|
initialSelectedMCPToolNames,
|
|
1632
1647
|
defaultSelectedMCPToolNames,
|
|
@@ -244,6 +244,13 @@ export interface AgentSessionConfig {
|
|
|
244
244
|
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
245
245
|
/** System prompt builder that can consider tool availability */
|
|
246
246
|
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
|
|
247
|
+
/**
|
|
248
|
+
* Optional accessor for live MCP server instructions. Read by the session's
|
|
249
|
+
* `rebuildSystemPrompt`-skip optimization to detect server-side instruction
|
|
250
|
+
* changes (e.g. an MCP server upgrade) that would otherwise pass the tool-set
|
|
251
|
+
* signature comparison and silently keep a stale prompt cached.
|
|
252
|
+
*/
|
|
253
|
+
getMcpServerInstructions?: () => Map<string, string> | undefined;
|
|
247
254
|
/** Enable hidden-by-default MCP tool discovery for this session. */
|
|
248
255
|
mcpDiscoveryEnabled?: boolean;
|
|
249
256
|
/** MCP tool names to activate for the current session when discovery mode is enabled. */
|
|
@@ -511,7 +518,15 @@ export class AgentSession {
|
|
|
511
518
|
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
|
|
512
519
|
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
513
520
|
#rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
|
|
521
|
+
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
|
|
514
522
|
#baseSystemPrompt: string;
|
|
523
|
+
/**
|
|
524
|
+
* Signature of the (toolNames, tool descriptions) tuple passed to the most
|
|
525
|
+
* recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
|
|
526
|
+
* when MCP servers reconnect without changing their tool definitions, which is
|
|
527
|
+
* the dominant cause of prompt-cache invalidation in long sessions.
|
|
528
|
+
*/
|
|
529
|
+
#lastAppliedToolSignature: string | undefined;
|
|
515
530
|
#mcpDiscoveryEnabled = false;
|
|
516
531
|
#discoverableMCPTools = new Map<string, DiscoverableMCPTool>();
|
|
517
532
|
#discoverableMCPSearchIndex: DiscoverableMCPSearchIndex | null = null;
|
|
@@ -595,6 +610,7 @@ export class AgentSession {
|
|
|
595
610
|
this.#onResponse = config.onResponse;
|
|
596
611
|
this.#convertToLlm = config.convertToLlm ?? convertToLlm;
|
|
597
612
|
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
|
613
|
+
this.#getMcpServerInstructions = config.getMcpServerInstructions;
|
|
598
614
|
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
|
599
615
|
this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
|
|
600
616
|
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
|
|
@@ -2211,10 +2227,18 @@ export class AgentSession {
|
|
|
2211
2227
|
}
|
|
2212
2228
|
this.agent.setTools(tools);
|
|
2213
2229
|
|
|
2214
|
-
// Rebuild base system prompt with new tool set
|
|
2230
|
+
// Rebuild base system prompt with new tool set, but only when the tool set
|
|
2231
|
+
// actually changed. MCP servers can reconnect at arbitrary times and call
|
|
2232
|
+
// `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
|
|
2233
|
+
// tool list is byte-identical. Skipping the rebuild keeps the system prompt
|
|
2234
|
+
// stable, which is required for Anthropic prompt caching to keep hitting.
|
|
2215
2235
|
if (this.#rebuildSystemPrompt) {
|
|
2216
|
-
|
|
2217
|
-
|
|
2236
|
+
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
|
|
2237
|
+
if (signature !== this.#lastAppliedToolSignature) {
|
|
2238
|
+
this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
|
|
2239
|
+
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
|
2240
|
+
this.#lastAppliedToolSignature = signature;
|
|
2241
|
+
}
|
|
2218
2242
|
}
|
|
2219
2243
|
if (options?.persistMCPSelection !== false) {
|
|
2220
2244
|
this.#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames);
|
|
@@ -2256,6 +2280,86 @@ export class AgentSession {
|
|
|
2256
2280
|
const activeToolNames = this.getActiveToolNames();
|
|
2257
2281
|
this.#baseSystemPrompt = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
|
|
2258
2282
|
this.agent.setSystemPrompt(this.#baseSystemPrompt);
|
|
2283
|
+
// Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
|
|
2284
|
+
// the same tool set does not re-rebuild on top of the explicit refresh we
|
|
2285
|
+
// just performed (and conversely, a different set forces a fresh rebuild).
|
|
2286
|
+
const activeTools = activeToolNames
|
|
2287
|
+
.map(name => this.#toolRegistry.get(name))
|
|
2288
|
+
.filter((tool): tool is AgentTool => tool != null);
|
|
2289
|
+
this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/**
|
|
2293
|
+
* Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
|
|
2294
|
+
* Two calls producing identical signatures are guaranteed to produce identical
|
|
2295
|
+
* system prompt bytes, so the rebuild can be skipped.
|
|
2296
|
+
*
|
|
2297
|
+
* The signature covers:
|
|
2298
|
+
* 1. Active tool names in order (the prompt renders them in this order).
|
|
2299
|
+
* 2. Active tool labels, descriptions, and wire-visible names — all are
|
|
2300
|
+
* rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\``
|
|
2301
|
+
* and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from
|
|
2302
|
+
* `tool.customWireName` and overrides the internal name on the model wire
|
|
2303
|
+
* (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode);
|
|
2304
|
+
* a stale wire name would desync prompt guidance from actual tool routing.
|
|
2305
|
+
* 3. When MCP discovery is on, every registry tool's name+label+description+
|
|
2306
|
+
* customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP
|
|
2307
|
+
* tools that are not in the active set.
|
|
2308
|
+
* 4. MCP server instructions text (per server), since `rebuildSystemPrompt`
|
|
2309
|
+
* embeds these in the appended prompt under "## MCP Server Instructions".
|
|
2310
|
+
* A server upgrade can change instructions while keeping tools identical.
|
|
2311
|
+
*
|
|
2312
|
+
* Settings-driven tool metadata is covered automatically: built-in tools that
|
|
2313
|
+
* depend on settings expose `description`/`label` via getters (see `TaskTool`,
|
|
2314
|
+
* `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every
|
|
2315
|
+
* call - so a settings flip that mutates the rendered string differs the signature
|
|
2316
|
+
* the next time `#applyActiveToolsByName` runs. Do not refactor `describeTool` to
|
|
2317
|
+
* cache per-tool strings without preserving this property.
|
|
2318
|
+
*
|
|
2319
|
+
* Inputs NOT covered: tool input schemas; memory instructions read from disk;
|
|
2320
|
+
* and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
|
|
2321
|
+
* `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
|
|
2322
|
+
* closure-captured ones cannot change at runtime regardless of skip behavior.
|
|
2323
|
+
* For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
|
|
2324
|
+
* after side-effecting changes; see e.g. the memory hooks and
|
|
2325
|
+
* `#syncEditToolModeAfterModelChange`.
|
|
2326
|
+
*
|
|
2327
|
+
* The current calendar date IS covered (appended as a segment) because
|
|
2328
|
+
* `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
|
|
2329
|
+
* Without this, a session spanning midnight with only tool-stable MCP
|
|
2330
|
+
* reconnects would keep yesterday's date indefinitely.
|
|
2331
|
+
*/
|
|
2332
|
+
#computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
|
|
2333
|
+
// Order-preserving join: any reorder must produce a different signature so
|
|
2334
|
+
// the rebuild fires and the new tool list reaches the API.
|
|
2335
|
+
const nameSegment = toolNames.join("\u0001");
|
|
2336
|
+
const describeTool = (tool: AgentTool): string =>
|
|
2337
|
+
`${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
|
|
2338
|
+
const descriptionSegment = tools.map(describeTool).join("\u0002");
|
|
2339
|
+
let registrySegment = "";
|
|
2340
|
+
if (this.#mcpDiscoveryEnabled) {
|
|
2341
|
+
// Registry iteration order is not load-bearing for the prompt content, so we
|
|
2342
|
+
// sort to keep the signature insensitive to incidental insertion order.
|
|
2343
|
+
const entries: string[] = [];
|
|
2344
|
+
for (const tool of this.#toolRegistry.values()) {
|
|
2345
|
+
entries.push(describeTool(tool));
|
|
2346
|
+
}
|
|
2347
|
+
entries.sort();
|
|
2348
|
+
registrySegment = entries.join("\u0004");
|
|
2349
|
+
}
|
|
2350
|
+
let instructionsSegment = "";
|
|
2351
|
+
const serverInstructions = this.#getMcpServerInstructions?.();
|
|
2352
|
+
if (serverInstructions && serverInstructions.size > 0) {
|
|
2353
|
+
// Sort by server name so transport flap order does not perturb the signature.
|
|
2354
|
+
const entries: string[] = [];
|
|
2355
|
+
for (const [server, instructions] of serverInstructions) {
|
|
2356
|
+
entries.push(`${server}=${instructions}`);
|
|
2357
|
+
}
|
|
2358
|
+
entries.sort();
|
|
2359
|
+
instructionsSegment = entries.join("\u0006");
|
|
2360
|
+
}
|
|
2361
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
2362
|
+
return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
|
|
2259
2363
|
}
|
|
2260
2364
|
|
|
2261
2365
|
/**
|
|
@@ -4248,9 +4352,10 @@ export class AgentSession {
|
|
|
4248
4352
|
}
|
|
4249
4353
|
|
|
4250
4354
|
// Start a new session
|
|
4355
|
+
const previousSessionFile = this.sessionFile;
|
|
4251
4356
|
await this.sessionManager.flush();
|
|
4252
4357
|
this.#asyncJobManager?.cancelAll();
|
|
4253
|
-
await this.sessionManager.newSession();
|
|
4358
|
+
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
|
|
4254
4359
|
this.agent.reset();
|
|
4255
4360
|
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
4256
4361
|
this.#steeringMessages = [];
|
|
@@ -4262,6 +4367,7 @@ export class AgentSession {
|
|
|
4262
4367
|
// Inject the handoff document as a custom message
|
|
4263
4368
|
const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
|
|
4264
4369
|
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
|
|
4370
|
+
await this.sessionManager.ensureOnDisk();
|
|
4265
4371
|
let savedPath: string | undefined;
|
|
4266
4372
|
if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
|
|
4267
4373
|
const artifactsDir = this.sessionManager.getArtifactsDir();
|
package/src/task/agents.ts
CHANGED
|
@@ -69,10 +69,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
|
|
69
69
|
},
|
|
70
70
|
];
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
name: def.fileName,
|
|
74
|
-
content: buildAgentContent(def),
|
|
75
|
-
}));
|
|
72
|
+
// Computed lazily on first loadBundledAgents() call to avoid eager prompt.render at module load.
|
|
76
73
|
|
|
77
74
|
export class AgentParsingError extends Error {
|
|
78
75
|
constructor(
|
|
@@ -133,7 +130,9 @@ export function loadBundledAgents(): AgentDefinition[] {
|
|
|
133
130
|
if (bundledAgentsCache !== null) {
|
|
134
131
|
return bundledAgentsCache;
|
|
135
132
|
}
|
|
136
|
-
bundledAgentsCache =
|
|
133
|
+
bundledAgentsCache = EMBEDDED_AGENT_DEFS.map(def =>
|
|
134
|
+
parseAgent(`embedded:${def.fileName}`, buildAgentContent(def), "bundled"),
|
|
135
|
+
);
|
|
137
136
|
return bundledAgentsCache;
|
|
138
137
|
}
|
|
139
138
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { unzipSync } from "fflate";
|
|
2
1
|
import { ToolError } from "./tool-errors";
|
|
3
2
|
|
|
3
|
+
let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
|
|
4
|
+
async function loadFflate(): Promise<typeof import("fflate")> {
|
|
5
|
+
if (!fflateModulePromise) fflateModulePromise = import("fflate");
|
|
6
|
+
return fflateModulePromise;
|
|
7
|
+
}
|
|
8
|
+
|
|
4
9
|
export type ArchiveFormat = "zip" | "tar" | "tar.gz";
|
|
5
10
|
|
|
6
11
|
export interface ArchivePathCandidate {
|
|
@@ -150,7 +155,8 @@ async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
|
|
|
150
155
|
return entries;
|
|
151
156
|
}
|
|
152
157
|
|
|
153
|
-
function readZipEntries(bytes: Uint8Array): ArchiveIndexEntry[] {
|
|
158
|
+
async function readZipEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
|
|
159
|
+
const { unzipSync } = await loadFflate();
|
|
154
160
|
let files: Record<string, Uint8Array>;
|
|
155
161
|
try {
|
|
156
162
|
files = unzipSync(bytes);
|
|
@@ -310,6 +316,6 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
|
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
const bytes = await Bun.file(filePath).bytes();
|
|
313
|
-
const entries = format === "zip" ? readZipEntries(bytes) : await readTarEntries(bytes);
|
|
319
|
+
const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
|
|
314
320
|
return new ArchiveReader(format, entries);
|
|
315
321
|
}
|
|
@@ -26,13 +26,17 @@ function normalize(text: string | null | undefined): string | undefined {
|
|
|
26
26
|
* CSS selector chain over the same pre-parsed DOM. Returns null if neither
|
|
27
27
|
* path yields usable content.
|
|
28
28
|
*/
|
|
29
|
-
export function extractReadableFromHtml(
|
|
29
|
+
export async function extractReadableFromHtml(
|
|
30
|
+
html: string,
|
|
31
|
+
url: string,
|
|
32
|
+
format: ReadableFormat,
|
|
33
|
+
): Promise<ReadableResult | null> {
|
|
30
34
|
const { document } = parseHTML(html);
|
|
31
35
|
|
|
32
36
|
// --- Primary: Readability article extraction ---
|
|
33
37
|
const article = new Readability(document).parse();
|
|
34
38
|
if (article) {
|
|
35
|
-
const result = toReadableResult(url, format, article.textContent, article.content, {
|
|
39
|
+
const result = await toReadableResult(url, format, article.textContent, article.content, {
|
|
36
40
|
title: article.title,
|
|
37
41
|
byline: article.byline,
|
|
38
42
|
excerpt: article.excerpt,
|
|
@@ -55,7 +59,7 @@ export function extractReadableFromHtml(html: string, url: string, format: Reada
|
|
|
55
59
|
const innerHTML = el.innerHTML?.trim();
|
|
56
60
|
const textContent = el.textContent?.trim();
|
|
57
61
|
if (!innerHTML || !textContent) continue;
|
|
58
|
-
const result = toReadableResult(url, format, textContent, innerHTML, {
|
|
62
|
+
const result = await toReadableResult(url, format, textContent, innerHTML, {
|
|
59
63
|
title: document.title,
|
|
60
64
|
excerpt: textContent.slice(0, 240),
|
|
61
65
|
length: textContent.length,
|
|
@@ -67,15 +71,16 @@ export function extractReadableFromHtml(html: string, url: string, format: Reada
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/** Shared builder for both extraction paths. */
|
|
70
|
-
function toReadableResult(
|
|
74
|
+
async function toReadableResult(
|
|
71
75
|
url: string,
|
|
72
76
|
format: ReadableFormat,
|
|
73
77
|
textContent: string | null | undefined,
|
|
74
78
|
htmlContent: string | null | undefined,
|
|
75
79
|
meta: { title?: string | null; byline?: string | null; excerpt?: string | null; length?: number | null },
|
|
76
|
-
): ReadableResult | null {
|
|
80
|
+
): Promise<ReadableResult | null> {
|
|
77
81
|
const text = normalize(textContent);
|
|
78
|
-
const markdown =
|
|
82
|
+
const markdown =
|
|
83
|
+
format === "markdown" ? (normalize(await htmlToBasicMarkdown(htmlContent ?? "")) ?? text) : undefined;
|
|
79
84
|
const normalizedText = format === "text" ? text : undefined;
|
|
80
85
|
if (!normalizedText && !markdown) return null;
|
|
81
86
|
return {
|
|
@@ -16,7 +16,6 @@ import type {
|
|
|
16
16
|
WorkerInitPayload,
|
|
17
17
|
WorkerOutbound,
|
|
18
18
|
} from "./tab-protocol";
|
|
19
|
-
import { WorkerCore } from "./tab-worker";
|
|
20
19
|
|
|
21
20
|
interface WorkerHandle {
|
|
22
21
|
send(msg: WorkerInbound, transferList?: Transferable[]): void;
|
|
@@ -398,7 +397,7 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
|
|
|
398
397
|
* entry. This preserves normal browser behavior but cannot interrupt synchronous
|
|
399
398
|
* infinite loops because user code runs on the main thread.
|
|
400
399
|
*/
|
|
401
|
-
function spawnInlineWorker(): WorkerHandle {
|
|
400
|
+
async function spawnInlineWorker(): Promise<WorkerHandle> {
|
|
402
401
|
const hostListeners = new Set<(message: WorkerOutbound) => void>();
|
|
403
402
|
const workerListeners = new Set<(message: WorkerInbound) => void>();
|
|
404
403
|
const workerTransport: Transport = {
|
|
@@ -413,6 +412,7 @@ function spawnInlineWorker(): WorkerHandle {
|
|
|
413
412
|
},
|
|
414
413
|
close: () => {},
|
|
415
414
|
};
|
|
415
|
+
const { WorkerCore } = await import("./tab-worker");
|
|
416
416
|
new WorkerCore(workerTransport);
|
|
417
417
|
return {
|
|
418
418
|
mode: "inline",
|
package/src/tools/browser.ts
CHANGED
|
@@ -110,12 +110,14 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
|
|
|
110
110
|
export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
|
|
111
111
|
readonly name = "browser";
|
|
112
112
|
readonly label = "Browser";
|
|
113
|
-
readonly description: string;
|
|
114
113
|
readonly parameters = browserSchema;
|
|
115
114
|
readonly strict = true;
|
|
116
115
|
|
|
117
|
-
constructor(private readonly session: ToolSession) {
|
|
118
|
-
|
|
116
|
+
constructor(private readonly session: ToolSession) {}
|
|
117
|
+
#description?: string;
|
|
118
|
+
get description(): string {
|
|
119
|
+
this.#description ??= prompt.render(browserDescription, {});
|
|
120
|
+
return this.#description;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/** Restart browser to apply mode changes (e.g. headless toggle). Drops only headless browsers. */
|
package/src/tools/image-gen.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { getAntigravityUserAgent, getEnvApiKey, type Model, StringEnum } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import {
|
|
5
5
|
CODEX_BASE_URL,
|
|
6
6
|
getCodexAccountId,
|
|
@@ -1055,7 +1055,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
|
|
|
1055
1055
|
Authorization: `Bearer ${apiKey.apiKey}`,
|
|
1056
1056
|
"Content-Type": "application/json",
|
|
1057
1057
|
Accept: "text/event-stream",
|
|
1058
|
-
|
|
1058
|
+
"User-Agent": getAntigravityUserAgent(),
|
|
1059
1059
|
},
|
|
1060
1060
|
body: JSON.stringify(requestBody),
|
|
1061
1061
|
signal: requestSignal,
|
package/src/tools/write.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
6
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { type Static, Type } from "@sinclair/typebox";
|
|
9
|
-
import { unzipSync, zipSync } from "fflate";
|
|
10
9
|
import { stripHashlinePrefixes } from "../edit";
|
|
11
10
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
12
11
|
import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
|
|
@@ -44,6 +43,12 @@ import {
|
|
|
44
43
|
import { ToolError } from "./tool-errors";
|
|
45
44
|
import { toolResult } from "./tool-result";
|
|
46
45
|
|
|
46
|
+
let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
|
|
47
|
+
async function loadFflate(): Promise<typeof import("fflate")> {
|
|
48
|
+
if (!fflateModulePromise) fflateModulePromise = import("fflate");
|
|
49
|
+
return fflateModulePromise;
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
const writeSchema = Type.Object({
|
|
48
53
|
path: Type.String({ description: "file path", examples: ["src/new.ts"] }),
|
|
49
54
|
content: Type.String({ description: "file content" }),
|
|
@@ -229,6 +234,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
229
234
|
if (resolvedArchivePath.exists) {
|
|
230
235
|
try {
|
|
231
236
|
const bytes = await Bun.file(resolvedArchivePath.absolutePath).bytes();
|
|
237
|
+
const { unzipSync } = await loadFflate();
|
|
232
238
|
const existing = unzipSync(new Uint8Array(bytes));
|
|
233
239
|
for (const [entryPath, data] of Object.entries(existing)) {
|
|
234
240
|
zipEntries[entryPath.replace(/\\/g, "/")] = data;
|
|
@@ -241,6 +247,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
241
247
|
zipEntries[resolvedArchivePath.archiveSubPath] = new TextEncoder().encode(content);
|
|
242
248
|
|
|
243
249
|
try {
|
|
250
|
+
const { zipSync } = await loadFflate();
|
|
244
251
|
const zipBuffer = zipSync(zipEntries);
|
|
245
252
|
await Bun.write(resolvedArchivePath.absolutePath, zipBuffer);
|
|
246
253
|
} catch (error) {
|
|
@@ -66,10 +66,10 @@ function formatDate(date?: CrossrefDate): string | null {
|
|
|
66
66
|
return formatted.join("-");
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function formatAbstract(abstract?: string): string | null {
|
|
69
|
+
async function formatAbstract(abstract?: string): Promise<string | null> {
|
|
70
70
|
if (!abstract) return null;
|
|
71
71
|
const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, match => (match.startsWith("</") ? "</p>" : "<p>"));
|
|
72
|
-
const markdown = htmlToBasicMarkdown(normalized);
|
|
72
|
+
const markdown = await htmlToBasicMarkdown(normalized);
|
|
73
73
|
return markdown.trim().length > 0 ? markdown : null;
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -114,7 +114,7 @@ export const handleCrossref: SpecialHandler = async (
|
|
|
114
114
|
formatDate(message.issued) ||
|
|
115
115
|
formatDate(message.created);
|
|
116
116
|
const doiValue = message.DOI || doi;
|
|
117
|
-
const abstract = formatAbstract(message.abstract);
|
|
117
|
+
const abstract = await formatAbstract(message.abstract);
|
|
118
118
|
const type = message.type?.replace(/-/g, " ");
|
|
119
119
|
|
|
120
120
|
let md = `# ${title}\n\n`;
|
|
@@ -133,7 +133,7 @@ export const handleDevTo: SpecialHandler = async (
|
|
|
133
133
|
if (article.body_markdown) {
|
|
134
134
|
md += article.body_markdown;
|
|
135
135
|
} else if (article.body_html) {
|
|
136
|
-
md += htmlToBasicMarkdown(article.body_html);
|
|
136
|
+
md += await htmlToBasicMarkdown(article.body_html);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
notes.push("Fetched via dev.to API");
|
|
@@ -77,12 +77,12 @@ function formatCategory(topic: DiscourseTopic): string | null {
|
|
|
77
77
|
return parts.length ? parts.join(" ") : null;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function formatPostBody(post: DiscoursePost): string {
|
|
80
|
+
async function formatPostBody(post: DiscoursePost): Promise<string> {
|
|
81
81
|
const raw = post.raw?.trim();
|
|
82
82
|
if (raw) return raw;
|
|
83
83
|
const cooked = post.cooked?.trim();
|
|
84
84
|
if (!cooked) return "";
|
|
85
|
-
return htmlToBasicMarkdown(cooked);
|
|
85
|
+
return await htmlToBasicMarkdown(cooked);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function buildTopicUrl(baseUrl: string, topicId: string): string {
|
|
@@ -168,9 +168,9 @@ export const handleDiscourse: SpecialHandler = async (
|
|
|
168
168
|
md += "\n";
|
|
169
169
|
|
|
170
170
|
const description = topic.excerpt
|
|
171
|
-
? htmlToBasicMarkdown(topic.excerpt)
|
|
171
|
+
? await htmlToBasicMarkdown(topic.excerpt)
|
|
172
172
|
: posts.length
|
|
173
|
-
? formatPostBody(posts[0])
|
|
173
|
+
? await formatPostBody(posts[0])
|
|
174
174
|
: "";
|
|
175
175
|
if (description) {
|
|
176
176
|
md += `## Description\n\n${description}\n\n`;
|
|
@@ -182,7 +182,7 @@ export const handleDiscourse: SpecialHandler = async (
|
|
|
182
182
|
const author = formatAuthor({ name: post.name, username: post.username });
|
|
183
183
|
const date = formatIsoDate(post.created_at);
|
|
184
184
|
const likes = post.like_count ?? 0;
|
|
185
|
-
const content = formatPostBody(post);
|
|
185
|
+
const content = await formatPostBody(post);
|
|
186
186
|
const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
|
|
187
187
|
|
|
188
188
|
md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
|
|
@@ -112,7 +112,7 @@ export const handleFirefoxAddons: SpecialHandler = async (
|
|
|
112
112
|
const name = getLocalizedText(data.name, defaultLocale) ?? slug;
|
|
113
113
|
const summary = getLocalizedText(data.summary, defaultLocale);
|
|
114
114
|
const descriptionRaw = getLocalizedText(data.description, defaultLocale);
|
|
115
|
-
const description = descriptionRaw ? htmlToBasicMarkdown(descriptionRaw) : undefined;
|
|
115
|
+
const description = descriptionRaw ? await htmlToBasicMarkdown(descriptionRaw) : undefined;
|
|
116
116
|
|
|
117
117
|
const authors = (data.authors ?? [])
|
|
118
118
|
.map(author => author.name ?? "")
|
|
@@ -170,7 +170,7 @@ export const handleFlathub: SpecialHandler = async (
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (app.description) {
|
|
173
|
-
const description = htmlToBasicMarkdown(app.description);
|
|
173
|
+
const description = await htmlToBasicMarkdown(app.description);
|
|
174
174
|
if (description) md += `\n## Description\n\n${description}\n`;
|
|
175
175
|
}
|
|
176
176
|
|
|
@@ -204,7 +204,7 @@ export const handleFlathub: SpecialHandler = async (
|
|
|
204
204
|
md += `${line}\n`;
|
|
205
205
|
|
|
206
206
|
if (release.description) {
|
|
207
|
-
const releaseDesc = htmlToBasicMarkdown(release.description).replace(/\n+/g, " ").trim();
|
|
207
|
+
const releaseDesc = (await htmlToBasicMarkdown(release.description)).replace(/\n+/g, " ").trim();
|
|
208
208
|
if (releaseDesc) md += ` - ${releaseDesc}\n`;
|
|
209
209
|
}
|
|
210
210
|
}
|
|
@@ -259,7 +259,7 @@ async function renderGitLabIssue(
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
md += `\n---\n\n## Description\n\n`;
|
|
262
|
-
md += issue.description ? htmlToBasicMarkdown(issue.description) : "*No description*";
|
|
262
|
+
md += issue.description ? await htmlToBasicMarkdown(issue.description) : "*No description*";
|
|
263
263
|
|
|
264
264
|
return { content: md, ok: true };
|
|
265
265
|
}
|
|
@@ -159,7 +159,7 @@ export const handleGoPkg: SpecialHandler = async (
|
|
|
159
159
|
// Get overview paragraph
|
|
160
160
|
const overview = docSection.querySelector(".go-Message");
|
|
161
161
|
if (overview) {
|
|
162
|
-
const overviewMd = htmlToBasicMarkdown(overview.innerHTML);
|
|
162
|
+
const overviewMd = await htmlToBasicMarkdown(overview.innerHTML);
|
|
163
163
|
sections.push(overviewMd);
|
|
164
164
|
sections.push("");
|
|
165
165
|
}
|
|
@@ -172,7 +172,7 @@ export const handleGoPkg: SpecialHandler = async (
|
|
|
172
172
|
const docParts: string[] = [];
|
|
173
173
|
for (let i = 0; i < Math.min(3, paragraphs.length); i++) {
|
|
174
174
|
const p = paragraphs[i];
|
|
175
|
-
const text = htmlToBasicMarkdown(p.innerHTML).trim();
|
|
175
|
+
const text = (await htmlToBasicMarkdown(p.innerHTML)).trim();
|
|
176
176
|
if (text) {
|
|
177
177
|
docParts.push(text);
|
|
178
178
|
}
|
|
@@ -108,7 +108,7 @@ export const handleJetBrainsMarketplace: SpecialHandler = async (
|
|
|
108
108
|
|
|
109
109
|
const vendorName = plugin.vendor?.name ?? plugin.vendor?.publicName;
|
|
110
110
|
const descriptionSource = plugin.description ?? plugin.preview ?? "";
|
|
111
|
-
const description = descriptionSource ? htmlToBasicMarkdown(descriptionSource) : "";
|
|
111
|
+
const description = descriptionSource ? await htmlToBasicMarkdown(descriptionSource) : "";
|
|
112
112
|
const tags = (plugin.tags ?? []).map(tag => tag.name).filter(Boolean) as string[];
|
|
113
113
|
const rating = extractRating(plugin);
|
|
114
114
|
const buildCompatibility = update ? formatBuildCompatibility(update) : null;
|
|
@@ -89,11 +89,11 @@ function formatDate(isoDate: string): string {
|
|
|
89
89
|
/**
|
|
90
90
|
* Format a status/post as markdown
|
|
91
91
|
*/
|
|
92
|
-
function formatStatus(status: MastodonStatus, isReblog = false): string {
|
|
92
|
+
async function formatStatus(status: MastodonStatus, isReblog = false): Promise<string> {
|
|
93
93
|
// Handle reblogs (boosts)
|
|
94
94
|
if (status.reblog && !isReblog) {
|
|
95
95
|
let md = `🔁 **${status.account.display_name || status.account.username}** boosted:\n\n`;
|
|
96
|
-
md += formatStatus(status.reblog, true);
|
|
96
|
+
md += await formatStatus(status.reblog, true);
|
|
97
97
|
return md;
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -116,7 +116,7 @@ function formatStatus(status: MastodonStatus, isReblog = false): string {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
// Main content (convert HTML to markdown)
|
|
119
|
-
const content = htmlToBasicMarkdown(status.content);
|
|
119
|
+
const content = await htmlToBasicMarkdown(status.content);
|
|
120
120
|
md += `${content}\n\n`;
|
|
121
121
|
|
|
122
122
|
// Poll
|
|
@@ -152,7 +152,7 @@ function formatStatus(status: MastodonStatus, isReblog = false): string {
|
|
|
152
152
|
/**
|
|
153
153
|
* Format an account/profile as markdown
|
|
154
154
|
*/
|
|
155
|
-
function formatAccount(account: MastodonAccount): string {
|
|
155
|
+
async function formatAccount(account: MastodonAccount): Promise<string> {
|
|
156
156
|
let md = `# ${account.display_name || account.username}\n\n`;
|
|
157
157
|
|
|
158
158
|
md += `**@${account.acct}**`;
|
|
@@ -161,7 +161,7 @@ function formatAccount(account: MastodonAccount): string {
|
|
|
161
161
|
|
|
162
162
|
// Bio
|
|
163
163
|
if (account.note) {
|
|
164
|
-
const bio = htmlToBasicMarkdown(account.note);
|
|
164
|
+
const bio = await htmlToBasicMarkdown(account.note);
|
|
165
165
|
if (bio && bio !== account.display_name) {
|
|
166
166
|
md += `${bio}\n\n`;
|
|
167
167
|
}
|
|
@@ -179,7 +179,7 @@ function formatAccount(account: MastodonAccount): string {
|
|
|
179
179
|
if (account.fields && account.fields.length > 0) {
|
|
180
180
|
md += "\n**Profile Fields:**\n";
|
|
181
181
|
for (const field of account.fields) {
|
|
182
|
-
const value = htmlToBasicMarkdown(field.value);
|
|
182
|
+
const value = await htmlToBasicMarkdown(field.value);
|
|
183
183
|
md += `- **${field.name}:** ${value}\n`;
|
|
184
184
|
}
|
|
185
185
|
}
|
|
@@ -228,7 +228,7 @@ export const handleMastodon: SpecialHandler = async (
|
|
|
228
228
|
const status = tryParseJson<MastodonStatus>(result.content);
|
|
229
229
|
if (!status) return null;
|
|
230
230
|
|
|
231
|
-
const md = formatStatus(status);
|
|
231
|
+
const md = await formatStatus(status);
|
|
232
232
|
|
|
233
233
|
return buildResult(md, {
|
|
234
234
|
url,
|
|
@@ -263,7 +263,7 @@ export const handleMastodon: SpecialHandler = async (
|
|
|
263
263
|
signal,
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
let md = formatAccount(account);
|
|
266
|
+
let md = await formatAccount(account);
|
|
267
267
|
|
|
268
268
|
if (statusesResult.ok) {
|
|
269
269
|
const statuses = tryParseJson<MastodonStatus[]>(statusesResult.content);
|
|
@@ -271,7 +271,7 @@ export const handleMastodon: SpecialHandler = async (
|
|
|
271
271
|
md += "\n---\n\n## Recent Posts\n\n";
|
|
272
272
|
for (const status of statuses.slice(0, 5)) {
|
|
273
273
|
md += `### ${formatDate(status.created_at)}\n\n`;
|
|
274
|
-
const content = htmlToBasicMarkdown(status.content);
|
|
274
|
+
const content = await htmlToBasicMarkdown(status.content);
|
|
275
275
|
md += `${content}\n\n`;
|
|
276
276
|
md += `\uD83D\uDCAC ${status.replies_count} \u00B7 \uD83D\uDD01 ${status.reblogs_count} \u00B7 \u2B50 ${status.favourites_count}\n\n`;
|
|
277
277
|
}
|
package/src/web/scrapers/mdn.ts
CHANGED
|
@@ -29,7 +29,7 @@ interface MDNDoc {
|
|
|
29
29
|
/**
|
|
30
30
|
* Convert MDN body sections to markdown
|
|
31
31
|
*/
|
|
32
|
-
function convertMDNBody(sections: MDNSection[]): string {
|
|
32
|
+
async function convertMDNBody(sections: MDNSection[]): Promise<string> {
|
|
33
33
|
const parts: string[] = [];
|
|
34
34
|
|
|
35
35
|
for (const section of sections) {
|
|
@@ -38,7 +38,7 @@ function convertMDNBody(sections: MDNSection[]): string {
|
|
|
38
38
|
switch (type) {
|
|
39
39
|
case "prose":
|
|
40
40
|
if (value.content) {
|
|
41
|
-
const markdown = htmlToBasicMarkdown(value.content);
|
|
41
|
+
const markdown = await htmlToBasicMarkdown(value.content);
|
|
42
42
|
if (value.title) {
|
|
43
43
|
const level = value.isH3 ? "###" : "##";
|
|
44
44
|
parts.push(`${level} ${value.title}\n\n${markdown}`);
|
|
@@ -74,7 +74,7 @@ function convertMDNBody(sections: MDNSection[]): string {
|
|
|
74
74
|
if (value.items) {
|
|
75
75
|
for (const item of value.items) {
|
|
76
76
|
parts.push(`**${item.term}**`);
|
|
77
|
-
const desc = htmlToBasicMarkdown(item.description);
|
|
77
|
+
const desc = await htmlToBasicMarkdown(item.description);
|
|
78
78
|
parts.push(desc);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -83,9 +83,13 @@ function convertMDNBody(sections: MDNSection[]): string {
|
|
|
83
83
|
case "table":
|
|
84
84
|
if (value.rows && value.rows.length > 0) {
|
|
85
85
|
// Simple markdown table
|
|
86
|
-
const header = value.rows[0].map(cell => htmlToBasicMarkdown(cell)).join(" | ");
|
|
86
|
+
const header = (await Promise.all(value.rows[0].map(cell => htmlToBasicMarkdown(cell)))).join(" | ");
|
|
87
87
|
const separator = value.rows[0].map(() => "---").join(" | ");
|
|
88
|
-
const bodyRows =
|
|
88
|
+
const bodyRows = await Promise.all(
|
|
89
|
+
value.rows
|
|
90
|
+
.slice(1)
|
|
91
|
+
.map(async row => (await Promise.all(row.map(cell => htmlToBasicMarkdown(cell)))).join(" | ")),
|
|
92
|
+
);
|
|
89
93
|
|
|
90
94
|
parts.push(`| ${header} |`);
|
|
91
95
|
parts.push(`| ${separator} |`);
|
|
@@ -144,12 +148,12 @@ export const handleMDN: SpecialHandler = async (url: string, timeout: number, si
|
|
|
144
148
|
parts.push(`# ${doc.title}`);
|
|
145
149
|
|
|
146
150
|
if (doc.summary) {
|
|
147
|
-
const summary = htmlToBasicMarkdown(doc.summary);
|
|
151
|
+
const summary = await htmlToBasicMarkdown(doc.summary);
|
|
148
152
|
parts.push(summary);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
if (doc.body && doc.body.length > 0) {
|
|
152
|
-
const bodyMarkdown = convertMDNBody(doc.body);
|
|
156
|
+
const bodyMarkdown = await convertMDNBody(doc.body);
|
|
153
157
|
parts.push(bodyMarkdown);
|
|
154
158
|
}
|
|
155
159
|
|
|
@@ -125,7 +125,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number,
|
|
|
125
125
|
/<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
|
126
126
|
);
|
|
127
127
|
if (readmeMatch) {
|
|
128
|
-
const readme = htmlToBasicMarkdown(readmeMatch[1]);
|
|
128
|
+
const readme = await htmlToBasicMarkdown(readmeMatch[1]);
|
|
129
129
|
|
|
130
130
|
if (readme.length > 100) {
|
|
131
131
|
md += `## README\n\n${readme}\n`;
|
package/src/web/scrapers/rawg.ts
CHANGED
|
@@ -63,7 +63,7 @@ export const handleRawg: SpecialHandler = async (
|
|
|
63
63
|
md += `**RAWG:** https://rawg.io/games/${encodeURIComponent(slug)}\n`;
|
|
64
64
|
md += "\n";
|
|
65
65
|
|
|
66
|
-
const description = extractDescription(game);
|
|
66
|
+
const description = await extractDescription(game);
|
|
67
67
|
if (description) {
|
|
68
68
|
md += `## Description\n\n${description}\n`;
|
|
69
69
|
}
|
|
@@ -91,11 +91,11 @@ function requiresApiKey(game: RawgGameResponse): boolean {
|
|
|
91
91
|
return detail.includes("api key") || detail.includes("key is required") || detail.includes("apikey");
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function extractDescription(game: RawgGameResponse): string | null {
|
|
94
|
+
async function extractDescription(game: RawgGameResponse): Promise<string | null> {
|
|
95
95
|
if (game.description_raw) return game.description_raw.trim();
|
|
96
96
|
if (!game.description) return null;
|
|
97
97
|
|
|
98
|
-
const markdown = htmlToBasicMarkdown(game.description).trim();
|
|
98
|
+
const markdown = (await htmlToBasicMarkdown(game.description)).trim();
|
|
99
99
|
return markdown || null;
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -101,7 +101,7 @@ export const handleReadTheDocs: SpecialHandler = async (
|
|
|
101
101
|
// If no raw source, convert HTML to markdown
|
|
102
102
|
if (!content && mainContent) {
|
|
103
103
|
const html = mainContent.innerHTML;
|
|
104
|
-
content = htmlToBasicMarkdown(html);
|
|
104
|
+
content = await htmlToBasicMarkdown(html);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (!content) {
|
package/src/web/scrapers/spdx.ts
CHANGED
|
@@ -94,7 +94,7 @@ export const handleSpdx: SpecialHandler = async (
|
|
|
94
94
|
const licenseText = license.licenseText
|
|
95
95
|
? license.licenseText
|
|
96
96
|
: license.licenseTextHtml
|
|
97
|
-
? htmlToBasicMarkdown(license.licenseTextHtml)
|
|
97
|
+
? await htmlToBasicMarkdown(license.licenseTextHtml)
|
|
98
98
|
: null;
|
|
99
99
|
|
|
100
100
|
if (licenseText) {
|
|
@@ -90,7 +90,7 @@ export const handleStackOverflow: SpecialHandler = async (
|
|
|
90
90
|
md += question.is_answered ? " (Answered)" : "";
|
|
91
91
|
md += `\n**Tags:** ${question.tags.join(", ")}\n`;
|
|
92
92
|
md += `**Asked by:** ${question.owner.display_name} · ${formatIsoDate(question.creation_date * 1000)}\n\n`;
|
|
93
|
-
md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
|
|
93
|
+
md += `---\n\n## Question\n\n${await htmlToBasicMarkdown(question.body)}\n\n`;
|
|
94
94
|
|
|
95
95
|
// Fetch answers
|
|
96
96
|
const aUrl = `https://api.stackexchange.com/2.3/questions/${questionId}/answers?order=desc&sort=votes&site=${site}&filter=withbody`;
|
|
@@ -103,7 +103,7 @@ export const handleStackOverflow: SpecialHandler = async (
|
|
|
103
103
|
for (const answer of aData.items.slice(0, 5)) {
|
|
104
104
|
const accepted = answer.is_accepted ? " (Accepted)" : "";
|
|
105
105
|
md += `### Score: ${answer.score}${accepted} · by ${answer.owner.display_name}\n\n`;
|
|
106
|
-
md += `${htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
|
|
106
|
+
md += `${await htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Shared types and utilities for web-fetch handlers
|
|
3
3
|
*/
|
|
4
4
|
import { ptree } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import TurndownService from "turndown";
|
|
6
|
-
|
|
5
|
+
import type TurndownService from "turndown";
|
|
6
|
+
|
|
7
7
|
import { ToolAbortError } from "../../tools/tool-errors";
|
|
8
8
|
|
|
9
9
|
export { formatNumber } from "@oh-my-pi/pi-utils";
|
|
@@ -155,28 +155,8 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
|
|
|
155
155
|
return { content: "", contentType: "", finalUrl: url, ok: false };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
/** Module-level Turndown instance —
|
|
159
|
-
|
|
160
|
-
headingStyle: "atx",
|
|
161
|
-
codeBlockStyle: "fenced",
|
|
162
|
-
bulletListMarker: "-",
|
|
163
|
-
});
|
|
164
|
-
turndown.use(gfm);
|
|
165
|
-
turndown.addRule("strikethrough", {
|
|
166
|
-
filter: ["del", "s", "strike"],
|
|
167
|
-
replacement(content) {
|
|
168
|
-
return `~~${content}~~`;
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
turndown.addRule("heading", {
|
|
172
|
-
filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
|
173
|
-
replacement(content, node) {
|
|
174
|
-
const level = Number(node.nodeName.charAt(1));
|
|
175
|
-
const prefix = "#".repeat(level);
|
|
176
|
-
const cleaned = content.replace(/\\([.])/g, "$1").trim();
|
|
177
|
-
return `\n\n${prefix} ${cleaned}\n\n`;
|
|
178
|
-
},
|
|
179
|
-
});
|
|
158
|
+
/** Module-level Turndown instance — built lazily on first use. */
|
|
159
|
+
let turndownPromise: Promise<TurndownService> | undefined;
|
|
180
160
|
|
|
181
161
|
type TurndownListParent = {
|
|
182
162
|
nodeName: string;
|
|
@@ -184,27 +164,61 @@ type TurndownListParent = {
|
|
|
184
164
|
children: ArrayLike<unknown>;
|
|
185
165
|
};
|
|
186
166
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
167
|
+
function getTurndown(): Promise<TurndownService> {
|
|
168
|
+
turndownPromise ||= initTurndown();
|
|
169
|
+
return turndownPromise;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function initTurndown(): Promise<TurndownService> {
|
|
173
|
+
const [{ default: TurndownService }, { gfm }] = await Promise.all([
|
|
174
|
+
import("turndown"),
|
|
175
|
+
import("turndown-plugin-gfm"),
|
|
176
|
+
]);
|
|
177
|
+
const turndown = new TurndownService({
|
|
178
|
+
headingStyle: "atx",
|
|
179
|
+
codeBlockStyle: "fenced",
|
|
180
|
+
bulletListMarker: "-",
|
|
181
|
+
});
|
|
182
|
+
turndown.use(gfm);
|
|
183
|
+
turndown.addRule("strikethrough", {
|
|
184
|
+
filter: ["del", "s", "strike"],
|
|
185
|
+
replacement(content) {
|
|
186
|
+
return `~~${content}~~`;
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
turndown.addRule("heading", {
|
|
190
|
+
filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
|
|
191
|
+
replacement(content, node) {
|
|
192
|
+
const level = Number(node.nodeName.charAt(1));
|
|
193
|
+
const prefix = "#".repeat(level);
|
|
194
|
+
const cleaned = content.replace(/\\([.])/g, "$1").trim();
|
|
195
|
+
return `\n\n${prefix} ${cleaned}\n\n`;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
turndown.addRule("listItem", {
|
|
199
|
+
filter: "li",
|
|
200
|
+
replacement(content, node, options) {
|
|
201
|
+
content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
|
|
202
|
+
const parent = node.parentNode as unknown as TurndownListParent | null;
|
|
203
|
+
let prefix = `${options.bulletListMarker} `;
|
|
204
|
+
if (parent?.nodeName === "OL") {
|
|
205
|
+
const start = parent.getAttribute("start");
|
|
206
|
+
const index = Array.prototype.indexOf.call(parent.children, node);
|
|
207
|
+
prefix = `${(start ? Number(start) : 1) + index}. `;
|
|
208
|
+
}
|
|
209
|
+
return prefix + content + (node.nextSibling ? "\n" : "");
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
return turndown;
|
|
213
|
+
}
|
|
201
214
|
|
|
202
215
|
/**
|
|
203
216
|
* Convert HTML to markdown using Turndown with GFM support.
|
|
204
217
|
* Strips script/style tags before conversion.
|
|
205
218
|
*/
|
|
206
|
-
export function htmlToBasicMarkdown(html: string): string {
|
|
219
|
+
export async function htmlToBasicMarkdown(html: string): Promise<string> {
|
|
207
220
|
const cleaned = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
221
|
+
const turndown = await getTurndown();
|
|
208
222
|
return turndown.turndown(cleaned).trim();
|
|
209
223
|
}
|
|
210
224
|
|
package/src/web/scrapers/w3c.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const handleW3c: SpecialHandler = async (
|
|
|
100
100
|
const title = getString(specPayload, "title");
|
|
101
101
|
const shortnameValue = getString(specPayload, "shortname") ?? shortname;
|
|
102
102
|
const description = getString(specPayload, "description") ?? getString(specPayload, "abstract");
|
|
103
|
-
const abstract = description ? htmlToBasicMarkdown(description) : undefined;
|
|
103
|
+
const abstract = description ? await htmlToBasicMarkdown(description) : undefined;
|
|
104
104
|
|
|
105
105
|
const latestVersionUrl =
|
|
106
106
|
getString(latestPayload, "uri") ??
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
|
10
10
|
extractRetryDelay,
|
|
11
|
-
|
|
11
|
+
getAntigravityUserAgent,
|
|
12
12
|
getGeminiCliHeaders,
|
|
13
13
|
} from "@oh-my-pi/pi-ai";
|
|
14
14
|
import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity";
|
|
@@ -248,7 +248,7 @@ async function callGeminiSearch(
|
|
|
248
248
|
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
249
249
|
}> {
|
|
250
250
|
const endpoints = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
|
|
251
|
-
const headers = auth.isAntigravity ?
|
|
251
|
+
const headers = auth.isAntigravity ? { "User-Agent": getAntigravityUserAgent() } : getGeminiCliHeaders();
|
|
252
252
|
|
|
253
253
|
const requestMetadata = auth.isAntigravity
|
|
254
254
|
? {
|