@iinm/plain-agent 1.1.1 → 1.3.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.
package/src/config.d.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  import { ModelDefinition, PlatformConfig } from "./modelDefinition";
2
2
  import { ToolUsePattern } from "./tool";
3
+ import { AskURLToolOptions } from "./tools/askURL.mjs";
4
+ import { AskWebToolOptions } from "./tools/askWeb.mjs";
3
5
  import { ExecCommandSanboxConfig } from "./tools/execCommand";
4
-
5
- export type ClaudeCodePluginConfig = {
6
- name: string;
7
- path: string;
8
- };
6
+ import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
9
7
 
10
8
  export type AppConfig = {
11
9
  model?: string;
@@ -18,24 +16,12 @@ export type AppConfig = {
18
16
  };
19
17
  sandbox?: ExecCommandSanboxConfig;
20
18
  tools?: {
21
- /**
22
- * - Vertex AI: requires baseURL and account
23
- * - AI Studio: requires apiKey
24
- */
25
- askGoogle?: {
26
- platform?: "vertex-ai";
27
- baseURL?: string;
28
- account?: string;
29
- apiKey?: string;
30
- model?: string;
31
- };
32
- searchWeb?: {
33
- tavilyApiKey?: string;
34
- };
19
+ askWeb?: AskWebToolOptions;
20
+ askURL?: AskURLToolOptions;
35
21
  };
36
22
  mcpServers?: Record<string, MCPServerConfig>;
37
23
  notifyCmd?: string;
38
- claudeCodePlugins?: ClaudeCodePluginConfig[];
24
+ claudeCodePlugins?: ClaudeCodePluginRepo[];
39
25
  };
40
26
 
41
27
  export type MCPServerConfig = {
package/src/config.mjs CHANGED
@@ -76,14 +76,18 @@ export async function loadAppConfig(options = {}) {
76
76
  },
77
77
  sandbox: config.sandbox ?? merged.sandbox,
78
78
  tools: {
79
- searchWeb: {
80
- ...(merged.tools?.searchWeb ?? {}),
81
- ...(config.tools?.searchWeb ?? {}),
82
- },
83
- askGoogle: {
84
- ...(merged.tools?.askGoogle ?? {}),
85
- ...(config.tools?.askGoogle ?? {}),
86
- },
79
+ askWeb: config.tools?.askWeb
80
+ ? {
81
+ ...(merged.tools?.askWeb ?? {}),
82
+ ...config.tools.askWeb,
83
+ }
84
+ : merged.tools?.askWeb,
85
+ askURL: config.tools?.askURL
86
+ ? {
87
+ ...(merged.tools?.askURL ?? {}),
88
+ ...config.tools.askURL,
89
+ }
90
+ : merged.tools?.askWeb,
87
91
  },
88
92
  mcpServers: {
89
93
  ...(merged.mcpServers ?? {}),
@@ -1,3 +1,5 @@
1
+ /** @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" */
2
+
1
3
  import crypto from "node:crypto";
2
4
  import fs from "node:fs/promises";
3
5
  import path from "node:path";
@@ -7,7 +9,6 @@ import {
7
9
  AGENT_PROJECT_METADATA_DIR,
8
10
  AGENT_ROOT,
9
11
  AGENT_USER_CONFIG_DIR,
10
- CLAUDE_CODE_PLUGIN_DIR,
11
12
  } from "../env.mjs";
12
13
 
13
14
  /**
@@ -21,10 +22,11 @@ import {
21
22
 
22
23
  /**
23
24
  * Load all agent roles from the predefined directories.
24
- * @param {Array<{name: string, path: string}>} [claudeCodePlugins]
25
+ * @param {ClaudeCodePlugin[]} [claudeCodePlugins]
25
26
  * @returns {Promise<Map<string, AgentRole>>}
26
27
  */
27
28
  export async function loadAgentRoles(claudeCodePlugins) {
29
+ /** @type {Array<{dir: string, idPrefix: string, only?: RegExp}>} */
28
30
  const agentDirs = [
29
31
  {
30
32
  dir: path.resolve(AGENT_ROOT, ".config", "agents.predefined"),
@@ -41,11 +43,10 @@ export async function loadAgentRoles(claudeCodePlugins) {
41
43
  // Add plugin directories if provided
42
44
  if (claudeCodePlugins) {
43
45
  for (const plugin of claudeCodePlugins) {
44
- const pluginBase = path.join(CLAUDE_CODE_PLUGIN_DIR, plugin.path);
45
-
46
46
  agentDirs.push({
47
- dir: path.join(pluginBase, "agents"),
47
+ dir: path.join(plugin.path, "agents"),
48
48
  idPrefix: `claude/${plugin.name}:`,
49
+ only: plugin.only,
49
50
  });
50
51
  }
51
52
  }
@@ -53,7 +54,7 @@ export async function loadAgentRoles(claudeCodePlugins) {
53
54
  /** @type {Map<string, AgentRole>} */
54
55
  const roles = new Map();
55
56
 
56
- for (const { dir, idPrefix } of agentDirs) {
57
+ for (const { dir, idPrefix, only } of agentDirs) {
57
58
  const files = await getMarkdownFiles(dir).catch((err) => {
58
59
  if (err.code !== "ENOENT") {
59
60
  console.warn(`Failed to list agent roles in ${dir}:`, err);
@@ -70,6 +71,11 @@ export async function loadAgentRoles(claudeCodePlugins) {
70
71
 
71
72
  if (content === null) continue;
72
73
 
74
+ // Filter by only pattern if specified
75
+ if (only && !only.test(file)) {
76
+ continue;
77
+ }
78
+
73
79
  let role = parseAgentRole(file, content, fullPath, idPrefix);
74
80
  if (role.import) {
75
81
  role = await mergeRemoteRole(role, file, fullPath);
@@ -1,3 +1,5 @@
1
+ /** @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs" */
2
+
1
3
  import { execFileSync } from "node:child_process";
2
4
  import crypto from "node:crypto";
3
5
  import fs from "node:fs/promises";
@@ -8,7 +10,6 @@ import {
8
10
  AGENT_PROJECT_METADATA_DIR,
9
11
  AGENT_ROOT,
10
12
  AGENT_USER_CONFIG_DIR,
11
- CLAUDE_CODE_PLUGIN_DIR,
12
13
  } from "../env.mjs";
13
14
 
14
15
  /**
@@ -25,10 +26,11 @@ import {
25
26
 
26
27
  /**
27
28
  * Load all prompts from the predefined directories.
28
- * @param {Array<{name: string, path: string}>} [claudeCodePlugins]
29
+ * @param {ClaudeCodePlugin[]} [claudeCodePlugins]
29
30
  * @returns {Promise<Map<string, Prompt>>}
30
31
  */
31
32
  export async function loadPrompts(claudeCodePlugins) {
33
+ /** @type {Array<{dir: string, idPrefix: string, only?: RegExp}>} */
32
34
  const promptDirs = [
33
35
  {
34
36
  dir: path.resolve(AGENT_ROOT, ".config", "prompts.predefined"),
@@ -49,18 +51,18 @@ export async function loadPrompts(claudeCodePlugins) {
49
51
  // Add plugin directories if provided
50
52
  if (claudeCodePlugins) {
51
53
  for (const plugin of claudeCodePlugins) {
52
- const pluginBase = path.join(CLAUDE_CODE_PLUGIN_DIR, plugin.path);
53
-
54
54
  // Commands
55
55
  promptDirs.push({
56
- dir: path.join(pluginBase, "commands"),
56
+ dir: path.join(plugin.path, "commands"),
57
57
  idPrefix: `claude/${plugin.name}/commands:`,
58
+ only: plugin.only,
58
59
  });
59
60
 
60
61
  // Skills
61
62
  promptDirs.push({
62
- dir: path.join(pluginBase, "skills"),
63
+ dir: path.join(plugin.path, "skills"),
63
64
  idPrefix: `claude/${plugin.name}/skills:`,
65
+ only: plugin.only,
64
66
  });
65
67
  }
66
68
  }
@@ -68,7 +70,7 @@ export async function loadPrompts(claudeCodePlugins) {
68
70
  /** @type {Map<string, Prompt>} */
69
71
  const prompts = new Map();
70
72
 
71
- for (const { dir, idPrefix } of promptDirs) {
73
+ for (const { dir, idPrefix, only } of promptDirs) {
72
74
  const files = await getMarkdownFiles(dir).catch((err) => {
73
75
  if (err.code !== "ENOENT") {
74
76
  console.warn(`Failed to list prompts in ${dir}:`, err);
@@ -85,6 +87,11 @@ export async function loadPrompts(claudeCodePlugins) {
85
87
 
86
88
  if (content === null) continue;
87
89
 
90
+ // Filter by only pattern if specified
91
+ if (only && !only.test(file)) {
92
+ continue;
93
+ }
94
+
88
95
  // Ignore all files in the skills/ directory except for SKILL.md.
89
96
  if (fullPath.match(/\/skills\//) && !file.endsWith("/SKILL.md")) {
90
97
  continue;
@@ -240,6 +247,7 @@ async function getMarkdownFiles(dir, baseDir = dir) {
240
247
  */
241
248
  function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
242
249
  const rawId = relativePath.replace(/\/SKILL\.md$/, "").replace(/\.md$/, "");
250
+ const isSkill = relativePath.endsWith("SKILL.md");
243
251
  const isShortcut = rawId.startsWith("shortcuts/");
244
252
  const id = isShortcut
245
253
  ? idPrefix + rawId.replace(/^shortcuts\//, "")
@@ -257,7 +265,7 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
257
265
  content: fileContent.trim(),
258
266
  filePath: fullPath,
259
267
  isShortcut,
260
- isSkill: relativePath.endsWith("SKILL.md"),
268
+ isSkill,
261
269
  };
262
270
  }
263
271
 
@@ -281,7 +289,7 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
281
289
  parseFrontmatterField(match[1], "user-invocable") === "true" ||
282
290
  undefined,
283
291
  isShortcut,
284
- isSkill: relativePath.endsWith("SKILL.md"),
292
+ isSkill,
285
293
  };
286
294
  }
287
295
  const userInvocable = frontmatter["user-invocable"];
@@ -304,7 +312,6 @@ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
304
312
  * @param {string} field
305
313
  * @returns {string | undefined}
306
314
  */
307
-
308
315
  function parseFrontmatterField(frontmatter, field) {
309
316
  const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
310
317
  const match = frontmatter.match(regex);
package/src/main.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  import { styleText } from "node:util";
6
6
  import { createAgent } from "./agent.mjs";
7
+ import {
8
+ installClaudeCodePlugins,
9
+ resolvePluginPaths,
10
+ } from "./claudeCodePlugin.mjs";
7
11
  import { parseCliArgs, printHelp } from "./cliArgs.mjs";
8
12
  import { startBatchSession } from "./cliBatch.mjs";
9
13
  import { startInteractiveSession } from "./cliInteractive.mjs";
@@ -18,23 +22,22 @@ import {
18
22
  import { setupMCPServer } from "./mcp.mjs";
19
23
  import { createModelCaller } from "./modelCaller.mjs";
20
24
  import { createPrompt } from "./prompt.mjs";
21
- import { createAskGoogleTool } from "./tools/askGoogle.mjs";
25
+ import { createAskURLTool } from "./tools/askURL.mjs";
26
+ import { createAskWebTool } from "./tools/askWeb.mjs";
22
27
  import { createDelegateToSubagentTool } from "./tools/delegateToSubagent.mjs";
23
28
  import { createExecCommandTool } from "./tools/execCommand.mjs";
24
- import { fetchWebPageTool } from "./tools/fetchWebPage.mjs";
25
29
  import { patchFileTool } from "./tools/patchFile.mjs";
26
30
  import { createReportAsSubagentTool } from "./tools/reportAsSubagent.mjs";
27
- import { createTavilySearchTool } from "./tools/tavilySearch.mjs";
28
31
  import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
29
32
  import { writeFileTool } from "./tools/writeFile.mjs";
30
33
  import { createToolUseApprover } from "./toolUseApprover.mjs";
31
34
 
32
35
  const cliArgs = parseCliArgs(process.argv);
33
- if (cliArgs.showHelp) {
36
+ if (cliArgs.subcommand.type === "help") {
34
37
  printHelp();
35
38
  }
36
39
 
37
- if (cliArgs.listModels) {
40
+ if (cliArgs.subcommand.type === "list-models") {
38
41
  const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
39
42
  if (!appConfig.models || appConfig.models.length === 0) {
40
43
  console.error("No models found in configuration.");
@@ -49,6 +52,11 @@ if (cliArgs.listModels) {
49
52
  process.exit(0);
50
53
  }
51
54
 
55
+ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
56
+ await installClaudeCodePlugins();
57
+ process.exit(0);
58
+ }
59
+
52
60
  (async () => {
53
61
  const startTime = new Date();
54
62
  const sessionId = [
@@ -57,12 +65,18 @@ if (cliArgs.listModels) {
57
65
  `0${startTime.getMinutes()}`.slice(-2),
58
66
  ].join("-");
59
67
  const tmuxSessionId = `agent-${sessionId}`;
60
- const isBatchMode = Boolean(cliArgs.batch);
68
+
69
+ const isBatchMode = cliArgs.subcommand.type === "batch";
70
+ const configFiles =
71
+ cliArgs.subcommand.type === "batch" ||
72
+ cliArgs.subcommand.type === "interactive"
73
+ ? cliArgs.subcommand.config
74
+ : [];
61
75
 
62
76
  const { appConfig, loadedConfigPath } = await loadAppConfig({
63
77
  skipUserConfig: isBatchMode,
64
78
  skipTrustCheck: isBatchMode,
65
- configFiles: cliArgs.config,
79
+ configFiles,
66
80
  });
67
81
 
68
82
  // In batch mode, skip human-readable output
@@ -122,9 +136,17 @@ if (cliArgs.listModels) {
122
136
  }
123
137
  }
124
138
 
125
- const modelNameWithVariant = cliArgs.model || appConfig.model || "";
126
- const agentRoles = await loadAgentRoles(appConfig.claudeCodePlugins);
127
- const prompts = await loadPrompts(appConfig.claudeCodePlugins);
139
+ const modelFromConfig = appConfig.model || "";
140
+ const modelFromArgs =
141
+ cliArgs.subcommand.type === "batch" ||
142
+ cliArgs.subcommand.type === "interactive"
143
+ ? cliArgs.subcommand.model
144
+ : null;
145
+ const modelNameWithVariant = modelFromArgs || modelFromConfig;
146
+
147
+ const pluginPaths = resolvePluginPaths(appConfig.claudeCodePlugins ?? []);
148
+ const agentRoles = await loadAgentRoles(pluginPaths);
149
+ const prompts = await loadPrompts(pluginPaths);
128
150
 
129
151
  const prompt = createPrompt({
130
152
  username: USER_NAME,
@@ -142,23 +164,16 @@ if (cliArgs.listModels) {
142
164
  writeFileTool,
143
165
  patchFileTool,
144
166
  createTmuxCommandTool({ sandbox: appConfig.sandbox }),
145
- fetchWebPageTool,
146
167
  createDelegateToSubagentTool(),
147
168
  createReportAsSubagentTool(),
148
169
  ];
149
170
 
150
- if (appConfig.tools?.searchWeb?.tavilyApiKey) {
151
- builtinTools.push(createTavilySearchTool(appConfig.tools.searchWeb));
171
+ if (appConfig.tools?.askWeb) {
172
+ builtinTools.push(createAskWebTool(appConfig.tools.askWeb));
152
173
  }
153
174
 
154
- if (appConfig.tools?.askGoogle) {
155
- builtinTools.push(
156
- createAskGoogleTool({
157
- platform: appConfig.tools.askGoogle.platform,
158
- baseURL: appConfig.tools.askGoogle.baseURL,
159
- apiKey: appConfig.tools.askGoogle.apiKey,
160
- }),
161
- );
175
+ if (appConfig.tools?.askURL) {
176
+ builtinTools.push(createAskURLTool(appConfig.tools.askURL));
162
177
  }
163
178
 
164
179
  const toolUseApprover = createToolUseApprover({
@@ -224,19 +239,20 @@ if (cliArgs.listModels) {
224
239
  },
225
240
  };
226
241
 
227
- if (isBatchMode) {
228
- if (!cliArgs.batch) {
242
+ if (cliArgs.subcommand.type === "batch") {
243
+ const task = cliArgs.subcommand.task;
244
+ if (!task) {
229
245
  throw new Error("Batch task is required in batch mode");
230
246
  }
231
247
  await startBatchSession({
232
248
  ...sessionOptions,
233
- task: cliArgs.batch,
249
+ task,
234
250
  });
235
251
  } else {
236
252
  startInteractiveSession({
237
253
  ...sessionOptions,
238
254
  notifyCmd: appConfig.notifyCmd || AGENT_NOTIFY_CMD_DEFAULT,
239
- claudeCodePlugins: appConfig.claudeCodePlugins,
255
+ claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
240
256
  });
241
257
  }
242
258
  })().catch((err) => {
package/src/mcp.mjs CHANGED
@@ -1,12 +1,11 @@
1
1
  /**
2
- * @import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- * @import { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js";
4
2
  * @import { StructuredToolResultContent, Tool, ToolImplementation } from "./tool";
5
3
  * @import { MCPServerConfig } from "./config";
6
4
  */
7
5
 
8
6
  import { mkdir, open } from "node:fs/promises";
9
7
  import path from "node:path";
8
+ import { Client } from "@modelcontextprotocol/client";
10
9
  import { AGENT_PROJECT_METADATA_DIR } from "./env.mjs";
11
10
  import { writeTmpFile } from "./tmpfile.mjs";
12
11
  import { noThrow } from "./utils/noThrow.mjs";
@@ -52,7 +51,7 @@ export async function setupMCPServer(serverName, serverConfig) {
52
51
  /**
53
52
  * @typedef {Object} MCPClientOptions
54
53
  * @property {string} serverName - The name of the MCP server.
55
- * @property {StdioServerParameters} params - The transport to use for the client.
54
+ * @property {import("@modelcontextprotocol/client").StdioServerParameters} params - The transport to use for the client.
56
55
  */
57
56
 
58
57
  /**
@@ -60,10 +59,7 @@ export async function setupMCPServer(serverName, serverConfig) {
60
59
  * @returns {Promise<{client: Client; cleanup: () => void}>} - The MCP client and cleanup function.
61
60
  */
62
61
  async function startMCPServer(options) {
63
- const mcpClient = await import("@modelcontextprotocol/sdk/client/index.js");
64
- const mcpClientStdio = await import(
65
- "@modelcontextprotocol/sdk/client/stdio.js"
66
- );
62
+ const mcpClient = await import("@modelcontextprotocol/client");
67
63
 
68
64
  const client = new mcpClient.Client({
69
65
  name: "undefined",
@@ -83,7 +79,7 @@ async function startMCPServer(options) {
83
79
  const logPath = path.join(logDir, `mcp--${options.serverName}.stderr`);
84
80
  const stderrLogFile = await open(logPath, "a");
85
81
 
86
- const transport = new mcpClientStdio.StdioClientTransport({
82
+ const transport = new mcpClient.StdioClientTransport({
87
83
  ...restParams,
88
84
  env: env ? { ...defaultEnv, ...env } : undefined,
89
85
  stderr: stderrLogFile.fd,
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ */
4
+
5
+ import { styleText } from "node:util";
6
+ import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
7
+ import { noThrow } from "../utils/noThrow.mjs";
8
+
9
+ /** @typedef {AskURLToolGeminiOptions | AskURLToolGeminiVertexAIOptions} AskURLToolOptions */
10
+
11
+ /**
12
+ * @typedef {Object} AskURLToolGeminiOptions
13
+ * @property {"gemini"} provider
14
+ * @property {string=} baseURL
15
+ * @property {string} apiKey
16
+ * @property {string} model
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} AskURLToolGeminiVertexAIOptions
21
+ * @property {"gemini-vertex-ai"} provider
22
+ * @property {string} baseURL
23
+ * @property {string=} account
24
+ * @property {string} model
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} AskWebInput
29
+ * @property {string} question
30
+ */
31
+
32
+ /**
33
+ * @param {AskURLToolOptions} config
34
+ * @returns {Tool}
35
+ */
36
+ export function createAskURLTool(config) {
37
+ /**
38
+ * @param {AskWebInput} input
39
+ * @param {number} retryCount
40
+ * @returns {Promise<string | Error>}
41
+ */
42
+ async function askURL(input, retryCount = 0) {
43
+ const model = config.model ?? "gemini-3-flash-preview";
44
+ const url =
45
+ config.provider === "gemini-vertex-ai"
46
+ ? `${config.baseURL}/publishers/google/models/${config.model}:generateContent`
47
+ : config.baseURL
48
+ ? `${config.baseURL}/models/${model}:generateContent`
49
+ : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
50
+
51
+ /** @type {Record<string,string>} */
52
+ const authHeader =
53
+ config.provider === "gemini-vertex-ai"
54
+ ? {
55
+ Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
56
+ }
57
+ : {
58
+ "x-goog-api-key": config.apiKey ?? "",
59
+ };
60
+
61
+ const data = {
62
+ contents: [
63
+ {
64
+ role: "user",
65
+ parts: [
66
+ {
67
+ text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
68
+
69
+ Question: ${input.question}`,
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ tools: [
75
+ {
76
+ url_context: {},
77
+ },
78
+ ],
79
+ };
80
+
81
+ const response = await fetch(url, {
82
+ method: "POST",
83
+ headers: {
84
+ ...authHeader,
85
+ "Content-Type": "application/json",
86
+ },
87
+ body: JSON.stringify(data),
88
+ signal: AbortSignal.timeout(120 * 1000),
89
+ });
90
+
91
+ if (response.status === 429 || response.status >= 500) {
92
+ const interval = Math.min(2 * 2 ** retryCount, 16);
93
+ console.error(
94
+ styleText(
95
+ "yellow",
96
+ `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
97
+ ),
98
+ );
99
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
100
+ return askURL(input, retryCount + 1);
101
+ }
102
+
103
+ if (!response.ok) {
104
+ return new Error(
105
+ `Failed to ask Web: status=${response.status}, body=${await response.text()}`,
106
+ );
107
+ }
108
+
109
+ const body = await response.json();
110
+
111
+ const candidate = body.candidates?.[0];
112
+ const text = candidate?.content?.parts?.[0]?.text;
113
+ /** @type {{segment?:{startIndex:number,endIndex:number,text:string},groundingChunkIndices?:number[]}[] | undefined} */
114
+ const supports = candidate?.groundingMetadata?.groundingSupports;
115
+ /** @type {{web?:{uri:string,title:string}}[] | undefined} */
116
+ const chunks = candidate?.groundingMetadata?.groundingChunks;
117
+
118
+ if (typeof text !== "string") {
119
+ return new Error(
120
+ `Unexpected response format from Google: ${JSON.stringify(body)}`,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * @param {string} source
126
+ * @param {number} byteIndex
127
+ * @param {string} insertText
128
+ */
129
+ const insertTextAtUtf8ByteIndex = (source, byteIndex, insertText) => {
130
+ const sourceBuffer = Buffer.from(source, "utf8");
131
+ const normalizedByteIndex = Math.max(
132
+ 0,
133
+ Math.min(byteIndex, sourceBuffer.length),
134
+ );
135
+
136
+ return Buffer.concat([
137
+ sourceBuffer.subarray(0, normalizedByteIndex),
138
+ Buffer.from(insertText, "utf8"),
139
+ sourceBuffer.subarray(normalizedByteIndex),
140
+ ]).toString("utf8");
141
+ };
142
+
143
+ // Sort by end_index desc because Gemini grounding indexes are byte offsets
144
+ // into the original UTF-8 text.
145
+ const sortedSupports = supports?.toSorted(
146
+ (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0),
147
+ );
148
+
149
+ // Insert citations using UTF-8 byte offsets.
150
+ let textWithCitations = text;
151
+ for (const support of sortedSupports ?? []) {
152
+ const endIndex = support.segment?.endIndex;
153
+ if (
154
+ typeof endIndex !== "number" ||
155
+ !support.groundingChunkIndices?.length
156
+ ) {
157
+ continue;
158
+ }
159
+
160
+ textWithCitations = insertTextAtUtf8ByteIndex(
161
+ textWithCitations,
162
+ endIndex,
163
+ ` [${support.groundingChunkIndices.map((i) => i + 1).join(", ")}] `,
164
+ );
165
+ }
166
+
167
+ const chunkString = (chunks ?? [])
168
+ .map(
169
+ (chunk, index) =>
170
+ `- [${index + 1} - ${chunk.web?.title}](${chunk.web?.uri})`,
171
+ )
172
+ .join("\n");
173
+
174
+ return [textWithCitations, chunkString].join("\n\n");
175
+ }
176
+
177
+ return {
178
+ def: {
179
+ name: "ask_url",
180
+ description:
181
+ "Use one or more provided URLs to answer a question. Include the URLs in your question.",
182
+ inputSchema: {
183
+ type: "object",
184
+ properties: {
185
+ question: {
186
+ type: "string",
187
+ description:
188
+ "The question to ask, including one or more URLs to use as context.",
189
+ },
190
+ },
191
+ required: ["question"],
192
+ },
193
+ },
194
+
195
+ /**
196
+ * @param {AskWebInput} input
197
+ * @returns {Promise<string | Error>}
198
+ */
199
+ impl: async (input) => await noThrow(async () => askURL(input, 0)),
200
+ };
201
+ }