Merge branch 'dev' into snapshot-node-shim-stuff

This commit is contained in:
Aiden Cline 2026-04-10 19:55:48 -05:00 committed by GitHub
commit 8bce02e567
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1301 additions and 1165 deletions

View file

@ -169,6 +169,8 @@ export namespace Bus {
}), }),
) )
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer) const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,

View 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 })

View file

@ -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) const { runPromise } = makeRuntime(Service, defaultLayer)

View file

@ -226,7 +226,7 @@ export namespace Vcs {
}), }),
) )
const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),

View file

@ -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) const { runPromise } = makeRuntime(Service, defaultLayer)

View file

@ -245,7 +245,7 @@ export namespace SessionProcessor {
case "reasoning-end": case "reasoning-end":
if (!(value.id in ctx.reasoningMap)) return if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd() ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePart(ctx.reasoningMap[value.id]) yield* session.updatePart(ctx.reasoningMap[value.id])
@ -425,7 +425,7 @@ export namespace SessionProcessor {
case "text-end": case "text-end":
if (!ctx.currentText) return if (!ctx.currentText) return
ctx.currentText.text = ctx.currentText.text.trimEnd() ctx.currentText.text = ctx.currentText.text
ctx.currentText.text = (yield* plugin.trigger( ctx.currentText.text = (yield* plugin.trigger(
"experimental.text.complete", "experimental.text.complete",
{ {

View file

@ -46,7 +46,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
// @ts-ignore // @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!, abort: options.abortSignal!,
messageID: input.processor.message.id, messageID: input.processor.message.id,
callID: options.toolCallId, callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
agent: input.agent.name, agent: input.agent.name,
messages: input.messages, messages: input.messages,
metadata: (val) => metadata: (val) =>
@ -586,7 +586,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID, sessionID,
abort: signal, abort: signal,
callID: part.callID, callID: part.callID,
extra: { bypassAgentCheck: true }, extra: { bypassAgentCheck: true, promptOps },
messages: msgs, messages: msgs,
metadata(val: { title?: string; metadata?: Record<string, any> }) { metadata(val: { title?: string; metadata?: Record<string, any> }) {
return Effect.runPromise( 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 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({ return Service.of({
cancel, cancel,
prompt, 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.pipe(
Layer.provide(SessionRunState.defaultLayer), Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer), Layer.provide(SessionStatus.defaultLayer),

View file

@ -1,16 +1,16 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import * as fs from "fs/promises" import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Bus } from "../bus" import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Patch } from "../patch" import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { trimDiff } from "./edit" import { trimDiff } from "./edit"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem" import { AppFileSystem } from "../filesystem"
import DESCRIPTION from "./apply_patch.txt" import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file" import { File } from "../file"
import { Format } from "../format" 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"), patchText: z.string().describe("The full patch text that describes all changes to be made"),
}) })
export const ApplyPatchTool = Tool.define("apply_patch", { export const ApplyPatchTool = Tool.defineEffect(
description: DESCRIPTION, "apply_patch",
parameters: PatchParams, Effect.gen(function* () {
async execute(params, ctx) { const lsp = yield* LSP.Service
if (!params.patchText) { const afs = yield* AppFileSystem.Service
throw new Error("patchText is required") const format = yield* Format.Service
}
// Parse the patch to get hunks const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
let hunks: Patch.Hunk[] if (!params.patchText) {
try { return yield* Effect.fail(new Error("patchText is required"))
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")
} }
throw new Error("apply_patch verification failed: no hunks found")
}
// Validate file paths and check permissions // Parse the patch to get hunks
const fileChanges: Array<{ let hunks: Patch.Hunk[]
filePath: string try {
oldContent: string const parseResult = Patch.parsePatch(params.patchText)
newContent: string hunks = parseResult.hunks
type: "add" | "update" | "delete" | "move" } catch (error) {
movePath?: string return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
diff: string }
additions: number
deletions: number
}> = []
let totalDiff = "" if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
for (const hunk of hunks) { if (normalized === "*** Begin Patch\n*** End Patch") {
const filePath = path.resolve(Instance.directory, hunk.path) return yield* Effect.fail(new Error("patch rejected: empty patch"))
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
} }
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
}
case "update": { // Validate file paths and check permissions
// Check if file exists for update const fileChanges: Array<{
const stats = await fs.stat(filePath).catch(() => null) filePath: string
if (!stats || stats.isDirectory()) { oldContent: string
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) 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") case "update": {
let newContent = oldContent // 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 const oldContent = yield* afs.readFileString(filePath)
try { let newContent = oldContent
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content // Apply the update chunks to get new content
} catch (error) { try {
throw new Error(`apply_patch verification failed: ${error}`) 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 const deletions = contentToDelete.split("\n").length
let deletions = 0
for (const change of diffLines(oldContent, newContent)) { fileChanges.push({
if (change.added) additions += change.count || 0 filePath,
if (change.removed) deletions += change.count || 0 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) // Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({ const files = fileChanges.map((change) => ({
filePath: change.filePath, filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type, type: change.type,
patch: change.diff, patch: change.diff,
additions: change.additions, additions: change.additions,
deletions: change.deletions, deletions: change.deletions,
movePath: change.movePath, movePath: change.movePath,
})) }))
// Check permissions if needed // Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
await ctx.ask({ yield* Effect.promise(() =>
permission: "edit", ctx.ask({
patterns: relativePaths, permission: "edit",
always: ["*"], patterns: relativePaths,
metadata: { always: ["*"],
filepath: relativePaths.join(", "), metadata: {
diff: totalDiff, filepath: relativePaths.join(", "),
files, diff: totalDiff,
}, files,
}) },
}),
)
// Apply the changes // Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
for (const change of fileChanges) { for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) { switch (change.type) {
case "add": 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) {
// Create parent directories (recursive: true is safe on existing/root dirs) // 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") yield* afs.writeWithDirs(change.filePath, change.newContent)
await fs.unlink(change.filePath) 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.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" }) break
} }
break
case "delete": if (edited) {
await fs.unlink(change.filePath) yield* format.file(edited)
updates.push({ file: change.filePath, event: "unlink" }) Bus.publish(File.Event.Edited, { file: edited })
break }
} }
if (edited) { // Publish file change events
await Format.file(edited) for (const update of updates) {
Bus.publish(File.Event.Edited, { file: edited }) Bus.publish(FileWatcher.Event.Updated, update)
} }
}
// Publish file change events // Notify LSP of file changes and collect diagnostics
for (const update of updates) { for (const change of fileChanges) {
await Bus.publish(FileWatcher.Event.Updated, update) if (change.type === "delete") continue
} const target = change.movePath ?? change.filePath
yield* lsp.touchFile(target, true)
// 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("\\", "/")}`
} }
if (change.type === "delete") { const diagnostics = yield* lsp.diagnostics()
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
// 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 { return {
title: output, description: DESCRIPTION,
metadata: { parameters: PatchParams,
diff: totalDiff, async execute(params: z.infer<typeof PatchParams>, ctx) {
files, return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
diagnostics,
}, },
output,
} }
}, }),
}) )

View file

@ -5,6 +5,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
@ -17,7 +18,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_DIAGNOSTICS_PER_FILE = 20
@ -34,136 +35,161 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n") return text.replaceAll("\n", "\r\n")
} }
export const EditTool = Tool.define("edit", { const Parameters = z.object({
description: DESCRIPTION, filePath: z.string().describe("The absolute path to the file to modify"),
parameters: z.object({ oldString: z.string().describe("The text to replace"),
filePath: z.string().describe("The absolute path to the file to modify"), newString: z.string().describe("The text to replace it with (must be different from oldString)"),
oldString: z.string().describe("The text to replace"), replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"), })
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (params.oldString === params.newString) { export const EditTool = Tool.defineEffect(
throw new Error("No changes to apply: oldString and newString are identical.") "edit",
} Effect.gen(function* () {
const lsp = yield* LSP.Service
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) const filetime = yield* FileTime.Service
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
await FileTime.read(ctx.sessionID, filePath)
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
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 this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return { return {
metadata: { description: DESCRIPTION,
diagnostics, parameters: Parameters,
diff, execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
filediff, Effect.gen(function* () {
}, if (!params.filePath) {
title: `${path.relative(Instance.worktree, filePath)}`, throw new Error("filePath is required")
output, }
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(Instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
yield* filetime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
await FileTime.read(ctx.sessionID, filePath)
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
await FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
yield* lsp.touchFile(filePath, true)
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
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 this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return {
metadata: {
diagnostics,
diff,
filediff,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }),
}) )
export type Replacer = (content: string, find: string) => Generator<string, void, unknown> export type Replacer = (content: string, find: string) => Generator<string, void, unknown>

View file

@ -1,156 +1,177 @@
import z from "zod" 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 { Tool } from "./tool"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep" 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 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 { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define("grep", { export const GrepTool = Tool.defineEffect(
description: DESCRIPTION, "grep",
parameters: z.object({ Effect.gen(function* () {
pattern: z.string().describe("The regex pattern to search for in file contents"), const spawner = yield* ChildProcessSpawner
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)")
}
return { return {
title: params.pattern, description: DESCRIPTION,
metadata: { parameters: z.object({
matches: totalMatches, pattern: z.string().describe("The regex pattern to search for in file contents"),
truncated, 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}")'),
output: outputLines.join("\n"), }),
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),
} }
}, }),
}) )

View file

@ -1,10 +1,12 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" 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 { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
export const IGNORE_PATTERNS = [ export const IGNORE_PATTERNS = [
"node_modules/", "node_modules/",
@ -35,87 +37,100 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100 const LIMIT = 100
export const ListTool = Tool.define("list", { export const ListTool = Tool.defineEffect(
description: DESCRIPTION, "list",
parameters: z.object({ Effect.gen(function* () {
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), const rg = yield* Ripgrep.Service
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
files.push(file)
if (files.length >= LIMIT) break
}
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return { return {
title: path.relative(Instance.worktree, searchPath), description: DESCRIPTION,
metadata: { parameters: z.object({
count: files.length, path: z
truncated: files.length >= LIMIT, .string()
}, .describe("The absolute path to the directory to list (must be absolute, not relative)")
output, .optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
Effect.gen(function* () {
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* Effect.promise(() =>
ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
}),
)
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
Stream.take(LIMIT),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }),
}) )

View file

@ -1,46 +1,63 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { EditTool } from "./edit" import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt" import DESCRIPTION from "./multiedit.txt"
import path from "path" import path from "path"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
export const MultiEditTool = Tool.define("multiedit", { export const MultiEditTool = Tool.defineEffect(
description: DESCRIPTION, "multiedit",
parameters: z.object({ Effect.gen(function* () {
filePath: z.string().describe("The absolute path to the file to modify"), const editInfo = yield* EditTool
edits: z const edit = yield* Effect.promise(() => editInfo.init())
.array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const tool = await EditTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
results.push(result)
}
return { return {
title: path.relative(Instance.worktree, params.filePath), description: DESCRIPTION,
metadata: { parameters: z.object({
results: results.map((r) => r.metadata), filePath: z.string().describe("The absolute path to the file to modify"),
}, edits: z
output: results.at(-1)!.output, .array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
execute: (
params: {
filePath: string
edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>
},
ctx: Tool.Context,
) =>
Effect.gen(function* () {
const results = []
for (const [, entry] of params.edits.entries()) {
const result = yield* Effect.promise(() =>
edit.execute(
{
filePath: params.filePath,
oldString: entry.oldString,
newString: entry.newString,
replaceAll: entry.replaceAll,
},
ctx,
),
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }),
}) )

View file

@ -34,6 +34,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@ -91,6 +92,7 @@ export namespace ToolRegistry {
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner | ChildProcessSpawner
| Ripgrep.Service | Ripgrep.Service
| Format.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -110,6 +112,11 @@ export namespace ToolRegistry {
const bash = yield* BashTool const bash = yield* BashTool
const codesearch = yield* CodeSearchTool const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool 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>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@ -171,16 +178,16 @@ export namespace ToolRegistry {
bash: Tool.init(bash), bash: Tool.init(bash),
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(globtool), glob: Tool.init(globtool),
grep: Tool.init(GrepTool), grep: Tool.init(greptool),
edit: Tool.init(EditTool), edit: Tool.init(edit),
write: Tool.init(WriteTool), write: Tool.init(writetool),
task: Tool.init(task), task: Tool.init(task),
fetch: Tool.init(webfetch), fetch: Tool.init(webfetch),
todo: Tool.init(todo), todo: Tool.init(todo),
search: Tool.init(websearch), search: Tool.init(websearch),
code: Tool.init(codesearch), code: Tool.init(codesearch),
skill: Tool.init(SkillTool), skill: Tool.init(skilltool),
patch: Tool.init(ApplyPatchTool), patch: Tool.init(patchtool),
question: Tool.init(question), question: Tool.init(question),
lsp: Tool.init(lsptool), lsp: Tool.init(lsptool),
plan: Tool.init(plan), plan: Tool.init(plan),
@ -322,6 +329,7 @@ export namespace ToolRegistry {
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
), ),

View file

@ -1,99 +1,101 @@
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
const Parameters = z.object({ const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"), name: z.string().describe("The name of the skill from available_skills"),
}) })
export const SkillTool = Tool.define("skill", async () => { export const SkillTool = Tool.defineEffect(
const list = await Skill.available() "skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
const description = return async () => {
list.length === 0 const list = await Effect.runPromise(skill.available())
? "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 { const description =
description, list.length === 0
parameters: Parameters, ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
async execute(params: z.infer<typeof Parameters>, ctx) { : [
const skill = await Skill.get(params.name) "Load a specialized skill that provides domain-specific instructions and workflows.",
"",
if (!skill) { "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
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"}`) "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.',
await ctx.ask({ "",
permission: "skill", "The following skills provide specialized sets of instructions for particular tasks",
patterns: [params.name], "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
always: [params.name], "",
metadata: {}, Skill.fmt(list, { verbose: false }),
}) ].join("\n")
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"))
return { return {
title: `Loaded skill: ${skill.name}`, description,
output: [ parameters: Parameters,
`<skill_content name="${skill.name}">`, execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
`# Skill: ${skill.name}`, Effect.gen(function* () {
"", const info = yield* skill.get(params.name)
skill.content.trim(),
"", if (!info) {
`Base directory for this skill: ${base}`, const all = yield* skill.all()
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", const available = all.map((s) => s.name).join(", ")
"Note: file list is sampled.", throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
"", }
"<skill_files>",
files, yield* Effect.promise(() =>
"</skill_files>", ctx.ask({
"</skill_content>", permission: "skill",
].join("\n"), patterns: [params.name],
metadata: { always: [params.name],
name: skill.name, metadata: {},
dir, }),
}, )
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),
} }
}, }
} }),
}) )

View file

@ -5,11 +5,17 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt" import type { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Effect } from "effect" import { Effect } from "effect"
import { Log } from "@/util/log" 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 id = "task"
const parameters = z.object({ 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() const messageID = MessageID.ascending()
function cancel() { function cancel() {
SessionPrompt.cancel(nextSession.id) ops.cancel(nextSession.id)
} }
return yield* Effect.acquireUseRelease( return yield* Effect.acquireUseRelease(
@ -125,9 +134,9 @@ export const TaskTool = Tool.defineEffect(
}), }),
() => () =>
Effect.gen(function* () { 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(() => const result = yield* Effect.promise(() =>
SessionPrompt.prompt({ ops.prompt({
messageID, messageID,
sessionID: nextSession.id, sessionID: nextSession.id,
model: { model: {

View file

@ -1,5 +1,6 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff" import { createTwoFilesPatch } from "diff"
@ -9,76 +10,94 @@ import { File } from "../file"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Format } from "../format" import { Format } from "../format"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem" import { AppFileSystem } from "../filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { trimDiff } from "./edit" import { trimDiff } from "./edit"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
export const WriteTool = Tool.define("write", { export const WriteTool = Tool.defineEffect(
description: DESCRIPTION, "write",
parameters: z.object({ Effect.gen(function* () {
content: z.string().describe("The content to write to the file"), const lsp = yield* LSP.Service
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), const fs = yield* AppFileSystem.Service
}), const filetime = yield* FileTime.Service
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filepath)
const exists = await Filesystem.exists(filepath)
const contentOld = exists ? await Filesystem.readText(filepath) : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
})
await Filesystem.write(filepath, params.content)
await Format.file(filepath)
Bus.publish(File.Event.Edited, { file: filepath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
})
await FileTime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
await LSP.touchFile(filepath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilepath = Filesystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) continue
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` : ""
if (file === normalizedFilepath) {
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
continue
}
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
projectDiagnosticsCount++
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return { return {
title: path.relative(Instance.worktree, filepath), description: DESCRIPTION,
metadata: { parameters: z.object({
diagnostics, content: z.string().describe("The content to write to the file"),
filepath, filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
exists: exists, }),
}, execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
output, Effect.gen(function* () {
const filepath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(Instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filepath)
const exists = yield* fs.existsSafe(filepath)
const contentOld = exists ? yield* fs.readFileString(filepath) : ""
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
yield* Effect.promise(() =>
ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
always: ["*"],
metadata: {
filepath,
diff,
},
}),
)
yield* fs.writeWithDirs(filepath, params.content)
yield* Effect.promise(() => Format.file(filepath))
Bus.publish(File.Event.Edited, { file: filepath })
yield* Effect.promise(() =>
Bus.publish(FileWatcher.Event.Updated, {
file: filepath,
event: exists ? "change" : "add",
}),
)
yield* filetime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
yield* lsp.touchFile(filepath, true)
const diagnostics = yield* lsp.diagnostics()
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
for (const [file, issues] of Object.entries(diagnostics)) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) continue
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`
: ""
if (file === normalizedFilepath) {
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
continue
}
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
projectDiagnosticsCount++
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
return {
title: path.relative(Instance.worktree, filepath),
metadata: {
diagnostics,
filepath,
exists: exists,
},
output,
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }),
}) )

View file

@ -590,7 +590,7 @@ export namespace Worktree {
}), }),
) )
const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),

View file

@ -38,6 +38,7 @@ import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server" import { reply, TestLLMServer } from "../lib/llm-server"
@ -174,6 +175,7 @@ function makeHttp() {
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),

View file

@ -54,6 +54,7 @@ import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
Log.init({ print: false }) Log.init({ print: false })
@ -138,6 +139,7 @@ function makeHttp() {
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),

View file

@ -1,11 +1,17 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import * as fs from "fs/promises" import * as fs from "fs/promises"
import { Effect, ManagedRuntime, Layer } from "effect"
import { ApplyPatchTool } from "../../src/tool/apply_patch" import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance" 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 { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
const baseCtx = { const baseCtx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""), messageID: MessageID.make(""),
@ -40,7 +46,8 @@ type ToolCtx = typeof baseCtx & {
} }
const execute = async (params: { patchText: string }, ctx: ToolCtx) => { 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) return tool.execute(params, ctx)
} }

View file

@ -1,10 +1,12 @@
import { afterEach, describe, test, expect } from "bun:test" import { afterAll, afterEach, describe, test, expect } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Effect, Layer, ManagedRuntime } from "effect"
import { EditTool } from "../../src/tool/edit" import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@ -27,6 +29,20 @@ async function touch(file: string, time: number) {
await fs.utimes(file, date, date) await fs.utimes(file, date, date)
} }
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer))
afterAll(async () => {
await runtime.dispose()
})
const resolve = () =>
runtime.runPromise(
Effect.gen(function* () {
const info = yield* EditTool
return yield* Effect.promise(() => info.init())
}),
)
describe("tool.edit", () => { describe("tool.edit", () => {
describe("creating new files", () => { describe("creating new files", () => {
test("creates new file when oldString is empty", async () => { test("creates new file when oldString is empty", async () => {
@ -36,7 +52,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
const result = await edit.execute( const result = await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -61,7 +77,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -91,7 +107,7 @@ describe("tool.edit", () => {
const events: string[] = [] const events: string[] = []
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -119,7 +135,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
const result = await edit.execute( const result = await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -146,7 +162,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -169,7 +185,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -194,7 +210,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -217,7 +233,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -249,7 +265,7 @@ describe("tool.edit", () => {
await touch(filepath, 2_000) await touch(filepath, 2_000)
// Try to edit with the new content // Try to edit with the new content
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -274,7 +290,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -307,7 +323,7 @@ describe("tool.edit", () => {
const events: string[] = [] const events: string[] = []
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -335,7 +351,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -361,7 +377,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -385,7 +401,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -410,7 +426,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, dirpath) await FileTime.read(ctx.sessionID, dirpath)
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@ -435,7 +451,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
const result = await edit.execute( const result = await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@ -502,7 +518,7 @@ describe("tool.edit", () => {
return await Instance.provide({ return await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
const filePath = path.join(tmp.path, "test.txt") const filePath = path.join(tmp.path, "test.txt")
await FileTime.read(ctx.sessionID, filePath) await FileTime.read(ctx.sessionID, filePath)
await edit.execute( await edit.execute(
@ -647,7 +663,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
// Two concurrent edits // Two concurrent edits
const promise1 = edit.execute( const promise1 = edit.execute(

View file

@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
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"
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 = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@ -23,7 +31,7 @@ describe("tool.grep", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const grep = await GrepTool.init() const grep = await initGrep()
const result = await grep.execute( const result = await grep.execute(
{ {
pattern: "export", pattern: "export",
@ -47,7 +55,7 @@ describe("tool.grep", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const grep = await GrepTool.init() const grep = await initGrep()
const result = await grep.execute( const result = await grep.execute(
{ {
pattern: "xyznonexistentpatternxyz123", pattern: "xyznonexistentpatternxyz123",
@ -72,7 +80,7 @@ describe("tool.grep", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const grep = await GrepTool.init() const grep = await initGrep()
const result = await grep.execute( const result = await grep.execute(
{ {
pattern: "line", pattern: "line",

View file

@ -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 { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
@ -148,7 +150,9 @@ Use this skill.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { 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 requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,

View file

@ -6,10 +6,10 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" 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 { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/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 { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -62,6 +62,17 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
return { chat, assistant } 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 { function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
const id = MessageID.ascending() const id = MessageID.ascending()
return { return {
@ -180,21 +191,8 @@ describe("tool.task", () => {
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts let seen: SessionPrompt.PromptInput | undefined
const prompt = SessionPrompt.prompt const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
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
}),
)
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
def.execute( def.execute(
@ -209,6 +207,7 @@ describe("tool.task", () => {
messageID: assistant.id, messageID: assistant.id,
agent: "build", agent: "build",
abort: new AbortController().signal, abort: new AbortController().signal,
extra: { promptOps },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async () => {}, ask: async () => {},
@ -232,20 +231,10 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
const calls: unknown[] = [] const calls: unknown[] = []
const promptOps = stubOps()
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }] const exec = (extra?: Record<string, any>) =>
SessionPrompt.prompt = async (input) => reply(input, "done")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
Effect.promise(() => Effect.promise(() =>
def.execute( def.execute(
{ {
@ -258,7 +247,7 @@ describe("tool.task", () => {
messageID: assistant.id, messageID: assistant.id,
agent: "build", agent: "build",
abort: new AbortController().signal, abort: new AbortController().signal,
extra, extra: { promptOps, ...extra },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async (input) => { ask: async (input) => {
@ -292,21 +281,8 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts let seen: SessionPrompt.PromptInput | undefined
const prompt = SessionPrompt.prompt const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
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
}),
)
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
def.execute( def.execute(
@ -321,6 +297,7 @@ describe("tool.task", () => {
messageID: assistant.id, messageID: assistant.id,
agent: "build", agent: "build",
abort: new AbortController().signal, abort: new AbortController().signal,
extra: { promptOps },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async () => {}, ask: async () => {},
@ -346,21 +323,8 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts let seen: SessionPrompt.PromptInput | undefined
const prompt = SessionPrompt.prompt const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
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
}),
)
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
def.execute( def.execute(
@ -374,6 +338,7 @@ describe("tool.task", () => {
messageID: assistant.id, messageID: assistant.id,
agent: "build", agent: "build",
abort: new AbortController().signal, abort: new AbortController().signal,
extra: { promptOps },
messages: [], messages: [],
metadata() {}, metadata() {},
ask: async () => {}, ask: async () => {},

View file

@ -1,10 +1,17 @@
import { afterEach, describe, test, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write" import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time"
import { Tool } from "../../src/tool/tool"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test-write-session"), sessionID: SessionID.make("ses_test-write-session"),
@ -21,333 +28,216 @@ afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
const it = testEffect(
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
)
const init = Effect.fn("WriteToolTest.init")(function* () {
const info = yield* WriteTool
return yield* Effect.promise(() => info.init())
})
const run = Effect.fn("WriteToolTest.run")(function* (
args: Tool.InferParameters<typeof WriteTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* Effect.promise(() => tool.execute(args, next))
})
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
const ft = yield* FileTime.Service
yield* ft.read(sessionID as any, filepath)
})
describe("tool.write", () => { describe("tool.write", () => {
describe("new file creation", () => { describe("new file creation", () => {
test("writes content to new file", async () => { it.live("writes content to new file", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "newfile.txt") Effect.gen(function* () {
const filepath = path.join(dir, "newfile.txt")
await Instance.provide({ const result = yield* run({ filePath: filepath, content: "Hello, World!" })
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "Hello, World!",
},
ctx,
)
expect(result.output).toContain("Wrote file successfully") expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false) expect(result.metadata.exists).toBe(false)
const content = await fs.readFile(filepath, "utf-8") const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("Hello, World!") expect(content).toBe("Hello, World!")
}, }),
}) ),
}) )
test("creates parent directories if needed", async () => { it.live("creates parent directories if needed", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "nested", "deep", "file.txt") Effect.gen(function* () {
const filepath = path.join(dir, "nested", "deep", "file.txt")
yield* run({ filePath: filepath, content: "nested content" })
await Instance.provide({ const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: "nested content",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested content") expect(content).toBe("nested content")
}, }),
}) ),
}) )
test("handles relative paths by resolving to instance directory", async () => { it.live("handles relative paths by resolving to instance directory", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* run({ filePath: "relative.txt", content: "relative content" })
await Instance.provide({ const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8"))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: "relative.txt",
content: "relative content",
},
ctx,
)
const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
expect(content).toBe("relative content") expect(content).toBe("relative content")
}, }),
}) ),
}) )
}) })
describe("existing file overwrite", () => { describe("existing file overwrite", () => {
test("overwrites existing file content", async () => { it.live("overwrites existing file content", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "existing.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "old content", "utf-8") const filepath = path.join(dir, "existing.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
yield* markRead(ctx.sessionID, filepath)
// First read the file to satisfy FileTime requirement const result = yield* run({ filePath: filepath, content: "new content" })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
await FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "new content",
},
ctx,
)
expect(result.output).toContain("Wrote file successfully") expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(true) expect(result.metadata.exists).toBe(true)
const content = await fs.readFile(filepath, "utf-8") const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("new content") expect(content).toBe("new content")
}, }),
}) ),
}) )
test("returns diff in metadata for existing files", async () => { it.live("returns diff in metadata for existing files", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "file.txt") Effect.gen(function* () {
await fs.writeFile(filepath, "old", "utf-8") const filepath = path.join(dir, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
yield* markRead(ctx.sessionID, filepath)
await Instance.provide({ const result = yield* run({ filePath: filepath, content: "new" })
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
await FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "new",
},
ctx,
)
// Diff should be in metadata
expect(result.metadata).toHaveProperty("filepath", filepath) expect(result.metadata).toHaveProperty("filepath", filepath)
expect(result.metadata).toHaveProperty("exists", true) expect(result.metadata).toHaveProperty("exists", true)
}, }),
}) ),
}) )
}) })
describe("file permissions", () => { describe("file permissions", () => {
test("sets file permissions when writing sensitive data", async () => { it.live("sets file permissions when writing sensitive data", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "sensitive.json") Effect.gen(function* () {
const filepath = path.join(dir, "sensitive.json")
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: JSON.stringify({ secret: "data" }),
},
ctx,
)
// On Unix systems, check permissions
if (process.platform !== "win32") { if (process.platform !== "win32") {
const stats = await fs.stat(filepath) const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.mode & 0o777).toBe(0o644) expect(stats.mode & 0o777).toBe(0o644)
} }
}, }),
}) ),
}) )
}) })
describe("content types", () => { describe("content types", () => {
test("writes JSON content", async () => { it.live("writes JSON content", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "data.json") Effect.gen(function* () {
const data = { key: "value", nested: { array: [1, 2, 3] } } const filepath = path.join(dir, "data.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
await Instance.provide({ const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: JSON.stringify(data, null, 2),
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(JSON.parse(content)).toEqual(data) expect(JSON.parse(content)).toEqual(data)
}, }),
}) ),
}) )
test("writes binary-safe content", async () => { it.live("writes binary-safe content", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "binary.bin") Effect.gen(function* () {
const content = "Hello\x00World\x01\x02\x03" const filepath = path.join(dir, "binary.bin")
const content = "Hello\x00World\x01\x02\x03"
yield* run({ filePath: filepath, content })
await Instance.provide({ const buf = yield* Effect.promise(() => fs.readFile(filepath))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content,
},
ctx,
)
const buf = await fs.readFile(filepath)
expect(buf.toString()).toBe(content) expect(buf.toString()).toBe(content)
}, }),
}) ),
}) )
test("writes empty content", async () => { it.live("writes empty content", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "empty.txt") Effect.gen(function* () {
const filepath = path.join(dir, "empty.txt")
yield* run({ filePath: filepath, content: "" })
await Instance.provide({ const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: "",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("") expect(content).toBe("")
const stats = await fs.stat(filepath) const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.size).toBe(0) expect(stats.size).toBe(0)
}, }),
}) ),
}) )
test("writes multi-line content", async () => { it.live("writes multi-line content", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "multiline.txt") Effect.gen(function* () {
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") const filepath = path.join(dir, "multiline.txt")
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
yield* run({ filePath: filepath, content: lines })
await Instance.provide({ const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: lines,
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe(lines) expect(content).toBe(lines)
}, }),
}) ),
}) )
test("handles different line endings", async () => { it.live("handles different line endings", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "crlf.txt") Effect.gen(function* () {
const content = "Line 1\r\nLine 2\r\nLine 3" const filepath = path.join(dir, "crlf.txt")
const content = "Line 1\r\nLine 2\r\nLine 3"
yield* run({ filePath: filepath, content })
await Instance.provide({ const buf = yield* Effect.promise(() => fs.readFile(filepath))
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content,
},
ctx,
)
const buf = await fs.readFile(filepath)
expect(buf.toString()).toBe(content) expect(buf.toString()).toBe(content)
}, }),
}) ),
}) )
}) })
describe("error handling", () => { describe("error handling", () => {
test("throws error when OS denies write access", async () => { it.live("throws error when OS denies write access", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const readonlyPath = path.join(tmp.path, "readonly.txt") Effect.gen(function* () {
const readonlyPath = path.join(dir, "readonly.txt")
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
yield* markRead(ctx.sessionID, readonlyPath)
// Create a read-only file const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
await fs.writeFile(readonlyPath, "test", "utf-8") expect(exit._tag).toBe("Failure")
await fs.chmod(readonlyPath, 0o444) }),
),
await Instance.provide({ )
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
await FileTime.read(ctx.sessionID, readonlyPath)
const write = await WriteTool.init()
await expect(
write.execute(
{
filePath: readonlyPath,
content: "new content",
},
ctx,
),
).rejects.toThrow()
},
})
})
}) })
describe("title generation", () => { describe("title generation", () => {
test("returns relative path as title", async () => { it.live("returns relative path as title", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
const filepath = path.join(tmp.path, "src", "components", "Button.tsx") Effect.gen(function* () {
await fs.mkdir(path.dirname(filepath), { recursive: true }) const filepath = path.join(dir, "src", "components", "Button.tsx")
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "export const Button = () => {}",
},
ctx,
)
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
}, }),
}) ),
}) )
}) })
}) })

View file

@ -213,7 +213,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiMo V2 Pro Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
- MiMo V2 Omni Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiMo V2 Omni Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
- Qwen3.6 Plus Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Qwen3.6 Plus Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
- Nemotron 3 Super Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية.
- OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -225,7 +225,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke:
- MiMo V2 Pro Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiMo V2 Pro Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
- MiMo V2 Omni Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiMo V2 Omni Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
- Qwen3.6 Plus Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Qwen3.6 Plus Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
- Nemotron 3 Super Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke.
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -223,7 +223,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti
- MiMo V2 Pro Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiMo V2 Pro Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
- MiMo V2 Omni Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiMo V2 Omni Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
- Qwen3.6 Plus Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Qwen3.6 Plus Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
- Nemotron 3 Super Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data.
- OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -209,7 +209,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer
- MiMo V2 Pro Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiMo V2 Pro Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
- MiMo V2 Omni Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiMo V2 Omni Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
- Qwen3.6 Plus Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Qwen3.6 Plus Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
- Nemotron 3 Super Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten.
- OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert.
- Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert. - Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert.

View file

@ -223,7 +223,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po
- MiMo V2 Pro Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiMo V2 Pro Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
- MiMo V2 Omni Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiMo V2 Omni Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
- Qwen3.6 Plus Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Qwen3.6 Plus Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
- Nemotron 3 Super Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales.
- OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -209,7 +209,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique
- MiMo V2 Pro Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiMo V2 Pro Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
- MiMo V2 Omni Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiMo V2 Omni Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
- Qwen3.6 Plus Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Qwen3.6 Plus Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
- Nemotron 3 Super Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles.
- OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -223,7 +223,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol
- MiMo V2 Pro Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiMo V2 Pro Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
- MiMo V2 Omni Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiMo V2 Omni Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
- Qwen3.6 Plus Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Qwen3.6 Plus Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
- Nemotron 3 Super Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati.
- OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiMo V2 Pro Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
- MiMo V2 Omni Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiMo V2 Omni Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
- Qwen3.6 Plus Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Qwen3.6 Plus Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
- Nemotron 3 Super Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります - Nemotron 3 Super FreeNVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください
- OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。
- Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。 - Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。

View file

@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiMo V2 Pro Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
- MiMo V2 Omni Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiMo V2 Omni Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
- Qwen3.6 Plus Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Qwen3.6 Plus Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
- Nemotron 3 Super Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요.
- OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다.
- Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다. - Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다.

View file

@ -223,7 +223,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer
- MiMo V2 Pro Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiMo V2 Pro Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
- MiMo V2 Omni Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiMo V2 Omni Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
- Qwen3.6 Plus Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Qwen3.6 Plus Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
- Nemotron 3 Super Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data.
- OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -224,7 +224,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero
- MiMo V2 Pro Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiMo V2 Pro Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
- MiMo V2 Omni Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiMo V2 Omni Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
- Qwen3.6 Plus Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Qwen3.6 Plus Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
- Nemotron 3 Super Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych.
- OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -209,7 +209,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol
- MiMo V2 Pro Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiMo V2 Pro Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
- MiMo V2 Omni Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiMo V2 Omni Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
- Qwen3.6 Plus Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Qwen3.6 Plus Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
- Nemotron 3 Super Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais.
- OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -223,7 +223,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiMo V2 Pro Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
- MiMo V2 Omni Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiMo V2 Omni Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
- Qwen3.6 Plus Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Qwen3.6 Plus Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
- Nemotron 3 Super Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные.
- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -211,7 +211,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiMo V2 Pro Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
- MiMo V2 Omni Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiMo V2 Omni Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
- Qwen3.6 Plus Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Qwen3.6 Plus Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
- Nemotron 3 Super Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ.
- OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -209,7 +209,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention
- MiMo V2 Pro Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiMo V2 Pro Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
- MiMo V2 Omni Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiMo V2 Omni Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
- Qwen3.6 Plus Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Qwen3.6 Plus Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
- Nemotron 3 Super Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin.
- OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır.
- Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır. - Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır.

View file

@ -215,7 +215,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
- Big Pickle: During its free period, collected data may be used to improve the model. - Big Pickle: During its free period, collected data may be used to improve the model.
- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
- Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model. - Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model.
- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model. - Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data.
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).

View file

@ -209,7 +209,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free在免费期间收集的数据可能会被用于改进模型。 - MiMo V2 Pro Free在免费期间收集的数据可能会被用于改进模型。
- MiMo V2 Omni Free在免费期间收集的数据可能会被用于改进模型。 - MiMo V2 Omni Free在免费期间收集的数据可能会被用于改进模型。
- Qwen3.6 Plus Free在免费期间收集的数据可能会被用于改进模型。 - Qwen3.6 Plus Free在免费期间收集的数据可能会被用于改进模型。
- Nemotron 3 Super Free:在免费期间,收集的数据可能会被用于改进模型 - Nemotron 3 Super FreeNVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据
- OpenAI APIs请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - OpenAI APIs请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
- Anthropic APIs请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 - Anthropic APIs请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。

View file

@ -216,7 +216,7 @@ https://opencode.ai/zen/v1/models
- MiMo V2 Pro Free: 在免費期間,收集到的資料可能會用於改進模型。 - MiMo V2 Pro Free: 在免費期間,收集到的資料可能會用於改進模型。
- MiMo V2 Omni Free: 在免費期間,收集到的資料可能會用於改進模型。 - MiMo V2 Omni Free: 在免費期間,收集到的資料可能會用於改進模型。
- Qwen3.6 Plus Free: 在免費期間,收集到的資料可能會用於改進模型。 - Qwen3.6 Plus Free: 在免費期間,收集到的資料可能會用於改進模型。
- Nemotron 3 Super Free: 在免費期間,收集到的資料可能會用於改進模型 - Nemotron 3 Super FreeNVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料
- OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
- Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 - Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。