mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-11 00:14:53 +00:00
Compare commits
6 Commits
refactor/l
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6fe01465 | ||
|
|
cf27a73397 | ||
|
|
f2c492a8e6 | ||
|
|
0556774097 | ||
|
|
d9d5a0615e | ||
|
|
d72ddd71fa |
@@ -169,6 +169,8 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
|
||||
100
packages/opencode/src/effect/app-runtime.ts
Normal file
100
packages/opencode/src/effect/app-runtime.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
import { Observability } from "./oltp"
|
||||
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Bus } from "@/bus"
|
||||
import { Auth } from "@/auth"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Git } from "@/git"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Skill } from "@/skill"
|
||||
import { Discovery } from "@/skill/discovery"
|
||||
import { Question } from "@/question"
|
||||
import { Permission } from "@/permission"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Session } from "@/session"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionProcessor } from "@/session/processor"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { LSP } from "@/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Observability.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Storage.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Discovery.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionProcessor.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
McpAuth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
)
|
||||
|
||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
@@ -141,7 +141,7 @@ export namespace McpAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export namespace Vcs {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
|
||||
@@ -359,7 +359,7 @@ export namespace Pty {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
|
||||
// @ts-ignore
|
||||
@@ -356,7 +356,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
abort: options.abortSignal!,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: (val) =>
|
||||
@@ -586,7 +586,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
sessionID,
|
||||
abort: signal,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true },
|
||||
extra: { bypassAgentCheck: true, promptOps },
|
||||
messages: msgs,
|
||||
metadata(val: { title?: string; metadata?: Record<string, any> }) {
|
||||
return Effect.runPromise(
|
||||
@@ -1655,6 +1655,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return result
|
||||
})
|
||||
|
||||
const promptOps: TaskPromptOps = {
|
||||
cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
|
||||
prompt: (input) => Effect.runPromise(prompt(input)),
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
cancel,
|
||||
prompt,
|
||||
@@ -1666,7 +1672,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = Layer.suspend(() =>
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Patch } from "../patch"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { trimDiff } from "./edit"
|
||||
import { LSP } from "../lsp"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { Format } from "../format"
|
||||
@@ -19,261 +19,278 @@ const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
async execute(params, ctx) {
|
||||
if (!params.patchText) {
|
||||
throw new Error("patchText is required")
|
||||
}
|
||||
export const ApplyPatchTool = Tool.defineEffect(
|
||||
"apply_patch",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
|
||||
// Parse the patch to get hunks
|
||||
let hunks: Patch.Hunk[]
|
||||
try {
|
||||
const parseResult = Patch.parsePatch(params.patchText)
|
||||
hunks = parseResult.hunks
|
||||
} catch (error) {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
}
|
||||
|
||||
if (hunks.length === 0) {
|
||||
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
throw new Error("patch rejected: empty patch")
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
|
||||
if (!params.patchText) {
|
||||
return yield* Effect.fail(new Error("patchText is required"))
|
||||
}
|
||||
throw new Error("apply_patch verification failed: no hunks found")
|
||||
}
|
||||
|
||||
// Validate file paths and check permissions
|
||||
const fileChanges: Array<{
|
||||
filePath: string
|
||||
oldContent: string
|
||||
newContent: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
diff: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}> = []
|
||||
// Parse the patch to get hunks
|
||||
let hunks: Patch.Hunk[]
|
||||
try {
|
||||
const parseResult = Patch.parsePatch(params.patchText)
|
||||
hunks = parseResult.hunks
|
||||
} catch (error) {
|
||||
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
|
||||
}
|
||||
|
||||
let totalDiff = ""
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
switch (hunk.type) {
|
||||
case "add": {
|
||||
const oldContent = ""
|
||||
const newContent =
|
||||
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
if (hunks.length === 0) {
|
||||
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
return yield* Effect.fail(new Error("patch rejected: empty patch"))
|
||||
}
|
||||
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
|
||||
}
|
||||
|
||||
case "update": {
|
||||
// Check if file exists for update
|
||||
const stats = await fs.stat(filePath).catch(() => null)
|
||||
if (!stats || stats.isDirectory()) {
|
||||
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||
// Validate file paths and check permissions
|
||||
const fileChanges: Array<{
|
||||
filePath: string
|
||||
oldContent: string
|
||||
newContent: string
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
diff: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}> = []
|
||||
|
||||
let totalDiff = ""
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
yield* assertExternalDirectoryEffect(ctx, filePath)
|
||||
|
||||
switch (hunk.type) {
|
||||
case "add": {
|
||||
const oldContent = ""
|
||||
const newContent =
|
||||
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
case "update": {
|
||||
// Check if file exists for update
|
||||
const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!stats || stats.type === "Directory") {
|
||||
return yield* Effect.fail(
|
||||
new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
|
||||
)
|
||||
}
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
} catch (error) {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
const oldContent = yield* afs.readFileString(filePath)
|
||||
let newContent = oldContent
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
} catch (error) {
|
||||
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
|
||||
}
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
}
|
||||
|
||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||
yield* assertExternalDirectoryEffect(ctx, movePath)
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath,
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
|
||||
case "delete": {
|
||||
const contentToDelete = yield* afs
|
||||
.readFileString(filePath)
|
||||
.pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))))
|
||||
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const change of diffLines(oldContent, newContent)) {
|
||||
if (change.added) additions += change.count || 0
|
||||
if (change.removed) deletions += change.count || 0
|
||||
const deletions = contentToDelete.split("\n").length
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
diff: deleteDiff,
|
||||
additions: 0,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
|
||||
await assertExternalDirectory(ctx, movePath)
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath,
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
|
||||
throw new Error(`apply_patch verification failed: ${error}`)
|
||||
})
|
||||
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||
|
||||
const deletions = contentToDelete.split("\n").length
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
diff: deleteDiff,
|
||||
additions: 0,
|
||||
deletions,
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-file metadata for UI rendering (used for both permission and result)
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
|
||||
type: change.type,
|
||||
patch: change.diff,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
// Build per-file metadata for UI rendering (used for both permission and result)
|
||||
const files = fileChanges.map((change) => ({
|
||||
filePath: change.filePath,
|
||||
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
|
||||
type: change.type,
|
||||
patch: change.diff,
|
||||
additions: change.additions,
|
||||
deletions: change.deletions,
|
||||
movePath: change.movePath,
|
||||
}))
|
||||
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
})
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Apply the changes
|
||||
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
|
||||
// Apply the changes
|
||||
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
|
||||
|
||||
for (const change of fileChanges) {
|
||||
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
updates.push({ file: change.filePath, event: "add" })
|
||||
break
|
||||
|
||||
case "update":
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
updates.push({ file: change.filePath, event: "change" })
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
for (const change of fileChanges) {
|
||||
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
|
||||
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
||||
await fs.unlink(change.filePath)
|
||||
|
||||
yield* afs.writeWithDirs(change.filePath, change.newContent)
|
||||
updates.push({ file: change.filePath, event: "add" })
|
||||
break
|
||||
|
||||
case "update":
|
||||
yield* afs.writeWithDirs(change.filePath, change.newContent)
|
||||
updates.push({ file: change.filePath, event: "change" })
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
|
||||
yield* afs.writeWithDirs(change.movePath!, change.newContent)
|
||||
yield* afs.remove(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
updates.push({ file: change.movePath, event: "add" })
|
||||
}
|
||||
break
|
||||
|
||||
case "delete":
|
||||
yield* afs.remove(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
updates.push({ file: change.movePath, event: "add" })
|
||||
}
|
||||
break
|
||||
break
|
||||
}
|
||||
|
||||
case "delete":
|
||||
await fs.unlink(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
break
|
||||
if (edited) {
|
||||
yield* format.file(edited)
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
if (edited) {
|
||||
await Format.file(edited)
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
Bus.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
await Bus.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
await LSP.touchFile(target, true)
|
||||
}
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
|
||||
// Generate output summary
|
||||
const summaryLines = fileChanges.map((change) => {
|
||||
if (change.type === "add") {
|
||||
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
yield* lsp.touchFile(target, true)
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
|
||||
// Generate output summary
|
||||
const summaryLines = fileChanges.map((change) => {
|
||||
if (change.type === "add") {
|
||||
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
|
||||
}
|
||||
const target = change.movePath ?? change.filePath
|
||||
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
|
||||
})
|
||||
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||
|
||||
// Report LSP errors for changed files
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const normalized = AppFileSystem.normalizePath(target)
|
||||
const issues = diagnostics[normalized] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: output,
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
files,
|
||||
diagnostics,
|
||||
},
|
||||
output,
|
||||
}
|
||||
const target = change.movePath ?? change.filePath
|
||||
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
|
||||
})
|
||||
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||
|
||||
// Report LSP errors for changed files
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const normalized = Filesystem.normalizePath(target)
|
||||
const issues = diagnostics[normalized] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: output,
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
files,
|
||||
diagnostics,
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
async execute(params: z.infer<typeof PatchParams>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,156 +1,177 @@
|
||||
import z from "zod"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Process } from "../util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const GrepTool = Tool.define("grep", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.pattern) {
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
await ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
})
|
||||
|
||||
let searchPath = params.path ?? Instance.directory
|
||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const rgPath = await Ripgrep.filepath()
|
||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
args.push(searchPath)
|
||||
|
||||
const proc = Process.spawn([rgPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
abort: ctx.abort,
|
||||
})
|
||||
|
||||
if (!proc.stdout || !proc.stderr) {
|
||||
throw new Error("Process output not available")
|
||||
}
|
||||
|
||||
const output = await text(proc.stdout)
|
||||
const errorOutput = await text(proc.stderr)
|
||||
const exitCode = await proc.exited
|
||||
|
||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||
// Only fail if exit code is 2 AND no output was produced
|
||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== 2) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
const hasErrors = exitCode === 2
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = output.trim().split(/\r?\n/)
|
||||
const matches = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
|
||||
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 limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
}
|
||||
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) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
`(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)")
|
||||
}
|
||||
export const GrepTool = Tool.defineEffect(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: totalMatches,
|
||||
truncated,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The regex pattern to search for in file contents"),
|
||||
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
|
||||
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
|
||||
}),
|
||||
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.pattern) {
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
let searchPath = params.path ?? Instance.directory
|
||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
|
||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
args.push(searchPath)
|
||||
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(rgPath, args, {
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
|
||||
const [output, errorOutput] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
|
||||
const exitCode = yield* handle.exitCode
|
||||
|
||||
return { output, errorOutput, exitCode }
|
||||
}),
|
||||
)
|
||||
|
||||
const { output, errorOutput, exitCode } = result
|
||||
|
||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
|
||||
// Only fail if exit code is 2 AND no output was produced
|
||||
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && exitCode !== 2) {
|
||||
throw new Error(`ripgrep failed: ${errorOutput}`)
|
||||
}
|
||||
|
||||
const hasErrors = exitCode === 2
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = output.trim().split(/\r?\n/)
|
||||
const matches = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue
|
||||
|
||||
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 limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
|
||||
if (finalMatches.length === 0) {
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: { matches: 0, truncated: false },
|
||||
output: "No files found",
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
}
|
||||
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) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
`(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 {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: totalMatches,
|
||||
truncated,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
@@ -91,6 +92,7 @@ export namespace ToolRegistry {
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
| Ripgrep.Service
|
||||
| Format.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -112,6 +114,9 @@ export namespace ToolRegistry {
|
||||
const globtool = yield* GlobTool
|
||||
const writetool = yield* WriteTool
|
||||
const edit = yield* EditTool
|
||||
const greptool = yield* GrepTool
|
||||
const patchtool = yield* ApplyPatchTool
|
||||
const skilltool = yield* SkillTool
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
@@ -173,7 +178,7 @@ export namespace ToolRegistry {
|
||||
bash: Tool.init(bash),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(globtool),
|
||||
grep: Tool.init(GrepTool),
|
||||
grep: Tool.init(greptool),
|
||||
edit: Tool.init(edit),
|
||||
write: Tool.init(writetool),
|
||||
task: Tool.init(task),
|
||||
@@ -181,8 +186,8 @@ export namespace ToolRegistry {
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(websearch),
|
||||
code: Tool.init(codesearch),
|
||||
skill: Tool.init(SkillTool),
|
||||
patch: Tool.init(ApplyPatchTool),
|
||||
skill: Tool.init(skilltool),
|
||||
patch: Tool.init(patchtool),
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
@@ -324,6 +329,7 @@ export namespace ToolRegistry {
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
),
|
||||
|
||||
@@ -1,99 +1,101 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../skill"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.define("skill", async () => {
|
||||
const list = await Skill.available()
|
||||
export const SkillTool = Tool.defineEffect(
|
||||
"skill",
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
: [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
return async () => {
|
||||
const list = await Effect.runPromise(skill.available())
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters: Parameters,
|
||||
async execute(params: z.infer<typeof Parameters>, ctx) {
|
||||
const skill = await Skill.get(params.name)
|
||||
|
||||
if (!skill) {
|
||||
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
await ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const dir = path.dirname(skill.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = await iife(async () => {
|
||||
const arr = []
|
||||
for await (const file of Ripgrep.files({
|
||||
cwd: dir,
|
||||
follow: false,
|
||||
hidden: true,
|
||||
signal: ctx.abort,
|
||||
})) {
|
||||
if (file.includes("SKILL.md")) {
|
||||
continue
|
||||
}
|
||||
arr.push(path.resolve(dir, file))
|
||||
if (arr.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
|
||||
const description =
|
||||
list.length === 0
|
||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
||||
: [
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
||||
"",
|
||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
||||
"",
|
||||
"The following skills provide specialized sets of instructions for particular tasks",
|
||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
||||
"",
|
||||
Skill.fmt(list, { verbose: false }),
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${skill.name}`,
|
||||
output: [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
"",
|
||||
skill.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
dir,
|
||||
},
|
||||
description,
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((s) => s.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||
Stream.map((file) => path.resolve(dir, file)),
|
||||
Stream.take(limit),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||
)
|
||||
|
||||
return {
|
||||
title: `Loaded skill: ${info.name}`,
|
||||
output: [
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# Skill: ${info.name}`,
|
||||
"",
|
||||
info.content.trim(),
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
"Note: file list is sampled.",
|
||||
"",
|
||||
"<skill_files>",
|
||||
files,
|
||||
"</skill_files>",
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
name: info.name,
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,11 +5,17 @@ import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "../config/config"
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export interface TaskPromptOps {
|
||||
cancel(sessionID: SessionID): void
|
||||
resolvePromptParts(template: string): Promise<SessionPrompt.PromptInput["parts"]>
|
||||
prompt(input: SessionPrompt.PromptInput): Promise<MessageV2.WithParts>
|
||||
}
|
||||
|
||||
const id = "task"
|
||||
|
||||
const parameters = z.object({
|
||||
@@ -113,10 +119,13 @@ export const TaskTool = Tool.defineEffect(
|
||||
},
|
||||
})
|
||||
|
||||
const ops = ctx.extra?.promptOps as TaskPromptOps
|
||||
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
|
||||
function cancel() {
|
||||
SessionPrompt.cancel(nextSession.id)
|
||||
ops.cancel(nextSession.id)
|
||||
}
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
@@ -125,9 +134,9 @@ export const TaskTool = Tool.defineEffect(
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
|
||||
const parts = yield* Effect.promise(() => ops.resolvePromptParts(params.prompt))
|
||||
const result = yield* Effect.promise(() =>
|
||||
SessionPrompt.prompt({
|
||||
ops.prompt({
|
||||
messageID,
|
||||
sessionID: nextSession.id,
|
||||
model: {
|
||||
|
||||
@@ -590,7 +590,7 @@ export namespace Worktree {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Truncate } from "../../src/tool/truncate"
|
||||
import { Log } from "../../src/util/log"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
@@ -174,6 +175,7 @@ function makeHttp() {
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Truncate } from "../../src/tool/truncate"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -138,6 +139,7 @@ function makeHttp() {
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Effect, ManagedRuntime, Layer } from "effect"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
@@ -40,7 +46,8 @@ type ToolCtx = typeof baseCtx & {
|
||||
}
|
||||
|
||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const tool = await ApplyPatchTool.init()
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await info.init()
|
||||
return tool.execute(params, ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { GrepTool } from "../../src/tool/grep"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function initGrep() {
|
||||
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -23,7 +31,7 @@ describe("tool.grep", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const grep = await GrepTool.init()
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
@@ -47,7 +55,7 @@ describe("tool.grep", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await GrepTool.init()
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
@@ -72,7 +80,7 @@ describe("tool.grep", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await GrepTool.init()
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -148,7 +150,9 @@ Use this skill.
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tool = await SkillTool.init()
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
|
||||
const info = await runtime.runPromise(SkillTool)
|
||||
const tool = await info.init()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
|
||||
@@ -6,10 +6,10 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import type { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { TaskTool } from "../../src/tool/task"
|
||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -62,6 +62,17 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||
return { chat, assistant }
|
||||
})
|
||||
|
||||
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
|
||||
return {
|
||||
cancel() {},
|
||||
resolvePromptParts: async (template) => [{ type: "text", text: template }],
|
||||
prompt: async (input) => {
|
||||
opts?.onPrompt?.(input)
|
||||
return reply(input, opts?.text ?? "done")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
|
||||
const id = MessageID.ascending()
|
||||
return {
|
||||
@@ -180,21 +191,8 @@ describe("tool.task", () => {
|
||||
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "resumed")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
@@ -209,6 +207,7 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
@@ -232,20 +231,10 @@ describe("tool.task", () => {
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
const calls: unknown[] = []
|
||||
const promptOps = stubOps()
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => reply(input, "done")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
|
||||
const exec = (extra?: Record<string, any>) =>
|
||||
Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
@@ -258,7 +247,7 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra,
|
||||
extra: { promptOps, ...extra },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async (input) => {
|
||||
@@ -292,21 +281,8 @@ describe("tool.task", () => {
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "created")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
@@ -321,6 +297,7 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
@@ -346,21 +323,8 @@ describe("tool.task", () => {
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "done")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
@@ -374,6 +338,7 @@ describe("tool.task", () => {
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
|
||||
Reference in New Issue
Block a user