@akanjs/devkit 3.0.0-beta.14 → 3.0.0-beta.16

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 (59) hide show
  1. package/README.md +1 -0
  2. package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
  3. package/codeAgent/agent/AkanCodeServices.ts +104 -0
  4. package/codeAgent/agent/CodeAgent.ts +543 -0
  5. package/codeAgent/agent/CodeAgentAsks.ts +87 -0
  6. package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
  7. package/codeAgent/agent/CodeAgentGate.ts +119 -0
  8. package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
  9. package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
  10. package/codeAgent/agent/CodeAgentUi.ts +52 -0
  11. package/codeAgent/agent/CodeMailbox.ts +175 -0
  12. package/codeAgent/agent/CodeSessionFork.ts +71 -0
  13. package/codeAgent/agent/CodeSessionIndex.ts +104 -0
  14. package/codeAgent/agent/SubagentPool.ts +173 -0
  15. package/codeAgent/agent/akanCodeModel.ts +81 -0
  16. package/codeAgent/agent/akanCodePaths.ts +31 -0
  17. package/codeAgent/agent/akanSystemPrompt.ts +49 -0
  18. package/codeAgent/agent/codeAgentContract.test.ts +529 -0
  19. package/codeAgent/agent/codeMailbox.test.ts +80 -0
  20. package/codeAgent/agent/codeSessionFork.test.ts +60 -0
  21. package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
  22. package/codeAgent/agent/consoleToStderr.ts +18 -0
  23. package/codeAgent/feedback/DevLogFeedback.ts +97 -0
  24. package/codeAgent/feedback/PreviewChrome.ts +106 -0
  25. package/codeAgent/feedback/PreviewFeedback.ts +53 -0
  26. package/codeAgent/feedback/PreviewView.ts +107 -0
  27. package/codeAgent/feedback/TurnFeedback.ts +113 -0
  28. package/codeAgent/feedback/VerifyFeedback.ts +46 -0
  29. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  30. package/codeAgent/index.ts +43 -0
  31. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  32. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  33. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  34. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  35. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  36. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  37. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  38. package/codeAgent/skills/akan-store/SKILL.md +63 -0
  39. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  40. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  41. package/codeAgent/tools/AkanCli.ts +62 -0
  42. package/codeAgent/tools/AkanCodePlugins.ts +135 -0
  43. package/codeAgent/tools/AkanEditScope.ts +58 -0
  44. package/codeAgent/tools/AkanEnvKeys.ts +56 -0
  45. package/codeAgent/tools/AkanToolPack.ts +114 -0
  46. package/codeAgent/tools/AkanVerifier.ts +55 -0
  47. package/codeAgent/tools/MailToolPack.ts +85 -0
  48. package/codeAgent/tools/McpClient.ts +162 -0
  49. package/codeAgent/tools/McpToolPack.ts +134 -0
  50. package/codeAgent/tools/SessionToolPack.ts +101 -0
  51. package/codeAgent/tools/WebToolPack.ts +153 -0
  52. package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
  53. package/executors.ts +5 -0
  54. package/index.ts +2 -0
  55. package/package.json +8 -2
  56. package/piDependencyPins.test.ts +55 -0
  57. package/stripAnsi.ts +3 -0
  58. package/testHelpers.ts +179 -0
  59. package/types.ts +1 -0
@@ -0,0 +1,162 @@
1
+ import type { CodeAgentMcpServerRef } from "akanjs/common";
2
+
3
+ export interface McpToolInfo {
4
+ name: string;
5
+ description?: string;
6
+ inputSchema: { type: "object"; properties: Record<string, unknown>; required?: string[] };
7
+ }
8
+
9
+ interface JsonRpcResponse {
10
+ id?: number;
11
+ result?: { tools?: McpToolInfo[]; content?: unknown; isError?: boolean };
12
+ error?: { code: number; message: string };
13
+ }
14
+
15
+ const protocolVersion = "2025-06-18";
16
+
17
+ /**
18
+ * A client for one MCP server, over stdio or streamable HTTP.
19
+ *
20
+ * **Every failure is non-fatal.** A server that will not start, answers nothing, or dies mid-session costs its
21
+ * own tools and nothing else: an agent that cannot finish a turn because an unrelated integration is down is
22
+ * worse than one with fewer tools.
23
+ */
24
+ export class McpClient {
25
+ readonly ref: CodeAgentMcpServerRef;
26
+ #proc: Bun.Subprocess<"pipe", "pipe", "ignore"> | undefined;
27
+ #nextId = 0;
28
+ #buffer = "";
29
+ readonly #pending = new Map<number, (response: JsonRpcResponse) => void>();
30
+ #sessionId: string | undefined;
31
+
32
+ constructor(ref: CodeAgentMcpServerRef) {
33
+ this.ref = ref;
34
+ }
35
+
36
+ async connect() {
37
+ if (this.ref.transport === "stdio") this.#spawn();
38
+ const initialize = await this.#call("initialize", {
39
+ protocolVersion,
40
+ capabilities: {},
41
+ clientInfo: { name: "akan-code", version: "1" },
42
+ });
43
+ if (initialize.error) throw new Error(initialize.error.message);
44
+ await this.#notify("notifications/initialized");
45
+ return this;
46
+ }
47
+
48
+ async listTools(): Promise<McpToolInfo[]> {
49
+ const response = await this.#call("tools/list", {});
50
+ if (response.error) throw new Error(response.error.message);
51
+ return response.result?.tools ?? [];
52
+ }
53
+
54
+ async callTool(name: string, args: Record<string, unknown>) {
55
+ const response = await this.#call("tools/call", { name, arguments: args });
56
+ if (response.error) return { text: response.error.message, isError: true };
57
+ return { text: McpClient.#renderContent(response.result?.content), isError: !!response.result?.isError };
58
+ }
59
+
60
+ close() {
61
+ this.#proc?.kill();
62
+ this.#proc = undefined;
63
+ for (const resolve of this.#pending.values()) resolve({ error: { code: -1, message: "MCP server closed" } });
64
+ this.#pending.clear();
65
+ }
66
+
67
+ #spawn() {
68
+ if (!this.ref.command) throw new Error(`MCP server "${this.ref.name}" declares no command`);
69
+ this.#proc = Bun.spawn([this.ref.command, ...(this.ref.args ?? [])], {
70
+ stdin: "pipe",
71
+ stdout: "pipe",
72
+ // A server's diagnostics belong in its own stderr, never mixed into the JSON-RPC stream.
73
+ stderr: "ignore",
74
+ env: { ...process.env, ...this.ref.env },
75
+ });
76
+ void this.#readStdout();
77
+ }
78
+
79
+ async #readStdout() {
80
+ const decoder = new TextDecoder();
81
+ for await (const chunk of this.#proc?.stdout ?? []) {
82
+ this.#buffer += decoder.decode(chunk as Uint8Array, { stream: true });
83
+ let index = this.#buffer.indexOf("\n");
84
+ while (index >= 0) {
85
+ const line = this.#buffer.slice(0, index).trim();
86
+ this.#buffer = this.#buffer.slice(index + 1);
87
+ if (line) this.#receive(line);
88
+ index = this.#buffer.indexOf("\n");
89
+ }
90
+ }
91
+ }
92
+
93
+ #receive(line: string) {
94
+ try {
95
+ const message = JSON.parse(line) as JsonRpcResponse;
96
+ if (typeof message.id !== "number") return;
97
+ this.#pending.get(message.id)?.(message);
98
+ this.#pending.delete(message.id);
99
+ } catch {
100
+ // A server that writes anything but JSON-RPC on stdout is misbehaving; dropping the line keeps us alive.
101
+ }
102
+ }
103
+
104
+ async #call(method: string, params: unknown): Promise<JsonRpcResponse> {
105
+ this.#nextId += 1;
106
+ const id = this.#nextId;
107
+ const request = { jsonrpc: "2.0", id, method, params };
108
+ if (this.ref.transport === "http") return await this.#post(request);
109
+ this.#proc?.stdin.write(`${JSON.stringify(request)}\n`);
110
+ return await new Promise<JsonRpcResponse>((resolve) => {
111
+ this.#pending.set(id, resolve);
112
+ setTimeout(() => {
113
+ if (!this.#pending.delete(id)) return;
114
+ resolve({ error: { code: -2, message: `MCP server "${this.ref.name}" timed out on ${method}` } });
115
+ }, 30_000);
116
+ });
117
+ }
118
+
119
+ async #notify(method: string) {
120
+ const request = { jsonrpc: "2.0", method };
121
+ if (this.ref.transport === "http") await this.#post(request);
122
+ else this.#proc?.stdin.write(`${JSON.stringify(request)}\n`);
123
+ }
124
+
125
+ async #post(body: unknown): Promise<JsonRpcResponse> {
126
+ if (!this.ref.url) throw new Error(`MCP server "${this.ref.name}" declares no url`);
127
+ const response = await fetch(this.ref.url, {
128
+ method: "POST",
129
+ headers: {
130
+ "content-type": "application/json",
131
+ // Streamable HTTP lets a server answer either way; a client that omits the SSE type gets a 406.
132
+ accept: "application/json, text/event-stream",
133
+ ...(this.#sessionId ? { "mcp-session-id": this.#sessionId } : {}),
134
+ },
135
+ body: JSON.stringify(body),
136
+ signal: AbortSignal.timeout(30_000),
137
+ });
138
+ this.#sessionId = response.headers.get("mcp-session-id") ?? this.#sessionId;
139
+ const text = await response.text();
140
+ if (!text.trim()) return {};
141
+ // An SSE answer to a single request carries one `data:` line holding the whole JSON-RPC response.
142
+ const payload = text.startsWith("event:") || text.startsWith("data:") ? McpClient.#firstSseData(text) : text;
143
+ return payload ? (JSON.parse(payload) as JsonRpcResponse) : {};
144
+ }
145
+
146
+ static #firstSseData(text: string) {
147
+ return text
148
+ .split("\n")
149
+ .find((line) => line.startsWith("data:"))
150
+ ?.slice(5)
151
+ .trim();
152
+ }
153
+
154
+ static #renderContent(content: unknown) {
155
+ if (!Array.isArray(content)) return typeof content === "string" ? content : JSON.stringify(content ?? null);
156
+ return content
157
+ .map((part: { type?: string; text?: string }) =>
158
+ part.type === "text" ? (part.text ?? "") : JSON.stringify(part),
159
+ )
160
+ .join("\n");
161
+ }
162
+ }
@@ -0,0 +1,134 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
3
+ import { type CodeAgentMcpServerRef, type CodeAgentProfile, codeAgentClip, codeAgentOutputChars } from "akanjs/common";
4
+ import { Type } from "typebox";
5
+ import { akanCodePaths } from "../agent/akanCodePaths";
6
+ import { McpClient, type McpToolInfo } from "./McpClient";
7
+
8
+ export interface McpToolPackOptions {
9
+ workspaceRoot: string;
10
+ profile: CodeAgentProfile;
11
+ onNotice?: (message: string) => void;
12
+ }
13
+
14
+ interface ServerFileEntry {
15
+ command?: string;
16
+ args?: string[];
17
+ env?: Record<string, string>;
18
+ url?: string;
19
+ type?: string;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Connects the workspace's MCP servers and publishes their tools.
25
+ *
26
+ * Servers are declared in `.akan/code/mcp.json` in the shape the editors already use (`mcpServers`), so a
27
+ * developer can paste the block they already have rather than learn a second format.
28
+ *
29
+ * Tool names are prefixed with the server. Two servers offering `search` is the normal case, not the edge one,
30
+ * and a collision that silently shadows one of them is invisible from the model's side.
31
+ */
32
+ export class McpToolPack {
33
+ readonly #options: McpToolPackOptions;
34
+ readonly #clients: { client: McpClient; tools: McpToolInfo[] }[] = [];
35
+
36
+ private constructor(options: McpToolPackOptions) {
37
+ this.#options = options;
38
+ }
39
+
40
+ /**
41
+ * Connects every declared server before the session exists, because the session's tool allowlist has to name
42
+ * these tools and their names are only knowable from a live `tools/list`.
43
+ */
44
+ static async connect(options: McpToolPackOptions) {
45
+ const refs = McpToolPack.#refs(options);
46
+ if (!refs.length) return undefined;
47
+ const pack = new McpToolPack(options);
48
+ for (const ref of refs) {
49
+ try {
50
+ const client = await new McpClient(ref).connect();
51
+ pack.#clients.push({ client, tools: await client.listTools() });
52
+ } catch (error) {
53
+ // One unreachable integration must not cost the agent its turn, so it costs only its own tools.
54
+ options.onNotice?.(`MCP server "${ref.name}" is unavailable: ${String(error)}`);
55
+ }
56
+ }
57
+ return pack.#clients.length ? pack : undefined;
58
+ }
59
+
60
+ get toolNames() {
61
+ return this.#clients.flatMap(({ client, tools }) =>
62
+ tools.map((tool) => McpToolPack.#nameOf(client.ref.name, tool.name)),
63
+ );
64
+ }
65
+
66
+ extension(): InlineExtension {
67
+ return { name: "akan-mcp", factory: (pi: ExtensionAPI) => this.#register(pi) };
68
+ }
69
+
70
+ close() {
71
+ for (const { client } of this.#clients) client.close();
72
+ }
73
+
74
+ #register(pi: ExtensionAPI) {
75
+ for (const { client, tools } of this.#clients)
76
+ for (const tool of tools) {
77
+ const name = McpToolPack.#nameOf(client.ref.name, tool.name);
78
+ const description = tool.description ?? tool.name;
79
+ pi.registerTool({
80
+ name,
81
+ label: `${client.ref.name}: ${tool.name}`,
82
+ description,
83
+ promptSnippet: `${name}: ${description.split(".")[0] ?? description}`,
84
+ // The server's own schema goes through untouched; it is the party that validates the call.
85
+ parameters: Type.Unsafe<Record<string, unknown>>(tool.inputSchema),
86
+ execute: async (_id, params) => {
87
+ const result = await client.callTool(tool.name, params ?? {});
88
+ return {
89
+ content: [{ type: "text", text: codeAgentClip(result.text, codeAgentOutputChars) }],
90
+ details: undefined,
91
+ isError: result.isError,
92
+ };
93
+ },
94
+ });
95
+ }
96
+ }
97
+
98
+ static #nameOf(server: string, tool: string) {
99
+ return `mcp__${server.replace(/[^a-zA-Z0-9_]/g, "_")}__${tool}`;
100
+ }
101
+
102
+ static #refs(options: McpToolPackOptions): CodeAgentMcpServerRef[] {
103
+ if (options.profile.tools.mcp === "off") return [];
104
+ const declared = Array.isArray(options.profile.tools.mcp) ? options.profile.tools.mcp : [];
105
+ return [...McpToolPack.#fromFile(options.workspaceRoot), ...declared];
106
+ }
107
+
108
+ static #fromFile(workspaceRoot: string): CodeAgentMcpServerRef[] {
109
+ const file = akanCodePaths.mcpFile(workspaceRoot);
110
+ if (!existsSync(file)) return [];
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as {
113
+ mcpServers?: Record<string, ServerFileEntry>;
114
+ servers?: Record<string, ServerFileEntry>;
115
+ };
116
+ const entries = Object.entries(parsed.mcpServers ?? parsed.servers ?? {});
117
+ return entries
118
+ .filter(([, entry]) => !entry.disabled)
119
+ .map(([name, entry]): CodeAgentMcpServerRef => {
120
+ const transport = entry.url ? "http" : "stdio";
121
+ return {
122
+ name,
123
+ transport,
124
+ ...(entry.command ? { command: entry.command } : {}),
125
+ ...(entry.args ? { args: entry.args } : {}),
126
+ ...(entry.url ? { url: entry.url } : {}),
127
+ ...(entry.env ? { env: entry.env } : {}),
128
+ };
129
+ });
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,101 @@
1
+ import type { ExtensionAPI, InlineExtension, SessionInfo } from "@earendil-works/pi-coding-agent";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { type CodeAgentProfile, codeAgentClip, codeAgentOutputChars } from "akanjs/common";
4
+ import { Type } from "typebox";
5
+ import { akanCodePaths } from "../agent/akanCodePaths";
6
+
7
+ export interface SessionToolPackOptions {
8
+ workspaceRoot: string;
9
+ cwd: string;
10
+ profile: CodeAgentProfile;
11
+ /** Excluded from results: reading your own transcript back is a way to spend a context window twice. */
12
+ currentSessionId: () => string;
13
+ }
14
+
15
+ const snippetChars = 240;
16
+
17
+ /**
18
+ * Lets the agent reach its own earlier conversations in this workspace.
19
+ *
20
+ * Every past session is already on disk as JSONL under `.akan/code/sessions/`, so this is a reader, not a
21
+ * second store. "We decided last Tuesday to keep the adapter in `srvkit/`" is the kind of thing that exists
22
+ * nowhere else — not in the code, not in git, not in the guide.
23
+ */
24
+ export class SessionToolPack {
25
+ readonly #options: SessionToolPackOptions;
26
+
27
+ constructor(options: SessionToolPackOptions) {
28
+ this.#options = options;
29
+ }
30
+
31
+ static readonly toolNames = ["session_search", "session_read"];
32
+
33
+ names() {
34
+ return this.#options.profile.session.crossSession ? SessionToolPack.toolNames : [];
35
+ }
36
+
37
+ extension(): InlineExtension | undefined {
38
+ if (!this.names().length) return undefined;
39
+ return { name: "akan-sessions", factory: (pi: ExtensionAPI) => this.#register(pi) };
40
+ }
41
+
42
+ #register(pi: ExtensionAPI) {
43
+ pi.registerTool({
44
+ name: "session_search",
45
+ label: "Search sessions",
46
+ description:
47
+ "Search earlier coding-agent conversations in this workspace for a phrase, and get back the matching sessions with a snippet. Use it when the user refers to something decided before, or when you suspect this problem has been solved here already.",
48
+ promptSnippet: "session_search: find earlier conversations in this workspace by phrase",
49
+ parameters: Type.Object({
50
+ query: Type.String({ description: "A phrase to look for, matched case-insensitively." }),
51
+ limit: Type.Optional(Type.Number({ description: "How many sessions to return, default 5." })),
52
+ }),
53
+ execute: async (_id, params) => {
54
+ const matches = await this.#search(params.query, params.limit ?? 5);
55
+ return { content: [{ type: "text", text: matches }], details: undefined };
56
+ },
57
+ });
58
+ pi.registerTool({
59
+ name: "session_read",
60
+ label: "Read session",
61
+ description: "Read one earlier conversation, found with session_search, as plain text.",
62
+ promptSnippet: "session_read: read one earlier conversation by id",
63
+ parameters: Type.Object({ sessionId: Type.String() }),
64
+ execute: async (_id, params) => {
65
+ const text = await this.#read(params.sessionId);
66
+ return { content: [{ type: "text", text }], details: undefined, isError: text.startsWith("No session") };
67
+ },
68
+ });
69
+ }
70
+
71
+ async #search(query: string, limit: number) {
72
+ const needle = query.toLowerCase();
73
+ const sessions = (await this.#list()).filter(
74
+ (session) =>
75
+ session.id !== this.#options.currentSessionId() && session.allMessagesText.toLowerCase().includes(needle),
76
+ );
77
+ if (!sessions.length) return `No earlier session mentions "${query}".`;
78
+ return sessions
79
+ .slice(0, limit)
80
+ .map((session) => {
81
+ const at = session.allMessagesText.toLowerCase().indexOf(needle);
82
+ const snippet = session.allMessagesText.slice(Math.max(0, at - 80), at + snippetChars).replace(/\s+/g, " ");
83
+ return `- ${session.id} (${session.modified.toISOString().slice(0, 10)}${session.name ? `, "${session.name}"` : ""})\n …${snippet}…`;
84
+ })
85
+ .join("\n");
86
+ }
87
+
88
+ async #read(sessionId: string) {
89
+ const session = (await this.#list()).find((entry) => entry.id === sessionId);
90
+ if (!session) return `No session with id ${sessionId} in this workspace.`;
91
+ return codeAgentClip(session.allMessagesText, codeAgentOutputChars * 2);
92
+ }
93
+
94
+ /**
95
+ * Sessions are listed for this workspace's directory only. A global list would reach another checkout's
96
+ * conversations, which is somebody else's project even when it is the same person's machine.
97
+ */
98
+ async #list(): Promise<SessionInfo[]> {
99
+ return await SessionManager.list(this.#options.cwd, akanCodePaths.sessionsDir(this.#options.workspaceRoot));
100
+ }
101
+ }
@@ -0,0 +1,153 @@
1
+ import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
2
+ import { type CodeAgentProfile, codeAgentClip, codeAgentOutputChars } from "akanjs/common";
3
+ import { Type } from "typebox";
4
+
5
+ export interface WebToolPackOptions {
6
+ profile: CodeAgentProfile;
7
+ }
8
+
9
+ interface SearchProvider {
10
+ name: string;
11
+ envKey: string;
12
+ search(key: string, query: string, count: number): Promise<string>;
13
+ }
14
+
15
+ /**
16
+ * `web_fetch` and `web_search`, gated by the profile's network settings.
17
+ *
18
+ * Search needs a provider nobody ships a free key for, so the tool is registered only when one of the known
19
+ * environment keys is present. Registering it unconditionally and failing at call time costs prompt tokens for
20
+ * a capability that does not exist, and teaches the model to retry a tool that can never work.
21
+ */
22
+ export class WebToolPack {
23
+ readonly #options: WebToolPackOptions;
24
+
25
+ constructor(options: WebToolPackOptions) {
26
+ this.#options = options;
27
+ }
28
+
29
+ static providers: SearchProvider[] = [
30
+ {
31
+ name: "brave",
32
+ envKey: "BRAVE_API_KEY",
33
+ search: async (key, query, count) => {
34
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
35
+ const response = await fetch(url, { headers: { "x-subscription-token": key, accept: "application/json" } });
36
+ const body = (await response.json()) as {
37
+ web?: { results?: { title: string; url: string; description: string }[] };
38
+ };
39
+ return (body.web?.results ?? []).map((r) => `- ${r.title}\n ${r.url}\n ${r.description}`).join("\n");
40
+ },
41
+ },
42
+ {
43
+ name: "tavily",
44
+ envKey: "TAVILY_API_KEY",
45
+ search: async (key, query, count) => {
46
+ const response = await fetch("https://api.tavily.com/search", {
47
+ method: "POST",
48
+ headers: { "content-type": "application/json" },
49
+ body: JSON.stringify({ api_key: key, query, max_results: count }),
50
+ });
51
+ const body = (await response.json()) as { results?: { title: string; url: string; content: string }[] };
52
+ return (body.results ?? []).map((r) => `- ${r.title}\n ${r.url}\n ${r.content}`).join("\n");
53
+ },
54
+ },
55
+ ];
56
+
57
+ /** Names the session allowlist must carry. Empty when the profile, or the environment, offers nothing. */
58
+ names() {
59
+ const names: string[] = [];
60
+ if (this.#options.profile.tools.web.fetch) names.push("web_fetch");
61
+ if (this.#options.profile.tools.web.search && WebToolPack.#provider()) names.push("web_search");
62
+ return names;
63
+ }
64
+
65
+ extension(): InlineExtension | undefined {
66
+ if (!this.names().length) return undefined;
67
+ return { name: "akan-web", factory: (pi: ExtensionAPI) => this.#register(pi) };
68
+ }
69
+
70
+ #register(pi: ExtensionAPI) {
71
+ if (this.#options.profile.tools.web.fetch) this.#registerFetch(pi);
72
+ const provider = WebToolPack.#provider();
73
+ if (this.#options.profile.tools.web.search && provider) this.#registerSearch(pi, provider);
74
+ }
75
+
76
+ #registerFetch(pi: ExtensionAPI) {
77
+ const allowHosts = this.#options.profile.network.allowHosts;
78
+ pi.registerTool({
79
+ name: "web_fetch",
80
+ label: "Fetch",
81
+ description: "Fetch a URL and return its readable text. HTML is stripped to text; the result is truncated.",
82
+ promptSnippet: "web_fetch: fetch a URL and return its readable text",
83
+ parameters: Type.Object({ url: Type.String({ description: "Absolute http(s) URL." }) }),
84
+ execute: async (_id, params) => {
85
+ const refusal = WebToolPack.#refuseHost(params.url, allowHosts);
86
+ if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
87
+ const response = await fetch(params.url, { signal: AbortSignal.timeout(20_000), redirect: "follow" });
88
+ const text = WebToolPack.#readable(await response.text());
89
+ return {
90
+ content: [
91
+ { type: "text", text: `${response.status} ${params.url}\n\n${codeAgentClip(text, codeAgentOutputChars)}` },
92
+ ],
93
+ details: undefined,
94
+ isError: !response.ok,
95
+ };
96
+ },
97
+ });
98
+ }
99
+
100
+ #registerSearch(pi: ExtensionAPI, provider: SearchProvider) {
101
+ pi.registerTool({
102
+ name: "web_search",
103
+ label: "Search",
104
+ description: `Search the web (${provider.name}) and return the top results as title, url and snippet.`,
105
+ promptSnippet: "web_search: search the web and return titles, urls and snippets",
106
+ parameters: Type.Object({
107
+ query: Type.String(),
108
+ count: Type.Optional(Type.Number({ description: "How many results, default 5." })),
109
+ }),
110
+ execute: async (_id, params) => {
111
+ const key = process.env[provider.envKey] ?? "";
112
+ const results = await provider.search(key, params.query, params.count ?? 5);
113
+ return {
114
+ content: [{ type: "text", text: codeAgentClip(results || "(no results)", codeAgentOutputChars) }],
115
+ details: undefined,
116
+ };
117
+ },
118
+ });
119
+ }
120
+
121
+ static #provider() {
122
+ return WebToolPack.providers.find((provider) => !!process.env[provider.envKey]);
123
+ }
124
+
125
+ static #refuseHost(url: string, allowHosts: string[] | undefined) {
126
+ let parsed: URL;
127
+ try {
128
+ parsed = new URL(url);
129
+ } catch {
130
+ return `"${url}" is not an absolute URL.`;
131
+ }
132
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Only http and https URLs can be fetched.";
133
+ if (allowHosts?.length && !allowHosts.includes(parsed.hostname))
134
+ return `This profile only allows ${allowHosts.join(", ")}.`;
135
+ return undefined;
136
+ }
137
+
138
+ /** Enough to read a documentation page; a full HTML parser is not worth a dependency for that. */
139
+ static #readable(html: string) {
140
+ return html
141
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
142
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
143
+ .replace(/<[^>]+>/g, " ")
144
+ .replace(/&nbsp;/g, " ")
145
+ .replace(/&amp;/g, "&")
146
+ .replace(/&lt;/g, "<")
147
+ .replace(/&gt;/g, ">")
148
+ .replace(/&quot;/g, '"')
149
+ .replace(/[ \t]+/g, " ")
150
+ .replace(/\n{3,}/g, "\n\n")
151
+ .trim();
152
+ }
153
+ }