Jelajahi Sumber

feat: unwrap tool namespaces to flat exports + barrel (#22762)

Kit Langton 4 bulan lalu
induk
melakukan
5011465c81
48 mengubah file dengan 578 tambahan dan 581 penghapusan
  1. 1 1
      packages/opencode/src/agent/agent.ts
  2. 1 1
      packages/opencode/src/cli/cmd/debug/agent.ts
  3. 1 1
      packages/opencode/src/cli/cmd/run.ts
  4. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  5. 2 2
      packages/opencode/src/effect/app-runtime.ts
  6. 1 1
      packages/opencode/src/server/instance/experimental.ts
  7. 3 3
      packages/opencode/src/session/prompt.ts
  8. 1 1
      packages/opencode/src/tool/apply_patch.ts
  9. 2 2
      packages/opencode/src/tool/bash.ts
  10. 1 1
      packages/opencode/src/tool/codesearch.ts
  11. 1 1
      packages/opencode/src/tool/edit.ts
  12. 1 1
      packages/opencode/src/tool/external-directory.ts
  13. 1 1
      packages/opencode/src/tool/glob.ts
  14. 1 1
      packages/opencode/src/tool/grep.ts
  15. 3 0
      packages/opencode/src/tool/index.ts
  16. 1 1
      packages/opencode/src/tool/invalid.ts
  17. 1 1
      packages/opencode/src/tool/lsp.ts
  18. 1 1
      packages/opencode/src/tool/multiedit.ts
  19. 1 1
      packages/opencode/src/tool/plan.ts
  20. 1 1
      packages/opencode/src/tool/question.ts
  21. 1 1
      packages/opencode/src/tool/read.ts
  22. 267 269
      packages/opencode/src/tool/registry.ts
  23. 1 1
      packages/opencode/src/tool/skill.ts
  24. 1 1
      packages/opencode/src/tool/task.ts
  25. 1 1
      packages/opencode/src/tool/todo.ts
  26. 124 126
      packages/opencode/src/tool/tool.ts
  27. 126 128
      packages/opencode/src/tool/truncate.ts
  28. 1 1
      packages/opencode/src/tool/webfetch.ts
  29. 1 1
      packages/opencode/src/tool/websearch.ts
  30. 1 1
      packages/opencode/src/tool/write.ts
  31. 4 4
      packages/opencode/test/agent/agent.test.ts
  32. 2 2
      packages/opencode/test/session/prompt-effect.test.ts
  33. 2 2
      packages/opencode/test/session/snapshot-tool-race.test.ts
  34. 1 1
      packages/opencode/test/tool/apply_patch.test.ts
  35. 1 1
      packages/opencode/test/tool/bash.test.ts
  36. 1 1
      packages/opencode/test/tool/edit.test.ts
  37. 1 1
      packages/opencode/test/tool/external-directory.test.ts
  38. 1 1
      packages/opencode/test/tool/glob.test.ts
  39. 1 1
      packages/opencode/test/tool/grep.test.ts
  40. 1 1
      packages/opencode/test/tool/question.test.ts
  41. 2 2
      packages/opencode/test/tool/read.test.ts
  42. 1 1
      packages/opencode/test/tool/registry.test.ts
  43. 2 2
      packages/opencode/test/tool/skill.test.ts
  44. 2 2
      packages/opencode/test/tool/task.test.ts
  45. 2 2
      packages/opencode/test/tool/tool-define.test.ts
  46. 1 1
      packages/opencode/test/tool/truncation.test.ts
  47. 1 1
      packages/opencode/test/tool/webfetch.test.ts
  48. 2 2
      packages/opencode/test/tool/write.test.ts

+ 1 - 1
packages/opencode/src/agent/agent.ts

@@ -4,7 +4,7 @@ import { Provider } from "../provider"
 import { ModelID, ProviderID } from "../provider/schema"
 import { generateObject, streamObject, type ModelMessage } from "ai"
 import { Instance } from "../project/instance"
-import { Truncate } from "../tool/truncate"
+import { Truncate } from "../tool"
 import { Auth } from "../auth"
 import { ProviderTransform } from "../provider/transform"
 

+ 1 - 1
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -6,7 +6,7 @@ import { Provider } from "../../../provider"
 import { Session } from "../../../session"
 import type { MessageV2 } from "../../../session/message-v2"
 import { MessageID, PartID } from "../../../session/schema"
-import { ToolRegistry } from "../../../tool/registry"
+import { ToolRegistry } from "../../../tool"
 import { Instance } from "../../../project/instance"
 import { Permission } from "../../../permission"
 import { iife } from "../../../util/iife"

+ 1 - 1
packages/opencode/src/cli/cmd/run.ts

@@ -12,7 +12,7 @@ import { Server } from "../../server/server"
 import { Provider } from "../../provider"
 import { Agent } from "../../agent/agent"
 import { Permission } from "../../permission"
-import { Tool } from "../../tool/tool"
+import { Tool } from "../../tool"
 import { GlobTool } from "../../tool/glob"
 import { GrepTool } from "../../tool/grep"
 import { ReadTool } from "../../tool/read"

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -34,7 +34,7 @@ import type {
 } from "@opencode-ai/sdk/v2"
 import { useLocal } from "@tui/context/local"
 import { Locale } from "@/util"
-import type { Tool } from "@/tool/tool"
+import type { Tool } from "@/tool"
 import type { ReadTool } from "@/tool/read"
 import type { WriteTool } from "@/tool/write"
 import { BashTool } from "@/tool/bash"

+ 2 - 2
packages/opencode/src/effect/app-runtime.ts

@@ -37,8 +37,8 @@ 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 { Truncate } from "@/tool"
+import { ToolRegistry } from "@/tool"
 import { Format } from "@/format"
 import { Project } from "@/project"
 import { Vcs } from "@/project"

+ 1 - 1
packages/opencode/src/server/instance/experimental.ts

@@ -2,7 +2,7 @@ import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
 import { ProviderID, ModelID } from "../../provider/schema"
-import { ToolRegistry } from "../../tool/registry"
+import { ToolRegistry } from "../../tool"
 import { Worktree } from "../../worktree"
 import { Instance } from "../../project/instance"
 import { Project } from "../../project"

+ 3 - 3
packages/opencode/src/session/prompt.ts

@@ -19,7 +19,7 @@ import { Plugin } from "../plugin"
 import PROMPT_PLAN from "../session/prompt/plan.txt"
 import BUILD_SWITCH from "../session/prompt/build-switch.txt"
 import MAX_STEPS from "../session/prompt/max-steps.txt"
-import { ToolRegistry } from "../tool/registry"
+import { ToolRegistry } from "../tool"
 import { MCP } from "../mcp"
 import { LSP } from "../lsp"
 import { FileTime } from "../file/time"
@@ -34,13 +34,13 @@ import { ConfigMarkdown } from "../config"
 import { SessionSummary } from "./summary"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { SessionProcessor } from "./processor"
-import { Tool } from "@/tool/tool"
+import { Tool } from "@/tool"
 import { Permission } from "@/permission"
 import { SessionStatus } from "./status"
 import { LLM } from "./llm"
 import { Shell } from "@/shell/shell"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Truncate } from "@/tool/truncate"
+import { Truncate } from "@/tool"
 import { decodeDataUrl } from "@/util/data-url"
 import { Process } from "@/util"
 import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"

+ 1 - 1
packages/opencode/src/tool/apply_patch.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import * as path from "path"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { Bus } from "../bus"
 import { FileWatcher } from "../file/watcher"
 import { Instance } from "../project/instance"

+ 2 - 2
packages/opencode/src/tool/bash.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import os from "os"
 import { createWriteStream } from "node:fs"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import path from "path"
 import DESCRIPTION from "./bash.txt"
 import { Log } from "../util"
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag"
 import { Shell } from "@/shell/shell"
 
 import { BashArity } from "@/permission/arity"
-import { Truncate } from "./truncate"
+import * as Truncate from "./truncate"
 import { Plugin } from "@/plugin"
 import { Effect, Stream } from "effect"
 import { ChildProcess } from "effect/unstable/process"

+ 1 - 1
packages/opencode/src/tool/codesearch.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import { Effect } from "effect"
 import { HttpClient } from "effect/unstable/http"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./codesearch.txt"
 

+ 1 - 1
packages/opencode/src/tool/edit.ts

@@ -6,7 +6,7 @@
 import z from "zod"
 import * as path from "path"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch, diffLines } from "diff"
 import DESCRIPTION from "./edit.txt"

+ 1 - 1
packages/opencode/src/tool/external-directory.ts

@@ -2,7 +2,7 @@ import path from "path"
 import { Effect } from "effect"
 import { EffectLogger } from "@/effect"
 import { InstanceState } from "@/effect"
-import type { Tool } from "./tool"
+import type * as Tool from "./tool"
 import { Instance } from "../project/instance"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 

+ 1 - 1
packages/opencode/src/tool/glob.ts

@@ -7,7 +7,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Ripgrep } from "../file/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 
 export const GlobTool = Tool.define(
   "glob",

+ 1 - 1
packages/opencode/src/tool/grep.ts

@@ -6,7 +6,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Ripgrep } from "../file/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./grep.txt"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 
 const MAX_LINE_LENGTH = 2000
 

+ 3 - 0
packages/opencode/src/tool/index.ts

@@ -0,0 +1,3 @@
+export * as Truncate from "./truncate"
+export * as ToolRegistry from "./registry"
+export * as Tool from "./tool"

+ 1 - 1
packages/opencode/src/tool/invalid.ts

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 
 export const InvalidTool = Tool.define(
   "invalid",

+ 1 - 1
packages/opencode/src/tool/lsp.ts

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import path from "path"
 import { LSP } from "../lsp"
 import DESCRIPTION from "./lsp.txt"

+ 1 - 1
packages/opencode/src/tool/multiedit.ts

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { EditTool } from "./edit"
 import DESCRIPTION from "./multiedit.txt"
 import path from "path"

+ 1 - 1
packages/opencode/src/tool/plan.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import path from "path"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { Question } from "../question"
 import { Session } from "../session"
 import { MessageV2 } from "../session/message-v2"

+ 1 - 1
packages/opencode/src/tool/question.ts

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { Question } from "../question"
 import DESCRIPTION from "./question.txt"
 

+ 1 - 1
packages/opencode/src/tool/read.ts

@@ -4,7 +4,7 @@ import { createReadStream } from "fs"
 import { open } from "fs/promises"
 import * as path from "path"
 import { createInterface } from "readline"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { LSP } from "../lsp"
 import { FileTime } from "../file/time"

+ 267 - 269
packages/opencode/src/tool/registry.ts

@@ -12,7 +12,7 @@ import { WebFetchTool } from "./webfetch"
 import { WriteTool } from "./write"
 import { InvalidTool } from "./invalid"
 import { SkillTool } from "./skill"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { Config } from "../config"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
 import z from "zod"
@@ -24,7 +24,7 @@ import { CodeSearchTool } from "./codesearch"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util"
 import { LspTool } from "./lsp"
-import { Truncate } from "./truncate"
+import * as Truncate from "./truncate"
 import { ApplyPatchTool } from "./apply_patch"
 import { Glob } from "@opencode-ai/shared/util/glob"
 import path from "path"
@@ -47,299 +47,297 @@ import { Agent } from "../agent/agent"
 import { Skill } from "../skill"
 import { Permission } from "@/permission"
 
-export namespace ToolRegistry {
-  const log = Log.create({ service: "tool.registry" })
+const log = Log.create({ service: "tool.registry" })
 
-  type TaskDef = Tool.InferDef<typeof TaskTool>
-  type ReadDef = Tool.InferDef<typeof ReadTool>
+type TaskDef = Tool.InferDef<typeof TaskTool>
+type ReadDef = Tool.InferDef<typeof ReadTool>
 
-  type State = {
-    custom: Tool.Def[]
-    builtin: Tool.Def[]
-    task: TaskDef
-    read: ReadDef
-  }
+type State = {
+  custom: Tool.Def[]
+  builtin: Tool.Def[]
+  task: TaskDef
+  read: ReadDef
+}
 
-  export interface Interface {
-    readonly ids: () => Effect.Effect<string[]>
-    readonly all: () => Effect.Effect<Tool.Def[]>
-    readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
-    readonly tools: (model: {
-      providerID: ProviderID
-      modelID: ModelID
-      agent: Agent.Info
-    }) => Effect.Effect<Tool.Def[]>
-  }
+export interface Interface {
+  readonly ids: () => Effect.Effect<string[]>
+  readonly all: () => Effect.Effect<Tool.Def[]>
+  readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
+  readonly tools: (model: {
+    providerID: ProviderID
+    modelID: ModelID
+    agent: Agent.Info
+  }) => Effect.Effect<Tool.Def[]>
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
 
-  export const layer: Layer.Layer<
-    Service,
-    never,
-    | Config.Service
-    | Plugin.Service
-    | Question.Service
-    | Todo.Service
-    | Agent.Service
-    | Skill.Service
-    | Session.Service
-    | Provider.Service
-    | LSP.Service
-    | FileTime.Service
-    | Instruction.Service
-    | AppFileSystem.Service
-    | Bus.Service
-    | HttpClient.HttpClient
-    | ChildProcessSpawner
-    | Ripgrep.Service
-    | Format.Service
-    | Truncate.Service
-  > = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const config = yield* Config.Service
-      const plugin = yield* Plugin.Service
-      const agents = yield* Agent.Service
-      const skill = yield* Skill.Service
-      const truncate = yield* Truncate.Service
+export const layer: Layer.Layer<
+  Service,
+  never,
+  | Config.Service
+  | Plugin.Service
+  | Question.Service
+  | Todo.Service
+  | Agent.Service
+  | Skill.Service
+  | Session.Service
+  | Provider.Service
+  | LSP.Service
+  | FileTime.Service
+  | Instruction.Service
+  | AppFileSystem.Service
+  | Bus.Service
+  | HttpClient.HttpClient
+  | ChildProcessSpawner
+  | Ripgrep.Service
+  | Format.Service
+  | Truncate.Service
+> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const config = yield* Config.Service
+    const plugin = yield* Plugin.Service
+    const agents = yield* Agent.Service
+    const skill = yield* Skill.Service
+    const truncate = yield* Truncate.Service
 
-      const invalid = yield* InvalidTool
-      const task = yield* TaskTool
-      const read = yield* ReadTool
-      const question = yield* QuestionTool
-      const todo = yield* TodoWriteTool
-      const lsptool = yield* LspTool
-      const plan = yield* PlanExitTool
-      const webfetch = yield* WebFetchTool
-      const websearch = yield* WebSearchTool
-      const bash = yield* BashTool
-      const codesearch = yield* CodeSearchTool
-      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 agent = yield* Agent.Service
+    const invalid = yield* InvalidTool
+    const task = yield* TaskTool
+    const read = yield* ReadTool
+    const question = yield* QuestionTool
+    const todo = yield* TodoWriteTool
+    const lsptool = yield* LspTool
+    const plan = yield* PlanExitTool
+    const webfetch = yield* WebFetchTool
+    const websearch = yield* WebSearchTool
+    const bash = yield* BashTool
+    const codesearch = yield* CodeSearchTool
+    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 agent = yield* Agent.Service
 
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("ToolRegistry.state")(function* (ctx) {
-          const custom: Tool.Def[] = []
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("ToolRegistry.state")(function* (ctx) {
+        const custom: Tool.Def[] = []
 
-          function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
-            return {
-              id,
-              parameters: z.object(def.args),
-              description: def.description,
-              execute: (args, toolCtx) =>
-                Effect.gen(function* () {
-                  const pluginCtx: PluginToolContext = {
-                    ...toolCtx,
-                    ask: (req) => toolCtx.ask(req),
-                    directory: ctx.directory,
-                    worktree: ctx.worktree,
-                  }
-                  const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
-                  const info = yield* agent.get(toolCtx.agent)
-                  const out = yield* truncate.output(result, {}, info)
-                  return {
-                    title: "",
-                    output: out.truncated ? out.content : result,
-                    metadata: {
-                      truncated: out.truncated,
-                      outputPath: out.truncated ? out.outputPath : undefined,
-                    },
-                  }
-                }),
-            }
+        function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
+          return {
+            id,
+            parameters: z.object(def.args),
+            description: def.description,
+            execute: (args, toolCtx) =>
+              Effect.gen(function* () {
+                const pluginCtx: PluginToolContext = {
+                  ...toolCtx,
+                  ask: (req) => toolCtx.ask(req),
+                  directory: ctx.directory,
+                  worktree: ctx.worktree,
+                }
+                const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
+                const info = yield* agent.get(toolCtx.agent)
+                const out = yield* truncate.output(result, {}, info)
+                return {
+                  title: "",
+                  output: out.truncated ? out.content : result,
+                  metadata: {
+                    truncated: out.truncated,
+                    outputPath: out.truncated ? out.outputPath : undefined,
+                  },
+                }
+              }),
           }
+        }
 
-          const dirs = yield* config.directories()
-          const matches = dirs.flatMap((dir) =>
-            Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
+        const dirs = yield* config.directories()
+        const matches = dirs.flatMap((dir) =>
+          Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
+        )
+        if (matches.length) yield* config.waitForDependencies()
+        for (const match of matches) {
+          const namespace = path.basename(match, path.extname(match))
+          const mod = yield* Effect.promise(
+            () => import(process.platform === "win32" ? match : pathToFileURL(match).href),
           )
-          if (matches.length) yield* config.waitForDependencies()
-          for (const match of matches) {
-            const namespace = path.basename(match, path.extname(match))
-            const mod = yield* Effect.promise(
-              () => import(process.platform === "win32" ? match : pathToFileURL(match).href),
-            )
-            for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
-              custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
-            }
+          for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
+            custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
           }
+        }
 
-          const plugins = yield* plugin.list()
-          for (const p of plugins) {
-            for (const [id, def] of Object.entries(p.tool ?? {})) {
-              custom.push(fromPlugin(id, def))
-            }
+        const plugins = yield* plugin.list()
+        for (const p of plugins) {
+          for (const [id, def] of Object.entries(p.tool ?? {})) {
+            custom.push(fromPlugin(id, def))
           }
+        }
 
-          yield* config.get()
-          const questionEnabled =
-            ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
+        yield* config.get()
+        const questionEnabled =
+          ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
 
-          const tool = yield* Effect.all({
-            invalid: Tool.init(invalid),
-            bash: Tool.init(bash),
-            read: Tool.init(read),
-            glob: Tool.init(globtool),
-            grep: Tool.init(greptool),
-            edit: Tool.init(edit),
-            write: Tool.init(writetool),
-            task: Tool.init(task),
-            fetch: Tool.init(webfetch),
-            todo: Tool.init(todo),
-            search: Tool.init(websearch),
-            code: Tool.init(codesearch),
-            skill: Tool.init(skilltool),
-            patch: Tool.init(patchtool),
-            question: Tool.init(question),
-            lsp: Tool.init(lsptool),
-            plan: Tool.init(plan),
-          })
+        const tool = yield* Effect.all({
+          invalid: Tool.init(invalid),
+          bash: Tool.init(bash),
+          read: Tool.init(read),
+          glob: Tool.init(globtool),
+          grep: Tool.init(greptool),
+          edit: Tool.init(edit),
+          write: Tool.init(writetool),
+          task: Tool.init(task),
+          fetch: Tool.init(webfetch),
+          todo: Tool.init(todo),
+          search: Tool.init(websearch),
+          code: Tool.init(codesearch),
+          skill: Tool.init(skilltool),
+          patch: Tool.init(patchtool),
+          question: Tool.init(question),
+          lsp: Tool.init(lsptool),
+          plan: Tool.init(plan),
+        })
 
-          return {
-            custom,
-            builtin: [
-              tool.invalid,
-              ...(questionEnabled ? [tool.question] : []),
-              tool.bash,
-              tool.read,
-              tool.glob,
-              tool.grep,
-              tool.edit,
-              tool.write,
-              tool.task,
-              tool.fetch,
-              tool.todo,
-              tool.search,
-              tool.code,
-              tool.skill,
-              tool.patch,
-              ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
-              ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
-            ],
-            task: tool.task,
-            read: tool.read,
-          }
-        }),
-      )
+        return {
+          custom,
+          builtin: [
+            tool.invalid,
+            ...(questionEnabled ? [tool.question] : []),
+            tool.bash,
+            tool.read,
+            tool.glob,
+            tool.grep,
+            tool.edit,
+            tool.write,
+            tool.task,
+            tool.fetch,
+            tool.todo,
+            tool.search,
+            tool.code,
+            tool.skill,
+            tool.patch,
+            ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
+            ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
+          ],
+          task: tool.task,
+          read: tool.read,
+        }
+      }),
+    )
 
-      const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
-        const s = yield* InstanceState.get(state)
-        return [...s.builtin, ...s.custom] as Tool.Def[]
-      })
+    const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
+      const s = yield* InstanceState.get(state)
+      return [...s.builtin, ...s.custom] as Tool.Def[]
+    })
 
-      const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
-        return (yield* all()).map((tool) => tool.id)
-      })
+    const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
+      return (yield* all()).map((tool) => tool.id)
+    })
 
-      const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {
-        const list = yield* skill.available(agent)
-        if (list.length === 0) return "No skills are currently available."
-        return [
-          "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")
-      })
+    const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {
+      const list = yield* skill.available(agent)
+      if (list.length === 0) return "No skills are currently available."
+      return [
+        "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")
+    })
 
-      const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {
-        const items = (yield* agents.list()).filter((item) => item.mode !== "primary")
-        const filtered = items.filter(
-          (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",
+    const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {
+      const items = (yield* agents.list()).filter((item) => item.mode !== "primary")
+      const filtered = items.filter(
+        (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",
+      )
+      const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
+      const description = list
+        .map(
+          (item) =>
+            `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
         )
-        const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
-        const description = list
-          .map(
-            (item) =>
-              `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
-          )
-          .join("\n")
-        return ["Available agent types and the tools they have access to:", description].join("\n")
-      })
-
-      const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
-        const filtered = (yield* all()).filter((tool) => {
-          if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
-            return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
-          }
+        .join("\n")
+      return ["Available agent types and the tools they have access to:", description].join("\n")
+    })
 
-          const usePatch =
-            input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
-          if (tool.id === ApplyPatchTool.id) return usePatch
-          if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
+    const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
+      const filtered = (yield* all()).filter((tool) => {
+        if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
+          return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
+        }
 
-          return true
-        })
+        const usePatch =
+          input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
+        if (tool.id === ApplyPatchTool.id) return usePatch
+        if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
 
-        return yield* Effect.forEach(
-          filtered,
-          Effect.fnUntraced(function* (tool: Tool.Def) {
-            using _ = log.time(tool.id)
-            const output = {
-              description: tool.description,
-              parameters: tool.parameters,
-            }
-            yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
-            return {
-              id: tool.id,
-              description: [
-                output.description,
-                tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
-                tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
-              ]
-                .filter(Boolean)
-                .join("\n"),
-              parameters: output.parameters,
-              execute: tool.execute,
-              formatValidationError: tool.formatValidationError,
-            }
-          }),
-          { concurrency: "unbounded" },
-        )
+        return true
       })
 
-      const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () {
-        const s = yield* InstanceState.get(state)
-        return { task: s.task, read: s.read }
-      })
+      return yield* Effect.forEach(
+        filtered,
+        Effect.fnUntraced(function* (tool: Tool.Def) {
+          using _ = log.time(tool.id)
+          const output = {
+            description: tool.description,
+            parameters: tool.parameters,
+          }
+          yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
+          return {
+            id: tool.id,
+            description: [
+              output.description,
+              tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
+              tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
+            ]
+              .filter(Boolean)
+              .join("\n"),
+            parameters: output.parameters,
+            execute: tool.execute,
+            formatValidationError: tool.formatValidationError,
+          }
+        }),
+        { concurrency: "unbounded" },
+      )
+    })
 
-      return Service.of({ ids, all, named, tools })
-    }),
-  )
+    const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () {
+      const s = yield* InstanceState.get(state)
+      return { task: s.task, read: s.read }
+    })
 
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(Config.defaultLayer),
-      Layer.provide(Plugin.defaultLayer),
-      Layer.provide(Question.defaultLayer),
-      Layer.provide(Todo.defaultLayer),
-      Layer.provide(Skill.defaultLayer),
-      Layer.provide(Agent.defaultLayer),
-      Layer.provide(Session.defaultLayer),
-      Layer.provide(Provider.defaultLayer),
-      Layer.provide(LSP.defaultLayer),
-      Layer.provide(FileTime.defaultLayer),
-      Layer.provide(Instruction.defaultLayer),
-      Layer.provide(AppFileSystem.defaultLayer),
-      Layer.provide(Bus.layer),
-      Layer.provide(FetchHttpClient.layer),
-      Layer.provide(Format.defaultLayer),
-      Layer.provide(CrossSpawnSpawner.defaultLayer),
-      Layer.provide(Ripgrep.defaultLayer),
-      Layer.provide(Truncate.defaultLayer),
-    ),
-  )
-}
+    return Service.of({ ids, all, named, tools })
+  }),
+)
+
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(Plugin.defaultLayer),
+    Layer.provide(Question.defaultLayer),
+    Layer.provide(Todo.defaultLayer),
+    Layer.provide(Skill.defaultLayer),
+    Layer.provide(Agent.defaultLayer),
+    Layer.provide(Session.defaultLayer),
+    Layer.provide(Provider.defaultLayer),
+    Layer.provide(LSP.defaultLayer),
+    Layer.provide(FileTime.defaultLayer),
+    Layer.provide(Instruction.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Bus.layer),
+    Layer.provide(FetchHttpClient.layer),
+    Layer.provide(Format.defaultLayer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
+    Layer.provide(Ripgrep.defaultLayer),
+    Layer.provide(Truncate.defaultLayer),
+  ),
+)

+ 1 - 1
packages/opencode/src/tool/skill.ts

@@ -6,7 +6,7 @@ import * as Stream from "effect/Stream"
 import { EffectLogger } from "@/effect"
 import { Ripgrep } from "../file/ripgrep"
 import { Skill } from "../skill"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 
 const Parameters = z.object({
   name: z.string().describe("The name of the skill from available_skills"),

+ 1 - 1
packages/opencode/src/tool/task.ts

@@ -1,4 +1,4 @@
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import DESCRIPTION from "./task.txt"
 import z from "zod"
 import { Session } from "../session"

+ 1 - 1
packages/opencode/src/tool/todo.ts

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import DESCRIPTION_WRITE from "./todowrite.txt"
 import { Todo } from "../session/todo"
 

+ 124 - 126
packages/opencode/src/tool/tool.ts

@@ -3,146 +3,144 @@ import { Effect } from "effect"
 import type { MessageV2 } from "../session/message-v2"
 import type { Permission } from "../permission"
 import type { SessionID, MessageID } from "../session/schema"
-import { Truncate } from "./truncate"
+import * as Truncate from "./truncate"
 import { Agent } from "@/agent/agent"
 
-export namespace Tool {
-  interface Metadata {
-    [key: string]: any
-  }
+interface Metadata {
+  [key: string]: any
+}
 
-  // TODO: remove this hack
-  export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
+// TODO: remove this hack
+export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
 
-  export type Context<M extends Metadata = Metadata> = {
-    sessionID: SessionID
-    messageID: MessageID
-    agent: string
-    abort: AbortSignal
-    callID?: string
-    extra?: { [key: string]: any }
-    messages: MessageV2.WithParts[]
-    metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
-    ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
-  }
+export type Context<M extends Metadata = Metadata> = {
+  sessionID: SessionID
+  messageID: MessageID
+  agent: string
+  abort: AbortSignal
+  callID?: string
+  extra?: { [key: string]: any }
+  messages: MessageV2.WithParts[]
+  metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
+  ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
+}
 
-  export interface ExecuteResult<M extends Metadata = Metadata> {
-    title: string
-    metadata: M
-    output: string
-    attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
-  }
+export interface ExecuteResult<M extends Metadata = Metadata> {
+  title: string
+  metadata: M
+  output: string
+  attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
+}
 
-  export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
-    id: string
-    description: string
-    parameters: Parameters
-    execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
-    formatValidationError?(error: z.ZodError): string
-  }
-  export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
-    Def<Parameters, M>,
-    "id"
-  >
+export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
+  id: string
+  description: string
+  parameters: Parameters
+  execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
+  formatValidationError?(error: z.ZodError): string
+}
+export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
+  Def<Parameters, M>,
+  "id"
+>
 
-  export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
-    id: string
-    init: () => Effect.Effect<DefWithoutID<Parameters, M>>
-  }
+export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
+  id: string
+  init: () => Effect.Effect<DefWithoutID<Parameters, M>>
+}
 
-  type Init<Parameters extends z.ZodType, M extends Metadata> =
-    | DefWithoutID<Parameters, M>
-    | (() => Effect.Effect<DefWithoutID<Parameters, M>>)
+type Init<Parameters extends z.ZodType, M extends Metadata> =
+  | DefWithoutID<Parameters, M>
+  | (() => Effect.Effect<DefWithoutID<Parameters, M>>)
 
-  export type InferParameters<T> =
-    T extends Info<infer P, any>
+export type InferParameters<T> =
+  T extends Info<infer P, any>
+    ? z.infer<P>
+    : T extends Effect.Effect<Info<infer P, any>, any, any>
       ? z.infer<P>
-      : T extends Effect.Effect<Info<infer P, any>, any, any>
-        ? z.infer<P>
-        : never
-  export type InferMetadata<T> =
-    T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
+      : never
+export type InferMetadata<T> =
+  T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
 
-  export type InferDef<T> =
-    T extends Info<infer P, infer M>
+export type InferDef<T> =
+  T extends Info<infer P, infer M>
+    ? Def<P, M>
+    : T extends Effect.Effect<Info<infer P, infer M>, any, any>
       ? Def<P, M>
-      : T extends Effect.Effect<Info<infer P, infer M>, any, any>
-        ? Def<P, M>
-        : never
+      : never
 
-  function wrap<Parameters extends z.ZodType, Result extends Metadata>(
-    id: string,
-    init: Init<Parameters, Result>,
-    truncate: Truncate.Interface,
-    agents: Agent.Interface,
-  ) {
-    return () =>
-      Effect.gen(function* () {
-        const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
-        const execute = toolInfo.execute
-        toolInfo.execute = (args, ctx) => {
-          const attrs = {
-            "tool.name": id,
-            "session.id": ctx.sessionID,
-            "message.id": ctx.messageID,
-            ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
-          }
-          return Effect.gen(function* () {
-            yield* Effect.try({
-              try: () => toolInfo.parameters.parse(args),
-              catch: (error) => {
-                if (error instanceof z.ZodError && toolInfo.formatValidationError) {
-                  return new Error(toolInfo.formatValidationError(error), { cause: error })
-                }
-                return new Error(
-                  `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
-                  { cause: error },
-                )
-              },
-            })
-            const result = yield* execute(args, ctx)
-            if (result.metadata.truncated !== undefined) {
-              return result
-            }
-            const agent = yield* agents.get(ctx.agent)
-            const truncated = yield* truncate.output(result.output, {}, agent)
-            return {
-              ...result,
-              output: truncated.content,
-              metadata: {
-                ...result.metadata,
-                truncated: truncated.truncated,
-                ...(truncated.truncated && { outputPath: truncated.outputPath }),
-              },
-            }
-          }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))
+function wrap<Parameters extends z.ZodType, Result extends Metadata>(
+  id: string,
+  init: Init<Parameters, Result>,
+  truncate: Truncate.Interface,
+  agents: Agent.Interface,
+) {
+  return () =>
+    Effect.gen(function* () {
+      const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
+      const execute = toolInfo.execute
+      toolInfo.execute = (args, ctx) => {
+        const attrs = {
+          "tool.name": id,
+          "session.id": ctx.sessionID,
+          "message.id": ctx.messageID,
+          ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),
         }
-        return toolInfo
-      })
-  }
-
-  export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
-    id: ID,
-    init: Effect.Effect<Init<Parameters, Result>, never, R>,
-  ): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
-    return Object.assign(
-      Effect.gen(function* () {
-        const resolved = yield* init
-        const truncate = yield* Truncate.Service
-        const agents = yield* Agent.Service
-        return { id, init: wrap(id, resolved, truncate, agents) }
-      }),
-      { id },
-    )
-  }
-
-  export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
-    return Effect.gen(function* () {
-      const init = yield* info.init()
-      return {
-        ...init,
-        id: info.id,
+        return Effect.gen(function* () {
+          yield* Effect.try({
+            try: () => toolInfo.parameters.parse(args),
+            catch: (error) => {
+              if (error instanceof z.ZodError && toolInfo.formatValidationError) {
+                return new Error(toolInfo.formatValidationError(error), { cause: error })
+              }
+              return new Error(
+                `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
+                { cause: error },
+              )
+            },
+          })
+          const result = yield* execute(args, ctx)
+          if (result.metadata.truncated !== undefined) {
+            return result
+          }
+          const agent = yield* agents.get(ctx.agent)
+          const truncated = yield* truncate.output(result.output, {}, agent)
+          return {
+            ...result,
+            output: truncated.content,
+            metadata: {
+              ...result.metadata,
+              truncated: truncated.truncated,
+              ...(truncated.truncated && { outputPath: truncated.outputPath }),
+            },
+          }
+        }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))
       }
+      return toolInfo
     })
-  }
+}
+
+export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
+  id: ID,
+  init: Effect.Effect<Init<Parameters, Result>, never, R>,
+): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
+  return Object.assign(
+    Effect.gen(function* () {
+      const resolved = yield* init
+      const truncate = yield* Truncate.Service
+      const agents = yield* Agent.Service
+      return { id, init: wrap(id, resolved, truncate, agents) }
+    }),
+    { id },
+  )
+}
+
+export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
+  return Effect.gen(function* () {
+    const init = yield* info.init()
+    return {
+      ...init,
+      id: info.id,
+    }
+  })
 }

+ 126 - 128
packages/opencode/src/tool/truncate.ts

@@ -9,136 +9,134 @@ import { Log } from "../util"
 import { ToolID } from "./schema"
 import { TRUNCATION_DIR } from "./truncation-dir"
 
-export namespace Truncate {
-  const log = Log.create({ service: "truncation" })
-  const RETENTION = Duration.days(7)
-
-  export const MAX_LINES = 2000
-  export const MAX_BYTES = 50 * 1024
-  export const DIR = TRUNCATION_DIR
-  export const GLOB = path.join(TRUNCATION_DIR, "*")
-
-  export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
-
-  export interface Options {
-    maxLines?: number
-    maxBytes?: number
-    direction?: "head" | "tail"
-  }
-
-  function hasTaskTool(agent?: Agent.Info) {
-    if (!agent?.permission) return false
-    return evaluate("task", "*", agent.permission).action !== "deny"
-  }
-
-  export interface Interface {
-    readonly cleanup: () => Effect.Effect<void>
-    readonly write: (text: string) => Effect.Effect<string>
-    /**
-     * Returns output unchanged when it fits within the limits, otherwise writes the full text
-     * to the truncation directory and returns a preview plus a hint to inspect the saved file.
-     */
-    readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
-
-      const cleanup = Effect.fn("Truncate.cleanup")(function* () {
-        const cutoff = Identifier.timestamp(
-          Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
-        )
-        const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
-          Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
-          Effect.catch(() => Effect.succeed([])),
-        )
-        for (const entry of entries) {
-          if (Identifier.timestamp(entry) >= cutoff) continue
-          yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
-        }
-      })
-
-      const write = Effect.fn("Truncate.write")(function* (text: string) {
-        const file = path.join(TRUNCATION_DIR, ToolID.ascending())
-        yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
-        yield* fs.writeFileString(file, text).pipe(Effect.orDie)
-        return file
-      })
-
-      const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
-        const maxLines = options.maxLines ?? MAX_LINES
-        const maxBytes = options.maxBytes ?? MAX_BYTES
-        const direction = options.direction ?? "head"
-        const lines = text.split("\n")
-        const totalBytes = Buffer.byteLength(text, "utf-8")
-
-        if (lines.length <= maxLines && totalBytes <= maxBytes) {
-          return { content: text, truncated: false } as const
-        }
+const log = Log.create({ service: "truncation" })
+const RETENTION = Duration.days(7)
 
-        const out: string[] = []
-        let i = 0
-        let bytes = 0
-        let hitBytes = false
-
-        if (direction === "head") {
-          for (i = 0; i < lines.length && i < maxLines; i++) {
-            const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
-            if (bytes + size > maxBytes) {
-              hitBytes = true
-              break
-            }
-            out.push(lines[i])
-            bytes += size
-          }
-        } else {
-          for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
-            const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
-            if (bytes + size > maxBytes) {
-              hitBytes = true
-              break
-            }
-            out.unshift(lines[i])
-            bytes += size
-          }
-        }
+export const MAX_LINES = 2000
+export const MAX_BYTES = 50 * 1024
+export const DIR = TRUNCATION_DIR
+export const GLOB = path.join(TRUNCATION_DIR, "*")
 
-        const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
-        const unit = hitBytes ? "bytes" : "lines"
-        const preview = out.join("\n")
-        const file = yield* write(text)
-
-        const hint = hasTaskTool(agent)
-          ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
-          : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
-
-        return {
-          content:
-            direction === "head"
-              ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
-              : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
-          truncated: true,
-          outputPath: file,
-        } as const
-      })
-
-      yield* cleanup().pipe(
-        Effect.catchCause((cause) => {
-          log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
-          return Effect.void
-        }),
-        Effect.repeat(Schedule.spaced(Duration.hours(1))),
-        Effect.delay(Duration.minutes(1)),
-        Effect.forkScoped,
-      )
+export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+export interface Options {
+  maxLines?: number
+  maxBytes?: number
+  direction?: "head" | "tail"
+}
 
-      return Service.of({ cleanup, write, output })
-    }),
-  )
+function hasTaskTool(agent?: Agent.Info) {
+  if (!agent?.permission) return false
+  return evaluate("task", "*", agent.permission).action !== "deny"
+}
 
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
+export interface Interface {
+  readonly cleanup: () => Effect.Effect<void>
+  readonly write: (text: string) => Effect.Effect<string>
+  /**
+   * Returns output unchanged when it fits within the limits, otherwise writes the full text
+   * to the truncation directory and returns a preview plus a hint to inspect the saved file.
+   */
+  readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
 }
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+
+    const cleanup = Effect.fn("Truncate.cleanup")(function* () {
+      const cutoff = Identifier.timestamp(
+        Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
+      )
+      const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
+        Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
+        Effect.catch(() => Effect.succeed([])),
+      )
+      for (const entry of entries) {
+        if (Identifier.timestamp(entry) >= cutoff) continue
+        yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
+      }
+    })
+
+    const write = Effect.fn("Truncate.write")(function* (text: string) {
+      const file = path.join(TRUNCATION_DIR, ToolID.ascending())
+      yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
+      yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+      return file
+    })
+
+    const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
+      const maxLines = options.maxLines ?? MAX_LINES
+      const maxBytes = options.maxBytes ?? MAX_BYTES
+      const direction = options.direction ?? "head"
+      const lines = text.split("\n")
+      const totalBytes = Buffer.byteLength(text, "utf-8")
+
+      if (lines.length <= maxLines && totalBytes <= maxBytes) {
+        return { content: text, truncated: false } as const
+      }
+
+      const out: string[] = []
+      let i = 0
+      let bytes = 0
+      let hitBytes = false
+
+      if (direction === "head") {
+        for (i = 0; i < lines.length && i < maxLines; i++) {
+          const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+          if (bytes + size > maxBytes) {
+            hitBytes = true
+            break
+          }
+          out.push(lines[i])
+          bytes += size
+        }
+      } else {
+        for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+          const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+          if (bytes + size > maxBytes) {
+            hitBytes = true
+            break
+          }
+          out.unshift(lines[i])
+          bytes += size
+        }
+      }
+
+      const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+      const unit = hitBytes ? "bytes" : "lines"
+      const preview = out.join("\n")
+      const file = yield* write(text)
+
+      const hint = hasTaskTool(agent)
+        ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+        : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+
+      return {
+        content:
+          direction === "head"
+            ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+            : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
+        truncated: true,
+        outputPath: file,
+      } as const
+    })
+
+    yield* cleanup().pipe(
+      Effect.catchCause((cause) => {
+        log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
+        return Effect.void
+      }),
+      Effect.repeat(Schedule.spaced(Duration.hours(1))),
+      Effect.delay(Duration.minutes(1)),
+      Effect.forkScoped,
+    )
+
+    return Service.of({ cleanup, write, output })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))

+ 1 - 1
packages/opencode/src/tool/webfetch.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import { Effect } from "effect"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import TurndownService from "turndown"
 import DESCRIPTION from "./webfetch.txt"
 

+ 1 - 1
packages/opencode/src/tool/websearch.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import { Effect } from "effect"
 import { HttpClient } from "effect/unstable/http"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./websearch.txt"
 

+ 1 - 1
packages/opencode/src/tool/write.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import * as path from "path"
 import { Effect } from "effect"
-import { Tool } from "./tool"
+import * as Tool from "./tool"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch } from "diff"
 import DESCRIPTION from "./write.txt"

+ 4 - 4
packages/opencode/test/agent/agent.test.ts

@@ -84,7 +84,7 @@ test("explore agent denies edit and write", async () => {
 })
 
 test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool")
   await using tmp = await tmpdir()
   await Instance.provide({
     directory: tmp.path,
@@ -496,7 +496,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
 })
 
 test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool")
   await using tmp = await tmpdir({
     config: {
       permission: {
@@ -516,7 +516,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
 })
 
 test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool")
   await using tmp = await tmpdir({
     config: {
       agent: {
@@ -540,7 +540,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
 })
 
 test("explicit Truncate.GLOB deny is respected", async () => {
-  const { Truncate } = await import("../../src/tool/truncate")
+  const { Truncate } = await import("../../src/tool")
   await using tmp = await tmpdir({
     config: {
       permission: {

+ 2 - 2
packages/opencode/test/session/prompt-effect.test.ts

@@ -34,8 +34,8 @@ import { Skill } from "../../src/skill"
 import { SystemPrompt } from "../../src/session/system"
 import { Shell } from "../../src/shell/shell"
 import { Snapshot } from "../../src/snapshot"
-import { ToolRegistry } from "../../src/tool/registry"
-import { Truncate } from "../../src/tool/truncate"
+import { ToolRegistry } from "../../src/tool"
+import { Truncate } from "../../src/tool"
 import { Log } from "../../src/util"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Ripgrep } from "../../src/file/ripgrep"

+ 2 - 2
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -50,8 +50,8 @@ import { SessionProcessor } from "../../src/session/processor"
 import { SessionRunState } from "../../src/session/run-state"
 import { SessionStatus } from "../../src/session/status"
 import { Snapshot } from "../../src/snapshot"
-import { ToolRegistry } from "../../src/tool/registry"
-import { Truncate } from "../../src/tool/truncate"
+import { ToolRegistry } from "../../src/tool"
+import { Truncate } from "../../src/tool"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Ripgrep } from "../../src/file/ripgrep"

+ 1 - 1
packages/opencode/test/tool/apply_patch.test.ts

@@ -9,7 +9,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Format } from "../../src/format"
 import { Agent } from "../../src/agent/agent"
 import { Bus } from "../../src/bus"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { tmpdir } from "../fixture/fixture"
 import { SessionID, MessageID } from "../../src/session/schema"
 

+ 1 - 1
packages/opencode/test/tool/bash.test.ts

@@ -9,7 +9,7 @@ import { Filesystem } from "../../src/util"
 import { tmpdir } from "../fixture/fixture"
 import type { Permission } from "../../src/permission"
 import { Agent } from "../../src/agent/agent"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"

+ 1 - 1
packages/opencode/test/tool/edit.test.ts

@@ -12,7 +12,7 @@ import { Format } from "../../src/format"
 import { Agent } from "../../src/agent/agent"
 import { Bus } from "../../src/bus"
 import { BusEvent } from "../../src/bus/bus-event"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {

+ 1 - 1
packages/opencode/test/tool/external-directory.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
 import { Effect } from "effect"
-import type { Tool } from "../../src/tool/tool"
+import type { Tool } from "../../src/tool"
 import { Instance } from "../../src/project/instance"
 import { assertExternalDirectory } from "../../src/tool/external-directory"
 import { Filesystem } from "../../src/util"

+ 1 - 1
packages/opencode/test/tool/glob.test.ts

@@ -6,7 +6,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Ripgrep } from "../../src/file/ripgrep"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { Agent } from "../../src/agent/agent"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"

+ 1 - 1
packages/opencode/test/tool/grep.test.ts

@@ -5,7 +5,7 @@ import { GrepTool } from "../../src/tool/grep"
 import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
 import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { Agent } from "../../src/agent/agent"
 import { Ripgrep } from "../../src/file/ripgrep"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"

+ 1 - 1
packages/opencode/test/tool/question.test.ts

@@ -5,7 +5,7 @@ import { Question } from "../../src/question"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { Agent } from "../../src/agent/agent"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 

+ 2 - 2
packages/opencode/test/tool/read.test.ts

@@ -11,8 +11,8 @@ import { Instance } from "../../src/project/instance"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { Instruction } from "../../src/session/instruction"
 import { ReadTool } from "../../src/tool/read"
-import { Truncate } from "../../src/tool/truncate"
-import { Tool } from "../../src/tool/tool"
+import { Truncate } from "../../src/tool"
+import { Tool } from "../../src/tool"
 import { Filesystem } from "../../src/util"
 import { provideInstance, tmpdirScoped } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"

+ 1 - 1
packages/opencode/test/tool/registry.test.ts

@@ -4,7 +4,7 @@ import fs from "fs/promises"
 import { Effect, Layer } from "effect"
 import { Instance } from "../../src/project/instance"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { ToolRegistry } from "../../src/tool/registry"
+import { ToolRegistry } from "../../src/tool"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 

+ 2 - 2
packages/opencode/test/tool/skill.test.ts

@@ -4,10 +4,10 @@ import { afterEach, describe, expect } from "bun:test"
 import path from "path"
 import { pathToFileURL } from "url"
 import type { Permission } from "../../src/permission"
-import type { Tool } from "../../src/tool/tool"
+import type { Tool } from "../../src/tool"
 import { Instance } from "../../src/project/instance"
 import { SkillTool } from "../../src/tool/skill"
-import { ToolRegistry } from "../../src/tool/registry"
+import { ToolRegistry } from "../../src/tool"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { testEffect } from "../lib/effect"

+ 2 - 2
packages/opencode/test/tool/task.test.ts

@@ -10,8 +10,8 @@ import type { SessionPrompt } from "../../src/session/prompt"
 import { MessageID, PartID } from "../../src/session/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
-import { Truncate } from "../../src/tool/truncate"
-import { ToolRegistry } from "../../src/tool/registry"
+import { Truncate } from "../../src/tool"
+import { ToolRegistry } from "../../src/tool"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 

+ 2 - 2
packages/opencode/test/tool/tool-define.test.ts

@@ -2,8 +2,8 @@ import { describe, test, expect } from "bun:test"
 import { Effect, Layer, ManagedRuntime } from "effect"
 import z from "zod"
 import { Agent } from "../../src/agent/agent"
-import { Tool } from "../../src/tool/tool"
-import { Truncate } from "../../src/tool/truncate"
+import { Tool } from "../../src/tool"
+import { Truncate } from "../../src/tool"
 
 const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
 

+ 1 - 1
packages/opencode/test/tool/truncation.test.ts

@@ -1,7 +1,7 @@
 import { describe, test, expect } from "bun:test"
 import { NodeFileSystem } from "@effect/platform-node"
 import { Effect, FileSystem, Layer } from "effect"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { Identifier } from "../../src/id/id"
 import { Process } from "../../src/util"
 import { Filesystem } from "../../src/util"

+ 1 - 1
packages/opencode/test/tool/webfetch.test.ts

@@ -3,7 +3,7 @@ import path from "path"
 import { Effect, Layer } from "effect"
 import { FetchHttpClient } from "effect/unstable/http"
 import { Agent } from "../../src/agent/agent"
-import { Truncate } from "../../src/tool/truncate"
+import { Truncate } from "../../src/tool"
 import { Instance } from "../../src/project/instance"
 import { WebFetchTool } from "../../src/tool/webfetch"
 import { SessionID, MessageID } from "../../src/session/schema"

+ 2 - 2
packages/opencode/test/tool/write.test.ts

@@ -9,8 +9,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { FileTime } from "../../src/file/time"
 import { Bus } from "../../src/bus"
 import { Format } from "../../src/format"
-import { Truncate } from "../../src/tool/truncate"
-import { Tool } from "../../src/tool/tool"
+import { Truncate } from "../../src/tool"
+import { Tool } from "../../src/tool"
 import { Agent } from "../../src/agent/agent"
 import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"