s-app-scoder-agent 0.9.3 → 0.9.6
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 +615 -638
- package/dist/{guardrails.js → agent/guardrails.js} +1 -1
- package/dist/agent/interaction.js +70 -0
- package/dist/agent/mcp-bridge.js +136 -0
- package/dist/agent/prompt-context.js +83 -0
- package/dist/agent/run-loop.js +318 -0
- package/dist/agent/session-persistence.js +61 -0
- package/dist/agent/tool-executor.js +158 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/verification.js +99 -0
- package/dist/agent.js +239 -1002
- package/dist/cli/args.js +230 -0
- package/dist/cli/init.js +79 -0
- package/dist/cli/main.js +211 -0
- package/dist/cli/mcp-command.js +185 -0
- package/dist/cli/repl.js +77 -0
- package/dist/cli/run-once.js +26 -0
- package/dist/{tui/commands.js → commands/definitions.js} +6 -16
- package/dist/commands/registry.js +260 -0
- package/dist/{config.js → config/config.js} +1 -1
- package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
- package/dist/{prompt.js → context/prompt.js} +111 -9
- package/dist/{skills.js → context/skills.js} +41 -2
- package/dist/index.js +5 -798
- package/dist/io/console-reporter.js +24 -0
- package/dist/io/reporter.js +1 -0
- package/dist/mcp/connection.js +227 -0
- package/dist/mcp/env.js +8 -0
- package/dist/{mcp.js → mcp/manager.js} +3 -515
- package/dist/mcp/sse.js +28 -0
- package/dist/mcp/transport-http.js +140 -0
- package/dist/mcp/transport-stdio.js +98 -0
- package/dist/mcp/types.js +11 -0
- package/dist/media/image.js +149 -0
- package/dist/{server.js → server/server.js} +1 -1
- package/dist/{cost.js → session/cost.js} +1 -1
- package/dist/{session.js → session/session.js} +1 -1
- package/dist/{version.js → shared/version.js} +1 -1
- package/dist/tools/apply-patch.js +62 -0
- package/dist/tools/edit-file.js +29 -0
- package/dist/tools/file-edit-common.js +124 -0
- package/dist/tools/list-files.js +64 -0
- package/dist/tools/path-safety.js +75 -0
- package/dist/tools/read-file.js +64 -0
- package/dist/tools/registry.js +297 -0
- package/dist/tools/run-command.js +192 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/search.js +237 -0
- package/dist/tools/task-tool.js +48 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/write-file.js +34 -0
- package/dist/tui/app.js +59 -532
- package/dist/tui/ask-user-modal.js +2 -1
- package/dist/tui/at-complete.js +2 -93
- package/dist/tui/charset.js +41 -0
- package/dist/tui/chat-views.js +72 -16
- package/dist/tui/chrome.js +11 -5
- package/dist/tui/hooks/use-command-host.js +26 -0
- package/dist/tui/hooks/use-event-queue.js +69 -0
- package/dist/tui/hooks/use-keybinds.js +87 -0
- package/dist/tui/hooks/use-scroll.js +38 -0
- package/dist/tui/hooks/use-session-resume.js +47 -0
- package/dist/tui/hooks/use-submit.js +106 -0
- package/dist/tui/image.js +2 -149
- package/dist/tui/input.js +1 -1
- package/dist/tui/markdown/ast.js +5 -0
- package/dist/tui/markdown/diagram.js +239 -0
- package/dist/tui/markdown/inline-render.js +24 -0
- package/dist/tui/markdown/inline.js +93 -0
- package/dist/tui/markdown/parse.js +139 -0
- package/dist/tui/markdown/render.js +146 -0
- package/dist/tui/markdown/table.js +106 -0
- package/dist/tui/markdown.js +32 -379
- package/dist/tui/permission-modal.js +2 -1
- package/dist/tui/selectors.js +157 -0
- package/dist/tui/session-picker.js +4 -3
- package/dist/tui/sidebar.js +130 -15
- package/dist/tui/state.js +47 -118
- package/dist/tui/text-width.js +139 -0
- package/dist/tui/theme.js +7 -2
- package/dist/{build.js → workspace/build.js} +1 -1
- package/dist/workspace/file-index.js +93 -0
- package/package.json +5 -1
- package/dist/tools.js +0 -1223
- package/dist/tui/command-registry.js +0 -477
- package/dist/tui/review.js +0 -64
- package/dist/tui/transcript.js +0 -145
- /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
- /package/dist/{agents.js → context/agents.js} +0 -0
- /package/dist/{compaction.js → context/compaction.js} +0 -0
- /package/dist/{context.js → context/context.js} +0 -0
- /package/dist/{memory.js → context/memory.js} +0 -0
- /package/dist/{repo-map.js → context/repo-map.js} +0 -0
- /package/dist/{ui.js → io/terminal.js} +0 -0
- /package/dist/{llm.js → llm/llm.js} +0 -0
- /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
- /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
- /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
- /package/dist/{retry.js → llm/retry.js} +0 -0
- /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
- /package/dist/{permissions.js → security/permissions.js} +0 -0
- /package/dist/{sandbox.js → security/sandbox.js} +0 -0
- /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
- /package/dist/{logger.js → session/logger.js} +0 -0
- /package/dist/{steering.js → session/steering.js} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
- /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
- /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
- /package/dist/{diff.js → workspace/diff.js} +0 -0
- /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
- /package/dist/{watch.js → workspace/watch.js} +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { expandEnv } from "./env.js";
|
|
3
|
+
/** stdio transport — spawn process, line-delimited JSON-RPC trên stdin/stdout. */
|
|
4
|
+
export class StdioTransport {
|
|
5
|
+
cfg;
|
|
6
|
+
kind = "stdio";
|
|
7
|
+
proc = null;
|
|
8
|
+
/** Tail of the server's stderr (last 4KB) — surfaced on connect failures. */
|
|
9
|
+
stderrTail = "";
|
|
10
|
+
buf = "";
|
|
11
|
+
msgCb = null;
|
|
12
|
+
downCb = null;
|
|
13
|
+
constructor(cfg) {
|
|
14
|
+
this.cfg = cfg;
|
|
15
|
+
}
|
|
16
|
+
get alive() {
|
|
17
|
+
return this.proc !== null && this.proc.exitCode === null;
|
|
18
|
+
}
|
|
19
|
+
onMessage(cb) {
|
|
20
|
+
this.msgCb = cb;
|
|
21
|
+
}
|
|
22
|
+
onDown(cb) {
|
|
23
|
+
this.downCb = cb;
|
|
24
|
+
}
|
|
25
|
+
async start() {
|
|
26
|
+
const command = this.cfg.command;
|
|
27
|
+
if (!command)
|
|
28
|
+
throw new Error("MCP stdio transport requires \"command\" in the server config");
|
|
29
|
+
const p = spawn(command, this.cfg.args ?? [], {
|
|
30
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
31
|
+
env: { ...process.env, ...expandEnv(this.cfg.env) },
|
|
32
|
+
// Windows: npx/foo.cmd chỉ spawn được qua shell (libuv không tự tìm .cmd).
|
|
33
|
+
shell: process.platform === "win32",
|
|
34
|
+
});
|
|
35
|
+
this.proc = p;
|
|
36
|
+
p.stderr?.on("data", (c) => {
|
|
37
|
+
// Keep the tail for diagnostics (server logs/help output on crash);
|
|
38
|
+
// otherwise stderr is intentionally ignored.
|
|
39
|
+
this.stderrTail = (this.stderrTail + c.toString("utf8")).slice(-4096);
|
|
40
|
+
});
|
|
41
|
+
p.on("error", (e) => this.downCb?.(new Error(`spawn: ${e.message}`)));
|
|
42
|
+
p.on("exit", (code) => this.downCb?.(new Error(`process exited (code ${code ?? "?"})`)));
|
|
43
|
+
p.stdout?.on("data", (c) => this.onChunk(c));
|
|
44
|
+
}
|
|
45
|
+
send(msg) {
|
|
46
|
+
if (!this.proc?.stdin?.writable)
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
try {
|
|
49
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore */ }
|
|
52
|
+
return Promise.resolve();
|
|
53
|
+
}
|
|
54
|
+
close() {
|
|
55
|
+
const p = this.proc;
|
|
56
|
+
this.proc = null;
|
|
57
|
+
if (!p)
|
|
58
|
+
return;
|
|
59
|
+
// Release pipe handles so the parent process can exit immediately.
|
|
60
|
+
// On Windows shell:true (cmd.exe wrapper), p.kill() only kills cmd.exe —
|
|
61
|
+
// the real node child is orphaned and keeps stdout open. Use taskkill /T
|
|
62
|
+
// to kill the whole process tree; this also works for the plain node case.
|
|
63
|
+
try {
|
|
64
|
+
p.stdin?.end();
|
|
65
|
+
}
|
|
66
|
+
catch { /* ignore */ }
|
|
67
|
+
try {
|
|
68
|
+
p.stdout?.destroy();
|
|
69
|
+
}
|
|
70
|
+
catch { /* ignore */ }
|
|
71
|
+
try {
|
|
72
|
+
p.stderr?.destroy();
|
|
73
|
+
}
|
|
74
|
+
catch { /* ignore */ }
|
|
75
|
+
try {
|
|
76
|
+
p.kill();
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
if (process.platform === "win32" && p.pid) {
|
|
80
|
+
// Fire-and-forget; ignore errors — process may already be gone.
|
|
81
|
+
spawnSync("taskkill", ["/pid", String(p.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
onChunk(chunk) {
|
|
85
|
+
this.buf += chunk.toString("utf8");
|
|
86
|
+
let i;
|
|
87
|
+
while ((i = this.buf.indexOf("\n")) !== -1) {
|
|
88
|
+
const line = this.buf.slice(0, i).trim();
|
|
89
|
+
this.buf = this.buf.slice(i + 1);
|
|
90
|
+
if (!line)
|
|
91
|
+
continue;
|
|
92
|
+
try {
|
|
93
|
+
this.msgCb?.(JSON.parse(line));
|
|
94
|
+
}
|
|
95
|
+
catch { /* skip malformed */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const MCP_TOOL_PREFIX = "mcp__";
|
|
2
|
+
export function mcpToolKey(serverName, toolName) {
|
|
3
|
+
return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
|
|
4
|
+
}
|
|
5
|
+
/** Phase 4: generic MCP resource/prompt tools (advertised only when available). */
|
|
6
|
+
export const MCP_RESOURCE_TOOL_NAMES = new Set([
|
|
7
|
+
"mcp_list_resources",
|
|
8
|
+
"mcp_read_resource",
|
|
9
|
+
"mcp_list_prompts",
|
|
10
|
+
"mcp_get_prompt",
|
|
11
|
+
]);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T7: image render trong chat — detect terminal protocol + halfblock preview.
|
|
3
|
+
* Không thêm dependency: halfblock qua unicode upper-half ▀ với truecolor
|
|
4
|
+
* (iTerm2/kitty hỗ trợ COLORTERM=truecolor). Terminal không hỗ trợ → fallback
|
|
5
|
+
* link text. ponytail: 40px width cố định, không đo cell size thật — nâng
|
|
6
|
+
* cấp khi cần aspect ratio chuẩn.
|
|
7
|
+
*/
|
|
8
|
+
import { inflateSync } from "node:zlib";
|
|
9
|
+
export function detectImageCaps(env = process.env) {
|
|
10
|
+
const truecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
|
|
11
|
+
const iterm = env.TERM_PROGRAM === "iTerm.app";
|
|
12
|
+
const kitty = env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined;
|
|
13
|
+
return { truecolor, iterm, kitty };
|
|
14
|
+
}
|
|
15
|
+
const IMG_MAGIC = [
|
|
16
|
+
{ ext: "png", test: (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 },
|
|
17
|
+
{ ext: "jpg", test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 },
|
|
18
|
+
{ ext: "gif", test: (b) => b.length > 6 && b.toString("ascii", 0, 3) === "GIF" },
|
|
19
|
+
{ ext: "bmp", test: (b) => b.length > 2 && b[0] === 0x42 && b[1] === 0x4d },
|
|
20
|
+
];
|
|
21
|
+
/** Detect image từ magic bytes (đuôi file không đáng tin). */
|
|
22
|
+
export function detectImageBytes(buf) {
|
|
23
|
+
return IMG_MAGIC.find((m) => m.test(buf))?.ext ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** Decode PNG/JPG size (width/height) — chỉ PNG (IHDR) và JPEG (SOF markers). */
|
|
26
|
+
export function imageSize(buf) {
|
|
27
|
+
if (detectImageBytes(buf) === "png" && buf.length > 24) {
|
|
28
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
29
|
+
}
|
|
30
|
+
if (detectImageBytes(buf) === "jpg") {
|
|
31
|
+
let i = 2;
|
|
32
|
+
while (i + 9 < buf.length) {
|
|
33
|
+
if (buf[i] !== 0xff)
|
|
34
|
+
break;
|
|
35
|
+
const marker = buf[i + 1];
|
|
36
|
+
const len = buf.readUInt16BE(i + 2);
|
|
37
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
38
|
+
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
39
|
+
}
|
|
40
|
+
i += 2 + len;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Render buffer ảnh thành mảng dòng "▀" (2 pixel ảnh / 1 dòng terminal).
|
|
47
|
+
* Trả về null khi terminal không truecolor (caller fallback link).
|
|
48
|
+
*/
|
|
49
|
+
export function halfblockRows(buf, maxCols = 40) {
|
|
50
|
+
const png = detectImageBytes(buf) === "png";
|
|
51
|
+
if (!png)
|
|
52
|
+
return null; // ponytail: chỉ decode PNG thuần (zlib builtin); JPG → null
|
|
53
|
+
const size = imageSize(buf);
|
|
54
|
+
if (!size || size.width === 0 || size.height === 0)
|
|
55
|
+
return null;
|
|
56
|
+
// Decode IDAT: zlib inflate + unfilter scanlines.
|
|
57
|
+
let rows;
|
|
58
|
+
try {
|
|
59
|
+
rows = decodePngPixels(buf, size.width, size.height);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const w = size.width;
|
|
65
|
+
const cols = Math.min(maxCols, w);
|
|
66
|
+
const step = w / cols; // downsample theo cột
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let py = 0; py < size.height; py += 2) {
|
|
69
|
+
let line = "";
|
|
70
|
+
for (let cx = 0; cx < cols; cx++) {
|
|
71
|
+
const px = Math.min(w - 1, Math.floor(cx * step));
|
|
72
|
+
const top = pixelAt(rows, w, px, py);
|
|
73
|
+
const bot = py + 1 < size.height ? pixelAt(rows, w, px, py + 1) : [0, 0, 0];
|
|
74
|
+
line += `\x1b[38;2;${top[0]};${top[1]};${top[2]};48;2;${bot[0]};${bot[1]};${bot[2]}m▀`;
|
|
75
|
+
}
|
|
76
|
+
out.push({ text: line + "\x1b[0m" });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function pixelAt(rows, w, x, y) {
|
|
81
|
+
const o = (y * w + x) * 3;
|
|
82
|
+
return [rows[o], rows[o + 1], rows[o + 2]];
|
|
83
|
+
}
|
|
84
|
+
/** PNG IDAT decode (zlib builtin) + unfilter — RGB/RGBA 8-bit không interlace. */
|
|
85
|
+
function decodePngPixels(buf, w, h) {
|
|
86
|
+
// Scan chunks: gather IDAT data.
|
|
87
|
+
let off = 8;
|
|
88
|
+
const idat = [];
|
|
89
|
+
let bitDepth = 8;
|
|
90
|
+
let colorType = 2;
|
|
91
|
+
while (off + 8 <= buf.length) {
|
|
92
|
+
const len = buf.readUInt32BE(off);
|
|
93
|
+
const type = buf.toString("ascii", off + 4, off + 8);
|
|
94
|
+
if (type === "IHDR") {
|
|
95
|
+
bitDepth = buf[off + 16];
|
|
96
|
+
colorType = buf[off + 17];
|
|
97
|
+
}
|
|
98
|
+
if (type === "IDAT")
|
|
99
|
+
idat.push(buf.subarray(off + 8, off + 8 + len));
|
|
100
|
+
if (type === "IEND")
|
|
101
|
+
break;
|
|
102
|
+
off += 12 + len;
|
|
103
|
+
}
|
|
104
|
+
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6))
|
|
105
|
+
throw new Error("unsupported png");
|
|
106
|
+
const bpp = colorType === 6 ? 4 : 3;
|
|
107
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
108
|
+
const stride = w * bpp;
|
|
109
|
+
const px = new Uint8Array(w * h * 3);
|
|
110
|
+
let prev = new Uint8Array(stride);
|
|
111
|
+
for (let y = 0; y < h; y++) {
|
|
112
|
+
const f = raw[y * (stride + 1)];
|
|
113
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
114
|
+
const cur = new Uint8Array(stride);
|
|
115
|
+
for (let x = 0; x < stride; x++) {
|
|
116
|
+
const a = x >= bpp ? cur[x - bpp] : 0;
|
|
117
|
+
const b = prev[x];
|
|
118
|
+
const c = x >= bpp ? prev[x - bpp] : 0;
|
|
119
|
+
let v = line[x];
|
|
120
|
+
switch (f) {
|
|
121
|
+
case 1:
|
|
122
|
+
v += a;
|
|
123
|
+
break;
|
|
124
|
+
case 2:
|
|
125
|
+
v += b;
|
|
126
|
+
break;
|
|
127
|
+
case 3:
|
|
128
|
+
v += (a + b) >> 1;
|
|
129
|
+
break;
|
|
130
|
+
case 4: {
|
|
131
|
+
const p = a + b - c;
|
|
132
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
133
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cur[x] = v & 0xff;
|
|
138
|
+
}
|
|
139
|
+
for (let x = 0; x < w; x++) {
|
|
140
|
+
const so = x * bpp;
|
|
141
|
+
const do_ = (y * w + x) * 3;
|
|
142
|
+
px[do_] = cur[so];
|
|
143
|
+
px[do_ + 1] = cur[so + 1];
|
|
144
|
+
px[do_ + 2] = cur[so + 2];
|
|
145
|
+
}
|
|
146
|
+
prev = cur;
|
|
147
|
+
}
|
|
148
|
+
return px;
|
|
149
|
+
}
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { existsSync, statSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
|
-
import { Agent } from "
|
|
6
|
+
import { Agent } from "../agent.js";
|
|
7
7
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
8
8
|
function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { join, resolve } from "node:path";
|
|
12
|
-
import { resolveModelPricing } from "
|
|
12
|
+
import { resolveModelPricing } from "../shared/constants.js";
|
|
13
13
|
/** Extract `llm_usage` events from a list of raw log events. */
|
|
14
14
|
export function extractUsageEvents(sessionId, events) {
|
|
15
15
|
const out = [];
|
|
@@ -174,7 +174,7 @@ export class EventSessionStore {
|
|
|
174
174
|
get finished() {
|
|
175
175
|
return this._finished;
|
|
176
176
|
}
|
|
177
|
-
/** Persist a manifest (cheap summary for /
|
|
177
|
+
/** Persist a manifest (cheap summary for the session picker / `scoder list`). */
|
|
178
178
|
writeManifest(meta) {
|
|
179
179
|
if (!this.enabled)
|
|
180
180
|
return;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "
|
|
9
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
|
|
10
10
|
export const CLIENT_NAME = "app-scoder-agent";
|
|
11
11
|
export function getVersion() {
|
|
12
12
|
try {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, statSync } from "node:fs";
|
|
2
|
+
import { unifiedDiff } from "../workspace/diff.js";
|
|
3
|
+
import { safePath, MAX_FILE_SIZE_BYTES } from "./path-safety.js";
|
|
4
|
+
import { gateWrite, toToolDiff, formatFile, applyOneEdit } from "./file-edit-common.js";
|
|
5
|
+
export async function toolApplyPatch(ctx, args) {
|
|
6
|
+
let abs;
|
|
7
|
+
try {
|
|
8
|
+
abs = safePath(ctx.workdir, args.path);
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
14
|
+
return { ok: false, output: "ERROR: edits must be a non-empty array." };
|
|
15
|
+
}
|
|
16
|
+
if (args.edits.length > 50) {
|
|
17
|
+
return { ok: false, output: `ERROR: too many edits (${args.edits.length}); split into chunks of at most 50.` };
|
|
18
|
+
}
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
if (statSync(abs).size > MAX_FILE_SIZE_BYTES) {
|
|
22
|
+
return { ok: false, output: `ERROR: ${args.path} exceeds the ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB patch limit.` };
|
|
23
|
+
}
|
|
24
|
+
content = readFileSync(abs, "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return { ok: false, output: `ERROR: cannot read ${args.path}: ${err.message}` };
|
|
28
|
+
}
|
|
29
|
+
const original = content;
|
|
30
|
+
let fuzzy = 0;
|
|
31
|
+
for (let i = 0; i < args.edits.length; i++) {
|
|
32
|
+
const e = args.edits[i];
|
|
33
|
+
if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
|
|
34
|
+
return { ok: false, output: `ERROR: edit #${i + 1} must have string old_string and new_string. No changes written.` };
|
|
35
|
+
}
|
|
36
|
+
const outcome = applyOneEdit(content, args.path, e.old_string, e.new_string);
|
|
37
|
+
if (!outcome.ok) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
output: `ERROR: edit #${i + 1}/${args.edits.length} failed — ${outcome.error} No changes written.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
content = outcome.content;
|
|
44
|
+
if (outcome.fuzzy)
|
|
45
|
+
fuzzy++;
|
|
46
|
+
}
|
|
47
|
+
if (content === original) {
|
|
48
|
+
return { ok: true, output: `No changes to ${args.path} (all edits were no-ops).` };
|
|
49
|
+
}
|
|
50
|
+
const diff = unifiedDiff(args.path, original, content);
|
|
51
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
52
|
+
if (gate)
|
|
53
|
+
return gate;
|
|
54
|
+
writeFileSync(abs, content, "utf8");
|
|
55
|
+
const fmtNote = formatFile(ctx, abs);
|
|
56
|
+
const fuzzyNote = fuzzy > 0 ? ` (${fuzzy} fuzzy whitespace match${fuzzy === 1 ? "" : "es"})` : "";
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
output: `Applied ${args.edits.length} edit${args.edits.length === 1 ? "" : "s"} to ${args.path}${fuzzyNote}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
60
|
+
diff: toToolDiff(args.path, diff),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { unifiedDiff } from "../workspace/diff.js";
|
|
3
|
+
import { safePath } from "./path-safety.js";
|
|
4
|
+
import { gateWrite, toToolDiff, formatFile, applyOneEdit } from "./file-edit-common.js";
|
|
5
|
+
export async function toolEditFile(ctx, args) {
|
|
6
|
+
let abs;
|
|
7
|
+
try {
|
|
8
|
+
abs = safePath(ctx.workdir, args.path);
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
12
|
+
}
|
|
13
|
+
const content = readFileSync(abs, "utf8");
|
|
14
|
+
const outcome = applyOneEdit(content, args.path, args.old_string, args.new_string);
|
|
15
|
+
if (!outcome.ok)
|
|
16
|
+
return { ok: false, output: outcome.error };
|
|
17
|
+
const newContent = outcome.content;
|
|
18
|
+
const diff = unifiedDiff(args.path, content, newContent);
|
|
19
|
+
const gate = await gateWrite(ctx, args.path, diff);
|
|
20
|
+
if (gate)
|
|
21
|
+
return gate;
|
|
22
|
+
writeFileSync(abs, newContent, "utf8");
|
|
23
|
+
const fmtNote = formatFile(ctx, abs);
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
output: `Edited ${args.path}${outcome.fuzzy ? " (fuzzy whitespace match)" : ""}${fmtNote ? `\n${fmtNote}` : ""}`,
|
|
27
|
+
diff: toToolDiff(args.path, diff),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { consoleReporter } from "../io/console-reporter.js";
|
|
5
|
+
const FORMATTER_TIMEOUT_MS = 30_000;
|
|
6
|
+
export function toToolDiff(path, diff) {
|
|
7
|
+
if (!diff.text && !diff.truncated)
|
|
8
|
+
return undefined;
|
|
9
|
+
return { path, text: diff.text, added: diff.added, removed: diff.removed, truncated: diff.truncated };
|
|
10
|
+
}
|
|
11
|
+
/** Shared permission/diff gate for file writes. Returns null = approved. */
|
|
12
|
+
export async function gateWrite(ctx, path, diff) {
|
|
13
|
+
const mode = ctx.permissionMode ?? "auto";
|
|
14
|
+
if (mode === "readonly") {
|
|
15
|
+
if (diff.text)
|
|
16
|
+
(ctx.reporter ?? consoleReporter).printDiff(diff.text);
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
|
|
20
|
+
diff: toToolDiff(path, diff),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (mode === "ask" && ctx.permissions) {
|
|
24
|
+
if (diff.text)
|
|
25
|
+
(ctx.reporter ?? consoleReporter).printDiff(diff.text);
|
|
26
|
+
const allowed = await ctx.permissions.confirmEdit(path, diff.text);
|
|
27
|
+
if (!allowed) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.`,
|
|
31
|
+
diff: toToolDiff(path, diff),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else if (diff.text && !diff.truncated) {
|
|
36
|
+
(ctx.reporter ?? consoleReporter).printDiff(diff.text);
|
|
37
|
+
}
|
|
38
|
+
else if (diff.truncated) {
|
|
39
|
+
(ctx.reporter ?? consoleReporter).diffSummary(path, diff.added, diff.removed);
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** Run the project formatter on a just-written file. */
|
|
44
|
+
export function formatFile(ctx, abs) {
|
|
45
|
+
const fmt = ctx.formatter;
|
|
46
|
+
if (!fmt)
|
|
47
|
+
return null;
|
|
48
|
+
let cmd;
|
|
49
|
+
if (typeof fmt === "string" && fmt !== "auto") {
|
|
50
|
+
cmd = fmt.startsWith("npm:")
|
|
51
|
+
? ["npm", "run", fmt.slice(4), "--", abs]
|
|
52
|
+
: [fmt, "--write", abs];
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const localBin = (name) => {
|
|
56
|
+
const base = join(ctx.workdir, "node_modules", ".bin", name);
|
|
57
|
+
if (process.platform === "win32" && existsSync(`${base}.cmd`))
|
|
58
|
+
return `${base}.cmd`;
|
|
59
|
+
if (existsSync(base))
|
|
60
|
+
return base;
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
const prettier = localBin("prettier");
|
|
64
|
+
const biome = localBin("biome");
|
|
65
|
+
const eslint = localBin("eslint");
|
|
66
|
+
if (prettier)
|
|
67
|
+
cmd = [prettier, "--write", abs];
|
|
68
|
+
else if (biome)
|
|
69
|
+
cmd = [biome, "format", "--write", abs];
|
|
70
|
+
else if (eslint)
|
|
71
|
+
cmd = [eslint, "--fix", abs];
|
|
72
|
+
else
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const r = spawnSync(cmd[0], cmd.slice(1), {
|
|
76
|
+
cwd: ctx.workdir,
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: FORMATTER_TIMEOUT_MS,
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
shell: process.platform === "win32",
|
|
81
|
+
});
|
|
82
|
+
if (r.error)
|
|
83
|
+
return `formatter notice: ${r.error.message.split("\n")[0]}`;
|
|
84
|
+
if (r.status !== 0) {
|
|
85
|
+
return `formatter notice: ${cmd[0]} exited ${r.status}\n${(r.stderr ?? r.stdout ?? "").slice(0, 500)}`;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/** v2: whitespace-insensitive regex for edit_file fuzzy fallback. */
|
|
90
|
+
export function wsInsensitiveRegex(s) {
|
|
91
|
+
let rx = "";
|
|
92
|
+
for (const ch of s) {
|
|
93
|
+
if (/\s/.test(ch))
|
|
94
|
+
rx += "\\s+";
|
|
95
|
+
else
|
|
96
|
+
rx += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
97
|
+
}
|
|
98
|
+
return new RegExp(rx);
|
|
99
|
+
}
|
|
100
|
+
/** Apply one exact (or whitespace-tolerant) replacement to in-memory content. */
|
|
101
|
+
export function applyOneEdit(content, path, oldString, newString) {
|
|
102
|
+
if (typeof oldString !== "string" || oldString.length === 0) {
|
|
103
|
+
return { ok: false, error: "old_string must be a non-empty string." };
|
|
104
|
+
}
|
|
105
|
+
if (content.includes(oldString)) {
|
|
106
|
+
const count = content.split(oldString).length - 1;
|
|
107
|
+
if (count > 1) {
|
|
108
|
+
return { ok: false, error: `old_string matches ${count} locations in ${path}; include more surrounding context to make it unique.` };
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, content: content.replace(oldString, () => newString), fuzzy: false };
|
|
111
|
+
}
|
|
112
|
+
const all = [...content.matchAll(new RegExp(wsInsensitiveRegex(oldString).source, "g"))];
|
|
113
|
+
if (all.length === 0) {
|
|
114
|
+
return { ok: false, error: `old_string not found in ${path}. Read the file again and retry with exact text.` };
|
|
115
|
+
}
|
|
116
|
+
if (all.length > 1) {
|
|
117
|
+
return { ok: false, error: `old_string not found exactly and ${all.length} whitespace-tolerant matches exist in ${path}; include more surrounding context to disambiguate.` };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
content: content.slice(0, all[0].index) + newString + content.slice(all[0].index + all[0][0].length),
|
|
122
|
+
fuzzy: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { safePath, toPosInt, toResult, clamp } from "./path-safety.js";
|
|
4
|
+
import { globToRegExp } from "./search.js";
|
|
5
|
+
export function toolListFiles(workdir, args) {
|
|
6
|
+
return toResult(() => {
|
|
7
|
+
const root = safePath(workdir, args.path ?? ".");
|
|
8
|
+
const skip = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
9
|
+
// v2: configurable depth (default 4, cap 8), hidden-dotfiles opt-in, exclude globs.
|
|
10
|
+
const maxDepth = Math.min(toPosInt(args.depth, 4), 8);
|
|
11
|
+
const includeHidden = args.include_hidden === true || args.include_hidden === "true";
|
|
12
|
+
const excludeRx = args.exclude
|
|
13
|
+
? new RegExp(args.exclude.split(/[,\s]+/).filter(Boolean)
|
|
14
|
+
.map((g) => globToRegExp(g).source)
|
|
15
|
+
.join("|"))
|
|
16
|
+
: null;
|
|
17
|
+
const out = [];
|
|
18
|
+
const walk = (dir, depth) => {
|
|
19
|
+
if (depth > maxDepth || out.length > 500)
|
|
20
|
+
return;
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(dir).sort();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const e of entries) {
|
|
29
|
+
if (skip.has(e))
|
|
30
|
+
continue;
|
|
31
|
+
if (!includeHidden && e.startsWith(".") && e !== ".github")
|
|
32
|
+
continue;
|
|
33
|
+
if (excludeRx?.test(e))
|
|
34
|
+
continue;
|
|
35
|
+
const full = join(dir, e);
|
|
36
|
+
let st;
|
|
37
|
+
try {
|
|
38
|
+
safePath(workdir, relative(workdir, full));
|
|
39
|
+
st = statSync(full);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const rel = relative(workdir, full).split(sep).join("/");
|
|
45
|
+
if (st.isDirectory()) {
|
|
46
|
+
out.push(`${rel}/`);
|
|
47
|
+
walk(full, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
out.push(`${rel} (${st.size}b)`);
|
|
51
|
+
}
|
|
52
|
+
if (out.length > 500) {
|
|
53
|
+
out.push("... [truncated]");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(root, 0);
|
|
59
|
+
return { ok: true, output: out.length > 0 ? clamp(out.join("\n")) : "(empty directory)" };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// grep / glob (I-07)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname, basename, relative, isAbsolute } from "node:path";
|
|
3
|
+
export let MAX_OUTPUT_CHARS = 20_000;
|
|
4
|
+
export function setMaxOutputChars(n) {
|
|
5
|
+
if (Number.isFinite(n) && n > 0)
|
|
6
|
+
MAX_OUTPUT_CHARS = Math.floor(n);
|
|
7
|
+
}
|
|
8
|
+
export const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
|
|
9
|
+
export function clamp(s, max = MAX_OUTPUT_CHARS) {
|
|
10
|
+
if (s.length <= max)
|
|
11
|
+
return s;
|
|
12
|
+
return s.slice(0, max) + `\n... [truncated, ${s.length - max} more chars]`;
|
|
13
|
+
}
|
|
14
|
+
/** Resolve a user-supplied path inside workdir; reject escapes and symlink traversal. */
|
|
15
|
+
export function safePath(workdir, p) {
|
|
16
|
+
const abs = isAbsolute(p) ? resolve(p) : resolve(workdir, p);
|
|
17
|
+
const relLex = relative(workdir, abs);
|
|
18
|
+
if (relLex.startsWith("..") || isAbsolute(relLex)) {
|
|
19
|
+
throw new Error(`Path escapes working directory: ${p}`);
|
|
20
|
+
}
|
|
21
|
+
let realWorkdir = workdir;
|
|
22
|
+
try {
|
|
23
|
+
realWorkdir = realpathSync(workdir);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const code = err.code;
|
|
27
|
+
if (code !== "ENOENT") {
|
|
28
|
+
throw new Error(`Cannot resolve working directory "${workdir}": ${err.message}`, { cause: err });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let probe = abs;
|
|
32
|
+
const missing = [];
|
|
33
|
+
for (;;) {
|
|
34
|
+
try {
|
|
35
|
+
const real = realpathSync(probe);
|
|
36
|
+
const reconstructed = real + [...missing].reverse().map((s) => `/${s}`).join("");
|
|
37
|
+
const rel = relative(realWorkdir, reconstructed.length > 0 ? reconstructed : real);
|
|
38
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
39
|
+
throw new Error(`Path escapes working directory: ${p}`);
|
|
40
|
+
}
|
|
41
|
+
return abs;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err.code === "ENOENT" && probe !== dirname(probe)) {
|
|
45
|
+
missing.push(basename(probe));
|
|
46
|
+
probe = dirname(probe);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Turn a sync tool step into a ToolResult, converting thrown errors (e.g. path escapes) into structured results. */
|
|
54
|
+
export function toResult(fn) {
|
|
55
|
+
try {
|
|
56
|
+
return fn();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const PROTECTED_READ_PATTERNS = [/^\.env$/, /\/\.env$/, /^\.env\.[^/]+$/, /\/\.env\.[^/]+$/];
|
|
63
|
+
const PROTECTED_READ_EXEMPT = [/^\.env\.example$/, /\/\.env\.example$/, /^\.env\.sample$/, /\/\.env\.sample$/, /^\.env\.template$/, /\/\.env\.template$/];
|
|
64
|
+
export function isProtectedEnvFile(relPath) {
|
|
65
|
+
if (PROTECTED_READ_EXEMPT.some((rx) => rx.test(relPath)))
|
|
66
|
+
return "allow";
|
|
67
|
+
if (PROTECTED_READ_PATTERNS.some((rx) => rx.test(relPath)))
|
|
68
|
+
return "ask";
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Coerce a schema-provided numeric-ish arg to a positive int, else fallback. */
|
|
72
|
+
export function toPosInt(v, fallback) {
|
|
73
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
74
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
|
|
75
|
+
}
|