grep.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. export * as GrepTool from "./grep"
  2. import { ToolFailure } from "@kirincode-ai/llm"
  3. import { Effect, Layer, Schema } from "effect"
  4. import path from "path"
  5. import { makeLocationNode } from "../effect/app-node"
  6. import { FileSystem } from "../filesystem"
  7. import { FSUtil } from "../fs-util"
  8. import { Location } from "../location"
  9. import { PermissionV2 } from "../permission"
  10. import { Ripgrep } from "../ripgrep"
  11. import { RelativePath } from "../schema"
  12. import { ToolRegistry } from "./registry"
  13. import { Tool } from "./tool"
  14. import { Tools } from "./tools"
  15. export const name = "grep"
  16. export const Input = Schema.Struct({
  17. pattern: FileSystem.GrepInput.fields.pattern.annotate({
  18. description: "Regex pattern to search for in file contents",
  19. }),
  20. path: RelativePath.pipe(Schema.optional).annotate({
  21. description: "Relative directory to search. Defaults to the active Location.",
  22. }),
  23. include: FileSystem.GrepInput.fields.include.annotate({
  24. description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
  25. }),
  26. limit: FileSystem.GrepInput.fields.limit.annotate({
  27. description: "Maximum matches to return",
  28. }),
  29. })
  30. export const Output = Schema.Array(FileSystem.Match)
  31. type ModelOutput = typeof Output.Encoded
  32. /** Format raw search matches into the familiar concise model output. */
  33. export const toModelOutput = (output: ModelOutput) => {
  34. const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
  35. let current = ""
  36. for (const match of output) {
  37. if (current !== match.entry.path) {
  38. if (current) lines.push("")
  39. current = match.entry.path
  40. lines.push(`${match.entry.path}:`)
  41. }
  42. lines.push(` Line ${match.line}: ${match.text}`)
  43. }
  44. return lines.join("\n")
  45. }
  46. /** Grep leaf that defaults its filesystem root to the active Location. */
  47. const layer = Layer.effectDiscard(
  48. Effect.gen(function* () {
  49. const tools = yield* Tools.Service
  50. const fs = yield* FSUtil.Service
  51. const ripgrep = yield* Ripgrep.Service
  52. const location = yield* Location.Service
  53. const permission = yield* PermissionV2.Service
  54. yield* tools
  55. .register({
  56. [name]: Tool.make({
  57. description:
  58. "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
  59. input: Input,
  60. output: Output,
  61. toModelOutput: ({ output }) => [
  62. {
  63. type: "text",
  64. text: toModelOutput(
  65. output.map((match) => ({
  66. ...match,
  67. entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
  68. })),
  69. ),
  70. },
  71. ],
  72. execute: (input, context) =>
  73. Effect.gen(function* () {
  74. yield* permission.assert({
  75. action: name,
  76. resources: [input.pattern],
  77. save: ["*"],
  78. metadata: {
  79. root: ".",
  80. path: input.path,
  81. include: input.include,
  82. limit: input.limit,
  83. },
  84. sessionID: context.sessionID,
  85. agent: context.agent,
  86. source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
  87. })
  88. const target = path.resolve(location.directory, input.path ?? ".")
  89. const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
  90. return yield* ripgrep
  91. .grep({
  92. cwd: info?.type === "Directory" ? target : path.dirname(target),
  93. pattern: input.pattern,
  94. file: info?.type === "File" ? path.basename(target) : undefined,
  95. include: input.include,
  96. limit: input.limit ?? Number.MAX_SAFE_INTEGER,
  97. })
  98. .pipe(
  99. Effect.map((result) =>
  100. result.map((match) =>
  101. FileSystem.Match.make({
  102. ...match,
  103. entry: FileSystem.Entry.make({
  104. ...match.entry,
  105. path: RelativePath.make(
  106. path.relative(
  107. location.directory,
  108. path.resolve(
  109. info?.type === "Directory" ? target : path.dirname(target),
  110. match.entry.path,
  111. ),
  112. ),
  113. ),
  114. }),
  115. }),
  116. ),
  117. ),
  118. )
  119. }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
  120. }),
  121. })
  122. .pipe(Effect.orDie)
  123. }),
  124. )
  125. export const node = makeLocationNode({
  126. name: "tool/grep",
  127. layer,
  128. deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
  129. })