read.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import { z } from "zod"
  2. import * as fs from "fs"
  3. import * as path from "path"
  4. import { Tool } from "./tool"
  5. import { LSP } from "../lsp"
  6. import { FileTimes } from "./util/file-times"
  7. import DESCRIPTION from "./read.txt"
  8. import { App } from "../app/app"
  9. const MAX_READ_SIZE = 250 * 1024
  10. const DEFAULT_READ_LIMIT = 2000
  11. const MAX_LINE_LENGTH = 2000
  12. export const ReadTool = Tool.define({
  13. id: "opencode.read",
  14. description: DESCRIPTION,
  15. parameters: z.object({
  16. filePath: z.string().describe("The path to the file to read"),
  17. offset: z
  18. .number()
  19. .describe("The line number to start reading from (0-based)")
  20. .nullable(),
  21. limit: z
  22. .number()
  23. .describe("The number of lines to read (defaults to 2000)")
  24. .nullable(),
  25. }),
  26. async execute(params, ctx) {
  27. let filePath = params.filePath
  28. if (!path.isAbsolute(filePath)) {
  29. filePath = path.join(process.cwd(), filePath)
  30. }
  31. const file = Bun.file(filePath)
  32. if (!(await file.exists())) {
  33. const dir = path.dirname(filePath)
  34. const base = path.basename(filePath)
  35. const dirEntries = fs.readdirSync(dir)
  36. const suggestions = dirEntries
  37. .filter(
  38. (entry) =>
  39. entry.toLowerCase().includes(base.toLowerCase()) ||
  40. base.toLowerCase().includes(entry.toLowerCase()),
  41. )
  42. .map((entry) => path.join(dir, entry))
  43. .slice(0, 3)
  44. if (suggestions.length > 0) {
  45. throw new Error(
  46. `File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
  47. )
  48. }
  49. throw new Error(`File not found: ${filePath}`)
  50. }
  51. const stats = await file.stat()
  52. if (stats.size > MAX_READ_SIZE)
  53. throw new Error(
  54. `File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
  55. )
  56. const limit = params.limit ?? DEFAULT_READ_LIMIT
  57. const offset = params.offset || 0
  58. const isImage = isImageFile(filePath)
  59. if (isImage)
  60. throw new Error(
  61. `This is an image file of type: ${isImage}\nUse a different tool to process images`,
  62. )
  63. const lines = await file.text().then((text) => text.split("\n"))
  64. const raw = lines.slice(offset, offset + limit).map((line) => {
  65. return line.length > MAX_LINE_LENGTH
  66. ? line.substring(0, MAX_LINE_LENGTH) + "..."
  67. : line
  68. })
  69. const content = raw.map((line, index) => {
  70. return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
  71. })
  72. const preview = raw.slice(0, 20).join("\n")
  73. let output = "<file>\n"
  74. output += content.join("\n")
  75. if (lines.length > offset + content.length) {
  76. output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
  77. offset + content.length
  78. })`
  79. }
  80. output += "\n</file>"
  81. // just warms the lsp client
  82. await LSP.touchFile(filePath, true)
  83. FileTimes.read(ctx.sessionID, filePath)
  84. return {
  85. output,
  86. metadata: {
  87. preview,
  88. title: path.relative(App.info().path.root, filePath),
  89. },
  90. }
  91. },
  92. })
  93. function isImageFile(filePath: string): string | false {
  94. const ext = path.extname(filePath).toLowerCase()
  95. switch (ext) {
  96. case ".jpg":
  97. case ".jpeg":
  98. return "JPEG"
  99. case ".png":
  100. return "PNG"
  101. case ".gif":
  102. return "GIF"
  103. case ".bmp":
  104. return "BMP"
  105. case ".svg":
  106. return "SVG"
  107. case ".webp":
  108. return "WebP"
  109. default:
  110. return false
  111. }
  112. }