thincoder 0.12.37 → 0.12.39

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,6 @@
1
+ Get the current date, time, weekday, and timezone.
2
+
3
+ **Route to get_current_time instead of bash:**
4
+ - `date` / `time` → get_current_time
5
+
6
+ Use it whenever a task depends on the current time or date (deadlines, freshness, timestamps) rather than shelling out.
package/src/tools/git.md CHANGED
@@ -2,14 +2,20 @@ Run a git command. Use this to see uncommitted changes, staged changes, diff aga
2
2
  - action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.
3
3
  - action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.
4
4
  - action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.
5
+ - action='show': Show a commit's details (--stat). Set ref=<ref> to inspect a specific commit (default HEAD).
5
6
  - action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).
7
+ - action='rm': Untrack a file/directory (git rm --cached — keeps the file on disk). path is required.
8
+ - action='commit': Stage all changes and commit. message is required. Confirms with the user (outward action).
9
+ - action='push': Push the current branch to the remote. Confirms with the user (outward action).
6
10
 
7
11
  Parameters:
8
- - action (required): diff / status / log / checkpoint
12
+ - action (required): diff / status / log / show / checkpoint / rm / commit / push
9
13
  - staged: (diff) Show staged changes instead of working tree
10
- - path: (diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to
11
- - ref: (diff) Compare against this ref (default HEAD)
14
+ - path: (diff/log/checkpoint:cat/checkpoint:rewind/rm) File or directory to scope to
15
+ - ref: (show) Commit ref to inspect (default HEAD)
12
16
  - count: (log) Number of commits (default 10)
13
17
  - oneline: (log) One-line-per-commit format
18
+ - message: (commit) Commit message — required for commit
19
+ - filter: Optional — keep only status/diff/log output lines matching this regex (case-insensitive)
14
20
  - checkpointAction: (checkpoint) list snapshots / create one / restore by id / read file from snapshot
15
- - checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
21
+ - checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
package/src/tools/git.mjs CHANGED
@@ -7,19 +7,44 @@ import { escapeXml } from "../agent/helpers.mjs";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { join } from "node:path";
9
9
 
10
+ /** Keep only output lines matching a regex (git filter, case-insensitive). */
11
+ function filterLines(output, filter) {
12
+ if (!filter) return output
13
+ try {
14
+ const re = new RegExp(filter, "i")
15
+ const lines = output.split("\n").filter((l) => re.test(l))
16
+ return lines.length ? lines.join("\n") : `(no lines matched filter "${filter}")`
17
+ } catch (e) {
18
+ return `Error: filter regex invalid: ${e.message}`
19
+ }
20
+ }
21
+
22
+ /** Run git and report failure (stderr + exit code) instead of swallowing it.
23
+ * Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
24
+ function runGitStrict(cwd, cmdArgs) {
25
+ try {
26
+ const out = execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
27
+ return { ok: true, out }
28
+ } catch (e) {
29
+ return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
30
+ }
31
+ }
32
+
10
33
  export const gitTool = {
11
34
  name: "git",
12
35
  description: DESC("git"),
13
36
  parameters: {
14
37
  type: "object",
15
38
  properties: {
16
- action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
39
+ action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "rm", "commit", "push"], description: "diff / status / log / show / checkpoint / rm / commit / push" },
17
40
  // diff/log params
18
41
  staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
19
- path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind) File or directory to scope to" },
20
- ref: { type: "string", description: "(diff) Compare against this ref (default HEAD)" },
42
+ path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind/rm) File or directory to scope to" },
43
+ ref: { type: "string", description: "(show) Commit ref to show (default HEAD)" },
21
44
  count: { type: "number", description: "(log) Number of commits (default 10)" },
22
45
  oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
46
+ message: { type: "string", description: "(commit) Commit message — required for commit" },
47
+ filter: { type: "string", description: "Optional: keep only status/diff/log output lines matching this regex (case-insensitive)" },
23
48
  // checkpoint params
24
49
  checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
25
50
  checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
@@ -35,7 +60,7 @@ export const gitTool = {
35
60
  const flags = args.staged ? ["--staged"] : []
36
61
  const paths = args.path ? [args.path] : []
37
62
  const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
38
- return truncate(out || "(no changes)")
63
+ return truncate(filterLines(out || "(no changes)", args.filter))
39
64
  }
40
65
  case "status": {
41
66
  const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
@@ -68,17 +93,44 @@ export const gitTool = {
68
93
  if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
69
94
  if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
70
95
  if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
71
- return truncate(parts.join("\n\n"))
96
+ return truncate(filterLines(parts.join("\n\n"), args.filter))
72
97
  }
73
98
  case "log": {
74
- const n = Math.min(Math.max(1, args.count ?? 10), 200)
99
+ const parsed = Number.parseInt(args.count, 10)
100
+ const n = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 10
75
101
  const isOneline = args.oneline
76
102
  const cmdArgs = isOneline
77
103
  ? ["log", "-" + n, "--oneline"]
78
104
  : ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
79
105
  if (args.path) cmdArgs.push("--", args.path)
80
106
  const out = runGit(ctx.cwd, cmdArgs)
81
- return truncate(out || "(no commits)")
107
+ return truncate(filterLines(out || "(no commits)", args.filter))
108
+ }
109
+ case "show": {
110
+ const ref = args.ref ?? "HEAD"
111
+ if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
112
+ const out = runGit(ctx.cwd, ["show", "--stat", ref])
113
+ return truncate(out || "(no such commit)")
114
+ }
115
+ case "rm": {
116
+ if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
117
+ const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", args.path])
118
+ return r.ok ? truncate(r.out || `Untracked ${args.path} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
119
+ }
120
+ case "commit": {
121
+ if (!args.message) return "Error: commit requires message"
122
+ const add = runGitStrict(ctx.cwd, ["add", "-A"])
123
+ if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
124
+ const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
125
+ const parts = []
126
+ if (add.out) parts.push(add.out)
127
+ if (commit.ok) { if (commit.out) parts.push(commit.out) }
128
+ else parts.push(`git commit failed: ${commit.err || "(no output)"}`)
129
+ return truncate(parts.join("\n") || "(commit produced no output)")
130
+ }
131
+ case "push": {
132
+ const r = runGitStrict(ctx.cwd, ["push"])
133
+ return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
82
134
  }
83
135
  case "checkpoint": {
84
136
  const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
@@ -139,7 +191,7 @@ export const gitTool = {
139
191
  throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
140
192
  }
141
193
  default:
142
- return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
194
+ return `Unknown action '${args.action}'. Use: diff | status | log | show | checkpoint | rm | commit | push`
143
195
  }
144
196
  },
145
197
  }
package/src/tools/grep.md CHANGED
@@ -1,9 +1,11 @@
1
1
  Search file contents with a regex. Returns matching lines as path:line: content.
2
2
 
3
3
  Parameters:
4
- - pattern (required): JavaScript regular expression
4
+ - pattern (required): JavaScript regular expression, or a literal string when literal=true
5
5
  - path: Directory or file to search (default cwd)
6
6
  - glob: Only search files matching this glob (e.g. '*.mjs')
7
+ - ignoreCase: Case-insensitive match (default false)
8
+ - literal: Literal string match — no regex interpretation (default false; use for strings with `. \` etc.)
7
9
  - before: Lines of context to show before each match (grep -B). Default 0
8
10
  - after: Lines of context to show after each match (grep -A). Default 0
9
11
 
@@ -10,6 +10,8 @@ import { checklistTool } from "./checklist.mjs";
10
10
  import { lintTool } from "./linter.mjs";
11
11
  import { lspTool } from "./lsp.mjs";
12
12
  import { codeModeTool } from "./codemode.mjs";
13
+ import { fileOpsTool, processTool, getCurrentTimeTool, sleepTool } from "./ops.mjs";
14
+ import { treeTool } from "./tree.mjs";
13
15
 
14
16
  export const builtinTools = [
15
17
  readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
@@ -17,6 +19,8 @@ export const builtinTools = [
17
19
  websearchTool, lsTool, fetchTool, deleteTool,
18
20
  gitTool, questionTool,
19
21
  checklistTool, lintTool, lspTool, codeModeTool,
22
+ fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
23
+ treeTool,
20
24
  ];
21
25
 
22
26
  export {
@@ -25,4 +29,6 @@ export {
25
29
  websearchTool, lsTool, fetchTool, deleteTool,
26
30
  gitTool, questionTool,
27
31
  checklistTool, lintTool, lspTool, codeModeTool,
28
- };
32
+ fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
33
+ treeTool,
34
+ };
package/src/tools/ls.md CHANGED
@@ -2,6 +2,7 @@ List directory contents with type, size, and modification time. Directories list
2
2
 
3
3
  Parameters:
4
4
  - path: Directory path (default cwd)
5
+ - filter: Only list entries matching this glob (e.g. '*.mjs', '*test*') — a wildcard filter, not a full listing
5
6
 
6
7
  Notes:
7
8
  - Shows first 500 entries
@@ -0,0 +1,142 @@
1
+ /**
2
+ * ops.mjs — operational tools: file_ops (move/copy/rename), process (list),
3
+ * get_current_time, sleep. Each exists so the model reaches for a dedicated tool
4
+ * instead of shelling out to `bash` for the same operation (parity with thinworker).
5
+ */
6
+ import { DESC, resolveInCwd, truncate } from "./shared.mjs"
7
+ import { cp, rename, rm } from "node:fs/promises"
8
+ import { execFileSync } from "node:child_process"
9
+
10
+ // ─── file_ops ──────────────────────────────────────────────────
11
+
12
+ export const fileOpsTool = {
13
+ name: "file_ops",
14
+ description: DESC("file_ops"),
15
+ parameters: {
16
+ type: "object",
17
+ properties: {
18
+ action: { type: "string", enum: ["move", "copy", "rename"], description: "move | copy | rename" },
19
+ source: { type: "string", description: "Source path, relative to cwd or absolute" },
20
+ dest: { type: "string", description: "Destination path" },
21
+ },
22
+ required: ["action", "source", "dest"],
23
+ },
24
+ readonly: false,
25
+ async execute({ action, source, dest }, ctx) {
26
+ if (typeof source !== "string" || !source) return "Error: source is required"
27
+ if (typeof dest !== "string" || !dest) return "Error: dest is required"
28
+ if (!["move", "copy", "rename"].includes(action)) return `Error: action must be move | copy | rename (got "${action}")`
29
+ const src = resolveInCwd(ctx, source)
30
+ const dst = resolveInCwd(ctx, dest)
31
+ if (src === dst) return "Error: source and dest resolve to the same path"
32
+
33
+ if (action === "copy") {
34
+ await cp(src, dst, { recursive: true, force: true })
35
+ return `Copied ${source} → ${dest}`
36
+ }
37
+ // move & rename share the rename syscall; cross-device move falls back to copy+rm.
38
+ try {
39
+ await rename(src, dst)
40
+ } catch (e) {
41
+ if (e?.code !== "EXDEV") throw e
42
+ await cp(src, dst, { recursive: true, force: true })
43
+ await rm(src, { recursive: true, force: true })
44
+ }
45
+ return `${action === "rename" ? "Renamed" : "Moved"} ${source} → ${dest}`
46
+ },
47
+ }
48
+
49
+ // ─── process ───────────────────────────────────────────────────
50
+
51
+ export const processTool = {
52
+ name: "process",
53
+ description: DESC("process"),
54
+ parameters: {
55
+ type: "object",
56
+ properties: {
57
+ name: { type: "string", description: "Optional name substring filter (case-insensitive)" },
58
+ },
59
+ },
60
+ readonly: true,
61
+ async execute({ name }, ctx) {
62
+ const filter = typeof name === "string" && name.trim() ? name.trim().toLowerCase() : null
63
+ let rows
64
+ try {
65
+ rows = process.platform === "win32" ? listWindows() : listPosix()
66
+ } catch (e) {
67
+ return `process listing failed: ${e?.message ?? String(e)}`
68
+ }
69
+ if (filter) rows = rows.filter((r) => r.name.toLowerCase().includes(filter))
70
+ if (rows.length === 0) return filter ? `No running processes match "${name}"` : "(no processes)"
71
+ return truncate(rows.map((r) => `${r.name}\tPID ${r.pid}${r.mem ? `\t${r.mem}` : ""}`).join("\n"))
72
+ },
73
+ }
74
+
75
+ function listWindows() {
76
+ // tasklist /FO CSV /NH → lines: "name.exe","1234","Console","1","12,345 K"
77
+ const out = execFileSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 10000 })
78
+ const rows = []
79
+ for (const line of out.split("\n")) {
80
+ const parts = line.split('","')
81
+ if (parts.length < 2) continue
82
+ const name = parts[0].replace(/^"/, "").trim()
83
+ const pid = parts[1].replace(/"/, "").trim()
84
+ const mem = parts[4] ? parts[4].replace(/"/, "").trim() : ""
85
+ if (!name || !pid) continue
86
+ rows.push({ name, pid, mem })
87
+ }
88
+ return rows
89
+ }
90
+
91
+ function listPosix() {
92
+ const out = execFileSync("ps", ["-eo", "pid=,comm="], { encoding: "utf8", timeout: 10000 })
93
+ const rows = []
94
+ for (const line of out.split("\n")) {
95
+ const m = line.trim().match(/^(\d+)\s+(.+)$/)
96
+ if (m) rows.push({ name: m[2], pid: m[1], mem: "" })
97
+ }
98
+ return rows
99
+ }
100
+
101
+ // ─── get_current_time ──────────────────────────────────────────
102
+
103
+ export const getCurrentTimeTool = {
104
+ name: "get_current_time",
105
+ description: DESC("get_current_time"),
106
+ parameters: { type: "object", properties: {} },
107
+ readonly: true,
108
+ async execute() {
109
+ const now = new Date()
110
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "unknown"
111
+ const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
112
+ return `Date: ${now.toISOString()} (UTC)\nTimezone: ${tz}\nWeekday: ${days[now.getDay()]}\nLocal: ${now.toLocaleString()}`
113
+ },
114
+ }
115
+
116
+ // ─── sleep ─────────────────────────────────────────────────────
117
+
118
+ export const sleepTool = {
119
+ name: "sleep",
120
+ description: DESC("sleep"),
121
+ parameters: {
122
+ type: "object",
123
+ properties: {
124
+ seconds: { type: "number", description: "Seconds to wait (1-300)" },
125
+ reason: { type: "string", description: "Why wait (shown to the user)" },
126
+ },
127
+ required: ["seconds"],
128
+ },
129
+ readonly: true,
130
+ async execute({ seconds, reason }, ctx) {
131
+ const raw = Number(seconds)
132
+ const n = Number.isFinite(raw) ? Math.min(Math.max(Math.round(raw), 1), 300) : 1
133
+ await new Promise((resolve, reject) => {
134
+ const t = setTimeout(resolve, n * 1000)
135
+ if (ctx?.signal) {
136
+ if (ctx.signal.aborted) { clearTimeout(t); reject(new Error("aborted")) }
137
+ else ctx.signal.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")) }, { once: true })
138
+ }
139
+ })
140
+ return `Waited ${n}s${reason ? ` (${reason})` : ""}`
141
+ },
142
+ }
@@ -0,0 +1,10 @@
1
+ List running processes, optionally filtered by name. Returns process name / PID / memory.
2
+
3
+ **Route to process instead of bash:**
4
+ - `tasklist` (Windows) / `ps aux` (POSIX) → process
5
+
6
+ Parameters:
7
+ - name (optional): substring filter (case-insensitive), e.g. "node", "python"
8
+
9
+ Notes:
10
+ - List-only. To kill a process, use `bash taskkill /PID <pid> /F` (Windows) or `bash kill <pid>` — and confirm with the user first.
@@ -0,0 +1,5 @@
1
+ Wait a number of seconds before continuing. Use to wait for a web page to load, an async task to finish, or to respect a rate limit — cheaper than repeatedly polling.
2
+
3
+ Parameters:
4
+ - seconds (required): how many seconds to wait (1-300)
5
+ - reason (optional): why you are waiting (shown to the user)
@@ -17,6 +17,20 @@ import { join } from "node:path";
17
17
  /** Maximum buffer size per stream (stdout / stderr) before truncation */
18
18
  const MAX_STREAM_BUF = 2_000_000
19
19
 
20
+ /** Escape a string for literal regex matching (grep literal=true). */
21
+ function escapeRegExp(s) {
22
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
23
+ }
24
+
25
+ /** Keep only output lines matching a regex (bash filter, case-insensitive). */
26
+ function applyLineFilter(output, filter) {
27
+ let re
28
+ try { re = new RegExp(filter, "i") } catch (e) { return `Error: filter regex invalid: ${e.message}` }
29
+ const lines = output.split("\n").filter((l) => re.test(l))
30
+ if (lines.length === 0) return `(no output lines matched filter "${filter}")`
31
+ return truncate(lines.join("\n"))
32
+ }
33
+
20
34
  // ====================================================================
21
35
  // bash — command execution with safety gates
22
36
  // ====================================================================
@@ -223,6 +237,7 @@ export const bashTool = {
223
237
  properties: {
224
238
  command: { type: "string", description: "Shell command to execute" },
225
239
  timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
240
+ filter: { type: "string", description: "Optional: only return output lines matching this regex (case-insensitive)" },
226
241
  },
227
242
  required: ["command"],
228
243
  },
@@ -241,7 +256,8 @@ export const bashTool = {
241
256
  onOutput: ctx.onOutput,
242
257
  shell: ctx.agent?.config?.shell ?? null,
243
258
  })
244
- return guard ? `${guard.notice}\n\n${result}` : result
259
+ const filtered = args.filter ? applyLineFilter(result, args.filter) : result
260
+ return guard ? `${guard.notice}\n\n${filtered}` : filtered
245
261
  },
246
262
  }
247
263
 
@@ -305,9 +321,11 @@ export const grepTool = {
305
321
  parameters: {
306
322
  type: "object",
307
323
  properties: {
308
- pattern: { type: "string", description: "Regular expression" },
324
+ pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
309
325
  path: { type: "string", description: "Directory or file to search (default cwd)" },
310
326
  glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
327
+ ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
328
+ literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
311
329
  before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
312
330
  after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
313
331
  },
@@ -318,7 +336,8 @@ export const grepTool = {
318
336
  const base = resolveInCwd(ctx, args.path ?? ".")
319
337
  let regex
320
338
  try {
321
- regex = new RegExp(args.pattern)
339
+ const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
340
+ regex = new RegExp(pat, args.ignoreCase ? "i" : "")
322
341
  } catch (e) {
323
342
  throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
324
343
  }
@@ -409,11 +428,13 @@ export const lsTool = {
409
428
  type: "object",
410
429
  properties: {
411
430
  path: { type: "string", description: "Directory path (default cwd)" },
431
+ filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
412
432
  },
413
433
  },
414
434
  readonly: true,
415
435
  async execute(args, ctx) {
416
436
  const abs = resolveInCwd(ctx, args.path ?? ".")
437
+ const filterRe = args.filter ? globToRegex(args.filter) : null
417
438
  let entries
418
439
  try {
419
440
  entries = await readdir(abs, { withFileTypes: true })
@@ -422,7 +443,10 @@ export const lsTool = {
422
443
  throw e
423
444
  }
424
445
  const rows = await Promise.all(
425
- entries.slice(0, 500).map(async (e) => {
446
+ entries
447
+ .filter((e) => !filterRe || filterRe.test(e.name))
448
+ .slice(0, 500)
449
+ .map(async (e) => {
426
450
  const s = await stat(join(abs, e.name)).catch(() => null)
427
451
  const isDir = e.isDirectory()
428
452
  return {
@@ -439,5 +463,3 @@ export const lsTool = {
439
463
  return truncate(out.join("\n"))
440
464
  },
441
465
  }
442
-
443
- // ---------------------------------------------------------------- fetch
@@ -0,0 +1,13 @@
1
+ Generate a directory tree of the codebase (default depth 3). Skips dotfiles, .git/node_modules/dist/build/bin/obj and other build/vendor dirs, and binary files. Use to quickly see which modules exist and where files live.
2
+
3
+ **Route to tree instead of bash:**
4
+ - `tree` / `find .` / `dir /s` → tree
5
+
6
+ Parameters:
7
+ - path: Root directory (default cwd)
8
+ - depth: Tree depth (default 3, max 6). Directories are listed before files, both sorted.
9
+
10
+ Notes:
11
+ - Capped at 200 entries.
12
+ - Directories end with `/`; tree-drawing uses `├──`/`└──`/`│`.
13
+ - Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * tree.mjs — directory tree tool (parity with thinworker `repomap`).
3
+ * Renders a repo's directory tree (default depth 3), skipping dotfiles,
4
+ * build/vendor dirs and binary files, so the model can see which modules
5
+ * exist without shelling out to `tree`/`find`.
6
+ */
7
+ import { DESC, truncate, resolveInCwd } from "./shared.mjs"
8
+ import { readdir, stat } from "node:fs/promises"
9
+ import { join, basename, extname } from "node:path"
10
+
11
+ const MAX_ENTRIES = 200
12
+ const DEFAULT_DEPTH = 3
13
+ const SKIP_DIRS = new Set(["node_modules", "bin", "obj", "dist", "build", "coverage", "turbo", ".git", ".thincoder", ".vs", ".venv", "__pycache__", ".idea"])
14
+ const BINARY_EXTS = new Set([".exe", ".dll", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".docx", ".xlsx", ".pptx", ".zip", ".7z", ".mp3", ".mp4", ".woff", ".woff2", ".ico"])
15
+
16
+ export const treeTool = {
17
+ name: "tree",
18
+ description: DESC("tree"),
19
+ parameters: {
20
+ type: "object",
21
+ properties: {
22
+ path: { type: "string", description: "Root directory (default cwd)" },
23
+ depth: { type: "integer", description: `Tree depth (default ${DEFAULT_DEPTH}, max 6)` },
24
+ },
25
+ required: [],
26
+ },
27
+ readonly: true,
28
+ async execute(args, ctx) {
29
+ const root = resolveInCwd(ctx, args.path ?? ".")
30
+ let st
31
+ try { st = await stat(root) } catch { return `Error: directory not found: ${args.path ?? "."}` }
32
+ if (!st.isDirectory()) return `Error: not a directory: ${args.path ?? "."}`
33
+ const d = Number(args.depth)
34
+ const maxDepth = Number.isInteger(d) && d > 0 ? Math.min(d, 6) : DEFAULT_DEPTH
35
+ const lines = [basename(root) + "/"]
36
+ const state = { count: 1, capped: false }
37
+ await walk(root, 0, maxDepth, "", lines, state)
38
+ return truncate(lines.join("\n"))
39
+ },
40
+ }
41
+
42
+ async function walk(dir, depth, maxDepth, prefix, lines, state) {
43
+ if (state.capped) return
44
+ let entries
45
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
46
+ const items = []
47
+ for (const e of entries) {
48
+ if (e.name.startsWith(".")) continue // dotfiles + dotdirs
49
+ const isDir = e.isDirectory()
50
+ if (isDir) { if (SKIP_DIRS.has(e.name)) continue; items.push({ name: e.name, isDir: true }) }
51
+ else if (!BINARY_EXTS.has(extname(e.name).toLowerCase())) items.push({ name: e.name, isDir: false })
52
+ }
53
+ items.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))
54
+
55
+ for (let i = 0; i < items.length; i++) {
56
+ if (state.capped) return
57
+ if (state.count >= MAX_ENTRIES) { state.capped = true; lines.push(prefix + "…(更多项已省略)"); return }
58
+ const { name, isDir } = items[i]
59
+ const isLast = i === items.length - 1
60
+ lines.push(prefix + (isLast ? "└── " : "├── ") + (isDir ? name + "/" : name))
61
+ state.count++
62
+ if (isDir && depth + 1 < maxDepth) {
63
+ await walk(join(dir, name), depth + 1, maxDepth, prefix + (isLast ? " " : "│ "), lines, state)
64
+ }
65
+ }
66
+ }