@iinm/plain-agent 1.9.4 → 1.10.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/README.md CHANGED
@@ -114,25 +114,35 @@ Create the configuration.
114
114
  }
115
115
  ],
116
116
 
117
- // (Optional) Enable web search tools
117
+ // (Optional) Enable web tools
118
118
  "tools": {
119
- // askWeb: Searches the web to answer questions requiring up-to-date information or external sources.
120
- "askWeb": {
119
+ "webSearch": {
121
120
  "provider": "gemini",
122
121
  "apiKey": "<GEMINI_API_KEY>",
123
122
  "model": "gemini-3-flash-preview"
123
+
124
124
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
125
125
  // "provider": "gemini-vertex-ai",
126
126
  // "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project_id>/locations/<location>",
127
127
  // "model": "gemini-3-flash-preview"
128
+
129
+ // Or use a custom command
130
+ // "provider": "command",
131
+ // "command": "bash",
132
+ // "args": ["-c", "w3m -dump -o display_link_number=1 \"https://lite.duckduckgo.com/lite?q=$*\"", "-"]
128
133
  },
129
134
 
130
- // askURL: Answers questions based on provided URL content.
131
- "askURL": {
135
+ "webFetch": {
132
136
  "provider": "gemini",
133
137
  "apiKey": "<GEMINI_API_KEY>",
134
138
  "model": "gemini-3-flash-preview"
139
+
135
140
  // Or use Vertex AI (Requires gcloud CLI to get authentication token)
141
+
142
+ // Or use a custom command
143
+ // "provider": "command",
144
+ // "command": "w3m",
145
+ // "args": ["-dump", "-o", "display_link_number=1"]
136
146
  }
137
147
  }
138
148
  }
@@ -402,7 +412,7 @@ Files are loaded in the following order. Settings in later files override earlie
402
412
  "action": "allow"
403
413
  },
404
414
  {
405
- "toolName": { "$regex": "^(ask_web|ask_url)$" },
415
+ "toolName": { "$regex": "^(web_search|web_fetch)$" },
406
416
  "action": "allow"
407
417
  }
408
418
  // ⚠️ Never do this. mcp run outside the sandbox, so they can send anything externally.
@@ -461,7 +471,7 @@ Files are loaded in the following order. Settings in later files override earlie
461
471
  },
462
472
 
463
473
  {
464
- "toolName": { "$regex": "^(ask_web|ask_url)$" },
474
+ "toolName": { "$regex": "^(web_search|web_fetch)$" },
465
475
  "action": "allow"
466
476
  },
467
477
 
@@ -553,8 +563,8 @@ The agent can use the following tools to assist with tasks:
553
563
  - **patch_file**: Patch a file.
554
564
  - **exec_command**: Run a command without shell interpretation.
555
565
  - **tmux_command**: Run a tmux command.
556
- - **ask_web**: Use the web search to answer questions that need up-to-date information or supporting sources. (requires Google API key or Vertex AI configuration).
557
- - **ask_url**: Use one or more provided URLs to answer a question. Include the URLs in your question. (requires Google API key or Vertex AI configuration).
566
+ - **web_search**: Search the web with one or more keyword sets and answer a question based on the combined results (requires Google API key, Vertex AI configuration, or the `command` provider with a local search command).
567
+ - **web_fetch**: Fetch the contents of a single URL and answer a question based on it (requires Google API key, Vertex AI configuration, or the `command` provider with a local fetch command such as `w3m`, `curl`, or `lynx`).
558
568
  - **switch_to_subagent**: Switch to a subagent role within the same conversation, focusing on the specified goal.
559
569
  - **switch_to_main_agent**: Switch back to the main agent role and report the result. After reporting, the subagent's conversation history is removed from the context.
560
570
  - **compact_context**: Compact the conversation context by discarding prior messages and reloading task state from a memory file. Use when the context has grown large but the task is not yet complete. Can also be invoked via the `/compact` slash command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.9.4",
3
+ "version": "1.10.0",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cliCost.mjs CHANGED
@@ -87,6 +87,24 @@ export function parseDateOnly(value) {
87
87
  return date;
88
88
  }
89
89
 
90
+ /**
91
+ * Deduplicate usage records by sessionId, keeping only the last record for each session.
92
+ * This prevents double-counting when a session is resumed and exited multiple times.
93
+ *
94
+ * @param {UsageRecord[]} records - Records in chronological order (oldest first)
95
+ * @returns {UsageRecord[]} Deduplicated records (one per sessionId)
96
+ */
97
+ function deduplicateBySessionId(records) {
98
+ /** @type {Map<string, UsageRecord>} */
99
+ const bySessionId = new Map();
100
+ for (const record of records) {
101
+ if (record.sessionId) {
102
+ bySessionId.set(record.sessionId, record);
103
+ }
104
+ }
105
+ return Array.from(bySessionId.values());
106
+ }
107
+
90
108
  /**
91
109
  * Aggregate usage records into a cost report.
92
110
  *
@@ -103,12 +121,14 @@ export function aggregateUsage(records, period) {
103
121
  );
104
122
  }
105
123
 
124
+ const deduplicated = deduplicateBySessionId(records);
125
+
106
126
  /** @type {Map<string, Map<string, DailyEntry>>} */
107
127
  const byCurrency = new Map();
108
128
  let noPricingSessionCount = 0;
109
129
  let excludedOutOfRange = 0;
110
130
 
111
- for (const record of records) {
131
+ for (const record of deduplicated) {
112
132
  if (record.timestamp == null) {
113
133
  excludedOutOfRange++;
114
134
  continue;
@@ -152,20 +152,27 @@ export async function formatToolUse(toolUse) {
152
152
  ].join("\n");
153
153
  }
154
154
 
155
- if (toolName === "ask_web") {
156
- /** @type {Partial<import("./tools/askWeb.mjs").AskWebInput>} */
157
- const askWebInput = input;
158
- return [`tool: ${toolName}`, `question: ${askWebInput.question}`].join(
159
- "\n",
160
- );
155
+ if (toolName === "web_search") {
156
+ /** @type {Partial<import("./tools/webSearch.mjs").WebSearchInput>} */
157
+ const webSearchInput = input;
158
+ const searchesLine = webSearchInput.searches
159
+ ? webSearchInput.searches.map((s) => s.keywords.join(" ")).join(" | ")
160
+ : "";
161
+ return [
162
+ `tool: ${toolName}`,
163
+ `searches: ${searchesLine}`,
164
+ `question: ${webSearchInput.question}`,
165
+ ].join("\n");
161
166
  }
162
167
 
163
- if (toolName === "ask_url") {
164
- /** @type {Partial<import("./tools/askURL.mjs").AskURLInput>} */
165
- const askURLInput = input;
166
- return [`tool: ${toolName}`, `question: ${askURLInput.question}`].join(
167
- "\n",
168
- );
168
+ if (toolName === "web_fetch") {
169
+ /** @type {Partial<import("./tools/webFetch.mjs").WebFetchInput>} */
170
+ const webFetchInput = input;
171
+ return [
172
+ `tool: ${toolName}`,
173
+ `url: ${webFetchInput.url}`,
174
+ `question: ${webFetchInput.question}`,
175
+ ].join("\n");
169
176
  }
170
177
 
171
178
  const { provider: _, ...filteredToolUse } = toolUse;
package/src/config.d.ts CHANGED
@@ -1,11 +1,71 @@
1
1
  import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
2
2
  import { ModelDefinition, PlatformConfig } from "./modelDefinition";
3
3
  import { ToolUsePattern } from "./tool";
4
- import { AskURLToolOptions } from "./tools/askURL.mjs";
5
- import { AskWebToolOptions } from "./tools/askWeb.mjs";
6
4
  import { ExecCommandSanboxConfig } from "./tools/execCommand";
5
+ import {
6
+ WebFetchToolGeminiOptions,
7
+ WebFetchToolGeminiVertexAIOptions,
8
+ } from "./tools/webFetch.mjs";
9
+ import {
10
+ WebSearchToolGeminiOptions,
11
+ WebSearchToolGeminiVertexAIOptions,
12
+ } from "./tools/webSearch.mjs";
7
13
  import { VoiceInputConfig } from "./voiceInput.mjs";
8
14
 
15
+ /**
16
+ * JSON-serializable webFetch configuration.
17
+ *
18
+ * The `command` provider runs an arbitrary local command per fetch to
19
+ * download a URL's content; the agent's main model is then used to answer
20
+ * based on the dumped output. The runtime tool factory receives a resolved
21
+ * `modelCaller` instead — see `WebFetchToolOptions` in `tools/webFetch.mjs`.
22
+ */
23
+ export type WebFetchToolConfig =
24
+ | WebFetchToolGeminiOptions
25
+ | WebFetchToolGeminiVertexAIOptions
26
+ | WebFetchToolCommandJsonConfig;
27
+
28
+ export type WebFetchToolCommandJsonConfig = {
29
+ provider: "command";
30
+ /** Executable used to fetch the URL (e.g., `"w3m"`, `"curl"`). */
31
+ command: string;
32
+ /** Arguments passed before the URL (e.g., `["-dump"]`). The URL is appended automatically. */
33
+ args: string[];
34
+ /** Per-call timeout in milliseconds (default 30000). */
35
+ timeoutMs?: number;
36
+ /** Extra environment variables, merged on top of PATH / HOME / LANG. */
37
+ env?: Record<string, string>;
38
+ maxLength?: number;
39
+ };
40
+
41
+ /**
42
+ * JSON-serializable webSearch configuration.
43
+ *
44
+ * The `command` provider runs an arbitrary local command per keyword set
45
+ * to perform a search; the agent's main model is then used to filter the
46
+ * combined results down to entries relevant to the question. The runtime
47
+ * tool factory receives a resolved `modelCaller` instead — see
48
+ * `WebSearchToolOptions` in `tools/webSearch.mjs`.
49
+ */
50
+ export type WebSearchToolConfig =
51
+ | WebSearchToolGeminiOptions
52
+ | WebSearchToolGeminiVertexAIOptions
53
+ | WebSearchToolCommandJsonConfig;
54
+
55
+ export type WebSearchToolCommandJsonConfig = {
56
+ provider: "command";
57
+ /** Executable used to perform each search (e.g., a wrapper around a search API). */
58
+ command: string;
59
+ /** Arguments passed before each keyword set (e.g., `["-n", "5"]`). Keywords are appended automatically. */
60
+ args: string[];
61
+ /** Per-search timeout in milliseconds (default 30000). */
62
+ timeoutMs?: number;
63
+ /** Extra environment variables, merged on top of PATH / HOME / LANG. */
64
+ env?: Record<string, string>;
65
+ maxLengthPerSearch?: number;
66
+ maxTotalLength?: number;
67
+ };
68
+
9
69
  export type AppConfig = {
10
70
  model?: string;
11
71
  models?: ModelDefinition[];
@@ -17,8 +77,8 @@ export type AppConfig = {
17
77
  };
18
78
  sandbox?: ExecCommandSanboxConfig;
19
79
  tools?: {
20
- askWeb?: AskWebToolOptions;
21
- askURL?: AskURLToolOptions;
80
+ webSearch?: WebSearchToolConfig;
81
+ webFetch?: WebFetchToolConfig;
22
82
  };
23
83
  mcpServers?: Record<string, MCPServerConfig>;
24
84
  notifyCmd?: { command: string; args?: string[] };
package/src/config.mjs CHANGED
@@ -76,18 +76,18 @@ export async function loadAppConfig(options = {}) {
76
76
  },
77
77
  sandbox: config.sandbox ?? merged.sandbox,
78
78
  tools: {
79
- askWeb: config.tools?.askWeb
79
+ webSearch: config.tools?.webSearch
80
80
  ? {
81
- ...(merged.tools?.askWeb ?? {}),
82
- ...config.tools.askWeb,
81
+ ...(merged.tools?.webSearch ?? {}),
82
+ ...config.tools.webSearch,
83
83
  }
84
- : merged.tools?.askWeb,
85
- askURL: config.tools?.askURL
84
+ : merged.tools?.webSearch,
85
+ webFetch: config.tools?.webFetch
86
86
  ? {
87
- ...(merged.tools?.askURL ?? {}),
88
- ...config.tools.askURL,
87
+ ...(merged.tools?.webFetch ?? {}),
88
+ ...config.tools.webFetch,
89
89
  }
90
- : merged.tools?.askURL,
90
+ : merged.tools?.webFetch,
91
91
  },
92
92
  mcpServers: {
93
93
  ...(merged.mcpServers ?? {}),
package/src/main.mjs CHANGED
@@ -22,8 +22,6 @@ import { setupMCPServer } from "./mcpIntegration.mjs";
22
22
  import { createModelCaller } from "./modelCaller.mjs";
23
23
  import { createPrompt } from "./prompt.mjs";
24
24
  import { listSessions, loadSession } from "./sessionStore.mjs";
25
- import { createAskURLTool } from "./tools/askURL.mjs";
26
- import { createAskWebTool } from "./tools/askWeb.mjs";
27
25
  import { createCompactContextTool } from "./tools/compactContext.mjs";
28
26
  import { createExecCommandTool } from "./tools/execCommand.mjs";
29
27
  import { createPatchFileTool } from "./tools/patchFile.mjs";
@@ -31,6 +29,8 @@ import { readFileTool } from "./tools/readFile.mjs";
31
29
  import { createSwitchToMainAgentTool } from "./tools/switchToMainAgent.mjs";
32
30
  import { createSwitchToSubagentTool } from "./tools/switchToSubagent.mjs";
33
31
  import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
32
+ import { createWebFetchTool } from "./tools/webFetch.mjs";
33
+ import { createWebSearchTool } from "./tools/webSearch.mjs";
34
34
  import { writeFileTool } from "./tools/writeFile.mjs";
35
35
  import { createToolUseApprover } from "./toolUseApprover.mjs";
36
36
 
@@ -179,7 +179,7 @@ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
179
179
  console.log(
180
180
  styleText(
181
181
  "yellow",
182
- ` workingDir differs (saved: ${resumedState.workingDir}, current: ${process.cwd()})`,
182
+ ` ⚠️ workingDir differs (saved: ${resumedState.workingDir}, current: ${process.cwd()})`,
183
183
  ),
184
184
  );
185
185
  }
@@ -285,28 +285,6 @@ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
285
285
  createSwitchToMainAgentTool(),
286
286
  ];
287
287
 
288
- if (appConfig.tools?.askWeb) {
289
- builtinTools.push(createAskWebTool(appConfig.tools.askWeb));
290
- }
291
-
292
- if (appConfig.tools?.askURL) {
293
- builtinTools.push(createAskURLTool(appConfig.tools.askURL));
294
- }
295
-
296
- const toolUseApprover = createToolUseApprover({
297
- maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
298
- defaultAction: appConfig.autoApproval?.defaultAction || "ask",
299
- patterns: appConfig.autoApproval?.patterns || [],
300
- maskApprovalInput: (toolName, input) => {
301
- for (const tool of builtinTools) {
302
- if (tool.def.name === toolName && tool.maskApprovalInput) {
303
- return tool.maskApprovalInput(input);
304
- }
305
- }
306
- return input;
307
- },
308
- });
309
-
310
288
  const [modelName, modelVariant] = modelNameWithVariant.split("+");
311
289
  const modelDef = (appConfig.models ?? []).find(
312
290
  (entry) => entry.name === modelName && entry.variant === modelVariant,
@@ -328,14 +306,83 @@ if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
328
306
  );
329
307
  }
330
308
 
309
+ if (appConfig.tools?.webSearch) {
310
+ const webSearchConfig = appConfig.tools.webSearch;
311
+ if (webSearchConfig.provider === "command") {
312
+ const webSearchCallModel = createModelCaller({
313
+ ...modelDef,
314
+ platform: {
315
+ ...modelDef.platform,
316
+ ...platform,
317
+ },
318
+ });
319
+ builtinTools.push(
320
+ createWebSearchTool({
321
+ provider: "command",
322
+ command: webSearchConfig.command,
323
+ args: webSearchConfig.args,
324
+ timeoutMs: webSearchConfig.timeoutMs,
325
+ env: webSearchConfig.env,
326
+ modelCaller: webSearchCallModel,
327
+ maxLengthPerSearch: webSearchConfig.maxLengthPerSearch,
328
+ maxTotalLength: webSearchConfig.maxTotalLength,
329
+ }),
330
+ );
331
+ } else {
332
+ builtinTools.push(createWebSearchTool(webSearchConfig));
333
+ }
334
+ }
335
+
336
+ if (appConfig.tools?.webFetch) {
337
+ const webFetchConfig = appConfig.tools.webFetch;
338
+ if (webFetchConfig.provider === "command") {
339
+ const webFetchCallModel = createModelCaller({
340
+ ...modelDef,
341
+ platform: {
342
+ ...modelDef.platform,
343
+ ...platform,
344
+ },
345
+ });
346
+ builtinTools.push(
347
+ createWebFetchTool({
348
+ provider: "command",
349
+ command: webFetchConfig.command,
350
+ args: webFetchConfig.args,
351
+ timeoutMs: webFetchConfig.timeoutMs,
352
+ env: webFetchConfig.env,
353
+ modelCaller: webFetchCallModel,
354
+ maxLength: webFetchConfig.maxLength,
355
+ }),
356
+ );
357
+ } else {
358
+ builtinTools.push(createWebFetchTool(webFetchConfig));
359
+ }
360
+ }
361
+
362
+ const toolUseApprover = createToolUseApprover({
363
+ maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
364
+ defaultAction: appConfig.autoApproval?.defaultAction || "ask",
365
+ patterns: appConfig.autoApproval?.patterns || [],
366
+ maskApprovalInput: (toolName, input) => {
367
+ for (const tool of builtinTools) {
368
+ if (tool.def.name === toolName && tool.maskApprovalInput) {
369
+ return tool.maskApprovalInput(input);
370
+ }
371
+ }
372
+ return input;
373
+ },
374
+ });
375
+
376
+ const agentCallModel = createModelCaller({
377
+ ...modelDef,
378
+ platform: {
379
+ ...modelDef.platform,
380
+ ...platform,
381
+ },
382
+ });
383
+
331
384
  const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
332
- callModel: createModelCaller({
333
- ...modelDef,
334
- platform: {
335
- ...modelDef.platform,
336
- ...platform,
337
- },
338
- }),
385
+ callModel: agentCallModel,
339
386
  prompt,
340
387
  tools: [...builtinTools, ...mcpTools],
341
388
  toolUseApprover,