thincoder 0.8.2 → 0.8.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 +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +12 -11
- package/src/agent-tools/plan.mjs +5 -6
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +7 -26
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +82 -92
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +56 -53
- package/src/context.mjs +59 -61
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +0 -6
- package/src/prompts/discipline.md +9 -0
- package/src/prompts/main.md +24 -24
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -42
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +20 -14
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/ls.md +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +55 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +5 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +3 -12
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +108 -23
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +11 -3
- package/src/tui/cmd-goal.mjs +3 -12
- package/src/tui/cmd-help.mjs +2 -2
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +38 -25
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +33 -13
- package/src/tui/cmd-plan.mjs +3 -12
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +4 -11
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +4 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +34 -26
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +22 -22
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/tools/patch.mjs
CHANGED
|
@@ -13,11 +13,12 @@ import { existsSync } from "node:fs";
|
|
|
13
13
|
import { join, relative, dirname } from "node:path";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
|
|
17
|
+
* Consume hunk lines by the line counts in the @@ header — LLMs often strip context blank lines to pure empty lines,
|
|
18
|
+
* so we use counts rather than first characters to determine hunk boundaries
|
|
18
19
|
*/
|
|
19
20
|
function parsePatch(patch) {
|
|
20
|
-
//
|
|
21
|
+
// Patch text often comes from CRLF terminals/model output; trailing \r mixed into hunk content breaks context matching, strip uniformly
|
|
21
22
|
const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
|
|
22
23
|
const files = []
|
|
23
24
|
let cur = null
|
|
@@ -48,7 +49,7 @@ function parsePatch(patch) {
|
|
|
48
49
|
if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
|
|
49
50
|
const hl = lines[i]
|
|
50
51
|
if (hl.startsWith("\\")) { i++; continue } // ""
|
|
51
|
-
const tag = hl === "" ? " " : hl[0] //
|
|
52
|
+
const tag = hl === "" ? " " : hl[0] // pure blank line: treat leniently as context line
|
|
52
53
|
const text = hl === "" ? "" : hl.slice(1)
|
|
53
54
|
if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
|
|
54
55
|
else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
|
|
@@ -59,13 +60,13 @@ function parsePatch(patch) {
|
|
|
59
60
|
cur.hunks.push(hunk)
|
|
60
61
|
continue
|
|
61
62
|
}
|
|
62
|
-
i++ // diff --git / index /
|
|
63
|
+
i++ // skip diff --git / index / blank lines and other metadata
|
|
63
64
|
}
|
|
64
65
|
if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
|
|
65
66
|
return files
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
/**
|
|
69
|
+
/** Apply hunks sequentially onto an in-memory line array; any failure throws (caller guarantees nothing is written to disk). Ignores trailing \r when comparing; context lines retain original bytes */
|
|
69
70
|
function applyHunks(fileLines, hunks, eol, path) {
|
|
70
71
|
const cr = eol === "\r\n" ? "\r" : ""
|
|
71
72
|
for (let h = 0; h < hunks.length; h++) {
|
|
@@ -88,7 +89,7 @@ function applyHunks(fileLines, hunks, eol, path) {
|
|
|
88
89
|
const out = []
|
|
89
90
|
let src = pos
|
|
90
91
|
for (const op of hunks[h].ops) {
|
|
91
|
-
if (op.type === " ") out.push(fileLines[src++]) //
|
|
92
|
+
if (op.type === " ") out.push(fileLines[src++]) // preserve original context line (trailing whitespace as-is)
|
|
92
93
|
else if (op.type === "-") src++
|
|
93
94
|
else out.push(op.text + cr)
|
|
94
95
|
}
|
|
@@ -107,13 +108,13 @@ export const applyPatchTool = {
|
|
|
107
108
|
required: ["patch"],
|
|
108
109
|
},
|
|
109
110
|
readonly: false,
|
|
110
|
-
/**
|
|
111
|
+
/** For the agent layer to track touched files (multi-path, replaces single path parameter) */
|
|
111
112
|
touchedPaths(args) {
|
|
112
113
|
try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
|
|
113
114
|
},
|
|
114
115
|
async execute(args, ctx) {
|
|
115
116
|
const files = parsePatch(args.patch ?? "")
|
|
116
|
-
//
|
|
117
|
+
// Read all into memory first for trial: if any hunk fails, abort entirely — never write a partial patch (atomicity)
|
|
117
118
|
const planned = []
|
|
118
119
|
for (const f of files) {
|
|
119
120
|
const abs = resolveInCwd(ctx, f.path)
|
|
@@ -129,7 +130,7 @@ export const applyPatchTool = {
|
|
|
129
130
|
planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
|
-
//
|
|
133
|
+
// Multi-file write: write all to .tmp first, rename only after all succeed — failure cleans up written .tmp without affecting committed files
|
|
133
134
|
const { rename, unlink } = await import("node:fs/promises")
|
|
134
135
|
const written = []
|
|
135
136
|
try {
|
|
@@ -142,17 +143,18 @@ export const applyPatchTool = {
|
|
|
142
143
|
await rename(p.abs + ".thincoder-tmp", p.abs)
|
|
143
144
|
}
|
|
144
145
|
} catch (renameError) {
|
|
145
|
-
// rename
|
|
146
|
+
// rename phase failed: clean up leftover .tmp files
|
|
146
147
|
for (const abs of written) {
|
|
147
148
|
try { await unlink(abs + ".thincoder-tmp") } catch {}
|
|
148
149
|
}
|
|
149
150
|
throw renameError
|
|
150
151
|
}
|
|
151
152
|
const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
|
|
152
|
-
const
|
|
153
|
-
const r = autoSyntaxCheck(p.abs)
|
|
153
|
+
const syntaxChecks = await Promise.all(planned.map(async (p) => {
|
|
154
|
+
const r = await autoSyntaxCheck(p.abs)
|
|
154
155
|
return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
|
|
155
|
-
})
|
|
156
|
+
}))
|
|
157
|
+
const syntaxResults = syntaxChecks.filter(Boolean).join("\n")
|
|
156
158
|
return `Applied patch to ${planned.length} file(s):\n${summary}${syntaxResults ? "\n\nSyntax checks:\n" + syntaxResults : ""}`
|
|
157
159
|
},
|
|
158
160
|
}
|
|
@@ -181,7 +183,7 @@ export const syntaxCheckTool = {
|
|
|
181
183
|
})
|
|
182
184
|
return `Syntax OK: ${args.path}`
|
|
183
185
|
} catch (e) {
|
|
184
|
-
// node --check
|
|
186
|
+
// node --check writes errors to stderr
|
|
185
187
|
const msg = (e.stderr || e.stdout || e.message || "").trim()
|
|
186
188
|
return `Syntax error in ${args.path}:\n${msg || "(unknown)"}`
|
|
187
189
|
}
|
|
@@ -207,15 +209,15 @@ export const deleteTool = {
|
|
|
207
209
|
if (!existsSync(abs)) throw new Error(`File not found: ${args.path}`)
|
|
208
210
|
const s = await stat(abs)
|
|
209
211
|
if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
|
|
210
|
-
// git
|
|
211
|
-
//
|
|
212
|
+
// git-tracked files: refuse direct deletion (safety net); untracked: allow
|
|
213
|
+
// Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
|
|
212
214
|
const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
|
|
213
215
|
let tracked = false
|
|
214
216
|
try {
|
|
215
217
|
execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
|
|
216
218
|
tracked = true
|
|
217
219
|
} catch {
|
|
218
|
-
//
|
|
220
|
+
// untracked / non-git repo
|
|
219
221
|
}
|
|
220
222
|
if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
|
|
221
223
|
await unlink(abs)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repomap-parse.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* repomap-parse.mjs — repo dependency graph parser (zero dependencies, pure regex)
|
|
3
|
+
* Gets known file list from code_chunks, parses each file's import/export relationships in real time,
|
|
4
|
+
* builds forward dependency graph + reverse reference graph. Shared by repomap.mjs's buildSummary / buildOutline.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, existsSync } from "node:fs"
|
|
7
7
|
import { join } from "node:path"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Scan all files, build forward dependency graph + reverse reference graph.
|
|
11
|
+
* Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
|
|
12
12
|
*/
|
|
13
13
|
export function buildDepGraph(db, cwd) {
|
|
14
14
|
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
@@ -34,7 +34,7 @@ export function buildDepGraph(db, cwd) {
|
|
|
34
34
|
exports = parseExports(lines, ext)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// Resolve import paths to relative paths (handle ./ ../)
|
|
38
38
|
const resolved = []
|
|
39
39
|
for (let imp of imports) {
|
|
40
40
|
if (imp.startsWith("./")) imp = imp.slice(2)
|
|
@@ -62,17 +62,17 @@ export function buildDepGraph(db, cwd) {
|
|
|
62
62
|
return { deps, importers, fileCount: allFiles.length }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// ----------------------------------------------------------
|
|
65
|
+
// ---------------------------------------------------------- internal implementation
|
|
66
66
|
|
|
67
67
|
function normalizeExt(p) {
|
|
68
68
|
return p.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
|
|
72
72
|
function parseImports(lines, ext) {
|
|
73
73
|
const imports = []
|
|
74
74
|
const text = lines.join("\n")
|
|
75
|
-
//
|
|
75
|
+
// standard import
|
|
76
76
|
const re = /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+\s*,?\s*(?:{[^}]*})?)\s*from\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g
|
|
77
77
|
let m
|
|
78
78
|
while ((m = re.exec(text))) {
|
|
@@ -90,7 +90,7 @@ function parseImports(lines, ext) {
|
|
|
90
90
|
return [...new Set(imports)]
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
93
|
+
/** Extract JS/TS file export symbols */
|
|
94
94
|
function parseExports(lines, ext) {
|
|
95
95
|
const exports = []
|
|
96
96
|
const text = lines.join("\n")
|
|
@@ -107,17 +107,17 @@ function parseExports(lines, ext) {
|
|
|
107
107
|
if (name) exports.push(name)
|
|
108
108
|
else if (!exports.some((e) => e === "default")) exports.push("default")
|
|
109
109
|
}
|
|
110
|
-
// export { a, b as c }
|
|
110
|
+
// export { a, b as c } — prefer the "as" alias as the exported name
|
|
111
111
|
const braceRe = /export\s*\{([^}]+)\}/g
|
|
112
112
|
while ((m = braceRe.exec(text))) {
|
|
113
113
|
for (const name of m[1].split(",")) {
|
|
114
114
|
const parts = name.trim().split(/\s+/)
|
|
115
|
-
// "a as b" → b
|
|
115
|
+
// "a as b" → b (exported name), "a" → a
|
|
116
116
|
const exported = parts.length >= 3 ? parts[2] : parts[0]
|
|
117
117
|
if (exported) exports.push(exported)
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
-
// export const { a, b } =
|
|
120
|
+
// export const { a, b } = ... (destructured export)
|
|
121
121
|
const destructRe = /export\s+(?:const|let|var)\s*\{([^}]+)\}\s*=/g
|
|
122
122
|
while ((m = destructRe.exec(text))) {
|
|
123
123
|
for (const name of m[1].split(",")) {
|
|
@@ -129,7 +129,7 @@ function parseExports(lines, ext) {
|
|
|
129
129
|
return [...new Set(exports)]
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/** Extract Python imports and top-level def/class */
|
|
133
133
|
function parsePyOutline(lines) {
|
|
134
134
|
const imports = []
|
|
135
135
|
const symbols = []
|
|
@@ -155,9 +155,9 @@ function parsePyOutline(lines) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* Python
|
|
159
|
-
*
|
|
160
|
-
*
|
|
158
|
+
* Python relative import → relative file path:
|
|
159
|
+
* Leading n dots mean go up n-1 levels ("." = current package), module dots become path separators.
|
|
160
|
+
* Non-relative imports (not starting with .) or bare package imports ("from . import x") return null.
|
|
161
161
|
*/
|
|
162
162
|
function pyRelPath(mod) {
|
|
163
163
|
if (!mod?.startsWith(".")) return null
|
package/src/tools/repomap.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repomap.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* repomap.mjs — repo dependency outline (zero dependencies, pure regex)
|
|
3
|
+
* Real-time import/export parsing, generates compact text for LLMs to understand code structure.
|
|
4
|
+
* No index stored — reads and parses files on each call, ~50ms.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, existsSync } from "node:fs"
|
|
7
7
|
import { join } from "node:path"
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
|
|
10
10
|
function parseImports(lines, ext) {
|
|
11
11
|
const imports = []
|
|
12
12
|
const text = lines.join("\n")
|
|
13
|
-
//
|
|
13
|
+
// standard import
|
|
14
14
|
const re = /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+\s*,?\s*(?:{[^}]*})?)\s*from\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g
|
|
15
15
|
let m
|
|
16
16
|
while ((m = re.exec(text))) {
|
|
@@ -28,7 +28,7 @@ function parseImports(lines, ext) {
|
|
|
28
28
|
return [...new Set(imports)]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** Extract JS/TS file export symbols */
|
|
32
32
|
function parseExports(lines, ext) {
|
|
33
33
|
const exports = []
|
|
34
34
|
const text = lines.join("\n")
|
|
@@ -45,17 +45,17 @@ function parseExports(lines, ext) {
|
|
|
45
45
|
if (name) exports.push(name)
|
|
46
46
|
else if (!exports.some((e) => e === "default")) exports.push("default")
|
|
47
47
|
}
|
|
48
|
-
// export { a, b as c }
|
|
48
|
+
// export { a, b as c } — prefer the "as" alias as the exported name
|
|
49
49
|
const braceRe = /export\s*\{([^}]+)\}/g
|
|
50
50
|
while ((m = braceRe.exec(text))) {
|
|
51
51
|
for (const name of m[1].split(",")) {
|
|
52
52
|
const parts = name.trim().split(/\s+/)
|
|
53
|
-
// "a as b" → b
|
|
53
|
+
// "a as b" → b (exported name), "a" → a
|
|
54
54
|
const exported = parts.length >= 3 ? parts[2] : parts[0]
|
|
55
55
|
if (exported) exports.push(exported)
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
// export const { a, b } =
|
|
58
|
+
// export const { a, b } = ... (destructured export)
|
|
59
59
|
const destructRe = /export\s+(?:const|let|var)\s*\{([^}]+)\}\s*=/g
|
|
60
60
|
while ((m = destructRe.exec(text))) {
|
|
61
61
|
for (const name of m[1].split(",")) {
|
|
@@ -67,7 +67,7 @@ function parseExports(lines, ext) {
|
|
|
67
67
|
return [...new Set(exports)]
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** Extract Python imports and top-level def/class */
|
|
71
71
|
function parsePyOutline(lines) {
|
|
72
72
|
const imports = []
|
|
73
73
|
const symbols = []
|
|
@@ -93,9 +93,9 @@ function parsePyOutline(lines) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Python
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
* Python relative import → relative file path:
|
|
97
|
+
* Leading n dots mean go up n-1 levels ("." = current package), module dots become path separators.
|
|
98
|
+
* Non-relative imports (not starting with .) or bare package imports ("from . import x") return null.
|
|
99
99
|
*/
|
|
100
100
|
function pyRelPath(mod) {
|
|
101
101
|
if (!mod?.startsWith(".")) return null
|
|
@@ -110,8 +110,8 @@ function normalizeExt(p) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
113
|
+
* Internal: scan all files, build forward dependency graph + reverse reference graph.
|
|
114
|
+
* Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
|
|
115
115
|
*/
|
|
116
116
|
function _buildDepGraph(db, cwd) {
|
|
117
117
|
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
@@ -137,7 +137,7 @@ function _buildDepGraph(db, cwd) {
|
|
|
137
137
|
exports = parseExports(lines, ext)
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
//
|
|
140
|
+
// Resolve import paths to relative paths (handle ./ ../)
|
|
141
141
|
const resolved = []
|
|
142
142
|
for (let imp of imports) {
|
|
143
143
|
if (imp.startsWith("./")) imp = imp.slice(2)
|
|
@@ -166,12 +166,12 @@ function _buildDepGraph(db, cwd) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* 1.
|
|
172
|
-
* 2.
|
|
173
|
-
* 3.
|
|
174
|
-
*
|
|
169
|
+
* Generate a compact architecture summary (replaces the old full-dump injection).
|
|
170
|
+
* Three layers, each with decreasing information density:
|
|
171
|
+
* 1. Directory-level dependencies (meaningful only for multi-directory projects, skipped for single-directory)
|
|
172
|
+
* 2. Hub files Top-12 (most-imported files — architecture skeleton)
|
|
173
|
+
* 3. Entry points (files with no importers — startup/top-level entry points)
|
|
174
|
+
* Output is naturally bounded (~1000-2000 chars), no more OUTLINE_INJECT_MAX hard truncation.
|
|
175
175
|
*/
|
|
176
176
|
export function buildSummary(db, cwd) {
|
|
177
177
|
const graph = _buildDepGraph(db, cwd)
|
|
@@ -181,7 +181,7 @@ export function buildSummary(db, cwd) {
|
|
|
181
181
|
const out = []
|
|
182
182
|
out.push(`${fileCount} source files indexed.`)
|
|
183
183
|
|
|
184
|
-
// 1)
|
|
184
|
+
// 1) Directory-level dependencies
|
|
185
185
|
const dirDeps = new Map() // dir → Set<imported-dir>
|
|
186
186
|
const dirSet = new Set()
|
|
187
187
|
for (const [rel, d] of deps) {
|
|
@@ -204,7 +204,7 @@ export function buildSummary(db, cwd) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// 2)
|
|
207
|
+
// 2) Hub files Top-12: sorted by import count descending
|
|
208
208
|
const HUB_LIMIT = 12
|
|
209
209
|
const hubScores = []
|
|
210
210
|
for (const [rel] of deps) {
|
|
@@ -227,7 +227,7 @@ export function buildSummary(db, cwd) {
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
// 3)
|
|
230
|
+
// 3) Entry points: files not imported by others (leaf/entry)
|
|
231
231
|
const entries = []
|
|
232
232
|
for (const [rel] of deps) {
|
|
233
233
|
const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
@@ -247,7 +247,7 @@ export function buildSummary(db, cwd) {
|
|
|
247
247
|
return out.join("\n")
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
/**
|
|
250
|
+
/** Get known file list from code_chunks (reuse index), parse by path to generate outline text */
|
|
251
251
|
export function buildOutline(db, cwd, focusPath) {
|
|
252
252
|
const graph = _buildDepGraph(db, cwd)
|
|
253
253
|
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
@@ -260,7 +260,7 @@ export function buildOutline(db, cwd, focusPath) {
|
|
|
260
260
|
const d = deps.get(rel)
|
|
261
261
|
if (!d) continue
|
|
262
262
|
const parts = []
|
|
263
|
-
// imported by
|
|
263
|
+
// imported by (strip extension when matching, since import paths usually don't include .mjs/.js suffix)
|
|
264
264
|
const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
265
265
|
const rev = importers.get(key)
|
|
266
266
|
if (rev?.size) parts.push(`← imported by: ${[...rev].join(", ")}`)
|
|
@@ -281,8 +281,8 @@ export function buildOutline(db, cwd, focusPath) {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
284
|
+
* Build the repo_outline tool (read-only).
|
|
285
|
+
* Requires memory.db (reuses code_chunks file list) and cwd.
|
|
286
286
|
*/
|
|
287
287
|
export function repoOutlineTool(db, cwd) {
|
|
288
288
|
return {
|
package/src/tools/shared.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tools/shared.mjs —
|
|
3
|
-
*
|
|
2
|
+
* tools/shared.mjs — shared tool utilities, constants, OpenAI schema conversion
|
|
3
|
+
* Imported by tools/file.mjs / system.mjs / web.mjs / git.mjs
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { spawn, execFileSync } from "node:child_process"
|
|
6
|
+
import { spawn, execFileSync, execFile } from "node:child_process"
|
|
7
7
|
import { readFileSync, existsSync, realpathSync } from "node:fs"
|
|
8
8
|
import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
|
|
9
9
|
import { fileURLToPath } from "node:url"
|
|
@@ -13,11 +13,14 @@ export const DESC = (name) => readFileSync(join(__dirname, "..", "tools", `${nam
|
|
|
13
13
|
|
|
14
14
|
export const MAX_READ_LINES = 2000
|
|
15
15
|
export const MAX_OUTPUT_CHARS = 200_000
|
|
16
|
+
|
|
17
|
+
const ENCODING_DETECT_MAX_TRIM = 3
|
|
18
|
+
const SYNTAX_CHECK_TIMEOUT = 10000
|
|
16
19
|
export const BASH_TIMEOUT_MS = 120_000
|
|
17
20
|
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
18
21
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
19
22
|
|
|
20
|
-
/**
|
|
23
|
+
/** Convert to OpenAI tools parameter format */
|
|
21
24
|
export function toOpenAISchema(tool) {
|
|
22
25
|
return {
|
|
23
26
|
type: "function",
|
|
@@ -29,7 +32,7 @@ export function toOpenAISchema(tool) {
|
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
/**
|
|
35
|
+
/** Strip ANSI escape sequences */
|
|
33
36
|
export function sanitizeOutput(s) {
|
|
34
37
|
return s
|
|
35
38
|
.replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
@@ -37,12 +40,13 @@ export function sanitizeOutput(s) {
|
|
|
37
40
|
.replace(/\r/g, "\n")
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/** Truncate text to max chars, appending a truncation notice */
|
|
40
44
|
export function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
41
45
|
if (text.length <= max) return text
|
|
42
46
|
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
/**
|
|
49
|
+
/** Read response body with a byte limit */
|
|
46
50
|
export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
47
51
|
if (!response.body) return ""
|
|
48
52
|
const reader = response.body.getReader()
|
|
@@ -59,7 +63,8 @@ export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
|
59
63
|
return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/**
|
|
66
|
+
/** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
|
|
67
|
+
* Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
|
|
63
68
|
export function makeDecoder() {
|
|
64
69
|
let decoder = null
|
|
65
70
|
let pending = Buffer.alloc(0)
|
|
@@ -68,7 +73,7 @@ export function makeDecoder() {
|
|
|
68
73
|
if (!decoder) {
|
|
69
74
|
const hasHighByte = pending.some((b) => b >= 0x80)
|
|
70
75
|
if (!hasHighByte) { const s = pending.toString("ascii"); pending = Buffer.alloc(0); return s }
|
|
71
|
-
for (let trim = 0; trim <=
|
|
76
|
+
for (let trim = 0; trim <= ENCODING_DETECT_MAX_TRIM && !decoder; trim++) {
|
|
72
77
|
try { new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim)); decoder = new TextDecoder("utf-8") }
|
|
73
78
|
catch { /* continue */ }
|
|
74
79
|
}
|
|
@@ -80,7 +85,7 @@ export function makeDecoder() {
|
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
/**
|
|
88
|
+
/** Single-file git diff. Silently returns empty on failure. Large diffs that exceed maxBuffer are truncated rather than swallowed. */
|
|
84
89
|
export function gitDiffOne(cwd, abs) {
|
|
85
90
|
try {
|
|
86
91
|
const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
|
|
@@ -91,7 +96,7 @@ export function gitDiffOne(cwd, abs) {
|
|
|
91
96
|
if (lines.length <= 200) return diff
|
|
92
97
|
return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
|
|
93
98
|
} catch (e) {
|
|
94
|
-
// maxBuffer
|
|
99
|
+
// maxBuffer overflow: e.stdout contains partial collected output; other errors (non-git repo etc.) return empty
|
|
95
100
|
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
96
101
|
const lines = e.stdout.toString().split("\n")
|
|
97
102
|
return lines.slice(0, 200).join("\n") + `\n... (diff too large, showing first 200 of more lines)`
|
|
@@ -100,19 +105,32 @@ export function gitDiffOne(cwd, abs) {
|
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
107
|
|
|
103
|
-
/**
|
|
104
|
-
export function autoSyntaxCheck(abs) {
|
|
108
|
+
/** Auto syntax check after file modification */
|
|
109
|
+
export async function autoSyntaxCheck(abs) {
|
|
105
110
|
if (!/\.(m?js)$/i.test(abs)) return ""
|
|
106
111
|
try {
|
|
107
|
-
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
const child = execFile("node", ["--check", abs], { timeout: SYNTAX_CHECK_TIMEOUT, stdio: ["ignore", "pipe", "pipe"] })
|
|
114
|
+
let stderr = ""
|
|
115
|
+
child.stderr.on("data", (d) => { stderr += d.toString() })
|
|
116
|
+
child.on("error", reject)
|
|
117
|
+
child.on("close", (code) => {
|
|
118
|
+
if (code === 0) resolve()
|
|
119
|
+
else {
|
|
120
|
+
const err = new Error(stderr.trim() || `node --check exited with code ${code}`)
|
|
121
|
+
err.stderr = stderr
|
|
122
|
+
reject(err)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
108
126
|
return "\nSyntax: OK"
|
|
109
127
|
} catch (e) {
|
|
110
128
|
const err = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
|
|
111
|
-
return `\nSyntax: FAILED — ${err}`
|
|
129
|
+
return `\nSyntax: FAILED — ${err}\n(If this file was corrupted by a bad edit, recover it from a checkpoint: checkpoint action=list then action=rewind with the latest id.)`
|
|
112
130
|
}
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
/**
|
|
133
|
+
/** Resolve realpath by walking up the directory tree */
|
|
116
134
|
export function realpathNearest(abs) {
|
|
117
135
|
let cur = abs
|
|
118
136
|
const tail = []
|
|
@@ -127,11 +145,13 @@ export function realpathNearest(abs) {
|
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
const realCwdCache = new Map()
|
|
148
|
+
/** Resolve cwd to realpath, cached */
|
|
130
149
|
export function realCwd(cwd) {
|
|
131
150
|
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
132
151
|
return realCwdCache.get(cwd)
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
/** Assert that a resolved path is inside cwd; throws on escape */
|
|
135
155
|
export function assertInside(cwd, resolved, p) {
|
|
136
156
|
const rel = relative(cwd, resolved)
|
|
137
157
|
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
@@ -139,6 +159,7 @@ export function assertInside(cwd, resolved, p) {
|
|
|
139
159
|
}
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
/** Resolve a user-supplied path relative to cwd, asserting it stays within cwd */
|
|
142
163
|
export function resolveInCwd(ctx, p) {
|
|
143
164
|
const cwd = realCwd(ctx.cwd)
|
|
144
165
|
const resolved = resolve(cwd, p)
|
|
@@ -148,25 +169,31 @@ export function resolveInCwd(ctx, p) {
|
|
|
148
169
|
return resolved
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
/**
|
|
172
|
+
/** Resolve a path relative to cwd without boundary check — use only when the user explicitly provides an external path */
|
|
173
|
+
export function resolveExternal(ctx, p) {
|
|
174
|
+
const cwd = realCwd(ctx.cwd)
|
|
175
|
+
return resolve(cwd, p)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Coarse segmentation for destructive pre-check (also splits on > >> < so destructive detection still works through redirection) */
|
|
152
179
|
export function shellSegments(command) {
|
|
153
180
|
return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
|
|
154
181
|
}
|
|
155
182
|
|
|
156
|
-
/**
|
|
183
|
+
/** Detect shell output/input redirection (> >> < followed by filename) — not excluded inside quotes, conservative block */
|
|
157
184
|
export function hasFileRedirection(command) {
|
|
158
185
|
return /(^|[\s;&|])>{1,2}\s*\S/.test(command) || /(^|[\s;&|])<\s*\S/.test(command)
|
|
159
186
|
}
|
|
160
187
|
|
|
161
|
-
/**
|
|
188
|
+
/** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
|
|
162
189
|
export function isDestructiveCommand(seg) {
|
|
163
190
|
const s = seg
|
|
164
|
-
// rm
|
|
191
|
+
// rm with both recursive (-r/-R) and force (-f) flags: -rf / -fr / -r -f / -Rf etc.
|
|
165
192
|
if (/\brm\b/.test(s) && /\s-\S*r/i.test(s) && /\s-\S*f/i.test(s)) return true
|
|
166
193
|
if (/\brmdir\b/i.test(s)) return true
|
|
167
194
|
if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
|
|
168
195
|
if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
|
|
169
|
-
// format
|
|
196
|
+
// format called as a command (exclude --format= option false positives)
|
|
170
197
|
if (/\bformat\b\s+\S/i.test(s) && !/--format\b/i.test(s)) return true
|
|
171
198
|
if (/\bshred\b/i.test(s)) return true
|
|
172
199
|
if (/\bdd\b/.test(s) && /\bof=/i.test(s)) return true
|
|
@@ -176,7 +203,7 @@ export function isDestructiveCommand(seg) {
|
|
|
176
203
|
return false
|
|
177
204
|
}
|
|
178
205
|
|
|
179
|
-
/**
|
|
206
|
+
/** Whether a single command segment destroys uncommitted changes */
|
|
180
207
|
export function isDestructiveGitSegment(seg) {
|
|
181
208
|
if (!/^\s*git\s/.test(seg)) return false
|
|
182
209
|
if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
|
|
@@ -186,7 +213,7 @@ export function isDestructiveGitSegment(seg) {
|
|
|
186
213
|
return false
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
/** cwd
|
|
216
|
+
/** Whether cwd is inside a git repository */
|
|
190
217
|
export function insideGitRepo(cwd) {
|
|
191
218
|
try {
|
|
192
219
|
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
@@ -196,7 +223,7 @@ export function insideGitRepo(cwd) {
|
|
|
196
223
|
} catch { return false }
|
|
197
224
|
}
|
|
198
225
|
|
|
199
|
-
/** glob
|
|
226
|
+
/** Convert glob pattern to regex */
|
|
200
227
|
export function globToRegex(pattern) {
|
|
201
228
|
const DS = "\u0001", DP = "\u0002"
|
|
202
229
|
const escaped = pattern
|
|
@@ -208,7 +235,7 @@ export function globToRegex(pattern) {
|
|
|
208
235
|
return new RegExp(`^${escaped}$`)
|
|
209
236
|
}
|
|
210
237
|
|
|
211
|
-
/**
|
|
238
|
+
/** Strip HTML tags */
|
|
212
239
|
export function stripTags(html) {
|
|
213
240
|
return html
|
|
214
241
|
.replace(/<[^>]+>/g, "")
|
|
@@ -223,7 +250,7 @@ export function stripTags(html) {
|
|
|
223
250
|
.trim()
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
/** HTML →
|
|
253
|
+
/** HTML → plain text: strip scripts/styles, newline block tags, strip tags, decode entities, compress blank lines */
|
|
227
254
|
export function htmlToText(html) {
|
|
228
255
|
return html
|
|
229
256
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
@@ -239,18 +266,18 @@ export function htmlToText(html) {
|
|
|
239
266
|
.replace(/</g, "<")
|
|
240
267
|
.replace(/>/g, ">")
|
|
241
268
|
.replace(/"/g, '"')
|
|
242
|
-
.replace(/&/g, "&") // &
|
|
269
|
+
.replace(/&/g, "&") // & must be decoded last, otherwise &lt; gets double-decoded to <
|
|
243
270
|
.replace(/[ \t]+/g, " ")
|
|
244
271
|
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
245
272
|
.trim()
|
|
246
273
|
}
|
|
247
274
|
|
|
248
|
-
/**
|
|
275
|
+
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty. */
|
|
249
276
|
export function runGit(cwd, cmdArgs) {
|
|
250
277
|
try {
|
|
251
278
|
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
252
279
|
} catch (e) {
|
|
253
|
-
// ERR_CHILD_PROCESS_STDIO_MAXBUFFER
|
|
280
|
+
// ERR_CHILD_PROCESS_STDIO_MAXBUFFER: e.stdout contains partial output, return first 200 lines
|
|
254
281
|
if (e.stdout) return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
|
|
255
282
|
return ""
|
|
256
283
|
}
|