@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 +8 -0
- package/package.json +1 -1
- package/src/agent.js +0 -0
- package/src/cli.js +12 -6
- package/src/config.js +3 -1
- package/src/tools.js +34 -1
- package/src/ui.js +43 -13
- package/src/workspace.js +116 -0
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
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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(/ /g, " ")
|
|
256
|
+
.replace(/</g, "<")
|
|
257
|
+
.replace(/>/g, ">")
|
|
258
|
+
.replace(/"/g, "\"")
|
|
259
|
+
.replace(/�*39;|�*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(/&/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;
|