@jc20231028/local-code-agent 0.1.1 → 0.1.3
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 +16 -0
- package/package.json +1 -1
- package/src/agent.js +0 -0
- package/src/cli.js +33 -1
- package/src/config.js +3 -1
- package/src/tools.js +35 -10
- package/src/ui.js +32 -0
- package/src/workspace.js +15 -4
package/README.md
CHANGED
|
@@ -49,6 +49,13 @@ local-code chat
|
|
|
49
49
|
npm 只會把執行檔裝進當下專案的 `node_modules/.bin`,不會加進系統 PATH,
|
|
50
50
|
在 cmd 直接打 `local-code` 會抓不到指令。這種情況下要嘛加 `-g` 重裝,要嘛用 `npx local-code chat` 執行。
|
|
51
51
|
|
|
52
|
+
如果你已經用沒加 `-g` 的方式裝過,先移除本地安裝再改用全域安裝:
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
npm uninstall @jc20231028/local-code-agent
|
|
56
|
+
npm install -g @jc20231028/local-code-agent
|
|
57
|
+
```
|
|
58
|
+
|
|
52
59
|
`provider` / `model` 留空時,`local-code chat` 第一次啟動就會直接跳出互動選單讓你選(見下方「初始化設定」),
|
|
53
60
|
不需要先手動跑 `local-code init`——`init` 只是用來印出設定檔範例,不是必要步驟。
|
|
54
61
|
|
|
@@ -89,6 +96,7 @@ node ./bin/local-code.js init
|
|
|
89
96
|
"lmStudioBaseUrl": "http://127.0.0.1:1234",
|
|
90
97
|
"maxSteps": 12,
|
|
91
98
|
"allowCommands": false,
|
|
99
|
+
"allowWrites": false,
|
|
92
100
|
"temperature": 0.2
|
|
93
101
|
}
|
|
94
102
|
```
|
|
@@ -133,6 +141,14 @@ node ./bin/local-code.js run "編譯並執行這個 C# 專案"
|
|
|
133
141
|
node ./bin/local-code.js run "執行測試並修正失敗案例" --allow-commands
|
|
134
142
|
```
|
|
135
143
|
|
|
144
|
+
同樣地,模型呼叫 `write_file`、`append_file`、`replace_in_file`、`make_directory` 這些會建立/覆寫/修改檔案或資料夾的工具時,預設也會先印出要變更的路徑(和內容預覽)並問 `Allow this change? [y/N]:`,按 `y` 才會真的寫入;沒有 TTY 時一樣直接安全拒絕。想跳過詢問可以加 `--allow-writes`:
|
|
145
|
+
|
|
146
|
+
```powershell
|
|
147
|
+
node ./bin/local-code.js run "幫我建立這個功能的檔案" --allow-writes
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`run_command` 之外的其他工具(`list_files`、`read_file`、`search_text`)只是讀取,不會跳出詢問。每一步驟模型在做什麼、呼叫了哪個工具、帶了什麼參數,都會即時印在終端機(stderr),不會等到最後才一次顯示結果。
|
|
151
|
+
|
|
136
152
|
列出目前可用的 Skill:
|
|
137
153
|
|
|
138
154
|
```powershell
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
Binary file
|
package/src/cli.js
CHANGED
|
@@ -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,33 @@ 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
|
+
default:
|
|
153
|
+
return JSON.stringify(args);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function truncateForLog(text, max) {
|
|
158
|
+
const flattened = text.replace(/\s+/g, " ").trim();
|
|
159
|
+
return flattened.length > max ? `${flattened.slice(0, max)}...` : flattened;
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
function printResult(result) {
|
|
133
163
|
if (result.failed) {
|
|
134
164
|
console.log(`⚠️ ${result.content}`);
|
|
@@ -453,6 +483,7 @@ async function printInitExample(cwd) {
|
|
|
453
483
|
lmStudioBaseUrl: "http://127.0.0.1:1234",
|
|
454
484
|
maxSteps: 12,
|
|
455
485
|
allowCommands: false,
|
|
486
|
+
allowWrites: false,
|
|
456
487
|
temperature: 0.2
|
|
457
488
|
};
|
|
458
489
|
|
|
@@ -518,6 +549,7 @@ Options:
|
|
|
518
549
|
--model MODEL_NAME
|
|
519
550
|
--workspace PATH
|
|
520
551
|
--allow-commands
|
|
552
|
+
--allow-writes
|
|
521
553
|
--max-steps 12
|
|
522
554
|
--temperature 0.2
|
|
523
555
|
--ollama-base-url http://127.0.0.1:11434
|
package/src/config.js
CHANGED
|
@@ -9,6 +9,7 @@ const DEFAULTS = {
|
|
|
9
9
|
lmStudioBaseUrl: "http://127.0.0.1:1234",
|
|
10
10
|
maxSteps: 12,
|
|
11
11
|
allowCommands: false,
|
|
12
|
+
allowWrites: false,
|
|
12
13
|
temperature: 0.2
|
|
13
14
|
};
|
|
14
15
|
|
|
@@ -65,7 +66,8 @@ function readEnvConfig() {
|
|
|
65
66
|
model: process.env.LOCAL_CODE_MODEL,
|
|
66
67
|
ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
|
|
67
68
|
lmStudioBaseUrl: process.env.LM_STUDIO_BASE_URL,
|
|
68
|
-
allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS)
|
|
69
|
+
allowCommands: parseBoolean(process.env.LOCAL_CODE_ALLOW_COMMANDS),
|
|
70
|
+
allowWrites: parseBoolean(process.env.LOCAL_CODE_ALLOW_WRITES)
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
|
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, 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.",
|
|
@@ -89,6 +106,14 @@ export function createToolset(workspace) {
|
|
|
89
106
|
};
|
|
90
107
|
}
|
|
91
108
|
|
|
109
|
+
async function approveWrite(workspace, { action, path, preview }) {
|
|
110
|
+
if (workspace.allowWrites) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return confirmWrite({ action, path, preview, cwd: workspace.rootPath });
|
|
115
|
+
}
|
|
116
|
+
|
|
92
117
|
async function withSyntaxCheck(workspace, targetPath, writeAction) {
|
|
93
118
|
const message = await writeAction();
|
|
94
119
|
const result = await checkSyntax(workspace.resolvePath(targetPath));
|
package/src/ui.js
CHANGED
|
@@ -174,6 +174,38 @@ export async function confirmCommand({ command, args = [], cwd }) {
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
const WRITE_ACTION_LABELS = {
|
|
178
|
+
write: "create/overwrite file",
|
|
179
|
+
append: "append to file",
|
|
180
|
+
replace: "replace text in file",
|
|
181
|
+
mkdir: "create directory"
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export async function confirmWrite({ action, path: targetPath, preview = "", cwd }) {
|
|
185
|
+
if (!isInteractive()) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const label = WRITE_ACTION_LABELS[action] ?? action;
|
|
190
|
+
output.write(`\n${style("Model wants to " + label + ":", "yellow")} ${targetPath}\n`);
|
|
191
|
+
if (cwd) {
|
|
192
|
+
output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (preview) {
|
|
196
|
+
const snippet = preview.length > 400 ? `${preview.slice(0, 400)}...` : preview;
|
|
197
|
+
output.write(`${style("Preview:", "dim")}\n${snippet}\n`);
|
|
198
|
+
}
|
|
199
|
+
|
|
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();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
177
209
|
export function printNote(message) {
|
|
178
210
|
console.log(style(message, "dim"));
|
|
179
211
|
}
|
package/src/workspace.js
CHANGED
|
@@ -12,6 +12,13 @@ export class Workspace {
|
|
|
12
12
|
constructor(rootPath, options = {}) {
|
|
13
13
|
this.rootPath = path.resolve(rootPath);
|
|
14
14
|
this.allowCommands = Boolean(options.allowCommands);
|
|
15
|
+
this.allowWrites = Boolean(options.allowWrites);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
assertWriteApproved(approved) {
|
|
19
|
+
if (!this.allowWrites && !approved) {
|
|
20
|
+
throw new Error("File change was not approved. Re-run with --allow-writes to skip the prompt, or approve it when asked.");
|
|
21
|
+
}
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
resolvePath(targetPath = ".") {
|
|
@@ -47,21 +54,24 @@ export class Workspace {
|
|
|
47
54
|
return fs.readFile(fullPath, "utf8");
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
async writeFile(targetPath, content) {
|
|
57
|
+
async writeFile(targetPath, content, { approved = false } = {}) {
|
|
58
|
+
this.assertWriteApproved(approved);
|
|
51
59
|
const fullPath = this.resolvePath(targetPath);
|
|
52
60
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
53
61
|
await fs.writeFile(fullPath, content, "utf8");
|
|
54
62
|
return `Wrote ${path.relative(this.rootPath, fullPath)}`;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
async appendFile(targetPath, content) {
|
|
65
|
+
async appendFile(targetPath, content, { approved = false } = {}) {
|
|
66
|
+
this.assertWriteApproved(approved);
|
|
58
67
|
const fullPath = this.resolvePath(targetPath);
|
|
59
68
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
60
69
|
await fs.appendFile(fullPath, content, "utf8");
|
|
61
70
|
return `Appended to ${path.relative(this.rootPath, fullPath)}`;
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
async replaceInFile(targetPath, findText, replaceText, replaceAll = false) {
|
|
73
|
+
async replaceInFile(targetPath, findText, replaceText, replaceAll = false, { approved = false } = {}) {
|
|
74
|
+
this.assertWriteApproved(approved);
|
|
65
75
|
const fullPath = this.resolvePath(targetPath);
|
|
66
76
|
const current = await fs.readFile(fullPath, "utf8");
|
|
67
77
|
if (!current.includes(findText)) {
|
|
@@ -102,7 +112,8 @@ export class Workspace {
|
|
|
102
112
|
return matches;
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
async makeDirectory(targetPath) {
|
|
115
|
+
async makeDirectory(targetPath, { approved = false } = {}) {
|
|
116
|
+
this.assertWriteApproved(approved);
|
|
106
117
|
const fullPath = this.resolvePath(targetPath);
|
|
107
118
|
await fs.mkdir(fullPath, { recursive: true });
|
|
108
119
|
return `Created ${path.relative(this.rootPath, fullPath)}`;
|