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/session.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* session.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* session.mjs — session persistence
|
|
3
|
+
* Each project (keyed by cwd hash) keeps up to 5 session slots, rotated by last-access time.
|
|
4
|
+
* Two recovery needs stored separately: agent recovery (history) needs context continuity, user recovery (display) needs WYSIWYG.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* File layout: {hash}.json (current), {hash}.json.1~5 (slots), {hash}.json.manifest (slot metadata)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createHash } from "node:crypto"
|
|
@@ -12,18 +12,20 @@ import { join, dirname } from "node:path"
|
|
|
12
12
|
import { configDir } from "./config.mjs"
|
|
13
13
|
|
|
14
14
|
const MAX_SLOTS = 5
|
|
15
|
+
const CWD_HASH_LEN = 12
|
|
15
16
|
|
|
17
|
+
/** Derive session file path from cwd hash */
|
|
16
18
|
export function sessionPath(cwd) {
|
|
17
|
-
const hash = createHash("sha1").update(cwd).digest("hex").slice(0,
|
|
19
|
+
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
|
|
18
20
|
return join(configDir, "sessions", `${hash}.json`)
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
|
|
22
24
|
function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
|
|
23
25
|
|
|
24
|
-
/**
|
|
25
|
-
* rename
|
|
26
|
-
*
|
|
26
|
+
/** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash.
|
|
27
|
+
* rename is atomic on POSIX; on Windows with existing target, Node 24 uses MoveFileExW+REPLACE_EXISTING for atomic replace.
|
|
28
|
+
* Some older Windows filesystems may throw EPERM — retry once. */
|
|
27
29
|
function writeSessionFile(p, data) {
|
|
28
30
|
mkdirSync(dirname(p), { recursive: true })
|
|
29
31
|
const tmp = `${p}.tmp`
|
|
@@ -31,13 +33,19 @@ function writeSessionFile(p, data) {
|
|
|
31
33
|
try {
|
|
32
34
|
renameSync(tmp, p)
|
|
33
35
|
} catch {
|
|
34
|
-
// Windows
|
|
36
|
+
// Windows: rename may fail due to antivirus lock / network drive contention — delete target and retry
|
|
35
37
|
try { unlinkSync(p) } catch {}
|
|
36
|
-
|
|
38
|
+
try {
|
|
39
|
+
renameSync(tmp, p)
|
|
40
|
+
// rename succeeded: clean up temp file
|
|
41
|
+
try { unlinkSync(tmp) } catch {}
|
|
42
|
+
} catch {
|
|
43
|
+
// rename still failed: keep tmp as fallback data (next read prefers main file; if missing, tmp is at least there)
|
|
44
|
+
}
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
// ==========
|
|
48
|
+
// ========== slot management ==========
|
|
41
49
|
|
|
42
50
|
function loadManifest(cwd) {
|
|
43
51
|
try {
|
|
@@ -51,14 +59,14 @@ function saveManifest(cwd, m) {
|
|
|
51
59
|
writeSessionFile(manifestPath(cwd), m)
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
/**
|
|
62
|
+
/** Archive current session to a free slot — evict oldest if full; exclude specifies a slot that must not be evicted (switchToSlot target) */
|
|
55
63
|
export function archiveCurrent(cwd, { exclude } = {}) {
|
|
56
64
|
const src = sessionPath(cwd)
|
|
57
65
|
if (!existsSync(src)) return
|
|
58
66
|
const m = loadManifest(cwd)
|
|
59
67
|
|
|
60
68
|
let slot
|
|
61
|
-
//
|
|
69
|
+
// Only count numeric-keyed slots, exclude legacy non-numeric keys like _currentName
|
|
62
70
|
const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
|
|
63
71
|
if (entries.length < MAX_SLOTS) {
|
|
64
72
|
slot = 1
|
|
@@ -69,12 +77,12 @@ export function archiveCurrent(cwd, { exclude } = {}) {
|
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
const dst = slotPath(cwd, slot)
|
|
72
|
-
//
|
|
80
|
+
// Copy (rename would lose current); use atomic write to prevent truncated JSON in archive from mid-crash
|
|
73
81
|
let data
|
|
74
82
|
try {
|
|
75
83
|
data = JSON.parse(readFileSync(src, "utf8"))
|
|
76
84
|
} catch {
|
|
77
|
-
//
|
|
85
|
+
// Session file corrupted, abandon archive; next save will overwrite
|
|
78
86
|
return
|
|
79
87
|
}
|
|
80
88
|
writeSessionFile(dst, data)
|
|
@@ -84,7 +92,7 @@ export function archiveCurrent(cwd, { exclude } = {}) {
|
|
|
84
92
|
return slot
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
/**
|
|
95
|
+
/** List all archive slots, newest first */
|
|
88
96
|
export function listSlots(cwd) {
|
|
89
97
|
const m = loadManifest(cwd)
|
|
90
98
|
return Object.entries(m.slots)
|
|
@@ -92,29 +100,29 @@ export function listSlots(cwd) {
|
|
|
92
100
|
.sort((a, b) => b.timestamp - a.timestamp)
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
/**
|
|
103
|
+
/** Switch to a specific slot: archive current → copy slot file to current → return recovered data (null on failure) */
|
|
96
104
|
export function switchToSlot(cwd, slot) {
|
|
97
105
|
const m = loadManifest(cwd)
|
|
98
106
|
if (!m.slots[slot]) return null
|
|
99
107
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
108
|
+
// Archive current (internally writes manifest; our `m` is stale after, must reload)
|
|
109
|
+
// When full, exclude target slot: otherwise oldest=target, archive would overwrite target then copy back, permanently losing the target session
|
|
102
110
|
archiveCurrent(cwd, { exclude: slot })
|
|
103
111
|
|
|
104
|
-
//
|
|
112
|
+
// Slot file → current (copy+unlink, not rename: Windows rename on existing target throws EPERM)
|
|
105
113
|
const src = slotPath(cwd, slot)
|
|
106
114
|
const dst = sessionPath(cwd)
|
|
107
115
|
if (!existsSync(src)) return null
|
|
108
116
|
try {
|
|
109
|
-
try { unlinkSync(dst) } catch { /*
|
|
117
|
+
try { unlinkSync(dst) } catch { /* doesn't exist, that's fine */ }
|
|
110
118
|
copyFileSync(src, dst)
|
|
111
119
|
unlinkSync(src)
|
|
112
120
|
} catch {
|
|
113
|
-
//
|
|
121
|
+
// File operations failed (disk full / permissions / lock), abandon switch
|
|
114
122
|
return null
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
//
|
|
125
|
+
// Reload manifest (archiveCurrent modified it)
|
|
118
126
|
const m2 = loadManifest(cwd)
|
|
119
127
|
delete m2.slots[slot]
|
|
120
128
|
saveManifest(cwd, m2)
|
|
@@ -122,7 +130,7 @@ export function switchToSlot(cwd, slot) {
|
|
|
122
130
|
return loadSession(cwd)
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
// ==========
|
|
133
|
+
// ========== legacy transient prefix cleanup ==========
|
|
126
134
|
|
|
127
135
|
const LEGACY_TRANSIENT_PREFIXES = [
|
|
128
136
|
"[System reminder: working directory snapshot:",
|
|
@@ -137,8 +145,9 @@ function isLegacyTransient(m) {
|
|
|
137
145
|
)
|
|
138
146
|
}
|
|
139
147
|
|
|
140
|
-
// ==========
|
|
148
|
+
// ========== core read/write ==========
|
|
141
149
|
|
|
150
|
+
/** Save agent state and display lines to the session file (atomic write) */
|
|
142
151
|
export function saveSession(agent, display) {
|
|
143
152
|
const history = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
|
|
144
153
|
const data = {
|
|
@@ -158,22 +167,45 @@ export function saveSession(agent, display) {
|
|
|
158
167
|
writeSessionFile(sessionPath(agent.cwd), data)
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
/** Load session data from disk; returns null if missing, corrupted, or version mismatch */
|
|
161
171
|
export function loadSession(cwd) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
const tryLoad = (p) => {
|
|
173
|
+
try {
|
|
174
|
+
if (!existsSync(p)) return null
|
|
175
|
+
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
176
|
+
if (data?.version !== 1 && data?.version !== 2) return null
|
|
177
|
+
if (!Array.isArray(data.history)) return null
|
|
178
|
+
if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
|
|
179
|
+
data.history = data.history.filter((m) => !isLegacyTransient(m))
|
|
180
|
+
data.display = Array.isArray(data.display)
|
|
181
|
+
? data.display.filter((l) => l && typeof l.text === "string").map((l) => ({ text: l.text, color: l.color }))
|
|
182
|
+
: []
|
|
183
|
+
data._recovered = false
|
|
184
|
+
return data
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { _error: e }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const p = sessionPath(cwd)
|
|
190
|
+
let result = tryLoad(p)
|
|
191
|
+
if (result?._error) {
|
|
192
|
+
// Main file corrupted — try the .tmp fallback from a failed atomic write
|
|
193
|
+
console.error(`[session] failed to load ${p}: ${result._error.message}. Trying .tmp fallback...`)
|
|
194
|
+
const tmpResult = tryLoad(`${p}.tmp`)
|
|
195
|
+
if (tmpResult && !tmpResult._error) {
|
|
196
|
+
console.error(`[session] recovered from .tmp fallback`)
|
|
197
|
+
result = tmpResult
|
|
198
|
+
result._recovered = true
|
|
199
|
+
} else {
|
|
200
|
+
console.error(`[session] .tmp fallback also failed — session lost. Backing up corrupted file as .corrupted.`)
|
|
201
|
+
try { renameSync(p, `${p}.corrupted`) } catch {}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result && !result._error ? result : null
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
/** Apply loaded session data onto an agent object; returns true if provider was switched */
|
|
177
209
|
export function applySession(agent, data) {
|
|
178
210
|
agent.history = data.history
|
|
179
211
|
agent.tasks = data.tasks ?? []
|
|
@@ -182,9 +214,7 @@ export function applySession(agent, data) {
|
|
|
182
214
|
agent.goal = data.goal ?? null
|
|
183
215
|
agent._pendingReminders = data.pendingReminders ?? []
|
|
184
216
|
agent._sessionStart = data.sessionStart ?? null
|
|
185
|
-
//
|
|
186
|
-
agent._turnsSinceTaskUpdate = 0
|
|
187
|
-
agent._turnsInPlanMode = 0
|
|
217
|
+
// Reset stall/compaction state on session switch
|
|
188
218
|
agent._compressFailures = 0
|
|
189
219
|
agent._verifyRetries = 0
|
|
190
220
|
agent._verifyPassed = false
|
|
@@ -199,12 +229,13 @@ export function applySession(agent, data) {
|
|
|
199
229
|
return false
|
|
200
230
|
}
|
|
201
231
|
|
|
232
|
+
/** Archive current session and reset the session file to empty state */
|
|
202
233
|
export function clearSession(cwd) {
|
|
203
234
|
try {
|
|
204
235
|
archiveCurrent(cwd)
|
|
205
236
|
const p = sessionPath(cwd)
|
|
206
237
|
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
|
|
207
238
|
} catch {
|
|
208
|
-
//
|
|
239
|
+
// Can't clear, oh well — next save will overwrite
|
|
209
240
|
}
|
|
210
241
|
}
|
package/src/skills.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skills.mjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* skills.mjs — skill system
|
|
3
|
+
* Discovers .md skill files from .thincoder/skills/ directory,
|
|
4
|
+
* injects into system prompt for agent to load on demand.
|
|
5
|
+
* Use the skill tool to activate a specific skill; content is written into conversation history wrapped in <skill-loaded>.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFile, readdir, stat } from "node:fs/promises"
|
|
9
9
|
import { join } from "node:path"
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Scan .thincoder/skills/ directory, return skill list.
|
|
13
|
+
* Each skill: { name, path, description } — name is the filename (without extension).
|
|
14
|
+
* Returns empty array if directory is missing or empty.
|
|
15
15
|
*/
|
|
16
16
|
export async function loadSkills(cwd) {
|
|
17
17
|
const dir = join(cwd, ".thincoder", "skills")
|
|
@@ -23,13 +23,13 @@ export async function loadSkills(cwd) {
|
|
|
23
23
|
}
|
|
24
24
|
const skills = []
|
|
25
25
|
for (const name of entries) {
|
|
26
|
-
if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue //
|
|
26
|
+
if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // must match readSkill's name validation; prevents "listed but unreadable"
|
|
27
27
|
const p = join(dir, name)
|
|
28
28
|
try {
|
|
29
29
|
const s = await stat(p)
|
|
30
30
|
if (!s.isFile()) continue
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// Extract description (first non-empty, non-heading line in first 400 chars);
|
|
32
|
+
// skip entire frontmatter block, otherwise frontmatter fields (e.g. "name: x") get mistaken for description
|
|
33
33
|
const head = await readFile(p, "utf8")
|
|
34
34
|
const body = head.slice(0, 400).split("\n")
|
|
35
35
|
let desc = ""
|
|
@@ -45,16 +45,16 @@ export async function loadSkills(cwd) {
|
|
|
45
45
|
}
|
|
46
46
|
skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
|
|
47
47
|
} catch {
|
|
48
|
-
//
|
|
48
|
+
// Read failure — skip
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
return skills
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* Generate skill listing text for system prompt injection.
|
|
56
|
+
* At most 3 (small footprint); overflow marked "... and N more".
|
|
57
|
+
* Prefixed with DISREGARD: when listing refreshes (skills added/removed), old listings are auto-invalidated without needing to delete history (inspired by kimi-code).
|
|
58
58
|
*/
|
|
59
59
|
export function formatSkillListing(skills) {
|
|
60
60
|
if (skills.length === 0) return ""
|
|
@@ -65,11 +65,11 @@ export function formatSkillListing(skills) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* Read the full content of a specific skill file.
|
|
69
|
+
* Returns text, or null if not found.
|
|
70
70
|
*/
|
|
71
71
|
export async function readSkill(cwd, name) {
|
|
72
|
-
//
|
|
72
|
+
// Safety check: skill name must be alphanumeric + hyphens/underscores only
|
|
73
73
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
|
|
74
74
|
const p = join(cwd, ".thincoder", "skills", `${name}.md`)
|
|
75
75
|
try {
|
package/src/tools/checkpoint.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
List, create, and restore workspace snapshots (checkpoints). Git repositories only.
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
|
-
- action (required): "list" | "create" | "rewind"
|
|
5
|
-
- id: snapshot id (required for rewind)
|
|
4
|
+
- action (required): "list" | "create" | "rewind" | "cat"
|
|
5
|
+
- id: snapshot id (required for rewind and cat; optional for list — when given, shows the file tree inside that snapshot)
|
|
6
|
+
- path: for rewind — restore only this single file; for cat — read this file's content from the snapshot
|
|
6
7
|
|
|
7
8
|
Notes:
|
|
8
9
|
- A checkpoint is AUTO-CREATED before every user task. If uncommitted work was destroyed (by you, a git command, or a failed refactor), use action=list then action=rewind with the latest id to recover it
|
|
9
10
|
- A checkpoint captures all uncommitted state: tracked-file changes (as a diff) plus copies of untracked files
|
|
10
11
|
- Rewind first snapshots the current state, so rewinding is itself reversible
|
|
12
|
+
- Rewind AUTO-RECOVERS: if git apply fails (corrupt patch), the pre-rewind state is restored — never lose data
|
|
11
13
|
- Create one manually before risky bulk operations
|
|
14
|
+
- list now shows which files changed (tracked + untracked); use path to recover individual files selectively
|
|
15
|
+
- cat reads a file's content from a snapshot without touching the worktree — useful for inspecting before rewinding
|
package/src/tools/file.mjs
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
MAX_READ_LINES,
|
|
5
5
|
gitDiffOne,
|
|
6
6
|
autoSyntaxCheck,
|
|
7
|
-
resolveInCwd
|
|
7
|
+
resolveInCwd,
|
|
8
|
+
resolveExternal,
|
|
8
9
|
} from "./shared.mjs";
|
|
9
10
|
import { mkdir } from "node:fs/promises";
|
|
10
11
|
import { readFile } from "node:fs/promises";
|
|
@@ -13,6 +14,9 @@ import { writeFile } from "node:fs/promises";
|
|
|
13
14
|
import { unlink } from "node:fs/promises";
|
|
14
15
|
import { join, relative, dirname } from "node:path";
|
|
15
16
|
|
|
17
|
+
const MAX_FILE_READ_BYTES = 10_000_000
|
|
18
|
+
const MAX_IMAGE_BYTES = 15_000_000
|
|
19
|
+
|
|
16
20
|
export const readTool = {
|
|
17
21
|
name: "read",
|
|
18
22
|
description: DESC("read"),
|
|
@@ -22,15 +26,16 @@ export const readTool = {
|
|
|
22
26
|
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
23
27
|
offset: { type: "number", description: "1-based line number to start from" },
|
|
24
28
|
limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
|
|
29
|
+
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." },
|
|
25
30
|
},
|
|
26
31
|
required: ["path"],
|
|
27
32
|
},
|
|
28
33
|
readonly: true,
|
|
29
34
|
async execute(args, ctx) {
|
|
30
|
-
const abs = resolveInCwd(ctx, args.path)
|
|
31
|
-
//
|
|
35
|
+
const abs = args.allowExternal ? resolveExternal(ctx, args.path) : resolveInCwd(ctx, args.path)
|
|
36
|
+
// Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
|
|
32
37
|
const st = await stat(abs).catch(() => null)
|
|
33
|
-
if (st && st.size >
|
|
38
|
+
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.`)
|
|
34
39
|
const content = await readFile(abs, "utf8")
|
|
35
40
|
const lines = content.split("\n")
|
|
36
41
|
const offset = Math.max(1, args.offset ?? 1)
|
|
@@ -57,15 +62,16 @@ export const readImageTool = {
|
|
|
57
62
|
required: ["path"],
|
|
58
63
|
},
|
|
59
64
|
readonly: true,
|
|
60
|
-
|
|
65
|
+
multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
|
|
66
|
+
/** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
|
|
61
67
|
async execute(args, ctx) {
|
|
62
68
|
const abs = resolveInCwd(ctx, args.path)
|
|
63
69
|
const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
|
|
64
70
|
const mime = IMAGE_EXTENSIONS[ext]
|
|
65
71
|
if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
|
|
66
|
-
//
|
|
72
|
+
// Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
|
|
67
73
|
const imgStat = await stat(abs).catch(() => null)
|
|
68
|
-
if (imgStat && imgStat.size >
|
|
74
|
+
if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
|
|
69
75
|
const buf = await readFile(abs) // raw buffer, no encoding
|
|
70
76
|
const b64 = buf.toString("base64")
|
|
71
77
|
const bytes = buf.length
|
|
@@ -73,10 +79,10 @@ export const readImageTool = {
|
|
|
73
79
|
text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
|
|
74
80
|
images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
|
|
75
81
|
})
|
|
76
|
-
//
|
|
82
|
+
// Paste-created temp files: delete after use, no litter
|
|
77
83
|
const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
|
|
78
84
|
if (basename.startsWith(".thincoder-paste-")) {
|
|
79
|
-
try { await unlink(abs) } catch { /*
|
|
85
|
+
try { await unlink(abs) } catch { /* can't delete, so be it */ }
|
|
80
86
|
}
|
|
81
87
|
return result
|
|
82
88
|
},
|
|
@@ -103,7 +109,7 @@ export const writeTool = {
|
|
|
103
109
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
104
110
|
await writeFile(abs, args.content, "utf8")
|
|
105
111
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
106
|
-
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
|
|
112
|
+
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
107
113
|
},
|
|
108
114
|
}
|
|
109
115
|
|
|
@@ -131,7 +137,7 @@ export const editTool = {
|
|
|
131
137
|
const content = await readFile(abs, "utf8")
|
|
132
138
|
const occurrences = content.split(args.old_string).length - 1
|
|
133
139
|
if (occurrences === 0) {
|
|
134
|
-
//
|
|
140
|
+
// Give clues to help the model locate: first-line preview + common causes
|
|
135
141
|
const preview = args.old_string.slice(0, 100).split("\n")[0]
|
|
136
142
|
throw new Error(
|
|
137
143
|
`old_string not found in ${args.path}\n` +
|
|
@@ -144,11 +150,11 @@ export const editTool = {
|
|
|
144
150
|
}
|
|
145
151
|
const updated = args.replace_all
|
|
146
152
|
? content.split(args.old_string).join(args.new_string)
|
|
147
|
-
//
|
|
153
|
+
// Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
|
|
148
154
|
: content.replace(args.old_string, () => args.new_string)
|
|
149
155
|
await writeFile(abs, updated, "utf8")
|
|
150
156
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
151
|
-
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
|
|
157
|
+
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
152
158
|
},
|
|
153
159
|
}
|
|
154
160
|
|
|
@@ -199,7 +205,7 @@ export const insertAfterTool = {
|
|
|
199
205
|
const updated = lines.join("\n")
|
|
200
206
|
await writeFile(abs, updated, "utf8")
|
|
201
207
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
202
|
-
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
|
|
208
|
+
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
203
209
|
},
|
|
204
210
|
}
|
|
205
211
|
|
package/src/tools/git.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export const gitDiffTool = {
|
|
|
20
20
|
readonly: true,
|
|
21
21
|
execute(args, ctx) {
|
|
22
22
|
const ref = args.ref ?? "HEAD"
|
|
23
|
-
// ref
|
|
23
|
+
// ref supplied by model and placed before "--": validate charset, prevent "--output=..." etc. from being treated as git options
|
|
24
24
|
if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
25
25
|
const flags = args.staged ? ["--staged"] : []
|
|
26
26
|
const paths = args.path ? [args.path] : []
|
|
@@ -49,14 +49,14 @@ export const gitStatusTool = {
|
|
|
49
49
|
const conflicts = []
|
|
50
50
|
for (const line of porcelain.split("\n")) {
|
|
51
51
|
if (!line) continue
|
|
52
|
-
// porcelain: XY path — 2
|
|
53
|
-
//
|
|
52
|
+
// porcelain: XY path — 2 status chars + space + file path (some environments have only 1 space)
|
|
53
|
+
// Strip possible CR (execFileSync on some Windows git leaves \r at end of line but not in newline)
|
|
54
54
|
const clean = line.replace(/\r/g, "")
|
|
55
|
-
//
|
|
55
|
+
// Try matching "XY path" or "XY path" (variable spacing)
|
|
56
56
|
const m = clean.match(/^(..?)\s+(.+)$/)
|
|
57
57
|
if (!m) continue
|
|
58
58
|
const [, status, rawFile] = m
|
|
59
|
-
//
|
|
59
|
+
// Rename entries in porcelain output are "R old -> new", split for clarity instead of treating as a literal filename
|
|
60
60
|
const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
|
|
61
61
|
const idx = status[0] ?? " "
|
|
62
62
|
const wt = status[1] ?? " "
|
|
@@ -128,7 +128,7 @@ export const questionTool = {
|
|
|
128
128
|
},
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
131
|
+
/** Execute a git command; returns empty string when not a git repo / git unavailable */
|
|
132
132
|
|
|
133
133
|
// ---------------------------------------------------------------- checkpoint
|
|
134
134
|
|
|
@@ -138,8 +138,9 @@ export const checkpointTool = {
|
|
|
138
138
|
parameters: {
|
|
139
139
|
type: "object",
|
|
140
140
|
properties: {
|
|
141
|
-
action: { type: "string", enum: ["list", "create", "rewind"], description: "list snapshots / create one now / restore a snapshot by id" },
|
|
142
|
-
id: { type: "string", description: "Snapshot id (required for rewind)" },
|
|
141
|
+
action: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "list snapshots / create one now / restore a snapshot by id / read a file's content from a snapshot" },
|
|
142
|
+
id: { type: "string", description: "Snapshot id (required for rewind and cat; optional for list — shows file tree of that snapshot)" },
|
|
143
|
+
path: { type: "string", description: "Restore only this single file from the checkpoint (tracked or untracked). Other files are left untouched." },
|
|
143
144
|
},
|
|
144
145
|
required: ["action"],
|
|
145
146
|
},
|
|
@@ -149,18 +150,88 @@ export const checkpointTool = {
|
|
|
149
150
|
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
150
151
|
if (args.action === "create") {
|
|
151
152
|
const cp = await createCheckpoint(ctx.cwd)
|
|
152
|
-
return `Checkpoint ${cp.id} created (${cp.files} file(s)
|
|
153
|
+
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
153
154
|
}
|
|
154
155
|
if (args.action === "rewind") {
|
|
155
156
|
if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
|
|
156
|
-
const s = await rewind(ctx.cwd, args.id)
|
|
157
|
-
|
|
157
|
+
const s = await rewind(ctx.cwd, args.id, { path: args.path })
|
|
158
|
+
if (args.path) {
|
|
159
|
+
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.id}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
160
|
+
}
|
|
161
|
+
return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored ?? 0} untracked file(s) restored, ${s.deleted ?? 0} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
162
|
+
}
|
|
163
|
+
if (args.action === "cat") {
|
|
164
|
+
if (!args.id) throw new Error("id is required for cat — use action=list to see snapshot ids")
|
|
165
|
+
if (!args.path) throw new Error("path is required for cat — specify which file to read")
|
|
166
|
+
const { catFile } = await import("../git/checkpoint.mjs")
|
|
167
|
+
return await catFile(ctx.cwd, args.id, args.path)
|
|
158
168
|
}
|
|
159
169
|
if (args.action === "list") {
|
|
160
170
|
const cps = await listCheckpoints(ctx.cwd)
|
|
161
171
|
if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
|
|
162
|
-
|
|
172
|
+
|
|
173
|
+
// Specific id: show the file tree within that snapshot
|
|
174
|
+
if (args.id) {
|
|
175
|
+
const cp = cps.find((c) => c.id === args.id)
|
|
176
|
+
if (!cp) throw new Error(`checkpoint ${args.id} not found`)
|
|
177
|
+
return formatFileTree(cp)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Overview: list of all snapshots
|
|
181
|
+
return cps.map((c) => {
|
|
182
|
+
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
183
|
+
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
|
|
184
|
+
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
|
|
185
|
+
return parts.join(" ")
|
|
186
|
+
}).join("\n")
|
|
163
187
|
}
|
|
164
188
|
throw new Error(`Unknown action: ${args.action}`)
|
|
165
189
|
},
|
|
166
190
|
}
|
|
191
|
+
|
|
192
|
+
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
193
|
+
function formatFileTree(cp) {
|
|
194
|
+
const all = [
|
|
195
|
+
...(cp.tracked ?? []).map((f) => ({ path: f, type: "" })),
|
|
196
|
+
...(cp.untracked ?? []).map((f) => ({ path: f, type: " (untracked)" })),
|
|
197
|
+
]
|
|
198
|
+
if (all.length === 0) return "(empty checkpoint)"
|
|
199
|
+
|
|
200
|
+
// Sort by path (directories group naturally)
|
|
201
|
+
all.sort((a, b) => a.path.localeCompare(b.path))
|
|
202
|
+
|
|
203
|
+
// Build directory → file list map
|
|
204
|
+
const tree = new Map()
|
|
205
|
+
for (const { path, type } of all) {
|
|
206
|
+
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
|
|
207
|
+
if (!tree.has(dir)) tree.set(dir, [])
|
|
208
|
+
tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Output sorted by directory
|
|
212
|
+
const lines = []
|
|
213
|
+
const dirs = [...tree.keys()].sort()
|
|
214
|
+
for (const dir of dirs) {
|
|
215
|
+
if (dir !== "." && !lines.includes(dir + "/")) {
|
|
216
|
+
// Parent directory before child directory
|
|
217
|
+
const parts = dir.split("/")
|
|
218
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
219
|
+
const prefix = parts.slice(0, i).join("/") + "/"
|
|
220
|
+
if (!lines.includes(prefix)) lines.push(prefix)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Ensure directories precede files
|
|
225
|
+
for (const dir of dirs) {
|
|
226
|
+
if (dir !== ".") {
|
|
227
|
+
for (const { name, type } of tree.get(dir)) {
|
|
228
|
+
lines.push(` ${dir}/${name}${type}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const { name, type } of tree.get(".") ?? []) {
|
|
233
|
+
lines.push(name + type)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines.join("\n")
|
|
237
|
+
}
|
package/src/tools/index.mjs
CHANGED
package/src/tools/ls.md
CHANGED