thincoder 0.8.10 → 0.8.12

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.
Files changed (52) hide show
  1. package/README.md +16 -0
  2. package/bin/thincoder.mjs +115 -0
  3. package/package.json +1 -1
  4. package/src/advisor.mjs +105 -0
  5. package/src/agent/dispatch.mjs +35 -0
  6. package/src/agent/helpers.mjs +1 -1
  7. package/src/agent/setup.mjs +21 -7
  8. package/src/agent-tools/subagent.mjs +1 -1
  9. package/src/agent-tools/timer.mjs +41 -0
  10. package/src/agent-tools/verify.mjs +165 -56
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +128 -20
  13. package/src/auto-think.mjs +83 -0
  14. package/src/cli/make-agent.mjs +9 -0
  15. package/src/config.mjs +18 -18
  16. package/src/git/checkpoint.mjs +2 -1
  17. package/src/git/gitmem.mjs +8 -2
  18. package/src/markdown.mjs +1 -1
  19. package/src/mcp/transport-http.mjs +8 -2
  20. package/src/memory/code-index.mjs +2 -2
  21. package/src/memory/code-sync.mjs +92 -35
  22. package/src/memory/core.mjs +10 -1
  23. package/src/memory/docs.mjs +24 -26
  24. package/src/memory/schema.mjs +16 -3
  25. package/src/prompts/coder.md +7 -4
  26. package/src/prompts/discipline.md +55 -16
  27. package/src/prompts/main.md +15 -11
  28. package/src/prompts/system.md +33 -7
  29. package/src/provider/core.mjs +186 -20
  30. package/src/provider/index.mjs +1 -1
  31. package/src/rules.mjs +53 -0
  32. package/src/session.mjs +1 -1
  33. package/src/tools/checklist.md +7 -0
  34. package/src/tools/checklist.mjs +114 -0
  35. package/src/tools/file.mjs +82 -1
  36. package/src/tools/hashline_edit.md +12 -0
  37. package/src/tools/index.mjs +7 -3
  38. package/src/tools/linter.md +13 -0
  39. package/src/tools/linter.mjs +146 -0
  40. package/src/tools/read.md +3 -2
  41. package/src/tools/repomap.mjs +14 -9
  42. package/src/tui/agent-turn.mjs +9 -2
  43. package/src/tui/ansi.mjs +1 -0
  44. package/src/tui/cmd-advisor.mjs +68 -0
  45. package/src/tui/cmd-think.mjs +36 -10
  46. package/src/tui/index.mjs +2 -1
  47. package/src/tui/key-handler.mjs +36 -1
  48. package/src/tui/layout.mjs +3 -1
  49. package/src/tui/pickers.mjs +15 -15
  50. package/src/tui/render-frame.mjs +17 -8
  51. package/src/tui/slash-commands.mjs +3 -0
  52. package/src/tools/repomap-parse.mjs +0 -168
@@ -0,0 +1,114 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
2
+ import { join, dirname } from "node:path"
3
+ import { DESC } from "./shared.mjs"
4
+
5
+ const CHECKLIST = "checklist.md"
6
+ const DONE = "checklist-done.md"
7
+
8
+ function checklistPath(cwd) { return join(cwd, ".thincoder", CHECKLIST) }
9
+ function donePath(cwd) { return join(cwd, ".thincoder", DONE) }
10
+
11
+ /** Parse checklist file into array of { index, status, text } */
12
+ function parse(filePath) {
13
+ if (!existsSync(filePath)) return []
14
+ const lines = readFileSync(filePath, "utf-8").split("\n")
15
+ const items = []
16
+ let idx = 0
17
+ for (const line of lines) {
18
+ const m = line.match(/^- \[(.)\] (.+)$/)
19
+ if (m) {
20
+ idx++
21
+ const raw = m[1]
22
+ const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
23
+ items.push({ index: idx, status, text: m[2].trim() })
24
+ }
25
+ }
26
+ return items
27
+ }
28
+
29
+ /** Write items back to file */
30
+ function write(filePath, items) {
31
+ mkdirSync(dirname(filePath), { recursive: true })
32
+ const lines = []
33
+ for (const item of items) {
34
+ const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
35
+ lines.push(`- [${mark}] ${item.text}`)
36
+ }
37
+ writeFileSync(filePath, lines.join("\n") + "\n")
38
+ }
39
+
40
+ /** Parse pending items only (for context injection) */
41
+ export function pendingItems(cwd) {
42
+ return parse(checklistPath(cwd)).filter(i => i.status !== "done")
43
+ }
44
+
45
+ export const checklistTool = {
46
+ name: "checklist",
47
+ description: DESC("checklist"),
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ action: {
52
+ type: "string",
53
+ enum: ["add", "mark", "list"],
54
+ description: "add a new item / mark item status / list all items"
55
+ },
56
+ item: {
57
+ type: "string",
58
+ description: "Item text (required for add)"
59
+ },
60
+ index: {
61
+ type: "number",
62
+ description: "1-based item index (required for mark)"
63
+ },
64
+ status: {
65
+ type: "string",
66
+ enum: ["pending", "in_progress", "done"],
67
+ description: "New status (required for mark)"
68
+ },
69
+ },
70
+ required: ["action"],
71
+ },
72
+ readonly: false,
73
+ execute(args, ctx) {
74
+ switch (args.action) {
75
+ case "add": {
76
+ if (!args.item || typeof args.item !== "string") return "Error: 'item' is required for add"
77
+ const items = parse(checklistPath(ctx.cwd))
78
+ items.push({ index: items.length + 1, status: "pending", text: args.item })
79
+ write(checklistPath(ctx.cwd), items)
80
+ return `Added: [ ] ${args.item}`
81
+ }
82
+ case "mark": {
83
+ if (args.index == null) return "Error: 'index' is required for mark"
84
+ const status = args.status
85
+ if (!status || !["pending", "in_progress", "done"].includes(status)) return "Error: 'status' is required (pending|in_progress|done)"
86
+ const cp = checklistPath(ctx.cwd)
87
+ const items = parse(cp)
88
+ if (args.index < 1 || args.index > items.length) return `Error: index ${args.index} out of range (1-${items.length})`
89
+ const item = items[args.index - 1]
90
+ const old = item.status
91
+ if (old === status) return `Already ${status}: ${item.text}`
92
+ item.status = status
93
+ if (status === "done") {
94
+ // Move to done file
95
+ const dp = donePath(ctx.cwd)
96
+ const doneItems = parse(dp)
97
+ doneItems.push(item)
98
+ write(dp, doneItems)
99
+ items.splice(args.index - 1, 1)
100
+ }
101
+ write(cp, items)
102
+ return `Marked #${args.index} ${old} → ${status}: ${item.text}`
103
+ }
104
+ case "list": {
105
+ const items = parse(checklistPath(ctx.cwd))
106
+ if (items.length === 0) return "(checklist is empty)"
107
+ const marks = { pending: " ", in_progress: "~", done: "x" }
108
+ return items.map(i => `- [${marks[i.status]}] ${i.text}`).join("\n")
109
+ }
110
+ default:
111
+ return `Error: unknown action '${args.action}'`
112
+ }
113
+ },
114
+ }
@@ -7,6 +7,8 @@ import {
7
7
  resolveInCwd,
8
8
  resolveExternal,
9
9
  } from "./shared.mjs";
10
+ import { specForModel } from "../config.mjs";
11
+ import { createHash } from "node:crypto";
10
12
  import { mkdir } from "node:fs/promises";
11
13
  import { readFile } from "node:fs/promises";
12
14
  import { stat } from "node:fs/promises";
@@ -27,6 +29,7 @@ export const readTool = {
27
29
  offset: { type: "number", description: "1-based line number to start from" },
28
30
  limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
29
31
  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." },
32
+ hashes: { type: "boolean", description: "Include SHA256 line hashes for hash-based editing (default false). Use when you plan to edit the file with hashline_edit." },
30
33
  },
31
34
  required: ["path"],
32
35
  },
@@ -41,7 +44,14 @@ export const readTool = {
41
44
  const offset = Math.max(1, args.offset ?? 1)
42
45
  const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
43
46
  const slice = lines.slice(offset - 1, offset - 1 + limit)
44
- const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n")
47
+ const numbered = slice.map((l, i) => {
48
+ const ln = offset + i
49
+ if (args.hashes) {
50
+ const h = createHash("sha256").update(l).digest("hex").slice(0, 12)
51
+ return `${ln}\t[${h}] ${l}`
52
+ }
53
+ return `${ln}\t${l}`
54
+ }).join("\n")
45
55
  const suffix = offset - 1 + limit < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
46
56
  return truncate(numbered + suffix)
47
57
  },
@@ -65,6 +75,15 @@ export const readImageTool = {
65
75
  multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
66
76
  /** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
67
77
  async execute(args, ctx) {
78
+ // Vision capability gate: injecting an image into a text-only model's history poisons the whole
79
+ // conversation (every subsequent request 400s on the image part). Refuse before reading the file.
80
+ const model = ctx.agent?.provider?.model
81
+ if (model && !specForModel(model).multimodal) {
82
+ throw new Error(
83
+ `Model "${model}" does not support image input — read_image is unavailable with this provider. ` +
84
+ `Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.`
85
+ )
86
+ }
68
87
  const abs = resolveInCwd(ctx, args.path)
69
88
  const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
70
89
  const mime = IMAGE_EXTENSIONS[ext]
@@ -209,3 +228,65 @@ export const insertAfterTool = {
209
228
  },
210
229
  }
211
230
 
231
+ // ---------------------------------------------------------------- hashline_edit
232
+
233
+ /**
234
+ * Compute a 12-char hex SHA256 hash for a line (exact content, no trimming).
235
+ * Used by both read (hashes=true) and hashline_edit for hash-based matching.
236
+ */
237
+ export function hashLine(content) {
238
+ return createHash("sha256").update(content).digest("hex").slice(0, 12)
239
+ }
240
+
241
+ export const hashlineEditTool = {
242
+ name: "hashline_edit",
243
+ description: DESC("hashline_edit"),
244
+ parameters: {
245
+ type: "object",
246
+ properties: {
247
+ path: { type: "string", description: "File path" },
248
+ old_hashes: { type: "array", items: { type: "string" }, description: "SHA256 hashes (12 chars) of the lines to replace. Read the file with hashes=true first to obtain these hashes. Single line: pass 1 hash; multiple lines: pass the exact sequence of hashes." },
249
+ new_content: { type: "string", description: "Replacement text (can span multiple lines)" },
250
+ },
251
+ required: ["path", "old_hashes", "new_content"],
252
+ },
253
+ readonly: false,
254
+ async execute(args, ctx) {
255
+ const abs = resolveInCwd(ctx, args.path)
256
+ 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")
258
+ const lines = content.split("\n")
259
+ const fileHashes = lines.map((l) => hashLine(l))
260
+ const target = args.old_hashes
261
+
262
+ // Sliding-window match: find the exact sequence of hashes
263
+ let pos = -1
264
+ for (let i = 0; i <= fileHashes.length - target.length; i++) {
265
+ let match = true
266
+ for (let j = 0; j < target.length; j++) {
267
+ if (fileHashes[i + j] !== target[j]) { match = false; break }
268
+ }
269
+ if (match) { pos = i; break }
270
+ }
271
+
272
+ if (pos === -1) {
273
+ // Help the model recover: show the current file hashes for context
274
+ const maxShow = Math.min(fileHashes.length, 50)
275
+ const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
276
+ const preview = target.join(" ")
277
+ throw new Error(
278
+ `Hash sequence not found in ${args.path}: ${preview}\n` +
279
+ `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}`
280
+ )
281
+ }
282
+
283
+ // Replace: remove old lines, insert new lines at the same position
284
+ const newLines = args.new_content.split("\n")
285
+ lines.splice(pos, target.length, ...newLines)
286
+ const updated = lines.join("\n")
287
+ await writeFile(abs, updated, "utf8")
288
+ const diff = gitDiffOne(ctx.cwd, abs)
289
+ return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
290
+ },
291
+ }
292
+
@@ -0,0 +1,12 @@
1
+ Edit a file using content-hash addressing instead of string matching. More reliable than edit when whitespace or encoding varies — hashes are computed from exact line bytes on disk.
2
+
3
+ Parameters:
4
+ - path (required): File path
5
+ - old_hashes (required): Array of SHA256 hashes (12-char hex) identifying lines to replace. Read the file with hashes=true first to obtain these hashes. For a single line, pass [hash]; for a contiguous block, pass [hash1, hash2, ...] in order.
6
+ - new_content (required): Replacement text (multi-line ok, \n separated)
7
+
8
+ Notes:
9
+ - The hash of each line is computed as SHA256(line_content).slice(0, 12) — the same algorithm used by read(hashes=true)
10
+ - Hashes are position-independent: they identify lines by content, not by line number (which changes after edits)
11
+ - If the hash sequence isn't found, the error will include the current file's hashes so you can retry with corrected values
12
+ - Prefer this over edit when: 1) the file may have mixed whitespace/encoding, 2) you want to edit a block of lines with a single call
@@ -1,22 +1,26 @@
1
1
  // tools/index.mjs — backend-compatible re-export
2
2
  export { toOpenAISchema } from "./shared.mjs";
3
3
 
4
- import { readTool, writeTool, editTool, insertAfterTool, readImageTool } from "./file.mjs";
4
+ import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
5
5
  import { applyPatchTool, syntaxCheckTool, deleteTool } from "./patch.mjs";
6
6
  import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
7
7
  import { websearchTool, fetchTool } from "./web.mjs";
8
8
  import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
9
+ import { checklistTool } from "./checklist.mjs";
10
+ import { linterTool } from "./linter.mjs";
9
11
 
10
12
  export const builtinTools = [
11
- readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
13
+ readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
12
14
  syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
13
15
  websearchTool, lsTool, fetchTool, deleteTool,
14
16
  gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
17
+ checklistTool, linterTool,
15
18
  ];
16
19
 
17
20
  export {
18
- readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
21
+ readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
19
22
  syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
20
23
  websearchTool, lsTool, fetchTool, deleteTool,
21
24
  gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
25
+ checklistTool, linterTool,
22
26
  };
@@ -0,0 +1,13 @@
1
+ Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.
2
+
3
+ Parameters:
4
+ - path (optional): File to check (default: most recently modified file)
5
+
6
+ Supported languages:
7
+ - .js/.mjs/.cjs/.jsx: eslint (if config present) → node --check (built-in, always available)
8
+ - .ts/.tsx/.mts/.cts: eslint → tsc --noEmit (if tsconfig.json present) → node --check
9
+ - .py: ruff (if installed)
10
+ - .rs: cargo check (if Cargo.toml present)
11
+ - .go: go vet
12
+
13
+ Does NOT install anything — only uses tools already available in the project.
@@ -0,0 +1,146 @@
1
+ import { DESC, resolveInCwd } from "./shared.mjs"
2
+
3
+ export const linterTool = {
4
+ name: "linter",
5
+ description: DESC("linter"),
6
+ parameters: {
7
+ type: "object",
8
+ properties: {
9
+ path: { type: "string", description: "File path (default: most recently modified file)" },
10
+ },
11
+ },
12
+ readonly: true,
13
+ async execute(args, ctx) {
14
+ const { execFileSync } = await import("node:child_process")
15
+ const { existsSync } = await import("node:fs")
16
+ const { join, relative } = await import("node:path")
17
+ const abs = args.path ? resolveInCwd(ctx, args.path) : (ctx.agent?._touchedFiles?.at(-1) || null)
18
+ if (!abs) return "linter: no file specified and no recently modified file to check"
19
+
20
+ const ext = abs.split(".").pop()?.toLowerCase()
21
+ const checkers = LANG_CHECKERS[ext]
22
+ if (!checkers) return `linter: no linter configured for .${ext} files. Supported: ${Object.keys(LANG_CHECKERS).map(e => `.${e}`).join(", ")}`
23
+
24
+ for (const checker of checkers) {
25
+ const result = await checker(abs, { cwd: ctx.cwd, existsSync, execFileSync, join, relative })
26
+ if (result !== null) return result
27
+ }
28
+ return `linter: no linter available for ${args.path || abs}. Install one?`
29
+ },
30
+ }
31
+
32
+ // ─── Checker definitions ──────────────────────
33
+
34
+ async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
35
+ // Walk up to find eslint config
36
+ let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
37
+ while (true) {
38
+ for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
39
+ if (existsSync(join(cwd, dir, cfg))) {
40
+ try {
41
+ const cfgDir = join(cwd, dir)
42
+ const relPath = relative(cfgDir, file)
43
+ execFileSync("npx", ["eslint", "--no-color", "--format", "compact", relPath], {
44
+ cwd: cfgDir, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
45
+ })
46
+ return "✓ eslint: no issues"
47
+ } catch (e) {
48
+ const stdout = (e.stdout || "").trim()
49
+ if (stdout) return stdout
50
+ return `✗ eslint: ${(e.stderr || e.message).slice(0, 500)}`
51
+ }
52
+ }
53
+ }
54
+ const parent = dir.split("/").slice(0, -1).join("/")
55
+ if (!parent || parent === dir) break
56
+ dir = parent
57
+ }
58
+ return null
59
+ }
60
+
61
+ async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
62
+ if (!existsSync(join(cwd, "tsconfig.json"))) return null
63
+ if (!/\.(ts|tsx|mts|cts)$/.test(file)) return null
64
+ try {
65
+ execFileSync("npx", ["tsc", "--noEmit", "--pretty", "false"], {
66
+ cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"],
67
+ })
68
+ return "✓ tsc: no type errors"
69
+ } catch (e) {
70
+ const stdout = (e.stdout || "").trim()
71
+ if (stdout) return stdout
72
+ return `✗ tsc: ${(e.stderr || e.message).slice(0, 500)}`
73
+ }
74
+ }
75
+
76
+ async function nodeCheck(file, { execFileSync, cwd }) {
77
+ if (!/\.(m?js|cjs)$/.test(file)) return null
78
+ try {
79
+ execFileSync(process.execPath, ["--check", file], {
80
+ cwd, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
81
+ })
82
+ return "✓ node --check: Syntax OK"
83
+ } catch (e) {
84
+ return `✗ node --check: ${(e.stderr || e.message).slice(0, 500)}`
85
+ }
86
+ }
87
+
88
+ async function ruffCheck(file, { cwd, execFileSync }) {
89
+ if (!/\.py$/.test(file)) return null
90
+ try {
91
+ execFileSync("ruff", ["check", "--output-format", "concise", file], {
92
+ cwd, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
93
+ })
94
+ return "✓ ruff: no issues"
95
+ } catch (e) {
96
+ if (e.code === "ENOENT") return "linter: ruff not installed. Run: pip install ruff"
97
+ const stdout = (e.stdout || "").trim()
98
+ if (stdout) return stdout
99
+ return `✗ ruff: ${(e.stderr || e.message).slice(0, 500)}`
100
+ }
101
+ }
102
+
103
+ async function cargoCheck(file, { cwd, existsSync, execFileSync, join }) {
104
+ if (!/\.rs$/.test(file)) return null
105
+ if (!existsSync(join(cwd, "Cargo.toml"))) return null
106
+ const fname = file.split(/[\\/]/).pop()
107
+ try {
108
+ const out = execFileSync("cargo", ["check", "--message-format", "short"], {
109
+ cwd, encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "pipe"],
110
+ })
111
+ const errors = out.split("\n").filter(l => l.includes(fname))
112
+ return errors.length > 0 ? errors.join("\n") : "✓ cargo check: no errors"
113
+ } catch (e) {
114
+ const combined = ((e.stdout || "") + "\n" + (e.stderr || "")).trim()
115
+ const errors = combined.split("\n").filter(l => l.includes(fname) || l.startsWith("error"))
116
+ return errors.length > 0 ? errors.join("\n") : `✗ cargo check failed:\n${combined.slice(0, 1000)}`
117
+ }
118
+ }
119
+
120
+ async function goVet(file, { cwd, execFileSync }) {
121
+ if (!/\.go$/.test(file)) return null
122
+ try {
123
+ execFileSync("go", ["vet", file], {
124
+ cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"],
125
+ })
126
+ return "✓ go vet: no issues"
127
+ } catch (e) {
128
+ return `✗ go vet: ${(e.stderr || e.message).slice(0, 500)}`
129
+ }
130
+ }
131
+
132
+ // ─── Language → checkers (first available wins) ──
133
+
134
+ const LANG_CHECKERS = {
135
+ js: [eslintCheck, nodeCheck],
136
+ mjs: [eslintCheck, nodeCheck],
137
+ cjs: [eslintCheck, nodeCheck],
138
+ jsx: [eslintCheck, nodeCheck],
139
+ ts: [eslintCheck, tscCheck],
140
+ tsx: [eslintCheck, tscCheck],
141
+ mts: [eslintCheck, tscCheck],
142
+ cts: [eslintCheck, tscCheck],
143
+ py: [ruffCheck],
144
+ rs: [cargoCheck],
145
+ go: [goVet],
146
+ }
package/src/tools/read.md CHANGED
@@ -1,10 +1,11 @@
1
1
  Read a text file. Returns numbered lines. Use offset/limit to page large files.
2
-
3
2
  Parameters:
4
- - path (required): File path, relative to cwd or absolute
3
+ - path (required): File path, relative to cwd or absolute (alias: filePath)
5
4
  - offset: 1-based line number to start reading from
6
5
  - limit: Max lines to return (default 2000)
6
+ - hashes: Include SHA256 content hashes per line (for hashline_edit). Set true before using hashline_edit.
7
7
 
8
8
  Notes:
9
9
  - Always prefer this over `cat` or shell-based reading — it caps output and avoids large dumps
10
10
  - Use offset for pagination when the file is large
11
+ - When you plan to edit the file, set hashes=true to get line hashes for hashline_edit — hash-based editing avoids whitespace/encoding matching failures
@@ -3,7 +3,8 @@
3
3
  * Real-time import/export parsing, generates compact text for LLMs to understand code structure.
4
4
  * No index stored — reads and parses files on each call, ~50ms.
5
5
  */
6
- import { readFileSync, existsSync } from "node:fs"
6
+ import { existsSync } from "node:fs"
7
+ import { readFile } from "node:fs/promises"
7
8
  import { join } from "node:path"
8
9
 
9
10
  /** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
@@ -113,17 +114,19 @@ function normalizeExt(p) {
113
114
  * Internal: scan all files, build forward dependency graph + reverse reference graph.
114
115
  * Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
115
116
  */
116
- function _buildDepGraph(db, cwd) {
117
+ async function _buildDepGraph(db, cwd) {
117
118
  const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
118
119
  if (allFiles.length === 0) return null
119
120
 
120
121
  const deps = new Map() // path → { imports: Set, exports: Set, size: number, dir: string }
121
122
  const importers = new Map() // importee → Set<importer>
122
123
 
123
- for (const rel of allFiles) {
124
+ for (let i = 0; i < allFiles.length; i++) {
125
+ const rel = allFiles[i]
124
126
  const abs = join(cwd, ...rel.split("/"))
125
127
  if (!existsSync(abs)) continue
126
- const text = readFileSync(abs, "utf8")
128
+ let text
129
+ try { text = await readFile(abs, "utf8") } catch { continue }
127
130
  const lines = text.split("\n")
128
131
  const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
129
132
 
@@ -160,6 +163,8 @@ function _buildDepGraph(db, cwd) {
160
163
  if (!importers.has(r)) importers.set(r, new Set())
161
164
  importers.get(r).add(rel)
162
165
  }
166
+ // Yield the event loop every 20 files to prevent TUI freeze
167
+ if (i % 20 === 19) await new Promise(r => setImmediate(r))
163
168
  }
164
169
 
165
170
  return { deps, importers, fileCount: allFiles.length }
@@ -173,8 +178,8 @@ function _buildDepGraph(db, cwd) {
173
178
  * 3. Entry points (files with no importers — startup/top-level entry points)
174
179
  * Output is naturally bounded (~1000-2000 chars), no more OUTLINE_INJECT_MAX hard truncation.
175
180
  */
176
- export function buildSummary(db, cwd) {
177
- const graph = _buildDepGraph(db, cwd)
181
+ export async function buildSummary(db, cwd) {
182
+ const graph = await _buildDepGraph(db, cwd)
178
183
  if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
179
184
  const { deps, importers, fileCount } = graph
180
185
 
@@ -248,8 +253,8 @@ export function buildSummary(db, cwd) {
248
253
  }
249
254
 
250
255
  /** Get known file list from code_chunks (reuse index), parse by path to generate outline text */
251
- export function buildOutline(db, cwd, focusPath) {
252
- const graph = _buildDepGraph(db, cwd)
256
+ export async function buildOutline(db, cwd, focusPath) {
257
+ const graph = await _buildDepGraph(db, cwd)
253
258
  if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
254
259
  const { deps, importers } = graph
255
260
 
@@ -298,7 +303,7 @@ export function repoOutlineTool(db, cwd) {
298
303
  },
299
304
  readonly: true,
300
305
  async execute(args) {
301
- const outline = buildOutline(db, cwd, args.path ?? null)
306
+ const outline = await buildOutline(db, cwd, args.path ?? null)
302
307
  return outline
303
308
  },
304
309
  }
@@ -30,6 +30,7 @@ export async function runAgentTurn(ctx, text) {
30
30
  state.currentTool = null
31
31
  state.processingStarted = Date.now()
32
32
  state.controller = new AbortController()
33
+ state.interruptPrompt = null
33
34
  // Refresh status bar every second during processing (elapsed timer)
34
35
  const ticker = setInterval(() => {
35
36
  if (state.processing) render()
@@ -179,10 +180,13 @@ export async function runAgentTurn(ctx, text) {
179
180
  state.tokens.completion += usage.completion_tokens ?? 0
180
181
  state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
181
182
  state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
183
+ state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
182
184
  },
183
185
  // Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
184
186
  onWait: ({ phase, seconds }) => {
185
- state.status = phase === "gate" ? `TPM throttle wait ~${seconds}s` : `Rate-limited 429, retry in ${seconds}s`
187
+ if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
188
+ else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
189
+ else state.status = `Rate-limited 429, retry in ${seconds}s`
186
190
  render()
187
191
  },
188
192
  onTaskUpdate: (items) => {
@@ -193,12 +197,15 @@ export async function runAgentTurn(ctx, text) {
193
197
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
194
198
  render()
195
199
  },
200
+ onAdvisor: (note) => {
201
+ pushLine(` [advisor] ${note.replace(/\n/g, "\n ")}`, C.advisor)
202
+ },
196
203
  // Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
197
204
  onTurnEnd: (() => {
198
205
  let n = 0
199
206
  return () => {
200
207
  if (++n % 5 !== 0) return
201
- try { saveSession(agent, state.lines) } catch {}
208
+ try { saveSession(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
202
209
  }
203
210
  })(),
204
211
  }
package/src/tui/ansi.mjs CHANGED
@@ -33,4 +33,5 @@ export const C = {
33
33
  error: ansi.fg(1),
34
34
  dim: ansi.gray,
35
35
  warn: ansi.fg(3),
36
+ advisor: `${ESC}[92m`, // bright green — visible on dark backgrounds
36
37
  }
@@ -0,0 +1,68 @@
1
+ /** /advisor command: toggle advisor on/off, select model.
2
+ * ctx: { agent, openPicker, pushLine } */
3
+ import { C } from "./ansi.mjs"
4
+
5
+ export async function handleAdvisorCommand(ctx) {
6
+ const { agent, openPicker, pushLine } = ctx
7
+ const cfg = agent.config.advisor ??= {}
8
+ const enabled = cfg.enabled === true
9
+ const curProvider = cfg.provider || agent.activeProvider
10
+ const curModel = cfg.model || agent.provider.model
11
+
12
+ const entries = [
13
+ { type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
14
+ { type: "item", text: `Model: ${curProvider}/${curModel}`, action: "model" },
15
+ ]
16
+
17
+ openPicker({
18
+ title: "Advisor",
19
+ entries,
20
+ onSelect: async (e) => {
21
+ if (e.action === "toggle") {
22
+ cfg.enabled = !cfg.enabled
23
+ agent._pendingReminders = agent._pendingReminders ?? []
24
+ if (cfg.enabled) {
25
+ agent._pendingReminders.push("[系统提醒: Advisor 审查已开启。每轮操作后,你的输出将被审查,观察结果可能作为系统提醒注入。请批判性参考——这是观察,不是命令。]")
26
+ } else {
27
+ agent._pendingReminders.push("[系统提醒: Advisor 审查已关闭。后续轮次不再自动审查。]")
28
+ }
29
+ } else if (e.action === "model") {
30
+ await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
31
+ }
32
+ },
33
+ })
34
+ }
35
+
36
+ async function openAdvisorModelPicker(ctx) {
37
+ const { agent, openPicker, pushLine } = ctx
38
+ const providers = agent.providers || []
39
+
40
+ // Build flat list: each provider's name + a "use current model" entry
41
+ const entries = []
42
+ let idx = 0
43
+ for (const p of providers) {
44
+ const mark = p.name === agent.activeProvider ? "* " : " "
45
+ entries.push({ type: "item", text: `${mark}${p.name} — ${p.baseURL}`, action: "set_provider", provider: p.name, model: p.model })
46
+ idx++
47
+ }
48
+
49
+ openPicker({
50
+ title: "Advisor Model",
51
+ entries,
52
+ onSelect: async (e) => {
53
+ if (e.action === "set_provider") {
54
+ const cfg = agent.config.advisor ??= {}
55
+ if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
56
+ // Same as main — clear override (use main pool)
57
+ delete cfg.provider
58
+ delete cfg.model
59
+ pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`)
60
+ } else {
61
+ cfg.provider = e.provider
62
+ cfg.model = e.model
63
+ pushLine(`Advisor: ${e.provider}/${e.model}`)
64
+ }
65
+ }
66
+ },
67
+ })
68
+ }