@shanepadgett/tau-agent 0.1.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 (138) hide show
  1. package/README.md +30 -0
  2. package/docs/extending-tau-agent.md +119 -0
  3. package/docs/subagents.md +57 -0
  4. package/docs/tui.md +107 -0
  5. package/extensions/appshot/README.md +11 -0
  6. package/extensions/appshot/capture.swift +191 -0
  7. package/extensions/appshot/index.ts +245 -0
  8. package/extensions/appshot/native-helper.ts +102 -0
  9. package/extensions/attention/README.md +23 -0
  10. package/extensions/attention/index.ts +71 -0
  11. package/extensions/auto-name/README.md +26 -0
  12. package/extensions/auto-name/index.ts +163 -0
  13. package/extensions/branch/README.md +22 -0
  14. package/extensions/branch/index.ts +145 -0
  15. package/extensions/branch/panel.ts +148 -0
  16. package/extensions/clear-screen/README.md +29 -0
  17. package/extensions/clear-screen/index.ts +75 -0
  18. package/extensions/commit/README.md +15 -0
  19. package/extensions/commit/commit-plan.ts +328 -0
  20. package/extensions/commit/git-change-set.ts +268 -0
  21. package/extensions/commit/index.ts +138 -0
  22. package/extensions/commit/review-ui.ts +745 -0
  23. package/extensions/explore/README.md +7 -0
  24. package/extensions/explore/autoread.ts +140 -0
  25. package/extensions/explore/find.ts +147 -0
  26. package/extensions/explore/grep.ts +749 -0
  27. package/extensions/explore/index.ts +17 -0
  28. package/extensions/explore/limits.ts +22 -0
  29. package/extensions/explore/ls.ts +113 -0
  30. package/extensions/explore/path-display.ts +41 -0
  31. package/extensions/explore/path-tree.ts +150 -0
  32. package/extensions/explore/read.ts +160 -0
  33. package/extensions/explore/result.ts +29 -0
  34. package/extensions/explore/traverse.ts +226 -0
  35. package/extensions/footer/README.md +23 -0
  36. package/extensions/footer/index.ts +534 -0
  37. package/extensions/footer/settings.ts +13 -0
  38. package/extensions/ideas/README.md +10 -0
  39. package/extensions/ideas/browser.ts +66 -0
  40. package/extensions/ideas/index.ts +31 -0
  41. package/extensions/ideas/store.ts +32 -0
  42. package/extensions/image-gen/README.md +11 -0
  43. package/extensions/image-gen/client.ts +186 -0
  44. package/extensions/image-gen/index.ts +152 -0
  45. package/extensions/manage-sessions/README.md +12 -0
  46. package/extensions/manage-sessions/index.ts +58 -0
  47. package/extensions/manage-sessions/manager-ui.ts +323 -0
  48. package/extensions/manage-sessions/sessions.ts +126 -0
  49. package/extensions/patch/README.md +48 -0
  50. package/extensions/patch/executor.ts +355 -0
  51. package/extensions/patch/index.ts +170 -0
  52. package/extensions/patch/matcher.ts +236 -0
  53. package/extensions/patch/parser.ts +347 -0
  54. package/extensions/patch/render.ts +247 -0
  55. package/extensions/patch/summary.ts +35 -0
  56. package/extensions/publish/README.md +11 -0
  57. package/extensions/publish/index.ts +225 -0
  58. package/extensions/qna/README.md +45 -0
  59. package/extensions/qna/additional-context-body.ts +51 -0
  60. package/extensions/qna/body-render.ts +6 -0
  61. package/extensions/qna/choice-question-body.ts +322 -0
  62. package/extensions/qna/index.ts +245 -0
  63. package/extensions/qna/inline-editor-row.ts +56 -0
  64. package/extensions/qna/input-question-body.ts +89 -0
  65. package/extensions/qna/model.ts +317 -0
  66. package/extensions/qna/panel.ts +240 -0
  67. package/extensions/qna/ui.ts +16 -0
  68. package/extensions/reference/README.md +47 -0
  69. package/extensions/reference/index.ts +76 -0
  70. package/extensions/reference/panel.ts +874 -0
  71. package/extensions/reference/settings.ts +29 -0
  72. package/extensions/run-summary/README.md +5 -0
  73. package/extensions/run-summary/index.ts +98 -0
  74. package/extensions/silent-command-runner/README.md +25 -0
  75. package/extensions/silent-command-runner/index.ts +468 -0
  76. package/extensions/silent-command-runner/settings.ts +65 -0
  77. package/extensions/soul/README.md +19 -0
  78. package/extensions/soul/index.ts +20 -0
  79. package/extensions/soul/prompt.ts +254 -0
  80. package/extensions/soul/settings.ts +13 -0
  81. package/extensions/stash/README.md +48 -0
  82. package/extensions/stash/browser.ts +51 -0
  83. package/extensions/stash/index.ts +55 -0
  84. package/extensions/stash/store.ts +36 -0
  85. package/extensions/subagent/README.md +34 -0
  86. package/extensions/subagent/agents/scout.md +85 -0
  87. package/extensions/subagent/agents/web-research.md +95 -0
  88. package/extensions/subagent/agents.ts +196 -0
  89. package/extensions/subagent/index.ts +214 -0
  90. package/extensions/subagent/render.ts +89 -0
  91. package/extensions/subagent/run.ts +325 -0
  92. package/extensions/tau/README.md +17 -0
  93. package/extensions/tau/index.ts +122 -0
  94. package/extensions/tau-help/README.md +3 -0
  95. package/extensions/tau-help/help.md +125 -0
  96. package/extensions/tau-help/index.ts +68 -0
  97. package/extensions/turn-budget/README.md +14 -0
  98. package/extensions/turn-budget/index.ts +150 -0
  99. package/extensions/turn-budget/settings.ts +35 -0
  100. package/extensions/web/README.md +13 -0
  101. package/extensions/web/codesearch.ts +79 -0
  102. package/extensions/web/exa.ts +101 -0
  103. package/extensions/web/html.ts +67 -0
  104. package/extensions/web/index.ts +13 -0
  105. package/extensions/web/limits.ts +11 -0
  106. package/extensions/web/tool-output.ts +54 -0
  107. package/extensions/web/webfetch.ts +177 -0
  108. package/extensions/web/websearch.ts +100 -0
  109. package/package.json +57 -0
  110. package/prompts/.gitkeep +1 -0
  111. package/prompts/cavemanify.md +25 -0
  112. package/prompts/implement.md +25 -0
  113. package/prompts/interview.md +48 -0
  114. package/prompts/plan-feature.md +30 -0
  115. package/prompts/plan-implementation.md +50 -0
  116. package/schemas/tau.schema.json +205 -0
  117. package/shared/agent-blocked.ts +12 -0
  118. package/shared/description.ts +28 -0
  119. package/shared/events.ts +135 -0
  120. package/shared/git.ts +41 -0
  121. package/shared/injected-context.ts +69 -0
  122. package/shared/jsonl-store.ts +108 -0
  123. package/shared/model-fallback/index.ts +278 -0
  124. package/shared/model-fallback/settings.ts +18 -0
  125. package/shared/model-fallback/types.ts +8 -0
  126. package/shared/ranges.ts +21 -0
  127. package/shared/settings/define.ts +15 -0
  128. package/shared/settings/files.ts +20 -0
  129. package/shared/settings/json.ts +41 -0
  130. package/shared/settings/load.ts +59 -0
  131. package/shared/settings/merge.ts +21 -0
  132. package/shared/settings/paths.ts +41 -0
  133. package/shared/settings/schema.ts +25 -0
  134. package/shared/settings/specs.ts +23 -0
  135. package/shared/text.ts +11 -0
  136. package/shared/tool-row-state.ts +43 -0
  137. package/skills/writing-preferences/SKILL.md +136 -0
  138. package/themes/.gitkeep +1 -0
@@ -0,0 +1,150 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
3
+ import { Marker } from "@shanepadgett/tau-tui";
4
+ import turnBudgetSettings from "./settings.ts";
5
+
6
+ const MARKER_TYPE = "tau.turn-budget.marker";
7
+
8
+ interface Settings {
9
+ enabled: boolean;
10
+ turnLimit: number;
11
+ nudgeEveryTurns: number;
12
+ softCapIncrement: number;
13
+ }
14
+
15
+ type Hint =
16
+ | { kind: "normal"; used: number; cap: number }
17
+ | { kind: "extended"; used: number; previousCap: number; newCap: number };
18
+
19
+ interface MarkerDetails {
20
+ used: number;
21
+ cap: number;
22
+ extended: boolean;
23
+ sequence: number;
24
+ }
25
+
26
+ export default function turnBudgetExtension(pi: ExtensionAPI): void {
27
+ let settings = normalizeSettings(turnBudgetSettings.defaults);
28
+ let turnCount = 0;
29
+ let softCap = settings.turnLimit;
30
+ let nextMarkerSequence = 1;
31
+ let activeMarkerSequence: number | undefined;
32
+
33
+ pi.registerMessageRenderer<MarkerDetails>(MARKER_TYPE, (message, _options, theme) => {
34
+ const details = readMarkerDetails(message.details);
35
+ if (!details) return undefined;
36
+ return new Marker({
37
+ theme,
38
+ state: "muted",
39
+ label: "Turn Budget:",
40
+ parts: [`${details.used}/${details.cap}`, ...(details.extended ? ["Soft cap extended."] : [])],
41
+ });
42
+ });
43
+
44
+ pi.on("before_agent_start", (event) => {
45
+ if (!settings.enabled) return undefined;
46
+ return {
47
+ systemPrompt: `${event.systemPrompt}\n\nTurn-budget steering messages are internal instructions. Work within them silently. Do not mention or acknowledge turn counts, budget messages, or budget summaries.`,
48
+ };
49
+ });
50
+
51
+ pi.on("session_start", async (_event, ctx) => {
52
+ settings = normalizeSettings(await loadTauExtensionSettings(ctx, turnBudgetSettings));
53
+ });
54
+
55
+ pi.on("agent_start", () => {
56
+ turnCount = 0;
57
+ softCap = settings.turnLimit;
58
+ activeMarkerSequence = undefined;
59
+ });
60
+
61
+ pi.on("turn_end", (event) => {
62
+ if (!settings.enabled) return undefined;
63
+ if (event.toolResults.length === 0) return undefined;
64
+ turnCount += 1;
65
+ if (turnCount >= softCap) {
66
+ const hint: Hint = {
67
+ kind: "extended",
68
+ used: turnCount,
69
+ previousCap: softCap,
70
+ newCap: turnCount + settings.softCapIncrement,
71
+ };
72
+ activeMarkerSequence = nextMarkerSequence;
73
+ sendTurnBudgetMessage(pi, hint, nextMarkerSequence);
74
+ nextMarkerSequence += 1;
75
+ softCap = hint.newCap;
76
+ return undefined;
77
+ }
78
+ if (turnCount % settings.nudgeEveryTurns === 0) {
79
+ const hint: Hint = { kind: "normal", used: turnCount, cap: softCap };
80
+ activeMarkerSequence = nextMarkerSequence;
81
+ sendTurnBudgetMessage(pi, hint, nextMarkerSequence);
82
+ nextMarkerSequence += 1;
83
+ }
84
+ return undefined;
85
+ });
86
+
87
+ pi.on("context", (event) => {
88
+ const messages = event.messages.filter(
89
+ (message) =>
90
+ message.role !== "custom" || message.customType !== MARKER_TYPE || markerIsActive(message.details),
91
+ );
92
+ if (messages.length === event.messages.length) return undefined;
93
+ return { messages };
94
+ });
95
+
96
+ pi.on("after_provider_response", () => {
97
+ activeMarkerSequence = undefined;
98
+ });
99
+
100
+ function markerIsActive(value: unknown): boolean {
101
+ if (activeMarkerSequence === undefined) return false;
102
+ const details = readMarkerDetails(value);
103
+ return details?.sequence === activeMarkerSequence;
104
+ }
105
+ }
106
+
107
+ function sendTurnBudgetMessage(pi: ExtensionAPI, hint: Hint, sequence: number): void {
108
+ pi.sendMessage<MarkerDetails>({
109
+ customType: MARKER_TYPE,
110
+ content: formatSteeringMessage(hint),
111
+ display: true,
112
+ details: markerDetails(hint, sequence),
113
+ });
114
+ }
115
+
116
+ function formatSteeringMessage(hint: Hint): string {
117
+ if (hint.kind === "extended") {
118
+ return `Turn budget: ${hint.used}/${hint.previousCap} turns used for this user prompt. Soft cap extended to ${hint.newCap}. Batch tools when more tool work remains.`;
119
+ }
120
+ return `Turn budget: ${hint.used}/${hint.cap} turns used for this user prompt. Batch tools when more tool work remains.`;
121
+ }
122
+
123
+ function normalizeSettings(value: typeof turnBudgetSettings.defaults): Settings {
124
+ return {
125
+ enabled: value.enabled ?? true,
126
+ turnLimit: Number.isInteger(value.turnLimit) && value.turnLimit > 0 ? value.turnLimit : 30,
127
+ nudgeEveryTurns: Number.isInteger(value.nudgeEveryTurns) && value.nudgeEveryTurns > 0 ? value.nudgeEveryTurns : 5,
128
+ softCapIncrement:
129
+ Number.isInteger(value.softCapIncrement) && value.softCapIncrement > 0 ? value.softCapIncrement : 10,
130
+ };
131
+ }
132
+
133
+ function markerDetails(hint: Hint, sequence: number): MarkerDetails {
134
+ return hint.kind === "extended"
135
+ ? { used: hint.used, cap: hint.newCap, extended: true, sequence }
136
+ : { used: hint.used, cap: hint.cap, extended: false, sequence };
137
+ }
138
+
139
+ function readMarkerDetails(value: unknown): MarkerDetails | undefined {
140
+ if (!value || typeof value !== "object") return undefined;
141
+ const record = value as Record<string, unknown>;
142
+ const used = record.used;
143
+ const cap = record.cap;
144
+ const sequence = record.sequence;
145
+ if (typeof used !== "number" || !Number.isInteger(used) || used < 0) return undefined;
146
+ if (typeof cap !== "number" || !Number.isInteger(cap) || cap < 1) return undefined;
147
+ if (typeof sequence !== "number" || !Number.isInteger(sequence) || sequence < 1) return undefined;
148
+ if (typeof record.extended !== "boolean") return undefined;
149
+ return { used, cap, extended: record.extended, sequence };
150
+ }
@@ -0,0 +1,35 @@
1
+ import { Type } from "typebox";
2
+ import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
3
+
4
+ export default defineTauExtensionSettings({
5
+ key: "turnBudget",
6
+ defaults: {
7
+ enabled: true as boolean,
8
+ turnLimit: 30 as number,
9
+ nudgeEveryTurns: 5 as number,
10
+ softCapIncrement: 10 as number,
11
+ },
12
+ schema: Type.Object(
13
+ {
14
+ enabled: Type.Optional(Type.Boolean({ default: true, description: "Enable turn-budget hints." })),
15
+ turnLimit: Type.Optional(
16
+ Type.Integer({
17
+ default: 30,
18
+ minimum: 1,
19
+ description: "Initial soft cap for tool-using turns per user prompt.",
20
+ }),
21
+ ),
22
+ nudgeEveryTurns: Type.Optional(
23
+ Type.Integer({
24
+ default: 5,
25
+ minimum: 1,
26
+ description: "Tool-using turn interval between turn-budget hints.",
27
+ }),
28
+ ),
29
+ softCapIncrement: Type.Optional(
30
+ Type.Integer({ default: 10, minimum: 1, description: "Turns added when the soft cap is reached." }),
31
+ ),
32
+ },
33
+ { additionalProperties: false },
34
+ ),
35
+ });
@@ -0,0 +1,13 @@
1
+ # Web
2
+
3
+ The web extension adds three low-level tools:
4
+
5
+ - `webfetch` retrieves a known HTTP(S) URL as Markdown, text, raw HTML, or an inline image.
6
+ - `websearch` discovers public pages and current external information through Exa.
7
+ - `codesearch` retrieves code examples and implementation-focused documentation context through Exa.
8
+
9
+ Keeping these jobs separate lets agents fetch a known source without searching, or search for the kind of context the task needs. Agents invoke each tool directly and can combine them in a separate research workflow.
10
+
11
+ `websearch` and `codesearch` can use an optional `EXA_API_KEY`. The key does not apply to `webfetch`.
12
+
13
+ The extension permits outbound HTTP(S) requests. It has no domain allowlist.
@@ -0,0 +1,79 @@
1
+ import { defineTool, type TruncationResult } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { type Static, Type } from "typebox";
4
+ import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
5
+ import { callExa } from "./exa.ts";
6
+ import { clampInteger, normalizeTimeout } from "./limits.ts";
7
+ import { renderWebToolResult, truncateCallSummary, truncateToolOutput } from "./tool-output.ts";
8
+
9
+ const codeSearchParams = Type.Object(
10
+ {
11
+ query: Type.String({ description: "Code or documentation search query" }),
12
+ tokensNum: Type.Optional(Type.Number({ description: "Target context token budget (default: 5000)" })),
13
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 25, max: 600)" })),
14
+ },
15
+ { additionalProperties: false },
16
+ );
17
+
18
+ type CodeSearchParams = Static<typeof codeSearchParams>;
19
+ interface CodeSearchDetails {
20
+ query: string;
21
+ tokensNum: number;
22
+ truncation?: TruncationResult;
23
+ }
24
+
25
+ export function createCodeSearchTool(rowState: ToolRowStateStore) {
26
+ return defineTool<typeof codeSearchParams, CodeSearchDetails | undefined>({
27
+ name: "codesearch",
28
+ label: "Code Search",
29
+ description:
30
+ "Search Exa for implementation-oriented code and documentation context. Output is truncated to 2,000 lines or 50 KB.",
31
+ promptSnippet: "Search code and documentation context for implementation details",
32
+ promptGuidelines: [
33
+ "Use codesearch for API usage, code examples, and implementation-oriented documentation.",
34
+ "Use websearch for broad discovery and webfetch for a known URL.",
35
+ "Use a separate research workflow instead of codesearch when several searches, fetches, and synthesis are needed.",
36
+ ],
37
+ parameters: codeSearchParams,
38
+ async execute(_toolCallId, params, signal, onUpdate) {
39
+ const timeout = normalizeTimeout(params.timeout, 25);
40
+ const tokensNum = clampInteger(params.tokensNum, 5000, 500, 20_000);
41
+ await onUpdate?.({ content: [{ type: "text", text: "Searching code context..." }], details: undefined });
42
+ try {
43
+ const output =
44
+ (await callExa(
45
+ { toolName: "get_code_context_exa", arguments: { query: params.query, tokensNum } },
46
+ signal,
47
+ timeout,
48
+ )) ?? "No code context found. Try a more specific query with library/language names.";
49
+ const truncated = truncateToolOutput(output);
50
+ return {
51
+ content: [{ type: "text", text: truncated.text }],
52
+ details: {
53
+ query: params.query,
54
+ tokensNum,
55
+ ...(truncated.truncation ? { truncation: truncated.truncation } : {}),
56
+ },
57
+ };
58
+ } catch (error) {
59
+ if (error instanceof Error && error.name === "TimeoutError") {
60
+ throw new Error(`Code search timed out after ${timeout}s`);
61
+ }
62
+ throw error;
63
+ }
64
+ },
65
+ renderCall(args: CodeSearchParams, theme, context) {
66
+ rowState.watch(context.toolCallId, context.invalidate);
67
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
68
+ const title = formatToolRowTitle(rowState, context.toolCallId, "codesearch", theme);
69
+ const summary = truncateCallSummary((args.query ?? "").trim()) || "…";
70
+ text.setText(
71
+ `${title} ${theme.fg("accent", summary)} ${theme.fg("muted", `(tokens=${clampInteger(args.tokensNum, 5000, 500, 20_000)})`)}`,
72
+ );
73
+ return text;
74
+ },
75
+ renderResult(result, options, theme, context) {
76
+ return renderWebToolResult(result, options, theme, context);
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,101 @@
1
+ const EXA_ENDPOINT = "https://mcp.exa.ai/mcp";
2
+
3
+ interface FetchResponse {
4
+ ok: boolean;
5
+ status: number;
6
+ text(): Promise<string>;
7
+ }
8
+
9
+ export interface ExaRequest {
10
+ toolName: "web_search_exa" | "get_code_context_exa";
11
+ arguments: Record<string, unknown>;
12
+ }
13
+
14
+ function objectValue(value: unknown): Record<string, unknown> | undefined {
15
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
16
+ }
17
+
18
+ function extractText(payload: unknown): string | undefined {
19
+ const root = objectValue(payload);
20
+ if (!root) return undefined;
21
+ const error = objectValue(root.error);
22
+ if (error) {
23
+ const message = typeof error.message === "string" ? error.message : JSON.stringify(error);
24
+ throw new Error(`Exa JSON-RPC error: ${message}`);
25
+ }
26
+
27
+ const result = objectValue(root.result);
28
+ if (!result || !Array.isArray(result.content)) return undefined;
29
+ const text = result.content
30
+ .map((item) => objectValue(item))
31
+ .filter((item): item is Record<string, unknown> => item !== undefined)
32
+ .filter((item) => item.type === "text" && typeof item.text === "string")
33
+ .map((item) => item.text as string)
34
+ .join("\n");
35
+ return text.length > 0 ? text : undefined;
36
+ }
37
+
38
+ function parseSse(raw: string): string | undefined {
39
+ for (const event of raw.split(/\r?\n\r?\n/)) {
40
+ const data = event
41
+ .split(/\r?\n/)
42
+ .filter((line) => line.startsWith("data:"))
43
+ .map((line) => line.slice(5).replace(/^ /, ""))
44
+ .join("\n");
45
+ if (!data || data === "[DONE]") continue;
46
+ try {
47
+ const text = extractText(JSON.parse(data) as unknown);
48
+ if (text !== undefined) return text;
49
+ } catch (error) {
50
+ if (error instanceof SyntaxError) continue;
51
+ throw error;
52
+ }
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ export async function callExa(
58
+ request: ExaRequest,
59
+ signal: AbortSignal | undefined,
60
+ timeoutSeconds: number,
61
+ ): Promise<string | undefined> {
62
+ const timeoutSignal = AbortSignal.timeout(timeoutSeconds * 1000);
63
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
64
+ const headers: Record<string, string> = {
65
+ accept: "application/json, text/event-stream",
66
+ "content-type": "application/json",
67
+ };
68
+ const apiKey = process.env.EXA_API_KEY?.trim();
69
+ if (apiKey) headers["x-api-key"] = apiKey;
70
+
71
+ try {
72
+ const response = (await fetch(EXA_ENDPOINT, {
73
+ method: "POST",
74
+ headers,
75
+ body: JSON.stringify({
76
+ jsonrpc: "2.0",
77
+ id: 1,
78
+ method: "tools/call",
79
+ params: { name: request.toolName, arguments: request.arguments },
80
+ }),
81
+ signal: requestSignal,
82
+ })) as FetchResponse;
83
+ if (!response.ok) {
84
+ throw new Error(`Exa request failed (${response.status}): ${await response.text()}`);
85
+ }
86
+
87
+ const raw = await response.text();
88
+ try {
89
+ const direct = extractText(JSON.parse(raw) as unknown);
90
+ if (direct !== undefined) return direct;
91
+ } catch (error) {
92
+ if (!(error instanceof SyntaxError)) throw error;
93
+ }
94
+ return parseSse(raw);
95
+ } catch (error) {
96
+ if (timeoutSignal.aborted && signal?.aborted !== true) {
97
+ throw new DOMException("Exa request timed out", "TimeoutError");
98
+ }
99
+ throw error;
100
+ }
101
+ }
@@ -0,0 +1,67 @@
1
+ function decodeCodePoint(value: number): string {
2
+ if (!Number.isFinite(value) || value < 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) {
3
+ return "";
4
+ }
5
+ return String.fromCodePoint(value);
6
+ }
7
+
8
+ function decodeEntities(text: string): string {
9
+ return text
10
+ .replace(/&nbsp;/gi, " ")
11
+ .replace(/&amp;/gi, "&")
12
+ .replace(/&lt;/gi, "<")
13
+ .replace(/&gt;/gi, ">")
14
+ .replace(/&quot;/gi, '"')
15
+ .replace(/&apos;/gi, "'")
16
+ .replace(/&#39;/gi, "'")
17
+ .replace(/&#(\d+);/g, (_match, value: string) => decodeCodePoint(Number.parseInt(value, 10)))
18
+ .replace(/&#x([\da-f]+);/gi, (_match, value: string) => decodeCodePoint(Number.parseInt(value, 16)));
19
+ }
20
+
21
+ function removeNoise(html: string): string {
22
+ return html
23
+ .replace(/<(head|script|style|noscript|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "")
24
+ .replace(/<(head|script|style|noscript|iframe|object|embed)\b[^>]*\/?\s*>/gi, "");
25
+ }
26
+
27
+ function normalize(text: string): string {
28
+ return decodeEntities(text)
29
+ .replace(/\r\n?/g, "\n")
30
+ .replace(/\t/g, " ")
31
+ .split("\n")
32
+ .map((line) => line.replace(/[^\S\n]+/g, " ").trim())
33
+ .join("\n")
34
+ .replace(/\n{3,}/g, "\n\n")
35
+ .trim();
36
+ }
37
+
38
+ export function htmlToText(html: string): string {
39
+ return normalize(
40
+ removeNoise(html)
41
+ .replace(/<br\s*\/?>/gi, "\n")
42
+ .replace(/<\/p\s*>/gi, "\n\n")
43
+ .replace(/<li\b[^>]*>/gi, "\n- ")
44
+ .replace(/<\/li\s*>/gi, "")
45
+ .replace(/<\/(div|section|article|header|footer|main|aside|tr|h[1-6]|ul|ol|table)\s*>/gi, "\n")
46
+ .replace(/<[^>]+>/g, " "),
47
+ );
48
+ }
49
+
50
+ export function htmlToMarkdown(html: string): string {
51
+ return normalize(
52
+ removeNoise(html)
53
+ .replace(
54
+ /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi,
55
+ (_match, level: string, body: string) => `\n${"#".repeat(Number(level))} ${body}\n`,
56
+ )
57
+ .replace(/<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>/gi, "[$2]($1)")
58
+ .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi, "**$2**")
59
+ .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi, "*$2*")
60
+ .replace(/<code\b[^>]*>([\s\S]*?)<\/code\s*>/gi, "`$1`")
61
+ .replace(/<li\b[^>]*>/gi, "\n- ")
62
+ .replace(/<\/li\s*>/gi, "")
63
+ .replace(/<br\s*\/?>/gi, "\n")
64
+ .replace(/<\/(p|div|section|article|header|footer|main|aside|ul|ol|table|tr)\s*>/gi, "\n")
65
+ .replace(/<[^>]+>/g, " "),
66
+ );
67
+ }
@@ -0,0 +1,13 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createToolRowStateStore } from "../../shared/tool-row-state.js";
3
+ import { createCodeSearchTool } from "./codesearch.ts";
4
+ import { createWebFetchTool } from "./webfetch.ts";
5
+ import { createWebSearchTool } from "./websearch.ts";
6
+
7
+ export default function webExtension(pi: ExtensionAPI): void {
8
+ const rowState = createToolRowStateStore(pi, "web.tool-row-state");
9
+ pi.registerTool(createWebFetchTool(rowState));
10
+ pi.registerTool(createWebSearchTool(rowState));
11
+ pi.registerTool(createCodeSearchTool(rowState));
12
+ pi.on("session_start", () => rowState.clear());
13
+ }
@@ -0,0 +1,11 @@
1
+ const MAX_TIMEOUT_SECONDS = 600;
2
+
3
+ export function clampInteger(value: number | undefined, fallback: number, minimum: number, maximum: number): number {
4
+ const effective = value === undefined || !Number.isFinite(value) ? fallback : value;
5
+ return Math.max(minimum, Math.min(Math.floor(effective), maximum));
6
+ }
7
+
8
+ export function normalizeTimeout(value: number | undefined, fallback: number): number {
9
+ if (value === undefined || !Number.isFinite(value) || value <= 0) return fallback;
10
+ return Math.min(Math.floor(value), MAX_TIMEOUT_SECONDS);
11
+ }
@@ -0,0 +1,54 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ formatSize,
5
+ truncateHead,
6
+ type AgentToolResult,
7
+ type Theme,
8
+ type ToolRenderResultOptions,
9
+ type TruncationResult,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { type Component, Text } from "@earendil-works/pi-tui";
12
+
13
+ export function truncateToolOutput(text: string): { text: string; truncation?: TruncationResult } {
14
+ const truncation = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
15
+ if (!truncation.truncated) return { text: truncation.content };
16
+
17
+ return {
18
+ text:
19
+ truncation.content +
20
+ `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines ` +
21
+ `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`,
22
+ truncation,
23
+ };
24
+ }
25
+
26
+ export function truncateCallSummary(text: string): string {
27
+ return text.length <= 90 ? text : `${text.slice(0, 89)}…`;
28
+ }
29
+
30
+ export function renderWebToolResult(
31
+ result: AgentToolResult<unknown>,
32
+ options: ToolRenderResultOptions,
33
+ theme: Theme,
34
+ context: { lastComponent: Component | undefined },
35
+ ): Text {
36
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
37
+ const firstText = result.content.find((item) => item.type === "text");
38
+
39
+ if (options.isPartial) {
40
+ text.setText("");
41
+ return text;
42
+ }
43
+ if (!options.expanded || firstText?.type !== "text") {
44
+ text.setText("");
45
+ return text;
46
+ }
47
+
48
+ const output = firstText.text
49
+ .split("\n")
50
+ .map((line) => theme.fg("toolOutput", line))
51
+ .join("\n");
52
+ text.setText(output ? `\n${output}` : "");
53
+ return text;
54
+ }