thincoder 0.2.0 → 0.4.0

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.
@@ -0,0 +1,11 @@
1
+ Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.
2
+
3
+ Parameters:
4
+ - pattern (required): Glob pattern — supports **, *, ?, and character classes
5
+ - path: Directory to search in (default cwd)
6
+
7
+ Notes:
8
+ - Skips node_modules, .git, dist, build, .turbo, coverage
9
+ - Results capped at 1000 matches
10
+ - Use this to discover file structure; use grep to search file contents
11
+ - Prefer patterns with a literal anchor (extension or subdirectory) over bare wildcards
@@ -0,0 +1,12 @@
1
+ Search file contents with a regex. Returns matching lines as path:line: content.
2
+
3
+ Parameters:
4
+ - pattern (required): JavaScript regular expression
5
+ - path: Directory or file to search (default cwd)
6
+ - glob: Only search files matching this glob (e.g. '*.mjs')
7
+
8
+ Notes:
9
+ - Skips node_modules, .git, dist, build, .turbo, coverage
10
+ - Results capped at 200 matches
11
+ - Binary/unreadable files are silently skipped
12
+ - Use this to find usages, definitions, patterns; use glob to find files by name
@@ -0,0 +1,9 @@
1
+ List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).
2
+
3
+ Parameters:
4
+ - path: Directory path (default cwd)
5
+
6
+ Notes:
7
+ - Shows first 500 entries
8
+ - Directories are prefixed with `/` and listed before files
9
+ - Use this for a quick overview; use glob when you have a specific file pattern in mind
@@ -0,0 +1,10 @@
1
+ Ask the user a question and wait for their response. Use when the task is ambiguous, you need a design decision, or you're stuck and need human judgment.
2
+
3
+ Parameters:
4
+ - question (required): The question to ask the user
5
+ - options: Array of single-choice options for the user to pick from (optional)
6
+
7
+ Notes:
8
+ - The agent loop pauses until the user answers
9
+ - The answer is injected as the next user message
10
+ - Use sparingly — prefer making reasonable decisions when possible
@@ -0,0 +1,10 @@
1
+ Read a text file. Returns numbered lines. Use offset/limit to page large files.
2
+
3
+ Parameters:
4
+ - path (required): File path, relative to cwd or absolute
5
+ - offset: 1-based line number to start reading from
6
+ - limit: Max lines to return (default 2000)
7
+
8
+ Notes:
9
+ - Always prefer this over `cat` or shell-based reading — it caps output and avoids large dumps
10
+ - Use offset for pagination when the file is large
@@ -0,0 +1,10 @@
1
+ Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
2
+
3
+ Parameters:
4
+ - query (required): Search query
5
+ - limit: Max results (default 8)
6
+
7
+ Notes:
8
+ - Use this for information that is NOT in the local codebase — current docs, error messages, API references
9
+ - Follow up with `fetch` to read full pages from the results
10
+ - Results are scraped from Bing HTML — some formatting may be imperfect
@@ -0,0 +1,9 @@
1
+ Write content to a file. Creates parent directories; overwrites existing file.
2
+
3
+ Parameters:
4
+ - path (required): File path, relative to cwd or absolute
5
+ - content (required): Full content to write
6
+
7
+ Notes:
8
+ - This overwrites the entire file — use `edit` for targeted changes
9
+ - The file is atomic: it either writes completely or fails
package/src/tools.mjs CHANGED
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * tools.mjs — 内置工具集
3
- * read / write / edit / bash / glob / grep,零依赖实现。
3
+ * read / write / edit / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question,零依赖实现。
4
+ * 工具描述从 src/tools/*.md 加载(方便人读和修改)。
4
5
  * readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
5
6
  */
6
7
 
7
- import { spawn } from "node:child_process"
8
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"
9
- import { dirname, join, resolve } from "node:path"
8
+ import { spawn, execFileSync } from "node:child_process"
9
+ import { mkdir, readFile, readdir, stat, writeFile, unlink } from "node:fs/promises"
10
+ import { readFileSync, existsSync } from "node:fs"
11
+ import { dirname, join, resolve, relative } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url))
15
+ const DESC = (name) => readFileSync(join(__dirname, "tools", `${name}.md`), "utf8")
10
16
 
11
17
  const MAX_READ_LINES = 2000
12
18
  const MAX_OUTPUT_CHARS = 50_000
@@ -48,8 +54,7 @@ function resolveInCwd(ctx, p) {
48
54
 
49
55
  const readTool = {
50
56
  name: "read",
51
- description:
52
- "Read a text file. Returns numbered lines. Use offset/limit to page large files.",
57
+ description: DESC("read"),
53
58
  parameters: {
54
59
  type: "object",
55
60
  properties: {
@@ -77,7 +82,7 @@ const readTool = {
77
82
 
78
83
  const writeTool = {
79
84
  name: "write",
80
- description: "Write content to a file. Creates parent directories; overwrites existing file.",
85
+ description: DESC("write"),
81
86
  parameters: {
82
87
  type: "object",
83
88
  properties: {
@@ -99,8 +104,7 @@ const writeTool = {
99
104
 
100
105
  const editTool = {
101
106
  name: "edit",
102
- description:
103
- "Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.",
107
+ description: DESC("edit"),
104
108
  parameters: {
105
109
  type: "object",
106
110
  properties: {
@@ -134,8 +138,7 @@ const editTool = {
134
138
 
135
139
  const bashTool = {
136
140
  name: "bash",
137
- description:
138
- "Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.",
141
+ description: DESC("bash"),
139
142
  parameters: {
140
143
  type: "object",
141
144
  properties: {
@@ -210,6 +213,10 @@ const bashTool = {
210
213
  child.stderr.on("data", onData)
211
214
 
212
215
  const timer = setTimeout(() => child.kill(), args.timeout ?? BASH_TIMEOUT_MS)
216
+ // 用户中止:杀进程
217
+ if (ctx.signal) {
218
+ ctx.signal.addEventListener("abort", () => child.kill(), { once: true })
219
+ }
213
220
  child.on("error", (error) => {
214
221
  clearTimeout(timer)
215
222
  resolve(truncate(`Command failed: ${error.message}\n${out}`))
@@ -218,7 +225,7 @@ const bashTool = {
218
225
  clearTimeout(timer)
219
226
  out += sanitizeOutput(feed(Buffer.alloc(0), true)) // 最终判定 + 冲刷解码器尾部
220
227
  const suffix = signal
221
- ? "\n(killed: timeout)"
228
+ ? `\n(killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"})`
222
229
  : code !== 0
223
230
  ? `\n(exit code ${code})`
224
231
  : ""
@@ -232,7 +239,7 @@ const bashTool = {
232
239
 
233
240
  const globTool = {
234
241
  name: "glob",
235
- description: "Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.",
242
+ description: DESC("glob"),
236
243
  parameters: {
237
244
  type: "object",
238
245
  properties: {
@@ -295,7 +302,7 @@ function globToRegex(pattern) {
295
302
 
296
303
  const grepTool = {
297
304
  name: "grep",
298
- description: "Search file contents with a regex. Returns matching lines as path:line: content.",
305
+ description: DESC("grep"),
299
306
  parameters: {
300
307
  type: "object",
301
308
  properties: {
@@ -357,8 +364,7 @@ const grepTool = {
357
364
 
358
365
  const websearchTool = {
359
366
  name: "websearch",
360
- description:
361
- "Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.",
367
+ description: DESC("websearch"),
362
368
  parameters: {
363
369
  type: "object",
364
370
  properties: {
@@ -422,8 +428,7 @@ function stripTags(html) {
422
428
 
423
429
  const lsTool = {
424
430
  name: "ls",
425
- description:
426
- "List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).",
431
+ description: DESC("ls"),
427
432
  parameters: {
428
433
  type: "object",
429
434
  properties: {
@@ -457,8 +462,7 @@ const lsTool = {
457
462
 
458
463
  const fetchTool = {
459
464
  name: "fetch",
460
- description:
461
- "Fetch a URL and return its content as text (HTML pages are stripped to readable text). Use after websearch to read full documents.",
465
+ description: DESC("fetch"),
462
466
  parameters: {
463
467
  type: "object",
464
468
  properties: {
@@ -511,3 +515,170 @@ function htmlToText(html) {
511
515
  }
512
516
 
513
517
  export const builtinTools = [readTool, writeTool, editTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
518
+
519
+ // ---------------------------------------------------------------- delete
520
+
521
+ const deleteTool = {
522
+ name: "delete",
523
+ description: DESC("delete"),
524
+ parameters: {
525
+ type: "object",
526
+ properties: {
527
+ path: { type: "string", description: "File path (relative to cwd or absolute)" },
528
+ force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
529
+ },
530
+ required: ["path"],
531
+ },
532
+ readonly: false,
533
+ async execute(args, ctx) {
534
+ const abs = resolveInCwd(ctx, args.path)
535
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`)
536
+ const s = await stat(abs)
537
+ if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
538
+ // git 跟踪文件拒绝直接删除(安全网);未跟踪的放行
539
+ // 用解析后的相对路径(统一正斜杠),防反斜杠/非常规路径绕过 ls-files 匹配
540
+ const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
541
+ let tracked = false
542
+ try {
543
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
544
+ tracked = true
545
+ } catch {
546
+ // 未跟踪 / 非 git 仓库
547
+ }
548
+ if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
549
+ await unlink(abs)
550
+ return `Deleted ${abs}`
551
+ },
552
+ }
553
+
554
+ // ---------------------------------------------------------------- git_diff
555
+
556
+ const gitDiffTool = {
557
+ name: "git_diff",
558
+ description: DESC("git_diff"),
559
+ parameters: {
560
+ type: "object",
561
+ properties: {
562
+ staged: { type: "boolean", description: "Show staged changes (default false)" },
563
+ path: { type: "string", description: "File or directory to diff (default all)" },
564
+ ref: { type: "string", description: "Compare against this ref (default HEAD)" },
565
+ },
566
+ },
567
+ readonly: true,
568
+ execute(args, ctx) {
569
+ const ref = args.ref ?? "HEAD"
570
+ const flags = args.staged ? ["--staged"] : []
571
+ const paths = args.path ? [args.path] : []
572
+ const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
573
+ return truncate(out || "(no changes)")
574
+ },
575
+ }
576
+
577
+ // ---------------------------------------------------------------- git_status
578
+
579
+ const gitStatusTool = {
580
+ name: "git_status",
581
+ description: DESC("git_status"),
582
+ parameters: {
583
+ type: "object",
584
+ properties: {},
585
+ },
586
+ readonly: true,
587
+ execute(_args, ctx) {
588
+ const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
589
+ if (!porcelain) return "(clean — no changes)"
590
+
591
+ const staged = []
592
+ const unstaged = []
593
+ const untracked = []
594
+ const conflicts = []
595
+ for (const line of porcelain.split("\n")) {
596
+ if (!line) continue
597
+ // porcelain: XY path — 2 状态字符 + 空格 + 文件路径(部分环境只 1 空格)
598
+ // 去掉可能的 CR(execFileSync 在某些 Windows git 下会残留 \r 在行末但不在换行符中)
599
+ const clean = line.replace(/\r/g, "")
600
+ // 尝试匹配 "XY path" 或 "XY path"(可变间距)
601
+ const m = clean.match(/^(..?)\s+(.+)$/)
602
+ if (!m) continue
603
+ const [, status, file] = m
604
+ const idx = status[0] ?? " "
605
+ const wt = status[1] ?? " "
606
+ if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
607
+ conflicts.push(file)
608
+ } else if (idx === "?" && wt === "?") {
609
+ untracked.push(file)
610
+ } else {
611
+ if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
612
+ if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
613
+ }
614
+ }
615
+ const parts = []
616
+ if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
617
+ if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
618
+ if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
619
+ if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
620
+ return truncate(parts.join("\n\n"))
621
+ },
622
+ }
623
+
624
+ // ---------------------------------------------------------------- git_log
625
+
626
+ const gitLogTool = {
627
+ name: "git_log",
628
+ description: DESC("git_log"),
629
+ parameters: {
630
+ type: "object",
631
+ properties: {
632
+ count: { type: "number", description: "Number of commits (default 10)" },
633
+ path: { type: "string", description: "File or directory (default all)" },
634
+ oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
635
+ },
636
+ },
637
+ readonly: true,
638
+ execute(args, ctx) {
639
+ const n = args.count ?? 10
640
+ const isOneline = args.oneline
641
+ const cmdArgs = isOneline
642
+ ? ["log", "-" + n, "--oneline"]
643
+ : ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
644
+ if (args.path) cmdArgs.push("--", args.path)
645
+ const out = runGit(ctx.cwd, cmdArgs)
646
+ return truncate(out || "(no commits)")
647
+ },
648
+ }
649
+
650
+ // ---------------------------------------------------------------- question
651
+
652
+ const questionTool = {
653
+ name: "question",
654
+ description: DESC("question"),
655
+ parameters: {
656
+ type: "object",
657
+ properties: {
658
+ question: { type: "string", description: "The question to ask the user" },
659
+ options: {
660
+ type: "array",
661
+ items: { type: "string" },
662
+ description: "Single-choice options for the user to pick from (optional)",
663
+ },
664
+ },
665
+ required: ["question"],
666
+ },
667
+ readonly: true,
668
+ async execute(args, ctx) {
669
+ if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
670
+ return ctx.onQuestion(args.question, args.options ?? [])
671
+ },
672
+ }
673
+
674
+ /** 执行 git 命令;非 git 仓库 / git 不可用时返回空字符串 */
675
+ function runGit(cwd, cmdArgs) {
676
+ try {
677
+ return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
678
+ } catch {
679
+ return ""
680
+ }
681
+ }
682
+
683
+ export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool }
684
+ builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool)