thincoder 0.8.11 → 0.8.13
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 +27 -0
- package/bin/thincoder.mjs +115 -0
- package/package.json +1 -1
- package/src/advisor.mjs +105 -0
- package/src/agent/dispatch.mjs +35 -0
- package/src/agent/setup.mjs +9 -10
- package/src/agent-tools/subagent.mjs +1 -1
- package/src/agent-tools/timer.mjs +41 -0
- package/src/agent-tools/verify.mjs +165 -56
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +128 -21
- package/src/auto-think.mjs +83 -0
- package/src/cli/make-agent.mjs +9 -0
- package/src/config.mjs +18 -18
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/git/checkpoint.mjs +2 -1
- package/src/git/gitmem.mjs +8 -2
- package/src/markdown.mjs +1 -1
- package/src/mcp/transport-http.mjs +11 -4
- package/src/memory/code-index.mjs +2 -2
- package/src/memory/code-sync.mjs +92 -35
- package/src/memory/core.mjs +10 -1
- package/src/memory/docs.mjs +25 -28
- package/src/memory/schema.mjs +16 -3
- package/src/prompts/coder.md +7 -4
- package/src/prompts/discipline.md +47 -15
- package/src/prompts/main.md +15 -11
- package/src/prompts/system.md +33 -7
- package/src/provider/core.mjs +142 -15
- package/src/provider/index.mjs +1 -1
- package/src/rules.mjs +53 -0
- package/src/session.mjs +9 -3
- package/src/tools/file.mjs +114 -5
- package/src/tools/hashline_edit.md +12 -0
- package/src/tools/index.mjs +6 -4
- package/src/tools/linter.md +13 -0
- package/src/tools/linter.mjs +146 -0
- package/src/tools/patch.mjs +7 -3
- package/src/tools/read.md +3 -2
- package/src/tools/repomap.mjs +19 -10
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +17 -2
- package/src/tui/ansi.mjs +5 -0
- package/src/tui/cmd-advisor.mjs +68 -0
- package/src/tui/cmd-think.mjs +36 -10
- package/src/tui/index.mjs +167 -54
- package/src/tui/key-handler.mjs +36 -1
- package/src/tui/layout.mjs +6 -4
- package/src/tui/pickers.mjs +15 -15
- package/src/tui/render-frame.mjs +240 -167
- package/src/tui/slash-commands.mjs +3 -0
- package/src/tools/repomap-parse.mjs +0 -168
package/src/tools/file.mjs
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
autoSyntaxCheck,
|
|
7
7
|
resolveInCwd,
|
|
8
8
|
resolveExternal,
|
|
9
|
+
normalizeEOL,
|
|
9
10
|
} from "./shared.mjs";
|
|
11
|
+
import { specForModel } from "../config.mjs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
10
13
|
import { mkdir } from "node:fs/promises";
|
|
11
14
|
import { readFile } from "node:fs/promises";
|
|
12
15
|
import { stat } from "node:fs/promises";
|
|
@@ -27,6 +30,7 @@ export const readTool = {
|
|
|
27
30
|
offset: { type: "number", description: "1-based line number to start from" },
|
|
28
31
|
limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
|
|
29
32
|
allowExternal: { type: "boolean", description: "Allow reading files outside the working directory. Only set true when the user explicitly provided an external path — never use this to explore beyond cwd on your own." },
|
|
33
|
+
hashes: { type: "boolean", description: "Include SHA256 line hashes for hash-based editing (default false). Use when you plan to edit the file with hashline_edit." },
|
|
30
34
|
},
|
|
31
35
|
required: ["path"],
|
|
32
36
|
},
|
|
@@ -36,12 +40,19 @@ export const readTool = {
|
|
|
36
40
|
// Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
|
|
37
41
|
const st = await stat(abs).catch(() => null)
|
|
38
42
|
if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
|
|
39
|
-
const content = await readFile(abs, "utf8")
|
|
43
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
40
44
|
const lines = content.split("\n")
|
|
41
45
|
const offset = Math.max(1, args.offset ?? 1)
|
|
42
46
|
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
43
47
|
const slice = lines.slice(offset - 1, offset - 1 + limit)
|
|
44
|
-
const numbered = slice.map((l, i) =>
|
|
48
|
+
const numbered = slice.map((l, i) => {
|
|
49
|
+
const ln = offset + i
|
|
50
|
+
if (args.hashes) {
|
|
51
|
+
const h = createHash("sha256").update(l).digest("hex").slice(0, 12)
|
|
52
|
+
return `${ln}\t[${h}] ${l}`
|
|
53
|
+
}
|
|
54
|
+
return `${ln}\t${l}`
|
|
55
|
+
}).join("\n")
|
|
45
56
|
const suffix = offset - 1 + limit < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
|
|
46
57
|
return truncate(numbered + suffix)
|
|
47
58
|
},
|
|
@@ -65,6 +76,15 @@ export const readImageTool = {
|
|
|
65
76
|
multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
|
|
66
77
|
/** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
|
|
67
78
|
async execute(args, ctx) {
|
|
79
|
+
// Vision capability gate: injecting an image into a text-only model's history poisons the whole
|
|
80
|
+
// conversation (every subsequent request 400s on the image part). Refuse before reading the file.
|
|
81
|
+
const model = ctx.agent?.provider?.model
|
|
82
|
+
if (model && !specForModel(model).multimodal) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Model "${model}" does not support image input — read_image is unavailable with this provider. ` +
|
|
85
|
+
`Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.`
|
|
86
|
+
)
|
|
87
|
+
}
|
|
68
88
|
const abs = resolveInCwd(ctx, args.path)
|
|
69
89
|
const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
|
|
70
90
|
const mime = IMAGE_EXTENSIONS[ext]
|
|
@@ -134,7 +154,7 @@ export const editTool = {
|
|
|
134
154
|
if (!args.old_string) {
|
|
135
155
|
throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
|
|
136
156
|
}
|
|
137
|
-
const content = await readFile(abs, "utf8")
|
|
157
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
138
158
|
const occurrences = content.split(args.old_string).length - 1
|
|
139
159
|
if (occurrences === 0) {
|
|
140
160
|
// Give clues to help the model locate: first-line preview + common causes
|
|
@@ -176,7 +196,7 @@ export const insertAfterTool = {
|
|
|
176
196
|
readonly: false,
|
|
177
197
|
async execute(args, ctx) {
|
|
178
198
|
const abs = resolveInCwd(ctx, args.path)
|
|
179
|
-
const text = await readFile(abs, "utf8")
|
|
199
|
+
const text = normalizeEOL(await readFile(abs, "utf8"))
|
|
180
200
|
const lines = text.split("\n")
|
|
181
201
|
|
|
182
202
|
let targetLine
|
|
@@ -189,7 +209,12 @@ export const insertAfterTool = {
|
|
|
189
209
|
throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
|
|
190
210
|
}
|
|
191
211
|
} else if (args.after_regex) {
|
|
192
|
-
|
|
212
|
+
let regex
|
|
213
|
+
try {
|
|
214
|
+
regex = new RegExp(args.after_regex)
|
|
215
|
+
} catch (e) {
|
|
216
|
+
throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`)
|
|
217
|
+
}
|
|
193
218
|
const matches = []
|
|
194
219
|
for (let i = 0; i < lines.length; i++) {
|
|
195
220
|
if (regex.test(lines[i])) matches.push(i + 1)
|
|
@@ -209,3 +234,87 @@ export const insertAfterTool = {
|
|
|
209
234
|
},
|
|
210
235
|
}
|
|
211
236
|
|
|
237
|
+
// ---------------------------------------------------------------- hashline_edit
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Compute a 12-char hex SHA256 hash for a line (exact content, no trimming).
|
|
241
|
+
* Used by both read (hashes=true) and hashline_edit for hash-based matching.
|
|
242
|
+
*/
|
|
243
|
+
export function hashLine(content) {
|
|
244
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 12)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const hashlineEditTool = {
|
|
248
|
+
name: "hashline_edit",
|
|
249
|
+
description: DESC("hashline_edit"),
|
|
250
|
+
parameters: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
path: { type: "string", description: "File path" },
|
|
254
|
+
old_hashes: { type: "array", items: { type: "string" }, description: "SHA256 hashes (12 chars) of the lines to replace. Read the file with hashes=true first to obtain these hashes. Single line: pass 1 hash; multiple lines: pass the exact sequence of hashes." },
|
|
255
|
+
new_content: { type: "string", description: "Replacement text (can span multiple lines)" },
|
|
256
|
+
},
|
|
257
|
+
required: ["path", "old_hashes", "new_content"],
|
|
258
|
+
},
|
|
259
|
+
readonly: false,
|
|
260
|
+
async execute(args, ctx) {
|
|
261
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
262
|
+
if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
|
|
263
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
264
|
+
const lines = content.split("\n")
|
|
265
|
+
const fileHashes = lines.map((l) => hashLine(l))
|
|
266
|
+
const target = args.old_hashes
|
|
267
|
+
|
|
268
|
+
// Sliding-window match: find all occurrences of the hash sequence.
|
|
269
|
+
// When multiple matches are found (e.g. empty lines), report positions so the
|
|
270
|
+
// model can include more context lines (adjacent lines with unique hashes).
|
|
271
|
+
const matches = []
|
|
272
|
+
for (let i = 0; i <= fileHashes.length - target.length; i++) {
|
|
273
|
+
let match = true
|
|
274
|
+
for (let j = 0; j < target.length; j++) {
|
|
275
|
+
if (fileHashes[i + j] !== target[j]) { match = false; break }
|
|
276
|
+
}
|
|
277
|
+
if (match) matches.push(i)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (matches.length === 0) {
|
|
281
|
+
// Help the model recover: show the current file hashes for context
|
|
282
|
+
const maxShow = Math.min(fileHashes.length, 50)
|
|
283
|
+
const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
|
|
284
|
+
const preview = target.join(" ")
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Hash sequence not found in ${args.path}: ${preview}\n` +
|
|
287
|
+
`The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}`
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (matches.length > 1) {
|
|
292
|
+
const ctx = 2 // lines of surrounding context
|
|
293
|
+
const detail = matches.map((m) => {
|
|
294
|
+
const start = Math.max(0, m - ctx)
|
|
295
|
+
const end = Math.min(lines.length, m + target.length + ctx)
|
|
296
|
+
const preview = lines.slice(start, end).map((l, i) => {
|
|
297
|
+
const ln = start + i + 1
|
|
298
|
+
const marker = m <= ln - 1 && ln - 1 < m + target.length ? ">" : " "
|
|
299
|
+
return `${marker} L${ln}: ${l.slice(0, 80)}`
|
|
300
|
+
}).join("\n")
|
|
301
|
+
return ` Match at line ${m + 1} (${target.length} line(s)):\n${preview}`
|
|
302
|
+
}).join("\n\n")
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Hash sequence matches ${matches.length} positions in ${args.path} — ambiguous.\n` +
|
|
305
|
+
`Include more surrounding lines (unique-hash lines before/after the target) to disambiguate.\n\n` +
|
|
306
|
+
`All matches with surrounding context:\n\n${detail}`
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const pos = matches[0]
|
|
311
|
+
// Replace: remove old lines, insert new lines at the same position
|
|
312
|
+
const newLines = args.new_content.split("\n")
|
|
313
|
+
lines.splice(pos, target.length, ...newLines)
|
|
314
|
+
const updated = lines.join("\n")
|
|
315
|
+
await writeFile(abs, updated, "utf8")
|
|
316
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
317
|
+
return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Edit a file using content-hash addressing instead of string matching. More reliable than edit when whitespace or encoding varies — hashes are computed from exact line bytes on disk.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path
|
|
5
|
+
- old_hashes (required): Array of SHA256 hashes (12-char hex) identifying lines to replace. Read the file with hashes=true first to obtain these hashes. For a single line, pass [hash]; for a contiguous block, pass [hash1, hash2, ...] in order.
|
|
6
|
+
- new_content (required): Replacement text (multi-line ok, \n separated)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- The hash of each line is computed as SHA256(line_content).slice(0, 12) — the same algorithm used by read(hashes=true)
|
|
10
|
+
- Hashes are position-independent: they identify lines by content, not by line number (which changes after edits)
|
|
11
|
+
- If the hash sequence isn't found, the error will include the current file's hashes so you can retry with corrected values
|
|
12
|
+
- Prefer this over edit when: 1) the file may have mixed whitespace/encoding, 2) you want to edit a block of lines with a single call
|
package/src/tools/index.mjs
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
// tools/index.mjs — backend-compatible re-export
|
|
2
2
|
export { toOpenAISchema } from "./shared.mjs";
|
|
3
3
|
|
|
4
|
-
import { readTool, writeTool, editTool, insertAfterTool, readImageTool } from "./file.mjs";
|
|
4
|
+
import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
|
|
5
5
|
import { applyPatchTool, syntaxCheckTool, deleteTool } from "./patch.mjs";
|
|
6
6
|
import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
|
|
7
7
|
import { websearchTool, fetchTool } from "./web.mjs";
|
|
8
8
|
import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
|
|
9
9
|
import { checklistTool } from "./checklist.mjs";
|
|
10
|
+
import { linterTool } from "./linter.mjs";
|
|
10
11
|
|
|
11
12
|
export const builtinTools = [
|
|
12
|
-
readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
|
|
13
|
+
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
13
14
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
14
15
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
15
16
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
16
|
-
checklistTool,
|
|
17
|
+
checklistTool, linterTool,
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
export {
|
|
20
|
-
readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
|
|
21
|
+
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
21
22
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
22
23
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
23
24
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
25
|
+
checklistTool, linterTool,
|
|
24
26
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (optional): File to check (default: most recently modified file)
|
|
5
|
+
|
|
6
|
+
Supported languages:
|
|
7
|
+
- .js/.mjs/.cjs/.jsx: eslint (if config present) → node --check (built-in, always available)
|
|
8
|
+
- .ts/.tsx/.mts/.cts: eslint → tsc --noEmit (if tsconfig.json present) → node --check
|
|
9
|
+
- .py: ruff (if installed)
|
|
10
|
+
- .rs: cargo check (if Cargo.toml present)
|
|
11
|
+
- .go: go vet
|
|
12
|
+
|
|
13
|
+
Does NOT install anything — only uses tools already available in the project.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { DESC, resolveInCwd } from "./shared.mjs"
|
|
2
|
+
|
|
3
|
+
export const linterTool = {
|
|
4
|
+
name: "linter",
|
|
5
|
+
description: DESC("linter"),
|
|
6
|
+
parameters: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
path: { type: "string", description: "File path (default: most recently modified file)" },
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
readonly: true,
|
|
13
|
+
async execute(args, ctx) {
|
|
14
|
+
const { execFileSync } = await import("node:child_process")
|
|
15
|
+
const { existsSync } = await import("node:fs")
|
|
16
|
+
const { join, relative } = await import("node:path")
|
|
17
|
+
const abs = args.path ? resolveInCwd(ctx, args.path) : (ctx.agent?._touchedFiles?.at(-1) || null)
|
|
18
|
+
if (!abs) return "linter: no file specified and no recently modified file to check"
|
|
19
|
+
|
|
20
|
+
const ext = abs.split(".").pop()?.toLowerCase()
|
|
21
|
+
const checkers = LANG_CHECKERS[ext]
|
|
22
|
+
if (!checkers) return `linter: no linter configured for .${ext} files. Supported: ${Object.keys(LANG_CHECKERS).map(e => `.${e}`).join(", ")}`
|
|
23
|
+
|
|
24
|
+
for (const checker of checkers) {
|
|
25
|
+
const result = await checker(abs, { cwd: ctx.cwd, existsSync, execFileSync, join, relative })
|
|
26
|
+
if (result !== null) return result
|
|
27
|
+
}
|
|
28
|
+
return `linter: no linter available for ${args.path || abs}. Install one?`
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Checker definitions ──────────────────────
|
|
33
|
+
|
|
34
|
+
async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
|
|
35
|
+
// Walk up to find eslint config
|
|
36
|
+
let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
|
|
37
|
+
while (true) {
|
|
38
|
+
for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
|
|
39
|
+
if (existsSync(join(cwd, dir, cfg))) {
|
|
40
|
+
try {
|
|
41
|
+
const cfgDir = join(cwd, dir)
|
|
42
|
+
const relPath = relative(cfgDir, file)
|
|
43
|
+
execFileSync("npx", ["eslint", "--no-color", "--format", "compact", relPath], {
|
|
44
|
+
cwd: cfgDir, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
})
|
|
46
|
+
return "✓ eslint: no issues"
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const stdout = (e.stdout || "").trim()
|
|
49
|
+
if (stdout) return stdout
|
|
50
|
+
return `✗ eslint: ${(e.stderr || e.message).slice(0, 500)}`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const parent = dir.split("/").slice(0, -1).join("/")
|
|
55
|
+
if (!parent || parent === dir) break
|
|
56
|
+
dir = parent
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
|
|
62
|
+
if (!existsSync(join(cwd, "tsconfig.json"))) return null
|
|
63
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(file)) return null
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("npx", ["tsc", "--noEmit", "--pretty", "false"], {
|
|
66
|
+
cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
})
|
|
68
|
+
return "✓ tsc: no type errors"
|
|
69
|
+
} catch (e) {
|
|
70
|
+
const stdout = (e.stdout || "").trim()
|
|
71
|
+
if (stdout) return stdout
|
|
72
|
+
return `✗ tsc: ${(e.stderr || e.message).slice(0, 500)}`
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function nodeCheck(file, { execFileSync, cwd }) {
|
|
77
|
+
if (!/\.(m?js|cjs)$/.test(file)) return null
|
|
78
|
+
try {
|
|
79
|
+
execFileSync(process.execPath, ["--check", file], {
|
|
80
|
+
cwd, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
})
|
|
82
|
+
return "✓ node --check: Syntax OK"
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return `✗ node --check: ${(e.stderr || e.message).slice(0, 500)}`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function ruffCheck(file, { cwd, execFileSync }) {
|
|
89
|
+
if (!/\.py$/.test(file)) return null
|
|
90
|
+
try {
|
|
91
|
+
execFileSync("ruff", ["check", "--output-format", "concise", file], {
|
|
92
|
+
cwd, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
})
|
|
94
|
+
return "✓ ruff: no issues"
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code === "ENOENT") return "linter: ruff not installed. Run: pip install ruff"
|
|
97
|
+
const stdout = (e.stdout || "").trim()
|
|
98
|
+
if (stdout) return stdout
|
|
99
|
+
return `✗ ruff: ${(e.stderr || e.message).slice(0, 500)}`
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cargoCheck(file, { cwd, existsSync, execFileSync, join }) {
|
|
104
|
+
if (!/\.rs$/.test(file)) return null
|
|
105
|
+
if (!existsSync(join(cwd, "Cargo.toml"))) return null
|
|
106
|
+
const fname = file.split(/[\\/]/).pop()
|
|
107
|
+
try {
|
|
108
|
+
const out = execFileSync("cargo", ["check", "--message-format", "short"], {
|
|
109
|
+
cwd, encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
})
|
|
111
|
+
const errors = out.split("\n").filter(l => l.includes(fname))
|
|
112
|
+
return errors.length > 0 ? errors.join("\n") : "✓ cargo check: no errors"
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const combined = ((e.stdout || "") + "\n" + (e.stderr || "")).trim()
|
|
115
|
+
const errors = combined.split("\n").filter(l => l.includes(fname) || l.startsWith("error"))
|
|
116
|
+
return errors.length > 0 ? errors.join("\n") : `✗ cargo check failed:\n${combined.slice(0, 1000)}`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function goVet(file, { cwd, execFileSync }) {
|
|
121
|
+
if (!/\.go$/.test(file)) return null
|
|
122
|
+
try {
|
|
123
|
+
execFileSync("go", ["vet", file], {
|
|
124
|
+
cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"],
|
|
125
|
+
})
|
|
126
|
+
return "✓ go vet: no issues"
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return `✗ go vet: ${(e.stderr || e.message).slice(0, 500)}`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Language → checkers (first available wins) ──
|
|
133
|
+
|
|
134
|
+
const LANG_CHECKERS = {
|
|
135
|
+
js: [eslintCheck, nodeCheck],
|
|
136
|
+
mjs: [eslintCheck, nodeCheck],
|
|
137
|
+
cjs: [eslintCheck, nodeCheck],
|
|
138
|
+
jsx: [eslintCheck, nodeCheck],
|
|
139
|
+
ts: [eslintCheck, tscCheck],
|
|
140
|
+
tsx: [eslintCheck, tscCheck],
|
|
141
|
+
mts: [eslintCheck, tscCheck],
|
|
142
|
+
cts: [eslintCheck, tscCheck],
|
|
143
|
+
py: [ruffCheck],
|
|
144
|
+
rs: [cargoCheck],
|
|
145
|
+
go: [goVet],
|
|
146
|
+
}
|
package/src/tools/patch.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { mkdir } from "node:fs/promises";
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
|
-
import { stat } from "node:fs/promises";
|
|
9
|
+
import { stat, lstat } from "node:fs/promises";
|
|
10
10
|
import { writeFile } from "node:fs/promises";
|
|
11
11
|
import { unlink } from "node:fs/promises";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
@@ -206,8 +206,12 @@ export const deleteTool = {
|
|
|
206
206
|
readonly: false,
|
|
207
207
|
async execute(args, ctx) {
|
|
208
208
|
const abs = resolveInCwd(ctx, args.path)
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
let s
|
|
210
|
+
try {
|
|
211
|
+
s = await lstat(abs)
|
|
212
|
+
} catch {
|
|
213
|
+
throw new Error(`File not found: ${args.path}`)
|
|
214
|
+
}
|
|
211
215
|
if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
|
|
212
216
|
// git-tracked files: refuse direct deletion (safety net); untracked: allow
|
|
213
217
|
// Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
|
package/src/tools/read.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Read a text file. Returns numbered lines. Use offset/limit to page large files.
|
|
2
|
-
|
|
3
2
|
Parameters:
|
|
4
|
-
- path (required): File path, relative to cwd or absolute
|
|
3
|
+
- path (required): File path, relative to cwd or absolute (alias: filePath)
|
|
5
4
|
- offset: 1-based line number to start reading from
|
|
6
5
|
- limit: Max lines to return (default 2000)
|
|
6
|
+
- hashes: Include SHA256 content hashes per line (for hashline_edit). Set true before using hashline_edit.
|
|
7
7
|
|
|
8
8
|
Notes:
|
|
9
9
|
- Always prefer this over `cat` or shell-based reading — it caps output and avoids large dumps
|
|
10
10
|
- Use offset for pagination when the file is large
|
|
11
|
+
- When you plan to edit the file, set hashes=true to get line hashes for hashline_edit — hash-based editing avoids whitespace/encoding matching failures
|
package/src/tools/repomap.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repomap.mjs — repo dependency outline
|
|
2
|
+
* repomap.mjs — repo dependency outline
|
|
3
3
|
* Real-time import/export parsing, generates compact text for LLMs to understand code structure.
|
|
4
4
|
* No index stored — reads and parses files on each call, ~50ms.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import { readFile, stat } from "node:fs/promises"
|
|
7
8
|
import { join } from "node:path"
|
|
9
|
+
import { normalizeEOL } from "./shared.mjs"
|
|
8
10
|
|
|
9
11
|
/** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
|
|
10
12
|
function parseImports(lines, ext) {
|
|
@@ -113,17 +115,22 @@ function normalizeExt(p) {
|
|
|
113
115
|
* Internal: scan all files, build forward dependency graph + reverse reference graph.
|
|
114
116
|
* Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
|
|
115
117
|
*/
|
|
116
|
-
function _buildDepGraph(db, cwd) {
|
|
118
|
+
async function _buildDepGraph(db, cwd) {
|
|
117
119
|
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
118
120
|
if (allFiles.length === 0) return null
|
|
119
121
|
|
|
120
122
|
const deps = new Map() // path → { imports: Set, exports: Set, size: number, dir: string }
|
|
121
123
|
const importers = new Map() // importee → Set<importer>
|
|
122
124
|
|
|
123
|
-
for (
|
|
125
|
+
for (let i = 0; i < allFiles.length; i++) {
|
|
126
|
+
const rel = allFiles[i]
|
|
124
127
|
const abs = join(cwd, ...rel.split("/"))
|
|
125
128
|
if (!existsSync(abs)) continue
|
|
126
|
-
|
|
129
|
+
// Large file guard: skip files over 10MB to prevent OOM
|
|
130
|
+
const fst = await stat(abs).catch(() => null)
|
|
131
|
+
if (fst && fst.size > 10_000_000) continue
|
|
132
|
+
let text
|
|
133
|
+
try { text = normalizeEOL(await readFile(abs, "utf8")) } catch { continue }
|
|
127
134
|
const lines = text.split("\n")
|
|
128
135
|
const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
|
|
129
136
|
|
|
@@ -160,6 +167,8 @@ function _buildDepGraph(db, cwd) {
|
|
|
160
167
|
if (!importers.has(r)) importers.set(r, new Set())
|
|
161
168
|
importers.get(r).add(rel)
|
|
162
169
|
}
|
|
170
|
+
// Yield the event loop every 20 files to prevent TUI freeze
|
|
171
|
+
if (i % 20 === 19) await new Promise(r => setImmediate(r))
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
return { deps, importers, fileCount: allFiles.length }
|
|
@@ -173,8 +182,8 @@ function _buildDepGraph(db, cwd) {
|
|
|
173
182
|
* 3. Entry points (files with no importers — startup/top-level entry points)
|
|
174
183
|
* Output is naturally bounded (~1000-2000 chars), no more OUTLINE_INJECT_MAX hard truncation.
|
|
175
184
|
*/
|
|
176
|
-
export function buildSummary(db, cwd) {
|
|
177
|
-
const graph = _buildDepGraph(db, cwd)
|
|
185
|
+
export async function buildSummary(db, cwd) {
|
|
186
|
+
const graph = await _buildDepGraph(db, cwd)
|
|
178
187
|
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
179
188
|
const { deps, importers, fileCount } = graph
|
|
180
189
|
|
|
@@ -248,8 +257,8 @@ export function buildSummary(db, cwd) {
|
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
/** Get known file list from code_chunks (reuse index), parse by path to generate outline text */
|
|
251
|
-
export function buildOutline(db, cwd, focusPath) {
|
|
252
|
-
const graph = _buildDepGraph(db, cwd)
|
|
260
|
+
export async function buildOutline(db, cwd, focusPath) {
|
|
261
|
+
const graph = await _buildDepGraph(db, cwd)
|
|
253
262
|
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
254
263
|
const { deps, importers } = graph
|
|
255
264
|
|
|
@@ -298,7 +307,7 @@ export function repoOutlineTool(db, cwd) {
|
|
|
298
307
|
},
|
|
299
308
|
readonly: true,
|
|
300
309
|
async execute(args) {
|
|
301
|
-
const outline = buildOutline(db, cwd, args.path ?? null)
|
|
310
|
+
const outline = await buildOutline(db, cwd, args.path ?? null)
|
|
302
311
|
return outline
|
|
303
312
|
},
|
|
304
313
|
}
|
package/src/tools/shared.mjs
CHANGED
|
@@ -20,6 +20,13 @@ export const BASH_TIMEOUT_MS = 120_000
|
|
|
20
20
|
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
21
21
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
22
22
|
|
|
23
|
+
/** Normalize Windows line endings to Unix: \r\n → \n.
|
|
24
|
+
* Applied on every text-file read so that edit/hash matching
|
|
25
|
+
* and hash computation are platform-consistent. */
|
|
26
|
+
export function normalizeEOL(text) {
|
|
27
|
+
return text.replace(/\r\n/g, "\n")
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
/** Convert to OpenAI tools parameter format */
|
|
24
31
|
export function toOpenAISchema(tool) {
|
|
25
32
|
return {
|
package/src/tools/system.mjs
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
isDestructiveCommand,
|
|
12
12
|
hasFileRedirection,
|
|
13
13
|
insideGitRepo,
|
|
14
|
-
globToRegex
|
|
14
|
+
globToRegex,
|
|
15
|
+
normalizeEOL,
|
|
15
16
|
} from "./shared.mjs";
|
|
16
17
|
import { spawn, execFileSync } from "node:child_process";
|
|
17
18
|
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
@@ -222,6 +223,8 @@ async function* walkFiles(dir, rel = "") {
|
|
|
222
223
|
return
|
|
223
224
|
}
|
|
224
225
|
for (const e of entries) {
|
|
226
|
+
// Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
|
|
227
|
+
if (e.isSymbolicLink()) continue
|
|
225
228
|
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
226
229
|
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
227
230
|
if (e.isDirectory()) {
|
|
@@ -253,7 +256,12 @@ export const grepTool = {
|
|
|
253
256
|
readonly: true,
|
|
254
257
|
async execute(args, ctx) {
|
|
255
258
|
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
256
|
-
|
|
259
|
+
let regex
|
|
260
|
+
try {
|
|
261
|
+
regex = new RegExp(args.pattern)
|
|
262
|
+
} catch (e) {
|
|
263
|
+
throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
|
|
264
|
+
}
|
|
257
265
|
const fileFilter = args.glob ? globToRegex(args.glob) : null
|
|
258
266
|
const before = Math.max(0, Math.floor(args.before ?? 0))
|
|
259
267
|
const after = Math.max(0, Math.floor(args.after ?? 0))
|
|
@@ -267,7 +275,7 @@ export const grepTool = {
|
|
|
267
275
|
// Large file guard: skip files over 10MB to prevent OOM
|
|
268
276
|
const fst = await stat(file)
|
|
269
277
|
if (fst.size > 10_000_000) return
|
|
270
|
-
content = await readFile(file, "utf8")
|
|
278
|
+
content = normalizeEOL(await readFile(file, "utf8"))
|
|
271
279
|
} catch {
|
|
272
280
|
return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
|
|
273
281
|
}
|
|
@@ -346,7 +354,13 @@ export const lsTool = {
|
|
|
346
354
|
readonly: true,
|
|
347
355
|
async execute(args, ctx) {
|
|
348
356
|
const abs = resolveInCwd(ctx, args.path ?? ".")
|
|
349
|
-
|
|
357
|
+
let entries
|
|
358
|
+
try {
|
|
359
|
+
entries = await readdir(abs, { withFileTypes: true })
|
|
360
|
+
} catch (e) {
|
|
361
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`)
|
|
362
|
+
throw e
|
|
363
|
+
}
|
|
350
364
|
const rows = await Promise.all(
|
|
351
365
|
entries.slice(0, 500).map(async (e) => {
|
|
352
366
|
const s = await stat(join(abs, e.name)).catch(() => null)
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
30
30
|
state.currentTool = null
|
|
31
31
|
state.processingStarted = Date.now()
|
|
32
32
|
state.controller = new AbortController()
|
|
33
|
+
state.interruptPrompt = null
|
|
33
34
|
// Refresh status bar every second during processing (elapsed timer)
|
|
34
35
|
const ticker = setInterval(() => {
|
|
35
36
|
if (state.processing) render()
|
|
@@ -179,10 +180,13 @@ export async function runAgentTurn(ctx, text) {
|
|
|
179
180
|
state.tokens.completion += usage.completion_tokens ?? 0
|
|
180
181
|
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
181
182
|
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
183
|
+
state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
|
|
182
184
|
},
|
|
183
185
|
// Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
|
|
184
186
|
onWait: ({ phase, seconds }) => {
|
|
185
|
-
|
|
187
|
+
if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
|
|
188
|
+
else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
|
|
189
|
+
else state.status = `Rate-limited 429, retry in ${seconds}s`
|
|
186
190
|
render()
|
|
187
191
|
},
|
|
188
192
|
onTaskUpdate: (items) => {
|
|
@@ -193,12 +197,15 @@ export async function runAgentTurn(ctx, text) {
|
|
|
193
197
|
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
194
198
|
render()
|
|
195
199
|
},
|
|
200
|
+
onAdvisor: (note) => {
|
|
201
|
+
pushLine(` [advisor] ${note.replace(/\n/g, "\n ")}`, C.advisor)
|
|
202
|
+
},
|
|
196
203
|
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
197
204
|
onTurnEnd: (() => {
|
|
198
205
|
let n = 0
|
|
199
206
|
return () => {
|
|
200
207
|
if (++n % 5 !== 0) return
|
|
201
|
-
try { saveSession(agent, state.lines) } catch {}
|
|
208
|
+
try { saveSession(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
202
209
|
}
|
|
203
210
|
})(),
|
|
204
211
|
}
|
|
@@ -211,6 +218,14 @@ export async function runAgentTurn(ctx, text) {
|
|
|
211
218
|
} catch (error) {
|
|
212
219
|
flushStream()
|
|
213
220
|
if (error.name === "AbortError" || state.controller?.signal.aborted) {
|
|
221
|
+
// Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
|
|
222
|
+
// may have already injected it into history, but the aborted signal prevents retry.
|
|
223
|
+
// Recreate the controller and resume from the same context.
|
|
224
|
+
if (state.controller?.signal?.reason?.interrupt) {
|
|
225
|
+
state.controller = new AbortController()
|
|
226
|
+
resume = true
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
214
229
|
pushLine("[stopped]", C.warn)
|
|
215
230
|
break
|
|
216
231
|
}
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -17,6 +17,10 @@ export const ansi = {
|
|
|
17
17
|
clearLine: `${ESC}[K`,
|
|
18
18
|
clearToEnd: `${ESC}[J`,
|
|
19
19
|
clearScreen: `${ESC}[2J`,
|
|
20
|
+
saveCursor: `${ESC}7`, // DECSC — save cursor position
|
|
21
|
+
restoreCursor: `${ESC}8`, // DECRC — restore cursor position
|
|
22
|
+
syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
|
|
23
|
+
syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
|
|
20
24
|
reset: `${ESC}[0m`,
|
|
21
25
|
dim: `${ESC}[2m`,
|
|
22
26
|
bold: `${ESC}[1m`,
|
|
@@ -33,4 +37,5 @@ export const C = {
|
|
|
33
37
|
error: ansi.fg(1),
|
|
34
38
|
dim: ansi.gray,
|
|
35
39
|
warn: ansi.fg(3),
|
|
40
|
+
advisor: `${ESC}[92m`, // bright green — visible on dark backgrounds
|
|
36
41
|
}
|