thincoder 0.8.12 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/agent/setup.mjs +0 -3
- package/src/agent.mjs +6 -7
- package/src/auto-think.mjs +1 -1
- package/src/context.mjs +3 -1
- package/src/distill.mjs +19 -4
- package/src/embedding.mjs +3 -1
- package/src/mcp/transport-http.mjs +3 -2
- package/src/memory/docs.mjs +1 -2
- package/src/provider/core.mjs +8 -1
- package/src/session.mjs +8 -2
- package/src/tools/file.mjs +37 -9
- package/src/tools/patch.mjs +7 -3
- package/src/tools/repomap.mjs +7 -3
- package/src/tools/shared.mjs +7 -0
- package/src/tools/system.mjs +18 -4
- package/src/tui/agent-turn.mjs +8 -0
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/cmd-advisor.mjs +2 -2
- package/src/tui/index.mjs +165 -53
- package/src/tui/key-handler.mjs +2 -2
- package/src/tui/layout.mjs +3 -3
- package/src/tui/render-frame.mjs +232 -168
package/README.md
CHANGED
|
@@ -205,6 +205,22 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
205
205
|
|
|
206
206
|
## Changelog
|
|
207
207
|
|
|
208
|
+
### 0.8.13 (2026-07)
|
|
209
|
+
- **TUI: incremental rendering** — panel-level cache (`panelCache`) with sync-update bracketing (`DECSET 2026`). Only redraws changed panels, eliminating flicker. `saveCursor`/`restoreCursor` for efficient cursor positioning. Panel order reorganized: `header → conversation → subagent → output → todo → picker → permission → queue → input → status`.
|
|
210
|
+
- **Ctrl+I inject resume** — Ctrl+I (or Tab during processing) now properly interrupts, injects the message, and *resumes* the agent loop. Controller is recreated after abort. Added active signal check in SSE read loop for faster abort on Windows.
|
|
211
|
+
- **Processing hints** — Input box shows "Ctrl+U clear" hint during processing. Tab during processing treated as Ctrl+I. Slash commands re-render the frame.
|
|
212
|
+
- **Compression visibility** — `compressIfNeeded` now forwards `onToken`/`onReasoning` callbacks, making compression activity visible in the TUI.
|
|
213
|
+
- **Session: data-preserving fallback** — when atomic rename fails during session save, fall back to direct write instead of losing data.
|
|
214
|
+
- **Distill: balanced-bracket JSON extraction** — handles nested arrays in LLM output (e.g. `"tags": ["a", "b"]`), replacing the broken non-greedy regex approach.
|
|
215
|
+
- **File tools: EOL normalization** — `normalizeEOL` (`\r\n` → `\n`) applied on all reads (`read`, `edit`, `hashline_edit`, `insert_after`, `grep`, `repomap`), making hash computation and string matching platform-consistent.
|
|
216
|
+
- **hashline_edit: multiple-match detection** — when a hash sequence matches multiple positions, reports all with surrounding context instead of silently picking one.
|
|
217
|
+
- **delete: symlink-safe** — uses `lstat` instead of `stat` to correctly identify symlinks (not directories even if pointing to one).
|
|
218
|
+
- **repomap: large file guard** — skip files >10MB in dependency outline builds to prevent OOM.
|
|
219
|
+
- **Improved error messages** — `grep` and `insert_after` now catch invalid regex patterns at validation time with clear error messages.
|
|
220
|
+
- **Advisor session persistence** — advisor config saved/restored across sessions.
|
|
221
|
+
- **Timeout hardening** — auto-think uses `AbortSignal.timeout(5s)`, embedding requests add 60s timeout, MCP HTTP connect uses `INIT_TIMEOUT_MS`, fetch timeout extended to 10 minutes.
|
|
222
|
+
- **ClearScreen on exit** — terminal restored with `clearScreen` ANSI on TUI cleanup.
|
|
223
|
+
|
|
208
224
|
### 0.8.12 (2026-07)
|
|
209
225
|
- **Indexing: git-repo-only** — `codeSync` and `docSync` now only index inside git worktrees (via `git ls-files`), respecting `.gitignore`. Non-git directories get empty indexes. Prevents 2.9GB memory.db from accidentally indexing entire user profiles (AppData, browser extensions, Office add-ins, Program Files)
|
|
210
226
|
- **Indexing: file-size caps** — code files >1MB and doc files >512KB are skipped during bulk indexing (minified bundles, test fixtures, generated code)
|
package/package.json
CHANGED
package/src/agent/setup.mjs
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
|
|
3
3
|
*/
|
|
4
|
-
import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
|
|
5
4
|
import { search as memorySearch, docSearch } from "../memory.mjs"
|
|
6
5
|
import { toOpenAISchema } from "../tools/index.mjs"
|
|
7
6
|
import { loadSkills, formatSkillListing } from "../skills.mjs"
|
|
8
|
-
import { specForModel } from "../config.mjs"
|
|
9
|
-
import { join } from "node:path"
|
|
10
7
|
import {
|
|
11
8
|
escapeXml, repairHistory, listWorkDir, readonlyToolNames,
|
|
12
9
|
collectGitContext, loadProjectInstructions, OUTLINE_INJECT_PREFIX,
|
package/src/agent.mjs
CHANGED
|
@@ -92,7 +92,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
92
92
|
const lastRole = agent.history.at(-1)?.role
|
|
93
93
|
if (lastRole === "user" || lastRole === "tool") {
|
|
94
94
|
try {
|
|
95
|
-
if (await compressIfNeeded(agent, threshold)) {
|
|
95
|
+
if (await compressIfNeeded(agent, threshold, callbacks)) {
|
|
96
96
|
agent._compressFailures = 0
|
|
97
97
|
recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
|
|
98
98
|
callbacks.onCompress?.()
|
|
@@ -118,7 +118,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
118
118
|
// Runs only on turn 0 of user input; failure is silent — falls back to current setting.
|
|
119
119
|
if (agent.config?.agent?.autoThink && turn === 0) {
|
|
120
120
|
const { classifyAndApply } = await import("./auto-think.mjs")
|
|
121
|
-
classifyAndApply(agent, turn).catch(() => {})
|
|
121
|
+
await classifyAndApply(agent, turn).catch(() => {})
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
try {
|
|
@@ -132,14 +132,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
132
132
|
})
|
|
133
133
|
} catch (e) {
|
|
134
134
|
// User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
|
|
135
|
-
//
|
|
136
|
-
// Inject the user's message into history and retry from the same context.
|
|
135
|
+
// Inject the message into history and let the outer loop recreate the controller.
|
|
137
136
|
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
138
137
|
agent.history.push({
|
|
139
138
|
role: "user",
|
|
140
139
|
content: `[User interrupt: ${signal.reason.message}]`,
|
|
141
140
|
})
|
|
142
|
-
continue
|
|
143
141
|
}
|
|
144
142
|
throw e
|
|
145
143
|
}
|
|
@@ -170,7 +168,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
170
168
|
}
|
|
171
169
|
|
|
172
170
|
// User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
|
|
173
|
-
// was partially generated. Commit partial output + inject user message, then
|
|
171
|
+
// was partially generated. Commit partial output + inject user message, then signal
|
|
172
|
+
// the outer loop to recreate the controller and resume.
|
|
174
173
|
if (response.interrupted) {
|
|
175
174
|
if (response.content) {
|
|
176
175
|
agent.history.push({ role: "assistant", content: response.content })
|
|
@@ -179,7 +178,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
179
178
|
role: "user",
|
|
180
179
|
content: `[User interrupt: ${response.interruptMessage}]`,
|
|
181
180
|
})
|
|
182
|
-
|
|
181
|
+
throw Object.assign(new Error("User interrupted"), { name: "AbortError" })
|
|
183
182
|
}
|
|
184
183
|
|
|
185
184
|
if (response.usage) {
|
package/src/auto-think.mjs
CHANGED
|
@@ -62,7 +62,7 @@ export async function classifyAndApply(agent, turn) {
|
|
|
62
62
|
{ role: "user", content: prompt.slice(0, 2000) },
|
|
63
63
|
],
|
|
64
64
|
tools: [],
|
|
65
|
-
signal:
|
|
65
|
+
signal: AbortSignal.timeout(5_000),
|
|
66
66
|
})
|
|
67
67
|
const word = (response.content ?? "").trim().toLowerCase()
|
|
68
68
|
if (word.startsWith("low")) level = "low"
|
package/src/context.mjs
CHANGED
|
@@ -143,7 +143,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
143
143
|
* Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
|
|
144
144
|
* Automatically re-injects task list state after compaction.
|
|
145
145
|
*/
|
|
146
|
-
export async function compressIfNeeded(agent, threshold) {
|
|
146
|
+
export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
147
147
|
const history = agent.history
|
|
148
148
|
// Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
|
|
149
149
|
// Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
|
|
@@ -174,6 +174,8 @@ export async function compressIfNeeded(agent, threshold) {
|
|
|
174
174
|
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
|
|
175
175
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
176
176
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
177
|
+
onToken: callbacks?.onToken,
|
|
178
|
+
onReasoning: callbacks?.onReasoning,
|
|
177
179
|
})
|
|
178
180
|
|
|
179
181
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
package/src/distill.mjs
CHANGED
|
@@ -47,11 +47,26 @@ export async function extractCandidates(provider, transcript) {
|
|
|
47
47
|
const res = await chat(provider, {
|
|
48
48
|
messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
|
|
49
49
|
})
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Balanced-bracket extraction: find the first '[' and track depth through nested
|
|
51
|
+
// brackets (tags arrays, nested objects, etc.) until the matching ']'.
|
|
52
|
+
// Non-greedy regex (/\[[\s\S]*?\]/) stops at the FIRST ']', which is wrong when
|
|
53
|
+
// LLM output contains nested arrays like `"tags": ["a", "b"]`.
|
|
54
|
+
const start = res.content.indexOf("[")
|
|
55
|
+
if (start === -1) return []
|
|
56
|
+
let depth = 0
|
|
57
|
+
let end = -1
|
|
58
|
+
for (let i = start; i < res.content.length; i++) {
|
|
59
|
+
const ch = res.content[i]
|
|
60
|
+
if (ch === "[" && (i === start || res.content[i - 1] !== "\\")) depth++
|
|
61
|
+
else if (ch === "]" && res.content[i - 1] !== "\\") {
|
|
62
|
+
depth--
|
|
63
|
+
if (depth === 0) { end = i + 1; break }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (end === -1) return []
|
|
67
|
+
const jsonText = res.content.slice(start, end)
|
|
53
68
|
try {
|
|
54
|
-
const parsed = JSON.parse(
|
|
69
|
+
const parsed = JSON.parse(jsonText)
|
|
55
70
|
if (!Array.isArray(parsed)) return []
|
|
56
71
|
return parsed.filter((c) => c?.type && c?.title && c?.content)
|
|
57
72
|
} catch {
|
package/src/embedding.mjs
CHANGED
|
@@ -84,7 +84,9 @@ async function requestWithRetry(embedder, input, signal) {
|
|
|
84
84
|
Authorization: `Bearer ${embedder.apiKey}`,
|
|
85
85
|
},
|
|
86
86
|
body: JSON.stringify({ model: embedder.model, input }),
|
|
87
|
-
signal
|
|
87
|
+
signal: signal
|
|
88
|
+
? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
|
|
89
|
+
: AbortSignal.timeout(60_000),
|
|
88
90
|
})
|
|
89
91
|
} catch (error) {
|
|
90
92
|
if (error.name === "AbortError") throw error
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp/transport-http.mjs — MCP HTTP + SSE transport (Streamable HTTP)
|
|
3
3
|
*/
|
|
4
|
-
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
|
|
4
|
+
import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, INIT_TIMEOUT_MS, withTimeout } from "./helpers.mjs"
|
|
5
5
|
|
|
6
6
|
/** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
|
|
7
7
|
export function httpTransport(baseURL, extraHeaders = {}) {
|
|
@@ -56,10 +56,11 @@ export function httpTransport(baseURL, extraHeaders = {}) {
|
|
|
56
56
|
if (closed) return
|
|
57
57
|
abortController?.abort()
|
|
58
58
|
abortController = new AbortController()
|
|
59
|
+
const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(INIT_TIMEOUT_MS)])
|
|
59
60
|
const resp = await fetch(url, {
|
|
60
61
|
method: "GET",
|
|
61
62
|
headers: { Accept: "text/event-stream", ...extraHeaders },
|
|
62
|
-
signal
|
|
63
|
+
signal,
|
|
63
64
|
})
|
|
64
65
|
if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
|
|
65
66
|
eventSource = parseSSE(resp)
|
package/src/memory/docs.mjs
CHANGED
|
@@ -7,12 +7,11 @@ import { join } from "node:path"
|
|
|
7
7
|
import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
|
|
8
8
|
import { commitAndPush } from "../git/gitmem.mjs"
|
|
9
9
|
import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
|
|
10
|
-
import { buildFtsQuery, put, search, putMarkdown } from "./core.mjs"
|
|
10
|
+
import { buildFtsQuery, put, search, putMarkdown, EMBED_TEXT_MAX_LEN } from "./core.mjs"
|
|
11
11
|
import { _upsertDocFile, yieldTick } from "./code-index.mjs"
|
|
12
12
|
import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
|
|
13
13
|
|
|
14
14
|
const DOC_EMBED_BATCH = 64
|
|
15
|
-
const EMBED_TEXT_MAX_LEN = 2000
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
|
package/src/provider/core.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
estimateRequestTokens, rateGate, recordRate,
|
|
11
11
|
} from "./rate.mjs"
|
|
12
12
|
|
|
13
|
-
const FETCH_TIMEOUT_MS =
|
|
13
|
+
const FETCH_TIMEOUT_MS = 600_000
|
|
14
14
|
|
|
15
15
|
/** Create a validated provider config object from raw config */
|
|
16
16
|
export function createProvider(config) {
|
|
@@ -315,6 +315,13 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal })
|
|
|
315
315
|
if (!response.body) throw new Error("No stream response body")
|
|
316
316
|
try {
|
|
317
317
|
for await (const chunk of response.body) {
|
|
318
|
+
// Active signal check: Ctrl+I abort should halt stream immediately, not wait for
|
|
319
|
+
// the underlying fetch stream to propagate the abort (delayed on Windows).
|
|
320
|
+
if (signal?.aborted) {
|
|
321
|
+
const e = new DOMException("The operation was aborted", "AbortError")
|
|
322
|
+
e.reason = signal.reason
|
|
323
|
+
throw e
|
|
324
|
+
}
|
|
318
325
|
buffer += decoder.decode(chunk, { stream: true })
|
|
319
326
|
const lines = buffer.split("\n")
|
|
320
327
|
buffer = lines.pop()
|
package/src/session.mjs
CHANGED
|
@@ -40,7 +40,9 @@ function writeSessionFile(p, data) {
|
|
|
40
40
|
// rename succeeded: clean up temp file
|
|
41
41
|
try { unlinkSync(tmp) } catch {}
|
|
42
42
|
} catch {
|
|
43
|
-
// rename still failed
|
|
43
|
+
// rename still failed — fall back to direct write (non-atomic but data-preserving)
|
|
44
|
+
// p was deleted above; avoid losing both old and new data
|
|
45
|
+
writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -161,6 +163,7 @@ export function saveSession(agent, display) {
|
|
|
161
163
|
planMode: agent.planMode ?? false,
|
|
162
164
|
autoApprove: agent.autoApprove ?? false,
|
|
163
165
|
goal: agent.goal ?? null,
|
|
166
|
+
advisor: agent.config?.advisor ?? null,
|
|
164
167
|
pendingReminders: agent._pendingReminders ?? [],
|
|
165
168
|
sessionStart: agent._sessionStart ?? null,
|
|
166
169
|
}
|
|
@@ -214,6 +217,9 @@ export function applySession(agent, data) {
|
|
|
214
217
|
agent.goal = data.goal ?? null
|
|
215
218
|
agent._pendingReminders = data.pendingReminders ?? []
|
|
216
219
|
agent._sessionStart = data.sessionStart ?? null
|
|
220
|
+
if (data.advisor) {
|
|
221
|
+
agent.config.advisor = { ...data.advisor }
|
|
222
|
+
}
|
|
217
223
|
// Reset stall/compaction state on session switch
|
|
218
224
|
agent._compressFailures = 0
|
|
219
225
|
agent._verifyRetries = 0
|
|
@@ -234,7 +240,7 @@ export function clearSession(cwd) {
|
|
|
234
240
|
try {
|
|
235
241
|
archiveCurrent(cwd)
|
|
236
242
|
const p = sessionPath(cwd)
|
|
237
|
-
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
|
|
243
|
+
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null })
|
|
238
244
|
} catch {
|
|
239
245
|
// Can't clear, oh well — next save will overwrite
|
|
240
246
|
}
|
package/src/tools/file.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
autoSyntaxCheck,
|
|
7
7
|
resolveInCwd,
|
|
8
8
|
resolveExternal,
|
|
9
|
+
normalizeEOL,
|
|
9
10
|
} from "./shared.mjs";
|
|
10
11
|
import { specForModel } from "../config.mjs";
|
|
11
12
|
import { createHash } from "node:crypto";
|
|
@@ -39,7 +40,7 @@ export const readTool = {
|
|
|
39
40
|
// Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
|
|
40
41
|
const st = await stat(abs).catch(() => null)
|
|
41
42
|
if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
|
|
42
|
-
const content = await readFile(abs, "utf8")
|
|
43
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
43
44
|
const lines = content.split("\n")
|
|
44
45
|
const offset = Math.max(1, args.offset ?? 1)
|
|
45
46
|
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
@@ -153,7 +154,7 @@ export const editTool = {
|
|
|
153
154
|
if (!args.old_string) {
|
|
154
155
|
throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
|
|
155
156
|
}
|
|
156
|
-
const content = await readFile(abs, "utf8")
|
|
157
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
157
158
|
const occurrences = content.split(args.old_string).length - 1
|
|
158
159
|
if (occurrences === 0) {
|
|
159
160
|
// Give clues to help the model locate: first-line preview + common causes
|
|
@@ -195,7 +196,7 @@ export const insertAfterTool = {
|
|
|
195
196
|
readonly: false,
|
|
196
197
|
async execute(args, ctx) {
|
|
197
198
|
const abs = resolveInCwd(ctx, args.path)
|
|
198
|
-
const text = await readFile(abs, "utf8")
|
|
199
|
+
const text = normalizeEOL(await readFile(abs, "utf8"))
|
|
199
200
|
const lines = text.split("\n")
|
|
200
201
|
|
|
201
202
|
let targetLine
|
|
@@ -208,7 +209,12 @@ export const insertAfterTool = {
|
|
|
208
209
|
throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
|
|
209
210
|
}
|
|
210
211
|
} else if (args.after_regex) {
|
|
211
|
-
|
|
212
|
+
let regex
|
|
213
|
+
try {
|
|
214
|
+
regex = new RegExp(args.after_regex)
|
|
215
|
+
} catch (e) {
|
|
216
|
+
throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`)
|
|
217
|
+
}
|
|
212
218
|
const matches = []
|
|
213
219
|
for (let i = 0; i < lines.length; i++) {
|
|
214
220
|
if (regex.test(lines[i])) matches.push(i + 1)
|
|
@@ -254,22 +260,24 @@ export const hashlineEditTool = {
|
|
|
254
260
|
async execute(args, ctx) {
|
|
255
261
|
const abs = resolveInCwd(ctx, args.path)
|
|
256
262
|
if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
|
|
257
|
-
const content = await readFile(abs, "utf8")
|
|
263
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
258
264
|
const lines = content.split("\n")
|
|
259
265
|
const fileHashes = lines.map((l) => hashLine(l))
|
|
260
266
|
const target = args.old_hashes
|
|
261
267
|
|
|
262
|
-
// Sliding-window match: find the
|
|
263
|
-
|
|
268
|
+
// Sliding-window match: find all occurrences of the hash sequence.
|
|
269
|
+
// When multiple matches are found (e.g. empty lines), report positions so the
|
|
270
|
+
// model can include more context lines (adjacent lines with unique hashes).
|
|
271
|
+
const matches = []
|
|
264
272
|
for (let i = 0; i <= fileHashes.length - target.length; i++) {
|
|
265
273
|
let match = true
|
|
266
274
|
for (let j = 0; j < target.length; j++) {
|
|
267
275
|
if (fileHashes[i + j] !== target[j]) { match = false; break }
|
|
268
276
|
}
|
|
269
|
-
if (match)
|
|
277
|
+
if (match) matches.push(i)
|
|
270
278
|
}
|
|
271
279
|
|
|
272
|
-
if (
|
|
280
|
+
if (matches.length === 0) {
|
|
273
281
|
// Help the model recover: show the current file hashes for context
|
|
274
282
|
const maxShow = Math.min(fileHashes.length, 50)
|
|
275
283
|
const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
|
|
@@ -280,6 +288,26 @@ export const hashlineEditTool = {
|
|
|
280
288
|
)
|
|
281
289
|
}
|
|
282
290
|
|
|
291
|
+
if (matches.length > 1) {
|
|
292
|
+
const ctx = 2 // lines of surrounding context
|
|
293
|
+
const detail = matches.map((m) => {
|
|
294
|
+
const start = Math.max(0, m - ctx)
|
|
295
|
+
const end = Math.min(lines.length, m + target.length + ctx)
|
|
296
|
+
const preview = lines.slice(start, end).map((l, i) => {
|
|
297
|
+
const ln = start + i + 1
|
|
298
|
+
const marker = m <= ln - 1 && ln - 1 < m + target.length ? ">" : " "
|
|
299
|
+
return `${marker} L${ln}: ${l.slice(0, 80)}`
|
|
300
|
+
}).join("\n")
|
|
301
|
+
return ` Match at line ${m + 1} (${target.length} line(s)):\n${preview}`
|
|
302
|
+
}).join("\n\n")
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Hash sequence matches ${matches.length} positions in ${args.path} — ambiguous.\n` +
|
|
305
|
+
`Include more surrounding lines (unique-hash lines before/after the target) to disambiguate.\n\n` +
|
|
306
|
+
`All matches with surrounding context:\n\n${detail}`
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const pos = matches[0]
|
|
283
311
|
// Replace: remove old lines, insert new lines at the same position
|
|
284
312
|
const newLines = args.new_content.split("\n")
|
|
285
313
|
lines.splice(pos, target.length, ...newLines)
|
package/src/tools/patch.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { mkdir } from "node:fs/promises";
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
|
-
import { stat } from "node:fs/promises";
|
|
9
|
+
import { stat, lstat } from "node:fs/promises";
|
|
10
10
|
import { writeFile } from "node:fs/promises";
|
|
11
11
|
import { unlink } from "node:fs/promises";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
@@ -206,8 +206,12 @@ export const deleteTool = {
|
|
|
206
206
|
readonly: false,
|
|
207
207
|
async execute(args, ctx) {
|
|
208
208
|
const abs = resolveInCwd(ctx, args.path)
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
let s
|
|
210
|
+
try {
|
|
211
|
+
s = await lstat(abs)
|
|
212
|
+
} catch {
|
|
213
|
+
throw new Error(`File not found: ${args.path}`)
|
|
214
|
+
}
|
|
211
215
|
if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
|
|
212
216
|
// git-tracked files: refuse direct deletion (safety net); untracked: allow
|
|
213
217
|
// Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
|
package/src/tools/repomap.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repomap.mjs — repo dependency outline
|
|
2
|
+
* repomap.mjs — repo dependency outline
|
|
3
3
|
* Real-time import/export parsing, generates compact text for LLMs to understand code structure.
|
|
4
4
|
* No index stored — reads and parses files on each call, ~50ms.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from "node:fs"
|
|
7
|
-
import { readFile } from "node:fs/promises"
|
|
7
|
+
import { readFile, stat } from "node:fs/promises"
|
|
8
8
|
import { join } from "node:path"
|
|
9
|
+
import { normalizeEOL } from "./shared.mjs"
|
|
9
10
|
|
|
10
11
|
/** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
|
|
11
12
|
function parseImports(lines, ext) {
|
|
@@ -125,8 +126,11 @@ async function _buildDepGraph(db, cwd) {
|
|
|
125
126
|
const rel = allFiles[i]
|
|
126
127
|
const abs = join(cwd, ...rel.split("/"))
|
|
127
128
|
if (!existsSync(abs)) continue
|
|
129
|
+
// Large file guard: skip files over 10MB to prevent OOM
|
|
130
|
+
const fst = await stat(abs).catch(() => null)
|
|
131
|
+
if (fst && fst.size > 10_000_000) continue
|
|
128
132
|
let text
|
|
129
|
-
try { text = await readFile(abs, "utf8") } catch { continue }
|
|
133
|
+
try { text = normalizeEOL(await readFile(abs, "utf8")) } catch { continue }
|
|
130
134
|
const lines = text.split("\n")
|
|
131
135
|
const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
|
|
132
136
|
|
package/src/tools/shared.mjs
CHANGED
|
@@ -20,6 +20,13 @@ export const BASH_TIMEOUT_MS = 120_000
|
|
|
20
20
|
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
21
21
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
22
22
|
|
|
23
|
+
/** Normalize Windows line endings to Unix: \r\n → \n.
|
|
24
|
+
* Applied on every text-file read so that edit/hash matching
|
|
25
|
+
* and hash computation are platform-consistent. */
|
|
26
|
+
export function normalizeEOL(text) {
|
|
27
|
+
return text.replace(/\r\n/g, "\n")
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
/** Convert to OpenAI tools parameter format */
|
|
24
31
|
export function toOpenAISchema(tool) {
|
|
25
32
|
return {
|
package/src/tools/system.mjs
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
isDestructiveCommand,
|
|
12
12
|
hasFileRedirection,
|
|
13
13
|
insideGitRepo,
|
|
14
|
-
globToRegex
|
|
14
|
+
globToRegex,
|
|
15
|
+
normalizeEOL,
|
|
15
16
|
} from "./shared.mjs";
|
|
16
17
|
import { spawn, execFileSync } from "node:child_process";
|
|
17
18
|
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
@@ -222,6 +223,8 @@ async function* walkFiles(dir, rel = "") {
|
|
|
222
223
|
return
|
|
223
224
|
}
|
|
224
225
|
for (const e of entries) {
|
|
226
|
+
// Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
|
|
227
|
+
if (e.isSymbolicLink()) continue
|
|
225
228
|
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
|
|
226
229
|
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
227
230
|
if (e.isDirectory()) {
|
|
@@ -253,7 +256,12 @@ export const grepTool = {
|
|
|
253
256
|
readonly: true,
|
|
254
257
|
async execute(args, ctx) {
|
|
255
258
|
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
256
|
-
|
|
259
|
+
let regex
|
|
260
|
+
try {
|
|
261
|
+
regex = new RegExp(args.pattern)
|
|
262
|
+
} catch (e) {
|
|
263
|
+
throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
|
|
264
|
+
}
|
|
257
265
|
const fileFilter = args.glob ? globToRegex(args.glob) : null
|
|
258
266
|
const before = Math.max(0, Math.floor(args.before ?? 0))
|
|
259
267
|
const after = Math.max(0, Math.floor(args.after ?? 0))
|
|
@@ -267,7 +275,7 @@ export const grepTool = {
|
|
|
267
275
|
// Large file guard: skip files over 10MB to prevent OOM
|
|
268
276
|
const fst = await stat(file)
|
|
269
277
|
if (fst.size > 10_000_000) return
|
|
270
|
-
content = await readFile(file, "utf8")
|
|
278
|
+
content = normalizeEOL(await readFile(file, "utf8"))
|
|
271
279
|
} catch {
|
|
272
280
|
return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
|
|
273
281
|
}
|
|
@@ -346,7 +354,13 @@ export const lsTool = {
|
|
|
346
354
|
readonly: true,
|
|
347
355
|
async execute(args, ctx) {
|
|
348
356
|
const abs = resolveInCwd(ctx, args.path ?? ".")
|
|
349
|
-
|
|
357
|
+
let entries
|
|
358
|
+
try {
|
|
359
|
+
entries = await readdir(abs, { withFileTypes: true })
|
|
360
|
+
} catch (e) {
|
|
361
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`)
|
|
362
|
+
throw e
|
|
363
|
+
}
|
|
350
364
|
const rows = await Promise.all(
|
|
351
365
|
entries.slice(0, 500).map(async (e) => {
|
|
352
366
|
const s = await stat(join(abs, e.name)).catch(() => null)
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -218,6 +218,14 @@ export async function runAgentTurn(ctx, text) {
|
|
|
218
218
|
} catch (error) {
|
|
219
219
|
flushStream()
|
|
220
220
|
if (error.name === "AbortError" || state.controller?.signal.aborted) {
|
|
221
|
+
// Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
|
|
222
|
+
// may have already injected it into history, but the aborted signal prevents retry.
|
|
223
|
+
// Recreate the controller and resume from the same context.
|
|
224
|
+
if (state.controller?.signal?.reason?.interrupt) {
|
|
225
|
+
state.controller = new AbortController()
|
|
226
|
+
resume = true
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
221
229
|
pushLine("[stopped]", C.warn)
|
|
222
230
|
break
|
|
223
231
|
}
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -17,6 +17,10 @@ export const ansi = {
|
|
|
17
17
|
clearLine: `${ESC}[K`,
|
|
18
18
|
clearToEnd: `${ESC}[J`,
|
|
19
19
|
clearScreen: `${ESC}[2J`,
|
|
20
|
+
saveCursor: `${ESC}7`, // DECSC — save cursor position
|
|
21
|
+
restoreCursor: `${ESC}8`, // DECRC — restore cursor position
|
|
22
|
+
syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
|
|
23
|
+
syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
|
|
20
24
|
reset: `${ESC}[0m`,
|
|
21
25
|
dim: `${ESC}[2m`,
|
|
22
26
|
bold: `${ESC}[1m`,
|
package/src/tui/cmd-advisor.mjs
CHANGED
|
@@ -56,11 +56,11 @@ async function openAdvisorModelPicker(ctx) {
|
|
|
56
56
|
// Same as main — clear override (use main pool)
|
|
57
57
|
delete cfg.provider
|
|
58
58
|
delete cfg.model
|
|
59
|
-
pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})
|
|
59
|
+
pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
|
|
60
60
|
} else {
|
|
61
61
|
cfg.provider = e.provider
|
|
62
62
|
cfg.model = e.model
|
|
63
|
-
pushLine(`Advisor: ${e.provider}/${e.model}
|
|
63
|
+
pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
},
|