@jc20231028/local-code-agent 0.1.3 → 0.1.4

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
@@ -27,6 +27,7 @@
27
27
  - 進行局部字串替換
28
28
  - 寫入 `.py` / `.js` / `.mjs` 後自動做語法檢查,結果會回饋給模型自我修正
29
29
  - 執行本地命令(`dotnet build`、`npm test`、`python xxx.py` 等)來編譯/測試/執行程式碼——預設每次執行前會在終端機跳出來問你要不要允許,`--allow-commands` 則整個 session 都自動允許不再詢問
30
+ - 上網查資料:`web_search`(DuckDuckGo 搜尋,回傳標題/連結/摘要)、`web_fetch`(抓單一網頁並轉成純文字給模型讀)——讓模型能回答訓練資料截止日之後的新資訊。預設每次連網前也會在終端機問你要不要允許,`--allow-network` 則整個 session 都自動允許不再詢問
30
31
  - 用 `/名稱` 打關鍵字叫出自訂 Skill(見下方「Skill 系統」)
31
32
  - 任務進度 Checkpoint:存目標/待辦事項,並自動附上最近對話內容,跨 session 恢復(見下方「任務進度 Checkpoint」)
32
33
 
@@ -97,6 +98,7 @@ node ./bin/local-code.js init
97
98
  "maxSteps": 12,
98
99
  "allowCommands": false,
99
100
  "allowWrites": false,
101
+ "allowNetwork": false,
100
102
  "temperature": 0.2
101
103
  }
102
104
  ```
@@ -147,6 +149,12 @@ node ./bin/local-code.js run "執行測試並修正失敗案例" --allow-command
147
149
  node ./bin/local-code.js run "幫我建立這個功能的檔案" --allow-writes
148
150
  ```
149
151
 
152
+ 模型呼叫 `web_search`(查 DuckDuckGo)或 `web_fetch`(抓網頁內容)時,一樣預設會先問 `Allow this network request? [y/N]:`,按 `y` 才會真的發出連線;沒有 TTY 時直接安全拒絕。這讓模型能查到訓練資料截止日之後的新資訊(例如新版本號、近期新聞),不用只靠舊的訓練知識回答。想跳過詢問可以加 `--allow-network`:
153
+
154
+ ```powershell
155
+ node ./bin/local-code.js run "幫我查一下最新的 Node.js LTS 版本" --allow-network
156
+ ```
157
+
150
158
  `run_command` 之外的其他工具(`list_files`、`read_file`、`search_text`)只是讀取,不會跳出詢問。每一步驟模型在做什麼、呼叫了哪個工具、帶了什麼參數,都會即時印在終端機(stderr),不會等到最後才一次顯示結果。
151
159
 
152
160
  列出目前可用的 Skill:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc20231028/local-code-agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A local coding CLI that uses Ollama or LM Studio models to inspect and modify project files.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
Binary file
package/src/cli.js CHANGED
@@ -1,6 +1,4 @@
1
1
  import path from "node:path";
2
- import readline from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
4
2
  import {
5
3
  loadAppState,
6
4
  loadConfig,
@@ -18,7 +16,9 @@ import {
18
16
  summarizeProvider
19
17
  } from "./runtime.js";
20
18
  import {
19
+ closeSharedReadline,
21
20
  color,
21
+ getSharedReadline,
22
22
  isInteractive,
23
23
  printNote,
24
24
  printSplash,
@@ -149,6 +149,10 @@ function summarizeToolCall(toolCall) {
149
149
  return args.path ?? ".";
150
150
  case "run_command":
151
151
  return [args.command, ...(args.args ?? [])].join(" ");
152
+ case "web_search":
153
+ return `"${args.query ?? ""}"`;
154
+ case "web_fetch":
155
+ return args.url ?? "";
152
156
  default:
153
157
  return JSON.stringify(args);
154
158
  }
@@ -194,7 +198,7 @@ function buildUnknownSkillMessage(token, skills) {
194
198
  }
195
199
 
196
200
  async function runChat(config, skills) {
197
- const rl = readline.createInterface({ input, output });
201
+ const rl = getSharedReadline();
198
202
  let activeConfig = { ...config };
199
203
  let chatState = await loadChatState(activeConfig);
200
204
  let session = createChatSession(activeConfig, chatState.history);
@@ -324,7 +328,7 @@ async function runChat(config, skills) {
324
328
  printResult(result);
325
329
  }
326
330
  } finally {
327
- rl.close();
331
+ closeSharedReadline();
328
332
  }
329
333
  }
330
334
 
@@ -377,7 +381,7 @@ async function runCheckpointCommand(config, subcommand, rest) {
377
381
  }
378
382
 
379
383
  async function cmdCheckpointSave(config) {
380
- const rl = readline.createInterface({ input, output });
384
+ const rl = getSharedReadline();
381
385
  try {
382
386
  const fields = await collectCheckpointFields(rl);
383
387
  const state = await loadAppState(config.statePath);
@@ -389,7 +393,7 @@ async function cmdCheckpointSave(config) {
389
393
 
390
394
  console.log(`\ncheckpoint saved: ${checkpoint.id}`);
391
395
  } finally {
392
- rl.close();
396
+ closeSharedReadline();
393
397
  }
394
398
  }
395
399
 
@@ -484,6 +488,7 @@ async function printInitExample(cwd) {
484
488
  maxSteps: 12,
485
489
  allowCommands: false,
486
490
  allowWrites: false,
491
+ allowNetwork: false,
487
492
  temperature: 0.2
488
493
  };
489
494
 
@@ -550,6 +555,7 @@ Options:
550
555
  --workspace PATH
551
556
  --allow-commands
552
557
  --allow-writes
558
+ --allow-network
553
559
  --max-steps 12
554
560
  --temperature 0.2
555
561
  --ollama-base-url http://127.0.0.1:11434
package/src/config.js CHANGED
@@ -10,6 +10,7 @@ const DEFAULTS = {
10
10
  maxSteps: 12,
11
11
  allowCommands: false,
12
12
  allowWrites: false,
13
+ allowNetwork: false,
13
14
  temperature: 0.2
14
15
  };
15
16
 
@@ -67,7 +68,8 @@ function readEnvConfig() {
67
68
  ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
68
69
  lmStudioBaseUrl: process.env.LM_STUDIO_BASE_URL,
69
70
  allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS),
70
- allowWrites: parseBoolean(process.env.LOCAL_CODE_ALLOW_WRITES)
71
+ allowWrites: parseBoolean(process.env.LOCAL_CODE_ALLOW_WRITES),
72
+ allowNetwork: parseBoolean(process.env.LOCAL_CODE_ALLOW_NETWORK)
71
73
  };
72
74
  }
73
75
 
package/src/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { checkSyntax } from "./syntaxCheck.js";
2
- import { confirmCommand, confirmWrite } from "./ui.js";
2
+ import { confirmCommand, confirmNetwork, confirmWrite } from "./ui.js";
3
3
 
4
4
  export function createToolset(workspace) {
5
5
  const tools = {
@@ -79,6 +79,31 @@ export function createToolset(workspace) {
79
79
  const approved = await confirmCommand({ command, args, cwd: workspace.rootPath });
80
80
  return workspace.runCommand(command, args, { approved });
81
81
  }
82
+ },
83
+ web_search: {
84
+ description: [
85
+ "Search the public web (DuckDuckGo) and return up to `limit` results as {title, url, snippet}.",
86
+ "Use this when the user asks about recent events, current versions/releases, prices, or anything that may have changed since your training data - do not answer from memory alone in those cases.",
87
+ "Follow up with web_fetch on a promising result to read the full page.",
88
+ "Unless the session was started with --allow-network, the user is asked to approve each network request in the terminal before it runs."
89
+ ].join(" "),
90
+ args: { query: "string", limit: "number?" },
91
+ run: async ({ query, limit = 5 }) => {
92
+ const approved = await approveNetwork(workspace, { action: "web_search", detail: query });
93
+ return workspace.searchWeb(query, { approved, limit });
94
+ }
95
+ },
96
+ web_fetch: {
97
+ description: [
98
+ "Fetch a web page by URL and return its title and readable text content (HTML tags stripped, truncated to maxChars).",
99
+ "Use this to read a specific page, e.g. a result from web_search or a URL the user gave you.",
100
+ "Unless the session was started with --allow-network, the user is asked to approve each network request in the terminal before it runs."
101
+ ].join(" "),
102
+ args: { url: "string", maxChars: "number?" },
103
+ run: async ({ url, maxChars = 8000 }) => {
104
+ const approved = await approveNetwork(workspace, { action: "web_fetch", detail: url });
105
+ return workspace.fetchUrl(url, { approved, maxChars });
106
+ }
82
107
  }
83
108
  };
84
109
 
@@ -114,6 +139,14 @@ async function approveWrite(workspace, { action, path, preview }) {
114
139
  return confirmWrite({ action, path, preview, cwd: workspace.rootPath });
115
140
  }
116
141
 
142
+ async function approveNetwork(workspace, { action, detail }) {
143
+ if (workspace.allowNetwork) {
144
+ return true;
145
+ }
146
+
147
+ return confirmNetwork({ action, detail, cwd: workspace.rootPath });
148
+ }
149
+
117
150
  async function withSyntaxCheck(workspace, targetPath, writeAction) {
118
151
  const message = await writeAction();
119
152
  const result = await checkSyntax(workspace.resolvePath(targetPath));
package/src/ui.js CHANGED
@@ -2,6 +2,25 @@ import readline from "node:readline";
2
2
  import readlinePromises from "node:readline/promises";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
 
5
+ let sharedRl = null;
6
+
7
+ // A second readline interface on the same stdin (e.g. a confirm prompt opened
8
+ // while chat's main loop still owns one) causes duplicated/garbled input, since
9
+ // both instances read every keystroke. Reuse a single process-wide interface instead.
10
+ export function getSharedReadline() {
11
+ if (!sharedRl) {
12
+ sharedRl = readlinePromises.createInterface({ input, output });
13
+ }
14
+ return sharedRl;
15
+ }
16
+
17
+ export function closeSharedReadline() {
18
+ if (sharedRl) {
19
+ sharedRl.close();
20
+ sharedRl = null;
21
+ }
22
+ }
23
+
5
24
  const ANSI = {
6
25
  reset: "\u001b[0m",
7
26
  bold: "\u001b[1m",
@@ -165,13 +184,9 @@ export async function confirmCommand({ command, args = [], cwd }) {
165
184
  output.write(`\n${style("Model wants to run:", "yellow")} ${commandLine}\n`);
166
185
  output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
167
186
 
168
- const rl = readlinePromises.createInterface({ input, output });
169
- try {
170
- const answer = (await rl.question("Allow this command? [y/N]: ")).trim().toLowerCase();
171
- return answer === "y" || answer === "yes";
172
- } finally {
173
- rl.close();
174
- }
187
+ const rl = getSharedReadline();
188
+ const answer = (await rl.question("Allow this command? [y/N]: ")).trim().toLowerCase();
189
+ return answer === "y" || answer === "yes";
175
190
  }
176
191
 
177
192
  const WRITE_ACTION_LABELS = {
@@ -197,13 +212,28 @@ export async function confirmWrite({ action, path: targetPath, preview = "", cwd
197
212
  output.write(`${style("Preview:", "dim")}\n${snippet}\n`);
198
213
  }
199
214
 
200
- const rl = readlinePromises.createInterface({ input, output });
201
- try {
202
- const answer = (await rl.question("Allow this change? [y/N]: ")).trim().toLowerCase();
203
- return answer === "y" || answer === "yes";
204
- } finally {
205
- rl.close();
215
+ const rl = getSharedReadline();
216
+ const answer = (await rl.question("Allow this change? [y/N]: ")).trim().toLowerCase();
217
+ return answer === "y" || answer === "yes";
218
+ }
219
+
220
+ export async function confirmNetwork({ action, detail = "", cwd }) {
221
+ if (!isInteractive()) {
222
+ return false;
223
+ }
224
+
225
+ output.write(`\n${style("Model wants to access the network:", "yellow")} ${action}\n`);
226
+ if (detail) {
227
+ const snippet = detail.length > 200 ? `${detail.slice(0, 200)}...` : detail;
228
+ output.write(`${style("Detail:", "dim")} ${snippet}\n`);
206
229
  }
230
+ if (cwd) {
231
+ output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
232
+ }
233
+
234
+ const rl = getSharedReadline();
235
+ const answer = (await rl.question("Allow this network request? [y/N]: ")).trim().toLowerCase();
236
+ return answer === "y" || answer === "yes";
207
237
  }
208
238
 
209
239
  export function printNote(message) {
package/src/workspace.js CHANGED
@@ -8,11 +8,15 @@ const DEFAULT_IGNORES = new Set([
8
8
  ".DS_Store"
9
9
  ]);
10
10
 
11
+ const NETWORK_TIMEOUT_MS = 15000;
12
+ const USER_AGENT = "Mozilla/5.0 (compatible; local-code-agent/1.0)";
13
+
11
14
  export class Workspace {
12
15
  constructor(rootPath, options = {}) {
13
16
  this.rootPath = path.resolve(rootPath);
14
17
  this.allowCommands = Boolean(options.allowCommands);
15
18
  this.allowWrites = Boolean(options.allowWrites);
19
+ this.allowNetwork = Boolean(options.allowNetwork);
16
20
  }
17
21
 
18
22
  assertWriteApproved(approved) {
@@ -21,6 +25,12 @@ export class Workspace {
21
25
  }
22
26
  }
23
27
 
28
+ assertNetworkApproved(approved) {
29
+ if (!this.allowNetwork && !approved) {
30
+ throw new Error("Network access was not approved. Re-run with --allow-network to skip the prompt, or approve it when asked.");
31
+ }
32
+ }
33
+
24
34
  resolvePath(targetPath = ".") {
25
35
  const normalizedTarget = targetPath === "" ? "." : targetPath;
26
36
  const fullPath = path.resolve(this.rootPath, normalizedTarget);
@@ -155,6 +165,112 @@ export class Workspace {
155
165
  });
156
166
  });
157
167
  }
168
+
169
+ async fetchUrl(targetUrl, { approved = false, maxChars = 8000 } = {}) {
170
+ this.assertNetworkApproved(approved);
171
+
172
+ const parsed = new URL(targetUrl);
173
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
174
+ throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
175
+ }
176
+
177
+ const response = await fetchWithTimeout(parsed.toString());
178
+ if (!response.ok) {
179
+ throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
180
+ }
181
+
182
+ const html = await response.text();
183
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
184
+ const title = titleMatch ? decodeEntities(titleMatch[1]).trim() : "";
185
+ const text = stripTags(html);
186
+
187
+ return {
188
+ url: parsed.toString(),
189
+ title,
190
+ text: text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
191
+ };
192
+ }
193
+
194
+ async searchWeb(query, { approved = false, limit = 5 } = {}) {
195
+ this.assertNetworkApproved(approved);
196
+
197
+ const response = await fetchWithTimeout("https://html.duckduckgo.com/html/", {
198
+ method: "POST",
199
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
200
+ body: `q=${encodeURIComponent(query)}`
201
+ });
202
+ if (!response.ok) {
203
+ throw new Error(`Search failed: ${response.status} ${response.statusText}`);
204
+ }
205
+
206
+ const html = await response.text();
207
+ const snippets = [];
208
+ const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
209
+ let snippetMatch;
210
+ while ((snippetMatch = snippetPattern.exec(html)) !== null) {
211
+ snippets.push(stripTags(snippetMatch[1]));
212
+ }
213
+
214
+ const results = [];
215
+ const linkPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
216
+ let linkMatch;
217
+ while ((linkMatch = linkPattern.exec(html)) !== null && results.length < limit) {
218
+ results.push({
219
+ title: stripTags(linkMatch[2]),
220
+ url: resolveDuckDuckGoUrl(linkMatch[1]),
221
+ snippet: snippets[results.length] ?? ""
222
+ });
223
+ }
224
+
225
+ return results;
226
+ }
227
+ }
228
+
229
+ async function fetchWithTimeout(url, options = {}) {
230
+ const controller = new AbortController();
231
+ const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
232
+ try {
233
+ return await fetch(url, {
234
+ ...options,
235
+ signal: controller.signal,
236
+ headers: { "User-Agent": USER_AGENT, ...options.headers }
237
+ });
238
+ } finally {
239
+ clearTimeout(timer);
240
+ }
241
+ }
242
+
243
+ function resolveDuckDuckGoUrl(href) {
244
+ try {
245
+ const url = new URL(href.startsWith("//") ? `https:${href}` : href);
246
+ const target = url.searchParams.get("uddg");
247
+ return target ? decodeURIComponent(target) : url.toString();
248
+ } catch {
249
+ return href;
250
+ }
251
+ }
252
+
253
+ function decodeEntities(text) {
254
+ return text
255
+ .replace(/&nbsp;/g, " ")
256
+ .replace(/&lt;/g, "<")
257
+ .replace(/&gt;/g, ">")
258
+ .replace(/&quot;/g, "\"")
259
+ .replace(/&#0*39;|&#x0*27;/gi, "'")
260
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
261
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
262
+ .replace(/&amp;/g, "&");
263
+ }
264
+
265
+ function stripTags(html) {
266
+ return decodeEntities(
267
+ html
268
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
269
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
270
+ .replace(/<[^>]+>/g, " ")
271
+ )
272
+ .replace(/\s+/g, " ")
273
+ .trim();
158
274
  }
159
275
 
160
276
  const MAX_SCANNED_ENTRIES = 5000;