Compare commits
14 Commits
dev
...
feat/fff-s
| Author | SHA1 | Date |
|---|---|---|
|
|
19a503aa7e | |
|
|
3b32584efa | |
|
|
1fbd87210b | |
|
|
db4fc9fdab | |
|
|
05145ba8f2 | |
|
|
dcfade755c | |
|
|
0ca507fd00 | |
|
|
8379612c52 | |
|
|
842a6eadc5 | |
|
|
00ce0a7ab7 | |
|
|
f977cbe2b8 | |
|
|
96b58aadd6 | |
|
|
2948b2fb73 | |
|
|
b5a7ad7085 |
44
bun.lock
44
bun.lock
|
|
@ -327,6 +327,7 @@
|
||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
"@ff-labs/fff-node": "0.4.2",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
|
@ -345,7 +346,6 @@
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@zip.js/zip.js": "2.7.62",
|
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"ai-gateway-provider": "2.3.1",
|
"ai-gateway-provider": "2.3.1",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
|
|
@ -1103,6 +1103,24 @@
|
||||||
|
|
||||||
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R9ieZvBaAmYNbdGT3gs2HUQ0Sm4I5tBrJwOepdCoeIZvJFI71hCY2DCFzeoXH2wbxMsPF70c1FSr8qERhcrbVw=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-iJNgUdkS1sVMhWe6l60ZmG9BcSB87CdO65K4AuMbwHQZTHxje9Sapf+AWPGYem6H0endS7HF7ejH+yoZmCF0uw=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vldJC/j/Kf0LVS599CPTeIaBAd+8J6FFJ1euWn4OoSu63P3CD+9ITrmPWkIGUrt+0myOXABAx0KgLBGADtIAKg=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RgL1Oq6QMZm+M4R14SYLtiRMUObA8k+EHIftaplKpLu4Cr0q5lCclRszS0o0Le0hmFrrIvMn6pFRE7LoEzKqAQ=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ysImURWrxLT7WFTn46NrXOg4ygbuIp4NkKbWzOAzLYoMOU5JRllUxb3huw3sZNbXn+/9tpq3OE9VmWuAi0YZ/w=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Us4ysq/oCrcf+h5lOPzDbxFJ8WI8bSVbSVudYHYFpe54417oWtbokdbzgb5Yx7108dW7jCDtGkxq+Cnau2002A=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-rkF8lNEUhaZmGESJdQGSnIReg5hMDbb7giFxTlEzgeFRkWZpjKkDajGVuJ+Rd2bI5AoxUNuTDUfvta5EkQ2S5g=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-wtSZiI2/7Z61GdVlGxPtXcuQV4EyoHgVBLhJ5wXcGwEQLp/r8GUWzSpN7iDQaOKKEvHbT2XiEbcbdw+jhDR7qQ=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.4.2", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.4.2", "@ff-labs/fff-bin-darwin-x64": "0.4.2", "@ff-labs/fff-bin-linux-arm64-gnu": "0.4.2", "@ff-labs/fff-bin-linux-arm64-musl": "0.4.2", "@ff-labs/fff-bin-linux-x64-gnu": "0.4.2", "@ff-labs/fff-bin-linux-x64-musl": "0.4.2", "@ff-labs/fff-bin-win32-arm64": "0.4.2", "@ff-labs/fff-bin-win32-x64": "0.4.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-R1jjyvWmLC6qLOxFwdZhhA4UrOZY6r5nuqsuMpdsrDOhMMktJsbhMDZzRqXIy+GXTQBqAF1oBhW6FN6ahTCPBA=="],
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||||
|
|
@ -2241,7 +2259,27 @@
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||||
|
|
||||||
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
|
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-V4nmlXdOYZEa7GOxSExVG95SLp8FE0iTq2yKeN54UlfNMr3Sik+1Ff57LcCv7qYcn4TBqnBAt5rT3FAM6T6caQ=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YlnTMIyzfW3mAULC5ZA774nzQfFlYXM0rrfq/8ZzWt+IMbYk55a++jrI+6JeKV+1EqlDS3TFBEFtjdBNG94KzQ=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sI3LpQQ34SX4nyOHc5yxA7FSqs9qPEUMqW/y/wWo9cuyPpaHMFsi/BeOVYsnC0syp3FrY7gzn6RnD6PlXCktXg=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-1WkcGkJTlwh4ZA59htKI+RXhiL3oKiYwLv7PO8LUf6FuADK73s5GcXp67iakKu243uYu+qGYr4RHco4ySddYhQ=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-J2PwqviycZxaEVA0Bwv38LqGDGSB9A1DPN4iYginYJZSvTvKW8kh7Tis0HbZrX1YDKnY8hi3lt0N0tCTNPDH5Q=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hn1W1hBPssTaqikU1Bqp1XUdDdOgbnYVIOtR++LVx66hhrtjf/xrIUQOhTm+NmOFDG16JUKXe1skfM4gpaqYwg=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-kW6e+oCYZPvpH2ppPsffA18e1aLowtmWTRjVlyHtY04g/nQDepQvDUkkcvInh9fW5jLna7PjHvktW1tVgYIj2A=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HTwblAzruUS16nQPrez3ozvEHm1Xxh8J8w7rZYrpmAcNl1hzyOT8z/hY70M9Rt9fOqQ4Ovgor9qVy/U3ZJo0ZA=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WeZkGl2BP1U4tRhEQH+FXLQS52N8obp74smK5AAGOfzPAT1pHkq6+dVkC1QCSIt7dHJs7SPtlnQw+5DkdZYlWA=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.1", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-rNGgMeCH5mdeHiMiJgt7wWXovZ+FHEfXhU9p4zZBH4n8M1/QnEsRUwlapISPLpILSGpoYS6iBuq9/fUlZY8Mhg=="],
|
||||||
|
|
||||||
|
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dr2LcLD2CXo2a7BktlOpV68QhayqiI112KxIJC9tBgQO/Dkdg4CPsdqmvzzLhFo64iC5RLl2BT7M5lJImrfUWw=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
|
||||||
|
|
||||||
|
|
@ -2941,6 +2979,8 @@
|
||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"ffi-rs": ["ffi-rs@1.3.1", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-arm64": "1.3.1", "@yuuang/ffi-rs-darwin-x64": "1.3.1", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.1", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.1", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.1", "@yuuang/ffi-rs-linux-x64-musl": "1.3.1", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.1", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.1", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.1" } }, "sha512-ZyNXL9fnclnZV+waQmWB9JrfbIEyxQa1OWtMrHOrAgcC04PgP5hBMG5TdhVN8N4uT/eul8zCFMVnJUukAFFlXA=="],
|
||||||
|
|
||||||
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||||
|
|
||||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||||
|
|
|
||||||
|
|
@ -572,14 +572,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const seen = new Set(open)
|
const seen = new Set(open)
|
||||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||||
if (!query.trim()) return [...agents, ...pinned]
|
if (!query.trim()) return [...agents, ...pinned]
|
||||||
const paths = await files.searchFilesAndDirectories(query)
|
const pathy = /[./\\]/.test(query)
|
||||||
|
const seek = query.replaceAll("\\", "/")
|
||||||
|
const paths = await files.searchFiles(seek)
|
||||||
const fileOptions: AtOption[] = paths
|
const fileOptions: AtOption[] = paths
|
||||||
.filter((path) => !seen.has(path))
|
.filter((path) => !seen.has(path))
|
||||||
.map((path) => ({ type: "file", path, display: path }))
|
.map((path) => ({ type: "file", path, display: path }))
|
||||||
|
if (pathy) return fileOptions
|
||||||
return [...agents, ...pinned, ...fileOptions]
|
return [...agents, ...pinned, ...fileOptions]
|
||||||
},
|
},
|
||||||
key: atKey,
|
key: atKey,
|
||||||
filterKeys: ["display"],
|
filterKeys: ["display"],
|
||||||
|
stale: false,
|
||||||
|
fuzzy: (query) => !/[./\\]/.test(query),
|
||||||
groupBy: (item) => {
|
groupBy: (item) => {
|
||||||
if (item.type === "agent") return "agent"
|
if (item.type === "agent") return "agent"
|
||||||
if (item.recent) return "recent"
|
if (item.recent) return "recent"
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@
|
||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
"@ff-labs/fff-node": "0.4.2",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
|
@ -108,7 +109,6 @@
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@zip.js/zip.js": "2.7.62",
|
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"ai-gateway-provider": "2.3.1",
|
"ai-gateway-provider": "2.3.1",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { EOL } from "os"
|
||||||
import { File } from "../../../file"
|
import { File } from "../../../file"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Fff } from "@/file/fff"
|
||||||
|
|
||||||
const FileSearchCommand = cmd({
|
const FileSearchCommand = cmd({
|
||||||
command: "search <query>",
|
command: "search <query>",
|
||||||
|
|
@ -77,7 +77,7 @@ const FileTreeCommand = cmd({
|
||||||
default: process.cwd(),
|
default: process.cwd(),
|
||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
|
const files = await Fff.tree({ cwd: args.dir, limit: 200 })
|
||||||
console.log(JSON.stringify(files, null, 2))
|
console.log(JSON.stringify(files, null, 2))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { cmd } from "../cmd"
|
||||||
import { ConfigCommand } from "./config"
|
import { ConfigCommand } from "./config"
|
||||||
import { FileCommand } from "./file"
|
import { FileCommand } from "./file"
|
||||||
import { LSPCommand } from "./lsp"
|
import { LSPCommand } from "./lsp"
|
||||||
import { RipgrepCommand } from "./ripgrep"
|
import { SearchCommand } from "./search"
|
||||||
import { ScrapCommand } from "./scrap"
|
import { ScrapCommand } from "./scrap"
|
||||||
import { SkillCommand } from "./skill"
|
import { SkillCommand } from "./skill"
|
||||||
import { SnapshotCommand } from "./snapshot"
|
import { SnapshotCommand } from "./snapshot"
|
||||||
|
|
@ -17,7 +17,7 @@ export const DebugCommand = cmd({
|
||||||
yargs
|
yargs
|
||||||
.command(ConfigCommand)
|
.command(ConfigCommand)
|
||||||
.command(LSPCommand)
|
.command(LSPCommand)
|
||||||
.command(RipgrepCommand)
|
.command(SearchCommand)
|
||||||
.command(FileCommand)
|
.command(FileCommand)
|
||||||
.command(ScrapCommand)
|
.command(ScrapCommand)
|
||||||
.command(SkillCommand)
|
.command(SkillCommand)
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,34 @@
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Ripgrep } from "../../../file/ripgrep"
|
import { Fff } from "../../../file/fff"
|
||||||
import { Instance } from "../../../project/instance"
|
import { Instance } from "../../../project/instance"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
import { Glob } from "@/util/glob"
|
||||||
|
|
||||||
export const RipgrepCommand = cmd({
|
export const SearchCommand = cmd({
|
||||||
command: "rg",
|
command: "search",
|
||||||
describe: "ripgrep debugging utilities",
|
describe: "fff search debugging utilities",
|
||||||
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
|
builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(ContentCommand).demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const TreeCommand = cmd({
|
const TreeCommand = cmd({
|
||||||
command: "tree",
|
command: "tree",
|
||||||
describe: "show file tree using ripgrep",
|
describe: "show file tree using fff",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.option("limit", {
|
yargs.option("limit", {
|
||||||
type: "number",
|
type: "number",
|
||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
|
process.stdout.write((await Fff.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const FilesCommand = cmd({
|
const FilesCommand = cmd({
|
||||||
command: "files",
|
command: "files",
|
||||||
describe: "list files using ripgrep",
|
describe: "list files using fff",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.option("query", {
|
.option("query", {
|
||||||
|
|
@ -44,22 +45,24 @@ const FilesCommand = cmd({
|
||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const files: string[] = []
|
const limit = args.limit ?? 100
|
||||||
for await (const file of Ripgrep.files({
|
const files = (await Glob.scan("**/*", {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
glob: args.glob ? [args.glob] : undefined,
|
include: "file",
|
||||||
})) {
|
dot: true,
|
||||||
files.push(file)
|
}))
|
||||||
if (args.limit && files.length >= args.limit) break
|
.map((x) => x.replaceAll("\\", "/"))
|
||||||
}
|
.filter((x) => Fff.allowed({ rel: x, hidden: true, glob: args.glob ? [args.glob] : undefined }))
|
||||||
|
.filter((x) => !args.query || x.includes(args.query))
|
||||||
|
.slice(0, limit)
|
||||||
process.stdout.write(files.join(EOL) + EOL)
|
process.stdout.write(files.join(EOL) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const SearchCommand = cmd({
|
const ContentCommand = cmd({
|
||||||
command: "search <pattern>",
|
command: "content <pattern>",
|
||||||
describe: "search file contents using ripgrep",
|
describe: "search file contents using fff",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.positional("pattern", {
|
.positional("pattern", {
|
||||||
|
|
@ -76,12 +79,12 @@ const SearchCommand = cmd({
|
||||||
description: "Limit number of results",
|
description: "Limit number of results",
|
||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
const results = await Ripgrep.search({
|
const rows = await Fff.search({
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
pattern: args.pattern,
|
pattern: args.pattern,
|
||||||
glob: args.glob as string[] | undefined,
|
glob: args.glob as string[] | undefined,
|
||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
})
|
})
|
||||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
process.stdout.write(JSON.stringify(rows, null, 2) + EOL)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import {
|
||||||
|
FileFinder,
|
||||||
|
type FileItem,
|
||||||
|
type GrepCursor,
|
||||||
|
type GrepMatch,
|
||||||
|
type GrepMode,
|
||||||
|
type SearchResult,
|
||||||
|
} from "@ff-labs/fff-node"
|
||||||
|
import z from "zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
|
export namespace Fff {
|
||||||
|
const log = Log.create({ service: "file.fff" })
|
||||||
|
|
||||||
|
export const Match = z.object({
|
||||||
|
path: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
lines: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
line_number: z.number(),
|
||||||
|
absolute_offset: z.number(),
|
||||||
|
submatches: z.array(
|
||||||
|
z.object({
|
||||||
|
match: z.object({
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
start: z.number(),
|
||||||
|
end: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = Instance.state(
|
||||||
|
async () => ({
|
||||||
|
map: new Map<string, FileFinder>(),
|
||||||
|
pending: new Map<string, Promise<FileFinder>>(),
|
||||||
|
}),
|
||||||
|
async (state) => {
|
||||||
|
for (const pick of state.map.values()) pick.destroy()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const root = path.join(Global.Path.cache, "fff")
|
||||||
|
|
||||||
|
function key(dir: string) {
|
||||||
|
return Buffer.from(dir).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function db(dir: string) {
|
||||||
|
await fs.mkdir(root, { recursive: true })
|
||||||
|
const id = key(dir)
|
||||||
|
return {
|
||||||
|
frecency: path.join(root, `${id}.frecency.mdb`),
|
||||||
|
history: path.join(root, `${id}.history.mdb`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(pick: FileFinder) {
|
||||||
|
const git = pick.refreshGitStatus()
|
||||||
|
if (!git.ok) {
|
||||||
|
log.warn("git refresh failed", { error: git.error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function picker(cwd: string) {
|
||||||
|
const dir = Filesystem.resolve(cwd)
|
||||||
|
const memo = await state()
|
||||||
|
const cached = memo.map.get(dir)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const wait = memo.pending.get(dir)
|
||||||
|
if (wait) return wait
|
||||||
|
|
||||||
|
const next = (async () => {
|
||||||
|
const files = await db(dir)
|
||||||
|
const made = FileFinder.create({
|
||||||
|
basePath: dir,
|
||||||
|
frecencyDbPath: files.frecency,
|
||||||
|
historyDbPath: files.history,
|
||||||
|
aiMode: true,
|
||||||
|
})
|
||||||
|
if (!made.ok) throw new Error(made.error)
|
||||||
|
|
||||||
|
const pick = made.value
|
||||||
|
const done = await pick.waitForScan(5000)
|
||||||
|
if (!done.ok) {
|
||||||
|
pick.destroy()
|
||||||
|
throw new Error(done.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo.map.set(dir, pick)
|
||||||
|
refresh(pick)
|
||||||
|
return pick
|
||||||
|
})()
|
||||||
|
|
||||||
|
memo.pending.set(dir, next)
|
||||||
|
try {
|
||||||
|
return await next
|
||||||
|
} finally {
|
||||||
|
if (memo.pending.get(dir) === next) memo.pending.delete(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function files(input: { cwd: string; query: string; page?: number; size?: number; current?: string }) {
|
||||||
|
const pick = await picker(input.cwd)
|
||||||
|
const out = pick.fileSearch(input.query, {
|
||||||
|
pageIndex: input.page ?? 0,
|
||||||
|
pageSize: input.size ?? 100,
|
||||||
|
currentFile: input.current,
|
||||||
|
})
|
||||||
|
if (!out.ok) throw new Error(out.error)
|
||||||
|
return out.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grep(input: {
|
||||||
|
cwd: string
|
||||||
|
query: string
|
||||||
|
mode?: GrepMode
|
||||||
|
max?: number
|
||||||
|
before?: number
|
||||||
|
after?: number
|
||||||
|
budget?: number
|
||||||
|
cursor?: GrepCursor | null
|
||||||
|
}) {
|
||||||
|
const pick = await picker(input.cwd)
|
||||||
|
const out = pick.grep(input.query, {
|
||||||
|
mode: input.mode,
|
||||||
|
maxMatchesPerFile: input.max,
|
||||||
|
beforeContext: input.before,
|
||||||
|
afterContext: input.after,
|
||||||
|
timeBudgetMs: input.budget,
|
||||||
|
cursor: input.cursor,
|
||||||
|
})
|
||||||
|
if (!out.ok) throw new Error(out.error)
|
||||||
|
return out.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(text: string) {
|
||||||
|
return text.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidden(rel: string) {
|
||||||
|
return norm(rel)
|
||||||
|
.split("/")
|
||||||
|
.some((part) => part.startsWith("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
function accept(rel: string, file: string, glob?: string[], show?: boolean) {
|
||||||
|
if (show === false && hidden(rel)) return false
|
||||||
|
if (!glob?.length) return true
|
||||||
|
const allow = glob.filter((x) => !x.startsWith("!"))
|
||||||
|
const deny = glob.filter((x) => x.startsWith("!")).map((x) => x.slice(1))
|
||||||
|
if (allow.length > 0 && !allow.some((x) => Glob.match(x, rel) || Glob.match(x, file))) return false
|
||||||
|
if (deny.some((x) => Glob.match(x, rel) || Glob.match(x, file))) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowed(input: { rel: string; file?: string; glob?: string[]; hidden?: boolean }) {
|
||||||
|
return accept(input.rel, input.file ?? input.rel.split("/").at(-1) ?? input.rel, input.glob, input.hidden !== false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
const files = (await Glob.scan("**/*", {
|
||||||
|
cwd: input.cwd,
|
||||||
|
include: "file",
|
||||||
|
dot: true,
|
||||||
|
}))
|
||||||
|
.map((row) => norm(row))
|
||||||
|
.filter((row) => allowed({ rel: row, hidden: true }))
|
||||||
|
.toSorted((a, b) => a.localeCompare(b))
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
interface Node {
|
||||||
|
name: string
|
||||||
|
children: Map<string, Node>
|
||||||
|
}
|
||||||
|
|
||||||
|
function dir(node: Node, name: string) {
|
||||||
|
const old = node.children.get(name)
|
||||||
|
if (old) return old
|
||||||
|
const next = { name, children: new Map<string, Node>() }
|
||||||
|
node.children.set(name, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = { name: "", children: new Map<string, Node>() }
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.includes(".opencode")) continue
|
||||||
|
const parts = file.split("/")
|
||||||
|
if (parts.length < 2) continue
|
||||||
|
let node = root
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
node = dir(node, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(node: Node): number {
|
||||||
|
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = count(root)
|
||||||
|
const limit = input.limit ?? total
|
||||||
|
const lines: string[] = []
|
||||||
|
const queue = Array.from(root.children.values())
|
||||||
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((node) => ({ node, path: node.name }))
|
||||||
|
|
||||||
|
let used = 0
|
||||||
|
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
const row = queue[i]
|
||||||
|
lines.push(row.path)
|
||||||
|
used++
|
||||||
|
queue.push(
|
||||||
|
...Array.from(row.node.children.values())
|
||||||
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((node) => ({ node, path: `${row.path}/${node.name}` })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
glob?: string[]
|
||||||
|
limit?: number
|
||||||
|
follow?: boolean
|
||||||
|
}) {
|
||||||
|
const out = await grep({
|
||||||
|
cwd: input.cwd,
|
||||||
|
query: input.pattern,
|
||||||
|
mode: "regex",
|
||||||
|
max: input.limit,
|
||||||
|
})
|
||||||
|
const rows = out.items
|
||||||
|
.filter((row) => accept(norm(row.relativePath), row.fileName, input.glob, true))
|
||||||
|
.slice(0, input.limit)
|
||||||
|
.map((row) => ({
|
||||||
|
path: { text: row.relativePath },
|
||||||
|
lines: { text: row.lineContent },
|
||||||
|
line_number: row.lineNumber,
|
||||||
|
absolute_offset: row.byteOffset,
|
||||||
|
submatches: row.matchRanges
|
||||||
|
.map(([start, end]) => {
|
||||||
|
const text = row.lineContent.slice(start, end)
|
||||||
|
if (!text) return undefined
|
||||||
|
return {
|
||||||
|
match: { text },
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((row) => row !== undefined),
|
||||||
|
}))
|
||||||
|
return Match.array().parse(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Search = SearchResult
|
||||||
|
export type File = FileItem
|
||||||
|
export type Hit = GrepMatch
|
||||||
|
}
|
||||||
|
|
@ -12,9 +12,10 @@ import z from "zod"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Fff } from "./fff"
|
||||||
import { Protected } from "./protected"
|
import { Protected } from "./protected"
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
|
|
@ -384,7 +385,13 @@ export namespace File {
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
} else {
|
} else {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
for (const file of (
|
||||||
|
await Glob.scan("**/*", {
|
||||||
|
cwd: Instance.directory,
|
||||||
|
include: "file",
|
||||||
|
dot: true,
|
||||||
|
})
|
||||||
|
).toSorted((a, b) => a.localeCompare(b))) {
|
||||||
next.files.push(file)
|
next.files.push(file)
|
||||||
let current = file
|
let current = file
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -642,15 +649,41 @@ export namespace File {
|
||||||
dirs?: boolean
|
dirs?: boolean
|
||||||
type?: "file" | "directory"
|
type?: "file" | "directory"
|
||||||
}) {
|
}) {
|
||||||
|
const query = input.query.trim()
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
|
const slash = /[\\/]/.test(query)
|
||||||
|
const dot = query.includes(".")
|
||||||
|
const fast = slash || dot
|
||||||
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
|
if (query && fast && kind === "file") {
|
||||||
|
const files = yield* Effect.promise(() =>
|
||||||
|
Fff.files({
|
||||||
|
cwd: Instance.directory,
|
||||||
|
query,
|
||||||
|
size: slash ? limit : Math.max(limit * 5, 100),
|
||||||
|
})
|
||||||
|
.then((out) => {
|
||||||
|
const rows = Array.from(new Set(out.items.map((item) => item.relativePath.replaceAll("\\", "/"))))
|
||||||
|
if (slash || !dot) return rows.slice(0, limit)
|
||||||
|
const name = query.toLowerCase()
|
||||||
|
const exact = rows.filter((file) => file.split("/").at(-1)?.toLowerCase() === name)
|
||||||
|
const sort = exact.length ? exact.toSorted((a, b) => a.length - b.length || a.localeCompare(b)) : rows
|
||||||
|
return sort.slice(0, limit)
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
|
)
|
||||||
|
if (files.length) {
|
||||||
|
log.info("search", { query, kind, results: files.length, mode: "fff" })
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
yield* ensure()
|
yield* ensure()
|
||||||
const { cache } = yield* InstanceState.get(state)
|
const { cache } = yield* InstanceState.get(state)
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const query = input.query.trim()
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
|
||||||
log.info("search", { query, kind })
|
|
||||||
|
|
||||||
const result = cache
|
const result = cache
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,376 +0,0 @@
|
||||||
// Ripgrep utility functions
|
|
||||||
import path from "path"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import z from "zod"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { which } from "../util/which"
|
|
||||||
import { text } from "node:stream/consumers"
|
|
||||||
|
|
||||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
|
|
||||||
export namespace Ripgrep {
|
|
||||||
const log = Log.create({ service: "ripgrep" })
|
|
||||||
const Stats = z.object({
|
|
||||||
elapsed: z.object({
|
|
||||||
secs: z.number(),
|
|
||||||
nanos: z.number(),
|
|
||||||
human: z.string(),
|
|
||||||
}),
|
|
||||||
searches: z.number(),
|
|
||||||
searches_with_match: z.number(),
|
|
||||||
bytes_searched: z.number(),
|
|
||||||
bytes_printed: z.number(),
|
|
||||||
matched_lines: z.number(),
|
|
||||||
matches: z.number(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Begin = z.object({
|
|
||||||
type: z.literal("begin"),
|
|
||||||
data: z.object({
|
|
||||||
path: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Match = z.object({
|
|
||||||
type: z.literal("match"),
|
|
||||||
data: z.object({
|
|
||||||
path: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
}),
|
|
||||||
lines: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
}),
|
|
||||||
line_number: z.number(),
|
|
||||||
absolute_offset: z.number(),
|
|
||||||
submatches: z.array(
|
|
||||||
z.object({
|
|
||||||
match: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
}),
|
|
||||||
start: z.number(),
|
|
||||||
end: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const End = z.object({
|
|
||||||
type: z.literal("end"),
|
|
||||||
data: z.object({
|
|
||||||
path: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
}),
|
|
||||||
binary_offset: z.number().nullable(),
|
|
||||||
stats: Stats,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Summary = z.object({
|
|
||||||
type: z.literal("summary"),
|
|
||||||
data: z.object({
|
|
||||||
elapsed_total: z.object({
|
|
||||||
human: z.string(),
|
|
||||||
nanos: z.number(),
|
|
||||||
secs: z.number(),
|
|
||||||
}),
|
|
||||||
stats: Stats,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Result = z.union([Begin, Match, End, Summary])
|
|
||||||
|
|
||||||
export type Result = z.infer<typeof Result>
|
|
||||||
export type Match = z.infer<typeof Match>
|
|
||||||
export type Begin = z.infer<typeof Begin>
|
|
||||||
export type End = z.infer<typeof End>
|
|
||||||
export type Summary = z.infer<typeof Summary>
|
|
||||||
const PLATFORM = {
|
|
||||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
|
||||||
"arm64-linux": {
|
|
||||||
platform: "aarch64-unknown-linux-gnu",
|
|
||||||
extension: "tar.gz",
|
|
||||||
},
|
|
||||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
|
||||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
|
||||||
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
|
||||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const ExtractionFailedError = NamedError.create(
|
|
||||||
"RipgrepExtractionFailedError",
|
|
||||||
z.object({
|
|
||||||
filepath: z.string(),
|
|
||||||
stderr: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const UnsupportedPlatformError = NamedError.create(
|
|
||||||
"RipgrepUnsupportedPlatformError",
|
|
||||||
z.object({
|
|
||||||
platform: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const DownloadFailedError = NamedError.create(
|
|
||||||
"RipgrepDownloadFailedError",
|
|
||||||
z.object({
|
|
||||||
url: z.string(),
|
|
||||||
status: z.number(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const state = lazy(async () => {
|
|
||||||
const system = which("rg")
|
|
||||||
if (system) {
|
|
||||||
const stat = await fs.stat(system).catch(() => undefined)
|
|
||||||
if (stat?.isFile()) return { filepath: system }
|
|
||||||
log.warn("bun.which returned invalid rg path", { filepath: system })
|
|
||||||
}
|
|
||||||
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
|
|
||||||
|
|
||||||
if (!(await Filesystem.exists(filepath))) {
|
|
||||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
|
||||||
const config = PLATFORM[platformKey]
|
|
||||||
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
|
||||||
|
|
||||||
const version = "14.1.1"
|
|
||||||
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
|
|
||||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
|
||||||
|
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
|
||||||
const archivePath = path.join(Global.Path.bin, filename)
|
|
||||||
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
|
|
||||||
if (config.extension === "tar.gz") {
|
|
||||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
|
||||||
|
|
||||||
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
|
||||||
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
|
||||||
|
|
||||||
const proc = Process.spawn(args, {
|
|
||||||
cwd: Global.Path.bin,
|
|
||||||
stderr: "pipe",
|
|
||||||
stdout: "pipe",
|
|
||||||
})
|
|
||||||
const exit = await proc.exited
|
|
||||||
if (exit !== 0) {
|
|
||||||
const stderr = proc.stderr ? await text(proc.stderr) : ""
|
|
||||||
throw new ExtractionFailedError({
|
|
||||||
filepath,
|
|
||||||
stderr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (config.extension === "zip") {
|
|
||||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
|
|
||||||
const entries = await zipFileReader.getEntries()
|
|
||||||
let rgEntry: any
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.filename.endsWith("rg.exe")) {
|
|
||||||
rgEntry = entry
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rgEntry) {
|
|
||||||
throw new ExtractionFailedError({
|
|
||||||
filepath: archivePath,
|
|
||||||
stderr: "rg.exe not found in zip archive",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
|
||||||
if (!rgBlob) {
|
|
||||||
throw new ExtractionFailedError({
|
|
||||||
filepath: archivePath,
|
|
||||||
stderr: "Failed to extract rg.exe from zip archive",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
|
|
||||||
await zipFileReader.close()
|
|
||||||
}
|
|
||||||
await fs.unlink(archivePath)
|
|
||||||
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
filepath,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function filepath() {
|
|
||||||
const { filepath } = await state()
|
|
||||||
return filepath
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function* files(input: {
|
|
||||||
cwd: string
|
|
||||||
glob?: string[]
|
|
||||||
hidden?: boolean
|
|
||||||
follow?: boolean
|
|
||||||
maxDepth?: number
|
|
||||||
signal?: AbortSignal
|
|
||||||
}) {
|
|
||||||
input.signal?.throwIfAborted()
|
|
||||||
|
|
||||||
const args = [await filepath(), "--files", "--glob=!.git/*"]
|
|
||||||
if (input.follow) args.push("--follow")
|
|
||||||
if (input.hidden !== false) args.push("--hidden")
|
|
||||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
|
||||||
if (input.glob) {
|
|
||||||
for (const g of input.glob) {
|
|
||||||
args.push(`--glob=${g}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard against invalid cwd to provide a consistent ENOENT error.
|
|
||||||
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
|
|
||||||
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
|
||||||
code: "ENOENT",
|
|
||||||
errno: -2,
|
|
||||||
path: input.cwd,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = Process.spawn(args, {
|
|
||||||
cwd: input.cwd,
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "ignore",
|
|
||||||
abort: input.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!proc.stdout) {
|
|
||||||
throw new Error("Process output not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
let buffer = ""
|
|
||||||
const stream = proc.stdout as AsyncIterable<Buffer | string>
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
input.signal?.throwIfAborted()
|
|
||||||
|
|
||||||
buffer += typeof chunk === "string" ? chunk : chunk.toString()
|
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
|
||||||
const lines = buffer.split(/\r?\n/)
|
|
||||||
buffer = lines.pop() || ""
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line) yield line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer) yield buffer
|
|
||||||
await proc.exited
|
|
||||||
|
|
||||||
input.signal?.throwIfAborted()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
|
||||||
log.info("tree", input)
|
|
||||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
|
||||||
interface Node {
|
|
||||||
name: string
|
|
||||||
children: Map<string, Node>
|
|
||||||
}
|
|
||||||
|
|
||||||
function dir(node: Node, name: string) {
|
|
||||||
const existing = node.children.get(name)
|
|
||||||
if (existing) return existing
|
|
||||||
const next = { name, children: new Map() }
|
|
||||||
node.children.set(name, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const root: Node = { name: "", children: new Map() }
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.includes(".opencode")) continue
|
|
||||||
const parts = file.split(path.sep)
|
|
||||||
if (parts.length < 2) continue
|
|
||||||
let node = root
|
|
||||||
for (const part of parts.slice(0, -1)) {
|
|
||||||
node = dir(node, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function count(node: Node): number {
|
|
||||||
let total = 0
|
|
||||||
for (const child of node.children.values()) {
|
|
||||||
total += 1 + count(child)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = count(root)
|
|
||||||
const limit = input.limit ?? total
|
|
||||||
const lines: string[] = []
|
|
||||||
const queue: { node: Node; path: string }[] = []
|
|
||||||
for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
||||||
queue.push({ node: child, path: child.name })
|
|
||||||
}
|
|
||||||
|
|
||||||
let used = 0
|
|
||||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
|
||||||
const { node, path } = queue[i]
|
|
||||||
lines.push(path)
|
|
||||||
used++
|
|
||||||
for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
||||||
queue.push({ node: child, path: `${path}/${child.name}` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function search(input: {
|
|
||||||
cwd: string
|
|
||||||
pattern: string
|
|
||||||
glob?: string[]
|
|
||||||
limit?: number
|
|
||||||
follow?: boolean
|
|
||||||
}) {
|
|
||||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
|
|
||||||
if (input.follow) args.push("--follow")
|
|
||||||
|
|
||||||
if (input.glob) {
|
|
||||||
for (const g of input.glob) {
|
|
||||||
args.push(`--glob=${g}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.limit) {
|
|
||||||
args.push(`--max-count=${input.limit}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("--")
|
|
||||||
args.push(input.pattern)
|
|
||||||
|
|
||||||
const result = await Process.text(args, {
|
|
||||||
cwd: input.cwd,
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
|
||||||
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
|
|
||||||
// Parse JSON lines from ripgrep output
|
|
||||||
|
|
||||||
return lines
|
|
||||||
.map((line) => JSON.parse(line))
|
|
||||||
.map((parsed) => Result.parse(parsed))
|
|
||||||
.filter((r) => r.type === "match")
|
|
||||||
.map((r) => r.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { File } from "../../file"
|
import { File } from "../../file"
|
||||||
import { Ripgrep } from "../../file/ripgrep"
|
import { Fff } from "../../file/fff"
|
||||||
import { LSP } from "../../lsp"
|
import { LSP } from "../../lsp"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
|
@ -13,14 +13,14 @@ export const FileRoutes = lazy(() =>
|
||||||
"/find",
|
"/find",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Find text",
|
summary: "Find text",
|
||||||
description: "Search for text patterns across files in the project using ripgrep.",
|
description: "Search for text patterns across files in the project.",
|
||||||
operationId: "find.text",
|
operationId: "find.text",
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "Matches",
|
description: "Matches",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Ripgrep.Match.shape.data.array()),
|
schema: resolver(Fff.Match.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -34,7 +34,7 @@ export const FileRoutes = lazy(() =>
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const pattern = c.req.valid("query").pattern
|
const pattern = c.req.valid("query").pattern
|
||||||
const result = await Ripgrep.search({
|
const result = await Fff.search({
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
pattern,
|
pattern,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Fff } from "../file/fff"
|
||||||
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ export namespace SystemPrompt {
|
||||||
`<directories>`,
|
`<directories>`,
|
||||||
` ${
|
` ${
|
||||||
project.vcs === "git" && false
|
project.vcs === "git" && false
|
||||||
? await Ripgrep.tree({
|
? await Fff.tree({
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,95 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Fff } from "../file/fff"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { assertExternalDirectory } from "./external-directory"
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
path: string
|
||||||
|
rel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function include(pattern: string) {
|
||||||
|
const val = pattern.trim().replaceAll("\\", "/")
|
||||||
|
if (!val) return "*"
|
||||||
|
const flat = val.replaceAll("**/", "").replaceAll("/**", "/")
|
||||||
|
const idx = flat.lastIndexOf("/")
|
||||||
|
if (idx < 0) return flat
|
||||||
|
const dir = flat.slice(0, idx + 1)
|
||||||
|
const glob = flat.slice(idx + 1)
|
||||||
|
if (!glob) return dir
|
||||||
|
return `${dir} ${glob}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function words(text: string) {
|
||||||
|
return text.trim().split(/\s+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(text: string) {
|
||||||
|
return text.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidden(rel: string) {
|
||||||
|
return norm(rel).split("/").includes(".git")
|
||||||
|
}
|
||||||
|
|
||||||
|
function broad(pattern: string) {
|
||||||
|
const val = norm(pattern.trim())
|
||||||
|
if (!val) return true
|
||||||
|
if (["*", "**", "**/*", "./**", "./**/*"].includes(val)) return true
|
||||||
|
return /^(\*\*\/)?\*$/.test(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowed(pattern: string, rel: string) {
|
||||||
|
if (Glob.match(pattern, rel)) return true
|
||||||
|
const file = rel.split("/").at(-1) ?? rel
|
||||||
|
return Glob.match(pattern, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(items: { path: string; relativePath: string }[]) {
|
||||||
|
return items
|
||||||
|
.map((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
rel: norm(item.relativePath),
|
||||||
|
}))
|
||||||
|
.filter((item) => !hidden(item.rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
function top(rows: Row[]) {
|
||||||
|
const out = new Map<string, number>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const parts = row.rel.split("/")
|
||||||
|
const key = parts.length < 2 ? "." : parts.slice(0, Math.min(2, parts.length - 1)).join("/") + "/"
|
||||||
|
out.set(key, (out.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return Array.from(out.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||||
|
.slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan(pattern: string, dir: string) {
|
||||||
|
const direct = await Glob.scan(pattern, {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
dot: true,
|
||||||
|
})
|
||||||
|
const out = direct.length > 0 ? direct : await Glob.scan(`**/${pattern}`, {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
dot: true,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
.map((file) => ({
|
||||||
|
path: file,
|
||||||
|
rel: norm(path.relative(dir, file)),
|
||||||
|
}))
|
||||||
|
.filter((item) => !hidden(item.rel))
|
||||||
|
}
|
||||||
|
|
||||||
export const GlobTool = Tool.define("glob", {
|
export const GlobTool = Tool.define("glob", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|
@ -29,35 +113,60 @@ export const GlobTool = Tool.define("glob", {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let search = params.path ?? Instance.directory
|
let dir = params.path ?? Instance.directory
|
||||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
dir = path.isAbsolute(dir) ? dir : path.resolve(Instance.directory, dir)
|
||||||
await assertExternalDirectory(ctx, search, { kind: "directory" })
|
await assertExternalDirectory(ctx, dir, { kind: "directory" })
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const files = []
|
const wide = broad(params.pattern)
|
||||||
let truncated = false
|
const size = wide ? 400 : limit + 1
|
||||||
for await (const file of Ripgrep.files({
|
|
||||||
cwd: search,
|
const first = await Fff.files({
|
||||||
glob: [params.pattern],
|
cwd: dir,
|
||||||
signal: ctx.abort,
|
query: include(params.pattern),
|
||||||
})) {
|
size,
|
||||||
if (files.length >= limit) {
|
current: path.join(dir, ".opencode"),
|
||||||
truncated = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const full = path.resolve(search, file)
|
|
||||||
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
|
|
||||||
files.push({
|
|
||||||
path: full,
|
|
||||||
mtime: stats,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let fallback = false
|
||||||
|
let rows = pick(first.items).filter((row) => allowed(params.pattern, row.rel))
|
||||||
|
if (!rows.length) {
|
||||||
|
const list = words(params.pattern)
|
||||||
|
if (list.length >= 3) {
|
||||||
|
const short = list.slice(0, 2).join(" ")
|
||||||
|
const next = await Fff.files({
|
||||||
|
cwd: dir,
|
||||||
|
query: include(short),
|
||||||
|
size,
|
||||||
|
current: path.join(dir, ".opencode"),
|
||||||
|
})
|
||||||
|
rows = pick(next.items).filter((row) => allowed(params.pattern, row.rel))
|
||||||
}
|
}
|
||||||
files.sort((a, b) => b.mtime - a.mtime)
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
fallback = true
|
||||||
|
rows = (await scan(params.pattern, dir)).filter((row) => allowed(params.pattern, row.rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = rows.length > limit
|
||||||
|
const files = rows.slice(0, limit).map((row) => row.path)
|
||||||
|
|
||||||
const output = []
|
const output = []
|
||||||
if (files.length === 0) output.push("No files found")
|
if (files.length === 0) output.push("No files found")
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
output.push(...files.map((f) => f.path))
|
output.push(...files)
|
||||||
|
if (wide && truncated) {
|
||||||
|
const dirs = top(rows)
|
||||||
|
if (dirs.length > 0) {
|
||||||
|
output.push("")
|
||||||
|
output.push("Top directories in this result set:")
|
||||||
|
output.push(...dirs.map(([dir, count]) => `${dir} (${count})`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fallback) {
|
||||||
|
output.push("")
|
||||||
|
output.push("(Used filesystem glob fallback for this pattern.)")
|
||||||
|
}
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
output.push("")
|
output.push("")
|
||||||
output.push(
|
output.push(
|
||||||
|
|
@ -67,7 +176,7 @@ export const GlobTool = Tool.define("glob", {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: path.relative(Instance.worktree, search),
|
title: path.relative(Instance.worktree, dir),
|
||||||
metadata: {
|
metadata: {
|
||||||
count: files.length,
|
count: files.length,
|
||||||
truncated,
|
truncated,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
- Fast file pattern matching tool that works with any codebase size
|
- Fast file pattern matching tool that uses fuzzy-first indexing and frecency ranking
|
||||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||||
- Returns matching file paths sorted by modification time
|
- Returns matching file paths prioritized by recent and relevant files
|
||||||
- Use this tool when you need to find files by name patterns
|
- Use this tool when you need to find files by name patterns
|
||||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,136 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { text } from "node:stream/consumers"
|
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Fff } from "../file/fff"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import type { GrepMode } from "@ff-labs/fff-node"
|
||||||
import { Process } from "../util/process"
|
|
||||||
|
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { assertExternalDirectory } from "./external-directory"
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE = 180
|
||||||
|
const MAX_MATCH = 100
|
||||||
|
const MAX_DEF_FIRST = 8
|
||||||
|
const MAX_DEF_NEXT = 5
|
||||||
|
|
||||||
|
function isRegex(pattern: string) {
|
||||||
|
return /[.*+?^${}()|[\]\\]/.test(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConstraint(text: string) {
|
||||||
|
return text.startsWith("!") || text.startsWith("*") || text.endsWith("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(text: string) {
|
||||||
|
return text.replaceAll(":", "").replaceAll("-", "").replaceAll("_", "").toLowerCase().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function include(text?: string) {
|
||||||
|
if (!text) return undefined
|
||||||
|
const val = text.trim().replaceAll("\\", "/")
|
||||||
|
if (!val) return undefined
|
||||||
|
const flat = val.replaceAll("**/", "").replaceAll("/**", "/")
|
||||||
|
const idx = flat.lastIndexOf("/")
|
||||||
|
if (idx < 0) return flat
|
||||||
|
const dir = flat.slice(0, idx + 1)
|
||||||
|
const glob = flat.slice(idx + 1)
|
||||||
|
if (!glob) return dir
|
||||||
|
return `${dir} ${glob}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(pattern: string, inc?: string) {
|
||||||
|
if (!inc) return pattern
|
||||||
|
return `${inc} ${pattern}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function norm(text: string) {
|
||||||
|
return text.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowed(hit: Fff.Hit, inc?: string) {
|
||||||
|
if (!inc) return true
|
||||||
|
const rel = norm(hit.relativePath)
|
||||||
|
if (Glob.match(inc, rel)) return true
|
||||||
|
return Glob.match(inc, norm(hit.fileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
function def(line: string) {
|
||||||
|
const text = line.trim()
|
||||||
|
if (!text) return false
|
||||||
|
return /^(export\s+)?(default\s+)?(async\s+)?(function|class|interface|type|enum|const|let|var)\b/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function imp(line: string) {
|
||||||
|
return /^(import\b|export\s+\{.*\}\s+from\b|use\b|#include\b|require\()/.test(line.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(text: string, ranges: [number, number][]) {
|
||||||
|
const trim = text.trim()
|
||||||
|
if (trim.length <= MAX_LINE) return trim
|
||||||
|
const first = ranges[0]
|
||||||
|
if (!first) return trim.slice(0, MAX_LINE - 3) + "..."
|
||||||
|
const start = Math.max(0, first[0] - Math.floor(MAX_LINE / 3))
|
||||||
|
const end = Math.min(trim.length, start + MAX_LINE)
|
||||||
|
const body = trim.slice(start, end)
|
||||||
|
const pre = start > 0 ? "..." : ""
|
||||||
|
const post = end < trim.length ? "..." : ""
|
||||||
|
return pre + body + post
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(rows: Item[]) {
|
||||||
|
const out = new Map<string, Item[]>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = out.get(row.hit.path)
|
||||||
|
if (list) {
|
||||||
|
list.push(row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.set(row.hit.path, [row])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
hit: Fff.Hit
|
||||||
|
def: boolean
|
||||||
|
imp: boolean
|
||||||
|
idx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(input: {
|
||||||
|
cwd: string
|
||||||
|
pattern: string
|
||||||
|
inc?: string
|
||||||
|
mode: GrepMode
|
||||||
|
max: number
|
||||||
|
before: number
|
||||||
|
after: number
|
||||||
|
}) {
|
||||||
|
const first = await Fff.grep({
|
||||||
|
cwd: input.cwd,
|
||||||
|
query: query(input.pattern, include(input.inc)),
|
||||||
|
mode: input.mode,
|
||||||
|
max: input.max,
|
||||||
|
before: input.before,
|
||||||
|
after: input.after,
|
||||||
|
})
|
||||||
|
const head = first.items.filter((hit) => allowed(hit, input.inc))
|
||||||
|
if (head.length) return { out: first, hits: head }
|
||||||
|
if (!input.inc) return { out: first, hits: head }
|
||||||
|
const raw = await Fff.grep({
|
||||||
|
cwd: input.cwd,
|
||||||
|
query: input.pattern,
|
||||||
|
mode: input.mode,
|
||||||
|
max: input.max,
|
||||||
|
before: input.before,
|
||||||
|
after: input.after,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
out: raw,
|
||||||
|
hits: raw.items.filter((hit) => allowed(hit, input.inc)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GrepTool = Tool.define("grep", {
|
export const GrepTool = Tool.define("grep", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|
@ -35,35 +155,86 @@ export const GrepTool = Tool.define("grep", {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let searchPath = params.path ?? Instance.directory
|
let dir = params.path ?? Instance.directory
|
||||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
dir = path.isAbsolute(dir) ? dir : path.resolve(Instance.directory, dir)
|
||||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
await assertExternalDirectory(ctx, dir, { kind: "directory" })
|
||||||
|
|
||||||
const rgPath = await Ripgrep.filepath()
|
const mode = isRegex(params.pattern) ? "regex" : "plain"
|
||||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
const exact = await run({
|
||||||
if (params.include) {
|
cwd: dir,
|
||||||
args.push("--glob", params.include)
|
pattern: params.pattern,
|
||||||
}
|
inc: params.include,
|
||||||
args.push(searchPath)
|
mode,
|
||||||
|
max: 10,
|
||||||
const proc = Process.spawn([rgPath, ...args], {
|
before: 0,
|
||||||
stdout: "pipe",
|
after: 4,
|
||||||
stderr: "pipe",
|
|
||||||
abort: ctx.abort,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!proc.stdout || !proc.stderr) {
|
let phase = "exact"
|
||||||
throw new Error("Process output not available")
|
let note = ""
|
||||||
|
let warn = exact.out.regexFallbackError
|
||||||
|
let hits = exact.hits
|
||||||
|
|
||||||
|
if (!hits.length) {
|
||||||
|
const words = params.pattern.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (words.length >= 2 && !isConstraint(words[0])) {
|
||||||
|
const next = words.slice(1).join(" ")
|
||||||
|
const step = await run({
|
||||||
|
cwd: dir,
|
||||||
|
pattern: next,
|
||||||
|
inc: params.include,
|
||||||
|
mode: isRegex(next) ? "regex" : "plain",
|
||||||
|
max: 10,
|
||||||
|
before: 0,
|
||||||
|
after: 4,
|
||||||
|
})
|
||||||
|
warn = warn ?? step.out.regexFallbackError
|
||||||
|
if (step.hits.length > 0 && step.hits.length <= 10) {
|
||||||
|
phase = "broad"
|
||||||
|
note = `0 exact matches. Broadened query \`${next}\`:`
|
||||||
|
hits = step.hits
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = await text(proc.stdout)
|
if (!hits.length) {
|
||||||
const errorOutput = await text(proc.stderr)
|
const fuzzy = clean(params.pattern)
|
||||||
const exitCode = await proc.exited
|
if (fuzzy) {
|
||||||
|
const step = await run({
|
||||||
|
cwd: dir,
|
||||||
|
pattern: fuzzy,
|
||||||
|
inc: params.include,
|
||||||
|
mode: "fuzzy",
|
||||||
|
max: 3,
|
||||||
|
before: 0,
|
||||||
|
after: 2,
|
||||||
|
})
|
||||||
|
if (step.hits.length) {
|
||||||
|
phase = "fuzzy"
|
||||||
|
note = `0 exact matches. ${step.hits.length} approximate:`
|
||||||
|
hits = step.hits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
if (!hits.length && params.pattern.includes("/")) {
|
||||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
const files = await Fff.files({
|
||||||
// Only fail if exit code is 2 AND no output was produced
|
cwd: dir,
|
||||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
query: params.pattern,
|
||||||
|
size: 1,
|
||||||
|
})
|
||||||
|
const row = files.items[0]
|
||||||
|
const score = files.scores[0]
|
||||||
|
if (row && score && score.baseScore > params.pattern.length * 10) {
|
||||||
|
return {
|
||||||
|
title: params.pattern,
|
||||||
|
metadata: { matches: 0, truncated: false },
|
||||||
|
output: `0 content matches. But there is a relevant file path:\n${row.path}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hits.length) {
|
||||||
return {
|
return {
|
||||||
title: params.pattern,
|
title: params.pattern,
|
||||||
metadata: { matches: 0, truncated: false },
|
metadata: { matches: 0, truncated: false },
|
||||||
|
|
@ -71,86 +242,74 @@ export const GrepTool = Tool.define("grep", {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitCode !== 0 && exitCode !== 2) {
|
const rows = hits.map((hit, idx) => ({
|
||||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
hit,
|
||||||
}
|
idx,
|
||||||
|
def: def(hit.lineContent),
|
||||||
const hasErrors = exitCode === 2
|
imp: imp(hit.lineContent),
|
||||||
|
}))
|
||||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
const hasDef = rows.some((row) => row.def)
|
||||||
const lines = output.trim().split(/\r?\n/)
|
const show = hasDef ? rows.filter((row) => !row.imp || row.def) : rows
|
||||||
const matches = []
|
show.sort((a, b) => {
|
||||||
|
const ak = a.def ? 0 : a.imp ? 2 : 1
|
||||||
for (const line of lines) {
|
const bk = b.def ? 0 : b.imp ? 2 : 1
|
||||||
if (!line) continue
|
if (ak !== bk) return ak - bk
|
||||||
|
return a.idx - b.idx
|
||||||
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
|
|
||||||
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
|
|
||||||
|
|
||||||
const lineNum = parseInt(lineNumStr, 10)
|
|
||||||
const lineText = lineTextParts.join("|")
|
|
||||||
|
|
||||||
const stats = Filesystem.stat(filePath)
|
|
||||||
if (!stats) continue
|
|
||||||
|
|
||||||
matches.push({
|
|
||||||
path: filePath,
|
|
||||||
modTime: stats.mtime.getTime(),
|
|
||||||
lineNum,
|
|
||||||
lineText,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
matches.sort((a, b) => b.modTime - a.modTime)
|
const total = show.length
|
||||||
|
const trim = show.slice(0, MAX_MATCH)
|
||||||
|
const over = total > MAX_MATCH
|
||||||
|
const files = new Set(trim.map((row) => row.hit.path)).size
|
||||||
|
const budget = files <= 3 ? 5000 : files <= 8 ? 3500 : 2500
|
||||||
|
const read = (trim.find((row) => row.def) ?? trim[0]).hit.path
|
||||||
|
|
||||||
const limit = 100
|
const out: string[] = []
|
||||||
const truncated = matches.length > limit
|
if (phase === "exact") out.push(`Found ${total} matches${over ? ` (showing first ${MAX_MATCH})` : ""}`)
|
||||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
if (phase !== "exact") out.push(note)
|
||||||
|
out.push(`Read ${read}`)
|
||||||
|
if (warn) out.push(`! regex failed: ${warn}`)
|
||||||
|
|
||||||
if (finalMatches.length === 0) {
|
const by = group(trim)
|
||||||
return {
|
let used = out.join("\n").length
|
||||||
title: params.pattern,
|
let cut = false
|
||||||
metadata: { matches: 0, truncated: false },
|
let firstDef = true
|
||||||
output: "No files found",
|
let shown = 0
|
||||||
|
for (const [file, list] of by.entries()) {
|
||||||
|
const chunk = ["", `${file}:`]
|
||||||
|
let add = 0
|
||||||
|
for (const row of list) {
|
||||||
|
add++
|
||||||
|
chunk.push(` Line ${row.hit.lineNumber}: ${line(row.hit.lineContent, row.hit.matchRanges)}`)
|
||||||
|
if (!row.def) continue
|
||||||
|
const max = firstDef ? MAX_DEF_FIRST : MAX_DEF_NEXT
|
||||||
|
firstDef = false
|
||||||
|
for (const extra of (row.hit.contextAfter ?? []).slice(0, max)) {
|
||||||
|
chunk.push(` ${line(extra, [])}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const text = chunk.join("\n")
|
||||||
const totalMatches = matches.length
|
if (used + text.length > budget && shown > 0) {
|
||||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
cut = true
|
||||||
|
break
|
||||||
let currentFile = ""
|
|
||||||
for (const match of finalMatches) {
|
|
||||||
if (currentFile !== match.path) {
|
|
||||||
if (currentFile !== "") {
|
|
||||||
outputLines.push("")
|
|
||||||
}
|
}
|
||||||
currentFile = match.path
|
out.push(...chunk)
|
||||||
outputLines.push(`${match.path}:`)
|
used += text.length
|
||||||
}
|
shown += add
|
||||||
const truncatedLineText =
|
|
||||||
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
|
|
||||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (truncated) {
|
if (over || cut) {
|
||||||
outputLines.push("")
|
out.push("")
|
||||||
outputLines.push(
|
out.push(`(Results truncated: showing first ${shown} results. Consider using a more specific path or pattern.)`)
|
||||||
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasErrors) {
|
|
||||||
outputLines.push("")
|
|
||||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: params.pattern,
|
title: params.pattern,
|
||||||
metadata: {
|
metadata: {
|
||||||
matches: totalMatches,
|
matches: total,
|
||||||
truncated,
|
truncated: over || cut,
|
||||||
},
|
},
|
||||||
output: outputLines.join("\n"),
|
output: out.join("\n"),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
- Fast content search tool that works with any codebase size
|
- Fast content search tool that uses fuzzy-first indexing and frecency ranking
|
||||||
- Searches file contents using regular expressions
|
- Searches file contents with plain text, regex, and typo-tolerant fuzzy fallback
|
||||||
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
- Supports regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||||
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
||||||
- Returns file paths and line numbers with at least one match sorted by modification time
|
- Returns file paths and line numbers, prioritizing likely definitions and high-signal results
|
||||||
|
- Includes smart retries (query broadening and path suggestions) when exact matches fail
|
||||||
- Use this tool when you need to find files containing specific patterns
|
- Use this tool when you need to find files containing specific patterns
|
||||||
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
|
||||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { Tool } from "./tool"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import DESCRIPTION from "./ls.txt"
|
import DESCRIPTION from "./ls.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Fff } from "../file/fff"
|
||||||
import { assertExternalDirectory } from "./external-directory"
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
|
||||||
export const IGNORE_PATTERNS = [
|
export const IGNORE_PATTERNS = [
|
||||||
"node_modules/",
|
"node_modules/",
|
||||||
|
|
@ -55,11 +56,18 @@ export const ListTool = Tool.define("list", {
|
||||||
})
|
})
|
||||||
|
|
||||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||||
const files = []
|
const rows = (await Glob.scan("**/*", {
|
||||||
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
|
cwd: searchPath,
|
||||||
files.push(file)
|
include: "file",
|
||||||
if (files.length >= LIMIT) break
|
dot: true,
|
||||||
}
|
}))
|
||||||
|
.map((row) => row.replaceAll("\\", "/"))
|
||||||
|
.filter((row) => {
|
||||||
|
ctx.abort.throwIfAborted()
|
||||||
|
return Fff.allowed({ rel: row, glob: ignoreGlobs, hidden: true })
|
||||||
|
})
|
||||||
|
.toSorted((a, b) => a.localeCompare(b))
|
||||||
|
const files = rows.slice(0, LIMIT)
|
||||||
|
|
||||||
// Build directory structure
|
// Build directory structure
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
|
|
@ -113,7 +121,7 @@ export const ListTool = Tool.define("list", {
|
||||||
title: path.relative(Instance.worktree, searchPath),
|
title: path.relative(Instance.worktree, searchPath),
|
||||||
metadata: {
|
metadata: {
|
||||||
count: files.length,
|
count: files.length,
|
||||||
truncated: files.length >= LIMIT,
|
truncated: rows.length > LIMIT,
|
||||||
},
|
},
|
||||||
output,
|
output,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { pathToFileURL } from "url"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Fff } from "../file/fff"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
|
||||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||||
const list = await Skill.available(ctx?.agent)
|
const list = await Skill.available(ctx?.agent)
|
||||||
|
|
@ -60,22 +61,17 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||||
|
|
||||||
const limit = 10
|
const limit = 10
|
||||||
const files = await iife(async () => {
|
const files = await iife(async () => {
|
||||||
const arr = []
|
ctx.abort.throwIfAborted()
|
||||||
for await (const file of Ripgrep.files({
|
return (await Glob.scan("**/*", {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
follow: false,
|
include: "file",
|
||||||
hidden: true,
|
dot: true,
|
||||||
signal: ctx.abort,
|
}))
|
||||||
})) {
|
.map((file) => file.replaceAll("\\", "/"))
|
||||||
if (file.includes("SKILL.md")) {
|
.filter((file) => Fff.allowed({ rel: file, hidden: true, glob: ["!node_modules/*", "!.git/*"] }))
|
||||||
continue
|
.filter((file) => !file.includes("SKILL.md"))
|
||||||
}
|
.slice(0, limit)
|
||||||
arr.push(path.resolve(dir, file))
|
.map((file) => path.resolve(dir, file))
|
||||||
if (arr.length >= limit) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
|
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Fff } from "../../src/file/fff"
|
||||||
|
|
||||||
|
async function write(file: string, body: string) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await fs.writeFile(file, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("file.fff", () => {
|
||||||
|
test("allowed respects hidden filter", async () => {
|
||||||
|
expect(Fff.allowed({ rel: "visible.txt", hidden: true })).toBe(true)
|
||||||
|
expect(Fff.allowed({ rel: ".opencode/thing.json", hidden: true })).toBe(true)
|
||||||
|
expect(Fff.allowed({ rel: ".opencode/thing.json", hidden: false })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("search returns empty when nothing matches", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const hits = await Fff.search({
|
||||||
|
cwd: tmp.path,
|
||||||
|
pattern: "needle",
|
||||||
|
})
|
||||||
|
expect(hits).toEqual([])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tree builds and truncates", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await fs.mkdir(path.join(dir, "a", "b"), { recursive: true })
|
||||||
|
await write(path.join(dir, "a", "b", "c.ts"), "export const x = 1\n")
|
||||||
|
await write(path.join(dir, "a", "d.ts"), "export const y = 1\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const tree = await Fff.tree({ cwd: tmp.path, limit: 1 })
|
||||||
|
expect(tree).toContain("a")
|
||||||
|
expect(tree).toContain("truncated")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
|
||||||
|
|
||||||
describe("file.ripgrep", () => {
|
|
||||||
test("defaults to include hidden", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
|
||||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
|
||||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
|
|
||||||
const hasVisible = files.includes("visible.txt")
|
|
||||||
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
|
|
||||||
expect(hasVisible).toBe(true)
|
|
||||||
expect(hasHidden).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("hidden false excludes hidden", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
|
||||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
|
||||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
|
|
||||||
const hasVisible = files.includes("visible.txt")
|
|
||||||
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
|
|
||||||
expect(hasVisible).toBe(true)
|
|
||||||
expect(hasHidden).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("search returns empty when nothing matches", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const hits = await Ripgrep.search({
|
|
||||||
cwd: tmp.path,
|
|
||||||
pattern: "needle",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(hits).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { GlobTool } from "../../src/tool/glob"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
|
async function write(file: string, body: string) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await fs.writeFile(file, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: SessionID.make("ses_test"),
|
||||||
|
messageID: MessageID.make(""),
|
||||||
|
callID: "",
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => {},
|
||||||
|
ask: async () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tool.glob", () => {
|
||||||
|
test("finds files by glob pattern", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await write(path.join(dir, "src", "foo.ts"), "export const foo = 1\n")
|
||||||
|
await write(path.join(dir, "src", "bar.ts"), "export const bar = 1\n")
|
||||||
|
await write(path.join(dir, "src", "baz.js"), "export const baz = 1\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const glob = await GlobTool.init()
|
||||||
|
const result = await glob.execute(
|
||||||
|
{
|
||||||
|
pattern: "*.ts",
|
||||||
|
path: tmp.path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.count).toBe(2)
|
||||||
|
expect(result.output).toContain(path.join(tmp.path, "src", "foo.ts"))
|
||||||
|
expect(result.output).toContain(path.join(tmp.path, "src", "bar.ts"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns no files found for unmatched patterns", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await write(path.join(dir, "src", "foo.ts"), "export const foo = 1\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const glob = await GlobTool.init()
|
||||||
|
const result = await glob.execute(
|
||||||
|
{
|
||||||
|
pattern: "*.py",
|
||||||
|
path: tmp.path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.count).toBe(0)
|
||||||
|
expect(result.output).toBe("No files found")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back for brace glob patterns", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await write(path.join(dir, "src", "foo.ts"), "export const foo = 1\n")
|
||||||
|
await write(path.join(dir, "src", "bar.js"), "export const bar = 1\n")
|
||||||
|
await write(path.join(dir, "src", "baz.py"), "print('baz')\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const glob = await GlobTool.init()
|
||||||
|
const result = await glob.execute(
|
||||||
|
{
|
||||||
|
pattern: "*.{ts,js}",
|
||||||
|
path: tmp.path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.count).toBe(2)
|
||||||
|
expect(result.output).toContain(path.join(tmp.path, "src", "foo.ts"))
|
||||||
|
expect(result.output).toContain(path.join(tmp.path, "src", "bar.js"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { GrepTool } from "../../src/tool/grep"
|
import { GrepTool } from "../../src/tool/grep"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
|
async function write(file: string, body: string) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await fs.writeFile(file, body)
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
messageID: MessageID.make(""),
|
messageID: MessageID.make(""),
|
||||||
|
|
@ -41,7 +47,7 @@ describe("tool.grep", () => {
|
||||||
test("no matches returns correct output", async () => {
|
test("no matches returns correct output", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "test.txt"), "hello world")
|
await write(path.join(dir, "test.txt"), "hello world")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|
@ -65,8 +71,7 @@ describe("tool.grep", () => {
|
||||||
// This test verifies the regex split handles both \n and \r\n
|
// This test verifies the regex split handles both \n and \r\n
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
// Create a test file with content
|
await write(path.join(dir, "test.txt"), "line1\nline2\nline3")
|
||||||
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
|
|
@ -84,28 +89,50 @@ describe("tool.grep", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("broadens multi-word query when exact has no match", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await write(path.join(dir, "test.txt"), "upload completed\n")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const grep = await GrepTool.init()
|
||||||
|
const result = await grep.execute(
|
||||||
|
{
|
||||||
|
pattern: "prepare upload",
|
||||||
|
path: tmp.path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||||
|
expect(result.output).toContain("Broadened query")
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("CRLF regex handling", () => {
|
test("suggests path when content has no match", async () => {
|
||||||
test("regex correctly splits Unix line endings", () => {
|
await using tmp = await tmpdir({
|
||||||
const unixOutput = "file1.txt|1|content1\nfile2.txt|2|content2\nfile3.txt|3|content3"
|
init: async (dir) => {
|
||||||
const lines = unixOutput.trim().split(/\r?\n/)
|
await write(path.join(dir, "src", "server", "auth.ts"), "export const token = 1\n")
|
||||||
expect(lines.length).toBe(3)
|
},
|
||||||
expect(lines[0]).toBe("file1.txt|1|content1")
|
})
|
||||||
expect(lines[2]).toBe("file3.txt|3|content3")
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const grep = await GrepTool.init()
|
||||||
|
const result = await grep.execute(
|
||||||
|
{
|
||||||
|
pattern: "src/server/auth.ts",
|
||||||
|
path: tmp.path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
expect(result.metadata.matches).toBe(0)
|
||||||
|
expect(result.output).toContain("relevant file path")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
test("regex correctly splits Windows CRLF line endings", () => {
|
|
||||||
const windowsOutput = "file1.txt|1|content1\r\nfile2.txt|2|content2\r\nfile3.txt|3|content3"
|
|
||||||
const lines = windowsOutput.trim().split(/\r?\n/)
|
|
||||||
expect(lines.length).toBe(3)
|
|
||||||
expect(lines[0]).toBe("file1.txt|1|content1")
|
|
||||||
expect(lines[2]).toBe("file3.txt|3|content3")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("regex handles mixed line endings", () => {
|
|
||||||
const mixedOutput = "file1.txt|1|content1\nfile2.txt|2|content2\r\nfile3.txt|3|content3"
|
|
||||||
const lines = mixedOutput.trim().split(/\r?\n/)
|
|
||||||
expect(lines.length).toBe(3)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export interface FilteredListProps<T> {
|
||||||
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
|
||||||
onSelect?: (value: T | undefined, index: number) => void
|
onSelect?: (value: T | undefined, index: number) => void
|
||||||
noInitialSelection?: boolean
|
noInitialSelection?: boolean
|
||||||
|
stale?: boolean
|
||||||
|
fuzzy?: boolean | ((filter: string) => boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilteredList<T>(props: FilteredListProps<T>) {
|
export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||||
|
|
@ -30,11 +32,12 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||||
async ({ filter, items }) => {
|
async ({ filter, items }) => {
|
||||||
const query = filter ?? ""
|
const query = filter ?? ""
|
||||||
const needle = query.toLowerCase()
|
const needle = query.toLowerCase()
|
||||||
|
const fuzzy = typeof props.fuzzy === "function" ? props.fuzzy(query) : (props.fuzzy ?? true)
|
||||||
const all = (await Promise.resolve(items)) || []
|
const all = (await Promise.resolve(items)) || []
|
||||||
const result = pipe(
|
const result = pipe(
|
||||||
all,
|
all,
|
||||||
(x) => {
|
(x) => {
|
||||||
if (!needle) return x
|
if (!needle || !fuzzy) return x
|
||||||
if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
|
||||||
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
return fuzzysort.go(needle, x).map((x) => x.target) as T[]
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +54,9 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||||
)
|
)
|
||||||
|
|
||||||
const flat = createMemo(() => {
|
const flat = createMemo(() => {
|
||||||
|
const groups = props.stale === false && grouped.loading ? empty : grouped.latest || []
|
||||||
return pipe(
|
return pipe(
|
||||||
grouped.latest || [],
|
groups,
|
||||||
flatMap((x) => x.items),
|
flatMap((x) => x.items),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue