thincoder 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/thincoder.cjs +4 -0
- package/bin/thincoder.mjs +25 -25
- package/package.json +2 -2
- 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 +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- 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 +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- 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 +55 -53
- package/src/context.mjs +59 -56
- 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 +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- 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 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +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 +49 -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 +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- 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 +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- 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 +14 -14
- 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/provider/core.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* provider/core.mjs — LLM
|
|
2
|
+
* provider/core.mjs — LLM call core
|
|
3
3
|
* chat / listModels / createProvider / requestWithRetry / readSSE
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
estimateRequestTokens, rateGate, recordRate,
|
|
11
11
|
} from "./rate.mjs"
|
|
12
12
|
|
|
13
|
+
const FETCH_TIMEOUT_MS = 120000
|
|
14
|
+
|
|
15
|
+
/** Create a validated provider config object from raw config */
|
|
13
16
|
export function createProvider(config) {
|
|
14
|
-
if (!config?.baseURL) throw new Error("provider config: baseURL is required")
|
|
15
|
-
if (!config?.apiKey) throw new Error("provider config: apiKey is required
|
|
16
|
-
if (!config?.model) throw new Error("provider config: model is required")
|
|
17
|
+
if (!config?.baseURL) throw new Error("provider config: baseURL is required — configure providers in ~/.thincoder/config.json")
|
|
18
|
+
if (!config?.apiKey) throw new Error("provider config: apiKey is required — set THINCODER_API_KEY env or configure in ~/.thincoder/config.json")
|
|
19
|
+
if (!config?.model) throw new Error("provider config: model is required — configure in ~/.thincoder/config.json")
|
|
17
20
|
return {
|
|
18
21
|
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
19
22
|
apiKey: config.apiKey,
|
|
@@ -27,6 +30,7 @@ export function createProvider(config) {
|
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
/** Send a streaming chat completion request with automatic continuation on truncation */
|
|
30
34
|
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
|
|
31
35
|
const spec = specForModel(provider.model)
|
|
32
36
|
const body = {
|
|
@@ -108,6 +112,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
108
112
|
return result
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
/** List available model IDs from the provider's /models endpoint */
|
|
111
116
|
export async function listModels(provider, { signal } = {}) {
|
|
112
117
|
const response = await fetch(`${provider.baseURL}/models`, {
|
|
113
118
|
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
@@ -138,7 +143,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
138
143
|
Authorization: `Bearer ${provider.apiKey}`,
|
|
139
144
|
},
|
|
140
145
|
body: JSON.stringify(body),
|
|
141
|
-
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(
|
|
146
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
142
147
|
})
|
|
143
148
|
} catch (error) {
|
|
144
149
|
if (error.name === "AbortError") throw error
|
|
@@ -233,7 +238,7 @@ async function readSSE(response, { onToken, onReasoning }) {
|
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
function betaBaseURL(baseURL) {
|
|
236
|
-
// DeepSeek prefix
|
|
241
|
+
// DeepSeek prefix continuation uses /beta endpoint; only handle /v1 suffix, append /beta when /v1 is missing
|
|
237
242
|
if (/\/v1$/.test(baseURL)) return baseURL.replace(/\/v1$/, "/beta")
|
|
238
243
|
return baseURL.endsWith("/") ? baseURL + "beta" : baseURL + "/beta"
|
|
239
244
|
}
|
package/src/provider/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* provider/index.mjs —
|
|
3
|
-
* import { chat } from "./provider" →
|
|
2
|
+
* provider/index.mjs — backward-compatible re-export
|
|
3
|
+
* import { chat } from "./provider" → resolves to this file
|
|
4
4
|
*/
|
|
5
5
|
export { chat, createProvider, listModels } from "./core.mjs"
|
|
6
6
|
export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
|
package/src/provider/rate.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* provider/rate.mjs — TPM/RPM
|
|
3
|
-
*
|
|
2
|
+
* provider/rate.mjs — TPM/RPM proactive throttling gate
|
|
3
|
+
* Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { specForModel } from "../config.mjs"
|
|
@@ -11,8 +11,8 @@ export const MAX_CONTINUATIONS = 3
|
|
|
11
11
|
export const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Test hooks: sleep/clock/window length are replaceable (offline tests can't really wait 60s).
|
|
15
|
+
* Production code should never call setTimeout/sleep directly — always go through these.
|
|
16
16
|
*/
|
|
17
17
|
export const _rateHooks = {
|
|
18
18
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -23,20 +23,20 @@ export const _rateHooks = {
|
|
|
23
23
|
const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
|
|
24
24
|
|
|
25
25
|
function rateKey(provider) {
|
|
26
|
-
//
|
|
26
|
+
// Normalize: /beta and /v1 are treated as the same account's rate-limit window (DeepSeek prefix continuation switches to /beta endpoint)
|
|
27
27
|
const base = provider.baseURL.replace(/\/beta$/, "/v1")
|
|
28
28
|
return `${base}|${provider.apiKey ?? ""}`
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* ASCII
|
|
31
|
+
/** Rough estimate of text token count.
|
|
32
|
+
* ASCII ~4 chars/token; non-ASCII (CJK/emoji) ~1 char/token (conservative; measured BPE is 1.5-2.5 chars/token). */
|
|
33
33
|
export function estimateText(s) {
|
|
34
34
|
let nonAscii = 0
|
|
35
35
|
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
|
|
36
36
|
return Math.ceil((s.length - nonAscii) / 4) + nonAscii
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/** Estimated prompt tokens for this request */
|
|
40
40
|
export function estimateRequestTokens(body) {
|
|
41
41
|
let tokens = 0
|
|
42
42
|
for (const m of body.messages ?? []) {
|
|
@@ -50,7 +50,7 @@ export function estimateRequestTokens(body) {
|
|
|
50
50
|
return tokens
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/** Gate: sleep until window frees space when over budget */
|
|
54
54
|
export async function rateGate(provider, estimated, onWait, signal) {
|
|
55
55
|
const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
|
|
56
56
|
const rpm = provider.rpm ?? null
|
|
@@ -87,7 +87,7 @@ export async function rateGate(provider, estimated, onWait, signal) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/**
|
|
90
|
+
/** Accounting: record measured usage after response returns */
|
|
91
91
|
export function recordRate(provider, estimated, usage) {
|
|
92
92
|
if (provider.tpm == null && provider.rpm == null) return
|
|
93
93
|
const key = rateKey(provider)
|
|
@@ -98,7 +98,7 @@ export function recordRate(provider, estimated, usage) {
|
|
|
98
98
|
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
99
99
|
w.requests.push(now)
|
|
100
100
|
w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
|
|
101
|
-
//
|
|
101
|
+
// Delete entry when window is empty, preventing unbounded Map growth across long-running provider configs
|
|
102
102
|
if (w.tokens.length === 0 && w.requests.length === 0) rateWindows.delete(key)
|
|
103
103
|
else rateWindows.set(key, w)
|
|
104
104
|
}
|
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,7 +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
|
-
//
|
|
217
|
+
// Reset turn counters: after switching sessions, should not inherit old session's stall/compaction state
|
|
186
218
|
agent._turnsSinceTaskUpdate = 0
|
|
187
219
|
agent._turnsInPlanMode = 0
|
|
188
220
|
agent._compressFailures = 0
|
|
@@ -199,12 +231,13 @@ export function applySession(agent, data) {
|
|
|
199
231
|
return false
|
|
200
232
|
}
|
|
201
233
|
|
|
234
|
+
/** Archive current session and reset the session file to empty state */
|
|
202
235
|
export function clearSession(cwd) {
|
|
203
236
|
try {
|
|
204
237
|
archiveCurrent(cwd)
|
|
205
238
|
const p = sessionPath(cwd)
|
|
206
239
|
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
|
|
207
240
|
} catch {
|
|
208
|
-
//
|
|
241
|
+
// Can't clear, oh well — next save will overwrite
|
|
209
242
|
}
|
|
210
243
|
}
|
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
|
@@ -13,6 +13,9 @@ import { writeFile } from "node:fs/promises";
|
|
|
13
13
|
import { unlink } from "node:fs/promises";
|
|
14
14
|
import { join, relative, dirname } from "node:path";
|
|
15
15
|
|
|
16
|
+
const MAX_FILE_READ_BYTES = 10_000_000
|
|
17
|
+
const MAX_IMAGE_BYTES = 15_000_000
|
|
18
|
+
|
|
16
19
|
export const readTool = {
|
|
17
20
|
name: "read",
|
|
18
21
|
description: DESC("read"),
|
|
@@ -28,9 +31,9 @@ export const readTool = {
|
|
|
28
31
|
readonly: true,
|
|
29
32
|
async execute(args, ctx) {
|
|
30
33
|
const abs = resolveInCwd(ctx, args.path)
|
|
31
|
-
//
|
|
34
|
+
// Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
|
|
32
35
|
const st = await stat(abs).catch(() => null)
|
|
33
|
-
if (st && st.size >
|
|
36
|
+
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
37
|
const content = await readFile(abs, "utf8")
|
|
35
38
|
const lines = content.split("\n")
|
|
36
39
|
const offset = Math.max(1, args.offset ?? 1)
|
|
@@ -57,15 +60,16 @@ export const readImageTool = {
|
|
|
57
60
|
required: ["path"],
|
|
58
61
|
},
|
|
59
62
|
readonly: true,
|
|
60
|
-
|
|
63
|
+
multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
|
|
64
|
+
/** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
|
|
61
65
|
async execute(args, ctx) {
|
|
62
66
|
const abs = resolveInCwd(ctx, args.path)
|
|
63
67
|
const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
|
|
64
68
|
const mime = IMAGE_EXTENSIONS[ext]
|
|
65
69
|
if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
|
|
66
|
-
//
|
|
70
|
+
// Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
|
|
67
71
|
const imgStat = await stat(abs).catch(() => null)
|
|
68
|
-
if (imgStat && imgStat.size >
|
|
72
|
+
if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
|
|
69
73
|
const buf = await readFile(abs) // raw buffer, no encoding
|
|
70
74
|
const b64 = buf.toString("base64")
|
|
71
75
|
const bytes = buf.length
|
|
@@ -73,10 +77,10 @@ export const readImageTool = {
|
|
|
73
77
|
text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
|
|
74
78
|
images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
|
|
75
79
|
})
|
|
76
|
-
//
|
|
80
|
+
// Paste-created temp files: delete after use, no litter
|
|
77
81
|
const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
|
|
78
82
|
if (basename.startsWith(".thincoder-paste-")) {
|
|
79
|
-
try { await unlink(abs) } catch { /*
|
|
83
|
+
try { await unlink(abs) } catch { /* can't delete, so be it */ }
|
|
80
84
|
}
|
|
81
85
|
return result
|
|
82
86
|
},
|
|
@@ -103,7 +107,7 @@ export const writeTool = {
|
|
|
103
107
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
104
108
|
await writeFile(abs, args.content, "utf8")
|
|
105
109
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
106
|
-
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
|
|
110
|
+
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
107
111
|
},
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -131,7 +135,7 @@ export const editTool = {
|
|
|
131
135
|
const content = await readFile(abs, "utf8")
|
|
132
136
|
const occurrences = content.split(args.old_string).length - 1
|
|
133
137
|
if (occurrences === 0) {
|
|
134
|
-
//
|
|
138
|
+
// Give clues to help the model locate: first-line preview + common causes
|
|
135
139
|
const preview = args.old_string.slice(0, 100).split("\n")[0]
|
|
136
140
|
throw new Error(
|
|
137
141
|
`old_string not found in ${args.path}\n` +
|
|
@@ -144,11 +148,11 @@ export const editTool = {
|
|
|
144
148
|
}
|
|
145
149
|
const updated = args.replace_all
|
|
146
150
|
? content.split(args.old_string).join(args.new_string)
|
|
147
|
-
//
|
|
151
|
+
// Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
|
|
148
152
|
: content.replace(args.old_string, () => args.new_string)
|
|
149
153
|
await writeFile(abs, updated, "utf8")
|
|
150
154
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
151
|
-
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
|
|
155
|
+
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
152
156
|
},
|
|
153
157
|
}
|
|
154
158
|
|
|
@@ -199,7 +203,7 @@ export const insertAfterTool = {
|
|
|
199
203
|
const updated = lines.join("\n")
|
|
200
204
|
await writeFile(abs, updated, "utf8")
|
|
201
205
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
202
|
-
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
|
|
206
|
+
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
203
207
|
},
|
|
204
208
|
}
|
|
205
209
|
|