@jc20231028/local-code-agent 0.1.1 → 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 +24 -0
- package/package.json +1 -1
- package/src/agent.js +0 -0
- package/src/cli.js +45 -7
- package/src/config.js +5 -1
- package/src/tools.js +68 -10
- package/src/ui.js +68 -6
- package/src/workspace.js +131 -4
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
|
|
|
@@ -49,6 +50,13 @@ local-code chat
|
|
|
49
50
|
npm 只會把執行檔裝進當下專案的 `node_modules/.bin`,不會加進系統 PATH,
|
|
50
51
|
在 cmd 直接打 `local-code` 會抓不到指令。這種情況下要嘛加 `-g` 重裝,要嘛用 `npx local-code chat` 執行。
|
|
51
52
|
|
|
53
|
+
如果你已經用沒加 `-g` 的方式裝過,先移除本地安裝再改用全域安裝:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
npm uninstall @jc20231028/local-code-agent
|
|
57
|
+
npm install -g @jc20231028/local-code-agent
|
|
58
|
+
```
|
|
59
|
+
|
|
52
60
|
`provider` / `model` 留空時,`local-code chat` 第一次啟動就會直接跳出互動選單讓你選(見下方「初始化設定」),
|
|
53
61
|
不需要先手動跑 `local-code init`——`init` 只是用來印出設定檔範例,不是必要步驟。
|
|
54
62
|
|
|
@@ -89,6 +97,8 @@ node ./bin/local-code.js init
|
|
|
89
97
|
"lmStudioBaseUrl": "http://127.0.0.1:1234",
|
|
90
98
|
"maxSteps": 12,
|
|
91
99
|
"allowCommands": false,
|
|
100
|
+
"allowWrites": false,
|
|
101
|
+
"allowNetwork": false,
|
|
92
102
|
"temperature": 0.2
|
|
93
103
|
}
|
|
94
104
|
```
|
|
@@ -133,6 +143,20 @@ node ./bin/local-code.js run "編譯並執行這個 C# 專案"
|
|
|
133
143
|
node ./bin/local-code.js run "執行測試並修正失敗案例" --allow-commands
|
|
134
144
|
```
|
|
135
145
|
|
|
146
|
+
同樣地,模型呼叫 `write_file`、`append_file`、`replace_in_file`、`make_directory` 這些會建立/覆寫/修改檔案或資料夾的工具時,預設也會先印出要變更的路徑(和內容預覽)並問 `Allow this change? [y/N]:`,按 `y` 才會真的寫入;沒有 TTY 時一樣直接安全拒絕。想跳過詢問可以加 `--allow-writes`:
|
|
147
|
+
|
|
148
|
+
```powershell
|
|
149
|
+
node ./bin/local-code.js run "幫我建立這個功能的檔案" --allow-writes
|
|
150
|
+
```
|
|
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
|
+
|
|
158
|
+
`run_command` 之外的其他工具(`list_files`、`read_file`、`search_text`)只是讀取,不會跳出詢問。每一步驟模型在做什麼、呼叫了哪個工具、帶了什麼參數,都會即時印在終端機(stderr),不會等到最後才一次顯示結果。
|
|
159
|
+
|
|
136
160
|
列出目前可用的 Skill:
|
|
137
161
|
|
|
138
162
|
```powershell
|
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,
|
|
@@ -116,8 +116,11 @@ function buildProgressHooks() {
|
|
|
116
116
|
onStep(step) {
|
|
117
117
|
console.error(`step ${step}: waiting for model...`);
|
|
118
118
|
},
|
|
119
|
+
onReasoning({ step, text }) {
|
|
120
|
+
console.error(`step ${step} thinking: ${truncateForLog(text, 200)}`);
|
|
121
|
+
},
|
|
119
122
|
onToolCall(toolCall) {
|
|
120
|
-
console.error(`step result: called tool "${toolCall.tool}"`);
|
|
123
|
+
console.error(`step result: called tool "${toolCall.tool}" -> ${summarizeToolCall(toolCall)}`);
|
|
121
124
|
},
|
|
122
125
|
onToolCallError({ step, reason, message, attempt }) {
|
|
123
126
|
const label = reason === "truncated" ? "reply cut off (length limit)" : "invalid tool call JSON";
|
|
@@ -129,6 +132,37 @@ function buildProgressHooks() {
|
|
|
129
132
|
};
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
function summarizeToolCall(toolCall) {
|
|
136
|
+
const args = toolCall.args ?? {};
|
|
137
|
+
switch (toolCall.tool) {
|
|
138
|
+
case "read_file":
|
|
139
|
+
case "make_directory":
|
|
140
|
+
return args.path ?? "";
|
|
141
|
+
case "write_file":
|
|
142
|
+
case "append_file":
|
|
143
|
+
return `${args.path ?? ""} (${String(args.content ?? "").length} chars)`;
|
|
144
|
+
case "replace_in_file":
|
|
145
|
+
return `${args.path ?? ""} (find: "${truncateForLog(args.findText ?? "", 40)}")`;
|
|
146
|
+
case "search_text":
|
|
147
|
+
return `"${args.query ?? ""}" in ${args.path ?? "."}`;
|
|
148
|
+
case "list_files":
|
|
149
|
+
return args.path ?? ".";
|
|
150
|
+
case "run_command":
|
|
151
|
+
return [args.command, ...(args.args ?? [])].join(" ");
|
|
152
|
+
case "web_search":
|
|
153
|
+
return `"${args.query ?? ""}"`;
|
|
154
|
+
case "web_fetch":
|
|
155
|
+
return args.url ?? "";
|
|
156
|
+
default:
|
|
157
|
+
return JSON.stringify(args);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function truncateForLog(text, max) {
|
|
162
|
+
const flattened = text.replace(/\s+/g, " ").trim();
|
|
163
|
+
return flattened.length > max ? `${flattened.slice(0, max)}...` : flattened;
|
|
164
|
+
}
|
|
165
|
+
|
|
132
166
|
function printResult(result) {
|
|
133
167
|
if (result.failed) {
|
|
134
168
|
console.log(`⚠️ ${result.content}`);
|
|
@@ -164,7 +198,7 @@ function buildUnknownSkillMessage(token, skills) {
|
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
async function runChat(config, skills) {
|
|
167
|
-
const rl =
|
|
201
|
+
const rl = getSharedReadline();
|
|
168
202
|
let activeConfig = { ...config };
|
|
169
203
|
let chatState = await loadChatState(activeConfig);
|
|
170
204
|
let session = createChatSession(activeConfig, chatState.history);
|
|
@@ -294,7 +328,7 @@ async function runChat(config, skills) {
|
|
|
294
328
|
printResult(result);
|
|
295
329
|
}
|
|
296
330
|
} finally {
|
|
297
|
-
|
|
331
|
+
closeSharedReadline();
|
|
298
332
|
}
|
|
299
333
|
}
|
|
300
334
|
|
|
@@ -347,7 +381,7 @@ async function runCheckpointCommand(config, subcommand, rest) {
|
|
|
347
381
|
}
|
|
348
382
|
|
|
349
383
|
async function cmdCheckpointSave(config) {
|
|
350
|
-
const rl =
|
|
384
|
+
const rl = getSharedReadline();
|
|
351
385
|
try {
|
|
352
386
|
const fields = await collectCheckpointFields(rl);
|
|
353
387
|
const state = await loadAppState(config.statePath);
|
|
@@ -359,7 +393,7 @@ async function cmdCheckpointSave(config) {
|
|
|
359
393
|
|
|
360
394
|
console.log(`\ncheckpoint saved: ${checkpoint.id}`);
|
|
361
395
|
} finally {
|
|
362
|
-
|
|
396
|
+
closeSharedReadline();
|
|
363
397
|
}
|
|
364
398
|
}
|
|
365
399
|
|
|
@@ -453,6 +487,8 @@ async function printInitExample(cwd) {
|
|
|
453
487
|
lmStudioBaseUrl: "http://127.0.0.1:1234",
|
|
454
488
|
maxSteps: 12,
|
|
455
489
|
allowCommands: false,
|
|
490
|
+
allowWrites: false,
|
|
491
|
+
allowNetwork: false,
|
|
456
492
|
temperature: 0.2
|
|
457
493
|
};
|
|
458
494
|
|
|
@@ -518,6 +554,8 @@ Options:
|
|
|
518
554
|
--model MODEL_NAME
|
|
519
555
|
--workspace PATH
|
|
520
556
|
--allow-commands
|
|
557
|
+
--allow-writes
|
|
558
|
+
--allow-network
|
|
521
559
|
--max-steps 12
|
|
522
560
|
--temperature 0.2
|
|
523
561
|
--ollama-base-url http://127.0.0.1:11434
|
package/src/config.js
CHANGED
|
@@ -9,6 +9,8 @@ const DEFAULTS = {
|
|
|
9
9
|
lmStudioBaseUrl: "http://127.0.0.1:1234",
|
|
10
10
|
maxSteps: 12,
|
|
11
11
|
allowCommands: false,
|
|
12
|
+
allowWrites: false,
|
|
13
|
+
allowNetwork: false,
|
|
12
14
|
temperature: 0.2
|
|
13
15
|
};
|
|
14
16
|
|
|
@@ -65,7 +67,9 @@ function readEnvConfig() {
|
|
|
65
67
|
model: process.env.LOCAL_CODE_MODEL,
|
|
66
68
|
ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
|
|
67
69
|
lmStudioBaseUrl: process.env.LM_STUDIO_BASE_URL,
|
|
68
|
-
allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS)
|
|
70
|
+
allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS),
|
|
71
|
+
allowWrites: parseBoolean(process.env.LOCAL_CODE_ALLOW_WRITES),
|
|
72
|
+
allowNetwork: parseBoolean(process.env.LOCAL_CODE_ALLOW_NETWORK)
|
|
69
73
|
};
|
|
70
74
|
}
|
|
71
75
|
|
package/src/tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkSyntax } from "./syntaxCheck.js";
|
|
2
|
-
import { confirmCommand } from "./ui.js";
|
|
2
|
+
import { confirmCommand, confirmNetwork, confirmWrite } from "./ui.js";
|
|
3
3
|
|
|
4
4
|
export function createToolset(workspace) {
|
|
5
5
|
const tools = {
|
|
@@ -17,15 +17,22 @@ export function createToolset(workspace) {
|
|
|
17
17
|
description: [
|
|
18
18
|
"Create or overwrite a UTF-8 file inside the workspace.",
|
|
19
19
|
"For files longer than ~40 lines, write a small skeleton first (imports and function signatures), then use append_file to add the rest in smaller pieces.",
|
|
20
|
-
"Do not use this to add to a file that already has content you want to keep - use append_file instead, so you never have to retype existing content."
|
|
20
|
+
"Do not use this to add to a file that already has content you want to keep - use append_file instead, so you never have to retype existing content.",
|
|
21
|
+
"Unless the session was started with --allow-writes, the user is asked to approve each file change in the terminal before it is written."
|
|
21
22
|
].join(" "),
|
|
22
23
|
args: { path: "string", content: "string" },
|
|
23
|
-
run: async ({ path, content }) =>
|
|
24
|
+
run: async ({ path, content }) => {
|
|
25
|
+
const approved = await approveWrite(workspace, { action: "write", path, preview: content });
|
|
26
|
+
return withSyntaxCheck(workspace, path, () => workspace.writeFile(path, content, { approved }));
|
|
27
|
+
}
|
|
24
28
|
},
|
|
25
29
|
append_file: {
|
|
26
30
|
description: "Append content to the end of a file, creating it if it does not exist. Prefer this over write_file when a file already has content you want to keep.",
|
|
27
31
|
args: { path: "string", content: "string" },
|
|
28
|
-
run: async ({ path, content }) =>
|
|
32
|
+
run: async ({ path, content }) => {
|
|
33
|
+
const approved = await approveWrite(workspace, { action: "append", path, preview: content });
|
|
34
|
+
return withSyntaxCheck(workspace, path, () => workspace.appendFile(path, content, { approved }));
|
|
35
|
+
}
|
|
29
36
|
},
|
|
30
37
|
replace_in_file: {
|
|
31
38
|
description: "Replace text inside an existing file.",
|
|
@@ -35,11 +42,18 @@ export function createToolset(workspace) {
|
|
|
35
42
|
replaceText: "string",
|
|
36
43
|
replaceAll: "boolean?"
|
|
37
44
|
},
|
|
38
|
-
run: async (args) =>
|
|
39
|
-
workspace,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
run: async (args) => {
|
|
46
|
+
const approved = await approveWrite(workspace, {
|
|
47
|
+
action: "replace",
|
|
48
|
+
path: args.path,
|
|
49
|
+
preview: `Find:\n${args.findText}\nReplace with:\n${args.replaceText}`
|
|
50
|
+
});
|
|
51
|
+
return withSyntaxCheck(
|
|
52
|
+
workspace,
|
|
53
|
+
args.path,
|
|
54
|
+
() => workspace.replaceInFile(args.path, args.findText, args.replaceText, args.replaceAll, { approved })
|
|
55
|
+
);
|
|
56
|
+
}
|
|
43
57
|
},
|
|
44
58
|
search_text: {
|
|
45
59
|
description: "Search for a plain text query across workspace files.",
|
|
@@ -49,7 +63,10 @@ export function createToolset(workspace) {
|
|
|
49
63
|
make_directory: {
|
|
50
64
|
description: "Create a directory recursively in the workspace.",
|
|
51
65
|
args: { path: "string" },
|
|
52
|
-
run: async ({ path }) =>
|
|
66
|
+
run: async ({ path }) => {
|
|
67
|
+
const approved = await approveWrite(workspace, { action: "mkdir", path });
|
|
68
|
+
return workspace.makeDirectory(path, { approved });
|
|
69
|
+
}
|
|
53
70
|
},
|
|
54
71
|
run_command: {
|
|
55
72
|
description: "Run a local shell command inside the workspace (e.g. dotnet run, npm test, python script.py) to build, test, or execute code. Unless the session was started with --allow-commands, the user is asked to approve each command in the terminal before it runs.",
|
|
@@ -62,6 +79,31 @@ export function createToolset(workspace) {
|
|
|
62
79
|
const approved = await confirmCommand({ command, args, cwd: workspace.rootPath });
|
|
63
80
|
return workspace.runCommand(command, args, { approved });
|
|
64
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
|
+
}
|
|
65
107
|
}
|
|
66
108
|
};
|
|
67
109
|
|
|
@@ -89,6 +131,22 @@ export function createToolset(workspace) {
|
|
|
89
131
|
};
|
|
90
132
|
}
|
|
91
133
|
|
|
134
|
+
async function approveWrite(workspace, { action, path, preview }) {
|
|
135
|
+
if (workspace.allowWrites) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return confirmWrite({ action, path, preview, cwd: workspace.rootPath });
|
|
140
|
+
}
|
|
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
|
+
|
|
92
150
|
async function withSyntaxCheck(workspace, targetPath, writeAction) {
|
|
93
151
|
const message = await writeAction();
|
|
94
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,56 @@ 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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
187
|
+
const rl = getSharedReadline();
|
|
188
|
+
const answer = (await rl.question("Allow this command? [y/N]: ")).trim().toLowerCase();
|
|
189
|
+
return answer === "y" || answer === "yes";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const WRITE_ACTION_LABELS = {
|
|
193
|
+
write: "create/overwrite file",
|
|
194
|
+
append: "append to file",
|
|
195
|
+
replace: "replace text in file",
|
|
196
|
+
mkdir: "create directory"
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export async function confirmWrite({ action, path: targetPath, preview = "", cwd }) {
|
|
200
|
+
if (!isInteractive()) {
|
|
201
|
+
return false;
|
|
174
202
|
}
|
|
203
|
+
|
|
204
|
+
const label = WRITE_ACTION_LABELS[action] ?? action;
|
|
205
|
+
output.write(`\n${style("Model wants to " + label + ":", "yellow")} ${targetPath}\n`);
|
|
206
|
+
if (cwd) {
|
|
207
|
+
output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (preview) {
|
|
211
|
+
const snippet = preview.length > 400 ? `${preview.slice(0, 400)}...` : preview;
|
|
212
|
+
output.write(`${style("Preview:", "dim")}\n${snippet}\n`);
|
|
213
|
+
}
|
|
214
|
+
|
|
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`);
|
|
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";
|
|
175
237
|
}
|
|
176
238
|
|
|
177
239
|
export function printNote(message) {
|
package/src/workspace.js
CHANGED
|
@@ -8,10 +8,27 @@ 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);
|
|
18
|
+
this.allowWrites = Boolean(options.allowWrites);
|
|
19
|
+
this.allowNetwork = Boolean(options.allowNetwork);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
assertWriteApproved(approved) {
|
|
23
|
+
if (!this.allowWrites && !approved) {
|
|
24
|
+
throw new Error("File change was not approved. Re-run with --allow-writes to skip the prompt, or approve it when asked.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
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
|
+
}
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
resolvePath(targetPath = ".") {
|
|
@@ -47,21 +64,24 @@ export class Workspace {
|
|
|
47
64
|
return fs.readFile(fullPath, "utf8");
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
async writeFile(targetPath, content) {
|
|
67
|
+
async writeFile(targetPath, content, { approved = false } = {}) {
|
|
68
|
+
this.assertWriteApproved(approved);
|
|
51
69
|
const fullPath = this.resolvePath(targetPath);
|
|
52
70
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
53
71
|
await fs.writeFile(fullPath, content, "utf8");
|
|
54
72
|
return `Wrote ${path.relative(this.rootPath, fullPath)}`;
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
async appendFile(targetPath, content) {
|
|
75
|
+
async appendFile(targetPath, content, { approved = false } = {}) {
|
|
76
|
+
this.assertWriteApproved(approved);
|
|
58
77
|
const fullPath = this.resolvePath(targetPath);
|
|
59
78
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
60
79
|
await fs.appendFile(fullPath, content, "utf8");
|
|
61
80
|
return `Appended to ${path.relative(this.rootPath, fullPath)}`;
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
async replaceInFile(targetPath, findText, replaceText, replaceAll = false) {
|
|
83
|
+
async replaceInFile(targetPath, findText, replaceText, replaceAll = false, { approved = false } = {}) {
|
|
84
|
+
this.assertWriteApproved(approved);
|
|
65
85
|
const fullPath = this.resolvePath(targetPath);
|
|
66
86
|
const current = await fs.readFile(fullPath, "utf8");
|
|
67
87
|
if (!current.includes(findText)) {
|
|
@@ -102,7 +122,8 @@ export class Workspace {
|
|
|
102
122
|
return matches;
|
|
103
123
|
}
|
|
104
124
|
|
|
105
|
-
async makeDirectory(targetPath) {
|
|
125
|
+
async makeDirectory(targetPath, { approved = false } = {}) {
|
|
126
|
+
this.assertWriteApproved(approved);
|
|
106
127
|
const fullPath = this.resolvePath(targetPath);
|
|
107
128
|
await fs.mkdir(fullPath, { recursive: true });
|
|
108
129
|
return `Created ${path.relative(this.rootPath, fullPath)}`;
|
|
@@ -144,6 +165,112 @@ export class Workspace {
|
|
|
144
165
|
});
|
|
145
166
|
});
|
|
146
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();
|
|
147
274
|
}
|
|
148
275
|
|
|
149
276
|
const MAX_SCANNED_ENTRIES = 5000;
|